diff options
| -rw-r--r-- | fietsboek/templates/browse.jinja2 | 24 | ||||
| -rw-r--r-- | fietsboek/views/browse.py | 82 | 
2 files changed, 100 insertions, 6 deletions
diff --git a/fietsboek/templates/browse.jinja2 b/fietsboek/templates/browse.jinja2 index 6723d19..9a16785 100644 --- a/fietsboek/templates/browse.jinja2 +++ b/fietsboek/templates/browse.jinja2 @@ -214,6 +214,30 @@    </div>    {% endfor %}    <button type="button" class="btn btn-primary ui-element" id="archiveDownloadButton" disabled><i class="bi bi-file-earmark-zip"></i> {{ _("page.browse.download_multiple") }}</button> + +  <nav aria-label="Page navigation"> +    <ul class="pagination justify-content-center"> +      {% if page_previous is none %} +      <li class="page-item disabled"> +        <span class="page-link">Previous</span> +      </li> +      {% else %} +      <li class="page-item"> +        <a class="page-link" href="{{ page_previous | safe }}">Previous</a> +      </li> +      {% endif %} +      {% if page_next is none %} +      <li class="page-item disabled"> +        <span class="page-link">Next</span> +      </li> +      {% else %} +      <li class="page-item"> +        <a class="page-link" href="{{ page_next | safe }}">Next</a> +      </li> +      {% endif %} +    </ul> +  </nav> +    {% elif used_filters %}    <p>{{ _("page.browse.no_results") }}</p>    {% else %} diff --git a/fietsboek/views/browse.py b/fietsboek/views/browse.py index 97bee35..e901e76 100644 --- a/fietsboek/views/browse.py +++ b/fietsboek/views/browse.py @@ -1,10 +1,11 @@  """Views for browsing all tracks."""  import datetime +import urllib.parse  from collections.abc import Callable, Iterable  from enum import Enum  from io import RawIOBase -from typing import TypeVar +from typing import Iterator, TypeVar  from zipfile import ZIP_DEFLATED, ZipFile  from pyramid.httpexceptions import HTTPBadRequest, HTTPForbidden, HTTPNotFound @@ -12,12 +13,14 @@ from pyramid.request import Request  from pyramid.response import Response  from pyramid.view import view_config  from sqlalchemy import func, not_, or_, select -from sqlalchemy.orm import aliased +from sqlalchemy.orm import aliased, Session  from sqlalchemy.sql import Select  from .. import models, util +from ..data import DataManager  from ..models.track import TrackType, TrackWithMetadata +TRACKS_PER_PAGE = 20  T = TypeVar("T", bound=Enum)  AliasedTrack = type[models.Track] @@ -69,6 +72,14 @@ def _get_enum(enum: type[T], value: str) -> T:          raise HTTPBadRequest(f"Invalid enum value {value!r}") from exc +def _with_page(url: str, num: int) -> str: +    parsed = urllib.parse.urlparse(url) +    query = urllib.parse.parse_qs(parsed.query) +    query["page"] = [str(num)] +    new_qs = urllib.parse.urlencode(query, doseq=True) +    return urllib.parse.urlunparse(parsed._replace(query=new_qs)) + +  class ResultOrder(Enum):      """Enum representing the different ways in which the tracks can be sorted      in the result.""" @@ -415,6 +426,49 @@ def apply_order(query: Select, track: AliasedTrack, order: ResultOrder) -> Selec      return query +def paginate( +    dbsession: Session, +    data_manager: DataManager, +    query: Select, +    filters: Filter, +    start: int, +    num: int, +) -> Iterator[TrackWithMetadata]: +    """Paginates a query. + +    Unlike a simple OFFSET/LIMIT solution, this generator will request more +    elements if the filters end up throwing tracks out. + +    :param dbsession: The current database session. +    :param data_manager: The current data manager. +    :param query: The (filtered and ordered) query. +    :param filters: The filters to apply after retrieving elements from the +        database. +    :param track: The aliased ``Track`` class. +    :param start: The offset from which to start the pagination. +    :param num: How many items to retrieve at maximum. +    :return: An iterable over ``num`` tracks (or fewer). +    """ +    # pylint: disable=too-many-arguments,too-many-positional-arguments +    num_retrieved = 0 +    offset = start +    while num_retrieved < num: +        # Best to try and get all at once +        num_query = num - num_retrieved +        this_query = query.offset(offset).limit(num_query) +        offset += num_query + +        tracks = list(dbsession.execute(this_query).scalars()) +        if not tracks: +            break + +        for track in tracks: +            track = TrackWithMetadata(track, data_manager) +            if filters.apply(track): +                num_retrieved += 1 +                yield track + +  @view_config(      route_name="browse", renderer="fietsboek:templates/browse.jinja2", request_method="GET"  ) @@ -431,18 +485,34 @@ def browse(request: Request) -> Response:      query = select(track).join(models.TrackCache, isouter=True)      query = filters.compile(query, track) +    if "page" in request.params: +        page = _get_int(request, "page") +    else: +        page = 1 +      order = ResultOrder.DATE_DESC      if request.params.get("sort"):          order = _get_enum(ResultOrder, request.params.get("sort"))      query = apply_order(query, track, order) -    tracks = request.dbsession.execute(query).scalars() -    tracks = (TrackWithMetadata(track, request.data_manager) for track in tracks) -    tracks = [track for track in tracks if filters.apply(track)] +    tracks = list( +        paginate( +            request.dbsession, +            request.data_manager, +            query, +            filters, +            (page - 1) * TRACKS_PER_PAGE, +            # We request one more so we can tell easily if there is a next page +            TRACKS_PER_PAGE + 1, +        ) +    ) +      return { -        "tracks": tracks, +        "tracks": tracks[:TRACKS_PER_PAGE],          "mps_to_kph": util.mps_to_kph,          "used_filters": bool(filters), +        "page_previous": None if page == 1 else _with_page(request.url, page - 1), +        "page_next": None if len(tracks) <= TRACKS_PER_PAGE else _with_page(request.url, page + 1),      }  | 
