diff options
| -rw-r--r-- | asset-sources/theme.scss | 20 | ||||
| -rw-r--r-- | fietsboek/data.py | 27 | ||||
| -rw-r--r-- | fietsboek/routes.py | 9 | ||||
| -rw-r--r-- | fietsboek/static/theme.css | 20 | ||||
| -rw-r--r-- | fietsboek/static/theme.css.map | 2 | ||||
| -rw-r--r-- | fietsboek/templates/admin.jinja2 | 49 | ||||
| -rw-r--r-- | fietsboek/templates/admin_badges.jinja2 | 45 | ||||
| -rw-r--r-- | fietsboek/templates/admin_overview.jinja2 | 62 | ||||
| -rw-r--r-- | fietsboek/util.py | 14 | ||||
| -rw-r--r-- | fietsboek/views/admin.py | 88 | 
10 files changed, 279 insertions, 57 deletions
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 @@  <div class="container">    <h1>{{ _("page.admin.title") }}</h1> -  <h2>{{ _("page.admin.badges") }}</h2> +  <div id="adminContainer"> +    <aside id="adminNavigation"> +      <nav class="nav nav-pills nav-fill flex-column"> +        <a class="nav-link{% if admin_index == 0 %} active{% endif %}" href="{{ request.route_url('admin') }}">Overview</a> +        <a class="nav-link{% if admin_index == 1 %} active{% endif %}" href="{{ request.route_url('admin-badge') }}">Badges</a> +      </nav> +    </aside> -  <div class="list-group"> -    {% for badge in badges %} -    <span href="#" class="list-group-item list-group-item-action d-flex admin-badge-list"> -      {{ util.render_badge(badge) }} -      <form method="POST" enctype="multipart/form-data" action="{{ request.route_path('admin-badge-edit') }}"> -        <input type="hidden" name="badge-edit-id" value="{{ badge.id }}"> -        <div class="mb-3"> -          <input type="text" class="form-control" name="badge-title" value="{{ badge.title }}"> -        </div> -        <div class="mb-3"> -          <input class="form-control" type="file" name="badge-image"> -        </div> -        {{ util.hidden_csrf_input() }} -        <div class="mb-3"> -          <button class="btn btn-primary">{{ _("page.admin.badge.edit") }}</button> -        </div> -      </form> -      <form method="POST" action="{{ request.route_path('admin-badge-delete') }}"> -        <input type="hidden" name="badge-delete-id" value="{{ badge.id }}"> -        {{ util.hidden_csrf_input() }} -        <button class="btn btn-danger"><i class="bi bi-trash"></i> {{ _("page.admin.badge.delete_badge") }}</button> -      </form> -    </span> -    {% endfor %} -  </div> - -  <form method="POST" enctype="multipart/form-data" action="{{ request.route_path('admin-badge-add') }}"> -    <div class="mb-3"> -      <label for="badge-title" class="form-label">{{ _("page.admin.badges.badge_title") }}</label> -      <input type="text" class="form-control" id="badge-title" name="badge-title"> +    <div id="adminContent"> +      {% block admin_content %} +      {% endblock %}      </div> -    <div class="mb-3"> -      <label for="badge-image" class="form-label">{{ _("page.admin.badges.badge_image") }}</label> -      <input class="form-control" type="file" name="badge-image"> -    </div> -    {{ util.hidden_csrf_input() }} -    <button type="submit" class="btn btn-primary">{{ _("page.admin.badges.add_badge") }}</button> -  </form> +  </div>  </div>  {% 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 %} +<h2>{{ _("page.admin.badges") }}</h2> + +<div class="list-group"> +  {% for badge in badges %} +  <span href="#" class="list-group-item list-group-item-action d-flex admin-badge-list"> +    {{ util.render_badge(badge) }} +    <form method="POST" enctype="multipart/form-data" action="{{ request.route_path('admin-badge-edit') }}"> +      <input type="hidden" name="badge-edit-id" value="{{ badge.id }}"> +      <div class="mb-3"> +        <input type="text" class="form-control" name="badge-title" value="{{ badge.title }}"> +      </div> +      <div class="mb-3"> +        <input class="form-control" type="file" name="badge-image"> +      </div> +      {{ util.hidden_csrf_input() }} +      <div class="mb-3"> +        <button class="btn btn-primary">{{ _("page.admin.badge.edit") }}</button> +      </div> +    </form> +    <form method="POST" action="{{ request.route_path('admin-badge-delete') }}"> +      <input type="hidden" name="badge-delete-id" value="{{ badge.id }}"> +      {{ util.hidden_csrf_input() }} +      <button class="btn btn-danger"><i class="bi bi-trash"></i> {{ _("page.admin.badge.delete_badge") }}</button> +    </form> +  </span> +  {% endfor %} +</div> + +<form method="POST" enctype="multipart/form-data" action="{{ request.route_path('admin-badge-add') }}"> +  <div class="mb-3"> +    <label for="badge-title" class="form-label">{{ _("page.admin.badges.badge_title") }}</label> +    <input type="text" class="form-control" id="badge-title" name="badge-title"> +  </div> +  <div class="mb-3"> +    <label for="badge-image" class="form-label">{{ _("page.admin.badges.badge_image") }}</label> +    <input class="form-control" type="file" name="badge-image"> +  </div> +  {{ util.hidden_csrf_input() }} +  <button type="submit" class="btn btn-primary">{{ _("page.admin.badges.add_badge") }}</button> +</form> +{% 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 %} +<p class="admin-stat"> +  This instance has… +</p> + +<p class="admin-stat"> +  … {{ user_count }} users +</p> + +<p class="admin-stat"> +  … {{ track_count }} tracks +</p> + +<p class="admin-stat"> +  … {{ (total_size / 1024  / 1024) | round(2) }} MiB bytes of data +</p> + +<div style="position: relative; height: 500px; margin: auto; width: 75%;"> +  <canvas id="graph-size-breakdown"></canvas> +</div> +{% endblock %} + +{% block latescripts %} +<script> +  (function() { +    const data = { +      labels: ['GPX', 'Images', 'User maps'], +      datasets: [ +        { +          label: "MiB", +          data: [ +            {{ (size_breakdown.gpx_files / 1024 / 1024) | tojson }}, +            {{ (size_breakdown.image_files / 1024  / 1024) | tojson }}, +            {{ (size_breakdown.user_maps / 1024 / 1024) | tojson }} +          ] +        } +      ] +    }; + +    const config = { +      type: 'pie', +      data: data, +      options: { +        responsive: true, +        plugins: { +          legend: { +            position: 'top', +          }, +          title: { +            display: true, +            text: 'Storage breakdown' +          } +        } +      } +    }; + +    new Chart("graph-size-breakdown", config); +  })(); +</script> +{% 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"]  | 
