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.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

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.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/<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'),
]
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
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

View file

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

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):
# 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()

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.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

View file

View file

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

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.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()

View file

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