aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--asset-sources/theme.scss4
-rw-r--r--doc/user/images/fixed_elevation_jump.pngbin0 -> 61739 bytes
-rw-r--r--doc/user/images/wrong_elevation_jump.pngbin0 -> 39593 bytes
-rw-r--r--doc/user/transformers.rst29
-rw-r--r--fietsboek/actions.py2
-rw-r--r--fietsboek/data.py10
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.mobin13563 -> 13807 bytes
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.po54
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.mobin12726 -> 12916 bytes
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.po52
-rw-r--r--fietsboek/locale/fietslog.pot48
-rw-r--r--fietsboek/models/user.py7
-rw-r--r--fietsboek/scripts/fietscron.py12
-rw-r--r--fietsboek/static/theme.css4
-rw-r--r--fietsboek/static/theme.css.map2
-rw-r--r--fietsboek/templates/home.jinja216
-rw-r--r--fietsboek/templates/layout.jinja26
-rw-r--r--fietsboek/transformers/__init__.py100
-rw-r--r--fietsboek/transformers/elevation.py147
-rw-r--r--fietsboek/util.py23
-rw-r--r--fietsboek/views/default.py42
-rw-r--r--fietsboek/views/detail.py19
-rw-r--r--fietsboek/views/profile.py34
-rw-r--r--tests/assets/Elevation_Jump.gpx.gzbin0 -> 4417 bytes
-rw-r--r--tests/integration/test_smoke.py2
-rw-r--r--tests/playwright/test_transformers.py29
26 files changed, 439 insertions, 203 deletions
diff --git a/asset-sources/theme.scss b/asset-sources/theme.scss
index 40aa55d..faa4f44 100644
--- a/asset-sources/theme.scss
+++ b/asset-sources/theme.scss
@@ -9,6 +9,10 @@ strong {
font-weight: 700;
}
+.brand-link {
+ text-decoration: none;
+}
+
.badge-container {
width: 50px;
height: 50px;
diff --git a/doc/user/images/fixed_elevation_jump.png b/doc/user/images/fixed_elevation_jump.png
new file mode 100644
index 0000000..4d1334a
--- /dev/null
+++ b/doc/user/images/fixed_elevation_jump.png
Binary files differ
diff --git a/doc/user/images/wrong_elevation_jump.png b/doc/user/images/wrong_elevation_jump.png
new file mode 100644
index 0000000..03d454d
--- /dev/null
+++ b/doc/user/images/wrong_elevation_jump.png
Binary files differ
diff --git a/doc/user/transformers.rst b/doc/user/transformers.rst
index d053052..7ce4bc7 100644
--- a/doc/user/transformers.rst
+++ b/doc/user/transformers.rst
@@ -19,6 +19,11 @@ 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.
+Keep in mind that the transformers provide a "quick and convenient" way to
+apply a predefined set of changes to a track. If you need to do fine-grained
+edits to a GPX file, you need to use a different tool and edit the file before
+uploading it to Fietsboek.
+
Fix Null Elevation
------------------
@@ -44,6 +49,30 @@ point.
To fix those points, the transformer will find the first correct point, and
copy its elevation to the wrong points.
+Fix Elevation Jumps
+-------------------
+
+The *fix elevation jumps* transformer eliminates big elevation jumps in the
+middle of a track. This is useful to deal with "stitched" GPX files, where the
+elevation is consistent within a single track, but the absolute value might not
+be correct (e.g. if the device recalibrates):
+
+.. image:: images/wrong_elevation_jump.png
+ :width: 600
+ :alt: The elevation profile having a big jump in the middle.
+
+In this track, the device was re-calibrated mid-track. The transformer will
+adjust the elevation values:
+
+.. image:: images/fixed_elevation_jump.png
+ :width: 600
+ :alt: The same elevation profile with the jump removed.
+
+The detection of jumps work similarly to the *fix null elevation* transformer,
+with the difference that it works in the middle of tracks. It will consider the
+earlier points as anchor, and then adjust the later points such that the first
+point after the jump has the same elevation as the last point before the jump.
+
Remove Breaks
-------------
diff --git a/fietsboek/actions.py b/fietsboek/actions.py
index 6afbfa4..b9221c3 100644
--- a/fietsboek/actions.py
+++ b/fietsboek/actions.py
@@ -208,7 +208,7 @@ def execute_transformers(request: Request, track: models.Track) -> Optional[gpxp
transformer.execute(gpx)
LOGGER.debug("Saving transformed file for %d", track.id)
- manager.compress_gpx(gpx.to_xml().encode("utf-8"))
+ manager.compress_gpx(util.encode_gpx(gpx))
LOGGER.debug("Saving new transformers on %d", track.id)
track.transformers = serialized
diff --git a/fietsboek/data.py b/fietsboek/data.py
index 6bb2c8c..9e0d45d 100644
--- a/fietsboek/data.py
+++ b/fietsboek/data.py
@@ -17,7 +17,7 @@ import brotli
import gpxpy
from filelock import FileLock
-from .util import secure_filename
+from . import util
LOGGER = logging.getLogger(__name__)
@@ -32,7 +32,7 @@ def generate_filename(filename: Optional[str]) -> str:
:return: The generated filename.
"""
if filename:
- good_name = secure_filename(filename)
+ good_name = util.secure_filename(filename)
if good_name:
random_prefix = "".join(random.choice(string.ascii_lowercase) for _ in range(5))
return f"{random_prefix}_{good_name}"
@@ -324,7 +324,7 @@ class TrackDataDir:
gpx.description = description
gpx.time = time
- self.compress_gpx(gpx.to_xml().encode("utf-8"))
+ self.compress_gpx(util.encode_gpx(gpx))
def backup(self):
"""Create a backup of the GPX file."""
@@ -356,7 +356,7 @@ class TrackDataDir:
:param image_id: ID of the image.
:return: A path pointing to the requested image.
"""
- image = self.path / "images" / secure_filename(image_id)
+ image = self.path / "images" / util.secure_filename(image_id)
if not image.exists():
raise FileNotFoundError("The requested image does not exist")
return image
@@ -388,7 +388,7 @@ class TrackDataDir:
:param image_id: ID of the image.
"""
# Be sure to not delete anything else than the image file
- image_id = secure_filename(image_id)
+ image_id = util.secure_filename(image_id)
if "/" in image_id or "\\" in image_id:
return
path = self.image_path(image_id)
diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.mo b/fietsboek/locale/de/LC_MESSAGES/messages.mo
index eb24766..887f9bc 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 0c32f64..f5528c9 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-05-09 19:55+0200\n"
+"POT-Creation-Date: 2023-05-22 22:40+0200\n"
"PO-Revision-Date: 2022-07-02 17:35+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de\n"
@@ -725,23 +725,33 @@ msgstr "E-Mail-Adresse des Freundes"
msgid "page.my_profile.send_friend_request"
msgstr "Freundschaftsanfrage senden"
-#: fietsboek/transformers/__init__.py:140
+#: fietsboek/transformers/breaks.py:31
+msgid "transformers.remove-breaks.title"
+msgstr "Pausen entfernen"
+
+#: fietsboek/transformers/breaks.py:35
+msgid "transformers.remove-breaks.description"
+msgstr "Diese Transformation entfernt längere Pausen aus der Aufnahme."
+
+#: fietsboek/transformers/elevation.py:42
msgid "transformers.fix-null-elevation.title"
msgstr "Nullhöhen beheben"
-#: fietsboek/transformers/__init__.py:144
+#: fietsboek/transformers/elevation.py:46
msgid "transformers.fix-null-elevation.description"
msgstr ""
"Diese Transformation passt die Höhenangabe für Punkte an, bei denen die "
"Höhenangabe fehlt."
-#: fietsboek/transformers/breaks.py:31
-msgid "transformers.remove-breaks.title"
-msgstr "Pausen entfernen"
+#: fietsboek/transformers/elevation.py:115
+msgid "transformers.fix-elevation-jumps"
+msgstr "Höhensprünge beheben"
-#: fietsboek/transformers/breaks.py:35
-msgid "transformers.remove-breaks.description"
-msgstr "Diese Transformation entfernt längere Pausen aus der Aufnahme."
+#: fietsboek/transformers/elevation.py:119
+msgid "transformers.fix-elevation-jumps.description"
+msgstr ""
+"Diese Transformation passt die Höhenangabe für Punkte an, bei denen die "
+"Höhe sprunghaft steigt oder fällt."
#: fietsboek/views/account.py:54
msgid "flash.invalid_name"
@@ -778,35 +788,35 @@ msgstr "Wappen bearbeitet"
msgid "flash.badge_deleted"
msgstr "Wappen gelöscht"
-#: fietsboek/views/default.py:121
+#: fietsboek/views/default.py:115
msgid "flash.invalid_credentials"
msgstr "Ungültige Nutzerdaten"
-#: fietsboek/views/default.py:125
+#: fietsboek/views/default.py:119
msgid "flash.account_not_verified"
msgstr "Konto noch nicht bestätigt"
-#: fietsboek/views/default.py:128
+#: fietsboek/views/default.py:122
msgid "flash.logged_in"
msgstr "Du bist nun angemeldet"
-#: fietsboek/views/default.py:150
+#: fietsboek/views/default.py:142
msgid "flash.logged_out"
msgstr "Du bist nun abgemeldet"
-#: fietsboek/views/default.py:184
+#: fietsboek/views/default.py:172
msgid "flash.reset_invalid_email"
msgstr "Ungültige E-Mail-Adresse angegeben"
-#: fietsboek/views/default.py:189
+#: fietsboek/views/default.py:177
msgid "flash.password_token_generated"
msgstr "Ein Link zum Zurücksetzen des Passworts wurde versandt"
-#: fietsboek/views/default.py:194
+#: fietsboek/views/default.py:182
msgid "page.password_reset.email.subject"
msgstr "Fietsboek Passwortzurücksetzung"
-#: fietsboek/views/default.py:197
+#: fietsboek/views/default.py:185
msgid "page.password_reset.email.body"
msgstr ""
"Du kannst Dein Fietsboek-Passwort hier zurücksetzen: {}\n"
@@ -814,15 +824,19 @@ msgstr ""
"Falls Du keine Passwortzurücksetzung beantragt hast, dann ignoriere diese"
" E-Mail."
-#: fietsboek/views/default.py:230
+#: fietsboek/views/default.py:214
+msgid "flash.token_expired"
+msgstr "Der Link ist nicht mehr gültig"
+
+#: fietsboek/views/default.py:220
msgid "flash.email_verified"
msgstr "E-Mail-Adresse bestätigt"
-#: fietsboek/views/default.py:244
+#: fietsboek/views/default.py:234
msgid "flash.password_updated"
msgstr "Passwort aktualisiert"
-#: fietsboek/views/detail.py:140
+#: fietsboek/views/detail.py:155
msgid "flash.track_deleted"
msgstr "Strecke gelöscht"
diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.mo b/fietsboek/locale/en/LC_MESSAGES/messages.mo
index 055dbd8..bfd051f 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 8f8d1f3..2ec7b76 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-05-09 19:55+0200\n"
+"POT-Creation-Date: 2023-05-22 22:40+0200\n"
"PO-Revision-Date: 2023-04-03 20:42+0200\n"
"Last-Translator: \n"
"Language: en\n"
@@ -719,14 +719,6 @@ msgstr "Email of the friend"
msgid "page.my_profile.send_friend_request"
msgstr "Send friend request"
-#: 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/transformers/breaks.py:31
msgid "transformers.remove-breaks.title"
msgstr "Remove breaks"
@@ -735,6 +727,22 @@ msgstr "Remove breaks"
msgid "transformers.remove-breaks.description"
msgstr "This transformer removes long breaks from the recording"
+#: fietsboek/transformers/elevation.py:42
+msgid "transformers.fix-null-elevation.title"
+msgstr "Fix null elevation"
+
+#: fietsboek/transformers/elevation.py:46
+msgid "transformers.fix-null-elevation.description"
+msgstr "This transformer fixes the elevation of points whose elevation is unset."
+
+#: fietsboek/transformers/elevation.py:115
+msgid "transformers.fix-elevation-jumps"
+msgstr "Fix elevation jumps"
+
+#: fietsboek/transformers/elevation.py:119
+msgid "transformers.fix-elevation-jumps.description"
+msgstr "This transformer fixes abrupt jumps in the elevation value."
+
#: fietsboek/views/account.py:54
msgid "flash.invalid_name"
msgstr "Invalid name"
@@ -770,50 +778,54 @@ msgstr "Badge has been modified"
msgid "flash.badge_deleted"
msgstr "Badge has been deleted"
-#: fietsboek/views/default.py:121
+#: fietsboek/views/default.py:115
msgid "flash.invalid_credentials"
msgstr "Invalid login credentials"
-#: fietsboek/views/default.py:125
+#: fietsboek/views/default.py:119
msgid "flash.account_not_verified"
msgstr "Your account is not verified yet"
-#: fietsboek/views/default.py:128
+#: fietsboek/views/default.py:122
msgid "flash.logged_in"
msgstr "You are now logged in"
-#: fietsboek/views/default.py:150
+#: fietsboek/views/default.py:142
msgid "flash.logged_out"
msgstr "You have been logged out"
-#: fietsboek/views/default.py:184
+#: fietsboek/views/default.py:172
msgid "flash.reset_invalid_email"
msgstr "Invalid email address provided"
-#: fietsboek/views/default.py:189
+#: fietsboek/views/default.py:177
msgid "flash.password_token_generated"
msgstr "A password reset email has been sent"
-#: fietsboek/views/default.py:194
+#: fietsboek/views/default.py:182
msgid "page.password_reset.email.subject"
msgstr "Fietsboek Password Reset"
-#: fietsboek/views/default.py:197
+#: fietsboek/views/default.py:185
msgid "page.password_reset.email.body"
msgstr ""
"You can reset your Fietsboek password here: {}\n"
"\n"
"If you did not request a password reset, ignore this email."
-#: fietsboek/views/default.py:230
+#: fietsboek/views/default.py:214
+msgid "flash.token_expired"
+msgstr "The link has expired"
+
+#: fietsboek/views/default.py:220
msgid "flash.email_verified"
msgstr "Your email address has been verified"
-#: fietsboek/views/default.py:244
+#: fietsboek/views/default.py:234
msgid "flash.password_updated"
msgstr "Password has been updated"
-#: fietsboek/views/detail.py:140
+#: fietsboek/views/detail.py:155
msgid "flash.track_deleted"
msgstr "Track has been deleted"
diff --git a/fietsboek/locale/fietslog.pot b/fietsboek/locale/fietslog.pot
index 8cab0ed..113d2d1 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-05-09 19:55+0200\n"
+"POT-Creation-Date: 2023-05-22 22:40+0200\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"
@@ -714,20 +714,28 @@ msgstr ""
msgid "page.my_profile.send_friend_request"
msgstr ""
-#: fietsboek/transformers/__init__.py:140
+#: fietsboek/transformers/breaks.py:31
+msgid "transformers.remove-breaks.title"
+msgstr ""
+
+#: fietsboek/transformers/breaks.py:35
+msgid "transformers.remove-breaks.description"
+msgstr ""
+
+#: fietsboek/transformers/elevation.py:42
msgid "transformers.fix-null-elevation.title"
msgstr ""
-#: fietsboek/transformers/__init__.py:144
+#: fietsboek/transformers/elevation.py:46
msgid "transformers.fix-null-elevation.description"
msgstr ""
-#: fietsboek/transformers/breaks.py:31
-msgid "transformers.remove-breaks.title"
+#: fietsboek/transformers/elevation.py:115
+msgid "transformers.fix-elevation-jumps"
msgstr ""
-#: fietsboek/transformers/breaks.py:35
-msgid "transformers.remove-breaks.description"
+#: fietsboek/transformers/elevation.py:119
+msgid "transformers.fix-elevation-jumps.description"
msgstr ""
#: fietsboek/views/account.py:54
@@ -762,47 +770,51 @@ msgstr ""
msgid "flash.badge_deleted"
msgstr ""
-#: fietsboek/views/default.py:121
+#: fietsboek/views/default.py:115
msgid "flash.invalid_credentials"
msgstr ""
-#: fietsboek/views/default.py:125
+#: fietsboek/views/default.py:119
msgid "flash.account_not_verified"
msgstr ""
-#: fietsboek/views/default.py:128
+#: fietsboek/views/default.py:122
msgid "flash.logged_in"
msgstr ""
-#: fietsboek/views/default.py:150
+#: fietsboek/views/default.py:142
msgid "flash.logged_out"
msgstr ""
-#: fietsboek/views/default.py:184
+#: fietsboek/views/default.py:172
msgid "flash.reset_invalid_email"
msgstr ""
-#: fietsboek/views/default.py:189
+#: fietsboek/views/default.py:177
msgid "flash.password_token_generated"
msgstr ""
-#: fietsboek/views/default.py:194
+#: fietsboek/views/default.py:182
msgid "page.password_reset.email.subject"
msgstr ""
-#: fietsboek/views/default.py:197
+#: fietsboek/views/default.py:185
msgid "page.password_reset.email.body"
msgstr ""
-#: fietsboek/views/default.py:230
+#: fietsboek/views/default.py:214
+msgid "flash.token_expired"
+msgstr ""
+
+#: fietsboek/views/default.py:220
msgid "flash.email_verified"
msgstr ""
-#: fietsboek/views/default.py:244
+#: fietsboek/views/default.py:234
msgid "flash.password_updated"
msgstr ""
-#: fietsboek/views/detail.py:140
+#: fietsboek/views/detail.py:155
msgid "flash.track_deleted"
msgstr ""
diff --git a/fietsboek/models/user.py b/fietsboek/models/user.py
index e1b4841..4201d14 100644
--- a/fietsboek/models/user.py
+++ b/fietsboek/models/user.py
@@ -53,6 +53,9 @@ SCRYPT_PARAMETERS = {
}
SALT_LENGTH = 32
+TOKEN_LIFETIME = datetime.timedelta(hours=24)
+"""Maximum validity time of a token."""
+
friends_assoc = Table(
"friends_assoc",
@@ -457,5 +460,9 @@ class Token(Base):
now = datetime.datetime.utcnow()
return cls(user=user, uuid=token_uuid, date=now, token_type=token_type)
+ def age(self) -> datetime.timedelta:
+ """Returns the age of the token."""
+ return abs(datetime.datetime.utcnow() - self.date)
+
Index("idx_token_uuid", Token.uuid, unique=True)
diff --git a/fietsboek/scripts/fietscron.py b/fietsboek/scripts/fietscron.py
index 8116204..417b188 100644
--- a/fietsboek/scripts/fietscron.py
+++ b/fietsboek/scripts/fietscron.py
@@ -16,6 +16,7 @@ from .. import config as mod_config
from .. import hittekaart, models
from ..config import Config
from ..data import DataManager
+from ..models.user import TOKEN_LIFETIME
from . import config_option
LOGGER = logging.getLogger(__name__)
@@ -48,6 +49,7 @@ def cli(config):
LOGGER.debug("Starting maintenance tasks")
remove_old_uploads(engine)
+ remove_old_tokens(engine)
rebuild_cache(engine, data_manager)
if config.hittekaart_autogenerate:
@@ -65,6 +67,16 @@ def remove_old_uploads(engine: Engine):
session.commit()
+def remove_old_tokens(engine: Engine):
+ """Removes old tokens from the database."""
+ LOGGER.info("Deleting old tokens")
+ limit = datetime.datetime.utcnow() - TOKEN_LIFETIME
+ session = Session(engine)
+ stmt = delete(models.Token).where(models.Token.date < limit)
+ session.execute(stmt)
+ session.commit()
+
+
def rebuild_cache(engine: Engine, data_manager: DataManager):
"""Rebuilds the cache entries that are currently missing."""
LOGGER.debug("Rebuilding caches")
diff --git a/fietsboek/static/theme.css b/fietsboek/static/theme.css
index d2e7f9d..d97192b 100644
--- a/fietsboek/static/theme.css
+++ b/fietsboek/static/theme.css
@@ -9,6 +9,10 @@ strong {
font-weight: 700;
}
+.brand-link {
+ text-decoration: none;
+}
+
.badge-container {
width: 50px;
height: 50px;
diff --git a/fietsboek/static/theme.css.map b/fietsboek/static/theme.css.map
index 9d4524a..632d85f 100644
--- a/fietsboek/static/theme.css.map
+++ b/fietsboek/static/theme.css.map
@@ -1 +1 @@
-{"version":3,"sourceRoot":"","sources":["../../asset-sources/theme.scss"],"names":[],"mappings":"AAAA;EACE;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EAqCE;EACA;EACA;EACA;EACA;EACA;;AAzCA;EACE;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAWJ;EACI;;;AAGJ;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;;AAEA;EACE;EACA;;AAGF;EACE;;;AAIJ;AACA;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE","file":"theme.css"} \ No newline at end of file
+{"version":3,"sourceRoot":"","sources":["../../asset-sources/theme.scss"],"names":[],"mappings":"AAAA;EACE;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EAqCE;EACA;EACA;EACA;EACA;EACA;;AAzCA;EACE;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAWJ;EACI;;;AAGJ;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;;AAEA;EACE;EACA;;AAGF;EACE;;;AAIJ;AACA;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE","file":"theme.css"} \ No newline at end of file
diff --git a/fietsboek/templates/home.jinja2 b/fietsboek/templates/home.jinja2
index e89c9bd..53eefc7 100644
--- a/fietsboek/templates/home.jinja2
+++ b/fietsboek/templates/home.jinja2
@@ -2,7 +2,16 @@
{% block content %}
<div class="container">
- <h1>{{ _("page.home.title") }}</h1>
+ <div class="clearfix">
+ <h1 class="float-start">{{ _("page.home.title") }}</h1>
+ {% if summary %}
+ <div class="float-end mb-2 mt-2">
+ <button type="button" class="btn btn-outline-dark" id="changeHomeSorting">
+ {% if sorted_ascending %}<i class="bi bi-sort-down"></i>{% else %}<i class="bi bi-sort-up"></i>{% endif %}
+ </button>
+ </div>
+ {% endif %}
+ </div>
{% if unfinished_uploads %}
<div class="alert alert-warning">
{{ _("page.home.unfinished_uploads") }}
@@ -14,11 +23,6 @@
</div>
{% endif %}
{% if summary %}
- <div class="d-flex justify-content-end mb-2">
- <button type="button" class="btn btn-outline-dark" id="changeHomeSorting">
- {% if sorted_ascending %}<i class="bi bi-sort-down"></i>{% else %}<i class="bi bi-sort-up"></i>{% endif %}
- </button>
- </div>
<div class="list-group list-group-root">
{% for year in summary %}
<a class="list-group-item list-group-item-primary">
diff --git a/fietsboek/templates/layout.jinja2 b/fietsboek/templates/layout.jinja2
index b6fcea0..3206d97 100644
--- a/fietsboek/templates/layout.jinja2
+++ b/fietsboek/templates/layout.jinja2
@@ -37,7 +37,9 @@ const Legende = false;
<body>
<nav class="navbar navbar-dark bg-dark navbar-expand-lg">
<div class="container-fluid">
- <span class="navbar-brand mb-0 h1"><i class="bi bi-bicycle"></i> Fietsboek</span>
+ <a href="{{ request.route_url('home') }}" class="brand-link">
+ <span class="navbar-brand mb-0 h1"><i class="bi bi-bicycle"></i> Fietsboek</span>
+ </a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar" aria-controls="navbar" aria-expanded="false" aria-label="{{ _('page.navbar.toggle') }}">
<span class="navbar-toggler-icon"></span>
</button>
@@ -117,7 +119,7 @@ const Legende = false;
<!-- Placed at the end of the document so the pages load faster -->
<script src="{{request.static_url('fietsboek:static/bootstrap.bundle.min.js')}}"></script>
<!-- Pre-load leaflet Javascript. This lets us use Leaflet on any page, without relying on GPXViewer to load it -->
- <script src="{{request.static_url('fietsboek:static/GM_Utils/leaflet/leaflet.js')}}">
+ <script src="{{request.static_url('fietsboek:static/GM_Utils/leaflet/leaflet.js')}}"></script>
<!-- Our patch to the GPX viewer, load before the actual GPX viewer -->
<script src="{{request.static_url('fietsboek:static/osm-monkeypatch.js')}}"></script>
<!-- Jürgen Berkemeier's GPX viewer -->
diff --git a/fietsboek/transformers/__init__.py b/fietsboek/transformers/__init__.py
index 330e699..b1a0245 100644
--- a/fietsboek/transformers/__init__.py
+++ b/fietsboek/transformers/__init__.py
@@ -10,11 +10,10 @@ function to load and apply transformers.
"""
from abc import ABC, abstractmethod
-from collections.abc import Callable, Iterable, Mapping
-from itertools import islice
+from collections.abc import Mapping
from typing import Literal, NamedTuple, TypeVar
-from gpxpy.gpx import GPX, GPXTrackPoint
+from gpxpy.gpx import GPX
from pydantic import BaseModel
from pyramid.i18n import TranslationString
from pyramid.request import Request
@@ -128,99 +127,6 @@ class Transformer(ABC):
"""
-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 gpx.walk(only_points=True)
-
- 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)
- )
-
- # 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.
@@ -228,9 +134,11 @@ def list_transformers() -> list[type[Transformer]]:
"""
# pylint: disable=import-outside-toplevel,cyclic-import
from .breaks import RemoveBreaks
+ from .elevation import FixElevationJumps, FixNullElevation
return [
FixNullElevation,
+ FixElevationJumps,
RemoveBreaks,
]
diff --git a/fietsboek/transformers/elevation.py b/fietsboek/transformers/elevation.py
new file mode 100644
index 0000000..0af5161
--- /dev/null
+++ b/fietsboek/transformers/elevation.py
@@ -0,0 +1,147 @@
+"""Transformers that deal with elevation changes in the track."""
+from collections.abc import Callable, Iterable
+from itertools import islice, zip_longest
+
+from gpxpy.gpx import GPX, GPXTrackPoint
+from pyramid.i18n import TranslationString
+
+from . import Parameters, Transformer
+
+_ = TranslationString
+
+MAX_ORGANIC_SLOPE: float = 1.0
+
+
+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
+
+
+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 gpx.walk(only_points=True)
+
+ 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)
+ )
+
+ # 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.
+ """
+ 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 slope(point, next_point) < MAX_ORGANIC_SLOPE
+ ):
+ bad_until = i
+ final_elevation = point.elevation
+ break
+
+ for point in islice(points(), None, bad_until):
+ point.elevation = final_elevation
+
+
+class FixElevationJumps(Transformer):
+ """A transformer that fixes big jumps in the elevation."""
+
+ @classmethod
+ def identifier(cls) -> str:
+ return "fix-elevation-jumps"
+
+ @classmethod
+ def name(cls) -> TranslationString:
+ return _("transformers.fix-elevation-jumps")
+
+ @classmethod
+ def description(cls) -> TranslationString:
+ return _("transformers.fix-elevation-jumps.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):
+ current_adjustment = 0.0
+
+ points = gpx.walk(only_points=True)
+ next_points = gpx.walk(only_points=True)
+
+ 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
+
+
+__all__ = ["FixNullElevation", "FixElevationJumps"]
diff --git a/fietsboek/util.py b/fietsboek/util.py
index acbb45d..04f5551 100644
--- a/fietsboek/util.py
+++ b/fietsboek/util.py
@@ -1,5 +1,6 @@
"""Various utility functions."""
import datetime
+import html
import importlib.resources
import os
import re
@@ -65,9 +66,9 @@ def safe_markdown(md_source: str) -> Markup:
:param md_source: The markdown source.
:return: The safe HTML transformed version.
"""
- html = markdown.markdown(md_source, output_format="html")
- html = nh3.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES)
- return Markup(html)
+ converted = markdown.markdown(md_source, output_format="html")
+ converted = nh3.clean(converted, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES)
+ return Markup(converted)
def fix_iso_timestamp(timestamp: str) -> str:
@@ -377,6 +378,22 @@ def tile_url(request: Request, route_name: str, **kwargs: str) -> str:
return route.replace("__x__", "{x}").replace("__y__", "{y}").replace("__z__", "{z}")
+def encode_gpx(gpx: gpxpy.gpx.GPX) -> bytes:
+ """Encodes a GPX in-memory representation to the XML representation.
+
+ This ensures that the resulting XML file is valid.
+
+ Returns the contents of the XML as bytes, ready to be written to disk.
+
+ :param gpx: The GPX file to encode. Might be modified!
+ :return: The encoded GPX content.
+ """
+ for track in gpx.tracks:
+ if track.link:
+ track.link = html.escape(track.link)
+ return gpx.to_xml().encode("utf-8")
+
+
def secure_filename(filename: str) -> str:
r"""Pass it a filename and it will return a secure version of it. This
filename can then safely be stored on a regular file system and passed
diff --git a/fietsboek/views/default.py b/fietsboek/views/default.py
index 08f32ce..b8b1835 100644
--- a/fietsboek/views/default.py
+++ b/fietsboek/views/default.py
@@ -4,6 +4,8 @@ from pyramid.httpexceptions import HTTPFound, HTTPNotFound
from pyramid.i18n import TranslationString as _
from pyramid.interfaces import ISecurityPolicy
from pyramid.renderers import render_to_response
+from pyramid.request import Request
+from pyramid.response import Response
from pyramid.security import forget, remember
from pyramid.view import view_config
from sqlalchemy import select
@@ -12,17 +14,15 @@ from sqlalchemy.orm import aliased
from .. import email, models, summaries, util
from ..models.track import TrackType, TrackWithMetadata
-from ..models.user import PasswordMismatch, TokenType
+from ..models.user import TOKEN_LIFETIME, PasswordMismatch, TokenType
@view_config(route_name="home", renderer="fietsboek:templates/home.jinja2")
-def home(request):
+def home(request: Request) -> Response:
"""Renders the home page.
:param request: The Pyramid request.
- :type request: pyramid.request.Request
:return: The HTTP response.
- :rtype: pyramid.response.Response
"""
if not request.identity:
# See if the admin set a custom home page
@@ -73,13 +73,11 @@ def home(request):
@view_config(route_name="static-page", renderer="fietsboek:templates/static-page.jinja2")
-def static_page(request):
+def static_page(request: Request) -> Response:
"""Renders a static page.
:param request: The Pyramid request.
- :type request: pyramid.request.Request
:return: The HTTP response.
- :rtype: pyramid.response.Response
"""
page = request.pages.find(request.matchdict["slug"], request)
if page is None:
@@ -92,26 +90,22 @@ def static_page(request):
@view_config(route_name="login", renderer="fietsboek:templates/login.jinja2", request_method="GET")
-def login(request):
+def login(request: Request) -> Response:
"""Renders the login page.
:param request: The Pyramid request.
- :type request: pyramid.request.Request
:return: The HTTP response.
- :rtype: pyramid.response.Response
"""
# pylint: disable=unused-argument
return {}
@view_config(route_name="login", request_method="POST")
-def do_login(request):
+def do_login(request: Request) -> Response:
"""Endpoint for the login form.
:param request: The Pyramid request.
- :type request: pyramid.request.Request
:return: The HTTP response.
- :rtype: pyramid.response.Response
"""
query = models.User.query_by_email(request.params["email"])
try:
@@ -139,13 +133,11 @@ def do_login(request):
@view_config(route_name="logout")
-def logout(request):
+def logout(request: Request) -> Response:
"""Logs the user out.
:param request: The Pyramid request.
- :type request: pyramid.request.Request
:return: The HTTP response.
- :rtype: pyramid.response.Response
"""
request.session.flash(request.localizer.translate(_("flash.logged_out")))
headers = forget(request)
@@ -157,26 +149,22 @@ def logout(request):
request_method="GET",
renderer="fietsboek:templates/request_password.jinja2",
)
-def password_reset(request):
+def password_reset(request: Request) -> Response:
"""Form to request a new password.
:param request: The Pyramid request.
- :type request: pyramid.request.Request
:return: The HTTP response.
- :rtype: pyramid.response.Response
"""
# pylint: disable=unused-argument
return {}
@view_config(route_name="password-reset", request_method="POST")
-def do_password_reset(request):
+def do_password_reset(request: Request) -> Response:
"""Endpoint for the password request form.
:param request: The Pyramid request.
- :type request: pyramid.request.Request
:return: The HTTP response.
- :rtype: pyramid.response.Response
"""
query = models.User.query_by_email(request.params["email"])
user = request.dbsession.execute(query).scalar_one_or_none()
@@ -209,14 +197,12 @@ def do_password_reset(request):
@view_config(route_name="use-token")
-def use_token(request):
+def use_token(request: Request) -> Response:
"""Endpoint with which a user can use a token for a password reset or email
verification.
:param request: The Pyramid request.
- :type request: pyramid.request.Request
:return: The HTTP response.
- :rtype: pyramid.response.Response
"""
token = request.dbsession.execute(
select(models.Token).filter_by(uuid=request.matchdict["uuid"])
@@ -224,6 +210,10 @@ def use_token(request):
if token is None:
return HTTPNotFound()
+ if token.age() > TOKEN_LIFETIME:
+ request.session.flash(request.localizer.translate(_("flash.token_expired")))
+ return HTTPFound(request.route_url("home"))
+
if token.token_type == TokenType.VERIFY_EMAIL:
token.user.is_verified = True
request.dbsession.delete(token)
@@ -243,4 +233,4 @@ def use_token(request):
request.dbsession.delete(token)
request.session.flash(request.localizer.translate(_("flash.password_updated")))
return HTTPFound(request.route_url("login"))
- return None
+ raise NotImplementedError("No matching action found")
diff --git a/fietsboek/views/detail.py b/fietsboek/views/detail.py
index f540a11..ec66922 100644
--- a/fietsboek/views/detail.py
+++ b/fietsboek/views/detail.py
@@ -20,6 +20,17 @@ from ..models.track import TrackWithMetadata
LOGGER = logging.getLogger(__name__)
+def _sort_key(image_name: str) -> str:
+ """Returns the "key" by which the image should be sorted.
+
+ :param image_name: Name of the image (on disk).
+ :return: The value that should be used to sort the image.
+ """
+ if "_" not in image_name:
+ return image_name
+ return image_name.split("_", 1)[1]
+
+
@view_config(
route_name="details", renderer="fietsboek:templates/details.jinja2", permission="track.view"
)
@@ -51,9 +62,13 @@ def details(request):
query = select(models.ImageMetadata).filter_by(track=track, image_name=image_name)
image_metadata = request.dbsession.execute(query).scalar_one_or_none()
if image_metadata:
- images.append((img_src, image_metadata.description))
+ images.append((_sort_key(image_name), img_src, image_metadata.description))
else:
- images.append((img_src, ""))
+ images.append((_sort_key(image_name), img_src, ""))
+
+ images.sort(key=lambda element: element[0])
+ # Strip off the sort key again
+ images = [(image[1], image[2]) for image in images]
with_meta = TrackWithMetadata(track, request.data_manager)
return {
diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py
index e73df42..c7f932d 100644
--- a/fietsboek/views/profile.py
+++ b/fietsboek/views/profile.py
@@ -16,6 +16,31 @@ from .. import models, util
from ..data import UserDataDir
from ..models.track import TrackType, TrackWithMetadata
+# A well-made transparent tile is actually pretty small (only 116 bytes), which
+# is even smaller than our HTTP 404 page. So not only is it more efficient
+# (bandwidth-wise) to transfer the transparent PNG, it also means that we can
+# set cache headers, which the client will honor.
+#
+# Since the tile is so small, we've embedded it right here in the source.
+#
+# The tile is taken and adapted from hittekaart, which in turn took inspiration
+# from https://www.mjt.me.uk/posts/smallest-png/ and
+# http://www.libpng.org/pub/png/spec/1.2/PNG-Contents.html
+# fmt: off
+EMPTY_TILE = bytes([
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
+ 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00,
+ 0x01, 0x03, 0x00, 0x00, 0x00, 0x66, 0xbc, 0x3a, 0x25, 0x00, 0x00, 0x00,
+ 0x03, 0x50, 0x4c, 0x54, 0x45, 0x00, 0xff, 0x00, 0x34, 0x5e, 0xc0, 0xa8,
+ 0x00, 0x00, 0x00, 0x01, 0x74, 0x52, 0x4e, 0x53, 0x00, 0x40, 0xe6, 0xd8,
+ 0x66, 0x00, 0x00, 0x00, 0x1f, 0x49, 0x44, 0x41, 0x54, 0x68, 0x81, 0xed,
+ 0xc1, 0x01, 0x0d, 0x00, 0x00, 0x00, 0xc2, 0xa0, 0xf7, 0x4f, 0x6d, 0x0e,
+ 0x37, 0xa0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbe, 0x0d,
+ 0x21, 0x00, 0x00, 0x01, 0x9a, 0x60, 0xe1, 0xd5, 0x00, 0x00, 0x00, 0x00,
+ 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
+])
+# fmt: on
+
@dataclass
class CumulativeStats:
@@ -157,7 +182,12 @@ def profile(request: Request) -> dict:
}
-@view_config(route_name="user-tile", request_method="GET", permission="profile.view")
+@view_config(
+ route_name="user-tile",
+ request_method="GET",
+ permission="profile.view",
+ http_cache=datetime.timedelta(hours=1),
+)
def user_tile(request: Request) -> Response:
"""Returns a single tile from the user's own overlay maps.
@@ -194,7 +224,7 @@ def user_tile(request: Request) -> Response:
)
result = result.fetchone()
if result is None:
- return HTTPNotFound()
+ return Response(EMPTY_TILE, content_type="image/png")
return Response(result[0], content_type="image/png")
diff --git a/tests/assets/Elevation_Jump.gpx.gz b/tests/assets/Elevation_Jump.gpx.gz
new file mode 100644
index 0000000..836ddb1
--- /dev/null
+++ b/tests/assets/Elevation_Jump.gpx.gz
Binary files differ
diff --git a/tests/integration/test_smoke.py b/tests/integration/test_smoke.py
index 5176e88..cead68d 100644
--- a/tests/integration/test_smoke.py
+++ b/tests/integration/test_smoke.py
@@ -1,7 +1,7 @@
def test_home(testapp):
res = testapp.get("/")
assert res.status_code == 200
- assert b'<h1>Home</h1>' in res.body
+ assert b'<h1 class="float-start">Home</h1>' in res.body
def test_maintenance(testapp, data_manager):
diff --git a/tests/playwright/test_transformers.py b/tests/playwright/test_transformers.py
index d4f07e1..fc89afb 100644
--- a/tests/playwright/test_transformers.py
+++ b/tests/playwright/test_transformers.py
@@ -1,3 +1,4 @@
+import gpxpy
from playwright.sync_api import Page, expect
from sqlalchemy import select
@@ -141,3 +142,31 @@ def test_transformer_steep_slope_edited(page: Page, playwright_helper, tmp_path,
track = dbaccess.execute(select(models.Track).filter_by(id=track_id)).scalar_one()
assert track.cache.uphill < 2
+
+
+def test_transformer_elevation_jump_enabled(page: Page, playwright_helper, tmp_path, data_manager):
+ playwright_helper.login()
+
+ page.goto("/")
+ page.get_by_text("Upload").click()
+
+ extract_and_upload(page, "Elevation_Jump.gpx.gz", tmp_path)
+
+ page.locator("#transformer-heading-2 .accordion-button").click()
+ page.locator("#transformer-2.collapse.show").wait_for()
+ page.locator("#transformer-enabled-2").check()
+
+ page.locator(".btn", has_text="Upload").click()
+
+ 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())
+ points = iter(gpx.walk(only_points=True))
+ next(points)
+ for prev_point, point in zip(gpx.walk(only_points=True), points):
+ # The given GPX has a jump of 94 between two consecutive points, so
+ # here we assert that that jump is gone.
+ assert abs(prev_point.elevation - point.elevation) < 10.0