aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--fietsboek/actions.py65
-rw-r--r--fietsboek/alembic/versions/20220808_d085998b49ca.py6
-rw-r--r--fietsboek/alembic/versions/20250607_2ebe1bf66430.py6
-rw-r--r--fietsboek/alembic/versions/20251019_90b39fdf6e4b.py46
-rw-r--r--fietsboek/convert.py126
-rw-r--r--fietsboek/data.py88
-rw-r--r--fietsboek/geo.py148
-rw-r--r--fietsboek/hittekaart.py18
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.mobin17892 -> 17985 bytes
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.po146
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.mobin16808 -> 16894 bytes
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.po146
-rw-r--r--fietsboek/locale/fietslog.pot144
-rw-r--r--fietsboek/models/__init__.py2
-rw-r--r--fietsboek/models/track.py175
-rw-r--r--fietsboek/scripts/fietscron.py11
-rw-r--r--fietsboek/templates/admin_overview.jinja26
-rw-r--r--fietsboek/trackmap.py13
-rw-r--r--fietsboek/transformers/__init__.py9
-rw-r--r--fietsboek/transformers/breaks.py57
-rw-r--r--fietsboek/transformers/elevation.py28
-rw-r--r--fietsboek/updater/scripts/upd_20251109_nm561argcq1s8w27.py162
-rw-r--r--fietsboek/util.py40
-rw-r--r--fietsboek/views/admin.py40
-rw-r--r--fietsboek/views/browse.py18
-rw-r--r--fietsboek/views/default.py2
-rw-r--r--fietsboek/views/detail.py26
-rw-r--r--fietsboek/views/edit.py25
-rw-r--r--fietsboek/views/profile.py13
-rw-r--r--fietsboek/views/upload.py28
-rw-r--r--tests/integration/test_browse.py12
-rw-r--r--tests/playwright/test_transformers.py19
-rw-r--r--tests/unit/test_util.py13
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
index 6526f6f..22982c0 100644
--- a/fietsboek/locale/de/LC_MESSAGES/messages.mo
+++ b/fietsboek/locale/de/LC_MESSAGES/messages.mo
Binary files differ
diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.po b/fietsboek/locale/de/LC_MESSAGES/messages.po
index f306a46..e9c65c6 100644
--- a/fietsboek/locale/de/LC_MESSAGES/messages.po
+++ b/fietsboek/locale/de/LC_MESSAGES/messages.po
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2025-06-12 22:39+0200\n"
+"POT-Creation-Date: 2025-11-01 15:25+0100\n"
"PO-Revision-Date: 2022-07-02 17:35+0200\n"
"Last-Translator: FULL NAME <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
index 18f473c..d54ad31 100644
--- a/fietsboek/locale/en/LC_MESSAGES/messages.mo
+++ b/fietsboek/locale/en/LC_MESSAGES/messages.mo
Binary files differ
diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.po b/fietsboek/locale/en/LC_MESSAGES/messages.po
index 89e183d..7ccdf1c 100644
--- a/fietsboek/locale/en/LC_MESSAGES/messages.po
+++ b/fietsboek/locale/en/LC_MESSAGES/messages.po
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2025-06-12 22:39+0200\n"
+"POT-Creation-Date: 2025-11-01 15:25+0100\n"
"PO-Revision-Date: 2023-04-03 20:42+0200\n"
"Last-Translator: \n"
"Language: en\n"
@@ -18,54 +18,54 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
-#: fietsboek/actions.py:278
+#: fietsboek/actions.py:265
msgid "email.verify_mail.subject"
msgstr "Fietsboek Account Verification"
-#: fietsboek/actions.py:281
+#: fietsboek/actions.py:268
msgid "email.verify.text"
msgstr ""
"To verify your Fietsboek account, please use this link: {}\n"
"\n"
"If you did not create an account, ignore this email."
-#: fietsboek/util.py:334
+#: fietsboek/util.py:297
msgid "password_constraint.mismatch"
msgstr "Passwords don't match"
-#: fietsboek/util.py:336
+#: fietsboek/util.py:299
msgid "password_constraint.length"
msgstr "Password not long enough"
-#: fietsboek/models/track.py:622
+#: fietsboek/models/track.py:725
msgid "tooltip.table.length"
msgstr "Length"
-#: fietsboek/models/track.py:623
+#: fietsboek/models/track.py:726
msgid "tooltip.table.people"
msgstr "# People"
-#: fietsboek/models/track.py:624
+#: fietsboek/models/track.py:727
msgid "tooltip.table.uphill"
msgstr "Uphill"
-#: fietsboek/models/track.py:625
+#: fietsboek/models/track.py:728
msgid "tooltip.table.downhill"
msgstr "Downhill"
-#: fietsboek/models/track.py:626 fietsboek/templates/home.jinja2:7
+#: fietsboek/models/track.py:729 fietsboek/templates/home.jinja2:7
msgid "tooltip.table.moving_time"
msgstr "Moving Time"
-#: fietsboek/models/track.py:627 fietsboek/templates/home.jinja2:8
+#: fietsboek/models/track.py:730 fietsboek/templates/home.jinja2:8
msgid "tooltip.table.stopped_time"
msgstr "Stopped Time"
-#: fietsboek/models/track.py:629
+#: fietsboek/models/track.py:732
msgid "tooltip.table.max_speed"
msgstr "Max Speed"
-#: fietsboek/models/track.py:633
+#: fietsboek/models/track.py:736
msgid "tooltip.table.avg_speed"
msgstr "Average Speed"
@@ -176,22 +176,26 @@ msgid "admin.overview.last_cronjob"
msgstr "Last cronjob"
#: fietsboek/templates/admin_overview.jinja2:55
-msgid "admin.overview.storage_graph.label.gpx"
-msgstr "GPX"
+msgid "admin.overview.storage_graph.label.track_data"
+msgstr "Track data"
#: fietsboek/templates/admin_overview.jinja2:56
+msgid "admin.overview.storage_graph.label.backups"
+msgstr "File backups"
+
+#: fietsboek/templates/admin_overview.jinja2:57
msgid "admin.overview.storage_graph.label.images"
msgstr "Images"
-#: fietsboek/templates/admin_overview.jinja2:57
+#: fietsboek/templates/admin_overview.jinja2:58
msgid "admin.overview.storage_graph.label.previews"
msgstr "Preview images"
-#: fietsboek/templates/admin_overview.jinja2:58
+#: fietsboek/templates/admin_overview.jinja2:59
msgid "admin.overview.storage_graph.label.user_maps"
msgstr "User maps"
-#: fietsboek/templates/admin_overview.jinja2:84
+#: fietsboek/templates/admin_overview.jinja2:86
msgid "admin.overview.storage_graph.title"
msgstr "Storage breakdown"
@@ -291,52 +295,52 @@ msgstr "This is a recording of a track"
msgid "page.browse.synthetic_tooltip"
msgstr "This is a pre-planned track"
-#: fietsboek/templates/browse.jinja2:162 fietsboek/templates/details.jinja2:103
+#: fietsboek/templates/browse.jinja2:162 fietsboek/templates/details.jinja2:120
#: fietsboek/templates/profile_overview.jinja2:20
msgid "page.details.date"
msgstr "Date"
-#: fietsboek/templates/browse.jinja2:164 fietsboek/templates/details.jinja2:117
+#: fietsboek/templates/browse.jinja2:164 fietsboek/templates/details.jinja2:134
#: fietsboek/templates/profile_overview.jinja2:22
msgid "page.details.length"
msgstr "Length"
-#: fietsboek/templates/browse.jinja2:169 fietsboek/templates/details.jinja2:108
+#: fietsboek/templates/browse.jinja2:169 fietsboek/templates/details.jinja2:125
#: fietsboek/templates/profile_overview.jinja2:26
msgid "page.details.start_time"
msgstr "Record Start"
-#: fietsboek/templates/browse.jinja2:171 fietsboek/templates/details.jinja2:112
+#: fietsboek/templates/browse.jinja2:171 fietsboek/templates/details.jinja2:129
#: fietsboek/templates/profile_overview.jinja2:28
msgid "page.details.end_time"
msgstr "Record End"
-#: fietsboek/templates/browse.jinja2:176 fietsboek/templates/details.jinja2:121
+#: fietsboek/templates/browse.jinja2:176 fietsboek/templates/details.jinja2:138
#: fietsboek/templates/profile_overview.jinja2:32
msgid "page.details.uphill"
msgstr "Uphill"
-#: fietsboek/templates/browse.jinja2:178 fietsboek/templates/details.jinja2:125
+#: fietsboek/templates/browse.jinja2:178 fietsboek/templates/details.jinja2:142
#: fietsboek/templates/profile_overview.jinja2:34
msgid "page.details.downhill"
msgstr "Downhill"
-#: fietsboek/templates/browse.jinja2:183 fietsboek/templates/details.jinja2:130
+#: fietsboek/templates/browse.jinja2:183 fietsboek/templates/details.jinja2:147
#: fietsboek/templates/profile_overview.jinja2:38
msgid "page.details.moving_time"
msgstr "Moving Time"
-#: fietsboek/templates/browse.jinja2:185 fietsboek/templates/details.jinja2:134
+#: fietsboek/templates/browse.jinja2:185 fietsboek/templates/details.jinja2:151
#: fietsboek/templates/profile_overview.jinja2:40
msgid "page.details.stopped_time"
msgstr "Stopped Time"
-#: fietsboek/templates/browse.jinja2:189 fietsboek/templates/details.jinja2:138
+#: fietsboek/templates/browse.jinja2:189 fietsboek/templates/details.jinja2:155
#: fietsboek/templates/profile_overview.jinja2:44
msgid "page.details.max_speed"
msgstr "Max Speed"
-#: fietsboek/templates/browse.jinja2:191 fietsboek/templates/details.jinja2:142
+#: fietsboek/templates/browse.jinja2:191 fietsboek/templates/details.jinja2:159
#: fietsboek/templates/profile_overview.jinja2:46
msgid "page.details.avg_speed"
msgstr "Average Speed"
@@ -409,88 +413,88 @@ msgstr "Repeat password"
msgid "page.create_account.create"
msgstr "Create"
-#: fietsboek/templates/details.jinja2:7
+#: fietsboek/templates/details.jinja2:24
msgid "page.details.title"
msgstr "Track Details"
-#: fietsboek/templates/details.jinja2:20
+#: fietsboek/templates/details.jinja2:37
msgid "page.details.edit"
msgstr "Edit"
-#: fietsboek/templates/details.jinja2:21
+#: fietsboek/templates/details.jinja2:38
msgid "page.details.share"
msgstr "Share"
-#: fietsboek/templates/details.jinja2:22
+#: fietsboek/templates/details.jinja2:39
msgid "page.details.delete"
msgstr "Delete"
-#: fietsboek/templates/details.jinja2:28
+#: fietsboek/templates/details.jinja2:45
msgid "page.details.sharelink.title"
msgstr "Share Link"
-#: fietsboek/templates/details.jinja2:32
+#: fietsboek/templates/details.jinja2:49
msgid "page.details.sharelink.info"
msgstr "Everyone with access to this link can view the track!"
-#: fietsboek/templates/details.jinja2:39
+#: fietsboek/templates/details.jinja2:56
msgid "page.details.sharelink.invalidate"
msgstr "Invalidate link"
-#: fietsboek/templates/details.jinja2:41
+#: fietsboek/templates/details.jinja2:58
msgid "page.details.sharelink.close"
msgstr "Close"
-#: fietsboek/templates/details.jinja2:51
+#: fietsboek/templates/details.jinja2:68
msgid "page.details.delete.title"
msgstr "Delete Track"
-#: fietsboek/templates/details.jinja2:55
+#: fietsboek/templates/details.jinja2:72
msgid "page.details.delete.info"
msgstr "Deleting this track will remove all associated information with it!"
-#: fietsboek/templates/details.jinja2:60
+#: fietsboek/templates/details.jinja2:77
msgid "page.details.delete.delete"
msgstr "Delete"
-#: fietsboek/templates/details.jinja2:62
+#: fietsboek/templates/details.jinja2:79
msgid "page.details.delete.close"
msgstr "Abort"
-#: fietsboek/templates/details.jinja2:81
+#: fietsboek/templates/details.jinja2:98
msgid "page.details.tags"
msgstr "Tagged as"
-#: fietsboek/templates/details.jinja2:91 fietsboek/templates/edit.jinja2:10
+#: fietsboek/templates/details.jinja2:108 fietsboek/templates/edit.jinja2:10
#: fietsboek/templates/finish_upload.jinja2:10
msgid "page.noscript"
msgstr "JavaScript is disabled, please enable JavaScript"
-#: fietsboek/templates/details.jinja2:97
+#: fietsboek/templates/details.jinja2:114
msgid "page.details.download"
msgstr "Download Tour"
-#: fietsboek/templates/details.jinja2:187
+#: fietsboek/templates/details.jinja2:204
msgid "page.details.comments"
msgstr "Comments"
-#: fietsboek/templates/details.jinja2:191
+#: fietsboek/templates/details.jinja2:208
msgid "page.details.comments.author"
msgstr "Comment by {}"
-#: fietsboek/templates/details.jinja2:208
+#: fietsboek/templates/details.jinja2:225
msgid "page.details.comments.new.title"
msgstr "Create a new comment"
-#: fietsboek/templates/details.jinja2:211
+#: fietsboek/templates/details.jinja2:228
msgid "page.details.comments.new.input_title"
msgstr "Title"
-#: fietsboek/templates/details.jinja2:212
+#: fietsboek/templates/details.jinja2:229
msgid "page.details.comments.new.input_comment"
msgstr "Comment"
-#: fietsboek/templates/details.jinja2:215
+#: fietsboek/templates/details.jinja2:232
msgid "page.details.comments.new.submit"
msgstr "Submit"
@@ -658,51 +662,51 @@ msgstr[1] "%(num)d tracks"
msgid "page.home.total"
msgstr "Total"
-#: fietsboek/templates/layout.jinja2:43
+#: fietsboek/templates/layout.jinja2:44
msgid "page.navbar.toggle"
msgstr "Toggle navigation"
-#: fietsboek/templates/layout.jinja2:54
+#: fietsboek/templates/layout.jinja2:55
msgid "page.navbar.home"
msgstr "Home"
-#: fietsboek/templates/layout.jinja2:57
+#: fietsboek/templates/layout.jinja2:58
msgid "page.navbar.browse"
msgstr "Browse"
-#: fietsboek/templates/layout.jinja2:61
+#: fietsboek/templates/layout.jinja2:62
msgid "page.navbar.upload"
msgstr "Upload"
-#: fietsboek/templates/layout.jinja2:70
+#: fietsboek/templates/layout.jinja2:71
msgid "page.navbar.user"
msgstr "User"
-#: fietsboek/templates/layout.jinja2:74
+#: fietsboek/templates/layout.jinja2:75
msgid "page.navbar.welcome_user"
msgstr "Welcome, {}!"
-#: fietsboek/templates/layout.jinja2:77
+#: fietsboek/templates/layout.jinja2:78
msgid "page.navbar.logout"
msgstr "Logout"
-#: fietsboek/templates/layout.jinja2:80
+#: fietsboek/templates/layout.jinja2:81
msgid "page.navbar.profile"
msgstr "Profile"
-#: fietsboek/templates/layout.jinja2:83
+#: fietsboek/templates/layout.jinja2:84
msgid "page.navbar.user_data"
msgstr "Personal Data"
-#: fietsboek/templates/layout.jinja2:87
+#: fietsboek/templates/layout.jinja2:88
msgid "page.navbar.admin"
msgstr "Admin"
-#: fietsboek/templates/layout.jinja2:93
+#: fietsboek/templates/layout.jinja2:94
msgid "page.navbar.login"
msgstr "Login"
-#: fietsboek/templates/layout.jinja2:97
+#: fietsboek/templates/layout.jinja2:98
msgid "page.navbar.create_account"
msgstr "Create Account"
@@ -968,11 +972,11 @@ msgstr "Fix null elevation"
msgid "transformers.fix-null-elevation.description"
msgstr "This transformer fixes the elevation of points whose elevation is unset."
-#: fietsboek/transformers/elevation.py:116
+#: fietsboek/transformers/elevation.py:109
msgid "transformers.fix-elevation-jumps"
msgstr "Fix elevation jumps"
-#: fietsboek/transformers/elevation.py:120
+#: fietsboek/transformers/elevation.py:113
msgid "transformers.fix-elevation-jumps.description"
msgstr "This transformer fixes abrupt jumps in the elevation value."
@@ -988,15 +992,15 @@ msgstr "Invalid email"
msgid "flash.a_confirmation_link_has_been_sent"
msgstr "A confirmation link has been sent"
-#: fietsboek/views/admin.py:156
+#: fietsboek/views/admin.py:168
msgid "flash.badge_added"
msgstr "Badge has been added"
-#: fietsboek/views/admin.py:180
+#: fietsboek/views/admin.py:192
msgid "flash.badge_modified"
msgstr "Badge has been modified"
-#: fietsboek/views/admin.py:200
+#: fietsboek/views/admin.py:212
msgid "flash.badge_deleted"
msgstr "Badge has been deleted"
@@ -1055,23 +1059,23 @@ msgstr "Your email address has been verified"
msgid "flash.password_updated"
msgstr "Password has been updated"
-#: fietsboek/views/detail.py:166
+#: fietsboek/views/detail.py:189
msgid "flash.track_deleted"
msgstr "Track has been deleted"
-#: fietsboek/views/edit.py:100 fietsboek/views/upload.py:66
+#: fietsboek/views/edit.py:97 fietsboek/views/upload.py:63
msgid "flash.invalid_file"
msgstr "Invalid GPX file selected"
-#: fietsboek/views/upload.py:54
+#: fietsboek/views/upload.py:53
msgid "flash.no_file_selected"
msgstr "No file selected"
-#: fietsboek/views/upload.py:194
+#: fietsboek/views/upload.py:182
msgid "flash.upload_success"
msgstr "Upload successful"
-#: fietsboek/views/upload.py:213
+#: fietsboek/views/upload.py:201
msgid "flash.upload_cancelled"
msgstr "Upload cancelled"
diff --git a/fietsboek/locale/fietslog.pot b/fietsboek/locale/fietslog.pot
index 6383760..cedd3ac 100644
--- a/fietsboek/locale/fietslog.pot
+++ b/fietsboek/locale/fietslog.pot
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2025-06-12 22:39+0200\n"
+"POT-Creation-Date: 2025-11-01 15:25+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <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)