From 41a5b32d8e613c2d5bf34db1ec89089ad2a98ba7 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sun, 19 Oct 2025 20:48:23 +0200 Subject: return GPX with data from database --- .../alembic/versions/20251019_90b39fdf6e4b.py | 35 +++++++++++ fietsboek/models/track.py | 73 +++++++++++++++++++++- fietsboek/views/detail.py | 19 ++---- 3 files changed, 113 insertions(+), 14 deletions(-) create mode 100644 fietsboek/alembic/versions/20251019_90b39fdf6e4b.py diff --git a/fietsboek/alembic/versions/20251019_90b39fdf6e4b.py b/fietsboek/alembic/versions/20251019_90b39fdf6e4b.py new file mode 100644 index 0000000..abc43fe --- /dev/null +++ b/fietsboek/alembic/versions/20251019_90b39fdf6e4b.py @@ -0,0 +1,35 @@ +"""add table for track points + +Revision ID: 90b39fdf6e4b +Revises: 2ebe1bf66430 +Create Date: 2025-10-19 20:17:12.562653 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '90b39fdf6e4b' +down_revision = '2ebe1bf66430' +branch_labels = None +depends_on = None + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('track_points', + sa.Column('track_id', sa.Integer(), nullable=False), + sa.Column('index', sa.Integer(), nullable=False), + sa.Column('longitude', sa.Float(), nullable=True), + sa.Column('latitude', sa.Float(), nullable=True), + sa.Column('elevation', sa.Float(), nullable=True), + sa.Column('time_offset', sa.Float(), nullable=True), + sa.ForeignKeyConstraint(['track_id'], ['tracks.id'], name=op.f('fk_track_points_track_id_tracks')), + sa.PrimaryKeyConstraint('track_id', 'index', name=op.f('pk_track_points')) + ) + # ### end Alembic commands ### + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('track_points') + # ### end Alembic commands ### diff --git a/fietsboek/models/track.py b/fietsboek/models/track.py index 0921437..e9c09d6 100644 --- a/fietsboek/models/track.py +++ b/fietsboek/models/track.py @@ -49,7 +49,7 @@ from sqlalchemy import ( ) from sqlalchemy.orm import Mapped, relationship -from .. import util +from .. import geo, util from .meta import Base if TYPE_CHECKING: @@ -151,6 +151,27 @@ track_favourite_assoc = Table( Column("user_id", ForeignKey("users.id"), primary_key=True), ) + +class TrackPoint(Base): + __tablename__ = "track_points" + track_id = Column(Integer, ForeignKey("tracks.id"), primary_key=True) + index = Column(Integer, primary_key=True) + longitude = Column(Float) + latitude = Column(Float) + elevation = Column(Float) + time_offset = Column(Float) + + track: Mapped["Track"] = relationship("Track", back_populates="points") + + def to_geo_point(self) -> geo.Point: + return geo.Point( + latitude=self.latitude, + longitude=self.longitude, + elevation=self.elevation, + time_offset=self.time_offset, + ) + + # Some words about timezone handling in saved tracks: # https://www.youtube.com/watch?v=-5wpm-gesOY # @@ -235,6 +256,9 @@ class Track(Base): transformers = Column(JsonText) owner: Mapped["models.User"] = relationship("User", back_populates="tracks") + points: Mapped[list["TrackPoint"]] = relationship( + "TrackPoint", back_populates="track", cascade="all, delete-orphan", + ) cache: Mapped[Optional["TrackCache"]] = relationship( "TrackCache", back_populates="track", uselist=False, cascade="all, delete-orphan" ) @@ -316,6 +340,53 @@ class Track(Base): ) return acl + def set_path(self, path: geo.Path): + """Sets this track's represented path to the given path. + + :param path: The new GPS path of this track. + """ + self.points = [ + TrackPoint( + track=self, + index=i, + longitude=point.longitude, + latitude=point.latitude, + elevation=point.elevation, + time_offset=point.time_offset, + ) + for i, point in enumerate(path.points) + ] + + def path(self) -> geo.Path: + """Returns the path of this track. + + :return: The GPS path of this track. + """ + return geo.Path([ + point.to_geo_point() for point in sorted(self.points, key=lambda p: p.index) + ]) + + def gpx_xml(self) -> bytes: + """Returns an XML representation of this track. + + :return: The XML representation (a GPX file). + """ + gpx = gpxpy.gpx.GPX() + gpx.description = self.description + gpx.name = self.title + segment = gpxpy.gpx.GPXTrackSegment() + for point in self.path().points: + segment.points.append(gpxpy.gpx.GPXTrackPoint( + latitude=point.latitude, + longitude=point.longitude, + elevation=point.elevation, + time=self.date + datetime.timedelta(seconds=point.time_offset), + )) + track = gpxpy.gpx.GPXTrack() + track.segments.append(segment) + gpx.tracks.append(track) + return gpx.to_xml(prettyprint=False).encode("utf-8") + @property def date(self): """The time-zone-aware date this track has set. diff --git a/fietsboek/views/detail.py b/fietsboek/views/detail.py index 209b516..e0ac87a 100644 --- a/fietsboek/views/detail.py +++ b/fietsboek/views/detail.py @@ -6,6 +6,7 @@ import io import logging from html.parser import HTMLParser +import brotli import gpxpy from markupsafe import Markup from pyramid.httpexceptions import ( @@ -129,37 +130,29 @@ def gpx(request): :rtype: pyramid.response.Response """ track: Track = request.context - try: - manager = request.data_manager.open(track.id) - except FileNotFoundError: - LOGGER.error("Track exists in database, but not on disk: %d", track.id) - return HTTPInternalServerError() if track.title: wanted_filename = f"{track.id} - {util.secure_filename(track.title)}.gpx" else: wanted_filename = f"{track.id}.gpx" content_disposition = f'attachment; filename="{wanted_filename}"' + gpx_data = track.gpx_xml() # We can be nice to the client if they support it, and deliver the gzipped # data straight. This saves decompression time on the server and saves a # lot of bandwidth. accepted = request.accept_encoding.acceptable_offers(["br", "gzip", "identity"]) for encoding, _qvalue in accepted: if encoding == "br": - response = FileResponse( - str(manager.gpx_path()), - request, - content_type="application/gpx+xml", - content_encoding="br", - ) + data = brotli.compress(gpx_data) + response = Response(data, content_type="application/gpx+xml", content_encoding="br") break if encoding == "gzip": # gzip'ed GPX files are so much smaller than uncompressed ones, it # is worth re-compressing them for the client - data = gzip.compress(manager.decompress_gpx()) + data = gzip.compress(gpx_data) response = Response(data, content_type="application/gpx+xml", content_encoding="gzip") break if encoding == "identity": - response = Response(manager.decompress_gpx(), content_type="application/gpx+xml") + response = Response(gpx_data, content_type="application/gpx+xml") break else: return HTTPNotAcceptable("No data with acceptable encoding found") -- cgit v1.2.3