From 1b45ab1bb4f36b966b49287a0a5436d63822c827 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Mon, 20 Mar 2023 21:04:42 +0100 Subject: rename profile to user-data This is not really a profile page, but rather a page to change your personal data, so it should be named accordingly (in preparation for real profiles). Additionally, we use POST requests to deal with the data change. --- fietsboek/routes.py | 3 +- fietsboek/templates/layout.jinja2 | 2 +- fietsboek/templates/profile.jinja2 | 83 ------------------ fietsboek/templates/user_data.jinja2 | 83 ++++++++++++++++++ fietsboek/views/profile.py | 159 ----------------------------------- fietsboek/views/user_data.py | 159 +++++++++++++++++++++++++++++++++++ 6 files changed, 244 insertions(+), 245 deletions(-) delete mode 100644 fietsboek/templates/profile.jinja2 create mode 100644 fietsboek/templates/user_data.jinja2 delete mode 100644 fietsboek/views/profile.py create mode 100644 fietsboek/views/user_data.py diff --git a/fietsboek/routes.py b/fietsboek/routes.py index 9e71686..cf6af82 100644 --- a/fietsboek/routes.py +++ b/fietsboek/routes.py @@ -53,8 +53,7 @@ def includeme(config): config.add_route("admin-badge-edit", "/admin/edit-badge") config.add_route("admin-badge-delete", "/admin/delete-badge") - config.add_route("profile", "/me") - config.add_route("change-profile", "/me/personal-data") + config.add_route("user-data", "/me") config.add_route("add-friend", "/me/send-friend-request") config.add_route("delete-friend", "/me/delete-friend") config.add_route("accept-friend", "/me/accept-friend") diff --git a/fietsboek/templates/layout.jinja2 b/fietsboek/templates/layout.jinja2 index d4e0a0d..8f322f6 100644 --- a/fietsboek/templates/layout.jinja2 +++ b/fietsboek/templates/layout.jinja2 @@ -73,7 +73,7 @@ const Legende = false; {{ _("page.navbar.logout") }}
  • - {{ _("page.navbar.profile") }} + {{ _("page.navbar.profile") }}
  • {% if request.identity.is_admin %}
  • diff --git a/fietsboek/templates/profile.jinja2 b/fietsboek/templates/profile.jinja2 deleted file mode 100644 index ef2ab5f..0000000 --- a/fietsboek/templates/profile.jinja2 +++ /dev/null @@ -1,83 +0,0 @@ -{% extends "layout.jinja2" %} - -{% import "util.jinja2" as util with context %} - -{% block content %} -
    -

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

    - -
    - -

    {{ _("page.my_profile.personal_data") }}

    - -
    -
    - - -
    -
    - -
    - {{ _("page.my_profile.personal_data.password_invalid") }} -
    - -
    -
    - -
    - {{ _("page.my_profile.personal_data.password_must_match") }} -
    - -
    - {{ util.hidden_csrf_input() }} - -
    - -
    - -

    {{ _("page.my_profile.friends") }}

    - -
      - {% for friend in user.get_friends() %} -
    • -
      - - {{ util.hidden_csrf_input() }} - -
      - {{ friend.name }} ({{ friend.email }}) -
    • - {% endfor %} - {% for friend_request in incoming_friend_requests %} -
    • -
      - - {{ util.hidden_csrf_input() }} - -
      - {{ friend_request.sender.name }} ({{ friend_request.sender.email }}) -
    • - {% endfor %} - {% for friend_request in outgoing_friend_requests %} -
    • {{ friend_request.recipient.name }} ({{ friend_request.recipient.email }})
    • - {% endfor %} -
    - -
    -
    - {{ util.hidden_csrf_input() }} -
    -
    -
    - - -
    -
    -
    - -
    -
    -
    -
    -
    -{% endblock %} diff --git a/fietsboek/templates/user_data.jinja2 b/fietsboek/templates/user_data.jinja2 new file mode 100644 index 0000000..15588e8 --- /dev/null +++ b/fietsboek/templates/user_data.jinja2 @@ -0,0 +1,83 @@ +{% extends "layout.jinja2" %} + +{% import "util.jinja2" as util with context %} + +{% block content %} +
    +

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

    + +
    + +

    {{ _("page.my_profile.personal_data") }}

    + +
    +
    + + +
    +
    + +
    + {{ _("page.my_profile.personal_data.password_invalid") }} +
    + +
    +
    + +
    + {{ _("page.my_profile.personal_data.password_must_match") }} +
    + +
    + {{ util.hidden_csrf_input() }} + +
    + +
    + +

    {{ _("page.my_profile.friends") }}

    + +
      + {% for friend in user.get_friends() %} +
    • +
      + + {{ util.hidden_csrf_input() }} + +
      + {{ friend.name }} ({{ friend.email }}) +
    • + {% endfor %} + {% for friend_request in incoming_friend_requests %} +
    • +
      + + {{ util.hidden_csrf_input() }} + +
      + {{ friend_request.sender.name }} ({{ friend_request.sender.email }}) +
    • + {% endfor %} + {% for friend_request in outgoing_friend_requests %} +
    • {{ friend_request.recipient.name }} ({{ friend_request.recipient.email }})
    • + {% endfor %} +
    + +
    +
    + {{ util.hidden_csrf_input() }} +
    +
    +
    + + +
    +
    +
    + +
    +
    +
    +
    +
    +{% endblock %} diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py deleted file mode 100644 index 6354465..0000000 --- a/fietsboek/views/profile.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Views corresponding to the user profile.""" -import datetime - -from pyramid.httpexceptions import HTTPForbidden, HTTPFound, HTTPNotFound -from pyramid.i18n import TranslationString as _ -from pyramid.view import view_config -from sqlalchemy import select - -from .. import models, util - - -@view_config( - route_name="profile", - renderer="fietsboek:templates/profile.jinja2", - permission="user", - request_method="GET", -) -def profile(request): - """Provides the profile overview. - - :param request: The Pyramid request. - :type request: pyramid.request.Request - :return: The HTTP response. - :rtype: pyramid.response.Response - """ - - coming_requests = request.dbsession.execute( - select(models.FriendRequest).filter_by(recipient_id=request.identity.id) - ).scalars() - going_requests = request.dbsession.execute( - select(models.FriendRequest).filter_by(sender_id=request.identity.id) - ).scalars() - return { - "user": request.identity, - "outgoing_friend_requests": going_requests, - "incoming_friend_requests": coming_requests, - } - - -@view_config(route_name="change-profile", permission="user", request_method="POST") -def do_change_profile(request): - """Endpoint to change the personal data. - - :param request: The Pyramid request. - :type request: pyramid.request.Request - :return: The HTTP response. - :rtype: pyramid.response.Response - """ - password = request.params["password"] - if password: - try: - util.check_password_constraints(password, request.params["repeat-password"]) - except ValueError as exc: - request.session.flash(request.localizer.translate(exc.args[0])) - return HTTPFound(request.route_url("profile")) - request.identity.set_password(request.params["password"]) - name = request.params["name"] - if request.identity.name != name: - request.identity.name = name - request.session.flash(request.localizer.translate(_("flash.personal_data_updated"))) - return HTTPFound(request.route_url("profile")) - - -@view_config(route_name="add-friend", permission="user", request_method="POST") -def do_add_friend(request): - """Sends a friend request. - - This is the endpoint of a form on the profile overview. - - :param request: The Pyramid request. - :type request: pyramid.request.Request - :return: The HTTP response. - :rtype: pyramid.response.Response - """ - email = request.params["friend-email"] - candidate = request.dbsession.execute(models.User.query_by_email(email)).scalar_one_or_none() - if candidate is None: - request.session.flash(request.localizer.translate(_("flash.friend_not_found"))) - return HTTPFound(request.route_url("profile")) - - if candidate in request.identity.get_friends() or candidate in [ - x.recipient for x in request.identity.outgoing_requests - ]: - request.session.flash(request.localizer.translate(_("flash.friend_already_exists"))) - return HTTPFound(request.route_url("profile")) - - for incoming in request.identity.incoming_requests: - if incoming.sender == candidate: - # We have an incoming request from that person, so we just accept that - request.identity.add_friend(candidate) - request.dbsession.delete(incoming) - request.session.flash(request.localizer.translate(_("flash.friend_added"))) - return HTTPFound(request.route_url("profile")) - - # Nothing helped, so we send the friend request - friend_req = models.FriendRequest( - sender=request.identity, - recipient=candidate, - date=datetime.datetime.utcnow(), - ) - request.dbsession.add(friend_req) - request.session.flash(request.localizer.translate(_("flash.friend_request_sent"))) - return HTTPFound(request.route_url("profile")) - - -@view_config(route_name="delete-friend", permission="user", request_method="POST") -def do_delete_friend(request): - """Deletes a friend. - - This is the endpoint of a form on the profile overview. - - :param request: The Pyramid request. - :type request: pyramid.request.Request - :return: The HTTP response. - :rtype: pyramid.response.Response - """ - friend = request.dbsession.execute( - select(models.User).filter_by(id=request.params["friend-id"]) - ).scalar_one_or_none() - if friend: - request.identity.remove_friend(friend) - return HTTPFound(request.route_url("profile")) - - -@view_config(route_name="accept-friend", permission="user", request_method="POST") -def do_accept_friend(request): - """Accepts a friend request. - - This is the endpoint of a form on the profile overview. - - :param request: The Pyramid request. - :type request: pyramid.request.Request - :return: The HTTP response. - :rtype: pyramid.response.Response - """ - friend_request = request.dbsession.execute( - select(models.FriendRequest).filter_by(id=request.params["request-id"]) - ).scalar_one_or_none() - if friend_request is None: - return HTTPNotFound() - if friend_request.recipient != request.identity: - return HTTPForbidden() - - friend_request.sender.add_friend(friend_request.recipient) - request.dbsession.delete(friend_request) - return HTTPFound(request.route_url("profile")) - - -@view_config(route_name="json-friends", renderer="json", permission="user") -def json_friends(request): - """Returns a JSON-ified list of the user's friends. - - :param request: The Pyramid request. - :type request: pyramid.request.Request - :return: The HTTP response. - :rtype: pyramid.response.Response - """ - friends = [{"name": friend.name, "id": friend.id} for friend in request.identity.get_friends()] - return friends diff --git a/fietsboek/views/user_data.py b/fietsboek/views/user_data.py new file mode 100644 index 0000000..a6ad11d --- /dev/null +++ b/fietsboek/views/user_data.py @@ -0,0 +1,159 @@ +"""Views corresponding to the user profile.""" +import datetime + +from pyramid.httpexceptions import HTTPForbidden, HTTPFound, HTTPNotFound +from pyramid.i18n import TranslationString as _ +from pyramid.view import view_config +from sqlalchemy import select + +from .. import models, util + + +@view_config( + route_name="user-data", + renderer="fietsboek:templates/user_data.jinja2", + permission="user", + request_method="GET", +) +def user_data(request): + """Provides the user's data. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ + + coming_requests = request.dbsession.execute( + select(models.FriendRequest).filter_by(recipient_id=request.identity.id) + ).scalars() + going_requests = request.dbsession.execute( + select(models.FriendRequest).filter_by(sender_id=request.identity.id) + ).scalars() + return { + "user": request.identity, + "outgoing_friend_requests": going_requests, + "incoming_friend_requests": coming_requests, + } + + +@view_config(route_name="user-data", permission="user", request_method="POST") +def do_change_profile(request): + """Endpoint to change the personal data. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ + password = request.params["password"] + if password: + try: + util.check_password_constraints(password, request.params["repeat-password"]) + except ValueError as exc: + request.session.flash(request.localizer.translate(exc.args[0])) + return HTTPFound(request.route_url("user-data")) + request.identity.set_password(request.params["password"]) + name = request.params["name"] + if request.identity.name != name: + request.identity.name = name + request.session.flash(request.localizer.translate(_("flash.personal_data_updated"))) + return HTTPFound(request.route_url("user-data")) + + +@view_config(route_name="add-friend", permission="user", request_method="POST") +def do_add_friend(request): + """Sends a friend request. + + This is the endpoint of a form on the profile overview. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ + email = request.params["friend-email"] + candidate = request.dbsession.execute(models.User.query_by_email(email)).scalar_one_or_none() + if candidate is None: + request.session.flash(request.localizer.translate(_("flash.friend_not_found"))) + return HTTPFound(request.route_url("user-data")) + + if candidate in request.identity.get_friends() or candidate in [ + x.recipient for x in request.identity.outgoing_requests + ]: + request.session.flash(request.localizer.translate(_("flash.friend_already_exists"))) + return HTTPFound(request.route_url("user-data")) + + for incoming in request.identity.incoming_requests: + if incoming.sender == candidate: + # We have an incoming request from that person, so we just accept that + request.identity.add_friend(candidate) + request.dbsession.delete(incoming) + request.session.flash(request.localizer.translate(_("flash.friend_added"))) + return HTTPFound(request.route_url("user-data")) + + # Nothing helped, so we send the friend request + friend_req = models.FriendRequest( + sender=request.identity, + recipient=candidate, + date=datetime.datetime.utcnow(), + ) + request.dbsession.add(friend_req) + request.session.flash(request.localizer.translate(_("flash.friend_request_sent"))) + return HTTPFound(request.route_url("user-data")) + + +@view_config(route_name="delete-friend", permission="user", request_method="POST") +def do_delete_friend(request): + """Deletes a friend. + + This is the endpoint of a form on the profile overview. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ + friend = request.dbsession.execute( + select(models.User).filter_by(id=request.params["friend-id"]) + ).scalar_one_or_none() + if friend: + request.identity.remove_friend(friend) + return HTTPFound(request.route_url("user-data")) + + +@view_config(route_name="accept-friend", permission="user", request_method="POST") +def do_accept_friend(request): + """Accepts a friend request. + + This is the endpoint of a form on the profile overview. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ + friend_request = request.dbsession.execute( + select(models.FriendRequest).filter_by(id=request.params["request-id"]) + ).scalar_one_or_none() + if friend_request is None: + return HTTPNotFound() + if friend_request.recipient != request.identity: + return HTTPForbidden() + + friend_request.sender.add_friend(friend_request.recipient) + request.dbsession.delete(friend_request) + return HTTPFound(request.route_url("user-data")) + + +@view_config(route_name="json-friends", renderer="json", permission="user") +def json_friends(request): + """Returns a JSON-ified list of the user's friends. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ + friends = [{"name": friend.name, "id": friend.id} for friend in request.identity.get_friends()] + return friends -- cgit v1.2.3 From 9aa6266d0e29523aecc6e449e40fd64d0574485b Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Mon, 20 Mar 2023 21:52:24 +0100 Subject: first scaffolding for profiles --- fietsboek/models/user.py | 30 ++++++++++++++++++ fietsboek/routes.py | 2 ++ fietsboek/templates/profile.jinja2 | 36 ++++++++++++++++++++++ fietsboek/views/profile.py | 63 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+) create mode 100644 fietsboek/templates/profile.jinja2 create mode 100644 fietsboek/views/profile.py diff --git a/fietsboek/models/user.py b/fietsboek/models/user.py index 62c935e..82be8d7 100644 --- a/fietsboek/models/user.py +++ b/fietsboek/models/user.py @@ -7,6 +7,9 @@ from functools import reduce from cryptography.exceptions import InvalidKey from cryptography.hazmat.primitives.kdf.scrypt import Scrypt +from pyramid.authorization import ALL_PERMISSIONS, Allow +from pyramid.httpexceptions import HTTPNotFound +from pyramid.request import Request from sqlalchemy import ( Boolean, Column, @@ -134,6 +137,33 @@ class User(Base): """ return select(cls).filter(func.lower(email) == func.lower(cls.email)) + @classmethod + def factory(cls, request: Request) -> "User": + """Factory method to pass to a route definition. + + This factory retrieves the track based on the ``track_id`` matched + route parameter, and returns the track. If the track is not found, + ``HTTPNotFound`` is raised. + + :raises pyramid.httpexception.NotFound: If the track is not found. + :param request: The pyramid request. + :return: The track. + """ + user_id = request.matchdict["user_id"] + query = select(cls).filter_by(id=user_id) + track = request.dbsession.execute(query).scalar_one_or_none() + if track is None: + raise HTTPNotFound() + return track + + def __acl__(self): + # Basic ACL: Permissions for the admin, the owner and the share link + acl = [ + (Allow, "group:admins", ALL_PERMISSIONS), + (Allow, f"user:{self.id}", ALL_PERMISSIONS), + ] + return acl + def set_password(self, new_password): """Sets a new password for the user. diff --git a/fietsboek/routes.py b/fietsboek/routes.py index cf6af82..5e05269 100644 --- a/fietsboek/routes.py +++ b/fietsboek/routes.py @@ -59,4 +59,6 @@ def includeme(config): config.add_route("accept-friend", "/me/accept-friend") config.add_route("json-friends", "/me/friends.json") + config.add_route("profile", "/user/{user_id}", factory="fietsboek.models.User.factory") + config.add_route("tile-proxy", "/tile/{provider}/{z:\\d+}/{x:\\d+}/{y:\\d+}") diff --git a/fietsboek/templates/profile.jinja2 b/fietsboek/templates/profile.jinja2 new file mode 100644 index 0000000..da4d732 --- /dev/null +++ b/fietsboek/templates/profile.jinja2 @@ -0,0 +1,36 @@ +{% extends "layout.jinja2" %} + +{% block content %} +
    +

    {{ user.name }}

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    {{ _("page.details.length") }}{{ (total_length / 1000) | round(2) | format_decimal }} km
    {{ _("page.details.uphill") }}{{ total_uphill | round(2) | format_decimal }} m
    {{ _("page.details.downhill") }}{{ total_downhill | round(2) | format_decimal }} m
    {{ _("page.details.moving_time") }}{{ total_moving_time }}
    {{ _("page.details.stopped_time") }}{{ total_stopped_time }}
    {{ _("page.details.max_speed") }}{{ mps_to_kph(max_speed) | round(2) | format_decimal }} km/h
    {{ _("page.details.avg_speed") }}{{ mps_to_kph(avg_speed) | round(2) | format_decimal }} km/h
    +
    +{% endblock %} diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py new file mode 100644 index 0000000..488ffb0 --- /dev/null +++ b/fietsboek/views/profile.py @@ -0,0 +1,63 @@ +import datetime + +from pyramid.request import Request +from pyramid.view import view_config + +from .. import models, util +from ..models.track import TrackType, TrackWithMetadata + + +def round_to_seconds(value: datetime.timedelta) -> datetime.timedelta: + """Round a timedelta to full seconds. + + :param value: The input value. + :return: The rounded value. + """ + return util.round_timedelta_to_multiple(value, datetime.timedelta(seconds=1)) + + +@view_config( + route_name="profile", + renderer="fietsboek:templates/profile.jinja2", + request_method="GET", +) +def profile(request: Request) -> dict: + total_length = 0.0 + total_uphill = 0.0 + total_downhill = 0.0 + total_moving_time = datetime.timedelta(0) + total_stopped_time = datetime.timedelta(0) + max_speed = 0.0 + + track: models.Track + for track in request.context.tracks: + if track.type != TrackType.ORGANIC: + continue + + meta = TrackWithMetadata(track, request.data_manager) + + total_length += meta.length + total_uphill += meta.uphill + total_downhill += meta.downhill + total_moving_time += meta.moving_time + total_stopped_time += meta.stopped_time + max_speed = max(max_speed, meta.max_speed) + + avg_speed = total_length / total_moving_time.total_seconds() + total_moving_time = round_to_seconds(total_moving_time) + total_stopped_time = round_to_seconds(total_stopped_time) + + return { + "user": request.context, + "total_length": total_length, + "total_uphill": total_uphill, + "total_downhill": total_downhill, + "total_moving_time": total_moving_time, + "total_stopped_time": total_stopped_time, + "max_speed": max_speed, + "avg_speed": avg_speed, + "mps_to_kph": util.mps_to_kph, + } + + +__all__ = ["profile"] -- cgit v1.2.3 From 7e2ed3206897632c59ce62e7e19852d04b4c256e Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Wed, 22 Mar 2023 20:02:18 +0100 Subject: show heatmap on profile So far, fietsboek does not generate them, but if you happen (by accident) to have hittekaart output a heatmap to the right location, the profile page will now show it. --- fietsboek/data.py | 37 +++++++++++++++++++ fietsboek/routes.py | 5 +++ fietsboek/templates/layout.jinja2 | 6 ++++ fietsboek/templates/profile.jinja2 | 40 +++++++++++++++++++++ fietsboek/views/profile.py | 73 +++++++++++++++++++++++++++++++++++++- 5 files changed, 160 insertions(+), 1 deletion(-) diff --git a/fietsboek/data.py b/fietsboek/data.py index 9f49c49..9be47da 100644 --- a/fietsboek/data.py +++ b/fietsboek/data.py @@ -54,6 +54,9 @@ class DataManager: def _track_data_dir(self, track_id): return self.data_dir / "tracks" / str(track_id) + def _user_data_dir(self, user_id): + return self.data_dir / "users" / str(user_id) + def maintenance_mode(self) -> Optional[str]: """Checks whether the maintenance mode is enabled. @@ -99,6 +102,18 @@ class DataManager: raise FileNotFoundError(f"The path {path} is not a directory") from None return TrackDataDir(track_id, path) + def open_user(self, user_id: int) -> "UserDataDir": + """Opens a user's data directory. + + :raises FileNotFoundError: If the user directory does not exist. + :param user_id: ID of the user. + :return: The manager that can be used to manage this user's data. + """ + path = self._user_data_dir(user_id) + if not path.is_dir(): + raise FileNotFoundError(f"The path {path} is not a directory") from None + return UserDataDir(user_id, path) + class TrackDataDir: """Manager for a single track's data. @@ -360,3 +375,25 @@ class TrackDataDir: self.journal.append(("delete_image", path, path.read_bytes())) path.unlink() + + +class UserDataDir: + """Manager for a single user's data.""" + + def __init__(self, user_id: int, path: Path): + self.user_id = user_id + self.path = path + + def heatmap_path(self) -> Path: + """Returns the path for the heatmap tile file. + + :return: The path of the heatmap SQLite databse. + """ + return self.path / "heatmap.sqlite" + + def tilehunt_path(self) -> Path: + """Returns the path for the tilehunt tile file. + + :return: The path of the tilehunt SQLite database. + """ + return self.path / "tilehunt.sqlite" diff --git a/fietsboek/routes.py b/fietsboek/routes.py index 5e05269..8f109d9 100644 --- a/fietsboek/routes.py +++ b/fietsboek/routes.py @@ -60,5 +60,10 @@ def includeme(config): config.add_route("json-friends", "/me/friends.json") config.add_route("profile", "/user/{user_id}", factory="fietsboek.models.User.factory") + config.add_route( + "user-tile", + "/user/{user_id}/tile/{map}/{z:\\d+}/{x:\\d+}/{y:\\d+}", + factory="fietsboek.models.User.factory", + ) config.add_route("tile-proxy", "/tile/{provider}/{z:\\d+}/{x:\\d+}/{y:\\d+}") diff --git a/fietsboek/templates/layout.jinja2 b/fietsboek/templates/layout.jinja2 index 8f322f6..19fb700 100644 --- a/fietsboek/templates/layout.jinja2 +++ b/fietsboek/templates/layout.jinja2 @@ -17,6 +17,8 @@ + + + + + {% block latescripts %} + {% endblock %} diff --git a/fietsboek/templates/profile.jinja2 b/fietsboek/templates/profile.jinja2 index da4d732..1363216 100644 --- a/fietsboek/templates/profile.jinja2 +++ b/fietsboek/templates/profile.jinja2 @@ -3,6 +3,11 @@ {% block content %}

    {{ user.name }}

    + + {% if heatmap_url or tilehunt_url %} +
    + {% endif %} + @@ -34,3 +39,38 @@
    {{ _("page.details.length") }} {{ (total_length / 1000) | round(2) | format_decimal }} km
    {% endblock %} + +{% block latescripts %} + +{% endblock %} diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py index 488ffb0..94133f0 100644 --- a/fietsboek/views/profile.py +++ b/fietsboek/views/profile.py @@ -1,9 +1,13 @@ import datetime +import sqlite3 +from pyramid.httpexceptions import HTTPNotFound from pyramid.request import Request +from pyramid.response import Response from pyramid.view import view_config from .. import models, util +from ..data import UserDataDir from ..models.track import TrackType, TrackWithMetadata @@ -47,6 +51,38 @@ def profile(request: Request) -> dict: total_moving_time = round_to_seconds(total_moving_time) total_stopped_time = round_to_seconds(total_stopped_time) + heatmap_url = None + tilehunt_url = None + try: + user_dir: UserDataDir = request.data_manager.open_user(request.context.id) + except FileNotFoundError: + pass + else: + if user_dir.heatmap_path().is_file(): + heatmap_url = request.route_url( + "user-tile", + user_id=request.context.id, + map="heatmap", + z="ZZZ", + x="XXX", + y="YYY", + ) + heatmap_url = ( + heatmap_url.replace("ZZZ", "{z}").replace("XXX", "{x}").replace("YYY", "{y}") + ) + if user_dir.tilehunt_path().is_file(): + tilehunt_url = request.route_url( + "user-tile", + user_id=request.context.id, + map="tilehunt", + z="ZZZ", + x="XXX", + y="YYY", + ) + tilehunt_url = ( + tilehunt_url.replace("ZZZ", "{z}").replace("XXX", "{x}").replace("YYY", "{y}") + ) + return { "user": request.context, "total_length": total_length, @@ -57,7 +93,42 @@ def profile(request: Request) -> dict: "max_speed": max_speed, "avg_speed": avg_speed, "mps_to_kph": util.mps_to_kph, + "heatmap_url": heatmap_url, + "tilehunt_url": tilehunt_url, } -__all__ = ["profile"] +@view_config(route_name="user-tile", request_method="GET") +def user_tile(request: Request) -> Response: + """Returns a single tile from the user's own overlay maps. + + :param request: The pyramid request. + :return: The response, with the tile content (or an error). + """ + try: + user_dir: UserDataDir = request.data_manager.open_user(request.context.id) + except FileNotFoundError: + return HTTPNotFound() + + paths = { + "heatmap": user_dir.heatmap_path(), + "tilehunt": user_dir.tilehunt_path(), + } + path = paths.get(request.matchdict["map"]) + if path is None: + return HTTPNotFound() + + connection = sqlite3.connect(path) + cursor = connection.cursor() + result = cursor.execute( + "SELECT data FROM tiles WHERE zoom = ? AND x = ? AND y = ?;", + (int(request.matchdict["z"]), int(request.matchdict["x"]), int(request.matchdict["y"])), + ) + result = result.fetchone() + if result is None: + return HTTPNotFound() + + return Response(result[0], content_type="image/png") + + +__all__ = ["profile", "user_tile"] -- cgit v1.2.3 From 5cfba8d37ca07fc4e61fe979c169da8c7baba850 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Wed, 22 Mar 2023 20:04:37 +0100 Subject: add docstrings --- fietsboek/models/user.py | 1 - fietsboek/views/profile.py | 6 ++++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/fietsboek/models/user.py b/fietsboek/models/user.py index 82be8d7..58a04bd 100644 --- a/fietsboek/models/user.py +++ b/fietsboek/models/user.py @@ -157,7 +157,6 @@ class User(Base): return track def __acl__(self): - # Basic ACL: Permissions for the admin, the owner and the share link acl = [ (Allow, "group:admins", ALL_PERMISSIONS), (Allow, f"user:{self.id}", ALL_PERMISSIONS), diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py index 94133f0..6bd566a 100644 --- a/fietsboek/views/profile.py +++ b/fietsboek/views/profile.py @@ -1,3 +1,4 @@ +"""Endpoints for the user profile pages.""" import datetime import sqlite3 @@ -26,6 +27,11 @@ def round_to_seconds(value: datetime.timedelta) -> datetime.timedelta: request_method="GET", ) def profile(request: Request) -> dict: + """Shows the profile page. + + :param request: The pyramid request. + :return: The template parameters. + """ total_length = 0.0 total_uphill = 0.0 total_downhill = 0.0 -- cgit v1.2.3 From 56a214206fb44c83a69326f6798d842eac19511b Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 25 Mar 2023 13:40:39 +0100 Subject: profile: show first base layer per default Otherwise it does look very empty when you open the page. --- fietsboek/templates/profile.jinja2 | 68 +++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/fietsboek/templates/profile.jinja2 b/fietsboek/templates/profile.jinja2 index 1363216..34bb2de 100644 --- a/fietsboek/templates/profile.jinja2 +++ b/fietsboek/templates/profile.jinja2 @@ -42,35 +42,51 @@ {% block latescripts %} {% endblock %} -- cgit v1.2.3 From 6581d947c212b46d22f0fbb17afa80394faf2521 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 25 Mar 2023 13:46:45 +0100 Subject: add number of tracks to profile --- fietsboek/templates/profile.jinja2 | 4 ++++ fietsboek/views/profile.py | 3 +++ 2 files changed, 7 insertions(+) diff --git a/fietsboek/templates/profile.jinja2 b/fietsboek/templates/profile.jinja2 index 34bb2de..5170109 100644 --- a/fietsboek/templates/profile.jinja2 +++ b/fietsboek/templates/profile.jinja2 @@ -36,6 +36,10 @@ {{ _("page.details.avg_speed") }} {{ mps_to_kph(avg_speed) | round(2) | format_decimal }} km/h + + {{ _("page.profile.number_of_tracks") }} + {{ number_of_tracks }} + {% endblock %} diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py index 6bd566a..657f955 100644 --- a/fietsboek/views/profile.py +++ b/fietsboek/views/profile.py @@ -38,6 +38,7 @@ def profile(request: Request) -> dict: total_moving_time = datetime.timedelta(0) total_stopped_time = datetime.timedelta(0) max_speed = 0.0 + number_of_tracks = 0 track: models.Track for track in request.context.tracks: @@ -52,6 +53,7 @@ def profile(request: Request) -> dict: total_moving_time += meta.moving_time total_stopped_time += meta.stopped_time max_speed = max(max_speed, meta.max_speed) + number_of_tracks += 1 avg_speed = total_length / total_moving_time.total_seconds() total_moving_time = round_to_seconds(total_moving_time) @@ -99,6 +101,7 @@ def profile(request: Request) -> dict: "max_speed": max_speed, "avg_speed": avg_speed, "mps_to_kph": util.mps_to_kph, + "number_of_tracks": number_of_tracks, "heatmap_url": heatmap_url, "tilehunt_url": tilehunt_url, } -- cgit v1.2.3 From 62fb4209102f6665584c786ef72aa16fa1ffd604 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 25 Mar 2023 13:49:18 +0100 Subject: use own i18n strings for profile page --- fietsboek/templates/profile.jinja2 | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/fietsboek/templates/profile.jinja2 b/fietsboek/templates/profile.jinja2 index 5170109..516cee9 100644 --- a/fietsboek/templates/profile.jinja2 +++ b/fietsboek/templates/profile.jinja2 @@ -9,32 +9,32 @@ {% endif %} - - + + - - + + - - + + - - + + - - + + - - + + - - + + -- cgit v1.2.3 From 2d5376d2003364f1a810ac4c7c9fcb38779417da Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 25 Mar 2023 14:03:17 +0100 Subject: profile: take into account all tracks With the previous logic, we only counted those that the user themselves uploaded. --- fietsboek/views/profile.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py index 657f955..6aef499 100644 --- a/fietsboek/views/profile.py +++ b/fietsboek/views/profile.py @@ -6,6 +6,8 @@ from pyramid.httpexceptions import HTTPNotFound from pyramid.request import Request from pyramid.response import Response from pyramid.view import view_config +from sqlalchemy import select +from sqlalchemy.orm import aliased from .. import models, util from ..data import UserDataDir @@ -40,11 +42,10 @@ def profile(request: Request) -> dict: max_speed = 0.0 number_of_tracks = 0 + query = request.context.all_tracks_query() + query = select(aliased(models.Track, query)).where(query.c.type == TrackType.ORGANIC) track: models.Track - for track in request.context.tracks: - if track.type != TrackType.ORGANIC: - continue - + for track in request.dbsession.execute(query).scalars(): meta = TrackWithMetadata(track, request.data_manager) total_length += meta.length -- cgit v1.2.3 From 390c01fa0af7f82847f93e590963114b3bcf880e Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 25 Mar 2023 15:22:28 +0100 Subject: first integration of hittekaart into fietsboek This makes it a bit easier to generate heatmaps, but at the moment, it only works manually. The "long-term" goal is to have fietscron generate heatmaps on a regular basis. --- fietsboek/config.py | 3 + fietsboek/data.py | 11 ++++ fietsboek/hittekaart.py | 125 ++++++++++++++++++++++++++++++++++++++++++ fietsboek/scripts/fietsctl.py | 48 +++++++++++++++- 4 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 fietsboek/hittekaart.py diff --git a/fietsboek/config.py b/fietsboek/config.py index a5a87a8..3fc191f 100644 --- a/fietsboek/config.py +++ b/fietsboek/config.py @@ -195,6 +195,9 @@ class Config(BaseModel): tile_layers: typing.List[TileLayerConfig] = [] """Tile layers.""" + hittekaart_bin: str = Field("", alias="hittekaart.bin") + """Path to the hittekaart binary.""" + @validator("session_key") def _good_session_key(cls, value): """Ensures that the session key has been changed from its default diff --git a/fietsboek/data.py b/fietsboek/data.py index 9be47da..7457986 100644 --- a/fietsboek/data.py +++ b/fietsboek/data.py @@ -82,6 +82,17 @@ class DataManager: path.mkdir(parents=True) return TrackDataDir(track_id, path, journal=True, is_fresh=True) + def initialize_user(self, user_id: int) -> "UserDataDir": + """Creates the data directory for a user. + + :raises FileExistsError: If the directory already exists. + :param user_id: ID of the user. + :return: The manager that can be used to manage this user's data. + """ + path = self._user_data_dir(user_id) + path.mkdir(parents=True) + return UserDataDir(user_id, path) + def purge(self, track_id: int): """Forcefully purges all data from the given track. diff --git a/fietsboek/hittekaart.py b/fietsboek/hittekaart.py new file mode 100644 index 0000000..f580c51 --- /dev/null +++ b/fietsboek/hittekaart.py @@ -0,0 +1,125 @@ +"""Interface to the hittekaart_ application to generate heatmaps. + +.. _hittekaart: https://gitlab.com/dunj3/hittekaart +""" +import enum +import logging +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.orm import aliased +from sqlalchemy.orm.session import Session + +from . import models +from .data import DataManager +from .models.track import TrackType + +LOGGER = logging.getLogger(__name__) + + +class Mode(enum.Enum): + """Heatmap generation mode. + + This enum represents the different types of overlay maps that + ``hittekaart`` can generate. + """ + + HEATMAP = "heatmap" + TILEHUNTER = "tilehunter" + + +def generate( + output: Path, + mode: Mode, + input_files: list[Path], + *, + exe_path: Optional[Path] = None, + threads: int = 0, +): + """Calls hittekaart with the given arguments. + + :param output: Output filename. Note that this function always uses the + sqlite output mode. + :param mode: What to generate. + :param input_files: List of paths to the input files. + :param exe_path: Path to the hittekaart binary. If not given, + ``hittekaart`` is searched in the path. + :param threads: Number of threads that ``hittekaart`` should use. Defaults + to 0, which uses all available cores. + """ + # There are two reasons why we do the tempfile dance: + # 1. hittekaart refuses to overwrite existing files + # 2. This way we can (hope for?) an atomic move (at least if temporary file + # is on the same filesystem). In the future, we might want to enforce + # this, but for now, it's alright. + with tempfile.TemporaryDirectory() as tempdir: + tmpfile = Path(tempdir) / "hittekaart.sqlite" + binary = str(exe_path) if exe_path else "hittekaart" + cmdline = [ + binary, + "--sqlite", + "-o", + str(tmpfile), + "-m", + mode.value, + "-t", + str(threads), + "--", + ] + cmdline.extend(map(str, input_files)) + LOGGER.debug("Running %r", cmdline) + subprocess.run(cmdline, check=True) + + LOGGER.debug("Moving temporary file") + shutil.move(tmpfile, output) + + +def generate_for( + user: models.User, + dbsession: Session, + data_manager: DataManager, + mode: Mode, + *, + exe_path: Optional[Path] = None, + threads: int = 0, +): + """Uses :meth:`generate` to generate a heatmap for the given user. + + This function automatically retrieves the user's tracks from the database + and passes them to ``hittekaart``. + + The output is saved in the user's data directory using the + ``data_manager``. + + :param user: The user for which to generate the map. + :param dbsession: The database session. + :param data_manager: The data manager. + :param mode: The mode of the heatmap. + :param exe_path: See :meth:`generate`. + :param threads: See :meth:`generate`. + """ + query = user.all_tracks_query() + query = select(aliased(models.Track, query)).where(query.c.type == TrackType.ORGANIC) + input_paths = [] + for track in dbsession.execute(query).scalars(): + path = data_manager.open(track.id).gpx_path() + input_paths.append(path) + + try: + user_dir = data_manager.initialize_user(user.id) + except FileExistsError: + user_dir = data_manager.open_user(user.id) + + output_paths = { + Mode.HEATMAP: user_dir.heatmap_path(), + Mode.TILEHUNTER: user_dir.tilehunt_path(), + } + + generate(output_paths[mode], mode, input_paths, exe_path=exe_path, threads=threads) + + +__all__ = ["Mode", "generate", "generate_for"] diff --git a/fietsboek/scripts/fietsctl.py b/fietsboek/scripts/fietsctl.py index 0043862..8be226a 100644 --- a/fietsboek/scripts/fietsctl.py +++ b/fietsboek/scripts/fietsctl.py @@ -8,7 +8,7 @@ from pyramid.paster import bootstrap, setup_logging from pyramid.scripting import AppEnvironment from sqlalchemy import select -from .. import __VERSION__, models +from .. import __VERSION__, hittekaart, models from . import config_option EXIT_OKAY = 0 @@ -219,6 +219,52 @@ def cmd_maintenance_mode(ctx: click.Context, config: str, disable: bool, reason: (data_manager.data_dir / "MAINTENANCE").write_text(reason, encoding="utf-8") +@cli.command("hittekaart") +@config_option +@click.option( + "--mode", + "modes", + help="Heatmap type to generate", + type=click.Choice([mode.value for mode in hittekaart.Mode]), + multiple=True, + default=["heatmap"], +) +@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") +@click.pass_context +def cmd_hittekaart( + ctx: click.Context, + config: str, + modes: list[str], + id_: Optional[int], + email: Optional[str], +): + """Generate heatmap for a user.""" + env = setup(config) + modes = [hittekaart.Mode(mode) for mode in modes] + + if id_ is not None: + query = select(models.User).filter_by(id=id_) + else: + query = models.User.query_by_email(email) + + exe_path = env["request"].config.hittekaart_bin + with env["request"].tm: + dbsession = env["request"].dbsession + data_manager = env["request"].data_manager + 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) + + click.echo(f"Generating overlay maps for {user.name}...") + + for mode in modes: + hittekaart.generate_for(user, dbsession, data_manager, mode, exe_path=exe_path) + click.echo(f"Generated {mode.value}") + + @cli.command("version") def cmd_version(): """Show the installed fietsboek version.""" -- cgit v1.2.3 From 0b3452e7954e74791be001f8d88716dc864e2a40 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Mon, 27 Mar 2023 22:59:21 +0200 Subject: profile: move cumulative stats to helper class The reason for that is that we want to add "longest track"/"shortest track" soon, and the profile() function is getting a bit long --- fietsboek/views/profile.py | 86 +++++++++++++++++++++++++++++++--------------- 1 file changed, 59 insertions(+), 27 deletions(-) diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py index 6aef499..2a4203e 100644 --- a/fietsboek/views/profile.py +++ b/fietsboek/views/profile.py @@ -1,6 +1,7 @@ """Endpoints for the user profile pages.""" import datetime import sqlite3 +from dataclasses import dataclass from pyramid.httpexceptions import HTTPNotFound from pyramid.request import Request @@ -14,6 +15,54 @@ from ..data import UserDataDir from ..models.track import TrackType, TrackWithMetadata +@dataclass +class CumulativeStats: + """Cumulative user stats. + + The values start out with default values, and tracks can be merged in via + :meth:`add`. + """ + + count: int = 0 + """Number of tracks added.""" + + length: float = 0.0 + """Total length, in meters.""" + + uphill: float = 0.0 + """Total uphill, in meters.""" + + downhill: float = 0.0 + """Total downhill, in meters.""" + + moving_time: datetime.timedelta = datetime.timedelta(0) + """Total time spent moving.""" + + stopped_time: datetime.timedelta = datetime.timedelta(0) + """Total time standing still.""" + + max_speed: float = 0.0 + """Overall maximum speed, in m/s.""" + + def add(self, track: TrackWithMetadata): + """Adds a track to this stats collection. + + :param track: The track to add, with accompanying metadata. + """ + self.count += 1 + self.length += track.length + self.uphill += track.uphill + self.downhill += track.downhill + self.moving_time += track.moving_time + self.stopped_time += track.stopped_time + self.max_speed = max(self.max_speed, track.max_speed) + + @property + def avg_speed(self) -> float: + """Average speed, in m/s.""" + return self.length / self.moving_time.total_seconds() + + def round_to_seconds(value: datetime.timedelta) -> datetime.timedelta: """Round a timedelta to full seconds. @@ -34,31 +83,14 @@ def profile(request: Request) -> dict: :param request: The pyramid request. :return: The template parameters. """ - total_length = 0.0 - total_uphill = 0.0 - total_downhill = 0.0 - total_moving_time = datetime.timedelta(0) - total_stopped_time = datetime.timedelta(0) - max_speed = 0.0 - number_of_tracks = 0 + total = CumulativeStats() query = request.context.all_tracks_query() query = select(aliased(models.Track, query)).where(query.c.type == TrackType.ORGANIC) track: models.Track for track in request.dbsession.execute(query).scalars(): meta = TrackWithMetadata(track, request.data_manager) - - total_length += meta.length - total_uphill += meta.uphill - total_downhill += meta.downhill - total_moving_time += meta.moving_time - total_stopped_time += meta.stopped_time - max_speed = max(max_speed, meta.max_speed) - number_of_tracks += 1 - - avg_speed = total_length / total_moving_time.total_seconds() - total_moving_time = round_to_seconds(total_moving_time) - total_stopped_time = round_to_seconds(total_stopped_time) + total.add(meta) heatmap_url = None tilehunt_url = None @@ -94,15 +126,15 @@ def profile(request: Request) -> dict: return { "user": request.context, - "total_length": total_length, - "total_uphill": total_uphill, - "total_downhill": total_downhill, - "total_moving_time": total_moving_time, - "total_stopped_time": total_stopped_time, - "max_speed": max_speed, - "avg_speed": avg_speed, + "total_length": total.length, + "total_uphill": total.uphill, + "total_downhill": total.downhill, + "total_moving_time": round_to_seconds(total.moving_time), + "total_stopped_time": round_to_seconds(total.stopped_time), + "max_speed": total.max_speed, + "avg_speed": total.avg_speed, "mps_to_kph": util.mps_to_kph, - "number_of_tracks": number_of_tracks, + "number_of_tracks": total.count, "heatmap_url": heatmap_url, "tilehunt_url": tilehunt_url, } -- cgit v1.2.3 From 3025e048750779c30fa6ce9d51a98f88aa8e7f10 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Mon, 27 Mar 2023 23:13:11 +0200 Subject: profile: add longest and shortest track --- fietsboek/templates/profile.jinja2 | 80 ++++++++++++++++++++++++++++++++++---- fietsboek/views/profile.py | 31 +++++++++++---- 2 files changed, 95 insertions(+), 16 deletions(-) diff --git a/fietsboek/templates/profile.jinja2 b/fietsboek/templates/profile.jinja2 index 516cee9..fe49990 100644 --- a/fietsboek/templates/profile.jinja2 +++ b/fietsboek/templates/profile.jinja2 @@ -1,5 +1,59 @@ {% extends "layout.jinja2" %} +{% macro render_track_card(track) %} +
    +
    + {{ track.title | default(track.date, true) }} + {% if track.text_tags() %} + {% for tag in track.tags %}{{ tag.tag }} {% endfor %} + {% endif %} +
    +
    +
    {{ _("page.details.length") }}{{ (total_length / 1000) | round(2) | format_decimal }} km{{ _("page.profile.length") }}{{ (total_length / 1000) | round(2) | format_decimal }} km
    {{ _("page.details.uphill") }}{{ total_uphill | round(2) | format_decimal }} m{{ _("page.profile.uphill") }}{{ total_uphill | round(2) | format_decimal }} m
    {{ _("page.details.downhill") }}{{ total_downhill | round(2) | format_decimal }} m{{ _("page.profile.downhill") }}{{ total_downhill | round(2) | format_decimal }} m
    {{ _("page.details.moving_time") }}{{ total_moving_time }}{{ _("page.profile.moving_time") }}{{ total_moving_time }}
    {{ _("page.details.stopped_time") }}{{ total_stopped_time }}{{ _("page.profile.stopped_time") }}{{ total_stopped_time }}
    {{ _("page.details.max_speed") }}{{ mps_to_kph(max_speed) | round(2) | format_decimal }} km/h{{ _("page.profile.max_speed") }}{{ mps_to_kph(max_speed) | round(2) | format_decimal }} km/h
    {{ _("page.details.avg_speed") }}{{ mps_to_kph(avg_speed) | round(2) | format_decimal }} km/h{{ _("page.profile.avg_speed") }}{{ mps_to_kph(avg_speed) | round(2) | format_decimal }} km/h
    {{ _("page.profile.number_of_tracks") }}
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    {{ _("page.details.date") }}{{ track.date | format_datetime }}{{ _("page.details.length") }}{{ (track.length / 1000) | round(2) | format_decimal }} km
    {{ _("page.details.start_time") }}{{ track.start_time | format_datetime }}{{ _("page.details.end_time") }}{{ track.end_time | format_datetime }}
    {{ _("page.details.uphill") }}{{ track.uphill | round(2) | format_decimal }} m{{ _("page.details.downhill") }}{{ track.downhill | round(2) | format_decimal }} m
    {{ _("page.details.moving_time") }}{{ track.moving_time }}{{ _("page.details.stopped_time") }}{{ track.stopped_time }}
    {{ _("page.details.max_speed") }}{{ mps_to_kph(track.max_speed) | round(2) | format_decimal }} km/h{{ _("page.details.avg_speed") }}{{ mps_to_kph(track.avg_speed) | round(2) | format_decimal }} km/h
    + +
      +
    • {{ track.owner.name }}
    • + {% for user in track.tagged_people %} +
    • {{ user.name }}
    • + {% endfor %} +
    + + +{% endmacro %} + {% block content %}

    {{ user.name }}

    @@ -10,37 +64,47 @@ - + - + - + - + - + - + - + - +
    {{ _("page.profile.length") }}{{ (total_length / 1000) | round(2) | format_decimal }} km{{ (total.length / 1000) | round(2) | format_decimal }} km
    {{ _("page.profile.uphill") }}{{ total_uphill | round(2) | format_decimal }} m{{ total.uphill | round(2) | format_decimal }} m
    {{ _("page.profile.downhill") }}{{ total_downhill | round(2) | format_decimal }} m{{ total.downhill | round(2) | format_decimal }} m
    {{ _("page.profile.moving_time") }}{{ total_moving_time }}{{ total.moving_time }}
    {{ _("page.profile.stopped_time") }}{{ total_stopped_time }}{{ total.stopped_time }}
    {{ _("page.profile.max_speed") }}{{ mps_to_kph(max_speed) | round(2) | format_decimal }} km/h{{ mps_to_kph(total.max_speed) | round(2) | format_decimal }} km/h
    {{ _("page.profile.avg_speed") }}{{ mps_to_kph(avg_speed) | round(2) | format_decimal }} km/h{{ mps_to_kph(total.avg_speed) | round(2) | format_decimal }} km/h
    {{ _("page.profile.number_of_tracks") }}{{ number_of_tracks }}{{ total.count }}
    + + {% if total.longest_distance_track %} +

    {{ _("page.profile.longest_distance_track") }}

    + {{ render_track_card(total.longest_distance_track) }} + {% endif %} + + {% if total.shortest_distance_track %} +

    {{ _("page.profile.shortest_distance_track") }}

    + {{ render_track_card(total.shortest_distance_track) }} + {% endif %}
    {% endblock %} diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py index 2a4203e..df49c3c 100644 --- a/fietsboek/views/profile.py +++ b/fietsboek/views/profile.py @@ -2,6 +2,7 @@ import datetime import sqlite3 from dataclasses import dataclass +from typing import Optional from pyramid.httpexceptions import HTTPNotFound from pyramid.request import Request @@ -44,6 +45,12 @@ class CumulativeStats: max_speed: float = 0.0 """Overall maximum speed, in m/s.""" + longest_distance_track: Optional[TrackWithMetadata] = None + """The track with the longest distance.""" + + shortest_distance_track: Optional[TrackWithMetadata] = None + """The track with the shortest distance.""" + def add(self, track: TrackWithMetadata): """Adds a track to this stats collection. @@ -57,6 +64,18 @@ class CumulativeStats: self.stopped_time += track.stopped_time self.max_speed = max(self.max_speed, track.max_speed) + if ( + self.shortest_distance_track is None + or self.longest_distance_track.length < track.length + ): + self.longest_distance_track = track + + if ( + self.shortest_distance_track is None + or self.shortest_distance_track.length > track.length + ): + self.shortest_distance_track = track + @property def avg_speed(self) -> float: """Average speed, in m/s.""" @@ -92,6 +111,9 @@ def profile(request: Request) -> dict: meta = TrackWithMetadata(track, request.data_manager) total.add(meta) + total.moving_time = round_to_seconds(total.moving_time) + total.stopped_time = round_to_seconds(total.stopped_time) + heatmap_url = None tilehunt_url = None try: @@ -126,15 +148,8 @@ def profile(request: Request) -> dict: return { "user": request.context, - "total_length": total.length, - "total_uphill": total.uphill, - "total_downhill": total.downhill, - "total_moving_time": round_to_seconds(total.moving_time), - "total_stopped_time": round_to_seconds(total.stopped_time), - "max_speed": total.max_speed, - "avg_speed": total.avg_speed, + "total": total, "mps_to_kph": util.mps_to_kph, - "number_of_tracks": total.count, "heatmap_url": heatmap_url, "tilehunt_url": tilehunt_url, } -- cgit v1.2.3 From 71353f58ba124cff889dffcaf0993e1df72e5067 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Wed, 29 Mar 2023 19:04:01 +0200 Subject: fix longest/shortest confusion --- fietsboek/views/profile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py index df49c3c..f7b1d79 100644 --- a/fietsboek/views/profile.py +++ b/fietsboek/views/profile.py @@ -65,7 +65,7 @@ class CumulativeStats: self.max_speed = max(self.max_speed, track.max_speed) if ( - self.shortest_distance_track is None + self.longest_distance_track is None or self.longest_distance_track.length < track.length ): self.longest_distance_track = track -- cgit v1.2.3 From 66444356a76370ec9c1a5b15576b48ff63bbbed3 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Wed, 29 Mar 2023 19:04:12 +0200 Subject: profile: show longest duration & duration tracks --- fietsboek/models/track.py | 13 +++++++++++++ fietsboek/templates/profile.jinja2 | 10 ++++++++++ fietsboek/views/profile.py | 19 +++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/fietsboek/models/track.py b/fietsboek/models/track.py index e0d2820..4b85d11 100644 --- a/fietsboek/models/track.py +++ b/fietsboek/models/track.py @@ -550,6 +550,19 @@ class TrackWithMetadata: return self._meta()["end_time"] return self.cache.end_time + @property + def duration(self) -> datetime.timedelta: + """Returns the duration of this track. + + This is equivalent to ``end_time - start_time``, given that + no DST change happens between those points. + + Alternatively, it is equivalent to ``moving_time + stopped_time``. + + :return: The track duration. + """ + return self.end_time - self.start_time + def html_tooltip(self, localizer: Localizer) -> Markup: """Generate a quick summary of the track as a HTML element. diff --git a/fietsboek/templates/profile.jinja2 b/fietsboek/templates/profile.jinja2 index fe49990..4906247 100644 --- a/fietsboek/templates/profile.jinja2 +++ b/fietsboek/templates/profile.jinja2 @@ -105,6 +105,16 @@

    {{ _("page.profile.shortest_distance_track") }}

    {{ render_track_card(total.shortest_distance_track) }} {% endif %} + + {% if total.longest_duration_track %} +

    {{ _("page.profile.longest_duration_track") }}

    + {{ render_track_card(total.longest_duration_track) }} + {% endif %} + + {% if total.shortest_duration_track %} +

    {{ _("page.profile.shortest_duration_track") }}

    + {{ render_track_card(total.shortest_duration_track) }} + {% endif %} {% endblock %} diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py index f7b1d79..bf1475c 100644 --- a/fietsboek/views/profile.py +++ b/fietsboek/views/profile.py @@ -23,6 +23,7 @@ class CumulativeStats: The values start out with default values, and tracks can be merged in via :meth:`add`. """ + # pylint: disable=too-many-instance-attributes count: int = 0 """Number of tracks added.""" @@ -51,6 +52,12 @@ class CumulativeStats: shortest_distance_track: Optional[TrackWithMetadata] = None """The track with the shortest distance.""" + longest_duration_track: Optional[TrackWithMetadata] = None + """The track with the longest time.""" + + shortest_duration_track: Optional[TrackWithMetadata] = None + """The track with the shortest time.""" + def add(self, track: TrackWithMetadata): """Adds a track to this stats collection. @@ -76,6 +83,18 @@ class CumulativeStats: ): self.shortest_distance_track = track + if ( + self.longest_duration_track is None + or self.longest_duration_track.duration < track.duration + ): + self.longest_duration_track = track + + if ( + self.shortest_duration_track is None + or self.shortest_duration_track.duration > track.duration + ): + self.shortest_duration_track = track + @property def avg_speed(self) -> float: """Average speed, in m/s.""" -- cgit v1.2.3 From 233c688fed5abf3db150052801db7d6732daa44b Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Wed, 29 Mar 2023 20:16:06 +0200 Subject: fix lint --- fietsboek/views/profile.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py index bf1475c..a08fad7 100644 --- a/fietsboek/views/profile.py +++ b/fietsboek/views/profile.py @@ -23,6 +23,7 @@ class CumulativeStats: The values start out with default values, and tracks can be merged in via :meth:`add`. """ + # pylint: disable=too-many-instance-attributes count: int = 0 @@ -71,10 +72,7 @@ class CumulativeStats: self.stopped_time += track.stopped_time self.max_speed = max(self.max_speed, track.max_speed) - if ( - self.longest_distance_track is None - or self.longest_distance_track.length < track.length - ): + if self.longest_distance_track is None or self.longest_distance_track.length < track.length: self.longest_distance_track = track if ( -- cgit v1.2.3 From c4708163ac9e8157f9c875fb201cfd05f6419c70 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 25 Mar 2023 15:42:11 +0100 Subject: stop sqlite3 DB creation if it doesnt exist This would otherwise happen if e.g. the user has the page open, the SQLite file is deleted, the user then activates the overlay layer, and the sqlite3.connect() creates the database. --- fietsboek/views/profile.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py index a08fad7..9f68d2f 100644 --- a/fietsboek/views/profile.py +++ b/fietsboek/views/profile.py @@ -1,6 +1,7 @@ """Endpoints for the user profile pages.""" import datetime import sqlite3 +import urllib.parse from dataclasses import dataclass from typing import Optional @@ -192,7 +193,16 @@ def user_tile(request: Request) -> Response: if path is None: return HTTPNotFound() - connection = sqlite3.connect(path) + # See + # https://docs.python.org/3/library/sqlite3.html#how-to-work-with-sqlite-uris + # https://stackoverflow.com/questions/10205744/opening-sqlite3-database-from-python-in-read-only-mode + # https://stackoverflow.com/questions/17170202/dont-want-to-create-a-new-database-if-it-doesnt-already-exists + sqlite_uri = urllib.parse.urlunparse(("file", "", str(path), "", "mode=ro", "")) + try: + connection = sqlite3.connect(sqlite_uri, uri=True) + except sqlite3.OperationalError: + return HTTPNotFound() + cursor = connection.cursor() result = cursor.execute( "SELECT data FROM tiles WHERE zoom = ? AND x = ? AND y = ?;", -- cgit v1.2.3 From c8520a5501fc6a472345446597037b4f00687259 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 25 Mar 2023 17:22:08 +0100 Subject: make fietscron call hittekaart --- fietsboek/config.py | 16 ++++++++++ fietsboek/scripts/fietscron.py | 70 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/fietsboek/config.py b/fietsboek/config.py index 3fc191f..42d717c 100644 --- a/fietsboek/config.py +++ b/fietsboek/config.py @@ -198,6 +198,9 @@ class Config(BaseModel): hittekaart_bin: str = Field("", alias="hittekaart.bin") """Path to the hittekaart binary.""" + hittekaart_autogenerate: PyramidList = Field([], alias="hittekaart.autogenerate") + """Overlay maps to automatically generate.""" + @validator("session_key") def _good_session_key(cls, value): """Ensures that the session key has been changed from its default @@ -225,6 +228,19 @@ class Config(BaseModel): raise ValueError("Unknown stamen maps: " + ", ".join(bad_maps)) return value + @validator("hittekaart_autogenerate") + def _known_hittekaart_modes(cls, value): + """Ensures that the hittekaart modes are valid.""" + # pylint: disable=import-outside-toplevel + from . import hittekaart + + modes = set(value) + known_modes = {mode.value for mode in hittekaart.Mode} + bad_modes = modes - known_modes + if bad_modes: + raise ValueError("Unknown hittekaart overlays: " + ", ".join(bad_modes)) + return value + def derive_secret(self, what_for): """Derive a secret for other parts of the application. diff --git a/fietsboek/scripts/fietscron.py b/fietsboek/scripts/fietscron.py index a142f39..e422fb2 100644 --- a/fietsboek/scripts/fietscron.py +++ b/fietsboek/scripts/fietscron.py @@ -2,15 +2,19 @@ import datetime import logging import logging.config +from pathlib import Path import click import pyramid.paster +import redis as mod_redis +from redis import Redis from sqlalchemy import create_engine, delete, exists, not_, select from sqlalchemy.engine import Engine from sqlalchemy.orm import Session from .. import config as mod_config -from .. import models +from .. import hittekaart, models +from ..config import Config from ..data import DataManager LOGGER = logging.getLogger(__name__) @@ -32,6 +36,7 @@ def cli(config): \b * Deletes pending uploads that are older than 24 hours. * Rebuilds the cache for missing tracks. + * (optional) Runs ``hittekaart`` to generate heatmaps """ logging.config.fileConfig(config) settings = pyramid.paster.get_appsettings(config) @@ -50,6 +55,10 @@ def cli(config): remove_old_uploads(engine) rebuild_cache(engine, data_manager) + if config.hittekaart_autogenerate: + redis = mod_redis.from_url(config.redis_url) + run_hittekaart(engine, data_manager, redis, config) + def remove_old_uploads(engine: Engine): """Removes old uploads from the database.""" @@ -77,4 +86,63 @@ def rebuild_cache(engine: Engine, data_manager: DataManager): session.commit() +def run_hittekaart(engine: Engine, data_manager: DataManager, redis: Redis, config: Config): + """Run outstanding hittekaart requests.""" + # The logic here is as follows: + # We keep two lists: a high-priority one and a low-priority one + # If there are high priority entries, we run all of them. + # They are refilled when users upload tracks. + # If there are no high priority entries, we run a single low priority one. + # If there are no low priority entries, we re-fill the queue by adding all tracks. + # This way, we ensure that we "catch" modifications fast, but we also + # re-generate all maps over time (e.g. if the hittekaart version changes or + # we miss an update). + modes = [hittekaart.Mode(mode) for mode in config.hittekaart_autogenerate] + exe_path = Path(config.hittekaart_bin) if config.hittekaart_bin else None + session = Session(engine) + had_hq_item = False + + while True: + # High-priority queue + item = redis.spop("hittekaart:queue:high") + if item is None: + break + user = session.execute(select(models.User).filter_by(id=int(item))).scalar() + if user is None: + LOGGER.debug("User %d had a queue entry but was not found", item) + break + + for mode in modes: + LOGGER.info("Generating %s for user %d", mode.value, user.id) + hittekaart.generate_for(user, session, data_manager, mode, exe_path=exe_path) + + if had_hq_item: + return + + # Low-priority queue + item = redis.spop("hittekaart:queue:low") + if item is None: + refill_queue(session, redis) + item = redis.spop("hittekaart:queue:low") + if item is None: + LOGGER.debug("No users, no hittekaarts") + return + + user = session.execute(select(models.User).filter_by(id=int(item))).scalar() + if user is None: + LOGGER.debug("User %d had a queue entry but was not found", item) + return + + for mode in modes: + LOGGER.info("Generating %s for user %d", mode.value, user.id) + hittekaart.generate_for(user, session, data_manager, mode, exe_path=exe_path) + + +def refill_queue(session: Session, redis: Redis): + """Refills the low-priority hittekaart queue by adding all users to it.""" + LOGGER.debug("Refilling low-priority queue") + for user in session.execute(select(models.User)).scalars(): + redis.sadd("hittekaart:queue:low", str(user.id)) + + __all__ = ["cli"] -- cgit v1.2.3 From 809a2416c93318ec2061d6dc49b662bd8abf1c46 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 25 Mar 2023 17:53:02 +0100 Subject: insert user into hittekaart queue on track uploads --- fietsboek/scripts/fietscron.py | 2 +- fietsboek/views/detail.py | 4 ++++ fietsboek/views/upload.py | 3 +++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/fietsboek/scripts/fietscron.py b/fietsboek/scripts/fietscron.py index e422fb2..897078c 100644 --- a/fietsboek/scripts/fietscron.py +++ b/fietsboek/scripts/fietscron.py @@ -142,7 +142,7 @@ def refill_queue(session: Session, redis: Redis): """Refills the low-priority hittekaart queue by adding all users to it.""" LOGGER.debug("Refilling low-priority queue") for user in session.execute(select(models.User)).scalars(): - redis.sadd("hittekaart:queue:low", str(user.id)) + redis.sadd("hittekaart:queue:low", user.id) __all__ = ["cli"] diff --git a/fietsboek/views/detail.py b/fietsboek/views/detail.py index 259e5d6..f540a11 100644 --- a/fietsboek/views/detail.py +++ b/fietsboek/views/detail.py @@ -138,6 +138,10 @@ def delete_track(request): request.dbsession.delete(track) request.data_manager.purge(track_id) request.session.flash(request.localizer.translate(_("flash.track_deleted"))) + + if request.config.hittekaart_autogenerate: + request.redis.sadd("hittekaart:queue:high", request.identity.id) + return HTTPFound(request.route_url("home")) diff --git a/fietsboek/views/upload.py b/fietsboek/views/upload.py index fd93034..6fccdba 100644 --- a/fietsboek/views/upload.py +++ b/fietsboek/views/upload.py @@ -187,6 +187,9 @@ def do_finish_upload(request): request.session.flash(request.localizer.translate(_("flash.upload_success"))) + if request.config.hittekaart_autogenerate: + request.redis.sadd("hittekaart:queue:high", request.identity.id) + return HTTPFound(request.route_url("details", track_id=track.id)) -- cgit v1.2.3 From 4360b2582309c99306476140b302ab82e8158cda Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 25 Mar 2023 18:01:46 +0100 Subject: suppress hittekaart standard output Otherwise it'll show progress bars. --- fietsboek/hittekaart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fietsboek/hittekaart.py b/fietsboek/hittekaart.py index f580c51..a41f8b7 100644 --- a/fietsboek/hittekaart.py +++ b/fietsboek/hittekaart.py @@ -72,7 +72,7 @@ def generate( ] cmdline.extend(map(str, input_files)) LOGGER.debug("Running %r", cmdline) - subprocess.run(cmdline, check=True) + subprocess.run(cmdline, check=True, stdout=subprocess.DEVNULL) LOGGER.debug("Moving temporary file") shutil.move(tmpfile, output) -- cgit v1.2.3 From ace7c4ab80e7bd7ac2acdebede6437c56636e7b1 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Fri, 31 Mar 2023 23:29:48 +0200 Subject: add a link to the profile to the user menu --- fietsboek/templates/layout.jinja2 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/fietsboek/templates/layout.jinja2 b/fietsboek/templates/layout.jinja2 index 19fb700..b6fcea0 100644 --- a/fietsboek/templates/layout.jinja2 +++ b/fietsboek/templates/layout.jinja2 @@ -75,7 +75,10 @@ const Legende = false; {{ _("page.navbar.logout") }}
  • - {{ _("page.navbar.profile") }} + {{ _("page.navbar.profile") }} +
  • +
  • + {{ _("page.navbar.user_data") }}
  • {% if request.identity.is_admin %}
  • -- cgit v1.2.3 From 114274f900904070e941c4a544426b5d9d1267d2 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Fri, 31 Mar 2023 23:57:59 +0200 Subject: hide ugliness of tile url function We basically do the same hacky trick in two different places, so maybe we should put it into a separate function, test it, and if a better implementation arises, swap it. --- fietsboek/jinja2.py | 9 +++------ fietsboek/util.py | 27 +++++++++++++++++++++++++++ fietsboek/views/profile.py | 25 +++---------------------- tests/unit/test_util.py | 9 +++++++++ 4 files changed, 42 insertions(+), 28 deletions(-) diff --git a/fietsboek/jinja2.py b/fietsboek/jinja2.py index a3ca7d8..f5ae7d7 100644 --- a/fietsboek/jinja2.py +++ b/fietsboek/jinja2.py @@ -9,6 +9,8 @@ from jinja2.runtime import Context from markupsafe import Markup from pyramid.request import Request +from . import util + @jinja2.pass_context def filter_format_decimal(ctx: Context, value: float) -> str: @@ -94,12 +96,7 @@ def global_embed_tile_layers(request: Request) -> Markup: else: def _url(source): - return ( - request.route_url("tile-proxy", provider=source.layer_id, x="{x}", y="{y}", z="{z}") - .replace("%7Bx%7D", "{x}") - .replace("%7By%7D", "{y}") - .replace("%7Bz%7D", "{z}") - ) + return util.tile_url(request, "tile-proxy", provider=source.layer_id) return Markup( json.dumps( diff --git a/fietsboek/util.py b/fietsboek/util.py index d6d0aea..c507cf1 100644 --- a/fietsboek/util.py +++ b/fietsboek/util.py @@ -322,6 +322,33 @@ def read_localized_resource( return f"{locale_name}:{path}" +def tile_url(request: Request, route_name: str, **kwargs: str) -> str: + """Generates a URL for tiles. + + This is basically :meth:`request.route_url`, but it keeps the + ``{x}``/``{y}``/``{z}`` placeholders intact for consumption by Leaflet. + + Expects that the URL takes a ``x``, ``y`` and ``z`` parameter. + + :param request: The Pyramid request. + :param route_name: The name of the route. + :param kwargs: Remaining route parameters. Will be passed to ``route_url``. + :return: The route URL, with intact placeholders. + """ + # This feels hacky (because it is), but I don't see a better way. The route + # generation is basically a closure that fills the placeholders + # all-or-none, and there is no way to get a "structured" representation of + # the URL without re-parsing it. + # Using {x} triggers the urlquoting, so we need to do some .replace() calls + # in the end anyway. Might as well use something that looks a bit better, + # and not %7Bx%7D. + kwargs["x"] = "__x__" + kwargs["y"] = "__y__" + kwargs["z"] = "__z__" + route = request.route_url(route_name, **kwargs) + return route.replace("__x__", "{x}").replace("__y__", "{y}").replace("__z__", "{z}") + + 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/profile.py b/fietsboek/views/profile.py index 9f68d2f..4d23ae4 100644 --- a/fietsboek/views/profile.py +++ b/fietsboek/views/profile.py @@ -132,6 +132,7 @@ def profile(request: Request) -> dict: total.moving_time = round_to_seconds(total.moving_time) total.stopped_time = round_to_seconds(total.stopped_time) + user_id = request.context.id heatmap_url = None tilehunt_url = None try: @@ -140,29 +141,9 @@ def profile(request: Request) -> dict: pass else: if user_dir.heatmap_path().is_file(): - heatmap_url = request.route_url( - "user-tile", - user_id=request.context.id, - map="heatmap", - z="ZZZ", - x="XXX", - y="YYY", - ) - heatmap_url = ( - heatmap_url.replace("ZZZ", "{z}").replace("XXX", "{x}").replace("YYY", "{y}") - ) + heatmap_url = util.tile_url(request, "user-tile", user_id=user_id, map="heatmap") if user_dir.tilehunt_path().is_file(): - tilehunt_url = request.route_url( - "user-tile", - user_id=request.context.id, - map="tilehunt", - z="ZZZ", - x="XXX", - y="YYY", - ) - tilehunt_url = ( - tilehunt_url.replace("ZZZ", "{z}").replace("XXX", "{x}").replace("YYY", "{y}") - ) + tilehunt_url = util.tile_url(request, "user-tile", user_id=user_id, map="tilehunt") return { "user": request.context, diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py index b35f218..949acb5 100644 --- a/tests/unit/test_util.py +++ b/tests/unit/test_util.py @@ -82,3 +82,12 @@ def test_tour_metadata(gpx_file): @pytest.mark.parametrize('mps, kph', [(1, 3.6), (10, 36)]) def test_mps_to_kph(mps, kph): assert util.mps_to_kph(mps) == pytest.approx(kph, 0.1) + + +def test_tile_url(app_request): + route_url = util.tile_url(app_request, "tile-proxy", provider="bobby") + + assert "{x}" in route_url + assert "{y}" in route_url + assert "{z}" in route_url + assert "bobby" in route_url -- cgit v1.2.3 From 73561d641ddc52eeca438d100472820721c6a04e Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 1 Apr 2023 00:00:27 +0200 Subject: fix copy/paste error We copied the factory method from Track, but forgot to change some of the words. Now the code fits better with what the function is doing. As a bonus, pylint no longer complains about a duplicate method. --- fietsboek/models/user.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/fietsboek/models/user.py b/fietsboek/models/user.py index 58a04bd..e1b4841 100644 --- a/fietsboek/models/user.py +++ b/fietsboek/models/user.py @@ -141,20 +141,20 @@ class User(Base): def factory(cls, request: Request) -> "User": """Factory method to pass to a route definition. - This factory retrieves the track based on the ``track_id`` matched - route parameter, and returns the track. If the track is not found, + This factory retrieves the user based on the ``user_id`` matched + route parameter, and returns the user. If the user is not found, ``HTTPNotFound`` is raised. - :raises pyramid.httpexception.NotFound: If the track is not found. + :raises pyramid.httpexception.NotFound: If the user is not found. :param request: The pyramid request. - :return: The track. + :return: The user. """ user_id = request.matchdict["user_id"] query = select(cls).filter_by(id=user_id) - track = request.dbsession.execute(query).scalar_one_or_none() - if track is None: + user = request.dbsession.execute(query).scalar_one_or_none() + if user is None: raise HTTPNotFound() - return track + return user def __acl__(self): acl = [ -- cgit v1.2.3 From ed0200a14a6bc54c25b2a82fd2fc9ed62f04ac94 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 1 Apr 2023 11:30:44 +0200 Subject: actually check permission for user profiles Otherwise everyone can just access any profile. --- fietsboek/views/profile.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py index 4d23ae4..81ec16d 100644 --- a/fietsboek/views/profile.py +++ b/fietsboek/views/profile.py @@ -113,6 +113,7 @@ def round_to_seconds(value: datetime.timedelta) -> datetime.timedelta: route_name="profile", renderer="fietsboek:templates/profile.jinja2", request_method="GET", + permission="profile.view", ) def profile(request: Request) -> dict: """Shows the profile page. @@ -154,7 +155,7 @@ def profile(request: Request) -> dict: } -@view_config(route_name="user-tile", request_method="GET") +@view_config(route_name="user-tile", request_method="GET", permission="profile.view") def user_tile(request: Request) -> Response: """Returns a single tile from the user's own overlay maps. -- cgit v1.2.3 From 982d6c8cd5ba6ade04683e5699fc9fc170e4c109 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 1 Apr 2023 13:05:40 +0200 Subject: fix round_to_multiple for second-level accuracy It was ovious that this is broken when you try to round "0s" to second-level granularity, and you end up with "1s". The problem comes from the fact that we use the integer divison when checking whether we should round up or down, but then also use strict inequality. To fix this, we now also round down if the second_offset is equal to the halfway point (which in the case of second-level granularity is 0). --- fietsboek/util.py | 2 +- tests/unit/test_util.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/fietsboek/util.py b/fietsboek/util.py index c507cf1..02981c0 100644 --- a/fietsboek/util.py +++ b/fietsboek/util.py @@ -91,7 +91,7 @@ def round_timedelta_to_multiple( """ lower = value.total_seconds() // multiples.total_seconds() * multiples.total_seconds() second_offset = value.total_seconds() - lower - if second_offset < multiples.total_seconds() // 2: + if second_offset <= multiples.total_seconds() // 2: # Round down return datetime.timedelta(seconds=lower) # Round up diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py index 949acb5..0ac5c33 100644 --- a/tests/unit/test_util.py +++ b/tests/unit/test_util.py @@ -29,6 +29,11 @@ def test_fix_iso_timestamp(timestamp, fixed): @pytest.mark.parametrize('delta, multiple, expected', [ + ( + timedelta(seconds=0), + timedelta(seconds=1), + timedelta(seconds=0), + ), ( timedelta(minutes=42), timedelta(minutes=15), -- cgit v1.2.3 From bc5074a52b81e85245d628aebd23eb65c496c35a Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 1 Apr 2023 13:07:23 +0200 Subject: fix division by zero for profile page Now that round_to_seconds can actually return 0, we need to catch this case. --- fietsboek/views/profile.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py index 81ec16d..e73df42 100644 --- a/fietsboek/views/profile.py +++ b/fietsboek/views/profile.py @@ -97,6 +97,8 @@ class CumulativeStats: @property def avg_speed(self) -> float: """Average speed, in m/s.""" + if not self.moving_time: + return 0.0 return self.length / self.moving_time.total_seconds() -- cgit v1.2.3 From e37e056c36272e5f0c0e23bd993290208e14a979 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 1 Apr 2023 13:08:02 +0200 Subject: add some very basic tests for profiles I'd like to have more, but this is a start (and already caught some errors, see the last two commits). --- tests/playwright/test_profiles.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/playwright/test_profiles.py diff --git a/tests/playwright/test_profiles.py b/tests/playwright/test_profiles.py new file mode 100644 index 0000000..40d8ba8 --- /dev/null +++ b/tests/playwright/test_profiles.py @@ -0,0 +1,27 @@ +from playwright.sync_api import Page, expect + +from .conftest import Helper + + +def test_forbidden(page: Page, playwright_helper: Helper): + john = playwright_helper.john_doe() + + with page.expect_response(lambda resp: resp.status == 403): + page.goto(f"/user/{john.id}") + + +def test_profile(page: Page, playwright_helper: Helper): + playwright_helper.login() + + page.goto("/") + page.get_by_role("button", name="User").click() + page.get_by_role("link", name="Profile").click() + + expect(page.locator("#profileLength")).to_have_text("0 km") + expect(page.locator("#profileUphill")).to_have_text("0 m") + expect(page.locator("#profileDownhill")).to_have_text("0 m") + expect(page.locator("#profileMovingTime")).to_have_text("0:00:00") + expect(page.locator("#profileStoppedTime")).to_have_text("0:00:00") + expect(page.locator("#profileMaxSpeed")).to_have_text("0 km/h") + expect(page.locator("#profileAvgSpeed")).to_have_text("0 km/h") + expect(page.locator("#profileNumberOfTracks")).to_have_text("0") -- cgit v1.2.3 From 78ea2acd5950919ee65960ef8d87359d223a0068 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 1 Apr 2023 13:14:17 +0200 Subject: fix isolated running of unit tests They caused issues because they might not have created the database tables or the data directory. Since the cleanup job runs globally after every test, it should take that into consideration and not error out. --- tests/conftest.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index a499bec..d4394cd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,7 @@ import pytest import transaction import webtest -from sqlalchemy import delete, select +from sqlalchemy import delete, inspect, select from fietsboek import main, models from fietsboek.data import DataManager @@ -59,12 +59,18 @@ def data_manager(app_settings): def _cleanup_data(app_settings): yield engine = models.get_engine(app_settings) + db_meta = inspect(engine) with engine.begin() as connection: for table in reversed(Base.metadata.sorted_tables): - connection.execute(table.delete()) - data_dir = Path(app_settings["fietsboek.data_dir"]) - if (data_dir / "tracks").is_dir(): - shutil.rmtree(data_dir / "tracks") + # The unit tests don't always set up the tables, so be gentle when + # tearing them down + if db_meta.has_table(table.name): + connection.execute(table.delete()) + # The unit tests also often don't have a data directory, so be gentle here as well + if "fietsboek.data_dir" in app_settings: + data_dir = Path(app_settings["fietsboek.data_dir"]) + if (data_dir / "tracks").is_dir(): + shutil.rmtree(data_dir / "tracks") @pytest.fixture(scope='session') def app(app_settings, dbengine, tmp_path_factory): -- cgit v1.2.3 From e3b9e0e1de60acb441794c36b1aee55ff8f1d94f Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sat, 1 Apr 2023 13:17:40 +0200 Subject: remove import across toplevel Python seems to do fine, but pylint complains (probably rightfully, since the tests do not represent packages). We lose type hinting for the playwright_helper, but there's probably a better way to do it in the future. --- tests/playwright/test_profiles.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/playwright/test_profiles.py b/tests/playwright/test_profiles.py index 40d8ba8..7e5fb3c 100644 --- a/tests/playwright/test_profiles.py +++ b/tests/playwright/test_profiles.py @@ -1,16 +1,14 @@ from playwright.sync_api import Page, expect -from .conftest import Helper - -def test_forbidden(page: Page, playwright_helper: Helper): +def test_forbidden(page: Page, playwright_helper): john = playwright_helper.john_doe() with page.expect_response(lambda resp: resp.status == 403): page.goto(f"/user/{john.id}") -def test_profile(page: Page, playwright_helper: Helper): +def test_profile(page: Page, playwright_helper): playwright_helper.login() page.goto("/") -- cgit v1.2.3 From 7efa4be4dc5c41dfb8cd57815fc0d689aab543e0 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Mon, 3 Apr 2023 20:57:15 +0200 Subject: add translations for profile page I'm not too happy with the longest/shortest time/duration words (neither in English nor in German), but those will do for now until we can think of better ones. --- fietsboek/locale/de/LC_MESSAGES/messages.mo | Bin 12157 -> 13053 bytes fietsboek/locale/de/LC_MESSAGES/messages.po | 224 +++++++++++++++++--------- fietsboek/locale/en/LC_MESSAGES/messages.mo | Bin 11441 -> 12272 bytes fietsboek/locale/en/LC_MESSAGES/messages.po | 237 ++++++++++++++++++---------- fietsboek/locale/fietslog.pot | 200 +++++++++++++++-------- 5 files changed, 435 insertions(+), 226 deletions(-) diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.mo b/fietsboek/locale/de/LC_MESSAGES/messages.mo index a79349d..7627f21 100644 Binary files a/fietsboek/locale/de/LC_MESSAGES/messages.mo and b/fietsboek/locale/de/LC_MESSAGES/messages.mo differ diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.po b/fietsboek/locale/de/LC_MESSAGES/messages.po index 8b59ecb..500d73b 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-03-07 20:11+0100\n" +"POT-Creation-Date: 2023-04-03 20:34+0200\n" "PO-Revision-Date: 2022-07-02 17:35+0200\n" "Last-Translator: FULL NAME \n" "Language: de\n" @@ -18,39 +18,39 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.11.0\n" -#: fietsboek/util.py:276 +#: fietsboek/util.py:275 msgid "password_constraint.mismatch" msgstr "Passwörter stimmen nicht überein" -#: fietsboek/util.py:278 +#: fietsboek/util.py:277 msgid "password_constraint.length" msgstr "Passwort zu kurz" -#: fietsboek/models/track.py:566 +#: fietsboek/models/track.py:579 msgid "tooltip.table.length" msgstr "Länge" -#: fietsboek/models/track.py:567 +#: fietsboek/models/track.py:580 msgid "tooltip.table.uphill" msgstr "Bergauf" -#: fietsboek/models/track.py:568 +#: fietsboek/models/track.py:581 msgid "tooltip.table.downhill" msgstr "Bergab" -#: fietsboek/models/track.py:569 +#: fietsboek/models/track.py:582 msgid "tooltip.table.moving_time" msgstr "Fahrzeit" -#: fietsboek/models/track.py:570 +#: fietsboek/models/track.py:583 msgid "tooltip.table.stopped_time" msgstr "Haltezeit" -#: fietsboek/models/track.py:572 +#: fietsboek/models/track.py:585 msgid "tooltip.table.max_speed" msgstr "Maximalgeschwindigkeit" -#: fietsboek/models/track.py:576 +#: fietsboek/models/track.py:589 msgid "tooltip.table.avg_speed" msgstr "Durchschnittsgeschwindigkeit" @@ -155,42 +155,52 @@ msgid "page.browse.synthetic_tooltip" msgstr "Dies ist eine geplante Strecke" #: fietsboek/templates/browse.jinja2:132 fietsboek/templates/details.jinja2:90 +#: fietsboek/templates/profile.jinja2:15 msgid "page.details.date" msgstr "Datum" #: fietsboek/templates/browse.jinja2:134 fietsboek/templates/details.jinja2:104 +#: fietsboek/templates/profile.jinja2:17 msgid "page.details.length" msgstr "Länge" #: fietsboek/templates/browse.jinja2:139 fietsboek/templates/details.jinja2:95 +#: fietsboek/templates/profile.jinja2:21 msgid "page.details.start_time" msgstr "Startzeit" #: fietsboek/templates/browse.jinja2:141 fietsboek/templates/details.jinja2:99 +#: fietsboek/templates/profile.jinja2:23 msgid "page.details.end_time" msgstr "Endzeit" #: fietsboek/templates/browse.jinja2:146 fietsboek/templates/details.jinja2:108 +#: fietsboek/templates/profile.jinja2:27 msgid "page.details.uphill" msgstr "Bergauf" #: fietsboek/templates/browse.jinja2:148 fietsboek/templates/details.jinja2:112 +#: fietsboek/templates/profile.jinja2:29 msgid "page.details.downhill" msgstr "Bergab" #: fietsboek/templates/browse.jinja2:153 fietsboek/templates/details.jinja2:117 +#: fietsboek/templates/profile.jinja2:33 msgid "page.details.moving_time" msgstr "Fahrzeit" #: fietsboek/templates/browse.jinja2:155 fietsboek/templates/details.jinja2:121 +#: fietsboek/templates/profile.jinja2:35 msgid "page.details.stopped_time" msgstr "Haltezeit" #: fietsboek/templates/browse.jinja2:159 fietsboek/templates/details.jinja2:125 +#: fietsboek/templates/profile.jinja2:39 msgid "page.details.max_speed" msgstr "maximale Geschwindigkeit" #: fietsboek/templates/browse.jinja2:161 fietsboek/templates/details.jinja2:129 +#: fietsboek/templates/profile.jinja2:41 msgid "page.details.avg_speed" msgstr "durchschnittliche Geschwindigkeit" @@ -471,47 +481,51 @@ msgstr[1] "%(num)d Strecken" msgid "page.home.total" msgstr "Gesamt" -#: fietsboek/templates/layout.jinja2:39 +#: fietsboek/templates/layout.jinja2:41 msgid "page.navbar.toggle" msgstr "Navigation umschalten" -#: fietsboek/templates/layout.jinja2:50 +#: fietsboek/templates/layout.jinja2:52 msgid "page.navbar.home" msgstr "Startseite" -#: fietsboek/templates/layout.jinja2:53 +#: fietsboek/templates/layout.jinja2:55 msgid "page.navbar.browse" msgstr "Stöbern" -#: fietsboek/templates/layout.jinja2:57 +#: fietsboek/templates/layout.jinja2:59 msgid "page.navbar.upload" msgstr "Hochladen" -#: fietsboek/templates/layout.jinja2:66 +#: fietsboek/templates/layout.jinja2:68 msgid "page.navbar.user" msgstr "Nutzer" -#: fietsboek/templates/layout.jinja2:70 +#: fietsboek/templates/layout.jinja2:72 msgid "page.navbar.welcome_user" msgstr "Willkommen, {}!" -#: fietsboek/templates/layout.jinja2:73 +#: fietsboek/templates/layout.jinja2:75 msgid "page.navbar.logout" msgstr "Abmelden" -#: fietsboek/templates/layout.jinja2:76 +#: fietsboek/templates/layout.jinja2:78 msgid "page.navbar.profile" msgstr "Profil" -#: fietsboek/templates/layout.jinja2:80 +#: fietsboek/templates/layout.jinja2:81 +msgid "page.navbar.user_data" +msgstr "Persönliche Daten" + +#: fietsboek/templates/layout.jinja2:85 msgid "page.navbar.admin" msgstr "Admin" -#: fietsboek/templates/layout.jinja2:86 +#: fietsboek/templates/layout.jinja2:91 msgid "page.navbar.login" msgstr "Anmelden" -#: fietsboek/templates/layout.jinja2:90 +#: fietsboek/templates/layout.jinja2:95 msgid "page.navbar.create_account" msgstr "Konto Erstellen" @@ -563,80 +577,136 @@ msgstr "Passwörter stimmen nicht überein" msgid "page.password_reset.reset" msgstr "Zurücksetzen" -#: fietsboek/templates/profile.jinja2:7 +#: fietsboek/templates/profile.jinja2:66 +msgid "page.profile.length" +msgstr "Länge" + +#: fietsboek/templates/profile.jinja2:70 +msgid "page.profile.uphill" +msgstr "Bergauf" + +#: fietsboek/templates/profile.jinja2:74 +msgid "page.profile.downhill" +msgstr "Bergab" + +#: fietsboek/templates/profile.jinja2:78 +msgid "page.profile.moving_time" +msgstr "Fahrzeit" + +#: fietsboek/templates/profile.jinja2:82 +msgid "page.profile.stopped_time" +msgstr "Haltezeit" + +#: fietsboek/templates/profile.jinja2:86 +msgid "page.profile.max_speed" +msgstr "maximale Geschwindigkeit" + +#: fietsboek/templates/profile.jinja2:90 +msgid "page.profile.avg_speed" +msgstr "durchschnittliche Geschwindigkeit" + +#: fietsboek/templates/profile.jinja2:94 +msgid "page.profile.number_of_tracks" +msgstr "Anzahl der Strecken" + +#: fietsboek/templates/profile.jinja2:100 +msgid "page.profile.longest_distance_track" +msgstr "Weiteste Strecke" + +#: fietsboek/templates/profile.jinja2:105 +msgid "page.profile.shortest_distance_track" +msgstr "Kürzeste Strecke" + +#: fietsboek/templates/profile.jinja2:110 +msgid "page.profile.longest_duration_track" +msgstr "Am Längsten Dauernde Strecke" + +#: fietsboek/templates/profile.jinja2:115 +msgid "page.profile.shortest_duration_track" +msgstr "Am Kürzesten Dauernde Strecke" + +#: fietsboek/templates/profile.jinja2:135 +msgid "page.profile.heatmap" +msgstr "Heatmap" + +#: fietsboek/templates/profile.jinja2:140 +msgid "page.profile.tilehunt" +msgstr "Kacheljäger" + +#: fietsboek/templates/request_password.jinja2:5 +msgid "page.request_password.title" +msgstr "Passwortzurücksetzung Beantragen" + +#: fietsboek/templates/request_password.jinja2:6 +msgid "page.request_password.info" +msgstr "" +"Wenn Du Dein Passwort vergessen hast, kannst Du Deine E-Mail-Adresse hier" +" eingeben und einen Link zum Zurücksetzen Deines Passworts erhalten." + +#: fietsboek/templates/request_password.jinja2:12 +msgid "page.request_password.email" +msgstr "E-Mail-Adresse" + +#: fietsboek/templates/request_password.jinja2:17 +msgid "page.request_password.request" +msgstr "Anfrage senden" + +#: fietsboek/templates/upload.jinja2:9 +msgid "page.upload.form.gpx" +msgstr "GPX Datei" + +#: fietsboek/templates/user_data.jinja2:7 msgid "page.my_profile.title" msgstr "Mein Profil" -#: fietsboek/templates/profile.jinja2:11 +#: fietsboek/templates/user_data.jinja2:11 msgid "page.my_profile.personal_data" msgstr "Persönliche Daten" -#: fietsboek/templates/profile.jinja2:16 +#: fietsboek/templates/user_data.jinja2:16 msgid "page.my_profile.personal_data.name" msgstr "Name" -#: fietsboek/templates/profile.jinja2:21 +#: fietsboek/templates/user_data.jinja2:21 msgid "page.my_profile.personal_data.password_invalid" msgstr "Passwort zu kurz" -#: fietsboek/templates/profile.jinja2:23 +#: fietsboek/templates/user_data.jinja2:23 msgid "page.my_profile.personal_data.password" msgstr "Passwort" -#: fietsboek/templates/profile.jinja2:28 +#: fietsboek/templates/user_data.jinja2:28 msgid "page.my_profile.personal_data.password_must_match" msgstr "Passwörter müssen übereinstimmen" -#: fietsboek/templates/profile.jinja2:30 +#: fietsboek/templates/user_data.jinja2:30 msgid "page.my_profile.personal_data.repeat_password" msgstr "Passwort wiederholen" -#: fietsboek/templates/profile.jinja2:33 +#: fietsboek/templates/user_data.jinja2:33 msgid "page.my_profile.personal_data.save" msgstr "Speichern" -#: fietsboek/templates/profile.jinja2:38 +#: fietsboek/templates/user_data.jinja2:38 msgid "page.my_profile.friends" msgstr "Freunde" -#: fietsboek/templates/profile.jinja2:46 +#: fietsboek/templates/user_data.jinja2:46 msgid "page.my_profile.unfriend" msgstr "Entfreunden" -#: fietsboek/templates/profile.jinja2:56 +#: fietsboek/templates/user_data.jinja2:56 msgid "page.my_profile.accept_friend" msgstr "Annehmen" -#: fietsboek/templates/profile.jinja2:73 +#: fietsboek/templates/user_data.jinja2:73 msgid "page.my_profile.friend_request_email" msgstr "E-Mail-Adresse des Freundes" -#: fietsboek/templates/profile.jinja2:77 +#: fietsboek/templates/user_data.jinja2:77 msgid "page.my_profile.send_friend_request" msgstr "Freundschaftsanfrage senden" -#: fietsboek/templates/request_password.jinja2:5 -msgid "page.request_password.title" -msgstr "Passwortzurücksetzung Beantragen" - -#: fietsboek/templates/request_password.jinja2:6 -msgid "page.request_password.info" -msgstr "" -"Wenn Du Dein Passwort vergessen hast, kannst Du Deine E-Mail-Adresse hier" -" eingeben und einen Link zum Zurücksetzen Deines Passworts erhalten." - -#: fietsboek/templates/request_password.jinja2:12 -msgid "page.request_password.email" -msgstr "E-Mail-Adresse" - -#: fietsboek/templates/request_password.jinja2:17 -msgid "page.request_password.request" -msgstr "Anfrage senden" - -#: fietsboek/templates/upload.jinja2:9 -msgid "page.upload.form.gpx" -msgstr "GPX Datei" - #: fietsboek/transformers/__init__.py:140 msgid "transformers.fix-null-elevation.title" msgstr "Nullhöhen beheben" @@ -730,26 +800,6 @@ msgstr "Passwort aktualisiert" msgid "flash.track_deleted" msgstr "Strecke gelöscht" -#: fietsboek/views/profile.py:60 -msgid "flash.personal_data_updated" -msgstr "Persönliche Daten wurden gespeichert" - -#: fietsboek/views/profile.py:78 -msgid "flash.friend_not_found" -msgstr "Das angegebene Konto wurde nicht gefunden" - -#: fietsboek/views/profile.py:84 -msgid "flash.friend_already_exists" -msgstr "Dieser Freund existiert bereits" - -#: fietsboek/views/profile.py:92 -msgid "flash.friend_added" -msgstr "Freund hinzugefügt" - -#: fietsboek/views/profile.py:102 -msgid "flash.friend_request_sent" -msgstr "Freundschaftsanfrage gesendet" - #: fietsboek/views/upload.py:52 msgid "flash.no_file_selected" msgstr "Keine Datei ausgewählt" @@ -762,7 +812,27 @@ msgstr "Ungültige GPX-Datei gesendet" msgid "flash.upload_success" msgstr "Hochladen erfolgreich" -#: fietsboek/views/upload.py:204 +#: fietsboek/views/upload.py:207 msgid "flash.upload_cancelled" msgstr "Hochladen abgebrochen" +#: fietsboek/views/user_data.py:60 +msgid "flash.personal_data_updated" +msgstr "Persönliche Daten wurden gespeichert" + +#: fietsboek/views/user_data.py:78 +msgid "flash.friend_not_found" +msgstr "Das angegebene Konto wurde nicht gefunden" + +#: fietsboek/views/user_data.py:84 +msgid "flash.friend_already_exists" +msgstr "Dieser Freund existiert bereits" + +#: fietsboek/views/user_data.py:92 +msgid "flash.friend_added" +msgstr "Freund hinzugefügt" + +#: fietsboek/views/user_data.py:102 +msgid "flash.friend_request_sent" +msgstr "Freundschaftsanfrage gesendet" + diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.mo b/fietsboek/locale/en/LC_MESSAGES/messages.mo index 13debb5..723b1a5 100644 Binary files a/fietsboek/locale/en/LC_MESSAGES/messages.mo and b/fietsboek/locale/en/LC_MESSAGES/messages.mo differ diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.po b/fietsboek/locale/en/LC_MESSAGES/messages.po index 632e0b7..94fd6a6 100644 --- a/fietsboek/locale/en/LC_MESSAGES/messages.po +++ b/fietsboek/locale/en/LC_MESSAGES/messages.po @@ -7,50 +7,51 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-03-07 20:11+0100\n" -"PO-Revision-Date: 2022-06-28 13:11+0200\n" +"POT-Creation-Date: 2023-04-03 20:34+0200\n" +"PO-Revision-Date: 2023-04-03 20:42+0200\n" "Last-Translator: \n" -"Language: en\n" "Language-Team: en \n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language: en\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Generated-By: Babel 2.11.0\n" +"X-Generator: Poedit 3.2.2\n" -#: fietsboek/util.py:276 +#: fietsboek/util.py:275 msgid "password_constraint.mismatch" msgstr "Passwords don't match" -#: fietsboek/util.py:278 +#: fietsboek/util.py:277 msgid "password_constraint.length" msgstr "Password not long enough" -#: fietsboek/models/track.py:566 +#: fietsboek/models/track.py:579 msgid "tooltip.table.length" msgstr "Length" -#: fietsboek/models/track.py:567 +#: fietsboek/models/track.py:580 msgid "tooltip.table.uphill" msgstr "Uphill" -#: fietsboek/models/track.py:568 +#: fietsboek/models/track.py:581 msgid "tooltip.table.downhill" msgstr "Downhill" -#: fietsboek/models/track.py:569 +#: fietsboek/models/track.py:582 msgid "tooltip.table.moving_time" msgstr "Moving Time" -#: fietsboek/models/track.py:570 +#: fietsboek/models/track.py:583 msgid "tooltip.table.stopped_time" msgstr "Stopped Time" -#: fietsboek/models/track.py:572 +#: fietsboek/models/track.py:585 msgid "tooltip.table.max_speed" msgstr "Max Speed" -#: fietsboek/models/track.py:576 +#: fietsboek/models/track.py:589 msgid "tooltip.table.avg_speed" msgstr "Average Speed" @@ -155,42 +156,52 @@ msgid "page.browse.synthetic_tooltip" msgstr "This is a pre-planned track" #: fietsboek/templates/browse.jinja2:132 fietsboek/templates/details.jinja2:90 +#: fietsboek/templates/profile.jinja2:15 msgid "page.details.date" msgstr "Date" #: fietsboek/templates/browse.jinja2:134 fietsboek/templates/details.jinja2:104 +#: fietsboek/templates/profile.jinja2:17 msgid "page.details.length" msgstr "Length" #: fietsboek/templates/browse.jinja2:139 fietsboek/templates/details.jinja2:95 +#: fietsboek/templates/profile.jinja2:21 msgid "page.details.start_time" msgstr "Record Start" #: fietsboek/templates/browse.jinja2:141 fietsboek/templates/details.jinja2:99 +#: fietsboek/templates/profile.jinja2:23 msgid "page.details.end_time" msgstr "Record End" #: fietsboek/templates/browse.jinja2:146 fietsboek/templates/details.jinja2:108 +#: fietsboek/templates/profile.jinja2:27 msgid "page.details.uphill" msgstr "Uphill" #: fietsboek/templates/browse.jinja2:148 fietsboek/templates/details.jinja2:112 +#: fietsboek/templates/profile.jinja2:29 msgid "page.details.downhill" msgstr "Downhill" #: fietsboek/templates/browse.jinja2:153 fietsboek/templates/details.jinja2:117 +#: fietsboek/templates/profile.jinja2:33 msgid "page.details.moving_time" msgstr "Moving Time" #: fietsboek/templates/browse.jinja2:155 fietsboek/templates/details.jinja2:121 +#: fietsboek/templates/profile.jinja2:35 msgid "page.details.stopped_time" msgstr "Stopped Time" #: fietsboek/templates/browse.jinja2:159 fietsboek/templates/details.jinja2:125 +#: fietsboek/templates/profile.jinja2:39 msgid "page.details.max_speed" msgstr "Max Speed" #: fietsboek/templates/browse.jinja2:161 fietsboek/templates/details.jinja2:129 +#: fietsboek/templates/profile.jinja2:41 msgid "page.details.avg_speed" msgstr "Average Speed" @@ -378,8 +389,8 @@ msgstr "Public" #: fietsboek/templates/edit_form.jinja2:21 msgid "page.track.form.visibility.info" msgstr "" -"Note that tagged people and people with the share link can always view " -"the track." +"Note that tagged people and people with the share link can always view the " +"track." #: fietsboek/templates/edit_form.jinja2:25 msgid "page.track.form.type" @@ -438,8 +449,7 @@ msgstr "Apply" msgid "page.track.form.transformer.enable" msgstr "Apply transformation" -#: fietsboek/templates/finish_upload.jinja2:8 -#: fietsboek/templates/upload.jinja2:6 +#: fietsboek/templates/finish_upload.jinja2:8 fietsboek/templates/upload.jinja2:6 msgid "page.upload.title" msgstr "Upload" @@ -467,47 +477,51 @@ msgstr[1] "%(num)d tracks" msgid "page.home.total" msgstr "Total" -#: fietsboek/templates/layout.jinja2:39 +#: fietsboek/templates/layout.jinja2:41 msgid "page.navbar.toggle" msgstr "Toggle navigation" -#: fietsboek/templates/layout.jinja2:50 +#: fietsboek/templates/layout.jinja2:52 msgid "page.navbar.home" msgstr "Home" -#: fietsboek/templates/layout.jinja2:53 +#: fietsboek/templates/layout.jinja2:55 msgid "page.navbar.browse" msgstr "Browse" -#: fietsboek/templates/layout.jinja2:57 +#: fietsboek/templates/layout.jinja2:59 msgid "page.navbar.upload" msgstr "Upload" -#: fietsboek/templates/layout.jinja2:66 +#: fietsboek/templates/layout.jinja2:68 msgid "page.navbar.user" msgstr "User" -#: fietsboek/templates/layout.jinja2:70 +#: fietsboek/templates/layout.jinja2:72 msgid "page.navbar.welcome_user" msgstr "Welcome, {}!" -#: fietsboek/templates/layout.jinja2:73 +#: fietsboek/templates/layout.jinja2:75 msgid "page.navbar.logout" msgstr "Logout" -#: fietsboek/templates/layout.jinja2:76 +#: fietsboek/templates/layout.jinja2:78 msgid "page.navbar.profile" msgstr "Profile" -#: fietsboek/templates/layout.jinja2:80 +#: fietsboek/templates/layout.jinja2:81 +msgid "page.navbar.user_data" +msgstr "Personal Data" + +#: fietsboek/templates/layout.jinja2:85 msgid "page.navbar.admin" msgstr "Admin" -#: fietsboek/templates/layout.jinja2:86 +#: fietsboek/templates/layout.jinja2:91 msgid "page.navbar.login" msgstr "Login" -#: fietsboek/templates/layout.jinja2:90 +#: fietsboek/templates/layout.jinja2:95 msgid "page.navbar.create_account" msgstr "Create Account" @@ -559,80 +573,136 @@ msgstr "Passwords must match" msgid "page.password_reset.reset" msgstr "Reset" -#: fietsboek/templates/profile.jinja2:7 +#: fietsboek/templates/profile.jinja2:66 +msgid "page.profile.length" +msgstr "Length" + +#: fietsboek/templates/profile.jinja2:70 +msgid "page.profile.uphill" +msgstr "Uphill" + +#: fietsboek/templates/profile.jinja2:74 +msgid "page.profile.downhill" +msgstr "Downhill" + +#: fietsboek/templates/profile.jinja2:78 +msgid "page.profile.moving_time" +msgstr "Moving Time" + +#: fietsboek/templates/profile.jinja2:82 +msgid "page.profile.stopped_time" +msgstr "Stopped Time" + +#: fietsboek/templates/profile.jinja2:86 +msgid "page.profile.max_speed" +msgstr "Max Speed" + +#: fietsboek/templates/profile.jinja2:90 +msgid "page.profile.avg_speed" +msgstr "Average Speed" + +#: fietsboek/templates/profile.jinja2:94 +msgid "page.profile.number_of_tracks" +msgstr "Number of tracks" + +#: fietsboek/templates/profile.jinja2:100 +msgid "page.profile.longest_distance_track" +msgstr "Longest Track" + +#: fietsboek/templates/profile.jinja2:105 +msgid "page.profile.shortest_distance_track" +msgstr "Shortest Track" + +#: fietsboek/templates/profile.jinja2:110 +msgid "page.profile.longest_duration_track" +msgstr "Most Time-Consuming Track" + +#: fietsboek/templates/profile.jinja2:115 +msgid "page.profile.shortest_duration_track" +msgstr "Quickest Track" + +#: fietsboek/templates/profile.jinja2:135 +msgid "page.profile.heatmap" +msgstr "Heat Map" + +#: fietsboek/templates/profile.jinja2:140 +msgid "page.profile.tilehunt" +msgstr "Tilehunt" + +#: fietsboek/templates/request_password.jinja2:5 +msgid "page.request_password.title" +msgstr "Request a Password Reset" + +#: fietsboek/templates/request_password.jinja2:6 +msgid "page.request_password.info" +msgstr "" +"If you forgot your password, you can type in your email address below and " +"receive a link to reset your password" + +#: fietsboek/templates/request_password.jinja2:12 +msgid "page.request_password.email" +msgstr "Email" + +#: fietsboek/templates/request_password.jinja2:17 +msgid "page.request_password.request" +msgstr "Send request" + +#: fietsboek/templates/upload.jinja2:9 +msgid "page.upload.form.gpx" +msgstr "GPX file" + +#: fietsboek/templates/user_data.jinja2:7 msgid "page.my_profile.title" msgstr "My Profile" -#: fietsboek/templates/profile.jinja2:11 +#: fietsboek/templates/user_data.jinja2:11 msgid "page.my_profile.personal_data" msgstr "Personal Data" -#: fietsboek/templates/profile.jinja2:16 +#: fietsboek/templates/user_data.jinja2:16 msgid "page.my_profile.personal_data.name" msgstr "My name" -#: fietsboek/templates/profile.jinja2:21 +#: fietsboek/templates/user_data.jinja2:21 msgid "page.my_profile.personal_data.password_invalid" msgstr "Password not long enough" -#: fietsboek/templates/profile.jinja2:23 +#: fietsboek/templates/user_data.jinja2:23 msgid "page.my_profile.personal_data.password" msgstr "Password" -#: fietsboek/templates/profile.jinja2:28 +#: fietsboek/templates/user_data.jinja2:28 msgid "page.my_profile.personal_data.password_must_match" msgstr "Passwords must match" -#: fietsboek/templates/profile.jinja2:30 +#: fietsboek/templates/user_data.jinja2:30 msgid "page.my_profile.personal_data.repeat_password" msgstr "Repeat password" -#: fietsboek/templates/profile.jinja2:33 +#: fietsboek/templates/user_data.jinja2:33 msgid "page.my_profile.personal_data.save" msgstr "Save" -#: fietsboek/templates/profile.jinja2:38 +#: fietsboek/templates/user_data.jinja2:38 msgid "page.my_profile.friends" msgstr "Friends" -#: fietsboek/templates/profile.jinja2:46 +#: fietsboek/templates/user_data.jinja2:46 msgid "page.my_profile.unfriend" msgstr "Unfriend" -#: fietsboek/templates/profile.jinja2:56 +#: fietsboek/templates/user_data.jinja2:56 msgid "page.my_profile.accept_friend" msgstr "Accept" -#: fietsboek/templates/profile.jinja2:73 +#: fietsboek/templates/user_data.jinja2:73 msgid "page.my_profile.friend_request_email" msgstr "Email of the friend" -#: fietsboek/templates/profile.jinja2:77 +#: fietsboek/templates/user_data.jinja2:77 msgid "page.my_profile.send_friend_request" msgstr "Send friend request" -#: fietsboek/templates/request_password.jinja2:5 -msgid "page.request_password.title" -msgstr "Request a Password Reset" - -#: fietsboek/templates/request_password.jinja2:6 -msgid "page.request_password.info" -msgstr "" -"If you forgot your password, you can type in your email address below and" -" receive a link to reset your password" - -#: fietsboek/templates/request_password.jinja2:12 -msgid "page.request_password.email" -msgstr "Email" - -#: fietsboek/templates/request_password.jinja2:17 -msgid "page.request_password.request" -msgstr "Send request" - -#: fietsboek/templates/upload.jinja2:9 -msgid "page.upload.form.gpx" -msgstr "GPX file" - #: fietsboek/transformers/__init__.py:140 msgid "transformers.fix-null-elevation.title" msgstr "Fix null elevation" @@ -723,26 +793,6 @@ msgstr "Password has been updated" msgid "flash.track_deleted" msgstr "Track has been deleted" -#: fietsboek/views/profile.py:60 -msgid "flash.personal_data_updated" -msgstr "Personal data has been updated" - -#: fietsboek/views/profile.py:78 -msgid "flash.friend_not_found" -msgstr "The friend was not found" - -#: fietsboek/views/profile.py:84 -msgid "flash.friend_already_exists" -msgstr "Friend already exists" - -#: fietsboek/views/profile.py:92 -msgid "flash.friend_added" -msgstr "Friend has been added" - -#: fietsboek/views/profile.py:102 -msgid "flash.friend_request_sent" -msgstr "Friend request sent" - #: fietsboek/views/upload.py:52 msgid "flash.no_file_selected" msgstr "No file selected" @@ -755,7 +805,26 @@ msgstr "Invalid GPX file selected" msgid "flash.upload_success" msgstr "Upload successful" -#: fietsboek/views/upload.py:204 +#: fietsboek/views/upload.py:207 msgid "flash.upload_cancelled" msgstr "Upload cancelled" +#: fietsboek/views/user_data.py:60 +msgid "flash.personal_data_updated" +msgstr "Personal data has been updated" + +#: fietsboek/views/user_data.py:78 +msgid "flash.friend_not_found" +msgstr "The friend was not found" + +#: fietsboek/views/user_data.py:84 +msgid "flash.friend_already_exists" +msgstr "Friend already exists" + +#: fietsboek/views/user_data.py:92 +msgid "flash.friend_added" +msgstr "Friend has been added" + +#: fietsboek/views/user_data.py:102 +msgid "flash.friend_request_sent" +msgstr "Friend request sent" diff --git a/fietsboek/locale/fietslog.pot b/fietsboek/locale/fietslog.pot index 70f010a..a232c05 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-03-07 20:11+0100\n" +"POT-Creation-Date: 2023-04-03 20:34+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,39 +17,39 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.11.0\n" -#: fietsboek/util.py:276 +#: fietsboek/util.py:275 msgid "password_constraint.mismatch" msgstr "" -#: fietsboek/util.py:278 +#: fietsboek/util.py:277 msgid "password_constraint.length" msgstr "" -#: fietsboek/models/track.py:566 +#: fietsboek/models/track.py:579 msgid "tooltip.table.length" msgstr "" -#: fietsboek/models/track.py:567 +#: fietsboek/models/track.py:580 msgid "tooltip.table.uphill" msgstr "" -#: fietsboek/models/track.py:568 +#: fietsboek/models/track.py:581 msgid "tooltip.table.downhill" msgstr "" -#: fietsboek/models/track.py:569 +#: fietsboek/models/track.py:582 msgid "tooltip.table.moving_time" msgstr "" -#: fietsboek/models/track.py:570 +#: fietsboek/models/track.py:583 msgid "tooltip.table.stopped_time" msgstr "" -#: fietsboek/models/track.py:572 +#: fietsboek/models/track.py:585 msgid "tooltip.table.max_speed" msgstr "" -#: fietsboek/models/track.py:576 +#: fietsboek/models/track.py:589 msgid "tooltip.table.avg_speed" msgstr "" @@ -154,42 +154,52 @@ msgid "page.browse.synthetic_tooltip" msgstr "" #: fietsboek/templates/browse.jinja2:132 fietsboek/templates/details.jinja2:90 +#: fietsboek/templates/profile.jinja2:15 msgid "page.details.date" msgstr "" #: fietsboek/templates/browse.jinja2:134 fietsboek/templates/details.jinja2:104 +#: fietsboek/templates/profile.jinja2:17 msgid "page.details.length" msgstr "" #: fietsboek/templates/browse.jinja2:139 fietsboek/templates/details.jinja2:95 +#: fietsboek/templates/profile.jinja2:21 msgid "page.details.start_time" msgstr "" #: fietsboek/templates/browse.jinja2:141 fietsboek/templates/details.jinja2:99 +#: fietsboek/templates/profile.jinja2:23 msgid "page.details.end_time" msgstr "" #: fietsboek/templates/browse.jinja2:146 fietsboek/templates/details.jinja2:108 +#: fietsboek/templates/profile.jinja2:27 msgid "page.details.uphill" msgstr "" #: fietsboek/templates/browse.jinja2:148 fietsboek/templates/details.jinja2:112 +#: fietsboek/templates/profile.jinja2:29 msgid "page.details.downhill" msgstr "" #: fietsboek/templates/browse.jinja2:153 fietsboek/templates/details.jinja2:117 +#: fietsboek/templates/profile.jinja2:33 msgid "page.details.moving_time" msgstr "" #: fietsboek/templates/browse.jinja2:155 fietsboek/templates/details.jinja2:121 +#: fietsboek/templates/profile.jinja2:35 msgid "page.details.stopped_time" msgstr "" #: fietsboek/templates/browse.jinja2:159 fietsboek/templates/details.jinja2:125 +#: fietsboek/templates/profile.jinja2:39 msgid "page.details.max_speed" msgstr "" #: fietsboek/templates/browse.jinja2:161 fietsboek/templates/details.jinja2:129 +#: fietsboek/templates/profile.jinja2:41 msgid "page.details.avg_speed" msgstr "" @@ -464,47 +474,51 @@ msgstr[1] "" msgid "page.home.total" msgstr "" -#: fietsboek/templates/layout.jinja2:39 +#: fietsboek/templates/layout.jinja2:41 msgid "page.navbar.toggle" msgstr "" -#: fietsboek/templates/layout.jinja2:50 +#: fietsboek/templates/layout.jinja2:52 msgid "page.navbar.home" msgstr "" -#: fietsboek/templates/layout.jinja2:53 +#: fietsboek/templates/layout.jinja2:55 msgid "page.navbar.browse" msgstr "" -#: fietsboek/templates/layout.jinja2:57 +#: fietsboek/templates/layout.jinja2:59 msgid "page.navbar.upload" msgstr "" -#: fietsboek/templates/layout.jinja2:66 +#: fietsboek/templates/layout.jinja2:68 msgid "page.navbar.user" msgstr "" -#: fietsboek/templates/layout.jinja2:70 +#: fietsboek/templates/layout.jinja2:72 msgid "page.navbar.welcome_user" msgstr "" -#: fietsboek/templates/layout.jinja2:73 +#: fietsboek/templates/layout.jinja2:75 msgid "page.navbar.logout" msgstr "" -#: fietsboek/templates/layout.jinja2:76 +#: fietsboek/templates/layout.jinja2:78 msgid "page.navbar.profile" msgstr "" -#: fietsboek/templates/layout.jinja2:80 +#: fietsboek/templates/layout.jinja2:81 +msgid "page.navbar.user_data" +msgstr "" + +#: fietsboek/templates/layout.jinja2:85 msgid "page.navbar.admin" msgstr "" -#: fietsboek/templates/layout.jinja2:86 +#: fietsboek/templates/layout.jinja2:91 msgid "page.navbar.login" msgstr "" -#: fietsboek/templates/layout.jinja2:90 +#: fietsboek/templates/layout.jinja2:95 msgid "page.navbar.create_account" msgstr "" @@ -556,56 +570,60 @@ msgstr "" msgid "page.password_reset.reset" msgstr "" -#: fietsboek/templates/profile.jinja2:7 -msgid "page.my_profile.title" +#: fietsboek/templates/profile.jinja2:66 +msgid "page.profile.length" msgstr "" -#: fietsboek/templates/profile.jinja2:11 -msgid "page.my_profile.personal_data" +#: fietsboek/templates/profile.jinja2:70 +msgid "page.profile.uphill" msgstr "" -#: fietsboek/templates/profile.jinja2:16 -msgid "page.my_profile.personal_data.name" +#: fietsboek/templates/profile.jinja2:74 +msgid "page.profile.downhill" msgstr "" -#: fietsboek/templates/profile.jinja2:21 -msgid "page.my_profile.personal_data.password_invalid" +#: fietsboek/templates/profile.jinja2:78 +msgid "page.profile.moving_time" msgstr "" -#: fietsboek/templates/profile.jinja2:23 -msgid "page.my_profile.personal_data.password" +#: fietsboek/templates/profile.jinja2:82 +msgid "page.profile.stopped_time" msgstr "" -#: fietsboek/templates/profile.jinja2:28 -msgid "page.my_profile.personal_data.password_must_match" +#: fietsboek/templates/profile.jinja2:86 +msgid "page.profile.max_speed" msgstr "" -#: fietsboek/templates/profile.jinja2:30 -msgid "page.my_profile.personal_data.repeat_password" +#: fietsboek/templates/profile.jinja2:90 +msgid "page.profile.avg_speed" msgstr "" -#: fietsboek/templates/profile.jinja2:33 -msgid "page.my_profile.personal_data.save" +#: fietsboek/templates/profile.jinja2:94 +msgid "page.profile.number_of_tracks" msgstr "" -#: fietsboek/templates/profile.jinja2:38 -msgid "page.my_profile.friends" +#: fietsboek/templates/profile.jinja2:100 +msgid "page.profile.longest_distance_track" msgstr "" -#: fietsboek/templates/profile.jinja2:46 -msgid "page.my_profile.unfriend" +#: fietsboek/templates/profile.jinja2:105 +msgid "page.profile.shortest_distance_track" msgstr "" -#: fietsboek/templates/profile.jinja2:56 -msgid "page.my_profile.accept_friend" +#: fietsboek/templates/profile.jinja2:110 +msgid "page.profile.longest_duration_track" msgstr "" -#: fietsboek/templates/profile.jinja2:73 -msgid "page.my_profile.friend_request_email" +#: fietsboek/templates/profile.jinja2:115 +msgid "page.profile.shortest_duration_track" msgstr "" -#: fietsboek/templates/profile.jinja2:77 -msgid "page.my_profile.send_friend_request" +#: fietsboek/templates/profile.jinja2:135 +msgid "page.profile.heatmap" +msgstr "" + +#: fietsboek/templates/profile.jinja2:140 +msgid "page.profile.tilehunt" msgstr "" #: fietsboek/templates/request_password.jinja2:5 @@ -628,6 +646,58 @@ msgstr "" msgid "page.upload.form.gpx" msgstr "" +#: fietsboek/templates/user_data.jinja2:7 +msgid "page.my_profile.title" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:11 +msgid "page.my_profile.personal_data" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:16 +msgid "page.my_profile.personal_data.name" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:21 +msgid "page.my_profile.personal_data.password_invalid" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:23 +msgid "page.my_profile.personal_data.password" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:28 +msgid "page.my_profile.personal_data.password_must_match" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:30 +msgid "page.my_profile.personal_data.repeat_password" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:33 +msgid "page.my_profile.personal_data.save" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:38 +msgid "page.my_profile.friends" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:46 +msgid "page.my_profile.unfriend" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:56 +msgid "page.my_profile.accept_friend" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:73 +msgid "page.my_profile.friend_request_email" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:77 +msgid "page.my_profile.send_friend_request" +msgstr "" + #: fietsboek/transformers/__init__.py:140 msgid "transformers.fix-null-elevation.title" msgstr "" @@ -712,39 +782,39 @@ msgstr "" msgid "flash.track_deleted" msgstr "" -#: fietsboek/views/profile.py:60 -msgid "flash.personal_data_updated" +#: fietsboek/views/upload.py:52 +msgid "flash.no_file_selected" msgstr "" -#: fietsboek/views/profile.py:78 -msgid "flash.friend_not_found" +#: fietsboek/views/upload.py:62 +msgid "flash.invalid_file" msgstr "" -#: fietsboek/views/profile.py:84 -msgid "flash.friend_already_exists" +#: fietsboek/views/upload.py:188 +msgid "flash.upload_success" msgstr "" -#: fietsboek/views/profile.py:92 -msgid "flash.friend_added" +#: fietsboek/views/upload.py:207 +msgid "flash.upload_cancelled" msgstr "" -#: fietsboek/views/profile.py:102 -msgid "flash.friend_request_sent" +#: fietsboek/views/user_data.py:60 +msgid "flash.personal_data_updated" msgstr "" -#: fietsboek/views/upload.py:52 -msgid "flash.no_file_selected" +#: fietsboek/views/user_data.py:78 +msgid "flash.friend_not_found" msgstr "" -#: fietsboek/views/upload.py:62 -msgid "flash.invalid_file" +#: fietsboek/views/user_data.py:84 +msgid "flash.friend_already_exists" msgstr "" -#: fietsboek/views/upload.py:188 -msgid "flash.upload_success" +#: fietsboek/views/user_data.py:92 +msgid "flash.friend_added" msgstr "" -#: fietsboek/views/upload.py:204 -msgid "flash.upload_cancelled" +#: fietsboek/views/user_data.py:102 +msgid "flash.friend_request_sent" msgstr "" -- cgit v1.2.3 From ec3bf656ef3365026678edc95c4cbaf4a07e6d5e Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Mon, 3 Apr 2023 21:01:18 +0200 Subject: hittekaart: no error if no input files are passed --- fietsboek/hittekaart.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/fietsboek/hittekaart.py b/fietsboek/hittekaart.py index a41f8b7..69b8d42 100644 --- a/fietsboek/hittekaart.py +++ b/fietsboek/hittekaart.py @@ -51,6 +51,8 @@ def generate( :param threads: Number of threads that ``hittekaart`` should use. Defaults to 0, which uses all available cores. """ + if not input_files: + return # There are two reasons why we do the tempfile dance: # 1. hittekaart refuses to overwrite existing files # 2. This way we can (hope for?) an atomic move (at least if temporary file @@ -109,6 +111,9 @@ def generate_for( path = data_manager.open(track.id).gpx_path() input_paths.append(path) + if not input_paths: + return + try: user_dir = data_manager.initialize_user(user.id) except FileExistsError: -- cgit v1.2.3 From 51b109c2452542d4ae0e7aef806040b77d1d3292 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Mon, 3 Apr 2023 21:05:08 +0200 Subject: fietscron: add queue priority for hittekaart log --- fietsboek/scripts/fietscron.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/fietsboek/scripts/fietscron.py b/fietsboek/scripts/fietscron.py index 897078c..f0dc438 100644 --- a/fietsboek/scripts/fietscron.py +++ b/fietsboek/scripts/fietscron.py @@ -109,11 +109,11 @@ def run_hittekaart(engine: Engine, data_manager: DataManager, redis: Redis, conf break user = session.execute(select(models.User).filter_by(id=int(item))).scalar() if user is None: - LOGGER.debug("User %d had a queue entry but was not found", item) + LOGGER.debug("User %d had a high-priority queue entry but was not found", item) break for mode in modes: - LOGGER.info("Generating %s for user %d", mode.value, user.id) + LOGGER.info("Generating %s for user %d (high-priority)", mode.value, user.id) hittekaart.generate_for(user, session, data_manager, mode, exe_path=exe_path) if had_hq_item: @@ -130,11 +130,11 @@ def run_hittekaart(engine: Engine, data_manager: DataManager, redis: Redis, conf user = session.execute(select(models.User).filter_by(id=int(item))).scalar() if user is None: - LOGGER.debug("User %d had a queue entry but was not found", item) + LOGGER.debug("User %d had a low-priority queue entry but was not found", item) return for mode in modes: - LOGGER.info("Generating %s for user %d", mode.value, user.id) + LOGGER.info("Generating %s for user %d (low-priority)", mode.value, user.id) hittekaart.generate_for(user, session, data_manager, mode, exe_path=exe_path) -- cgit v1.2.3 From 7ddf5e685ceff763f309df8d3ba43fb9762265a8 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Mon, 3 Apr 2023 21:15:17 +0200 Subject: doc: document hittekaart configuration options --- doc/administration/configuration.rst | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/doc/administration/configuration.rst b/doc/administration/configuration.rst index e77e3cc..d272234 100644 --- a/doc/administration/configuration.rst +++ b/doc/administration/configuration.rst @@ -213,3 +213,37 @@ true``. This will cause the tiles to be loaded directly by the client. In addition, depending on the jurisdiction, you might be required to tell your users that third party content is included in your site, and that their IP will be accessible to the third party. + +Hittekaart Integration +---------------------- + +Fietsboek can use hittekaart_ to generate heat maps for users. For that, you +can set ``hittekaart.bin`` to the path to the ``hittekaart`` binary. If unset, +it is assumed that the binary can be found in your ``$PATH``. + +In addition, you can set ``hittekaart.autogenerate`` to the list of overlay +maps you want to automatically generate and update. By default, this list is +empty, which means that Fietsboek will not generate any overlays on its own. +You can add ``heatmap`` and/or ``tilehunter`` to generate those maps +automatically. + +.. note:: + + The ``hittekaart.autogenerate`` has no effect on the ``fietsctl + hittekaart`` command. You can always use ``fietsctl`` to generate heat maps + for specific users! + +.. warning:: + + Depending on the geospatial area that a user covers with their tracks, an + overlay map can get relatively large (~100 MiB for mine). Keep that in mind + when hosting a larger number of users. + +An example configuration excerpt can look like this: + +.. code-block:: ini + + hittekaart.bin = /usr/local/bin/hittekaart + hittekaart.autogenerate = heatmap tilehunter + +.. _hittekaart: https://gitlab.com/dunj3/hittekaart -- cgit v1.2.3 From 5b051b9f97892784d281556db3d5f8b01671568d Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Mon, 3 Apr 2023 21:22:49 +0200 Subject: add a fietsctl hittekaart --delete option --- fietsboek/scripts/fietsctl.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/fietsboek/scripts/fietsctl.py b/fietsboek/scripts/fietsctl.py index 8be226a..7f7170c 100644 --- a/fietsboek/scripts/fietsctl.py +++ b/fietsboek/scripts/fietsctl.py @@ -9,6 +9,7 @@ from pyramid.scripting import AppEnvironment from sqlalchemy import select from .. import __VERSION__, hittekaart, models +from ..data import DataManager from . import config_option EXIT_OKAY = 0 @@ -229,6 +230,7 @@ def cmd_maintenance_mode(ctx: click.Context, config: str, disable: bool, reason: multiple=True, default=["heatmap"], ) +@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") @@ -237,6 +239,7 @@ def cmd_hittekaart( ctx: click.Context, config: str, modes: list[str], + delete: bool, id_: Optional[int], email: Optional[str], ): @@ -252,12 +255,23 @@ def cmd_hittekaart( exe_path = env["request"].config.hittekaart_bin with env["request"].tm: dbsession = env["request"].dbsession - data_manager = env["request"].data_manager + data_manager: DataManager = env["request"].data_manager 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 delete: + try: + user_manager = data_manager.open_user(user.id) + except FileNotFoundError: + return + if hittekaart.Mode.HEATMAP in modes: + user_manager.heatmap_path().unlink(missing_ok=True) + if hittekaart.Mode.TILEHUNTER in modes: + user_manager.tilehunt_path().unlink(missing_ok=True) + return + click.echo(f"Generating overlay maps for {user.name}...") for mode in modes: -- cgit v1.2.3