in-between state, we now need to fix geometry field serialization

This commit is contained in:
Laura Klünder 2024-08-26 14:27:25 +02:00
parent 6db9e28a74
commit f729efb9d4
6 changed files with 205 additions and 103 deletions

View file

@ -125,6 +125,7 @@ class ChangeSet(models.Model):
Wrap Objects Wrap Objects
""" """
def fill_changes_cache(self): def fill_changes_cache(self):
return
""" """
Get all changed objects and fill this ChangeSet's changes cache. Get all changed objects and fill this ChangeSet's changes cache.
Only executable once, if something is changed later the cache will be automatically updated. Only executable once, if something is changed later the cache will be automatically updated.
@ -213,24 +214,9 @@ class ChangeSet(models.Model):
@contextmanager @contextmanager
def lock_to_edit(self, request=None): def lock_to_edit(self, request=None):
with transaction.atomic(): with transaction.atomic():
user = request.user if request is not None and request.user.is_authenticated else None
if self.pk is not None: if self.pk is not None:
changeset = ChangeSet.objects.select_for_update().get(pk=self.pk) changeset = ChangeSet.objects.select_for_update().get(pk=self.pk)
self._object_changed = False
yield changeset yield changeset
if self._object_changed:
update = changeset.updates.create(user=user, objects_changed=True)
changeset.last_update = update
changeset.last_change = update
changeset.save()
elif self.direct_editing:
with MapUpdate.lock():
changed_geometries.reset()
ChangeSet.objects_changed_count = 0
yield self
if ChangeSet.objects_changed_count:
MapUpdate.objects.create(user=user, type='direct_edit')
else: else:
yield self yield self
@ -460,6 +446,7 @@ class ChangeSet(models.Model):
if self.direct_editing: if self.direct_editing:
return _('Direct editing active') return _('Direct editing active')
return _('No objects changed') return _('No objects changed')
return 'something was changed' # todo: make this nice again
return (ngettext_lazy('%(num)d object changed', '%(num)d objects changed', 'num') % return (ngettext_lazy('%(num)d object changed', '%(num)d objects changed', 'num') %
{'num': self.changed_objects_count}) {'num': self.changed_objects_count})

View file

@ -1,7 +1,12 @@
import copy
import datetime import datetime
import json
from dataclasses import dataclass
from typing import TypeAlias, Any, Annotated, Literal, Union from typing import TypeAlias, Any, Annotated, Literal, Union
from django.db import models from django.apps import apps
from django.core import serializers
from django.db.models import Model
from django.utils import timezone from django.utils import timezone
from pydantic.config import ConfigDict from pydantic.config import ConfigDict
from pydantic.fields import Field from pydantic.fields import Field
@ -18,7 +23,7 @@ class ObjectReference(BaseSchema):
id: int id: int
@classmethod @classmethod
def from_instance(cls, instance: models.Model): def from_instance(cls, instance: Model):
""" """
This method will not convert the ID yet! This method will not convert the ID yet!
""" """
@ -29,20 +34,46 @@ class BaseOperation(BaseSchema):
obj: ObjectReference obj: ObjectReference
datetime: Annotated[datetime.datetime, Field(default_factory=timezone.now)] datetime: Annotated[datetime.datetime, Field(default_factory=timezone.now)]
def apply(self, values: FieldValuesDict, instance: Model) -> Model:
raise NotImplementedError
class CreateObjectOperation(BaseOperation): class CreateObjectOperation(BaseOperation):
type: Literal["create"] = "create" type: Literal["create"] = "create"
fields: FieldValuesDict fields: FieldValuesDict
def apply_create(self) -> Model:
instance = list(serializers.deserialize("json", json.dumps([{
"model": f"mapdata.{self.obj.model}",
"pk": self.obj.id,
"fields": self.fields,
}])))[0]
instance.save(save_m2m=False)
return instance.object
class UpdateObjectOperation(BaseOperation): class UpdateObjectOperation(BaseOperation):
type: Literal["update"] = "update" type: Literal["update"] = "update"
fields: FieldValuesDict fields: FieldValuesDict
def apply(self, values: FieldValuesDict, instance: Model) -> Model:
values.update(self.fields)
instance = list(serializers.deserialize("json", json.dumps([{
"model": f"mapdata.{self.obj.model}",
"pk": self.obj.id,
"fields": self.fields,
}])))[0]
instance.save(save_m2m=False)
return instance.object
class DeleteObjectOperation(BaseOperation): class DeleteObjectOperation(BaseOperation):
type: Literal["delete"] = "delete" type: Literal["delete"] = "delete"
def apply(self, values: FieldValuesDict, instance: Model) -> Model:
instance.delete()
return instance
class UpdateManyToManyOperation(BaseOperation): class UpdateManyToManyOperation(BaseOperation):
type: Literal["m2m_add"] = "m2m_update" type: Literal["m2m_add"] = "m2m_update"
@ -50,11 +81,23 @@ class UpdateManyToManyOperation(BaseOperation):
add_values: set[int] = set() add_values: set[int] = set()
remove_values: set[int] = set() remove_values: set[int] = set()
def apply(self, values: FieldValuesDict, instance: Model) -> Model:
values[self.field] = sorted((set(values[self.field]) | self.add_values) - self.remove_values)
field_manager = getattr(instance, self.field)
field_manager.add(*self.add_values)
field_manager.remove(*self.remove_values)
return instance
class ClearManyToManyOperation(BaseOperation): class ClearManyToManyOperation(BaseOperation):
type: Literal["m2m_clear"] = "m2m_clear" type: Literal["m2m_clear"] = "m2m_clear"
field: str field: str
def apply(self, values: FieldValuesDict, instance: Model) -> Model:
values[self.field] = []
getattr(instance, self.field).clear()
return instance
DatabaseOperation = Annotated[ DatabaseOperation = Annotated[
Union[ Union[
@ -69,6 +112,45 @@ DatabaseOperation = Annotated[
class CollectedChanges(BaseSchema): class CollectedChanges(BaseSchema):
prev_reprs: dict[ObjectReference, str] = {} prev_reprs: dict[str, dict[int, str]] = {}
prev_values: dict[ObjectReference, FieldValuesDict] = {} prev_values: dict[str, dict[int, FieldValuesDict]] = {}
operations: list[DatabaseOperation] = [] operations: list[DatabaseOperation] = []
def prefetch(self) -> "CollectedChangesPrefetch":
ids_to_query: dict[str, set[int]] = {model_name: set(val.keys())
for model_name, val in self.prev_values.items()}
instances: dict[ObjectReference, Model] = {}
for model_name, ids in ids_to_query.items():
model = apps.get_model("mapdata", model_name)
instances.update(dict((ObjectReference(model=model_name, id=instance.pk), instance)
for instance in model.objects.filter(pk__in=ids)))
return CollectedChangesPrefetch(changes=self, instances=instances)
@dataclass
class CollectedChangesPrefetch:
changes: CollectedChanges
instances: dict[ObjectReference, Model]
def apply(self):
prev_values = copy.deepcopy(self.changes.prev_values)
for operation in self.changes.operations:
if isinstance(operation, CreateObjectOperation):
self.instances[operation.obj] = operation.apply_create()
else:
in_prev_values = operation.obj.id in prev_values.get(operation.obj.model, {})
if not in_prev_values:
print('WARN WARN WARN')
values = prev_values.setdefault(operation.obj.model, {}).setdefault(operation.obj.id, {})
try:
instance = self.instances[operation.obj]
except KeyError:
if not in_prev_values:
instance = apps.get_model("mapdata", operation.obj.model).filter(pk=operation.obj.id).first()
else:
instance = None
if instance is not None:
operation.apply(values=values, instance=instance)

View file

@ -40,8 +40,8 @@ class DatabaseOverlayManager:
try: try:
with transaction.atomic(): with transaction.atomic():
manager = DatabaseOverlayManager(changes) manager = DatabaseOverlayManager(changes)
manager.changes.prefetch().apply()
overlay_state.manager = manager overlay_state.manager = manager
# todo: apply changes so far
yield manager yield manager
if not commit: if not commit:
raise InterceptAbortTransaction raise InterceptAbortTransaction
@ -50,6 +50,9 @@ class DatabaseOverlayManager:
finally: finally:
overlay_state.manager = None overlay_state.manager = None
def save_new_operations(self):
self.changes.operations.extend(self.new_operations)
@staticmethod @staticmethod
def get_model_field_values(instance: Model) -> FieldValuesDict: def get_model_field_values(instance: Model) -> FieldValuesDict:
return json.loads(serializers.serialize("json", [instance]))[0]["fields"] return json.loads(serializers.serialize("json", [instance]))[0]["fields"]
@ -59,8 +62,8 @@ class DatabaseOverlayManager:
pre_change_values = self.pre_change_values.pop(ref, None) pre_change_values = self.pre_change_values.pop(ref, None)
if pre_change_values: if pre_change_values:
self.changes.prev_values[ref] = pre_change_values self.changes.prev_values.setdefault(ref.model, {})[ref.id] = pre_change_values
self.changes.prev_reprs[ref] = str(instance) self.changes.prev_reprs.setdefault(ref.model, {})[ref.id] = str(instance)
return ref return ref
@ -68,7 +71,7 @@ class DatabaseOverlayManager:
if instance.pk is None: if instance.pk is None:
return return
ref = ObjectReference.from_instance(instance) ref = ObjectReference.from_instance(instance)
if ref not in self.pre_change_values and ref not in self.changes.prev_values: if ref not in self.pre_change_values and ref.id not in self.changes.prev_values.get(ref.model, {}):
self.pre_change_values[ref] = self.get_model_field_values( self.pre_change_values[ref] = self.get_model_field_values(
instance._meta.model.objects.get(pk=instance.pk) instance._meta.model.objects.get(pk=instance.pk)
) )

View file

@ -1,5 +1,6 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections import OrderedDict from collections import OrderedDict
from contextlib import contextmanager
from functools import wraps from functools import wraps
from typing import Optional from typing import Optional
@ -17,19 +18,52 @@ from django.utils.translation import gettext_lazy as _
from c3nav.editor.models import ChangeSet from c3nav.editor.models import ChangeSet
from c3nav.editor.overlay import DatabaseOverlayManager from c3nav.editor.overlay import DatabaseOverlayManager
from c3nav.mapdata.models import MapUpdate
from c3nav.mapdata.models.access import AccessPermission from c3nav.mapdata.models.access import AccessPermission
from c3nav.mapdata.models.base import SerializableMixin from c3nav.mapdata.models.base import SerializableMixin
from c3nav.mapdata.utils.cache.changes import changed_geometries
from c3nav.mapdata.utils.user import can_access_editor from c3nav.mapdata.utils.user import can_access_editor
@contextmanager
def maybe_lock_changeset_to_edit(request):
if request.changeset.pk:
with request.changeset.lock_to_edit(request=request) as changeset:
request.changeset = changeset
yield
else:
yield
def accesses_mapdata(func): def accesses_mapdata(func):
@wraps(func) @wraps(func)
def wrapped(request, *args, **kwargs): def wrapped(request, *args, **kwargs):
changes = None if request.changeset.direct_editing is None else request.changeset.changes writable_method = request.method in ("POST", "PUT")
with DatabaseOverlayManager.enable(changes, commit=request.changeset.direct_editing) as manager:
result = func(request, *args, **kwargs) if request.changeset.direct_editing:
print("operations", manager.new_operations) with MapUpdate.lock():
return result changed_geometries.reset()
with DatabaseOverlayManager.enable(changes=None, commit=writable_method) as manager:
result = func(request, *args, **kwargs)
if manager.new_operations:
if writable_method:
MapUpdate.objects.create(user=request.user, type='direct_edit')
else:
raise ValueError # todo: good error message, but this shouldn't happen
else:
with maybe_lock_changeset_to_edit(request=request):
with DatabaseOverlayManager.enable(changes=request.changeset.changes, commit=False) as manager:
result = func(request, *args, **kwargs)
if manager.new_operations:
manager.save_new_operations()
print(request.changeset.changes)
print('saving to changeset!!')
request.changeset.save()
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 wrapped return wrapped
@ -48,7 +82,8 @@ def sidebar_view(func=None, select_related=None, api_hybrid=False):
if not can_access_editor(request): if not can_access_editor(request):
raise PermissionDenied raise PermissionDenied
request.changeset = ChangeSet.get_for_request(request, select_related) if getattr(request, "changeset", None) is None:
request.changeset = ChangeSet.get_for_request(request, select_related)
if api: if api:
request.is_delete = request.method == 'DELETE' request.is_delete = request.method == 'DELETE'

View file

@ -38,24 +38,24 @@ def changeset_detail(request, pk):
if request.method == 'POST': if request.method == 'POST':
restore = request.POST.get('restore') restore = request.POST.get('restore')
if restore and restore.isdigit(): if restore and restore.isdigit():
with changeset.lock_to_edit(request) as changeset: raise NotImplementedError # todo: restore (no pun intended) this feature
if changeset.can_edit(request): # if request.changeset.can_edit(request):
try: # try:
changed_object = changeset.changed_objects_set.get(pk=restore) # changed_object = changeset.changed_objects_set.get(pk=restore)
except Exception: # except Exception:
pass # pass
else: # else:
try: # try:
changed_object.restore() # changed_object.restore()
messages.success(request, _('Object has been successfully restored.')) # messages.success(request, _('Object has been successfully restored.'))
except PermissionError: # except PermissionError:
messages.error(request, _('You cannot restore this object, because it depends on ' # messages.error(request, _('You cannot restore this object, because it depends on '
'a deleted object or it would violate a unique contraint.')) # 'a deleted object or it would violate a unique contraint.'))
#
else: # else:
messages.error(request, _('You can not edit changes on this change set.')) # messages.error(request, _('You can not edit changes on this change set.'))
#
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(request) as changeset:

View file

@ -128,7 +128,7 @@ def space_detail(request, level, pk):
def get_changeset_exceeded(request): def get_changeset_exceeded(request):
return request.user_permissions.max_changeset_changes <= request.changeset.changed_objects_count return request.user_permissions.max_changeset_changes <= len(request.changeset.changes.operations)
@etag(editor_etag_func) @etag(editor_etag_func)
@ -315,15 +315,14 @@ def edit(request, pk=None, model=None, level=None, space=None, on_top_of=None, e
) )
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: if request.changeset.can_edit(request): # todo: move this somewhere else
if changeset.can_edit(request): obj.delete()
obj.delete() else:
else: return APIHybridMessageRedirectResponse(
return APIHybridMessageRedirectResponse( level='error',
level='error', message=_('You can not edit changes on this changeset.'),
message=_('You can not edit changes on this changeset.'), redirect_to=request.path, status_code=403,
redirect_to=request.path, status_code=403, )
)
if model == Level: if model == Level:
if obj.on_top_of_id is not None: if obj.on_top_of_id is not None:
@ -360,28 +359,27 @@ def edit(request, pk=None, model=None, level=None, space=None, on_top_of=None, e
if on_top_of is not None: if on_top_of is not None:
obj.on_top_of = on_top_of obj.on_top_of = on_top_of
with request.changeset.lock_to_edit(request) as changeset: if request.changeset.can_edit(request): # todo: move this somewhere else
if changeset.can_edit(request): try:
try: obj.save()
obj.save() except IntegrityError:
except IntegrityError: error = APIHybridError(status_code=400, message=_('Duplicate entry.'))
error = APIHybridError(status_code=400, message=_('Duplicate entry.'))
else:
if form.redirect_slugs is not None:
for slug in form.add_redirect_slugs:
obj.redirects.create(slug=slug)
for slug in form.remove_redirect_slugs:
obj.redirects.filter(slug=slug).delete()
form.save_m2m()
return APIHybridMessageRedirectResponse(
level='success',
message=_('Object was successfully saved.'),
redirect_to=ctx['back_url']
)
else: else:
error = APIHybridError(status_code=403, message=_('You can not edit changes on this changeset.')) if form.redirect_slugs is not None:
for slug in form.add_redirect_slugs:
obj.redirects.create(slug=slug)
for slug in form.remove_redirect_slugs:
obj.redirects.filter(slug=slug).delete()
form.save_m2m()
return APIHybridMessageRedirectResponse(
level='success',
message=_('Object was successfully saved.'),
redirect_to=ctx['back_url']
)
else:
error = APIHybridError(status_code=403, message=_('You can not edit changes on this changeset.'))
else: else:
form = get_editor_form(model)(instance=obj, request=request, space_id=space_id, form = get_editor_form(model)(instance=obj, request=request, space_id=space_id,
@ -640,14 +638,13 @@ def graph_edit(request, level=None, space=None):
return redirect(request.path) return redirect(request.path)
if request.POST.get('delete_confirm') == '1': if request.POST.get('delete_confirm') == '1':
with request.changeset.lock_to_edit(request) as changeset: if request.changeset.can_edit(request): # todo: move this somewhere else
if changeset.can_edit(request): node.edges_from_here.all().delete()
node.edges_from_here.all().delete() node.edges_to_here.all().delete()
node.edges_to_here.all().delete() node.delete()
node.delete() else:
else: messages.error(request, _('You can not edit changes on this changeset.'))
messages.error(request, _('You can not edit changes on this changeset.')) return redirect(request.path)
return redirect(request.path)
messages.success(request, _('Graph Node was successfully deleted.')) messages.success(request, _('Graph Node was successfully deleted.'))
return redirect(request.path) return redirect(request.path)
return render(request, 'editor/delete.html', { return render(request, 'editor/delete.html', {
@ -677,13 +674,12 @@ def graph_edit(request, level=None, space=None):
active_node = None active_node = None
set_active_node = True set_active_node = True
else: else:
with request.changeset.lock_to_edit(request) as changeset: if request.changeset.can_edit(request): # todo: move this somewhere else
if changeset.can_edit(request): connect_nodes(request, active_node, clicked_node, edge_settings_form)
connect_nodes(request, active_node, clicked_node, edge_settings_form) active_node = clicked_node if edge_settings_form.cleaned_data['activate_next'] else None
active_node = clicked_node if edge_settings_form.cleaned_data['activate_next'] else None set_active_node = True
set_active_node = True else:
else: messages.error(request, _('You can not edit changes on this changeset.'))
messages.error(request, _('You can not edit changes on this changeset.'))
elif (clicked_node is None and clicked_position is not None and elif (clicked_node is None and clicked_position is not None and
active_node is None and space.geometry.contains(clicked_position)): active_node is None and space.geometry.contains(clicked_position)):
@ -692,16 +688,15 @@ def graph_edit(request, level=None, space=None):
messages.error(request, _('You can not add graph nodes because your changeset is full.')) messages.error(request, _('You can not add graph nodes because your changeset is full.'))
return redirect(request.path) return redirect(request.path)
with request.changeset.lock_to_edit(request) as changeset: if request.changeset.can_edit(request): # todo: move this somewhere else
if changeset.can_edit(request): node = GraphNode(space=space, geometry=clicked_position)
node = GraphNode(space=space, geometry=clicked_position) node.save()
node.save() messages.success(request, _('New graph node created.'))
messages.success(request, _('New graph node created.'))
active_node = None active_node = None
set_active_node = True set_active_node = True
else: else:
messages.error(request, _('You can not edit changes on this changeset.')) messages.error(request, _('You can not edit changes on this changeset.'))
if set_active_node: if set_active_node:
connections = {} connections = {}