diff options
28 files changed, 881 insertions, 127 deletions
@@ -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 Binary files differindex 34bd05a..7d7af8d 100644 --- a/fietsboek/locale/de/LC_MESSAGES/messages.mo +++ b/fietsboek/locale/de/LC_MESSAGES/messages.mo diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.po b/fietsboek/locale/de/LC_MESSAGES/messages.po index 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 Binary files differindex 127794c..8261149 100644 --- a/fietsboek/locale/en/LC_MESSAGES/messages.mo +++ b/fietsboek/locale/en/LC_MESSAGES/messages.mo diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.po b/fietsboek/locale/en/LC_MESSAGES/messages.po index 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 |