diff options
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 Binary files differnew file mode 100644 index 0000000..4d1334a --- /dev/null +++ b/doc/user/images/fixed_elevation_jump.png diff --git a/doc/user/images/wrong_elevation_jump.png b/doc/user/images/wrong_elevation_jump.png Binary files differnew file mode 100644 index 0000000..03d454d --- /dev/null +++ b/doc/user/images/wrong_elevation_jump.png 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 Binary files differindex eb24766..887f9bc 100644 --- a/fietsboek/locale/de/LC_MESSAGES/messages.mo +++ b/fietsboek/locale/de/LC_MESSAGES/messages.mo diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.po b/fietsboek/locale/de/LC_MESSAGES/messages.po index 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 Binary files differindex 055dbd8..bfd051f 100644 --- a/fietsboek/locale/en/LC_MESSAGES/messages.mo +++ b/fietsboek/locale/en/LC_MESSAGES/messages.mo diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.po b/fietsboek/locale/en/LC_MESSAGES/messages.po index 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 Binary files differnew file mode 100644 index 0000000..836ddb1 --- /dev/null +++ b/tests/assets/Elevation_Jump.gpx.gz 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 |