From 0f1731ec025ccc5144e5b33db34fd9e6526bc66e Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Wed, 15 Oct 2025 21:42:19 +0200 Subject: initial geo.Path implementation Since we want to move GPX data into the database, we need to do all the things that gpxpy currently does for us, including the length and speed computations. This is the start. --- fietsboek/geo.py | 132 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 fietsboek/geo.py diff --git a/fietsboek/geo.py b/fietsboek/geo.py new file mode 100644 index 0000000..c665705 --- /dev/null +++ b/fietsboek/geo.py @@ -0,0 +1,132 @@ +from dataclasses import dataclass +from itertools import islice +from math import sqrt, sin, cos, radians + +import gpxpy + + +# WGS-84 equatorial radius, also called the semi-major axis. +# https://en.wikipedia.org/wiki/Earth_radius +EARTH_RADIUS = 6378137.0 +"""Radius of the earth, in meters.""" + +# https://en.wikipedia.org/wiki/Preferred_walking_speed +MOVING_THRESHOLD = 1.1 +"""Speed which is considered to be the moving threshold, in m/s.""" + + +@dataclass +class MovementData: + duration: float = 0.0 + """Duration of the path, in seconds.""" + + moving_duration: float = 0.0 + """Duration spent moving, in seconds.""" + + stopped_duration: float = 0.0 + """Duration spent stopped, in seconds.""" + + length: float = 0.0 + """Length of the path, in meters.""" + + average_speed: float = 0.0 + """Average speed, in m/s.""" + + maximum_speed: float = 0.0 + """Maximum speed, in m/s.""" + + uphill: float = 0.0 + """Uphill elevation, in meters.""" + + downhill: float = 0.0 + """Downhill elevation, in meters.""" + + +@dataclass(slots=True) +class Point: + longitude: float + latitude: float + elevation: float + time_offset: float + + def distance(self, other: "Point") -> float: + """Returns the distance between this point and the given other point in + meters. + """ + r_1 = EARTH_RADIUS + self.elevation + r_2 = EARTH_RADIUS + other.elevation + # The formula assumes that 0° is straight upward, but 0° in geo + # coordinates is actually on the equator plane. + t_1 = radians(90 - self.latitude) + t_2 = radians(90 - other.latitude) + p_1 = radians(self.longitude) + p_2 = radians(other.longitude) + # See + # https://en.wikipedia.org/wiki/Spherical_coordinate_system#Distance_in_spherical_coordinates + # While this is not the Haversine formula for distances along the + # circle curvature, it allows us to take the elevation into account, + # and for most GPS point differences that we encounter it should be + # enough. + radicand = ( + r_1**2 + + r_2**2 - + 2 * r_1 * r_2 * ( + sin(t_1) * sin(t_2) * cos(p_1 - p_2) + + cos(t_1) * cos(t_2) + ) + ) + if radicand < 0.0: + return 0.0 + return sqrt(radicand) + + +class Path: + @classmethod + def from_gpx(cls, gpx: gpxpy.gpx.GPX) -> "Path": + points = [] + start_time = None + + for track in gpx.tracks: + for segment in track.segments: + for point in segment.points: + if start_time is None: + start_time = point.time + + time_offset = (point.time - start_time).total_seconds() + points.append(Point( + longitude=point.longitude, + latitude=point.latitude, + elevation=point.elevation, + time_offset=time_offset, + )) + + return cls(points) + + def __init__(self, points: list[Point]): + self.points = points + + def _point_pairs(self): + return zip(self.points, islice(self.points, 1, None)) + + def movement_data(self) -> MovementData: + """Returns the movement data.""" + movement_data = MovementData() + for a, b in self._point_pairs(): + distance = a.distance(b) + time = b.time_offset - a.time_offset + speed = distance / time + elevation = b.elevation - a.elevation + + movement_data.length += distance + if speed >= MOVING_THRESHOLD: + movement_data.moving_duration += time + else: + movement_data.stopped_duration += time + movement_data.maximum_speed = max(movement_data.maximum_speed, speed) + if elevation > 0.0: + movement_data.uphill += elevation + else: + movement_data.downhill += -elevation + movement_data.duration = b.time_offset + movement_data.average_speed = movement_data.length / movement_data.moving_duration + return movement_data -- cgit v1.2.3 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 From 7b5740fbef267b26c7fc1493736e3edb52fd0173 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sun, 19 Oct 2025 22:14:35 +0200 Subject: move GPX reading into convert --- fietsboek/actions.py | 36 +++++++------------- fietsboek/convert.py | 86 +++++++++++++++++++++++++++++++++++++++-------- fietsboek/data.py | 84 ++++----------------------------------------- fietsboek/geo.py | 21 ------------ fietsboek/views/upload.py | 27 +++++---------- 5 files changed, 100 insertions(+), 154 deletions(-) diff --git a/fietsboek/actions.py b/fietsboek/actions.py index 3f14308..9c1142b 100644 --- a/fietsboek/actions.py +++ b/fietsboek/actions.py @@ -19,7 +19,7 @@ from pyramid.request import Request from sqlalchemy import select from sqlalchemy.orm.session import Session -from . import email, models, trackmap +from . import convert, email, geo, models, trackmap from . import transformers as mod_transformers from . import util from .config import TileLayerConfig @@ -74,16 +74,15 @@ def add_track( """ # pylint: disable=too-many-positional-arguments,too-many-locals,too-many-arguments LOGGER.debug("Inserting new track...") - track = models.Track( - owner=owner, - title=title, - visibility=visibility, - type=track_type, - description=description, - badges=badges, - link_secret=util.random_link_secret(), - tagged_people=tagged_people, - ) + track = convert.smart_convert(gpx_data) + track.owner = owner + track.title = title + track.visibility = visibility + track.type = track_type + track.description = description + track.badges = badges + track.link_secret = util.random_link_secret() + track.tagged_people = tagged_people track.date = date track.sync_tags(tags) dbsession.add(track) @@ -93,11 +92,10 @@ def add_track( LOGGER.debug("Creating a new data folder for %d", track.id) assert track.id is not None with data_manager.initialize(track.id) as manager: - LOGGER.debug("Saving GPX to %s", manager.gpx_path()) - manager.compress_gpx(gpx_data) - manager.backup() + LOGGER.debug("Saving backup to %s", manager.backup_path()) + manager.compress_backup(gpx_data) - gpx = gpxpy.parse(gpx_data) + gpx = gpxpy.parse(track.gpx_xml()) for transformer in transformers: LOGGER.debug("Running %s with %r", transformer, transformer.parameters) transformer.execute(gpx) @@ -116,14 +114,6 @@ def add_track( preview_image.save(image_io, "PNG") manager.set_preview(image_io.getvalue()) - manager.engrave_metadata( - title=track.title, - description=track.description, - author_name=track.owner.name, - time=track.date, - gpx=gpx, - ) - return track diff --git a/fietsboek/convert.py b/fietsboek/convert.py index d3bfb22..2e8b5db 100644 --- a/fietsboek/convert.py +++ b/fietsboek/convert.py @@ -1,7 +1,12 @@ """Conversion functions to convert between various recording formats.""" +import datetime +from typing import Optional import fitparse -from gpxpy.gpx import GPX, GPXTrack, GPXTrackPoint, GPXTrackSegment +import gpxpy + +from . import geo, util +from .models import Track FIT_RECORD_FIELDS = ["position_lat", "position_long", "altitude", "timestamp"] @@ -15,8 +20,8 @@ def semicircles_to_deg(circles: int) -> float: return circles * (180 / 2**31) -def from_fit(data: bytes) -> GPX: - """Reads a .fit as GPX data. +def from_fit(data: bytes) -> Track: + """Reads a .fit as track data. This uses the fitparse_ library under the hood. @@ -26,30 +31,81 @@ def from_fit(data: bytes) -> GPX: :return: The converted structure. """ fitfile = fitparse.FitFile(data) + start_time = None points = [] for record in fitfile.get_messages("record"): values = record.get_values() try: if any(values[field] is None for field in FIT_RECORD_FIELDS): continue - point = GPXTrackPoint( + time = values["timestamp"] + if start_time is None: + start_time = time + point = geo.Point( latitude=semicircles_to_deg(values["position_lat"]), longitude=semicircles_to_deg(values["position_long"]), elevation=values["altitude"], - time=values["timestamp"], + time_offset=time - start_time, ) except KeyError: pass else: points.append(point) - track = GPXTrack() - track.segments = [GPXTrackSegment(points)] - gpx = GPX() - gpx.tracks = [track] - return gpx + path = geo.Path(points) + track = Track() + track.set_path(path) + return track + + +def from_gpx(data: bytes) -> Track: + """Reads a .gpx as track data. + + This uses the gpxpy_ library under the hood. + .. _gpxpy: https://github.com/tkrajina/gpxpy -def smart_convert(data: bytes) -> bytes: + :param data: The input bytes. + :return: The converted structure. + """ + gpx = gpxpy.parse(data) + points = [] + start_time = None + + for track in gpx.tracks: + for segment in track.segments: + for point in segment.points: + if start_time is None: + start_time = point.time + + time_offset = (point.time - start_time).total_seconds() + points.append(geo.Point( + longitude=point.longitude, + latitude=point.latitude, + elevation=point.elevation, + time_offset=time_offset, + )) + + timezone = util.guess_gpx_timezone(gpx) + date = gpx.time or gpx.get_time_bounds().start_time or datetime.datetime.now() + date = date.astimezone(timezone) + track_name = gpx.name + track_desc = gpx.description + for track in gpx.tracks: + if not track_name and track.name: + track_name = track.name + if not track_desc and track.description: + track_desc = track.description + + path = geo.Path(points) + track = Track() + track.set_path(path) + track.title = track_name + track.description = track_desc + track.date = date + return track + + +def smart_convert(data: bytes) -> Optional[Track]: """Tries to be smart in converting the input bytes. This function automatically applies the correct conversion if possible. @@ -61,8 +117,10 @@ def smart_convert(data: bytes) -> bytes: :return: The converted content. """ if len(data) > 11 and data[9:12] == b"FIT": - return from_fit(data).to_xml().encode("utf-8") - return data + return from_fit(data) + if data.startswith(b" Path: - """Returns the path of the GPX file. - - This file contains the (brotli) compressed GPX data. - - :return: The path where the GPX is supposed to be. - """ - return self.path / "track.gpx.br" - - def compress_gpx(self, data: bytes, quality: int = 4): - """Set the GPX content to the compressed form of data. + def compress_backup(self, data: bytes, quality: int = 4): + """Set the content of the backup to the compressed form of data. - If you want to write compressed data directly, use :meth:`gpx_path` to + If you want to write compressed data directly, use :meth:`backup_path` to get the path of the GPX file. :param data: The GPX data (uncompressed). :param quality: Compression quality, from 0 to 11 - 11 is highest quality but slowest compression speed. """ - if self.journal is not None: - # First, we check if we already saved an old state of the GPX data - for action, *_ in self.journal: - if action == "compress_gpx": - break - else: - # We did not save a state yet - old_data = None if not self.gpx_path().is_file() else self.gpx_path().read_bytes() - self.journal.append(("compress_gpx", old_data)) - compressed = brotli.compress(data, quality=quality) - self.gpx_path().write_bytes(compressed) + self.backup_path().write_bytes(compressed) - def decompress_gpx(self) -> bytes: - """Returns the GPX bytes decompressed. + def decompress_backup(self) -> bytes: + """Returns the backup bytes decompressed. :return: The saved GPX file, decompressed. """ - return brotli.decompress(self.gpx_path().read_bytes()) - - def engrave_metadata( - self, - title: Optional[str], - description: Optional[str], - author_name: Optional[str], - time: Optional[datetime.datetime], - *, - gpx: Optional[gpxpy.gpx.GPX] = None, - ): - """Engrave the given metadata into the GPX file. - - Note that this will overwrite all existing metadata in the given - fields. - - If ``None`` is given, it will erase that specific part of the metadata. - - :param title: The title of the track. - :param description: The description of the track. - :param creator: Name of the track's creator. - :param time: Time of the track. - :param gpx: The pre-parsed GPX track, to save time if it is already parsed. - """ - # pylint: disable=too-many-arguments - if gpx is None: - gpx = gpxpy.parse(self.decompress_gpx()) - # First we delete the existing metadata - for track in gpx.tracks: - track.name = None - track.description = None - - # Now we add the new metadata - gpx.author_name = author_name - gpx.name = title - gpx.description = description - gpx.time = time - - self.compress_gpx(util.encode_gpx(gpx)) - - def backup(self): - """Create a backup of the GPX file.""" - shutil.copy(self.gpx_path(), self.backup_path()) + return brotli.decompress(self.backup_path().read_bytes()) def backup_path(self) -> Path: """Path of the GPX backup file. diff --git a/fietsboek/geo.py b/fietsboek/geo.py index c665705..348a4b9 100644 --- a/fietsboek/geo.py +++ b/fietsboek/geo.py @@ -81,27 +81,6 @@ class Point: class Path: - @classmethod - def from_gpx(cls, gpx: gpxpy.gpx.GPX) -> "Path": - points = [] - start_time = None - - for track in gpx.tracks: - for segment in track.segments: - for point in segment.points: - if start_time is None: - start_time = point.time - - time_offset = (point.time - start_time).total_seconds() - points.append(Point( - longitude=point.longitude, - latitude=point.latitude, - elevation=point.elevation, - time_offset=time_offset, - )) - - return cls(points) - def __init__(self, points: list[Point]): self.points = points diff --git a/fietsboek/views/upload.py b/fietsboek/views/upload.py index 3eb1099..84eb95f 100644 --- a/fietsboek/views/upload.py +++ b/fietsboek/views/upload.py @@ -54,14 +54,12 @@ def do_upload(request): request.session.flash(request.localizer.translate(_("flash.no_file_selected"))) return HTTPFound(request.route_url("upload")) - gpx = convert.smart_convert(gpx) - # Before we do anything, we check if we can parse the file. # gpxpy might throw different exceptions, so we simply catch `Exception` # here - if we can't parse it, we don't care too much why at this point. # pylint: disable=broad-except try: - gpxpy.parse(gpx) + track = convert.smart_convert(gpx) except Exception as exc: request.session.flash(request.localizer.translate(_("flash.invalid_file"))) LOGGER.info("Could not parse gpx: %s", exc) @@ -73,7 +71,7 @@ def do_upload(request): owner=request.identity, uploaded_at=now, ) - upload.gpx_data = gpx + upload.gpx_data = track.gpx_xml() request.dbsession.add(upload) request.dbsession.flush() @@ -111,28 +109,19 @@ def finish_upload(request): upload = request.context badges = request.dbsession.execute(select(models.Badge)).scalars() badges = [(False, badge) for badge in badges] - gpx = gpxpy.parse(upload.gpx_data) - timezone = util.guess_gpx_timezone(gpx) - date = gpx.time or gpx.get_time_bounds().start_time or datetime.datetime.now() - date = date.astimezone(timezone) - tz_offset = timezone.utcoffset(date) + track = convert.smart_convert(upload.gpx_data) + timezone = track.date.tzinfo + tz_offset = timezone.utcoffset(track.date) tz_offset = 0 if tz_offset is None else tz_offset.total_seconds() - track_name = "" - track_desc = "" - for track in gpx.tracks: - if not track_name and track.name: - track_name = track.name - if not track_desc and track.description: - track_desc = track.description return { "preview_id": upload.id, - "upload_title": gpx.name or track_name, - "upload_date": date, + "upload_title": track.title, + "upload_date": track.date, "upload_date_tz": int(tz_offset // 60), "upload_visibility": Visibility.PRIVATE, "upload_type": TrackType.ORGANIC, - "upload_description": gpx.description or track_desc, + "upload_description": track.description, "upload_tags": set(), "upload_tagged_people": [], "badges": badges, -- cgit v1.2.3 From 9be283944a72019ee0d151011c049fc21ca7e718 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sun, 19 Oct 2025 22:28:20 +0200 Subject: have trackmap's be rendered on geo.Path --- fietsboek/actions.py | 2 +- fietsboek/trackmap.py | 12 ++++++------ fietsboek/views/detail.py | 3 +-- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/fietsboek/actions.py b/fietsboek/actions.py index 9c1142b..0e2284b 100644 --- a/fietsboek/actions.py +++ b/fietsboek/actions.py @@ -109,7 +109,7 @@ def add_track( dbsession.add(track.cache) LOGGER.debug("Building preview image for %s", track.id) - preview_image = trackmap.render(gpx, layer, tile_requester) + preview_image = trackmap.render(track.path(), layer, tile_requester) image_io = io.BytesIO() preview_image.save(image_io, "PNG") manager.set_preview(image_io.getvalue()) diff --git a/fietsboek/trackmap.py b/fietsboek/trackmap.py index 584bf72..a850051 100644 --- a/fietsboek/trackmap.py +++ b/fietsboek/trackmap.py @@ -3,9 +3,9 @@ import io import math -from gpxpy.gpx import GPX from PIL import Image, ImageDraw +from . import geo from .config import TileLayerConfig from .views.tileproxy import TileRequester @@ -44,7 +44,7 @@ class TrackMapRenderer: def __init__( self, - track: GPX, + track: geo.Path, requester: TileRequester, size: tuple[int, int], layer: TileLayerConfig, @@ -73,7 +73,7 @@ class TrackMapRenderer: min_x, max_x = 2**zoom * TILE_SIZE, 0 min_y, max_y = 2**zoom * TILE_SIZE, 0 - for point in self.track.walk(only_points=True): + for point in self.track.points: x, y = to_web_mercator(point.latitude, point.longitude, zoom) min_x = min(min_x, x) max_x = max(max_x, x) @@ -112,7 +112,7 @@ class TrackMapRenderer: def _draw_lines(self, image, zoom, start_x, start_y): coords = ( to_web_mercator(point.latitude, point.longitude, zoom) - for point in self.track.walk(only_points=True) + for point in self.track.points ) coords = [(x - start_x, y - start_y) for x, y in coords] @@ -120,10 +120,10 @@ class TrackMapRenderer: draw.line(coords, fill=self.color, width=self.line_width, joint="curve") -def render(track: GPX, layer: TileLayerConfig, requester: TileRequester) -> Image.Image: +def render(track: geo.Path, layer: TileLayerConfig, requester: TileRequester) -> Image.Image: """Shorthand to construct a :class:`TrackMapRenderer` and render the preview. - :param track: Parsed track to render. + :param track: Track to render. :param layer: The tile layer to take the map tiles from. :param requester: The requester which will be used to request the tiles. :return: The image containing the rendered preview. diff --git a/fietsboek/views/detail.py b/fietsboek/views/detail.py index e0ac87a..2f2d887 100644 --- a/fietsboek/views/detail.py +++ b/fietsboek/views/detail.py @@ -274,8 +274,7 @@ def track_map(request: Request): loader: ITileRequester = request.registry.getUtility(ITileRequester) layer = request.config.public_tile_layers()[0] - parsed_gpx = gpxpy.parse(manager.decompress_gpx()) - track_image = trackmap.render(parsed_gpx, layer, loader) + track_image = trackmap.render(track.path(), layer, loader) imageio = io.BytesIO() track_image.save(imageio, "png") -- cgit v1.2.3 From d29b5ba644773f94a12244cb47a85dc5aa7623a8 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Tue, 21 Oct 2025 21:24:53 +0200 Subject: take metadata from geo module --- fietsboek/actions.py | 2 +- fietsboek/models/track.py | 53 ++++++++++++++++++++-------------------------- fietsboek/util.py | 37 -------------------------------- fietsboek/views/browse.py | 2 +- fietsboek/views/default.py | 2 +- fietsboek/views/detail.py | 2 +- fietsboek/views/profile.py | 4 ++-- 7 files changed, 29 insertions(+), 73 deletions(-) diff --git a/fietsboek/actions.py b/fietsboek/actions.py index 0e2284b..f49283d 100644 --- a/fietsboek/actions.py +++ b/fietsboek/actions.py @@ -105,7 +105,7 @@ def add_track( # Best time to build the cache is right after the upload, but *after* the # transformers have been applied! - track.ensure_cache(gpx) + track.ensure_cache() dbsession.add(track.cache) LOGGER.debug("Building preview image for %s", track.id) diff --git a/fietsboek/models/track.py b/fietsboek/models/track.py index e9c09d6..7a7aff0 100644 --- a/fietsboek/models/track.py +++ b/fietsboek/models/track.py @@ -445,24 +445,21 @@ class Track(Base): result = ACLHelper().permits(self, principals, "track.view") return isinstance(result, ACLAllowed) - def ensure_cache(self, gpx_data: Union[str, bytes, gpxpy.gpx.GPX]): - """Ensure that a cached version of this track's metadata exists. - - :param gpx_data: GPX data (uncompressed) from which to build the cache. - """ + def ensure_cache(self): + """Ensure that a cached version of this track's metadata exists.""" if self.cache is not None: return self.cache = TrackCache(track=self) - meta = util.tour_metadata(gpx_data) - self.cache.length = meta["length"] - self.cache.uphill = meta["uphill"] - self.cache.downhill = meta["downhill"] - self.cache.moving_time = meta["moving_time"] - self.cache.stopped_time = meta["stopped_time"] - self.cache.max_speed = meta["max_speed"] - self.cache.avg_speed = meta["avg_speed"] - self.cache.start_time = meta["start_time"] - self.cache.end_time = meta["end_time"] + meta = self.path().movement_data() + self.cache.length = meta.length + self.cache.uphill = meta.uphill + self.cache.downhill = meta.downhill + self.cache.moving_time = meta.moving_duration + self.cache.stopped_time = meta.stopped_duration + self.cache.max_speed = meta.maximum_speed + self.cache.avg_speed = meta.average_speed + self.cache.start_time = self.date + self.cache.end_time = self.date + datetime.timedelta(seconds=meta.duration) def text_tags(self): """Returns a set of textual tags. @@ -542,7 +539,7 @@ class Track(Base): class TrackWithMetadata: """A class to add metadata to a :class:`Track`. - This basically caches the result of :func:`fietsboek.util.tour_metadata`, + This basically caches the result of :func:`fietsboek.geo.Path.movement_data`, or uses the track's cache if possible. Loading of the metadata is lazy on first access. The track is accessible as @@ -551,10 +548,9 @@ class TrackWithMetadata: # pylint: disable=too-many-public-methods - def __init__(self, track: Track, data_manager): + def __init__(self, track: Track): self.track = track self.cache = track.cache - self.data_manager = data_manager self._cached_meta: Optional[dict] = None def _meta(self): @@ -562,8 +558,7 @@ class TrackWithMetadata: if self._cached_meta: return self._cached_meta - data = self.data_manager.open(self.track.id).decompress_gpx() - self._cached_meta = util.tour_metadata(data) + self._cached_meta = self.track.path().movement_data() return self._cached_meta @property @@ -573,7 +568,7 @@ class TrackWithMetadata: :return: Length of the track in meters. """ if self.cache is None or self.cache.length is None: - return self._meta()["length"] + return self._meta().length return float(self.cache.length) @property @@ -583,7 +578,7 @@ class TrackWithMetadata: :return: Downhill in meters. """ if self.cache is None or self.cache.downhill is None: - return self._meta()["downhill"] + return self._meta().downhill return float(self.cache.downhill) @property @@ -593,7 +588,7 @@ class TrackWithMetadata: :return: Uphill in meters. """ if self.cache is None or self.cache.uphill is None: - return self._meta()["uphill"] + return self._meta().uphill return float(self.cache.uphill) @property @@ -615,7 +610,7 @@ class TrackWithMetadata: :return: Stopped time in seconds. """ if self.cache is None or self.cache.stopped_time is None: - value = self._meta()["stopped_time"] + value = self._meta().moving_duration else: value = self.cache.stopped_time return datetime.timedelta(seconds=value) @@ -627,7 +622,7 @@ class TrackWithMetadata: :return: Maximum speed in meters/second. """ if self.cache is None or self.cache.max_speed is None: - return self._meta()["max_speed"] + return self._meta().maximum_speed return float(self.cache.max_speed) @property @@ -637,7 +632,7 @@ class TrackWithMetadata: :return: Average speed in meters/second. """ if self.cache is None or self.cache.avg_speed is None: - return self._meta()["avg_speed"] + return self._meta().average_speed return float(self.cache.avg_speed) @property @@ -648,9 +643,7 @@ class TrackWithMetadata: :return: Start time. """ - if self.cache is None or self.cache.start_time is None: - return self._meta()["start_time"] - return self.cache.start_time + return self.track.date @property def end_time(self) -> datetime.datetime: @@ -661,7 +654,7 @@ class TrackWithMetadata: :return: End time. """ if self.cache is None or self.cache.end_time is None: - return self._meta()["end_time"] + return self.track.date + datetime.timedelta(seconds=self._meta().duration) return self.cache.end_time @property diff --git a/fietsboek/util.py b/fietsboek/util.py index 5611c51..27c333d 100644 --- a/fietsboek/util.py +++ b/fietsboek/util.py @@ -173,43 +173,6 @@ def guess_gpx_timezone(gpx: gpxpy.gpx.GPX) -> datetime.tzinfo: return datetime.timezone.utc -def tour_metadata(gpx_data: Union[str, bytes, gpxpy.gpx.GPX]) -> dict: - """Calculate the metadata of the tour. - - Returns a dict with ``length``, ``uphill``, ``downhill``, ``moving_time``, - ``stopped_time``, ``max_speed``, ``avg_speed``, ``start_time`` and - ``end_time``. - - :param gpx_data: The GPX data of the tour. Can be pre-parsed to save time. - :return: A dictionary with the computed values. - """ - if isinstance(gpx_data, bytes): - gpx_data = gpx_data.decode("utf-8") - if isinstance(gpx_data, gpxpy.gpx.GPX): - gpx = gpx_data - else: - gpx = gpxpy.parse(gpx_data) - timezone = guess_gpx_timezone(gpx) - uphill, downhill = gpx.get_uphill_downhill() - moving_data = gpx.get_moving_data() - time_bounds = gpx.get_time_bounds() - try: - avg_speed = moving_data.moving_distance / moving_data.moving_time - except ZeroDivisionError: - avg_speed = 0.0 - return { - "length": gpx.length_3d(), - "uphill": uphill, - "downhill": downhill, - "moving_time": moving_data.moving_time, - "stopped_time": moving_data.stopped_time, - "max_speed": moving_data.max_speed, - "avg_speed": avg_speed, - "start_time": (time_bounds.start_time or DEFAULT_START_TIME).astimezone(timezone), - "end_time": (time_bounds.end_time or DEFAULT_END_TIME).astimezone(timezone), - } - - def mps_to_kph(mps: float) -> float: """Converts meters/second to kilometers/hour. diff --git a/fietsboek/views/browse.py b/fietsboek/views/browse.py index e8e3edf..a9e9d2e 100644 --- a/fietsboek/views/browse.py +++ b/fietsboek/views/browse.py @@ -463,7 +463,7 @@ def paginate( break for track in tracks: - track = TrackWithMetadata(track, data_manager) + track = TrackWithMetadata(track) if filters.apply(track): num_retrieved += 1 yield track diff --git a/fietsboek/views/default.py b/fietsboek/views/default.py index 8a9718d..320d02d 100644 --- a/fietsboek/views/default.py +++ b/fietsboek/views/default.py @@ -61,7 +61,7 @@ def home(request: Request) -> Response: gpx_data = request.data_manager.open(track.id).decompress_gpx() track.ensure_cache(gpx_data) request.dbsession.add(track.cache) - summary.add(TrackWithMetadata(track, request.data_manager)) + summary.add(TrackWithMetadata(track)) unfinished_uploads = request.identity.uploads diff --git a/fietsboek/views/detail.py b/fietsboek/views/detail.py index 2f2d887..5fa3beb 100644 --- a/fietsboek/views/detail.py +++ b/fietsboek/views/detail.py @@ -107,7 +107,7 @@ def details(request): # Strip off the sort key again images = [(image[1], image[2]) for image in images] - with_meta = TrackWithMetadata(track, request.data_manager) + with_meta = TrackWithMetadata(track) return { "track": with_meta, "show_organic": track.show_organic_data(), diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py index 15bc46c..2e18c19 100644 --- a/fietsboek/views/profile.py +++ b/fietsboek/views/profile.py @@ -54,7 +54,7 @@ def profile_data(request: Request) -> dict: query = select(aliased(models.Track, query)).where(query.c.type == TrackType.ORGANIC) track: models.Track for track in request.dbsession.execute(query).scalars(): - meta = TrackWithMetadata(track, request.data_manager) + meta = TrackWithMetadata(track) total.add(meta) total.moving_time = util.round_to_seconds(total.moving_time) @@ -340,7 +340,7 @@ def json_summary(request: Request) -> Response: if track.cache is None: LOGGER.debug("Skipping track %d as it has no cached metadata", track.id) continue - summary.add(TrackWithMetadata(track, request.data_manager)) + summary.add(TrackWithMetadata(track)) return {y.year: {m.month: m.total_length for m in y} for y in summary} -- cgit v1.2.3 From 5dde1e7dc097ba2b8ad0e1ab588f827e6ee6ece1 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Tue, 21 Oct 2025 21:25:18 +0200 Subject: fix FIT upload --- fietsboek/convert.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fietsboek/convert.py b/fietsboek/convert.py index 2e8b5db..df08833 100644 --- a/fietsboek/convert.py +++ b/fietsboek/convert.py @@ -45,7 +45,7 @@ def from_fit(data: bytes) -> Track: latitude=semicircles_to_deg(values["position_lat"]), longitude=semicircles_to_deg(values["position_long"]), elevation=values["altitude"], - time_offset=time - start_time, + time_offset=(time - start_time).total_seconds(), ) except KeyError: pass -- cgit v1.2.3 From 9e92b48eee1bb505272e20edfb8f3bec733db471 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Tue, 21 Oct 2025 21:25:27 +0200 Subject: fix FIT files not having the date set --- fietsboek/convert.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fietsboek/convert.py b/fietsboek/convert.py index df08833..5115dd7 100644 --- a/fietsboek/convert.py +++ b/fietsboek/convert.py @@ -54,6 +54,7 @@ def from_fit(data: bytes) -> Track: path = geo.Path(points) track = Track() track.set_path(path) + track.date = start_time return track -- cgit v1.2.3 From 54f177a359a6ab62414ffe7e47cc8565600ac784 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Tue, 21 Oct 2025 23:11:58 +0200 Subject: make transformers work on geo.Path --- fietsboek/actions.py | 24 ++++++++--------- fietsboek/geo.py | 38 +++++++++++++++++++++++++- fietsboek/transformers/__init__.py | 8 +++--- fietsboek/transformers/breaks.py | 54 +++++++++---------------------------- fietsboek/transformers/elevation.py | 26 +++++++----------- 5 files changed, 77 insertions(+), 73 deletions(-) diff --git a/fietsboek/actions.py b/fietsboek/actions.py index f49283d..ea19f63 100644 --- a/fietsboek/actions.py +++ b/fietsboek/actions.py @@ -91,6 +91,7 @@ def add_track( # Save the GPX data LOGGER.debug("Creating a new data folder for %d", track.id) assert track.id is not None + path = track.path() with data_manager.initialize(track.id) as manager: LOGGER.debug("Saving backup to %s", manager.backup_path()) manager.compress_backup(gpx_data) @@ -98,11 +99,13 @@ def add_track( gpx = gpxpy.parse(track.gpx_xml()) for transformer in transformers: LOGGER.debug("Running %s with %r", transformer, transformer.parameters) - transformer.execute(gpx) + transformer.execute(path) track.transformers = [ [tfm.identifier(), tfm.parameters.model_dump()] for tfm in transformers ] + track.set_path(path) + # Best time to build the cache is right after the upload, but *after* the # transformers have been applied! track.ensure_cache() @@ -188,7 +191,7 @@ def edit_images(request: Request, track: models.Track, *, manager: Optional[Trac request.dbsession.add(image_meta) -def execute_transformers(request: Request, track: models.Track) -> Optional[gpxpy.gpx.GPX]: +def execute_transformers(request: Request, track: models.Track): """Execute the transformers for the given track. Note that this function "short circuits" if the saved transformer settings @@ -199,7 +202,6 @@ def execute_transformers(request: Request, track: models.Track) -> Optional[gpxp :param request: The request. :param track: The track. - :return: The transformed track. """ # pylint: disable=too-many-locals LOGGER.debug("Executing transformers for %d", track.id) @@ -209,22 +211,21 @@ def execute_transformers(request: Request, track: models.Track) -> Optional[gpxp serialized = [[tfm.identifier(), tfm.parameters.model_dump()] for tfm in settings] if serialized == track.transformers: LOGGER.debug("Applied transformations match on %d, skipping", track.id) - return None + return # We always start with the backup, that way we don't get "deepfried GPX" # files by having the same filters run multiple times on the same input. # They are not idempotent after all. manager = request.data_manager.open(track.id) - gpx_bytes = manager.backup_path().read_bytes() - gpx_bytes = brotli.decompress(gpx_bytes) - gpx = gpxpy.parse(gpx_bytes) + backup_bytes = manager.decompress_backup() + reloaded = convert.smart_convert(backup_bytes) + path = reloaded.path() for transformer in settings: LOGGER.debug("Running %s with %r", transformer, transformer.parameters) - transformer.execute(gpx) + transformer.execute(path) - LOGGER.debug("Saving transformed file for %d", track.id) - manager.compress_gpx(util.encode_gpx(gpx)) + track.set_path(path) LOGGER.debug("Saving new transformers on %d", track.id) track.transformers = serialized @@ -232,9 +233,8 @@ def execute_transformers(request: Request, track: models.Track) -> Optional[gpxp LOGGER.debug("Rebuilding cache for %d", track.id) request.dbsession.delete(track.cache) track.cache = None - track.ensure_cache(gpx) + track.ensure_cache() request.dbsession.add(track.cache) - return gpx def send_verification_token(request: Request, user: models.User): diff --git a/fietsboek/geo.py b/fietsboek/geo.py index 348a4b9..8b016c0 100644 --- a/fietsboek/geo.py +++ b/fietsboek/geo.py @@ -79,6 +79,39 @@ class Point: return 0.0 return sqrt(radicand) + def flat_distance(self, other: "Point") -> float: + """Returns the distance between this point and the other point in + meters. + + This does not take elevation into account, and only looks at the 2d distance. + """ + r = EARTH_RADIUS + # The formula assumes that 0° is straight upward, but 0° in geo + # coordinates is actually on the equator plane. + t_1 = radians(90 - self.latitude) + t_2 = radians(90 - other.latitude) + p_1 = radians(self.longitude) + p_2 = radians(other.longitude) + # See + # https://en.wikipedia.org/wiki/Spherical_coordinate_system#Distance_in_spherical_coordinates + # While this is not the Haversine formula for distances along the + # circle curvature, it allows us to take the elevation into account, + # and for most GPS point differences that we encounter it should be + # enough. + radicand = ( + 2 * r**2 * ( + 1 - + ( + sin(t_1) * sin(t_2) * cos(p_1 - p_2) + + cos(t_1) * cos(t_2) + ) + ) + ) + if radicand < 0.0: + return 0.0 + return sqrt(radicand) + + class Path: def __init__(self, points: list[Point]): @@ -93,7 +126,10 @@ class Path: for a, b in self._point_pairs(): distance = a.distance(b) time = b.time_offset - a.time_offset - speed = distance / time + if time != 0: + speed = distance / time + else: + speed = 0.0 elevation = b.elevation - a.elevation movement_data.length += distance diff --git a/fietsboek/transformers/__init__.py b/fietsboek/transformers/__init__.py index b1a0245..d9c533b 100644 --- a/fietsboek/transformers/__init__.py +++ b/fietsboek/transformers/__init__.py @@ -18,6 +18,8 @@ from pydantic import BaseModel from pyramid.i18n import TranslationString from pyramid.request import Request +from .. import geo + _ = TranslationString T = TypeVar("T", bound="Transformer") @@ -117,12 +119,12 @@ class Transformer(ABC): pass @abstractmethod - def execute(self, gpx: GPX): + def execute(self, path: geo.Path): """Run the transformation on the input gpx. - This is expected to modify the GPX object to represent the new state. + This is expected to modify the path to represent the new state. - :param gpx: The GPX object to transform. Note that this object will be + :param path: The path to transform. Note that this object will be mutated! """ diff --git a/fietsboek/transformers/breaks.py b/fietsboek/transformers/breaks.py index 789fdfd..e8af6de 100644 --- a/fietsboek/transformers/breaks.py +++ b/fietsboek/transformers/breaks.py @@ -6,6 +6,7 @@ from gpxpy.gpx import GPX, GPXTrack from pyramid.i18n import TranslationString from . import Parameters, Transformer +from .. import geo _ = TranslationString @@ -47,34 +48,25 @@ class RemoveBreaks(Transformer): def parameters(self, value): pass - def execute(self, gpx: GPX): - for track in gpx.tracks: - self._clean(track) - - def _clean(self, track: GPXTrack): - if not track.get_points_no(): + def execute(self, path: geo.Path): + if not path.points: return i = 0 - while i < track.get_points_no(): - segment_idx, point_idx = index(track, i) - point = track.segments[segment_idx].points[point_idx] + while i < len(path.points): + point = path.points[i] # We check if the following points constitute a break, and if yes, # how many of them count = 0 current_length = 0.0 last_point = point - while True: - try: - j_segment, j_point = index(track, i + count + 1) - except IndexError: - break - current_point = track.segments[j_segment].points[j_point] - current_length += last_point.distance_3d(current_point) or 0.0 + while i + count + 1 < len(path.points): + current_point = path.points[i + count + 1] + current_length += last_point.distance(current_point) or 0.0 last_point = current_point - delta_t = datetime.timedelta(seconds=point.time_difference(last_point) or 0.0) + delta_t = datetime.timedelta(seconds=last_point.time_offset - point.time_offset or 0.0) if not delta_t or current_length / delta_t.total_seconds() > STOPPED_SPEED_LIMIT: break count += 1 @@ -85,7 +77,7 @@ class RemoveBreaks(Transformer): continue # At this point, check if the break is long enough to be removed - delta_t = datetime.timedelta(seconds=point.time_difference(last_point) or 0.0) + delta_t = datetime.timedelta(seconds=last_point.time_offset - point.time_offset or 0.0) if delta_t < MIN_BREAK_TO_REMOVE: i += 1 continue @@ -93,32 +85,12 @@ class RemoveBreaks(Transformer): # Here, we have a proper break to remove # Delete the points belonging to the break ... for _ in range(count): - j_segment, j_point = index(track, i + 1) - del track.segments[j_segment].points[j_point] + del path.points[i + 1] # ... and shift the time of the following points j = i + 1 - while j < track.get_points_no(): - j_segment, j_point = index(track, j) - track.segments[j_segment].points[j_point].adjust_time(-delta_t) - j += 1 - - -def index(track: GPXTrack, idx: int) -> tuple[int, int]: - """Takes a one-dimensional index (the point index) and returns an index - into the segment/segment points. - - :raises IndexError: When the given index is out of bounds. - :param track: The track for which to get the index. - :param idx: The "1D" index. - :return: A tuple with the segment index, and the index of the point within - the segment. - """ - for segment_idx, segment in enumerate(track.segments): - if idx < len(segment.points): - return (segment_idx, idx) - idx -= len(segment.points) - raise IndexError + for j_point in path.points[j:]: + j_point.time_offset -= delta_t.total_seconds() __all__ = ["RemoveBreaks"] diff --git a/fietsboek/transformers/elevation.py b/fietsboek/transformers/elevation.py index e1f7c7c..52e6d6f 100644 --- a/fietsboek/transformers/elevation.py +++ b/fietsboek/transformers/elevation.py @@ -7,13 +7,14 @@ from gpxpy.gpx import GPX, GPXTrackPoint from pyramid.i18n import TranslationString from . import Parameters, Transformer +from .. import geo _ = TranslationString MAX_ORGANIC_SLOPE: float = 1.0 -def slope(point_a: GPXTrackPoint, point_b: GPXTrackPoint) -> float: +def slope(point_a: geo.Point, point_b: geo.Point) -> float: """Returns the slope between two GPX points. This is defined as delta_h / euclid_distance. @@ -25,7 +26,7 @@ def slope(point_a: GPXTrackPoint, point_b: GPXTrackPoint) -> float: if point_a.elevation is None or point_b.elevation is None: return 0.0 delta_h = abs(point_a.elevation - point_b.elevation) - dist = point_a.distance_2d(point_b) + dist = point_a.flat_distance(point_b) if dist == 0.0 or dist is None: return 0.0 return delta_h / dist @@ -58,19 +59,12 @@ class FixNullElevation(Transformer): def parameters(self, value): pass - def execute(self, gpx: GPX): + def execute(self, path: geo.Path): def all_points(): - return gpx.walk(only_points=True) + return iter(path.points) def rev_points(): - # We cannot use reversed(gpx.walk(...)) since that is not a - # generator, so we do it manually. - return ( - point - for track in reversed(gpx.tracks) - for segment in reversed(track.segments) - for point in reversed(segment.points) - ) + return reversed(path.points) # First, from the front self.fixup(all_points) @@ -78,7 +72,7 @@ class FixNullElevation(Transformer): self.fixup(rev_points) @classmethod - def fixup(cls, points: Callable[[], Iterable[GPXTrackPoint]]): + def fixup(cls, points: Callable[[], Iterable[geo.Point]]): """Fixes the given GPX points. This iterates over the points and checks for the first point that has a @@ -131,11 +125,11 @@ class FixElevationJumps(Transformer): def parameters(self, value): pass - def execute(self, gpx: GPX): + def execute(self, path: geo.Path): current_adjustment = 0.0 - points = gpx.walk(only_points=True) - next_points = gpx.walk(only_points=True) + points = iter(path.points) + next_points = iter(path.points) for current_point, next_point in zip_longest(points, islice(next_points, 1, None)): point_adjustment = current_adjustment -- cgit v1.2.3 From 13032fc559f57eef32c0b8ae8ce6baf7aff0594c Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Tue, 21 Oct 2025 23:18:46 +0200 Subject: fix track editing --- fietsboek/views/edit.py | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/fietsboek/views/edit.py b/fietsboek/views/edit.py index 3daaacd..09c47f5 100644 --- a/fietsboek/views/edit.py +++ b/fietsboek/views/edit.py @@ -93,15 +93,13 @@ def do_edit(request): pass else: LOGGER.info("Setting new track for %s", track.id) - gpx_bytes = convert.smart_convert(gpx_bytes) - try: - gpxpy.parse(gpx_bytes) - except Exception as exc: + new_track = convert.smart_convert(gpx_bytes) + if new_track is None: request.session.flash(request.localizer.translate(_("flash.invalid_file"))) LOGGER.info("Could not parse updated gpx: %s", exc) return HTTPFound(request.route_url("edit", track_id=track.id)) - data.compress_gpx(gpx_bytes) - data.backup() + data.compress_backup(gpx_bytes) + track.set_path(new_track.path()) track.transformers = [] redo_cache = True @@ -117,21 +115,14 @@ def do_edit(request): track.sync_tags(tags) actions.edit_images(request, request.context, manager=data) - gpx = actions.execute_transformers(request, request.context) - data.engrave_metadata( - title=track.title, - description=track.description, - author_name=track.owner.name, - time=track.date, - gpx=gpx, - ) + actions.execute_transformers(request, request.context) # actions.execute_transformers automatically rebuilds the cache, so we only need to do # this if execute_transformers didn't do it - if redo_cache and gpx is None: + if redo_cache: LOGGER.info("New file detected, rebuilding cache for %s", track.id) track.cache = None - track.ensure_cache(gpx_bytes) + track.ensure_cache() request.dbsession.add(track.cache) return HTTPFound(request.route_url("details", track_id=track.id)) -- cgit v1.2.3 From 9dfe8c6282a38805ceb11ac9b9bbc848955ab785 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Tue, 21 Oct 2025 23:23:57 +0200 Subject: remove superfluous print statement --- fietsboek/transformers/elevation.py | 1 - 1 file changed, 1 deletion(-) diff --git a/fietsboek/transformers/elevation.py b/fietsboek/transformers/elevation.py index 52e6d6f..d5ecd1b 100644 --- a/fietsboek/transformers/elevation.py +++ b/fietsboek/transformers/elevation.py @@ -135,7 +135,6 @@ class FixElevationJumps(Transformer): point_adjustment = current_adjustment if next_point and slope(current_point, next_point) > MAX_ORGANIC_SLOPE: current_adjustment += current_point.elevation - next_point.elevation - print(f"{current_adjustment=}") current_point.elevation += point_adjustment -- cgit v1.2.3 From cb889cacd3e362e0bfe5ea2e241706777331de94 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Mon, 27 Oct 2025 21:04:40 +0100 Subject: add waypoints to database --- .../alembic/versions/20251019_90b39fdf6e4b.py | 12 ++++++++++ fietsboek/convert.py | 21 ++++++++++++++++- fietsboek/models/__init__.py | 2 +- fietsboek/models/track.py | 27 ++++++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/fietsboek/alembic/versions/20251019_90b39fdf6e4b.py b/fietsboek/alembic/versions/20251019_90b39fdf6e4b.py index abc43fe..1213ebf 100644 --- a/fietsboek/alembic/versions/20251019_90b39fdf6e4b.py +++ b/fietsboek/alembic/versions/20251019_90b39fdf6e4b.py @@ -27,9 +27,21 @@ def upgrade(): 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')) ) + op.create_table('waypoints', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('track_id', 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('name', sa.Text(), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['track_id'], ['tracks.id'], name=op.f('fk_waypoints_track_id_tracks')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_waypoints')) + ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('track_points') + op.drop_table('waypoints') # ### end Alembic commands ### diff --git a/fietsboek/convert.py b/fietsboek/convert.py index 5115dd7..04c7e73 100644 --- a/fietsboek/convert.py +++ b/fietsboek/convert.py @@ -6,7 +6,7 @@ import fitparse import gpxpy from . import geo, util -from .models import Track +from .models import Track, Waypoint FIT_RECORD_FIELDS = ["position_lat", "position_long", "altitude", "timestamp"] @@ -103,6 +103,25 @@ def from_gpx(data: bytes) -> Track: track.title = track_name track.description = track_desc track.date = date + + for waypoint in gpx.waypoints: + desc = None + # GPX waypoints can have both description and comment. It seems like + # comment is what is usually used (GPXViewer only shows the comment), + # so we'll prioritize that. + if waypoint.comment: + desc = waypoint.comment + if not desc and waypoint.description: + desc = waypoint.description + wpt = Waypoint( + longitude=waypoint.longitude, + latitude=waypoint.latitude, + elevation=waypoint.elevation, + name=waypoint.name, + description=desc, + ) + track.waypoints.append(wpt) + return track diff --git a/fietsboek/models/__init__.py b/fietsboek/models/__init__.py index 6f91eae..c70fee1 100644 --- a/fietsboek/models/__init__.py +++ b/fietsboek/models/__init__.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import configure_mappers, sessionmaker 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 # flake8: noqa +from .track import Tag, Track, TrackCache, Upload, Waypoint # flake8: noqa # 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/track.py b/fietsboek/models/track.py index 7a7aff0..33bbe3e 100644 --- a/fietsboek/models/track.py +++ b/fietsboek/models/track.py @@ -152,6 +152,19 @@ track_favourite_assoc = Table( ) +class Waypoint(Base): + __tablename__ = "waypoints" + id = Column(Integer, primary_key=True) + track_id = Column(Integer, ForeignKey("tracks.id"), nullable=False) + longitude = Column(Float) + latitude = Column(Float) + elevation = Column(Float) + name = Column(Text) + description = Column(Text) + + track: Mapped["Track"] = relationship("Track", back_populates="waypoints") + + class TrackPoint(Base): __tablename__ = "track_points" track_id = Column(Integer, ForeignKey("tracks.id"), primary_key=True) @@ -259,6 +272,9 @@ class Track(Base): points: Mapped[list["TrackPoint"]] = relationship( "TrackPoint", back_populates="track", cascade="all, delete-orphan", ) + waypoints: Mapped[list["Waypoint"]] = relationship( + "Waypoint", back_populates="track", cascade="all, delete-orphan", + ) cache: Mapped[Optional["TrackCache"]] = relationship( "TrackCache", back_populates="track", uselist=False, cascade="all, delete-orphan" ) @@ -385,6 +401,17 @@ class Track(Base): track = gpxpy.gpx.GPXTrack() track.segments.append(segment) gpx.tracks.append(track) + for wpt in self.waypoints: + gpx.waypoints.append( + gpxpy.gpx.GPXWaypoint( + longitude=wpt.longitude, + latitude=wpt.latitude, + elevation=wpt.elevation, + name=wpt.name, + comment=wpt.description, + description=wpt.description, + ) + ) return gpx.to_xml(prettyprint=False).encode("utf-8") @property -- cgit v1.2.3 From 96ff51dc494bcddf824a87113a184c7ce204a5c9 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Mon, 27 Oct 2025 21:05:09 +0100 Subject: fix alembic scripts in alembic version Basically, we make is_sqlite/is_postgres lazy, as the connection might not be bound yet in commands like `alembic history`. However, in those commands, upgrade/downgrade is not called, so it doesn't matter. --- fietsboek/alembic/versions/20220808_d085998b49ca.py | 6 +++--- fietsboek/alembic/versions/20250607_2ebe1bf66430.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/fietsboek/alembic/versions/20220808_d085998b49ca.py b/fietsboek/alembic/versions/20220808_d085998b49ca.py index 2c5b71d..5b47668 100644 --- a/fietsboek/alembic/versions/20220808_d085998b49ca.py +++ b/fietsboek/alembic/versions/20220808_d085998b49ca.py @@ -14,10 +14,10 @@ down_revision = '091ce24409fe' branch_labels = None depends_on = None -is_postgres = op.get_bind().dialect.name == "postgresql" +is_postgres = lambda: op.get_bind().dialect.name == "postgresql" def upgrade(): - if is_postgres: + if is_postgres(): tracktype = sa.dialects.postgresql.ENUM("ORGANIC", "SYNTHETIC", name="tracktype") tracktype.create(op.get_bind()) op.add_column("tracks", sa.Column("type", tracktype, nullable=True)) @@ -27,5 +27,5 @@ def upgrade(): def downgrade(): op.drop_column('tracks', 'type') - if is_postgres: + if is_postgres(): op.execute("DROP TYPE tracktype;") diff --git a/fietsboek/alembic/versions/20250607_2ebe1bf66430.py b/fietsboek/alembic/versions/20250607_2ebe1bf66430.py index d7c811e..d1c2c2f 100644 --- a/fietsboek/alembic/versions/20250607_2ebe1bf66430.py +++ b/fietsboek/alembic/versions/20250607_2ebe1bf66430.py @@ -16,10 +16,10 @@ down_revision = '4566843039d6' branch_labels = None depends_on = None -is_sqlite = op.get_bind().dialect.name == "sqlite" +is_sqlite = lambda: op.get_bind().dialect.name == "sqlite" def upgrade(): - if is_sqlite: + if is_sqlite(): op.add_column('tracks', sa.Column('transformers_text', sa.Text, nullable=True)) op.execute('UPDATE tracks SET transformers_text=transformers;') try: @@ -37,7 +37,7 @@ def upgrade(): op.alter_column('tracks', 'transformers', type_=sa.Text) def downgrade(): - if is_sqlite: + if is_sqlite(): op.add_column('tracks', sa.Column('transfomers_json', sa.JSON, nullable=True)) op.execute('UPDATE tracks SET transformers_json=transformers;') op.drop_column('tracks', 'transformers') -- cgit v1.2.3 From 4f598169e1d66dab8dde4613d28d18af72db2962 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Tue, 28 Oct 2025 21:15:25 +0100 Subject: clean up unused imports --- fietsboek/actions.py | 5 +---- fietsboek/data.py | 2 -- fietsboek/models/track.py | 2 +- fietsboek/transformers/breaks.py | 5 +++-- fietsboek/transformers/elevation.py | 1 - fietsboek/util.py | 3 +-- fietsboek/views/browse.py | 3 --- fietsboek/views/detail.py | 2 -- fietsboek/views/edit.py | 2 -- fietsboek/views/profile.py | 2 +- fietsboek/views/upload.py | 1 - 11 files changed, 7 insertions(+), 21 deletions(-) diff --git a/fietsboek/actions.py b/fietsboek/actions.py index ea19f63..b4bced3 100644 --- a/fietsboek/actions.py +++ b/fietsboek/actions.py @@ -12,14 +12,12 @@ import logging import re from typing import Optional -import brotli -import gpxpy from pyramid.i18n import TranslationString as _ from pyramid.request import Request from sqlalchemy import select from sqlalchemy.orm.session import Session -from . import convert, email, geo, models, trackmap +from . import convert, email, models, trackmap from . import transformers as mod_transformers from . import util from .config import TileLayerConfig @@ -96,7 +94,6 @@ def add_track( LOGGER.debug("Saving backup to %s", manager.backup_path()) manager.compress_backup(gpx_data) - gpx = gpxpy.parse(track.gpx_xml()) for transformer in transformers: LOGGER.debug("Running %s with %r", transformer, transformer.parameters) transformer.execute(path) diff --git a/fietsboek/data.py b/fietsboek/data.py index c7fa7f4..6b690d8 100644 --- a/fietsboek/data.py +++ b/fietsboek/data.py @@ -7,7 +7,6 @@ the database itself. This module makes access to such data objects easier. # We don't have onexc yet in all supported versions, so let's ignore the # deprecation for now and stick with onerror: # pylint: disable=deprecated-argument -import datetime import logging import random import shutil @@ -17,7 +16,6 @@ from pathlib import Path from typing import BinaryIO, Literal, Optional import brotli -import gpxpy from filelock import FileLock from . import util diff --git a/fietsboek/models/track.py b/fietsboek/models/track.py index 33bbe3e..0162f39 100644 --- a/fietsboek/models/track.py +++ b/fietsboek/models/track.py @@ -18,7 +18,7 @@ import gzip import json import logging from itertools import chain -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING, Optional import gpxpy import sqlalchemy.types diff --git a/fietsboek/transformers/breaks.py b/fietsboek/transformers/breaks.py index e8af6de..f1279a0 100644 --- a/fietsboek/transformers/breaks.py +++ b/fietsboek/transformers/breaks.py @@ -2,7 +2,6 @@ import datetime -from gpxpy.gpx import GPX, GPXTrack from pyramid.i18n import TranslationString from . import Parameters, Transformer @@ -66,7 +65,9 @@ class RemoveBreaks(Transformer): current_length += last_point.distance(current_point) or 0.0 last_point = current_point - delta_t = datetime.timedelta(seconds=last_point.time_offset - point.time_offset or 0.0) + delta_t = datetime.timedelta( + seconds=last_point.time_offset - point.time_offset or 0.0 + ) if not delta_t or current_length / delta_t.total_seconds() > STOPPED_SPEED_LIMIT: break count += 1 diff --git a/fietsboek/transformers/elevation.py b/fietsboek/transformers/elevation.py index d5ecd1b..25b4a50 100644 --- a/fietsboek/transformers/elevation.py +++ b/fietsboek/transformers/elevation.py @@ -3,7 +3,6 @@ from collections.abc import Callable, Iterable from itertools import islice, zip_longest -from gpxpy.gpx import GPX, GPXTrackPoint from pyramid.i18n import TranslationString from . import Parameters, Transformer diff --git a/fietsboek/util.py b/fietsboek/util.py index 27c333d..156b7d4 100644 --- a/fietsboek/util.py +++ b/fietsboek/util.py @@ -8,7 +8,7 @@ import re import secrets import unicodedata from pathlib import Path -from typing import Optional, TypeVar, Union +from typing import Optional, TypeVar import babel import gpxpy @@ -491,7 +491,6 @@ __all__ = [ "round_timedelta_to_multiple", "round_to_seconds", "guess_gpx_timezone", - "tour_metadata", "mps_to_kph", "human_size", "month_name", diff --git a/fietsboek/views/browse.py b/fietsboek/views/browse.py index a9e9d2e..a01c015 100644 --- a/fietsboek/views/browse.py +++ b/fietsboek/views/browse.py @@ -428,7 +428,6 @@ def apply_order(query: Select, track: AliasedTrack, order: ResultOrder) -> Selec def paginate( dbsession: Session, - data_manager: DataManager, query: Select, filters: Filter, start: int, @@ -440,7 +439,6 @@ def paginate( elements if the filters end up throwing tracks out. :param dbsession: The current database session. - :param data_manager: The current data manager. :param query: The (filtered and ordered) query. :param filters: The filters to apply after retrieving elements from the database. @@ -498,7 +496,6 @@ def browse(request: Request) -> Response: tracks = list( paginate( request.dbsession, - request.data_manager, query, filters, (page - 1) * TRACKS_PER_PAGE, diff --git a/fietsboek/views/detail.py b/fietsboek/views/detail.py index 5fa3beb..8ca7836 100644 --- a/fietsboek/views/detail.py +++ b/fietsboek/views/detail.py @@ -7,11 +7,9 @@ import logging from html.parser import HTMLParser import brotli -import gpxpy from markupsafe import Markup from pyramid.httpexceptions import ( HTTPFound, - HTTPInternalServerError, HTTPNotAcceptable, HTTPNotFound, ) diff --git a/fietsboek/views/edit.py b/fietsboek/views/edit.py index 09c47f5..4e7b1f5 100644 --- a/fietsboek/views/edit.py +++ b/fietsboek/views/edit.py @@ -4,7 +4,6 @@ import datetime import logging from collections import namedtuple -import gpxpy from pyramid.httpexceptions import HTTPBadRequest, HTTPFound from pyramid.i18n import TranslationString as _ from pyramid.view import view_config @@ -96,7 +95,6 @@ def do_edit(request): new_track = convert.smart_convert(gpx_bytes) if new_track is None: request.session.flash(request.localizer.translate(_("flash.invalid_file"))) - LOGGER.info("Could not parse updated gpx: %s", exc) return HTTPFound(request.route_url("edit", track_id=track.id)) data.compress_backup(gpx_bytes) track.set_path(new_track.path()) diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py index 2e18c19..544373b 100644 --- a/fietsboek/views/profile.py +++ b/fietsboek/views/profile.py @@ -223,7 +223,7 @@ def calendar_rows( query = user.all_tracks_query() query = select(aliased(models.Track, query)).where(query.c.type == TrackType.ORGANIC) tracks = [ - TrackWithMetadata(track, data_manager) for track in dbsession.execute(query).scalars() + TrackWithMetadata(track) for track in dbsession.execute(query).scalars() ] # Step 2: Build the calendar diff --git a/fietsboek/views/upload.py b/fietsboek/views/upload.py index 84eb95f..5c86b29 100644 --- a/fietsboek/views/upload.py +++ b/fietsboek/views/upload.py @@ -3,7 +3,6 @@ import datetime import logging -import gpxpy from pyramid.httpexceptions import HTTPBadRequest, HTTPFound from pyramid.i18n import TranslationString as _ from pyramid.response import Response -- cgit v1.2.3 From f39826b7a2b7becc54dbcee9c1b2743d2e050b39 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Tue, 28 Oct 2025 21:19:19 +0100 Subject: add docstrings to geo.py --- fietsboek/geo.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/fietsboek/geo.py b/fietsboek/geo.py index 8b016c0..7118690 100644 --- a/fietsboek/geo.py +++ b/fietsboek/geo.py @@ -1,9 +1,8 @@ +"""This module implements GPS related functionality.""" from dataclasses import dataclass from itertools import islice from math import sqrt, sin, cos, radians -import gpxpy - # WGS-84 equatorial radius, also called the semi-major axis. # https://en.wikipedia.org/wiki/Earth_radius @@ -17,6 +16,9 @@ MOVING_THRESHOLD = 1.1 @dataclass class MovementData: + """Movement statistics for a path.""" + # pylint: disable=too-many-instance-attributes + duration: float = 0.0 """Duration of the path, in seconds.""" @@ -44,6 +46,7 @@ class MovementData: @dataclass(slots=True) class Point: + """A GPS point, represented as longitude/latitude/elevation.""" longitude: float latitude: float elevation: float @@ -114,6 +117,9 @@ class Point: class Path: + """A GPS path, that is a series of GPS points.""" + # pylint: disable=too-few-public-methods + def __init__(self, points: list[Point]): self.points = points -- cgit v1.2.3 From 3ea5482b476934ec53cb3a1ca86b3ce9bbedb2d0 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Tue, 28 Oct 2025 21:22:56 +0100 Subject: add docstrings to models/track.py --- fietsboek/models/track.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/fietsboek/models/track.py b/fietsboek/models/track.py index 0162f39..ed20a07 100644 --- a/fietsboek/models/track.py +++ b/fietsboek/models/track.py @@ -153,6 +153,12 @@ track_favourite_assoc = Table( class Waypoint(Base): + """A waypoint represents a "point of interest" along a path. + + Waypoints can have a name and description set. They exist outside of the + actual route. + """ + # pylint: disable=too-few-public-methods __tablename__ = "waypoints" id = Column(Integer, primary_key=True) track_id = Column(Integer, ForeignKey("tracks.id"), nullable=False) @@ -166,6 +172,8 @@ class Waypoint(Base): class TrackPoint(Base): + """A track point represents a single GPS point along a path.""" + # pylint: disable=too-few-public-methods __tablename__ = "track_points" track_id = Column(Integer, ForeignKey("tracks.id"), primary_key=True) index = Column(Integer, primary_key=True) @@ -177,6 +185,10 @@ class TrackPoint(Base): track: Mapped["Track"] = relationship("Track", back_populates="points") def to_geo_point(self) -> geo.Point: + """Converts this point (a database object) to a plain point. + + :return: The converted point. + """ return geo.Point( latitude=self.latitude, longitude=self.longitude, -- cgit v1.2.3 From 04d1d18ec666525890398f196b6a148179c7d305 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Tue, 28 Oct 2025 21:31:23 +0100 Subject: have hittekaart use the tracks from the database --- fietsboek/hittekaart.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/fietsboek/hittekaart.py b/fietsboek/hittekaart.py index 3b0c103..005fcf2 100644 --- a/fietsboek/hittekaart.py +++ b/fietsboek/hittekaart.py @@ -17,17 +17,12 @@ from sqlalchemy import select from sqlalchemy.orm import aliased from sqlalchemy.orm.session import Session -from . import models +from . import geo, models from .data import DataManager from .models.track import TrackType LOGGER = logging.getLogger(__name__) -COMPRESSION_MAP = { - ".br": "brotli", - ".gz": "gzip", -} - TILEHUNTER_ZOOM = 14 @@ -45,7 +40,7 @@ class Mode(enum.Enum): def generate( output: Path, mode: Mode, - input_files: list[Path], + input_files: list[geo.Path], *, threads: int = 0, ): @@ -54,7 +49,7 @@ def generate( :param output: Output filename. Note that this function always uses the sqlite output mode. :param mode: What to generate. - :param input_files: List of paths to the input files. + :param input_files: List of input paths. :param threads: Number of threads that ``hittekaart`` should use. Defaults to 0, which uses all available cores. """ @@ -74,7 +69,9 @@ def generate( LOGGER.debug("Loading tracks ...") tracks = [ - hittekaart_py.Track.from_file(bytes(input_file), COMPRESSION_MAP.get(input_file.suffix)) + hittekaart_py.Track.from_coordinates( + [(point.longitude, point.latitude) for point in input_file.points] + ) for input_file in input_files ] LOGGER.debug("Tracks loaded!") @@ -128,8 +125,7 @@ def generate_for( for track in dbsession.execute(query).scalars(): if track.id is None: continue - path = data_manager.open(track.id).gpx_path() - input_paths.append(path) + input_paths.append(track.path()) if not input_paths: return -- cgit v1.2.3 From cea67730a25376470dc34177865d09f319afcc08 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 1 Nov 2025 13:58:39 +0100 Subject: fix archive download --- fietsboek/views/browse.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/fietsboek/views/browse.py b/fietsboek/views/browse.py index a01c015..c024828 100644 --- a/fietsboek/views/browse.py +++ b/fietsboek/views/browse.py @@ -534,12 +534,18 @@ def archive(request: Request) -> Response: if not track.is_visible_to(request.identity): return HTTPForbidden() + # Since we stream the data, we need to ensure it's loaded before we close + # the session + for track in tracks: + request.dbsession.refresh(track, ["points", "waypoints"]) + request.dbsession.expunge(track) + def generate(): stream = Stream() with ZipFile(stream, "w", ZIP_DEFLATED) as zipfile: # type: ignore - for track_id in track_ids: - data = request.data_manager.open(track_id).decompress_gpx() - zipfile.writestr(f"track_{track_id}.gpx", data) + for track in tracks: + data = track.gpx_xml() + zipfile.writestr(f"track_{track.id}.gpx", data) yield stream.readall() yield stream.readall() -- cgit v1.2.3 From f5b6255f19433ac6db62658f24da73f62192c810 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 1 Nov 2025 14:09:25 +0100 Subject: don't choke if no GPX timestamps are set This usually happens in synthetic tracks, which we still want to load correctly. --- fietsboek/convert.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/fietsboek/convert.py b/fietsboek/convert.py index 04c7e73..7815ef4 100644 --- a/fietsboek/convert.py +++ b/fietsboek/convert.py @@ -78,7 +78,10 @@ def from_gpx(data: bytes) -> Track: if start_time is None: start_time = point.time - time_offset = (point.time - start_time).total_seconds() + if point.time is not None and start_time is not None: + time_offset = (point.time - start_time).total_seconds() + else: + time_offset = 0 points.append(geo.Point( longitude=point.longitude, latitude=point.latitude, -- cgit v1.2.3 From a32babc70accef5121b1e588523663628d2dfdeb Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 1 Nov 2025 14:13:41 +0100 Subject: don't choke if no timestamp or elevation is given This is a continuation of the previous commit, useful for synthetic tracks. We don't really care about the value, it shouldn't be shown anyway. --- fietsboek/convert.py | 2 +- fietsboek/geo.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/fietsboek/convert.py b/fietsboek/convert.py index 7815ef4..d0d1a19 100644 --- a/fietsboek/convert.py +++ b/fietsboek/convert.py @@ -85,7 +85,7 @@ def from_gpx(data: bytes) -> Track: points.append(geo.Point( longitude=point.longitude, latitude=point.latitude, - elevation=point.elevation, + elevation=point.elevation or 0.0, time_offset=time_offset, )) diff --git a/fietsboek/geo.py b/fietsboek/geo.py index 7118690..51bfff0 100644 --- a/fietsboek/geo.py +++ b/fietsboek/geo.py @@ -149,5 +149,9 @@ class Path: else: movement_data.downhill += -elevation movement_data.duration = b.time_offset - movement_data.average_speed = movement_data.length / movement_data.moving_duration + + if movement_data.moving_duration > 0: + movement_data.average_speed = movement_data.length / movement_data.moving_duration + else: + movement_data.average_speed = 0.0 return movement_data -- cgit v1.2.3 From 04f6e55ef9facf367a06839dfffd58230cf88d7f Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 1 Nov 2025 14:48:15 +0100 Subject: fix tests With the values being computed ourselves, they differ a bit from the previous gpxpy values. Therefore, we adjust the expected values in the tests. --- tests/integration/test_browse.py | 12 ++++++++---- tests/playwright/test_transformers.py | 19 ++++++++++--------- tests/unit/test_util.py | 13 ------------- 3 files changed, 18 insertions(+), 26 deletions(-) diff --git a/tests/integration/test_browse.py b/tests/integration/test_browse.py index 83218cc..4b38ddf 100644 --- a/tests/integration/test_browse.py +++ b/tests/integration/test_browse.py @@ -4,7 +4,7 @@ from contextlib import contextmanager from datetime import datetime from testutils import load_gpx_asset -from fietsboek import models +from fietsboek import convert, models from fietsboek.models.track import Visibility @@ -34,9 +34,10 @@ def added_tracks(tm, dbsession, owner, data_manager): tagged_people=[], ) track.date = datetime(2022, 3, 14, 9, 26, 54) + track.set_path(convert.smart_convert(load_gpx_asset("MyTourbook_1.gpx.gz")).path()) dbsession.add(track) dbsession.flush() - data_manager.initialize(track.id).compress_gpx(load_gpx_asset("MyTourbook_1.gpx.gz")) + data_manager.initialize(track.id) tracks.append(track) track_ids.append(track.id) @@ -50,9 +51,10 @@ def added_tracks(tm, dbsession, owner, data_manager): tagged_people=[], ) track.date = datetime(2022, 10, 29, 13, 37, 11) + track.set_path(convert.smart_convert(load_gpx_asset("Teasi_1.gpx.gz")).path()) dbsession.add(track) dbsession.flush() - data_manager.initialize(track.id).compress_gpx(load_gpx_asset("Teasi_1.gpx.gz")) + data_manager.initialize(track.id) tracks.append(track) track_ids.append(track.id) @@ -84,6 +86,7 @@ def a_lot_of_tracks(tm, dbsession, owner, data_manager): tm.abort() gpx_data = load_gpx_asset("MyTourbook_1.gpx.gz") + skel = convert.smart_convert(gpx_data) tracks = [] track_ids = [] @@ -99,9 +102,10 @@ def a_lot_of_tracks(tm, dbsession, owner, data_manager): tagged_people=[], ) track.date = datetime(2022 - index, 3, 14, 9, 26, 59) + track.set_path(skel.path()) dbsession.add(track) dbsession.flush() - data_manager.initialize(track.id).compress_gpx(gpx_data) + data_manager.initialize(track.id) tracks.append(track) track_ids.append(track.id) diff --git a/tests/playwright/test_transformers.py b/tests/playwright/test_transformers.py index fc89afb..d4e3456 100644 --- a/tests/playwright/test_transformers.py +++ b/tests/playwright/test_transformers.py @@ -26,7 +26,7 @@ def test_transformer_zero_elevation_disabled(page: Page, playwright_helper, tmp_ # Expect early (here and in the other tests) to ensure that the backend has # caught up with executing the transformer. Otherwise it might happen that # we read the database while the request is not finished yet. - expect(page.locator("#detailsUphill")).to_contain_text("167.7 m") + expect(page.locator("#detailsUphill")).to_contain_text("167.79 m") new_track_id = int(page.url.rsplit("/", 1)[1]) track = dbaccess.execute(select(models.Track).filter_by(id=new_track_id)).scalar_one() @@ -90,7 +90,7 @@ def test_transformer_steep_slope_disabled(page: Page, playwright_helper, tmp_pat page.locator(".btn", has_text="Upload").click() - expect(page.locator("#detailsUphill")).to_contain_text("61.54 m") + expect(page.locator("#detailsUphill")).to_contain_text("64.4 m") new_track_id = int(page.url.rsplit("/", 1)[1]) track = dbaccess.execute(select(models.Track).filter_by(id=new_track_id)).scalar_one() @@ -111,11 +111,11 @@ def test_transformer_steep_slope_enabled(page: Page, playwright_helper, tmp_path page.locator(".btn", has_text="Upload").click() - expect(page.locator("#detailsUphill")).to_contain_text("1.2 m") + expect(page.locator("#detailsUphill")).to_contain_text("2.4 m") new_track_id = int(page.url.rsplit("/", 1)[1]) track = dbaccess.execute(select(models.Track).filter_by(id=new_track_id)).scalar_one() - assert track.cache.uphill < 2 + assert track.cache.uphill < 3 def test_transformer_steep_slope_edited(page: Page, playwright_helper, tmp_path, dbaccess): @@ -137,14 +137,14 @@ def test_transformer_steep_slope_edited(page: Page, playwright_helper, tmp_path, page.locator(".btn", has_text="Save").click() - expect(page.locator("#detailsUphill")).to_contain_text("1.2 m") + expect(page.locator("#detailsUphill")).to_contain_text("2.4 m") track_id = int(page.url.rsplit("/", 1)[1]) track = dbaccess.execute(select(models.Track).filter_by(id=track_id)).scalar_one() - assert track.cache.uphill < 2 + assert track.cache.uphill < 3 -def test_transformer_elevation_jump_enabled(page: Page, playwright_helper, tmp_path, data_manager): +def test_transformer_elevation_jump_enabled(page: Page, playwright_helper, tmp_path, dbaccess): playwright_helper.login() page.goto("/") @@ -161,9 +161,10 @@ def test_transformer_elevation_jump_enabled(page: Page, playwright_helper, tmp_p page.locator(".alert", has_text="Upload successful").wait_for() new_track_id = int(page.url.rsplit("/", 1)[1]) - data = data_manager.open(new_track_id) - gpx = gpxpy.parse(data.decompress_gpx()) + gpx = gpxpy.parse( + dbaccess.execute(select(models.Track).filter_by(id=new_track_id)).scalar_one().gpx_xml() + ) points = iter(gpx.walk(only_points=True)) next(points) for prev_point, point in zip(gpx.walk(only_points=True), points): diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py index 6dc8e7d..0ecfdb2 100644 --- a/tests/unit/test_util.py +++ b/tests/unit/test_util.py @@ -71,19 +71,6 @@ def test_guess_gpx_timezone(gpx_file, offset): assert timezone.utcoffset(None) == offset -@pytest.mark.parametrize('gpx_file', [ - 'Teasi_1.gpx.gz', - 'MyTourbook_1.gpx.gz', - 'Synthetic_WT2.gpx.gz', - 'Synthetic_BRouter_1.gpx.gz', -]) -def test_tour_metadata(gpx_file): - # Here we simply make sure that we do not crash the metadata extraction - # function. - gpx_data = load_gpx_asset(gpx_file) - assert util.tour_metadata(gpx_data) is not None - - @pytest.mark.parametrize('mps, kph', [(1, 3.6), (10, 36)]) def test_mps_to_kph(mps, kph): assert util.mps_to_kph(mps) == pytest.approx(kph, 0.1) -- cgit v1.2.3 From 939826644ff09b38f807390c86b789306f858ba1 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 1 Nov 2025 15:23:45 +0100 Subject: fix size breakdown --- fietsboek/views/admin.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/fietsboek/views/admin.py b/fietsboek/views/admin.py index 821a9fc..3f036e2 100644 --- a/fietsboek/views/admin.py +++ b/fietsboek/views/admin.py @@ -10,7 +10,7 @@ from pyramid.httpexceptions import HTTPFound from pyramid.i18n import TranslationString as _ from pyramid.request import Request from pyramid.view import view_config -from sqlalchemy import func, select +from sqlalchemy import func, select, text from .. import models, util @@ -39,12 +39,22 @@ class SizeBreakdown: user_maps: int = 0 -def _get_size_breakdown(data_manager): +def _get_size_breakdown(dbsession, data_manager): breakdown = SizeBreakdown() + dialect = dbsession.bind.dialect.name + if dialect == "sqlite": + query = text("""SELECT SUM("pgsize") FROM "dbstat" WHERE name='track_points';""") + result = dbsession.execute(query).scalar_one() + breakdown.gpx_files += result + elif dialect == "postgresql": + database_name = dbsession.bind.url.database + query = text(f"""SELECT pg_relation_size('track_points');""") + result = dbsession.execute(query).scalar_one() + breakdown.gpx_files += result + for track_id in data_manager.list_tracks(): track = data_manager.open(track_id) - breakdown.gpx_files += _safe_size(track.gpx_path()) breakdown.gpx_files += _safe_size(track.backup_path()) breakdown.preview_files += _safe_size(track.preview_path()) for image_id in track.images(): @@ -82,7 +92,7 @@ def admin(request: Request): user_count = request.dbsession.execute(select(func.count()).select_from(models.User)).scalar() track_count = request.dbsession.execute(select(func.count()).select_from(models.Track)).scalar() size_total = request.data_manager.size() - size_breakdown = _get_size_breakdown(request.data_manager) + size_breakdown = _get_size_breakdown(request.dbsession, request.data_manager) try: distro = platform.freedesktop_os_release()["PRETTY_NAME"] -- cgit v1.2.3 From 335f49ab11e238e6bac6e651c3d583704836761f Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 1 Nov 2025 15:27:51 +0100 Subject: split track data and backups in storage breakdown --- fietsboek/locale/de/LC_MESSAGES/messages.mo | Bin 17892 -> 17985 bytes fietsboek/locale/de/LC_MESSAGES/messages.po | 146 ++++++++++++++-------------- fietsboek/locale/en/LC_MESSAGES/messages.mo | Bin 16808 -> 16894 bytes fietsboek/locale/en/LC_MESSAGES/messages.po | 146 ++++++++++++++-------------- fietsboek/locale/fietslog.pot | 144 ++++++++++++++------------- fietsboek/templates/admin_overview.jinja2 | 6 +- fietsboek/views/admin.py | 9 +- 7 files changed, 233 insertions(+), 218 deletions(-) diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.mo b/fietsboek/locale/de/LC_MESSAGES/messages.mo index 6526f6f..22982c0 100644 Binary files a/fietsboek/locale/de/LC_MESSAGES/messages.mo and b/fietsboek/locale/de/LC_MESSAGES/messages.mo differ diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.po b/fietsboek/locale/de/LC_MESSAGES/messages.po index f306a46..e9c65c6 100644 --- a/fietsboek/locale/de/LC_MESSAGES/messages.po +++ b/fietsboek/locale/de/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2025-06-12 22:39+0200\n" +"POT-Creation-Date: 2025-11-01 15:25+0100\n" "PO-Revision-Date: 2022-07-02 17:35+0200\n" "Last-Translator: FULL NAME \n" "Language: de\n" @@ -18,54 +18,54 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.17.0\n" -#: fietsboek/actions.py:278 +#: fietsboek/actions.py:265 msgid "email.verify_mail.subject" msgstr "Fietsboek Konto Bestätigung" -#: fietsboek/actions.py:281 +#: fietsboek/actions.py:268 msgid "email.verify.text" msgstr "" "Um Dein Fietsboek-Konto zu bestätigen, nutze diesen Link: {}\n" "\n" "Falls Du kein Konto angelegt hast, ignoriere diese E-Mail." -#: fietsboek/util.py:334 +#: fietsboek/util.py:297 msgid "password_constraint.mismatch" msgstr "Passwörter stimmen nicht überein" -#: fietsboek/util.py:336 +#: fietsboek/util.py:299 msgid "password_constraint.length" msgstr "Passwort zu kurz" -#: fietsboek/models/track.py:622 +#: fietsboek/models/track.py:725 msgid "tooltip.table.length" msgstr "Länge" -#: fietsboek/models/track.py:623 +#: fietsboek/models/track.py:726 msgid "tooltip.table.people" msgstr "# Personen" -#: fietsboek/models/track.py:624 +#: fietsboek/models/track.py:727 msgid "tooltip.table.uphill" msgstr "Bergauf" -#: fietsboek/models/track.py:625 +#: fietsboek/models/track.py:728 msgid "tooltip.table.downhill" msgstr "Bergab" -#: fietsboek/models/track.py:626 fietsboek/templates/home.jinja2:7 +#: fietsboek/models/track.py:729 fietsboek/templates/home.jinja2:7 msgid "tooltip.table.moving_time" msgstr "Fahrzeit" -#: fietsboek/models/track.py:627 fietsboek/templates/home.jinja2:8 +#: fietsboek/models/track.py:730 fietsboek/templates/home.jinja2:8 msgid "tooltip.table.stopped_time" msgstr "Haltezeit" -#: fietsboek/models/track.py:629 +#: fietsboek/models/track.py:732 msgid "tooltip.table.max_speed" msgstr "Maximalgeschwindigkeit" -#: fietsboek/models/track.py:633 +#: fietsboek/models/track.py:736 msgid "tooltip.table.avg_speed" msgstr "Durchschnittsgeschwindigkeit" @@ -176,22 +176,26 @@ msgid "admin.overview.last_cronjob" msgstr "Letzter Cronjob" #: fietsboek/templates/admin_overview.jinja2:55 -msgid "admin.overview.storage_graph.label.gpx" -msgstr "GPX" +msgid "admin.overview.storage_graph.label.track_data" +msgstr "Streckendaten" #: fietsboek/templates/admin_overview.jinja2:56 +msgid "admin.overview.storage_graph.label.backups" +msgstr "Sicherungskopien" + +#: fietsboek/templates/admin_overview.jinja2:57 msgid "admin.overview.storage_graph.label.images" msgstr "Bilder" -#: fietsboek/templates/admin_overview.jinja2:57 +#: fietsboek/templates/admin_overview.jinja2:58 msgid "admin.overview.storage_graph.label.previews" msgstr "Vorschaubilder" -#: fietsboek/templates/admin_overview.jinja2:58 +#: fietsboek/templates/admin_overview.jinja2:59 msgid "admin.overview.storage_graph.label.user_maps" msgstr "Nutzerkarten" -#: fietsboek/templates/admin_overview.jinja2:84 +#: fietsboek/templates/admin_overview.jinja2:86 msgid "admin.overview.storage_graph.title" msgstr "Speicherübersicht" @@ -291,52 +295,52 @@ msgstr "Dies ist eine Aufnahme einer Strecke" msgid "page.browse.synthetic_tooltip" msgstr "Dies ist eine geplante Strecke" -#: fietsboek/templates/browse.jinja2:162 fietsboek/templates/details.jinja2:103 +#: fietsboek/templates/browse.jinja2:162 fietsboek/templates/details.jinja2:120 #: fietsboek/templates/profile_overview.jinja2:20 msgid "page.details.date" msgstr "Datum" -#: fietsboek/templates/browse.jinja2:164 fietsboek/templates/details.jinja2:117 +#: fietsboek/templates/browse.jinja2:164 fietsboek/templates/details.jinja2:134 #: fietsboek/templates/profile_overview.jinja2:22 msgid "page.details.length" msgstr "Länge" -#: fietsboek/templates/browse.jinja2:169 fietsboek/templates/details.jinja2:108 +#: fietsboek/templates/browse.jinja2:169 fietsboek/templates/details.jinja2:125 #: fietsboek/templates/profile_overview.jinja2:26 msgid "page.details.start_time" msgstr "Startzeit" -#: fietsboek/templates/browse.jinja2:171 fietsboek/templates/details.jinja2:112 +#: fietsboek/templates/browse.jinja2:171 fietsboek/templates/details.jinja2:129 #: fietsboek/templates/profile_overview.jinja2:28 msgid "page.details.end_time" msgstr "Endzeit" -#: fietsboek/templates/browse.jinja2:176 fietsboek/templates/details.jinja2:121 +#: fietsboek/templates/browse.jinja2:176 fietsboek/templates/details.jinja2:138 #: fietsboek/templates/profile_overview.jinja2:32 msgid "page.details.uphill" msgstr "Bergauf" -#: fietsboek/templates/browse.jinja2:178 fietsboek/templates/details.jinja2:125 +#: fietsboek/templates/browse.jinja2:178 fietsboek/templates/details.jinja2:142 #: fietsboek/templates/profile_overview.jinja2:34 msgid "page.details.downhill" msgstr "Bergab" -#: fietsboek/templates/browse.jinja2:183 fietsboek/templates/details.jinja2:130 +#: fietsboek/templates/browse.jinja2:183 fietsboek/templates/details.jinja2:147 #: fietsboek/templates/profile_overview.jinja2:38 msgid "page.details.moving_time" msgstr "Fahrzeit" -#: fietsboek/templates/browse.jinja2:185 fietsboek/templates/details.jinja2:134 +#: fietsboek/templates/browse.jinja2:185 fietsboek/templates/details.jinja2:151 #: fietsboek/templates/profile_overview.jinja2:40 msgid "page.details.stopped_time" msgstr "Haltezeit" -#: fietsboek/templates/browse.jinja2:189 fietsboek/templates/details.jinja2:138 +#: fietsboek/templates/browse.jinja2:189 fietsboek/templates/details.jinja2:155 #: fietsboek/templates/profile_overview.jinja2:44 msgid "page.details.max_speed" msgstr "maximale Geschwindigkeit" -#: fietsboek/templates/browse.jinja2:191 fietsboek/templates/details.jinja2:142 +#: fietsboek/templates/browse.jinja2:191 fietsboek/templates/details.jinja2:159 #: fietsboek/templates/profile_overview.jinja2:46 msgid "page.details.avg_speed" msgstr "durchschnittliche Geschwindigkeit" @@ -411,90 +415,90 @@ msgstr "Passwort wiederholen" msgid "page.create_account.create" msgstr "Erstellen" -#: fietsboek/templates/details.jinja2:7 +#: fietsboek/templates/details.jinja2:24 msgid "page.details.title" msgstr "Details" -#: fietsboek/templates/details.jinja2:20 +#: fietsboek/templates/details.jinja2:37 msgid "page.details.edit" msgstr "Bearbeiten" -#: fietsboek/templates/details.jinja2:21 +#: fietsboek/templates/details.jinja2:38 msgid "page.details.share" msgstr "Teilen" -#: fietsboek/templates/details.jinja2:22 +#: fietsboek/templates/details.jinja2:39 msgid "page.details.delete" msgstr "Löschen" -#: fietsboek/templates/details.jinja2:28 +#: fietsboek/templates/details.jinja2:45 msgid "page.details.sharelink.title" msgstr "Link zum Teilen" -#: fietsboek/templates/details.jinja2:32 +#: fietsboek/templates/details.jinja2:49 msgid "page.details.sharelink.info" msgstr "Jeder mit Zugang zu diesem Link kann die Strecke ansehen!" -#: fietsboek/templates/details.jinja2:39 +#: fietsboek/templates/details.jinja2:56 msgid "page.details.sharelink.invalidate" msgstr "Link invalidieren" -#: fietsboek/templates/details.jinja2:41 +#: fietsboek/templates/details.jinja2:58 msgid "page.details.sharelink.close" msgstr "Schließen" -#: fietsboek/templates/details.jinja2:51 +#: fietsboek/templates/details.jinja2:68 msgid "page.details.delete.title" msgstr "Strecke Löschen" -#: fietsboek/templates/details.jinja2:55 +#: fietsboek/templates/details.jinja2:72 msgid "page.details.delete.info" msgstr "Das Löschen der Strecke wird alle damit verbundenen Informationen löschen!" -#: fietsboek/templates/details.jinja2:60 +#: fietsboek/templates/details.jinja2:77 msgid "page.details.delete.delete" msgstr "Löschen" -#: fietsboek/templates/details.jinja2:62 +#: fietsboek/templates/details.jinja2:79 msgid "page.details.delete.close" msgstr "Abbrechen" -#: fietsboek/templates/details.jinja2:81 +#: fietsboek/templates/details.jinja2:98 msgid "page.details.tags" msgstr "Schlagwörter" -#: fietsboek/templates/details.jinja2:91 fietsboek/templates/edit.jinja2:10 +#: fietsboek/templates/details.jinja2:108 fietsboek/templates/edit.jinja2:10 #: fietsboek/templates/finish_upload.jinja2:10 msgid "page.noscript" msgstr "" "JavaScript ist deaktiviert, zum Nutzen aller Funktionen bitte JavaScript " "aktivieren" -#: fietsboek/templates/details.jinja2:97 +#: fietsboek/templates/details.jinja2:114 msgid "page.details.download" msgstr "Herunterladen" -#: fietsboek/templates/details.jinja2:187 +#: fietsboek/templates/details.jinja2:204 msgid "page.details.comments" msgstr "Kommentare" -#: fietsboek/templates/details.jinja2:191 +#: fietsboek/templates/details.jinja2:208 msgid "page.details.comments.author" msgstr "Kommentar von {}" -#: fietsboek/templates/details.jinja2:208 +#: fietsboek/templates/details.jinja2:225 msgid "page.details.comments.new.title" msgstr "Kommentar erstellen" -#: fietsboek/templates/details.jinja2:211 +#: fietsboek/templates/details.jinja2:228 msgid "page.details.comments.new.input_title" msgstr "Titel" -#: fietsboek/templates/details.jinja2:212 +#: fietsboek/templates/details.jinja2:229 msgid "page.details.comments.new.input_comment" msgstr "Kommentar" -#: fietsboek/templates/details.jinja2:215 +#: fietsboek/templates/details.jinja2:232 msgid "page.details.comments.new.submit" msgstr "Absenden" @@ -664,51 +668,51 @@ msgstr[1] "%(num)d Strecken" msgid "page.home.total" msgstr "Gesamt" -#: fietsboek/templates/layout.jinja2:43 +#: fietsboek/templates/layout.jinja2:44 msgid "page.navbar.toggle" msgstr "Navigation umschalten" -#: fietsboek/templates/layout.jinja2:54 +#: fietsboek/templates/layout.jinja2:55 msgid "page.navbar.home" msgstr "Startseite" -#: fietsboek/templates/layout.jinja2:57 +#: fietsboek/templates/layout.jinja2:58 msgid "page.navbar.browse" msgstr "Stöbern" -#: fietsboek/templates/layout.jinja2:61 +#: fietsboek/templates/layout.jinja2:62 msgid "page.navbar.upload" msgstr "Hochladen" -#: fietsboek/templates/layout.jinja2:70 +#: fietsboek/templates/layout.jinja2:71 msgid "page.navbar.user" msgstr "Nutzer" -#: fietsboek/templates/layout.jinja2:74 +#: fietsboek/templates/layout.jinja2:75 msgid "page.navbar.welcome_user" msgstr "Willkommen, {}!" -#: fietsboek/templates/layout.jinja2:77 +#: fietsboek/templates/layout.jinja2:78 msgid "page.navbar.logout" msgstr "Abmelden" -#: fietsboek/templates/layout.jinja2:80 +#: fietsboek/templates/layout.jinja2:81 msgid "page.navbar.profile" msgstr "Profil" -#: fietsboek/templates/layout.jinja2:83 +#: fietsboek/templates/layout.jinja2:84 msgid "page.navbar.user_data" msgstr "Persönliche Daten" -#: fietsboek/templates/layout.jinja2:87 +#: fietsboek/templates/layout.jinja2:88 msgid "page.navbar.admin" msgstr "Admin" -#: fietsboek/templates/layout.jinja2:93 +#: fietsboek/templates/layout.jinja2:94 msgid "page.navbar.login" msgstr "Anmelden" -#: fietsboek/templates/layout.jinja2:97 +#: fietsboek/templates/layout.jinja2:98 msgid "page.navbar.create_account" msgstr "Konto Erstellen" @@ -976,11 +980,11 @@ msgstr "" "Diese Transformation passt die Höhenangabe für Punkte an, bei denen die " "Höhenangabe fehlt." -#: fietsboek/transformers/elevation.py:116 +#: fietsboek/transformers/elevation.py:109 msgid "transformers.fix-elevation-jumps" msgstr "Höhensprünge beheben" -#: fietsboek/transformers/elevation.py:120 +#: fietsboek/transformers/elevation.py:113 msgid "transformers.fix-elevation-jumps.description" msgstr "" "Diese Transformation passt die Höhenangabe für Punkte an, bei denen die " @@ -998,15 +1002,15 @@ msgstr "Ungültige E-Mail-Adresse" msgid "flash.a_confirmation_link_has_been_sent" msgstr "Ein Bestätigungslink wurde versandt" -#: fietsboek/views/admin.py:156 +#: fietsboek/views/admin.py:168 msgid "flash.badge_added" msgstr "Wappen hinzugefügt" -#: fietsboek/views/admin.py:180 +#: fietsboek/views/admin.py:192 msgid "flash.badge_modified" msgstr "Wappen bearbeitet" -#: fietsboek/views/admin.py:200 +#: fietsboek/views/admin.py:212 msgid "flash.badge_deleted" msgstr "Wappen gelöscht" @@ -1066,23 +1070,23 @@ msgstr "E-Mail-Adresse bestätigt" msgid "flash.password_updated" msgstr "Passwort aktualisiert" -#: fietsboek/views/detail.py:166 +#: fietsboek/views/detail.py:189 msgid "flash.track_deleted" msgstr "Strecke gelöscht" -#: fietsboek/views/edit.py:100 fietsboek/views/upload.py:66 +#: fietsboek/views/edit.py:97 fietsboek/views/upload.py:63 msgid "flash.invalid_file" msgstr "Ungültige GPX-Datei gesendet" -#: fietsboek/views/upload.py:54 +#: fietsboek/views/upload.py:53 msgid "flash.no_file_selected" msgstr "Keine Datei ausgewählt" -#: fietsboek/views/upload.py:194 +#: fietsboek/views/upload.py:182 msgid "flash.upload_success" msgstr "Hochladen erfolgreich" -#: fietsboek/views/upload.py:213 +#: fietsboek/views/upload.py:201 msgid "flash.upload_cancelled" msgstr "Hochladen abgebrochen" diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.mo b/fietsboek/locale/en/LC_MESSAGES/messages.mo index 18f473c..d54ad31 100644 Binary files a/fietsboek/locale/en/LC_MESSAGES/messages.mo and b/fietsboek/locale/en/LC_MESSAGES/messages.mo differ diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.po b/fietsboek/locale/en/LC_MESSAGES/messages.po index 89e183d..7ccdf1c 100644 --- a/fietsboek/locale/en/LC_MESSAGES/messages.po +++ b/fietsboek/locale/en/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2025-06-12 22:39+0200\n" +"POT-Creation-Date: 2025-11-01 15:25+0100\n" "PO-Revision-Date: 2023-04-03 20:42+0200\n" "Last-Translator: \n" "Language: en\n" @@ -18,54 +18,54 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.17.0\n" -#: fietsboek/actions.py:278 +#: fietsboek/actions.py:265 msgid "email.verify_mail.subject" msgstr "Fietsboek Account Verification" -#: fietsboek/actions.py:281 +#: fietsboek/actions.py:268 msgid "email.verify.text" msgstr "" "To verify your Fietsboek account, please use this link: {}\n" "\n" "If you did not create an account, ignore this email." -#: fietsboek/util.py:334 +#: fietsboek/util.py:297 msgid "password_constraint.mismatch" msgstr "Passwords don't match" -#: fietsboek/util.py:336 +#: fietsboek/util.py:299 msgid "password_constraint.length" msgstr "Password not long enough" -#: fietsboek/models/track.py:622 +#: fietsboek/models/track.py:725 msgid "tooltip.table.length" msgstr "Length" -#: fietsboek/models/track.py:623 +#: fietsboek/models/track.py:726 msgid "tooltip.table.people" msgstr "# People" -#: fietsboek/models/track.py:624 +#: fietsboek/models/track.py:727 msgid "tooltip.table.uphill" msgstr "Uphill" -#: fietsboek/models/track.py:625 +#: fietsboek/models/track.py:728 msgid "tooltip.table.downhill" msgstr "Downhill" -#: fietsboek/models/track.py:626 fietsboek/templates/home.jinja2:7 +#: fietsboek/models/track.py:729 fietsboek/templates/home.jinja2:7 msgid "tooltip.table.moving_time" msgstr "Moving Time" -#: fietsboek/models/track.py:627 fietsboek/templates/home.jinja2:8 +#: fietsboek/models/track.py:730 fietsboek/templates/home.jinja2:8 msgid "tooltip.table.stopped_time" msgstr "Stopped Time" -#: fietsboek/models/track.py:629 +#: fietsboek/models/track.py:732 msgid "tooltip.table.max_speed" msgstr "Max Speed" -#: fietsboek/models/track.py:633 +#: fietsboek/models/track.py:736 msgid "tooltip.table.avg_speed" msgstr "Average Speed" @@ -176,22 +176,26 @@ msgid "admin.overview.last_cronjob" msgstr "Last cronjob" #: fietsboek/templates/admin_overview.jinja2:55 -msgid "admin.overview.storage_graph.label.gpx" -msgstr "GPX" +msgid "admin.overview.storage_graph.label.track_data" +msgstr "Track data" #: fietsboek/templates/admin_overview.jinja2:56 +msgid "admin.overview.storage_graph.label.backups" +msgstr "File backups" + +#: fietsboek/templates/admin_overview.jinja2:57 msgid "admin.overview.storage_graph.label.images" msgstr "Images" -#: fietsboek/templates/admin_overview.jinja2:57 +#: fietsboek/templates/admin_overview.jinja2:58 msgid "admin.overview.storage_graph.label.previews" msgstr "Preview images" -#: fietsboek/templates/admin_overview.jinja2:58 +#: fietsboek/templates/admin_overview.jinja2:59 msgid "admin.overview.storage_graph.label.user_maps" msgstr "User maps" -#: fietsboek/templates/admin_overview.jinja2:84 +#: fietsboek/templates/admin_overview.jinja2:86 msgid "admin.overview.storage_graph.title" msgstr "Storage breakdown" @@ -291,52 +295,52 @@ msgstr "This is a recording of a track" msgid "page.browse.synthetic_tooltip" msgstr "This is a pre-planned track" -#: fietsboek/templates/browse.jinja2:162 fietsboek/templates/details.jinja2:103 +#: fietsboek/templates/browse.jinja2:162 fietsboek/templates/details.jinja2:120 #: fietsboek/templates/profile_overview.jinja2:20 msgid "page.details.date" msgstr "Date" -#: fietsboek/templates/browse.jinja2:164 fietsboek/templates/details.jinja2:117 +#: fietsboek/templates/browse.jinja2:164 fietsboek/templates/details.jinja2:134 #: fietsboek/templates/profile_overview.jinja2:22 msgid "page.details.length" msgstr "Length" -#: fietsboek/templates/browse.jinja2:169 fietsboek/templates/details.jinja2:108 +#: fietsboek/templates/browse.jinja2:169 fietsboek/templates/details.jinja2:125 #: fietsboek/templates/profile_overview.jinja2:26 msgid "page.details.start_time" msgstr "Record Start" -#: fietsboek/templates/browse.jinja2:171 fietsboek/templates/details.jinja2:112 +#: fietsboek/templates/browse.jinja2:171 fietsboek/templates/details.jinja2:129 #: fietsboek/templates/profile_overview.jinja2:28 msgid "page.details.end_time" msgstr "Record End" -#: fietsboek/templates/browse.jinja2:176 fietsboek/templates/details.jinja2:121 +#: fietsboek/templates/browse.jinja2:176 fietsboek/templates/details.jinja2:138 #: fietsboek/templates/profile_overview.jinja2:32 msgid "page.details.uphill" msgstr "Uphill" -#: fietsboek/templates/browse.jinja2:178 fietsboek/templates/details.jinja2:125 +#: fietsboek/templates/browse.jinja2:178 fietsboek/templates/details.jinja2:142 #: fietsboek/templates/profile_overview.jinja2:34 msgid "page.details.downhill" msgstr "Downhill" -#: fietsboek/templates/browse.jinja2:183 fietsboek/templates/details.jinja2:130 +#: fietsboek/templates/browse.jinja2:183 fietsboek/templates/details.jinja2:147 #: fietsboek/templates/profile_overview.jinja2:38 msgid "page.details.moving_time" msgstr "Moving Time" -#: fietsboek/templates/browse.jinja2:185 fietsboek/templates/details.jinja2:134 +#: fietsboek/templates/browse.jinja2:185 fietsboek/templates/details.jinja2:151 #: fietsboek/templates/profile_overview.jinja2:40 msgid "page.details.stopped_time" msgstr "Stopped Time" -#: fietsboek/templates/browse.jinja2:189 fietsboek/templates/details.jinja2:138 +#: fietsboek/templates/browse.jinja2:189 fietsboek/templates/details.jinja2:155 #: fietsboek/templates/profile_overview.jinja2:44 msgid "page.details.max_speed" msgstr "Max Speed" -#: fietsboek/templates/browse.jinja2:191 fietsboek/templates/details.jinja2:142 +#: fietsboek/templates/browse.jinja2:191 fietsboek/templates/details.jinja2:159 #: fietsboek/templates/profile_overview.jinja2:46 msgid "page.details.avg_speed" msgstr "Average Speed" @@ -409,88 +413,88 @@ msgstr "Repeat password" msgid "page.create_account.create" msgstr "Create" -#: fietsboek/templates/details.jinja2:7 +#: fietsboek/templates/details.jinja2:24 msgid "page.details.title" msgstr "Track Details" -#: fietsboek/templates/details.jinja2:20 +#: fietsboek/templates/details.jinja2:37 msgid "page.details.edit" msgstr "Edit" -#: fietsboek/templates/details.jinja2:21 +#: fietsboek/templates/details.jinja2:38 msgid "page.details.share" msgstr "Share" -#: fietsboek/templates/details.jinja2:22 +#: fietsboek/templates/details.jinja2:39 msgid "page.details.delete" msgstr "Delete" -#: fietsboek/templates/details.jinja2:28 +#: fietsboek/templates/details.jinja2:45 msgid "page.details.sharelink.title" msgstr "Share Link" -#: fietsboek/templates/details.jinja2:32 +#: fietsboek/templates/details.jinja2:49 msgid "page.details.sharelink.info" msgstr "Everyone with access to this link can view the track!" -#: fietsboek/templates/details.jinja2:39 +#: fietsboek/templates/details.jinja2:56 msgid "page.details.sharelink.invalidate" msgstr "Invalidate link" -#: fietsboek/templates/details.jinja2:41 +#: fietsboek/templates/details.jinja2:58 msgid "page.details.sharelink.close" msgstr "Close" -#: fietsboek/templates/details.jinja2:51 +#: fietsboek/templates/details.jinja2:68 msgid "page.details.delete.title" msgstr "Delete Track" -#: fietsboek/templates/details.jinja2:55 +#: fietsboek/templates/details.jinja2:72 msgid "page.details.delete.info" msgstr "Deleting this track will remove all associated information with it!" -#: fietsboek/templates/details.jinja2:60 +#: fietsboek/templates/details.jinja2:77 msgid "page.details.delete.delete" msgstr "Delete" -#: fietsboek/templates/details.jinja2:62 +#: fietsboek/templates/details.jinja2:79 msgid "page.details.delete.close" msgstr "Abort" -#: fietsboek/templates/details.jinja2:81 +#: fietsboek/templates/details.jinja2:98 msgid "page.details.tags" msgstr "Tagged as" -#: fietsboek/templates/details.jinja2:91 fietsboek/templates/edit.jinja2:10 +#: fietsboek/templates/details.jinja2:108 fietsboek/templates/edit.jinja2:10 #: fietsboek/templates/finish_upload.jinja2:10 msgid "page.noscript" msgstr "JavaScript is disabled, please enable JavaScript" -#: fietsboek/templates/details.jinja2:97 +#: fietsboek/templates/details.jinja2:114 msgid "page.details.download" msgstr "Download Tour" -#: fietsboek/templates/details.jinja2:187 +#: fietsboek/templates/details.jinja2:204 msgid "page.details.comments" msgstr "Comments" -#: fietsboek/templates/details.jinja2:191 +#: fietsboek/templates/details.jinja2:208 msgid "page.details.comments.author" msgstr "Comment by {}" -#: fietsboek/templates/details.jinja2:208 +#: fietsboek/templates/details.jinja2:225 msgid "page.details.comments.new.title" msgstr "Create a new comment" -#: fietsboek/templates/details.jinja2:211 +#: fietsboek/templates/details.jinja2:228 msgid "page.details.comments.new.input_title" msgstr "Title" -#: fietsboek/templates/details.jinja2:212 +#: fietsboek/templates/details.jinja2:229 msgid "page.details.comments.new.input_comment" msgstr "Comment" -#: fietsboek/templates/details.jinja2:215 +#: fietsboek/templates/details.jinja2:232 msgid "page.details.comments.new.submit" msgstr "Submit" @@ -658,51 +662,51 @@ msgstr[1] "%(num)d tracks" msgid "page.home.total" msgstr "Total" -#: fietsboek/templates/layout.jinja2:43 +#: fietsboek/templates/layout.jinja2:44 msgid "page.navbar.toggle" msgstr "Toggle navigation" -#: fietsboek/templates/layout.jinja2:54 +#: fietsboek/templates/layout.jinja2:55 msgid "page.navbar.home" msgstr "Home" -#: fietsboek/templates/layout.jinja2:57 +#: fietsboek/templates/layout.jinja2:58 msgid "page.navbar.browse" msgstr "Browse" -#: fietsboek/templates/layout.jinja2:61 +#: fietsboek/templates/layout.jinja2:62 msgid "page.navbar.upload" msgstr "Upload" -#: fietsboek/templates/layout.jinja2:70 +#: fietsboek/templates/layout.jinja2:71 msgid "page.navbar.user" msgstr "User" -#: fietsboek/templates/layout.jinja2:74 +#: fietsboek/templates/layout.jinja2:75 msgid "page.navbar.welcome_user" msgstr "Welcome, {}!" -#: fietsboek/templates/layout.jinja2:77 +#: fietsboek/templates/layout.jinja2:78 msgid "page.navbar.logout" msgstr "Logout" -#: fietsboek/templates/layout.jinja2:80 +#: fietsboek/templates/layout.jinja2:81 msgid "page.navbar.profile" msgstr "Profile" -#: fietsboek/templates/layout.jinja2:83 +#: fietsboek/templates/layout.jinja2:84 msgid "page.navbar.user_data" msgstr "Personal Data" -#: fietsboek/templates/layout.jinja2:87 +#: fietsboek/templates/layout.jinja2:88 msgid "page.navbar.admin" msgstr "Admin" -#: fietsboek/templates/layout.jinja2:93 +#: fietsboek/templates/layout.jinja2:94 msgid "page.navbar.login" msgstr "Login" -#: fietsboek/templates/layout.jinja2:97 +#: fietsboek/templates/layout.jinja2:98 msgid "page.navbar.create_account" msgstr "Create Account" @@ -968,11 +972,11 @@ msgstr "Fix null elevation" msgid "transformers.fix-null-elevation.description" msgstr "This transformer fixes the elevation of points whose elevation is unset." -#: fietsboek/transformers/elevation.py:116 +#: fietsboek/transformers/elevation.py:109 msgid "transformers.fix-elevation-jumps" msgstr "Fix elevation jumps" -#: fietsboek/transformers/elevation.py:120 +#: fietsboek/transformers/elevation.py:113 msgid "transformers.fix-elevation-jumps.description" msgstr "This transformer fixes abrupt jumps in the elevation value." @@ -988,15 +992,15 @@ msgstr "Invalid email" msgid "flash.a_confirmation_link_has_been_sent" msgstr "A confirmation link has been sent" -#: fietsboek/views/admin.py:156 +#: fietsboek/views/admin.py:168 msgid "flash.badge_added" msgstr "Badge has been added" -#: fietsboek/views/admin.py:180 +#: fietsboek/views/admin.py:192 msgid "flash.badge_modified" msgstr "Badge has been modified" -#: fietsboek/views/admin.py:200 +#: fietsboek/views/admin.py:212 msgid "flash.badge_deleted" msgstr "Badge has been deleted" @@ -1055,23 +1059,23 @@ msgstr "Your email address has been verified" msgid "flash.password_updated" msgstr "Password has been updated" -#: fietsboek/views/detail.py:166 +#: fietsboek/views/detail.py:189 msgid "flash.track_deleted" msgstr "Track has been deleted" -#: fietsboek/views/edit.py:100 fietsboek/views/upload.py:66 +#: fietsboek/views/edit.py:97 fietsboek/views/upload.py:63 msgid "flash.invalid_file" msgstr "Invalid GPX file selected" -#: fietsboek/views/upload.py:54 +#: fietsboek/views/upload.py:53 msgid "flash.no_file_selected" msgstr "No file selected" -#: fietsboek/views/upload.py:194 +#: fietsboek/views/upload.py:182 msgid "flash.upload_success" msgstr "Upload successful" -#: fietsboek/views/upload.py:213 +#: fietsboek/views/upload.py:201 msgid "flash.upload_cancelled" msgstr "Upload cancelled" diff --git a/fietsboek/locale/fietslog.pot b/fietsboek/locale/fietslog.pot index 6383760..cedd3ac 100644 --- a/fietsboek/locale/fietslog.pot +++ b/fietsboek/locale/fietslog.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2025-06-12 22:39+0200\n" +"POT-Creation-Date: 2025-11-01 15:25+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,51 +17,51 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.17.0\n" -#: fietsboek/actions.py:278 +#: fietsboek/actions.py:265 msgid "email.verify_mail.subject" msgstr "" -#: fietsboek/actions.py:281 +#: fietsboek/actions.py:268 msgid "email.verify.text" msgstr "" -#: fietsboek/util.py:334 +#: fietsboek/util.py:297 msgid "password_constraint.mismatch" msgstr "" -#: fietsboek/util.py:336 +#: fietsboek/util.py:299 msgid "password_constraint.length" msgstr "" -#: fietsboek/models/track.py:622 +#: fietsboek/models/track.py:725 msgid "tooltip.table.length" msgstr "" -#: fietsboek/models/track.py:623 +#: fietsboek/models/track.py:726 msgid "tooltip.table.people" msgstr "" -#: fietsboek/models/track.py:624 +#: fietsboek/models/track.py:727 msgid "tooltip.table.uphill" msgstr "" -#: fietsboek/models/track.py:625 +#: fietsboek/models/track.py:728 msgid "tooltip.table.downhill" msgstr "" -#: fietsboek/models/track.py:626 fietsboek/templates/home.jinja2:7 +#: fietsboek/models/track.py:729 fietsboek/templates/home.jinja2:7 msgid "tooltip.table.moving_time" msgstr "" -#: fietsboek/models/track.py:627 fietsboek/templates/home.jinja2:8 +#: fietsboek/models/track.py:730 fietsboek/templates/home.jinja2:8 msgid "tooltip.table.stopped_time" msgstr "" -#: fietsboek/models/track.py:629 +#: fietsboek/models/track.py:732 msgid "tooltip.table.max_speed" msgstr "" -#: fietsboek/models/track.py:633 +#: fietsboek/models/track.py:736 msgid "tooltip.table.avg_speed" msgstr "" @@ -170,22 +170,26 @@ msgid "admin.overview.last_cronjob" msgstr "" #: fietsboek/templates/admin_overview.jinja2:55 -msgid "admin.overview.storage_graph.label.gpx" +msgid "admin.overview.storage_graph.label.track_data" msgstr "" #: fietsboek/templates/admin_overview.jinja2:56 -msgid "admin.overview.storage_graph.label.images" +msgid "admin.overview.storage_graph.label.backups" msgstr "" #: fietsboek/templates/admin_overview.jinja2:57 -msgid "admin.overview.storage_graph.label.previews" +msgid "admin.overview.storage_graph.label.images" msgstr "" #: fietsboek/templates/admin_overview.jinja2:58 +msgid "admin.overview.storage_graph.label.previews" +msgstr "" + +#: fietsboek/templates/admin_overview.jinja2:59 msgid "admin.overview.storage_graph.label.user_maps" msgstr "" -#: fietsboek/templates/admin_overview.jinja2:84 +#: fietsboek/templates/admin_overview.jinja2:86 msgid "admin.overview.storage_graph.title" msgstr "" @@ -285,52 +289,52 @@ msgstr "" msgid "page.browse.synthetic_tooltip" msgstr "" -#: fietsboek/templates/browse.jinja2:162 fietsboek/templates/details.jinja2:103 +#: fietsboek/templates/browse.jinja2:162 fietsboek/templates/details.jinja2:120 #: fietsboek/templates/profile_overview.jinja2:20 msgid "page.details.date" msgstr "" -#: fietsboek/templates/browse.jinja2:164 fietsboek/templates/details.jinja2:117 +#: fietsboek/templates/browse.jinja2:164 fietsboek/templates/details.jinja2:134 #: fietsboek/templates/profile_overview.jinja2:22 msgid "page.details.length" msgstr "" -#: fietsboek/templates/browse.jinja2:169 fietsboek/templates/details.jinja2:108 +#: fietsboek/templates/browse.jinja2:169 fietsboek/templates/details.jinja2:125 #: fietsboek/templates/profile_overview.jinja2:26 msgid "page.details.start_time" msgstr "" -#: fietsboek/templates/browse.jinja2:171 fietsboek/templates/details.jinja2:112 +#: fietsboek/templates/browse.jinja2:171 fietsboek/templates/details.jinja2:129 #: fietsboek/templates/profile_overview.jinja2:28 msgid "page.details.end_time" msgstr "" -#: fietsboek/templates/browse.jinja2:176 fietsboek/templates/details.jinja2:121 +#: fietsboek/templates/browse.jinja2:176 fietsboek/templates/details.jinja2:138 #: fietsboek/templates/profile_overview.jinja2:32 msgid "page.details.uphill" msgstr "" -#: fietsboek/templates/browse.jinja2:178 fietsboek/templates/details.jinja2:125 +#: fietsboek/templates/browse.jinja2:178 fietsboek/templates/details.jinja2:142 #: fietsboek/templates/profile_overview.jinja2:34 msgid "page.details.downhill" msgstr "" -#: fietsboek/templates/browse.jinja2:183 fietsboek/templates/details.jinja2:130 +#: fietsboek/templates/browse.jinja2:183 fietsboek/templates/details.jinja2:147 #: fietsboek/templates/profile_overview.jinja2:38 msgid "page.details.moving_time" msgstr "" -#: fietsboek/templates/browse.jinja2:185 fietsboek/templates/details.jinja2:134 +#: fietsboek/templates/browse.jinja2:185 fietsboek/templates/details.jinja2:151 #: fietsboek/templates/profile_overview.jinja2:40 msgid "page.details.stopped_time" msgstr "" -#: fietsboek/templates/browse.jinja2:189 fietsboek/templates/details.jinja2:138 +#: fietsboek/templates/browse.jinja2:189 fietsboek/templates/details.jinja2:155 #: fietsboek/templates/profile_overview.jinja2:44 msgid "page.details.max_speed" msgstr "" -#: fietsboek/templates/browse.jinja2:191 fietsboek/templates/details.jinja2:142 +#: fietsboek/templates/browse.jinja2:191 fietsboek/templates/details.jinja2:159 #: fietsboek/templates/profile_overview.jinja2:46 msgid "page.details.avg_speed" msgstr "" @@ -403,88 +407,88 @@ msgstr "" msgid "page.create_account.create" msgstr "" -#: fietsboek/templates/details.jinja2:7 +#: fietsboek/templates/details.jinja2:24 msgid "page.details.title" msgstr "" -#: fietsboek/templates/details.jinja2:20 +#: fietsboek/templates/details.jinja2:37 msgid "page.details.edit" msgstr "" -#: fietsboek/templates/details.jinja2:21 +#: fietsboek/templates/details.jinja2:38 msgid "page.details.share" msgstr "" -#: fietsboek/templates/details.jinja2:22 +#: fietsboek/templates/details.jinja2:39 msgid "page.details.delete" msgstr "" -#: fietsboek/templates/details.jinja2:28 +#: fietsboek/templates/details.jinja2:45 msgid "page.details.sharelink.title" msgstr "" -#: fietsboek/templates/details.jinja2:32 +#: fietsboek/templates/details.jinja2:49 msgid "page.details.sharelink.info" msgstr "" -#: fietsboek/templates/details.jinja2:39 +#: fietsboek/templates/details.jinja2:56 msgid "page.details.sharelink.invalidate" msgstr "" -#: fietsboek/templates/details.jinja2:41 +#: fietsboek/templates/details.jinja2:58 msgid "page.details.sharelink.close" msgstr "" -#: fietsboek/templates/details.jinja2:51 +#: fietsboek/templates/details.jinja2:68 msgid "page.details.delete.title" msgstr "" -#: fietsboek/templates/details.jinja2:55 +#: fietsboek/templates/details.jinja2:72 msgid "page.details.delete.info" msgstr "" -#: fietsboek/templates/details.jinja2:60 +#: fietsboek/templates/details.jinja2:77 msgid "page.details.delete.delete" msgstr "" -#: fietsboek/templates/details.jinja2:62 +#: fietsboek/templates/details.jinja2:79 msgid "page.details.delete.close" msgstr "" -#: fietsboek/templates/details.jinja2:81 +#: fietsboek/templates/details.jinja2:98 msgid "page.details.tags" msgstr "" -#: fietsboek/templates/details.jinja2:91 fietsboek/templates/edit.jinja2:10 +#: fietsboek/templates/details.jinja2:108 fietsboek/templates/edit.jinja2:10 #: fietsboek/templates/finish_upload.jinja2:10 msgid "page.noscript" msgstr "" -#: fietsboek/templates/details.jinja2:97 +#: fietsboek/templates/details.jinja2:114 msgid "page.details.download" msgstr "" -#: fietsboek/templates/details.jinja2:187 +#: fietsboek/templates/details.jinja2:204 msgid "page.details.comments" msgstr "" -#: fietsboek/templates/details.jinja2:191 +#: fietsboek/templates/details.jinja2:208 msgid "page.details.comments.author" msgstr "" -#: fietsboek/templates/details.jinja2:208 +#: fietsboek/templates/details.jinja2:225 msgid "page.details.comments.new.title" msgstr "" -#: fietsboek/templates/details.jinja2:211 +#: fietsboek/templates/details.jinja2:228 msgid "page.details.comments.new.input_title" msgstr "" -#: fietsboek/templates/details.jinja2:212 +#: fietsboek/templates/details.jinja2:229 msgid "page.details.comments.new.input_comment" msgstr "" -#: fietsboek/templates/details.jinja2:215 +#: fietsboek/templates/details.jinja2:232 msgid "page.details.comments.new.submit" msgstr "" @@ -650,51 +654,51 @@ msgstr[1] "" msgid "page.home.total" msgstr "" -#: fietsboek/templates/layout.jinja2:43 +#: fietsboek/templates/layout.jinja2:44 msgid "page.navbar.toggle" msgstr "" -#: fietsboek/templates/layout.jinja2:54 +#: fietsboek/templates/layout.jinja2:55 msgid "page.navbar.home" msgstr "" -#: fietsboek/templates/layout.jinja2:57 +#: fietsboek/templates/layout.jinja2:58 msgid "page.navbar.browse" msgstr "" -#: fietsboek/templates/layout.jinja2:61 +#: fietsboek/templates/layout.jinja2:62 msgid "page.navbar.upload" msgstr "" -#: fietsboek/templates/layout.jinja2:70 +#: fietsboek/templates/layout.jinja2:71 msgid "page.navbar.user" msgstr "" -#: fietsboek/templates/layout.jinja2:74 +#: fietsboek/templates/layout.jinja2:75 msgid "page.navbar.welcome_user" msgstr "" -#: fietsboek/templates/layout.jinja2:77 +#: fietsboek/templates/layout.jinja2:78 msgid "page.navbar.logout" msgstr "" -#: fietsboek/templates/layout.jinja2:80 +#: fietsboek/templates/layout.jinja2:81 msgid "page.navbar.profile" msgstr "" -#: fietsboek/templates/layout.jinja2:83 +#: fietsboek/templates/layout.jinja2:84 msgid "page.navbar.user_data" msgstr "" -#: fietsboek/templates/layout.jinja2:87 +#: fietsboek/templates/layout.jinja2:88 msgid "page.navbar.admin" msgstr "" -#: fietsboek/templates/layout.jinja2:93 +#: fietsboek/templates/layout.jinja2:94 msgid "page.navbar.login" msgstr "" -#: fietsboek/templates/layout.jinja2:97 +#: fietsboek/templates/layout.jinja2:98 msgid "page.navbar.create_account" msgstr "" @@ -954,11 +958,11 @@ msgstr "" msgid "transformers.fix-null-elevation.description" msgstr "" -#: fietsboek/transformers/elevation.py:116 +#: fietsboek/transformers/elevation.py:109 msgid "transformers.fix-elevation-jumps" msgstr "" -#: fietsboek/transformers/elevation.py:120 +#: fietsboek/transformers/elevation.py:113 msgid "transformers.fix-elevation-jumps.description" msgstr "" @@ -974,15 +978,15 @@ msgstr "" msgid "flash.a_confirmation_link_has_been_sent" msgstr "" -#: fietsboek/views/admin.py:156 +#: fietsboek/views/admin.py:168 msgid "flash.badge_added" msgstr "" -#: fietsboek/views/admin.py:180 +#: fietsboek/views/admin.py:192 msgid "flash.badge_modified" msgstr "" -#: fietsboek/views/admin.py:200 +#: fietsboek/views/admin.py:212 msgid "flash.badge_deleted" msgstr "" @@ -1038,23 +1042,23 @@ msgstr "" msgid "flash.password_updated" msgstr "" -#: fietsboek/views/detail.py:166 +#: fietsboek/views/detail.py:189 msgid "flash.track_deleted" msgstr "" -#: fietsboek/views/edit.py:100 fietsboek/views/upload.py:66 +#: fietsboek/views/edit.py:97 fietsboek/views/upload.py:63 msgid "flash.invalid_file" msgstr "" -#: fietsboek/views/upload.py:54 +#: fietsboek/views/upload.py:53 msgid "flash.no_file_selected" msgstr "" -#: fietsboek/views/upload.py:194 +#: fietsboek/views/upload.py:182 msgid "flash.upload_success" msgstr "" -#: fietsboek/views/upload.py:213 +#: fietsboek/views/upload.py:201 msgid "flash.upload_cancelled" msgstr "" diff --git a/fietsboek/templates/admin_overview.jinja2 b/fietsboek/templates/admin_overview.jinja2 index 18a7633..e93e997 100644 --- a/fietsboek/templates/admin_overview.jinja2 +++ b/fietsboek/templates/admin_overview.jinja2 @@ -52,7 +52,8 @@ (function() { const data = { labels: [ - {{ _("admin.overview.storage_graph.label.gpx") | tojson }}, + {{ _("admin.overview.storage_graph.label.track_data") | tojson }}, + {{ _("admin.overview.storage_graph.label.backups") | tojson }}, {{ _("admin.overview.storage_graph.label.images") | tojson }}, {{ _("admin.overview.storage_graph.label.previews") | tojson }}, {{ _("admin.overview.storage_graph.label.user_maps") | tojson }} @@ -61,7 +62,8 @@ { label: "MiB", data: [ - {{ (size_breakdown.gpx_files / 1024 / 1024) | tojson }}, + {{ (size_breakdown.track_data / 1024 / 1024) | tojson }}, + {{ (size_breakdown.backups / 1024 / 1024) | tojson }}, {{ (size_breakdown.image_files / 1024 / 1024) | tojson }}, {{ (size_breakdown.preview_files / 1024 / 1024) | tojson }}, {{ (size_breakdown.user_maps / 1024 / 1024) | tojson }} diff --git a/fietsboek/views/admin.py b/fietsboek/views/admin.py index 3f036e2..b743c85 100644 --- a/fietsboek/views/admin.py +++ b/fietsboek/views/admin.py @@ -33,7 +33,8 @@ def _safe_size(path: Path) -> int: class SizeBreakdown: """A breakdown of what objects take how much storage.""" - gpx_files: int = 0 + track_data: int = 0 + backups: int = 0 image_files: int = 0 preview_files: int = 0 user_maps: int = 0 @@ -46,16 +47,16 @@ def _get_size_breakdown(dbsession, data_manager): if dialect == "sqlite": query = text("""SELECT SUM("pgsize") FROM "dbstat" WHERE name='track_points';""") result = dbsession.execute(query).scalar_one() - breakdown.gpx_files += result + breakdown.track_data += result elif dialect == "postgresql": database_name = dbsession.bind.url.database query = text(f"""SELECT pg_relation_size('track_points');""") result = dbsession.execute(query).scalar_one() - breakdown.gpx_files += result + breakdown.track_data += result for track_id in data_manager.list_tracks(): track = data_manager.open(track_id) - breakdown.gpx_files += _safe_size(track.backup_path()) + breakdown.backups += _safe_size(track.backup_path()) breakdown.preview_files += _safe_size(track.preview_path()) for image_id in track.images(): breakdown.image_files += _safe_size(track.image_path(image_id)) -- cgit v1.2.3 From 2dcc034c0123c45725204554e75ea151174142f7 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 1 Nov 2025 15:35:53 +0100 Subject: include database size in total size --- fietsboek/views/admin.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/fietsboek/views/admin.py b/fietsboek/views/admin.py index b743c85..14e4306 100644 --- a/fietsboek/views/admin.py +++ b/fietsboek/views/admin.py @@ -49,7 +49,6 @@ def _get_size_breakdown(dbsession, data_manager): result = dbsession.execute(query).scalar_one() breakdown.track_data += result elif dialect == "postgresql": - database_name = dbsession.bind.url.database query = text(f"""SELECT pg_relation_size('track_points');""") result = dbsession.execute(query).scalar_one() breakdown.track_data += result @@ -69,6 +68,22 @@ def _get_size_breakdown(dbsession, data_manager): return breakdown +def _get_db_size(dbsession): + dialect = dbsession.bind.dialect.name + if dialect == "sqlite": + query = text( + """SELECT page_size * page_count FROM pragma_page_count(), pragma_page_size();""" + ) + result = dbsession.execute(query).scalar_one() + return result + if dialect == "postgresql": + database_name = dbsession.bind.url.database + query = text(f"""SELECT pg_database_size('{database_name}');""") + result = dbsession.execute(query).scalar_one() + return result + return 0 + + def _get_fietsboek_version(): # pylint: disable=import-outside-toplevel from fietsboek import __VERSION__ @@ -92,7 +107,7 @@ def admin(request: Request): # pylint: disable=not-callable user_count = request.dbsession.execute(select(func.count()).select_from(models.User)).scalar() track_count = request.dbsession.execute(select(func.count()).select_from(models.Track)).scalar() - size_total = request.data_manager.size() + size_total = request.data_manager.size() + _get_db_size(request.dbsession) size_breakdown = _get_size_breakdown(request.dbsession, request.data_manager) try: -- cgit v1.2.3 From d8ef163bc48d654dc83a56cc6a6538ea442eee4f Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 1 Nov 2025 15:44:34 +0100 Subject: fix lint --- .../alembic/versions/20251019_90b39fdf6e4b.py | 3 +-- fietsboek/convert.py | 16 ++++++++----- fietsboek/geo.py | 27 +++++++-------------- fietsboek/models/track.py | 28 +++++++++++++--------- fietsboek/trackmap.py | 3 +-- fietsboek/transformers/__init__.py | 1 - fietsboek/transformers/breaks.py | 2 +- fietsboek/transformers/elevation.py | 2 +- fietsboek/views/admin.py | 2 +- fietsboek/views/browse.py | 1 - fietsboek/views/profile.py | 9 ++----- 11 files changed, 43 insertions(+), 51 deletions(-) diff --git a/fietsboek/alembic/versions/20251019_90b39fdf6e4b.py b/fietsboek/alembic/versions/20251019_90b39fdf6e4b.py index 1213ebf..0192920 100644 --- a/fietsboek/alembic/versions/20251019_90b39fdf6e4b.py +++ b/fietsboek/alembic/versions/20251019_90b39fdf6e4b.py @@ -5,9 +5,8 @@ Revises: 2ebe1bf66430 Create Date: 2025-10-19 20:17:12.562653 """ -from alembic import op import sqlalchemy as sa - +from alembic import op # revision identifiers, used by Alembic. revision = '90b39fdf6e4b' diff --git a/fietsboek/convert.py b/fietsboek/convert.py index d0d1a19..9cd3297 100644 --- a/fietsboek/convert.py +++ b/fietsboek/convert.py @@ -1,4 +1,5 @@ """Conversion functions to convert between various recording formats.""" + import datetime from typing import Optional @@ -68,6 +69,7 @@ def from_gpx(data: bytes) -> Track: :param data: The input bytes. :return: The converted structure. """ + # pylint: disable=too-many-locals gpx = gpxpy.parse(data) points = [] start_time = None @@ -82,12 +84,14 @@ def from_gpx(data: bytes) -> Track: time_offset = (point.time - start_time).total_seconds() else: time_offset = 0 - points.append(geo.Point( - longitude=point.longitude, - latitude=point.latitude, - elevation=point.elevation or 0.0, - time_offset=time_offset, - )) + points.append( + geo.Point( + longitude=point.longitude, + latitude=point.latitude, + elevation=point.elevation or 0.0, + time_offset=time_offset, + ) + ) timezone = util.guess_gpx_timezone(gpx) date = gpx.time or gpx.get_time_bounds().start_time or datetime.datetime.now() diff --git a/fietsboek/geo.py b/fietsboek/geo.py index 51bfff0..c0a10e7 100644 --- a/fietsboek/geo.py +++ b/fietsboek/geo.py @@ -1,8 +1,8 @@ """This module implements GPS related functionality.""" + from dataclasses import dataclass from itertools import islice -from math import sqrt, sin, cos, radians - +from math import cos, radians, sin, sqrt # WGS-84 equatorial radius, also called the semi-major axis. # https://en.wikipedia.org/wiki/Earth_radius @@ -17,6 +17,7 @@ MOVING_THRESHOLD = 1.1 @dataclass class MovementData: """Movement statistics for a path.""" + # pylint: disable=too-many-instance-attributes duration: float = 0.0 @@ -47,6 +48,7 @@ class MovementData: @dataclass(slots=True) class Point: """A GPS point, represented as longitude/latitude/elevation.""" + longitude: float latitude: float elevation: float @@ -71,12 +73,9 @@ class Point: # and for most GPS point differences that we encounter it should be # enough. radicand = ( - r_1**2 + - r_2**2 - - 2 * r_1 * r_2 * ( - sin(t_1) * sin(t_2) * cos(p_1 - p_2) + - cos(t_1) * cos(t_2) - ) + r_1**2 + + r_2**2 + - 2 * r_1 * r_2 * (sin(t_1) * sin(t_2) * cos(p_1 - p_2) + cos(t_1) * cos(t_2)) ) if radicand < 0.0: return 0.0 @@ -101,23 +100,15 @@ class Point: # circle curvature, it allows us to take the elevation into account, # and for most GPS point differences that we encounter it should be # enough. - radicand = ( - 2 * r**2 * ( - 1 - - ( - sin(t_1) * sin(t_2) * cos(p_1 - p_2) + - cos(t_1) * cos(t_2) - ) - ) - ) + radicand = 2 * r**2 * (1 - (sin(t_1) * sin(t_2) * cos(p_1 - p_2) + cos(t_1) * cos(t_2))) if radicand < 0.0: return 0.0 return sqrt(radicand) - class Path: """A GPS path, that is a series of GPS points.""" + # pylint: disable=too-few-public-methods def __init__(self, points: list[Point]): diff --git a/fietsboek/models/track.py b/fietsboek/models/track.py index ed20a07..0b75ce6 100644 --- a/fietsboek/models/track.py +++ b/fietsboek/models/track.py @@ -12,6 +12,8 @@ example all cached data to be re-computed without interfering with the other meta information. """ +# pylint: disable=too-many-lines + import datetime import enum import gzip @@ -158,6 +160,7 @@ class Waypoint(Base): Waypoints can have a name and description set. They exist outside of the actual route. """ + # pylint: disable=too-few-public-methods __tablename__ = "waypoints" id = Column(Integer, primary_key=True) @@ -173,6 +176,7 @@ class Waypoint(Base): class TrackPoint(Base): """A track point represents a single GPS point along a path.""" + # pylint: disable=too-few-public-methods __tablename__ = "track_points" track_id = Column(Integer, ForeignKey("tracks.id"), primary_key=True) @@ -282,10 +286,10 @@ class Track(Base): owner: Mapped["models.User"] = relationship("User", back_populates="tracks") points: Mapped[list["TrackPoint"]] = relationship( - "TrackPoint", back_populates="track", cascade="all, delete-orphan", + "TrackPoint", back_populates="track", cascade="all, delete-orphan" ) waypoints: Mapped[list["Waypoint"]] = relationship( - "Waypoint", back_populates="track", cascade="all, delete-orphan", + "Waypoint", back_populates="track", cascade="all, delete-orphan" ) cache: Mapped[Optional["TrackCache"]] = relationship( "TrackCache", back_populates="track", uselist=False, cascade="all, delete-orphan" @@ -390,9 +394,9 @@ class Track(Base): :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) - ]) + 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. @@ -404,12 +408,14 @@ class Track(Base): 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), - )) + 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) diff --git a/fietsboek/trackmap.py b/fietsboek/trackmap.py index a850051..9854211 100644 --- a/fietsboek/trackmap.py +++ b/fietsboek/trackmap.py @@ -111,8 +111,7 @@ class TrackMapRenderer: def _draw_lines(self, image, zoom, start_x, start_y): coords = ( - to_web_mercator(point.latitude, point.longitude, zoom) - for point in self.track.points + to_web_mercator(point.latitude, point.longitude, zoom) for point in self.track.points ) coords = [(x - start_x, y - start_y) for x, y in coords] diff --git a/fietsboek/transformers/__init__.py b/fietsboek/transformers/__init__.py index d9c533b..097fbaf 100644 --- a/fietsboek/transformers/__init__.py +++ b/fietsboek/transformers/__init__.py @@ -13,7 +13,6 @@ from abc import ABC, abstractmethod from collections.abc import Mapping from typing import Literal, NamedTuple, TypeVar -from gpxpy.gpx import GPX from pydantic import BaseModel from pyramid.i18n import TranslationString from pyramid.request import Request diff --git a/fietsboek/transformers/breaks.py b/fietsboek/transformers/breaks.py index f1279a0..1072eef 100644 --- a/fietsboek/transformers/breaks.py +++ b/fietsboek/transformers/breaks.py @@ -4,8 +4,8 @@ import datetime from pyramid.i18n import TranslationString -from . import Parameters, Transformer from .. import geo +from . import Parameters, Transformer _ = TranslationString diff --git a/fietsboek/transformers/elevation.py b/fietsboek/transformers/elevation.py index 25b4a50..27683bb 100644 --- a/fietsboek/transformers/elevation.py +++ b/fietsboek/transformers/elevation.py @@ -5,8 +5,8 @@ from itertools import islice, zip_longest from pyramid.i18n import TranslationString -from . import Parameters, Transformer from .. import geo +from . import Parameters, Transformer _ = TranslationString diff --git a/fietsboek/views/admin.py b/fietsboek/views/admin.py index 14e4306..0442a32 100644 --- a/fietsboek/views/admin.py +++ b/fietsboek/views/admin.py @@ -49,7 +49,7 @@ def _get_size_breakdown(dbsession, data_manager): result = dbsession.execute(query).scalar_one() breakdown.track_data += result elif dialect == "postgresql": - query = text(f"""SELECT pg_relation_size('track_points');""") + query = text("""SELECT pg_relation_size('track_points');""") result = dbsession.execute(query).scalar_one() breakdown.track_data += result diff --git a/fietsboek/views/browse.py b/fietsboek/views/browse.py index c024828..68d1416 100644 --- a/fietsboek/views/browse.py +++ b/fietsboek/views/browse.py @@ -17,7 +17,6 @@ from sqlalchemy.orm import Session, aliased from sqlalchemy.sql import Select from .. import models, util -from ..data import DataManager from ..models.track import TrackType, TrackWithMetadata TRACKS_PER_PAGE = 20 diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py index 544373b..d8ca386 100644 --- a/fietsboek/views/profile.py +++ b/fietsboek/views/profile.py @@ -14,7 +14,7 @@ from sqlalchemy import select from sqlalchemy.orm import aliased from .. import models, util -from ..data import DataManager, UserDataDir +from ..data import UserDataDir from ..models.track import TrackType, TrackWithMetadata from ..summaries import CumulativeStats, Summary @@ -132,7 +132,6 @@ def profile_calendar(request: Request) -> dict: data["user"] = request.context data["calendar_rows"] = calendar_rows( request.dbsession, - request.data_manager, request.context, date.year, date.month, @@ -161,7 +160,6 @@ def profile_calendar_ym(request: Request) -> dict: data["user"] = request.context data["calendar_rows"] = calendar_rows( request.dbsession, - request.data_manager, request.context, date.year, date.month, @@ -200,7 +198,6 @@ def cell_style(tracks: list[TrackWithMetadata]) -> str: def calendar_rows( dbsession: "sqlalchemy.orm.session.Session", - data_manager: DataManager, user: models.User, year: int, month: int, @@ -222,9 +219,7 @@ def calendar_rows( # Step 1: Retrieve all tracks query = user.all_tracks_query() query = select(aliased(models.Track, query)).where(query.c.type == TrackType.ORGANIC) - tracks = [ - TrackWithMetadata(track) for track in dbsession.execute(query).scalars() - ] + tracks = [TrackWithMetadata(track) for track in dbsession.execute(query).scalars()] # Step 2: Build the calendar days = [] -- cgit v1.2.3 From 9f26531a831a18b030385ac0c024099b26899574 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sun, 9 Nov 2025 12:08:06 +0100 Subject: fix fietscron --- fietsboek/scripts/fietscron.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/fietsboek/scripts/fietscron.py b/fietsboek/scripts/fietscron.py index 1a8e855..5027b90 100644 --- a/fietsboek/scripts/fietscron.py +++ b/fietsboek/scripts/fietscron.py @@ -54,7 +54,7 @@ def cli(config): LOGGER.debug("Starting maintenance tasks") remove_old_uploads(engine) remove_old_tokens(engine) - rebuild_cache(engine, data_manager) + rebuild_cache(engine) build_previews(engine, data_manager, redis, config) redis = mod_redis.from_url(config.redis_url) @@ -84,7 +84,7 @@ def remove_old_tokens(engine: Engine): session.commit() -def rebuild_cache(engine: Engine, data_manager: DataManager): +def rebuild_cache(engine: Engine): """Rebuilds the cache entries that are currently missing.""" LOGGER.debug("Rebuilding caches") session = Session(engine) @@ -95,8 +95,7 @@ def rebuild_cache(engine: Engine, data_manager: DataManager): for track in session.execute(needed_rebuilds).scalars(): assert track.id is not None LOGGER.info("Rebuilding cache for track %d", track.id) - gpx_data = data_manager.open(track.id).decompress_gpx() - track.ensure_cache(gpx_data) + track.ensure_cache() session.add(track) session.commit() @@ -122,8 +121,7 @@ def build_previews( continue LOGGER.debug("Building preview for %s", track.id) - gpx = gpxpy.parse(track_dir.decompress_gpx()) - preview = trackmap.render(gpx, layer, tile_requester) + preview = trackmap.render(track.path(), layer, tile_requester) with track_dir.lock(): with open(track_dir.preview_path(), "wb") as preview_file: preview.save(preview_file, "PNG") -- cgit v1.2.3 From 0b26bb759f2b479a6a7b274a4e1436f62608dcf5 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sun, 9 Nov 2025 12:27:55 +0100 Subject: fix SQLA warning about object not in session --- fietsboek/models/track.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fietsboek/models/track.py b/fietsboek/models/track.py index 0b75ce6..c61c4e3 100644 --- a/fietsboek/models/track.py +++ b/fietsboek/models/track.py @@ -494,7 +494,7 @@ class Track(Base): """Ensure that a cached version of this track's metadata exists.""" if self.cache is not None: return - self.cache = TrackCache(track=self) + self.cache = TrackCache() meta = self.path().movement_data() self.cache.length = meta.length self.cache.uphill = meta.uphill -- cgit v1.2.3 From ae55e81fd0c422eb4f1a232f0fcdc13c18022dbc Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sun, 9 Nov 2025 18:23:30 +0100 Subject: better error handling for convert.smart_convert Returning None is pretty nondescript, so let's make it official that we raise an exception there. --- fietsboek/convert.py | 20 +++++++++++++++++--- fietsboek/models/track.py | 2 +- fietsboek/views/edit.py | 6 ++++-- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/fietsboek/convert.py b/fietsboek/convert.py index 9cd3297..de6314f 100644 --- a/fietsboek/convert.py +++ b/fietsboek/convert.py @@ -12,6 +12,17 @@ from .models import Track, Waypoint FIT_RECORD_FIELDS = ["position_lat", "position_long", "altitude", "timestamp"] +class ConversionError(Exception): + """Error that occurred when loading a track from a file.""" + + +class UnknownFormat(ConversionError): + """The format of the source file could not be identified.""" + + def __str__(self): + return type(self).__doc__ + + def semicircles_to_deg(circles: int) -> float: """Convert semicircles coordinate to degree coordinate. @@ -30,6 +41,7 @@ def from_fit(data: bytes) -> Track: :param data: The input bytes. :return: The converted structure. + :raises ConversionError: If conversion failed. """ fitfile = fitparse.FitFile(data) start_time = None @@ -68,6 +80,7 @@ def from_gpx(data: bytes) -> Track: :param data: The input bytes. :return: The converted structure. + :raises ConversionError: If conversion failed. """ # pylint: disable=too-many-locals gpx = gpxpy.parse(data) @@ -132,7 +145,7 @@ def from_gpx(data: bytes) -> Track: return track -def smart_convert(data: bytes) -> Optional[Track]: +def smart_convert(data: bytes) -> Track: """Tries to be smart in converting the input bytes. This function automatically applies the correct conversion if possible. @@ -142,12 +155,13 @@ def smart_convert(data: bytes) -> Optional[Track]: :param data: The input bytes. :return: The converted content. + :raises ConversionError: When conversion fails. """ if len(data) > 11 and data[9:12] == b"FIT": return from_fit(data) if data.startswith(b" bytes: diff --git a/fietsboek/views/edit.py b/fietsboek/views/edit.py index 4e7b1f5..a40b8c2 100644 --- a/fietsboek/views/edit.py +++ b/fietsboek/views/edit.py @@ -92,9 +92,11 @@ def do_edit(request): pass else: LOGGER.info("Setting new track for %s", track.id) - new_track = convert.smart_convert(gpx_bytes) - if new_track is None: + try: + new_track = convert.smart_convert(gpx_bytes) + except convert.ConversionError as exc: request.session.flash(request.localizer.translate(_("flash.invalid_file"))) + LOGGER.info("Could not parse gpx: %s", exc) return HTTPFound(request.route_url("edit", track_id=track.id)) data.compress_backup(gpx_bytes) track.set_path(new_track.path()) -- cgit v1.2.3 From e32ac9d996a802c40140b5ae2ec97fc5b292f46c Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sun, 9 Nov 2025 18:29:41 +0100 Subject: change backup path to not contain .gpx Since this file could now also be a .fit file, let's just drop the extension. --- fietsboek/data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fietsboek/data.py b/fietsboek/data.py index 6b690d8..d4292d6 100644 --- a/fietsboek/data.py +++ b/fietsboek/data.py @@ -297,7 +297,7 @@ class TrackDataDir: :return: The path of the backup file. """ - return self.path / "track.bck.gpx.br" + return self.path / "track.bck.br" def images(self) -> list[str]: """Returns a list of images that belong to the track. -- cgit v1.2.3 From 50127789dbb976270dd973674e6a9e90181e7486 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sun, 9 Nov 2025 22:30:23 +0100 Subject: add update script for reading GPX to database --- .../scripts/upd_20251109_nm561argcq1s8w27.py | 156 +++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py diff --git a/fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py b/fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py new file mode 100644 index 0000000..7c0fcae --- /dev/null +++ b/fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py @@ -0,0 +1,156 @@ +"""Revision upgrade script nm561argcq1s8w27 + +This script moves data from the GPX files in the data directory to the SQL +database. + +Date created: 2025-11-09 18:27:48.493007 +""" +import datetime +import logging +import re +import shutil +from pathlib import Path + +import brotli +import gpxpy +from sqlalchemy import create_engine +from sqlalchemy.sql import text + +from fietsboek import convert +from fietsboek.updater.script import UpdateScript + +LOGGER = logging.getLogger(__name__) + +update_id = 'nm561argcq1s8w27' +previous = [ + 'v0.11.0', +] +alembic_revision = '90b39fdf6e4b' + + +class Up(UpdateScript): + def pre_alembic(self, config): + pass + + def post_alembic(self, config): + engine = create_engine(config["sqlalchemy.url"]) + connection = engine.connect() + data_dir = Path(config["fietsboek.data_dir"]) + + for track_dir in (data_dir / "tracks").iterdir(): + track_id = int(track_dir.name) + self.tell(f"Loading track {track_id}") + + gpx_path = track_dir / "track.gpx.br" + + # We're careful here, in case a previous update was interrupted + if not gpx_path.exists(): + continue + + gpx_bytes = brotli.decompress(gpx_path.read_bytes()) + + track = convert.smart_convert(gpx_bytes) + with connection.begin(): + connection.execute( + text("DELETE FROM track_points WHERE track_id = :id;"), + {"id": track_id}, + ) + connection.execute( + text("DELETE FROM waypoints WHERE track_id = :id;"), + {"id": track_id}, + ) + for index, point in enumerate(track.path().points): + connection.execute( + text("""INSERT INTO track_points ( + track_id, "index", longitude, latitude, elevation, time_offset + ) VALUES ( + :track_id, :index, :longitude, :latitude, :elevation, :time_offset + );"""), + { + "track_id": track_id, + "index": index, + "longitude": point.longitude, + "latitude": point.latitude, + "elevation": point.elevation, + "time_offset": point.time_offset, + }, + ) + for waypoint in track.waypoints: + connection.execute( + text("""INSERT INTO waypoints ( + track_id, longitude, latitude, elevation, name, description + ) VALUES ( + :track_id, :longitude, :latitude, :elevation, :name, :description + );"""), + { + "track_id": track_id, + "longitude": waypoint.longitude, + "latitude": waypoint.latitude, + "elevation": waypoint.elevation, + "name": waypoint.name, + "description": waypoint.description, + }, + ) + + gpx_path.unlink() + shutil.move( + track_dir / "track.bck.gpx.br", + track_dir / "track.bck.br", + ) + +class Down(UpdateScript): + def pre_alembic(self, config): + engine = create_engine(config["sqlalchemy.url"]) + connection = engine.connect() + data_dir = Path(config["fietsboek.data_dir"]) + + query = text("SELECT id, title, description FROM tracks;") + + for row in connection.execute(query): + gpx = gpxpy.gpx.GPX() + gpx.description = row.description + gpx.name = row.title + + segment = gpxpy.gpx.GPXTrackSegment() + points_query = text(""" + SELECT longitude, latitude, elevation, time_offset + FROM track_points WHERE track_id = :track_id ORDER BY "index"; + """) + for point in connection.execute(points_query, {"track_id": row.id}): + segment.points.append( + gpxpy.gpx.GPXTrackPoint( + latitude=point.latitude, + longitude=point.longitude, + elevation=point.elevation, + # This is wrong, should take proper track start time + # into account: + time=datetime.datetime.fromtimestamp(point.time_offset), + ) + ) + track = gpxpy.gpx.GPXTrack() + track.segments.append(segment) + gpx.tracks.append(track) + + waypoints_query = text(""" + SELECT longitude, latitude, elevation, name, description + FROM waypoints WHERE track_id = :track_id; + """) + for wpt in connection.execute(waypoints_query, {"track_id": row.id}): + gpx.waypoints.append( + gpxpy.gpx.GPXWaypoint( + longitude=wpt.longitude, + latitude=wpt.latitude, + elevation=wpt.elevation, + name=wpt.name, + comment=wpt.description, + description=wpt.description, + ) + ) + + xml_data = gpx.to_xml(prettyprint=False).encode("utf-8") + track_dir = data_dir / "tracks" / str(row.id) + (track_dir / "track.gpx.br").write_bytes(brotli.compress(xml_data)) + shutil.move(track_dir / "track.bck.br", track_dir / "track.bck.gpx.br") + + def post_alembic(self, config): + pass -- cgit v1.2.3 From 1d8f893314325f7356c8661b7b5d900426c12d25 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Tue, 11 Nov 2025 20:52:33 +0100 Subject: fix update script for empty data directories --- fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py b/fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py index 7c0fcae..1c82770 100644 --- a/fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py +++ b/fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py @@ -37,6 +37,10 @@ class Up(UpdateScript): connection = engine.connect() data_dir = Path(config["fietsboek.data_dir"]) + # This can happen in a fresh instance + if not (data_dir / "tracks").exists(): + return + for track_dir in (data_dir / "tracks").iterdir(): track_id = int(track_dir.name) self.tell(f"Loading track {track_id}") -- cgit v1.2.3 From 7796cfef6aca9232f1d31fa84fdaa50cb4bb2414 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Tue, 11 Nov 2025 20:55:28 +0100 Subject: fix lint --- fietsboek/convert.py | 1 - fietsboek/scripts/fietscron.py | 1 - fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/fietsboek/convert.py b/fietsboek/convert.py index de6314f..5d7b43e 100644 --- a/fietsboek/convert.py +++ b/fietsboek/convert.py @@ -1,7 +1,6 @@ """Conversion functions to convert between various recording formats.""" import datetime -from typing import Optional import fitparse import gpxpy diff --git a/fietsboek/scripts/fietscron.py b/fietsboek/scripts/fietscron.py index 5027b90..ea05f15 100644 --- a/fietsboek/scripts/fietscron.py +++ b/fietsboek/scripts/fietscron.py @@ -5,7 +5,6 @@ import logging import logging.config import click -import gpxpy import pyramid.paster import redis as mod_redis from redis import Redis diff --git a/fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py b/fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py index 1c82770..05a7233 100644 --- a/fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py +++ b/fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py @@ -7,7 +7,6 @@ Date created: 2025-11-09 18:27:48.493007 """ import datetime import logging -import re import shutil from pathlib import Path @@ -102,6 +101,7 @@ class Up(UpdateScript): track_dir / "track.bck.br", ) + class Down(UpdateScript): def pre_alembic(self, config): engine = create_engine(config["sqlalchemy.url"]) -- cgit v1.2.3 From 1fefd72e884296f7a4454d3534f5bcc3ac3e2299 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Wed, 12 Nov 2025 19:35:34 +0100 Subject: respect track date when downgrading --- fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py b/fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py index 05a7233..e3e5e47 100644 --- a/fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py +++ b/fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py @@ -108,13 +108,17 @@ class Down(UpdateScript): connection = engine.connect() data_dir = Path(config["fietsboek.data_dir"]) - query = text("SELECT id, title, description FROM tracks;") + query = text("SELECT id, title, description, date_raw FROM tracks;") for row in connection.execute(query): gpx = gpxpy.gpx.GPX() gpx.description = row.description gpx.name = row.title + start_date = row.date_raw + if isinstance(start_date, str): + start_date = datetime.datetime.fromisoformat(start_date) + segment = gpxpy.gpx.GPXTrackSegment() points_query = text(""" SELECT longitude, latitude, elevation, time_offset @@ -126,9 +130,7 @@ class Down(UpdateScript): latitude=point.latitude, longitude=point.longitude, elevation=point.elevation, - # This is wrong, should take proper track start time - # into account: - time=datetime.datetime.fromtimestamp(point.time_offset), + time=start_date + datetime.timedelta(seconds=point.time_offset), ) ) track = gpxpy.gpx.GPXTrack() -- cgit v1.2.3