don't edit mapdata when changeset is active, up next: log changes

This commit is contained in:
Laura Klünder 2024-08-22 14:47:48 +02:00
parent ca9cfc1e14
commit f7912d177d
8 changed files with 52 additions and 178 deletions

View file

@ -9,7 +9,7 @@ from c3nav.editor.api.base import api_etag_with_update_cache_key
from c3nav.editor.api.geometries import get_level_geometries_result, get_space_geometries_result
from c3nav.editor.api.schemas import EditorGeometriesElemSchema, EditorID, GeometryStylesSchema, UpdateCacheKey, \
EditorBeaconsLookup
from c3nav.editor.views.base import editor_etag_func
from c3nav.editor.views.base import editor_etag_func, use_changeset_mapdata
from c3nav.mapdata.api.base import api_etag
from c3nav.mapdata.models import Source
from c3nav.mapdata.schemas.responses import WithBoundsSchema
@ -63,6 +63,7 @@ def geometrystyles(request):
**auth_permission_responses},
openapi_extra={"security": [{"APIKeyAuth": ["editor_access"]}]})
@api_etag_with_update_cache_key(etag_func=editor_etag_func)
@use_changeset_mapdata
def space_geometries(request, space_id: EditorID, update_cache_key: UpdateCacheKey = None, **kwargs):
# newapi_etag_with_update_cache_key does the following, don't let it confuse you:
# - update_cache_key becomes the actual update_cache_key, not the one supplied be the user
@ -82,6 +83,7 @@ def space_geometries(request, space_id: EditorID, update_cache_key: UpdateCacheK
**auth_permission_responses},
openapi_extra={"security": [{"APIKeyAuth": ["editor_access"]}]})
@api_etag_with_update_cache_key(etag_func=editor_etag_func)
@use_changeset_mapdata
def level_geometries(request, level_id: EditorID, update_cache_key: UpdateCacheKey = None, **kwargs):
# newapi_etag_with_update_cache_key does the following, don't let it confuse you:
# - update_cache_key becomes the actual update_cache_key, not the one supplied be the user

View file

@ -0,0 +1,29 @@
from contextlib import contextmanager
from functools import wraps
from django.db import transaction
from c3nav.editor.models import ChangeSet
try:
from asgiref.local import Local as LocalContext
except ImportError:
from threading import local as LocalContext
intercept = LocalContext()
class InterceptAbortTransaction(Exception):
pass
@contextmanager
def enable_changeset_overlay(changeset):
try:
with transaction.atomic():
# todo: apply changes so far
yield
raise InterceptAbortTransaction
except InterceptAbortTransaction:
pass

View file

@ -1,3 +1,2 @@
from c3nav.editor.models.changedobject import ChangedObject # noqa
from c3nav.editor.models.changeset import ChangeSet # noqa
from c3nav.editor.models.changesetupdate import ChangeSetUpdate # noqa

View file

@ -1,68 +0,0 @@
import typing
from collections import OrderedDict
from decimal import Decimal
from itertools import chain
from django.contrib.contenttypes.models import ContentType
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.db.models import CharField, DecimalField, Field, TextField
from django.utils.translation import gettext_lazy as _
from c3nav.editor.wrappers import is_created_pk
from c3nav.mapdata.fields import I18nField
from c3nav.mapdata.models.locations import LocationRedirect
class ChangedObjectManager(models.Manager):
def get_queryset(self):
return super().get_queryset().select_related('content_type')
class ApplyToInstanceError(Exception):
pass
class NoopChangedObject:
pk = None
@classmethod
def apply_to_instance(cls, *args, **kwargs):
pass
class ChangedObject(models.Model):
changeset = models.ForeignKey('editor.ChangeSet', on_delete=models.CASCADE, verbose_name=_('Change Set'))
created = models.DateTimeField(auto_now_add=True, verbose_name=_('created'))
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
existing_object_pk = models.PositiveIntegerField(null=True, verbose_name=_('id of existing object'))
updated_fields = models.JSONField(default=dict, verbose_name=_('updated fields'),
encoder=DjangoJSONEncoder)
m2m_added = models.JSONField(default=dict, verbose_name=_('added m2m values'))
m2m_removed = models.JSONField(default=dict, verbose_name=_('removed m2m values'))
deleted = models.BooleanField(default=False, verbose_name=_('object was deleted'))
objects = ChangedObjectManager()
class Meta:
verbose_name = _('Changed object')
verbose_name_plural = _('Changed objects')
default_related_name = 'changed_objects_set'
base_manager_name = 'objects'
unique_together = ('changeset', 'content_type', 'existing_object_pk')
ordering = ['created', 'pk']
def __repr__(self):
return '<ChangedObject #%s on ChangeSet #%s>' % (str(self.pk), str(self.changeset_id))
def serialize(self):
return OrderedDict((
('pk', self.pk),
('type', self.model_class.__name__.lower()),
('object_pk', self.obj_pk),
('is_created', self.is_created),
('deleted', self.deleted),
('updated_fields', self.updated_fields),
('m2m_added', self.m2m_added),
('m2m_removed', self.m2m_removed),
))

View file

@ -17,13 +17,11 @@ from django.utils.timezone import make_naive
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext_lazy
from c3nav.editor.models.changedobject import ApplyToInstanceError, ChangedObject, NoopChangedObject
from c3nav.editor.tasks import send_changeset_proposed_notification
from c3nav.editor.wrappers import is_created_pk
from c3nav.mapdata.models import LocationSlug, MapUpdate
from c3nav.mapdata.models.locations import LocationRedirect
from c3nav.mapdata.utils.cache.changes import changed_geometries
from c3nav.mapdata.utils.models import get_submodels
class ChangeSet(models.Model):
@ -139,10 +137,6 @@ class ChangeSet(models.Model):
"""
Wrap Objects
"""
def relevant_changed_objects(self) -> typing.Iterable[ChangedObject]:
return self.changed_objects_set.exclude(existing_object_pk__isnull=True, deleted=True)
def fill_changes_cache(self):
"""
Get all changed objects and fill this ChangeSet's changes cache.
@ -182,97 +176,6 @@ class ChangeSet(models.Model):
return True
def iter_changed_objects(self) -> typing.Iterable[ChangedObject]:
return chain(*(changed_objects.values() for changed_objects in self.changed_objects.values()))
def _clean_changes(self):
if self.direct_editing:
return
with self.lock_to_edit() as changeset:
last_map_update_pk = MapUpdate.last_update()[0]
if changeset.last_cleaned_with_id == last_map_update_pk:
return
changed_objects = changeset.changed_objects_set.all()
# delete changed objects that refer in some way to deleted objects and clean up m2m changes
object_pks = {}
for changed_object in changed_objects:
changed_object.add_relevant_object_pks(object_pks)
to_save = set()
deleted_object_pks = {}
for model, pks in object_pks.items():
pks = set(pk for pk in pks if not is_created_pk(pk))
deleted_object_pks[model] = pks - set(model.objects.filter(pk__in=pks).values_list('pk', flat=True))
repeat = True
while repeat:
repeat = False
for changed_object in changed_objects:
if changed_object.handle_deleted_object_pks(deleted_object_pks):
to_save.add(changed_object)
if changed_object.pk is None:
repeat = True
# remove deleted objects
changed_objects = [obj for obj in changed_objects if obj.pk is not None]
# clean updated fields
objects = changeset.get_objects(many=False, changed_objects=changed_objects, prefetch_related=('groups', ))
for changed_object in changed_objects:
if changed_object.clean_updated_fields(objects):
to_save.add(changed_object)
# clean m2m
for changed_object in changed_objects:
if changed_object.clean_m2m(objects):
to_save.add(changed_object)
# remove duplicate slugs
slugs = set()
for changed_object in changed_objects:
if issubclass(changed_object.model_class, LocationSlug):
slug = changed_object.updated_fields.get('slug', None)
if slug is not None:
slugs.add(slug)
qs = LocationSlug.objects.filter(slug__in=slugs)
if slugs:
qs = qs.filter(reduce(operator.or_, (Q(slug__startswith=slug+'__') for slug in slugs)))
existing_slugs = dict(qs.values_list('slug', 'redirect__target_id'))
slug_length = LocationSlug._meta.get_field('slug').max_length
for changed_object in changed_objects:
if issubclass(changed_object.model_class, LocationSlug):
slug = changed_object.updated_fields.get('slug', None)
if slug is None:
continue
if slug in existing_slugs:
redirect_to = existing_slugs[slug]
if issubclass(changed_object.model_class, LocationRedirect) and redirect_to is not None:
to_save.discard(changed_object)
changed_object.delete()
continue
new_slug = slug
i = 0
while new_slug in existing_slugs:
suffix = '__'+str(i)
new_slug = slug[:slug_length-len(suffix)]+suffix
i += 1
slug = new_slug
changed_object.updated_fields['slug'] = new_slug
to_save.add(changed_object)
existing_slugs[slug] = (None if not issubclass(changed_object.model_class, LocationRedirect)
else changed_object.updated_fields['target'])
for changed_object in to_save:
changed_object.save(standalone=True)
changeset.last_cleaned_with_id = last_map_update_pk
changeset.save()
"""
Analyse Changes
"""
@ -313,16 +216,6 @@ class ChangeSet(models.Model):
def _object_changed(self, value):
self.object_changed_cache[self.pk] = value
cleaning_changes_cache = {}
@property
def _cleaning_changes(self):
return self.cleaning_changes_cache.get(self.pk, None)
@_cleaning_changes.setter
def _cleaning_changes(self, value):
self.cleaning_changes_cache[self.pk] = value
objects_changed_count = 0
@classmethod

View file

@ -15,12 +15,26 @@ from django.utils.cache import patch_vary_headers
from django.utils.translation import get_language
from django.utils.translation import gettext_lazy as _
from c3nav.editor.intercept import enable_changeset_overlay
from c3nav.editor.models import ChangeSet
from c3nav.mapdata.models.access import AccessPermission
from c3nav.mapdata.models.base import SerializableMixin
from c3nav.mapdata.utils.user import can_access_editor
def use_changeset_mapdata(func):
@wraps(func)
def wrapped(request, *args, **kwargs):
print('USE CHANGESET MAPDATA')
if request.changeset.direct_editing:
return func(request, *args, **kwargs)
with enable_changeset_overlay(request.changeset):
return func(request, *args, **kwargs)
return wrapped
def sidebar_view(func=None, select_related=None, api_hybrid=False):
if func is None:
def wrapped(inner_func):

View file

@ -19,7 +19,7 @@ from c3nav.editor.forms import GraphEdgeSettingsForm, GraphEditorActionForm, get
from c3nav.editor.utils import DefaultEditUtils, LevelChildEditUtils, SpaceChildEditUtils
from c3nav.editor.views.base import (APIHybridError, APIHybridFormTemplateResponse, APIHybridLoginRequiredResponse,
APIHybridMessageRedirectResponse, APIHybridTemplateContextResponse,
editor_etag_func, sidebar_view)
editor_etag_func, sidebar_view, use_changeset_mapdata)
from c3nav.mapdata.models import Level, Space, LocationGroupCategory, GraphNode, GraphEdge
from c3nav.mapdata.models.access import AccessPermission
from c3nav.mapdata.utils.user import can_access_editor
@ -44,6 +44,7 @@ def child_model(request, model: typing.Union[str, models.Model], kwargs=None, pa
@etag(editor_etag_func)
@use_changeset_mapdata
@sidebar_view(api_hybrid=True)
def main_index(request):
return APIHybridTemplateContextResponse('editor/index.html', {
@ -68,6 +69,7 @@ def main_index(request):
@etag(editor_etag_func)
@use_changeset_mapdata
@sidebar_view(api_hybrid=True)
def level_detail(request, pk):
qs = Level.objects.filter(Level.q_for_request(request))
@ -96,6 +98,7 @@ def level_detail(request, pk):
@etag(editor_etag_func)
@use_changeset_mapdata
@sidebar_view(api_hybrid=True)
def space_detail(request, level, pk):
# todo: HOW TO GET DATA
@ -129,6 +132,7 @@ def get_changeset_exceeded(request):
@etag(editor_etag_func)
@use_changeset_mapdata
@sidebar_view(api_hybrid=True)
def edit(request, pk=None, model=None, level=None, space=None, on_top_of=None, explicit_edit=False):
if isinstance(model, str):
@ -581,6 +585,7 @@ def connect_nodes(request, active_node, clicked_node, edge_settings_form):
@etag(editor_etag_func)
@use_changeset_mapdata
@sidebar_view
def graph_edit(request, level=None, space=None):
if not request.user_permissions.can_access_base_mapdata: