diff options
author | Daniel Schadt <kingdread@gmx.de> | 2023-04-13 20:12:32 +0200 |
---|---|---|
committer | Daniel Schadt <kingdread@gmx.de> | 2023-04-13 20:12:32 +0200 |
commit | be05f4d4e1729714ffb4c3c37b5dcedcd7c79c26 (patch) | |
tree | d1d444cbcb934b85dc59a7e8e18568614a8e2610 | |
parent | c6ba50be205763342992fdddad6ca59755051dd9 (diff) | |
parent | 5b051b9f97892784d281556db3d5f8b01671568d (diff) | |
download | fietsboek-be05f4d4e1729714ffb4c3c37b5dcedcd7c79c26.tar.gz fietsboek-be05f4d4e1729714ffb4c3c37b5dcedcd7c79c26.tar.bz2 fietsboek-be05f4d4e1729714ffb4c3c37b5dcedcd7c79c26.zip |
Merge branch 'profiles'
26 files changed, 1520 insertions, 447 deletions
diff --git a/doc/administration/configuration.rst b/doc/administration/configuration.rst index e77e3cc..d272234 100644 --- a/doc/administration/configuration.rst +++ b/doc/administration/configuration.rst @@ -213,3 +213,37 @@ true``. This will cause the tiles to be loaded directly by the client. In addition, depending on the jurisdiction, you might be required to tell your users that third party content is included in your site, and that their IP will be accessible to the third party. + +Hittekaart Integration +---------------------- + +Fietsboek can use hittekaart_ to generate heat maps for users. For that, you +can set ``hittekaart.bin`` to the path to the ``hittekaart`` binary. If unset, +it is assumed that the binary can be found in your ``$PATH``. + +In addition, you can set ``hittekaart.autogenerate`` to the list of overlay +maps you want to automatically generate and update. By default, this list is +empty, which means that Fietsboek will not generate any overlays on its own. +You can add ``heatmap`` and/or ``tilehunter`` to generate those maps +automatically. + +.. note:: + + The ``hittekaart.autogenerate`` has no effect on the ``fietsctl + hittekaart`` command. You can always use ``fietsctl`` to generate heat maps + for specific users! + +.. warning:: + + Depending on the geospatial area that a user covers with their tracks, an + overlay map can get relatively large (~100 MiB for mine). Keep that in mind + when hosting a larger number of users. + +An example configuration excerpt can look like this: + +.. code-block:: ini + + hittekaart.bin = /usr/local/bin/hittekaart + hittekaart.autogenerate = heatmap tilehunter + +.. _hittekaart: https://gitlab.com/dunj3/hittekaart diff --git a/fietsboek/config.py b/fietsboek/config.py index a5a87a8..42d717c 100644 --- a/fietsboek/config.py +++ b/fietsboek/config.py @@ -195,6 +195,12 @@ class Config(BaseModel): tile_layers: typing.List[TileLayerConfig] = [] """Tile layers.""" + hittekaart_bin: str = Field("", alias="hittekaart.bin") + """Path to the hittekaart binary.""" + + hittekaart_autogenerate: PyramidList = Field([], alias="hittekaart.autogenerate") + """Overlay maps to automatically generate.""" + @validator("session_key") def _good_session_key(cls, value): """Ensures that the session key has been changed from its default @@ -222,6 +228,19 @@ class Config(BaseModel): raise ValueError("Unknown stamen maps: " + ", ".join(bad_maps)) return value + @validator("hittekaart_autogenerate") + def _known_hittekaart_modes(cls, value): + """Ensures that the hittekaart modes are valid.""" + # pylint: disable=import-outside-toplevel + from . import hittekaart + + modes = set(value) + known_modes = {mode.value for mode in hittekaart.Mode} + bad_modes = modes - known_modes + if bad_modes: + raise ValueError("Unknown hittekaart overlays: " + ", ".join(bad_modes)) + return value + def derive_secret(self, what_for): """Derive a secret for other parts of the application. diff --git a/fietsboek/data.py b/fietsboek/data.py index 9f49c49..7457986 100644 --- a/fietsboek/data.py +++ b/fietsboek/data.py @@ -54,6 +54,9 @@ class DataManager: def _track_data_dir(self, track_id): return self.data_dir / "tracks" / str(track_id) + def _user_data_dir(self, user_id): + return self.data_dir / "users" / str(user_id) + def maintenance_mode(self) -> Optional[str]: """Checks whether the maintenance mode is enabled. @@ -79,6 +82,17 @@ class DataManager: path.mkdir(parents=True) return TrackDataDir(track_id, path, journal=True, is_fresh=True) + def initialize_user(self, user_id: int) -> "UserDataDir": + """Creates the data directory for a user. + + :raises FileExistsError: If the directory already exists. + :param user_id: ID of the user. + :return: The manager that can be used to manage this user's data. + """ + path = self._user_data_dir(user_id) + path.mkdir(parents=True) + return UserDataDir(user_id, path) + def purge(self, track_id: int): """Forcefully purges all data from the given track. @@ -99,6 +113,18 @@ class DataManager: raise FileNotFoundError(f"The path {path} is not a directory") from None return TrackDataDir(track_id, path) + def open_user(self, user_id: int) -> "UserDataDir": + """Opens a user's data directory. + + :raises FileNotFoundError: If the user directory does not exist. + :param user_id: ID of the user. + :return: The manager that can be used to manage this user's data. + """ + path = self._user_data_dir(user_id) + if not path.is_dir(): + raise FileNotFoundError(f"The path {path} is not a directory") from None + return UserDataDir(user_id, path) + class TrackDataDir: """Manager for a single track's data. @@ -360,3 +386,25 @@ class TrackDataDir: self.journal.append(("delete_image", path, path.read_bytes())) path.unlink() + + +class UserDataDir: + """Manager for a single user's data.""" + + def __init__(self, user_id: int, path: Path): + self.user_id = user_id + self.path = path + + def heatmap_path(self) -> Path: + """Returns the path for the heatmap tile file. + + :return: The path of the heatmap SQLite databse. + """ + return self.path / "heatmap.sqlite" + + def tilehunt_path(self) -> Path: + """Returns the path for the tilehunt tile file. + + :return: The path of the tilehunt SQLite database. + """ + return self.path / "tilehunt.sqlite" diff --git a/fietsboek/hittekaart.py b/fietsboek/hittekaart.py new file mode 100644 index 0000000..69b8d42 --- /dev/null +++ b/fietsboek/hittekaart.py @@ -0,0 +1,130 @@ +"""Interface to the hittekaart_ application to generate heatmaps. + +.. _hittekaart: https://gitlab.com/dunj3/hittekaart +""" +import enum +import logging +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.orm import aliased +from sqlalchemy.orm.session import Session + +from . import models +from .data import DataManager +from .models.track import TrackType + +LOGGER = logging.getLogger(__name__) + + +class Mode(enum.Enum): + """Heatmap generation mode. + + This enum represents the different types of overlay maps that + ``hittekaart`` can generate. + """ + + HEATMAP = "heatmap" + TILEHUNTER = "tilehunter" + + +def generate( + output: Path, + mode: Mode, + input_files: list[Path], + *, + exe_path: Optional[Path] = None, + threads: int = 0, +): + """Calls hittekaart with the given arguments. + + :param output: Output filename. Note that this function always uses the + sqlite output mode. + :param mode: What to generate. + :param input_files: List of paths to the input files. + :param exe_path: Path to the hittekaart binary. If not given, + ``hittekaart`` is searched in the path. + :param threads: Number of threads that ``hittekaart`` should use. Defaults + to 0, which uses all available cores. + """ + if not input_files: + return + # There are two reasons why we do the tempfile dance: + # 1. hittekaart refuses to overwrite existing files + # 2. This way we can (hope for?) an atomic move (at least if temporary file + # is on the same filesystem). In the future, we might want to enforce + # this, but for now, it's alright. + with tempfile.TemporaryDirectory() as tempdir: + tmpfile = Path(tempdir) / "hittekaart.sqlite" + binary = str(exe_path) if exe_path else "hittekaart" + cmdline = [ + binary, + "--sqlite", + "-o", + str(tmpfile), + "-m", + mode.value, + "-t", + str(threads), + "--", + ] + cmdline.extend(map(str, input_files)) + LOGGER.debug("Running %r", cmdline) + subprocess.run(cmdline, check=True, stdout=subprocess.DEVNULL) + + LOGGER.debug("Moving temporary file") + shutil.move(tmpfile, output) + + +def generate_for( + user: models.User, + dbsession: Session, + data_manager: DataManager, + mode: Mode, + *, + exe_path: Optional[Path] = None, + threads: int = 0, +): + """Uses :meth:`generate` to generate a heatmap for the given user. + + This function automatically retrieves the user's tracks from the database + and passes them to ``hittekaart``. + + The output is saved in the user's data directory using the + ``data_manager``. + + :param user: The user for which to generate the map. + :param dbsession: The database session. + :param data_manager: The data manager. + :param mode: The mode of the heatmap. + :param exe_path: See :meth:`generate`. + :param threads: See :meth:`generate`. + """ + query = user.all_tracks_query() + query = select(aliased(models.Track, query)).where(query.c.type == TrackType.ORGANIC) + input_paths = [] + for track in dbsession.execute(query).scalars(): + path = data_manager.open(track.id).gpx_path() + input_paths.append(path) + + if not input_paths: + return + + try: + user_dir = data_manager.initialize_user(user.id) + except FileExistsError: + user_dir = data_manager.open_user(user.id) + + output_paths = { + Mode.HEATMAP: user_dir.heatmap_path(), + Mode.TILEHUNTER: user_dir.tilehunt_path(), + } + + generate(output_paths[mode], mode, input_paths, exe_path=exe_path, threads=threads) + + +__all__ = ["Mode", "generate", "generate_for"] diff --git a/fietsboek/jinja2.py b/fietsboek/jinja2.py index a3ca7d8..f5ae7d7 100644 --- a/fietsboek/jinja2.py +++ b/fietsboek/jinja2.py @@ -9,6 +9,8 @@ from jinja2.runtime import Context from markupsafe import Markup from pyramid.request import Request +from . import util + @jinja2.pass_context def filter_format_decimal(ctx: Context, value: float) -> str: @@ -94,12 +96,7 @@ def global_embed_tile_layers(request: Request) -> Markup: else: def _url(source): - return ( - request.route_url("tile-proxy", provider=source.layer_id, x="{x}", y="{y}", z="{z}") - .replace("%7Bx%7D", "{x}") - .replace("%7By%7D", "{y}") - .replace("%7Bz%7D", "{z}") - ) + return util.tile_url(request, "tile-proxy", provider=source.layer_id) return Markup( json.dumps( diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.mo b/fietsboek/locale/de/LC_MESSAGES/messages.mo Binary files differindex a79349d..7627f21 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 8b59ecb..500d73b 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-03-07 20:11+0100\n" +"POT-Creation-Date: 2023-04-03 20:34+0200\n" "PO-Revision-Date: 2022-07-02 17:35+0200\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language: de\n" @@ -18,39 +18,39 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.11.0\n" -#: fietsboek/util.py:276 +#: fietsboek/util.py:275 msgid "password_constraint.mismatch" msgstr "Passwörter stimmen nicht überein" -#: fietsboek/util.py:278 +#: fietsboek/util.py:277 msgid "password_constraint.length" msgstr "Passwort zu kurz" -#: fietsboek/models/track.py:566 +#: fietsboek/models/track.py:579 msgid "tooltip.table.length" msgstr "Länge" -#: fietsboek/models/track.py:567 +#: fietsboek/models/track.py:580 msgid "tooltip.table.uphill" msgstr "Bergauf" -#: fietsboek/models/track.py:568 +#: fietsboek/models/track.py:581 msgid "tooltip.table.downhill" msgstr "Bergab" -#: fietsboek/models/track.py:569 +#: fietsboek/models/track.py:582 msgid "tooltip.table.moving_time" msgstr "Fahrzeit" -#: fietsboek/models/track.py:570 +#: fietsboek/models/track.py:583 msgid "tooltip.table.stopped_time" msgstr "Haltezeit" -#: fietsboek/models/track.py:572 +#: fietsboek/models/track.py:585 msgid "tooltip.table.max_speed" msgstr "Maximalgeschwindigkeit" -#: fietsboek/models/track.py:576 +#: fietsboek/models/track.py:589 msgid "tooltip.table.avg_speed" msgstr "Durchschnittsgeschwindigkeit" @@ -155,42 +155,52 @@ msgid "page.browse.synthetic_tooltip" msgstr "Dies ist eine geplante Strecke" #: fietsboek/templates/browse.jinja2:132 fietsboek/templates/details.jinja2:90 +#: fietsboek/templates/profile.jinja2:15 msgid "page.details.date" msgstr "Datum" #: fietsboek/templates/browse.jinja2:134 fietsboek/templates/details.jinja2:104 +#: fietsboek/templates/profile.jinja2:17 msgid "page.details.length" msgstr "Länge" #: fietsboek/templates/browse.jinja2:139 fietsboek/templates/details.jinja2:95 +#: fietsboek/templates/profile.jinja2:21 msgid "page.details.start_time" msgstr "Startzeit" #: fietsboek/templates/browse.jinja2:141 fietsboek/templates/details.jinja2:99 +#: fietsboek/templates/profile.jinja2:23 msgid "page.details.end_time" msgstr "Endzeit" #: fietsboek/templates/browse.jinja2:146 fietsboek/templates/details.jinja2:108 +#: fietsboek/templates/profile.jinja2:27 msgid "page.details.uphill" msgstr "Bergauf" #: fietsboek/templates/browse.jinja2:148 fietsboek/templates/details.jinja2:112 +#: fietsboek/templates/profile.jinja2:29 msgid "page.details.downhill" msgstr "Bergab" #: fietsboek/templates/browse.jinja2:153 fietsboek/templates/details.jinja2:117 +#: fietsboek/templates/profile.jinja2:33 msgid "page.details.moving_time" msgstr "Fahrzeit" #: fietsboek/templates/browse.jinja2:155 fietsboek/templates/details.jinja2:121 +#: fietsboek/templates/profile.jinja2:35 msgid "page.details.stopped_time" msgstr "Haltezeit" #: fietsboek/templates/browse.jinja2:159 fietsboek/templates/details.jinja2:125 +#: fietsboek/templates/profile.jinja2:39 msgid "page.details.max_speed" msgstr "maximale Geschwindigkeit" #: fietsboek/templates/browse.jinja2:161 fietsboek/templates/details.jinja2:129 +#: fietsboek/templates/profile.jinja2:41 msgid "page.details.avg_speed" msgstr "durchschnittliche Geschwindigkeit" @@ -471,47 +481,51 @@ msgstr[1] "%(num)d Strecken" msgid "page.home.total" msgstr "Gesamt" -#: fietsboek/templates/layout.jinja2:39 +#: fietsboek/templates/layout.jinja2:41 msgid "page.navbar.toggle" msgstr "Navigation umschalten" -#: fietsboek/templates/layout.jinja2:50 +#: fietsboek/templates/layout.jinja2:52 msgid "page.navbar.home" msgstr "Startseite" -#: fietsboek/templates/layout.jinja2:53 +#: fietsboek/templates/layout.jinja2:55 msgid "page.navbar.browse" msgstr "Stöbern" -#: fietsboek/templates/layout.jinja2:57 +#: fietsboek/templates/layout.jinja2:59 msgid "page.navbar.upload" msgstr "Hochladen" -#: fietsboek/templates/layout.jinja2:66 +#: fietsboek/templates/layout.jinja2:68 msgid "page.navbar.user" msgstr "Nutzer" -#: fietsboek/templates/layout.jinja2:70 +#: fietsboek/templates/layout.jinja2:72 msgid "page.navbar.welcome_user" msgstr "Willkommen, {}!" -#: fietsboek/templates/layout.jinja2:73 +#: fietsboek/templates/layout.jinja2:75 msgid "page.navbar.logout" msgstr "Abmelden" -#: fietsboek/templates/layout.jinja2:76 +#: fietsboek/templates/layout.jinja2:78 msgid "page.navbar.profile" msgstr "Profil" -#: fietsboek/templates/layout.jinja2:80 +#: fietsboek/templates/layout.jinja2:81 +msgid "page.navbar.user_data" +msgstr "Persönliche Daten" + +#: fietsboek/templates/layout.jinja2:85 msgid "page.navbar.admin" msgstr "Admin" -#: fietsboek/templates/layout.jinja2:86 +#: fietsboek/templates/layout.jinja2:91 msgid "page.navbar.login" msgstr "Anmelden" -#: fietsboek/templates/layout.jinja2:90 +#: fietsboek/templates/layout.jinja2:95 msgid "page.navbar.create_account" msgstr "Konto Erstellen" @@ -563,80 +577,136 @@ msgstr "Passwörter stimmen nicht überein" msgid "page.password_reset.reset" msgstr "Zurücksetzen" -#: fietsboek/templates/profile.jinja2:7 +#: fietsboek/templates/profile.jinja2:66 +msgid "page.profile.length" +msgstr "Länge" + +#: fietsboek/templates/profile.jinja2:70 +msgid "page.profile.uphill" +msgstr "Bergauf" + +#: fietsboek/templates/profile.jinja2:74 +msgid "page.profile.downhill" +msgstr "Bergab" + +#: fietsboek/templates/profile.jinja2:78 +msgid "page.profile.moving_time" +msgstr "Fahrzeit" + +#: fietsboek/templates/profile.jinja2:82 +msgid "page.profile.stopped_time" +msgstr "Haltezeit" + +#: fietsboek/templates/profile.jinja2:86 +msgid "page.profile.max_speed" +msgstr "maximale Geschwindigkeit" + +#: fietsboek/templates/profile.jinja2:90 +msgid "page.profile.avg_speed" +msgstr "durchschnittliche Geschwindigkeit" + +#: fietsboek/templates/profile.jinja2:94 +msgid "page.profile.number_of_tracks" +msgstr "Anzahl der Strecken" + +#: fietsboek/templates/profile.jinja2:100 +msgid "page.profile.longest_distance_track" +msgstr "Weiteste Strecke" + +#: fietsboek/templates/profile.jinja2:105 +msgid "page.profile.shortest_distance_track" +msgstr "Kürzeste Strecke" + +#: fietsboek/templates/profile.jinja2:110 +msgid "page.profile.longest_duration_track" +msgstr "Am Längsten Dauernde Strecke" + +#: fietsboek/templates/profile.jinja2:115 +msgid "page.profile.shortest_duration_track" +msgstr "Am Kürzesten Dauernde Strecke" + +#: fietsboek/templates/profile.jinja2:135 +msgid "page.profile.heatmap" +msgstr "Heatmap" + +#: fietsboek/templates/profile.jinja2:140 +msgid "page.profile.tilehunt" +msgstr "Kacheljäger" + +#: fietsboek/templates/request_password.jinja2:5 +msgid "page.request_password.title" +msgstr "Passwortzurücksetzung Beantragen" + +#: fietsboek/templates/request_password.jinja2:6 +msgid "page.request_password.info" +msgstr "" +"Wenn Du Dein Passwort vergessen hast, kannst Du Deine E-Mail-Adresse hier" +" eingeben und einen Link zum Zurücksetzen Deines Passworts erhalten." + +#: fietsboek/templates/request_password.jinja2:12 +msgid "page.request_password.email" +msgstr "E-Mail-Adresse" + +#: fietsboek/templates/request_password.jinja2:17 +msgid "page.request_password.request" +msgstr "Anfrage senden" + +#: fietsboek/templates/upload.jinja2:9 +msgid "page.upload.form.gpx" +msgstr "GPX Datei" + +#: fietsboek/templates/user_data.jinja2:7 msgid "page.my_profile.title" msgstr "Mein Profil" -#: fietsboek/templates/profile.jinja2:11 +#: fietsboek/templates/user_data.jinja2:11 msgid "page.my_profile.personal_data" msgstr "Persönliche Daten" -#: fietsboek/templates/profile.jinja2:16 +#: fietsboek/templates/user_data.jinja2:16 msgid "page.my_profile.personal_data.name" msgstr "Name" -#: fietsboek/templates/profile.jinja2:21 +#: fietsboek/templates/user_data.jinja2:21 msgid "page.my_profile.personal_data.password_invalid" msgstr "Passwort zu kurz" -#: fietsboek/templates/profile.jinja2:23 +#: fietsboek/templates/user_data.jinja2:23 msgid "page.my_profile.personal_data.password" msgstr "Passwort" -#: fietsboek/templates/profile.jinja2:28 +#: fietsboek/templates/user_data.jinja2:28 msgid "page.my_profile.personal_data.password_must_match" msgstr "Passwörter müssen übereinstimmen" -#: fietsboek/templates/profile.jinja2:30 +#: fietsboek/templates/user_data.jinja2:30 msgid "page.my_profile.personal_data.repeat_password" msgstr "Passwort wiederholen" -#: fietsboek/templates/profile.jinja2:33 +#: fietsboek/templates/user_data.jinja2:33 msgid "page.my_profile.personal_data.save" msgstr "Speichern" -#: fietsboek/templates/profile.jinja2:38 +#: fietsboek/templates/user_data.jinja2:38 msgid "page.my_profile.friends" msgstr "Freunde" -#: fietsboek/templates/profile.jinja2:46 +#: fietsboek/templates/user_data.jinja2:46 msgid "page.my_profile.unfriend" msgstr "Entfreunden" -#: fietsboek/templates/profile.jinja2:56 +#: fietsboek/templates/user_data.jinja2:56 msgid "page.my_profile.accept_friend" msgstr "Annehmen" -#: fietsboek/templates/profile.jinja2:73 +#: fietsboek/templates/user_data.jinja2:73 msgid "page.my_profile.friend_request_email" msgstr "E-Mail-Adresse des Freundes" -#: fietsboek/templates/profile.jinja2:77 +#: fietsboek/templates/user_data.jinja2:77 msgid "page.my_profile.send_friend_request" msgstr "Freundschaftsanfrage senden" -#: fietsboek/templates/request_password.jinja2:5 -msgid "page.request_password.title" -msgstr "Passwortzurücksetzung Beantragen" - -#: fietsboek/templates/request_password.jinja2:6 -msgid "page.request_password.info" -msgstr "" -"Wenn Du Dein Passwort vergessen hast, kannst Du Deine E-Mail-Adresse hier" -" eingeben und einen Link zum Zurücksetzen Deines Passworts erhalten." - -#: fietsboek/templates/request_password.jinja2:12 -msgid "page.request_password.email" -msgstr "E-Mail-Adresse" - -#: fietsboek/templates/request_password.jinja2:17 -msgid "page.request_password.request" -msgstr "Anfrage senden" - -#: fietsboek/templates/upload.jinja2:9 -msgid "page.upload.form.gpx" -msgstr "GPX Datei" - #: fietsboek/transformers/__init__.py:140 msgid "transformers.fix-null-elevation.title" msgstr "Nullhöhen beheben" @@ -730,26 +800,6 @@ msgstr "Passwort aktualisiert" msgid "flash.track_deleted" msgstr "Strecke gelöscht" -#: fietsboek/views/profile.py:60 -msgid "flash.personal_data_updated" -msgstr "Persönliche Daten wurden gespeichert" - -#: fietsboek/views/profile.py:78 -msgid "flash.friend_not_found" -msgstr "Das angegebene Konto wurde nicht gefunden" - -#: fietsboek/views/profile.py:84 -msgid "flash.friend_already_exists" -msgstr "Dieser Freund existiert bereits" - -#: fietsboek/views/profile.py:92 -msgid "flash.friend_added" -msgstr "Freund hinzugefügt" - -#: fietsboek/views/profile.py:102 -msgid "flash.friend_request_sent" -msgstr "Freundschaftsanfrage gesendet" - #: fietsboek/views/upload.py:52 msgid "flash.no_file_selected" msgstr "Keine Datei ausgewählt" @@ -762,7 +812,27 @@ msgstr "Ungültige GPX-Datei gesendet" msgid "flash.upload_success" msgstr "Hochladen erfolgreich" -#: fietsboek/views/upload.py:204 +#: fietsboek/views/upload.py:207 msgid "flash.upload_cancelled" msgstr "Hochladen abgebrochen" +#: fietsboek/views/user_data.py:60 +msgid "flash.personal_data_updated" +msgstr "Persönliche Daten wurden gespeichert" + +#: fietsboek/views/user_data.py:78 +msgid "flash.friend_not_found" +msgstr "Das angegebene Konto wurde nicht gefunden" + +#: fietsboek/views/user_data.py:84 +msgid "flash.friend_already_exists" +msgstr "Dieser Freund existiert bereits" + +#: fietsboek/views/user_data.py:92 +msgid "flash.friend_added" +msgstr "Freund hinzugefügt" + +#: fietsboek/views/user_data.py:102 +msgid "flash.friend_request_sent" +msgstr "Freundschaftsanfrage gesendet" + diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.mo b/fietsboek/locale/en/LC_MESSAGES/messages.mo Binary files differindex 13debb5..723b1a5 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 632e0b7..94fd6a6 100644 --- a/fietsboek/locale/en/LC_MESSAGES/messages.po +++ b/fietsboek/locale/en/LC_MESSAGES/messages.po @@ -7,50 +7,51 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-03-07 20:11+0100\n" -"PO-Revision-Date: 2022-06-28 13:11+0200\n" +"POT-Creation-Date: 2023-04-03 20:34+0200\n" +"PO-Revision-Date: 2023-04-03 20:42+0200\n" "Last-Translator: \n" -"Language: en\n" "Language-Team: en <LL@li.org>\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language: en\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Generated-By: Babel 2.11.0\n" +"X-Generator: Poedit 3.2.2\n" -#: fietsboek/util.py:276 +#: fietsboek/util.py:275 msgid "password_constraint.mismatch" msgstr "Passwords don't match" -#: fietsboek/util.py:278 +#: fietsboek/util.py:277 msgid "password_constraint.length" msgstr "Password not long enough" -#: fietsboek/models/track.py:566 +#: fietsboek/models/track.py:579 msgid "tooltip.table.length" msgstr "Length" -#: fietsboek/models/track.py:567 +#: fietsboek/models/track.py:580 msgid "tooltip.table.uphill" msgstr "Uphill" -#: fietsboek/models/track.py:568 +#: fietsboek/models/track.py:581 msgid "tooltip.table.downhill" msgstr "Downhill" -#: fietsboek/models/track.py:569 +#: fietsboek/models/track.py:582 msgid "tooltip.table.moving_time" msgstr "Moving Time" -#: fietsboek/models/track.py:570 +#: fietsboek/models/track.py:583 msgid "tooltip.table.stopped_time" msgstr "Stopped Time" -#: fietsboek/models/track.py:572 +#: fietsboek/models/track.py:585 msgid "tooltip.table.max_speed" msgstr "Max Speed" -#: fietsboek/models/track.py:576 +#: fietsboek/models/track.py:589 msgid "tooltip.table.avg_speed" msgstr "Average Speed" @@ -155,42 +156,52 @@ msgid "page.browse.synthetic_tooltip" msgstr "This is a pre-planned track" #: fietsboek/templates/browse.jinja2:132 fietsboek/templates/details.jinja2:90 +#: fietsboek/templates/profile.jinja2:15 msgid "page.details.date" msgstr "Date" #: fietsboek/templates/browse.jinja2:134 fietsboek/templates/details.jinja2:104 +#: fietsboek/templates/profile.jinja2:17 msgid "page.details.length" msgstr "Length" #: fietsboek/templates/browse.jinja2:139 fietsboek/templates/details.jinja2:95 +#: fietsboek/templates/profile.jinja2:21 msgid "page.details.start_time" msgstr "Record Start" #: fietsboek/templates/browse.jinja2:141 fietsboek/templates/details.jinja2:99 +#: fietsboek/templates/profile.jinja2:23 msgid "page.details.end_time" msgstr "Record End" #: fietsboek/templates/browse.jinja2:146 fietsboek/templates/details.jinja2:108 +#: fietsboek/templates/profile.jinja2:27 msgid "page.details.uphill" msgstr "Uphill" #: fietsboek/templates/browse.jinja2:148 fietsboek/templates/details.jinja2:112 +#: fietsboek/templates/profile.jinja2:29 msgid "page.details.downhill" msgstr "Downhill" #: fietsboek/templates/browse.jinja2:153 fietsboek/templates/details.jinja2:117 +#: fietsboek/templates/profile.jinja2:33 msgid "page.details.moving_time" msgstr "Moving Time" #: fietsboek/templates/browse.jinja2:155 fietsboek/templates/details.jinja2:121 +#: fietsboek/templates/profile.jinja2:35 msgid "page.details.stopped_time" msgstr "Stopped Time" #: fietsboek/templates/browse.jinja2:159 fietsboek/templates/details.jinja2:125 +#: fietsboek/templates/profile.jinja2:39 msgid "page.details.max_speed" msgstr "Max Speed" #: fietsboek/templates/browse.jinja2:161 fietsboek/templates/details.jinja2:129 +#: fietsboek/templates/profile.jinja2:41 msgid "page.details.avg_speed" msgstr "Average Speed" @@ -378,8 +389,8 @@ msgstr "Public" #: fietsboek/templates/edit_form.jinja2:21 msgid "page.track.form.visibility.info" msgstr "" -"Note that tagged people and people with the share link can always view " -"the track." +"Note that tagged people and people with the share link can always view the " +"track." #: fietsboek/templates/edit_form.jinja2:25 msgid "page.track.form.type" @@ -438,8 +449,7 @@ msgstr "Apply" msgid "page.track.form.transformer.enable" msgstr "Apply transformation" -#: fietsboek/templates/finish_upload.jinja2:8 -#: fietsboek/templates/upload.jinja2:6 +#: fietsboek/templates/finish_upload.jinja2:8 fietsboek/templates/upload.jinja2:6 msgid "page.upload.title" msgstr "Upload" @@ -467,47 +477,51 @@ msgstr[1] "%(num)d tracks" msgid "page.home.total" msgstr "Total" -#: fietsboek/templates/layout.jinja2:39 +#: fietsboek/templates/layout.jinja2:41 msgid "page.navbar.toggle" msgstr "Toggle navigation" -#: fietsboek/templates/layout.jinja2:50 +#: fietsboek/templates/layout.jinja2:52 msgid "page.navbar.home" msgstr "Home" -#: fietsboek/templates/layout.jinja2:53 +#: fietsboek/templates/layout.jinja2:55 msgid "page.navbar.browse" msgstr "Browse" -#: fietsboek/templates/layout.jinja2:57 +#: fietsboek/templates/layout.jinja2:59 msgid "page.navbar.upload" msgstr "Upload" -#: fietsboek/templates/layout.jinja2:66 +#: fietsboek/templates/layout.jinja2:68 msgid "page.navbar.user" msgstr "User" -#: fietsboek/templates/layout.jinja2:70 +#: fietsboek/templates/layout.jinja2:72 msgid "page.navbar.welcome_user" msgstr "Welcome, {}!" -#: fietsboek/templates/layout.jinja2:73 +#: fietsboek/templates/layout.jinja2:75 msgid "page.navbar.logout" msgstr "Logout" -#: fietsboek/templates/layout.jinja2:76 +#: fietsboek/templates/layout.jinja2:78 msgid "page.navbar.profile" msgstr "Profile" -#: fietsboek/templates/layout.jinja2:80 +#: fietsboek/templates/layout.jinja2:81 +msgid "page.navbar.user_data" +msgstr "Personal Data" + +#: fietsboek/templates/layout.jinja2:85 msgid "page.navbar.admin" msgstr "Admin" -#: fietsboek/templates/layout.jinja2:86 +#: fietsboek/templates/layout.jinja2:91 msgid "page.navbar.login" msgstr "Login" -#: fietsboek/templates/layout.jinja2:90 +#: fietsboek/templates/layout.jinja2:95 msgid "page.navbar.create_account" msgstr "Create Account" @@ -559,80 +573,136 @@ msgstr "Passwords must match" msgid "page.password_reset.reset" msgstr "Reset" -#: fietsboek/templates/profile.jinja2:7 +#: fietsboek/templates/profile.jinja2:66 +msgid "page.profile.length" +msgstr "Length" + +#: fietsboek/templates/profile.jinja2:70 +msgid "page.profile.uphill" +msgstr "Uphill" + +#: fietsboek/templates/profile.jinja2:74 +msgid "page.profile.downhill" +msgstr "Downhill" + +#: fietsboek/templates/profile.jinja2:78 +msgid "page.profile.moving_time" +msgstr "Moving Time" + +#: fietsboek/templates/profile.jinja2:82 +msgid "page.profile.stopped_time" +msgstr "Stopped Time" + +#: fietsboek/templates/profile.jinja2:86 +msgid "page.profile.max_speed" +msgstr "Max Speed" + +#: fietsboek/templates/profile.jinja2:90 +msgid "page.profile.avg_speed" +msgstr "Average Speed" + +#: fietsboek/templates/profile.jinja2:94 +msgid "page.profile.number_of_tracks" +msgstr "Number of tracks" + +#: fietsboek/templates/profile.jinja2:100 +msgid "page.profile.longest_distance_track" +msgstr "Longest Track" + +#: fietsboek/templates/profile.jinja2:105 +msgid "page.profile.shortest_distance_track" +msgstr "Shortest Track" + +#: fietsboek/templates/profile.jinja2:110 +msgid "page.profile.longest_duration_track" +msgstr "Most Time-Consuming Track" + +#: fietsboek/templates/profile.jinja2:115 +msgid "page.profile.shortest_duration_track" +msgstr "Quickest Track" + +#: fietsboek/templates/profile.jinja2:135 +msgid "page.profile.heatmap" +msgstr "Heat Map" + +#: fietsboek/templates/profile.jinja2:140 +msgid "page.profile.tilehunt" +msgstr "Tilehunt" + +#: fietsboek/templates/request_password.jinja2:5 +msgid "page.request_password.title" +msgstr "Request a Password Reset" + +#: fietsboek/templates/request_password.jinja2:6 +msgid "page.request_password.info" +msgstr "" +"If you forgot your password, you can type in your email address below and " +"receive a link to reset your password" + +#: fietsboek/templates/request_password.jinja2:12 +msgid "page.request_password.email" +msgstr "Email" + +#: fietsboek/templates/request_password.jinja2:17 +msgid "page.request_password.request" +msgstr "Send request" + +#: fietsboek/templates/upload.jinja2:9 +msgid "page.upload.form.gpx" +msgstr "GPX file" + +#: fietsboek/templates/user_data.jinja2:7 msgid "page.my_profile.title" msgstr "My Profile" -#: fietsboek/templates/profile.jinja2:11 +#: fietsboek/templates/user_data.jinja2:11 msgid "page.my_profile.personal_data" msgstr "Personal Data" -#: fietsboek/templates/profile.jinja2:16 +#: fietsboek/templates/user_data.jinja2:16 msgid "page.my_profile.personal_data.name" msgstr "My name" -#: fietsboek/templates/profile.jinja2:21 +#: fietsboek/templates/user_data.jinja2:21 msgid "page.my_profile.personal_data.password_invalid" msgstr "Password not long enough" -#: fietsboek/templates/profile.jinja2:23 +#: fietsboek/templates/user_data.jinja2:23 msgid "page.my_profile.personal_data.password" msgstr "Password" -#: fietsboek/templates/profile.jinja2:28 +#: fietsboek/templates/user_data.jinja2:28 msgid "page.my_profile.personal_data.password_must_match" msgstr "Passwords must match" -#: fietsboek/templates/profile.jinja2:30 +#: fietsboek/templates/user_data.jinja2:30 msgid "page.my_profile.personal_data.repeat_password" msgstr "Repeat password" -#: fietsboek/templates/profile.jinja2:33 +#: fietsboek/templates/user_data.jinja2:33 msgid "page.my_profile.personal_data.save" msgstr "Save" -#: fietsboek/templates/profile.jinja2:38 +#: fietsboek/templates/user_data.jinja2:38 msgid "page.my_profile.friends" msgstr "Friends" -#: fietsboek/templates/profile.jinja2:46 +#: fietsboek/templates/user_data.jinja2:46 msgid "page.my_profile.unfriend" msgstr "Unfriend" -#: fietsboek/templates/profile.jinja2:56 +#: fietsboek/templates/user_data.jinja2:56 msgid "page.my_profile.accept_friend" msgstr "Accept" -#: fietsboek/templates/profile.jinja2:73 +#: fietsboek/templates/user_data.jinja2:73 msgid "page.my_profile.friend_request_email" msgstr "Email of the friend" -#: fietsboek/templates/profile.jinja2:77 +#: fietsboek/templates/user_data.jinja2:77 msgid "page.my_profile.send_friend_request" msgstr "Send friend request" -#: fietsboek/templates/request_password.jinja2:5 -msgid "page.request_password.title" -msgstr "Request a Password Reset" - -#: fietsboek/templates/request_password.jinja2:6 -msgid "page.request_password.info" -msgstr "" -"If you forgot your password, you can type in your email address below and" -" receive a link to reset your password" - -#: fietsboek/templates/request_password.jinja2:12 -msgid "page.request_password.email" -msgstr "Email" - -#: fietsboek/templates/request_password.jinja2:17 -msgid "page.request_password.request" -msgstr "Send request" - -#: fietsboek/templates/upload.jinja2:9 -msgid "page.upload.form.gpx" -msgstr "GPX file" - #: fietsboek/transformers/__init__.py:140 msgid "transformers.fix-null-elevation.title" msgstr "Fix null elevation" @@ -723,26 +793,6 @@ msgstr "Password has been updated" msgid "flash.track_deleted" msgstr "Track has been deleted" -#: fietsboek/views/profile.py:60 -msgid "flash.personal_data_updated" -msgstr "Personal data has been updated" - -#: fietsboek/views/profile.py:78 -msgid "flash.friend_not_found" -msgstr "The friend was not found" - -#: fietsboek/views/profile.py:84 -msgid "flash.friend_already_exists" -msgstr "Friend already exists" - -#: fietsboek/views/profile.py:92 -msgid "flash.friend_added" -msgstr "Friend has been added" - -#: fietsboek/views/profile.py:102 -msgid "flash.friend_request_sent" -msgstr "Friend request sent" - #: fietsboek/views/upload.py:52 msgid "flash.no_file_selected" msgstr "No file selected" @@ -755,7 +805,26 @@ msgstr "Invalid GPX file selected" msgid "flash.upload_success" msgstr "Upload successful" -#: fietsboek/views/upload.py:204 +#: fietsboek/views/upload.py:207 msgid "flash.upload_cancelled" msgstr "Upload cancelled" +#: fietsboek/views/user_data.py:60 +msgid "flash.personal_data_updated" +msgstr "Personal data has been updated" + +#: fietsboek/views/user_data.py:78 +msgid "flash.friend_not_found" +msgstr "The friend was not found" + +#: fietsboek/views/user_data.py:84 +msgid "flash.friend_already_exists" +msgstr "Friend already exists" + +#: fietsboek/views/user_data.py:92 +msgid "flash.friend_added" +msgstr "Friend has been added" + +#: fietsboek/views/user_data.py:102 +msgid "flash.friend_request_sent" +msgstr "Friend request sent" diff --git a/fietsboek/locale/fietslog.pot b/fietsboek/locale/fietslog.pot index 70f010a..a232c05 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-03-07 20:11+0100\n" +"POT-Creation-Date: 2023-04-03 20:34+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" @@ -17,39 +17,39 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.11.0\n" -#: fietsboek/util.py:276 +#: fietsboek/util.py:275 msgid "password_constraint.mismatch" msgstr "" -#: fietsboek/util.py:278 +#: fietsboek/util.py:277 msgid "password_constraint.length" msgstr "" -#: fietsboek/models/track.py:566 +#: fietsboek/models/track.py:579 msgid "tooltip.table.length" msgstr "" -#: fietsboek/models/track.py:567 +#: fietsboek/models/track.py:580 msgid "tooltip.table.uphill" msgstr "" -#: fietsboek/models/track.py:568 +#: fietsboek/models/track.py:581 msgid "tooltip.table.downhill" msgstr "" -#: fietsboek/models/track.py:569 +#: fietsboek/models/track.py:582 msgid "tooltip.table.moving_time" msgstr "" -#: fietsboek/models/track.py:570 +#: fietsboek/models/track.py:583 msgid "tooltip.table.stopped_time" msgstr "" -#: fietsboek/models/track.py:572 +#: fietsboek/models/track.py:585 msgid "tooltip.table.max_speed" msgstr "" -#: fietsboek/models/track.py:576 +#: fietsboek/models/track.py:589 msgid "tooltip.table.avg_speed" msgstr "" @@ -154,42 +154,52 @@ msgid "page.browse.synthetic_tooltip" msgstr "" #: fietsboek/templates/browse.jinja2:132 fietsboek/templates/details.jinja2:90 +#: fietsboek/templates/profile.jinja2:15 msgid "page.details.date" msgstr "" #: fietsboek/templates/browse.jinja2:134 fietsboek/templates/details.jinja2:104 +#: fietsboek/templates/profile.jinja2:17 msgid "page.details.length" msgstr "" #: fietsboek/templates/browse.jinja2:139 fietsboek/templates/details.jinja2:95 +#: fietsboek/templates/profile.jinja2:21 msgid "page.details.start_time" msgstr "" #: fietsboek/templates/browse.jinja2:141 fietsboek/templates/details.jinja2:99 +#: fietsboek/templates/profile.jinja2:23 msgid "page.details.end_time" msgstr "" #: fietsboek/templates/browse.jinja2:146 fietsboek/templates/details.jinja2:108 +#: fietsboek/templates/profile.jinja2:27 msgid "page.details.uphill" msgstr "" #: fietsboek/templates/browse.jinja2:148 fietsboek/templates/details.jinja2:112 +#: fietsboek/templates/profile.jinja2:29 msgid "page.details.downhill" msgstr "" #: fietsboek/templates/browse.jinja2:153 fietsboek/templates/details.jinja2:117 +#: fietsboek/templates/profile.jinja2:33 msgid "page.details.moving_time" msgstr "" #: fietsboek/templates/browse.jinja2:155 fietsboek/templates/details.jinja2:121 +#: fietsboek/templates/profile.jinja2:35 msgid "page.details.stopped_time" msgstr "" #: fietsboek/templates/browse.jinja2:159 fietsboek/templates/details.jinja2:125 +#: fietsboek/templates/profile.jinja2:39 msgid "page.details.max_speed" msgstr "" #: fietsboek/templates/browse.jinja2:161 fietsboek/templates/details.jinja2:129 +#: fietsboek/templates/profile.jinja2:41 msgid "page.details.avg_speed" msgstr "" @@ -464,47 +474,51 @@ msgstr[1] "" msgid "page.home.total" msgstr "" -#: fietsboek/templates/layout.jinja2:39 +#: fietsboek/templates/layout.jinja2:41 msgid "page.navbar.toggle" msgstr "" -#: fietsboek/templates/layout.jinja2:50 +#: fietsboek/templates/layout.jinja2:52 msgid "page.navbar.home" msgstr "" -#: fietsboek/templates/layout.jinja2:53 +#: fietsboek/templates/layout.jinja2:55 msgid "page.navbar.browse" msgstr "" -#: fietsboek/templates/layout.jinja2:57 +#: fietsboek/templates/layout.jinja2:59 msgid "page.navbar.upload" msgstr "" -#: fietsboek/templates/layout.jinja2:66 +#: fietsboek/templates/layout.jinja2:68 msgid "page.navbar.user" msgstr "" -#: fietsboek/templates/layout.jinja2:70 +#: fietsboek/templates/layout.jinja2:72 msgid "page.navbar.welcome_user" msgstr "" -#: fietsboek/templates/layout.jinja2:73 +#: fietsboek/templates/layout.jinja2:75 msgid "page.navbar.logout" msgstr "" -#: fietsboek/templates/layout.jinja2:76 +#: fietsboek/templates/layout.jinja2:78 msgid "page.navbar.profile" msgstr "" -#: fietsboek/templates/layout.jinja2:80 +#: fietsboek/templates/layout.jinja2:81 +msgid "page.navbar.user_data" +msgstr "" + +#: fietsboek/templates/layout.jinja2:85 msgid "page.navbar.admin" msgstr "" -#: fietsboek/templates/layout.jinja2:86 +#: fietsboek/templates/layout.jinja2:91 msgid "page.navbar.login" msgstr "" -#: fietsboek/templates/layout.jinja2:90 +#: fietsboek/templates/layout.jinja2:95 msgid "page.navbar.create_account" msgstr "" @@ -556,56 +570,60 @@ msgstr "" msgid "page.password_reset.reset" msgstr "" -#: fietsboek/templates/profile.jinja2:7 -msgid "page.my_profile.title" +#: fietsboek/templates/profile.jinja2:66 +msgid "page.profile.length" msgstr "" -#: fietsboek/templates/profile.jinja2:11 -msgid "page.my_profile.personal_data" +#: fietsboek/templates/profile.jinja2:70 +msgid "page.profile.uphill" msgstr "" -#: fietsboek/templates/profile.jinja2:16 -msgid "page.my_profile.personal_data.name" +#: fietsboek/templates/profile.jinja2:74 +msgid "page.profile.downhill" msgstr "" -#: fietsboek/templates/profile.jinja2:21 -msgid "page.my_profile.personal_data.password_invalid" +#: fietsboek/templates/profile.jinja2:78 +msgid "page.profile.moving_time" msgstr "" -#: fietsboek/templates/profile.jinja2:23 -msgid "page.my_profile.personal_data.password" +#: fietsboek/templates/profile.jinja2:82 +msgid "page.profile.stopped_time" msgstr "" -#: fietsboek/templates/profile.jinja2:28 -msgid "page.my_profile.personal_data.password_must_match" +#: fietsboek/templates/profile.jinja2:86 +msgid "page.profile.max_speed" msgstr "" -#: fietsboek/templates/profile.jinja2:30 -msgid "page.my_profile.personal_data.repeat_password" +#: fietsboek/templates/profile.jinja2:90 +msgid "page.profile.avg_speed" msgstr "" -#: fietsboek/templates/profile.jinja2:33 -msgid "page.my_profile.personal_data.save" +#: fietsboek/templates/profile.jinja2:94 +msgid "page.profile.number_of_tracks" msgstr "" -#: fietsboek/templates/profile.jinja2:38 -msgid "page.my_profile.friends" +#: fietsboek/templates/profile.jinja2:100 +msgid "page.profile.longest_distance_track" msgstr "" -#: fietsboek/templates/profile.jinja2:46 -msgid "page.my_profile.unfriend" +#: fietsboek/templates/profile.jinja2:105 +msgid "page.profile.shortest_distance_track" msgstr "" -#: fietsboek/templates/profile.jinja2:56 -msgid "page.my_profile.accept_friend" +#: fietsboek/templates/profile.jinja2:110 +msgid "page.profile.longest_duration_track" msgstr "" -#: fietsboek/templates/profile.jinja2:73 -msgid "page.my_profile.friend_request_email" +#: fietsboek/templates/profile.jinja2:115 +msgid "page.profile.shortest_duration_track" msgstr "" -#: fietsboek/templates/profile.jinja2:77 -msgid "page.my_profile.send_friend_request" +#: fietsboek/templates/profile.jinja2:135 +msgid "page.profile.heatmap" +msgstr "" + +#: fietsboek/templates/profile.jinja2:140 +msgid "page.profile.tilehunt" msgstr "" #: fietsboek/templates/request_password.jinja2:5 @@ -628,6 +646,58 @@ msgstr "" msgid "page.upload.form.gpx" msgstr "" +#: fietsboek/templates/user_data.jinja2:7 +msgid "page.my_profile.title" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:11 +msgid "page.my_profile.personal_data" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:16 +msgid "page.my_profile.personal_data.name" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:21 +msgid "page.my_profile.personal_data.password_invalid" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:23 +msgid "page.my_profile.personal_data.password" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:28 +msgid "page.my_profile.personal_data.password_must_match" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:30 +msgid "page.my_profile.personal_data.repeat_password" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:33 +msgid "page.my_profile.personal_data.save" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:38 +msgid "page.my_profile.friends" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:46 +msgid "page.my_profile.unfriend" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:56 +msgid "page.my_profile.accept_friend" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:73 +msgid "page.my_profile.friend_request_email" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:77 +msgid "page.my_profile.send_friend_request" +msgstr "" + #: fietsboek/transformers/__init__.py:140 msgid "transformers.fix-null-elevation.title" msgstr "" @@ -712,39 +782,39 @@ msgstr "" msgid "flash.track_deleted" msgstr "" -#: fietsboek/views/profile.py:60 -msgid "flash.personal_data_updated" +#: fietsboek/views/upload.py:52 +msgid "flash.no_file_selected" msgstr "" -#: fietsboek/views/profile.py:78 -msgid "flash.friend_not_found" +#: fietsboek/views/upload.py:62 +msgid "flash.invalid_file" msgstr "" -#: fietsboek/views/profile.py:84 -msgid "flash.friend_already_exists" +#: fietsboek/views/upload.py:188 +msgid "flash.upload_success" msgstr "" -#: fietsboek/views/profile.py:92 -msgid "flash.friend_added" +#: fietsboek/views/upload.py:207 +msgid "flash.upload_cancelled" msgstr "" -#: fietsboek/views/profile.py:102 -msgid "flash.friend_request_sent" +#: fietsboek/views/user_data.py:60 +msgid "flash.personal_data_updated" msgstr "" -#: fietsboek/views/upload.py:52 -msgid "flash.no_file_selected" +#: fietsboek/views/user_data.py:78 +msgid "flash.friend_not_found" msgstr "" -#: fietsboek/views/upload.py:62 -msgid "flash.invalid_file" +#: fietsboek/views/user_data.py:84 +msgid "flash.friend_already_exists" msgstr "" -#: fietsboek/views/upload.py:188 -msgid "flash.upload_success" +#: fietsboek/views/user_data.py:92 +msgid "flash.friend_added" msgstr "" -#: fietsboek/views/upload.py:204 -msgid "flash.upload_cancelled" +#: fietsboek/views/user_data.py:102 +msgid "flash.friend_request_sent" msgstr "" diff --git a/fietsboek/models/track.py b/fietsboek/models/track.py index e0d2820..4b85d11 100644 --- a/fietsboek/models/track.py +++ b/fietsboek/models/track.py @@ -550,6 +550,19 @@ class TrackWithMetadata: return self._meta()["end_time"] return self.cache.end_time + @property + def duration(self) -> datetime.timedelta: + """Returns the duration of this track. + + This is equivalent to ``end_time - start_time``, given that + no DST change happens between those points. + + Alternatively, it is equivalent to ``moving_time + stopped_time``. + + :return: The track duration. + """ + return self.end_time - self.start_time + def html_tooltip(self, localizer: Localizer) -> Markup: """Generate a quick summary of the track as a HTML element. diff --git a/fietsboek/models/user.py b/fietsboek/models/user.py index 62c935e..e1b4841 100644 --- a/fietsboek/models/user.py +++ b/fietsboek/models/user.py @@ -7,6 +7,9 @@ from functools import reduce from cryptography.exceptions import InvalidKey from cryptography.hazmat.primitives.kdf.scrypt import Scrypt +from pyramid.authorization import ALL_PERMISSIONS, Allow +from pyramid.httpexceptions import HTTPNotFound +from pyramid.request import Request from sqlalchemy import ( Boolean, Column, @@ -134,6 +137,32 @@ class User(Base): """ return select(cls).filter(func.lower(email) == func.lower(cls.email)) + @classmethod + def factory(cls, request: Request) -> "User": + """Factory method to pass to a route definition. + + This factory retrieves the user based on the ``user_id`` matched + route parameter, and returns the user. If the user is not found, + ``HTTPNotFound`` is raised. + + :raises pyramid.httpexception.NotFound: If the user is not found. + :param request: The pyramid request. + :return: The user. + """ + user_id = request.matchdict["user_id"] + query = select(cls).filter_by(id=user_id) + user = request.dbsession.execute(query).scalar_one_or_none() + if user is None: + raise HTTPNotFound() + return user + + def __acl__(self): + acl = [ + (Allow, "group:admins", ALL_PERMISSIONS), + (Allow, f"user:{self.id}", ALL_PERMISSIONS), + ] + return acl + def set_password(self, new_password): """Sets a new password for the user. diff --git a/fietsboek/routes.py b/fietsboek/routes.py index 9e71686..8f109d9 100644 --- a/fietsboek/routes.py +++ b/fietsboek/routes.py @@ -53,11 +53,17 @@ def includeme(config): config.add_route("admin-badge-edit", "/admin/edit-badge") config.add_route("admin-badge-delete", "/admin/delete-badge") - config.add_route("profile", "/me") - config.add_route("change-profile", "/me/personal-data") + config.add_route("user-data", "/me") config.add_route("add-friend", "/me/send-friend-request") config.add_route("delete-friend", "/me/delete-friend") config.add_route("accept-friend", "/me/accept-friend") config.add_route("json-friends", "/me/friends.json") + config.add_route("profile", "/user/{user_id}", factory="fietsboek.models.User.factory") + config.add_route( + "user-tile", + "/user/{user_id}/tile/{map}/{z:\\d+}/{x:\\d+}/{y:\\d+}", + factory="fietsboek.models.User.factory", + ) + config.add_route("tile-proxy", "/tile/{provider}/{z:\\d+}/{x:\\d+}/{y:\\d+}") diff --git a/fietsboek/scripts/fietscron.py b/fietsboek/scripts/fietscron.py index 21af446..d8476a9 100644 --- a/fietsboek/scripts/fietscron.py +++ b/fietsboek/scripts/fietscron.py @@ -2,15 +2,19 @@ import datetime import logging import logging.config +from pathlib import Path import click import pyramid.paster +import redis as mod_redis +from redis import Redis from sqlalchemy import create_engine, delete, exists, not_, select from sqlalchemy.engine import Engine from sqlalchemy.orm import Session from .. import config as mod_config -from .. import models +from .. import hittekaart, models +from ..config import Config from ..data import DataManager from . import config_option @@ -27,6 +31,7 @@ def cli(config): \b * Deletes pending uploads that are older than 24 hours. * Rebuilds the cache for missing tracks. + * (optional) Runs ``hittekaart`` to generate heatmaps """ logging.config.fileConfig(config) settings = pyramid.paster.get_appsettings(config) @@ -45,6 +50,10 @@ def cli(config): remove_old_uploads(engine) rebuild_cache(engine, data_manager) + if config.hittekaart_autogenerate: + redis = mod_redis.from_url(config.redis_url) + run_hittekaart(engine, data_manager, redis, config) + def remove_old_uploads(engine: Engine): """Removes old uploads from the database.""" @@ -72,4 +81,63 @@ def rebuild_cache(engine: Engine, data_manager: DataManager): session.commit() +def run_hittekaart(engine: Engine, data_manager: DataManager, redis: Redis, config: Config): + """Run outstanding hittekaart requests.""" + # The logic here is as follows: + # We keep two lists: a high-priority one and a low-priority one + # If there are high priority entries, we run all of them. + # They are refilled when users upload tracks. + # If there are no high priority entries, we run a single low priority one. + # If there are no low priority entries, we re-fill the queue by adding all tracks. + # This way, we ensure that we "catch" modifications fast, but we also + # re-generate all maps over time (e.g. if the hittekaart version changes or + # we miss an update). + modes = [hittekaart.Mode(mode) for mode in config.hittekaart_autogenerate] + exe_path = Path(config.hittekaart_bin) if config.hittekaart_bin else None + session = Session(engine) + had_hq_item = False + + while True: + # High-priority queue + item = redis.spop("hittekaart:queue:high") + if item is None: + break + user = session.execute(select(models.User).filter_by(id=int(item))).scalar() + if user is None: + LOGGER.debug("User %d had a high-priority queue entry but was not found", item) + break + + for mode in modes: + LOGGER.info("Generating %s for user %d (high-priority)", mode.value, user.id) + hittekaart.generate_for(user, session, data_manager, mode, exe_path=exe_path) + + if had_hq_item: + return + + # Low-priority queue + item = redis.spop("hittekaart:queue:low") + if item is None: + refill_queue(session, redis) + item = redis.spop("hittekaart:queue:low") + if item is None: + LOGGER.debug("No users, no hittekaarts") + return + + user = session.execute(select(models.User).filter_by(id=int(item))).scalar() + if user is None: + LOGGER.debug("User %d had a low-priority queue entry but was not found", item) + return + + for mode in modes: + LOGGER.info("Generating %s for user %d (low-priority)", mode.value, user.id) + hittekaart.generate_for(user, session, data_manager, mode, exe_path=exe_path) + + +def refill_queue(session: Session, redis: Redis): + """Refills the low-priority hittekaart queue by adding all users to it.""" + LOGGER.debug("Refilling low-priority queue") + for user in session.execute(select(models.User)).scalars(): + redis.sadd("hittekaart:queue:low", user.id) + + __all__ = ["cli"] diff --git a/fietsboek/scripts/fietsctl.py b/fietsboek/scripts/fietsctl.py index 0043862..7f7170c 100644 --- a/fietsboek/scripts/fietsctl.py +++ b/fietsboek/scripts/fietsctl.py @@ -8,7 +8,8 @@ from pyramid.paster import bootstrap, setup_logging from pyramid.scripting import AppEnvironment from sqlalchemy import select -from .. import __VERSION__, models +from .. import __VERSION__, hittekaart, models +from ..data import DataManager from . import config_option EXIT_OKAY = 0 @@ -219,6 +220,65 @@ def cmd_maintenance_mode(ctx: click.Context, config: str, disable: bool, reason: (data_manager.data_dir / "MAINTENANCE").write_text(reason, encoding="utf-8") +@cli.command("hittekaart") +@config_option +@click.option( + "--mode", + "modes", + help="Heatmap type to generate", + type=click.Choice([mode.value for mode in hittekaart.Mode]), + multiple=True, + default=["heatmap"], +) +@click.option("--delete", help="Delete the specified heatmap", is_flag=True) +@optgroup.group("User selection", cls=RequiredMutuallyExclusiveOptionGroup) +@optgroup.option("--id", "-i", "id_", help="database ID of the user", type=int) +@optgroup.option("--email", "-e", help="email of the user") +@click.pass_context +def cmd_hittekaart( + ctx: click.Context, + config: str, + modes: list[str], + delete: bool, + id_: Optional[int], + email: Optional[str], +): + """Generate heatmap for a user.""" + env = setup(config) + modes = [hittekaart.Mode(mode) for mode in modes] + + if id_ is not None: + query = select(models.User).filter_by(id=id_) + else: + query = models.User.query_by_email(email) + + exe_path = env["request"].config.hittekaart_bin + with env["request"].tm: + dbsession = env["request"].dbsession + data_manager: DataManager = env["request"].data_manager + user = dbsession.execute(query).scalar_one_or_none() + if user is None: + click.echo("Error: No such user found.", err=True) + ctx.exit(EXIT_FAILURE) + + if delete: + try: + user_manager = data_manager.open_user(user.id) + except FileNotFoundError: + return + if hittekaart.Mode.HEATMAP in modes: + user_manager.heatmap_path().unlink(missing_ok=True) + if hittekaart.Mode.TILEHUNTER in modes: + user_manager.tilehunt_path().unlink(missing_ok=True) + return + + click.echo(f"Generating overlay maps for {user.name}...") + + for mode in modes: + hittekaart.generate_for(user, dbsession, data_manager, mode, exe_path=exe_path) + click.echo(f"Generated {mode.value}") + + @cli.command("version") def cmd_version(): """Show the installed fietsboek version.""" diff --git a/fietsboek/templates/layout.jinja2 b/fietsboek/templates/layout.jinja2 index d4e0a0d..b6fcea0 100644 --- a/fietsboek/templates/layout.jinja2 +++ b/fietsboek/templates/layout.jinja2 @@ -17,6 +17,8 @@ <!-- Custom styles for this scaffold --> <link href="{{request.static_url('fietsboek:static/fonts.css')}}" rel="stylesheet"> <link href="{{request.static_url('fietsboek:static/theme.css')}}" rel="stylesheet"> + <!-- Pre-load leaflet CSS --> + <link href="{{request.static_url('fietsboek:static/GM_Utils/leaflet/leaflet.css')}}" rel="stylesheet"> <script> const FRIENDS_URL = {{ request.route_url('json-friends') | tojson }}; @@ -73,7 +75,10 @@ const Legende = false; <a class="dropdown-item" href="{{ request.route_url('logout') }}">{{ _("page.navbar.logout") }}</a> </li> <li> - <a class="dropdown-item" href="{{ request.route_url('profile') }}">{{ _("page.navbar.profile") }}</a> + <a class="dropdown-item" href="{{ request.route_url('profile', user_id=request.identity.id) }}">{{ _("page.navbar.profile") }}</a> + </li> + <li> + <a class="dropdown-item" href="{{ request.route_url('user-data') }}">{{ _("page.navbar.user_data") }}</a> </li> {% if request.identity.is_admin %} <li> @@ -111,10 +116,14 @@ 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')}}"> <!-- 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 --> <script src="{{request.static_url('fietsboek:static/GM_Utils/GPX2GM.js')}}"></script> <script src="{{request.static_url('fietsboek:static/fietsboek.js')}}"></script> + {% block latescripts %} + {% endblock %} </body> </html> diff --git a/fietsboek/templates/profile.jinja2 b/fietsboek/templates/profile.jinja2 index ef2ab5f..4906247 100644 --- a/fietsboek/templates/profile.jinja2 +++ b/fietsboek/templates/profile.jinja2 @@ -1,83 +1,170 @@ {% extends "layout.jinja2" %} -{% import "util.jinja2" as util with context %} +{% macro render_track_card(track) %} + <div class="card mb-3"> + <h5 class="card-header"> + <a href="{{ request.route_url('details', track_id=track.id) }}">{{ track.title | default(track.date, true) }}</a> + {% if track.text_tags() %} + {% for tag in track.tags %}<span class="badge bg-info text-dark">{{ tag.tag }}</span> {% endfor %} + {% endif %} + </h5> + <div class="card-body"> + <table class="table table-hover table-sm browse-summary"> + <tbody> + <tr> + <th scope="row">{{ _("page.details.date") }}</th> + <td>{{ track.date | format_datetime }}</td> + <th scope="row">{{ _("page.details.length") }}</th> + <td>{{ (track.length / 1000) | round(2) | format_decimal }} km</td> + </tr> + <tr> + <th scope="row">{{ _("page.details.start_time") }}</th> + <td>{{ track.start_time | format_datetime }}</td> + <th scope="row">{{ _("page.details.end_time") }}</th> + <td>{{ track.end_time | format_datetime }}</td> + </tr> + <tr> + <th scope="row">{{ _("page.details.uphill") }}</th> + <td>{{ track.uphill | round(2) | format_decimal }} m</td> + <th scope="row">{{ _("page.details.downhill") }}</th> + <td>{{ track.downhill | round(2) | format_decimal }} m</td> + </tr> + <tr> + <th scope="row">{{ _("page.details.moving_time") }}</th> + <td>{{ track.moving_time }}</td> + <th scope="row">{{ _("page.details.stopped_time") }}</th> + <td>{{ track.stopped_time }}</td> + </tr> + <tr> + <th scope="row">{{ _("page.details.max_speed") }}</th> + <td>{{ mps_to_kph(track.max_speed) | round(2) | format_decimal }} km/h</td> + <th scope="row">{{ _("page.details.avg_speed") }}</th> + <td>{{ mps_to_kph(track.avg_speed) | round(2) | format_decimal }} km/h</td> + </tr> + </tbody> + </table> + + <ul> + <li>{{ track.owner.name }}</li> + {% for user in track.tagged_people %} + <li>{{ user.name }}</li> + {% endfor %} + </ul> + </div> + </div> +{% endmacro %} {% block content %} <div class="container"> - <h1>{{ _("page.my_profile.title") }}</h1> + <h1>{{ user.name }}</h1> - <hr> + {% if heatmap_url or tilehunt_url %} + <div id="userMap" style="height: 600px; width: 100%;"></div> + {% endif %} - <h2>{{ _("page.my_profile.personal_data") }}</h2> + <table class="table table-hover table-sm"> + <th scope="row">{{ _("page.profile.length") }}</th> + <td id="profileLength">{{ (total.length / 1000) | round(2) | format_decimal }} km</td> + </tr> + <tr> + <th scope="row">{{ _("page.profile.uphill") }}</th> + <td id="profileUphill">{{ total.uphill | round(2) | format_decimal }} m</td> + </tr> + <tr> + <th scope="row">{{ _("page.profile.downhill") }}</th> + <td id="profileDownhill">{{ total.downhill | round(2) | format_decimal }} m</td> + </tr> + <tr> + <th scope="row">{{ _("page.profile.moving_time") }}</th> + <td id="profileMovingTime">{{ total.moving_time }}</td> + </tr> + <tr> + <th scope="row">{{ _("page.profile.stopped_time") }}</th> + <td id="profileStoppedTime">{{ total.stopped_time }}</td> + </tr> + <tr> + <th scope="row">{{ _("page.profile.max_speed") }}</th> + <td id="profileMaxSpeed">{{ mps_to_kph(total.max_speed) | round(2) | format_decimal }} km/h</td> + </tr> + <tr> + <th scope="row">{{ _("page.profile.avg_speed") }}</th> + <td id="profileAvgSpeed">{{ mps_to_kph(total.avg_speed) | round(2) | format_decimal }} km/h</td> + </tr> + <tr> + <th scope="row">{{ _("page.profile.number_of_tracks") }}</th> + <td id="profileNumberOfTracks">{{ total.count }}</td> + </tr> + </table> - <form method="POST" action="{{ request.route_path('change-profile') }}" class="needs-validation" novalidate> - <div class="form-floating mb-3 col-lg-5"> - <input type="text" class="form-control" id="inputName" name="name" value="{{ user.name }}" placeholder="x"> - <label for="inputName">{{ _("page.my_profile.personal_data.name") }}</label> - </div> - <div class="form-floating mb-3 col-lg-5"> - <input type="password" class="form-control" id="inputPassword" name="password" onchange="checkPasswordValidity('#inputPassword', '#repeatPassword')" placeholder="x"> - <div class="invalid-feedback"> - {{ _("page.my_profile.personal_data.password_invalid") }} - </div> - <label for="inputPassword">{{ _("page.my_profile.personal_data.password") }}</label> - </div> - <div class="form-floating mb-3 col-lg-5"> - <input type="password" class="form-control" id="repeatPassword" name="repeat-password" onchange="checkPasswordValidity('#inputPassword', '#repeatPassword')" placeholder="x"> - <div class="invalid-feedback"> - {{ _("page.my_profile.personal_data.password_must_match") }} - </div> - <label for="repeatPassword">{{ _("page.my_profile.personal_data.repeat_password") }}</label> - </div> - {{ util.hidden_csrf_input() }} - <button type="submit" class="btn btn-primary"><i class="bi bi-save"></i> {{ _("page.my_profile.personal_data.save") }}</button> - </form> - - <hr> - - <h2>{{ _("page.my_profile.friends") }}</h2> - - <ul class="list-group"> - {% for friend in user.get_friends() %} - <li class="list-group-item d-flex align-items-center"> - <form action="{{ request.route_url('delete-friend') }}" method="POST"> - <input type="hidden" name="friend-id" value="{{ friend.id }}"> - {{ util.hidden_csrf_input() }} - <button type="submit" class="btn btn-danger"><i class="bi bi-person-dash"></i> {{ _("page.my_profile.unfriend") }}</button> - </form> - <span class="ms-3">{{ friend.name }} ({{ friend.email }})</span> - </li> - {% endfor %} - {% for friend_request in incoming_friend_requests %} - <li class="list-group-item list-group-item-success d-flex align-items-center"> - <form action="{{ request.route_url('accept-friend') }}" method="POST"> - <input type="hidden" name="request-id" value="{{ friend_request.id }}"> - {{ util.hidden_csrf_input() }} - <button type="submit" class="btn btn-success"><i class="bi bi-person-check"></i> {{ _("page.my_profile.accept_friend") }}</button> - </form> - <span class="ms-3">{{ friend_request.sender.name }} ({{ friend_request.sender.email }})</span> - </li> - {% endfor %} - {% for friend_request in outgoing_friend_requests %} - <li class="list-group-item list-group-item-dark">{{ friend_request.recipient.name }} ({{ friend_request.recipient.email }})</li> - {% endfor %} - </ul> - - <div class="my-3"> - <form action="{{ request.route_url('add-friend') }}" method="POST"> - {{ util.hidden_csrf_input() }} - <div class="row align-items-center"> - <div class="col-lg-5"> - <div class="form-floating"> - <input type="email" id="friendRequestEmail" name="friend-email" class="form-control" placeholder="x"> - <label for="friendRequestEmail">{{ _("page.my_profile.friend_request_email") }}</label> - </div> - </div> - <div class="col-lg-3"> - <button class="btn btn-primary"><i class="bi bi-person-plus"></i> {{ _("page.my_profile.send_friend_request") }}</button> - </div> - </div> - </form> - </div> + {% if total.longest_distance_track %} + <h2>{{ _("page.profile.longest_distance_track") }}</h2> + {{ render_track_card(total.longest_distance_track) }} + {% endif %} + + {% if total.shortest_distance_track %} + <h2>{{ _("page.profile.shortest_distance_track") }}</h2> + {{ render_track_card(total.shortest_distance_track) }} + {% endif %} + + {% if total.longest_duration_track %} + <h2>{{ _("page.profile.longest_duration_track") }}</h2> + {{ render_track_card(total.longest_duration_track) }} + {% endif %} + + {% if total.shortest_duration_track %} + <h2>{{ _("page.profile.shortest_duration_track") }}</h2> + {{ render_track_card(total.shortest_duration_track) }} + {% endif %} </div> {% endblock %} + +{% block latescripts %} +<script> + (function() { + const renderMap = document.getElementById("userMap") !== null; + if (!renderMap) { + return; + } + + const map = L.map("userMap").setView([52.520008, 13.404954], 2); + + baseLayers = {}; + overlayLayers = {}; + + {% if heatmap_url %} + overlayLayers[{{ _("page.profile.heatmap") | tojson }}] = L.tileLayer({{ heatmap_url | tojson }}, { + maxZoom: 19, + }); + {% endif %} + {% if tilehunt_url %} + overlayLayers[{{ _("page.profile.tilehunt") | tojson }}] = L.tileLayer({{ tilehunt_url | tojson }}, { + maxZoom: 19, + }); + {% endif %} + + let defaultLayer = null; + + for (let layer of TILE_LAYERS) { + if (layer.type === "base") { + baseLayers[layer.name] = L.tileLayer(layer.url, { + maxZoom: layer.zoom, + attribution: layer.attribution, + }); + if (defaultLayer === null) { + defaultLayer = baseLayers[layer.name]; + } + } else if (layer.type === "overlay") { + overlayLayers[layer.name] = L.tileLayer(layer.url, { + attribution: layer.attribution, + }); + } + } + + + // Add the default layer via .addTo directly, otherwise it will not be + // selected at the start. + defaultLayer.addTo(map); + L.control.layers(baseLayers, overlayLayers).addTo(map); + })(); +</script> +{% endblock %} diff --git a/fietsboek/templates/user_data.jinja2 b/fietsboek/templates/user_data.jinja2 new file mode 100644 index 0000000..15588e8 --- /dev/null +++ b/fietsboek/templates/user_data.jinja2 @@ -0,0 +1,83 @@ +{% extends "layout.jinja2" %} + +{% import "util.jinja2" as util with context %} + +{% block content %} +<div class="container"> + <h1>{{ _("page.my_profile.title") }}</h1> + + <hr> + + <h2>{{ _("page.my_profile.personal_data") }}</h2> + + <form method="POST" action="{{ request.route_path('user-data') }}" class="needs-validation" novalidate> + <div class="form-floating mb-3 col-lg-5"> + <input type="text" class="form-control" id="inputName" name="name" value="{{ user.name }}" placeholder="x"> + <label for="inputName">{{ _("page.my_profile.personal_data.name") }}</label> + </div> + <div class="form-floating mb-3 col-lg-5"> + <input type="password" class="form-control" id="inputPassword" name="password" onchange="checkPasswordValidity('#inputPassword', '#repeatPassword')" placeholder="x"> + <div class="invalid-feedback"> + {{ _("page.my_profile.personal_data.password_invalid") }} + </div> + <label for="inputPassword">{{ _("page.my_profile.personal_data.password") }}</label> + </div> + <div class="form-floating mb-3 col-lg-5"> + <input type="password" class="form-control" id="repeatPassword" name="repeat-password" onchange="checkPasswordValidity('#inputPassword', '#repeatPassword')" placeholder="x"> + <div class="invalid-feedback"> + {{ _("page.my_profile.personal_data.password_must_match") }} + </div> + <label for="repeatPassword">{{ _("page.my_profile.personal_data.repeat_password") }}</label> + </div> + {{ util.hidden_csrf_input() }} + <button type="submit" class="btn btn-primary"><i class="bi bi-save"></i> {{ _("page.my_profile.personal_data.save") }}</button> + </form> + + <hr> + + <h2>{{ _("page.my_profile.friends") }}</h2> + + <ul class="list-group"> + {% for friend in user.get_friends() %} + <li class="list-group-item d-flex align-items-center"> + <form action="{{ request.route_url('delete-friend') }}" method="POST"> + <input type="hidden" name="friend-id" value="{{ friend.id }}"> + {{ util.hidden_csrf_input() }} + <button type="submit" class="btn btn-danger"><i class="bi bi-person-dash"></i> {{ _("page.my_profile.unfriend") }}</button> + </form> + <span class="ms-3">{{ friend.name }} ({{ friend.email }})</span> + </li> + {% endfor %} + {% for friend_request in incoming_friend_requests %} + <li class="list-group-item list-group-item-success d-flex align-items-center"> + <form action="{{ request.route_url('accept-friend') }}" method="POST"> + <input type="hidden" name="request-id" value="{{ friend_request.id }}"> + {{ util.hidden_csrf_input() }} + <button type="submit" class="btn btn-success"><i class="bi bi-person-check"></i> {{ _("page.my_profile.accept_friend") }}</button> + </form> + <span class="ms-3">{{ friend_request.sender.name }} ({{ friend_request.sender.email }})</span> + </li> + {% endfor %} + {% for friend_request in outgoing_friend_requests %} + <li class="list-group-item list-group-item-dark">{{ friend_request.recipient.name }} ({{ friend_request.recipient.email }})</li> + {% endfor %} + </ul> + + <div class="my-3"> + <form action="{{ request.route_url('add-friend') }}" method="POST"> + {{ util.hidden_csrf_input() }} + <div class="row align-items-center"> + <div class="col-lg-5"> + <div class="form-floating"> + <input type="email" id="friendRequestEmail" name="friend-email" class="form-control" placeholder="x"> + <label for="friendRequestEmail">{{ _("page.my_profile.friend_request_email") }}</label> + </div> + </div> + <div class="col-lg-3"> + <button class="btn btn-primary"><i class="bi bi-person-plus"></i> {{ _("page.my_profile.send_friend_request") }}</button> + </div> + </div> + </form> + </div> +</div> +{% endblock %} diff --git a/fietsboek/util.py b/fietsboek/util.py index d6d0aea..02981c0 100644 --- a/fietsboek/util.py +++ b/fietsboek/util.py @@ -91,7 +91,7 @@ def round_timedelta_to_multiple( """ lower = value.total_seconds() // multiples.total_seconds() * multiples.total_seconds() second_offset = value.total_seconds() - lower - if second_offset < multiples.total_seconds() // 2: + if second_offset <= multiples.total_seconds() // 2: # Round down return datetime.timedelta(seconds=lower) # Round up @@ -322,6 +322,33 @@ def read_localized_resource( return f"{locale_name}:{path}" +def tile_url(request: Request, route_name: str, **kwargs: str) -> str: + """Generates a URL for tiles. + + This is basically :meth:`request.route_url`, but it keeps the + ``{x}``/``{y}``/``{z}`` placeholders intact for consumption by Leaflet. + + Expects that the URL takes a ``x``, ``y`` and ``z`` parameter. + + :param request: The Pyramid request. + :param route_name: The name of the route. + :param kwargs: Remaining route parameters. Will be passed to ``route_url``. + :return: The route URL, with intact placeholders. + """ + # This feels hacky (because it is), but I don't see a better way. The route + # generation is basically a closure that fills the placeholders + # all-or-none, and there is no way to get a "structured" representation of + # the URL without re-parsing it. + # Using {x} triggers the urlquoting, so we need to do some .replace() calls + # in the end anyway. Might as well use something that looks a bit better, + # and not %7Bx%7D. + kwargs["x"] = "__x__" + kwargs["y"] = "__y__" + kwargs["z"] = "__z__" + route = request.route_url(route_name, **kwargs) + return route.replace("__x__", "{x}").replace("__y__", "{y}").replace("__z__", "{z}") + + 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/detail.py b/fietsboek/views/detail.py index 259e5d6..f540a11 100644 --- a/fietsboek/views/detail.py +++ b/fietsboek/views/detail.py @@ -138,6 +138,10 @@ def delete_track(request): request.dbsession.delete(track) request.data_manager.purge(track_id) request.session.flash(request.localizer.translate(_("flash.track_deleted"))) + + if request.config.hittekaart_autogenerate: + request.redis.sadd("hittekaart:queue:high", request.identity.id) + return HTTPFound(request.route_url("home")) diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py index 6354465..e73df42 100644 --- a/fietsboek/views/profile.py +++ b/fietsboek/views/profile.py @@ -1,159 +1,202 @@ -"""Views corresponding to the user profile.""" +"""Endpoints for the user profile pages.""" import datetime - -from pyramid.httpexceptions import HTTPForbidden, HTTPFound, HTTPNotFound -from pyramid.i18n import TranslationString as _ +import sqlite3 +import urllib.parse +from dataclasses import dataclass +from typing import Optional + +from pyramid.httpexceptions import HTTPNotFound +from pyramid.request import Request +from pyramid.response import Response from pyramid.view import view_config from sqlalchemy import select +from sqlalchemy.orm import aliased from .. import models, util +from ..data import UserDataDir +from ..models.track import TrackType, TrackWithMetadata -@view_config( - route_name="profile", - renderer="fietsboek:templates/profile.jinja2", - permission="user", - request_method="GET", -) -def profile(request): - """Provides the profile overview. +@dataclass +class CumulativeStats: + """Cumulative user stats. - :param request: The Pyramid request. - :type request: pyramid.request.Request - :return: The HTTP response. - :rtype: pyramid.response.Response + The values start out with default values, and tracks can be merged in via + :meth:`add`. """ - coming_requests = request.dbsession.execute( - select(models.FriendRequest).filter_by(recipient_id=request.identity.id) - ).scalars() - going_requests = request.dbsession.execute( - select(models.FriendRequest).filter_by(sender_id=request.identity.id) - ).scalars() - return { - "user": request.identity, - "outgoing_friend_requests": going_requests, - "incoming_friend_requests": coming_requests, - } + # pylint: disable=too-many-instance-attributes + count: int = 0 + """Number of tracks added.""" -@view_config(route_name="change-profile", permission="user", request_method="POST") -def do_change_profile(request): - """Endpoint to change the personal data. + length: float = 0.0 + """Total length, in meters.""" - :param request: The Pyramid request. - :type request: pyramid.request.Request - :return: The HTTP response. - :rtype: pyramid.response.Response - """ - password = request.params["password"] - if password: - try: - util.check_password_constraints(password, request.params["repeat-password"]) - except ValueError as exc: - request.session.flash(request.localizer.translate(exc.args[0])) - return HTTPFound(request.route_url("profile")) - request.identity.set_password(request.params["password"]) - name = request.params["name"] - if request.identity.name != name: - request.identity.name = name - request.session.flash(request.localizer.translate(_("flash.personal_data_updated"))) - return HTTPFound(request.route_url("profile")) - - -@view_config(route_name="add-friend", permission="user", request_method="POST") -def do_add_friend(request): - """Sends a friend request. - - This is the endpoint of a form on the profile overview. - - :param request: The Pyramid request. - :type request: pyramid.request.Request - :return: The HTTP response. - :rtype: pyramid.response.Response - """ - email = request.params["friend-email"] - candidate = request.dbsession.execute(models.User.query_by_email(email)).scalar_one_or_none() - if candidate is None: - request.session.flash(request.localizer.translate(_("flash.friend_not_found"))) - return HTTPFound(request.route_url("profile")) - - if candidate in request.identity.get_friends() or candidate in [ - x.recipient for x in request.identity.outgoing_requests - ]: - request.session.flash(request.localizer.translate(_("flash.friend_already_exists"))) - return HTTPFound(request.route_url("profile")) - - for incoming in request.identity.incoming_requests: - if incoming.sender == candidate: - # We have an incoming request from that person, so we just accept that - request.identity.add_friend(candidate) - request.dbsession.delete(incoming) - request.session.flash(request.localizer.translate(_("flash.friend_added"))) - return HTTPFound(request.route_url("profile")) - - # Nothing helped, so we send the friend request - friend_req = models.FriendRequest( - sender=request.identity, - recipient=candidate, - date=datetime.datetime.utcnow(), - ) - request.dbsession.add(friend_req) - request.session.flash(request.localizer.translate(_("flash.friend_request_sent"))) - return HTTPFound(request.route_url("profile")) + uphill: float = 0.0 + """Total uphill, in meters.""" + + downhill: float = 0.0 + """Total downhill, in meters.""" + + moving_time: datetime.timedelta = datetime.timedelta(0) + """Total time spent moving.""" + + stopped_time: datetime.timedelta = datetime.timedelta(0) + """Total time standing still.""" + + max_speed: float = 0.0 + """Overall maximum speed, in m/s.""" + + longest_distance_track: Optional[TrackWithMetadata] = None + """The track with the longest distance.""" + + shortest_distance_track: Optional[TrackWithMetadata] = None + """The track with the shortest distance.""" + + longest_duration_track: Optional[TrackWithMetadata] = None + """The track with the longest time.""" + + shortest_duration_track: Optional[TrackWithMetadata] = None + """The track with the shortest time.""" + + def add(self, track: TrackWithMetadata): + """Adds a track to this stats collection. + + :param track: The track to add, with accompanying metadata. + """ + self.count += 1 + self.length += track.length + self.uphill += track.uphill + self.downhill += track.downhill + self.moving_time += track.moving_time + self.stopped_time += track.stopped_time + self.max_speed = max(self.max_speed, track.max_speed) + + if self.longest_distance_track is None or self.longest_distance_track.length < track.length: + self.longest_distance_track = track + if ( + self.shortest_distance_track is None + or self.shortest_distance_track.length > track.length + ): + self.shortest_distance_track = track -@view_config(route_name="delete-friend", permission="user", request_method="POST") -def do_delete_friend(request): - """Deletes a friend. + if ( + self.longest_duration_track is None + or self.longest_duration_track.duration < track.duration + ): + self.longest_duration_track = track - This is the endpoint of a form on the profile overview. + if ( + self.shortest_duration_track is None + or self.shortest_duration_track.duration > track.duration + ): + self.shortest_duration_track = track - :param request: The Pyramid request. - :type request: pyramid.request.Request - :return: The HTTP response. - :rtype: pyramid.response.Response + @property + def avg_speed(self) -> float: + """Average speed, in m/s.""" + if not self.moving_time: + return 0.0 + return self.length / self.moving_time.total_seconds() + + +def round_to_seconds(value: datetime.timedelta) -> datetime.timedelta: + """Round a timedelta to full seconds. + + :param value: The input value. + :return: The rounded value. + """ + return util.round_timedelta_to_multiple(value, datetime.timedelta(seconds=1)) + + +@view_config( + route_name="profile", + renderer="fietsboek:templates/profile.jinja2", + request_method="GET", + permission="profile.view", +) +def profile(request: Request) -> dict: + """Shows the profile page. + + :param request: The pyramid request. + :return: The template parameters. """ - friend = request.dbsession.execute( - select(models.User).filter_by(id=request.params["friend-id"]) - ).scalar_one_or_none() - if friend: - request.identity.remove_friend(friend) - return HTTPFound(request.route_url("profile")) + total = CumulativeStats() + + query = request.context.all_tracks_query() + query = select(aliased(models.Track, query)).where(query.c.type == TrackType.ORGANIC) + track: models.Track + for track in request.dbsession.execute(query).scalars(): + meta = TrackWithMetadata(track, request.data_manager) + total.add(meta) + + total.moving_time = round_to_seconds(total.moving_time) + total.stopped_time = round_to_seconds(total.stopped_time) + + user_id = request.context.id + heatmap_url = None + tilehunt_url = None + try: + user_dir: UserDataDir = request.data_manager.open_user(request.context.id) + except FileNotFoundError: + pass + else: + if user_dir.heatmap_path().is_file(): + heatmap_url = util.tile_url(request, "user-tile", user_id=user_id, map="heatmap") + if user_dir.tilehunt_path().is_file(): + tilehunt_url = util.tile_url(request, "user-tile", user_id=user_id, map="tilehunt") + return { + "user": request.context, + "total": total, + "mps_to_kph": util.mps_to_kph, + "heatmap_url": heatmap_url, + "tilehunt_url": tilehunt_url, + } -@view_config(route_name="accept-friend", permission="user", request_method="POST") -def do_accept_friend(request): - """Accepts a friend request. - This is the endpoint of a form on the profile overview. +@view_config(route_name="user-tile", request_method="GET", permission="profile.view") +def user_tile(request: Request) -> Response: + """Returns a single tile from the user's own overlay maps. - :param request: The Pyramid request. - :type request: pyramid.request.Request - :return: The HTTP response. - :rtype: pyramid.response.Response + :param request: The pyramid request. + :return: The response, with the tile content (or an error). """ - friend_request = request.dbsession.execute( - select(models.FriendRequest).filter_by(id=request.params["request-id"]) - ).scalar_one_or_none() - if friend_request is None: + try: + user_dir: UserDataDir = request.data_manager.open_user(request.context.id) + except FileNotFoundError: return HTTPNotFound() - if friend_request.recipient != request.identity: - return HTTPForbidden() - friend_request.sender.add_friend(friend_request.recipient) - request.dbsession.delete(friend_request) - return HTTPFound(request.route_url("profile")) + paths = { + "heatmap": user_dir.heatmap_path(), + "tilehunt": user_dir.tilehunt_path(), + } + path = paths.get(request.matchdict["map"]) + if path is None: + return HTTPNotFound() + # See + # https://docs.python.org/3/library/sqlite3.html#how-to-work-with-sqlite-uris + # https://stackoverflow.com/questions/10205744/opening-sqlite3-database-from-python-in-read-only-mode + # https://stackoverflow.com/questions/17170202/dont-want-to-create-a-new-database-if-it-doesnt-already-exists + sqlite_uri = urllib.parse.urlunparse(("file", "", str(path), "", "mode=ro", "")) + try: + connection = sqlite3.connect(sqlite_uri, uri=True) + except sqlite3.OperationalError: + return HTTPNotFound() -@view_config(route_name="json-friends", renderer="json", permission="user") -def json_friends(request): - """Returns a JSON-ified list of the user's friends. + cursor = connection.cursor() + result = cursor.execute( + "SELECT data FROM tiles WHERE zoom = ? AND x = ? AND y = ?;", + (int(request.matchdict["z"]), int(request.matchdict["x"]), int(request.matchdict["y"])), + ) + result = result.fetchone() + if result is None: + return HTTPNotFound() - :param request: The Pyramid request. - :type request: pyramid.request.Request - :return: The HTTP response. - :rtype: pyramid.response.Response - """ - friends = [{"name": friend.name, "id": friend.id} for friend in request.identity.get_friends()] - return friends + return Response(result[0], content_type="image/png") + + +__all__ = ["profile", "user_tile"] diff --git a/fietsboek/views/upload.py b/fietsboek/views/upload.py index fd93034..6fccdba 100644 --- a/fietsboek/views/upload.py +++ b/fietsboek/views/upload.py @@ -187,6 +187,9 @@ def do_finish_upload(request): request.session.flash(request.localizer.translate(_("flash.upload_success"))) + if request.config.hittekaart_autogenerate: + request.redis.sadd("hittekaart:queue:high", request.identity.id) + return HTTPFound(request.route_url("details", track_id=track.id)) diff --git a/fietsboek/views/user_data.py b/fietsboek/views/user_data.py new file mode 100644 index 0000000..a6ad11d --- /dev/null +++ b/fietsboek/views/user_data.py @@ -0,0 +1,159 @@ +"""Views corresponding to the user profile.""" +import datetime + +from pyramid.httpexceptions import HTTPForbidden, HTTPFound, HTTPNotFound +from pyramid.i18n import TranslationString as _ +from pyramid.view import view_config +from sqlalchemy import select + +from .. import models, util + + +@view_config( + route_name="user-data", + renderer="fietsboek:templates/user_data.jinja2", + permission="user", + request_method="GET", +) +def user_data(request): + """Provides the user's data. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ + + coming_requests = request.dbsession.execute( + select(models.FriendRequest).filter_by(recipient_id=request.identity.id) + ).scalars() + going_requests = request.dbsession.execute( + select(models.FriendRequest).filter_by(sender_id=request.identity.id) + ).scalars() + return { + "user": request.identity, + "outgoing_friend_requests": going_requests, + "incoming_friend_requests": coming_requests, + } + + +@view_config(route_name="user-data", permission="user", request_method="POST") +def do_change_profile(request): + """Endpoint to change the personal data. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ + password = request.params["password"] + if password: + try: + util.check_password_constraints(password, request.params["repeat-password"]) + except ValueError as exc: + request.session.flash(request.localizer.translate(exc.args[0])) + return HTTPFound(request.route_url("user-data")) + request.identity.set_password(request.params["password"]) + name = request.params["name"] + if request.identity.name != name: + request.identity.name = name + request.session.flash(request.localizer.translate(_("flash.personal_data_updated"))) + return HTTPFound(request.route_url("user-data")) + + +@view_config(route_name="add-friend", permission="user", request_method="POST") +def do_add_friend(request): + """Sends a friend request. + + This is the endpoint of a form on the profile overview. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ + email = request.params["friend-email"] + candidate = request.dbsession.execute(models.User.query_by_email(email)).scalar_one_or_none() + if candidate is None: + request.session.flash(request.localizer.translate(_("flash.friend_not_found"))) + return HTTPFound(request.route_url("user-data")) + + if candidate in request.identity.get_friends() or candidate in [ + x.recipient for x in request.identity.outgoing_requests + ]: + request.session.flash(request.localizer.translate(_("flash.friend_already_exists"))) + return HTTPFound(request.route_url("user-data")) + + for incoming in request.identity.incoming_requests: + if incoming.sender == candidate: + # We have an incoming request from that person, so we just accept that + request.identity.add_friend(candidate) + request.dbsession.delete(incoming) + request.session.flash(request.localizer.translate(_("flash.friend_added"))) + return HTTPFound(request.route_url("user-data")) + + # Nothing helped, so we send the friend request + friend_req = models.FriendRequest( + sender=request.identity, + recipient=candidate, + date=datetime.datetime.utcnow(), + ) + request.dbsession.add(friend_req) + request.session.flash(request.localizer.translate(_("flash.friend_request_sent"))) + return HTTPFound(request.route_url("user-data")) + + +@view_config(route_name="delete-friend", permission="user", request_method="POST") +def do_delete_friend(request): + """Deletes a friend. + + This is the endpoint of a form on the profile overview. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ + friend = request.dbsession.execute( + select(models.User).filter_by(id=request.params["friend-id"]) + ).scalar_one_or_none() + if friend: + request.identity.remove_friend(friend) + return HTTPFound(request.route_url("user-data")) + + +@view_config(route_name="accept-friend", permission="user", request_method="POST") +def do_accept_friend(request): + """Accepts a friend request. + + This is the endpoint of a form on the profile overview. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ + friend_request = request.dbsession.execute( + select(models.FriendRequest).filter_by(id=request.params["request-id"]) + ).scalar_one_or_none() + if friend_request is None: + return HTTPNotFound() + if friend_request.recipient != request.identity: + return HTTPForbidden() + + friend_request.sender.add_friend(friend_request.recipient) + request.dbsession.delete(friend_request) + return HTTPFound(request.route_url("user-data")) + + +@view_config(route_name="json-friends", renderer="json", permission="user") +def json_friends(request): + """Returns a JSON-ified list of the user's friends. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ + friends = [{"name": friend.name, "id": friend.id} for friend in request.identity.get_friends()] + return friends diff --git a/tests/conftest.py b/tests/conftest.py index a499bec..d4394cd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,7 @@ import pytest import transaction import webtest -from sqlalchemy import delete, select +from sqlalchemy import delete, inspect, select from fietsboek import main, models from fietsboek.data import DataManager @@ -59,12 +59,18 @@ def data_manager(app_settings): def _cleanup_data(app_settings): yield engine = models.get_engine(app_settings) + db_meta = inspect(engine) with engine.begin() as connection: for table in reversed(Base.metadata.sorted_tables): - connection.execute(table.delete()) - data_dir = Path(app_settings["fietsboek.data_dir"]) - if (data_dir / "tracks").is_dir(): - shutil.rmtree(data_dir / "tracks") + # The unit tests don't always set up the tables, so be gentle when + # tearing them down + if db_meta.has_table(table.name): + connection.execute(table.delete()) + # The unit tests also often don't have a data directory, so be gentle here as well + if "fietsboek.data_dir" in app_settings: + data_dir = Path(app_settings["fietsboek.data_dir"]) + if (data_dir / "tracks").is_dir(): + shutil.rmtree(data_dir / "tracks") @pytest.fixture(scope='session') def app(app_settings, dbengine, tmp_path_factory): diff --git a/tests/playwright/test_profiles.py b/tests/playwright/test_profiles.py new file mode 100644 index 0000000..7e5fb3c --- /dev/null +++ b/tests/playwright/test_profiles.py @@ -0,0 +1,25 @@ +from playwright.sync_api import Page, expect + + +def test_forbidden(page: Page, playwright_helper): + john = playwright_helper.john_doe() + + with page.expect_response(lambda resp: resp.status == 403): + page.goto(f"/user/{john.id}") + + +def test_profile(page: Page, playwright_helper): + playwright_helper.login() + + page.goto("/") + page.get_by_role("button", name="User").click() + page.get_by_role("link", name="Profile").click() + + expect(page.locator("#profileLength")).to_have_text("0 km") + expect(page.locator("#profileUphill")).to_have_text("0 m") + expect(page.locator("#profileDownhill")).to_have_text("0 m") + expect(page.locator("#profileMovingTime")).to_have_text("0:00:00") + expect(page.locator("#profileStoppedTime")).to_have_text("0:00:00") + expect(page.locator("#profileMaxSpeed")).to_have_text("0 km/h") + expect(page.locator("#profileAvgSpeed")).to_have_text("0 km/h") + expect(page.locator("#profileNumberOfTracks")).to_have_text("0") diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py index b35f218..0ac5c33 100644 --- a/tests/unit/test_util.py +++ b/tests/unit/test_util.py @@ -30,6 +30,11 @@ def test_fix_iso_timestamp(timestamp, fixed): @pytest.mark.parametrize('delta, multiple, expected', [ ( + timedelta(seconds=0), + timedelta(seconds=1), + timedelta(seconds=0), + ), + ( timedelta(minutes=42), timedelta(minutes=15), timedelta(minutes=45), @@ -82,3 +87,12 @@ def test_tour_metadata(gpx_file): @pytest.mark.parametrize('mps, kph', [(1, 3.6), (10, 36)]) def test_mps_to_kph(mps, kph): assert util.mps_to_kph(mps) == pytest.approx(kph, 0.1) + + +def test_tile_url(app_request): + route_url = util.tile_url(app_request, "tile-proxy", provider="bobby") + + assert "{x}" in route_url + assert "{y}" in route_url + assert "{z}" in route_url + assert "bobby" in route_url |