From 71ad6034325a3e64ebadedfe2b52db01f91ea257 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 20 Dec 2025 14:50:47 +0100 Subject: first work on journeys For now, they need to be manually inserted into the database, but we can flesh out the model first before we add UI to edit journeys. Also, there is a lot of code duplication, unfortunately. --- fietsboek/models/__init__.py | 1 + fietsboek/models/journey.py | 171 +++++++++++++++++++++++++++++ fietsboek/models/track.py | 3 + fietsboek/models/user.py | 3 + fietsboek/routes.py | 9 ++ fietsboek/templates/journey_details.jinja2 | 91 +++++++++++++++ fietsboek/templates/journey_list.jinja2 | 21 ++++ fietsboek/views/journey.py | 67 +++++++++++ 8 files changed, 366 insertions(+) create mode 100644 fietsboek/models/journey.py create mode 100644 fietsboek/templates/journey_details.jinja2 create mode 100644 fietsboek/templates/journey_list.jinja2 create mode 100644 fietsboek/views/journey.py diff --git a/fietsboek/models/__init__.py b/fietsboek/models/__init__.py index 85664ca..74cf145 100644 --- a/fietsboek/models/__init__.py +++ b/fietsboek/models/__init__.py @@ -12,6 +12,7 @@ from .badge import Badge # flake8: noqa from .comment import Comment # flake8: noqa from .image import ImageMetadata # flake8: noqa from .track import Tag, Track, TrackCache, Upload, Waypoint # flake8: noqa +from .journey import Journey # Import or define all models here to ensure they are attached to the # ``Base.metadata`` prior to any initialization routines. diff --git a/fietsboek/models/journey.py b/fietsboek/models/journey.py new file mode 100644 index 0000000..50e26bf --- /dev/null +++ b/fietsboek/models/journey.py @@ -0,0 +1,171 @@ +import datetime +import io +import logging +import enum +from typing import Self, TYPE_CHECKING +from pyramid.authorization import ( + ALL_PERMISSIONS, + ACLAllowed, + ACLHelper, + Allow, + Authenticated, + Everyone, +) +from pyramid.httpexceptions import HTTPNotFound +from pyramid.request import Request +from sqlalchemy import ( + Column, + DateTime, + Enum, + Float, + ForeignKey, + Integer, + LargeBinary, + Table, + Text, + delete, + insert, + inspect, + select, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .. import geo, util +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): + __tablename__ = "journeys" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + owner_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id")) + title: Mapped[str | None] = mapped_column(Text) + description: Mapped[str | None] = mapped_column(Text) + visibility: Mapped[Visibility | None] = mapped_column(Enum(Visibility)) + 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 path(self) -> geo.Path: + """Returns the concatenated path of all contained tracks.""" + points = [point for track in self.tracks for point in track.path().points] + return geo.Path(points) + + def gpx_xml(self) -> bytes: + buf = io.BytesIO() + buf.write(b'') + buf.write(b'') + + buf.write(b"") + if self.title: + buf.write(b"%s" % util.xml_escape(self.title)) + if self.description: + buf.write(b"%s" % util.xml_escape(self.description)) + buf.write(b"") + + # Cache for easy access, especially the date is important since it's a + # dynamic property + write = buf.write + + write(b"") + write(b"") + for point in self.path().points: + write(b'') + write(b"") + write(str(point.elevation).encode("ascii")) + write(b"") + write(b"\n") + write(b"") + write(b"") + + write(b"") + + return buf.getvalue() + +__all__ = [ +] diff --git a/fietsboek/models/track.py b/fietsboek/models/track.py index 92ee978..cd99f4c 100644 --- a/fietsboek/models/track.py +++ b/fietsboek/models/track.py @@ -315,6 +315,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): diff --git a/fietsboek/models/user.py b/fietsboek/models/user.py index 69d6972..19d5e71 100644 --- a/fietsboek/models/user.py +++ b/fietsboek/models/user.py @@ -139,6 +139,9 @@ class User(Base): comments: Mapped[list["Comment"]] = relationship( "Comment", back_populates="author", cascade="all, delete-orphan" ) + journeys: Mapped[list["models.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( diff --git a/fietsboek/routes.py b/fietsboek/routes.py index b8a0113..33b12ff 100644 --- a/fietsboek/routes.py +++ b/fietsboek/routes.py @@ -57,6 +57,15 @@ 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("badge", "/badge/{badge_id}", factory="fietsboek.models.Badge.factory") config.add_route("admin", "/admin/") diff --git a/fietsboek/templates/journey_details.jinja2 b/fietsboek/templates/journey_details.jinja2 new file mode 100644 index 0000000..5b101a3 --- /dev/null +++ b/fietsboek/templates/journey_details.jinja2 @@ -0,0 +1,91 @@ +{% extends "layout.jinja2" %} +{% block content %} +
+

{{ journey.title }}

+ + {% set gpx_url = request.route_path("journey-gpx", journey_id=journey.id) %} +
+
+
+
+ + + + + + + + + + + + + + + + +
{{ _("page.details.length") }}{{ (movement_data.length / 1000) | round(2) | format_decimal }} km
{{ _("page.details.uphill") }}{{ movement_data.uphill | round(2) | format_decimal }} m
{{ _("page.details.downhill") }}{{ movement_data.downhill | round(2) | format_decimal }} m
+ +

{{ _("journey.tracks") }}

+ + {% for track in tracks %} +
+
+ {{ track.title | default(track.date, true) }} +
+
+
+ +
+
+ + + + + + + + + {% if track.show_organic_data() %} + + + + + + + {% endif %} + + + + + + + {% if track.show_organic_data() %} + + + + + + + + + + + + + {% endif %} + + + + + + + +
{{ _("page.details.date") }}{{ track.date | format_datetime }}{{ _("page.details.length") }}{{ (track.length / 1000) | round(2) | format_decimal }} km
{{ _("page.details.start_time") }}{{ track.start_time | format_datetime }}{{ _("page.details.end_time") }}{{ track.end_time | format_datetime }}
{{ _("page.details.uphill") }}{{ track.uphill | round(2) | format_decimal }} m{{ _("page.details.downhill") }}{{ track.downhill | round(2) | format_decimal }} m
{{ _("page.details.moving_time") }}{{ track.moving_time }}{{ _("page.details.stopped_time") }}{{ track.stopped_time }}
{{ _("page.details.max_speed") }}{{ mps_to_kph(track.max_speed) | round(2) | format_decimal }} km/h{{ _("page.details.avg_speed") }}{{ mps_to_kph(track.avg_speed) | round(2) | format_decimal }} km/h
{{ _("page.browse.card.comments") }}{{ track.comments | length }} {{ _("page.browse.card.images") }}{{ track.images | length }}
+
+
+
+ {% endfor %} +
+{% endblock %} diff --git a/fietsboek/templates/journey_list.jinja2 b/fietsboek/templates/journey_list.jinja2 new file mode 100644 index 0000000..3c5c0a8 --- /dev/null +++ b/fietsboek/templates/journey_list.jinja2 @@ -0,0 +1,21 @@ +{% extends "layout.jinja2" %} +{% block content %} +
+

{{ _("journeys.overview.title") }}

+ + {% for journey in journeys %} +
+ Rendered map of the journey +
+
{{ journey.title }}
+

{{ journey.description }}

+
+
    + {% for track in journey.tracks %} +
  • {{ track.title }}
  • + {% endfor %} +
+
+ {% endfor %} +
+{% endblock %} diff --git a/fietsboek/views/journey.py b/fietsboek/views/journey.py new file mode 100644 index 0000000..d4c349b --- /dev/null +++ b/fietsboek/views/journey.py @@ -0,0 +1,67 @@ +import io +import logging +from pyramid.request import Request +from pyramid.response import Response +from pyramid.view import view_config +from sqlalchemy import select + +from .. import trackmap, util +from ..models.journey import Journey +from ..models.track import 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): + journeys = request.dbsession.execute(select(Journey)).scalars() + return { + "journeys": journeys, + } + + +@view_config( + route_name="journey-details", + renderer="fietsboek:templates/journey_details.jinja2", + permission="journey.view", +) +def journey_details(request: Request): + journey: Journey = request.context + tracks = [TrackWithMetadata(track) for track in journey.tracks] + movement_data = journey.path().movement_data() + return { + "journey": journey, + "tracks": tracks, + "movement_data": movement_data, + "mps_to_kph": util.mps_to_kph, + } + + +@view_config(route_name="journey-gpx", http_cache=3600, permission="journey.view") +def journey_gpx(request: 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): + journey = request.context + + 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() + + response = Response(tile_data, content_type="image/png") + response.md5_etag() + return response -- cgit v1.2.3