aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--asset-sources/theme.scss4
-rw-r--r--doc/administration/installation.rst2
-rw-r--r--doc/index.rst1
-rw-r--r--doc/man.rst15
-rw-r--r--doc/man/fietsctl.rst260
-rw-r--r--fietsboek/actions.py45
-rw-r--r--fietsboek/data.py21
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.mobin13807 -> 14476 bytes
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.po123
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.mobin12916 -> 13505 bytes
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.po122
-rw-r--r--fietsboek/locale/fietslog.pot116
-rw-r--r--fietsboek/models/user.py7
-rw-r--r--fietsboek/routes.py1
-rw-r--r--fietsboek/scripts/fietscron.py12
-rw-r--r--fietsboek/scripts/fietsctl.py263
-rw-r--r--fietsboek/static/theme.css4
-rw-r--r--fietsboek/static/theme.css.map2
-rw-r--r--fietsboek/templates/home.jinja216
-rw-r--r--fietsboek/templates/layout.jinja26
-rw-r--r--fietsboek/templates/login.jinja22
-rw-r--r--fietsboek/templates/resend_verification.jinja222
-rw-r--r--fietsboek/util.py46
-rw-r--r--fietsboek/views/account.py23
-rw-r--r--fietsboek/views/default.py79
-rw-r--r--fietsboek/views/detail.py19
-rw-r--r--fietsboek/views/profile.py34
-rw-r--r--tests/integration/test_register.py40
-rw-r--r--tests/integration/test_smoke.py2
-rw-r--r--tests/unit/test_util.py16
-rw-r--r--tox.ini6
31 files changed, 1044 insertions, 265 deletions
diff --git a/asset-sources/theme.scss b/asset-sources/theme.scss
index 40aa55d..faa4f44 100644
--- a/asset-sources/theme.scss
+++ b/asset-sources/theme.scss
@@ -9,6 +9,10 @@ strong {
font-weight: 700;
}
+.brand-link {
+ text-decoration: none;
+}
+
.badge-container {
width: 50px;
height: 50px;
diff --git a/doc/administration/installation.rst b/doc/administration/installation.rst
index 53734b5..fd0def5 100644
--- a/doc/administration/installation.rst
+++ b/doc/administration/installation.rst
@@ -96,7 +96,7 @@ You can use the ``fietsctl`` command line program to add administrator users:
.. code:: bash
- .venv/bin/fietsctl -c production.ini useradd --admin
+ .venv/bin/fietsctl user add -c production.ini --admin
Running Fietsboek
-----------------
diff --git a/doc/index.rst b/doc/index.rst
index 0b69ef1..73dce3f 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -13,6 +13,7 @@ Welcome to Fietsboek's documentation!
administration
developer
user
+ man
.. toctree::
:maxdepth: 1
diff --git a/doc/man.rst b/doc/man.rst
new file mode 100644
index 0000000..03c7f78
--- /dev/null
+++ b/doc/man.rst
@@ -0,0 +1,15 @@
+Manpages
+========
+
+.. toctree::
+ :maxdepth: 1
+
+ man/fietsctl
+
+In this section, you will find the manpages for the various commands that are
+provided in Fietsboek.
+
+Each of those pages can also be turned into a "proper" manpage using
+``rst2man``::
+
+ rst2man.py doc/man/fietsctl.rst fietsctl.1
diff --git a/doc/man/fietsctl.rst b/doc/man/fietsctl.rst
new file mode 100644
index 0000000..67cbed4
--- /dev/null
+++ b/doc/man/fietsctl.rst
@@ -0,0 +1,260 @@
+fietsctl
+========
+
+Control utility for Fietsboek
+-----------------------------
+:Manual section: 1
+
+SYNPOSIS
+********
+
+.. code-block:: text
+
+ fietsctl maintenance-mode
+ fietsctl track {del|list}
+ fietsctl user {add|del|hittekaart|list|modify|passwd}
+ fietsctl version
+
+DESCRIPTION
+***********
+
+The ``fietsctl`` script is provided for administrative purposes. It allows you
+to manage the state and content of a Fietsboek instance from the command line.
+
+.. warning::
+
+ The ``fietsctl`` script does not do any access checking and does not
+ require and logins or passwords. You must use the permissions of your
+ system and database server to restrict the access to ``fietsctl`` by
+ restricting access to the data stores directly.
+
+Detailed versions of the commands are described below.
+
+Note that most commands expect the path to the configuration file to be given
+(e.g. ``-c production.ini``). The default uses ``fietsboek.ini``. This can be
+overridden using the ``-c``/``--config`` option.
+
+.. note::
+
+ All commands support the ``--help`` option, which will give you a quick
+ overview of how the command works and which options are available.
+
+USER MANAGEMENT
+***************
+
+You can use the ``fietsctl user`` subcommand to manage the users in the
+Fietsboek system.
+
+Many functions which deal with existing users (delete, modify, ...) allow the
+user to be specified either by their ID using the ``-i``/``--id`` option, or by
+their email address using ``-e``/``--email``. You can obtain the IDs of users
+using the ``fietsctl user list`` command.
+
+ADDING A USER
+#############
+
+.. code-block:: text
+
+ fietsctl user add [-c CONFIG] [--email EMAIL] [--password PASSWORD] [--admin]
+
+This command adds a user to the system. It can be called with no arguments, in
+which case all required values are prompted for.
+
+If the new user should be made an admin, use the ``--admin`` flag. If not
+given, the user will *not* be made an admin. In any case, the user is
+automatically verified. If you want to change the admin or verification status
+after creating a user, use the ``fietsctl user modify`` command (see below).
+
+It is advised that you do not supply the password on the command line to
+prevent it from appearing in the command history. Either disable the history,
+or leave out the password (in which case you will be prompted).
+
+Note that this function does not check the ``enable_account_registration``
+setting, so it can always be used to add new users to the system.
+
+Note further that this function does less checks then the web interface (e.g.
+it does not require an email verification), so be careful which values you
+enter.
+
+REMOVING A USER
+###############
+
+.. code-block:: text
+
+ fietsctl user del [-c CONFIG] [-f/--force] [-i/--id ID] [-e/--email EMAIL]
+
+Removes a user from the system. This will remove the user account and all
+associated data (the user's tracks, comments, ...).
+
+By default, the command will ask for confirmation. You can specify the
+``-f``/``--force`` flag to override this check.
+
+GENERATING HEATMAPS
+###################
+
+.. code-block:: text
+
+ fietsctl user hittekaart [-c CONFIG] [-i/--id ID] [-e/--email EMAIL] [--delete] [--mode heatmap|tilehunt]
+
+With ``fiettsctl user hittekaart`` you can force a hittekaart run for a
+specific user. By default, only the heatmap is generated, but you can use
+``--mode`` to select which overlay map you want to generate. You can also
+specify ``--mode`` multiple times to generate multiple heat maps with a single
+invocation.
+
+If you want to delete a heatmap, use the ``--delete`` option. It also respects
+the ``--mode`` selection, so you can delete individual types of heatmaps.
+
+LISTING USERS
+#############
+
+.. code-block:: text
+
+ fietsctl user list [-c CONFIG]
+
+Outputs a list of all users in the system, one user per line. Each line
+consists of:
+
+.. code-block:: text
+
+ [av] ID - EMAIL - NAME
+
+The "a" indicates that the user has admin permissions. If the user has no admin
+permissions, a "-" is shown instead.
+
+The "v" indicates that the user has their email address verified. If the user
+has not verified their email address, a "-" is shown instead.
+
+MODIFYING USERS
+###############
+
+.. code-block:: text
+
+ fietsctl user modify [-c CONFIG] [-i/--id ID] [-e/--email EMAIL] [--admin/--no-admin] [--verified/--no-verified]
+
+Modifies a user. This can currently be used to set the admin and verification
+status of a user. If you want to change the password, use ``fietsctl user
+passwd`` (see below). You cannot currently change the email address or name of
+a user with this command (note that the ``--email`` option is for user
+*selection*, not *modification*).
+
+If you do not specifiy either ``--admin`` or ``--no-admin``, then the current
+value of the user is not changed. The same goes for ``--verified`` and
+``--no-verified``, if neither is given, the current value is not changed.
+
+CHANGING USER PASSWORDS
+#######################
+
+.. code-block:: text
+
+ fietsctl user passwd [-c CONFIG] [-i/--id ID] [-e/--email EMAIL] [--password PASSWORD]
+
+Changes the password of the specified user. If the password is not given via
+``--password``, then the password is interactively prompted for. Be careful
+when using ``--password`` as sensitive information might end up in the shell
+history!
+
+Note that this function does fewer checks than the web interface, as such it is
+possible to set passwords that users cannot set themselves (e.g. very short
+ones).
+
+TRACK MANAGEMENT
+****************
+
+The ``fietsctl track`` subcommand can be used to manage the tracks.
+
+LISTING TRACKS
+##############
+
+.. code-block:: text
+
+ fietsctl track list [-c CONFIG]
+
+Lists all tracks in the system. This outputs one line per track, plus final
+summary information.
+
+For each track, the following information is shown:
+
+* The track's ID
+* The size of the track's data (this includes the size of the data directory,
+ but not the size of the database elements)
+* The track's owner (both name and email address)
+* The track's title.
+
+REMOVING A TRACK
+################
+
+.. code-block:: text
+
+ fietsctl track del [-c CONFIG] -i/--id ID [-f/--force]
+
+Deletes the specified track. The right ID can be found via the ``track list``
+command, or via the web interface.
+
+This command deletes the track including its pictures and comments.
+
+By default, the command will ask for confirmation. You can specify the
+``-f``/``--force`` flag to override this check.
+
+MAINTENANCE MODE
+****************
+
+The ``fietsctl maintenance-mode`` subcommand manages the maintenance mode.
+
+ACTIVATING MAINTENANCE
+######################
+
+.. code-block:: text
+
+ fietsctl maintenance-mode [-c CONFIG] REASON
+
+Enables the maintenance mode with the given reason. The reason should be a
+short string that describes why Fietsboek is disabled. It will be shown to the
+users on the error page.
+
+CHECKING MAINTENANCE
+####################
+
+.. code-block:: text
+
+ fietsctl maintenance-mode [-c CONFIG]
+
+Without the reason, the command will output the current status of the
+maintenance mode.
+
+DEACTIVATING MAINTENANCE
+########################
+
+.. code-block:: text
+
+ fietsctl maintenance-mode [-c CONFIG] --disable
+
+With the ``--disable`` option, the maintenance mode will be disabled.
+
+BUGS
+****
+
+Bugs can be reported either via the issue tracker
+(https://gitlab.com/dunj3/fietsboek/-/issues) or via email (fietsboek at
+kingdread dot de).
+
+AUTHOR
+******
+
+This program is part of Fietsboek, written by the Fietsboek authors.
+
+COPYRIGHT
+*********
+
+This program is free software: you can redistribute it and/or modify it under
+the terms of the GNU Affero General Public License as published by the Free
+Software Foundation, either version 3 of the License, or (at your option) any
+later version.
+
+This program is distributed in the hope that it will be useful, but WITHOUT ANY
+WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+details.
+
+You should have received a copy of the GNU Affero General Public License along
+with this program. If not, see <https://www.gnu.org/licenses/>.
diff --git a/fietsboek/actions.py b/fietsboek/actions.py
index 6afbfa4..395843f 100644
--- a/fietsboek/actions.py
+++ b/fietsboek/actions.py
@@ -12,15 +12,17 @@ from typing import List, Optional
import brotli
import gpxpy
+from pyramid.i18n import TranslationString as _
from pyramid.request import Request
from sqlalchemy import select
from sqlalchemy.orm.session import Session
-from . import models
+from . import email, models
from . import transformers as mod_transformers
from . import util
from .data import DataManager, TrackDataDir
from .models.track import TrackType, Visibility
+from .models.user import TokenType
LOGGER = logging.getLogger(__name__)
@@ -208,7 +210,7 @@ def execute_transformers(request: Request, track: models.Track) -> Optional[gpxp
transformer.execute(gpx)
LOGGER.debug("Saving transformed file for %d", track.id)
- manager.compress_gpx(gpx.to_xml().encode("utf-8"))
+ manager.compress_gpx(util.encode_gpx(gpx))
LOGGER.debug("Saving new transformers on %d", track.id)
track.transformers = serialized
@@ -219,3 +221,42 @@ def execute_transformers(request: Request, track: models.Track) -> Optional[gpxp
track.ensure_cache(gpx)
request.dbsession.add(track.cache)
return gpx
+
+
+def send_verification_token(request: Request, user: models.User):
+ """Creates a verification token and sends it to the user.
+
+ If no verification token exists yet, a fresh one is created.
+
+ If a token already exists, a fresh one is still created. The old token
+ stays valid until its expiry date.
+
+ Note that this function does not provide the user with feedback other than
+ the email. You may want to show a flash message or similar to show that
+ something has happened.
+
+ :param request: The request.
+ :param user: The user who to generate a verification token for.
+ """
+ # Some of this code appears in the password reset form as well, but that's
+ # fine for now.
+ # pylint: disable=duplicate-code
+ token = models.Token.generate(user, TokenType.VERIFY_EMAIL)
+ request.dbsession.add(token)
+
+ message = email.prepare_message(
+ request.config.email_from,
+ user.email,
+ request.localizer.translate(_("email.verify_mail.subject")),
+ )
+ message.set_content(
+ request.localizer.translate(_("email.verify.text")).format(
+ request.route_url("use-token", uuid=token.uuid)
+ )
+ )
+ email.send_message(
+ request.config.email_smtp_url,
+ request.config.email_username,
+ request.config.email_password.get_secret_value(),
+ message,
+ )
diff --git a/fietsboek/data.py b/fietsboek/data.py
index 7457986..9e0d45d 100644
--- a/fietsboek/data.py
+++ b/fietsboek/data.py
@@ -5,6 +5,7 @@ the database itself. This module makes access to such data objects easier.
"""
import datetime
import logging
+import os
import random
import shutil
import string
@@ -16,7 +17,7 @@ import brotli
import gpxpy
from filelock import FileLock
-from .util import secure_filename
+from . import util
LOGGER = logging.getLogger(__name__)
@@ -31,7 +32,7 @@ def generate_filename(filename: Optional[str]) -> str:
:return: The generated filename.
"""
if filename:
- good_name = secure_filename(filename)
+ good_name = util.secure_filename(filename)
if good_name:
random_prefix = "".join(random.choice(string.ascii_lowercase) for _ in range(5))
return f"{random_prefix}_{good_name}"
@@ -242,6 +243,16 @@ class TrackDataDir:
shutil.move(self.path, new_name)
self.journal.append(("purge", new_name))
+ def size(self) -> int:
+ """Returns the size of the data that this track entails.
+
+ :return: The size of bytes that this track consumes.
+ """
+ size = 0
+ for root, _, files in os.walk(self.path):
+ size += sum(os.path.getsize(os.path.join(root, fname)) for fname in files)
+ return size
+
def gpx_path(self) -> Path:
"""Returns the path of the GPX file.
@@ -313,7 +324,7 @@ class TrackDataDir:
gpx.description = description
gpx.time = time
- self.compress_gpx(gpx.to_xml().encode("utf-8"))
+ self.compress_gpx(util.encode_gpx(gpx))
def backup(self):
"""Create a backup of the GPX file."""
@@ -345,7 +356,7 @@ class TrackDataDir:
:param image_id: ID of the image.
:return: A path pointing to the requested image.
"""
- image = self.path / "images" / secure_filename(image_id)
+ image = self.path / "images" / util.secure_filename(image_id)
if not image.exists():
raise FileNotFoundError("The requested image does not exist")
return image
@@ -377,7 +388,7 @@ class TrackDataDir:
:param image_id: ID of the image.
"""
# Be sure to not delete anything else than the image file
- image_id = secure_filename(image_id)
+ image_id = util.secure_filename(image_id)
if "/" in image_id or "\\" in image_id:
return
path = self.image_path(image_id)
diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.mo b/fietsboek/locale/de/LC_MESSAGES/messages.mo
index 887f9bc..10f468c 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 5b6c654..cb62dbb 100644
--- a/fietsboek/locale/de/LC_MESSAGES/messages.po
+++ b/fietsboek/locale/de/LC_MESSAGES/messages.po
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2023-05-15 20:20+0200\n"
+"POT-Creation-Date: 2023-05-31 20:46+0200\n"
"PO-Revision-Date: 2022-07-02 17:35+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de\n"
@@ -18,11 +18,22 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.12.1\n"
-#: fietsboek/util.py:280
+#: fietsboek/actions.py:250
+msgid "email.verify_mail.subject"
+msgstr "Fietsboek Konto Bestätigung"
+
+#: fietsboek/actions.py:253
+msgid "email.verify.text"
+msgstr ""
+"Um Dein Fietsboek-Konto zu bestätigen, nutze diesen Link: {}\n"
+"\n"
+"Falls Du kein Konto angelegt hast, ignoriere diese E-Mail."
+
+#: fietsboek/util.py:304
msgid "password_constraint.mismatch"
msgstr "Passwörter stimmen nicht überein"
-#: fietsboek/util.py:282
+#: fietsboek/util.py:306
msgid "password_constraint.length"
msgstr "Passwort zu kurz"
@@ -478,72 +489,72 @@ msgstr "Absenden"
msgid "page.upload.form.cancel"
msgstr "Abbrechen"
-#: fietsboek/templates/home.jinja2:5
+#: fietsboek/templates/home.jinja2:6
msgid "page.home.title"
msgstr "Startseite"
-#: fietsboek/templates/home.jinja2:8
+#: fietsboek/templates/home.jinja2:17
msgid "page.home.unfinished_uploads"
msgstr ""
"Es sind noch nicht abgeschlossene Uploads vorhanden. Klicke auf die "
"Links, um sie fortzusetzen:"
-#: fietsboek/templates/home.jinja2:27 fietsboek/templates/home.jinja2:34
-#: fietsboek/templates/home.jinja2:52
+#: fietsboek/templates/home.jinja2:31 fietsboek/templates/home.jinja2:38
+#: fietsboek/templates/home.jinja2:56
msgid "page.home.summary.track"
msgid_plural "page.home.summary.tracks"
msgstr[0] "%(num)d Strecke"
msgstr[1] "%(num)d Strecken"
-#: fietsboek/templates/home.jinja2:52
+#: fietsboek/templates/home.jinja2:56
msgid "page.home.total"
msgstr "Gesamt"
-#: fietsboek/templates/layout.jinja2:41
+#: fietsboek/templates/layout.jinja2:43
msgid "page.navbar.toggle"
msgstr "Navigation umschalten"
-#: fietsboek/templates/layout.jinja2:52
+#: fietsboek/templates/layout.jinja2:54
msgid "page.navbar.home"
msgstr "Startseite"
-#: fietsboek/templates/layout.jinja2:55
+#: fietsboek/templates/layout.jinja2:57
msgid "page.navbar.browse"
msgstr "Stöbern"
-#: fietsboek/templates/layout.jinja2:59
+#: fietsboek/templates/layout.jinja2:61
msgid "page.navbar.upload"
msgstr "Hochladen"
-#: fietsboek/templates/layout.jinja2:68
+#: fietsboek/templates/layout.jinja2:70
msgid "page.navbar.user"
msgstr "Nutzer"
-#: fietsboek/templates/layout.jinja2:72
+#: fietsboek/templates/layout.jinja2:74
msgid "page.navbar.welcome_user"
msgstr "Willkommen, {}!"
-#: fietsboek/templates/layout.jinja2:75
+#: fietsboek/templates/layout.jinja2:77
msgid "page.navbar.logout"
msgstr "Abmelden"
-#: fietsboek/templates/layout.jinja2:78
+#: fietsboek/templates/layout.jinja2:80
msgid "page.navbar.profile"
msgstr "Profil"
-#: fietsboek/templates/layout.jinja2:81
+#: fietsboek/templates/layout.jinja2:83
msgid "page.navbar.user_data"
msgstr "Persönliche Daten"
-#: fietsboek/templates/layout.jinja2:85
+#: fietsboek/templates/layout.jinja2:87
msgid "page.navbar.admin"
msgstr "Admin"
-#: fietsboek/templates/layout.jinja2:91
+#: fietsboek/templates/layout.jinja2:93
msgid "page.navbar.login"
msgstr "Anmelden"
-#: fietsboek/templates/layout.jinja2:95
+#: fietsboek/templates/layout.jinja2:97
msgid "page.navbar.create_account"
msgstr "Konto Erstellen"
@@ -571,6 +582,10 @@ msgstr "Anmelden"
msgid "page.login.forgot_password"
msgstr "Passwort vergessen"
+#: fietsboek/templates/login.jinja2:45
+msgid "page.login.resend_verification"
+msgstr "Bestätigungsmail erneut senden"
+
#: fietsboek/templates/password_reset.jinja2:5
msgid "page.password_reset.title"
msgstr "Passwort Zurücksetzen"
@@ -669,6 +684,23 @@ msgstr "E-Mail-Adresse"
msgid "page.request_password.request"
msgstr "Anfrage senden"
+#: fietsboek/templates/resend_verification.jinja2:5
+msgid "page.resend_verification.title"
+msgstr "Bestätigungsmail Erneut Senden"
+
+#: fietsboek/templates/resend_verification.jinja2:6
+msgid "page.resend_verification.info"
+msgstr ""
+"Hier kannst Du eine neue E-Mail zur Bestätigung Deines Kontos anfordern."
+
+#: fietsboek/templates/resend_verification.jinja2:12
+msgid "page.resend_verification.email"
+msgstr "E-Mail-Adresse"
+
+#: fietsboek/templates/resend_verification.jinja2:17
+msgid "page.resend_verification.request"
+msgstr "E-Mail anfordern"
+
#: fietsboek/templates/upload.jinja2:9
msgid "page.upload.form.gpx"
msgstr "GPX Datei"
@@ -753,26 +785,15 @@ msgstr ""
"Diese Transformation passt die Höhenangabe für Punkte an, bei denen die "
"Höhe sprunghaft steigt oder fällt."
-#: fietsboek/views/account.py:54
+#: fietsboek/views/account.py:53
msgid "flash.invalid_name"
msgstr "Ungültiger Name"
-#: fietsboek/views/account.py:59
+#: fietsboek/views/account.py:58
msgid "flash.invalid_email"
msgstr "Ungültige E-Mail-Adresse"
-#: fietsboek/views/account.py:72
-msgid "email.verify_mail.subject"
-msgstr "Fietsboek Konto Bestätigung"
-
-#: fietsboek/views/account.py:75
-msgid "email.verify.text"
-msgstr ""
-"Um Dein Fietsboek-Konto zu bestätigen, nutze diesen Link: {}\n"
-"\n"
-"Falls Du kein Konto angelegt hast, ignoriere diese E-Mail."
-
-#: fietsboek/views/account.py:86
+#: fietsboek/views/account.py:67
msgid "flash.a_confirmation_link_has_been_sent"
msgstr "Ein Bestätigungslink wurde versandt"
@@ -788,35 +809,35 @@ msgstr "Wappen bearbeitet"
msgid "flash.badge_deleted"
msgstr "Wappen gelöscht"
-#: fietsboek/views/default.py:121
+#: fietsboek/views/default.py:115
msgid "flash.invalid_credentials"
msgstr "Ungültige Nutzerdaten"
-#: fietsboek/views/default.py:125
+#: fietsboek/views/default.py:119
msgid "flash.account_not_verified"
msgstr "Konto noch nicht bestätigt"
-#: fietsboek/views/default.py:128
+#: fietsboek/views/default.py:122
msgid "flash.logged_in"
msgstr "Du bist nun angemeldet"
-#: fietsboek/views/default.py:150
+#: fietsboek/views/default.py:142
msgid "flash.logged_out"
msgstr "Du bist nun abgemeldet"
-#: fietsboek/views/default.py:184
+#: fietsboek/views/default.py:172
msgid "flash.reset_invalid_email"
msgstr "Ungültige E-Mail-Adresse angegeben"
-#: fietsboek/views/default.py:189
+#: fietsboek/views/default.py:177
msgid "flash.password_token_generated"
msgstr "Ein Link zum Zurücksetzen des Passworts wurde versandt"
-#: fietsboek/views/default.py:194
+#: fietsboek/views/default.py:182
msgid "page.password_reset.email.subject"
msgstr "Fietsboek Passwortzurücksetzung"
-#: fietsboek/views/default.py:197
+#: fietsboek/views/default.py:185
msgid "page.password_reset.email.body"
msgstr ""
"Du kannst Dein Fietsboek-Passwort hier zurücksetzen: {}\n"
@@ -824,15 +845,27 @@ msgstr ""
"Falls Du keine Passwortzurücksetzung beantragt hast, dann ignoriere diese"
" E-Mail."
-#: fietsboek/views/default.py:230
+#: fietsboek/views/default.py:224
+msgid "flash.resend_verification_email_fail"
+msgstr "Ungültige E-Mail-Adresse angegeben"
+
+#: fietsboek/views/default.py:229
+msgid "flash.verification_token_generated"
+msgstr "Ein Link zur Bestätigung Deines Kontos wurde versandt"
+
+#: fietsboek/views/default.py:249
+msgid "flash.token_expired"
+msgstr "Der Link ist nicht mehr gültig"
+
+#: fietsboek/views/default.py:255
msgid "flash.email_verified"
msgstr "E-Mail-Adresse bestätigt"
-#: fietsboek/views/default.py:244
+#: fietsboek/views/default.py:269
msgid "flash.password_updated"
msgstr "Passwort aktualisiert"
-#: fietsboek/views/detail.py:140
+#: fietsboek/views/detail.py:155
msgid "flash.track_deleted"
msgstr "Strecke gelöscht"
diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.mo b/fietsboek/locale/en/LC_MESSAGES/messages.mo
index bfd051f..df35686 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 d1a8e8d..1cac820 100644
--- a/fietsboek/locale/en/LC_MESSAGES/messages.po
+++ b/fietsboek/locale/en/LC_MESSAGES/messages.po
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2023-05-15 20:20+0200\n"
+"POT-Creation-Date: 2023-05-31 20:46+0200\n"
"PO-Revision-Date: 2023-04-03 20:42+0200\n"
"Last-Translator: \n"
"Language: en\n"
@@ -18,11 +18,22 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.12.1\n"
-#: fietsboek/util.py:280
+#: fietsboek/actions.py:250
+msgid "email.verify_mail.subject"
+msgstr "Fietsboek Account Verification"
+
+#: fietsboek/actions.py:253
+msgid "email.verify.text"
+msgstr ""
+"To verify your Fietsboek account, please use this link: {}\n"
+"\n"
+"If you did not create an account, ignore this email."
+
+#: fietsboek/util.py:304
msgid "password_constraint.mismatch"
msgstr "Passwords don't match"
-#: fietsboek/util.py:282
+#: fietsboek/util.py:306
msgid "password_constraint.length"
msgstr "Password not long enough"
@@ -474,70 +485,70 @@ msgstr "Upload"
msgid "page.upload.form.cancel"
msgstr "Cancel"
-#: fietsboek/templates/home.jinja2:5
+#: fietsboek/templates/home.jinja2:6
msgid "page.home.title"
msgstr "Home"
-#: fietsboek/templates/home.jinja2:8
+#: fietsboek/templates/home.jinja2:17
msgid "page.home.unfinished_uploads"
msgstr "You have unfinished uploads. Click on the links below to resume them:"
-#: fietsboek/templates/home.jinja2:27 fietsboek/templates/home.jinja2:34
-#: fietsboek/templates/home.jinja2:52
+#: fietsboek/templates/home.jinja2:31 fietsboek/templates/home.jinja2:38
+#: fietsboek/templates/home.jinja2:56
msgid "page.home.summary.track"
msgid_plural "page.home.summary.tracks"
msgstr[0] "%(num)d track"
msgstr[1] "%(num)d tracks"
-#: fietsboek/templates/home.jinja2:52
+#: fietsboek/templates/home.jinja2:56
msgid "page.home.total"
msgstr "Total"
-#: fietsboek/templates/layout.jinja2:41
+#: fietsboek/templates/layout.jinja2:43
msgid "page.navbar.toggle"
msgstr "Toggle navigation"
-#: fietsboek/templates/layout.jinja2:52
+#: fietsboek/templates/layout.jinja2:54
msgid "page.navbar.home"
msgstr "Home"
-#: fietsboek/templates/layout.jinja2:55
+#: fietsboek/templates/layout.jinja2:57
msgid "page.navbar.browse"
msgstr "Browse"
-#: fietsboek/templates/layout.jinja2:59
+#: fietsboek/templates/layout.jinja2:61
msgid "page.navbar.upload"
msgstr "Upload"
-#: fietsboek/templates/layout.jinja2:68
+#: fietsboek/templates/layout.jinja2:70
msgid "page.navbar.user"
msgstr "User"
-#: fietsboek/templates/layout.jinja2:72
+#: fietsboek/templates/layout.jinja2:74
msgid "page.navbar.welcome_user"
msgstr "Welcome, {}!"
-#: fietsboek/templates/layout.jinja2:75
+#: fietsboek/templates/layout.jinja2:77
msgid "page.navbar.logout"
msgstr "Logout"
-#: fietsboek/templates/layout.jinja2:78
+#: fietsboek/templates/layout.jinja2:80
msgid "page.navbar.profile"
msgstr "Profile"
-#: fietsboek/templates/layout.jinja2:81
+#: fietsboek/templates/layout.jinja2:83
msgid "page.navbar.user_data"
msgstr "Personal Data"
-#: fietsboek/templates/layout.jinja2:85
+#: fietsboek/templates/layout.jinja2:87
msgid "page.navbar.admin"
msgstr "Admin"
-#: fietsboek/templates/layout.jinja2:91
+#: fietsboek/templates/layout.jinja2:93
msgid "page.navbar.login"
msgstr "Login"
-#: fietsboek/templates/layout.jinja2:95
+#: fietsboek/templates/layout.jinja2:97
msgid "page.navbar.create_account"
msgstr "Create Account"
@@ -565,6 +576,10 @@ msgstr "Login"
msgid "page.login.forgot_password"
msgstr "Forgot password"
+#: fietsboek/templates/login.jinja2:45
+msgid "page.login.resend_verification"
+msgstr "Re-send verification mail"
+
#: fietsboek/templates/password_reset.jinja2:5
msgid "page.password_reset.title"
msgstr "Reset Your Password"
@@ -663,6 +678,22 @@ msgstr "Email"
msgid "page.request_password.request"
msgstr "Send request"
+#: fietsboek/templates/resend_verification.jinja2:5
+msgid "page.resend_verification.title"
+msgstr "Re-send Verification Mail"
+
+#: fietsboek/templates/resend_verification.jinja2:6
+msgid "page.resend_verification.info"
+msgstr "Here you can request a new mail to verify your account"
+
+#: fietsboek/templates/resend_verification.jinja2:12
+msgid "page.resend_verification.email"
+msgstr "Email"
+
+#: fietsboek/templates/resend_verification.jinja2:17
+msgid "page.resend_verification.request"
+msgstr "Send request"
+
#: fietsboek/templates/upload.jinja2:9
msgid "page.upload.form.gpx"
msgstr "GPX file"
@@ -743,26 +774,15 @@ msgstr "Fix elevation jumps"
msgid "transformers.fix-elevation-jumps.description"
msgstr "This transformer fixes abrupt jumps in the elevation value."
-#: fietsboek/views/account.py:54
+#: fietsboek/views/account.py:53
msgid "flash.invalid_name"
msgstr "Invalid name"
-#: fietsboek/views/account.py:59
+#: fietsboek/views/account.py:58
msgid "flash.invalid_email"
msgstr "Invalid email"
-#: fietsboek/views/account.py:72
-msgid "email.verify_mail.subject"
-msgstr "Fietsboek Account Verification"
-
-#: fietsboek/views/account.py:75
-msgid "email.verify.text"
-msgstr ""
-"To verify your Fietsboek account, please use this link: {}\n"
-"\n"
-"If you did not create an account, ignore this email."
-
-#: fietsboek/views/account.py:86
+#: fietsboek/views/account.py:67
msgid "flash.a_confirmation_link_has_been_sent"
msgstr "A confirmation link has been sent"
@@ -778,50 +798,62 @@ msgstr "Badge has been modified"
msgid "flash.badge_deleted"
msgstr "Badge has been deleted"
-#: fietsboek/views/default.py:121
+#: fietsboek/views/default.py:115
msgid "flash.invalid_credentials"
msgstr "Invalid login credentials"
-#: fietsboek/views/default.py:125
+#: fietsboek/views/default.py:119
msgid "flash.account_not_verified"
msgstr "Your account is not verified yet"
-#: fietsboek/views/default.py:128
+#: fietsboek/views/default.py:122
msgid "flash.logged_in"
msgstr "You are now logged in"
-#: fietsboek/views/default.py:150
+#: fietsboek/views/default.py:142
msgid "flash.logged_out"
msgstr "You have been logged out"
-#: fietsboek/views/default.py:184
+#: fietsboek/views/default.py:172
msgid "flash.reset_invalid_email"
msgstr "Invalid email address provided"
-#: fietsboek/views/default.py:189
+#: fietsboek/views/default.py:177
msgid "flash.password_token_generated"
msgstr "A password reset email has been sent"
-#: fietsboek/views/default.py:194
+#: fietsboek/views/default.py:182
msgid "page.password_reset.email.subject"
msgstr "Fietsboek Password Reset"
-#: fietsboek/views/default.py:197
+#: fietsboek/views/default.py:185
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:230
+#: fietsboek/views/default.py:224
+msgid "flash.resend_verification_email_fail"
+msgstr "Invalid email address provided"
+
+#: fietsboek/views/default.py:229
+msgid "flash.verification_token_generated"
+msgstr "A verification email has been sent"
+
+#: fietsboek/views/default.py:249
+msgid "flash.token_expired"
+msgstr "The link has expired"
+
+#: fietsboek/views/default.py:255
msgid "flash.email_verified"
msgstr "Your email address has been verified"
-#: fietsboek/views/default.py:244
+#: fietsboek/views/default.py:269
msgid "flash.password_updated"
msgstr "Password has been updated"
-#: fietsboek/views/detail.py:140
+#: fietsboek/views/detail.py:155
msgid "flash.track_deleted"
msgstr "Track has been deleted"
diff --git a/fietsboek/locale/fietslog.pot b/fietsboek/locale/fietslog.pot
index 7e3d440..8d43284 100644
--- a/fietsboek/locale/fietslog.pot
+++ b/fietsboek/locale/fietslog.pot
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2023-05-15 20:20+0200\n"
+"POT-Creation-Date: 2023-05-31 20:46+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,11 +17,19 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.12.1\n"
-#: fietsboek/util.py:280
+#: fietsboek/actions.py:250
+msgid "email.verify_mail.subject"
+msgstr ""
+
+#: fietsboek/actions.py:253
+msgid "email.verify.text"
+msgstr ""
+
+#: fietsboek/util.py:304
msgid "password_constraint.mismatch"
msgstr ""
-#: fietsboek/util.py:282
+#: fietsboek/util.py:306
msgid "password_constraint.length"
msgstr ""
@@ -471,70 +479,70 @@ msgstr ""
msgid "page.upload.form.cancel"
msgstr ""
-#: fietsboek/templates/home.jinja2:5
+#: fietsboek/templates/home.jinja2:6
msgid "page.home.title"
msgstr ""
-#: fietsboek/templates/home.jinja2:8
+#: fietsboek/templates/home.jinja2:17
msgid "page.home.unfinished_uploads"
msgstr ""
-#: fietsboek/templates/home.jinja2:27 fietsboek/templates/home.jinja2:34
-#: fietsboek/templates/home.jinja2:52
+#: fietsboek/templates/home.jinja2:31 fietsboek/templates/home.jinja2:38
+#: fietsboek/templates/home.jinja2:56
msgid "page.home.summary.track"
msgid_plural "page.home.summary.tracks"
msgstr[0] ""
msgstr[1] ""
-#: fietsboek/templates/home.jinja2:52
+#: fietsboek/templates/home.jinja2:56
msgid "page.home.total"
msgstr ""
-#: fietsboek/templates/layout.jinja2:41
+#: fietsboek/templates/layout.jinja2:43
msgid "page.navbar.toggle"
msgstr ""
-#: fietsboek/templates/layout.jinja2:52
+#: fietsboek/templates/layout.jinja2:54
msgid "page.navbar.home"
msgstr ""
-#: fietsboek/templates/layout.jinja2:55
+#: fietsboek/templates/layout.jinja2:57
msgid "page.navbar.browse"
msgstr ""
-#: fietsboek/templates/layout.jinja2:59
+#: fietsboek/templates/layout.jinja2:61
msgid "page.navbar.upload"
msgstr ""
-#: fietsboek/templates/layout.jinja2:68
+#: fietsboek/templates/layout.jinja2:70
msgid "page.navbar.user"
msgstr ""
-#: fietsboek/templates/layout.jinja2:72
+#: fietsboek/templates/layout.jinja2:74
msgid "page.navbar.welcome_user"
msgstr ""
-#: fietsboek/templates/layout.jinja2:75
+#: fietsboek/templates/layout.jinja2:77
msgid "page.navbar.logout"
msgstr ""
-#: fietsboek/templates/layout.jinja2:78
+#: fietsboek/templates/layout.jinja2:80
msgid "page.navbar.profile"
msgstr ""
-#: fietsboek/templates/layout.jinja2:81
+#: fietsboek/templates/layout.jinja2:83
msgid "page.navbar.user_data"
msgstr ""
-#: fietsboek/templates/layout.jinja2:85
+#: fietsboek/templates/layout.jinja2:87
msgid "page.navbar.admin"
msgstr ""
-#: fietsboek/templates/layout.jinja2:91
+#: fietsboek/templates/layout.jinja2:93
msgid "page.navbar.login"
msgstr ""
-#: fietsboek/templates/layout.jinja2:95
+#: fietsboek/templates/layout.jinja2:97
msgid "page.navbar.create_account"
msgstr ""
@@ -562,6 +570,10 @@ msgstr ""
msgid "page.login.forgot_password"
msgstr ""
+#: fietsboek/templates/login.jinja2:45
+msgid "page.login.resend_verification"
+msgstr ""
+
#: fietsboek/templates/password_reset.jinja2:5
msgid "page.password_reset.title"
msgstr ""
@@ -658,6 +670,22 @@ msgstr ""
msgid "page.request_password.request"
msgstr ""
+#: fietsboek/templates/resend_verification.jinja2:5
+msgid "page.resend_verification.title"
+msgstr ""
+
+#: fietsboek/templates/resend_verification.jinja2:6
+msgid "page.resend_verification.info"
+msgstr ""
+
+#: fietsboek/templates/resend_verification.jinja2:12
+msgid "page.resend_verification.email"
+msgstr ""
+
+#: fietsboek/templates/resend_verification.jinja2:17
+msgid "page.resend_verification.request"
+msgstr ""
+
#: fietsboek/templates/upload.jinja2:9
msgid "page.upload.form.gpx"
msgstr ""
@@ -738,23 +766,15 @@ msgstr ""
msgid "transformers.fix-elevation-jumps.description"
msgstr ""
-#: fietsboek/views/account.py:54
+#: fietsboek/views/account.py:53
msgid "flash.invalid_name"
msgstr ""
-#: fietsboek/views/account.py:59
+#: fietsboek/views/account.py:58
msgid "flash.invalid_email"
msgstr ""
-#: fietsboek/views/account.py:72
-msgid "email.verify_mail.subject"
-msgstr ""
-
-#: fietsboek/views/account.py:75
-msgid "email.verify.text"
-msgstr ""
-
-#: fietsboek/views/account.py:86
+#: fietsboek/views/account.py:67
msgid "flash.a_confirmation_link_has_been_sent"
msgstr ""
@@ -770,47 +790,59 @@ msgstr ""
msgid "flash.badge_deleted"
msgstr ""
-#: fietsboek/views/default.py:121
+#: fietsboek/views/default.py:115
msgid "flash.invalid_credentials"
msgstr ""
-#: fietsboek/views/default.py:125
+#: fietsboek/views/default.py:119
msgid "flash.account_not_verified"
msgstr ""
-#: fietsboek/views/default.py:128
+#: fietsboek/views/default.py:122
msgid "flash.logged_in"
msgstr ""
-#: fietsboek/views/default.py:150
+#: fietsboek/views/default.py:142
msgid "flash.logged_out"
msgstr ""
-#: fietsboek/views/default.py:184
+#: fietsboek/views/default.py:172
msgid "flash.reset_invalid_email"
msgstr ""
-#: fietsboek/views/default.py:189
+#: fietsboek/views/default.py:177
msgid "flash.password_token_generated"
msgstr ""
-#: fietsboek/views/default.py:194
+#: fietsboek/views/default.py:182
msgid "page.password_reset.email.subject"
msgstr ""
-#: fietsboek/views/default.py:197
+#: fietsboek/views/default.py:185
msgid "page.password_reset.email.body"
msgstr ""
-#: fietsboek/views/default.py:230
+#: fietsboek/views/default.py:224
+msgid "flash.resend_verification_email_fail"
+msgstr ""
+
+#: fietsboek/views/default.py:229
+msgid "flash.verification_token_generated"
+msgstr ""
+
+#: fietsboek/views/default.py:249
+msgid "flash.token_expired"
+msgstr ""
+
+#: fietsboek/views/default.py:255
msgid "flash.email_verified"
msgstr ""
-#: fietsboek/views/default.py:244
+#: fietsboek/views/default.py:269
msgid "flash.password_updated"
msgstr ""
-#: fietsboek/views/detail.py:140
+#: fietsboek/views/detail.py:155
msgid "flash.track_deleted"
msgstr ""
diff --git a/fietsboek/models/user.py b/fietsboek/models/user.py
index e1b4841..4201d14 100644
--- a/fietsboek/models/user.py
+++ b/fietsboek/models/user.py
@@ -53,6 +53,9 @@ SCRYPT_PARAMETERS = {
}
SALT_LENGTH = 32
+TOKEN_LIFETIME = datetime.timedelta(hours=24)
+"""Maximum validity time of a token."""
+
friends_assoc = Table(
"friends_assoc",
@@ -457,5 +460,9 @@ class Token(Base):
now = datetime.datetime.utcnow()
return cls(user=user, uuid=token_uuid, date=now, token_type=token_type)
+ def age(self) -> datetime.timedelta:
+ """Returns the age of the token."""
+ return abs(datetime.datetime.utcnow() - self.date)
+
Index("idx_token_uuid", Token.uuid, unique=True)
diff --git a/fietsboek/routes.py b/fietsboek/routes.py
index 8f109d9..60a566d 100644
--- a/fietsboek/routes.py
+++ b/fietsboek/routes.py
@@ -14,6 +14,7 @@ def includeme(config):
config.add_route("track-archive", "/track/archive")
config.add_route("password-reset", "/password-reset")
+ config.add_route("resend-verification", "/resend-verification")
config.add_route("use-token", "/token/{uuid}")
config.add_route("create-account", "/create-account")
diff --git a/fietsboek/scripts/fietscron.py b/fietsboek/scripts/fietscron.py
index 8116204..417b188 100644
--- a/fietsboek/scripts/fietscron.py
+++ b/fietsboek/scripts/fietscron.py
@@ -16,6 +16,7 @@ from .. import config as mod_config
from .. import hittekaart, models
from ..config import Config
from ..data import DataManager
+from ..models.user import TOKEN_LIFETIME
from . import config_option
LOGGER = logging.getLogger(__name__)
@@ -48,6 +49,7 @@ def cli(config):
LOGGER.debug("Starting maintenance tasks")
remove_old_uploads(engine)
+ remove_old_tokens(engine)
rebuild_cache(engine, data_manager)
if config.hittekaart_autogenerate:
@@ -65,6 +67,16 @@ def remove_old_uploads(engine: Engine):
session.commit()
+def remove_old_tokens(engine: Engine):
+ """Removes old tokens from the database."""
+ LOGGER.info("Deleting old tokens")
+ limit = datetime.datetime.utcnow() - TOKEN_LIFETIME
+ session = Session(engine)
+ stmt = delete(models.Token).where(models.Token.date < limit)
+ session.execute(stmt)
+ session.commit()
+
+
def rebuild_cache(engine: Engine, data_manager: DataManager):
"""Rebuilds the cache entries that are currently missing."""
LOGGER.debug("Rebuilding caches")
diff --git a/fietsboek/scripts/fietsctl.py b/fietsboek/scripts/fietsctl.py
index 4255b18..59462df 100644
--- a/fietsboek/scripts/fietsctl.py
+++ b/fietsboek/scripts/fietsctl.py
@@ -1,5 +1,6 @@
"""Script to do maintenance work on a Fietsboek instance."""
# pylint: disable=too-many-arguments
+import logging
from typing import Optional
import click
@@ -8,13 +9,31 @@ from pyramid.paster import bootstrap, setup_logging
from pyramid.scripting import AppEnvironment
from sqlalchemy import select
-from .. import __VERSION__, hittekaart, models
+from .. import __VERSION__, hittekaart, models, util
from ..data import DataManager
from . import config_option
+LOGGER = logging.getLogger("fietsctl")
+
EXIT_OKAY = 0
EXIT_FAILURE = 1
+FG_USER_NAME = "yellow"
+FG_USER_EMAIL = "yellow"
+FG_USER_TAG = "red"
+FG_SIZE = "cyan"
+FG_TRACK_TITLE = "green"
+FG_TRACK_SIZE = "bright_red"
+
+
+def human_bool(value: bool) -> str:
+ """Formats a boolean for CLI output.
+
+ :param value: The value to format.
+ :return: The string representing the bool.
+ """
+ return "yes" if value else "no"
+
def setup(config_path: str) -> AppEnvironment:
"""Sets up the logging and app environment for the scripts.
@@ -38,10 +57,15 @@ def cli():
"""CLI main entry point."""
-@cli.command("useradd")
+@cli.group("user")
+def cmd_user():
+ """Management functions for user accounts."""
+
+
+@cmd_user.command("add")
@config_option
-@click.option("--email", help="email address of the user", prompt=True)
-@click.option("--name", help="name of the user", prompt=True)
+@click.option("--email", help="Email address of the user.", prompt=True)
+@click.option("--name", help="Name of the user.", prompt=True)
@click.option(
"--password",
help="password of the user",
@@ -49,9 +73,16 @@ def cli():
hide_input=True,
confirmation_prompt=True,
)
-@click.option("--admin", help="make the new user an admin", is_flag=True)
+@click.option("--admin", help="Make the new user an admin.", is_flag=True)
@click.pass_context
-def cmd_useradd(ctx: click.Context, config: str, email: str, name: str, password: str, admin: bool):
+def cmd_user_add(
+ ctx: click.Context,
+ config: str,
+ email: str,
+ name: str,
+ password: str,
+ admin: bool,
+):
"""Create a new user.
This user creation bypasses the "enable_account_registration" setting. It
@@ -89,14 +120,14 @@ def cmd_useradd(ctx: click.Context, config: str, email: str, name: str, password
click.echo(user_id)
-@cli.command("userdel")
+@cmd_user.command("del")
@config_option
-@click.option("--force", "-f", help="override the safety check", is_flag=True)
+@click.option("--force", "-f", help="Override the safety check.", is_flag=True)
@optgroup.group("User selection", cls=RequiredMutuallyExclusiveOptionGroup)
-@optgroup.option("--id", "-i", "id_", help="database ID of the user", type=int)
-@optgroup.option("--email", "-e", help="email of the user")
+@optgroup.option("--id", "-i", "id_", help="Database ID of the user.", type=int)
+@optgroup.option("--email", "-e", help="Email address of the user.")
@click.pass_context
-def cmd_userdel(
+def user_del(
ctx: click.Context,
config: str,
force: bool,
@@ -121,7 +152,7 @@ def cmd_userdel(
if user is None:
click.echo("Error: No such user found.", err=True)
ctx.exit(EXIT_FAILURE)
- click.echo(user.name)
+ click.secho(user.name, fg=FG_USER_NAME)
click.echo(user.email)
if not force:
if not click.confirm("Really delete this user?"):
@@ -131,9 +162,9 @@ def cmd_userdel(
click.echo("User deleted")
-@cli.command("userlist")
+@cmd_user.command("list")
@config_option
-def cmd_userlist(config: str):
+def cmd_user_list(config: str):
"""Prints a listing of all user accounts.
The format is:
@@ -153,17 +184,20 @@ def cmd_userlist(config: str):
"a" if user.is_admin else "-",
"v" if user.is_verified else "-",
)
- click.echo(f"{tag} {user.id} - {user.email} - {user.name}")
+ tag = click.style(tag, fg=FG_USER_TAG)
+ user_email = click.style(user.email, fg=FG_USER_EMAIL)
+ user_name = click.style(user.name, fg=FG_USER_NAME)
+ click.echo(f"{tag} {user.id} - {user_email} - {user_name}")
-@cli.command("passwd")
+@cmd_user.command("passwd")
@config_option
-@click.option("--password", help="password of the user")
+@click.option("--password", help="Password of the user.")
@optgroup.group("User selection", cls=RequiredMutuallyExclusiveOptionGroup)
-@optgroup.option("--id", "-i", "id_", help="database ID of the user", type=int)
-@optgroup.option("--email", "-e", help="email of the user")
+@optgroup.option("--id", "-i", "id_", help="Database ID of the user,", type=int)
+@optgroup.option("--email", "-e", help="Email address of the user.")
@click.pass_context
-def cmd_passwd(
+def cms_user_passwd(
ctx: click.Context,
config: str,
password: Optional[str],
@@ -186,56 +220,68 @@ def cmd_passwd(
password = click.prompt("Password", hide_input=True, confirmation_prompt=True)
user.set_password(password)
- click.echo(f"Changed password of {user.name} ({user.email})")
+ user_name = click.style(user.name, fg=FG_USER_NAME)
+ user_email = click.style(user.email, fg=FG_USER_EMAIL)
+ click.echo(f"Changed password of {user_name} ({user_email})")
-@cli.command("maintenance-mode")
+@cmd_user.command("modify")
@config_option
-@click.option("--disable", help="disable the maintenance mode", is_flag=True)
-@click.argument("reason", required=False)
+@click.option("--admin/--no-admin", help="Make the user an admin.", default=None)
+@click.option("--verified/--no-verified", help="Set the user verification status.", default=None)
+@optgroup.group("User selection", cls=RequiredMutuallyExclusiveOptionGroup)
+@optgroup.option("--id", "-i", "id_", help="Database ID of the user.", type=int)
+@optgroup.option("--email", "-e", help="Email address of the user.")
@click.pass_context
-def cmd_maintenance_mode(ctx: click.Context, config: str, disable: bool, reason: Optional[str]):
- """Check the status of the maintenance mode, or activate or deactive it.
-
- When REASON is given, enables the maintenance mode with the given text as
- reason.
-
- With neither --disable nor REASON given, just checks the state of the
- maintenance mode.
- """
+def cms_user_modify(
+ ctx: click.Context,
+ config: str,
+ admin: Optional[bool],
+ verified: Optional[bool],
+ id_: Optional[int],
+ email: Optional[str],
+):
+ """Modify a user."""
env = setup(config)
- data_manager = env["request"].data_manager
- if disable and reason:
- click.echo("Cannot enable and disable maintenance mode at the same time", err=True)
- ctx.exit(EXIT_FAILURE)
- elif not disable and not reason:
- maintenance = data_manager.maintenance_mode()
- if maintenance is None:
- click.echo("Maintenance mode is disabled")
- else:
- click.echo(f"Maintenance mode is enabled: {maintenance}")
- elif disable:
- (data_manager.data_dir / "MAINTENANCE").unlink()
+ if id_ is not None:
+ query = select(models.User).filter_by(id=id_)
else:
- (data_manager.data_dir / "MAINTENANCE").write_text(reason, encoding="utf-8")
+ query = models.User.query_by_email(email)
+ with env["request"].tm:
+ dbsession = env["request"].dbsession
+ user = dbsession.execute(query).scalar_one_or_none()
+ if user is None:
+ click.echo("Error: No such user found.", err=True)
+ ctx.exit(EXIT_FAILURE)
+ if admin is not None:
+ user.is_admin = admin
+ if verified is not None:
+ user.is_verified = verified
-@cli.command("hittekaart")
+ user_name = click.style(user.name, fg=FG_USER_NAME)
+ user_email = click.style(user.email, fg=FG_USER_EMAIL)
+ click.echo(f"{user_name} - {user_email}")
+ click.echo(f"Is admin: {human_bool(user.is_admin)}")
+ click.echo(f"Is verified: {human_bool(user.is_verified)}")
+
+
+@cmd_user.command("hittekaart")
@config_option
@click.option(
"--mode",
"modes",
- help="Heatmap type to generate",
+ help="Heatmap type to generate.",
type=click.Choice([mode.value for mode in hittekaart.Mode]),
multiple=True,
default=["heatmap"],
)
-@click.option("--delete", help="Delete the specified heatmap", is_flag=True)
+@click.option("--delete", help="Delete the specified heatmap.", is_flag=True)
@optgroup.group("User selection", cls=RequiredMutuallyExclusiveOptionGroup)
-@optgroup.option("--id", "-i", "id_", help="database ID of the user", type=int)
-@optgroup.option("--email", "-e", help="email of the user")
+@optgroup.option("--id", "-i", "id_", help="Database ID of the user.", type=int)
+@optgroup.option("--email", "-e", help="Email address of the user.")
@click.pass_context
-def cmd_hittekaart(
+def cmd_user_hittekaart(
ctx: click.Context,
config: str,
modes: list[str],
@@ -273,7 +319,7 @@ def cmd_hittekaart(
user_manager.tilehunt_path().unlink(missing_ok=True)
return
- click.echo(f"Generating overlay maps for {user.name}...")
+ click.echo(f"Generating overlay maps for {click.style(user.name, fg=FG_USER_NAME)}...")
for mode in modes:
hittekaart.generate_for(
@@ -282,8 +328,119 @@ def cmd_hittekaart(
click.echo(f"Generated {mode.value}")
+@cli.group("track")
+def cmd_track():
+ """Management functions for tracks."""
+
+
+@cmd_track.command("list")
+@config_option
+def cmd_track_list(config: str):
+ """List all tracks that are present in the system."""
+ env = setup(config)
+ total_size = 0
+ total_tracks = 0
+ with env["request"].tm:
+ dbsession = env["request"].dbsession
+ data_manager: DataManager = env["request"].data_manager
+ tracks = dbsession.execute(select(models.Track)).scalars()
+ for track in tracks:
+ total_tracks += 1
+ try:
+ track_size = data_manager.open(track.id).size()
+ except FileNotFoundError:
+ size = "---"
+ else:
+ total_size += track_size
+ size = util.human_size(track_size)
+ size = click.style(f"{size:>10}", fg=FG_TRACK_SIZE)
+ owner_name = click.style(track.owner.name, fg=FG_USER_NAME)
+ owner_email = click.style(track.owner.email, fg=FG_USER_EMAIL)
+ track_title = click.style(track.title, fg=FG_TRACK_TITLE)
+ click.echo(f"{track.id:>4} - {size} - {owner_name} <{owner_email}> - {track_title}")
+ click.echo("-" * 80)
+ click.echo(
+ f"Total: {total_tracks} - {click.style(util.human_size(total_size), fg=FG_TRACK_SIZE)}"
+ )
+
+
+@cmd_track.command("del")
+@config_option
+@click.option("--force", "-f", help="Override the safety check.", is_flag=True)
+@click.option("--id", "-i", "id_", help="Database ID of the track.", type=int, required=True)
+@click.pass_context
+def cmd_track_del(
+ ctx: click.Context,
+ config: str,
+ force: bool,
+ id_: Optional[int],
+):
+ """Delete a track.
+
+ This command deletes the track as well as any images and comments
+ associated with it.
+
+ This command is destructive and irreversibly deletes data.
+ """
+ env = setup(config)
+ query = select(models.Track).filter_by(id=id_)
+ with env["request"].tm:
+ dbsession = env["request"].dbsession
+ track = dbsession.execute(query).scalar_one_or_none()
+ if track is None:
+ click.echo("Error: No such track found.", err=True)
+ ctx.exit(EXIT_FAILURE)
+ click.secho(track.title, fg=FG_TRACK_TITLE)
+ if not force:
+ if not click.confirm("Really delete this track?"):
+ click.echo("Aborted by user.")
+ ctx.exit(EXIT_FAILURE)
+ try:
+ data = env["request"].data_manager.open(track.id)
+ except FileNotFoundError:
+ LOGGER.warning("Data directory not found for track - ignoring")
+ else:
+ data.purge()
+ dbsession.delete(track)
+ click.echo("Track deleted")
+
+
+@cli.command("maintenance-mode")
+@config_option
+@click.option("--disable", help="Disable the maintenance mode.", is_flag=True)
+@click.argument("reason", required=False)
+@click.pass_context
+def cmd_maintenance_mode(ctx: click.Context, config: str, disable: bool, reason: Optional[str]):
+ """Controls the maintenance mode.
+
+ When REASON is given, enables the maintenance mode with the given text as
+ reason.
+
+ With neither --disable nor REASON given, just checks the state of the
+ maintenance mode.
+ """
+ env = setup(config)
+ data_manager = env["request"].data_manager
+ if disable and reason:
+ click.echo("Cannot enable and disable maintenance mode at the same time", err=True)
+ ctx.exit(EXIT_FAILURE)
+ elif not disable and not reason:
+ maintenance = data_manager.maintenance_mode()
+ if maintenance is None:
+ click.echo("Maintenance mode is disabled")
+ else:
+ click.echo(f"Maintenance mode is enabled: {maintenance}")
+ elif disable:
+ (data_manager.data_dir / "MAINTENANCE").unlink()
+ else:
+ (data_manager.data_dir / "MAINTENANCE").write_text(reason, encoding="utf-8")
+
+
@cli.command("version")
def cmd_version():
"""Show the installed fietsboek version."""
name = __name__.split(".", 1)[0]
print(f"{name} {__VERSION__}")
+
+
+__all__ = ["cli"]
diff --git a/fietsboek/static/theme.css b/fietsboek/static/theme.css
index d2e7f9d..d97192b 100644
--- a/fietsboek/static/theme.css
+++ b/fietsboek/static/theme.css
@@ -9,6 +9,10 @@ strong {
font-weight: 700;
}
+.brand-link {
+ text-decoration: none;
+}
+
.badge-container {
width: 50px;
height: 50px;
diff --git a/fietsboek/static/theme.css.map b/fietsboek/static/theme.css.map
index 9d4524a..632d85f 100644
--- a/fietsboek/static/theme.css.map
+++ b/fietsboek/static/theme.css.map
@@ -1 +1 @@
-{"version":3,"sourceRoot":"","sources":["../../asset-sources/theme.scss"],"names":[],"mappings":"AAAA;EACE;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EAqCE;EACA;EACA;EACA;EACA;EACA;;AAzCA;EACE;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAWJ;EACI;;;AAGJ;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;;AAEA;EACE;EACA;;AAGF;EACE;;;AAIJ;AACA;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE","file":"theme.css"} \ No newline at end of file
+{"version":3,"sourceRoot":"","sources":["../../asset-sources/theme.scss"],"names":[],"mappings":"AAAA;EACE;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EAqCE;EACA;EACA;EACA;EACA;EACA;;AAzCA;EACE;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAWJ;EACI;;;AAGJ;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;;AAEA;EACE;EACA;;AAGF;EACE;;;AAIJ;AACA;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE","file":"theme.css"} \ No newline at end of file
diff --git a/fietsboek/templates/home.jinja2 b/fietsboek/templates/home.jinja2
index e89c9bd..53eefc7 100644
--- a/fietsboek/templates/home.jinja2
+++ b/fietsboek/templates/home.jinja2
@@ -2,7 +2,16 @@
{% block content %}
<div class="container">
- <h1>{{ _("page.home.title") }}</h1>
+ <div class="clearfix">
+ <h1 class="float-start">{{ _("page.home.title") }}</h1>
+ {% if summary %}
+ <div class="float-end mb-2 mt-2">
+ <button type="button" class="btn btn-outline-dark" id="changeHomeSorting">
+ {% if sorted_ascending %}<i class="bi bi-sort-down"></i>{% else %}<i class="bi bi-sort-up"></i>{% endif %}
+ </button>
+ </div>
+ {% endif %}
+ </div>
{% if unfinished_uploads %}
<div class="alert alert-warning">
{{ _("page.home.unfinished_uploads") }}
@@ -14,11 +23,6 @@
</div>
{% endif %}
{% if summary %}
- <div class="d-flex justify-content-end mb-2">
- <button type="button" class="btn btn-outline-dark" id="changeHomeSorting">
- {% if sorted_ascending %}<i class="bi bi-sort-down"></i>{% else %}<i class="bi bi-sort-up"></i>{% endif %}
- </button>
- </div>
<div class="list-group list-group-root">
{% for year in summary %}
<a class="list-group-item list-group-item-primary">
diff --git a/fietsboek/templates/layout.jinja2 b/fietsboek/templates/layout.jinja2
index b6fcea0..3206d97 100644
--- a/fietsboek/templates/layout.jinja2
+++ b/fietsboek/templates/layout.jinja2
@@ -37,7 +37,9 @@ const Legende = false;
<body>
<nav class="navbar navbar-dark bg-dark navbar-expand-lg">
<div class="container-fluid">
- <span class="navbar-brand mb-0 h1"><i class="bi bi-bicycle"></i> Fietsboek</span>
+ <a href="{{ request.route_url('home') }}" class="brand-link">
+ <span class="navbar-brand mb-0 h1"><i class="bi bi-bicycle"></i> Fietsboek</span>
+ </a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar" aria-controls="navbar" aria-expanded="false" aria-label="{{ _('page.navbar.toggle') }}">
<span class="navbar-toggler-icon"></span>
</button>
@@ -117,7 +119,7 @@ const Legende = false;
<!-- Placed at the end of the document so the pages load faster -->
<script src="{{request.static_url('fietsboek:static/bootstrap.bundle.min.js')}}"></script>
<!-- Pre-load leaflet Javascript. This lets us use Leaflet on any page, without relying on GPXViewer to load it -->
- <script src="{{request.static_url('fietsboek:static/GM_Utils/leaflet/leaflet.js')}}">
+ <script src="{{request.static_url('fietsboek:static/GM_Utils/leaflet/leaflet.js')}}"></script>
<!-- Our patch to the GPX viewer, load before the actual GPX viewer -->
<script src="{{request.static_url('fietsboek:static/osm-monkeypatch.js')}}"></script>
<!-- Jürgen Berkemeier's GPX viewer -->
diff --git a/fietsboek/templates/login.jinja2 b/fietsboek/templates/login.jinja2
index 7a9bf07..bdf2c99 100644
--- a/fietsboek/templates/login.jinja2
+++ b/fietsboek/templates/login.jinja2
@@ -41,6 +41,8 @@
<div class="row justify-content-center">
<div class="col-auto mb-3">
<a href="{{ request.route_url("password-reset") }}">{{ _("page.login.forgot_password") }}</a>
+ &bull;
+ <a href="{{ request.route_url("resend-verification") }}">{{ _("page.login.resend_verification") }}</a>
</div>
</div>
</form>
diff --git a/fietsboek/templates/resend_verification.jinja2 b/fietsboek/templates/resend_verification.jinja2
new file mode 100644
index 0000000..cc56854
--- /dev/null
+++ b/fietsboek/templates/resend_verification.jinja2
@@ -0,0 +1,22 @@
+{% extends "layout.jinja2" %}
+{% import "util.jinja2" as util with context %}
+{% block content %}
+<div class="container">
+ <h1>{{ _("page.resend_verification.title") }}</h1>
+ <p>{{ _("page.resend_verification.info") }}</p>
+ <form method="POST">
+ <div class="row align-items-center">
+ <div class="col-lg-5">
+ <div class="form-floating">
+ <input type="email" id="resendEmail" name="email" class="form-control" placeholder="x">
+ <label for="resendEmail">{{ _("page.resend_verification.email") }}</label>
+ </div>
+ </div>
+ {{ util.hidden_csrf_input() }}
+ <div class="col-lg-4">
+ <button class="btn btn-primary">{{ _("page.resend_verification.request") }}</button>
+ </div>
+ </div>
+ </form>
+</div>
+{% endblock %}
diff --git a/fietsboek/util.py b/fietsboek/util.py
index a296151..04f5551 100644
--- a/fietsboek/util.py
+++ b/fietsboek/util.py
@@ -1,5 +1,6 @@
"""Various utility functions."""
import datetime
+import html
import importlib.resources
import os
import re
@@ -65,9 +66,9 @@ def safe_markdown(md_source: str) -> Markup:
:param md_source: The markdown source.
:return: The safe HTML transformed version.
"""
- html = markdown.markdown(md_source, output_format="html")
- html = nh3.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES)
- return Markup(html)
+ converted = markdown.markdown(md_source, output_format="html")
+ converted = nh3.clean(converted, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES)
+ return Markup(converted)
def fix_iso_timestamp(timestamp: str) -> str:
@@ -202,6 +203,29 @@ def mps_to_kph(mps: float) -> float:
return mps / 1000 * 60 * 60
+def human_size(num_bytes: int) -> str:
+ """Formats the amount of bytes for human consumption.
+
+ :param num_bytes: The amount of bytes.
+ :return: The formatted amount.
+ """
+ num_bytes = float(num_bytes)
+ suffixes = ["B", "KiB", "MiB", "GiB"]
+ prefix = ""
+ if num_bytes < 0:
+ prefix = "-"
+ num_bytes = -num_bytes
+ for suffix in suffixes:
+ if num_bytes < 1024 or suffix == suffixes[-1]:
+ if suffix == "B":
+ # Don't do the decimal point for bytes
+ return f"{prefix}{int(num_bytes)} {suffix}"
+ return f"{prefix}{num_bytes:.1f} {suffix}"
+ num_bytes /= 1024
+ # Unreachable:
+ return ""
+
+
def month_name(request: Request, month: int) -> str:
"""Returns the localized name for the month with the given number.
@@ -354,6 +378,22 @@ def tile_url(request: Request, route_name: str, **kwargs: str) -> str:
return route.replace("__x__", "{x}").replace("__y__", "{y}").replace("__z__", "{z}")
+def encode_gpx(gpx: gpxpy.gpx.GPX) -> bytes:
+ """Encodes a GPX in-memory representation to the XML representation.
+
+ This ensures that the resulting XML file is valid.
+
+ Returns the contents of the XML as bytes, ready to be written to disk.
+
+ :param gpx: The GPX file to encode. Might be modified!
+ :return: The encoded GPX content.
+ """
+ for track in gpx.tracks:
+ if track.link:
+ track.link = html.escape(track.link)
+ return gpx.to_xml().encode("utf-8")
+
+
def secure_filename(filename: str) -> str:
r"""Pass it a filename and it will return a secure version of it. This
filename can then safely be stored on a regular file system and passed
diff --git a/fietsboek/views/account.py b/fietsboek/views/account.py
index 39a62e5..5400f0a 100644
--- a/fietsboek/views/account.py
+++ b/fietsboek/views/account.py
@@ -3,8 +3,7 @@ from pyramid.httpexceptions import HTTPForbidden, HTTPFound
from pyramid.i18n import TranslationString as _
from pyramid.view import view_config
-from .. import email, models, util
-from ..models.user import TokenType
+from .. import actions, models, util
@view_config(
@@ -63,25 +62,7 @@ def do_create_account(request):
user.set_password(password)
request.dbsession.add(user)
- token = models.Token.generate(user, TokenType.VERIFY_EMAIL)
- request.dbsession.add(token)
-
- message = email.prepare_message(
- request.config.email_from,
- user.email,
- request.localizer.translate(_("email.verify_mail.subject")),
- )
- message.set_content(
- request.localizer.translate(_("email.verify.text")).format(
- request.route_url("use-token", uuid=token.uuid)
- )
- )
- email.send_message(
- request.config.email_smtp_url,
- request.config.email_username,
- request.config.email_password.get_secret_value(),
- message,
- )
+ actions.send_verification_token(request, user)
request.session.flash(request.localizer.translate(_("flash.a_confirmation_link_has_been_sent")))
return HTTPFound(request.route_url("login"))
diff --git a/fietsboek/views/default.py b/fietsboek/views/default.py
index 08f32ce..f9942c3 100644
--- a/fietsboek/views/default.py
+++ b/fietsboek/views/default.py
@@ -4,25 +4,25 @@ from pyramid.httpexceptions import HTTPFound, HTTPNotFound
from pyramid.i18n import TranslationString as _
from pyramid.interfaces import ISecurityPolicy
from pyramid.renderers import render_to_response
+from pyramid.request import Request
+from pyramid.response import Response
from pyramid.security import forget, remember
from pyramid.view import view_config
from sqlalchemy import select
from sqlalchemy.exc import NoResultFound
from sqlalchemy.orm import aliased
-from .. import email, models, summaries, util
+from .. import actions, email, models, summaries, util
from ..models.track import TrackType, TrackWithMetadata
-from ..models.user import PasswordMismatch, TokenType
+from ..models.user import TOKEN_LIFETIME, PasswordMismatch, TokenType
@view_config(route_name="home", renderer="fietsboek:templates/home.jinja2")
-def home(request):
+def home(request: Request) -> Response:
"""Renders the home page.
:param request: The Pyramid request.
- :type request: pyramid.request.Request
:return: The HTTP response.
- :rtype: pyramid.response.Response
"""
if not request.identity:
# See if the admin set a custom home page
@@ -73,13 +73,11 @@ def home(request):
@view_config(route_name="static-page", renderer="fietsboek:templates/static-page.jinja2")
-def static_page(request):
+def static_page(request: Request) -> Response:
"""Renders a static page.
:param request: The Pyramid request.
- :type request: pyramid.request.Request
:return: The HTTP response.
- :rtype: pyramid.response.Response
"""
page = request.pages.find(request.matchdict["slug"], request)
if page is None:
@@ -92,26 +90,22 @@ def static_page(request):
@view_config(route_name="login", renderer="fietsboek:templates/login.jinja2", request_method="GET")
-def login(request):
+def login(request: Request) -> Response:
"""Renders the login page.
:param request: The Pyramid request.
- :type request: pyramid.request.Request
:return: The HTTP response.
- :rtype: pyramid.response.Response
"""
# pylint: disable=unused-argument
return {}
@view_config(route_name="login", request_method="POST")
-def do_login(request):
+def do_login(request: Request) -> Response:
"""Endpoint for the login form.
:param request: The Pyramid request.
- :type request: pyramid.request.Request
:return: The HTTP response.
- :rtype: pyramid.response.Response
"""
query = models.User.query_by_email(request.params["email"])
try:
@@ -139,13 +133,11 @@ def do_login(request):
@view_config(route_name="logout")
-def logout(request):
+def logout(request: Request) -> Response:
"""Logs the user out.
:param request: The Pyramid request.
- :type request: pyramid.request.Request
:return: The HTTP response.
- :rtype: pyramid.response.Response
"""
request.session.flash(request.localizer.translate(_("flash.logged_out")))
headers = forget(request)
@@ -157,26 +149,22 @@ def logout(request):
request_method="GET",
renderer="fietsboek:templates/request_password.jinja2",
)
-def password_reset(request):
+def password_reset(request: Request) -> Response:
"""Form to request a new password.
:param request: The Pyramid request.
- :type request: pyramid.request.Request
:return: The HTTP response.
- :rtype: pyramid.response.Response
"""
# pylint: disable=unused-argument
return {}
@view_config(route_name="password-reset", request_method="POST")
-def do_password_reset(request):
+def do_password_reset(request: Request) -> Response:
"""Endpoint for the password request form.
:param request: The Pyramid request.
- :type request: pyramid.request.Request
:return: The HTTP response.
- :rtype: pyramid.response.Response
"""
query = models.User.query_by_email(request.params["email"])
user = request.dbsession.execute(query).scalar_one_or_none()
@@ -208,15 +196,48 @@ def do_password_reset(request):
return HTTPFound(request.route_url("password-reset"))
+@view_config(
+ route_name="resend-verification",
+ request_method="GET",
+ renderer="fietsboek:templates/resend_verification.jinja2",
+)
+def resend_verification(_request: Request) -> Response:
+ """Form to request a new verification mail.
+
+ :param request: The Pyramid request.
+ :return: The HTTP response.
+ """
+ return {}
+
+
+@view_config(route_name="resend-verification", request_method="POST")
+def do_resend_verification(request: Request) -> Response:
+ """Endpoint for the verification resend form.
+
+ :param request: The Pyramid request.
+ :return: The HTTP response.
+ """
+ query = models.User.query_by_email(request.params["email"])
+ user = request.dbsession.execute(query).scalar_one_or_none()
+ if user is None or user.is_verified:
+ request.session.flash(
+ request.localizer.translate(_("flash.resend_verification_email_fail"))
+ )
+ return HTTPFound(request.route_url("resend-verification"))
+
+ actions.send_verification_token(request, user)
+ request.session.flash(request.localizer.translate(_("flash.verification_token_generated")))
+
+ return HTTPFound(request.route_url("login"))
+
+
@view_config(route_name="use-token")
-def use_token(request):
+def use_token(request: Request) -> Response:
"""Endpoint with which a user can use a token for a password reset or email
verification.
:param request: The Pyramid request.
- :type request: pyramid.request.Request
:return: The HTTP response.
- :rtype: pyramid.response.Response
"""
token = request.dbsession.execute(
select(models.Token).filter_by(uuid=request.matchdict["uuid"])
@@ -224,6 +245,10 @@ def use_token(request):
if token is None:
return HTTPNotFound()
+ if token.age() > TOKEN_LIFETIME:
+ request.session.flash(request.localizer.translate(_("flash.token_expired")))
+ return HTTPFound(request.route_url("home"))
+
if token.token_type == TokenType.VERIFY_EMAIL:
token.user.is_verified = True
request.dbsession.delete(token)
@@ -243,4 +268,4 @@ def use_token(request):
request.dbsession.delete(token)
request.session.flash(request.localizer.translate(_("flash.password_updated")))
return HTTPFound(request.route_url("login"))
- return None
+ raise NotImplementedError("No matching action found")
diff --git a/fietsboek/views/detail.py b/fietsboek/views/detail.py
index f540a11..ec66922 100644
--- a/fietsboek/views/detail.py
+++ b/fietsboek/views/detail.py
@@ -20,6 +20,17 @@ from ..models.track import TrackWithMetadata
LOGGER = logging.getLogger(__name__)
+def _sort_key(image_name: str) -> str:
+ """Returns the "key" by which the image should be sorted.
+
+ :param image_name: Name of the image (on disk).
+ :return: The value that should be used to sort the image.
+ """
+ if "_" not in image_name:
+ return image_name
+ return image_name.split("_", 1)[1]
+
+
@view_config(
route_name="details", renderer="fietsboek:templates/details.jinja2", permission="track.view"
)
@@ -51,9 +62,13 @@ def details(request):
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))
+ images.append((_sort_key(image_name), img_src, image_metadata.description))
else:
- images.append((img_src, ""))
+ images.append((_sort_key(image_name), img_src, ""))
+
+ images.sort(key=lambda element: element[0])
+ # Strip off the sort key again
+ images = [(image[1], image[2]) for image in images]
with_meta = TrackWithMetadata(track, request.data_manager)
return {
diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py
index e73df42..c7f932d 100644
--- a/fietsboek/views/profile.py
+++ b/fietsboek/views/profile.py
@@ -16,6 +16,31 @@ from .. import models, util
from ..data import UserDataDir
from ..models.track import TrackType, TrackWithMetadata
+# A well-made transparent tile is actually pretty small (only 116 bytes), which
+# is even smaller than our HTTP 404 page. So not only is it more efficient
+# (bandwidth-wise) to transfer the transparent PNG, it also means that we can
+# set cache headers, which the client will honor.
+#
+# Since the tile is so small, we've embedded it right here in the source.
+#
+# The tile is taken and adapted from hittekaart, which in turn took inspiration
+# from https://www.mjt.me.uk/posts/smallest-png/ and
+# http://www.libpng.org/pub/png/spec/1.2/PNG-Contents.html
+# fmt: off
+EMPTY_TILE = bytes([
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
+ 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00,
+ 0x01, 0x03, 0x00, 0x00, 0x00, 0x66, 0xbc, 0x3a, 0x25, 0x00, 0x00, 0x00,
+ 0x03, 0x50, 0x4c, 0x54, 0x45, 0x00, 0xff, 0x00, 0x34, 0x5e, 0xc0, 0xa8,
+ 0x00, 0x00, 0x00, 0x01, 0x74, 0x52, 0x4e, 0x53, 0x00, 0x40, 0xe6, 0xd8,
+ 0x66, 0x00, 0x00, 0x00, 0x1f, 0x49, 0x44, 0x41, 0x54, 0x68, 0x81, 0xed,
+ 0xc1, 0x01, 0x0d, 0x00, 0x00, 0x00, 0xc2, 0xa0, 0xf7, 0x4f, 0x6d, 0x0e,
+ 0x37, 0xa0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbe, 0x0d,
+ 0x21, 0x00, 0x00, 0x01, 0x9a, 0x60, 0xe1, 0xd5, 0x00, 0x00, 0x00, 0x00,
+ 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
+])
+# fmt: on
+
@dataclass
class CumulativeStats:
@@ -157,7 +182,12 @@ def profile(request: Request) -> dict:
}
-@view_config(route_name="user-tile", request_method="GET", permission="profile.view")
+@view_config(
+ route_name="user-tile",
+ request_method="GET",
+ permission="profile.view",
+ http_cache=datetime.timedelta(hours=1),
+)
def user_tile(request: Request) -> Response:
"""Returns a single tile from the user's own overlay maps.
@@ -194,7 +224,7 @@ def user_tile(request: Request) -> Response:
)
result = result.fetchone()
if result is None:
- return HTTPNotFound()
+ return Response(EMPTY_TILE, content_type="image/png")
return Response(result[0], content_type="image/png")
diff --git a/tests/integration/test_register.py b/tests/integration/test_register.py
index af1e313..dc7b00a 100644
--- a/tests/integration/test_register.py
+++ b/tests/integration/test_register.py
@@ -1,19 +1,28 @@
import re
+import pytest
+
import fietsboek.email
from fietsboek import models
VERIFICATION_LINK_PATTERN = re.compile("http://example.com(/token/[A-Za-z0-9-]+)")
-def test_registration_working(testapp, dbsession, route_path, monkeypatch):
- """Ensures that a user can register, including using the verification link."""
+@pytest.fixture
+def mailcatcher(monkeypatch):
+ """Monkeypatches the send mail functionality.
+
+ Returns the list of mails sent.
+ """
mails = []
- def send_message(server_url, username, password, message):
+ def send_message(_server_url, _username, _passwords, message):
mails.append(message)
-
monkeypatch.setattr(fietsboek.email, "send_message", send_message)
+ yield mails
+
+def test_registration_working(testapp, dbsession, route_path, mailcatcher):
+ """Ensures that a user can register, including using the verification link."""
registration = testapp.get(route_path('create-account'))
form = registration.form
form['email'] = 'foo-new@bar.com'
@@ -23,18 +32,37 @@ def test_registration_working(testapp, dbsession, route_path, monkeypatch):
response = form.submit().maybe_follow()
assert b'A confirmation link has been sent' in response.body
- assert len(mails) == 1
+ assert len(mailcatcher) == 1
user = dbsession.execute(models.User.query_by_email('foo-new@bar.com')).scalar_one()
assert not user.is_verified
- body = mails[0].get_body().get_content()
+ body = mailcatcher[0].get_body().get_content()
token_path = VERIFICATION_LINK_PATTERN.search(body).group(1)
testapp.get(token_path)
assert user.is_verified
+def test_resend_verification_mail(testapp, dbsession, route_path, mailcatcher):
+ """Ensures that the verification link re-sending works."""
+ registration = testapp.get(route_path('create-account'))
+ form = registration.form
+ form['email'] = 'foo-new@bar.com'
+ form['name'] = 'The new Foo'
+ form['password'] = 'foobarpassword'
+ form['repeat-password'] = 'foobarpassword'
+ form.submit().maybe_follow()
+
+ req = testapp.get(route_path('resend-verification'))
+ req.form['email'] = 'foo-new@bar.com'
+ req.form.submit().maybe_follow()
+
+ assert len(mailcatcher) == 2
+ assert VERIFICATION_LINK_PATTERN.search(mailcatcher[0].get_body().get_content())
+ assert VERIFICATION_LINK_PATTERN.search(mailcatcher[1].get_body().get_content())
+
+
def test_registration_short_password(testapp, route_path):
"""Ensures that passwords that are too short are rejected."""
registration = testapp.get(route_path('create-account'))
diff --git a/tests/integration/test_smoke.py b/tests/integration/test_smoke.py
index 5176e88..cead68d 100644
--- a/tests/integration/test_smoke.py
+++ b/tests/integration/test_smoke.py
@@ -1,7 +1,7 @@
def test_home(testapp):
res = testapp.get("/")
assert res.status_code == 200
- assert b'<h1>Home</h1>' in res.body
+ assert b'<h1 class="float-start">Home</h1>' in res.body
def test_maintenance(testapp, data_manager):
diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py
index 1a56911..6dc8e7d 100644
--- a/tests/unit/test_util.py
+++ b/tests/unit/test_util.py
@@ -88,6 +88,22 @@ def test_tour_metadata(gpx_file):
def test_mps_to_kph(mps, kph):
assert util.mps_to_kph(mps) == pytest.approx(kph, 0.1)
+@pytest.mark.parametrize('num_bytes, expected', [
+ (1, '1 B'),
+ (1023, '1023 B'),
+ (1024, '1.0 KiB'),
+ (1536, '1.5 KiB'),
+ (1024 ** 2, '1.0 MiB'),
+ (1024 ** 3, '1.0 GiB'),
+ (0, '0 B'),
+ # Negative sizes in itself are a bit weird, but they make sense as the
+ # difference between two size values, so they should still work.
+ (-1, '-1 B'),
+ (-1024, '-1.0 KiB'),
+])
+def test_human_size(num_bytes, expected):
+ assert util.human_size(num_bytes) == expected
+
def test_tile_url(app_request):
route_url = util.tile_url(app_request, "tile-proxy", provider="bobby")
diff --git a/tox.ini b/tox.ini
index 9eb73d0..d922d4b 100644
--- a/tox.ini
+++ b/tox.ini
@@ -44,13 +44,17 @@ commands =
flake8 fietsboek
[testenv:sphinx]
-allowlist_externals = make
+allowlist_externals =
+ make
+ mkdir
changedir={toxinidir}{/}doc
commands_pre =
poetry install -v --with docs
commands =
sphinx-apidoc -d 1 -f -M -e -o developer/module/ ../fietsboek "upd_*"
make html
+ mkdir -p _build/man
+ rst2man.py man/fietsctl.rst _build/man/fietsctl.1
[testenv:mypy]
commands_pre =