aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--fietsboek/alembic/versions/20251019_90b39fdf6e4b.py35
-rw-r--r--fietsboek/models/track.py73
-rw-r--r--fietsboek/views/detail.py19
3 files changed, 113 insertions, 14 deletions
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")