diff options
33 files changed, 1017 insertions, 621 deletions
diff --git a/fietsboek/actions.py b/fietsboek/actions.py index 3f14308..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 email, models, trackmap +from . import convert, email, models, trackmap from . import transformers as mod_transformers from . import util from .config import TileLayerConfig @@ -74,16 +72,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) @@ -92,38 +89,31 @@ 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 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) 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(gpx) + track.ensure_cache() 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()) - manager.engrave_metadata( - title=track.title, - description=track.description, - author_name=track.owner.name, - time=track.date, - gpx=gpx, - ) - return track @@ -198,7 +188,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 @@ -209,7 +199,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) @@ -219,22 +208,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 @@ -242,9 +230,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/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') diff --git a/fietsboek/alembic/versions/20251019_90b39fdf6e4b.py b/fietsboek/alembic/versions/20251019_90b39fdf6e4b.py new file mode 100644 index 0000000..0192920 --- /dev/null +++ b/fietsboek/alembic/versions/20251019_90b39fdf6e4b.py @@ -0,0 +1,46 @@ +"""add table for track points + +Revision ID: 90b39fdf6e4b +Revises: 2ebe1bf66430 +Create Date: 2025-10-19 20:17:12.562653 + +""" +import sqlalchemy as sa +from alembic import op + +# 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')) + ) + 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 d3bfb22..5d7b43e 100644 --- a/fietsboek/convert.py +++ b/fietsboek/convert.py @@ -1,11 +1,27 @@ """Conversion functions to convert between various recording formats.""" +import datetime + import fitparse -from gpxpy.gpx import GPX, GPXTrack, GPXTrackPoint, GPXTrackSegment +import gpxpy + +from . import geo, util +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. @@ -15,8 +31,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. @@ -24,32 +40,111 @@ def from_fit(data: bytes) -> GPX: :param data: The input bytes. :return: The converted structure. + :raises ConversionError: If conversion failed. """ 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).total_seconds(), ) 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) + track.date = start_time + return track -def smart_convert(data: bytes) -> bytes: +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 + + :param data: The input bytes. + :return: The converted structure. + :raises ConversionError: If conversion failed. + """ + # pylint: disable=too-many-locals + 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 + + 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, + 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() + 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 + + 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 + + +def smart_convert(data: bytes) -> Track: """Tries to be smart in converting the input bytes. This function automatically applies the correct conversion if possible. @@ -59,10 +154,13 @@ def smart_convert(data: bytes) -> bytes: :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).to_xml().encode("utf-8") - return data + return from_fit(data) + if data.startswith(b"<?xml") and b"<gpx" in data[:200]: + return from_gpx(data) + raise UnknownFormat() -__all__ = ["from_fit", "smart_convert"] +__all__ = ["ConversionError", "from_fit", "from_gpx", "smart_convert"] diff --git a/fietsboek/data.py b/fietsboek/data.py index 9d5a133..d4292d6 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 @@ -199,12 +197,6 @@ class TrackDataDir: if action == "purge": (new_name,) = rest shutil.move(new_name, self.path) - elif action == "compress_gpx": - (old_data,) = rest - if old_data is None: - self.gpx_path().unlink() - else: - self.gpx_path().write_bytes(old_data) elif action == "add_image": (image_path,) = rest image_path.unlink() @@ -235,9 +227,6 @@ class TrackDataDir: if action == "purge": (new_name,) = rest shutil.rmtree(new_name, ignore_errors=False, onerror=self._log_deletion_error) - elif action == "compress_gpx": - # Nothing to do here, the new data is already on the disk - pass elif action == "add_image": # Nothing to do here, the image is already saved pass @@ -283,93 +272,32 @@ class TrackDataDir: """ return util.recursive_size(self.path) - def gpx_path(self) -> 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. :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. diff --git a/fietsboek/geo.py b/fietsboek/geo.py new file mode 100644 index 0000000..c0a10e7 --- /dev/null +++ b/fietsboek/geo.py @@ -0,0 +1,148 @@ +"""This module implements GPS related functionality.""" + +from dataclasses import dataclass +from itertools import islice +from math import cos, radians, sin, sqrt + +# 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: + """Movement statistics for a path.""" + + # pylint: disable=too-many-instance-attributes + + 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: + """A GPS point, represented as longitude/latitude/elevation.""" + + 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) + + 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: + """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 + + 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 + if time != 0: + speed = distance / time + else: + speed = 0.0 + 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 + + 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 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 diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.mo b/fietsboek/locale/de/LC_MESSAGES/messages.mo Binary files differindex 6526f6f..22982c0 100644 --- a/fietsboek/locale/de/LC_MESSAGES/messages.mo +++ b/fietsboek/locale/de/LC_MESSAGES/messages.mo 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 <EMAIL@ADDRESS>\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 Binary files differindex 18f473c..d54ad31 100644 --- a/fietsboek/locale/en/LC_MESSAGES/messages.mo +++ b/fietsboek/locale/en/LC_MESSAGES/messages.mo 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 <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\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/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 0921437..bd4bd15 100644 --- a/fietsboek/models/track.py +++ b/fietsboek/models/track.py @@ -12,13 +12,15 @@ 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 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 @@ -49,7 +51,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 +153,54 @@ track_favourite_assoc = Table( Column("user_id", ForeignKey("users.id"), primary_key=True), ) + +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) + 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): + """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) + 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: + """Converts this point (a database object) to a plain point. + + :return: The converted 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 +285,12 @@ 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" + ) + 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" ) @@ -316,6 +372,66 @@ 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 or 0.0)] + ) + + 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) + 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 def date(self): """The time-zone-aware date this track has set. @@ -374,24 +490,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"] + self.cache = TrackCache() + 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. @@ -471,7 +584,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 @@ -480,10 +593,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): @@ -491,8 +603,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 @@ -502,7 +613,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 @@ -512,7 +623,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 @@ -522,7 +633,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 @@ -544,7 +655,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) @@ -556,7 +667,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 @@ -566,7 +677,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 @@ -577,9 +688,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: @@ -590,7 +699,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/scripts/fietscron.py b/fietsboek/scripts/fietscron.py index 1a8e855..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 @@ -54,7 +53,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 +83,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 +94,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 +120,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") 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/trackmap.py b/fietsboek/trackmap.py index 584bf72..9854211 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) @@ -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.walk(only_points=True) + 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] @@ -120,10 +119,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/transformers/__init__.py b/fietsboek/transformers/__init__.py index b1a0245..097fbaf 100644 --- a/fietsboek/transformers/__init__.py +++ b/fietsboek/transformers/__init__.py @@ -13,11 +13,12 @@ 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 +from .. import geo + _ = TranslationString T = TypeVar("T", bound="Transformer") @@ -117,12 +118,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..1072eef 100644 --- a/fietsboek/transformers/breaks.py +++ b/fietsboek/transformers/breaks.py @@ -2,9 +2,9 @@ import datetime -from gpxpy.gpx import GPX, GPXTrack from pyramid.i18n import TranslationString +from .. import geo from . import Parameters, Transformer _ = TranslationString @@ -47,34 +47,27 @@ 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 +78,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 +86,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..27683bb 100644 --- a/fietsboek/transformers/elevation.py +++ b/fietsboek/transformers/elevation.py @@ -3,9 +3,9 @@ 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 geo from . import Parameters, Transformer _ = TranslationString @@ -13,7 +13,7 @@ _ = 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 +25,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 +58,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 +71,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,17 +124,16 @@ 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 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 diff --git a/fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py b/fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py new file mode 100644 index 0000000..e3e5e47 --- /dev/null +++ b/fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py @@ -0,0 +1,162 @@ +"""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 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"]) + + # 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}") + + 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, 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 + 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, + time=start_date + datetime.timedelta(seconds=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 diff --git a/fietsboek/util.py b/fietsboek/util.py index 5611c51..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 @@ -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. @@ -528,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/admin.py b/fietsboek/views/admin.py index 821a9fc..0442a32 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 @@ -33,19 +33,29 @@ 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 -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.track_data += result + elif dialect == "postgresql": + query = text("""SELECT pg_relation_size('track_points');""") + result = dbsession.execute(query).scalar_one() + breakdown.track_data += 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.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)) @@ -58,6 +68,22 @@ def _get_size_breakdown(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__ @@ -81,8 +107,8 @@ 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_breakdown = _get_size_breakdown(request.data_manager) + size_total = request.data_manager.size() + _get_db_size(request.dbsession) + size_breakdown = _get_size_breakdown(request.dbsession, request.data_manager) try: distro = platform.freedesktop_os_release()["PRETTY_NAME"] diff --git a/fietsboek/views/browse.py b/fietsboek/views/browse.py index e8e3edf..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 @@ -428,7 +427,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 +438,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. @@ -463,7 +460,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 @@ -498,7 +495,6 @@ def browse(request: Request) -> Response: tracks = list( paginate( request.dbsession, - request.data_manager, query, filters, (page - 1) * TRACKS_PER_PAGE, @@ -537,12 +533,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() 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 209b516..8ca7836 100644 --- a/fietsboek/views/detail.py +++ b/fietsboek/views/detail.py @@ -6,11 +6,10 @@ import io import logging from html.parser import HTMLParser -import gpxpy +import brotli from markupsafe import Markup from pyramid.httpexceptions import ( HTTPFound, - HTTPInternalServerError, HTTPNotAcceptable, HTTPNotFound, ) @@ -106,7 +105,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(), @@ -129,37 +128,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") @@ -281,8 +272,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") diff --git a/fietsboek/views/edit.py b/fietsboek/views/edit.py index 3daaacd..a40b8c2 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 @@ -93,15 +92,14 @@ 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) + except convert.ConversionError as exc: request.session.flash(request.localizer.translate(_("flash.invalid_file"))) - LOGGER.info("Could not parse updated gpx: %s", exc) + LOGGER.info("Could not parse 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)) diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py index 15bc46c..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 @@ -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) @@ -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, data_manager) for track in dbsession.execute(query).scalars() - ] + tracks = [TrackWithMetadata(track) for track in dbsession.execute(query).scalars()] # Step 2: Build the calendar days = [] @@ -340,7 +335,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} diff --git a/fietsboek/views/upload.py b/fietsboek/views/upload.py index 3eb1099..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 @@ -54,14 +53,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 +70,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 +108,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, 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) |
