2024-10-29 12:21:10 +01:00
|
|
|
import operator
|
|
|
|
from functools import reduce
|
2024-09-26 13:19:29 +02:00
|
|
|
from itertools import chain
|
2024-11-05 13:33:16 +01:00
|
|
|
from typing import Type, Any, Union
|
2024-09-26 13:19:29 +02:00
|
|
|
|
|
|
|
from django.apps import apps
|
2024-11-05 13:33:16 +01:00
|
|
|
from django.db.models import Model, Q
|
|
|
|
from django.db.models.fields.reverse_related import ManyToOneRel, OneToOneRel
|
2024-10-29 12:14:48 +01:00
|
|
|
from pydantic.config import ConfigDict
|
2024-09-26 13:19:29 +02:00
|
|
|
|
|
|
|
from c3nav.api.schema import BaseSchema
|
|
|
|
from c3nav.editor.operations import DatabaseOperationCollection, CreateObjectOperation, UpdateObjectOperation, \
|
2024-10-29 12:14:48 +01:00
|
|
|
DeleteObjectOperation, ClearManyToManyOperation, FieldValuesDict, ObjectReference, PreviousObjectCollection, \
|
2024-11-05 13:33:16 +01:00
|
|
|
DatabaseOperation, ObjectID, FieldName, ModelName
|
2024-09-26 13:19:29 +02:00
|
|
|
from c3nav.mapdata.fields import I18nField
|
|
|
|
|
|
|
|
|
|
|
|
class ChangedManyToMany(BaseSchema):
|
|
|
|
cleared: bool = False
|
2024-11-05 13:33:16 +01:00
|
|
|
added: list[ObjectID] = []
|
|
|
|
removed: list[ObjectID] = []
|
2024-09-26 13:19:29 +02:00
|
|
|
|
|
|
|
|
|
|
|
class ChangedObject(BaseSchema):
|
|
|
|
obj: ObjectReference
|
|
|
|
titles: dict[str, str] | None
|
|
|
|
created: bool = False
|
|
|
|
deleted: bool = False
|
|
|
|
fields: FieldValuesDict = {}
|
2024-11-05 13:33:16 +01:00
|
|
|
m2m_changes: dict[FieldName, ChangedManyToMany] = {}
|
2024-09-26 13:19:29 +02:00
|
|
|
|
|
|
|
|
2024-10-29 12:14:48 +01:00
|
|
|
class OperationDependencyObjectExists(BaseSchema):
|
|
|
|
obj: ObjectReference
|
|
|
|
nullable: bool
|
|
|
|
|
|
|
|
|
|
|
|
class OperationDependencyUniqueValue(BaseSchema):
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
|
|
|
|
model: str
|
2024-11-05 13:33:16 +01:00
|
|
|
field: FieldName
|
2024-10-29 12:14:48 +01:00
|
|
|
value: Any
|
|
|
|
nullable: bool
|
|
|
|
|
|
|
|
|
|
|
|
class OperationDependencyNoProtectedReference(BaseSchema):
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
|
|
|
|
obj: ObjectReference
|
|
|
|
|
|
|
|
|
|
|
|
OperationDependency = Union[
|
|
|
|
OperationDependencyObjectExists,
|
|
|
|
OperationDependencyNoProtectedReference,
|
|
|
|
OperationDependencyUniqueValue,
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
class SingleOperationWithDependencies(BaseSchema):
|
2024-11-08 09:44:34 +01:00
|
|
|
uid: tuple
|
2024-10-29 12:14:48 +01:00
|
|
|
operation: DatabaseOperation
|
|
|
|
dependencies: set[OperationDependency] = set()
|
|
|
|
|
2024-11-05 13:33:16 +01:00
|
|
|
@property
|
|
|
|
def main_operation(self) -> DatabaseOperation:
|
|
|
|
return self.operation
|
|
|
|
|
2024-10-29 12:14:48 +01:00
|
|
|
|
|
|
|
class MergableOperationsWithDependencies(BaseSchema):
|
|
|
|
children: list[SingleOperationWithDependencies]
|
|
|
|
|
2024-10-29 12:21:10 +01:00
|
|
|
@property
|
|
|
|
def dependencies(self) -> set[OperationDependency]:
|
|
|
|
return reduce(operator.or_, (c.dependencies for c in self.children), set())
|
|
|
|
|
2024-11-05 13:33:16 +01:00
|
|
|
@property
|
|
|
|
def main_operation(self) -> DatabaseOperation:
|
|
|
|
return self.children[0].operation
|
|
|
|
|
2024-10-29 12:14:48 +01:00
|
|
|
|
|
|
|
OperationWithDependencies = Union[
|
|
|
|
SingleOperationWithDependencies,
|
|
|
|
MergableOperationsWithDependencies,
|
|
|
|
]
|
|
|
|
|
|
|
|
|
2024-11-05 13:33:16 +01:00
|
|
|
class FoundObjectReference(BaseSchema):
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
|
|
|
|
obj: ObjectReference
|
|
|
|
field: FieldName
|
|
|
|
on_delete: str
|
|
|
|
|
|
|
|
|
2024-10-29 12:14:48 +01:00
|
|
|
class DummyValue:
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
2024-11-07 15:19:10 +01:00
|
|
|
class OperationSituation(BaseSchema):
|
|
|
|
# operations done so far
|
|
|
|
operations: list[DatabaseOperation] = []
|
|
|
|
|
|
|
|
# remaining operations still to do
|
|
|
|
remaining_operations_with_dependencies: list[OperationWithDependencies] = []
|
|
|
|
|
|
|
|
# objects that still need to be created before some remaining operation (or that were simply deleted in this run)
|
|
|
|
missing_objects: dict[ModelName, set[ObjectID]] = {}
|
|
|
|
|
|
|
|
# unique values relevant for these operations that are currently not free
|
|
|
|
values_to_clear: dict[ModelName, dict[FieldName: set]] = {}
|
|
|
|
|
|
|
|
# references to objects that need to be removed for in this run
|
|
|
|
obj_references: dict[ModelName, dict[ObjectID, set[FoundObjectReference]]] = {}
|
|
|
|
|
|
|
|
|
2024-09-26 13:19:29 +02:00
|
|
|
class ChangedObjectCollection(BaseSchema):
|
|
|
|
"""
|
|
|
|
A collection of ChangedObject instances, sorted by model and id.
|
|
|
|
Also stores a PreviousObjectCollection for comparison with the current state.
|
|
|
|
Iterable as a list of ChangedObject instances.
|
|
|
|
"""
|
|
|
|
prev: PreviousObjectCollection = PreviousObjectCollection()
|
2024-11-05 13:33:16 +01:00
|
|
|
objects: dict[ModelName, dict[ObjectID, ChangedObject]] = {}
|
2024-09-26 13:19:29 +02:00
|
|
|
|
|
|
|
def __iter__(self):
|
2024-09-26 17:08:41 +02:00
|
|
|
yield from chain(*(objects.values() for model, objects in self.objects.items()))
|
|
|
|
|
|
|
|
def __len__(self):
|
|
|
|
return sum(len(v) for v in self.objects.values())
|
2024-09-26 13:19:29 +02:00
|
|
|
|
|
|
|
def add_operations(self, operations: DatabaseOperationCollection):
|
|
|
|
"""
|
|
|
|
Add the given operations, creating/updating changed objects to represent the resulting state.
|
|
|
|
"""
|
2024-09-26 17:08:41 +02:00
|
|
|
# todo: if something is being changed back, remove it from thingy?
|
|
|
|
self.prev.add_other(operations.prev)
|
|
|
|
for operation in operations:
|
2024-09-26 13:19:29 +02:00
|
|
|
changed_object = self.objects.setdefault(operation.obj.model, {}).get(operation.obj.id, None)
|
|
|
|
if changed_object is None:
|
|
|
|
changed_object = ChangedObject(obj=operation.obj,
|
|
|
|
titles=self.prev.get(operation.obj).titles)
|
|
|
|
self.objects[operation.obj.model][operation.obj.id] = changed_object
|
|
|
|
if isinstance(operation, CreateObjectOperation):
|
|
|
|
changed_object.created = True
|
|
|
|
changed_object.fields.update(operation.fields)
|
|
|
|
elif isinstance(operation, UpdateObjectOperation):
|
|
|
|
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:
|
|
|
|
changed_object.fields[field_name] = {
|
|
|
|
lang: val for lang, val in {**changed_object.fields[field_name], **value}.items()
|
|
|
|
}
|
|
|
|
else:
|
|
|
|
changed_object.fields[field_name] = value
|
|
|
|
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)
|
|
|
|
|
2024-09-26 19:12:39 +02:00
|
|
|
def clean_and_complete_prev(self):
|
2024-11-05 13:33:16 +01:00
|
|
|
ids: dict[ModelName, set[ObjectID]] = {}
|
2024-09-26 19:12:39 +02:00
|
|
|
for model_name, changed_objects in self.objects.items():
|
|
|
|
ids.setdefault(model_name, set()).update(set(changed_objects.keys()))
|
|
|
|
model = apps.get_model("mapdata", model_name)
|
2024-11-05 13:33:16 +01:00
|
|
|
relations: dict[FieldName, Type[Model]] = {field.name: field.related_model
|
|
|
|
for field in model.get_fields() if field.is_relation}
|
2024-09-26 19:12:39 +02:00
|
|
|
for obj in changed_objects.values():
|
|
|
|
for field_name, value in obj.fields.items():
|
|
|
|
related_model = relations.get(field_name, None)
|
|
|
|
if related_model is None or value is None:
|
|
|
|
continue
|
|
|
|
ids.setdefault(related_model._meta.model_name, set()).add(value)
|
|
|
|
for field_name, field_changes in obj.m2m_changes.items():
|
|
|
|
related_model = relations[field_name]
|
|
|
|
if field_changes.added or field_changes.removed:
|
|
|
|
ids.setdefault(related_model._meta.model_name, set()).update(field_changes.added)
|
|
|
|
ids.setdefault(related_model._meta.model_name, set()).update(field_changes.removed)
|
|
|
|
# todo: move this to some kind of "usage explanation" function, implement rest of this
|
|
|
|
|
2024-09-26 13:19:29 +02:00
|
|
|
@property
|
|
|
|
def as_operations(self) -> DatabaseOperationCollection:
|
2024-10-29 12:14:48 +01:00
|
|
|
current_objects = {}
|
|
|
|
for model_name, changed_objects in self.objects.items():
|
|
|
|
model = apps.get_model("mapdata", model_name)
|
|
|
|
current_objects[model_name] = {obj.pk: obj for obj in model.objects.filter(pk__in=changed_objects.keys())}
|
|
|
|
|
|
|
|
operations_with_dependencies: list[OperationWithDependencies] = []
|
|
|
|
for model_name, changed_objects in self.objects.items():
|
|
|
|
model = apps.get_model("mapdata", model_name)
|
|
|
|
|
|
|
|
for changed_obj in changed_objects.values():
|
|
|
|
if changed_obj.deleted:
|
|
|
|
if changed_obj.created:
|
|
|
|
continue
|
|
|
|
operations_with_dependencies.append(
|
|
|
|
SingleOperationWithDependencies(
|
2024-11-08 09:44:34 +01:00
|
|
|
uid=(changed_obj.obj, "delete"),
|
2024-10-29 12:14:48 +01:00
|
|
|
operation=DeleteObjectOperation(obj=changed_obj.obj),
|
|
|
|
dependencies={OperationDependencyNoProtectedReference(obj=changed_obj.obj)}
|
|
|
|
),
|
|
|
|
)
|
|
|
|
continue
|
|
|
|
|
|
|
|
initial_fields = dict()
|
|
|
|
obj_operations: list[OperationWithDependencies] = []
|
|
|
|
for name, value in changed_obj.fields.items():
|
|
|
|
if value is None:
|
|
|
|
initial_fields[name] = None
|
|
|
|
continue
|
|
|
|
field = model._meta.get_field(name)
|
|
|
|
dependencies = set()
|
|
|
|
# todo: prev
|
|
|
|
if field.is_relation:
|
|
|
|
dependencies.add(OperationDependencyObjectExists(obj=ObjectReference(
|
|
|
|
model=field.related_model._meta.model_name,
|
|
|
|
id=value,
|
|
|
|
)))
|
|
|
|
if field.unique:
|
|
|
|
dependencies.add(OperationDependencyUniqueValue(obj=ObjectReference(
|
|
|
|
model=model._meta.model_name,
|
|
|
|
field=name,
|
|
|
|
value=value,
|
|
|
|
)))
|
|
|
|
|
|
|
|
if not dependencies:
|
|
|
|
initial_fields[name] = None
|
|
|
|
continue
|
|
|
|
|
|
|
|
initial_fields[name] = DummyValue
|
|
|
|
obj_operations.append(SingleOperationWithDependencies(
|
2024-11-08 09:44:34 +01:00
|
|
|
uid=(changed_obj.obj, f"field_{name}"),
|
2024-10-29 12:14:48 +01:00
|
|
|
operation=UpdateObjectOperation(obj=changed_obj.obj, fields={name: value}),
|
|
|
|
dependencies=dependencies
|
|
|
|
))
|
|
|
|
|
|
|
|
obj_operations.insert(0, SingleOperationWithDependencies(
|
|
|
|
operation=(CreateObjectOperation if changed_obj.created else UpdateObjectOperation)(
|
2024-11-08 09:44:34 +01:00
|
|
|
uid=(changed_obj.obj, f"main"),
|
2024-10-29 12:14:48 +01:00
|
|
|
obj=changed_obj.obj,
|
|
|
|
fields=initial_fields,
|
|
|
|
)
|
|
|
|
))
|
|
|
|
|
|
|
|
if len(obj_operations) == 1:
|
|
|
|
operations_with_dependencies.append(obj_operations[0])
|
|
|
|
else:
|
|
|
|
operations_with_dependencies.append(MergableOperationsWithDependencies(operations=obj_operations))
|
|
|
|
|
|
|
|
from pprint import pprint
|
|
|
|
pprint(operations_with_dependencies)
|
|
|
|
|
2024-11-07 15:19:10 +01:00
|
|
|
start_situation = OperationSituation(
|
|
|
|
remaining_operations_with_dependencies = operations_with_dependencies,
|
|
|
|
)
|
|
|
|
|
2024-11-06 14:55:07 +01:00
|
|
|
# categorize operations to collect data for simulation/solving and problem detection
|
2024-11-07 15:19:10 +01:00
|
|
|
missing_objects: dict[ModelName, set[ObjectID]] = {} # objects that need to exist before
|
2024-11-05 13:33:16 +01:00
|
|
|
for operation in operations_with_dependencies:
|
|
|
|
main_operation = operation.main_operation
|
|
|
|
if isinstance(main_operation, DeleteObjectOperation):
|
2024-11-07 15:19:10 +01:00
|
|
|
missing_objects.setdefault(main_operation.obj.model, set()).add(main_operation.obj.id)
|
2024-11-05 13:33:16 +01:00
|
|
|
|
|
|
|
if isinstance(main_operation, UpdateObjectOperation):
|
2024-11-07 15:19:10 +01:00
|
|
|
missing_objects.setdefault(main_operation.obj.model, set()).add(main_operation.obj.id)
|
2024-11-05 13:33:16 +01:00
|
|
|
|
|
|
|
for dependency in operation.dependencies:
|
|
|
|
if isinstance(dependency, OperationDependencyObjectExists):
|
2024-11-07 15:19:10 +01:00
|
|
|
missing_objects.setdefault(dependency.obj.model, set()).add(dependency.obj.id)
|
2024-11-06 14:55:07 +01:00
|
|
|
elif isinstance(dependency, OperationDependencyUniqueValue):
|
2024-11-07 15:19:10 +01:00
|
|
|
start_situation.values_to_clear.setdefault(
|
|
|
|
dependency.obj.model, {}
|
|
|
|
).setdefault(dependency.field, set()).add(dependency.value)
|
2024-11-06 14:55:07 +01:00
|
|
|
# todo: check for duplicate unique values
|
2024-11-05 13:33:16 +01:00
|
|
|
|
|
|
|
# let's find which objects that need to exist before actually exist
|
2024-11-07 15:19:10 +01:00
|
|
|
for model, ids in missing_objects.items():
|
2024-11-05 13:33:16 +01:00
|
|
|
model_cls = apps.get_model('mapdata', model)
|
|
|
|
ids_found = set(model_cls.objects.filter(pk__in=ids).values_list('pk', flat=True))
|
2024-11-07 15:19:10 +01:00
|
|
|
start_situation.missing_objects = {id_ for id_ in ids if id_ not in ids_found}
|
2024-11-05 13:33:16 +01:00
|
|
|
|
|
|
|
# let's find which protected references objects we want to delete have
|
|
|
|
potential_fields: dict[ModelName, dict[FieldName, dict[ModelName, set[ObjectID]]]] = {}
|
2024-11-07 15:19:10 +01:00
|
|
|
for model, ids in missing_objects.items():
|
2024-11-05 13:33:16 +01:00
|
|
|
for field in apps.get_model('mapdata', model)._meta.get_fields():
|
|
|
|
if isinstance(field, (ManyToOneRel, OneToOneRel)) or field.model._meta.app_label != "mapdata":
|
|
|
|
continue
|
|
|
|
potential_fields.setdefault(field.related_model._meta.model_name,
|
|
|
|
{}).setdefault(field.field.attname, {})[model] = ids
|
|
|
|
|
|
|
|
# collect all references
|
|
|
|
for model, fields in potential_fields.items():
|
|
|
|
model_cls = apps.get_model('mapdata', model)
|
|
|
|
q = Q()
|
|
|
|
targets_reverse: dict[FieldName, dict[ObjectID, ModelName]] = {}
|
|
|
|
for field_name, targets in fields.items():
|
|
|
|
ids = reduce(operator.or_, targets.values(), set())
|
|
|
|
q |= Q(**{f'{field_name}__in': ids})
|
|
|
|
targets_reverse[field_name] = dict(chain(*(((id_, target_model) for id_, in target_ids)
|
|
|
|
for target_model, target_ids in targets)))
|
|
|
|
for result in model_cls.objects.filter(q).values("id", *fields.keys()):
|
|
|
|
source_ref = ObjectReference(model=model, id=result.pop("id"))
|
|
|
|
for field, target_id in result.items():
|
|
|
|
target_model = targets_reverse[field][target_id]
|
2024-11-07 15:19:10 +01:00
|
|
|
start_situation.obj_references.setdefault(target_model, {}).setdefault(target_id, set()).add(
|
2024-11-05 13:33:16 +01:00
|
|
|
FoundObjectReference(obj=source_ref, field=field,
|
|
|
|
on_delete=model_cls._meta.get_field(field).on_delete.__name__)
|
|
|
|
)
|
|
|
|
|
2024-10-29 12:14:48 +01:00
|
|
|
# todo: continue here
|
|
|
|
|
|
|
|
return DatabaseOperationCollection()
|