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  | 
