From 577ef97bbfa01d0e9a233cd11c9bb7bd96f4c9b2 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Tue, 25 Mar 2025 22:28:59 +0100 Subject: first working admin site size stats Still needs a bit of a design touchup and proper localozation. Also opens up the way for a cleaner admin interface and proper user/track lists in the admin panel. --- asset-sources/theme.scss | 20 +++++++ fietsboek/data.py | 27 ++++++++-- fietsboek/routes.py | 9 ++-- fietsboek/static/theme.css | 20 +++++++ fietsboek/static/theme.css.map | 2 +- fietsboek/templates/admin.jinja2 | 49 ++++------------- fietsboek/templates/admin_badges.jinja2 | 45 ++++++++++++++++ fietsboek/templates/admin_overview.jinja2 | 62 ++++++++++++++++++++++ fietsboek/util.py | 14 +++++ fietsboek/views/admin.py | 88 +++++++++++++++++++++++++++---- 10 files changed, 279 insertions(+), 57 deletions(-) create mode 100644 fietsboek/templates/admin_badges.jinja2 create mode 100644 fietsboek/templates/admin_overview.jinja2 diff --git a/asset-sources/theme.scss b/asset-sources/theme.scss index 7f89bf6..2fcb278 100644 --- a/asset-sources/theme.scss +++ b/asset-sources/theme.scss @@ -284,6 +284,26 @@ strong { text-align: center; } +/* Admin view layout: We have an extra sidebar for the navigation */ +#adminContainer { + display: grid; + grid-template-areas: "sidebar main"; + grid-template-columns: 1fr 5fr; + gap: 1rem; +} + +#adminNavigation { + grid-area: sidebar; +} + +#adminContent { + grid-area: main; +} + +.admin-stat { + font-size: 120%; +} + .list-group.list-group-root { padding: 0; overflow: hidden; diff --git a/fietsboek/data.py b/fietsboek/data.py index a7e9b19..459d0ce 100644 --- a/fietsboek/data.py +++ b/fietsboek/data.py @@ -9,7 +9,6 @@ the database itself. This module makes access to such data objects easier. # pylint: disable=deprecated-argument import datetime import logging -import os import random import shutil import string @@ -130,6 +129,27 @@ class DataManager: raise FileNotFoundError(f"The path {path} is not a directory") from None return UserDataDir(user_id, path) + def size(self) -> int: + """Returns the size of all data. + + :return: The size of all data in bytes. + """ + return util.recursive_size(self.data_dir) + + def list_tracks(self) -> list[int]: + """Returns a list of all tracks. + + :return: A list of all track IDs. + """ + return [int(track.name) for track in self._track_data_dir(".").iterdir()] + + def list_users(self) -> list[int]: + """Returns a list of all users. + + :return: A list of all user IDs. + """ + return [int(user.name) for user in self._user_data_dir(".").iterdir()] + class TrackDataDir: """Manager for a single track's data. @@ -252,10 +272,7 @@ class TrackDataDir: :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 + return util.recursive_size(self.path) def gpx_path(self) -> Path: """Returns the path of the GPX file. diff --git a/fietsboek/routes.py b/fietsboek/routes.py index 07e73cf..325e942 100644 --- a/fietsboek/routes.py +++ b/fietsboek/routes.py @@ -49,10 +49,11 @@ def includeme(config): config.add_route("badge", "/badge/{badge_id}", factory="fietsboek.models.Badge.factory") - config.add_route("admin", "/admin") - config.add_route("admin-badge-add", "/admin/add-badge") - config.add_route("admin-badge-edit", "/admin/edit-badge") - config.add_route("admin-badge-delete", "/admin/delete-badge") + config.add_route("admin", "/admin/") + config.add_route("admin-badge", "/admin/badges/") + config.add_route("admin-badge-add", "/admin/badges/add") + config.add_route("admin-badge-edit", "/admin/badges/edit") + config.add_route("admin-badge-delete", "/admin/badges/delete") config.add_route("user-data", "/me") config.add_route("add-friend", "/me/send-friend-request") diff --git a/fietsboek/static/theme.css b/fietsboek/static/theme.css index 2298b49..f048267 100644 --- a/fietsboek/static/theme.css +++ b/fietsboek/static/theme.css @@ -272,6 +272,26 @@ strong { text-align: center; } +/* Admin view layout: We have an extra sidebar for the navigation */ +#adminContainer { + display: grid; + grid-template-areas: "sidebar main"; + grid-template-columns: 1fr 5fr; + gap: 1rem; +} + +#adminNavigation { + grid-area: sidebar; +} + +#adminContent { + grid-area: main; +} + +.admin-stat { + font-size: 120%; +} + .list-group.list-group-root { padding: 0; overflow: hidden; diff --git a/fietsboek/static/theme.css.map b/fietsboek/static/theme.css.map index 08ac64f..e3b2de3 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;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;AAEA;EACE;EACA;EACA;;;AAIJ;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;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;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;;AAEA;EACE;EACA;;AAGF;EACE;;;AAIJ;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;EACA;;;AAEF;EACE;;AAEA;EACE;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;AAEA;EACE;EACA;;AAGF;EACE;EACA;;AAIJ;EACE;;AAEF;EACE;;AACA;EACE;;AAGJ;EACE;;AACA;EACE;;AAGJ;EACE;;AACA;EACE;;AAGJ;EACE;;AACA;EACE;;AAGJ;EACE;EACA;;AACA;EACE;;AAIJ;EACE;EACA;;;AAIJ;EACE;;;AAGF;AACA;EACE;;;AAGF;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;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;AAEA;EACE;EACA;EACA;;;AAIJ;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;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;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;;AAEA;EACE;EACA;;AAGF;EACE;;;AAIJ;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;EACA;;;AAEF;EACE;;AAEA;EACE;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;AAEA;EACE;EACA;;AAGF;EACE;EACA;;AAIJ;EACE;;AAEF;EACE;;AACA;EACE;;AAGJ;EACE;;AACA;EACE;;AAGJ;EACE;;AACA;EACE;;AAGJ;EACE;;AACA;EACE;;AAGJ;EACE;EACA;;AACA;EACE;;AAIJ;EACE;EACA;;;AAIJ;EACE;;;AAGF;AACA;EACE;;;AAGF;EACE;;;AAGF;AACA;EACE;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;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/admin.jinja2 b/fietsboek/templates/admin.jinja2 index 3201d8d..0cb9358 100644 --- a/fietsboek/templates/admin.jinja2 +++ b/fietsboek/templates/admin.jinja2 @@ -4,45 +4,18 @@

{{ _("page.admin.title") }}

-

{{ _("page.admin.badges") }}

+
+ -
- {% for badge in badges %} - - {{ util.render_badge(badge) }} -
- -
- -
-
- -
- {{ util.hidden_csrf_input() }} -
- -
-
-
- - {{ util.hidden_csrf_input() }} - -
-
- {% endfor %} -
- -
-
- - +
+ {% block admin_content %} + {% endblock %}
-
- - -
- {{ util.hidden_csrf_input() }} - - +
{% endblock %} diff --git a/fietsboek/templates/admin_badges.jinja2 b/fietsboek/templates/admin_badges.jinja2 new file mode 100644 index 0000000..fdc3608 --- /dev/null +++ b/fietsboek/templates/admin_badges.jinja2 @@ -0,0 +1,45 @@ +{% set admin_index = 1 %} +{% extends "admin.jinja2" %} +{% import "util.jinja2" as util with context %} +{% block admin_content %} +

{{ _("page.admin.badges") }}

+ +
+ {% for badge in badges %} + + {{ util.render_badge(badge) }} +
+ +
+ +
+
+ +
+ {{ util.hidden_csrf_input() }} +
+ +
+
+
+ + {{ util.hidden_csrf_input() }} + +
+
+ {% endfor %} +
+ +
+
+ + +
+
+ + +
+ {{ util.hidden_csrf_input() }} + +
+{% endblock %} diff --git a/fietsboek/templates/admin_overview.jinja2 b/fietsboek/templates/admin_overview.jinja2 new file mode 100644 index 0000000..0e1bac8 --- /dev/null +++ b/fietsboek/templates/admin_overview.jinja2 @@ -0,0 +1,62 @@ +{% set admin_index = 0 %} +{% extends "admin.jinja2" %} +{% block admin_content %} +

+ This instance has… +

+ +

+ … {{ user_count }} users +

+ +

+ … {{ track_count }} tracks +

+ +

+ … {{ (total_size / 1024 / 1024) | round(2) }} MiB bytes of data +

+ +
+ +
+{% endblock %} + +{% block latescripts %} + +{% endblock %} diff --git a/fietsboek/util.py b/fietsboek/util.py index 9284ce2..5a41b3f 100644 --- a/fietsboek/util.py +++ b/fietsboek/util.py @@ -7,6 +7,7 @@ import os import re import secrets import unicodedata +from pathlib import Path from typing import Optional, TypeVar, Union import babel @@ -504,6 +505,18 @@ def secure_filename(filename: str) -> str: return filename +def recursive_size(path: Path) -> int: + """Recursively determines the size of the given directory. + + :param path: The directory. + :return: The combined size, in bytes. + """ + size = 0 + for root, _, files in os.walk(path): + size += sum(os.path.getsize(os.path.join(root, fname)) for fname in files) + return size + + __all__ = [ "ALLOWED_TAGS", "ALLOWED_ATTRIBUTES", @@ -529,4 +542,5 @@ __all__ = [ "tile_url", "encode_gpx", "secure_filename", + "recursive_size", ] diff --git a/fietsboek/views/admin.py b/fietsboek/views/admin.py index d078794..78eec12 100644 --- a/fietsboek/views/admin.py +++ b/fietsboek/views/admin.py @@ -1,21 +1,91 @@ """Admin views.""" +import stat +from dataclasses import dataclass +from pathlib import Path + from pyramid.httpexceptions import HTTPFound from pyramid.i18n import TranslationString as _ +from pyramid.request import Request from pyramid.view import view_config -from sqlalchemy import select +from sqlalchemy import func, select + +from .. import models, util + + +def _safe_size(path: Path) -> int: + try: + res = path.stat() + if stat.S_ISDIR(res.st_mode): + return util.recursive_size(path) + if stat.S_ISREG(res.st_mode): + return res.st_size + return 0 + except FileNotFoundError: + return 0 + + +@dataclass +class SizeBreakdown: + """A breakdown of what objects take how much storage.""" -from .. import models + gpx_files: int = 0 + image_files: int = 0 + user_maps: int = 0 + + +def _get_size_breakdown(data_manager): + breakdown = SizeBreakdown() + + for track_id in data_manager.list_tracks(): + track = data_manager.open(track_id) + breakdown.gpx_files += _safe_size(track.gpx_path()) + for image_id in track.images(): + breakdown.image_files += _safe_size(track.image_path(image_id)) + + for user_id in data_manager.list_users(): + user = data_manager.open_user(user_id) + breakdown.user_maps += _safe_size(user.heatmap_path()) + breakdown.user_maps += _safe_size(user.tilehunt_path()) + + return breakdown @view_config( route_name="admin", - renderer="fietsboek:templates/admin.jinja2", + renderer="fietsboek:templates/admin_overview.jinja2", + request_method="GET", + permission="admin", +) +def admin(request: Request): + """Renders the admin overview. + + :param request: The Pyramid request. + :return: The HTTP response. + """ + # False-positive with func.count() + # pylint: disable=not-callable + user_count = request.dbsession.execute(select(func.count()).select_from(models.User)).scalar() + track_count = request.dbsession.execute(select(func.count()).select_from(models.Track)).scalar() + size_total = request.data_manager.size() + size_breakdown = _get_size_breakdown(request.data_manager) + + return { + "user_count": user_count, + "track_count": track_count, + "total_size": size_total, + "size_breakdown": size_breakdown, + } + + +@view_config( + route_name="admin-badge", + renderer="fietsboek:templates/admin_badges.jinja2", request_method="GET", permission="admin", ) -def admin(request): - """Renders the main admin overview. +def admin_badges(request): + """Renders the badges editor. :param request: The Pyramid request. :type request: pyramid.request.Request @@ -47,7 +117,7 @@ def do_badge_add(request): request.dbsession.add(badge) request.session.flash(request.localizer.translate(_("flash.badge_added"))) - return HTTPFound(request.route_url("admin")) + return HTTPFound(request.route_url("admin-badge")) @view_config(route_name="admin-badge-edit", permission="admin", request_method="POST") @@ -71,7 +141,7 @@ def do_badge_edit(request): badge.title = request.params["badge-title"] request.session.flash(request.localizer.translate(_("flash.badge_modified"))) - return HTTPFound(request.route_url("admin")) + return HTTPFound(request.route_url("admin-badge")) @view_config(route_name="admin-badge-delete", permission="admin", request_method="POST") @@ -91,7 +161,7 @@ def do_badge_delete(request): request.dbsession.delete(badge) request.session.flash(request.localizer.translate(_("flash.badge_deleted"))) - return HTTPFound(request.route_url("admin")) + return HTTPFound(request.route_url("admin-badge")) -__all__ = ["admin", "do_badge_add", "do_badge_edit", "do_badge_delete"] +__all__ = ["admin", "admin_badges", "do_badge_add", "do_badge_edit", "do_badge_delete"] -- cgit v1.2.3