quests can now be done!
This commit is contained in:
parent
df777ecc05
commit
d811170716
14 changed files with 219 additions and 63 deletions
|
@ -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
|
||||
|
||||
|
||||
|
|
20
src/c3nav/editor/templates/editor/quest_form.html
Normal file
20
src/c3nav/editor/templates/editor/quest_form.html
Normal 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 }}">« {% 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 %}
|
10
src/c3nav/editor/templates/editor/thanks.html
Normal file
10
src/c3nav/editor/templates/editor/thanks.html
Normal 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 %}
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}))
|
||||
|
|
|
@ -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()
|
||||
|
|
41
src/c3nav/editor/views/quest.py
Normal file
41
src/c3nav/editor/views/quest.py
Normal 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)
|
|
@ -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
|
||||
|
|
0
src/c3nav/mapdata/quests/__init__.py
Normal file
0
src/c3nav/mapdata/quests/__init__.py
Normal 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):
|
52
src/c3nav/mapdata/quests/simple.py
Normal file
52
src/c3nav/mapdata/quests/simple.py
Normal 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}'
|
|
@ -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()
|
||||
|
|
|
@ -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();
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue