aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--asset-sources/fietsboek.ts26
-rw-r--r--fietsboek/__init__.py2
-rw-r--r--fietsboek/alembic/versions/20251230_f9ca03541351.py43
-rw-r--r--fietsboek/data.py95
-rw-r--r--fietsboek/geo.py88
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.mobin18422 -> 20324 bytes
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.po208
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.mobin17307 -> 19139 bytes
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.po208
-rw-r--r--fietsboek/locale/fietslog.pot210
-rw-r--r--fietsboek/models/__init__.py1
-rw-r--r--fietsboek/models/journey.py187
-rw-r--r--fietsboek/models/track.py86
-rw-r--r--fietsboek/models/user.py41
-rw-r--r--fietsboek/routes.py29
-rw-r--r--fietsboek/static/fietsboek.js25
-rw-r--r--fietsboek/static/fietsboek.js.map2
-rw-r--r--fietsboek/templates/admin_overview.jinja26
-rw-r--r--fietsboek/templates/journey_details.jinja2180
-rw-r--r--fietsboek/templates/journey_edit.jinja221
-rw-r--r--fietsboek/templates/journey_form.jinja2261
-rw-r--r--fietsboek/templates/journey_list.jinja232
-rw-r--r--fietsboek/templates/journey_new.jinja221
-rw-r--r--fietsboek/templates/layout.jinja23
-rw-r--r--fietsboek/trackmap.py16
-rw-r--r--fietsboek/views/admin.py12
-rw-r--r--fietsboek/views/browse.py13
-rw-r--r--fietsboek/views/journey.py267
-rw-r--r--tests/conftest.py21
-rw-r--r--tests/playwright/conftest.py7
-rw-r--r--tests/playwright/test_journeys.py145
31 files changed, 2058 insertions, 198 deletions
diff --git a/asset-sources/fietsboek.ts b/asset-sources/fietsboek.ts
index 66caa4f..bcce661 100644
--- a/asset-sources/fietsboek.ts
+++ b/asset-sources/fietsboek.ts
@@ -540,6 +540,23 @@ function loadProfileStats() {
/* Used via in-page scripts, so make eslint happy */
loadProfileStats;
+/**
+ * Formats the given timestamp to the user's locale.
+ *
+ * @param timestamp - The timestamp in milliseconds since the epoch.
+ * @return The formatted string.
+ */
+function formatTimestamp(timestamp: number): string {
+ const date = new Date(timestamp);
+ // TypeScript complains about this, but according to MDN it is fine, at
+ // least in "somewhat modern" browsers
+ const intl = new Intl.DateTimeFormat(LOCALE, {
+ dateStyle: "medium",
+ timeStyle: "medium",
+ } as any);
+ return intl.format(date);
+}
+
document.addEventListener('DOMContentLoaded', function() {
window.fietsboekImageIndex = 0;
@@ -567,13 +584,6 @@ document.addEventListener('DOMContentLoaded', function() {
/* Format all datetimes to the local timezone */
document.querySelectorAll(".fietsboek-local-datetime").forEach((obj) => {
const timestamp = parseFloat(obj.attributes.getNamedItem("data-utc-timestamp")!.value);
- const date = new Date(timestamp * 1000);
- // TypeScript complains about this, but according to MDN it is fine, at
- // least in "somewhat modern" browsers
- const intl = new Intl.DateTimeFormat(LOCALE, {
- dateStyle: "medium",
- timeStyle: "medium",
- } as any);
- obj.innerHTML = intl.format(date);
+ obj.innerHTML = formatTimestamp(timestamp * 1000);
});
});
diff --git a/fietsboek/__init__.py b/fietsboek/__init__.py
index 4c3a340..1f21c5f 100644
--- a/fietsboek/__init__.py
+++ b/fietsboek/__init__.py
@@ -152,6 +152,8 @@ def create_data_folders(data_dir: Path):
(data_dir / "tracks").mkdir(exist_ok=True)
LOGGER.debug("Creating %s/users/", data_dir)
(data_dir / "users").mkdir(exist_ok=True)
+ LOGGER.debug("Creating %s/journeys/", data_dir)
+ (data_dir / "journeys").mkdir(exist_ok=True)
def main(global_config, **settings):
diff --git a/fietsboek/alembic/versions/20251230_f9ca03541351.py b/fietsboek/alembic/versions/20251230_f9ca03541351.py
new file mode 100644
index 0000000..e82f7fa
--- /dev/null
+++ b/fietsboek/alembic/versions/20251230_f9ca03541351.py
@@ -0,0 +1,43 @@
+"""add journeys table
+
+Revision ID: f9ca03541351
+Revises: 90b39fdf6e4b
+Create Date: 2025-12-30 22:23:17.765361
+
+"""
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = 'f9ca03541351'
+down_revision = '90b39fdf6e4b'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('journeys',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('owner_id', sa.Integer(), nullable=False),
+ sa.Column('title', sa.Text(), nullable=False),
+ sa.Column('description', sa.Text(), nullable=False),
+ sa.Column('visibility', sa.Enum('PRIVATE', 'FRIENDS', 'LOGGED_IN', 'PUBLIC', name='visibility'), nullable=False),
+ sa.Column('link_secret', sa.Text(), nullable=True),
+ sa.ForeignKeyConstraint(['owner_id'], ['users.id'], name=op.f('fk_journeys_owner_id_users')),
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_journeys'))
+ )
+ op.create_table('journey_track_assoc',
+ sa.Column('journey_id', sa.Integer(), nullable=False),
+ sa.Column('track_id', sa.Integer(), nullable=False),
+ sa.Column('sort_index', sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(['journey_id'], ['journeys.id'], name=op.f('fk_journey_track_assoc_journey_id_journeys')),
+ sa.ForeignKeyConstraint(['track_id'], ['tracks.id'], name=op.f('fk_journey_track_assoc_track_id_tracks')),
+ sa.PrimaryKeyConstraint('journey_id', 'track_id', name=op.f('pk_journey_track_assoc'))
+ )
+ # ### end Alembic commands ###
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('journey_track_assoc')
+ op.drop_table('journeys')
+ # ### end Alembic commands ###
diff --git a/fietsboek/data.py b/fietsboek/data.py
index d4bbb07..a6492c3 100644
--- a/fietsboek/data.py
+++ b/fietsboek/data.py
@@ -42,6 +42,10 @@ def generate_filename(filename: Optional[str]) -> str:
return str(uuid.uuid4())
+def _log_deletion_error(_, path, exc_info):
+ LOGGER.warning("Failed to remove %s", path, exc_info=exc_info)
+
+
class DataManager:
"""Data manager.
@@ -61,6 +65,9 @@ class DataManager:
def _user_data_dir(self, user_id):
return self.data_dir / "users" / str(user_id)
+ def _journey_data_dir(self, journey_id):
+ return self.data_dir / "journeys" / str(journey_id)
+
def maintenance_mode(self) -> Optional[str]:
"""Checks whether the maintenance mode is enabled.
@@ -103,6 +110,17 @@ class DataManager:
path.mkdir(parents=True)
return UserDataDir(user_id, path, txn=self.txn)
+ def initialize_journey(self, journey_id: int) -> "JourneyDataDir":
+ """Creates the data directory for a journey.
+
+ :raises FileExistsError: If the directory already exists.
+ :param journey_id: ID of the journey.
+ :return: The manager that can be used to manage this journey's data.
+ """
+ path = self._journey_data_dir(journey_id)
+ path.mkdir(parents=True)
+ return JourneyDataDir(journey_id, path)
+
def purge(self, track_id: int):
"""Forcefully purges all data from the given track.
@@ -135,6 +153,18 @@ class DataManager:
raise FileNotFoundError(f"The path {path} is not a directory") from None
return UserDataDir(user_id, path, txn=self.txn)
+ def open_journey(self, journey_id: int) -> "JourneyDataDir":
+ """Open a journey's data directory.
+
+ :raises FileNotFoundError: If the journey directory does not exist.
+ :param journey_id: ID of the journey.
+ :return: The manager that can be used to manage this journey's data.
+ """
+ path = self._journey_data_dir(journey_id)
+ if not path.is_dir():
+ raise FileNotFoundError(f"The path {path} is not a directory") from None
+ return JourneyDataDir(journey_id, path)
+
def size(self) -> int:
"""Returns the size of all data.
@@ -162,6 +192,16 @@ class DataManager:
except FileNotFoundError:
return []
+ def list_journeys(self) -> list[int]:
+ """Returns a list of all journeys.
+
+ :return: A list of all journey IDs.
+ """
+ try:
+ return [int(journey.name) for journey in self._journey_data_dir(".").iterdir()]
+ except FileNotFoundError:
+ return []
+
class TrackDataDir:
"""Manager for a single track's data.
@@ -185,10 +225,6 @@ class TrackDataDir:
"""
return FileLock(self.path / "lock")
- @staticmethod
- def _log_deletion_error(_, path, exc_info):
- LOGGER.warning("Failed to remove %s", path, exc_info=exc_info)
-
def purge(self):
"""Purge all data pertaining to the track.
@@ -199,7 +235,7 @@ class TrackDataDir:
self.txn.purge(self.path)
else:
if self.path.is_dir():
- shutil.rmtree(self.path, ignore_errors=False, onerror=self._log_deletion_error)
+ shutil.rmtree(self.path, ignore_errors=False, onerror=_log_deletion_error)
def size(self) -> int:
"""Returns the size of the data that this track entails.
@@ -343,4 +379,51 @@ class UserDataDir:
return self.path / "tilehunt.sqlite"
-__all__ = ["generate_filename", "DataManager", "TrackDataDir", "UserDataDir"]
+class JourneyDataDir:
+ """Manager for a single journey's data."""
+
+ def __init__(self, journey_id: int, path: Path, *, txn: Transaction | None = None):
+ self.journey_id = journey_id
+ self.path = path
+ self.txn = txn
+
+ def purge(self):
+ """Purge all data pertaining to the journey.
+
+ This function logs errors but raises no exception, as such it can
+ always be used to clean up after a track.
+ """
+ if self.txn:
+ self.txn.purge(self.path)
+ else:
+ if self.path.is_dir():
+ shutil.rmtree(self.path, ignore_errors=False, onerror=_log_deletion_error)
+
+ def preview_path(self) -> Path:
+ """Gets the path to the "preview image".
+
+ :return: The path to the preview image.
+ """
+ return self.path / "preview.png"
+
+ def set_preview(self, data: bytes):
+ """Sets the preview image to the given data.
+
+ :param data: The data of the preview image.
+ """
+ if self.txn:
+ self.txn.write_bytes(self.preview_path(), data)
+ else:
+ self.preview_path().write_bytes(data)
+
+ def remove_preview(self):
+ """Deletes the preview image."""
+ if not self.preview_path().exists():
+ return
+ if self.txn:
+ self.txn.unlink(self.preview_path())
+ else:
+ self.preview_path().unlink()
+
+
+__all__ = ["generate_filename", "DataManager", "TrackDataDir", "UserDataDir", "JourneyDataDir"]
diff --git a/fietsboek/geo.py b/fietsboek/geo.py
index c0a10e7..e6abb71 100644
--- a/fietsboek/geo.py
+++ b/fietsboek/geo.py
@@ -1,9 +1,13 @@
"""This module implements GPS related functionality."""
+import datetime
+import io
from dataclasses import dataclass
from itertools import islice
from math import cos, radians, sin, sqrt
+from . import util
+
# WGS-84 equatorial radius, also called the semi-major axis.
# https://en.wikipedia.org/wiki/Earth_radius
EARTH_RADIUS = 6378137.0
@@ -15,6 +19,17 @@ MOVING_THRESHOLD = 1.1
@dataclass
+class Waypoint:
+ """A waypoint, a special landmark marked in the track."""
+
+ longitude: float
+ latitude: float
+ elevation: float | None
+ name: str | None
+ description: str | None
+
+
+@dataclass
class MovementData:
"""Movement statistics for a path."""
@@ -146,3 +161,76 @@ class Path:
else:
movement_data.average_speed = 0.0
return movement_data
+
+
+def gpx_xml(
+ title: str | None,
+ description: str | None,
+ date: datetime.datetime,
+ points: list[Point],
+ waypoints: list[Waypoint],
+) -> bytes:
+ """Returns an XML representation of the given path.
+
+ :param title: The title of the resulting track.
+ :param description: The description of the resulting track.
+ :param points: The points that make up this track.
+ :param waypoints: The waypoints that should be included.
+ :return: The XML representation (a GPX file).
+ """
+ # This is a cumbersome way to do it, as we're re-implementing XML
+ # serialization logic. However, recreating the track in gpxpy and
+ # letting it serialize it is much slower:
+ # For a track with around 50,000 points, the gpxpy method takes
+ # ~5.9 seconds here, while the "manual" buffer takes only ~2.4 seconds.
+ # This is a speed-up we're happy to take!
+ buf = io.BytesIO()
+ buf.write(b'<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>')
+ buf.write(b'<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">')
+
+ buf.write(b"<metadata>")
+ if title:
+ buf.write(b"<name>%s</name>" % util.xml_escape(title))
+ if description:
+ buf.write(b"<desc>%s</desc>" % util.xml_escape(description))
+ buf.write(b"</metadata>")
+
+ # Cache for fast access
+ write = buf.write
+
+ write(b"<trk>")
+ write(b"<trkseg>")
+ for point in points:
+ write(b'<trkpt lat="')
+ write(str(point.latitude).encode("ascii"))
+ write(b'" lon="')
+ write(str(point.longitude).encode("ascii"))
+ write(b'">')
+ write(b"<ele>")
+ write(str(point.elevation).encode("ascii"))
+ write(b"</ele>")
+ write(b"<time>")
+ write(str(date + datetime.timedelta(seconds=point.time_offset)).encode("ascii"))
+ write(b"</time>")
+ write(b"</trkpt>\n")
+ write(b"</trkseg>")
+ write(b"</trk>")
+
+ # This loop is not as hot:
+ for wpt in waypoints:
+ write(
+ b'<wpt lat="%s" lon="%s">'
+ % (util.xml_escape(str(wpt.latitude)), util.xml_escape(str(wpt.longitude)))
+ )
+ if wpt.elevation is not None:
+ write(b"<ele>%s</ele>" % util.xml_escape(str(wpt.elevation)))
+ if wpt.name is not None:
+ write(b"<name>%s</name>" % util.xml_escape(wpt.name))
+ if wpt.description is not None:
+ write(b"<cmt>%s</cmt>" % util.xml_escape(wpt.description))
+ write(b"<desc>%s</desc>" % util.xml_escape(wpt.description))
+ write(b"</wpt>")
+
+ write(b"</gpx>")
+
+ return buf.getvalue()
diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.mo b/fietsboek/locale/de/LC_MESSAGES/messages.mo
index 1175786..356be25 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 7b6a70e..9367d28 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-11-25 20:03+0100\n"
+"POT-Creation-Date: 2026-01-03 19:25+0100\n"
"PO-Revision-Date: 2022-07-02 17:35+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de\n"
@@ -18,11 +18,11 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
-#: fietsboek/actions.py:268
+#: fietsboek/actions.py:266
msgid "email.verify_mail.subject"
msgstr "Fietsboek Konto Bestätigung"
-#: fietsboek/actions.py:271
+#: fietsboek/actions.py:269
msgid "email.verify.text"
msgstr ""
"Um Dein Fietsboek-Konto zu bestätigen, nutze diesen Link: {}\n"
@@ -61,43 +61,43 @@ msgstr "Maximalgeschwindigkeit"
msgid "pdf.table.avg_speed"
msgstr "Durchschnittsgeschwindigkeit"
-#: fietsboek/util.py:297
+#: fietsboek/util.py:299
msgid "password_constraint.mismatch"
msgstr "Passwörter stimmen nicht überein"
-#: fietsboek/util.py:299
+#: fietsboek/util.py:301
msgid "password_constraint.length"
msgstr "Passwort zu kurz"
-#: fietsboek/models/track.py:776
+#: fietsboek/models/track.py:774
msgid "tooltip.table.length"
msgstr "Länge"
-#: fietsboek/models/track.py:777
+#: fietsboek/models/track.py:775
msgid "tooltip.table.people"
msgstr "# Personen"
-#: fietsboek/models/track.py:778
+#: fietsboek/models/track.py:776
msgid "tooltip.table.uphill"
msgstr "Bergauf"
-#: fietsboek/models/track.py:779
+#: fietsboek/models/track.py:777
msgid "tooltip.table.downhill"
msgstr "Bergab"
-#: fietsboek/models/track.py:780 fietsboek/templates/home.jinja2:7
+#: fietsboek/models/track.py:778 fietsboek/templates/home.jinja2:7
msgid "tooltip.table.moving_time"
msgstr "Fahrzeit"
-#: fietsboek/models/track.py:781 fietsboek/templates/home.jinja2:8
+#: fietsboek/models/track.py:779 fietsboek/templates/home.jinja2:8
msgid "tooltip.table.stopped_time"
msgstr "Haltezeit"
-#: fietsboek/models/track.py:783
+#: fietsboek/models/track.py:781
msgid "tooltip.table.max_speed"
msgstr "Maximalgeschwindigkeit"
-#: fietsboek/models/track.py:787
+#: fietsboek/models/track.py:785
msgid "tooltip.table.avg_speed"
msgstr "Durchschnittsgeschwindigkeit"
@@ -220,14 +220,18 @@ msgid "admin.overview.storage_graph.label.images"
msgstr "Bilder"
#: fietsboek/templates/admin_overview.jinja2:58
-msgid "admin.overview.storage_graph.label.previews"
-msgstr "Vorschaubilder"
+msgid "admin.overview.storage_graph.label.track_previews"
+msgstr "Streckenvoransichten"
#: fietsboek/templates/admin_overview.jinja2:59
+msgid "admin.overview.storage_graph.label.journey_previews"
+msgstr "Reisenvoransichten"
+
+#: fietsboek/templates/admin_overview.jinja2:60
msgid "admin.overview.storage_graph.label.user_maps"
msgstr "Nutzerkarten"
-#: fietsboek/templates/admin_overview.jinja2:86
+#: fietsboek/templates/admin_overview.jinja2:88
msgid "admin.overview.storage_graph.title"
msgstr "Speicherübersicht"
@@ -328,60 +332,79 @@ msgid "page.browse.synthetic_tooltip"
msgstr "Dies ist eine geplante Strecke"
#: fietsboek/templates/browse.jinja2:162 fietsboek/templates/details.jinja2:127
+#: fietsboek/templates/journey_details.jinja2:124
#: fietsboek/templates/profile_overview.jinja2:20
msgid "page.details.date"
msgstr "Datum"
#: fietsboek/templates/browse.jinja2:164 fietsboek/templates/details.jinja2:141
+#: fietsboek/templates/journey_details.jinja2:76
+#: fietsboek/templates/journey_details.jinja2:126
#: fietsboek/templates/profile_overview.jinja2:22
msgid "page.details.length"
msgstr "Länge"
#: fietsboek/templates/browse.jinja2:169 fietsboek/templates/details.jinja2:132
+#: fietsboek/templates/journey_details.jinja2:131
#: fietsboek/templates/profile_overview.jinja2:26
msgid "page.details.start_time"
msgstr "Startzeit"
#: fietsboek/templates/browse.jinja2:171 fietsboek/templates/details.jinja2:136
+#: fietsboek/templates/journey_details.jinja2:133
#: fietsboek/templates/profile_overview.jinja2:28
msgid "page.details.end_time"
msgstr "Endzeit"
#: fietsboek/templates/browse.jinja2:176 fietsboek/templates/details.jinja2:145
+#: fietsboek/templates/journey_details.jinja2:80
+#: fietsboek/templates/journey_details.jinja2:138
#: fietsboek/templates/profile_overview.jinja2:32
msgid "page.details.uphill"
msgstr "Bergauf"
#: fietsboek/templates/browse.jinja2:178 fietsboek/templates/details.jinja2:149
+#: fietsboek/templates/journey_details.jinja2:84
+#: fietsboek/templates/journey_details.jinja2:140
#: fietsboek/templates/profile_overview.jinja2:34
msgid "page.details.downhill"
msgstr "Bergab"
#: fietsboek/templates/browse.jinja2:183 fietsboek/templates/details.jinja2:154
+#: fietsboek/templates/journey_details.jinja2:88
+#: fietsboek/templates/journey_details.jinja2:145
#: fietsboek/templates/profile_overview.jinja2:38
msgid "page.details.moving_time"
msgstr "Fahrzeit"
#: fietsboek/templates/browse.jinja2:185 fietsboek/templates/details.jinja2:158
+#: fietsboek/templates/journey_details.jinja2:92
+#: fietsboek/templates/journey_details.jinja2:147
#: fietsboek/templates/profile_overview.jinja2:40
msgid "page.details.stopped_time"
msgstr "Haltezeit"
#: fietsboek/templates/browse.jinja2:189 fietsboek/templates/details.jinja2:162
+#: fietsboek/templates/journey_details.jinja2:96
+#: fietsboek/templates/journey_details.jinja2:151
#: fietsboek/templates/profile_overview.jinja2:44
msgid "page.details.max_speed"
msgstr "maximale Geschwindigkeit"
#: fietsboek/templates/browse.jinja2:191 fietsboek/templates/details.jinja2:166
+#: fietsboek/templates/journey_details.jinja2:100
+#: fietsboek/templates/journey_details.jinja2:153
#: fietsboek/templates/profile_overview.jinja2:46
msgid "page.details.avg_speed"
msgstr "durchschnittliche Geschwindigkeit"
#: fietsboek/templates/browse.jinja2:196
+#: fietsboek/templates/journey_details.jinja2:158
msgid "page.browse.card.comments"
msgstr "Kommentare"
#: fietsboek/templates/browse.jinja2:198
+#: fietsboek/templates/journey_details.jinja2:160
msgid "page.browse.card.images"
msgstr "Bilder"
@@ -501,6 +524,7 @@ msgstr "Schlagwörter"
#: fietsboek/templates/details.jinja2:108 fietsboek/templates/edit.jinja2:10
#: fietsboek/templates/finish_upload.jinja2:10
+#: fietsboek/templates/journey_details.jinja2:66
msgid "page.noscript"
msgstr ""
"JavaScript ist deaktiviert, zum Nutzen aller Funktionen bitte JavaScript "
@@ -704,6 +728,118 @@ msgstr[1] "%(num)d Strecken"
msgid "page.home.total"
msgstr "Gesamt"
+#: fietsboek/templates/journey_details.jinja2:10
+msgid "journey.edit"
+msgstr "Bearbeiten"
+
+#: fietsboek/templates/journey_details.jinja2:11
+msgid "journey.share"
+msgstr "Teilen"
+
+#: fietsboek/templates/journey_details.jinja2:12
+msgid "journey.delete"
+msgstr "Löschen"
+
+#: fietsboek/templates/journey_details.jinja2:18
+msgid "journey.sharelink.title"
+msgstr "Link zum Teilen"
+
+#: fietsboek/templates/journey_details.jinja2:22
+msgid "journey.sharelink.info"
+msgstr "Jeder mit Zugang zu diesem Link kann die Reise ansehen!"
+
+#: fietsboek/templates/journey_details.jinja2:29
+msgid "journey.sharelink.invalidate"
+msgstr "Link invalidieren"
+
+#: fietsboek/templates/journey_details.jinja2:31
+msgid "journey.sharelink.close"
+msgstr "Schließen"
+
+#: fietsboek/templates/journey_details.jinja2:41
+msgid "journey.delete.title"
+msgstr "Reise Löschen"
+
+#: fietsboek/templates/journey_details.jinja2:45
+msgid "journey.delete.info"
+msgstr "Das Löschen der Reise wird die einzelnen Strecken nicht löschen."
+
+#: fietsboek/templates/journey_details.jinja2:50
+msgid "journey.delete.delete"
+msgstr "Löschen"
+
+#: fietsboek/templates/journey_details.jinja2:52
+msgid "journey.delete.close"
+msgstr "Abbrechen"
+
+#: fietsboek/templates/journey_details.jinja2:108
+msgid "journey.tracks"
+msgstr "Strecken"
+
+#: fietsboek/templates/journey_details.jinja2:174
+msgid "journeys.track.hidden"
+msgstr "Du hast nicht die Rechte, diese Strecke zu sehen. Sie ist versteckt."
+
+#: fietsboek/templates/journey_form.jinja2:40
+msgid "journeys.new.form.title"
+msgstr "Titel"
+
+#: fietsboek/templates/journey_form.jinja2:43
+msgid "journeys.new.form.requires_title"
+msgstr "Ein Titel wird benötigt"
+
+#: fietsboek/templates/journey_form.jinja2:47
+msgid "journeys.new.form.description"
+msgstr "Beschreibung"
+
+#: fietsboek/templates/journey_form.jinja2:51
+msgid "journeys.new.form.visibility"
+msgstr "Sichtbarkeit"
+
+#: fietsboek/templates/journey_form.jinja2:54
+msgid "journeys.new.form.visibility.private"
+msgstr "Privat"
+
+#: fietsboek/templates/journey_form.jinja2:55
+msgid "journeys.new.form.visibility.friends"
+msgstr "Nur Freunde"
+
+#: fietsboek/templates/journey_form.jinja2:56
+msgid "journeys.new.form.visibility.logged_in"
+msgstr "Angemeldete Nutzer"
+
+#: fietsboek/templates/journey_form.jinja2:57
+msgid "journeys.new.form.visibility.public"
+msgstr "Öffentlich"
+
+#: fietsboek/templates/journey_form.jinja2:62
+msgid "journeys.new.form.tracksearch"
+msgstr "Nach Strecken suchen"
+
+#: fietsboek/templates/journey_form.jinja2:71
+msgid "journeys.new.form.tracks"
+msgstr "Strecken (ziehen zum Ordnen)"
+
+#: fietsboek/templates/journey_form.jinja2:90
+msgid "journeys.new.form.submit"
+msgstr "Speichern"
+
+#: fietsboek/templates/journey_form.jinja2:93
+msgid "journeys.new.form.requires_tracks"
+msgstr "Es muss mindestens eine Strecke vorhanden sein"
+
+#: fietsboek/templates/journey_list.jinja2:4
+msgid "journeys.overview.title"
+msgstr "Reisen"
+
+#: fietsboek/templates/journey_list.jinja2:10
+msgid "journeys.overview.new"
+msgstr "Neue Reise"
+
+#: fietsboek/templates/journey_new.jinja2:10
+msgid "journeys.new.title"
+msgstr "Neue Reise"
+
#: fietsboek/templates/layout.jinja2:44
msgid "page.navbar.toggle"
msgstr "Navigation umschalten"
@@ -716,39 +852,43 @@ msgstr "Startseite"
msgid "page.navbar.browse"
msgstr "Stöbern"
-#: fietsboek/templates/layout.jinja2:62
+#: fietsboek/templates/layout.jinja2:61
+msgid "page.navbar.journeys"
+msgstr "Reisen"
+
+#: fietsboek/templates/layout.jinja2:65
msgid "page.navbar.upload"
msgstr "Hochladen"
-#: fietsboek/templates/layout.jinja2:71
+#: fietsboek/templates/layout.jinja2:74
msgid "page.navbar.user"
msgstr "Nutzer"
-#: fietsboek/templates/layout.jinja2:75
+#: fietsboek/templates/layout.jinja2:78
msgid "page.navbar.welcome_user"
msgstr "Willkommen, {}!"
-#: fietsboek/templates/layout.jinja2:78
+#: fietsboek/templates/layout.jinja2:81
msgid "page.navbar.logout"
msgstr "Abmelden"
-#: fietsboek/templates/layout.jinja2:81
+#: fietsboek/templates/layout.jinja2:84
msgid "page.navbar.profile"
msgstr "Profil"
-#: fietsboek/templates/layout.jinja2:84
+#: fietsboek/templates/layout.jinja2:87
msgid "page.navbar.user_data"
msgstr "Persönliche Daten"
-#: fietsboek/templates/layout.jinja2:88
+#: fietsboek/templates/layout.jinja2:91
msgid "page.navbar.admin"
msgstr "Admin"
-#: fietsboek/templates/layout.jinja2:94
+#: fietsboek/templates/layout.jinja2:97
msgid "page.navbar.login"
msgstr "Anmelden"
-#: fietsboek/templates/layout.jinja2:98
+#: fietsboek/templates/layout.jinja2:101
msgid "page.navbar.create_account"
msgstr "Konto Erstellen"
@@ -1038,15 +1178,15 @@ msgstr "Ungültige E-Mail-Adresse"
msgid "flash.a_confirmation_link_has_been_sent"
msgstr "Ein Bestätigungslink wurde versandt"
-#: fietsboek/views/admin.py:183
+#: fietsboek/views/admin.py:189
msgid "flash.badge_added"
msgstr "Wappen hinzugefügt"
-#: fietsboek/views/admin.py:207
+#: fietsboek/views/admin.py:213
msgid "flash.badge_modified"
msgstr "Wappen bearbeitet"
-#: fietsboek/views/admin.py:227
+#: fietsboek/views/admin.py:233
msgid "flash.badge_deleted"
msgstr "Wappen gelöscht"
@@ -1106,23 +1246,27 @@ msgstr "E-Mail-Adresse bestätigt"
msgid "flash.password_updated"
msgstr "Passwort aktualisiert"
-#: fietsboek/views/detail.py:189
+#: fietsboek/views/detail.py:187
msgid "flash.track_deleted"
msgstr "Strecke gelöscht"
-#: fietsboek/views/edit.py:98 fietsboek/views/upload.py:63
+#: fietsboek/views/edit.py:97 fietsboek/views/upload.py:63
msgid "flash.invalid_file"
msgstr "Ungültige GPX-Datei gesendet"
+#: fietsboek/views/journey.py:251
+msgid "flash.journey_deleted"
+msgstr "Reise gelöscht"
+
#: fietsboek/views/upload.py:53
msgid "flash.no_file_selected"
msgstr "Keine Datei ausgewählt"
-#: fietsboek/views/upload.py:182
+#: fietsboek/views/upload.py:177
msgid "flash.upload_success"
msgstr "Hochladen erfolgreich"
-#: fietsboek/views/upload.py:201
+#: fietsboek/views/upload.py:196
msgid "flash.upload_cancelled"
msgstr "Hochladen abgebrochen"
diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.mo b/fietsboek/locale/en/LC_MESSAGES/messages.mo
index 980fbe0..f4b7bbf 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 2d62c14..d52960b 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-11-25 20:03+0100\n"
+"POT-Creation-Date: 2026-01-03 19:25+0100\n"
"PO-Revision-Date: 2023-04-03 20:42+0200\n"
"Last-Translator: \n"
"Language: en\n"
@@ -18,11 +18,11 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
-#: fietsboek/actions.py:268
+#: fietsboek/actions.py:266
msgid "email.verify_mail.subject"
msgstr "Fietsboek Account Verification"
-#: fietsboek/actions.py:271
+#: fietsboek/actions.py:269
msgid "email.verify.text"
msgstr ""
"To verify your Fietsboek account, please use this link: {}\n"
@@ -61,43 +61,43 @@ msgstr "Max Speed"
msgid "pdf.table.avg_speed"
msgstr "Average Speed"
-#: fietsboek/util.py:297
+#: fietsboek/util.py:299
msgid "password_constraint.mismatch"
msgstr "Passwords don't match"
-#: fietsboek/util.py:299
+#: fietsboek/util.py:301
msgid "password_constraint.length"
msgstr "Password not long enough"
-#: fietsboek/models/track.py:776
+#: fietsboek/models/track.py:774
msgid "tooltip.table.length"
msgstr "Length"
-#: fietsboek/models/track.py:777
+#: fietsboek/models/track.py:775
msgid "tooltip.table.people"
msgstr "# People"
-#: fietsboek/models/track.py:778
+#: fietsboek/models/track.py:776
msgid "tooltip.table.uphill"
msgstr "Uphill"
-#: fietsboek/models/track.py:779
+#: fietsboek/models/track.py:777
msgid "tooltip.table.downhill"
msgstr "Downhill"
-#: fietsboek/models/track.py:780 fietsboek/templates/home.jinja2:7
+#: fietsboek/models/track.py:778 fietsboek/templates/home.jinja2:7
msgid "tooltip.table.moving_time"
msgstr "Moving Time"
-#: fietsboek/models/track.py:781 fietsboek/templates/home.jinja2:8
+#: fietsboek/models/track.py:779 fietsboek/templates/home.jinja2:8
msgid "tooltip.table.stopped_time"
msgstr "Stopped Time"
-#: fietsboek/models/track.py:783
+#: fietsboek/models/track.py:781
msgid "tooltip.table.max_speed"
msgstr "Max Speed"
-#: fietsboek/models/track.py:787
+#: fietsboek/models/track.py:785
msgid "tooltip.table.avg_speed"
msgstr "Average Speed"
@@ -220,14 +220,18 @@ msgid "admin.overview.storage_graph.label.images"
msgstr "Images"
#: fietsboek/templates/admin_overview.jinja2:58
-msgid "admin.overview.storage_graph.label.previews"
-msgstr "Preview images"
+msgid "admin.overview.storage_graph.label.track_previews"
+msgstr "Track previews"
#: fietsboek/templates/admin_overview.jinja2:59
+msgid "admin.overview.storage_graph.label.journey_previews"
+msgstr "Journey previews"
+
+#: fietsboek/templates/admin_overview.jinja2:60
msgid "admin.overview.storage_graph.label.user_maps"
msgstr "User maps"
-#: fietsboek/templates/admin_overview.jinja2:86
+#: fietsboek/templates/admin_overview.jinja2:88
msgid "admin.overview.storage_graph.title"
msgstr "Storage breakdown"
@@ -328,60 +332,79 @@ msgid "page.browse.synthetic_tooltip"
msgstr "This is a pre-planned track"
#: fietsboek/templates/browse.jinja2:162 fietsboek/templates/details.jinja2:127
+#: fietsboek/templates/journey_details.jinja2:124
#: fietsboek/templates/profile_overview.jinja2:20
msgid "page.details.date"
msgstr "Date"
#: fietsboek/templates/browse.jinja2:164 fietsboek/templates/details.jinja2:141
+#: fietsboek/templates/journey_details.jinja2:76
+#: fietsboek/templates/journey_details.jinja2:126
#: fietsboek/templates/profile_overview.jinja2:22
msgid "page.details.length"
msgstr "Length"
#: fietsboek/templates/browse.jinja2:169 fietsboek/templates/details.jinja2:132
+#: fietsboek/templates/journey_details.jinja2:131
#: fietsboek/templates/profile_overview.jinja2:26
msgid "page.details.start_time"
msgstr "Record Start"
#: fietsboek/templates/browse.jinja2:171 fietsboek/templates/details.jinja2:136
+#: fietsboek/templates/journey_details.jinja2:133
#: fietsboek/templates/profile_overview.jinja2:28
msgid "page.details.end_time"
msgstr "Record End"
#: fietsboek/templates/browse.jinja2:176 fietsboek/templates/details.jinja2:145
+#: fietsboek/templates/journey_details.jinja2:80
+#: fietsboek/templates/journey_details.jinja2:138
#: fietsboek/templates/profile_overview.jinja2:32
msgid "page.details.uphill"
msgstr "Uphill"
#: fietsboek/templates/browse.jinja2:178 fietsboek/templates/details.jinja2:149
+#: fietsboek/templates/journey_details.jinja2:84
+#: fietsboek/templates/journey_details.jinja2:140
#: fietsboek/templates/profile_overview.jinja2:34
msgid "page.details.downhill"
msgstr "Downhill"
#: fietsboek/templates/browse.jinja2:183 fietsboek/templates/details.jinja2:154
+#: fietsboek/templates/journey_details.jinja2:88
+#: fietsboek/templates/journey_details.jinja2:145
#: fietsboek/templates/profile_overview.jinja2:38
msgid "page.details.moving_time"
msgstr "Moving Time"
#: fietsboek/templates/browse.jinja2:185 fietsboek/templates/details.jinja2:158
+#: fietsboek/templates/journey_details.jinja2:92
+#: fietsboek/templates/journey_details.jinja2:147
#: fietsboek/templates/profile_overview.jinja2:40
msgid "page.details.stopped_time"
msgstr "Stopped Time"
#: fietsboek/templates/browse.jinja2:189 fietsboek/templates/details.jinja2:162
+#: fietsboek/templates/journey_details.jinja2:96
+#: fietsboek/templates/journey_details.jinja2:151
#: fietsboek/templates/profile_overview.jinja2:44
msgid "page.details.max_speed"
msgstr "Max Speed"
#: fietsboek/templates/browse.jinja2:191 fietsboek/templates/details.jinja2:166
+#: fietsboek/templates/journey_details.jinja2:100
+#: fietsboek/templates/journey_details.jinja2:153
#: fietsboek/templates/profile_overview.jinja2:46
msgid "page.details.avg_speed"
msgstr "Average Speed"
#: fietsboek/templates/browse.jinja2:196
+#: fietsboek/templates/journey_details.jinja2:158
msgid "page.browse.card.comments"
msgstr "Comments"
#: fietsboek/templates/browse.jinja2:198
+#: fietsboek/templates/journey_details.jinja2:160
msgid "page.browse.card.images"
msgstr "Images"
@@ -499,6 +522,7 @@ msgstr "Tagged as"
#: fietsboek/templates/details.jinja2:108 fietsboek/templates/edit.jinja2:10
#: fietsboek/templates/finish_upload.jinja2:10
+#: fietsboek/templates/journey_details.jinja2:66
msgid "page.noscript"
msgstr "JavaScript is disabled, please enable JavaScript"
@@ -698,6 +722,118 @@ msgstr[1] "%(num)d tracks"
msgid "page.home.total"
msgstr "Total"
+#: fietsboek/templates/journey_details.jinja2:10
+msgid "journey.edit"
+msgstr "Edit"
+
+#: fietsboek/templates/journey_details.jinja2:11
+msgid "journey.share"
+msgstr "Share"
+
+#: fietsboek/templates/journey_details.jinja2:12
+msgid "journey.delete"
+msgstr "Delete"
+
+#: fietsboek/templates/journey_details.jinja2:18
+msgid "journey.sharelink.title"
+msgstr "Share Link"
+
+#: fietsboek/templates/journey_details.jinja2:22
+msgid "journey.sharelink.info"
+msgstr "Everyone with access to this link can view the journey!"
+
+#: fietsboek/templates/journey_details.jinja2:29
+msgid "journey.sharelink.invalidate"
+msgstr "Invalidate link"
+
+#: fietsboek/templates/journey_details.jinja2:31
+msgid "journey.sharelink.close"
+msgstr "Close"
+
+#: fietsboek/templates/journey_details.jinja2:41
+msgid "journey.delete.title"
+msgstr "Delete Journey"
+
+#: fietsboek/templates/journey_details.jinja2:45
+msgid "journey.delete.info"
+msgstr "Deleting this journey will not remove the individual tracks."
+
+#: fietsboek/templates/journey_details.jinja2:50
+msgid "journey.delete.delete"
+msgstr "Delete"
+
+#: fietsboek/templates/journey_details.jinja2:52
+msgid "journey.delete.close"
+msgstr "Abort"
+
+#: fietsboek/templates/journey_details.jinja2:108
+msgid "journey.tracks"
+msgstr "Tracks"
+
+#: fietsboek/templates/journey_details.jinja2:174
+msgid "journeys.track.hidden"
+msgstr "This track is hidden, you don't have permission to view it."
+
+#: fietsboek/templates/journey_form.jinja2:40
+msgid "journeys.new.form.title"
+msgstr "Title"
+
+#: fietsboek/templates/journey_form.jinja2:43
+msgid "journeys.new.form.requires_title"
+msgstr "A title is required"
+
+#: fietsboek/templates/journey_form.jinja2:47
+msgid "journeys.new.form.description"
+msgstr "Description"
+
+#: fietsboek/templates/journey_form.jinja2:51
+msgid "journeys.new.form.visibility"
+msgstr "Visibility"
+
+#: fietsboek/templates/journey_form.jinja2:54
+msgid "journeys.new.form.visibility.private"
+msgstr "Private"
+
+#: fietsboek/templates/journey_form.jinja2:55
+msgid "journeys.new.form.visibility.friends"
+msgstr "Friends only"
+
+#: fietsboek/templates/journey_form.jinja2:56
+msgid "journeys.new.form.visibility.logged_in"
+msgstr "Logged in users"
+
+#: fietsboek/templates/journey_form.jinja2:57
+msgid "journeys.new.form.visibility.public"
+msgstr "Public"
+
+#: fietsboek/templates/journey_form.jinja2:62
+msgid "journeys.new.form.tracksearch"
+msgstr "Search for tracks"
+
+#: fietsboek/templates/journey_form.jinja2:71
+msgid "journeys.new.form.tracks"
+msgstr "Tracks (drag to re-order)"
+
+#: fietsboek/templates/journey_form.jinja2:90
+msgid "journeys.new.form.submit"
+msgstr "Save"
+
+#: fietsboek/templates/journey_form.jinja2:93
+msgid "journeys.new.form.requires_tracks"
+msgstr "A journey must have at least one track"
+
+#: fietsboek/templates/journey_list.jinja2:4
+msgid "journeys.overview.title"
+msgstr "Journeys"
+
+#: fietsboek/templates/journey_list.jinja2:10
+msgid "journeys.overview.new"
+msgstr "New journey"
+
+#: fietsboek/templates/journey_new.jinja2:10
+msgid "journeys.new.title"
+msgstr "New Journey"
+
#: fietsboek/templates/layout.jinja2:44
msgid "page.navbar.toggle"
msgstr "Toggle navigation"
@@ -710,39 +846,43 @@ msgstr "Home"
msgid "page.navbar.browse"
msgstr "Browse"
-#: fietsboek/templates/layout.jinja2:62
+#: fietsboek/templates/layout.jinja2:61
+msgid "page.navbar.journeys"
+msgstr "Journeys"
+
+#: fietsboek/templates/layout.jinja2:65
msgid "page.navbar.upload"
msgstr "Upload"
-#: fietsboek/templates/layout.jinja2:71
+#: fietsboek/templates/layout.jinja2:74
msgid "page.navbar.user"
msgstr "User"
-#: fietsboek/templates/layout.jinja2:75
+#: fietsboek/templates/layout.jinja2:78
msgid "page.navbar.welcome_user"
msgstr "Welcome, {}!"
-#: fietsboek/templates/layout.jinja2:78
+#: fietsboek/templates/layout.jinja2:81
msgid "page.navbar.logout"
msgstr "Logout"
-#: fietsboek/templates/layout.jinja2:81
+#: fietsboek/templates/layout.jinja2:84
msgid "page.navbar.profile"
msgstr "Profile"
-#: fietsboek/templates/layout.jinja2:84
+#: fietsboek/templates/layout.jinja2:87
msgid "page.navbar.user_data"
msgstr "Personal Data"
-#: fietsboek/templates/layout.jinja2:88
+#: fietsboek/templates/layout.jinja2:91
msgid "page.navbar.admin"
msgstr "Admin"
-#: fietsboek/templates/layout.jinja2:94
+#: fietsboek/templates/layout.jinja2:97
msgid "page.navbar.login"
msgstr "Login"
-#: fietsboek/templates/layout.jinja2:98
+#: fietsboek/templates/layout.jinja2:101
msgid "page.navbar.create_account"
msgstr "Create Account"
@@ -1028,15 +1168,15 @@ msgstr "Invalid email"
msgid "flash.a_confirmation_link_has_been_sent"
msgstr "A confirmation link has been sent"
-#: fietsboek/views/admin.py:183
+#: fietsboek/views/admin.py:189
msgid "flash.badge_added"
msgstr "Badge has been added"
-#: fietsboek/views/admin.py:207
+#: fietsboek/views/admin.py:213
msgid "flash.badge_modified"
msgstr "Badge has been modified"
-#: fietsboek/views/admin.py:227
+#: fietsboek/views/admin.py:233
msgid "flash.badge_deleted"
msgstr "Badge has been deleted"
@@ -1095,23 +1235,27 @@ msgstr "Your email address has been verified"
msgid "flash.password_updated"
msgstr "Password has been updated"
-#: fietsboek/views/detail.py:189
+#: fietsboek/views/detail.py:187
msgid "flash.track_deleted"
msgstr "Track has been deleted"
-#: fietsboek/views/edit.py:98 fietsboek/views/upload.py:63
+#: fietsboek/views/edit.py:97 fietsboek/views/upload.py:63
msgid "flash.invalid_file"
msgstr "Invalid GPX file selected"
+#: fietsboek/views/journey.py:251
+msgid "flash.journey_deleted"
+msgstr "Journey has been deleted"
+
#: fietsboek/views/upload.py:53
msgid "flash.no_file_selected"
msgstr "No file selected"
-#: fietsboek/views/upload.py:182
+#: fietsboek/views/upload.py:177
msgid "flash.upload_success"
msgstr "Upload successful"
-#: fietsboek/views/upload.py:201
+#: fietsboek/views/upload.py:196
msgid "flash.upload_cancelled"
msgstr "Upload cancelled"
diff --git a/fietsboek/locale/fietslog.pot b/fietsboek/locale/fietslog.pot
index 6db79b7..50a43a0 100644
--- a/fietsboek/locale/fietslog.pot
+++ b/fietsboek/locale/fietslog.pot
@@ -1,14 +1,14 @@
# Translations template for PROJECT.
-# Copyright (C) 2025 ORGANIZATION
+# Copyright (C) 2026 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
-# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
+# FIRST AUTHOR <EMAIL@ADDRESS>, 2026.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2025-11-25 20:03+0100\n"
+"POT-Creation-Date: 2026-01-03 19:25+0100\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"
@@ -17,11 +17,11 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
-#: fietsboek/actions.py:268
+#: fietsboek/actions.py:266
msgid "email.verify_mail.subject"
msgstr ""
-#: fietsboek/actions.py:271
+#: fietsboek/actions.py:269
msgid "email.verify.text"
msgstr ""
@@ -57,43 +57,43 @@ msgstr ""
msgid "pdf.table.avg_speed"
msgstr ""
-#: fietsboek/util.py:297
+#: fietsboek/util.py:299
msgid "password_constraint.mismatch"
msgstr ""
-#: fietsboek/util.py:299
+#: fietsboek/util.py:301
msgid "password_constraint.length"
msgstr ""
-#: fietsboek/models/track.py:776
+#: fietsboek/models/track.py:774
msgid "tooltip.table.length"
msgstr ""
-#: fietsboek/models/track.py:777
+#: fietsboek/models/track.py:775
msgid "tooltip.table.people"
msgstr ""
-#: fietsboek/models/track.py:778
+#: fietsboek/models/track.py:776
msgid "tooltip.table.uphill"
msgstr ""
-#: fietsboek/models/track.py:779
+#: fietsboek/models/track.py:777
msgid "tooltip.table.downhill"
msgstr ""
-#: fietsboek/models/track.py:780 fietsboek/templates/home.jinja2:7
+#: fietsboek/models/track.py:778 fietsboek/templates/home.jinja2:7
msgid "tooltip.table.moving_time"
msgstr ""
-#: fietsboek/models/track.py:781 fietsboek/templates/home.jinja2:8
+#: fietsboek/models/track.py:779 fietsboek/templates/home.jinja2:8
msgid "tooltip.table.stopped_time"
msgstr ""
-#: fietsboek/models/track.py:783
+#: fietsboek/models/track.py:781
msgid "tooltip.table.max_speed"
msgstr ""
-#: fietsboek/models/track.py:787
+#: fietsboek/models/track.py:785
msgid "tooltip.table.avg_speed"
msgstr ""
@@ -214,14 +214,18 @@ msgid "admin.overview.storage_graph.label.images"
msgstr ""
#: fietsboek/templates/admin_overview.jinja2:58
-msgid "admin.overview.storage_graph.label.previews"
+msgid "admin.overview.storage_graph.label.track_previews"
msgstr ""
#: fietsboek/templates/admin_overview.jinja2:59
+msgid "admin.overview.storage_graph.label.journey_previews"
+msgstr ""
+
+#: fietsboek/templates/admin_overview.jinja2:60
msgid "admin.overview.storage_graph.label.user_maps"
msgstr ""
-#: fietsboek/templates/admin_overview.jinja2:86
+#: fietsboek/templates/admin_overview.jinja2:88
msgid "admin.overview.storage_graph.title"
msgstr ""
@@ -322,60 +326,79 @@ msgid "page.browse.synthetic_tooltip"
msgstr ""
#: fietsboek/templates/browse.jinja2:162 fietsboek/templates/details.jinja2:127
+#: fietsboek/templates/journey_details.jinja2:124
#: fietsboek/templates/profile_overview.jinja2:20
msgid "page.details.date"
msgstr ""
#: fietsboek/templates/browse.jinja2:164 fietsboek/templates/details.jinja2:141
+#: fietsboek/templates/journey_details.jinja2:76
+#: fietsboek/templates/journey_details.jinja2:126
#: fietsboek/templates/profile_overview.jinja2:22
msgid "page.details.length"
msgstr ""
#: fietsboek/templates/browse.jinja2:169 fietsboek/templates/details.jinja2:132
+#: fietsboek/templates/journey_details.jinja2:131
#: fietsboek/templates/profile_overview.jinja2:26
msgid "page.details.start_time"
msgstr ""
#: fietsboek/templates/browse.jinja2:171 fietsboek/templates/details.jinja2:136
+#: fietsboek/templates/journey_details.jinja2:133
#: fietsboek/templates/profile_overview.jinja2:28
msgid "page.details.end_time"
msgstr ""
#: fietsboek/templates/browse.jinja2:176 fietsboek/templates/details.jinja2:145
+#: fietsboek/templates/journey_details.jinja2:80
+#: fietsboek/templates/journey_details.jinja2:138
#: fietsboek/templates/profile_overview.jinja2:32
msgid "page.details.uphill"
msgstr ""
#: fietsboek/templates/browse.jinja2:178 fietsboek/templates/details.jinja2:149
+#: fietsboek/templates/journey_details.jinja2:84
+#: fietsboek/templates/journey_details.jinja2:140
#: fietsboek/templates/profile_overview.jinja2:34
msgid "page.details.downhill"
msgstr ""
#: fietsboek/templates/browse.jinja2:183 fietsboek/templates/details.jinja2:154
+#: fietsboek/templates/journey_details.jinja2:88
+#: fietsboek/templates/journey_details.jinja2:145
#: fietsboek/templates/profile_overview.jinja2:38
msgid "page.details.moving_time"
msgstr ""
#: fietsboek/templates/browse.jinja2:185 fietsboek/templates/details.jinja2:158
+#: fietsboek/templates/journey_details.jinja2:92
+#: fietsboek/templates/journey_details.jinja2:147
#: fietsboek/templates/profile_overview.jinja2:40
msgid "page.details.stopped_time"
msgstr ""
#: fietsboek/templates/browse.jinja2:189 fietsboek/templates/details.jinja2:162
+#: fietsboek/templates/journey_details.jinja2:96
+#: fietsboek/templates/journey_details.jinja2:151
#: fietsboek/templates/profile_overview.jinja2:44
msgid "page.details.max_speed"
msgstr ""
#: fietsboek/templates/browse.jinja2:191 fietsboek/templates/details.jinja2:166
+#: fietsboek/templates/journey_details.jinja2:100
+#: fietsboek/templates/journey_details.jinja2:153
#: fietsboek/templates/profile_overview.jinja2:46
msgid "page.details.avg_speed"
msgstr ""
#: fietsboek/templates/browse.jinja2:196
+#: fietsboek/templates/journey_details.jinja2:158
msgid "page.browse.card.comments"
msgstr ""
#: fietsboek/templates/browse.jinja2:198
+#: fietsboek/templates/journey_details.jinja2:160
msgid "page.browse.card.images"
msgstr ""
@@ -493,6 +516,7 @@ msgstr ""
#: fietsboek/templates/details.jinja2:108 fietsboek/templates/edit.jinja2:10
#: fietsboek/templates/finish_upload.jinja2:10
+#: fietsboek/templates/journey_details.jinja2:66
msgid "page.noscript"
msgstr ""
@@ -690,6 +714,118 @@ msgstr[1] ""
msgid "page.home.total"
msgstr ""
+#: fietsboek/templates/journey_details.jinja2:10
+msgid "journey.edit"
+msgstr ""
+
+#: fietsboek/templates/journey_details.jinja2:11
+msgid "journey.share"
+msgstr ""
+
+#: fietsboek/templates/journey_details.jinja2:12
+msgid "journey.delete"
+msgstr ""
+
+#: fietsboek/templates/journey_details.jinja2:18
+msgid "journey.sharelink.title"
+msgstr ""
+
+#: fietsboek/templates/journey_details.jinja2:22
+msgid "journey.sharelink.info"
+msgstr ""
+
+#: fietsboek/templates/journey_details.jinja2:29
+msgid "journey.sharelink.invalidate"
+msgstr ""
+
+#: fietsboek/templates/journey_details.jinja2:31
+msgid "journey.sharelink.close"
+msgstr ""
+
+#: fietsboek/templates/journey_details.jinja2:41
+msgid "journey.delete.title"
+msgstr ""
+
+#: fietsboek/templates/journey_details.jinja2:45
+msgid "journey.delete.info"
+msgstr ""
+
+#: fietsboek/templates/journey_details.jinja2:50
+msgid "journey.delete.delete"
+msgstr ""
+
+#: fietsboek/templates/journey_details.jinja2:52
+msgid "journey.delete.close"
+msgstr ""
+
+#: fietsboek/templates/journey_details.jinja2:108
+msgid "journey.tracks"
+msgstr ""
+
+#: fietsboek/templates/journey_details.jinja2:174
+msgid "journeys.track.hidden"
+msgstr ""
+
+#: fietsboek/templates/journey_form.jinja2:40
+msgid "journeys.new.form.title"
+msgstr ""
+
+#: fietsboek/templates/journey_form.jinja2:43
+msgid "journeys.new.form.requires_title"
+msgstr ""
+
+#: fietsboek/templates/journey_form.jinja2:47
+msgid "journeys.new.form.description"
+msgstr ""
+
+#: fietsboek/templates/journey_form.jinja2:51
+msgid "journeys.new.form.visibility"
+msgstr ""
+
+#: fietsboek/templates/journey_form.jinja2:54
+msgid "journeys.new.form.visibility.private"
+msgstr ""
+
+#: fietsboek/templates/journey_form.jinja2:55
+msgid "journeys.new.form.visibility.friends"
+msgstr ""
+
+#: fietsboek/templates/journey_form.jinja2:56
+msgid "journeys.new.form.visibility.logged_in"
+msgstr ""
+
+#: fietsboek/templates/journey_form.jinja2:57
+msgid "journeys.new.form.visibility.public"
+msgstr ""
+
+#: fietsboek/templates/journey_form.jinja2:62
+msgid "journeys.new.form.tracksearch"
+msgstr ""
+
+#: fietsboek/templates/journey_form.jinja2:71
+msgid "journeys.new.form.tracks"
+msgstr ""
+
+#: fietsboek/templates/journey_form.jinja2:90
+msgid "journeys.new.form.submit"
+msgstr ""
+
+#: fietsboek/templates/journey_form.jinja2:93
+msgid "journeys.new.form.requires_tracks"
+msgstr ""
+
+#: fietsboek/templates/journey_list.jinja2:4
+msgid "journeys.overview.title"
+msgstr ""
+
+#: fietsboek/templates/journey_list.jinja2:10
+msgid "journeys.overview.new"
+msgstr ""
+
+#: fietsboek/templates/journey_new.jinja2:10
+msgid "journeys.new.title"
+msgstr ""
+
#: fietsboek/templates/layout.jinja2:44
msgid "page.navbar.toggle"
msgstr ""
@@ -702,39 +838,43 @@ msgstr ""
msgid "page.navbar.browse"
msgstr ""
-#: fietsboek/templates/layout.jinja2:62
+#: fietsboek/templates/layout.jinja2:61
+msgid "page.navbar.journeys"
+msgstr ""
+
+#: fietsboek/templates/layout.jinja2:65
msgid "page.navbar.upload"
msgstr ""
-#: fietsboek/templates/layout.jinja2:71
+#: fietsboek/templates/layout.jinja2:74
msgid "page.navbar.user"
msgstr ""
-#: fietsboek/templates/layout.jinja2:75
+#: fietsboek/templates/layout.jinja2:78
msgid "page.navbar.welcome_user"
msgstr ""
-#: fietsboek/templates/layout.jinja2:78
+#: fietsboek/templates/layout.jinja2:81
msgid "page.navbar.logout"
msgstr ""
-#: fietsboek/templates/layout.jinja2:81
+#: fietsboek/templates/layout.jinja2:84
msgid "page.navbar.profile"
msgstr ""
-#: fietsboek/templates/layout.jinja2:84
+#: fietsboek/templates/layout.jinja2:87
msgid "page.navbar.user_data"
msgstr ""
-#: fietsboek/templates/layout.jinja2:88
+#: fietsboek/templates/layout.jinja2:91
msgid "page.navbar.admin"
msgstr ""
-#: fietsboek/templates/layout.jinja2:94
+#: fietsboek/templates/layout.jinja2:97
msgid "page.navbar.login"
msgstr ""
-#: fietsboek/templates/layout.jinja2:98
+#: fietsboek/templates/layout.jinja2:101
msgid "page.navbar.create_account"
msgstr ""
@@ -1014,15 +1154,15 @@ msgstr ""
msgid "flash.a_confirmation_link_has_been_sent"
msgstr ""
-#: fietsboek/views/admin.py:183
+#: fietsboek/views/admin.py:189
msgid "flash.badge_added"
msgstr ""
-#: fietsboek/views/admin.py:207
+#: fietsboek/views/admin.py:213
msgid "flash.badge_modified"
msgstr ""
-#: fietsboek/views/admin.py:227
+#: fietsboek/views/admin.py:233
msgid "flash.badge_deleted"
msgstr ""
@@ -1078,23 +1218,27 @@ msgstr ""
msgid "flash.password_updated"
msgstr ""
-#: fietsboek/views/detail.py:189
+#: fietsboek/views/detail.py:187
msgid "flash.track_deleted"
msgstr ""
-#: fietsboek/views/edit.py:98 fietsboek/views/upload.py:63
+#: fietsboek/views/edit.py:97 fietsboek/views/upload.py:63
msgid "flash.invalid_file"
msgstr ""
+#: fietsboek/views/journey.py:251
+msgid "flash.journey_deleted"
+msgstr ""
+
#: fietsboek/views/upload.py:53
msgid "flash.no_file_selected"
msgstr ""
-#: fietsboek/views/upload.py:182
+#: fietsboek/views/upload.py:177
msgid "flash.upload_success"
msgstr ""
-#: fietsboek/views/upload.py:201
+#: fietsboek/views/upload.py:196
msgid "flash.upload_cancelled"
msgstr ""
diff --git a/fietsboek/models/__init__.py b/fietsboek/models/__init__.py
index 85664ca..2130901 100644
--- a/fietsboek/models/__init__.py
+++ b/fietsboek/models/__init__.py
@@ -11,6 +11,7 @@ from sqlalchemy.orm import configure_mappers, sessionmaker
from .badge import Badge # flake8: noqa
from .comment import Comment # flake8: noqa
from .image import ImageMetadata # flake8: noqa
+from .journey import Journey
from .track import Tag, Track, TrackCache, Upload, Waypoint # flake8: noqa
# Import or define all models here to ensure they are attached to the
diff --git a/fietsboek/models/journey.py b/fietsboek/models/journey.py
new file mode 100644
index 0000000..5b75878
--- /dev/null
+++ b/fietsboek/models/journey.py
@@ -0,0 +1,187 @@
+"""Journey model definition.
+
+A Journey is an ordered collection of tracks, with a title and (optionally) a description.
+"""
+
+import dataclasses
+import datetime
+import enum
+import logging
+from typing import TYPE_CHECKING, Self
+
+from pyramid.authorization import (
+ ALL_PERMISSIONS,
+ Allow,
+ Authenticated,
+ Everyone,
+)
+from pyramid.httpexceptions import HTTPNotFound
+from pyramid.request import Request
+from sqlalchemy import (
+ Column,
+ Enum,
+ ForeignKey,
+ Integer,
+ Table,
+ Text,
+ delete,
+ insert,
+ inspect,
+ select,
+)
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from .. import geo
+from .meta import Base
+
+if TYPE_CHECKING:
+ from .. import models
+
+
+LOGGER = logging.getLogger(__name__)
+
+
+class Visibility(enum.Enum):
+ """An enum representing the visibility of Journeys."""
+
+ PRIVATE = enum.auto()
+ """Only the owner can see the journey."""
+
+ FRIENDS = enum.auto()
+ """Friends can see the journey."""
+
+ LOGGED_IN = enum.auto()
+ """Logged in users can see the journey."""
+
+ PUBLIC = enum.auto()
+ """Everybody can see the journey."""
+
+
+journey_track_assoc = Table(
+ "journey_track_assoc",
+ Base.metadata,
+ Column("journey_id", ForeignKey("journeys.id"), primary_key=True),
+ Column("track_id", ForeignKey("tracks.id"), primary_key=True),
+ Column("sort_index", Integer, nullable=False),
+)
+
+
+class Journey(Base):
+ """A :class:`Journey` represents a collection of tracks, with a title and description."""
+
+ __tablename__ = "journeys"
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ owner_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
+ title: Mapped[str] = mapped_column(Text, nullable=False)
+ description: Mapped[str] = mapped_column(Text, nullable=False)
+ visibility: Mapped[Visibility] = mapped_column(Enum(Visibility), nullable=False)
+ link_secret: Mapped[str | None] = mapped_column(Text)
+
+ owner: Mapped["models.User"] = relationship("User", back_populates="journeys")
+ tracks: Mapped[list["models.Track"]] = relationship(
+ "Track",
+ back_populates="journeys",
+ secondary=journey_track_assoc,
+ order_by=journey_track_assoc.c.sort_index,
+ )
+
+ @classmethod
+ def factory(cls, request: Request) -> Self:
+ """Factory method to pass to a route definition.
+
+ This factory retrieves the journey based on the ``journey_id`` matched
+ route parameter, and returns the journey. If the journey is not found,
+ ``HTTPNotFound`` is raised.
+
+ :raises pyramid.httpexception.NotFound: If the journey is not found.
+ :param request: The pyramid request.
+ :type request: ~pyramid.request.Request
+ :return: The journey.
+ :type: Track
+ """
+ journey_id = request.matchdict["journey_id"]
+ query = select(cls).filter_by(id=journey_id)
+ journey = request.dbsession.execute(query).scalar_one_or_none()
+ if journey is None:
+ raise HTTPNotFound()
+ return journey
+
+ def __acl__(self):
+ # Basic ACL: Permissions for the admin, the owner and the share link
+ acl = [
+ (Allow, "group:admins", ALL_PERMISSIONS),
+ (
+ Allow,
+ f"user:{self.owner_id}",
+ [
+ "journey.view",
+ "journey.edit",
+ "journey.unshare",
+ "journey.comment",
+ "journey.delete",
+ ],
+ ),
+ (Allow, f"secret:{self.link_secret}", "journey.view"),
+ ]
+
+ if self.visibility == Visibility.PUBLIC:
+ acl.append((Allow, Everyone, "journey.view"))
+ acl.append((Allow, Authenticated, "journey.comment"))
+ elif self.visibility == Visibility.LOGGED_IN:
+ acl.append((Allow, Authenticated, ["journey.view", "journey.comment"]))
+ elif self.visibility == Visibility.FRIENDS:
+ acl.extend(
+ (Allow, f"user:{friend.id}", ["journey.view", "journey.comment"])
+ for friend in self.owner.get_friends()
+ )
+ return acl
+
+ def set_track_ids(self, track_ids: list[int]):
+ """Sets the IDs of the contained tracks.
+
+ The order is relevant and will be saved.
+
+ Needs to have a session, as it will directly issue INSERT statements.
+
+ :param track_ids: The IDs of the tracks that should be in this journey.
+ """
+ session = inspect(self).session
+ assert session is not None, "Can only use set_track_ids() if journey is in a session"
+ del_stmt = delete(journey_track_assoc).where(journey_track_assoc.c.journey_id == self.id)
+ session.execute(del_stmt)
+ for index, track_id in enumerate(track_ids, 1):
+ ins_stmt = insert(journey_track_assoc).values(
+ journey_id=self.id,
+ track_id=track_id,
+ sort_index=index,
+ )
+ session.execute(ins_stmt)
+
+ def path(self) -> geo.Path:
+ """Returns the concatenated path of all contained tracks."""
+ offset = 0.0
+ points = []
+ for track in self.tracks:
+ point = None
+ for point in track.path().points:
+ new_point = dataclasses.replace(point, time_offset=point.time_offset + offset)
+ points.append(new_point)
+ if point:
+ offset += point.time_offset
+ return geo.Path(points)
+
+ def gpx_xml(self) -> bytes:
+ """Returns a GPX XML that represents this journey.
+
+ :return: The XML file.
+ """
+ return geo.gpx_xml(
+ self.title,
+ self.description,
+ datetime.datetime.fromtimestamp(0).replace(tzinfo=datetime.UTC),
+ self.path().points,
+ [],
+ )
+
+
+__all__ = ["Journey", "Visibility"]
diff --git a/fietsboek/models/track.py b/fietsboek/models/track.py
index 92ee978..95e341f 100644
--- a/fietsboek/models/track.py
+++ b/fietsboek/models/track.py
@@ -17,7 +17,6 @@ meta information.
import datetime
import enum
import gzip
-import io
import json
import logging
from itertools import chain
@@ -176,6 +175,19 @@ class Waypoint(Base):
track: Mapped["Track"] = relationship("Track", back_populates="waypoints")
+ def to_geo_waypoint(self) -> geo.Waypoint:
+ """Converts this waypoint (a database object) to a plain waypoint.
+
+ :return: The converted point.
+ """
+ return geo.Waypoint(
+ latitude=self.latitude,
+ longitude=self.longitude,
+ elevation=self.elevation,
+ name=self.name,
+ description=self.description,
+ )
+
class TrackPoint(Base):
"""A track point represents a single GPS point along a path."""
@@ -315,6 +327,9 @@ class Track(Base):
favourees: Mapped[list["models.User"]] = relationship(
"User", secondary=track_favourite_assoc, back_populates="favourite_tracks"
)
+ journeys: Mapped[list["models.Journey"]] = relationship(
+ "Journey", secondary="journey_track_assoc", back_populates="tracks"
+ )
@classmethod
def factory(cls, request):
@@ -435,64 +450,13 @@ class Track(Base):
:return: The XML representation (a GPX file).
"""
- # This is a cumbersome way to do it, as we're re-implementing XML
- # serialization logic. However, recreating the track in gpxpy and
- # letting it serialize it is much slower:
- # For a track with around 50,000 points, the gpxpy method takes
- # ~5.9 seconds here, while the "manual" buffer takes only ~2.4 seconds.
- # This is a speed-up we're happy to take!
- buf = io.BytesIO()
- buf.write(b'<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>')
- buf.write(b'<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">')
-
- buf.write(b"<metadata>")
- if self.title:
- buf.write(b"<name>%s</name>" % util.xml_escape(self.title))
- if self.description:
- buf.write(b"<desc>%s</desc>" % util.xml_escape(self.description))
- buf.write(b"</metadata>")
-
- # Cache for easy access, especially the date is important since it's a
- # dynamic property
- date = self.date
- write = buf.write
-
- write(b"<trk>")
- write(b"<trkseg>")
- for point in self.path().points:
- write(b'<trkpt lat="')
- write(str(point.latitude).encode("ascii"))
- write(b'" lon="')
- write(str(point.longitude).encode("ascii"))
- write(b'">')
- write(b"<ele>")
- write(str(point.elevation).encode("ascii"))
- write(b"</ele>")
- write(b"<time>")
- write(str(date + datetime.timedelta(seconds=point.time_offset)).encode("ascii"))
- write(b"</time>")
- write(b"</trkpt>\n")
- write(b"</trkseg>")
- write(b"</trk>")
-
- # This loop is not as hot:
- for wpt in self.waypoints:
- write(
- b'<wpt lat="%s" lon="%s">'
- % (util.xml_escape(str(wpt.latitude)), util.xml_escape(str(wpt.longitude)))
- )
- if wpt.elevation is not None:
- write(b"<ele>%s</ele>" % util.xml_escape(str(wpt.elevation)))
- if wpt.name is not None:
- write(b"<name>%s</name>" % util.xml_escape(wpt.name))
- if wpt.description is not None:
- write(b"<cmt>%s</cmt>" % util.xml_escape(wpt.description))
- write(b"<desc>%s</desc>" % util.xml_escape(wpt.description))
- write(b"</wpt>")
-
- write(b"</gpx>")
-
- return buf.getvalue()
+ return geo.gpx_xml(
+ self.title,
+ self.description,
+ self.date,
+ self.path().points,
+ [wpt.to_geo_waypoint() for wpt in self.waypoints],
+ )
@property
def date(self):
@@ -578,6 +542,10 @@ class Track(Base):
self.cache.start_time = self.date
self.cache.end_time = self.date + datetime.timedelta(seconds=meta.duration)
+ def with_metadata(self) -> "TrackWithMetadata":
+ """Returns this track with attached path metadata."""
+ return TrackWithMetadata(self)
+
def text_tags(self):
"""Returns a set of textual tags.
diff --git a/fietsboek/models/user.py b/fietsboek/models/user.py
index 69d6972..551d920 100644
--- a/fietsboek/models/user.py
+++ b/fietsboek/models/user.py
@@ -34,11 +34,13 @@ from sqlalchemy.exc import NoResultFound
from sqlalchemy.orm import Mapped, Session, mapped_column, relationship, with_parent
from sqlalchemy.orm.attributes import flag_dirty
from sqlalchemy.orm.session import object_session
+from sqlalchemy.sql.expression import CompoundSelect
from .meta import Base
if TYPE_CHECKING:
from .comment import Comment
+ from .journey import Journey
from .track import Track, Upload
@@ -139,6 +141,7 @@ class User(Base):
comments: Mapped[list["Comment"]] = relationship(
"Comment", back_populates="author", cascade="all, delete-orphan"
)
+ journeys: Mapped[list["Journey"]] = relationship("Journey", back_populates="owner")
# We don't use them, but include them to ensure our cascading works
friends_1: Mapped[list["User"]] = relationship(
@@ -395,6 +398,44 @@ class User(Base):
return union(*queries)
+ @staticmethod
+ def visible_journeys_query(user: Optional["User"] = None) -> CompoundSelect:
+ """Returns a query that returns the visible journeys for a user.
+
+ If the user is ``None``, only public journeys will be returned.
+
+ :param user: The user which to query the journeys for.
+ :return: The query that selects all fitting journeys.
+ """
+ # Late import to avoid cycles
+ # pylint: disable=import-outside-toplevel,protected-access
+ from .journey import Journey, Visibility
+
+ queries = []
+
+ # Step 1: Own journeys
+ if user:
+ queries.append(select(Journey).where(with_parent(user, User.journeys)))
+
+ # Step 2: Public journeys
+ queries.append(select(Journey).filter_by(visibility=Visibility.PUBLIC))
+
+ # Step 3: Journeys for logged in users
+ if user:
+ queries.append(select(Journey).filter_by(visibility=Visibility.LOGGED_IN))
+
+ # Step 4: Friends' journeys
+ if user:
+ friend_query = user._friend_query().subquery()
+ friend_ids = select(friend_query.c.id)
+ queries.append(
+ select(Journey)
+ .filter_by(visibility=Visibility.FRIENDS)
+ .where(Journey.owner_id.in_(friend_ids))
+ )
+
+ return union(*queries)
+
def _friend_query(self):
qry1 = select(User).filter(
friends_assoc.c.user_1_id == self.id, friends_assoc.c.user_2_id == User.id
diff --git a/fietsboek/routes.py b/fietsboek/routes.py
index b8a0113..7042415 100644
--- a/fietsboek/routes.py
+++ b/fietsboek/routes.py
@@ -2,7 +2,7 @@
def includeme(config):
- # pylint: disable=missing-function-docstring
+ # pylint: disable=missing-function-docstring,too-many-statements
config.add_static_view("static", "static", cache_max_age=3600)
config.add_route("home", "/")
config.add_route("login", "/login")
@@ -57,6 +57,33 @@ def includeme(config):
factory="fietsboek.models.Track.factory",
)
+ config.add_route("journey-list", "/journey/")
+ config.add_route(
+ "journey-map",
+ "/journey/{journey_id}/preview",
+ factory="fietsboek.models.Journey.factory",
+ )
+ config.add_route(
+ "journey-gpx", "/journey/{journey_id}/gpx", factory="fietsboek.models.Journey.factory"
+ )
+ config.add_route(
+ "journey-details", "/journey/{journey_id}/", factory="fietsboek.models.Journey.factory"
+ )
+ config.add_route(
+ "journey-edit", "/journey/{journey_id}/edit", factory="fietsboek.models.Journey.factory"
+ )
+ config.add_route(
+ "journey-invalidate-share",
+ "/journey/{journey_id}/invalidate-link",
+ factory="fietsboek.models.Journey.factory",
+ )
+ config.add_route(
+ "delete-journey",
+ "/journey/{journey_id}/delete",
+ factory="fietsboek.models.Journey.factory",
+ )
+ config.add_route("journey-new", "/journey/new")
+
config.add_route("badge", "/badge/{badge_id}", factory="fietsboek.models.Badge.factory")
config.add_route("admin", "/admin/")
diff --git a/fietsboek/static/fietsboek.js b/fietsboek/static/fietsboek.js
index bbdf6e9..d7d903e 100644
--- a/fietsboek/static/fietsboek.js
+++ b/fietsboek/static/fietsboek.js
@@ -445,6 +445,22 @@ function loadProfileStats() {
}
/* Used via in-page scripts, so make eslint happy */
loadProfileStats;
+/**
+ * Formats the given timestamp to the user's locale.
+ *
+ * @param timestamp - The timestamp in milliseconds since the epoch.
+ * @return The formatted string.
+ */
+function formatTimestamp(timestamp) {
+ const date = new Date(timestamp);
+ // TypeScript complains about this, but according to MDN it is fine, at
+ // least in "somewhat modern" browsers
+ const intl = new Intl.DateTimeFormat(LOCALE, {
+ dateStyle: "medium",
+ timeStyle: "medium",
+ });
+ return intl.format(date);
+}
document.addEventListener('DOMContentLoaded', function () {
window.fietsboekImageIndex = 0;
/* Enable tooltips */
@@ -466,14 +482,7 @@ document.addEventListener('DOMContentLoaded', function () {
/* Format all datetimes to the local timezone */
document.querySelectorAll(".fietsboek-local-datetime").forEach((obj) => {
const timestamp = parseFloat(obj.attributes.getNamedItem("data-utc-timestamp").value);
- const date = new Date(timestamp * 1000);
- // TypeScript complains about this, but according to MDN it is fine, at
- // least in "somewhat modern" browsers
- const intl = new Intl.DateTimeFormat(LOCALE, {
- dateStyle: "medium",
- timeStyle: "medium",
- });
- obj.innerHTML = intl.format(date);
+ obj.innerHTML = formatTimestamp(timestamp * 1000);
});
});
//# sourceMappingURL=fietsboek.js.map \ No newline at end of file
diff --git a/fietsboek/static/fietsboek.js.map b/fietsboek/static/fietsboek.js.map
index 2299118..0dc6ff9 100644
--- a/fietsboek/static/fietsboek.js.map
+++ b/fietsboek/static/fietsboek.js.map
@@ -1 +1 @@
-{"version":3,"file":"fietsboek.js","sourceRoot":"","sources":["../../asset-sources/fietsboek.ts"],"names":[],"mappings":";AAqBA,kDAAkD;AAClD,CAAC,CAAS,EAAE,EAAE,CAAC,IAAI,CAAC;AAQpB;;;;;GAKG;AACH,SAAS,SAAS,CAAC,IAAY;;IAC3B,OAAO,MAAA,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC;SAC7B,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,UAAU,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,0CACxC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;AACxB,CAAC;AAGD;;;;;;;GAOG;AACH,SAAS,OAAO,CAAC,IAAY;IACzB,OAAO,IAAI,GAAG,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;AACnC,CAAC;AAED;;;;;;GAMG;AACH,SAAS,UAAU,CACf,QAAkB,EAClB,KAAQ,EACR,OAAoD;IAEpD,QAAQ,CAAC,gBAAgB,CAAC,QAAQ,CAAC;QAC/B,OAAO,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,gBAAgB,CAAC,KAAK,EAAE,OAAwB,CAAC,CAAC,CAAC;AAChF,CAAC;AAED;;;;GAIG;AACH,SAAS,eAAe,CAAC,KAAiB;IACtC,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,CAAC,QAAQ,CAAC;IACxC,MAAM,QAAQ,GAAI,KAAK,CAAC,MAAsB,CAAC,YAAY,CAAC,eAAe,CAAC,CAAC;IAC7E,QAAQ,CAAC,MAAM,GAAG,oBAAoB,QAAQ,UAAU,IAAI,EAAE,CAAC;IAC/D,MAAM,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC;IACzB,KAAK,CAAC,cAAc,EAAE,CAAC;AAC3B,CAAC;AAED,UAAU,CAAC,kBAAkB,EAAE,OAAO,EAAE,eAAe,CAAC,CAAC;AAEzD;;;;GAIG;AACH,SAAS,UAAU,CAAC,KAAiB;IACjC,MAAM,IAAI,GAAI,KAAK,CAAC,MAAsB,CAAC,OAAO,CAAC,MAAM,CAAE,CAAC;IAC5D,IAAI,CAAC,UAAW,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;AACvC,CAAC;AAED,UAAU,CAAC,YAAY,EAAE,OAAO,EAAE,UAAU,CAAC,CAAC;AAE9C;;GAEG;AACH,SAAS,MAAM;;IACX,MAAM,MAAM,GAAG,QAAQ,CAAC,aAAa,CAAC,UAAU,CAAqB,CAAC;IACtE,IAAI,MAAM,CAAC,KAAK,KAAK,EAAE,EAAE;QACrB,OAAO;KACV;IACD,MAAM,IAAI,GAAG,QAAQ,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC;IAC5C,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAChC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IAC5B,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IACnC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IAC9B,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAChC,IAAI,CAAC,gBAAgB,CAAC,OAAO,EAAE,UAAU,CAAC,CAAC;IAC3C,MAAM,IAAI,GAAG,QAAQ,CAAC,cAAc,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACnD,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;IACvB,MAAM,IAAI,GAAG,QAAQ,CAAC,aAAa,CAAC,GAAG,CAAC,CAAC;IACzC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACzB,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IAC3B,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;IACvB,MAAM,KAAK,GAAG,QAAQ,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;IAC9C,KAAK,CAAC,MAAM,GAAG,IAAI,CAAC;IACpB,KAAK,CAAC,IAAI,GAAG,OAAO,CAAC;IACrB,KAAK,CAAC,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC;IAC3B,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC;IACxB,MAAA,QAAQ,CAAC,aAAa,CAAC,WAAW,CAAC,0CAAE,WAAW,CAAC,IAAI,CAAC,CAAC;IACvD,MAAM,KAAK,GAAG,QAAQ,CAAC,cAAc,CAAC,GAAG,CAAC,CAAC;IAC3C,MAAA,QAAQ,CAAC,aAAa,CAAC,WAAW,CAAC,0CAAE,WAAW,CAAC,KAAK,CAAC,CAAC;IACxD,MAAM,CAAC,KAAK,GAAG,EAAE,CAAC;AACtB,CAAC;AAED,UAAU,CAAC,cAAc,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC;AAC5C,uCAAuC;AACvC,UAAU,CAAC,UAAU,EAAE,UAAU,EAAE,CAAC,KAAK,EAAE,EAAE;IACzC,IAAI,KAAK,CAAC,IAAI,IAAI,OAAO,EAAE;QACvB,KAAK,CAAC,cAAc,EAAE,CAAC;QACvB,MAAM,EAAE,CAAC;KACZ;AACL,CAAC,CAAC,CAAC;AAEH;;;;;GAKG;AACH,SAAS,qBAAqB,CAAC,IAAc,EAAE,MAAgB;IAC3D,MAAM,YAAY,GAAG,QAAQ,CAAC,aAAa,CAAC,IAAI,CAAqB,CAAC;IACtE,MAAM,cAAc,GAAG,QAAQ,CAAC,aAAa,CAAC,MAAM,CAAqB,CAAC;IAE1E,MAAM,IAAI,GAAG,YAAY,CAAC,OAAO,CAAC,MAAM,CAAE,CAAC;IAC3C,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC;IAEvC,sEAAsE;IACtE,sEAAsE;IACtE,IAAI,YAAY,CAAC,KAAK,CAAC,MAAM,IAAI,CAAC,IAAI,YAAY,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE;QACjE,YAAY,CAAC,iBAAiB,CAAC,WAAW,CAAC,CAAC;KAC/C;SAAM;QACH,YAAY,CAAC,iBAAiB,CAAC,EAAE,CAAC,CAAC;KACtC;IAED,IAAI,YAAY,CAAC,KAAK,IAAI,cAAc,CAAC,KAAK,EAAE;QAC5C,cAAc,CAAC,iBAAiB,CAAC,gBAAgB,CAAC,CAAC;KACtD;SAAM;QACH,cAAc,CAAC,iBAAiB,CAAC,EAAE,CAAC,CAAC;KACxC;AACL,CAAC;AAED,2EAA2E;AAC3E,qBAAqB,CAAC;AAEtB;;;;GAIG;AACH,SAAS,iBAAiB,CAAC,IAAc;IACrC,MAAM,SAAS,GAAG,QAAQ,CAAC,aAAa,CAAC,IAAI,CAAqB,CAAC;IACnE,IAAI,SAAS,CAAC,KAAK,CAAC,MAAM,IAAI,CAAC,EAAE;QAC7B,SAAS,CAAC,iBAAiB,CAAC,cAAc,CAAC,CAAC;KAC/C;AACL,CAAC;AAED,2EAA2E;AAC3E,iBAAiB,CAAC;AAElB;;;;;;;;GAQG;AACH,SAAS,cAAc,CAAC,QAAgB;IACpC,OAAO,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,gBAAgB,CAAC,0BAA0B,CAAC,CAAC;SACnE,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAuB,CAAC;SACrC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC;SAC9B,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC;SACvB,QAAQ,CAAC,QAAQ,CAAC,QAAQ,EAAE,CAAC,CAAC;AACvC,CAAC;AAED;;;GAGG;AACH,SAAS,aAAa;IAClB,MAAM,aAAa,GAAI,QAAQ,CAAC,aAAa,CAAC,oBAAoB,CAAsB;QACpF,KAAK,CAAC,WAAW,EAAE,CAAC;IACxB,MAAM,YAAY,GAAG,QAAQ,CAAC,aAAa,CAAC,eAAe,CAAE,CAAC;IAC9D,YAAY,CAAC,SAAS,GAAG,EAAE,CAAC;IAC5B,KAAK,CAAC,WAAW,CAAC;SACb,IAAI,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC;SACnC,IAAI,CAAC,CAAC,QAAsB,EAAE,EAAE;QAC7B,MAAM,SAAS,GACV,QAAQ,CAAC,aAAa,CAAC,wBAAwB,CAAyB,CAAC,OAAO,CAAC;QAEtF,yCAAyC;QACzC,IAAI,OAAO,GAAG,QAAQ,CAAC,MAAM,CACzB,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,CAC/D,CAAC;QAEF,8CAA8C;QAC9C,OAAO,GAAG,OAAO,CAAC,MAAM,CACpB,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,cAAc,CAAC,GAAG,CAAC,EAAE,CAAC,CACnC,CAAC;QAEF,OAAO,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,EAAE;;YACvB,MAAM,IAAI,GAAG,SAAS,CAAC,SAAS,CAAC,IAAI,CAAqB,CAAC;YAC1D,IAAI,CAAC,aAAa,CAAC,cAAc,CAAqB,CAAC,WAAW,GAAG,MAAM,CAAC,IAAI,CAAC;YAClF,MAAA,IAAI,CAAC,aAAa,CAAC,QAAQ,CAAC,0CAAE,gBAAgB,CAAC,OAAO,EAAE,CAAC,KAAiB,EAAE,EAAE;gBAC1E,MAAM,MAAM,GAAI,KAAK,CAAC,MAAsB,CAAC,OAAO,CAAC,QAAQ,CAAE,CAAC;gBAChE,MAAM,CAAC,UAAW,CAAC,UAAW,CAAC,WAAW,CAAC,MAAM,CAAC,UAAW,CAAC,CAAC;gBAE/D,MAAM,KAAK,GACN,QAAQ,CAAC,aAAa,CAAC,uBAAuB,CAA0B;qBACxE,OAAO;qBACP,SAAS,CAAC,IAAI,CAAqB,CAAC;gBACxC,KAAK,CAAC,aAAa,CAAC,cAAc,CAAqB;oBACpD,WAAW,GAAG,MAAM,CAAC,IAAI,CAAC;gBAC9B,KAAK,CAAC,aAAa,CAAC,OAAO,CAAE,CAAC,KAAK,GAAG,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,CAAC;gBAC3D,KAAK,CAAC,aAAa,CAAC,OAAO,CAAE,CAAC,eAAe,CAAC,UAAU,CAAC,CAAC;gBAC1D,KAAK,CAAC,aAAa,CAAC,QAAQ,CAAE,CAAC,gBAAgB,CAAC,OAAO,EAAE,mBAAmB,CAAC,CAAC;gBAC9E,QAAQ,CAAC,aAAa,CAAC,gBAAgB,CAAE,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC;YACjE,CAAC,CAAC,CAAC;YACH,YAAY,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;QACnC,CAAC,CAAC,CAAC;IACP,CAAC,CAAC,CAAC;AACX,CAAC;AAED,UAAU,CAAC,iBAAiB,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,aAAa,EAAE,CAAC,CAAC;AAC9D,4CAA4C;AAC5C,UAAU,CAAC,oBAAoB,EAAE,UAAU,EAAE,CAAC,KAAK,EAAE,EAAE;IACnD,IAAI,KAAK,CAAC,IAAI,IAAI,OAAO,EAAE;QACvB,KAAK,CAAC,cAAc,EAAE,CAAC;QACvB,aAAa,EAAE,CAAC;KACnB;AACL,CAAC,CAAC,CAAC;AAEH;;;;GAIG;AACH,SAAS,mBAAmB,CAAC,KAAiB;IAC1C,MAAM,MAAM,GAAI,KAAK,CAAC,MAAsB,CAAC,OAAO,CAAC,QAAQ,CAAE,CAAC;IAChE,MAAM,CAAC,UAAW,CAAC,UAAW,CAAC,WAAW,CAAC,MAAM,CAAC,UAAW,CAAC,CAAC;AACnE,CAAC;AAED,UAAU,CAAC,uBAAuB,EAAE,OAAO,EAAE,mBAAmB,CAAC,CAAC;AAElE;;;;;;;;GAQG;AACH,SAAS,oBAAoB,CAAC,KAAY;;IACtC,MAAM,MAAM,GAAG,KAAK,CAAC,MAA0B,CAAC;IAChD,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,IAAI,CAAC,MAAA,MAAM,CAAC,KAAK,mCAAI,EAAE,CAAC,EAAE;QAC/C,MAAM,CAAC,mBAAmB,EAAE,CAAC;QAE7B,MAAM,KAAK,GAAG,QAAQ,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;QAC9C,KAAK,CAAC,IAAI,GAAG,MAAM,CAAC;QACpB,KAAK,CAAC,MAAM,GAAG,IAAI,CAAC;QACpB,KAAK,CAAC,IAAI,GAAG,SAAS,MAAM,CAAC,mBAAmB,GAAG,CAAC;QAEpD,MAAM,QAAQ,GAAG,IAAI,YAAY,EAAE,CAAC;QACpC,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACzB,KAAK,CAAC,KAAK,GAAG,QAAQ,CAAC,KAAK,CAAC;QAE7B,MAAM,OAAO,GACR,QAAQ,CAAC,aAAa,CAAC,6BAA6B,CAAyB;YAC9E,OAAO;YACP,SAAS,CAAC,IAAI,CAAqB,CAAC;QACxC,OAAO,CAAC,aAAa,CAAC,KAAK,CAAE,CAAC,GAAG,GAAG,GAAG,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC;QAC9D,OAAO,CAAC,aAAa,CAAC,qBAAqB,CAAE;YACzC,gBAAgB,CAAC,OAAO,EAAE,wBAAyC,CAAC,CAAC;QACzE,OAAO,CAAC,aAAa,CAAC,+BAA+B,CAAE;YACnD,gBAAgB,CAAC,OAAO,EAAE,2BAA4C,CAAC,CAAC;QAC3E,OAAO,CAAC,aAAa,CAAC,+BAA+B,CAAsB;YACxE,IAAI,GAAG,qBAAqB,MAAM,CAAC,mBAAmB,GAAG,CAAC;QAC9D,OAAO,CAAC,aAAa,CAAC,KAAK,CAAE,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC;QAEjD,QAAQ,CAAC,aAAa,CAAC,iBAAiB,CAAE,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC;KACnE;IAED,MAAM,CAAC,KAAK,GAAG,EAAE,CAAC;AACtB,CAAC;AAED,UAAU,CAAC,gBAAgB,EAAE,QAAQ,EAAE,oBAAoB,CAAC,CAAC;AAE7D;;;;GAIG;AACH,SAAS,wBAAwB,CAAC,KAAiB;IAC/C,MAAM,OAAO,GAAI,KAAK,CAAC,MAAsB,CAAC,OAAO,CAAC,yBAAyB,CAAE,CAAC;IAClF,8DAA8D;IAC9D,MAAM,KAAK,GAAG,OAAO,CAAC,aAAa,CAAC,kBAAkB,CAAC,CAAC;IACxD,IAAI,KAAK,EAAE;QACP,OAAO,CAAC,UAAW,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC;QACzC,OAAO;KACV;IAED,4EAA4E;IAC5E,MAAM,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,2BAA2B,CAAE,CAAC;IACpE,OAAO,CAAC,eAAe,CAAC,UAAU,CAAC,CAAC;IACpC,OAAO,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC;IAC7B,OAAO,CAAC,UAAW,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC;IACzC,OAAO,CAAC,UAAW,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC;AAC7C,CAAC;AAED,UAAU,CAAC,qBAAqB,EAAE,OAAO,EAAE,wBAAwB,CAAC,CAAC;AAErE;;;;GAIG;AACH,SAAS,2BAA2B,CAAC,KAAiB;IAClD,MAAM,CAAC,qBAAqB,GAAI,KAAK,CAAC,MAAsB,CAAC,OAAO,CAAC,KAAK,CAAE,CAAC;IAE7E,MAAM,UAAU,GAEZ,MAAM,CAAC,qBAAqB,CAAC,aAAa,CAAC,+BAA+B,CAC7E,CAAC;IACF,MAAM,kBAAkB,GAAG,UAAU,CAAC,KAAK,CAAC;IAC5C,MAAM,QAAQ,GAAG,QAAQ,CAAC,cAAc,CAAC,uBAAuB,CAAE,CAAC;IACnE,QAAQ,CAAC,aAAa,CAAC,UAAU,CAAE,CAAC,KAAK,GAAG,kBAAkB,CAAC;IAE/D,MAAM,KAAK,GAAG,SAAS,CAAC,KAAK,CAAC,mBAAmB,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;IAChE,KAAK,CAAC,IAAI,EAAE,CAAC;AACjB,CAAC;AAED,UAAU,CAAC,+BAA+B,EAAE,OAAO,EAAE,2BAA2B,CAAC,CAAC;AAElF;;;;GAIG;AACH,SAAS,2BAA2B,CAAC,MAAkB;IACnD,MAAM,QAAQ,GAAG,QAAQ,CAAC,cAAc,CAAC,uBAAuB,CAAE,CAAC;IACnE,MAAM,iBAAiB,GAAG,QAAQ,CAAC,aAAa,CAAC,UAAU,CAAE,CAAC,KAAK,CAAC;IACnE,MAAM,CAAC,qBAAsB;QAC1B,aAAa,CAAC,+BAA+B,CAAsB;QACnE,KAAK,GAAG,iBAAiB,CAAC;IAC9B,MAAM,CAAC,qBAAsB;QACzB,aAAa,CAAC,KAAK,CAAE,CAAC,KAAK,GAAG,iBAAiB,CAAC;IAEpD,MAAM,KAAK,GAAG,SAAS,CAAC,KAAK,CAAC,mBAAmB,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;IAChE,KAAK,CAAC,IAAI,EAAE,CAAC;IAEb,MAAM,CAAC,qBAAqB,GAAG,IAAI,CAAC;AACxC,CAAC;AAED,UAAU,CAAC,2CAA2C,EAAE,OAAO,EAAE,2BAA2B,CAAC,CAAC;AAE9F;;;;GAIG;AACH,SAAS,aAAa,CAAC,KAAiB;IACpC,MAAM,OAAO,GAAG,KAAK,CAAC,MAAqB,CAAC;IAC5C,MAAM,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAE,CAAC;IACzC,MAAM,OAAO,GAAG,UAAU,CAAC,kBAAmB,CAAC;IAC/C,SAAS,CAAC,QAAQ,CAAC,mBAAmB,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,CAAC;IACzD,IAAI,OAAO,CAAC,SAAS,CAAC,QAAQ,CAAC,iBAAiB,CAAC,EAAE;QAC/C,OAAO,CAAC,SAAS,CAAC,MAAM,CAAC,iBAAiB,CAAC,CAAC;QAC5C,OAAO,CAAC,SAAS,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC;KAC7C;SAAM;QACH,OAAO,CAAC,SAAS,CAAC,MAAM,CAAC,kBAAkB,CAAC,CAAC;QAC7C,OAAO,CAAC,SAAS,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;KAC5C;AACL,CAAC;AAED,UAAU,CAAC,kBAAkB,EAAE,OAAO,EAAE,aAAa,CAAC,CAAC;AAEvD;;GAEG;AACH,UAAU,CAAC,wBAAwB,EAAE,OAAO,EAAE,GAAG,EAAE;IAC/C,MAAM,OAAO,GAAG,QAAQ,CAAC,gBAAgB,CAAC,2BAA2B,CAAC,CAAC;IACvE,MAAM,GAAG,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;IACrC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE;QAClB,GAAG,CAAC,YAAY,CAAC,MAAM,CAAC,YAAY,EAAG,CAAsB,CAAC,KAAK,CAAC,CAAC;IACzE,CAAC,CAAC,CAAC;IACH,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;AAChC,CAAC,CAAC,CAAC;AACH;;GAEG;AACH,UAAU,CAAC,mBAAmB,EAAE,QAAQ,EAAE,GAAG,EAAE;IAC3C,MAAM,OAAO,GAAG,QAAQ,CAAC,gBAAgB,CAAC,2BAA2B,CAAC,CAAC;IACvE,MAAM,cAAc,GAAG,QAAQ,CAAC,aAAa,CAAC,wBAAwB,CAAsB,CAAC;IAC7F,cAAc,CAAC,QAAQ,GAAG,CAAC,OAAO,CAAC,MAAM,IAAI,CAAC,CAAC,CAAC;AACpD,CAAC,CAAC,CAAC;AAEH;;;;;;GAMG;AACH,SAAS,uBAAuB,CAAC,KAAiB;IAC9C,MAAM,MAAM,GAAG,KAAK,CAAC,MAAqB,CAAC;IAC3C,MAAM,CAAC,OAAO,CAAC,cAAc,CAAE,CAAC,gBAAgB,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,EAAE,CAAC,CAAC;IACvF,MAAM,CAAC,OAAO,CAAC,cAAc,CAAE,CAAC,gBAAgB,CAAC,QAAQ,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,EAAE,CAAC,CAAC;AAC5F,CAAC;AAED,UAAU,CAAC,qBAAqB,EAAE,OAAO,EAAE,uBAAuB,CAAC,CAAC;AAGpE;;;;;;;GAOG;AACH,SAAS,iBAAiB,CAAC,MAAkB;;IACzC,MAAM,cAAc,GAAG,MAAA,SAAS,CAAC,cAAc,CAAC,mCAAI,KAAK,CAAC;IAC1D,MAAM,UAAU,GAAG,cAAc,IAAI,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC;IAC5D,QAAQ,CAAC,MAAM,GAAG,gBAAgB,UAAU,gBAAgB,CAAC;IAC7D,MAAM,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC;AAC7B,CAAC;AAED,UAAU,CAAC,oBAAoB,EAAE,OAAO,EAAE,iBAAiB,CAAC,CAAC;AAG7D;;;;;;;GAOG;AACH,SAAS,oBAAoB,CAAC,KAAiB;;IAC3C,MAAM,MAAM,GAAG,KAAK,CAAC,MAAqB,CAAC;IAC3C,MAAM,OAAO,GAAG,MAAM,CAAC,YAAY,CAAC,eAAe,CAAC,CAAC;IACrD,IAAI,OAAO,KAAK,IAAI,EAAE;QAClB,OAAO;KACV;IACD,MAAM,GAAG,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAC3C,MAAM,QAAQ,GAAG,IAAI,eAAe,EAAE,CAAC;IACvC,QAAQ,CAAC,MAAM,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;IACrC,QAAQ,CAAC,MAAM,CAAC,YAAY,EAAE,MAAA,SAAS,CAAC,YAAY,CAAC,mCAAI,EAAE,CAAC,CAAC;IAC7D,KAAK,CAAC,GAAG,EAAE;QACP,QAAQ,EAAE,MAAM;QAChB,MAAM,EAAE,QAAQ;KACnB,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE;QAC5C,MAAM,cAAc,GAAG,IAAI,CAAC,WAAW,CAAC,CAAC;QACzC,IAAI,cAAc,EAAE;YAChB,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,SAAS,EAAE,cAAc,CAAC,CAAC;SACvD;aAAM;YACH,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,cAAc,EAAE,SAAS,CAAC,CAAC;SACvD;IACL,CAAC,CAAC,CAAC,CAAC;AACR,CAAC;AAED,UAAU,CAAC,iBAAiB,EAAE,OAAO,EAAE,oBAAoB,CAAC,CAAC;AAE7D;;GAEG;AACH,SAAS,sBAAsB;IAC3B,MAAM,UAAU,GAAa,EAAE,CAAC;IAChC,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;IAC/B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,EAAE,EAAE;QACzB,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,cAAc,CAAC,MAAM,EAAE,EAAC,KAAK,EAAE,MAAM,EAAC,CAAC,CAAC,CAAC;QAC9D,IAAI,CAAC,QAAQ,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;KACxB;IACD,OAAO,UAAU,CAAC;AACtB,CAAC;AAED;;GAEG;AACH,SAAS,gBAAgB;IACrB,MAAM,UAAU,GAAG,sBAAsB,EAAE,CAAC;IAE5C,MAAM,GAAG,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;IACvC,KAAK,CAAC,GAAG,CAAC;SACL,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC;SACjC,IAAI,CAAC,CAAC,QAAqB,EAAE,EAAE;QAC5B,MAAM,QAAQ,GAAG,EAAE,CAAC;QACpB,KAAK,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE;YACnD,MAAM,IAAI,GAAG,EAAE,CAAC;YAChB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,EAAE,EAAE,CAAC,EAAE;gBAC1B,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;aACrD;YACD,QAAQ,CAAC,IAAI,CAAC;gBACV,IAAI,EAAE,IAAI;gBACV,KAAK,EAAE,IAAI;aACd,CAAC,CAAC;SACN;QAED,IAAI,KAAK,CACL,qBAAqB,EACrB;YACI,IAAI,EAAE,KAAK;YACX,IAAI,EAAE;gBACF,QAAQ,EAAE,QAAQ;gBAClB,MAAM,EAAE,UAAU;aACrB;SACJ,CACJ,CAAC;IACN,CAAC,CAAC,CAAC;AACX,CAAC;AAED,oDAAoD;AACpD,gBAAgB,CAAC;AAEjB,QAAQ,CAAC,gBAAgB,CAAC,kBAAkB,EAAE;IAC1C,MAAM,CAAC,mBAAmB,GAAG,CAAC,CAAC;IAE/B,qBAAqB;IACrB,MAAM,kBAAkB,GAAG,EAAE,CAAC,KAAK,CAAC,IAAI,CACpC,QAAQ,CAAC,gBAAgB,CAAC,4BAA4B,CAAC,CAC1D,CAAC;IACF,kBAAkB,CAAC,GAAG,CAAC,CAAC,gBAAgB,EAAE,EAAE;QACxC,OAAO,IAAI,SAAS,CAAC,OAAO,CAAC,gBAAgB,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAC;IACxE,CAAC,CAAC,CAAC;IAEH,sCAAsC;IACtC,MAAM,KAAK,GAAG,QAAQ,CAAC,gBAAgB,CAAC,mBAAmB,CAAC,CAAC;IAC7D,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,EAAE;QAC/B,IAAI,CAAC,gBAAgB,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,EAAE;YACtC,IAAI,CAAE,IAAwB,CAAC,aAAa,EAAE,EAAE;gBAC5C,KAAK,CAAC,cAAc,EAAE,CAAC;gBACvB,KAAK,CAAC,eAAe,EAAE,CAAC;aAC3B;YAED,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC;QACxC,CAAC,EAAE,KAAK,CAAC,CAAC;IACd,CAAC,CAAC,CAAC;IAEH,gDAAgD;IAChD,QAAQ,CAAC,gBAAgB,CAAC,2BAA2B,CAAC,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,EAAE;QACnE,MAAM,SAAS,GAAG,UAAU,CAAC,GAAG,CAAC,UAAU,CAAC,YAAY,CAAC,oBAAoB,CAAE,CAAC,KAAK,CAAC,CAAC;QACvF,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC;QACxC,uEAAuE;QACvE,sCAAsC;QACtC,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,cAAc,CAAC,MAAM,EAAE;YACzC,SAAS,EAAE,QAAQ;YACnB,SAAS,EAAE,QAAQ;SACf,CAAC,CAAC;QACV,GAAG,CAAC,SAAS,GAAG,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IACtC,CAAC,CAAC,CAAC;AACP,CAAC,CAAC,CAAC"} \ No newline at end of file
+{"version":3,"file":"fietsboek.js","sourceRoot":"","sources":["../../asset-sources/fietsboek.ts"],"names":[],"mappings":"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"} \ No newline at end of file
diff --git a/fietsboek/templates/admin_overview.jinja2 b/fietsboek/templates/admin_overview.jinja2
index e93e997..fbb626b 100644
--- a/fietsboek/templates/admin_overview.jinja2
+++ b/fietsboek/templates/admin_overview.jinja2
@@ -55,7 +55,8 @@
{{ _("admin.overview.storage_graph.label.track_data") | tojson }},
{{ _("admin.overview.storage_graph.label.backups") | tojson }},
{{ _("admin.overview.storage_graph.label.images") | tojson }},
- {{ _("admin.overview.storage_graph.label.previews") | tojson }},
+ {{ _("admin.overview.storage_graph.label.track_previews") | tojson }},
+ {{ _("admin.overview.storage_graph.label.journey_previews") | tojson }},
{{ _("admin.overview.storage_graph.label.user_maps") | tojson }}
],
datasets: [
@@ -65,7 +66,8 @@
{{ (size_breakdown.track_data / 1024 / 1024) | tojson }},
{{ (size_breakdown.backups / 1024 / 1024) | tojson }},
{{ (size_breakdown.image_files / 1024 / 1024) | tojson }},
- {{ (size_breakdown.preview_files / 1024 / 1024) | tojson }},
+ {{ (size_breakdown.track_previews / 1024 / 1024) | tojson }},
+ {{ (size_breakdown.journey_previews / 1024 / 1024) | tojson }},
{{ (size_breakdown.user_maps / 1024 / 1024) | tojson }}
]
}
diff --git a/fietsboek/templates/journey_details.jinja2 b/fietsboek/templates/journey_details.jinja2
new file mode 100644
index 0000000..a4941e5
--- /dev/null
+++ b/fietsboek/templates/journey_details.jinja2
@@ -0,0 +1,180 @@
+{% extends "layout.jinja2" %}
+{% import "util.jinja2" as util with context %}
+
+{% block content %}
+<div class="container">
+ <h1>{{ journey.title }}</h1>
+
+ {% if show_edit_link %}
+ <div class="btn-group mb-3" role="group">
+ <a class="btn btn-success ui-element" href="{{ request.route_path('journey-edit', journey_id=journey.id) }}"><i class="bi-pencil-square"></i> {{ _("journey.edit") }}</a>
+ <button type="button" class="btn btn-info ui-element" id="showShareLink" data-bs-toggle="modal" data-bs-target="#shareLinkModal"><i class="bi-share"></i> {{ _("journey.share") }}</button>
+ <button type="button" class="btn btn-danger ui-element" id="deleteLink" data-bs-toggle="modal" data-bs-target="#deleteModal"><i class="bi bi-trash"></i> {{ _("journey.delete") }}</button>
+ </div>
+ <div class="modal fade" id="shareLinkModal" tabindex="-1" aria-hidden="true">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">{{ _("journey.sharelink.title") }}</h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div>
+ <div class="modal-body">
+ <p>{{ _("journey.sharelink.info") }}</p>
+ {% set share_link = request.route_url('journey-details', journey_id=journey.id, _query=[("secret", journey.link_secret)]) %}
+ <a href="{{ share_link }}">{{ share_link }}</a>
+ </div>
+ <div class="modal-footer">
+ <form method="POST" action="{{ request.route_url('journey-invalidate-share', journey_id=journey.id) }}">
+ {{ util.hidden_csrf_input() }}
+ <button type="submit" class="btn btn-warning ui-element">{{ _("journey.sharelink.invalidate") }}</button>
+ </form>
+ <button type="button" class="btn btn-secondary ui-element" data-bs-dismiss="modal">{{ _("journey.sharelink.close") }}</button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">{{ _("journey.delete.title") }}</h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div>
+ <div class="modal-body">
+ <p>{{ _("journey.delete.info") }}</p>
+ </div>
+ <div class="modal-footer">
+ <form method="POST" action="{{ request.route_url('delete-journey', journey_id=journey.id) }}">
+ {{ util.hidden_csrf_input() }}
+ <button type="submit" class="btn btn-danger ui-element">{{ _("journey.delete.delete") }}</button>
+ </form>
+ <button type="button" class="btn btn-secondary ui-element" data-bs-dismiss="modal">{{ _("journey.delete.close") }}</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ {% endif %}
+
+ {% if 'secret' in request.GET %}
+ {% set gpx_url = request.route_path("journey-gpx", journey_id=journey.id, _query=[('secret', request.GET['secret'])]) %}
+ {% else %}
+ {% set gpx_url = request.route_path("journey-gpx", journey_id=journey.id) %}
+ {% endif %}
+ <div class="mb-3">
+ <div id="mainmap" class="gpxview:{{ gpx_url }}:OSM" style="width:100%;height:600px">
+ <noscript><p>{{ _("page.noscript") }}<p></noscript>
+ </div>
+ </div>
+ <div class="mb-3">
+ <div id="mainmap_hp" style="width:100%;height:300px"></div>
+ </div>
+
+ <table class="table table-hover" style="margin-top: 10px;">
+ <tbody>
+ <tr>
+ <th scope="row">{{ _("page.details.length") }}</th>
+ <td id="detailsLength">{{ (movement_data.length / 1000) | round(2) | format_decimal }} km</td>
+ </tr>
+ <tr>
+ <th scope="row">{{ _("page.details.uphill") }}</th>
+ <td id="detailsUphill">{{ movement_data.uphill | round(2) | format_decimal }} m</td>
+ </tr>
+ <tr>
+ <th scope="row">{{ _("page.details.downhill") }}</th>
+ <td id="detailsDownhill">{{ movement_data.downhill | round(2) | format_decimal }} m</td>
+ </tr>
+ <tr>
+ <th scope="row">{{ _("page.details.moving_time") }}</th>
+ <td id="detailsDownhill">{{ timedelta(seconds=movement_data.moving_duration) }}</td>
+ </tr>
+ <tr>
+ <th scope="row">{{ _("page.details.stopped_time") }}</th>
+ <td id="detailsDownhill">{{ timedelta(seconds=movement_data.stopped_duration) }}</td>
+ </tr>
+ <tr>
+ <th scope="row">{{ _("page.details.max_speed") }}</th>
+ <td id="detailsDownhill">{{ mps_to_kph(movement_data.maximum_speed) | round(2) | format_decimal }} km/h</td>
+ </tr>
+ <tr>
+ <th scope="row">{{ _("page.details.avg_speed") }}</th>
+ <td id="detailsDownhill">{{ mps_to_kph(movement_data.average_speed) | round(2) | format_decimal }} km/h</td>
+ </tr>
+ </tbody>
+ </table>
+
+ {{ md_to_html(journey.description) }}
+
+ <h2>{{ _("journey.tracks") }}</h2>
+
+ {% for track in tracks %}
+ {% if track.track.is_visible_to(request.identity) %}
+ <div class="card mb-3">
+ <h5 class="card-header">
+ <a href="{{ request.route_url('details', track_id=track.id) }}">{{ track.title | default(track.date, true) }}</a>
+ </h5>
+ <div class="card-body browse-track-card">
+ <div class="browse-track-preview">
+ <img src="{{ request.route_url('track-map', track_id=track.id) }}">
+ </div>
+ <div class="browse-track-data">
+ <table class="table table-hover table-sm browse-summary">
+ <tbody>
+ <tr>
+ <th scope="row">{{ _("page.details.date") }}</th>
+ <td>{{ track.date | format_datetime }}</td>
+ <th scope="row">{{ _("page.details.length") }}</th>
+ <td>{{ (track.length / 1000) | round(2) | format_decimal }} km</td>
+ </tr>
+ {% if track.show_organic_data() %}
+ <tr>
+ <th scope="row">{{ _("page.details.start_time") }}</th>
+ <td>{{ track.start_time | format_datetime }}</td>
+ <th scope="row">{{ _("page.details.end_time") }}</th>
+ <td>{{ track.end_time | format_datetime }}</td>
+ </tr>
+ {% endif %}
+ <tr>
+ <th scope="row">{{ _("page.details.uphill") }}</th>
+ <td>{{ track.uphill | round(2) | format_decimal }} m</td>
+ <th scope="row">{{ _("page.details.downhill") }}</th>
+ <td>{{ track.downhill | round(2) | format_decimal }} m</td>
+ </tr>
+ {% if track.show_organic_data() %}
+ <tr>
+ <th scope="row">{{ _("page.details.moving_time") }}</th>
+ <td>{{ track.moving_time }}</td>
+ <th scope="row">{{ _("page.details.stopped_time") }}</th>
+ <td>{{ track.stopped_time }}</td>
+ </tr>
+ <tr>
+ <th scope="row">{{ _("page.details.max_speed") }}</th>
+ <td>{{ mps_to_kph(track.max_speed) | round(2) | format_decimal }} km/h</td>
+ <th scope="row">{{ _("page.details.avg_speed") }}</th>
+ <td>{{ mps_to_kph(track.avg_speed) | round(2) | format_decimal }} km/h</td>
+ </tr>
+ {% endif %}
+ <tr>
+ <th scope="row"><i class="bi bi-chat-left-text-fill"></i> {{ _("page.browse.card.comments") }}</th>
+ <td>{{ track.comments | length }}</td>
+ <th scope="row"><i class="bi bi-images"></i> {{ _("page.browse.card.images") }}</th>
+ <td>{{ track.images | length }}</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+ {% else %}
+ <div class="card mb-3">
+ <h5 class="card-header">
+ {{ track.title | default(track.date, true) }}
+ </h5>
+ <div class="card-body">
+ {{ _("journeys.track.hidden") }}
+ </div>
+ </div>
+ {% endif %}
+ {% endfor %}
+</div>
+{% endblock %}
diff --git a/fietsboek/templates/journey_edit.jinja2 b/fietsboek/templates/journey_edit.jinja2
new file mode 100644
index 0000000..fa39228
--- /dev/null
+++ b/fietsboek/templates/journey_edit.jinja2
@@ -0,0 +1,21 @@
+{% extends "layout.jinja2" %}
+{% import "journey_form.jinja2" as form with context %}
+
+{% block extrahead %}
+{{ form.journey_css() }}
+{% endblock %}
+
+{% block content %}
+<div class="container">
+ <h1>{{ journey.title }}</h1>
+
+ <form method="POST">
+ {{ form.journey_form(journey) }}
+ </form>
+</div>
+
+{% endblock %}
+
+{% block latescripts %}
+{{ form.journey_js() }}
+{% endblock %}
diff --git a/fietsboek/templates/journey_form.jinja2 b/fietsboek/templates/journey_form.jinja2
new file mode 100644
index 0000000..85d01ca
--- /dev/null
+++ b/fietsboek/templates/journey_form.jinja2
@@ -0,0 +1,261 @@
+{% import "util.jinja2" as util with context %}
+
+{% macro journey_css() %}
+<style>
+.track-query-response, .journey-track {
+ background-color: var(--bs-body-bg);
+ padding: 0.375rem;
+ margin-bottom: 0.1rem;
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+
+ .track-title {
+ font-weight: 450;
+ font-size: 110%;
+ }
+
+ .track-date {
+ color: #808080;
+ }
+
+ .track-length {
+ color: #808080;
+ }
+}
+
+.journey-track {
+ cursor: grab;
+}
+
+.dragging {
+ opacity: 0.7;
+}
+</style>
+{% endmacro %}
+
+
+{% macro journey_form(journey) %}
+<div class="mb-3">
+ <label for="journeyTitle" class="form-label">{{ _("journeys.new.form.title") }}</label>
+ <input type="text" class="form-control" id="journeyTitle" name="journeyTitle" value="{{ journey.title }}" onchange="checkTitleValidity()">
+ <div class="invalid-feedback">
+ {{ _("journeys.new.form.requires_title") }}
+ </div>
+</div>
+<div class="mb-3">
+ <label for="journeyDescription" class="form-label">{{ _("journeys.new.form.description") }}</label>
+ <textarea class="form-control" id="journeyDescription" name="journeyDescription">{{ journey.description }}</textarea>
+</div>
+<div class="mb-3">
+ <label for="journeyVisibility" class="form-label">{{ _("journeys.new.form.visibility") }}</label>
+ <select class="form-select" id="journeyVisibility" name="journeyVisibility">
+ {% set visibility = journey.visibility.name if journey else "" %}
+ <option value="PRIVATE"{% if visibility== "PRIVATE" %} selected{% endif %}>{{ _("journeys.new.form.visibility.private") }}</option>
+ <option value="FRIENDS"{% if visibility== "FRIENDS" %} selected{% endif %}>{{ _("journeys.new.form.visibility.friends") }}</option>
+ <option value="LOGGED_IN"{% if visibility== "LOGGED_IN" %} selected{% endif %}>{{ _("journeys.new.form.visibility.logged_in") }}</option>
+ <option value="PUBLIC"{% if visibility== "PUBLIC" %} selected{% endif %}>{{ _("journeys.new.form.visibility.public") }}</option>
+ </select>
+</div>
+<div class="mb-3">
+ <p>
+ {{ _("journeys.new.form.tracksearch") }}
+ </p>
+ <div class="input-group">
+ <input type="text" id="trackSearch" placeholder="Title" class="form-control">
+ <button class="btn btn-secondary" id="trackSearchButton"><i class="bi bi-search"></i></button>
+ </div>
+</div>
+<div class="mb-3" id="trackSearchResults"></div>
+<div class="mb-3">
+ <p>{{ _("journeys.new.form.tracks") }}<p>
+</div>
+<div class="mb-3" id="journeyTracks">
+ {% for track in journey.tracks %}
+ <div class="journey-track" draggable="true">
+ <input type="hidden" name="journeyTrack[]" value="{{ track.id }}">
+ <button class="btn btn-danger btn-sm"><i class="bi bi-x-circle-fill"></i></button>
+ <div class="track-title">{{ track.title }}</div>
+ <div class="track-length">{{ (track.with_metadata().length / 1000) | round(2) }} km</div>
+ <div class="track-date">{{ track.date | format_datetime }}</div>
+ </div>
+ {% endfor %}
+</div>
+
+{{ util.hidden_csrf_input() }}
+
+<div>
+ <button class="btn btn-primary" type="submit" id="journeySubmit">
+ <i class="bi bi-save"></i>
+ {{ _("journeys.new.form.submit") }}
+ </button>
+ <div class="invalid-feedback">
+ {{ _("journeys.new.form.requires_tracks") }}
+ </div>
+</div>
+
+<template id="queryResponse">
+ <div class="track-query-response">
+ <button class="btn btn-success btn-sm"><i class="bi bi-plus-square-fill"></i></button>
+ <div class="track-title"></div>
+ <div class="track-length"></div>
+ <div class="track-date"></div>
+ </div>
+</template>
+
+<template id="journeyTrack">
+ <div class="journey-track" draggable="true">
+ <input type="hidden" name="journeyTrack[]">
+ <button class="btn btn-danger btn-sm"><i class="bi bi-x-circle-fill"></i></button>
+ <div class="track-title"></div>
+ <div class="track-length"></div>
+ <div class="track-date"></div>
+ </div>
+</template>
+{% endmacro %}
+
+
+{% macro journey_js() %}
+<script>
+// Make sure the mouse pointer stays "grab", even when leaving the list of
+// tracks.
+document.addEventListener("dragover", (event) => event.preventDefault());
+
+let trDrag;
+
+function trDragStart(event) {
+ trDrag = event.target;
+ event.target.closest(".journey-track").classList.add("dragging");
+ event.dataTransfer.effectAllowed = "move";
+}
+
+function trDragOver(event) {
+ let target = event.target.closest(".journey-track");
+
+ // Check whether we are in the top of bottom half of the element
+ let rect = target.getBoundingClientRect();
+ let is_top_half = event.clientY < rect.top + rect.height / 2;
+
+ if (is_top_half) {
+ target.insertAdjacentElement("beforebegin", trDrag);
+ } else {
+ target.insertAdjacentElement("afterend", trDrag);
+ }
+ event.preventDefault();
+}
+
+function trDragLeave(event) {
+ let target = event.target.closest(".journey-track");
+ target.style.marginTop = "";
+ target.style.marginBottom = "";
+ event.preventDefault();
+}
+
+function trDragEnd(event) {
+ trDrag.closest(".journey-track").classList.remove("dragging");
+ trDrag = null;
+}
+
+function removeTrackFromJourney(event) {
+ let track = event.target.closest("div");
+ track.parentNode.removeChild(track);
+
+ checkJourneyValidity();
+
+ event.preventDefault();
+}
+
+addHandler(".journey-track button", "click", removeTrackFromJourney);
+
+function addTrackToJourney(event) {
+ let track = event.target.closest("div");
+ let template = document.getElementById("journeyTrack");
+ let clone = document.importNode(template.content, true);
+
+ clone.querySelector("input").setAttribute("value", track.getAttribute("data-track-id"));
+ for (let sel of [".track-title", ".track-length", ".track-date"]) {
+ clone.querySelector(sel).textContent = track.querySelector(sel).textContent;
+ }
+ clone.querySelector("button").addEventListener("click", removeTrackFromJourney);
+ clone.querySelector(".journey-track").addEventListener("dragstart", trDragStart);
+ clone.querySelector(".journey-track").addEventListener("dragover", trDragOver);
+ clone.querySelector(".journey-track").addEventListener("dragleave", trDragLeave);
+ clone.querySelector(".journey-track").addEventListener("dragend", trDragEnd);
+
+ document.getElementById("journeyTracks").appendChild(clone);
+ track.parentElement.removeChild(track);
+
+ checkJourneyValidity();
+
+ event.preventDefault();
+}
+
+addHandler(".journey-track", "dragstart", trDragStart);
+addHandler(".journey-track", "dragover", trDragOver);
+addHandler(".journey-track", "dragleave", trDragLeave);
+addHandler(".journey-track", "dragend", trDragEnd);
+
+function hasTrack(id) {
+ for (let track of document.querySelectorAll(".journey-track")) {
+ let tid = track.querySelector("input").value;
+ if (parseInt(tid) == id) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function searchTracks() {
+ let template = document.getElementById("queryResponse");
+ let results = document.getElementById("trackSearchResults");
+ let pattern = document.getElementById("trackSearch").value;
+ let url = makeUrl(`/track/?format=json&search-terms=${encodeURIComponent(pattern)}`);
+ fetch(url)
+ .then((response) => response.json())
+ .then((response) => {
+ results.replaceChildren();
+ for (let track of response) {
+ if (hasTrack(track.id)) {
+ continue;
+ }
+ let clone = document.importNode(template.content, true);
+ clone.firstElementChild.setAttribute("data-track-id", track.id);
+ clone.querySelector(".track-title").textContent = track.title;
+ clone.querySelector(".track-date").textContent = formatTimestamp(track.date * 1000);
+ clone.querySelector(".track-length").textContent = `${(track.length / 1000).toFixed(2)} km`;
+ clone.querySelector("button").addEventListener("click", addTrackToJourney);
+ results.appendChild(clone);
+ }
+ });
+}
+
+function checkTitleValidity() {
+ let title = document.querySelector("#journeyTitle");
+ if (title.value.length > 0) {
+ title.setCustomValidity("");
+ } else {
+ title.setCustomValidity("title missing");
+ }
+}
+
+checkTitleValidity();
+
+function checkJourneyValidity() {
+ let btn = document.querySelector("#journeySubmit");
+ let track_count = document.querySelectorAll(".journey-track").length;
+
+ if (track_count == 0) {
+ btn.setCustomValidity("no tracks");
+ } else {
+ btn.setCustomValidity("");
+ }
+}
+
+checkJourneyValidity();
+
+document.querySelector("#trackSearchButton").addEventListener("click", (event) => {
+ searchTracks();
+ event.preventDefault();
+});
+</script>
+{% endmacro %}
diff --git a/fietsboek/templates/journey_list.jinja2 b/fietsboek/templates/journey_list.jinja2
new file mode 100644
index 0000000..8c7bfe9
--- /dev/null
+++ b/fietsboek/templates/journey_list.jinja2
@@ -0,0 +1,32 @@
+{% extends "layout.jinja2" %}
+{% block content %}
+<div class="container">
+ <h1>{{ _("journeys.overview.title") }}</h1>
+
+ {% if show_new_button %}
+ <div class="mb-3">
+ <a href="{{ request.route_url('journey-new') }}" class="btn btn-primary">
+ <i class="bi bi-plus-circle"></i>
+ {{ _("journeys.overview.new") }}
+ </a>
+ </div>
+ {% endif %}
+
+ {% for journey in journeys %}
+ <div class="card mb-5">
+ <img src="{{ request.route_url('journey-map', journey_id=journey.id) }}" class="card-img-top" alt="Rendered map of the journey">
+ <div class="card-body">
+ <h5 class="card-title">
+ <a href="{{ request.route_url('journey-details', journey_id=journey.id) }}">{{ journey.title }}</a>
+ </h5>
+ {{ md_to_html(journey.description) }}
+ </div>
+ <ul class="list-group list-group-flush">
+ {% for track in journey.tracks %}
+ <li class="list-group-item">{{ track.title | default(track.date, true) }}</li>
+ {% endfor %}
+ </ul>
+ </div>
+ {% endfor %}
+</div>
+{% endblock %}
diff --git a/fietsboek/templates/journey_new.jinja2 b/fietsboek/templates/journey_new.jinja2
new file mode 100644
index 0000000..b1cfffb
--- /dev/null
+++ b/fietsboek/templates/journey_new.jinja2
@@ -0,0 +1,21 @@
+{% extends "layout.jinja2" %}
+{% import "journey_form.jinja2" as form with context %}
+
+{% block extrahead %}
+{{ form.journey_css() }}
+{% endblock %}
+
+{% block content %}
+<div class="container">
+ <h1>{{ _("journeys.new.title") }}</h1>
+
+ <form method="POST" class="needs-validation" novalidate>
+ {{ form.journey_form(none) }}
+ </form>
+</div>
+
+{% endblock %}
+
+{% block latescripts %}
+{{ form.journey_js() }}
+{% endblock %}
diff --git a/fietsboek/templates/layout.jinja2 b/fietsboek/templates/layout.jinja2
index e1ce5db..3058553 100644
--- a/fietsboek/templates/layout.jinja2
+++ b/fietsboek/templates/layout.jinja2
@@ -57,6 +57,9 @@ const Legende = false;
<li class="nav-item">
<a class="nav-link" href="{{ request.route_url('browse') }}">{{ _("page.navbar.browse") }}</a>
</li>
+ <li class="nav-item">
+ <a class="nav-link" href="{{ request.route_url('journey-list') }}">{{ _("page.navbar.journeys") }}</a>
+ </li>
{% if request.identity is not none %}
<li class="nav-item">
<a class="nav-link" href="{{ request.route_url('upload') }}">{{ _("page.navbar.upload") }}</a>
diff --git a/fietsboek/trackmap.py b/fietsboek/trackmap.py
index 994b3dd..4cb13aa 100644
--- a/fietsboek/trackmap.py
+++ b/fietsboek/trackmap.py
@@ -11,6 +11,12 @@ from .views.tileproxy import TileRequester
TILE_SIZE = 256
+# This is arbitrarily set to provide some image in case a render is requested
+# for a track without points. I've arbitrarily chosen Berlin as the represented
+# area.
+DEFAULT_ZOOM = 9
+DEFAULT_BBOX = (70062, 70611, 42824, 43179)
+
def to_web_mercator(lat: float, lon: float, zoom: int) -> tuple[int, int]:
"""Convert a pari of latitude/longitude coordinates to web mercator form.
@@ -69,6 +75,9 @@ class TrackMapRenderer:
return image
def _find_zoom(self) -> tuple[int, tuple[int, int, int, int]]:
+ if not self.track.points:
+ return DEFAULT_ZOOM, DEFAULT_BBOX
+
for zoom in range(self.maxzoom or 19, 0, -1):
min_x, max_x = 2**zoom * TILE_SIZE, 0
min_y, max_y = 2**zoom * TILE_SIZE, 0
@@ -85,7 +94,7 @@ class TrackMapRenderer:
else:
return zoom, (min_x, max_x, min_y, max_y)
- return 1, (0, 512, 0, 512)
+ return DEFAULT_ZOOM, DEFAULT_BBOX
def _draw_base(self, image, zoom, bbox):
min_x, max_x, min_y, max_y = bbox
@@ -115,8 +124,9 @@ class TrackMapRenderer:
)
coords = [(x - start_x, y - start_y) for x, y in coords]
- draw = ImageDraw.Draw(image)
- draw.line(coords, fill=self.color, width=self.line_width, joint="curve")
+ if coords:
+ draw = ImageDraw.Draw(image)
+ draw.line(coords, fill=self.color, width=self.line_width, joint="curve")
def render(
diff --git a/fietsboek/views/admin.py b/fietsboek/views/admin.py
index 0442a32..f0aa271 100644
--- a/fietsboek/views/admin.py
+++ b/fietsboek/views/admin.py
@@ -13,6 +13,7 @@ from pyramid.view import view_config
from sqlalchemy import func, select, text
from .. import models, util
+from ..data import DataManager
GOOD_CRON_THRESHOLD = datetime.timedelta(hours=1)
@@ -36,11 +37,12 @@ class SizeBreakdown:
track_data: int = 0
backups: int = 0
image_files: int = 0
- preview_files: int = 0
+ track_previews: int = 0
+ journey_previews: int = 0
user_maps: int = 0
-def _get_size_breakdown(dbsession, data_manager):
+def _get_size_breakdown(dbsession, data_manager: DataManager):
breakdown = SizeBreakdown()
dialect = dbsession.bind.dialect.name
@@ -56,7 +58,7 @@ def _get_size_breakdown(dbsession, data_manager):
for track_id in data_manager.list_tracks():
track = data_manager.open(track_id)
breakdown.backups += _safe_size(track.backup_path())
- breakdown.preview_files += _safe_size(track.preview_path())
+ breakdown.track_previews += _safe_size(track.preview_path())
for image_id in track.images():
breakdown.image_files += _safe_size(track.image_path(image_id))
@@ -65,6 +67,10 @@ def _get_size_breakdown(dbsession, data_manager):
breakdown.user_maps += _safe_size(user.heatmap_path())
breakdown.user_maps += _safe_size(user.tilehunt_path())
+ for journey_id in data_manager.list_journeys():
+ journey = data_manager.open_journey(journey_id)
+ breakdown.journey_previews += _safe_size(journey.preview_path())
+
return breakdown
diff --git a/fietsboek/views/browse.py b/fietsboek/views/browse.py
index 68d1416..e2742ad 100644
--- a/fietsboek/views/browse.py
+++ b/fietsboek/views/browse.py
@@ -1,6 +1,7 @@
"""Views for browsing all tracks."""
import datetime
+import json
import urllib.parse
from collections.abc import Callable, Iterable
from enum import Enum
@@ -503,6 +504,18 @@ def browse(request: Request) -> Response:
)
)
+ if request.params.get("format") == "json":
+ obj = [
+ {
+ "id": track.id,
+ "title": track.title,
+ "date": (track.date or datetime.datetime.fromtimestamp(0)).timestamp(),
+ "length": track.length,
+ }
+ for track in tracks
+ ]
+ return Response(json.dumps(obj).encode("ascii"), content_type="application/json")
+
return {
"tracks": tracks[:TRACKS_PER_PAGE],
"mps_to_kph": util.mps_to_kph,
diff --git a/fietsboek/views/journey.py b/fietsboek/views/journey.py
new file mode 100644
index 0000000..74b0fad
--- /dev/null
+++ b/fietsboek/views/journey.py
@@ -0,0 +1,267 @@
+"""Views relating to journeys."""
+
+import io
+import logging
+from datetime import timedelta
+
+from pyramid.httpexceptions import HTTPBadRequest, HTTPFound
+from pyramid.i18n import TranslationString as _
+from pyramid.request import Request
+from pyramid.response import Response
+from pyramid.view import view_config
+from sqlalchemy import select
+from sqlalchemy.orm import aliased
+
+from .. import trackmap, util
+from ..data import JourneyDataDir
+from ..models import User
+from ..models.journey import Journey, Visibility
+from ..models.track import Track, TrackWithMetadata
+from .tileproxy import ITileRequester
+
+LOGGER = logging.getLogger(__name__)
+
+
+@view_config(
+ route_name="journey-list",
+ renderer="fietsboek:templates/journey_list.jinja2",
+)
+def journey_list(request: Request):
+ """Lists the available journeys.
+
+ :param request: The pyramid request.
+ """
+ query = select(aliased(Journey, User.visible_journeys_query(request.identity).subquery()))
+ journeys = request.dbsession.execute(query).scalars()
+ show_new_button = request.identity is not None
+ return {
+ "journeys": journeys,
+ "md_to_html": util.safe_markdown,
+ "show_new_button": show_new_button,
+ }
+
+
+@view_config(
+ route_name="journey-details",
+ renderer="fietsboek:templates/journey_details.jinja2",
+ permission="journey.view",
+)
+def journey_details(request: Request):
+ """Shows details for a single journey.
+
+ :param request: The pyramid request.
+ """
+ journey: Journey = request.context
+ tracks = [TrackWithMetadata(track) for track in journey.tracks]
+ movement_data = journey.path().movement_data()
+ show_edit_link = request.identity == journey.owner
+ return {
+ "journey": journey,
+ "tracks": tracks,
+ "movement_data": movement_data,
+ "mps_to_kph": util.mps_to_kph,
+ "md_to_html": util.safe_markdown,
+ "timedelta": timedelta,
+ "show_edit_link": show_edit_link,
+ }
+
+
+@view_config(route_name="journey-gpx", http_cache=3600, permission="journey.view")
+def journey_gpx(request: Request):
+ """The view that returns the journey's GPX.
+
+ :param request: The pyramid request.
+ """
+ gpx_xml = request.context.gpx_xml()
+ response = Response(gpx_xml, content_type="application/gpx+xml")
+ response.md5_etag()
+ return response
+
+
+@view_config(route_name="journey-map", http_cache=3600, permission="journey.view")
+def journey_map(request: Request):
+ """The journey preview map image.
+
+ :param request: The pyramid request.
+ """
+ journey: Journey = request.context
+ journey_data: JourneyDataDir = request.data_manager.open_journey(journey.id)
+ preview_path = journey_data.preview_path()
+
+ if preview_path.exists():
+ response = Response(preview_path.read_bytes(), content_type="image/png")
+ response.md5_etag()
+ return response
+
+ loader: ITileRequester = request.registry.getUtility(ITileRequester)
+ layer = request.config.public_tile_layers()[0]
+
+ track_image = trackmap.render(journey.path(), layer, loader, size=(1300, 300))
+
+ imageio = io.BytesIO()
+ track_image.save(imageio, "png")
+ tile_data = imageio.getvalue()
+
+ if not preview_path.exists():
+ LOGGER.debug("Setting preview at %s", preview_path)
+ journey_data.set_preview(tile_data)
+
+ response = Response(tile_data, content_type="image/png")
+ response.md5_etag()
+ return response
+
+
+@view_config(
+ route_name="journey-new",
+ renderer="fietsboek:templates/journey_new.jinja2",
+ permission="new-journey",
+)
+def journey_new(_request: Request):
+ """The form to add a new journey.
+
+ :param request: The pyramid request.
+ """
+ return {}
+
+
+@view_config(
+ route_name="journey-new",
+ permission="new-journey",
+ request_method="POST",
+)
+def do_journey_new(request: Request):
+ """Handler for submitting the new-journey form.
+
+ :param request: The pyramid request.
+ """
+ journey = Journey(
+ owner=request.identity,
+ title=request.params.get("journeyTitle"),
+ description=request.params.get("journeyDescription"),
+ visibility=_extract_visibility(request),
+ link_secret=util.random_link_secret(),
+ tracks=[],
+ )
+
+ request.dbsession.add(journey)
+ request.dbsession.flush()
+
+ track_ids = _extract_valid_tracks(request, set())
+ journey.set_track_ids(track_ids)
+
+ request.data_manager.initialize_journey(journey.id)
+
+ return HTTPFound(request.route_url("journey-details", journey_id=journey.id))
+
+
+@view_config(
+ route_name="journey-edit",
+ renderer="fietsboek:templates/journey_edit.jinja2",
+ permission="journey.edit",
+)
+def journey_edit(request: Request):
+ """The form to edit a journey.
+
+ :param request: The pyramid request.
+ """
+ journey: Journey = request.context
+ return {
+ "journey": journey,
+ }
+
+
+@view_config(
+ route_name="journey-edit",
+ permission="journey.edit",
+ request_method="POST",
+)
+def do_journey_edit(request: Request):
+ """Handler for submitting the edit-journey form.
+
+ :param request: The pyramid request.
+ """
+ journey: Journey = request.context
+ request.data_manager.open_journey(journey.id).remove_preview()
+
+ journey.title = request.params.get("journeyTitle")
+ journey.description = request.params.get("journeyDescription")
+ journey.visibility = _extract_visibility(request)
+
+ track_ids = _extract_valid_tracks(request, {track.id for track in journey.tracks})
+ journey.set_track_ids(track_ids)
+
+ request.dbsession.add(journey)
+
+ return HTTPFound(request.route_url("journey-details", journey_id=journey.id))
+
+
+def _extract_visibility(request: Request) -> Visibility:
+ key = request.params.get("journeyVisibility")
+ try:
+ return Visibility[key]
+ except KeyError:
+ raise HTTPBadRequest("Invalid visibility") from None
+
+
+def _extract_valid_tracks(request: Request, current_ids: set[int]) -> list[int]:
+ user: User = request.identity
+
+ if not request.params.get("journeyTitle"):
+ raise HTTPBadRequest("Needs a title")
+
+ try:
+ track_ids = [int(tid) for tid in request.params.getall("journeyTrack[]")]
+ except ValueError:
+ # Shouldn't happen if users don't tamper with the requests manually, so we don't translate
+ raise HTTPBadRequest("Invalid track ID") from None
+
+ if not track_ids:
+ raise HTTPBadRequest("No track IDs given")
+
+ for track_id in track_ids:
+ query = select(Track).filter_by(id=track_id)
+ track: Track = request.dbsession.execute(query).scalar_one_or_none()
+ if track is None:
+ raise HTTPBadRequest("Invalid track ID")
+ # We don't really want users to add tracks to journeys that they can't
+ # see, because that leaks information (e.g., you create a journey and
+ # add a single tracks, that gives you the clear path).
+ # However, if a track used to be visible and now is no longer, we don't
+ # want editing to fail, so we allow a non-visible track if it is already
+ # in the journey.
+ if not track.is_visible_to(user) and track_id not in current_ids:
+ raise HTTPBadRequest("Invalid track ID")
+
+ return track_ids
+
+
+@view_config(
+ route_name="delete-journey",
+ permission="journey.delete",
+ request_method="POST",
+)
+def do_journey_delete(request: Request):
+ """Handler to delete a journey.
+
+ :param request: The pyramid request.
+ """
+ journey: Journey = request.context
+ request.data_manager.open_journey(journey.id).purge()
+ request.dbsession.delete(journey)
+ request.session.flash(request.localizer.translate(_("flash.journey_deleted")))
+ return HTTPFound(request.route_url("journey-list"))
+
+
+@view_config(
+ route_name="journey-invalidate-share",
+ permission="journey.edit",
+ request_method="POST",
+)
+def do_journey_invalidate_share(request: Request):
+ """Handler to invalidate a journey share link.
+
+ :param request: The pyramid request.
+ """
+ journey: Journey = request.context
+ journey.link_secret = util.random_link_secret()
+ return HTTPFound(request.route_url("journey-details", journey_id=journey.id))
diff --git a/tests/conftest.py b/tests/conftest.py
index 732c8d2..add3b3f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -60,26 +60,31 @@ def dbengine(app_settings, ini_file):
def data_manager(app_settings):
return DataManager(Path(app_settings["fietsboek.data_dir"]))
+
+def clean_directory_content(path: Path):
+ if path.is_dir():
+ shutil.rmtree(path)
+ path.mkdir()
+
+
@pytest.fixture(autouse=True)
def _cleanup_data(app_settings):
yield
engine = models.get_engine(app_settings)
- db_meta = inspect(engine)
+ # Load all table names beforehand, as has_table() would cause lock conflicts
+ tables = inspect(engine).get_table_names()
with engine.begin() as connection:
for table in reversed(Base.metadata.sorted_tables):
# The unit tests don't always set up the tables, so be gentle when
# tearing them down
- if db_meta.has_table(table.name):
+ if table.name in tables:
connection.execute(table.delete())
# The unit tests also often don't have a data directory, so be gentle here as well
if "fietsboek.data_dir" in app_settings:
data_dir = Path(app_settings["fietsboek.data_dir"])
- if (data_dir / "tracks").is_dir():
- shutil.rmtree(data_dir / "tracks")
- (data_dir / "tracks").mkdir()
- if (data_dir / "users").is_dir():
- shutil.rmtree(data_dir / "users")
- (data_dir / "users").mkdir()
+ clean_directory_content(data_dir / "tracks")
+ clean_directory_content(data_dir / "users")
+ clean_directory_content(data_dir / "journeys")
@pytest.fixture(scope='module')
def app(app_settings, dbengine, tmp_path_factory):
diff --git a/tests/playwright/conftest.py b/tests/playwright/conftest.py
index adf5ef3..435daa6 100644
--- a/tests/playwright/conftest.py
+++ b/tests/playwright/conftest.py
@@ -110,7 +110,10 @@ class Helper:
)
def add_track(
- self, user: Optional[models.User] = None, track_name: str = "Teasi_1.gpx.gz"
+ self,
+ user: Optional[models.User] = None,
+ track_name: str = "Teasi_1.gpx.gz",
+ title: str = "Another awesome track",
) -> models.Track:
"""Add a track to the given user.
@@ -127,7 +130,7 @@ class Helper:
TileRequester(None),
config.public_tile_layers()[0],
owner=user,
- title="Another awesome track",
+ title=title,
visibility=Visibility.PRIVATE,
description="Another description",
track_type=TrackType.ORGANIC,
diff --git a/tests/playwright/test_journeys.py b/tests/playwright/test_journeys.py
new file mode 100644
index 0000000..f3fa0d2
--- /dev/null
+++ b/tests/playwright/test_journeys.py
@@ -0,0 +1,145 @@
+from playwright.sync_api import Page, expect
+from sqlalchemy import select
+
+from fietsboek import models
+
+
+def add_journey(playwright_helper, dbaccess, title):
+ """Adds a journey for testing purposes. Returns the journey ID."""
+ t_1 = playwright_helper.add_track(None, "Teasi_1.gpx.gz", "trayectoria uno")
+ t_2 = playwright_helper.add_track(None, "MyTourbook_1.gpx.gz", "trayectoria dos")
+
+ with dbaccess:
+ journey = models.Journey(
+ owner=playwright_helper.john_doe(),
+ title=title,
+ description="You saw sirens?",
+ visibility=models.journey.Visibility.PUBLIC,
+ )
+
+ dbaccess.add(journey)
+ dbaccess.flush()
+
+ journey.set_track_ids([t_1.id, t_2.id])
+ dbaccess.commit()
+ dbaccess.refresh(journey, ["id"])
+ dbaccess.expunge(journey)
+
+ playwright_helper.data_manager.initialize_journey(journey.id)
+
+ return journey.id
+
+
+def test_journey_list(page: Page, playwright_helper, dbaccess):
+ playwright_helper.login()
+
+ add_journey(playwright_helper, dbaccess, title="Our Journey")
+
+ page.goto("/journey/")
+ expect(page.locator("h5", has_text="Our Journey")).to_be_visible()
+ expect(page.locator("li", has_text="trayectoria uno")).to_be_visible()
+ expect(page.locator("li", has_text="trayectoria dos")).to_be_visible()
+
+
+def test_journey_new(page: Page, playwright_helper, dbaccess):
+ playwright_helper.login()
+
+ playwright_helper.add_track(None, "Teasi_1.gpx.gz", "trayectoria uno")
+ playwright_helper.add_track(None, "MyTourbook_1.gpx.gz", "trayectoria dos")
+ playwright_helper.add_track(None, "MyTourbook_1.gpx.gz", "trayectoria tres")
+
+ page.goto("/journey/")
+ page.get_by_text("New journey").click()
+
+ page.get_by_label("Title").fill("My Odyssey")
+ page.get_by_label("Description").fill("I saw sirens!")
+
+ page.locator("#trackSearch").fill("uno")
+ page.locator("#trackSearchButton").click()
+ page.locator(".track-query-response button").click()
+
+ page.locator("#trackSearch").fill("dos")
+ page.locator("#trackSearchButton").click()
+ page.locator(".track-query-response button").click()
+
+ page.locator("#trackSearch").fill("tres")
+ page.locator("#trackSearchButton").click()
+ page.locator(".track-query-response button").click()
+ page.locator(".journey-track", has_text="tres").locator(".btn").click()
+
+ page.locator(".btn", has_text="Save").click()
+
+ expect(page.locator("h1", has_text="My Odyssey")).to_be_visible()
+
+ expect(page.locator("h5", has_text="trayectoria uno")).to_be_visible()
+ expect(page.locator("h5", has_text="trayectoria dos")).to_be_visible()
+
+ journey = dbaccess.execute(select(models.Journey).filter_by(title="My Odyssey")).scalar_one()
+
+ assert journey.title == "My Odyssey"
+ assert journey.description == "I saw sirens!"
+ assert len(journey.tracks) == 2
+ assert journey.tracks[0].title == "trayectoria uno"
+ assert journey.tracks[1].title == "trayectoria dos"
+
+
+def test_journey_new_empty_title(page: Page, playwright_helper):
+ playwright_helper.login()
+
+ playwright_helper.add_track(None, "Teasi_1.gpx.gz", "trayectoria uno")
+
+ page.goto("/journey/")
+ page.get_by_text("New journey").click()
+
+ page.locator("#trackSearch").fill("uno")
+ page.locator("#trackSearchButton").click()
+ page.locator(".track-query-response button").click()
+ page.locator(".btn", has_text="Save").click()
+
+ expect(page.locator(".invalid-feedback", has_text="A title is required")).to_be_visible()
+
+
+def test_journey_new_no_tracks(page: Page, playwright_helper):
+ playwright_helper.login()
+
+ page.goto("/journey/")
+ page.get_by_text("New journey").click()
+
+ page.get_by_label("Title").fill("A title is there!")
+
+ page.locator(".btn", has_text="Save").click()
+
+ expect(page.locator(".invalid-feedback", has_text="A journey must have at least one track"))\
+ .to_be_visible()
+
+
+def test_journey_edit(page: Page, playwright_helper, dbaccess):
+ playwright_helper.login()
+
+ journey_id = add_journey(playwright_helper, dbaccess, title="Your Odyssey")
+
+ page.goto(f"/journey/{journey_id}/")
+
+ expect(page.locator("h1", has_text="Your Odyssey")).to_be_visible()
+
+ page.locator("a", has_text="Edit").click()
+
+ page.get_by_label("Title").fill("Their Odyssey")
+ page.get_by_label("Description").fill("Where is Homer?")
+
+ expect(page.locator(".track-title", has_text="trayectoria uno")).to_be_visible()
+ page.locator(".journey-track", has_text="uno").locator(".btn").click()
+ expect(page.locator(".track-title", has_text="trayectoria uno")).not_to_be_visible()
+
+ page.locator(".btn", has_text="Save").click()
+
+ expect(page.locator("h1", has_text="Their Odyssey")).to_be_visible()
+ expect(page.locator("h5", has_text="trayectoria uno")).not_to_be_visible()
+ expect(page.locator("h5", has_text="trayectoria dos")).to_be_visible()
+
+ journey = dbaccess.execute(select(models.Journey).filter_by(title="Their Odyssey")).scalar_one()
+
+ assert journey.title == "Their Odyssey"
+ assert journey.description == "Where is Homer?"
+ assert len(journey.tracks) == 1
+ assert journey.tracks[0].title == "trayectoria dos"