aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--doc/index.rst1
-rw-r--r--doc/user.rst12
-rw-r--r--doc/user/images/fixed_elevation.pngbin0 -> 8658 bytes
-rw-r--r--doc/user/images/wrong_elevation.pngbin0 -> 7824 bytes
-rw-r--r--doc/user/transformers.rst45
-rw-r--r--fietsboek/__init__.py2
-rw-r--r--fietsboek/actions.py79
-rw-r--r--fietsboek/alembic/versions/20230203_3149aa2d0114.py22
-rw-r--r--fietsboek/data.py12
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.mobin11855 -> 12157 bytes
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.po41
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.mobin11163 -> 11441 bytes
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.po39
-rw-r--r--fietsboek/locale/fietslog.pot39
-rw-r--r--fietsboek/models/track.py23
-rw-r--r--fietsboek/templates/details.jinja220
-rw-r--r--fietsboek/templates/edit_form.jinja248
-rw-r--r--fietsboek/transformers/__init__.py264
-rw-r--r--fietsboek/util.py9
-rw-r--r--fietsboek/views/edit.py2
-rw-r--r--fietsboek/views/upload.py3
-rw-r--r--pylint.toml2
-rw-r--r--tests/assets/Synthetic_Steep_Slope.gpx.gzbin0 -> 883 bytes
-rw-r--r--tests/assets/Synthetic_Zero_Elevation.gpx.gzbin0 -> 791 bytes
-rw-r--r--tests/playwright/conftest.py1
-rw-r--r--tests/playwright/test_basic.py4
-rw-r--r--tests/playwright/test_transformers.py130
-rw-r--r--tests/testutils.py24
28 files changed, 752 insertions, 70 deletions
diff --git a/doc/index.rst b/doc/index.rst
index 9ce95b9..0b69ef1 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -12,6 +12,7 @@ Welcome to Fietsboek's documentation!
administration
developer
+ user
.. toctree::
:maxdepth: 1
diff --git a/doc/user.rst b/doc/user.rst
new file mode 100644
index 0000000..ab210dc
--- /dev/null
+++ b/doc/user.rst
@@ -0,0 +1,12 @@
+User Guide
+==========
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Contents
+
+ user/transformers
+
+This is the user guide for Fietsboek! In here, you can find information that
+might be interesting if you plan on sharing your tracks on a Fietsboek
+instance.
diff --git a/doc/user/images/fixed_elevation.png b/doc/user/images/fixed_elevation.png
new file mode 100644
index 0000000..10d34c7
--- /dev/null
+++ b/doc/user/images/fixed_elevation.png
Binary files differ
diff --git a/doc/user/images/wrong_elevation.png b/doc/user/images/wrong_elevation.png
new file mode 100644
index 0000000..2e8557b
--- /dev/null
+++ b/doc/user/images/wrong_elevation.png
Binary files differ
diff --git a/doc/user/transformers.rst b/doc/user/transformers.rst
new file mode 100644
index 0000000..c8ddf01
--- /dev/null
+++ b/doc/user/transformers.rst
@@ -0,0 +1,45 @@
+Transformers
+============
+
+Transformers are small (or big) transformations that are applied to your GPX
+track after it has been uploaded. This allows Fietsboek to provide some common,
+simple editing options, without users having to do that themselves.
+
+All transformers are optional and disabled by default. You can enable
+transformers when uploading a track on the bottom of the page. You can also
+enable transformers for already uploaded tracks in the editing view.
+
+.. note::
+
+ When enabling transformers for already existing tracks, your browser might
+ not show the changed track. Try force-refreshing (Crtl + F5) the page so
+ that it reloads the GPX from the server.
+
+In other applications, transformers are sometimes called "filters". That term
+however has many different meanings (like the filters on the "Browse" page),
+and as such, Fietsboek calls them transformers.
+
+Fix Null Elevation
+------------------
+
+The *fix null elevation* transformer removes points at the start and end of a
+track that have a "wrong" elevation. This helps to avoid issues when GPX
+trackers don't have elevation data yet and fill in 0, leading to wrong uphill
+calculations and wrong diagrams:
+
+.. image:: images/wrong_elevation.png
+ :width: 200
+ :alt: An elevation graph that starts at 0 and makes a jump to 165.
+
+Activating the transformer will produce the following track:
+
+.. image:: images/fixed_elevation.png
+ :width: 200
+ :alt: The same track, but with a fixed elevation graph that starts at 165.
+
+The transformer considers "wrong" elevation to be either points that have an
+elevation of 0, or points that have a slope of more than 100% to the next
+point.
+
+To fix those points, the transformer will find the first correct point, and
+copy its elevation to the wrong points.
diff --git a/fietsboek/__init__.py b/fietsboek/__init__.py
index 98e7b99..41e8a04 100644
--- a/fietsboek/__init__.py
+++ b/fietsboek/__init__.py
@@ -29,6 +29,7 @@ from pyramid.session import SignedCookieSessionFactory
from . import config as mod_config
from . import jinja2 as mod_jinja2
+from . import transformers
from .data import DataManager
from .pages import Pages
from .security import SecurityPolicy
@@ -145,5 +146,6 @@ def main(_global_config, **settings):
jinja2_env.filters["format_datetime"] = mod_jinja2.filter_format_datetime
jinja2_env.filters["local_datetime"] = mod_jinja2.filter_local_datetime
jinja2_env.globals["embed_tile_layers"] = mod_jinja2.global_embed_tile_layers
+ jinja2_env.globals["list_transformers"] = transformers.list_transformers
return config.make_wsgi_app()
diff --git a/fietsboek/actions.py b/fietsboek/actions.py
index bba4185..e49e27d 100644
--- a/fietsboek/actions.py
+++ b/fietsboek/actions.py
@@ -10,13 +10,17 @@ import logging
import re
from typing import List, Optional
+import brotli
+import gpxpy
from pyramid.request import Request
from sqlalchemy import select
from sqlalchemy.orm.session import Session
-from fietsboek import models, util
-from fietsboek.data import DataManager, TrackDataDir
-from fietsboek.models.track import TrackType, Visibility
+from . import models, util
+from . import transformers as mod_transformers
+from . import util
+from .data import DataManager, TrackDataDir
+from .models.track import TrackType, Visibility
LOGGER = logging.getLogger(__name__)
@@ -33,6 +37,7 @@ def add_track(
badges: List[models.Badge],
tagged_people: List[models.User],
tags: List[str],
+ transformers: list[mod_transformers.Transformer],
gpx_data: bytes,
) -> models.Track:
"""Adds a track to the database.
@@ -54,11 +59,12 @@ def add_track(
:param badges: Badges to attach to the track.
:param tagged_people: List of people to tag.
:param tags: List of text tags for the track.
+ :param transformers: List of :class:`~fietsboek.transformers.Transformer` to apply.
:param gpx_data: Actual GPX data (uncompressed, straight from the source).
:return: The track object that has been inserted into the database. Useful
for its ``id`` attribute.
"""
- # pylint: disable=too-many-arguments
+ # pylint: disable=too-many-arguments,too-many-locals
LOGGER.debug("Inserting new track...")
track = models.Track(
owner=owner,
@@ -75,10 +81,6 @@ def add_track(
dbsession.add(track)
dbsession.flush()
- # Best time to build the cache is right after the upload
- track.ensure_cache(gpx_data)
- dbsession.add(track.cache)
-
# Save the GPX data
LOGGER.debug("Creating a new data folder for %d", track.id)
with data_manager.initialize(track.id) as manager:
@@ -86,11 +88,23 @@ def add_track(
manager.compress_gpx(gpx_data)
manager.backup()
+ gpx = gpxpy.parse(gpx_data)
+ for transformer in transformers:
+ LOGGER.debug("Running %s with %r", transformer, transformer.parameters)
+ transformer.execute(gpx)
+ track.transformers = [[tfm.identifier(), tfm.parameters.dict()] for tfm in transformers]
+
+ # Best time to build the cache is right after the upload, but *after* the
+ # transformers have been applied!
+ track.ensure_cache(gpx)
+ dbsession.add(track.cache)
+
manager.engrave_metadata(
title=track.title,
description=track.description,
author_name=track.owner.name,
time=track.date,
+ gpx=gpx,
)
return track
@@ -156,3 +170,52 @@ def edit_images(request: Request, track: models.Track, *, manager: Optional[Trac
image_meta = models.ImageMetadata.get_or_create(request.dbsession, track, image_id)
image_meta.description = description
request.dbsession.add(image_meta)
+
+
+def execute_transformers(request: Request, track: models.Track) -> Optional[gpxpy.gpx.GPX]:
+ """Execute the transformers for the given track.
+
+ Note that this function "short circuits" if the saved transformer settings
+ already match the settings given in the request.
+
+ This function saves the modified data, but does also return it in case you
+ need to do further processing (unless no transformations have taken place).
+
+ :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)
+
+ settings = mod_transformers.extract_from_request(request)
+
+ serialized = [[tfm.identifier(), tfm.parameters.dict()] for tfm in settings]
+ if serialized == track.transformers:
+ LOGGER.debug("Applied transformations match on %d, skipping", track.id)
+ return None
+
+ # 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)
+
+ for transformer in settings:
+ LOGGER.debug("Running %s with %r", transformer, transformer.parameters)
+ transformer.execute(gpx)
+
+ LOGGER.debug("Saving transformed file for %d", track.id)
+ manager.compress_gpx(gpx.to_xml().encode("utf-8"))
+
+ LOGGER.debug("Saving new transformers on %d", track.id)
+ track.transformers = serialized
+
+ LOGGER.debug("Rebuilding cache for %d", track.id)
+ request.dbsession.delete(track.cache)
+ track.cache = None
+ track.ensure_cache(gpx)
+ request.dbsession.add(track.cache)
+ return gpx
diff --git a/fietsboek/alembic/versions/20230203_3149aa2d0114.py b/fietsboek/alembic/versions/20230203_3149aa2d0114.py
new file mode 100644
index 0000000..eb9ef78
--- /dev/null
+++ b/fietsboek/alembic/versions/20230203_3149aa2d0114.py
@@ -0,0 +1,22 @@
+"""add transformer column
+
+Revision ID: 3149aa2d0114
+Revises: c939800af428
+Create Date: 2023-02-03 21:44:39.429564
+
+"""
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = '3149aa2d0114'
+down_revision = 'c939800af428'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ op.add_column('tracks', sa.Column('transformers', sa.JSON(), nullable=True))
+ op.execute('UPDATE tracks SET transformers="[]";')
+
+def downgrade():
+ op.drop_column('tracks', 'transformers')
diff --git a/fietsboek/data.py b/fietsboek/data.py
index cbe20e9..9f49c49 100644
--- a/fietsboek/data.py
+++ b/fietsboek/data.py
@@ -256,7 +256,13 @@ class TrackDataDir:
return brotli.decompress(self.gpx_path().read_bytes())
def engrave_metadata(
- self, title: str, description: str, author_name: str, time: datetime.datetime
+ self,
+ title: str,
+ description: str,
+ author_name: str,
+ time: datetime.datetime,
+ *,
+ gpx: Optional[gpxpy.gpx.GPX] = None,
):
"""Engrave the given metadata into the GPX file.
@@ -266,8 +272,10 @@ class TrackDataDir:
: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.
"""
- gpx = gpxpy.parse(self.decompress_gpx())
+ if gpx is None:
+ gpx = gpxpy.parse(self.decompress_gpx())
# First we delete the existing metadata
for track in gpx.tracks:
track.name = None
diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.mo b/fietsboek/locale/de/LC_MESSAGES/messages.mo
index 75b0abe..a79349d 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 8f6d9f2..8b59ecb 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: 2023-02-06 19:52+0100\n"
+"POT-Creation-Date: 2023-03-07 20:11+0100\n"
"PO-Revision-Date: 2022-07-02 17:35+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de\n"
@@ -18,39 +18,39 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.11.0\n"
-#: fietsboek/util.py:273
+#: fietsboek/util.py:276
msgid "password_constraint.mismatch"
msgstr "Passwörter stimmen nicht überein"
-#: fietsboek/util.py:275
+#: fietsboek/util.py:278
msgid "password_constraint.length"
msgstr "Passwort zu kurz"
-#: fietsboek/models/track.py:545
+#: fietsboek/models/track.py:566
msgid "tooltip.table.length"
msgstr "Länge"
-#: fietsboek/models/track.py:546
+#: fietsboek/models/track.py:567
msgid "tooltip.table.uphill"
msgstr "Bergauf"
-#: fietsboek/models/track.py:547
+#: fietsboek/models/track.py:568
msgid "tooltip.table.downhill"
msgstr "Bergab"
-#: fietsboek/models/track.py:548
+#: fietsboek/models/track.py:569
msgid "tooltip.table.moving_time"
msgstr "Fahrzeit"
-#: fietsboek/models/track.py:549
+#: fietsboek/models/track.py:570
msgid "tooltip.table.stopped_time"
msgstr "Haltezeit"
-#: fietsboek/models/track.py:551
+#: fietsboek/models/track.py:572
msgid "tooltip.table.max_speed"
msgstr "Maximalgeschwindigkeit"
-#: fietsboek/models/track.py:555
+#: fietsboek/models/track.py:576
msgid "tooltip.table.avg_speed"
msgstr "Durchschnittsgeschwindigkeit"
@@ -438,6 +438,10 @@ msgstr "Bildbeschreibung"
msgid "page.track.form.image_description_modal.save"
msgstr "Übernehmen"
+#: fietsboek/templates/edit_form.jinja2:166
+msgid "page.track.form.transformer.enable"
+msgstr "Transformation anwenden"
+
#: fietsboek/templates/finish_upload.jinja2:8
#: fietsboek/templates/upload.jinja2:6
msgid "page.upload.title"
@@ -456,7 +460,8 @@ msgstr "Abbrechen"
msgid "page.home.title"
msgstr "Startseite"
-#: fietsboek/templates/home.jinja2:12
+#: fietsboek/templates/home.jinja2:12 fietsboek/templates/home.jinja2:19
+#: fietsboek/templates/home.jinja2:37
msgid "page.home.summary.track"
msgid_plural "page.home.summary.tracks"
msgstr[0] "%(num)d Strecke"
@@ -632,6 +637,16 @@ msgstr "Anfrage senden"
msgid "page.upload.form.gpx"
msgstr "GPX Datei"
+#: fietsboek/transformers/__init__.py:140
+msgid "transformers.fix-null-elevation.title"
+msgstr "Nullhöhen beheben"
+
+#: fietsboek/transformers/__init__.py:144
+msgid "transformers.fix-null-elevation.description"
+msgstr ""
+"Diese Transformation passt die Höhenangabe für Punkte an, bei denen die "
+"Höhenangabe fehlt."
+
#: fietsboek/views/account.py:54
msgid "flash.invalid_name"
msgstr "Ungültiger Name"
@@ -743,11 +758,11 @@ msgstr "Keine Datei ausgewählt"
msgid "flash.invalid_file"
msgstr "Ungültige GPX-Datei gesendet"
-#: fietsboek/views/upload.py:181
+#: fietsboek/views/upload.py:188
msgid "flash.upload_success"
msgstr "Hochladen erfolgreich"
-#: fietsboek/views/upload.py:197
+#: fietsboek/views/upload.py:204
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 7a7d215..13debb5 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 c1f17a8..632e0b7 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: 2023-02-06 19:52+0100\n"
+"POT-Creation-Date: 2023-03-07 20:11+0100\n"
"PO-Revision-Date: 2022-06-28 13:11+0200\n"
"Last-Translator: \n"
"Language: en\n"
@@ -18,39 +18,39 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.11.0\n"
-#: fietsboek/util.py:273
+#: fietsboek/util.py:276
msgid "password_constraint.mismatch"
msgstr "Passwords don't match"
-#: fietsboek/util.py:275
+#: fietsboek/util.py:278
msgid "password_constraint.length"
msgstr "Password not long enough"
-#: fietsboek/models/track.py:545
+#: fietsboek/models/track.py:566
msgid "tooltip.table.length"
msgstr "Length"
-#: fietsboek/models/track.py:546
+#: fietsboek/models/track.py:567
msgid "tooltip.table.uphill"
msgstr "Uphill"
-#: fietsboek/models/track.py:547
+#: fietsboek/models/track.py:568
msgid "tooltip.table.downhill"
msgstr "Downhill"
-#: fietsboek/models/track.py:548
+#: fietsboek/models/track.py:569
msgid "tooltip.table.moving_time"
msgstr "Moving Time"
-#: fietsboek/models/track.py:549
+#: fietsboek/models/track.py:570
msgid "tooltip.table.stopped_time"
msgstr "Stopped Time"
-#: fietsboek/models/track.py:551
+#: fietsboek/models/track.py:572
msgid "tooltip.table.max_speed"
msgstr "Max Speed"
-#: fietsboek/models/track.py:555
+#: fietsboek/models/track.py:576
msgid "tooltip.table.avg_speed"
msgstr "Average Speed"
@@ -434,6 +434,10 @@ msgstr "Image description"
msgid "page.track.form.image_description_modal.save"
msgstr "Apply"
+#: fietsboek/templates/edit_form.jinja2:166
+msgid "page.track.form.transformer.enable"
+msgstr "Apply transformation"
+
#: fietsboek/templates/finish_upload.jinja2:8
#: fietsboek/templates/upload.jinja2:6
msgid "page.upload.title"
@@ -452,7 +456,8 @@ msgstr "Cancel"
msgid "page.home.title"
msgstr "Home"
-#: fietsboek/templates/home.jinja2:12
+#: fietsboek/templates/home.jinja2:12 fietsboek/templates/home.jinja2:19
+#: fietsboek/templates/home.jinja2:37
msgid "page.home.summary.track"
msgid_plural "page.home.summary.tracks"
msgstr[0] "%(num)d track"
@@ -628,6 +633,14 @@ msgstr "Send request"
msgid "page.upload.form.gpx"
msgstr "GPX file"
+#: fietsboek/transformers/__init__.py:140
+msgid "transformers.fix-null-elevation.title"
+msgstr "Fix null elevation"
+
+#: fietsboek/transformers/__init__.py:144
+msgid "transformers.fix-null-elevation.description"
+msgstr "This transformer fixes the elevation of points whose elevation is unset."
+
#: fietsboek/views/account.py:54
msgid "flash.invalid_name"
msgstr "Invalid name"
@@ -738,11 +751,11 @@ msgstr "No file selected"
msgid "flash.invalid_file"
msgstr "Invalid GPX file selected"
-#: fietsboek/views/upload.py:181
+#: fietsboek/views/upload.py:188
msgid "flash.upload_success"
msgstr "Upload successful"
-#: fietsboek/views/upload.py:197
+#: fietsboek/views/upload.py:204
msgid "flash.upload_cancelled"
msgstr "Upload cancelled"
diff --git a/fietsboek/locale/fietslog.pot b/fietsboek/locale/fietslog.pot
index f4e3d01..70f010a 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: 2023-02-06 19:52+0100\n"
+"POT-Creation-Date: 2023-03-07 20:11+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,39 +17,39 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.11.0\n"
-#: fietsboek/util.py:273
+#: fietsboek/util.py:276
msgid "password_constraint.mismatch"
msgstr ""
-#: fietsboek/util.py:275
+#: fietsboek/util.py:278
msgid "password_constraint.length"
msgstr ""
-#: fietsboek/models/track.py:545
+#: fietsboek/models/track.py:566
msgid "tooltip.table.length"
msgstr ""
-#: fietsboek/models/track.py:546
+#: fietsboek/models/track.py:567
msgid "tooltip.table.uphill"
msgstr ""
-#: fietsboek/models/track.py:547
+#: fietsboek/models/track.py:568
msgid "tooltip.table.downhill"
msgstr ""
-#: fietsboek/models/track.py:548
+#: fietsboek/models/track.py:569
msgid "tooltip.table.moving_time"
msgstr ""
-#: fietsboek/models/track.py:549
+#: fietsboek/models/track.py:570
msgid "tooltip.table.stopped_time"
msgstr ""
-#: fietsboek/models/track.py:551
+#: fietsboek/models/track.py:572
msgid "tooltip.table.max_speed"
msgstr ""
-#: fietsboek/models/track.py:555
+#: fietsboek/models/track.py:576
msgid "tooltip.table.avg_speed"
msgstr ""
@@ -431,6 +431,10 @@ msgstr ""
msgid "page.track.form.image_description_modal.save"
msgstr ""
+#: fietsboek/templates/edit_form.jinja2:166
+msgid "page.track.form.transformer.enable"
+msgstr ""
+
#: fietsboek/templates/finish_upload.jinja2:8
#: fietsboek/templates/upload.jinja2:6
msgid "page.upload.title"
@@ -449,7 +453,8 @@ msgstr ""
msgid "page.home.title"
msgstr ""
-#: fietsboek/templates/home.jinja2:12
+#: fietsboek/templates/home.jinja2:12 fietsboek/templates/home.jinja2:19
+#: fietsboek/templates/home.jinja2:37
msgid "page.home.summary.track"
msgid_plural "page.home.summary.tracks"
msgstr[0] ""
@@ -623,6 +628,14 @@ msgstr ""
msgid "page.upload.form.gpx"
msgstr ""
+#: fietsboek/transformers/__init__.py:140
+msgid "transformers.fix-null-elevation.title"
+msgstr ""
+
+#: fietsboek/transformers/__init__.py:144
+msgid "transformers.fix-null-elevation.description"
+msgstr ""
+
#: fietsboek/views/account.py:54
msgid "flash.invalid_name"
msgstr ""
@@ -727,11 +740,11 @@ msgstr ""
msgid "flash.invalid_file"
msgstr ""
-#: fietsboek/views/upload.py:181
+#: fietsboek/views/upload.py:188
msgid "flash.upload_success"
msgstr ""
-#: fietsboek/views/upload.py:197
+#: fietsboek/views/upload.py:204
msgid "flash.upload_cancelled"
msgstr ""
diff --git a/fietsboek/models/track.py b/fietsboek/models/track.py
index c5e4c48..e0d2820 100644
--- a/fietsboek/models/track.py
+++ b/fietsboek/models/track.py
@@ -18,6 +18,7 @@ import logging
from itertools import chain
from typing import TYPE_CHECKING, List, Optional, Set, Union
+import gpxpy
from babel.numbers import format_decimal
from markupsafe import Markup
from pyramid.authorization import (
@@ -32,6 +33,7 @@ from pyramid.httpexceptions import HTTPNotFound
from pyramid.i18n import Localizer
from pyramid.i18n import TranslationString as _
from sqlalchemy import (
+ JSON,
Column,
DateTime,
Enum,
@@ -174,6 +176,8 @@ class Track(Base):
:vartype link_secret: str
:ivar type: Type of the track
:vartype type: TrackType
+ :ivar transformers: The enabled transformers together with their parameters.
+ :vartype transformers: list[tuple[str, dict]]
:ivar owner: Owner of the track.
:vartype owner: fietsboek.models.user.User
:ivar cache: Cache for the computed track metadata.
@@ -198,6 +202,7 @@ class Track(Base):
visibility = Column(Enum(Visibility))
link_secret = Column(Text)
type = Column(Enum(TrackType))
+ transformers = Column(JSON)
owner = relationship("User", back_populates="tracks")
cache = relationship(
@@ -326,7 +331,7 @@ class Track(Base):
result = ACLHelper().permits(self, principals, "track.view")
return isinstance(result, ACLAllowed)
- def ensure_cache(self, gpx_data: Union[str, bytes]):
+ 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.
@@ -403,6 +408,22 @@ class Track(Base):
for i in to_delete[::-1]:
del self.tags[i]
+ def transformer_params_for(self, transformer_id: str) -> Optional[dict]:
+ """Returns the transformer parameters for the given transformer.
+
+ If the transformer is not active, returns ``None``.
+
+ :param transformer_id: The string ID of the transformer.
+ :return: The settings as a dictionary.
+ """
+ if not self.transformers:
+ return None
+
+ for t_id, settings in self.transformers:
+ if t_id == transformer_id:
+ return settings
+ return None
+
class TrackWithMetadata:
"""A class to add metadata to a :class:`Track`.
diff --git a/fietsboek/templates/details.jinja2 b/fietsboek/templates/details.jinja2
index 2ff492c..7c1ba8c 100644
--- a/fietsboek/templates/details.jinja2
+++ b/fietsboek/templates/details.jinja2
@@ -88,46 +88,46 @@
<tbody>
<tr>
<th scope="row">{{ _("page.details.date") }}</th>
- <td>{{ track.date | format_datetime }}</td>
+ <td id="detailsDate">{{ track.date | format_datetime }}</td>
</tr>
{% if show_organic %}
<tr>
<th scope="row">{{ _("page.details.start_time") }}</th>
- <td>{{ track.start_time | format_datetime }}</td>
+ <td id="detailsStartTime">{{ track.start_time | format_datetime }}</td>
</tr>
<tr>
<th scope="row">{{ _("page.details.end_time") }}</th>
- <td>{{ track.end_time | format_datetime }}</td>
+ <td id="detailsEndTime">{{ track.end_time | format_datetime }}</td>
</tr>
{% endif %}
<tr>
<th scope="row">{{ _("page.details.length") }}</th>
- <td>{{ (track.length / 1000) | round(2) | format_decimal }} km</td>
+ <td id="detailsLength">{{ (track.length / 1000) | round(2) | format_decimal }} km</td>
</tr>
<tr>
<th scope="row">{{ _("page.details.uphill") }}</th>
- <td>{{ track.uphill | round(2) | format_decimal }} m</td>
+ <td id="detailsUphill">{{ track.uphill | round(2) | format_decimal }} m</td>
</tr>
<tr>
<th scope="row">{{ _("page.details.downhill") }}</th>
- <td>{{ track.downhill | round(2) | format_decimal }} m</td>
+ <td id="detailsDownhill">{{ track.downhill | round(2) | format_decimal }} m</td>
</tr>
{% if show_organic %}
<tr>
<th scope="row">{{ _("page.details.moving_time") }}</th>
- <td>{{ track.moving_time }}</td>
+ <td id="detailsMovingTime">{{ track.moving_time }}</td>
</tr>
<tr>
<th scope="row">{{ _("page.details.stopped_time") }}</th>
- <td>{{ track.stopped_time }}</td>
+ <td id="detailsStoppedTime">{{ track.stopped_time }}</td>
</tr>
<tr>
<th scope="row">{{ _("page.details.max_speed") }}</th>
- <td>{{ mps_to_kph(track.max_speed) | round(2) | format_decimal }} km/h</td>
+ <td id="detailsMaxSpeed">{{ mps_to_kph(track.max_speed) | round(2) | format_decimal }} km/h</td>
</tr>
<tr>
<th scope="row">{{ _("page.details.avg_speed") }}</th>
- <td>{{ mps_to_kph(track.avg_speed) | round(2) | format_decimal }} km/h</td>
+ <td id="detailsAvgSpeed">{{ mps_to_kph(track.avg_speed) | round(2) | format_decimal }} km/h</td>
</tr>
{% endif %}
</tbody>
diff --git a/fietsboek/templates/edit_form.jinja2 b/fietsboek/templates/edit_form.jinja2
index bfd45a1..1abe076 100644
--- a/fietsboek/templates/edit_form.jinja2
+++ b/fietsboek/templates/edit_form.jinja2
@@ -142,4 +142,52 @@
</div>
</div>
</div>
+
+<div class="mb-3">
+ <div class="accordion accordion-flush">
+ {% for transformer in list_transformers() %}
+ <div class="accordion-item">
+ <h2 class="accordion-header" id="transformer-heading-{{ loop.index }}">
+ <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#transformer-{{ loop.index }}" aria-expanded="true" aria-controls="transformer-{{ loop.index }}">
+ {{ _(transformer.name()) }}
+ </button>
+ </h2>
+ </div>
+ <div id="transformer-{{ loop.index}}" class="accordion-collapse collapse" aria-labelledby="transformer-heading-{{ loop.index }}">
+ <div class="accordion-body">
+ {% set params = track.transformer_params_for(transformer.identifier()) if track else None %}
+ <!-- Short transformer description -->
+ <p>{{ _(transformer.description()) }}</p>
+
+ <!-- Checkbox to enable the transformer -->
+ <div class="form-check">
+ <input class="form-check-input" type="checkbox" value="on" id="transformer-enabled-{{ loop.index }}" name="transformer[{{ transformer.identifier() }}]"{% if params is not none %} checked{% endif %}>
+ <label class="form-check-label" for="transformer-enabled-{{ loop.index }}">
+ {{ _("page.track.form.transformer.enable") }}
+ </label>
+ </div>
+
+ <!-- Parameters as defined by the transformer -->
+ {% if params is not none %}
+ {{ render_parameters(transformer.identifier(), transformer.parameter_model().parse_obj(params).html_ui()) }}
+ {% else %}
+ {{ render_parameters(transformer.identifier(), transformer().parameters.html_ui()) }}
+ {% endif %}
+ </div>
+ </div>
+ {% endfor %}
+ </div>
+</div>
+{% endmacro %}
+
+{% macro render_parameters(identifier, params) %}
+{% for param in params %}
+{% if param.type == "int" %}
+<label for="transformer[{{ identifier }}][{{ param.name }}" class="form-label">{{ _(param.label) }}</label>
+<input id="transformer[{{ identifier }}][{{ param.name }}]" name="transformer[{{ identifier }}][{{ param.name }}]" type="number" value="{{ param.value }}" class="form-control" />
+{% elif param.type == "str" %}
+<label for="transformer[{{ identifier }}][{{ param.name }}" class="form-label">{{ _(param.label) }}</label>
+<input id="transformer[{{ identifier }}][{{ param.name }}]" name="transformer[{{ identifier }}][{{ param.name }}]" type="text" value="{{ param.value }}" class="form-control" />
+{% endif %}
+{% endfor %}
{% endmacro %}
diff --git a/fietsboek/transformers/__init__.py b/fietsboek/transformers/__init__.py
new file mode 100644
index 0000000..f6318d7
--- /dev/null
+++ b/fietsboek/transformers/__init__.py
@@ -0,0 +1,264 @@
+"""Fietsboek GPX transformers.
+
+A "transformer" is something like a filter - it takes in a GPX file and applies
+some small corrections, such as smoothing out the elevation. In order to avoid
+confusion with the naming (and the "filters" you have when searching for
+tracks), we call those "GPX filters" *transformers*.
+
+This module defines the abstract interface for transformers, as well as
+function to load and apply transformers.
+"""
+
+from abc import ABC, abstractmethod
+from collections.abc import Callable, Iterable, Mapping
+from itertools import islice
+from typing import Literal, NamedTuple, TypeVar
+
+from gpxpy.gpx import GPX, GPXTrackPoint
+from pydantic import BaseModel
+from pyramid.i18n import TranslationString
+from pyramid.request import Request
+
+_ = TranslationString
+
+T = TypeVar("T", bound="Transformer")
+
+
+class ParameterDefinition(NamedTuple):
+ """A parameter definition for the UI to render."""
+
+ type: Literal["int", "str"]
+ """Type of the parameter."""
+
+ name: str
+ """Name of the parameter.
+
+ This is the machine-readable identifier, not the human readable name.
+ """
+
+ label: TranslationString
+ """Human-readable label of the parameter."""
+
+ value: str
+ """The serialized value of the parameter."""
+
+
+class Parameters(BaseModel):
+ """Parameters for a transformer.
+
+ This is basically a wrapper around pydantic models that allows the
+ parameters to be serialized from and to POST request parameters.
+ """
+
+ def html_ui(self) -> list[ParameterDefinition]:
+ """Renders a HTML UI for this parameter set.
+
+ :return: The rendered UI, ready for inclusion.
+ """
+ return []
+
+ def read_from_request(self, data: Mapping[str, str]):
+ """Parses the parameters from the given request data.
+
+ :param data: The request data, e.g. from the POST values.
+ """
+
+
+class Transformer(ABC):
+ """A :class:`Transformer` is the main interface for track manipulation."""
+
+ @classmethod
+ @abstractmethod
+ def identifier(cls: type[T]) -> str:
+ """Returns a string identifier for this transformer.
+
+ This identifier is used when serializing/deserializing the filters.
+
+ :return: A machine-readable identifier for this transformer.
+ """
+
+ @classmethod
+ @abstractmethod
+ def name(cls: type[T]) -> TranslationString:
+ """The human-readable name of this transformer, as a translateable string.
+
+ :return: The transformer's name.
+ """
+
+ @classmethod
+ @abstractmethod
+ def description(cls: type[T]) -> TranslationString:
+ """A short description of what this transformer does.
+
+ :return: The transformer's description.
+ """
+
+ @classmethod
+ @abstractmethod
+ def parameter_model(cls: type[T]) -> type[Parameters]:
+ """Returns the parameter model that this transformer expects.
+
+ :return: The parameter model class.
+ """
+
+ @property
+ @abstractmethod
+ def parameters(self) -> Parameters:
+ """Returns the parameters of this transformer.
+
+ Note that the caller may modify the parameters, which should be
+ reflected in future applications of the transformer.
+
+ :return: The parameters.
+ """
+
+ @parameters.setter
+ @abstractmethod
+ def parameters(self, value: Parameters):
+ pass
+
+ @abstractmethod
+ def execute(self, gpx: GPX):
+ """Run the transformation on the input gpx.
+
+ This is expected to modify the GPX object to represent the new state.
+
+ :param gpx: The GPX object to transform. Note that this object will be
+ mutated!
+ """
+
+
+class FixNullElevation(Transformer):
+ """A transformer that fixes points with zero elevation."""
+
+ @classmethod
+ def identifier(cls) -> str:
+ return "fix-null-elevation"
+
+ @classmethod
+ def name(cls) -> TranslationString:
+ return _("transformers.fix-null-elevation.title")
+
+ @classmethod
+ def description(cls) -> TranslationString:
+ return _("transformers.fix-null-elevation.description")
+
+ @classmethod
+ def parameter_model(cls) -> type[Parameters]:
+ return Parameters
+
+ @property
+ def parameters(self) -> Parameters:
+ return Parameters()
+
+ @parameters.setter
+ def parameters(self, value):
+ pass
+
+ def execute(self, gpx: GPX):
+ def all_points():
+ return (
+ point
+ for track in gpx.tracks
+ for segment in track.segments
+ for point in segment.points
+ )
+
+ def rev_points():
+ return (
+ point
+ for track in reversed(gpx.tracks)
+ for segment in reversed(track.segments)
+ for point in reversed(segment.points)
+ )
+
+ # First, from the front
+ self.fixup(all_points)
+ # Then, from the back
+ self.fixup(rev_points)
+
+ @classmethod
+ def fixup(cls, points: Callable[[], Iterable[GPXTrackPoint]]):
+ """Fixes the given GPX points.
+
+ This iterates over the points and checks for the first point that has a
+ non-zero elevation, and a slope that doesn't exceed 100%. All previous
+ points will have their elevation adjusted to match this first "good
+ point".
+
+ :param points: A function that generates the iterable of points.
+ """
+ max_slope = 1.0
+
+ bad_until = 0
+ final_elevation = 0.0
+ for i, (point, next_point) in enumerate(zip(points(), islice(points(), 1, None))):
+ if (
+ point.elevation is not None
+ and point.elevation != 0.0
+ and cls.slope(point, next_point) < max_slope
+ ):
+ bad_until = i
+ final_elevation = point.elevation
+ break
+
+ for point in islice(points(), None, bad_until):
+ point.elevation = final_elevation
+
+ @staticmethod
+ def slope(point_a: GPXTrackPoint, point_b: GPXTrackPoint) -> float:
+ """Returns the slope between two GPX points.
+
+ This is defined as delta_h / euclid_distance.
+
+ :param point_a: First point.
+ :param point_b: Second point.
+ :return: The slope, as percentage.
+ """
+ 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)
+ if dist == 0.0 or dist is None:
+ return 0.0
+ return delta_h / dist
+
+
+def list_transformers() -> list[type[Transformer]]:
+ """Returns a list of all available transformers.
+
+ :return: A list of transformers.
+ """
+ return [
+ FixNullElevation,
+ ]
+
+
+def extract_from_request(request: Request) -> list[Transformer]:
+ """Extracts the list of transformers to execute from the given request.
+
+ Note that this sets up the transformers with the right parameters.
+
+ :param request: The pyramid request.
+ :return: The list of prepared transformers.
+ """
+ transformers = []
+ for tfm in list_transformers():
+ ident = tfm.identifier()
+ prefix = f"transformer[{ident}]"
+ req_params = {}
+ for name, val in request.params.items():
+ if name.startswith(prefix):
+ name = name[len(prefix) :]
+ name = name.strip("[]")
+ req_params[name] = val
+
+ if req_params.get("") == "on":
+ instance = tfm()
+ instance.parameters.read_from_request(req_params)
+ transformers.append(instance)
+
+ return transformers
+
+
+__all__ = ["Parameters", "Transformer", "list_transformers"]
diff --git a/fietsboek/util.py b/fietsboek/util.py
index c741550..68ba769 100644
--- a/fietsboek/util.py
+++ b/fietsboek/util.py
@@ -152,19 +152,22 @@ def guess_gpx_timezone(gpx: gpxpy.gpx.GPX) -> datetime.tzinfo:
return datetime.timezone.utc
-def tour_metadata(gpx_data: Union[str, bytes]) -> dict:
+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.
+ :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")
- gpx = gpxpy.parse(gpx_data)
+ 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()
diff --git a/fietsboek/views/edit.py b/fietsboek/views/edit.py
index 61741e5..881f404 100644
--- a/fietsboek/views/edit.py
+++ b/fietsboek/views/edit.py
@@ -95,11 +95,13 @@ 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,
)
return HTTPFound(request.route_url("details", track_id=track.id))
diff --git a/fietsboek/views/upload.py b/fietsboek/views/upload.py
index ed8f285..fd93034 100644
--- a/fietsboek/views/upload.py
+++ b/fietsboek/views/upload.py
@@ -9,7 +9,7 @@ from pyramid.response import Response
from pyramid.view import view_config
from sqlalchemy import select
-from .. import actions, models, util
+from .. import actions, models, transformers, util
from ..models.track import TrackType, Visibility
LOGGER = logging.getLogger(__name__)
@@ -170,6 +170,7 @@ def do_finish_upload(request):
tagged_people=tagged_people,
date=date,
tags=request.params.getall("tag[]"),
+ transformers=transformers.extract_from_request(request),
gpx_data=upload.gpx_data,
)
request.dbsession.delete(upload)
diff --git a/pylint.toml b/pylint.toml
index 9e3a63c..f1e4270 100644
--- a/pylint.toml
+++ b/pylint.toml
@@ -17,7 +17,7 @@
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
# for backward compatibility.)
-# extension-pkg-whitelist =
+extension-pkg-whitelist = ["pydantic"]
# Return non-zero exit code if any of these messages/categories are detected,
# even if score is above --fail-under value. Syntax same as enable. Messages
diff --git a/tests/assets/Synthetic_Steep_Slope.gpx.gz b/tests/assets/Synthetic_Steep_Slope.gpx.gz
new file mode 100644
index 0000000..244f78a
--- /dev/null
+++ b/tests/assets/Synthetic_Steep_Slope.gpx.gz
Binary files differ
diff --git a/tests/assets/Synthetic_Zero_Elevation.gpx.gz b/tests/assets/Synthetic_Zero_Elevation.gpx.gz
new file mode 100644
index 0000000..94abb0a
--- /dev/null
+++ b/tests/assets/Synthetic_Zero_Elevation.gpx.gz
Binary files differ
diff --git a/tests/playwright/conftest.py b/tests/playwright/conftest.py
index 18b7ad0..f57aca7 100644
--- a/tests/playwright/conftest.py
+++ b/tests/playwright/conftest.py
@@ -126,6 +126,7 @@ class Helper:
tags=[],
badges=[],
tagged_people=[],
+ transformers=[],
gpx_data=load_gpx_asset(track_name),
)
self.dbaccess.commit()
diff --git a/tests/playwright/test_basic.py b/tests/playwright/test_basic.py
index 3b3329a..a98e52d 100644
--- a/tests/playwright/test_basic.py
+++ b/tests/playwright/test_basic.py
@@ -51,7 +51,7 @@ def test_upload(page: Page, playwright_helper, tmp_path, dbaccess):
page.locator(".bi-upload").click()
# We now fill in most of the data
- page.get_by_label("Title").fill("An awesome track!")
+ page.get_by_label("Title", exact=True).fill("An awesome track!")
page.get_by_label("Date").type("07302022")
page.get_by_label("Date").press("Tab")
page.get_by_label("Date").type("12:41")
@@ -82,7 +82,7 @@ def test_edit(page: Page, playwright_helper, dbaccess):
page.locator(".btn", has_text="Edit").click()
# We now fill in most of the data
- page.get_by_label("Title").fill("Not so awesome anymore!")
+ page.get_by_label("Title", exact=True).fill("Not so awesome anymore!")
page.get_by_label("Date").type("09232019")
page.get_by_label("Date").press("Tab")
page.get_by_label("Date").type("15:28")
diff --git a/tests/playwright/test_transformers.py b/tests/playwright/test_transformers.py
new file mode 100644
index 0000000..0b2e4de
--- /dev/null
+++ b/tests/playwright/test_transformers.py
@@ -0,0 +1,130 @@
+from playwright.sync_api import Page, expect
+from sqlalchemy import select
+
+from testutils import extract_and_upload
+from fietsboek import models
+
+
+def test_transformer_zero_elevation_disabled(page: Page, playwright_helper, tmp_path, dbaccess):
+ playwright_helper.login()
+
+ page.goto("/")
+ page.get_by_text("Upload").click()
+
+ extract_and_upload(page, "Synthetic_Zero_Elevation.gpx.gz", tmp_path)
+
+ page.locator(".btn", has_text="Upload").click()
+
+ # 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")
+ 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 > 160
+
+
+def test_transformer_zero_elevation_enabled(page: Page, playwright_helper, tmp_path, dbaccess):
+ playwright_helper.login()
+
+ page.goto("/")
+ page.get_by_text("Upload").click()
+
+ extract_and_upload(page, "Synthetic_Zero_Elevation.gpx.gz", tmp_path)
+
+ page.locator("#transformer-heading-1 .accordion-button").click()
+ page.locator("#transformer-enabled-1").click()
+
+ page.locator(".btn", has_text="Upload").click()
+
+ expect(page.locator("#detailsUphill")).to_contain_text("0 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 < 0.1
+
+
+def test_transformer_zero_elevation_edited(page: Page, playwright_helper, tmp_path, dbaccess):
+ playwright_helper.login()
+
+ page.goto("/")
+ page.get_by_text("Upload").click()
+
+ extract_and_upload(page, "Synthetic_Zero_Elevation.gpx.gz", tmp_path)
+
+ page.locator(".btn", has_text="Upload").click()
+
+ page.locator(".btn", has_text="Edit").click()
+
+ page.locator("#transformer-heading-1 .accordion-button").click()
+ page.locator("#transformer-enabled-1").click()
+
+ page.locator(".btn", has_text="Save").click()
+
+ expect(page.locator("#detailsUphill")).to_contain_text("0 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 < 0.1
+
+
+def test_transformer_steep_slope_disabled(page: Page, playwright_helper, tmp_path, dbaccess):
+ playwright_helper.login()
+
+ page.goto("/")
+ page.get_by_text("Upload").click()
+
+ extract_and_upload(page, "Synthetic_Steep_Slope.gpx.gz", tmp_path)
+
+ page.locator(".btn", has_text="Upload").click()
+
+ expect(page.locator("#detailsUphill")).to_contain_text("61.54 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 > 60
+
+
+def test_transformer_steep_slope_enabled(page: Page, playwright_helper, tmp_path, dbaccess):
+ playwright_helper.login()
+
+ page.goto("/")
+ page.get_by_text("Upload").click()
+
+ extract_and_upload(page, "Synthetic_Steep_Slope.gpx.gz", tmp_path)
+
+ page.locator("#transformer-heading-1 .accordion-button").click()
+ page.locator("#transformer-enabled-1").click()
+
+ page.locator(".btn", has_text="Upload").click()
+
+ expect(page.locator("#detailsUphill")).to_contain_text("1.2 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
+
+
+def test_transformer_steep_slope_edited(page: Page, playwright_helper, tmp_path, dbaccess):
+ playwright_helper.login()
+
+ page.goto("/")
+ page.get_by_text("Upload").click()
+
+ extract_and_upload(page, "Synthetic_Steep_Slope.gpx.gz", tmp_path)
+
+ page.locator(".btn", has_text="Upload").click()
+
+ page.locator(".btn", has_text="Edit").click()
+
+ page.locator("#transformer-heading-1 .accordion-button").click()
+ page.locator("#transformer-enabled-1").click()
+
+ page.locator(".btn", has_text="Save").click()
+
+ expect(page.locator("#detailsUphill")).to_contain_text("1.2 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
diff --git a/tests/testutils.py b/tests/testutils.py
index 3ddbdbe..810bdf7 100644
--- a/tests/testutils.py
+++ b/tests/testutils.py
@@ -2,19 +2,37 @@
import gzip
from pathlib import Path
+from playwright.sync_api import Page
-def load_gpx_asset(filename):
+
+def load_gpx_asset(filename: str) -> bytes:
"""Load a GPX test asset.
This looks in the tests/assets/ folder, reads and unzips the file and
returns its contents.
:param filename: Name of the asset to load.
- :type filename: str
:return: The content of the asset as bytes.
- :rtype: bytes
"""
asset_dir = Path(__file__).parent / 'assets'
test_file = asset_dir / filename
with gzip.open(test_file, 'rb') as fobj:
return fobj.read()
+
+
+def extract_and_upload(page: Page, filename: str, tmp_path: Path):
+ """Extracts the given test asset, fills in the upload form and presses
+ upload.
+
+ :param page: The playwright page on which to execute the actions.
+ :param filename: The filename.
+ :param tmp_path: The temporary path (as given by pytest).
+ """
+ gpx_data = load_gpx_asset(filename)
+ gpx_path = tmp_path / "Upload.gpx"
+ with open(gpx_path, "wb") as gpx_fobj:
+ gpx_fobj.write(gpx_data)
+
+ page.get_by_label("GPX file").set_input_files(gpx_path)
+
+ page.locator(".bi-upload").click()