-
Notifications
You must be signed in to change notification settings - Fork 30
/
operations.py
774 lines (627 loc) · 34.2 KB
/
operations.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
import json
from copy import copy
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.db import models, transaction
from modelcluster.models import ClusterableModel, get_all_child_relations
from treebeard.mp_tree import MP_Node
from wagtail.core.models import Page
from .field_adapters import adapter_registry
from .locators import get_locator_for_model
from .models import get_base_model, get_base_model_for_path, get_model_for_path
from django.utils.functional import cached_property
# Models which should be updated to their latest version when encountered in object references
default_update_related_models = ['wagtailimages.image']
UPDATE_RELATED_MODELS = [
model_label.lower()
for model_label in getattr(settings, 'WAGTAILTRANSFER_UPDATE_RELATED_MODELS', default_update_related_models)
]
# Models which should NOT be created in response to being encountered in object references
default_no_follow_models = ['wagtailcore.page']
NO_FOLLOW_MODELS = [
model_label.lower()
for model_label in getattr(settings, 'WAGTAILTRANSFER_NO_FOLLOW_MODELS', default_no_follow_models)
]
class CircularDependencyException(Exception):
pass
class Objective:
"""
An objective identifies an individual database object that we want to exist on the destination
site as a result of this import. If must_update is true, it should additionally be updated to
the latest version that exists on the source site.
"""
def __init__(self, model, source_id, context, must_update=False):
self.model = model
self.source_id = source_id
self.context = context
self.must_update = must_update
# Whether this object exists at the destination; None indicates 'not checked yet'
self._exists_at_destination = None
self._destination_id = None
def _find_at_destination(self):
"""
Check if this object exists at the destination and populate self._exists_at_destination,
self._destination_id and self.context.destination_ids_by_source accordingly
"""
# see if there's already an entry in destination_ids_by_source
try:
self._destination_id = self.context.destination_ids_by_source[(self.model, self.source_id)]
self._exists_at_destination = True
return
except KeyError:
pass
# look up uid for this item;
# the export API is expected to supply the id->uid mapping for all referenced objects,
# so this lookup should always succeed (and if it doesn't, we leave the KeyError uncaught)
uid = self.context.uids_by_source[(self.model, self.source_id)]
destination_object = get_locator_for_model(self.model).find(uid)
if destination_object is None:
self._exists_at_destination = False
else:
self._destination_id = destination_object.pk
self._exists_at_destination = True
self.context.destination_ids_by_source[(self.model, self.source_id)] = self._destination_id
@property
def exists_at_destination(self):
if self._exists_at_destination is None:
self._find_at_destination()
return self._exists_at_destination
def __eq__(self, other):
return (
isinstance(other, Objective)
and (self.model, self.source_id, self.must_update) == (other.model, other.source_id, other.must_update)
)
def __hash__(self):
return hash((self.model, self.source_id, self.must_update))
class ImportContext:
"""
Persistent state required when running the import; this includes mappings from the source
site's IDs to the destination site's IDs, which will be added to as the import proceeds
(for example, once a page is created at the destination, we add its ID mapping so that we
can handle references to it that appear in other imported pages).
"""
def __init__(self):
# A mapping of objects on the source site to their IDs on the destination site.
# Keys are tuples of (model_class, source_id); values are destination IDs.
# model_class must be the highest concrete model in the inheritance tree - i.e.
# Page, not BlogPage
self.destination_ids_by_source = {}
# a mapping of objects on the source site to their UIDs.
# Keys are tuples of (model_class, source_id); values are UIDs.
self.uids_by_source = {}
# Mapping of source_urls to instances of ImportedFile
self.imported_files_by_source_url = {}
class ImportPlanner:
def __init__(self, root_page_source_pk=None, destination_parent_id=None, model=None):
if root_page_source_pk or destination_parent_id:
self.import_type = 'page'
self.root_page_source_pk = int(root_page_source_pk)
if destination_parent_id is None:
self.destination_parent_id = None
else:
self.destination_parent_id = int(destination_parent_id)
elif model:
self.import_type = 'model'
self.model = model
else:
raise NotImplementedError("Missing page kwargs or specified model kwarg")
self.context = ImportContext()
self.objectives = set()
# objectives that have not yet been converted into tasks
self.unhandled_objectives = set()
# a mapping of objects on the source site to their field data
self.object_data_by_source = {}
# A task describes something that needs to happen to reach an objective, e.g.
# "create page 123". This is represented as a tuple of (model_class, source_id, action),
# where action is 'create' or 'update'
# tasks that will be performed in this import
self.tasks = set()
# tasks that require us to fetch object data before we can convert them into operations.
self.postponed_tasks = set()
# objects we need to fetch to satisfy postponed_tasks, expressed as (model_class, source_id)
self.missing_object_data = set()
# objects which we have already requested and not got back, so they must be missing on the
# source too
self.really_missing_object_data = set()
# set of operations to be performed in this import.
# An operation is an object with a `run` method which accomplishes the task.
# It also has a list of dependencies - source IDs of objects that must exist at the
# destination before the `run` method can be called.
self.operations = set()
# Mapping from (model, source_id) to an operation that creates that object. If the object
# already exists at the destination, the value is None. This will be used to solve
# dependencies between operations, where a database record cannot be created/updated until
# an object that it references exists at the destination site
self.resolutions = {}
# Mapping from tasks to operations that perform the task. This will be used to identify
# cases where the same task arises multiple times over the course of planning the import,
# and prevent us from running the same database operation multiple times as a result
self.task_resolutions = {}
# Set of (model, source_id) tuples for items that have been explicitly selected for import
# (i.e. named in the 'ids_for_import' section of the API response), as opposed to pulled in
# through related object references
self.base_import_ids = set()
# Set of (model, source_id) tuples for items that we failed to create, either because
# NO_FOLLOW_MODELS told us not to, or because they did not exist on the source site.
self.failed_creations = set()
@classmethod
def for_page(cls, source, destination):
return cls(root_page_source_pk=source, destination_parent_id=destination)
@classmethod
def for_model(cls, model):
return cls(model=model)
def add_json(self, json_data):
"""
Add JSON data to the import plan. The data is a dict consisting of:
'ids_for_import': a list of [source_id, model_classname] pairs for the set of objects
explicitly requested to be imported. (For example, in a page import, this is the set of
descendant pages of the selected root page.)
'mappings': a list of mappings between UIDs and the object IDs that exist on the source
site, each mapping being expressed as the list
['appname.model_classname', source_id, uid].
All object references that appear in 'objects' (as foreign keys, rich text, streamfield
or anything else) must have an entry in this mappings table, unless the API on the
source side is able to determine with certaintly that the destination importer will not
use it (e.g. it is the parent page of the imported root page).
'objects': a list of dicts containing full object data used for creating or updating object
records. This may include additional objects beyond the ones listed in ids_for_import,
to assist in resolving related objects.
"""
data = json.loads(json_data)
# for each ID in the import list, add to base_import_ids as an object explicitly selected
# for import
for model_path, source_id in data['ids_for_import']:
model = get_base_model_for_path(model_path)
self.base_import_ids.add((model, source_id))
# add source id -> uid mappings to the uids_by_source dict, and add objectives
# for importing referenced models
for model_path, source_id, jsonish_uid in data['mappings']:
model = get_base_model_for_path(model_path)
uid = get_locator_for_model(model).uid_from_json(jsonish_uid)
self.context.uids_by_source[(model, source_id)] = uid
base_import = (model, source_id) in self.base_import_ids
if base_import or model_path not in NO_FOLLOW_MODELS:
objective = Objective(
model, source_id, self.context,
must_update=(base_import or model_path in UPDATE_RELATED_MODELS)
)
# add to the set of objectives that need handling
self._add_objective(objective)
# add object data to the object_data_by_source dict
for obj_data in data['objects']:
self._add_object_data_to_lookup(obj_data)
# retry tasks that were previously postponed due to missing object data
self._retry_tasks()
# Process all unhandled objectives - which may trigger new objectives as dependencies of
# the resulting operations - until no unhandled objectives remain
while self.unhandled_objectives:
objective = self.unhandled_objectives.pop()
self._handle_objective(objective)
def _add_object_data_to_lookup(self, obj_data):
model = get_base_model_for_path(obj_data['model'])
source_id = obj_data['pk']
self.object_data_by_source[(model, source_id)] = obj_data
def _add_objective(self, objective):
# add to the set of objectives that need handling, unless it's one we've already seen
# (in which case it's either in the queue to be handled, or has been handled already).
# An objective to update a model supercedes an objective to ensure it exists
if not objective.must_update:
update_objective = copy(objective)
update_objective.must_update = True
else:
update_objective = objective
if update_objective in self.objectives:
# We're already updating the model, so this objective isn't relevant
return
elif objective.must_update:
# We're going to add a new objective to update the model
# so we should remove any existing objective that doesn't update the model
no_update_objective = copy(objective)
no_update_objective.must_update = False
self.objectives.discard(no_update_objective)
self.unhandled_objectives.discard(no_update_objective)
self.objectives.add(objective)
self.unhandled_objectives.add(objective)
def _handle_objective(self, objective):
if not objective.exists_at_destination:
# object does not exist locally - create it if we're allowed to do so, i.e.
# it is in the set of objects explicitly selected for import, or it is a related object
# that we have not been blocked from following by NO_FOLLOW_MODELS
if (
objective.model._meta.label_lower in NO_FOLLOW_MODELS
and (objective.model, objective.source_id) not in self.base_import_ids
):
# NO_FOLLOW_MODELS prevents us from creating this object
self.failed_creations.add((objective.model, objective.source_id))
else:
task = ('create', objective.model, objective.source_id)
self._handle_task(task)
else:
# object already exists at the destination, so any objects referencing it can go ahead
# without being blocked by this task
self.resolutions[(objective.model, objective.source_id)] = None
if objective.must_update:
task = ('update', objective.model, objective.source_id)
self._handle_task(task)
def _handle_task(self, task):
"""
Attempt to convert a task into a corresponding operation.May fail if we do not yet have
the object data for this object, in which case it will be added to postponed_tasks
"""
# It's possible that over the course of planning the import, we will encounter multiple
# tasks relating to the same object. For example, a page may be part of the selected
# subtree to be imported, and, separately, be referenced from another page - both of
# these will trigger an 'update' or 'create' task for that page (according to whether
# it already exists or not).
# Given that the only defined task types are 'update' and 'create', and the choice between
# these depends ONLY on whether the object previously existed at the destination or not,
# we can be confident that all of the tasks we encounter for a given object will be the
# same type.
# Therefore, if we find an existing entry for this task in task_resolutions, we know that
# we've already handled this task and updated the ImportPlanner state accordingly
# (including `task_resolutions`, `resolutions` and `operations`), and should quit now
# rather than create duplicate database operations.
if task in self.task_resolutions:
return
action, model, source_id = task
try:
object_data = self.object_data_by_source[(model, source_id)]
except KeyError:
# Cannot complete this task during this pass; request the missing object data,
# unless we've already tried that
if (model, source_id) in self.really_missing_object_data:
# object data apparently doesn't exist on the source site either, so give up on
# this object entirely
if action == 'create':
self.failed_creations.add((model, source_id))
else:
# need to postpone this until we have the object data
self.postponed_tasks.add(task)
self.missing_object_data.add((model, source_id))
return
# retrieve the specific model for this object
specific_model = get_model_for_path(object_data['model'])
if issubclass(specific_model, MP_Node):
if object_data['parent_id'] is None:
# This is the root node; populate destination_ids_by_source so that we use the
# existing root node for any references to it, rather than creating a new one
destination_id = specific_model.get_first_root_node().pk
self.context.destination_ids_by_source[(model, source_id)] = destination_id
# No operation to be performed for this task
operation = None
elif action == 'create':
if issubclass(specific_model, Page) and source_id == self.root_page_source_pk:
# this is the root page of the import; ignore the parent ID in the source
# record and import at the requested destination instead
operation = CreateTreeModel(specific_model, object_data, self.destination_parent_id)
else:
operation = CreateTreeModel(specific_model, object_data)
else: # action == 'update'
destination_id = self.context.destination_ids_by_source[(model, source_id)]
obj = specific_model.objects.get(pk=destination_id)
operation = UpdateModel(obj, object_data)
else:
# non-tree model
if action == 'create':
operation = CreateModel(specific_model, object_data)
else: # action == 'update'
destination_id = self.context.destination_ids_by_source[(model, source_id)]
obj = specific_model.objects.get(pk=destination_id)
operation = UpdateModel(obj, object_data)
if issubclass(specific_model, ClusterableModel):
# Process child object relations for this item
# and add objectives to ensure that they're all updated to their newest versions
for rel in get_all_child_relations(specific_model):
related_base_model = get_base_model(rel.related_model)
child_uids = set()
for child_obj_pk in object_data['fields'][rel.name]:
# Add an objective for handling the child object. Regardless of whether
# this is a 'create' or 'update' task, we want the child objects to be at
# their most up-to-date versions, so set the objective to 'must update'
self._add_objective(
Objective(related_base_model, child_obj_pk, self.context, must_update=True)
)
if operation is not None:
self.operations.add(operation)
if action == 'create':
# For 'create' actions, record this operation in `resolutions`, so that any operations
# that identify this object as a dependency know that this operation has to happen
# first.
# (Alternatively, the operation can be None, and that's fine too: it means that we've
# been able to populate destination_ids_by_source with no further action, and so the
# dependent operation has nothing to wait for.)
# For 'update' actions, this doesn't matter, since we can happily fill in the
# destination ID wherever it's being referenced, regardless of whether that object has
# completed its update or not; in this case, we would have already set the resolution
# to None during _handle_objective.
self.resolutions[(model, source_id)] = operation
self.task_resolutions[task] = operation
if operation is not None:
for model, source_id, is_hard_dep in operation.dependencies:
self._add_objective(
Objective(model, source_id, self.context, must_update=(model._meta.label_lower in UPDATE_RELATED_MODELS))
)
for instance in operation.deletions(self.context):
self.operations.add(DeleteModel(instance))
def _retry_tasks(self):
"""
Retry tasks that were previously postponed due to missing object data
"""
previous_postponed_tasks = self.postponed_tasks
self.postponed_tasks = set()
for key in self.missing_object_data:
# The latest JSON packet should have populated object_data_by_source with any
# previously missing objects, if they exist at the source at all - so any that are
# still missing must also be missing at the source
if key not in self.object_data_by_source:
self.really_missing_object_data.add(key)
self.missing_object_data.clear()
for task in previous_postponed_tasks:
self._handle_task(task)
def run(self):
if self.unhandled_objectives or self.postponed_tasks:
raise ImproperlyConfigured("Cannot run import until all dependencies are resoved")
# filter out unsatisfiable operations
statuses = {}
satisfiable_operations = [
op for op in self.operations
if self._check_satisfiable(op, statuses)
]
# arrange operations into an order that satisfies dependencies
operation_order = []
for operation in satisfiable_operations:
self._add_to_operation_order(operation, operation_order, [operation])
# run operations in order
with transaction.atomic():
for operation in operation_order:
operation.run(self.context)
# pages must only have revisions saved after all child objects have been updated, imported, or deleted, otherwise
# they will capture outdated versions of child objects in the revision
for operation in operation_order:
if isinstance(operation.instance, Page):
operation.instance.save_revision()
def _check_satisfiable(self, operation, statuses):
# Check whether the given operation's dependencies are satisfiable. statuses is a dict of
# previous results - keys are (model, id) pairs and the value is:
# True - dependency is satisfiable
# False - dependency is not satisfiable
# None - the satisfiability check is currently in progress -
# if we encounter this we have found a circular dependency.
for (model, id, is_hard_dep) in operation.dependencies:
if not is_hard_dep:
continue # ignore soft dependencies here
try:
# Look for a previous result for this dependency
result = statuses[(model, id)]
if result is False or result is None:
# Dependency is known to be unsatisfiable, or we have just found a circular
# dependency
return False
except KeyError:
# No previous result - need to determine it now.
# Mark this as 'in progress', to spot circular dependencies
statuses[(model, id)] = None
# Look for a resolution for this dependency (i.e. an Operation that creates it)
try:
resolution = self.resolutions[(model, id)]
except KeyError:
# If the resolution is missing, it *should* be for one of the reasons we've
# accounted for and logged in failed_creations. Otherwise, that's a bug, and
# we should fail loudly now
if (model, id) not in self.failed_creations:
raise
# The dependency is not satisfiable (for a reason we know about in
# failed_creations), and so the overall operation fails too
statuses[(model, id)] = False
return False
if resolution is None:
# the dependency was already satisfied, with no further action required
statuses[(model, id)] = True
else:
# resolution is an Operation that we now need to check recursively
result = self._check_satisfiable(resolution, statuses)
statuses[(model, id)] = result
if result is False:
return False
# We've got through all the dependencies without anything failing. Yay!
return True
def _add_to_operation_order(self, operation, operation_order, path):
# path is the sequence of dependencies we've followed so far, starting from the top-level
# operation picked from satisfiable_operations in `run`, to find one we can add
if operation in operation_order:
# already in list - no need to add
return
for dep_model, dep_source_id, dep_is_hard in operation.dependencies:
# look up the resolution for this dependency (= an Operation or None)
try:
resolution = self.resolutions[(dep_model, dep_source_id)]
except KeyError:
# There is no resolution for this dependency - for example, it's a rich text link
# to a page outside of the subtree being imported (and NO_FOLLOW_MODELS tells us
# not to recursively import it).
# If everything is working properly, this should be a case we already encountered
# during task / objective solving and logged in failed_creations.
assert (dep_model, dep_source_id) in self.failed_creations
# Also, it should be a soft dependency, since we've eliminated unsatisfiable hard
# hard dependencies during _check_satisfiable.
assert not dep_is_hard
# Since this is a soft dependency, we can (and must!) leave it unsatisfied.
# Abandon this dependency and move on to the next in the list
continue
if resolution is None:
# dependency is already satisfied with no further action
continue
elif resolution in path:
# The resolution for this dependency is an operation that's currently under
# consideration, so we have a circular dependency. This will be one that we can
# resolve by breaking a soft dependency - a circular dependency consisting of
# only hard dependencies would have been caught by _check_satisfiable. So, raise
# an exception to be propagated back up the chain until we're back to a caller that
# can handle it gracefully - namely, a soft dependency that can be left
# unsatisfied.
raise CircularDependencyException()
else:
try:
# recursively add the operation that we're depending on here
self._add_to_operation_order(resolution, operation_order, path + [resolution])
except CircularDependencyException:
if dep_is_hard:
# we can't resolve the circular dependency by breaking the chain here,
# so propagate it to the next level up
raise
else:
# this is a soft dependency, and we can break the chain by leaving this
# unsatisfied. Abandon this dependency and move on to the next in the list
continue
operation_order.append(operation)
class Operation:
"""
Represents a single database operation to be performed during the data import. This operation
may depend on other operations to be completed first - for example, creating a page's parent
page. The import process works by building a dependency graph of operations (which may involve
multiple calls to the source site's API as new dependencies are encountered, making it
necessary to retrieve more data), finding a valid sequence to run them in, and running them all
within a transaction.
"""
def run(self, context):
raise NotImplementedError
@property
def dependencies(self):
"""
A set of (model, source_id, is_hard) tuples that should exist at the destination before we
can import this page.
is_hard is a boolean - if True, then the object MUST exist in order for this operation to
succeed; if False, then the operation can still complete without it (albeit possibly
with broken links).
"""
return set()
def deletions(self, context):
# the set of objects that must be deleted when we import this object
return set()
class SaveOperationMixin:
"""
Mixin class to handle the common logic of CreateModel and UpdateModel operations, namely:
* Writing the field data stored in `self.object_data` to the model instance `self.instance` -
which may be an existing instance (in the case of an update) or a new unsaved one (in the
case of a creation)
* Remapping any IDs of related ids that appear in this field data
* Declaring these related objects as dependencies
Requires subclasses to define `self.model`, `self.instance` and `self.object_data`.
"""
@cached_property
def base_model(self):
return get_base_model(self.model)
def _populate_fields(self, context):
for field in self.model._meta.get_fields():
try:
value = self.object_data['fields'][field.name]
except KeyError:
continue
adapter = adapter_registry.get_field_adapter(field)
if adapter:
adapter.populate_field(self.instance, value, context)
def _populate_many_to_many_fields(self, context):
save_needed = False
# for ManyToManyField, this must be done after saving so that the instance has an id.
# for ParentalManyToManyField, this could be done before, but doing both together avoids additional
# complexity as the method is identical
for field in self.model._meta.get_fields():
if isinstance(field, models.ManyToManyField):
try:
value = self.object_data['fields'][field.name]
except KeyError:
continue
target_model = get_base_model(field.related_model)
# translate list of source site ids to destination site ids
new_value = []
for pk in value:
try:
new_pk = context.destination_ids_by_source[(target_model, pk)]
except KeyError:
continue
new_value.append(new_pk)
getattr(self.instance, field.get_attname()).set(new_value)
save_needed = True
if save_needed:
# _save() for creating a page may attempt to re-add it as a child, so the instance (assumed to be already
# in the tree) is saved directly
self.instance.save()
def _save(self, context):
self.instance.save()
@cached_property
def dependencies(self):
# the set of objects that must be created before we can import this object
deps = super().dependencies
for field in self.model._meta.get_fields():
val = self.object_data['fields'].get(field.name)
adapter = adapter_registry.get_field_adapter(field)
if adapter:
deps.update(adapter.get_dependencies(val))
return deps
def deletions(self, context):
# the set of objects that must be deleted when we import this object
deletions = super().deletions(context)
for field in self.model._meta.get_fields():
val = self.object_data['fields'].get(field.name)
adapter = adapter_registry.get_field_adapter(field)
if adapter:
deletions.update(adapter.get_object_deletions(self.instance, val, context))
return deletions
class CreateModel(SaveOperationMixin, Operation):
def __init__(self, model, object_data):
self.model = model
self.object_data = object_data
self.instance = self.model()
def run(self, context):
# Create object and populate its attributes from field_data
self._populate_fields(context)
self._save(context)
self._populate_many_to_many_fields(context)
# record the UID for the newly created page
uid = context.uids_by_source[(self.base_model, self.object_data['pk'])]
get_locator_for_model(self.base_model).attach_uid(self.instance, uid)
# Also add it to destination_ids_by_source mapping
source_pk = self.object_data['pk']
context.destination_ids_by_source[(self.base_model, source_pk)] = self.instance.pk
class CreateTreeModel(CreateModel):
"""
Create an instance of a model that is structured in a Treebeard tree
For example: Pages and Collections
"""
def __init__(self, model, object_data, destination_parent_id=None):
super().__init__(model, object_data)
self.destination_parent_id = destination_parent_id
@cached_property
def dependencies(self):
deps = super().dependencies
if self.destination_parent_id is None:
# need to ensure parent page is imported before this one
deps.add(
(get_base_model(self.model), self.object_data['parent_id'], True),
)
return deps
def _save(self, context):
if self.destination_parent_id is None:
# The destination parent ID was not known at the time this operation was built,
# but should now exist in the page ID mapping
source_parent_id = self.object_data['parent_id']
self.destination_parent_id = context.destination_ids_by_source[(get_base_model(self.model), source_parent_id)]
parent = get_base_model(self.model).objects.get(id=self.destination_parent_id)
# Add the page to the database as a child of parent
parent.add_child(instance=self.instance)
class UpdateModel(SaveOperationMixin, Operation):
def __init__(self, instance, object_data):
self.instance = instance
self.model = type(instance)
self.object_data = object_data
def run(self, context):
self._populate_fields(context)
self._save(context)
self._populate_many_to_many_fields(context)
class DeleteModel(Operation):
def __init__(self, instance):
self.instance = instance
def run(self, context):
self.instance.delete()
# TODO: work out whether we need to check for incoming FK relations with on_delete=CASCADE
# and declare those as 'must delete this first' dependencies