diff options
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 Binary files differindex 1175786..356be25 100644 --- a/fietsboek/locale/de/LC_MESSAGES/messages.mo +++ b/fietsboek/locale/de/LC_MESSAGES/messages.mo diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.po b/fietsboek/locale/de/LC_MESSAGES/messages.po index 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 Binary files differindex 980fbe0..f4b7bbf 100644 --- a/fietsboek/locale/en/LC_MESSAGES/messages.mo +++ b/fietsboek/locale/en/LC_MESSAGES/messages.mo diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.po b/fietsboek/locale/en/LC_MESSAGES/messages.po index 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":"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"}
\ 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" |
