2024-08-26 14:27:25 +02:00
|
|
|
import copy
|
2024-08-26 11:49:59 +02:00
|
|
|
import datetime
|
2024-08-26 14:27:25 +02:00
|
|
|
import json
|
|
|
|
from dataclasses import dataclass
|
2024-08-26 11:49:59 +02:00
|
|
|
from typing import TypeAlias, Any, Annotated, Literal, Union
|
|
|
|
|
2024-08-26 14:27:25 +02:00
|
|
|
from django.apps import apps
|
|
|
|
from django.core import serializers
|
|
|
|
from django.db.models import Model
|
2024-08-26 11:49:59 +02:00
|
|
|
from django.utils import timezone
|
|
|
|
from pydantic.config import ConfigDict
|
|
|
|
from pydantic.fields import Field
|
|
|
|
from pydantic.types import Discriminator
|
|
|
|
|
|
|
|
from c3nav.api.schema import BaseSchema
|
2024-08-26 16:45:16 +02:00
|
|
|
from c3nav.mapdata.fields import I18nField
|
2024-08-26 11:49:59 +02:00
|
|
|
|
|
|
|
FieldValuesDict: TypeAlias = dict[str, Any]
|
|
|
|
|
|
|
|
|
|
|
|
class ObjectReference(BaseSchema):
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
model: str
|
|
|
|
id: int
|
|
|
|
|
|
|
|
@classmethod
|
2024-08-26 14:27:25 +02:00
|
|
|
def from_instance(cls, instance: Model):
|
2024-08-26 11:49:59 +02:00
|
|
|
"""
|
|
|
|
This method will not convert the ID yet!
|
|
|
|
"""
|
|
|
|
return cls(model=instance._meta.model_name, id=instance.pk)
|
|
|
|
|
|
|
|
|
|
|
|
class BaseOperation(BaseSchema):
|
|
|
|
obj: ObjectReference
|
|
|
|
datetime: Annotated[datetime.datetime, Field(default_factory=timezone.now)]
|
|
|
|
|
2024-08-26 14:27:25 +02:00
|
|
|
def apply(self, values: FieldValuesDict, instance: Model) -> Model:
|
|
|
|
raise NotImplementedError
|
|
|
|
|
2024-08-26 11:49:59 +02:00
|
|
|
|
|
|
|
class CreateObjectOperation(BaseOperation):
|
|
|
|
type: Literal["create"] = "create"
|
|
|
|
fields: FieldValuesDict
|
|
|
|
|
2024-08-26 14:27:25 +02:00
|
|
|
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
|
|
|
|
|
2024-08-26 11:49:59 +02:00
|
|
|
|
|
|
|
class UpdateObjectOperation(BaseOperation):
|
|
|
|
type: Literal["update"] = "update"
|
|
|
|
fields: FieldValuesDict
|
|
|
|
|
2024-08-26 14:27:25 +02:00
|
|
|
def apply(self, values: FieldValuesDict, instance: Model) -> Model:
|
2024-08-26 19:48:22 +02:00
|
|
|
model = apps.get_model('mapdata', self.obj.model)
|
|
|
|
for field_name, value in self.fields.items():
|
|
|
|
field = model._meta.get_field(field_name)
|
|
|
|
if isinstance(field, I18nField) and field_name in self.fields:
|
2024-08-26 19:50:03 +02:00
|
|
|
values[field_name] = {lang: val for lang, val in {**values[field_name], **value}.items()
|
|
|
|
if val is not None}
|
2024-08-26 19:48:22 +02:00
|
|
|
else:
|
|
|
|
values[field_name] = value
|
2024-08-26 14:27:25 +02:00
|
|
|
instance = list(serializers.deserialize("json", json.dumps([{
|
|
|
|
"model": f"mapdata.{self.obj.model}",
|
|
|
|
"pk": self.obj.id,
|
2024-08-26 16:45:16 +02:00
|
|
|
"fields": values,
|
2024-08-26 14:27:25 +02:00
|
|
|
}])))[0]
|
|
|
|
instance.save(save_m2m=False)
|
|
|
|
return instance.object
|
|
|
|
|
2024-08-26 11:49:59 +02:00
|
|
|
|
|
|
|
class DeleteObjectOperation(BaseOperation):
|
|
|
|
type: Literal["delete"] = "delete"
|
|
|
|
|
2024-08-26 14:27:25 +02:00
|
|
|
def apply(self, values: FieldValuesDict, instance: Model) -> Model:
|
|
|
|
instance.delete()
|
|
|
|
return instance
|
|
|
|
|
2024-08-26 11:49:59 +02:00
|
|
|
|
|
|
|
class UpdateManyToManyOperation(BaseOperation):
|
|
|
|
type: Literal["m2m_add"] = "m2m_update"
|
|
|
|
field: str
|
|
|
|
add_values: set[int] = set()
|
|
|
|
remove_values: set[int] = set()
|
|
|
|
|
2024-08-26 14:27:25 +02:00
|
|
|
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
|
|
|
|
|
2024-08-26 11:49:59 +02:00
|
|
|
|
|
|
|
class ClearManyToManyOperation(BaseOperation):
|
|
|
|
type: Literal["m2m_clear"] = "m2m_clear"
|
|
|
|
field: str
|
|
|
|
|
2024-08-26 14:27:25 +02:00
|
|
|
def apply(self, values: FieldValuesDict, instance: Model) -> Model:
|
|
|
|
values[self.field] = []
|
|
|
|
getattr(instance, self.field).clear()
|
|
|
|
return instance
|
|
|
|
|
2024-08-26 11:49:59 +02:00
|
|
|
|
|
|
|
DatabaseOperation = Annotated[
|
|
|
|
Union[
|
|
|
|
CreateObjectOperation,
|
|
|
|
UpdateObjectOperation,
|
|
|
|
DeleteObjectOperation,
|
|
|
|
UpdateManyToManyOperation,
|
|
|
|
ClearManyToManyOperation,
|
|
|
|
],
|
|
|
|
Discriminator("type"),
|
|
|
|
]
|
|
|
|
|
|
|
|
|
2024-08-26 14:58:37 +02:00
|
|
|
class ChangedManyToMany(BaseSchema):
|
|
|
|
cleared: bool = False
|
|
|
|
added: list[str] = []
|
|
|
|
removed: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
class ChangedObject(BaseSchema):
|
|
|
|
obj: ObjectReference
|
2024-08-26 16:55:22 +02:00
|
|
|
titles: dict[str, str]
|
2024-08-26 14:58:37 +02:00
|
|
|
created: bool = False
|
|
|
|
deleted: bool = False
|
|
|
|
fields: FieldValuesDict = {}
|
|
|
|
m2m_changes: dict[str, ChangedManyToMany] = {}
|
|
|
|
|
|
|
|
|
2024-08-26 11:49:59 +02:00
|
|
|
class CollectedChanges(BaseSchema):
|
2024-08-26 16:55:22 +02:00
|
|
|
prev_titles: dict[str, dict[int, dict[str, str]]] = {}
|
2024-08-26 14:27:25 +02:00
|
|
|
prev_values: dict[str, dict[int, FieldValuesDict]] = {}
|
|
|
|
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)
|
|
|
|
|
2024-08-26 14:58:37 +02:00
|
|
|
@property
|
|
|
|
def changed_objects(self) -> list[ChangedObject]:
|
|
|
|
objects = {}
|
|
|
|
for operation in self.operations:
|
|
|
|
changed_object = objects.get(operation.obj, None)
|
|
|
|
if changed_object is None:
|
|
|
|
changed_object = ChangedObject(obj=operation.obj,
|
2024-08-26 16:55:22 +02:00
|
|
|
titles=self.prev_titles[operation.obj.model][operation.obj.id])
|
2024-08-26 14:58:37 +02:00
|
|
|
objects[operation.obj] = changed_object
|
|
|
|
if isinstance(operation, CreateObjectOperation):
|
|
|
|
changed_object.created = True
|
|
|
|
changed_object.fields.update(operation.fields)
|
|
|
|
elif isinstance(operation, UpdateObjectOperation):
|
2024-08-26 16:45:16 +02:00
|
|
|
model = apps.get_model('mapdata', operation.obj.model)
|
|
|
|
for field_name, value in operation.fields.items():
|
|
|
|
field = model._meta.get_field(field_name)
|
|
|
|
if isinstance(field, I18nField) and field_name in changed_object.fields:
|
2024-08-26 19:50:03 +02:00
|
|
|
changed_object.fields[field_name] = {
|
|
|
|
lang: val for lang, val in {**changed_object.fields[field_name], **value}.items()
|
|
|
|
}
|
2024-08-26 16:45:16 +02:00
|
|
|
else:
|
|
|
|
changed_object.fields[field_name] = value
|
2024-08-26 14:58:37 +02:00
|
|
|
elif isinstance(operation, DeleteObjectOperation):
|
|
|
|
changed_object.deleted = False
|
|
|
|
else:
|
|
|
|
changed_m2m = changed_object.m2m_changes.get(operation.field, None)
|
|
|
|
if changed_m2m is None:
|
|
|
|
changed_m2m = ChangedManyToMany()
|
|
|
|
changed_object.m2m_changes[operation.field] = changed_m2m
|
|
|
|
if isinstance(operation, ClearManyToManyOperation):
|
|
|
|
changed_m2m.cleared = True
|
|
|
|
changed_m2m.added = []
|
|
|
|
changed_m2m.removed = []
|
|
|
|
else:
|
|
|
|
changed_m2m.added = sorted((set(changed_m2m.added) | operation.add_values)
|
|
|
|
- operation.remove_values)
|
|
|
|
changed_m2m.removed = sorted((set(changed_m2m.removed) - operation.add_values)
|
|
|
|
| operation.remove_values)
|
|
|
|
return list(objects.values())
|
|
|
|
|
2024-08-26 14:27:25 +02:00
|
|
|
|
|
|
|
@dataclass
|
|
|
|
class CollectedChangesPrefetch:
|
|
|
|
changes: CollectedChanges
|
|
|
|
instances: dict[ObjectReference, Model]
|
|
|
|
|
|
|
|
def apply(self):
|
2024-08-26 15:01:40 +02:00
|
|
|
# todo: what if unique constraint error occurs?
|
2024-08-26 14:27:25 +02:00
|
|
|
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)
|
|
|
|
|