aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.rst1
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.mobin17805 -> 17892 bytes
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.po32
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.mobin16726 -> 16808 bytes
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.po32
-rw-r--r--fietsboek/locale/fietslog.pot32
-rw-r--r--fietsboek/templates/browse.jinja224
-rw-r--r--fietsboek/views/browse.py82
-rw-r--r--tests/integration/test_browse.py74
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
index 5ddd7f3..6526f6f 100644
--- a/fietsboek/locale/de/LC_MESSAGES/messages.mo
+++ b/fietsboek/locale/de/LC_MESSAGES/messages.mo
Binary files differ
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
index 00a8b9c..18f473c 100644
--- a/fietsboek/locale/en/LC_MESSAGES/messages.mo
+++ b/fietsboek/locale/en/LC_MESSAGES/messages.mo
Binary files differ
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: