quests can now be done!

This commit is contained in:
Laura Klünder 2024-12-24 22:58:26 +01:00
parent df777ecc05
commit d811170716
14 changed files with 219 additions and 63 deletions

View file

@ -21,7 +21,7 @@ from c3nav.mapdata.forms import I18nModelFormMixin
from c3nav.mapdata.models import MapUpdate, Space from c3nav.mapdata.models import MapUpdate, Space
from c3nav.mapdata.models.access import (AccessPermission, AccessPermissionToken, AccessPermissionTokenItem, from c3nav.mapdata.models.access import (AccessPermission, AccessPermissionToken, AccessPermissionTokenItem,
AccessRestriction, AccessRestrictionGroup) AccessRestriction, AccessRestrictionGroup)
from c3nav.mapdata.quests import quest_types from c3nav.mapdata.quests.base import quest_types
from c3nav.site.models import Announcement from c3nav.site.models import Announcement

View file

@ -0,0 +1,20 @@
{% extends 'site/base.html' %}
{% load i18n %}
{% block content %}
<main class="account">
<h3>{{ title }}</h3>
{% if back_url %}
<p>
<a href="{{ back_url }}">&laquo; {% trans 'back' %}</a>
</p>
{% endif %}
<form method="post" action="{{ request.path_info }}">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">{% trans 'Submit answer' %}</button>
</form>
</main>
{% endblock %}

View file

@ -0,0 +1,10 @@
{% extends 'site/base.html' %}
{% load i18n %}
{% block content %}
<main class="account">
<h3>{% trans 'Thank you!' %}</h3>
<p>{% trans 'Have a cookie <3 🍪' %}</p>
</main>
{% endblock %}

View file

@ -1,10 +1,12 @@
from django.apps import apps from django.apps import apps
from django.urls import path 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.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.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.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.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 from c3nav.editor.views.users import user_detail, user_redirect
@ -55,6 +57,8 @@ urlpatterns = [
path('logout', logout_view, name='editor.logout'), path('logout', logout_view, name='editor.logout'),
path('register', register_view, name='editor.register'), path('register', register_view, name='editor.register'),
path('change_password', change_password_view, name='editor.change_password'), path('change_password', change_password_view, name='editor.change_password'),
path('quests/<str:quest_type>/<str:identifier>/', 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'), path('', main_index, name='editor.index'),
] ]
urlpatterns.extend(add_editor_urls('Level', with_list=False, explicit_edit=True)) urlpatterns.extend(add_editor_urls('Level', with_list=False, explicit_edit=True))

View file

@ -26,14 +26,35 @@ from c3nav.mapdata.utils.user import can_access_editor
@contextmanager @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)""" """ Lock the changeset of the given request, if it can be locked (= has ever been saved to the database)"""
if request.changeset.pk: if changeset.pk:
with request.changeset.lock_to_edit(request=request) as changeset: with changeset.lock_to_edit() as locked_changeset:
request.changeset = changeset yield locked_changeset
yield
else: 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 @contextmanager
@ -71,24 +92,9 @@ def accesses_mapdata(func):
raise ValueError # todo: good error message, but this shouldn't happen raise ValueError # todo: good error message, but this shouldn't happen
else: else:
# For non-direct editing, we will interact with the changeset # For non-direct editing, we will interact with the changeset
with maybe_lock_changeset_to_edit(request=request): with within_changeset(changeset=request.changeset, user=request.user) as locked_changeset:
# Turn the changes from the changeset into a list of operations request.changeset = locked_changeset
operations = request.changeset.as_operations return func(request, *args, **kwargs)
# 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()
return result return result
return wrapped return wrapped

View file

@ -62,7 +62,7 @@ def changeset_detail(request, pk):
return redirect(reverse('editor.changesets.detail', kwargs={'pk': changeset.pk})) return redirect(reverse('editor.changesets.detail', kwargs={'pk': changeset.pk}))
elif request.POST.get('activate') == '1': 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): if changeset.can_activate(request):
changeset.activate(request) changeset.activate(request)
messages.success(request, _('You activated this change set.')) 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.')) messages.info(request, _('You need to log in to propose changes.'))
return redirect(reverse('editor.login') + '?r=' + request.path) 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: if not changeset.title:
form = ChangeSetForm(instance=changeset, data=request.POST) form = ChangeSetForm(instance=changeset, data=request.POST)
if not form.is_valid(): 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.')) messages.info(request, _('You need to log in to apply changes.'))
return redirect(reverse('editor.login') + '?r=' + request.path) 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: if not changeset.title:
form = ChangeSetForm(instance=changeset, data=request.POST) form = ChangeSetForm(instance=changeset, data=request.POST)
if not form.is_valid(): if not form.is_valid():
@ -136,7 +136,7 @@ def changeset_detail(request, pk):
return redirect(reverse('editor.changesets.detail', kwargs={'pk': changeset.pk})) return redirect(reverse('editor.changesets.detail', kwargs={'pk': changeset.pk}))
elif request.POST.get('unpropose') == '1': 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): if changeset.can_unpropose(request):
changeset.unpropose(request.user) changeset.unpropose(request.user)
messages.success(request, _('You unproposed your changes.')) 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})) return redirect(reverse('editor.changesets.detail', kwargs={'pk': changeset.pk}))
elif request.POST.get('review') == '1': 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): if changeset.can_start_review(request):
changeset.start_review(request.user) changeset.start_review(request.user)
messages.success(request, _('You are now reviewing these changes.')) 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})) return redirect(reverse('editor.changesets.detail', kwargs={'pk': changeset.pk}))
elif request.POST.get('reject') == '1': 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): if not changeset.can_end_review(request):
messages.error(request, _('You cannot reject these changes.')) messages.error(request, _('You cannot reject these changes.'))
return redirect(reverse('editor.changesets.detail', kwargs={'pk': changeset.pk})) 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': 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): if not changeset.can_unreject(request):
messages.error(request, _('You cannot unreject these changes.')) messages.error(request, _('You cannot unreject these changes.'))
return redirect(reverse('editor.changesets.detail', kwargs={'pk': changeset.pk})) 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})) return redirect(reverse('editor.changesets.detail', kwargs={'pk': changeset.pk}))
elif request.POST.get('apply') == '1': 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): if not changeset.can_end_review(request):
messages.error(request, _('You cannot accept and apply these changes.')) messages.error(request, _('You cannot accept and apply these changes.'))
return redirect(reverse('editor.changesets.detail', kwargs={'pk': changeset.pk})) 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', {}) return render(request, 'editor/changeset_apply.html', {})
elif request.POST.get('delete') == '1': 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): if not changeset.can_delete(request):
messages.error(request, _('You cannot delete this change set.')) 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): if str(pk) != str(request.changeset.pk):
changeset = get_object_or_404(ChangeSet.qs_for_request(request), pk=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): if not changeset.can_edit(request):
messages.error(request, _('You cannot edit this change set.')) messages.error(request, _('You cannot edit this change set.'))
return redirect(reverse('editor.changesets.detail', kwargs={'pk': changeset.pk})) return redirect(reverse('editor.changesets.detail', kwargs={'pk': changeset.pk}))

View file

@ -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): if not new and ((request.POST.get('delete') == '1' and delete is not False) or delete):
# Delete this mapitem! # Delete this mapitem!
if request.POST.get('delete_confirm') == '1' or delete: 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): if changeset.can_edit(request):
obj.delete() obj.delete()
else: else:
@ -210,7 +210,7 @@ def overlay_feature_edit(request, level=None, overlay=None, pk=None):
obj.level = level obj.level = level
obj.overlay = overlay 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): if changeset.can_edit(request):
try: try:
obj.save() obj.save()

View file

@ -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)

View file

@ -21,7 +21,7 @@ from c3nav.mapdata.grid import grid
from c3nav.mapdata.models import Source, Theme, Area, Space from c3nav.mapdata.models import Source, Theme, Area, Space
from c3nav.mapdata.models.geometry.space import ObstacleGroup, Obstacle from c3nav.mapdata.models.geometry.space import ObstacleGroup, Obstacle
from c3nav.mapdata.models.locations import DynamicLocation, LocationRedirect, Position, LocationGroup 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.render.theme import ColorManager
from c3nav.mapdata.schemas.filters import BySearchableFilter, RemoveGeometryFilter from c3nav.mapdata.schemas.filters import BySearchableFilter, RemoveGeometryFilter
from c3nav.mapdata.schemas.model_base import AnyLocationID, AnyPositionID, CustomLocationID from c3nav.mapdata.schemas.model_base import AnyLocationID, AnyPositionID, CustomLocationID

View file

View file

@ -1,17 +1,16 @@
from abc import abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from itertools import chain 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.core.cache import cache
from django.utils.translation import gettext_lazy as _ from django.forms import ModelForm
from pydantic import BaseModel from pydantic import TypeAdapter, BaseModel
from pydantic.type_adapter import TypeAdapter
from shapely.geometry import Point, mapping
from c3nav.api.schema import BaseSchema, PointSchema 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.access import AccessPermission
from c3nav.mapdata.models.geometry.space import RangingBeacon
@dataclass @dataclass
@ -19,7 +18,6 @@ class Quest:
obj: Any obj: Any
@property @property
@abstractmethod
def point(self) -> dict: def point(self) -> dict:
raise NotImplementedError raise NotImplementedError
@ -43,8 +41,11 @@ class Quest:
def get_for_request(cls, request, identifier: Any) -> Optional[Self]: def get_for_request(cls, request, identifier: Any) -> Optional[Self]:
if not identifier.isdigit(): if not identifier.isdigit():
return None return None
if not (request.user.is_superuser or cls.quest_type in request.user_permissions.quests):
return None
results = list(chain( 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: if len(results) > 1:
raise ValueError('wrong number of results') raise ValueError('wrong number of results')
@ -52,6 +53,8 @@ class Quest:
@classmethod @classmethod
def get_all_for_request(cls, request) -> list[Self]: 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( return list(chain(
*(cls._obj_to_quests(obj) for obj in cls._qs_for_request(request)) *(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) cache.set(cache_key, result, 900)
return result 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]] = {} quest_types: dict[str, Type[BaseModel]] = {}
@ -76,20 +105,11 @@ def register_quest(cls):
return cls return cls
@register_quest def get_quest_for_request(request, quest_type: str, identifier: str) -> Optional[Quest]:
@dataclass quest_cls = quest_types.get(quest_type, None)
class RangingBeaconAltitudeQuest(Quest): if quest_cls is None:
quest_type = "ranging_beacon_altitude" return None
quest_type_label = _('Ranging Beacon Altitude') return quest_cls.get_for_request(request, identifier)
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 QuestSchema(BaseSchema): class QuestSchema(BaseSchema):

View file

@ -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}'

View file

@ -6,7 +6,6 @@ from django.utils.translation import ngettext_lazy
from c3nav.mapdata.models import DataOverlay from c3nav.mapdata.models import DataOverlay
from c3nav.mapdata.models.access import AccessPermission, AccessRestriction from c3nav.mapdata.models.access import AccessPermission, AccessRestriction
from c3nav.mapdata.models.locations import Position from c3nav.mapdata.models.locations import Position
from c3nav.mapdata.quests import quest_types
from c3nav.mapdata.schemas.models import DataOverlaySchema from c3nav.mapdata.schemas.models import DataOverlaySchema
@ -35,6 +34,7 @@ def get_user_data(request):
result['title'] = request.user.username result['title'] = request.user.username
# todo: cache this # todo: cache this
from c3nav.mapdata.quests.base import quest_types
result.update({ result.update({
'overlays': [ 'overlays': [
DataOverlaySchema.model_validate(overlay).model_dump() DataOverlaySchema.model_validate(overlay).model_dump()

View file

@ -2494,7 +2494,10 @@ QuestsControl = L.Control.extend({
c3nav_api.get('map/quests/') c3nav_api.get('map/quests/')
.then((data) => { .then((data) => {
for (const quest of 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(); .catch();