aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.rst13
-rw-r--r--fietsboek/__init__.py7
-rw-r--r--fietsboek/actions.py57
-rw-r--r--fietsboek/data.py125
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.mobin12059 -> 12157 bytes
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.po63
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.mobin11347 -> 11441 bytes
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.po59
-rw-r--r--fietsboek/locale/fietslog.pot59
-rw-r--r--fietsboek/models/meta.py2
-rw-r--r--fietsboek/summaries.py9
-rw-r--r--fietsboek/templates/home.jinja222
-rw-r--r--fietsboek/templates/layout.jinja23
-rw-r--r--fietsboek/views/edit.py4
-rw-r--r--fietsboek/views/tileproxy.py87
-rw-r--r--fietsboek/views/upload.py8
-rw-r--r--tests/conftest.py6
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
index 6043d8e..a79349d 100644
--- a/fietsboek/locale/de/LC_MESSAGES/messages.mo
+++ b/fietsboek/locale/de/LC_MESSAGES/messages.mo
Binary files differ
diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.po b/fietsboek/locale/de/LC_MESSAGES/messages.po
index 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
index 4f35491..13debb5 100644
--- a/fietsboek/locale/en/LC_MESSAGES/messages.mo
+++ b/fietsboek/locale/en/LC_MESSAGES/messages.mo
Binary files differ
diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.po b/fietsboek/locale/en/LC_MESSAGES/messages.po
index 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 }} &mdash; {{ (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) }} &mdash; {{ (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) }} &mdash; {{ (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) }} &mdash; {{ (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")