utils.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  1. import logging
  2. from collections import defaultdict, deque
  3. from django.apps import apps
  4. from django.contrib.contenttypes.models import ContentType
  5. from django.db import router, transaction
  6. from django.db.models import Q
  7. from django.utils.translation import gettext as _
  8. from dcim.constants import MODULE_TOKEN
  9. logger = logging.getLogger(f'netbox.{__name__}')
  10. def inherit_module_token(position, parent_positions):
  11. """
  12. Resolve a single {module} token in a bay position by inheriting from the position
  13. one level deeper in a module bay hierarchy. Returns position unchanged unless
  14. parent_positions is non-empty and position contains {module}, in which case the
  15. token is substituted with parent_positions[-1].
  16. Used by resolve_position_chain(), the single inheritance implementation shared by
  17. get_module_bay_positions() and the module move planner.
  18. """
  19. if parent_positions and MODULE_TOKEN in position:
  20. return position.replace(MODULE_TOKEN, parent_positions[-1])
  21. return position
  22. def get_module_bay_raw_positions(module_bay):
  23. """
  24. Given a module bay, traverse up the module hierarchy and return the stored
  25. (unresolved) bay position strings from root to leaf.
  26. Raises ValueError if the module bay hierarchy contains a cycle.
  27. """
  28. positions = []
  29. visited = set()
  30. while module_bay:
  31. if module_bay.pk in visited:
  32. raise ValueError(_("Module bay hierarchy contains a cycle."))
  33. visited.add(module_bay.pk)
  34. positions.append(module_bay.position or '')
  35. module_bay = module_bay.module.module_bay if module_bay.module else None
  36. positions.reverse()
  37. return positions
  38. def resolve_position_chain(raw_positions):
  39. """
  40. Apply leaf-to-root {module} token inheritance over a root-to-leaf list of raw bay
  41. positions: each position inherits from the resolved position one level deeper, and
  42. the leaf's own token is never resolved. Shared by get_module_bay_positions() and
  43. the module move planner so a planned chain always equals what a fresh walk
  44. computes once the planned positions are stored.
  45. """
  46. resolved = []
  47. for position in reversed(raw_positions):
  48. resolved.append(inherit_module_token(position, resolved))
  49. resolved.reverse()
  50. return resolved
  51. def get_module_bay_positions(module_bay):
  52. """
  53. Given a module bay, traverse up the module hierarchy and return a list of bay
  54. position strings from root to leaf, resolving any {module} tokens in each
  55. position using the parent position (position inheritance).
  56. Raises ValueError if the module bay hierarchy contains a cycle.
  57. """
  58. return resolve_position_chain(get_module_bay_raw_positions(module_bay))
  59. def resolve_module_placeholder(value, positions):
  60. """
  61. Resolve {module} placeholder tokens in a string using the given
  62. list of module bay positions (ordered root to leaf).
  63. A single {module} token resolves to the leaf (immediate parent) bay's position.
  64. Multiple tokens must match the tree depth and resolve level-by-level.
  65. Returns the resolved string.
  66. Raises ValueError if token count is greater than 1 and doesn't match tree depth.
  67. """
  68. if MODULE_TOKEN not in value:
  69. return value
  70. token_count = value.count(MODULE_TOKEN)
  71. if token_count == 1:
  72. return value.replace(MODULE_TOKEN, positions[-1])
  73. if token_count == len(positions):
  74. for pos in positions:
  75. value = value.replace(MODULE_TOKEN, pos, 1)
  76. return value
  77. raise ValueError(
  78. _("Cannot install module with placeholder values in a module bay tree "
  79. "{level} levels deep but {tokens} placeholders given.").format(
  80. level=len(positions), tokens=token_count
  81. )
  82. )
  83. def compile_path_node(ct_id, object_id):
  84. return f'{ct_id}:{object_id}'
  85. def decompile_path_node(repr):
  86. ct_id, object_id = repr.split(':')
  87. return int(ct_id), int(object_id)
  88. def object_to_path_node(obj):
  89. """
  90. Return a representation of an object suitable for inclusion in a CablePath path. Node representation is in the
  91. form <ContentType ID>:<Object ID>.
  92. """
  93. ct = ContentType.objects.get_for_model(obj)
  94. return compile_path_node(ct.pk, obj.pk)
  95. def path_node_to_object(repr):
  96. """
  97. Given the string representation of a path node, return the corresponding instance. If the object no longer
  98. exists, return None.
  99. """
  100. ct_id, object_id = decompile_path_node(repr)
  101. ct = ContentType.objects.get_for_id(ct_id)
  102. return ct.model_class().objects.filter(pk=object_id).first()
  103. def _get_cablepath_origin_objects(nodes):
  104. """
  105. Fetch fresh origins in bulk by content type, indexed by their encoded path nodes.
  106. """
  107. ids_by_type = defaultdict(set)
  108. for node in nodes:
  109. type_id, object_id = decompile_path_node(node)
  110. ids_by_type[type_id].add(object_id)
  111. objects = {}
  112. for type_id, object_ids in ids_by_type.items():
  113. model = ContentType.objects.get_for_id(type_id).model_class()
  114. if model is not None:
  115. # Recovery also reads link, so loading its direct relations avoids another query for every origin.
  116. relations = [
  117. field.name for field in model._meta.fields
  118. if field.many_to_one and field.name in ('cable', 'wireless_link')
  119. ]
  120. queryset = model.objects.select_related(*relations) if relations else model.objects.all()
  121. for object_id, obj in queryset.in_bulk(object_ids).items():
  122. objects[compile_path_node(type_id, object_id)] = obj
  123. return objects
  124. def create_cablepaths(objects):
  125. """
  126. Trace and replace paths for the supplied origins, recovering co-origins whose current paths are removed.
  127. The supplied objects may span multiple connectors on one link. Grouping and recovery share one worklist.
  128. :param objects: Iterable of cabled objects (e.g. Interfaces)
  129. """
  130. from dcim.models import CablePath, CableTermination, PathEndpoint
  131. origin_groups = _group_cablepath_origins(objects)
  132. pending = deque(origin_groups)
  133. if not pending:
  134. return
  135. processed = set()
  136. # Keep a savepoint so callers can catch tracing failures inside an outer transaction.
  137. # This provides rollback, but does not serialize concurrent rebuilds.
  138. with transaction.atomic(using=router.db_for_write(CablePath)):
  139. while pending:
  140. # Do not let recovery replace origins already rebuilt by this call.
  141. origins = [
  142. obj for obj in pending.popleft()
  143. if object_to_path_node(obj) not in processed
  144. ]
  145. if not origins:
  146. continue
  147. # Trace first, so an unsupported topology raises before anything is deleted
  148. path = CablePath.from_origin(origins)
  149. nodes = {object_to_path_node(obj) for obj in origins}
  150. processed.update(nodes)
  151. # `overlap` takes the encoded nodes directly, and matches nothing for an empty set
  152. for old_path in CablePath.objects.filter(_nodes__overlap=list(nodes)):
  153. # `_nodes` matches a node anywhere in a path, including as another path's destination
  154. if not old_path.path or not nodes.intersection(old_path.path[0]):
  155. continue
  156. # Recover only origins whose current path is being deleted, keeping different ends separate
  157. remaining = [node for node in old_path.path[0] if node not in processed]
  158. # Refresh immediately before this deletion, since earlier iterations can change _path references.
  159. origin_objects = _get_cablepath_origin_objects(remaining)
  160. eligible = []
  161. ids_by_type = defaultdict(set)
  162. for node in remaining:
  163. origin = origin_objects.get(node)
  164. if origin is None:
  165. continue
  166. # Absence of the back-reference is not evidence that a pointer was cleared
  167. if isinstance(origin, PathEndpoint) and origin._path_id != old_path.pk:
  168. continue
  169. if origin.link:
  170. eligible.append((node, origin))
  171. type_id, object_id = decompile_path_node(node)
  172. ids_by_type[type_id].add(object_id)
  173. # Sharing a cable is not sharing a cable end, so partition on the authoritative row
  174. rows = {}
  175. if ids_by_type:
  176. query = Q()
  177. for type_id, object_ids in ids_by_type.items():
  178. query |= Q(termination_type_id=type_id, termination_id__in=object_ids)
  179. for type_id, object_id, cable_id, cable_end, connector in CableTermination.objects.filter(
  180. query
  181. ).order_by().values_list(
  182. 'termination_type_id', 'termination_id', 'cable_id', 'cable_end', 'connector'
  183. ):
  184. rows[compile_path_node(type_id, object_id)] = (cable_id, cable_end, connector)
  185. by_end = defaultdict(list)
  186. for node, origin in eligible:
  187. row = rows.get(node)
  188. if row is None:
  189. # A wireless link or a channel subinterface owns no row, so it stands alone
  190. by_end[node].append(origin)
  191. continue
  192. cable_id, cable_end, connector = row
  193. # Tracing reads the cached cable, and only profiled peer lookup reads the cached end
  194. if origin.cable_id != cable_id or (
  195. origin.cable.profile and origin.cable_end != cable_end
  196. ):
  197. logger.warning(
  198. f'Skipping recovery of {origin._meta.label} #{origin.pk}: '
  199. f'cached cable #{origin.cable_id} end {origin.cable_end!r} does not match '
  200. f'termination cable #{cable_id} end {cable_end!r}'
  201. )
  202. continue
  203. # Group on the authoritative connector, without saving the endpoint
  204. origin.cable_connector = connector
  205. by_end[(cable_id, cable_end)].append(origin)
  206. for end_origins in by_end.values():
  207. pending.extend(_group_cablepath_origins(end_origins))
  208. old_path.delete()
  209. if path:
  210. path.save()
  211. def _group_cablepath_origins(objects):
  212. """
  213. Expand channelized interfaces and group origins on one link by connector, keeping channels separate.
  214. """
  215. from dcim.models import Interface
  216. # Expand any channelized interface into its channel subinterfaces. A channelized parent originates no path of its
  217. # own; instead, each channel subinterface traces independently from the single connector position it occupies.
  218. # Plain (non-channelized) origins pass through unchanged, keeping this expansion re-entrant, so a caller
  219. # which already holds channel subinterfaces does not re-expand them.
  220. expanded = []
  221. for obj in objects:
  222. if isinstance(obj, Interface) and obj.channels:
  223. expanded.extend(obj.child_interfaces.filter(channel_id__isnull=False, cable__isnull=False))
  224. else:
  225. expanded.append(obj)
  226. origin_groups = []
  227. connectors = defaultdict(list)
  228. for obj in expanded:
  229. if isinstance(obj, Interface) and obj.channel_id:
  230. origin_groups.append([obj])
  231. else:
  232. connectors[obj.cable_connector].append(obj)
  233. origin_groups.extend(connectors.values())
  234. return origin_groups
  235. def get_cable_end_terminations(cable_ends):
  236. """
  237. Return the current termination objects for each (cable ID, end) key, preserving connector order.
  238. Use the CableTermination's connector for grouping. The prefetch covers the physical cable relation only,
  239. so a link resolved through a wireless link still costs a query. This does not repair persisted endpoint
  240. fields, and inconsistent cached links or positions can still prevent tracing.
  241. """
  242. from dcim.models import CableTermination
  243. groups = {key: [] for key in cable_ends}
  244. if not groups:
  245. return groups
  246. cables_by_end = defaultdict(list)
  247. for cable_id, cable_end in groups:
  248. cables_by_end[cable_end].append(cable_id)
  249. query = Q()
  250. # Cable ends are A or B, so this needs at most two clauses regardless of the number of cables.
  251. for cable_end, cable_ids in cables_by_end.items():
  252. query |= Q(cable_id__in=cable_ids, cable_end=cable_end)
  253. terminations = CableTermination.objects.filter(query).order_by(
  254. 'cable_id', 'cable_end', 'connector', 'pk'
  255. ).prefetch_related('termination__cable')
  256. for termination in terminations:
  257. # A stale GenericForeignKey can still reference an object which no longer exists.
  258. if (obj := termination.termination) is not None:
  259. # Partition this fetched instance by its authoritative connector, without saving the endpoint.
  260. obj.cable_connector = termination.connector
  261. groups[(termination.cable_id, termination.cable_end)].append(obj)
  262. return groups
  263. def get_cablepath_origin_groups(objects):
  264. """
  265. Resolve originating objects to current cable ends in bulk. Channels and uncabled origins remain singletons.
  266. """
  267. from dcim.models import CableTermination, Interface
  268. objects = {object_to_path_node(obj): obj for obj in objects}
  269. if not objects:
  270. return {}
  271. ids_by_type = defaultdict(list)
  272. for node, obj in objects.items():
  273. if not (isinstance(obj, Interface) and obj.channel_id):
  274. type_id, object_id = decompile_path_node(node)
  275. ids_by_type[type_id].append(object_id)
  276. # Resolve membership from CableTermination, never from the endpoint's cached cable/end fields.
  277. keys = {}
  278. if ids_by_type:
  279. query = Q()
  280. for type_id, object_ids in ids_by_type.items():
  281. query |= Q(termination_type_id=type_id, termination_id__in=object_ids)
  282. for type_id, object_id, cable_id, cable_end in CableTermination.objects.filter(query).values_list(
  283. 'termination_type_id', 'termination_id', 'cable_id', 'cable_end'
  284. ):
  285. keys[compile_path_node(type_id, object_id)] = (cable_id, cable_end)
  286. cable_ends = get_cable_end_terminations(keys.values())
  287. groups = {}
  288. for node, obj in objects.items():
  289. if node in keys:
  290. groups[keys[node]] = cable_ends[keys[node]]
  291. else:
  292. groups[node] = [obj]
  293. return groups
  294. def rebuild_paths(terminations):
  295. """
  296. Rebuild paths traversing the given nodes from their origins' current cable-end membership.
  297. """
  298. from dcim.models import CablePath
  299. nodes = [object_to_path_node(obj) for obj in terminations]
  300. if not nodes:
  301. return
  302. # Snapshot the entire operation before replacement can retire another candidate. The transaction includes
  303. # candidate deletion as well as all replacements, and a failure must restore both. It does not serialize rebuilds.
  304. with transaction.atomic(using=router.db_for_write(CablePath)):
  305. cable_paths = list(CablePath.objects.filter(_nodes__overlap=nodes))
  306. origin_nodes = dict.fromkeys(
  307. node for cable_path in cable_paths for node in (cable_path.path[0] if cable_path.path else ())
  308. )
  309. origin_objects = _get_cablepath_origin_objects(origin_nodes)
  310. origins = [origin_objects[node] for node in origin_nodes if node in origin_objects]
  311. origin_groups = get_cablepath_origin_groups(origins)
  312. # A historical hop can name a parent which now originates paths through its channels.
  313. affected_nodes = set()
  314. for origin in origins:
  315. expanded_groups = _group_cablepath_origins([origin])
  316. for group in expanded_groups:
  317. affected_nodes.update(object_to_path_node(obj) for obj in group)
  318. # Current membership determines each complete group, but only candidate origins determine its scope.
  319. # Do not retrace unrelated connectors on the same cable end.
  320. rebuild_inputs = []
  321. scheduled = set()
  322. for current_origins in origin_groups.values():
  323. selected_origins = []
  324. current_groups = _group_cablepath_origins(current_origins)
  325. for group in current_groups:
  326. group_nodes = {object_to_path_node(obj) for obj in group}
  327. if group_nodes & affected_nodes and not group_nodes.issubset(scheduled):
  328. selected_origins.extend(group)
  329. # A parent's channels can also appear as individual candidate origins.
  330. scheduled.update(group_nodes)
  331. if selected_origins:
  332. # Keep one call per end so all requested groups precede recovery in the same worklist.
  333. rebuild_inputs.append(selected_origins)
  334. # Use the model method to clear current endpoint references, including those on disconnected origins.
  335. for cable_path in cable_paths:
  336. cable_path.delete()
  337. for origins in rebuild_inputs:
  338. create_cablepaths(origins)
  339. def rebuild_cable_paths(cable):
  340. """
  341. Delete and rebuild every CablePath affected by the given Cable, tracing freshly from the Cable's current
  342. terminations and from the origins of the affected paths. Used when a Cable's connectivity must be reconciled
  343. without its own save() having traced it: the channelization of a terminated interface has changed, or the Cable
  344. was written by a process which bypasses save().
  345. """
  346. from dcim.choices import CableEndChoices
  347. from dcim.models import CablePath, CableTermination, PathEndpoint
  348. with transaction.atomic(using=router.db_for_write(CablePath)):
  349. a_terminations, b_terminations = [], []
  350. for ct in CableTermination.objects.filter(cable=cable).prefetch_related('termination'):
  351. if ct.cable_end == CableEndChoices.SIDE_A:
  352. a_terminations.append(ct.termination)
  353. else:
  354. b_terminations.append(ct.termination)
  355. # Every path traversing the Cable, plus those traversing a termination which is not itself a path endpoint:
  356. # the latter may not reach the Cable yet (e.g. an incomplete path through a pass-through port which this
  357. # Cable completes).
  358. affected = {cp.pk: cp for cp in CablePath.objects.filter(_nodes__contains=cable)}
  359. for termination in (*a_terminations, *b_terminations):
  360. if not isinstance(termination, PathEndpoint):
  361. affected.update({cp.pk: cp for cp in CablePath.objects.filter(_nodes__contains=termination)})
  362. # Record each affected path's originating node(s) before deleting it. These are kept as compiled path
  363. # nodes; resolving them to objects is deferred to the paths which actually need restoring.
  364. origin_keys = {tuple(cp.path[0]) for cp in affected.values()}
  365. # Delete existing paths individually so each clears its `_path` back-reference on the originating endpoints.
  366. for cp in affected.values():
  367. cp.delete()
  368. # Trace from the Cable's own terminations first, so that a channelized origin is expanded into its channel
  369. # subinterfaces exactly once
  370. for nodes in (a_terminations, b_terminations):
  371. if nodes and isinstance(nodes[0], PathEndpoint):
  372. create_cablepaths(nodes)
  373. retraced = {tuple(cp.path[0]) for cp in CablePath.objects.filter(_nodes__contains=cable)}
  374. # Restore the affected paths which merely passed through the Cable: those originate elsewhere, so the
  375. # tracing above cannot reproduce them.
  376. for key in origin_keys - retraced:
  377. nodes = [obj for node in key if (obj := path_node_to_object(node))]
  378. if not nodes:
  379. continue
  380. # A path endpoint terminating this Cable belongs to the tracing above: that it produced no path
  381. # means the origin no longer has one (e.g. a channel subinterface moved to another parent).
  382. if any(isinstance(obj, PathEndpoint) and obj.cable_id == cable.pk for obj in nodes):
  383. continue
  384. # Nor restore an origin whose path has already been traced through another Cable
  385. if key in {tuple(cp.path[0]) for cp in CablePath.objects.filter(_nodes__contains=nodes[0])}:
  386. continue
  387. create_cablepaths(nodes)
  388. def update_interface_parents(device, interface_templates, module=None):
  389. """
  390. Used for device and module instantiation. Iterates all InterfaceTemplates with a parent assigned and applies it to
  391. the actual interfaces. Must run after all interfaces have been instantiated (so that every parent interface exists)
  392. and before update_interface_bridges() (so that channel subinterfaces validate against a populated parent).
  393. """
  394. Interface = apps.get_model('dcim', 'Interface')
  395. for interface_template in interface_templates.exclude(parent=None):
  396. interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module))
  397. interface.parent = Interface.objects.get(
  398. device=device,
  399. name=interface_template.parent.resolve_name(module=module)
  400. )
  401. interface.full_clean()
  402. interface.save()
  403. def update_interface_bridges(device, interface_templates, module=None):
  404. """
  405. Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned
  406. and applies it to the actual interfaces.
  407. """
  408. Interface = apps.get_model('dcim', 'Interface')
  409. for interface_template in interface_templates.exclude(bridge=None):
  410. interface = Interface.objects.get(
  411. device=device,
  412. name=interface_template.resolve_name(module=module, device=device)
  413. )
  414. if interface_template.bridge:
  415. interface.bridge = Interface.objects.get(
  416. device=device,
  417. name=interface_template.bridge.resolve_name(module=module, device=device)
  418. )
  419. interface.full_clean()
  420. interface.save()
  421. def create_port_mappings(device, device_or_module_type, module=None):
  422. """
  423. Replicate all front/rear port mappings from a DeviceType or ModuleType to the given device.
  424. """
  425. from dcim.models import FrontPort, PortMapping, RearPort
  426. templates = device_or_module_type.port_mappings.prefetch_related('front_port', 'rear_port')
  427. # Cache front & rear ports for efficient lookups by name
  428. front_ports = {
  429. fp.name: fp for fp in FrontPort.objects.filter(device=device)
  430. }
  431. rear_ports = {
  432. rp.name: rp for rp in RearPort.objects.filter(device=device)
  433. }
  434. # Replicate PortMappings
  435. mappings = []
  436. for template in templates:
  437. front_port = front_ports.get(template.front_port.resolve_name(module=module, device=device))
  438. rear_port = rear_ports.get(template.rear_port.resolve_name(module=module, device=device))
  439. mappings.append(
  440. PortMapping(
  441. device_id=front_port.device_id,
  442. front_port=front_port,
  443. front_port_position=template.front_port_position,
  444. rear_port=rear_port,
  445. rear_port_position=template.rear_port_position,
  446. )
  447. )
  448. # Bulk-created (no per-mapping ObjectChange) to match how every other component is instantiated.
  449. PortMapping.objects.bulk_create(mappings)
  450. def reconcile_port_mappings(mapping_model, parent_field, parent, desired):
  451. """
  452. Reconcile a parent port's mappings against `desired`, writing only the difference so unchanged
  453. mappings keep their PK (and emit no changelog entry). Changed/removed rows are deleted before
  454. replacements are created, all in one transaction, so position swaps don't trip the unique
  455. constraint. Per-row create()/delete() let the change-logging signals fire naturally.
  456. Args:
  457. mapping_model: PortMapping or PortTemplateMapping.
  458. parent_field: 'front_port' or 'rear_port' — the side being edited; its '<parent_field>_position'
  459. is each mapping's stable identity within the set.
  460. parent: the parent instance (FrontPort/RearPort or their templates).
  461. desired: iterable of dicts of mapping field values EXCLUDING the parent FK, using '<field>_id'
  462. for the opposite-port FK, e.g. {'front_port_position': 1, 'rear_port_id': 5,
  463. 'rear_port_position': 2}. save() derives device/device_type/module_type from the front port.
  464. """
  465. key_field = f'{parent_field}_position'
  466. other_field = 'rear_port' if parent_field == 'front_port' else 'front_port'
  467. value_fields = (f'{other_field}_id', f'{other_field}_position')
  468. def target(source):
  469. # The comparable "value" of a mapping: the opposite port and its position. Two mappings with
  470. # the same parent-side position but a different target represent a re-pointing of that slot.
  471. get = source.get if isinstance(source, dict) else lambda f: getattr(source, f)
  472. return tuple(get(f) for f in value_fields)
  473. desired_by_key = {d[key_field]: d for d in desired}
  474. with transaction.atomic(using=router.db_for_write(mapping_model)):
  475. # Lock the parent's existing mappings for the duration of the reconcile. Two requests editing
  476. # the same port would otherwise read the same snapshot and race, the second colliding on a
  477. # unique constraint when it recreates rows the first has already committed.
  478. existing = {
  479. getattr(m, key_field): m
  480. for m in mapping_model.objects.filter(**{parent_field: parent}).select_for_update()
  481. }
  482. # Delete rows that no longer exist or whose target changed (before creating, to free the slots)
  483. for key, mapping in existing.items():
  484. if key not in desired_by_key or target(mapping) != target(desired_by_key[key]):
  485. mapping.delete()
  486. # Create rows that are new or whose target changed
  487. for key, attrs in desired_by_key.items():
  488. if key not in existing or target(existing[key]) != target(attrs):
  489. mapping_model.objects.create(**{parent_field: parent, **attrs})