diff options
| -rw-r--r-- | CHANGELOG.rst | 13 | ||||
| -rw-r--r-- | fietsboek/__init__.py | 7 | ||||
| -rw-r--r-- | fietsboek/actions.py | 57 | ||||
| -rw-r--r-- | fietsboek/data.py | 125 | ||||
| -rw-r--r-- | fietsboek/locale/de/LC_MESSAGES/messages.mo | bin | 12059 -> 12157 bytes | |||
| -rw-r--r-- | fietsboek/locale/de/LC_MESSAGES/messages.po | 63 | ||||
| -rw-r--r-- | fietsboek/locale/en/LC_MESSAGES/messages.mo | bin | 11347 -> 11441 bytes | |||
| -rw-r--r-- | fietsboek/locale/en/LC_MESSAGES/messages.po | 59 | ||||
| -rw-r--r-- | fietsboek/locale/fietslog.pot | 59 | ||||
| -rw-r--r-- | fietsboek/models/meta.py | 2 | ||||
| -rw-r--r-- | fietsboek/summaries.py | 9 | ||||
| -rw-r--r-- | fietsboek/templates/home.jinja2 | 22 | ||||
| -rw-r--r-- | fietsboek/templates/layout.jinja2 | 3 | ||||
| -rw-r--r-- | fietsboek/views/edit.py | 4 | ||||
| -rw-r--r-- | fietsboek/views/tileproxy.py | 87 | ||||
| -rw-r--r-- | fietsboek/views/upload.py | 8 | ||||
| -rw-r--r-- | tests/conftest.py | 6 | 
17 files changed, 386 insertions, 138 deletions
diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8b08927..db73b5b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,19 @@ Added  ^^^^^  - The maintenance mode. +- The summary on the home page now shows the number of tracks per time period. +- The summary on the home page now shows the track length at first glance. + +Changed +^^^^^^^ + +- Python 3.9 is the new minimum Python version (up from 3.7). + +Fixed +^^^^^ + +- Page reading for systems that use a non-UTF-8 locale. +- The filename above the map is hidden again.  0.5.0 - 2023-01-12  ------------------ diff --git a/fietsboek/__init__.py b/fietsboek/__init__.py index 95ff394..41e8a04 100644 --- a/fietsboek/__init__.py +++ b/fietsboek/__init__.py @@ -90,7 +90,12 @@ def maintenance_mode(  def main(_global_config, **settings):      """This function returns a Pyramid WSGI application.""" +    # Avoid a circular import by not importing at the top level +    # pylint: disable=import-outside-toplevel,cyclic-import +    from .views.tileproxy import TileRequester +      parsed_config = mod_config.parse(settings) +    settings["jinja2.newstyle"] = True      def data_manager(request):          return DataManager(Path(request.config.data_dir)) @@ -134,6 +139,8 @@ def main(_global_config, **settings):          config.add_request_method(redis_, name="redis", reify=True)          config.add_request_method(config_, name="config", reify=True) +    config.registry.registerUtility(TileRequester()) +      jinja2_env = config.get_jinja2_environment()      jinja2_env.filters["format_decimal"] = mod_jinja2.filter_format_decimal      jinja2_env.filters["format_datetime"] = mod_jinja2.filter_format_datetime diff --git a/fietsboek/actions.py b/fietsboek/actions.py index 16631b2..e49e27d 100644 --- a/fietsboek/actions.py +++ b/fietsboek/actions.py @@ -16,10 +16,10 @@ from pyramid.request import Request  from sqlalchemy import select  from sqlalchemy.orm.session import Session -from . import models +from . import models, util  from . import transformers as mod_transformers  from . import util -from .data import DataManager +from .data import DataManager, TrackDataDir  from .models.track import TrackType, Visibility  LOGGER = logging.getLogger(__name__) @@ -83,35 +83,34 @@ def add_track(      # Save the GPX data      LOGGER.debug("Creating a new data folder for %d", track.id) -    manager = data_manager.initialize(track.id) -    LOGGER.debug("Saving GPX to %s", manager.gpx_path()) -    manager.compress_gpx(gpx_data) -    manager.backup() - -    gpx = gpxpy.parse(gpx_data) -    for transformer in transformers: -        LOGGER.debug("Running %s with %r", transformer, transformer.parameters) -        transformer.execute(gpx) -    manager.compress_gpx(gpx.to_xml().encode("utf-8")) -    track.transformers = [[tfm.identifier(), tfm.parameters.dict()] for tfm in transformers] - -    # Best time to build the cache is right after the upload, but *after* the -    # transformers have been applied! -    track.ensure_cache(gpx) -    dbsession.add(track.cache) - -    manager.engrave_metadata( -        title=track.title, -        description=track.description, -        author_name=track.owner.name, -        time=track.date, -        gpx=gpx, -    ) +    with data_manager.initialize(track.id) as manager: +        LOGGER.debug("Saving GPX to %s", manager.gpx_path()) +        manager.compress_gpx(gpx_data) +        manager.backup() + +        gpx = gpxpy.parse(gpx_data) +        for transformer in transformers: +            LOGGER.debug("Running %s with %r", transformer, transformer.parameters) +            transformer.execute(gpx) +        track.transformers = [[tfm.identifier(), tfm.parameters.dict()] for tfm in transformers] + +        # Best time to build the cache is right after the upload, but *after* the +        # transformers have been applied! +        track.ensure_cache(gpx) +        dbsession.add(track.cache) + +        manager.engrave_metadata( +            title=track.title, +            description=track.description, +            author_name=track.owner.name, +            time=track.date, +            gpx=gpx, +        )      return track -def edit_images(request: Request, track: models.Track): +def edit_images(request: Request, track: models.Track, *, manager: Optional[TrackDataDir] = None):      """Edit the images according to the given request.      This deletes and adds images and image descriptions as needed, based on the @@ -119,9 +118,11 @@ def edit_images(request: Request, track: models.Track):      :param request: The request.      :param track: The track to edit. +    :param manager: The track's data manager. If not given, it will +        automatically be opened from the request.      """      LOGGER.debug("Editing images for %d", track.id) -    manager = request.data_manager.open(track.id) +    manager = manager if manager else request.data_manager.open(track.id)      # Delete requested images      for image in request.params.getall("delete-image[]"): diff --git a/fietsboek/data.py b/fietsboek/data.py index 6906c84..9f49c49 100644 --- a/fietsboek/data.py +++ b/fietsboek/data.py @@ -10,7 +10,7 @@ import shutil  import string  import uuid  from pathlib import Path -from typing import BinaryIO, List, Optional +from typing import BinaryIO, List, Literal, Optional  import brotli  import gpxpy @@ -77,7 +77,7 @@ class DataManager:          """          path = self._track_data_dir(track_id)          path.mkdir(parents=True) -        return TrackDataDir(track_id, path) +        return TrackDataDir(track_id, path, journal=True, is_fresh=True)      def purge(self, track_id: int):          """Forcefully purges all data from the given track. @@ -101,11 +101,94 @@ class DataManager:  class TrackDataDir: -    """Manager for a single track's data.""" +    """Manager for a single track's data. + +    If initialized with ``journal = True``, then you can use :meth:`rollback` +    to roll back the changes in case of an error. In case of no error, use +    :meth:`commit` to commit the changes. If you don't want the "journalling" +    semantics, use ``journal = False``. +    """ -    def __init__(self, track_id: int, path: Path): +    def __init__(self, track_id: int, path: Path, *, journal: bool = False, is_fresh: bool = False):          self.track_id: int = track_id          self.path: Path = path +        self.journal: Optional[list] = [] if journal else None +        self.is_fresh = is_fresh + +    def __enter__(self) -> "TrackDataDir": +        if self.journal is None: +            self.journal = [] +        return self + +    def __exit__(self, exc_type, exc_val, exc_tb) -> Literal[False]: +        if exc_type is None and exc_val is None and exc_tb is None: +            self.commit() +        else: +            self.rollback() +        return False + +    def rollback(self): +        """Rolls back the journal, e.g. in case of error. + +        :raises ValueError: If the data directory was opened without the +            journal, this raises :exc:`ValueError`. +        """ +        LOGGER.debug("Rolling back state of %s", self.path) + +        if self.journal is None: +            raise ValueError("Rollback on a non-journalling data directory") + +        if self.is_fresh: +            # Shortcut if the directory is fresh, simply remove everything +            self.journal = None +            self.purge() +            return + +        for action, *rest in reversed(self.journal): +            if action == "purge": +                (new_name,) = rest +                shutil.move(new_name, self.path) +            elif action == "compress_gpx": +                (old_data,) = rest +                if old_data is None: +                    self.gpx_path().unlink() +                else: +                    self.gpx_path().write_bytes(old_data) +            elif action == "add_image": +                (image_path,) = rest +                image_path.unlink() +            elif action == "delete_image": +                path, data = rest +                path.write_bytes(data) + +        self.journal = None + +    def commit(self): +        """Commits all changed and deletes the journal. + +        Note that this function will do nothing if the journal is disabled, +        meaning it can always be called. +        """ +        LOGGER.debug("Committing journal for %s", self.path) + +        if self.journal is None: +            return + +        for action, *rest in reversed(self.journal): +            if action == "purge": +                (new_name,) = rest +                shutil.rmtree(new_name, ignore_errors=False, onerror=self._log_deletion_error) +            elif action == "compress_gpx": +                # Nothing to do here, the new data is already on the disk +                pass +            elif action == "add_image": +                # Nothing to do here, the image is already saved +                pass +            elif action == "delete_image": +                # Again, nothing to do here, we simply discard the in-memory image data +                pass + +        self.journal = None      def lock(self) -> FileLock:          """Returns a FileLock that can be used to lock access to the track's @@ -115,18 +198,23 @@ class TrackDataDir:          """          return FileLock(self.path / "lock") +    @staticmethod +    def _log_deletion_error(_, path, exc_info): +        LOGGER.warning("Failed to remove %s", path, exc_info=exc_info) +      def purge(self):          """Purge all data pertaining to the track.          This function logs errors but raises no exception, as such it can          always be used to clean up after a track.          """ - -        def log_error(_, path, exc_info): -            LOGGER.warning("Failed to remove %s", path, exc_info=exc_info) - -        if self.path.is_dir(): -            shutil.rmtree(self.path, ignore_errors=False, onerror=log_error) +        if self.journal is None: +            if self.path.is_dir(): +                shutil.rmtree(self.path, ignore_errors=False, onerror=self._log_deletion_error) +        else: +            new_name = self.path.with_name("trash-" + self.path.name) +            shutil.move(self.path, new_name) +            self.journal.append(("purge", new_name))      def gpx_path(self) -> Path:          """Returns the path of the GPX file. @@ -147,6 +235,16 @@ class TrackDataDir:          :param quality: Compression quality, from 0 to 11 - 11 is highest              quality but slowest compression speed.          """ +        if self.journal is not None: +            # First, we check if we already saved an old state of the GPX data +            for action, *_ in self.journal: +                if action == "compress_gpx": +                    break +            else: +                # We did not save a state yet +                old_data = None if not self.gpx_path().is_file() else self.gpx_path().read_bytes() +                self.journal.append(("compress_gpx", old_data)) +          compressed = brotli.compress(data, quality=quality)          self.gpx_path().write_bytes(compressed) @@ -241,6 +339,9 @@ class TrackDataDir:          with open(path, "wb") as fobj:              shutil.copyfileobj(image, fobj) +        if self.journal is not None: +            self.journal.append(("add_image", path)) +          return filename      def delete_image(self, image_id: str): @@ -254,4 +355,8 @@ class TrackDataDir:          if "/" in image_id or "\\" in image_id:              return          path = self.image_path(image_id) + +        if self.journal is not None: +            self.journal.append(("delete_image", path, path.read_bytes())) +          path.unlink() diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.mo b/fietsboek/locale/de/LC_MESSAGES/messages.mo Binary files differindex 6043d8e..a79349d 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 8787ec2..8b59ecb 100644 --- a/fietsboek/locale/de/LC_MESSAGES/messages.po +++ b/fietsboek/locale/de/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid ""  msgstr ""  "Project-Id-Version: PROJECT VERSION\n"  "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-02-15 23:01+0100\n" +"POT-Creation-Date: 2023-03-07 20:11+0100\n"  "PO-Revision-Date: 2022-07-02 17:35+0200\n"  "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"  "Language: de\n" @@ -18,39 +18,39 @@ msgstr ""  "Content-Transfer-Encoding: 8bit\n"  "Generated-By: Babel 2.11.0\n" -#: fietsboek/util.py:273 +#: fietsboek/util.py:276  msgid "password_constraint.mismatch"  msgstr "Passwörter stimmen nicht überein" -#: fietsboek/util.py:275 +#: fietsboek/util.py:278  msgid "password_constraint.length"  msgstr "Passwort zu kurz" -#: fietsboek/models/track.py:565 +#: fietsboek/models/track.py:566  msgid "tooltip.table.length"  msgstr "Länge" -#: fietsboek/models/track.py:566 +#: fietsboek/models/track.py:567  msgid "tooltip.table.uphill"  msgstr "Bergauf" -#: fietsboek/models/track.py:567 +#: fietsboek/models/track.py:568  msgid "tooltip.table.downhill"  msgstr "Bergab" -#: fietsboek/models/track.py:568 +#: fietsboek/models/track.py:569  msgid "tooltip.table.moving_time"  msgstr "Fahrzeit" -#: fietsboek/models/track.py:569 +#: fietsboek/models/track.py:570  msgid "tooltip.table.stopped_time"  msgstr "Haltezeit" -#: fietsboek/models/track.py:571 +#: fietsboek/models/track.py:572  msgid "tooltip.table.max_speed"  msgstr "Maximalgeschwindigkeit" -#: fietsboek/models/track.py:575 +#: fietsboek/models/track.py:576  msgid "tooltip.table.avg_speed"  msgstr "Durchschnittsgeschwindigkeit" @@ -460,51 +460,58 @@ msgstr "Abbrechen"  msgid "page.home.title"  msgstr "Startseite" -#: fietsboek/templates/home.jinja2:23 +#: fietsboek/templates/home.jinja2:12 fietsboek/templates/home.jinja2:19 +#: fietsboek/templates/home.jinja2:37 +msgid "page.home.summary.track" +msgid_plural "page.home.summary.tracks" +msgstr[0] "%(num)d Strecke" +msgstr[1] "%(num)d Strecken" + +#: fietsboek/templates/home.jinja2:37  msgid "page.home.total"  msgstr "Gesamt" -#: fietsboek/templates/layout.jinja2:36 +#: fietsboek/templates/layout.jinja2:39  msgid "page.navbar.toggle"  msgstr "Navigation umschalten" -#: fietsboek/templates/layout.jinja2:47 +#: fietsboek/templates/layout.jinja2:50  msgid "page.navbar.home"  msgstr "Startseite" -#: fietsboek/templates/layout.jinja2:50 +#: fietsboek/templates/layout.jinja2:53  msgid "page.navbar.browse"  msgstr "Stöbern" -#: fietsboek/templates/layout.jinja2:54 +#: fietsboek/templates/layout.jinja2:57  msgid "page.navbar.upload"  msgstr "Hochladen" -#: fietsboek/templates/layout.jinja2:63 +#: fietsboek/templates/layout.jinja2:66  msgid "page.navbar.user"  msgstr "Nutzer" -#: fietsboek/templates/layout.jinja2:67 +#: fietsboek/templates/layout.jinja2:70  msgid "page.navbar.welcome_user"  msgstr "Willkommen, {}!" -#: fietsboek/templates/layout.jinja2:70 +#: fietsboek/templates/layout.jinja2:73  msgid "page.navbar.logout"  msgstr "Abmelden" -#: fietsboek/templates/layout.jinja2:73 +#: fietsboek/templates/layout.jinja2:76  msgid "page.navbar.profile"  msgstr "Profil" -#: fietsboek/templates/layout.jinja2:77 +#: fietsboek/templates/layout.jinja2:80  msgid "page.navbar.admin"  msgstr "Admin" -#: fietsboek/templates/layout.jinja2:83 +#: fietsboek/templates/layout.jinja2:86  msgid "page.navbar.login"  msgstr "Anmelden" -#: fietsboek/templates/layout.jinja2:87 +#: fietsboek/templates/layout.jinja2:90  msgid "page.navbar.create_account"  msgstr "Konto Erstellen" @@ -630,13 +637,15 @@ msgstr "Anfrage senden"  msgid "page.upload.form.gpx"  msgstr "GPX Datei" -#: fietsboek/transformers/__init__.py:130 +#: fietsboek/transformers/__init__.py:140  msgid "transformers.fix-null-elevation.title"  msgstr "Nullhöhen beheben" -#: fietsboek/transformers/__init__.py:134 +#: fietsboek/transformers/__init__.py:144  msgid "transformers.fix-null-elevation.description" -msgstr "Diese Transformation passt die Höhenangabe für Punkte an, bei denen die Höhenangabe fehlt." +msgstr "" +"Diese Transformation passt die Höhenangabe für Punkte an, bei denen die " +"Höhenangabe fehlt."  #: fietsboek/views/account.py:54  msgid "flash.invalid_name" @@ -749,11 +758,11 @@ msgstr "Keine Datei ausgewählt"  msgid "flash.invalid_file"  msgstr "Ungültige GPX-Datei gesendet" -#: fietsboek/views/upload.py:182 +#: fietsboek/views/upload.py:188  msgid "flash.upload_success"  msgstr "Hochladen erfolgreich" -#: fietsboek/views/upload.py:198 +#: fietsboek/views/upload.py:204  msgid "flash.upload_cancelled"  msgstr "Hochladen abgebrochen" diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.mo b/fietsboek/locale/en/LC_MESSAGES/messages.mo Binary files differindex 4f35491..13debb5 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 5f3ab68..632e0b7 100644 --- a/fietsboek/locale/en/LC_MESSAGES/messages.po +++ b/fietsboek/locale/en/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid ""  msgstr ""  "Project-Id-Version: PROJECT VERSION\n"  "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-02-15 23:01+0100\n" +"POT-Creation-Date: 2023-03-07 20:11+0100\n"  "PO-Revision-Date: 2022-06-28 13:11+0200\n"  "Last-Translator: \n"  "Language: en\n" @@ -18,39 +18,39 @@ msgstr ""  "Content-Transfer-Encoding: 8bit\n"  "Generated-By: Babel 2.11.0\n" -#: fietsboek/util.py:273 +#: fietsboek/util.py:276  msgid "password_constraint.mismatch"  msgstr "Passwords don't match" -#: fietsboek/util.py:275 +#: fietsboek/util.py:278  msgid "password_constraint.length"  msgstr "Password not long enough" -#: fietsboek/models/track.py:565 +#: fietsboek/models/track.py:566  msgid "tooltip.table.length"  msgstr "Length" -#: fietsboek/models/track.py:566 +#: fietsboek/models/track.py:567  msgid "tooltip.table.uphill"  msgstr "Uphill" -#: fietsboek/models/track.py:567 +#: fietsboek/models/track.py:568  msgid "tooltip.table.downhill"  msgstr "Downhill" -#: fietsboek/models/track.py:568 +#: fietsboek/models/track.py:569  msgid "tooltip.table.moving_time"  msgstr "Moving Time" -#: fietsboek/models/track.py:569 +#: fietsboek/models/track.py:570  msgid "tooltip.table.stopped_time"  msgstr "Stopped Time" -#: fietsboek/models/track.py:571 +#: fietsboek/models/track.py:572  msgid "tooltip.table.max_speed"  msgstr "Max Speed" -#: fietsboek/models/track.py:575 +#: fietsboek/models/track.py:576  msgid "tooltip.table.avg_speed"  msgstr "Average Speed" @@ -456,51 +456,58 @@ msgstr "Cancel"  msgid "page.home.title"  msgstr "Home" -#: fietsboek/templates/home.jinja2:23 +#: fietsboek/templates/home.jinja2:12 fietsboek/templates/home.jinja2:19 +#: fietsboek/templates/home.jinja2:37 +msgid "page.home.summary.track" +msgid_plural "page.home.summary.tracks" +msgstr[0] "%(num)d track" +msgstr[1] "%(num)d tracks" + +#: fietsboek/templates/home.jinja2:37  msgid "page.home.total"  msgstr "Total" -#: fietsboek/templates/layout.jinja2:36 +#: fietsboek/templates/layout.jinja2:39  msgid "page.navbar.toggle"  msgstr "Toggle navigation" -#: fietsboek/templates/layout.jinja2:47 +#: fietsboek/templates/layout.jinja2:50  msgid "page.navbar.home"  msgstr "Home" -#: fietsboek/templates/layout.jinja2:50 +#: fietsboek/templates/layout.jinja2:53  msgid "page.navbar.browse"  msgstr "Browse" -#: fietsboek/templates/layout.jinja2:54 +#: fietsboek/templates/layout.jinja2:57  msgid "page.navbar.upload"  msgstr "Upload" -#: fietsboek/templates/layout.jinja2:63 +#: fietsboek/templates/layout.jinja2:66  msgid "page.navbar.user"  msgstr "User" -#: fietsboek/templates/layout.jinja2:67 +#: fietsboek/templates/layout.jinja2:70  msgid "page.navbar.welcome_user"  msgstr "Welcome, {}!" -#: fietsboek/templates/layout.jinja2:70 +#: fietsboek/templates/layout.jinja2:73  msgid "page.navbar.logout"  msgstr "Logout" -#: fietsboek/templates/layout.jinja2:73 +#: fietsboek/templates/layout.jinja2:76  msgid "page.navbar.profile"  msgstr "Profile" -#: fietsboek/templates/layout.jinja2:77 +#: fietsboek/templates/layout.jinja2:80  msgid "page.navbar.admin"  msgstr "Admin" -#: fietsboek/templates/layout.jinja2:83 +#: fietsboek/templates/layout.jinja2:86  msgid "page.navbar.login"  msgstr "Login" -#: fietsboek/templates/layout.jinja2:87 +#: fietsboek/templates/layout.jinja2:90  msgid "page.navbar.create_account"  msgstr "Create Account" @@ -626,11 +633,11 @@ msgstr "Send request"  msgid "page.upload.form.gpx"  msgstr "GPX file" -#: fietsboek/transformers/__init__.py:130 +#: fietsboek/transformers/__init__.py:140  msgid "transformers.fix-null-elevation.title"  msgstr "Fix null elevation" -#: fietsboek/transformers/__init__.py:134 +#: fietsboek/transformers/__init__.py:144  msgid "transformers.fix-null-elevation.description"  msgstr "This transformer fixes the elevation of points whose elevation is unset." @@ -744,11 +751,11 @@ msgstr "No file selected"  msgid "flash.invalid_file"  msgstr "Invalid GPX file selected" -#: fietsboek/views/upload.py:182 +#: fietsboek/views/upload.py:188  msgid "flash.upload_success"  msgstr "Upload successful" -#: fietsboek/views/upload.py:198 +#: fietsboek/views/upload.py:204  msgid "flash.upload_cancelled"  msgstr "Upload cancelled" diff --git a/fietsboek/locale/fietslog.pot b/fietsboek/locale/fietslog.pot index 05bc70a..70f010a 100644 --- a/fietsboek/locale/fietslog.pot +++ b/fietsboek/locale/fietslog.pot @@ -8,7 +8,7 @@ msgid ""  msgstr ""  "Project-Id-Version: PROJECT VERSION\n"  "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-02-15 23:01+0100\n" +"POT-Creation-Date: 2023-03-07 20:11+0100\n"  "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"  "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"  "Language-Team: LANGUAGE <LL@li.org>\n" @@ -17,39 +17,39 @@ msgstr ""  "Content-Transfer-Encoding: 8bit\n"  "Generated-By: Babel 2.11.0\n" -#: fietsboek/util.py:273 +#: fietsboek/util.py:276  msgid "password_constraint.mismatch"  msgstr "" -#: fietsboek/util.py:275 +#: fietsboek/util.py:278  msgid "password_constraint.length"  msgstr "" -#: fietsboek/models/track.py:565 +#: fietsboek/models/track.py:566  msgid "tooltip.table.length"  msgstr "" -#: fietsboek/models/track.py:566 +#: fietsboek/models/track.py:567  msgid "tooltip.table.uphill"  msgstr "" -#: fietsboek/models/track.py:567 +#: fietsboek/models/track.py:568  msgid "tooltip.table.downhill"  msgstr "" -#: fietsboek/models/track.py:568 +#: fietsboek/models/track.py:569  msgid "tooltip.table.moving_time"  msgstr "" -#: fietsboek/models/track.py:569 +#: fietsboek/models/track.py:570  msgid "tooltip.table.stopped_time"  msgstr "" -#: fietsboek/models/track.py:571 +#: fietsboek/models/track.py:572  msgid "tooltip.table.max_speed"  msgstr "" -#: fietsboek/models/track.py:575 +#: fietsboek/models/track.py:576  msgid "tooltip.table.avg_speed"  msgstr "" @@ -453,51 +453,58 @@ msgstr ""  msgid "page.home.title"  msgstr "" -#: fietsboek/templates/home.jinja2:23 +#: fietsboek/templates/home.jinja2:12 fietsboek/templates/home.jinja2:19 +#: fietsboek/templates/home.jinja2:37 +msgid "page.home.summary.track" +msgid_plural "page.home.summary.tracks" +msgstr[0] "" +msgstr[1] "" + +#: fietsboek/templates/home.jinja2:37  msgid "page.home.total"  msgstr "" -#: fietsboek/templates/layout.jinja2:36 +#: fietsboek/templates/layout.jinja2:39  msgid "page.navbar.toggle"  msgstr "" -#: fietsboek/templates/layout.jinja2:47 +#: fietsboek/templates/layout.jinja2:50  msgid "page.navbar.home"  msgstr "" -#: fietsboek/templates/layout.jinja2:50 +#: fietsboek/templates/layout.jinja2:53  msgid "page.navbar.browse"  msgstr "" -#: fietsboek/templates/layout.jinja2:54 +#: fietsboek/templates/layout.jinja2:57  msgid "page.navbar.upload"  msgstr "" -#: fietsboek/templates/layout.jinja2:63 +#: fietsboek/templates/layout.jinja2:66  msgid "page.navbar.user"  msgstr "" -#: fietsboek/templates/layout.jinja2:67 +#: fietsboek/templates/layout.jinja2:70  msgid "page.navbar.welcome_user"  msgstr "" -#: fietsboek/templates/layout.jinja2:70 +#: fietsboek/templates/layout.jinja2:73  msgid "page.navbar.logout"  msgstr "" -#: fietsboek/templates/layout.jinja2:73 +#: fietsboek/templates/layout.jinja2:76  msgid "page.navbar.profile"  msgstr "" -#: fietsboek/templates/layout.jinja2:77 +#: fietsboek/templates/layout.jinja2:80  msgid "page.navbar.admin"  msgstr "" -#: fietsboek/templates/layout.jinja2:83 +#: fietsboek/templates/layout.jinja2:86  msgid "page.navbar.login"  msgstr "" -#: fietsboek/templates/layout.jinja2:87 +#: fietsboek/templates/layout.jinja2:90  msgid "page.navbar.create_account"  msgstr "" @@ -621,11 +628,11 @@ msgstr ""  msgid "page.upload.form.gpx"  msgstr "" -#: fietsboek/transformers/__init__.py:130 +#: fietsboek/transformers/__init__.py:140  msgid "transformers.fix-null-elevation.title"  msgstr "" -#: fietsboek/transformers/__init__.py:134 +#: fietsboek/transformers/__init__.py:144  msgid "transformers.fix-null-elevation.description"  msgstr "" @@ -733,11 +740,11 @@ msgstr ""  msgid "flash.invalid_file"  msgstr "" -#: fietsboek/views/upload.py:182 +#: fietsboek/views/upload.py:188  msgid "flash.upload_success"  msgstr "" -#: fietsboek/views/upload.py:198 +#: fietsboek/views/upload.py:204  msgid "flash.upload_cancelled"  msgstr "" diff --git a/fietsboek/models/meta.py b/fietsboek/models/meta.py index 6b11a09..f06c863 100644 --- a/fietsboek/models/meta.py +++ b/fietsboek/models/meta.py @@ -1,5 +1,5 @@  """Base metadata definition for the SQLAlchemy models.""" -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import declarative_base  from sqlalchemy.schema import MetaData  # Recommended naming convention used by Alembic, as various different database diff --git a/fietsboek/summaries.py b/fietsboek/summaries.py index 670c8f2..ed4b197 100644 --- a/fietsboek/summaries.py +++ b/fietsboek/summaries.py @@ -19,6 +19,9 @@ class Summary:          items.sort(key=lambda y: y.year)          return iter(items) +    def __len__(self) -> int: +        return len(self.all_tracks()) +      def all_tracks(self) -> List[TrackWithMetadata]:          """Returns all tracks of the summary. @@ -62,6 +65,9 @@ class YearSummary:          items.sort(key=lambda x: x.month)          return iter(items) +    def __len__(self) -> int: +        return len(self.all_tracks()) +      def all_tracks(self) -> List[TrackWithMetadata]:          """Returns all tracks of the summary. @@ -104,6 +110,9 @@ class MonthSummary:          items.sort(key=lambda t: t.date)          return iter(items) +    def __len__(self) -> int: +        return len(self.all_tracks()) +      def all_tracks(self) -> List[TrackWithMetadata]:          """Returns all tracks of the summary. diff --git a/fietsboek/templates/home.jinja2 b/fietsboek/templates/home.jinja2 index 4a9c3ad..5e552e4 100644 --- a/fietsboek/templates/home.jinja2 +++ b/fietsboek/templates/home.jinja2 @@ -6,13 +6,27 @@    {% if summary %}    <div class="list-group list-group-root">      {% for year in summary %} -    <a class="list-group-item list-group-item-primary"><i class="bi bi-chevron-down summary-toggler"></i> {{ year.year }} — {{ (year.total_length / 1000) | round(2) | format_decimal }} km</a> +    <a class="list-group-item list-group-item-primary"> +      <i class="bi bi-chevron-down summary-toggler"></i> +      {{ year.year }} +      <span class="float-end">{{ ngettext("page.home.summary.track", "page.home.summary.tracks", year|length) }} — {{ (year.total_length / 1000) | round(2) | format_decimal }} km</span> +    </a>      <div class="list-group collapse show">        {% for month in year %} -      <a class="list-group-item list-group-item-secondary"><i class="bi bi-chevron-down summary-toggler"></i> {{ month_name(request, month.month) }} — {{ (month.total_length / 1000) | round(2) | format_decimal }} km</a> +      <a class="list-group-item list-group-item-secondary"> +        <i class="bi bi-chevron-down summary-toggler"></i> +        {{ month_name(request, month.month) }} +        <span class="float-end">{{ ngettext("page.home.summary.track", "page.home.summary.tracks", month|length) }} — {{ (month.total_length / 1000) | round(2) | format_decimal }} km</span> +      </a>        <div class="list-group collapse show">          {% for track in month %} -        <span class="list-group-item">{{ track.date.day }}: <a href="{{ request.route_url('details', track_id=track.id) }}" data-bs-toggle="tooltip" data-bs-container="body" data-bs-html="true" title="{{ track.html_tooltip(request.localizer) }}">{{ track.title | default(track.date, true) }}</a></span> +        <span class="list-group-item"> +          {{ track.date.day }}: +          <a href="{{ request.route_url('details', track_id=track.id) }}" data-bs-toggle="tooltip" data-bs-container="body" data-bs-html="true" title="{{ track.html_tooltip(request.localizer) }}"> +            {{ track.title | default(track.date, true) }} +          </a> +          <span class="float-end">{{ (track.length / 1000) | round(2) | format_decimal }} km</span> +        </span>          {% endfor %}        </div>        {% endfor %} @@ -20,7 +34,7 @@      {% endfor %}    </div>    <p> -    {{ _("page.home.total") }}: {{ (summary.total_length / 1000) | round(2) | format_decimal }} km +    {{ _("page.home.total") }}: {{ ngettext("page.home.summary.track", "page.home.summary.tracks", summary|length) }}, {{ (summary.total_length / 1000) | round(2) | format_decimal }} km    </p>    {% elif home_content %}    {{ home_content | safe }} diff --git a/fietsboek/templates/layout.jinja2 b/fietsboek/templates/layout.jinja2 index 5a10e66..d4e0a0d 100644 --- a/fietsboek/templates/layout.jinja2 +++ b/fietsboek/templates/layout.jinja2 @@ -23,8 +23,11 @@ const FRIENDS_URL = {{ request.route_url('json-friends') | tojson }};  const TILE_LAYERS = {{ embed_tile_layers(request) }};  const BASE_URL = {{ request.route_url('home') | tojson }};  const LOCALE = {{ request.localizer.locale_name.replace('_', '-') | tojson }}; + +// Settings for GPX Viewer. Check GPX2GM_Defs.js for a full list.  const Bestaetigung = false;  const Fullscreenbutton = true; +const Legende = false;      </script>    </head> diff --git a/fietsboek/views/edit.py b/fietsboek/views/edit.py index 9cc666f..881f404 100644 --- a/fietsboek/views/edit.py +++ b/fietsboek/views/edit.py @@ -82,7 +82,7 @@ def do_edit(request):      data: TrackDataDir = request.data_manager.open(track.id)      tz_offset = datetime.timedelta(minutes=int(request.params["date-tz"]))      date = datetime.datetime.fromisoformat(request.params["date"]) -    with data.lock(): +    with data, data.lock():          track.date = date.replace(tzinfo=datetime.timezone(tz_offset))          track.tagged_people = tagged_people @@ -94,7 +94,7 @@ def do_edit(request):          tags = request.params.getall("tag[]")          track.sync_tags(tags) -        actions.edit_images(request, request.context) +        actions.edit_images(request, request.context, manager=data)          gpx = actions.execute_transformers(request, request.context)          data.engrave_metadata(              title=track.title, diff --git a/fietsboek/views/tileproxy.py b/fietsboek/views/tileproxy.py index 87a742d..5ed79a5 100644 --- a/fietsboek/views/tileproxy.py +++ b/fietsboek/views/tileproxy.py @@ -9,8 +9,9 @@ Additionally, this protects the users' IP, as only fietsboek can see it.  import datetime  import logging  import random +import threading  from itertools import chain -from typing import List +from typing import List, Optional  import requests  from pyramid.httpexceptions import HTTPBadRequest, HTTPGatewayTimeout @@ -18,6 +19,7 @@ from pyramid.request import Request  from pyramid.response import Response  from pyramid.view import view_config  from requests.exceptions import ReadTimeout +from zope.interface import Interface, implementer  from .. import __VERSION__  from ..config import Config, LayerAccess, LayerType, TileLayerConfig @@ -37,7 +39,7 @@ DEFAULT_TILE_LAYERS = [      TileLayerConfig(          layer_id="osm",          name="OSM", -        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", +        url="https://tile.openstreetmap.org/{z}/{x}/{y}.png",          layer_type=LayerType.BASE,          zoom=19,          access=LayerAccess.PUBLIC, @@ -72,7 +74,7 @@ DEFAULT_TILE_LAYERS = [      TileLayerConfig(          layer_id="osmde",          name="OSMDE", -        url="https://{s}.tile.openstreetmap.de/tiles/osmde/{z}/{x}/{y}.png", +        url="https://tile.openstreetmap.de/tiles/osmde/{z}/{x}/{y}.png",          layer_type=LayerType.BASE,          zoom=19,          access=LayerAccess.PUBLIC, @@ -89,7 +91,7 @@ DEFAULT_TILE_LAYERS = [      TileLayerConfig(          layer_id="opentopo",          name="Open Topo", -        url="https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", +        url="https://tile.opentopomap.org/{z}/{x}/{y}.png",          layer_type=LayerType.BASE,          zoom=17,          access=LayerAccess.PUBLIC, @@ -217,6 +219,71 @@ PUNISHMENT_TTL = datetime.timedelta(minutes=10)  PUNISHMENT_THRESHOLD = 10  """Block a provider after that many requests have timed out.""" +MAX_CONCURRENT_CONNECTIONS = 2 +"""Maximum TCP connections per tile host.""" + +CONNECTION_CLOSE_TIMEOUT = datetime.timedelta(seconds=2) +"""Timeout after which keep-alive connections are killed. + +Note that new requests reset the timeout. +""" + + +class ITileRequester(Interface):  # pylint: disable=inherit-non-class +    """An interface to define the tile requester.""" + +    def load_tile(self, url: str, headers: Optional[dict[str, str]] = None) -> requests.Response: +        """Loads a tile at the given URL. + +        :param url: The URL of the tile to load. +        :param headers: Additional headers to send. +        :return: The response. +        """ +        raise NotImplementedError() + + +@implementer(ITileRequester) +class TileRequester:  # pylint: disable=too-few-public-methods +    """Implementation of the tile requester using requests sessions. + +    The benefit of this over doing ``requests.get`` is that we can re-use +    connections, and we ensure that we comply with the use policy of the tile +    servers by not hammering them with too many connections. +    """ + +    def __init__(self): +        self.session = requests.Session() +        adapter = requests.adapters.HTTPAdapter( +            pool_maxsize=MAX_CONCURRENT_CONNECTIONS, +            pool_block=True, +        ) +        self.session.mount("http://", adapter) +        self.session.mount("https://", adapter) +        self.lock = threading.Lock() +        self.closer = None + +    def load_tile(self, url: str, headers: Optional[dict[str, str]] = None) -> requests.Response: +        """Implementation of :meth:`ITileRequester.load_tile`.""" +        response = self.session.get(url, headers=headers, timeout=TIMEOUT.total_seconds()) +        response.raise_for_status() +        self._schedule_session_close() +        return response + +    def _schedule_session_close(self): +        with self.lock: +            if self.closer: +                self.closer.cancel() +            self.closer = threading.Timer( +                CONNECTION_CLOSE_TIMEOUT.total_seconds(), +                self._close_session, +            ) +            self.closer.start() + +    def _close_session(self): +        with self.lock: +            self.closer = None +            self.session.close() +  @view_config(route_name="tile-proxy", http_cache=3600)  def tile_proxy(request): @@ -227,6 +294,7 @@ def tile_proxy(request):      :return: The HTTP response.      :rtype: pyramid.response.Response      """ +    # pylint: disable=too-many-locals      if request.config.disable_tile_proxy:          raise HTTPBadRequest("Tile proxying is disabled") @@ -262,19 +330,18 @@ def tile_proxy(request):      if from_mail:          headers["from"] = from_mail +    loader: ITileRequester = request.registry.getUtility(ITileRequester)      try: -        resp = requests.get(url, headers=headers, timeout=TIMEOUT.total_seconds()) +        resp = loader.load_tile(url, headers=headers)      except ReadTimeout:          LOGGER.debug("Proxy timeout when accessing %r", url)          request.redis.incr(timeout_tracker)          request.redis.expire(timeout_tracker, PUNISHMENT_TTL)          raise HTTPGatewayTimeout(f"No response in time from {provider}") from None +    except requests.HTTPError as exc: +        LOGGER.info("Proxy request failed for %s: %s", provider, exc) +        return Response(f"Failed to get tile from {provider}", status_code=exc.response.status_code)      else: -        try: -            resp.raise_for_status() -        except requests.HTTPError as exc: -            LOGGER.info("Proxy request failed for %s: %s", provider, exc) -            return Response(f"Failed to get tile from {provider}", status_code=resp.status_code)          request.redis.set(cache_key, resp.content, ex=TTL)          return Response(resp.content, content_type=resp.headers.get("Content-type", content_type)) diff --git a/fietsboek/views/upload.py b/fietsboek/views/upload.py index 15fa990..fd93034 100644 --- a/fietsboek/views/upload.py +++ b/fietsboek/views/upload.py @@ -177,7 +177,13 @@ def do_finish_upload(request):      # Don't forget to add the images      LOGGER.debug("Starting to edit images for the upload") -    actions.edit_images(request, track) +    try: +        actions.edit_images(request, track) +    except Exception: +        # We just created the folder, so we'll be fine deleting it +        LOGGER.info("Deleting partially created folder for track %d", track.id) +        request.data_manager.open(track.id).purge() +        raise      request.session.flash(request.localizer.translate(_("flash.upload_success"))) diff --git a/tests/conftest.py b/tests/conftest.py index a203775..a499bec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -59,9 +59,9 @@ def data_manager(app_settings):  def _cleanup_data(app_settings):      yield      engine = models.get_engine(app_settings) -    connection = engine.connect() -    for table in reversed(Base.metadata.sorted_tables): -        connection.execute(table.delete()) +    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")  | 
