diff options
| -rw-r--r-- | CHANGELOG.rst | 1 | ||||
| -rw-r--r-- | fietsboek/locale/de/LC_MESSAGES/messages.mo | bin | 17805 -> 17892 bytes | |||
| -rw-r--r-- | fietsboek/locale/de/LC_MESSAGES/messages.po | 32 | ||||
| -rw-r--r-- | fietsboek/locale/en/LC_MESSAGES/messages.mo | bin | 16726 -> 16808 bytes | |||
| -rw-r--r-- | fietsboek/locale/en/LC_MESSAGES/messages.po | 32 | ||||
| -rw-r--r-- | fietsboek/locale/fietslog.pot | 32 | ||||
| -rw-r--r-- | fietsboek/templates/browse.jinja2 | 24 | ||||
| -rw-r--r-- | fietsboek/views/browse.py | 82 | ||||
| -rw-r--r-- | tests/integration/test_browse.py | 74 | 
9 files changed, 235 insertions, 42 deletions
diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a88eb05..1df3995 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,6 +17,7 @@ Changed  - There is now a proper 404 (Not Found) page.  - There is now a proper 403 (Forbidden) page.  - The heat map is now preselected on the profile page. +- The browse page now paginates its results.  Fixed  ^^^^^ diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.mo b/fietsboek/locale/de/LC_MESSAGES/messages.mo Binary files differindex 5ddd7f3..6526f6f 100644 --- a/fietsboek/locale/de/LC_MESSAGES/messages.mo +++ b/fietsboek/locale/de/LC_MESSAGES/messages.mo diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.po b/fietsboek/locale/de/LC_MESSAGES/messages.po index 6736e5a..f306a46 100644 --- a/fietsboek/locale/de/LC_MESSAGES/messages.po +++ b/fietsboek/locale/de/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid ""  msgstr ""  "Project-Id-Version: PROJECT VERSION\n"  "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2025-05-10 23:36+0200\n" +"POT-Creation-Date: 2025-06-12 22:39+0200\n"  "PO-Revision-Date: 2022-07-02 17:35+0200\n"  "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"  "Language: de\n" @@ -16,7 +16,7 @@ msgstr ""  "MIME-Version: 1.0\n"  "Content-Type: text/plain; charset=utf-8\n"  "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.15.0\n" +"Generated-By: Babel 2.17.0\n"  #: fietsboek/actions.py:278  msgid "email.verify_mail.subject" @@ -37,35 +37,35 @@ msgstr "Passwörter stimmen nicht überein"  msgid "password_constraint.length"  msgstr "Passwort zu kurz" -#: fietsboek/models/track.py:603 +#: fietsboek/models/track.py:622  msgid "tooltip.table.length"  msgstr "Länge" -#: fietsboek/models/track.py:604 +#: fietsboek/models/track.py:623  msgid "tooltip.table.people"  msgstr "# Personen" -#: fietsboek/models/track.py:605 +#: fietsboek/models/track.py:624  msgid "tooltip.table.uphill"  msgstr "Bergauf" -#: fietsboek/models/track.py:606 +#: fietsboek/models/track.py:625  msgid "tooltip.table.downhill"  msgstr "Bergab" -#: fietsboek/models/track.py:607 fietsboek/templates/home.jinja2:7 +#: fietsboek/models/track.py:626 fietsboek/templates/home.jinja2:7  msgid "tooltip.table.moving_time"  msgstr "Fahrzeit" -#: fietsboek/models/track.py:608 fietsboek/templates/home.jinja2:8 +#: fietsboek/models/track.py:627 fietsboek/templates/home.jinja2:8  msgid "tooltip.table.stopped_time"  msgstr "Haltezeit" -#: fietsboek/models/track.py:610 +#: fietsboek/models/track.py:629  msgid "tooltip.table.max_speed"  msgstr "Maximalgeschwindigkeit" -#: fietsboek/models/track.py:614 +#: fietsboek/models/track.py:633  msgid "tooltip.table.avg_speed"  msgstr "Durchschnittsgeschwindigkeit" @@ -353,11 +353,19 @@ msgstr "Bilder"  msgid "page.browse.download_multiple"  msgstr "ausgewählte Herunterladen" -#: fietsboek/templates/browse.jinja2:218 +#: fietsboek/templates/browse.jinja2:222 fietsboek/templates/browse.jinja2:226 +msgid "pagination.previous" +msgstr "Vorherige" + +#: fietsboek/templates/browse.jinja2:231 fietsboek/templates/browse.jinja2:235 +msgid "pagination.next" +msgstr "Nächste" + +#: fietsboek/templates/browse.jinja2:242  msgid "page.browse.no_results"  msgstr "Es wurden keine Strecken gefunden, die den Filtern entsprechen." -#: fietsboek/templates/browse.jinja2:220 +#: fietsboek/templates/browse.jinja2:244  msgid "page.browse.no_tracks"  msgstr ""  "Es wurden keine Strecken gefunden, auf die Du Zugriff hast. Versuche, " diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.mo b/fietsboek/locale/en/LC_MESSAGES/messages.mo Binary files differindex 00a8b9c..18f473c 100644 --- a/fietsboek/locale/en/LC_MESSAGES/messages.mo +++ b/fietsboek/locale/en/LC_MESSAGES/messages.mo diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.po b/fietsboek/locale/en/LC_MESSAGES/messages.po index f2f7583..89e183d 100644 --- a/fietsboek/locale/en/LC_MESSAGES/messages.po +++ b/fietsboek/locale/en/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid ""  msgstr ""  "Project-Id-Version: PROJECT VERSION\n"  "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2025-05-10 23:36+0200\n" +"POT-Creation-Date: 2025-06-12 22:39+0200\n"  "PO-Revision-Date: 2023-04-03 20:42+0200\n"  "Last-Translator: \n"  "Language: en\n" @@ -16,7 +16,7 @@ msgstr ""  "MIME-Version: 1.0\n"  "Content-Type: text/plain; charset=utf-8\n"  "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.15.0\n" +"Generated-By: Babel 2.17.0\n"  #: fietsboek/actions.py:278  msgid "email.verify_mail.subject" @@ -37,35 +37,35 @@ msgstr "Passwords don't match"  msgid "password_constraint.length"  msgstr "Password not long enough" -#: fietsboek/models/track.py:603 +#: fietsboek/models/track.py:622  msgid "tooltip.table.length"  msgstr "Length" -#: fietsboek/models/track.py:604 +#: fietsboek/models/track.py:623  msgid "tooltip.table.people"  msgstr "# People" -#: fietsboek/models/track.py:605 +#: fietsboek/models/track.py:624  msgid "tooltip.table.uphill"  msgstr "Uphill" -#: fietsboek/models/track.py:606 +#: fietsboek/models/track.py:625  msgid "tooltip.table.downhill"  msgstr "Downhill" -#: fietsboek/models/track.py:607 fietsboek/templates/home.jinja2:7 +#: fietsboek/models/track.py:626 fietsboek/templates/home.jinja2:7  msgid "tooltip.table.moving_time"  msgstr "Moving Time" -#: fietsboek/models/track.py:608 fietsboek/templates/home.jinja2:8 +#: fietsboek/models/track.py:627 fietsboek/templates/home.jinja2:8  msgid "tooltip.table.stopped_time"  msgstr "Stopped Time" -#: fietsboek/models/track.py:610 +#: fietsboek/models/track.py:629  msgid "tooltip.table.max_speed"  msgstr "Max Speed" -#: fietsboek/models/track.py:614 +#: fietsboek/models/track.py:633  msgid "tooltip.table.avg_speed"  msgstr "Average Speed" @@ -353,11 +353,19 @@ msgstr "Images"  msgid "page.browse.download_multiple"  msgstr "Download selected" -#: fietsboek/templates/browse.jinja2:218 +#: fietsboek/templates/browse.jinja2:222 fietsboek/templates/browse.jinja2:226 +msgid "pagination.previous" +msgstr "Previous" + +#: fietsboek/templates/browse.jinja2:231 fietsboek/templates/browse.jinja2:235 +msgid "pagination.next" +msgstr "Next" + +#: fietsboek/templates/browse.jinja2:242  msgid "page.browse.no_results"  msgstr "No results matching the filters were found." -#: fietsboek/templates/browse.jinja2:220 +#: fietsboek/templates/browse.jinja2:244  msgid "page.browse.no_tracks"  msgstr "You currently do not have access to any tracks. Try logging in." diff --git a/fietsboek/locale/fietslog.pot b/fietsboek/locale/fietslog.pot index 02fdb37..6383760 100644 --- a/fietsboek/locale/fietslog.pot +++ b/fietsboek/locale/fietslog.pot @@ -8,14 +8,14 @@ msgid ""  msgstr ""  "Project-Id-Version: PROJECT VERSION\n"  "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2025-05-10 23:36+0200\n" +"POT-Creation-Date: 2025-06-12 22:39+0200\n"  "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"  "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"  "Language-Team: LANGUAGE <LL@li.org>\n"  "MIME-Version: 1.0\n"  "Content-Type: text/plain; charset=utf-8\n"  "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.15.0\n" +"Generated-By: Babel 2.17.0\n"  #: fietsboek/actions.py:278  msgid "email.verify_mail.subject" @@ -33,35 +33,35 @@ msgstr ""  msgid "password_constraint.length"  msgstr "" -#: fietsboek/models/track.py:603 +#: fietsboek/models/track.py:622  msgid "tooltip.table.length"  msgstr "" -#: fietsboek/models/track.py:604 +#: fietsboek/models/track.py:623  msgid "tooltip.table.people"  msgstr "" -#: fietsboek/models/track.py:605 +#: fietsboek/models/track.py:624  msgid "tooltip.table.uphill"  msgstr "" -#: fietsboek/models/track.py:606 +#: fietsboek/models/track.py:625  msgid "tooltip.table.downhill"  msgstr "" -#: fietsboek/models/track.py:607 fietsboek/templates/home.jinja2:7 +#: fietsboek/models/track.py:626 fietsboek/templates/home.jinja2:7  msgid "tooltip.table.moving_time"  msgstr "" -#: fietsboek/models/track.py:608 fietsboek/templates/home.jinja2:8 +#: fietsboek/models/track.py:627 fietsboek/templates/home.jinja2:8  msgid "tooltip.table.stopped_time"  msgstr "" -#: fietsboek/models/track.py:610 +#: fietsboek/models/track.py:629  msgid "tooltip.table.max_speed"  msgstr "" -#: fietsboek/models/track.py:614 +#: fietsboek/models/track.py:633  msgid "tooltip.table.avg_speed"  msgstr "" @@ -347,11 +347,19 @@ msgstr ""  msgid "page.browse.download_multiple"  msgstr "" -#: fietsboek/templates/browse.jinja2:218 +#: fietsboek/templates/browse.jinja2:222 fietsboek/templates/browse.jinja2:226 +msgid "pagination.previous" +msgstr "" + +#: fietsboek/templates/browse.jinja2:231 fietsboek/templates/browse.jinja2:235 +msgid "pagination.next" +msgstr "" + +#: fietsboek/templates/browse.jinja2:242  msgid "page.browse.no_results"  msgstr "" -#: fietsboek/templates/browse.jinja2:220 +#: fietsboek/templates/browse.jinja2:244  msgid "page.browse.no_tracks"  msgstr "" diff --git a/fietsboek/templates/browse.jinja2 b/fietsboek/templates/browse.jinja2 index 6723d19..28693f6 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">{{ _("pagination.previous") }}</span> +      </li> +      {% else %} +      <li class="page-item"> +        <a class="page-link" href="{{ page_previous | safe }}">{{ _("pagination.previous") }}</a> +      </li> +      {% endif %} +      {% if page_next is none %} +      <li class="page-item disabled"> +        <span class="page-link">{{ _("pagination.next") }}</span> +      </li> +      {% else %} +      <li class="page-item"> +        <a class="page-link" href="{{ page_next | safe }}">{{ _("pagination.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..e8e3edf 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 Session, aliased  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),      } diff --git a/tests/integration/test_browse.py b/tests/integration/test_browse.py index 46ec329..83218cc 100644 --- a/tests/integration/test_browse.py +++ b/tests/integration/test_browse.py @@ -70,6 +70,55 @@ def added_tracks(tm, dbsession, owner, data_manager):          tm.doom() +@contextmanager +def a_lot_of_tracks(tm, dbsession, owner, data_manager): +    """Adds some tracks to the database session. + +    This function should be used as a context manager and it ensures that the +    added tracks are deleted again after the test, to make a clean slate for +    the next test. +    """ +    # The normal transaction is "doomed", so we need to abort it, start a fresh +    # one, and then explicitely commit it, otherwise we will not persist the +    # objects to the database. +    tm.abort() + +    gpx_data = load_gpx_asset("MyTourbook_1.gpx.gz") + +    tracks = [] +    track_ids = [] +    with tm: +        for index in range(50): +            track = models.Track( +                owner=owner, +                title=f"Traxi {index}", +                visibility=Visibility.PUBLIC, +                description="One of many", +                badges=[], +                link_secret="foobar", +                tagged_people=[], +            ) +            track.date = datetime(2022 - index, 3, 14, 9, 26, 59) +            dbsession.add(track) +            dbsession.flush() +            data_manager.initialize(track.id).compress_gpx(gpx_data) +            tracks.append(track) +            track_ids.append(track.id) + +    tm.begin() +    tm.doom() + +    try: +        yield track_ids +    finally: +        tm.abort() +        with tm: +            for track in tracks: +                dbsession.delete(track) +        tm.begin() +        tm.doom() + +  def test_browse(testapp, dbsession, route_path, logged_in, tm, data_manager):      # pylint: disable=too-many-positional-arguments      # Ensure there are some tracks in the database @@ -81,6 +130,31 @@ def test_browse(testapp, dbsession, route_path, logged_in, tm, data_manager):          assert "Barfoo" in browse.text +def test_browse_paged(testapp, dbsession, route_path, logged_in, tm, data_manager): +    # pylint: disable=too-many-positional-arguments +    with a_lot_of_tracks(tm, dbsession, logged_in, data_manager): +        page_1 = testapp.get(route_path("browse", _query=[("page", 1)])) +        assert "Traxi 0" in page_1.text +        assert "Traxi 10" in page_1.text +        assert "Traxi 20" not in page_1.text +        assert "Traxi 30" not in page_1.text +        assert "Traxi 40" not in page_1.text + +        page_2 = testapp.get(route_path("browse", _query=[("page", 2)])) +        assert "Traxi 0" not in page_2.text +        assert "Traxi 10" not in page_2.text +        assert "Traxi 20" in page_2.text +        assert "Traxi 30" in page_2.text +        assert "Traxi 40" not in page_2.text + +        page_3 = testapp.get(route_path("browse", _query=[("page", 3)])) +        assert "Traxi 0" not in page_3.text +        assert "Traxi 10" not in page_3.text +        assert "Traxi 20" not in page_3.text +        assert "Traxi 30" not in page_3.text +        assert "Traxi 40" in page_3.text + +  def test_archive(testapp, dbsession, route_path, logged_in, tm, data_manager):      # pylint: disable=too-many-positional-arguments      with added_tracks(tm, dbsession, logged_in, data_manager) as tracks:  | 
