aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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/data.py11
-rw-r--r--fietsboek/scripts/fietsctl.py263
-rw-r--r--fietsboek/util.py23
-rw-r--r--tests/unit/test_util.py16
-rw-r--r--tox.ini6
9 files changed, 542 insertions, 55 deletions
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/data.py b/fietsboek/data.py
index debddf1..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
@@ -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.
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/util.py b/fietsboek/util.py
index 199baab..04f5551 100644
--- a/fietsboek/util.py
+++ b/fietsboek/util.py
@@ -203,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.
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 =