diff options
| author | Daniel Schadt <kingdread@gmx.de> | 2025-12-26 00:13:52 +0100 |
|---|---|---|
| committer | Daniel Schadt <kingdread@gmx.de> | 2025-12-30 19:16:32 +0100 |
| commit | 4f41f3bc47746d867feedbd4ab16d8a6b53fd4d2 (patch) | |
| tree | 2172920a8d237d723d66f0b5f5734368e340f93a | |
| parent | 0c00bc442eabe4e42617ca2cf281496f871b3590 (diff) | |
| download | fietsboek-4f41f3bc47746d867feedbd4ab16d8a6b53fd4d2.tar.gz fietsboek-4f41f3bc47746d867feedbd4ab16d8a6b53fd4d2.tar.bz2 fietsboek-4f41f3bc47746d867feedbd4ab16d8a6b53fd4d2.zip | |
implement journey editing
| -rw-r--r-- | fietsboek/models/journey.py | 2 | ||||
| -rw-r--r-- | fietsboek/models/track.py | 4 | ||||
| -rw-r--r-- | fietsboek/routes.py | 3 | ||||
| -rw-r--r-- | fietsboek/templates/journey_details.jinja2 | 53 | ||||
| -rw-r--r-- | fietsboek/templates/journey_edit.jinja2 | 21 | ||||
| -rw-r--r-- | fietsboek/templates/journey_form.jinja2 | 222 | ||||
| -rw-r--r-- | fietsboek/templates/journey_new.jinja2 | 203 | ||||
| -rw-r--r-- | fietsboek/views/journey.py | 33 |
8 files changed, 342 insertions, 199 deletions
diff --git a/fietsboek/models/journey.py b/fietsboek/models/journey.py index c403a61..512146f 100644 --- a/fietsboek/models/journey.py +++ b/fietsboek/models/journey.py @@ -137,6 +137,8 @@ class Journey(Base): :param track_ids: The IDs of the tracks that should be in this journey. """ session = inspect(self).session + stmt = delete(journey_track_assoc).where(journey_track_assoc.c.journey_id == self.id) + session.execute(stmt) for index, track_id in enumerate(track_ids, 1): stmt = insert(journey_track_assoc).values( journey_id=self.id, diff --git a/fietsboek/models/track.py b/fietsboek/models/track.py index cd99f4c..fc5a68b 100644 --- a/fietsboek/models/track.py +++ b/fietsboek/models/track.py @@ -581,6 +581,10 @@ class Track(Base): self.cache.start_time = self.date self.cache.end_time = self.date + datetime.timedelta(seconds=meta.duration) + def with_metadata(self) -> "TrackWithMetadata": + """Returns this track with attached path metadata.""" + return TrackWithMetadata(self) + def text_tags(self): """Returns a set of textual tags. diff --git a/fietsboek/routes.py b/fietsboek/routes.py index bafb4b3..c7bce62 100644 --- a/fietsboek/routes.py +++ b/fietsboek/routes.py @@ -65,6 +65,9 @@ def includeme(config): ) config.add_route("journey-gpx", "/journey/{journey_id}/gpx", factory="fietsboek.models.Journey.factory") config.add_route("journey-details", "/journey/{journey_id}/", factory="fietsboek.models.Journey.factory") + config.add_route("journey-edit", "/journey/{journey_id}/edit", factory="fietsboek.models.Journey.factory") + config.add_route("journey-invalidate-share", "/journey/{journey_id}/invalidate-link", factory="fietsboek.models.Journey.factory") + config.add_route("delete-journey", "/journey/{journey_id}/delete", factory="fietsboek.models.Journey.factory") config.add_route("journey-new", "/journey/new") config.add_route("badge", "/badge/{badge_id}", factory="fietsboek.models.Badge.factory") diff --git a/fietsboek/templates/journey_details.jinja2 b/fietsboek/templates/journey_details.jinja2 index 624f78c..bc8137e 100644 --- a/fietsboek/templates/journey_details.jinja2 +++ b/fietsboek/templates/journey_details.jinja2 @@ -1,8 +1,61 @@ {% extends "layout.jinja2" %} +{% import "util.jinja2" as util with context %} + {% block content %} <div class="container"> <h1>{{ journey.title }}</h1> + {% if show_edit_link %} + <div class="btn-group mb-3" role="group"> + <a class="btn btn-success ui-element" href="{{ request.route_path('journey-edit', journey_id=journey.id) }}"><i class="bi-pencil-square"></i> {{ _("journey.edit") }}</a> + <button type="button" class="btn btn-info ui-element" id="showShareLink" data-bs-toggle="modal" data-bs-target="#shareLinkModal"><i class="bi-share"></i> {{ _("journey.share") }}</button> + <button type="button" class="btn btn-danger ui-element" id="deleteLink" data-bs-toggle="modal" data-bs-target="#deleteModal"><i class="bi bi-trash"></i> {{ _("journey.delete") }}</button> + </div> + <div class="modal fade" id="shareLinkModal" tabindex="-1" aria-hidden="true"> + <div class="modal-dialog"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title">{{ _("journey.sharelink.title") }}</h5> + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> + </div> + <div class="modal-body"> + <p>{{ _("journey.sharelink.info") }}</p> + {% set share_link = request.route_url('journey-details', journey_id=journey.id, _query=[("secret", journey.link_secret)]) %} + <a href="{{ share_link }}">{{ share_link }}</a> + </div> + <div class="modal-footer"> + <form method="POST" action="{{ request.route_url('journey-invalidate-share', journey_id=journey.id) }}"> + {{ util.hidden_csrf_input() }} + <button type="submit" class="btn btn-warning ui-element">{{ _("journey.sharelink.invalidate") }}</button> + </form> + <button type="button" class="btn btn-secondary ui-element" data-bs-dismiss="modal">{{ _("journey.sharelink.close") }}</button> + </div> + </div> + </div> + </div> + + <div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true"> + <div class="modal-dialog"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title">{{ _("journey.delete.title") }}</h5> + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> + </div> + <div class="modal-body"> + <p>{{ _("journey.delete.info") }}</p> + </div> + <div class="modal-footer"> + <form method="POST" action="{{ request.route_url('delete-journey', journey_id=journey.id) }}"> + {{ util.hidden_csrf_input() }} + <button type="submit" class="btn btn-danger ui-element">{{ _("journey.delete.delete") }}</button> + </form> + <button type="button" class="btn btn-secondary ui-element" data-bs-dismiss="modal">{{ _("journey.delete.close") }}</button> + </div> + </div> + </div> + </div> + {% endif %} + {% set gpx_url = request.route_path("journey-gpx", journey_id=journey.id) %} <div id="mainmap" class="gpxview:{{ gpx_url }}:OSM" style="width:100%;height:600px"> <noscript><p>{{ _("page.noscript") }}<p></noscript> diff --git a/fietsboek/templates/journey_edit.jinja2 b/fietsboek/templates/journey_edit.jinja2 new file mode 100644 index 0000000..fa39228 --- /dev/null +++ b/fietsboek/templates/journey_edit.jinja2 @@ -0,0 +1,21 @@ +{% extends "layout.jinja2" %} +{% import "journey_form.jinja2" as form with context %} + +{% block extrahead %} +{{ form.journey_css() }} +{% endblock %} + +{% block content %} +<div class="container"> + <h1>{{ journey.title }}</h1> + + <form method="POST"> + {{ form.journey_form(journey) }} + </form> +</div> + +{% endblock %} + +{% block latescripts %} +{{ form.journey_js() }} +{% endblock %} diff --git a/fietsboek/templates/journey_form.jinja2 b/fietsboek/templates/journey_form.jinja2 new file mode 100644 index 0000000..6a5df1f --- /dev/null +++ b/fietsboek/templates/journey_form.jinja2 @@ -0,0 +1,222 @@ +{% import "util.jinja2" as util with context %} + +{% macro journey_css() %} +<style> +.track-query-response, .journey-track { + background-color: var(--bs-body-bg); + padding: 0.375rem; + margin-bottom: 0.1rem; + display: flex; + align-items: center; + gap: 1rem; + + .track-title { + font-weight: 450; + font-size: 110%; + } + + .track-date { + color: #808080; + } + + .track-length { + color: #808080; + } +} + +.journey-track { + cursor: grab; +} + +.dragging { + opacity: 0.7; +} +</style> +{% endmacro %} + + +{% macro journey_form(journey) %} +<div class="mb-3"> + <label for="journeyTitle" class="form-label">{{ _("journeys.new.form.title") }}</label> + <input type="text" class="form-control" id="journeyTitle" name="journeyTitle" value="{{ journey.title }}"> +</div> +<div class="mb-3"> + <label for="journeyDescription" class="form-label">{{ _("journeys.new.form.description") }}</label> + <textarea class="form-control" id="journeyDescription" name="journeyDescription">{{ journey.description }}</textarea> +</div> +<div class="mb-3"> + <label for="journeyVisibility" class="form-label">{{ _("journeys.new.form.visibility") }}</label> + <select class="form-select" id="journeyVisibility" name="journeyVisibility"> + {% set visibility = journey.visibility.name if journey else "" %} + <option value="PRIVATE"{% if visibility== "PRIVATE" %} selected{% endif %}>{{ _("journeys.new.form.visibility.private") }}</option> + <option value="FRIENDS"{% if visibility== "FRIENDS" %} selected{% endif %}>{{ _("journeys.new.form.visibility.friends") }}</option> + <option value="FRIENDS_TAGGED"{% if visibility== "FRIENDS_TAGGED" %} selected{% endif %}>{{ _("journeys.new.form.visibility.friends_tagged") }}</option> + <option value="LOGGED_IN"{% if visibility== "LOGGED_IN" %} selected{% endif %}>{{ _("journeys.new.form.visibility.logged_in") }}</option> + <option value="PUBLIC"{% if visibility== "PUBLIC" %} selected{% endif %}>{{ _("journeys.new.form.visibility.public") }}</option> + </select> +</div> +<div class="mb-3"> + <p> + {{ _("journeys.new.form.tracksearch") }} + </p> + <div class="input-group"> + <input type="text" id="trackSearch" placeholder="Title" class="form-control"> + <button class="btn btn-secondary" id="trackSearchButton"><i class="bi bi-search"></i></button> + </div> +</div> +<div class="mb-3" id="trackSearchResults"></div> +<div class="mb-3"> + <p>{{ _("journeys.new.form.tracks") }}<p> +</div> +<div class="mb-3" id="journeyTracks"> + {% for track in journey.tracks %} + <div class="journey-track" draggable="true"> + <input type="hidden" name="journeyTrack[]" value="{{ track.id }}"> + <button class="btn btn-danger btn-sm"><i class="bi bi-x-circle-fill"></i></button> + <div class="track-title">{{ track.title }}</div> + <div class="track-length">{{ (track.with_metadata().length / 1000) | round(2) }} km</div> + <div class="track-date">{{ track.date | format_datetime }}</div> + </div> + {% endfor %} +</div> + +{{ util.hidden_csrf_input() }} + +<button class="btn btn-primary" type="submit"> + <i class="bi bi-save"></i> + {{ _("journeys.new.form.submit") }} +</button> + +<template id="queryResponse"> + <div class="track-query-response"> + <button class="btn btn-success btn-sm"><i class="bi bi-plus-square-fill"></i></button> + <div class="track-title"></div> + <div class="track-length"></div> + <div class="track-date"></div> + </div> +</template> + +<template id="journeyTrack"> + <div class="journey-track" draggable="true"> + <input type="hidden" name="journeyTrack[]"> + <button class="btn btn-danger btn-sm"><i class="bi bi-x-circle-fill"></i></button> + <div class="track-title"></div> + <div class="track-length"></div> + <div class="track-date"></div> + </div> +</template> +{% endmacro %} + + +{% macro journey_js() %} +<script> +// Make sure the mouse pointer stays "grab", even when leaving the list of +// tracks. +document.addEventListener("dragover", (event) => event.preventDefault()); + +let trDrag; + +function trDragStart(event) { + trDrag = event.target; + event.target.closest(".journey-track").classList.add("dragging"); + event.dataTransfer.effectAllowed = "move"; +} + +function trDragOver(event) { + let target = event.target.closest(".journey-track"); + + // Check whether we are in the top of bottom half of the element + let rect = target.getBoundingClientRect(); + let is_top_half = event.clientY < rect.top + rect.height / 2; + + if (is_top_half) { + target.insertAdjacentElement("beforebegin", trDrag); + } else { + target.insertAdjacentElement("afterend", trDrag); + } + event.preventDefault(); +} + +function trDragLeave(event) { + let target = event.target.closest(".journey-track"); + target.style.marginTop = ""; + target.style.marginBottom = ""; + event.preventDefault(); +} + +function trDragEnd(event) { + trDrag.closest(".journey-track").classList.remove("dragging"); + trDrag = null; +} + +function removeTrackFromJourney(event) { + let track = event.target.closest("div"); + track.parentNode.removeChild(track); + event.preventDefault(); +} + +function addTrackToJourney(event) { + let track = event.target.closest("div"); + let template = document.getElementById("journeyTrack"); + let clone = document.importNode(template.content, true); + + clone.querySelector("input").setAttribute("value", track.getAttribute("data-track-id")); + for (let sel of [".track-title", ".track-length", ".track-date"]) { + clone.querySelector(sel).textContent = track.querySelector(sel).textContent; + } + clone.querySelector("button").addEventListener("click", removeTrackFromJourney); + clone.querySelector(".journey-track").addEventListener("dragstart", trDragStart); + clone.querySelector(".journey-track").addEventListener("dragover", trDragOver); + clone.querySelector(".journey-track").addEventListener("dragleave", trDragLeave); + clone.querySelector(".journey-track").addEventListener("dragend", trDragEnd); + + document.getElementById("journeyTracks").appendChild(clone); + track.parentElement.removeChild(track); + event.preventDefault(); +} + +addHandler(".journey-track", "dragstart", trDragStart); +addHandler(".journey-track", "dragover", trDragOver); +addHandler(".journey-track", "dragleave", trDragLeave); +addHandler(".journey-track", "dragend", trDragEnd); + +function hasTrack(id) { + for (let track of document.querySelectorAll(".journey-track")) { + let tid = track.querySelector("input").value; + if (parseInt(tid) == id) { + return true; + } + } + return false; +} + +function searchTracks() { + let template = document.getElementById("queryResponse"); + let results = document.getElementById("trackSearchResults"); + let pattern = document.getElementById("trackSearch").value; + let url = makeUrl(`/track/?format=json&search-terms=${encodeURIComponent(pattern)}`); + fetch(url) + .then((response) => response.json()) + .then((response) => { + results.replaceChildren(); + for (let track of response) { + if (hasTrack(track.id)) { + continue; + } + let clone = document.importNode(template.content, true); + clone.firstElementChild.setAttribute("data-track-id", track.id); + clone.querySelector(".track-title").textContent = track.title; + clone.querySelector(".track-date").textContent = formatTimestamp(track.date * 1000); + clone.querySelector(".track-length").textContent = `${(track.length / 1000).toFixed(2)} km`; + clone.querySelector("button").addEventListener("click", addTrackToJourney); + results.appendChild(clone); + } + }); +} + +document.querySelector("#trackSearchButton").addEventListener("click", (event) => { + searchTracks(); + event.preventDefault(); +}); +</script> +{% endmacro %} diff --git a/fietsboek/templates/journey_new.jinja2 b/fietsboek/templates/journey_new.jinja2 index 9e8bb5c..37fbb76 100644 --- a/fietsboek/templates/journey_new.jinja2 +++ b/fietsboek/templates/journey_new.jinja2 @@ -1,38 +1,8 @@ {% extends "layout.jinja2" %} -{% import "util.jinja2" as util with context %} +{% import "journey_form.jinja2" as form with context %} {% block extrahead %} -<style> -.track-query-response, .journey-track { - background-color: var(--bs-body-bg); - padding: 0.375rem; - margin-bottom: 0.1rem; - display: flex; - align-items: center; - gap: 1rem; - - .track-title { - font-weight: 450; - font-size: 110%; - } - - .track-date { - color: #808080; - } - - .track-length { - color: #808080; - } -} - -.journey-track { - cursor: grab; -} - -.dragging { - opacity: 0.7; -} -</style> +{{ form.journey_css() }} {% endblock %} {% block content %} @@ -40,177 +10,12 @@ <h1>{{ _("journeys.new.title") }}</h1> <form method="POST"> - <div class="mb-3"> - <label for="journeyTitle" class="form-label">{{ _("journeys.new.form.title") }}</label> - <input type="text" class="form-control" id="journeyTitle" name="journeyTitle"> - </div> - <div class="mb-3"> - <label for="journeyDescription" class="form-label">{{ _("journeys.new.form.description") }}</label> - <textarea class="form-control" id="journeyDescription" name="journeyDescription"></textarea> - </div> - <div class="mb-3"> - <label for="journeyVisibility" class="form-label">{{ _("journeys.new.form.visibility") }}</label> - <select class="form-select" id="journeyVisibility" name="journeyVisibility"> - <option value="PRIVATE"{% if visibility== "PRIVATE" %} selected{% endif %}>{{ _("journeys.new.form.visibility.private") }}</option> - <option value="FRIENDS"{% if visibility== "FRIENDS" %} selected{% endif %}>{{ _("journeys.new.form.visibility.friends") }}</option> - <option value="FRIENDS_TAGGED"{% if visibility== "FRIENDS_TAGGED" %} selected{% endif %}>{{ _("journeys.new.form.visibility.friends_tagged") }}</option> - <option value="LOGGED_IN"{% if visibility== "LOGGED_IN" %} selected{% endif %}>{{ _("journeys.new.form.visibility.logged_in") }}</option> - <option value="PUBLIC"{% if visibility== "PUBLIC" %} selected{% endif %}>{{ _("journeys.new.form.visibility.public") }}</option> - </select> - </div> - <div class="mb-3"> - <p> - {{ _("journeys.new.form.tracksearch") }} - </p> - <div class="input-group"> - <input type="text" id="trackSearch" placeholder="Title" class="form-control"> - <button class="btn btn-secondary" id="trackSearchButton"><i class="bi bi-search"></i></button> - </div> - </div> - <div class="mb-3" id="trackSearchResults"></div> - <div class="mb-3"> - <p>{{ _("journeys.new.form.tracks") }}<p> - </div> - <div class="mb-3" id="journeyTracks"></div> - - {{ util.hidden_csrf_input() }} - - <button class="btn btn-primary" type="submit"> - <i class="bi bi-save"></i> - {{ _("journeys.new.form.submit") }} - </button> + {{ form.journey_form(none) }} </form> </div> -<template id="queryResponse"> - <div class="track-query-response"> - <button class="btn btn-success btn-sm"><i class="bi bi-plus-square-fill"></i></button> - <div class="track-title"></div> - <div class="track-length"></div> - <div class="track-date"></div> - </div> -</template> - -<template id="journeyTrack"> - <div class="journey-track" draggable="true"> - <input type="hidden" name="journeyTrack[]"> - <button class="btn btn-danger btn-sm"><i class="bi bi-x-circle-fill"></i></button> - <div class="track-title"></div> - <div class="track-length"></div> - <div class="track-date"></div> - </div> -</template> {% endblock %} {% block latescripts %} - <script> - // Make sure the mouse pointer stays "grab", even when leaving the list of - // tracks. - document.addEventListener("dragover", (event) => event.preventDefault()); - - let trDrag; - - function trDragStart(event) { - trDrag = event.target; - event.target.closest(".journey-track").classList.add("dragging"); - event.dataTransfer.effectAllowed = "move"; - } - - function trDragOver(event) { - let target = event.target.closest(".journey-track"); - - // Check whether we are in the top of bottom half of the element - let rect = target.getBoundingClientRect(); - let is_top_half = event.clientY < rect.top + rect.height / 2; - - if (is_top_half) { - target.insertAdjacentElement("beforebegin", trDrag); - } else { - target.insertAdjacentElement("afterend", trDrag); - } - event.preventDefault(); - } - - function trDragLeave(event) { - let target = event.target.closest(".journey-track"); - target.style.marginTop = ""; - target.style.marginBottom = ""; - event.preventDefault(); - } - - function trDragEnd(event) { - trDrag.closest(".journey-track").classList.remove("dragging"); - trDrag = null; - } - - function removeTrackFromJourney(event) { - let track = event.target.closest("div"); - track.parentNode.removeChild(track); - event.preventDefault(); - } - - function addTrackToJourney(event) { - let track = event.target.closest("div"); - let template = document.getElementById("journeyTrack"); - let clone = document.importNode(template.content, true); - - clone.querySelector("input").setAttribute("value", track.getAttribute("data-track-id")); - for (let sel of [".track-title", ".track-length", ".track-date"]) { - clone.querySelector(sel).textContent = track.querySelector(sel).textContent; - } - clone.querySelector("button").addEventListener("click", removeTrackFromJourney); - clone.querySelector(".journey-track").addEventListener("dragstart", trDragStart); - clone.querySelector(".journey-track").addEventListener("dragover", trDragOver); - clone.querySelector(".journey-track").addEventListener("dragleave", trDragLeave); - clone.querySelector(".journey-track").addEventListener("dragend", trDragEnd); - - document.getElementById("journeyTracks").appendChild(clone); - track.parentElement.removeChild(track); - event.preventDefault(); - } - - addHandler(".journey-track", "dragstart", trDragStart); - addHandler(".journey-track", "dragover", trDragOver); - addHandler(".journey-track", "dragleave", trDragLeave); - addHandler(".journey-track", "dragend", trDragEnd); - - function hasTrack(id) { - for (let track of document.querySelectorAll(".journey-track")) { - let tid = track.querySelector("input").value; - if (parseInt(tid) == id) { - return true; - } - } - return false; - } - - function searchTracks() { - let template = document.getElementById("queryResponse"); - let results = document.getElementById("trackSearchResults"); - let pattern = document.getElementById("trackSearch").value; - let url = makeUrl(`/track/?format=json&search-terms=${encodeURIComponent(pattern)}`); - fetch(url) - .then((response) => response.json()) - .then((response) => { - results.replaceChildren(); - for (let track of response) { - if (hasTrack(track.id)) { - continue; - } - let clone = document.importNode(template.content, true); - clone.firstElementChild.setAttribute("data-track-id", track.id); - clone.querySelector(".track-title").textContent = track.title; - clone.querySelector(".track-date").textContent = formatTimestamp(track.date * 1000); - clone.querySelector(".track-length").textContent = `${(track.length / 1000).toFixed(2)} km`; - clone.querySelector("button").addEventListener("click", addTrackToJourney); - results.appendChild(clone); - } - }); - } - - document.querySelector("#trackSearchButton").addEventListener("click", (event) => { - searchTracks(); - event.preventDefault(); - }); - </script> +{{ form.journey_js() }} {% endblock %} diff --git a/fietsboek/views/journey.py b/fietsboek/views/journey.py index ee1a44e..2b14c22 100644 --- a/fietsboek/views/journey.py +++ b/fietsboek/views/journey.py @@ -35,12 +35,14 @@ def journey_details(request: Request): journey: Journey = request.context tracks = [TrackWithMetadata(track) for track in journey.tracks] movement_data = journey.path().movement_data() + show_edit_link = request.identity == journey.owner return { "journey": journey, "tracks": tracks, "movement_data": movement_data, "mps_to_kph": util.mps_to_kph, "md_to_html": util.safe_markdown, + "show_edit_link": show_edit_link, } @@ -100,3 +102,34 @@ def do_journey_new(request: Request): journey.set_track_ids(track_ids) return HTTPFound(request.route_url("journey-details", journey_id=journey.id)) + + +@view_config( + route_name="journey-edit", + renderer="fietsboek:templates/journey_edit.jinja2", + permission="journey.edit", +) +def journey_edit(request: Request): + journey: Journey = request.context + return { + "journey": journey, + } + + +@view_config( + route_name="journey-edit", + permission="journey.edit", + request_method="POST", +) +def do_journey_edit(request: Request): + journey: Journey = request.context + + journey.title = request.params.get("journeyTitle") + journey.description = request.params.get("journeyDescription") + + track_ids = [int(tid) for tid in request.params.getall("journeyTrack[]")] + journey.set_track_ids(track_ids) + + request.dbsession.add(journey) + + return HTTPFound(request.route_url("journey-details", journey_id=journey.id)) |
