diff options
author | Daniel Schadt <kingdread@gmx.de> | 2025-06-18 12:52:09 +0200 |
---|---|---|
committer | Daniel Schadt <kingdread@gmx.de> | 2025-06-18 12:52:09 +0200 |
commit | 1136a785bf981d32bcc978e3c85fce6b61ad94ec (patch) | |
tree | 4f931693af5a769dc1c5d295609af345d2a908ce | |
parent | abba548d84fa66bd7ac81683d3a70611cd6a8a3b (diff) | |
parent | f911cedf08aec00c166d6c0954212e5dec0969e9 (diff) | |
download | fietsboek-1136a785bf981d32bcc978e3c85fce6b61ad94ec.tar.gz fietsboek-1136a785bf981d32bcc978e3c85fce6b61ad94ec.tar.bz2 fietsboek-1136a785bf981d32bcc978e3c85fce6b61ad94ec.zip |
Merge branch 'browse-pagination'
-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: |