aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Schadt <kingdread@gmx.de>2022-07-22 23:40:20 +0200
committerDaniel Schadt <kingdread@gmx.de>2022-07-22 23:45:34 +0200
commit109332ee71869f090a472bd2fb455f2d3f48b91e (patch)
tree21b3b00ffd2bb8d7eb982869cc9560c78663e4e3
parent4a67a98222443e286f8db9650327250021c76ef5 (diff)
parentd2a8a75667e7e2ce5c7bdd7fd351136b70ec7913 (diff)
downloadfietsboek-109332ee71869f090a472bd2fb455f2d3f48b91e.tar.gz
fietsboek-109332ee71869f090a472bd2fb455f2d3f48b91e.tar.bz2
fietsboek-109332ee71869f090a472bd2fb455f2d3f48b91e.zip
Merge branch 'image-upload'
-rw-r--r--.gitignore1
-rw-r--r--CHANGELOG.md17
-rw-r--r--development.ini3
-rw-r--r--doc/administration/backup.rst28
-rw-r--r--doc/administration/configuration.rst10
-rw-r--r--fietsboek/__init__.py11
-rw-r--r--fietsboek/alembic/versions/20220721_091ce24409fe.py30
-rw-r--r--fietsboek/data.py138
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.mobin9866 -> 10209 bytes
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.po95
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.mobin9289 -> 9612 bytes
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.po95
-rw-r--r--fietsboek/locale/fietslog.pot95
-rw-r--r--fietsboek/models/__init__.py1
-rw-r--r--fietsboek/models/image.py62
-rw-r--r--fietsboek/models/track.py3
-rw-r--r--fietsboek/routes.py2
-rw-r--r--fietsboek/static/fietsboek.js87
-rw-r--r--fietsboek/static/theme.css59
-rw-r--r--fietsboek/templates/details.jinja226
-rw-r--r--fietsboek/templates/edit.jinja24
-rw-r--r--fietsboek/templates/edit_form.jinja243
-rw-r--r--fietsboek/templates/finish_upload.jinja24
-rw-r--r--fietsboek/util.py69
-rw-r--r--fietsboek/views/detail.py41
-rw-r--r--fietsboek/views/edit.py75
-rw-r--r--fietsboek/views/upload.py6
-rw-r--r--tests/conftest.py3
28 files changed, 881 insertions, 127 deletions
diff --git a/.gitignore b/.gitignore
index 3afaa13..a833b62 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,4 @@ coverage
test
*.sqlite
.venv/
+/data
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 766e322..951e015 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,20 @@
+Unreleased
+----------
+
+**Added**
+- A button to delete tracks.
+- A small "user menu" in the top right corner.
+- A button to download multiple tracks in the "Browse" view.
+- Image uploading.
+
+**Changed**
+- The position of the login/logout buttons.
+- Style of the "Tag friend" button.
+
+**Fixed**
+- The track editing overwriting the timezone information.
+- The manifest file for inclusion of the README and CHANGELOG.
+
0.1.0 - 2022-07-09
------------------
diff --git a/development.ini b/development.ini
index 47e0231..eb33586 100644
--- a/development.ini
+++ b/development.ini
@@ -18,11 +18,12 @@ pyramid.includes =
pyramid_debugtoolbar
sqlalchemy.url = sqlite:///%(here)s/fietsboek.sqlite
+fietsboek.data_dir = %(here)s/data
retry.attempts = 3
email.from = fietsboek@kingdread.de
-email.smtp_url = debug://localhost:1025
+email.smtp_url = debug://
available_locales = en de
enable_account_registration = true
diff --git a/doc/administration/backup.rst b/doc/administration/backup.rst
index 9566e38..3973298 100644
--- a/doc/administration/backup.rst
+++ b/doc/administration/backup.rst
@@ -15,10 +15,20 @@ case data is lost due unforseen reasons.
Backup
------
-All of the Fietsboek data is contained in the database. You should refer to the
-manual of your DBMS to see what the procedure for a backup is. Using the
-preferred way for your DBMS ensures that your backup represents a consistent
-state of the database.
+The Fietsboek data lives in two places: Most of it is saved in the database,
+while track images and other "big files" are saved in the data directory.
+
+You should refer to the manual of your DBMS to see what the procedure for a
+backup is. Using the preferred way for your DBMS ensures that your backup
+represents a consistent state of the database.
+
+After backing up the database, you should back up the data directory with a
+tool of your choice, for example by using ``tar``, ``rsync`` or a proper backup
+tool like ``borg``:
+
+.. code:: bash
+
+ tar -czf backup.tar.gz path/to/data/dir
In addition to the actual data, you should also note down the Fietsboek version
and the database schema version, as backups can only be restored to the same
@@ -42,7 +52,7 @@ Note those value in addition to your backup.
Restore
-------
-The restoration process works in three steps:
+The restoration process works in four steps:
First, we ensure that we are on the correct Fietsboek version. If you are using
``git``, this can be done with ``git checkout``:
@@ -57,5 +67,9 @@ Then, we get the database schema to the right version:
.venv/bin/alembic -c production.ini upgrade NOTED_ALEMBIC_VERSION_HERE
-Finally, we can restore the data. This step is dependent on the DBMS that you,
-therefore you should consult its documentation.
+Now, we can restore the data in the database. This step is dependent on the
+DBMS that you, therefore you should consult its documentation.
+
+Finally, we can restore the data directory. This step depends on how you chose
+to back up the data directory earlier. In the case of ``tar``, you can simply
+extract the archive to the right location.
diff --git a/doc/administration/configuration.rst b/doc/administration/configuration.rst
index 48afde1..3927540 100644
--- a/doc/administration/configuration.rst
+++ b/doc/administration/configuration.rst
@@ -35,6 +35,7 @@ Most of the configuration is in the ``[app:main]`` category and looks like this:
session_key = <EDIT THIS>
sqlalchemy.url = sqlite:///%(here)s/fietsboek.sqlite
+ fietsboek.data_dir = %(here)s/data
retry.attempts = 3
@@ -51,6 +52,15 @@ Most of the configuration is in the ``[app:main]`` category and looks like this:
creation of new accounts via the web interface, for example if you want to
have a private instance. New accounts can always be created using the CLI
management tool.
+* ``session_key`` should be set to a random string of characters. This is the
+ key used to sign session data, so it should not get into wrong hands!
+* ``sqlalchemy.url`` is the URL to the database. See the `SQLAlchemy
+ documentation
+ <https://docs.sqlalchemy.org/en/14/core/engines.html#database-urls>`__ for
+ more information.
+* ``fietsboek.data_dir`` sets the directory for data uploads. This directory
+ must be writable by the Fietsboek process, as Fietsboek will save track
+ images in there.
* ``email.from`` sets the sender of emails, for example for account verifications.
* ``email.smtp_url`` sets the URL of the SMTP server. The following formats are accepted:
diff --git a/fietsboek/__init__.py b/fietsboek/__init__.py
index 9de9cbd..aa1d5c3 100644
--- a/fietsboek/__init__.py
+++ b/fietsboek/__init__.py
@@ -2,6 +2,8 @@
For more information, see the README or the included documentation.
"""
+from pathlib import Path
+
from pyramid.config import Configurator
from pyramid.session import SignedCookieSessionFactory
from pyramid.csrf import CookieCSRFStoragePolicy
@@ -9,6 +11,7 @@ from pyramid.settings import asbool, aslist
from pyramid.i18n import default_locale_negotiator
from .security import SecurityPolicy
+from .data import DataManager
from . import jinja2 as fiets_jinja2
@@ -46,6 +49,13 @@ def main(global_config, **settings):
if settings.get('session_key', '<EDIT THIS>') == '<EDIT THIS>':
raise ValueError("Please set a session signing key (session_key) in your settings!")
+ if 'fietsboek.data_dir' not in settings:
+ raise ValueError("Please set a data directory (fietsboek.data_dir) in your settings!")
+
+ def data_manager(request):
+ data_dir = request.registry.settings["fietsboek.data_dir"]
+ return DataManager(Path(data_dir))
+
settings['enable_account_registration'] = asbool(
settings.get('enable_account_registration', 'false'))
settings['available_locales'] = aslist(
@@ -63,6 +73,7 @@ def main(global_config, **settings):
config.set_csrf_storage_policy(CookieCSRFStoragePolicy())
config.set_default_csrf_options(require_csrf=True)
config.set_locale_negotiator(locale_negotiator)
+ config.add_request_method(data_manager, reify=True)
jinja2_env = config.get_jinja2_environment()
jinja2_env.filters['format_decimal'] = fiets_jinja2.filter_format_decimal
diff --git a/fietsboek/alembic/versions/20220721_091ce24409fe.py b/fietsboek/alembic/versions/20220721_091ce24409fe.py
new file mode 100644
index 0000000..88044b6
--- /dev/null
+++ b/fietsboek/alembic/versions/20220721_091ce24409fe.py
@@ -0,0 +1,30 @@
+"""add image metadata to uploads
+
+Revision ID: 091ce24409fe
+Revises: c89d9bdbfa68
+Create Date: 2022-07-21 23:24:54.241170
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '091ce24409fe'
+down_revision = 'c89d9bdbfa68'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ op.create_table('image_metadata',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('track_id', sa.Integer(), nullable=False),
+ sa.Column('image_name', sa.Text(), nullable=False),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.ForeignKeyConstraint(['track_id'], ['tracks.id'], name=op.f('fk_image_metadata_track_id_tracks')),
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_image_metadata')),
+ sa.UniqueConstraint('track_id', 'image_name', name=op.f('uq_image_metadata_track_id'))
+ )
+
+def downgrade():
+ op.drop_table('image_metadata')
diff --git a/fietsboek/data.py b/fietsboek/data.py
new file mode 100644
index 0000000..c1cd214
--- /dev/null
+++ b/fietsboek/data.py
@@ -0,0 +1,138 @@
+"""Data manager for fietsboek.
+
+Data are objects that belong to a track (such as images), but are not stored in
+the database itself. This module makes access to such data objects easier.
+"""
+import random
+import string
+import shutil
+import uuid
+import logging
+
+from .util import secure_filename
+
+
+LOGGER = logging.getLogger(__name__)
+
+
+def generate_filename(filename):
+ """Generates a safe-to-use filename for uploads.
+
+ If possible, tries to keep parts of the original filename intact, such as
+ the extension.
+
+ :param filename: The original filename.
+ :type filename: str
+ :return: The generated filename.
+ :rtype: str
+ """
+ if filename:
+ good_name = secure_filename(filename)
+ if good_name:
+ random_prefix = "".join(random.choice(string.ascii_lowercase) for _ in range(5))
+ return f"{random_prefix}_{good_name}"
+
+ return str(uuid.uuid4())
+
+
+class DataManager:
+ """Data manager.
+
+ The data manager is usually provided as ``request.data_manager`` and can be
+ used to access track's images and other on-disk data.
+
+ :ivar data_dir: Path to the data folder.
+ :vartype data_dir: pathlib.Path
+ """
+
+ def __init__(self, data_dir):
+ self.data_dir = data_dir
+
+ def _track_data_dir(self, track_id):
+ return self.data_dir / "tracks" / str(track_id)
+
+ def images(self, track_id):
+ """Returns a list of images that belong to a track.
+
+ :param track_id: Numerical ID of the track.
+ :type track_id: int
+ :return: A list of image IDs.
+ :rtype: list[str]
+ """
+ image_dir = self._track_data_dir(track_id) / "images"
+ if not image_dir.exists():
+ return []
+ images = []
+ for image in image_dir.iterdir():
+ images.append(image.name)
+ return images
+
+ def purge(self, track_id):
+ """Purge all data pertaining to the given track.
+
+ This function logs errors but raises no exception, as such it can
+ always be used to clean up after a track.
+
+ :param track_id: The ID of the track.
+ :type track_id: int
+ """
+ def log_error(_, path, exc_info):
+ LOGGER.warning("Failed to remove %s", path, exc_info=exc_info)
+
+ path = self._track_data_dir(track_id)
+ if path.is_dir():
+ shutil.rmtree(path, ignore_errors=False, onerror=log_error)
+
+ def image_path(self, track_id, image_id):
+ """Returns a path to a saved image.
+
+ :raises FileNotFoundError: If the given image could not be found.
+ :param track_id: ID of the track.
+ :type track_id: int
+ :param image_id: ID of the image.
+ :type image_id: str
+ :return: A path pointing to the requested image.
+ :rtype: pathlib.Path
+ """
+ image = self._track_data_dir(track_id) / "images" / secure_filename(image_id)
+ if not image.exists():
+ raise FileNotFoundError("The requested image does not exist")
+ return image
+
+ def add_image(self, track_id, image, filename=None):
+ """Saves an image to a track.
+
+ :param track_id: ID of the track.
+ :type track_id: int
+ :param image: The image, as a file-like object to read from.
+ :type image: file
+ :param filename: The image's original filename.
+ :type filename: str
+ :return: The ID of the saved image.
+ :rtype: str
+ """
+ image_dir = self._track_data_dir(track_id) / "images"
+ image_dir.mkdir(parents=True, exist_ok=True)
+
+ filename = generate_filename(filename)
+ path = image_dir / filename
+ with open(path, "wb") as fobj:
+ shutil.copyfileobj(image, fobj)
+
+ return filename
+
+ def delete_image(self, track_id, image_id):
+ """Deletes an image from a track.
+
+ :raises FileNotFoundError: If the given image could not be found.
+ :param track_id: ID of the track.
+ :type track_id: int
+ :param image_id: ID of the image.
+ :type image_id: str
+ """
+ # Be sure to not delete anything else than the image file
+ image_id = secure_filename(image_id)
+ if '/' in image_id or '\\' in image_id:
+ return
+ path = self.image_path(track_id, image_id)
+ path.unlink()
diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.mo b/fietsboek/locale/de/LC_MESSAGES/messages.mo
index 34bd05a..7d7af8d 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 0912079..35a6bd4 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: 2022-07-11 14:34+0200\n"
+"POT-Creation-Date: 2022-07-21 23:49+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.10.3\n"
-#: fietsboek/util.py:262
+#: fietsboek/util.py:273
msgid "password_constraint.mismatch"
msgstr "Passwörter stimmen nicht überein"
-#: fietsboek/util.py:264
+#: fietsboek/util.py:275
msgid "password_constraint.length"
msgstr "Passwort zu kurz"
-#: fietsboek/models/track.py:494
+#: fietsboek/models/track.py:498
msgid "tooltip.table.length"
msgstr "Länge"
-#: fietsboek/models/track.py:495
+#: fietsboek/models/track.py:499
msgid "tooltip.table.uphill"
msgstr "Bergauf"
-#: fietsboek/models/track.py:496
+#: fietsboek/models/track.py:500
msgid "tooltip.table.downhill"
msgstr "Bergab"
-#: fietsboek/models/track.py:497
+#: fietsboek/models/track.py:501
msgid "tooltip.table.moving_time"
msgstr "Fahrzeit"
-#: fietsboek/models/track.py:498
+#: fietsboek/models/track.py:502
msgid "tooltip.table.stopped_time"
msgstr "Haltezeit"
-#: fietsboek/models/track.py:499
+#: fietsboek/models/track.py:503
msgid "tooltip.table.max_speed"
msgstr "Maximalgeschwindigkeit"
-#: fietsboek/models/track.py:501
+#: fietsboek/models/track.py:505
msgid "tooltip.table.avg_speed"
msgstr "Durchschnittsgeschwindigkeit"
@@ -86,47 +86,51 @@ msgstr "Hinzufügen"
msgid "page.browse.title"
msgstr "Stöbern"
-#: fietsboek/templates/browse.jinja2:17 fietsboek/templates/details.jinja2:86
+#: fietsboek/templates/browse.jinja2:19 fietsboek/templates/details.jinja2:86
msgid "page.details.date"
msgstr "Datum"
-#: fietsboek/templates/browse.jinja2:19 fietsboek/templates/details.jinja2:98
+#: fietsboek/templates/browse.jinja2:21 fietsboek/templates/details.jinja2:98
msgid "page.details.length"
msgstr "Länge"
-#: fietsboek/templates/browse.jinja2:23 fietsboek/templates/details.jinja2:90
+#: fietsboek/templates/browse.jinja2:25 fietsboek/templates/details.jinja2:90
msgid "page.details.start_time"
msgstr "Startzeit"
-#: fietsboek/templates/browse.jinja2:25 fietsboek/templates/details.jinja2:94
+#: fietsboek/templates/browse.jinja2:27 fietsboek/templates/details.jinja2:94
msgid "page.details.end_time"
msgstr "Endzeit"
-#: fietsboek/templates/browse.jinja2:29 fietsboek/templates/details.jinja2:102
+#: fietsboek/templates/browse.jinja2:31 fietsboek/templates/details.jinja2:102
msgid "page.details.uphill"
msgstr "Bergauf"
-#: fietsboek/templates/browse.jinja2:31 fietsboek/templates/details.jinja2:106
+#: fietsboek/templates/browse.jinja2:33 fietsboek/templates/details.jinja2:106
msgid "page.details.downhill"
msgstr "Bergab"
-#: fietsboek/templates/browse.jinja2:35 fietsboek/templates/details.jinja2:110
+#: fietsboek/templates/browse.jinja2:37 fietsboek/templates/details.jinja2:110
msgid "page.details.moving_time"
msgstr "Fahrzeit"
-#: fietsboek/templates/browse.jinja2:37 fietsboek/templates/details.jinja2:114
+#: fietsboek/templates/browse.jinja2:39 fietsboek/templates/details.jinja2:114
msgid "page.details.stopped_time"
msgstr "Haltezeit"
-#: fietsboek/templates/browse.jinja2:41 fietsboek/templates/details.jinja2:118
+#: fietsboek/templates/browse.jinja2:43 fietsboek/templates/details.jinja2:118
msgid "page.details.max_speed"
msgstr "maximale Geschwindigkeit"
-#: fietsboek/templates/browse.jinja2:43 fietsboek/templates/details.jinja2:122
+#: fietsboek/templates/browse.jinja2:45 fietsboek/templates/details.jinja2:122
msgid "page.details.avg_speed"
msgstr "durchschnittliche Geschwindigkeit"
-#: fietsboek/templates/browse.jinja2:59
+#: fietsboek/templates/browse.jinja2:60
+msgid "page.browse.download_multiple"
+msgstr "ausgewählte Herunterladen"
+
+#: fietsboek/templates/browse.jinja2:62
msgid "page.browse.no_tracks"
msgstr ""
"Es wurden keine Strecken gefunden, auf die Du Zugriff hast. Versuche, "
@@ -235,27 +239,27 @@ msgstr ""
msgid "page.details.download"
msgstr "Herunterladen"
-#: fietsboek/templates/details.jinja2:140
+#: fietsboek/templates/details.jinja2:166
msgid "page.details.comments"
msgstr "Kommentare"
-#: fietsboek/templates/details.jinja2:144
+#: fietsboek/templates/details.jinja2:170
msgid "page.details.comments.author"
msgstr "Kommentar von {}"
-#: fietsboek/templates/details.jinja2:161
+#: fietsboek/templates/details.jinja2:187
msgid "page.details.comments.new.title"
msgstr "Kommentar erstellen"
-#: fietsboek/templates/details.jinja2:164
+#: fietsboek/templates/details.jinja2:190
msgid "page.details.comments.new.input_title"
msgstr "Titel"
-#: fietsboek/templates/details.jinja2:165
+#: fietsboek/templates/details.jinja2:191
msgid "page.details.comments.new.input_comment"
msgstr "Kommentar"
-#: fietsboek/templates/details.jinja2:168
+#: fietsboek/templates/details.jinja2:194
msgid "page.details.comments.new.submit"
msgstr "Absenden"
@@ -333,6 +337,23 @@ msgstr "Wappen"
msgid "page.track.form.description"
msgstr "Beschreibung"
+#: fietsboek/templates/edit_form.jinja2:94
+#: fietsboek/templates/edit_form.jinja2:108
+msgid "page.track.form.remove_image"
+msgstr "Bild entfernen"
+
+#: fietsboek/templates/edit_form.jinja2:103
+msgid "page.track.form.select_images"
+msgstr "Bilder auswählen"
+
+#: fietsboek/templates/edit_form.jinja2:119
+msgid "page.track.form.image_description_modal"
+msgstr "Bildbeschreibung"
+
+#: fietsboek/templates/edit_form.jinja2:126
+msgid "page.track.form.image_description_modal.save"
+msgstr "Übernehmen"
+
#: fietsboek/templates/finish_upload.jinja2:8
#: fietsboek/templates/upload.jinja2:6
msgid "page.upload.title"
@@ -580,7 +601,7 @@ msgstr "Ein Link zum Zurücksetzen des Passworts wurde versandt"
msgid "page.password_reset.email.subject"
msgstr "Fietsboek Passwortzurücksetzung"
-#: fietsboek/views/default.py:139
+#: fietsboek/views/default.py:138
msgid "page.password_reset.email.body"
msgstr ""
"Du kannst Dein Fietsboek-Passwort hier zurücksetzen: {}\n"
@@ -588,15 +609,15 @@ msgstr ""
"Falls Du keine Passwortzurücksetzung beantragt hast, dann ignoriere diese"
" E-Mail."
-#: fietsboek/views/default.py:166
+#: fietsboek/views/default.py:165
msgid "flash.email_verified"
msgstr "E-Mail-Adresse bestätigt"
-#: fietsboek/views/default.py:180
+#: fietsboek/views/default.py:179
msgid "flash.password_updated"
msgstr "Passwort aktualisiert"
-#: fietsboek/views/detail.py:71
+#: fietsboek/views/detail.py:88
msgid "flash.track_deleted"
msgstr "Strecke gelöscht"
@@ -604,19 +625,19 @@ msgstr "Strecke gelöscht"
msgid "flash.personal_data_updated"
msgstr "Persönliche Daten wurden gespeichert"
-#: fietsboek/views/profile.py:78
+#: fietsboek/views/profile.py:77
msgid "flash.friend_not_found"
msgstr "Das angegebene Konto wurde nicht gefunden"
-#: fietsboek/views/profile.py:83
+#: fietsboek/views/profile.py:82
msgid "flash.friend_already_exists"
msgstr "Dieser Freund existiert bereits"
-#: fietsboek/views/profile.py:91
+#: fietsboek/views/profile.py:90
msgid "flash.friend_added"
msgstr "Freund hinzugefügt"
-#: fietsboek/views/profile.py:101
+#: fietsboek/views/profile.py:100
msgid "flash.friend_request_sent"
msgstr "Freundschaftsanfrage gesendet"
@@ -628,11 +649,11 @@ msgstr "Keine Datei ausgewählt"
msgid "flash.invalid_file"
msgstr "Ungültige GPX-Datei gesendet"
-#: fietsboek/views/upload.py:172
+#: fietsboek/views/upload.py:178
msgid "flash.upload_success"
msgstr "Hochladen erfolgreich"
-#: fietsboek/views/upload.py:187
+#: fietsboek/views/upload.py:194
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 127794c..8261149 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 723c12c..c19184c 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: 2022-07-11 14:34+0200\n"
+"POT-Creation-Date: 2022-07-21 23:49+0200\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.10.3\n"
-#: fietsboek/util.py:262
+#: fietsboek/util.py:273
msgid "password_constraint.mismatch"
msgstr "Passwords don't match"
-#: fietsboek/util.py:264
+#: fietsboek/util.py:275
msgid "password_constraint.length"
msgstr "Password not long enough"
-#: fietsboek/models/track.py:494
+#: fietsboek/models/track.py:498
msgid "tooltip.table.length"
msgstr "Length"
-#: fietsboek/models/track.py:495
+#: fietsboek/models/track.py:499
msgid "tooltip.table.uphill"
msgstr "Uphill"
-#: fietsboek/models/track.py:496
+#: fietsboek/models/track.py:500
msgid "tooltip.table.downhill"
msgstr "Downhill"
-#: fietsboek/models/track.py:497
+#: fietsboek/models/track.py:501
msgid "tooltip.table.moving_time"
msgstr "Moving Time"
-#: fietsboek/models/track.py:498
+#: fietsboek/models/track.py:502
msgid "tooltip.table.stopped_time"
msgstr "Stopped Time"
-#: fietsboek/models/track.py:499
+#: fietsboek/models/track.py:503
msgid "tooltip.table.max_speed"
msgstr "Max Speed"
-#: fietsboek/models/track.py:501
+#: fietsboek/models/track.py:505
msgid "tooltip.table.avg_speed"
msgstr "Average Speed"
@@ -86,47 +86,51 @@ msgstr "Add Badge"
msgid "page.browse.title"
msgstr "Browse"
-#: fietsboek/templates/browse.jinja2:17 fietsboek/templates/details.jinja2:86
+#: fietsboek/templates/browse.jinja2:19 fietsboek/templates/details.jinja2:86
msgid "page.details.date"
msgstr "Date"
-#: fietsboek/templates/browse.jinja2:19 fietsboek/templates/details.jinja2:98
+#: fietsboek/templates/browse.jinja2:21 fietsboek/templates/details.jinja2:98
msgid "page.details.length"
msgstr "Length"
-#: fietsboek/templates/browse.jinja2:23 fietsboek/templates/details.jinja2:90
+#: fietsboek/templates/browse.jinja2:25 fietsboek/templates/details.jinja2:90
msgid "page.details.start_time"
msgstr "Record Start"
-#: fietsboek/templates/browse.jinja2:25 fietsboek/templates/details.jinja2:94
+#: fietsboek/templates/browse.jinja2:27 fietsboek/templates/details.jinja2:94
msgid "page.details.end_time"
msgstr "Record End"
-#: fietsboek/templates/browse.jinja2:29 fietsboek/templates/details.jinja2:102
+#: fietsboek/templates/browse.jinja2:31 fietsboek/templates/details.jinja2:102
msgid "page.details.uphill"
msgstr "Uphill"
-#: fietsboek/templates/browse.jinja2:31 fietsboek/templates/details.jinja2:106
+#: fietsboek/templates/browse.jinja2:33 fietsboek/templates/details.jinja2:106
msgid "page.details.downhill"
msgstr "Downhill"
-#: fietsboek/templates/browse.jinja2:35 fietsboek/templates/details.jinja2:110
+#: fietsboek/templates/browse.jinja2:37 fietsboek/templates/details.jinja2:110
msgid "page.details.moving_time"
msgstr "Moving Time"
-#: fietsboek/templates/browse.jinja2:37 fietsboek/templates/details.jinja2:114
+#: fietsboek/templates/browse.jinja2:39 fietsboek/templates/details.jinja2:114
msgid "page.details.stopped_time"
msgstr "Stopped Time"
-#: fietsboek/templates/browse.jinja2:41 fietsboek/templates/details.jinja2:118
+#: fietsboek/templates/browse.jinja2:43 fietsboek/templates/details.jinja2:118
msgid "page.details.max_speed"
msgstr "Max Speed"
-#: fietsboek/templates/browse.jinja2:43 fietsboek/templates/details.jinja2:122
+#: fietsboek/templates/browse.jinja2:45 fietsboek/templates/details.jinja2:122
msgid "page.details.avg_speed"
msgstr "Average Speed"
-#: fietsboek/templates/browse.jinja2:59
+#: fietsboek/templates/browse.jinja2:60
+msgid "page.browse.download_multiple"
+msgstr "Download selected"
+
+#: fietsboek/templates/browse.jinja2:62
msgid "page.browse.no_tracks"
msgstr "You currently do not have access to any tracks. Try logging in."
@@ -231,27 +235,27 @@ msgstr "JavaScript is disabled, please enable JavaScript"
msgid "page.details.download"
msgstr "Download Tour"
-#: fietsboek/templates/details.jinja2:140
+#: fietsboek/templates/details.jinja2:166
msgid "page.details.comments"
msgstr "Comments"
-#: fietsboek/templates/details.jinja2:144
+#: fietsboek/templates/details.jinja2:170
msgid "page.details.comments.author"
msgstr "Comment by {}"
-#: fietsboek/templates/details.jinja2:161
+#: fietsboek/templates/details.jinja2:187
msgid "page.details.comments.new.title"
msgstr "Create a new comment"
-#: fietsboek/templates/details.jinja2:164
+#: fietsboek/templates/details.jinja2:190
msgid "page.details.comments.new.input_title"
msgstr "Title"
-#: fietsboek/templates/details.jinja2:165
+#: fietsboek/templates/details.jinja2:191
msgid "page.details.comments.new.input_comment"
msgstr "Comment"
-#: fietsboek/templates/details.jinja2:168
+#: fietsboek/templates/details.jinja2:194
msgid "page.details.comments.new.submit"
msgstr "Submit"
@@ -329,6 +333,23 @@ msgstr "Badges"
msgid "page.track.form.description"
msgstr "Description"
+#: fietsboek/templates/edit_form.jinja2:94
+#: fietsboek/templates/edit_form.jinja2:108
+msgid "page.track.form.remove_image"
+msgstr "Remove image"
+
+#: fietsboek/templates/edit_form.jinja2:103
+msgid "page.track.form.select_images"
+msgstr "Select images"
+
+#: fietsboek/templates/edit_form.jinja2:119
+msgid "page.track.form.image_description_modal"
+msgstr "Image description"
+
+#: fietsboek/templates/edit_form.jinja2:126
+msgid "page.track.form.image_description_modal.save"
+msgstr "Apply"
+
#: fietsboek/templates/finish_upload.jinja2:8
#: fietsboek/templates/upload.jinja2:6
msgid "page.upload.title"
@@ -576,22 +597,22 @@ msgstr "A password reset email has been sent"
msgid "page.password_reset.email.subject"
msgstr "Fietsboek Password Reset"
-#: fietsboek/views/default.py:139
+#: fietsboek/views/default.py:138
msgid "page.password_reset.email.body"
msgstr ""
"You can reset your Fietsboek password here: {}\n"
"\n"
"If you did not request a password reset, ignore this email."
-#: fietsboek/views/default.py:166
+#: fietsboek/views/default.py:165
msgid "flash.email_verified"
msgstr "Your email address has been verified"
-#: fietsboek/views/default.py:180
+#: fietsboek/views/default.py:179
msgid "flash.password_updated"
msgstr "Password has been updated"
-#: fietsboek/views/detail.py:71
+#: fietsboek/views/detail.py:88
msgid "flash.track_deleted"
msgstr "Track has been deleted"
@@ -599,19 +620,19 @@ msgstr "Track has been deleted"
msgid "flash.personal_data_updated"
msgstr "Personal data has been updated"
-#: fietsboek/views/profile.py:78
+#: fietsboek/views/profile.py:77
msgid "flash.friend_not_found"
msgstr "The friend was not found"
-#: fietsboek/views/profile.py:83
+#: fietsboek/views/profile.py:82
msgid "flash.friend_already_exists"
msgstr "Friend already exists"
-#: fietsboek/views/profile.py:91
+#: fietsboek/views/profile.py:90
msgid "flash.friend_added"
msgstr "Friend has been added"
-#: fietsboek/views/profile.py:101
+#: fietsboek/views/profile.py:100
msgid "flash.friend_request_sent"
msgstr "Friend request sent"
@@ -623,11 +644,11 @@ msgstr "No file selected"
msgid "flash.invalid_file"
msgstr "Invalid GPX file selected"
-#: fietsboek/views/upload.py:172
+#: fietsboek/views/upload.py:178
msgid "flash.upload_success"
msgstr "Upload successful"
-#: fietsboek/views/upload.py:187
+#: fietsboek/views/upload.py:194
msgid "flash.upload_cancelled"
msgstr "Upload cancelled"
diff --git a/fietsboek/locale/fietslog.pot b/fietsboek/locale/fietslog.pot
index b3a5837..a30c79c 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: 2022-07-11 14:34+0200\n"
+"POT-Creation-Date: 2022-07-21 23:49+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.10.3\n"
-#: fietsboek/util.py:262
+#: fietsboek/util.py:273
msgid "password_constraint.mismatch"
msgstr ""
-#: fietsboek/util.py:264
+#: fietsboek/util.py:275
msgid "password_constraint.length"
msgstr ""
-#: fietsboek/models/track.py:494
+#: fietsboek/models/track.py:498
msgid "tooltip.table.length"
msgstr ""
-#: fietsboek/models/track.py:495
+#: fietsboek/models/track.py:499
msgid "tooltip.table.uphill"
msgstr ""
-#: fietsboek/models/track.py:496
+#: fietsboek/models/track.py:500
msgid "tooltip.table.downhill"
msgstr ""
-#: fietsboek/models/track.py:497
+#: fietsboek/models/track.py:501
msgid "tooltip.table.moving_time"
msgstr ""
-#: fietsboek/models/track.py:498
+#: fietsboek/models/track.py:502
msgid "tooltip.table.stopped_time"
msgstr ""
-#: fietsboek/models/track.py:499
+#: fietsboek/models/track.py:503
msgid "tooltip.table.max_speed"
msgstr ""
-#: fietsboek/models/track.py:501
+#: fietsboek/models/track.py:505
msgid "tooltip.table.avg_speed"
msgstr ""
@@ -85,47 +85,51 @@ msgstr ""
msgid "page.browse.title"
msgstr ""
-#: fietsboek/templates/browse.jinja2:17 fietsboek/templates/details.jinja2:86
+#: fietsboek/templates/browse.jinja2:19 fietsboek/templates/details.jinja2:86
msgid "page.details.date"
msgstr ""
-#: fietsboek/templates/browse.jinja2:19 fietsboek/templates/details.jinja2:98
+#: fietsboek/templates/browse.jinja2:21 fietsboek/templates/details.jinja2:98
msgid "page.details.length"
msgstr ""
-#: fietsboek/templates/browse.jinja2:23 fietsboek/templates/details.jinja2:90
+#: fietsboek/templates/browse.jinja2:25 fietsboek/templates/details.jinja2:90
msgid "page.details.start_time"
msgstr ""
-#: fietsboek/templates/browse.jinja2:25 fietsboek/templates/details.jinja2:94
+#: fietsboek/templates/browse.jinja2:27 fietsboek/templates/details.jinja2:94
msgid "page.details.end_time"
msgstr ""
-#: fietsboek/templates/browse.jinja2:29 fietsboek/templates/details.jinja2:102
+#: fietsboek/templates/browse.jinja2:31 fietsboek/templates/details.jinja2:102
msgid "page.details.uphill"
msgstr ""
-#: fietsboek/templates/browse.jinja2:31 fietsboek/templates/details.jinja2:106
+#: fietsboek/templates/browse.jinja2:33 fietsboek/templates/details.jinja2:106
msgid "page.details.downhill"
msgstr ""
-#: fietsboek/templates/browse.jinja2:35 fietsboek/templates/details.jinja2:110
+#: fietsboek/templates/browse.jinja2:37 fietsboek/templates/details.jinja2:110
msgid "page.details.moving_time"
msgstr ""
-#: fietsboek/templates/browse.jinja2:37 fietsboek/templates/details.jinja2:114
+#: fietsboek/templates/browse.jinja2:39 fietsboek/templates/details.jinja2:114
msgid "page.details.stopped_time"
msgstr ""
-#: fietsboek/templates/browse.jinja2:41 fietsboek/templates/details.jinja2:118
+#: fietsboek/templates/browse.jinja2:43 fietsboek/templates/details.jinja2:118
msgid "page.details.max_speed"
msgstr ""
-#: fietsboek/templates/browse.jinja2:43 fietsboek/templates/details.jinja2:122
+#: fietsboek/templates/browse.jinja2:45 fietsboek/templates/details.jinja2:122
msgid "page.details.avg_speed"
msgstr ""
-#: fietsboek/templates/browse.jinja2:59
+#: fietsboek/templates/browse.jinja2:60
+msgid "page.browse.download_multiple"
+msgstr ""
+
+#: fietsboek/templates/browse.jinja2:62
msgid "page.browse.no_tracks"
msgstr ""
@@ -230,27 +234,27 @@ msgstr ""
msgid "page.details.download"
msgstr ""
-#: fietsboek/templates/details.jinja2:140
+#: fietsboek/templates/details.jinja2:166
msgid "page.details.comments"
msgstr ""
-#: fietsboek/templates/details.jinja2:144
+#: fietsboek/templates/details.jinja2:170
msgid "page.details.comments.author"
msgstr ""
-#: fietsboek/templates/details.jinja2:161
+#: fietsboek/templates/details.jinja2:187
msgid "page.details.comments.new.title"
msgstr ""
-#: fietsboek/templates/details.jinja2:164
+#: fietsboek/templates/details.jinja2:190
msgid "page.details.comments.new.input_title"
msgstr ""
-#: fietsboek/templates/details.jinja2:165
+#: fietsboek/templates/details.jinja2:191
msgid "page.details.comments.new.input_comment"
msgstr ""
-#: fietsboek/templates/details.jinja2:168
+#: fietsboek/templates/details.jinja2:194
msgid "page.details.comments.new.submit"
msgstr ""
@@ -326,6 +330,23 @@ msgstr ""
msgid "page.track.form.description"
msgstr ""
+#: fietsboek/templates/edit_form.jinja2:94
+#: fietsboek/templates/edit_form.jinja2:108
+msgid "page.track.form.remove_image"
+msgstr ""
+
+#: fietsboek/templates/edit_form.jinja2:103
+msgid "page.track.form.select_images"
+msgstr ""
+
+#: fietsboek/templates/edit_form.jinja2:119
+msgid "page.track.form.image_description_modal"
+msgstr ""
+
+#: fietsboek/templates/edit_form.jinja2:126
+msgid "page.track.form.image_description_modal.save"
+msgstr ""
+
#: fietsboek/templates/finish_upload.jinja2:8
#: fietsboek/templates/upload.jinja2:6
msgid "page.upload.title"
@@ -568,19 +589,19 @@ msgstr ""
msgid "page.password_reset.email.subject"
msgstr ""
-#: fietsboek/views/default.py:139
+#: fietsboek/views/default.py:138
msgid "page.password_reset.email.body"
msgstr ""
-#: fietsboek/views/default.py:166
+#: fietsboek/views/default.py:165
msgid "flash.email_verified"
msgstr ""
-#: fietsboek/views/default.py:180
+#: fietsboek/views/default.py:179
msgid "flash.password_updated"
msgstr ""
-#: fietsboek/views/detail.py:71
+#: fietsboek/views/detail.py:88
msgid "flash.track_deleted"
msgstr ""
@@ -588,19 +609,19 @@ msgstr ""
msgid "flash.personal_data_updated"
msgstr ""
-#: fietsboek/views/profile.py:78
+#: fietsboek/views/profile.py:77
msgid "flash.friend_not_found"
msgstr ""
-#: fietsboek/views/profile.py:83
+#: fietsboek/views/profile.py:82
msgid "flash.friend_already_exists"
msgstr ""
-#: fietsboek/views/profile.py:91
+#: fietsboek/views/profile.py:90
msgid "flash.friend_added"
msgstr ""
-#: fietsboek/views/profile.py:101
+#: fietsboek/views/profile.py:100
msgid "flash.friend_request_sent"
msgstr ""
@@ -612,11 +633,11 @@ msgstr ""
msgid "flash.invalid_file"
msgstr ""
-#: fietsboek/views/upload.py:172
+#: fietsboek/views/upload.py:178
msgid "flash.upload_success"
msgstr ""
-#: fietsboek/views/upload.py:187
+#: fietsboek/views/upload.py:194
msgid "flash.upload_cancelled"
msgstr ""
diff --git a/fietsboek/models/__init__.py b/fietsboek/models/__init__.py
index 02d6257..53feb22 100644
--- a/fietsboek/models/__init__.py
+++ b/fietsboek/models/__init__.py
@@ -14,6 +14,7 @@ from .user import User, FriendRequest, Token # flake8: noqa
from .badge import Badge # flake8: noqa
from .track import Tag, Track, TrackCache, Upload # flake8: noqa
from .comment import Comment # flake8: noqa
+from .image import ImageMetadata # flake8: noqa
# Run ``configure_mappers`` after defining all of the models to ensure
# all relationships can be setup.
diff --git a/fietsboek/models/image.py b/fietsboek/models/image.py
new file mode 100644
index 0000000..4037619
--- /dev/null
+++ b/fietsboek/models/image.py
@@ -0,0 +1,62 @@
+"""Image metadata.
+
+The actual image data is saved on disk, we only store the metadata such as an
+image description here.
+"""
+from sqlalchemy import (
+ Column,
+ Integer,
+ ForeignKey,
+ Text,
+ UniqueConstraint,
+ select,
+)
+from sqlalchemy.orm import relationship
+
+from .meta import Base
+
+
+class ImageMetadata(Base):
+ """Represents metadata for an uploaded image.
+
+ :ivar id: Database ID.
+ :vartype id: int
+ :ivar track_id: ID of the track that this image belongs to.
+ :vartype track_id: int
+ :ivar image_name: Name of the image file.
+ :vartype image_name: str
+ :ivar description: The description that should be shown.
+ :vartype description: str
+ :ivar track: The track that this image belongs to.
+ :vartype track: fietsboek.models.track.Track
+ """
+ # pylint: disable=too-few-public-methods
+ __tablename__ = "image_metadata"
+ id = Column(Integer, primary_key=True)
+ track_id = Column(Integer, ForeignKey("tracks.id"), nullable=False)
+ image_name = Column(Text, nullable=False)
+ description = Column(Text)
+
+ track = relationship('Track', back_populates='images')
+
+ __table_args__ = (UniqueConstraint('track_id', 'image_name'),)
+
+ @classmethod
+ def get_or_create(cls, dbsession, track, image_name):
+ """Retrieves the image metadata for the given image, or creates a new
+ one if it doesn't exist.
+
+ :param dbsession: The database session.
+ :type dbsession: sqlalchemy.orm.session.Session
+ :param track: The track.
+ :type track: fietsboek.models.track.Track
+ :param image_name: The name of the image.
+ :type image_name: str
+ :return: The created/retrieved object.
+ :rtype: ImageMetadata
+ """
+ query = select(cls).filter_by(track=track, image_name=image_name)
+ result = dbsession.execute(query).scalar_one_or_none()
+ if result:
+ return result
+ return cls(track=track, image_name=image_name)
diff --git a/fietsboek/models/track.py b/fietsboek/models/track.py
index 95344d9..83c725f 100644
--- a/fietsboek/models/track.py
+++ b/fietsboek/models/track.py
@@ -160,6 +160,8 @@ class Track(Base):
:vartype badges: list[fietsboek.models.badge.Badge]
:ivar comments: Comments left on this track.
:vartype comments: list[fietsboek.models.comment.Comment]
+ :ivar images: Metadata of the images saved for this track.
+ :vartype images: list[fietsboek.models.image.ImageMetadata]
"""
__tablename__ = 'tracks'
id = Column(Integer, primary_key=True)
@@ -180,6 +182,7 @@ class Track(Base):
badges = relationship('Badge', secondary=track_badge_assoc, back_populates='tracks')
tags = relationship('Tag', back_populates='track', cascade="all, delete-orphan")
comments = relationship('Comment', back_populates='track', cascade="all, delete-orphan")
+ images = relationship('ImageMetadata', back_populates='track', cascade="all, delete-orphan")
@classmethod
def factory(cls, request):
diff --git a/fietsboek/routes.py b/fietsboek/routes.py
index a28f926..9d705c1 100644
--- a/fietsboek/routes.py
+++ b/fietsboek/routes.py
@@ -35,6 +35,8 @@ def includeme(config):
factory='fietsboek.models.Track.factory')
config.add_route('add-comment', '/track/{track_id}/comment',
factory='fietsboek.models.Track.factory')
+ config.add_route('image', '/track/{track_id}/images/{image_name}',
+ factory='fietsboek.models.Track.factory')
config.add_route('badge', '/badge/{badge_id}',
factory='fietsboek.models.Badge.factory')
diff --git a/fietsboek/static/fietsboek.js b/fietsboek/static/fietsboek.js
index 515f45e..c073e79 100644
--- a/fietsboek/static/fietsboek.js
+++ b/fietsboek/static/fietsboek.js
@@ -69,7 +69,75 @@ function removeFriendClicked(event) {
button.parentNode.parentNode.removeChild(button.parentNode);
}
+function imageSelectorChanged(event) {
+ for (var file of event.target.files) {
+ window.fietsboek_image_index++;
+
+ const input = document.createElement("input");
+ input.type = "file";
+ input.hidden = true;
+ input.name = `image[${window.fietsboek_image_index}]`;
+
+ const transfer = new DataTransfer();
+ transfer.items.add(file);
+ input.files = transfer.files;
+
+ let preview = document.querySelector("#trackImagePreviewBlueprint").cloneNode(true);
+ preview.removeAttribute("id");
+ preview.querySelector("img").src = URL.createObjectURL(file);
+ preview.querySelector("button.delete-image").addEventListener("click", deleteImageButtonClicked);
+ preview.querySelector("button.edit-image-description").addEventListener("click", editImageDescriptionClicked);
+ preview.querySelector("input.image-description-input").name = `image-description[${window.fietsboek_image_index}]`;
+ preview.appendChild(input);
+
+ document.querySelector("#trackImageList").appendChild(preview);
+ }
+
+ event.target.value = "";
+}
+
+function deleteImageButtonClicked(event) {
+ let preview = event.target.closest("div.track-image-preview");
+ /* If this was a image yet to be uploaded, simply remove it */
+ let input = preview.querySelector("input[type=file]");
+ if (input) {
+ preview.parentNode.removeChild(preview);
+ return;
+ }
+
+ /* Otherwise, we need to remove it but also insert a "delete-image" input */
+ let deleter = preview.querySelector("input.image-deleter-input");
+ deleter.removeAttribute("disabled");
+ preview.removeChild(deleter);
+ preview.parentNode.appendChild(deleter);
+ preview.parentNode.removeChild(preview);
+}
+
+function editImageDescriptionClicked(event) {
+ window.fietsboekCurrentImage = event.target.closest("div");
+
+ let currentDescription = event.target.closest("div").querySelector("input.image-description-input").value;
+ let modalDom = document.getElementById("imageDescriptionModal");
+ modalDom.querySelector("textarea").value = currentDescription;
+
+ let modal = bootstrap.Modal.getOrCreateInstance(modalDom, {});
+ modal.show();
+}
+
+function saveImageDescriptionClicked(event) {
+ let modalDom = document.getElementById("imageDescriptionModal");
+ let wantedDescription = modalDom.querySelector("textarea").value;
+ window.fietsboekCurrentImage.querySelector("input.image-description-input").value = wantedDescription;
+ window.fietsboekCurrentImage.querySelector("img").title = wantedDescription;
+
+ let modal = bootstrap.Modal.getOrCreateInstance(modalDom, {});
+ modal.hide();
+
+ window.fietsboekCurrentImage = undefined;
+}
+
document.addEventListener('DOMContentLoaded', function(event) {
+ window.fietsboek_image_index = 0;
/* Enable the "Add tag" button in the track edit page */
let $ = (selector) => document.querySelector(selector);
var button = $("#add-tag-btn");
@@ -158,4 +226,23 @@ document.addEventListener('DOMContentLoaded', function(event) {
let intl = new Intl.DateTimeFormat(LOCALE, {dateStyle: "medium", timeStyle: "medium"});
obj.innerHTML = intl.format(date);
});
+
+ /* Enable the image selector */
+ var button = $("#imageSelector");
+ if (button) {
+ button.addEventListener("change", imageSelectorChanged);
+ }
+
+ /* Enable the "delete image" buttons for the already existing images */
+ document.querySelectorAll("button.delete-image").forEach((b) => {
+ b.addEventListener("click", deleteImageButtonClicked);
+ });
+
+ /* Enable the "edit image description" buttons */
+ document.querySelectorAll("button.edit-image-description").forEach((b) => {
+ b.addEventListener("click", editImageDescriptionClicked);
+ });
+ document.querySelectorAll("#imageDescriptionModal button.btn-success").forEach((b) => {
+ b.addEventListener("click", saveImageDescriptionClicked);
+ });
});
diff --git a/fietsboek/static/theme.css b/fietsboek/static/theme.css
index 831b1f6..01ee06e 100644
--- a/fietsboek/static/theme.css
+++ b/fietsboek/static/theme.css
@@ -18,6 +18,65 @@ strong {
align-items: center;
}
+.carousel-item img {
+ max-height: 700px;
+ margin: auto;
+}
+
+.track-image-caption {
+ text-align: center;
+}
+
+#trackImageList {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.track-image-preview .delete-image {
+ position: absolute;
+ z-index: 5;
+ background-color: white;
+ right: 0px;
+}
+
+.track-image-preview .edit-image-description {
+ position: absolute;
+ z-index: 5;
+ right: 0px;
+ top: 2em;
+ width: 1em;
+ height: 1em;
+ border-radius: 0.375em;
+ border: 0;
+ box-sizing: content-box;
+ color: #000;
+ opacity: 0.5;
+ padding: 0.25em 0.25em;
+}
+
+.track-image-preview .edit-image-description i {
+ position: relative;
+ top: -0.25em;
+}
+
+.track-image-preview img {
+ max-width: 100%;
+ max-height: 100%;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+}
+
+.track-image-preview {
+ position: relative;
+ width: min(317px, 100%);
+ height: calc(9 / 16 * 317px);
+ border: 1px solid grey;
+ margin-right: 5px;
+ margin-bottom: 5px;
+}
+
.track-description img {
max-width: 100%;
}
diff --git a/fietsboek/templates/details.jinja2 b/fietsboek/templates/details.jinja2
index e22efbc..55a5c26 100644
--- a/fietsboek/templates/details.jinja2
+++ b/fietsboek/templates/details.jinja2
@@ -137,6 +137,32 @@
</div>
<hr>
{% endif %}
+ {% if images %}
+ <div id="trackImageShowcase" class="carousel carousel-dark slide" data-bs-ride="carousel">
+ <div class="carousel-inner">
+ {% for image, description in images %}
+ <div class="carousel-item{% if loop.first %} active{% endif %}">
+ <a href="{{ image }}" target="_blank">
+ <img src="{{ image }}" class="d-block">
+ </a>
+ {% if description %}
+ <div class="track-image-caption">
+ <p>{{ description }}</p>
+ </div>
+ {% endif %}
+ </div>
+ {% endfor %}
+ </div>
+ <button class="carousel-control-prev" type="button" data-bs-target="#trackImageShowcase" data-bs-slide="prev">
+ <span class="carousel-control-prev-icon" aria-hidden="true"></span>
+ <span class="visually-hidden">Previous</span>
+ </button>
+ <button class="carousel-control-next" type="button" data-bs-target="#trackImageShowcase" data-bs-slide="next">
+ <span class="carousel-control-next-icon" aria-hidden="true"></span>
+ <span class="visually-hidden">Next</span>
+ </button>
+ </div>
+ {% endif %}
<h2>{{ _("page.details.comments") }}</h2>
{% for comment in track.comments %}
<div class="card mb-3">
diff --git a/fietsboek/templates/edit.jinja2 b/fietsboek/templates/edit.jinja2
index 6053509..4e64f69 100644
--- a/fietsboek/templates/edit.jinja2
+++ b/fietsboek/templates/edit.jinja2
@@ -9,8 +9,8 @@
<div id="mainmap" class="gpxview:{{ request.route_path('gpx', track_id=track.id) }}:OSM" style="width:100%;height:600px">
<noscript><p>{{ _("page.noscript") }}<p></noscript>
</div>
- <form method="POST">
- {{ edit_form.edit_track(track.title, track.date_raw, track.date_tz or 0, track.visibility, track.description, track.text_tags(), badges, track.tagged_people) }}
+ <form method="POST" enctype="multipart/form-data">
+ {{ edit_form.edit_track(track.title, track.date_raw, track.date_tz or 0, track.visibility, track.description, track.text_tags(), badges, track.tagged_people, images) }}
{{ util.hidden_csrf_input() }}
<div class="btn-group" role="group">
<button type="submit" class="btn btn-primary"><i class="bi bi-save"></i> {{ _("page.edit.form.submit") }}</button>
diff --git a/fietsboek/templates/edit_form.jinja2 b/fietsboek/templates/edit_form.jinja2
index 3779d5d..c4a478c 100644
--- a/fietsboek/templates/edit_form.jinja2
+++ b/fietsboek/templates/edit_form.jinja2
@@ -1,4 +1,4 @@
-{% macro edit_track(title, date, date_tz, visibility, description, tags, badges, friends) %}
+{% macro edit_track(title, date, date_tz, visibility, description, tags, badges, friends, images) %}
<div class="mb-3">
<label for="formTitle" class="form-label">{{ _("page.track.form.title") }}</label>
<input class="form-control" type="text" id="formTitle" name="title" value="{{ title | default("", true) }}">
@@ -87,4 +87,45 @@
<label for="formDesc" class="form-label">{{ _("page.track.form.description") }}</label>
<textarea class="form-control" id="formDesc" name="description" rows="5">{{ description | default("", true) }}</textarea>
</div>
+<div class="mb-3">
+ <div id="trackImageList">
+ {% for image in images %}
+ <div class="track-image-preview">
+ <button type="button" class="btn-close delete-image" aria-label="{{ _("page.track.form.remove_image") }}"></button>
+ <button type="button" class="edit-image-description"><i class="bi bi-cursor-text"></i></button>
+ <input type="hidden" class="image-description-input" name="image-description[{{ image.name }}]" value="{{ image.description }}">
+ <input type="hidden" class="image-deleter-input" name="delete-image[]" value="{{ image.name }}" disabled>
+ <img src="{{ image.url }}" title="{{ image.description }}">
+ </div>
+ {% endfor %}
+ </div>
+ <input type="file" name="image[]" id="imageSelector" class="form-control" accept="image/*" style="display:none;" multiple>
+ <button type="button" onclick="document.querySelector('#imageSelector').click()" class="btn btn-primary"><i class="bi bi-images"></i> {{ _("page.track.form.select_images") }}</button>
+</div>
+<!-- Mode hidden templates -->
+<div style="display:none;">
+ <div id="trackImagePreviewBlueprint" class="track-image-preview">
+ <button type="button" class="btn-close delete-image" aria-label="{{ _("page.track.form.remove_image") }}"></button>
+ <button type="button" class="edit-image-description"><i class="bi bi-cursor-text"></i></button>
+ <input type="hidden" class="image-description-input" name="image-description[]" value="">
+ <img>
+ </div>
+</div>
+<!-- Edit image description modal -->
+<div class="modal" tabindex="-1" id="imageDescriptionModal">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">{{ _("page.track.form.image_description_modal") }}</h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div>
+ <div class="modal-body">
+ <textarea class="form-control"></textarea>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-success">{{ _("page.track.form.image_description_modal.save") }}</button>
+ </div>
+ </div>
+ </div>
+</div>
{% endmacro %}
diff --git a/fietsboek/templates/finish_upload.jinja2 b/fietsboek/templates/finish_upload.jinja2
index 58c67d4..d6f9f00 100644
--- a/fietsboek/templates/finish_upload.jinja2
+++ b/fietsboek/templates/finish_upload.jinja2
@@ -9,8 +9,8 @@
<div id="mainmap" class="gpxview:{{ request.route_path('preview', upload_id=preview_id) }}:OSM" style="width:100%;height:600px">
<noscript><p>{{ _("page.noscript") }}<p></noscript>
</div>
- <form method="POST">
- {{ edit_form.edit_track(upload_title, upload_date, upload_date_tz, upload_visibility, upload_description, upload_tags, badges, upload_tagged_people) }}
+ <form method="POST" enctype="multipart/form-data">
+ {{ edit_form.edit_track(upload_title, upload_date, upload_date_tz, upload_visibility, upload_description, upload_tags, badges, upload_tagged_people, []) }}
{{ util.hidden_csrf_input() }}
<div class="btn-group" role="group">
<button type="submit" class="btn btn-primary">{{ _("page.upload.form.submit") }}</button>
diff --git a/fietsboek/util.py b/fietsboek/util.py
index 1014c87..bab2074 100644
--- a/fietsboek/util.py
+++ b/fietsboek/util.py
@@ -2,6 +2,9 @@
import random
import string
import datetime
+import re
+import os
+import unicodedata
# Compat for Python < 3.9
import importlib_resources
@@ -25,6 +28,22 @@ ALLOWED_ATTRIBUTES = dict(bleach.sanitizer.ALLOWED_ATTRIBUTES)
ALLOWED_ATTRIBUTES['img'] = ['alt', 'src']
+_filename_ascii_strip_re = re.compile(r"[^A-Za-z0-9_.-]")
+_windows_device_files = (
+ "CON",
+ "AUX",
+ "COM1",
+ "COM2",
+ "COM3",
+ "COM4",
+ "LPT1",
+ "LPT2",
+ "LPT3",
+ "PRN",
+ "NUL",
+)
+
+
def safe_markdown(md_source):
"""Transform a markdown document into a safe HTML document.
@@ -289,3 +308,53 @@ def read_localized_resource(locale_name, path, raise_on_error=False):
if raise_on_error:
raise FileNotFoundError(f"Resource {path!r} not found")
return f"{locale_name}:{path}"
+
+
+def secure_filename(filename):
+ 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
+ to :func:`os.path.join`. The filename returned is an ASCII only string
+ for maximum portability.
+ On windows systems the function also makes sure that the file is not
+ named after one of the special device files.
+
+ >>> secure_filename("My cool movie.mov")
+ 'My_cool_movie.mov'
+ >>> secure_filename("../../../etc/passwd")
+ 'etc_passwd'
+ >>> secure_filename('i contain cool \xfcml\xe4uts.txt')
+ 'i_contain_cool_umlauts.txt'
+
+ The function might return an empty filename. It's your responsibility
+ to ensure that the filename is unique and that you abort or
+ generate a random filename if the function returned an empty one.
+
+ :param filename: the filename to secure
+ :type filename: str
+ :return: The secure filename.
+ :rtype: str
+ """
+ # Taken from
+ # https://github.com/pallets/werkzeug/blob/main/src/werkzeug/utils.py
+
+ filename = unicodedata.normalize("NFKD", filename)
+ filename = filename.encode("ascii", "ignore").decode("ascii")
+
+ for sep in os.path.sep, os.path.altsep:
+ if sep:
+ filename = filename.replace(sep, " ")
+ filename = str(_filename_ascii_strip_re.sub("", "_".join(filename.split()))).strip(
+ "._"
+ )
+
+ # on nt a couple of special files are present in each folder. We
+ # have to ensure that the target file is not such a filename. In
+ # this case we prepend an underline
+ if (
+ os.name == "nt"
+ and filename
+ and filename.split(".", maxsplit=1)[0].upper() in _windows_device_files
+ ):
+ filename = f"_{filename}"
+
+ return filename
diff --git a/fietsboek/views/detail.py b/fietsboek/views/detail.py
index 163a3c1..34d392b 100644
--- a/fietsboek/views/detail.py
+++ b/fietsboek/views/detail.py
@@ -2,9 +2,11 @@
import datetime
from pyramid.view import view_config
-from pyramid.response import Response
+from pyramid.response import Response, FileResponse
from pyramid.i18n import TranslationString as _
-from pyramid.httpexceptions import HTTPFound
+from pyramid.httpexceptions import HTTPFound, HTTPNotFound
+
+from sqlalchemy import select
from .. import models, util
@@ -22,12 +24,24 @@ def details(request):
track = request.context
description = util.safe_markdown(track.description)
show_edit_link = (track.owner == request.identity)
+
+ images = []
+ for image_name in request.data_manager.images(track.id):
+ img_src = request.route_url("image", track_id=track.id, image_name=image_name)
+ query = select(models.ImageMetadata).filter_by(track=track, image_name=image_name)
+ image_metadata = request.dbsession.execute(query).scalar_one_or_none()
+ if image_metadata:
+ images.append((img_src, image_metadata.description))
+ else:
+ images.append((img_src, ""))
+
return {
'track': track,
'show_edit_link': show_edit_link,
'mps_to_kph': util.mps_to_kph,
'comment_md_to_html': util.safe_markdown,
'description': description,
+ 'images': images,
}
@@ -68,7 +82,9 @@ def delete_track(request):
:rtype: pyramid.response.Response
"""
track = request.context
+ track_id = track.id
request.dbsession.delete(track)
+ request.data_manager.purge(track_id)
request.session.flash(request.localizer.translate(_("flash.track_deleted")))
return HTTPFound(request.route_url('home'))
@@ -85,6 +101,27 @@ def badge(request):
return Response(request.context.image)
+@view_config(route_name='image', http_cache=3600, permission='track.view')
+def image(request):
+ """Returns the image data for the requested image.
+
+ This ensures that the image is sent efficiently, by delegating to the WSGI
+ file wrapper if possible.
+
+ :param request: The Pyramid request.
+ :type request: pyramid.request.Request
+ :return: The HTTP response.
+ :rtype: pyramid.response.Response
+ """
+ track = request.context
+ try:
+ image_path = request.data_manager.image_path(track.id, request.matchdict['image_name'])
+ except FileNotFoundError:
+ return HTTPNotFound()
+ else:
+ return FileResponse(image_path, request)
+
+
@view_config(route_name="add-comment", request_method="POST", permission="track.comment")
def add_comment(request):
"""Endpoint to add a comment to a track.
diff --git a/fietsboek/views/edit.py b/fietsboek/views/edit.py
index aabbcbc..2c46527 100644
--- a/fietsboek/views/edit.py
+++ b/fietsboek/views/edit.py
@@ -1,5 +1,8 @@
"""Views for editing a track."""
+import re
+import logging
import datetime
+from collections import namedtuple
from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound, HTTPBadRequest
@@ -10,6 +13,11 @@ from .. import models, util
from ..models.track import Visibility
+ImageEmbed = namedtuple("ImageEmbed", "name url description")
+
+LOGGER = logging.getLogger(__name__)
+
+
@view_config(route_name='edit', renderer='fietsboek:templates/edit.jinja2',
permission='track.edit', request_method='GET')
def edit(request):
@@ -23,9 +31,23 @@ def edit(request):
track = request.context
badges = request.dbsession.execute(select(models.Badge)).scalars()
badges = [(badge in track.badges, badge) for badge in badges]
+
+ images = []
+ for image in request.data_manager.images(track.id):
+ metadata = request.dbsession.execute(
+ select(models.ImageMetadata).filter_by(track=track, image_name=image)
+ ).scalar_one_or_none()
+ if metadata:
+ description = metadata.description
+ else:
+ description = ""
+ img_src = request.route_url("image", track_id=track.id, image_name=image)
+ images.append(ImageEmbed(image, img_src, description))
+
return {
'track': track,
'badges': badges,
+ 'images': images,
}
@@ -61,4 +83,57 @@ def do_edit(request):
tags = request.params.getall("tag[]")
track.sync_tags(tags)
+ _edit_images(request)
+
return HTTPFound(request.route_url('details', track_id=track.id))
+
+
+def _edit_images(request):
+ track = request.context
+
+ # Delete requested images
+ for image in request.params.getall("delete-image[]"):
+ request.data_manager.delete_image(track.id, image)
+ image_meta = request.dbsession.execute(
+ select(models.ImageMetadata).filter_by(track_id=track.id, image_name=image)
+ ).scalar_one_or_none()
+ LOGGER.debug("Deleted image %s %s (metadata: %s)", track.id, image, image_meta)
+ if image_meta:
+ request.dbsession.delete(image_meta)
+
+ # Add new images
+ set_descriptions = set()
+ for param_name, image in request.params.items():
+ match = re.match("image\\[(\\d+)\\]$", param_name)
+ if not match:
+ continue
+ # Sent for the multi input
+ if image == b"":
+ continue
+
+ upload_id = match.group(1)
+ image_name = request.data_manager.add_image(track.id, image.file, image.filename)
+ image_meta = models.ImageMetadata(track=track, image_name=image_name)
+ image_meta.description = request.params.get(f"image-description[{upload_id}]", "")
+ request.dbsession.add(image_meta)
+ LOGGER.debug("Uploaded image %s %s", track.id, image_name)
+ set_descriptions.add(upload_id)
+
+ images = request.data_manager.images(track.id)
+ # Set image descriptions
+ for param_name, description in request.params.items():
+ match = re.match("image-description\\[(.+)\\]$", param_name)
+ if not match:
+ continue
+ image_id = match.group(1)
+ # Descriptions that we already set while adding new images can be
+ # ignored
+ if image_id in set_descriptions:
+ continue
+ # Did someone give us a wrong ID?!
+ if image_id not in images:
+ LOGGER.info("Got a ghost image description for track %s: %s", track.id, image_id)
+ continue
+ image_meta = models.ImageMetadata.get_or_create(request.dbsession, track, image_id)
+ image_meta.description = description
+ request.dbsession.add(image_meta)
diff --git a/fietsboek/views/upload.py b/fietsboek/views/upload.py
index 979291e..4f42b80 100644
--- a/fietsboek/views/upload.py
+++ b/fietsboek/views/upload.py
@@ -169,6 +169,12 @@ def do_finish_upload(request):
track.ensure_cache()
request.dbsession.add(track.cache)
+ # Don't forget to add the images
+ for image in request.params.getall("image[]"):
+ if image == b"":
+ continue
+ request.data_manager.add_image(track.id, image.file, image.filename)
+
request.session.flash(request.localizer.translate(_("flash.upload_success")))
return HTTPFound(request.route_url('details', track_id=track.id))
diff --git a/tests/conftest.py b/tests/conftest.py
index 79f0245..a91d2f4 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -47,7 +47,8 @@ def dbengine(app_settings, ini_file):
alembic.command.stamp(alembic_cfg, None, purge=True)
@pytest.fixture(scope='session')
-def app(app_settings, dbengine):
+def app(app_settings, dbengine, tmp_path_factory):
+ app_settings["fietsboek.data_dir"] = str(tmp_path_factory.mktemp("data"))
return main({}, dbengine=dbengine, **app_settings)
@pytest.fixture