aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Schadt <kingdread@gmx.de>2025-12-20 14:50:47 +0100
committerDaniel Schadt <kingdread@gmx.de>2025-12-20 14:50:47 +0100
commit71ad6034325a3e64ebadedfe2b52db01f91ea257 (patch)
tree7465ef61d4353b769118e5bf2c021de32138defe
parent5455a0c5216409ad5593c4b4bfd0fbfcd65b1e04 (diff)
downloadfietsboek-71ad6034325a3e64ebadedfe2b52db01f91ea257.tar.gz
fietsboek-71ad6034325a3e64ebadedfe2b52db01f91ea257.tar.bz2
fietsboek-71ad6034325a3e64ebadedfe2b52db01f91ea257.zip
first work on journeysjourneys
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.
-rw-r--r--fietsboek/models/__init__.py1
-rw-r--r--fietsboek/models/journey.py171
-rw-r--r--fietsboek/models/track.py3
-rw-r--r--fietsboek/models/user.py3
-rw-r--r--fietsboek/routes.py9
-rw-r--r--fietsboek/templates/journey_details.jinja291
-rw-r--r--fietsboek/templates/journey_list.jinja221
-rw-r--r--fietsboek/views/journey.py67
8 files changed, 366 insertions, 0 deletions
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'<?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
+ 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"</trkpt>\n")
+ write(b"</trkseg>")
+ write(b"</trk>")
+
+ write(b"</gpx>")
+
+ 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 %}
+<div class="container">
+ <h1>{{ journey.title }}</h1>
+
+ {% set gpx_url = request.route_path("journey-gpx", journey_id=journey.id) %}
+ <div id="mainmap" class="gpxview:{{ gpx_url }}:OSM" style="width:100%;height:600px">
+ <noscript><p>{{ _("page.noscript") }}<p></noscript>
+ </div>
+ <div id="mainmap_hp" style="width:100%;height:300px">
+ </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>
+ </tbody>
+ </table>
+
+ <h2>{{ _("journey.tracks") }}</h2>
+
+ {% for track in tracks %}
+ <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>
+ {% endfor %}
+</div>
+{% 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 %}
+<div class="container">
+ <h1>{{ _("journeys.overview.title") }}</h1>
+
+ {% for journey in journeys %}
+ <div class="card">
+ <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">{{ journey.title }}</h5>
+ <p class="card-text">{{ journey.description }}</p>
+ </div>
+ <ul class="list-group list-group-flush">
+ {% for track in journey.tracks %}
+ <li class="list-group-item">{{ track.title }}</li>
+ {% endfor %}
+ </ul>
+ </div>
+ {% endfor %}
+</div>
+{% 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