From d811170716cef7c6a9bf75ae496dea698a42132a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Kl=C3=BCnder?= Date: Tue, 24 Dec 2024 22:58:26 +0100 Subject: [PATCH] quests can now be done! --- src/c3nav/control/forms.py | 2 +- .../editor/templates/editor/quest_form.html | 20 ++++++ src/c3nav/editor/templates/editor/thanks.html | 10 +++ src/c3nav/editor/urls.py | 4 ++ src/c3nav/editor/views/base.py | 54 ++++++++------- src/c3nav/editor/views/changes.py | 20 +++--- src/c3nav/editor/views/overlays.py | 4 +- src/c3nav/editor/views/quest.py | 41 ++++++++++++ src/c3nav/mapdata/api/map.py | 2 +- src/c3nav/mapdata/quests/__init__.py | 0 .../mapdata/{quests.py => quests/base.py} | 66 ++++++++++++------- src/c3nav/mapdata/quests/simple.py | 52 +++++++++++++++ src/c3nav/mapdata/utils/user.py | 2 +- src/c3nav/site/static/site/js/c3nav.js | 5 +- 14 files changed, 219 insertions(+), 63 deletions(-) create mode 100644 src/c3nav/editor/templates/editor/quest_form.html create mode 100644 src/c3nav/editor/templates/editor/thanks.html create mode 100644 src/c3nav/editor/views/quest.py create mode 100644 src/c3nav/mapdata/quests/__init__.py rename src/c3nav/mapdata/{quests.py => quests/base.py} (58%) create mode 100644 src/c3nav/mapdata/quests/simple.py diff --git a/src/c3nav/control/forms.py b/src/c3nav/control/forms.py index 90428e7f..47d5e005 100644 --- a/src/c3nav/control/forms.py +++ b/src/c3nav/control/forms.py @@ -21,7 +21,7 @@ from c3nav.mapdata.forms import I18nModelFormMixin from c3nav.mapdata.models import MapUpdate, Space from c3nav.mapdata.models.access import (AccessPermission, AccessPermissionToken, AccessPermissionTokenItem, AccessRestriction, AccessRestrictionGroup) -from c3nav.mapdata.quests import quest_types +from c3nav.mapdata.quests.base import quest_types from c3nav.site.models import Announcement diff --git a/src/c3nav/editor/templates/editor/quest_form.html b/src/c3nav/editor/templates/editor/quest_form.html new file mode 100644 index 00000000..414aa1de --- /dev/null +++ b/src/c3nav/editor/templates/editor/quest_form.html @@ -0,0 +1,20 @@ +{% extends 'site/base.html' %} +{% load i18n %} + +{% block content %} +
+

{{ title }}

+ + {% if back_url %} +

+ « {% trans 'back' %} +

+ {% endif %} + +
+ {% csrf_token %} + {{ form.as_p }} + +
+
+{% endblock %} diff --git a/src/c3nav/editor/templates/editor/thanks.html b/src/c3nav/editor/templates/editor/thanks.html new file mode 100644 index 00000000..f78a1850 --- /dev/null +++ b/src/c3nav/editor/templates/editor/thanks.html @@ -0,0 +1,10 @@ +{% extends 'site/base.html' %} +{% load i18n %} + +{% block content %} +
+

{% trans 'Thank you!' %}

+ +

{% trans 'Have a cookie <3 šŸŖ' %}

+
+{% endblock %} diff --git a/src/c3nav/editor/urls.py b/src/c3nav/editor/urls.py index 89ffb95b..2c3e66dc 100644 --- a/src/c3nav/editor/urls.py +++ b/src/c3nav/editor/urls.py @@ -1,10 +1,12 @@ from django.apps import apps from django.urls import path +from django.views.generic import TemplateView from c3nav.editor.views.account import change_password_view, login_view, logout_view, register_view from c3nav.editor.views.changes import changeset_detail, changeset_edit, changeset_redirect from c3nav.editor.views.edit import edit, graph_edit, level_detail, list_objects, main_index, sourceimage, space_detail from c3nav.editor.views.overlays import overlays_list, overlay_features, overlay_feature_edit +from c3nav.editor.views.quest import QuestFormView from c3nav.editor.views.users import user_detail, user_redirect @@ -55,6 +57,8 @@ urlpatterns = [ path('logout', logout_view, name='editor.logout'), path('register', register_view, name='editor.register'), path('change_password', change_password_view, name='editor.change_password'), + path('quests///', QuestFormView.as_view(), name='editor.quest'), + path('thanks/', TemplateView.as_view(template_name="editor/thanks.html"), name='editor.thanks'), path('', main_index, name='editor.index'), ] urlpatterns.extend(add_editor_urls('Level', with_list=False, explicit_edit=True)) diff --git a/src/c3nav/editor/views/base.py b/src/c3nav/editor/views/base.py index ba1ea6ed..74612e65 100644 --- a/src/c3nav/editor/views/base.py +++ b/src/c3nav/editor/views/base.py @@ -26,14 +26,35 @@ from c3nav.mapdata.utils.user import can_access_editor @contextmanager -def maybe_lock_changeset_to_edit(request): +def maybe_lock_changeset_to_edit(changeset): """ Lock the changeset of the given request, if it can be locked (= has ever been saved to the database)""" - if request.changeset.pk: - with request.changeset.lock_to_edit(request=request) as changeset: - request.changeset = changeset - yield + if changeset.pk: + with changeset.lock_to_edit() as locked_changeset: + yield locked_changeset else: - yield + yield changeset + + +@contextmanager +def within_changeset(changeset, user): + with maybe_lock_changeset_to_edit(changeset=changeset) as locked_changeset: + # Turn the changes from the changeset into a list of operations + operations = locked_changeset.as_operations + + # Enable the overlay manager, temporarily applying the changeset changes + # commit is set to false, meaning all changes will be reset once we leave the manager + with DatabaseOverlayManager.enable(operations=operations, commit=False) as manager: + yield locked_changeset + if manager.operations: + # Add new operations to changeset + locked_changeset.changes.add_operations(manager.operations) + locked_changeset.save() + + # Add new changeset update + update = locked_changeset.updates.create(user=user, objects_changed=True) + locked_changeset.last_update = update + locked_changeset.last_change = update + locked_changeset.save() @contextmanager @@ -71,24 +92,9 @@ def accesses_mapdata(func): raise ValueError # todo: good error message, but this shouldn't happen else: # For non-direct editing, we will interact with the changeset - with maybe_lock_changeset_to_edit(request=request): - # Turn the changes from the changeset into a list of operations - operations = request.changeset.as_operations - - # Enable the overlay manager, temporarily applying the changeset changes - # commit is set to false, meaning all changes will be reset once we leave the manager - with DatabaseOverlayManager.enable(operations=operations, commit=False) as manager: - result = func(request, *args, **kwargs) - if manager.operations: - # Add new operations to changeset - request.changeset.changes.add_operations(manager.operations) - request.changeset.save() - - # Add new changeset update - update = request.changeset.updates.create(user=request.user, objects_changed=True) - request.changeset.last_update = update - request.changeset.last_change = update - request.changeset.save() + with within_changeset(changeset=request.changeset, user=request.user) as locked_changeset: + request.changeset = locked_changeset + return func(request, *args, **kwargs) return result return wrapped diff --git a/src/c3nav/editor/views/changes.py b/src/c3nav/editor/views/changes.py index e58fe764..8751726d 100644 --- a/src/c3nav/editor/views/changes.py +++ b/src/c3nav/editor/views/changes.py @@ -62,7 +62,7 @@ def changeset_detail(request, pk): return redirect(reverse('editor.changesets.detail', kwargs={'pk': changeset.pk})) elif request.POST.get('activate') == '1': - with changeset.lock_to_edit(request) as changeset: + with changeset.lock_to_edit() as changeset: if changeset.can_activate(request): changeset.activate(request) messages.success(request, _('You activated this change set.')) @@ -76,7 +76,7 @@ def changeset_detail(request, pk): messages.info(request, _('You need to log in to propose changes.')) return redirect(reverse('editor.login') + '?r=' + request.path) - with changeset.lock_to_edit(request) as changeset: + with changeset.lock_to_edit() as changeset: if not changeset.title: form = ChangeSetForm(instance=changeset, data=request.POST) if not form.is_valid(): @@ -108,7 +108,7 @@ def changeset_detail(request, pk): messages.info(request, _('You need to log in to apply changes.')) return redirect(reverse('editor.login') + '?r=' + request.path) - with changeset.lock_to_edit(request) as changeset: + with changeset.lock_to_edit() as changeset: if not changeset.title: form = ChangeSetForm(instance=changeset, data=request.POST) if not form.is_valid(): @@ -136,7 +136,7 @@ def changeset_detail(request, pk): return redirect(reverse('editor.changesets.detail', kwargs={'pk': changeset.pk})) elif request.POST.get('unpropose') == '1': - with changeset.lock_to_edit(request) as changeset: + with changeset.lock_to_edit() as changeset: if changeset.can_unpropose(request): changeset.unpropose(request.user) messages.success(request, _('You unproposed your changes.')) @@ -146,7 +146,7 @@ def changeset_detail(request, pk): return redirect(reverse('editor.changesets.detail', kwargs={'pk': changeset.pk})) elif request.POST.get('review') == '1': - with changeset.lock_to_edit(request) as changeset: + with changeset.lock_to_edit() as changeset: if changeset.can_start_review(request): changeset.start_review(request.user) messages.success(request, _('You are now reviewing these changes.')) @@ -156,7 +156,7 @@ def changeset_detail(request, pk): return redirect(reverse('editor.changesets.detail', kwargs={'pk': changeset.pk})) elif request.POST.get('reject') == '1': - with changeset.lock_to_edit(request) as changeset: + with changeset.lock_to_edit() as changeset: if not changeset.can_end_review(request): messages.error(request, _('You cannot reject these changes.')) return redirect(reverse('editor.changesets.detail', kwargs={'pk': changeset.pk})) @@ -176,7 +176,7 @@ def changeset_detail(request, pk): }) elif request.POST.get('unreject') == '1': - with changeset.lock_to_edit(request) as changeset: + with changeset.lock_to_edit() as changeset: if not changeset.can_unreject(request): messages.error(request, _('You cannot unreject these changes.')) return redirect(reverse('editor.changesets.detail', kwargs={'pk': changeset.pk})) @@ -187,7 +187,7 @@ def changeset_detail(request, pk): return redirect(reverse('editor.changesets.detail', kwargs={'pk': changeset.pk})) elif request.POST.get('apply') == '1': - with changeset.lock_to_edit(request) as changeset: + with changeset.lock_to_edit() as changeset: if not changeset.can_end_review(request): messages.error(request, _('You cannot accept and apply these changes.')) return redirect(reverse('editor.changesets.detail', kwargs={'pk': changeset.pk})) @@ -204,7 +204,7 @@ def changeset_detail(request, pk): return render(request, 'editor/changeset_apply.html', {}) elif request.POST.get('delete') == '1': - with changeset.lock_to_edit(request) as changeset: + with changeset.lock_to_edit() as changeset: if not changeset.can_delete(request): messages.error(request, _('You cannot delete this change set.')) @@ -502,7 +502,7 @@ def changeset_edit(request, pk): if str(pk) != str(request.changeset.pk): changeset = get_object_or_404(ChangeSet.qs_for_request(request), pk=pk) - with changeset.lock_to_edit(request) as changeset: + with changeset.lock_to_edit() as changeset: if not changeset.can_edit(request): messages.error(request, _('You cannot edit this change set.')) return redirect(reverse('editor.changesets.detail', kwargs={'pk': changeset.pk})) diff --git a/src/c3nav/editor/views/overlays.py b/src/c3nav/editor/views/overlays.py index 89fbeef7..bae5a223 100644 --- a/src/c3nav/editor/views/overlays.py +++ b/src/c3nav/editor/views/overlays.py @@ -179,7 +179,7 @@ def overlay_feature_edit(request, level=None, overlay=None, pk=None): if not new and ((request.POST.get('delete') == '1' and delete is not False) or delete): # Delete this mapitem! if request.POST.get('delete_confirm') == '1' or delete: - with request.changeset.lock_to_edit(request) as changeset: + with request.changeset.lock_to_edit() as changeset: if changeset.can_edit(request): obj.delete() else: @@ -210,7 +210,7 @@ def overlay_feature_edit(request, level=None, overlay=None, pk=None): obj.level = level obj.overlay = overlay - with request.changeset.lock_to_edit(request) as changeset: + with request.changeset.lock_to_edit() as changeset: if changeset.can_edit(request): try: obj.save() diff --git a/src/c3nav/editor/views/quest.py b/src/c3nav/editor/views/quest.py new file mode 100644 index 00000000..e1fbca2d --- /dev/null +++ b/src/c3nav/editor/views/quest.py @@ -0,0 +1,41 @@ +from functools import cached_property + +from django.http import Http404 +from django.urls import reverse_lazy +from django.views.generic.edit import FormView + +from c3nav.mapdata.quests.base import get_quest_for_request + + +class QuestFormView(FormView): + template_name = "editor/quest_form.html" + success_url = reverse_lazy("editor.thanks") + + @cached_property + def quest(self): + quest = get_quest_for_request(request=self.request, + quest_type=self.kwargs["quest_type"], + identifier=self.kwargs["identifier"]) + if quest is None: + raise Http404 + return quest + + def get_form_class(self): + return self.quest.get_form_class() + + def get_form_kwargs(self): + return { + "request": self.request, + **super().get_form_kwargs(), + **self.quest.get_form_kwargs(request=self.request), + } + + def get_context_data(self, **kwargs): + return { + **super().get_context_data(**kwargs), + "title": self.quest.quest_type_label, + } + + def form_valid(self, form): + form.save() + return super().form_valid(form) \ No newline at end of file diff --git a/src/c3nav/mapdata/api/map.py b/src/c3nav/mapdata/api/map.py index 8aa440f3..ddfd503e 100644 --- a/src/c3nav/mapdata/api/map.py +++ b/src/c3nav/mapdata/api/map.py @@ -21,7 +21,7 @@ from c3nav.mapdata.grid import grid from c3nav.mapdata.models import Source, Theme, Area, Space from c3nav.mapdata.models.geometry.space import ObstacleGroup, Obstacle from c3nav.mapdata.models.locations import DynamicLocation, LocationRedirect, Position, LocationGroup -from c3nav.mapdata.quests import QuestSchema, get_all_quests_for_request +from c3nav.mapdata.quests.base import QuestSchema, get_all_quests_for_request from c3nav.mapdata.render.theme import ColorManager from c3nav.mapdata.schemas.filters import BySearchableFilter, RemoveGeometryFilter from c3nav.mapdata.schemas.model_base import AnyLocationID, AnyPositionID, CustomLocationID diff --git a/src/c3nav/mapdata/quests/__init__.py b/src/c3nav/mapdata/quests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/c3nav/mapdata/quests.py b/src/c3nav/mapdata/quests/base.py similarity index 58% rename from src/c3nav/mapdata/quests.py rename to src/c3nav/mapdata/quests/base.py index 1d88570c..0c6049e5 100644 --- a/src/c3nav/mapdata/quests.py +++ b/src/c3nav/mapdata/quests/base.py @@ -1,17 +1,16 @@ -from abc import abstractmethod from dataclasses import dataclass from itertools import chain -from typing import Self, Optional, Any, Type +from typing import Any, Self, Optional, Type from django.core.cache import cache -from django.utils.translation import gettext_lazy as _ -from pydantic import BaseModel -from pydantic.type_adapter import TypeAdapter -from shapely.geometry import Point, mapping +from django.forms import ModelForm +from pydantic import TypeAdapter, BaseModel from c3nav.api.schema import BaseSchema, PointSchema +from c3nav.editor.models import ChangeSet +from c3nav.editor.views.base import within_changeset + from c3nav.mapdata.models.access import AccessPermission -from c3nav.mapdata.models.geometry.space import RangingBeacon @dataclass @@ -19,7 +18,6 @@ class Quest: obj: Any @property - @abstractmethod def point(self) -> dict: raise NotImplementedError @@ -43,8 +41,11 @@ class Quest: def get_for_request(cls, request, identifier: Any) -> Optional[Self]: if not identifier.isdigit(): return None + if not (request.user.is_superuser or cls.quest_type in request.user_permissions.quests): + return None + results = list(chain( - +(cls._obj_to_quests(obj) for obj in cls._qs_for_request(request).filter(pk=int(identifier))) + *(cls._obj_to_quests(obj) for obj in cls._qs_for_request(request).filter(pk=int(identifier))) )) if len(results) > 1: raise ValueError('wrong number of results') @@ -52,6 +53,8 @@ class Quest: @classmethod def get_all_for_request(cls, request) -> list[Self]: + if not (request.user.is_superuser or cls.quest_type in request.user_permissions.quests): + return None return list(chain( *(cls._obj_to_quests(obj) for obj in cls._qs_for_request(request)) )) @@ -67,6 +70,32 @@ class Quest: cache.set(cache_key, result, 900) return result + def get_form_class(self): + return self.form_class + + def get_form_kwargs(self, request): + return {"instance": self.obj} + + +class ChangeSetModelForm(ModelForm): + def __init__(self, request, **kwargs): + super().__init__(**kwargs) + self.request = request + + @property + def changeset_title(self): + raise NotImplementedError + + def save(self, **kwargs): + changeset = ChangeSet() + changeset.author = self.request.user + with within_changeset(changeset=changeset, user=self.request.user) as locked_changeset: + super().save(**kwargs) + with changeset.lock_to_edit() as locked_changeset: + locked_changeset.title = self.changeset_title + locked_changeset.description = 'quest' + locked_changeset.apply(self.request.user) + quest_types: dict[str, Type[BaseModel]] = {} @@ -76,20 +105,11 @@ def register_quest(cls): return cls -@register_quest -@dataclass -class RangingBeaconAltitudeQuest(Quest): - quest_type = "ranging_beacon_altitude" - quest_type_label = _('Ranging Beacon Altitude') - obj: RangingBeacon - - @property - def point(self) -> Point: - return mapping(self.obj.geometry) - - @classmethod - def _qs_for_request(cls, request): - return RangingBeacon.qs_for_request(request).select_related('space').filter(altitude_quest=True) +def get_quest_for_request(request, quest_type: str, identifier: str) -> Optional[Quest]: + quest_cls = quest_types.get(quest_type, None) + if quest_cls is None: + return None + return quest_cls.get_for_request(request, identifier) class QuestSchema(BaseSchema): diff --git a/src/c3nav/mapdata/quests/simple.py b/src/c3nav/mapdata/quests/simple.py new file mode 100644 index 00000000..fc36c153 --- /dev/null +++ b/src/c3nav/mapdata/quests/simple.py @@ -0,0 +1,52 @@ +from dataclasses import dataclass + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ +from shapely import Point +from shapely.geometry import mapping + +from c3nav.mapdata.models.geometry.space import RangingBeacon +from c3nav.mapdata.quests.base import register_quest, Quest, ChangeSetModelForm + + +@register_quest +@dataclass +class RangingBeaconAltitudeQuest(Quest): + quest_type = "ranging_beacon_altitude" + quest_type_label = _('Ranging Beacon Altitude') + form_class = RangingBeaconAltitudeQuestForm + obj: RangingBeacon + + @property + def point(self) -> Point: + return mapping(self.obj.geometry) + + @classmethod + def _qs_for_request(cls, request): + return RangingBeacon.qs_for_request(request).select_related('space').filter(altitude_quest=True) + + +class RangingBeaconAltitudeQuestForm(ChangeSetModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["altitude"].label = ( + _('How many meters above ground is the access point ā€œ%sā€ mounted?') % self.instance.title + ) + + def clean_altitude(self): + data = self.cleaned_data["altitude"] + if not data: + raise ValidationError(_("The AP should not be 0m above ground.")) + return data + + class Meta: + model = RangingBeacon + fields = ("altitude", ) + + def save(self, *args, **kwargs): + self.instance.altitude_quest = False + return super().save(*args, **kwargs) + + @property + def changeset_title(self): + return f'Altitude Quest: {self.instance.title}' diff --git a/src/c3nav/mapdata/utils/user.py b/src/c3nav/mapdata/utils/user.py index b5d08f3d..91e4a34b 100644 --- a/src/c3nav/mapdata/utils/user.py +++ b/src/c3nav/mapdata/utils/user.py @@ -6,7 +6,6 @@ from django.utils.translation import ngettext_lazy from c3nav.mapdata.models import DataOverlay from c3nav.mapdata.models.access import AccessPermission, AccessRestriction from c3nav.mapdata.models.locations import Position -from c3nav.mapdata.quests import quest_types from c3nav.mapdata.schemas.models import DataOverlaySchema @@ -35,6 +34,7 @@ def get_user_data(request): result['title'] = request.user.username # todo: cache this + from c3nav.mapdata.quests.base import quest_types result.update({ 'overlays': [ DataOverlaySchema.model_validate(overlay).model_dump() diff --git a/src/c3nav/site/static/site/js/c3nav.js b/src/c3nav/site/static/site/js/c3nav.js index 0dee93f8..944a0f08 100644 --- a/src/c3nav/site/static/site/js/c3nav.js +++ b/src/c3nav/site/static/site/js/c3nav.js @@ -2494,7 +2494,10 @@ QuestsControl = L.Control.extend({ c3nav_api.get('map/quests/') .then((data) => { for (const quest of data) { - const layer = L.geoJson(quest.point, {}).addTo(c3nav._questsLayers[quest.level_id]); + L.geoJson(quest.point, {}).addTo(c3nav._questsLayers[quest.level_id]).on('click', function() { + c3nav.open_modal(); + $.get(`/editor/quests/${quest.quest_type}/${quest.identifier}`, c3nav._modal_loaded).fail(c3nav._modal_error); + }); } }) .catch();