aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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