diff options
| -rw-r--r-- | fietsboek/models/__init__.py | 1 | ||||
| -rw-r--r-- | fietsboek/models/journey.py | 171 | ||||
| -rw-r--r-- | fietsboek/models/track.py | 3 | ||||
| -rw-r--r-- | fietsboek/models/user.py | 3 | ||||
| -rw-r--r-- | fietsboek/routes.py | 9 | ||||
| -rw-r--r-- | fietsboek/templates/journey_details.jinja2 | 91 | ||||
| -rw-r--r-- | fietsboek/templates/journey_list.jinja2 | 21 | ||||
| -rw-r--r-- | fietsboek/views/journey.py | 67 |
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 |
