test_models.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. import uuid
  2. from django.contrib.contenttypes.models import ContentType
  3. from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
  4. from django.test import RequestFactory, TestCase, tag
  5. from circuits.models import Circuit, CircuitTermination, CircuitType, Provider, ProviderNetwork
  6. from circuits.models.circuits import _set_circuit_terminations
  7. from core.choices import ObjectChangeActionChoices
  8. from core.models import ObjectChange
  9. from dcim.models import Location, Region, Site, SiteGroup
  10. from extras.choices import CustomFieldStatusChoices, CustomFieldTypeChoices
  11. from extras.models import CustomField
  12. from netbox.context_managers import event_tracking
  13. from users.models import User
  14. class CircuitTerminationTestCase(TestCase):
  15. @classmethod
  16. def setUpTestData(cls):
  17. provider = Provider.objects.create(name='Provider 1', slug='provider-1')
  18. circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
  19. cls.sites = (
  20. Site.objects.create(name='Site 1', slug='site-1'),
  21. Site.objects.create(name='Site 2', slug='site-2'),
  22. )
  23. cls.circuits = (
  24. Circuit.objects.create(cid='Circuit 1', provider=provider, type=circuit_type),
  25. Circuit.objects.create(cid='Circuit 2', provider=provider, type=circuit_type),
  26. )
  27. cls.provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider)
  28. def test_circuit_termination_creation_populates_circuit_cache(self):
  29. """
  30. When a CircuitTermination is created, the parent Circuit's termination_a or termination_z
  31. cache field should be populated.
  32. """
  33. # Create A termination
  34. termination_a = CircuitTermination.objects.create(
  35. circuit=self.circuits[0],
  36. term_side='A',
  37. termination=self.sites[0],
  38. )
  39. self.circuits[0].refresh_from_db()
  40. self.assertEqual(self.circuits[0].termination_a, termination_a)
  41. self.assertIsNone(self.circuits[0].termination_z)
  42. # Create Z termination
  43. termination_z = CircuitTermination.objects.create(
  44. circuit=self.circuits[0],
  45. term_side='Z',
  46. termination=self.sites[1],
  47. )
  48. self.circuits[0].refresh_from_db()
  49. self.assertEqual(self.circuits[0].termination_a, termination_a)
  50. self.assertEqual(self.circuits[0].termination_z, termination_z)
  51. def test_circuit_termination_circuit_change_clears_old_cache(self):
  52. """
  53. When a CircuitTermination's circuit is changed, the old Circuit's cache should be cleared
  54. and the new Circuit's cache should be populated.
  55. """
  56. # Create termination on self.circuits[0]
  57. termination = CircuitTermination.objects.create(
  58. circuit=self.circuits[0],
  59. term_side='A',
  60. termination=self.sites[0],
  61. )
  62. self.circuits[0].refresh_from_db()
  63. self.assertEqual(self.circuits[0].termination_a, termination)
  64. # Move termination to self.circuits[1]
  65. termination.circuit = self.circuits[1]
  66. termination.save()
  67. self.circuits[0].refresh_from_db()
  68. self.circuits[1].refresh_from_db()
  69. # Old circuit's cache should be cleared
  70. self.assertIsNone(self.circuits[0].termination_a)
  71. # New circuit's cache should be populated
  72. self.assertEqual(self.circuits[1].termination_a, termination)
  73. def test_circuit_termination_circuit_change_with_generator_update_fields(self):
  74. """
  75. A one-shot iterable passed as update_fields must still reach the database, so the
  76. circuit change is persisted and both caches are updated.
  77. """
  78. termination = CircuitTermination.objects.create(
  79. circuit=self.circuits[0],
  80. term_side='A',
  81. termination=self.sites[0],
  82. )
  83. termination.circuit = self.circuits[1]
  84. termination.save(update_fields=(field for field in ('circuit',)))
  85. termination.refresh_from_db()
  86. self.circuits[0].refresh_from_db()
  87. self.circuits[1].refresh_from_db()
  88. self.assertEqual(termination.circuit, self.circuits[1])
  89. self.assertIsNone(self.circuits[0].termination_a)
  90. self.assertEqual(self.circuits[1].termination_a, termination)
  91. def test_circuit_termination_term_side_change_clears_old_cache(self):
  92. """
  93. When a CircuitTermination's term_side is changed, the old side's cache should be cleared
  94. and the new side's cache should be populated.
  95. """
  96. # Create A termination
  97. termination = CircuitTermination.objects.create(
  98. circuit=self.circuits[0],
  99. term_side='A',
  100. termination=self.sites[0],
  101. )
  102. self.circuits[0].refresh_from_db()
  103. self.assertEqual(self.circuits[0].termination_a, termination)
  104. self.assertIsNone(self.circuits[0].termination_z)
  105. # Change from A to Z
  106. termination.term_side = 'Z'
  107. termination.save()
  108. self.circuits[0].refresh_from_db()
  109. # A side should be cleared, Z side should be populated
  110. self.assertIsNone(self.circuits[0].termination_a)
  111. self.assertEqual(self.circuits[0].termination_z, termination)
  112. def test_circuit_termination_circuit_and_term_side_change(self):
  113. """
  114. When both circuit and term_side are changed, the old Circuit's old side cache should be
  115. cleared and the new Circuit's new side cache should be populated.
  116. """
  117. # Create A termination on self.circuits[0]
  118. termination = CircuitTermination.objects.create(
  119. circuit=self.circuits[0],
  120. term_side='A',
  121. termination=self.sites[0],
  122. )
  123. self.circuits[0].refresh_from_db()
  124. self.assertEqual(self.circuits[0].termination_a, termination)
  125. # Change to self.circuits[1] Z side
  126. termination.circuit = self.circuits[1]
  127. termination.term_side = 'Z'
  128. termination.save()
  129. self.circuits[0].refresh_from_db()
  130. self.circuits[1].refresh_from_db()
  131. # Old circuit's A side should be cleared
  132. self.assertIsNone(self.circuits[0].termination_a)
  133. self.assertIsNone(self.circuits[0].termination_z)
  134. # New circuit's Z side should be populated
  135. self.assertIsNone(self.circuits[1].termination_a)
  136. self.assertEqual(self.circuits[1].termination_z, termination)
  137. def test_circuit_termination_deletion_clears_cache(self):
  138. """
  139. When a CircuitTermination is deleted, the parent Circuit's cache should be cleared.
  140. """
  141. termination = CircuitTermination.objects.create(
  142. circuit=self.circuits[0],
  143. term_side='A',
  144. termination=self.sites[0],
  145. )
  146. self.circuits[0].refresh_from_db()
  147. self.assertEqual(self.circuits[0].termination_a, termination)
  148. # Delete the termination
  149. termination.delete()
  150. self.circuits[0].refresh_from_db()
  151. # Cache should be cleared (SET_NULL behavior)
  152. self.assertIsNone(self.circuits[0].termination_a)
  153. def test_termination_required_when_termination_type_is_selected(self):
  154. """Model rejects type-without-target before generic GFK validation hits termination_id."""
  155. provider_network_type = ContentType.objects.get_for_model(ProviderNetwork)
  156. termination = CircuitTermination(
  157. circuit=self.circuits[0],
  158. term_side='A',
  159. termination_type=provider_network_type,
  160. )
  161. with self.assertRaises(ValidationError) as cm:
  162. termination.full_clean()
  163. errors = cm.exception.message_dict
  164. self.assertIn(NON_FIELD_ERRORS, errors)
  165. self.assertIn('Please select a Provider Network.', errors[NON_FIELD_ERRORS])
  166. self.assertNotIn('termination_id', errors)
  167. class CircuitTerminationDenormalizationTriggerTestCase(TestCase):
  168. """
  169. Verify the PostgreSQL triggers (installed by circuits migration 0058) that keep a
  170. CircuitTermination's denormalized scope columns in sync with its Site/Location.
  171. These replace the former Python `post_save` handler in netbox.denormalized. Unlike that
  172. handler, the triggers also fire for bulk QuerySet.update() writes (exercised below).
  173. """
  174. @classmethod
  175. def setUpTestData(cls):
  176. provider = Provider.objects.create(name='Provider 1', slug='provider-1')
  177. circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
  178. cls.circuit = Circuit.objects.create(cid='Circuit 1', provider=provider, type=circuit_type)
  179. def test_site_region_group_change_propagates_to_termination(self):
  180. region_a = Region.objects.create(name='Region A', slug='region-a')
  181. region_b = Region.objects.create(name='Region B', slug='region-b')
  182. group_a = SiteGroup.objects.create(name='Group A', slug='group-a')
  183. group_b = SiteGroup.objects.create(name='Group B', slug='group-b')
  184. site = Site.objects.create(name='Site', slug='site', region=region_a, group=group_a)
  185. termination = CircuitTermination.objects.create(
  186. circuit=self.circuit, term_side='A', termination=site,
  187. )
  188. self.assertEqual(termination._region, region_a)
  189. self.assertEqual(termination._site_group, group_a)
  190. # Reassign the Site's region/group; the trigger should update the termination.
  191. site.region = region_b
  192. site.group = group_b
  193. site.save()
  194. termination.refresh_from_db()
  195. self.assertEqual(termination._region, region_b)
  196. self.assertEqual(termination._site_group, group_b)
  197. def test_location_site_change_propagates_to_termination(self):
  198. region_a = Region.objects.create(name='Region A', slug='region-a')
  199. region_b = Region.objects.create(name='Region B', slug='region-b')
  200. group_a = SiteGroup.objects.create(name='Group A', slug='group-a')
  201. group_b = SiteGroup.objects.create(name='Group B', slug='group-b')
  202. site_a = Site.objects.create(name='Site A', slug='site-a', region=region_a, group=group_a)
  203. site_b = Site.objects.create(name='Site B', slug='site-b', region=region_b, group=group_b)
  204. location = Location.objects.create(name='Loc', slug='loc', site=site_a)
  205. termination = CircuitTermination.objects.create(
  206. circuit=self.circuit, term_side='A', termination=location,
  207. )
  208. self.assertEqual(termination._site, site_a)
  209. self.assertEqual(termination._location, location)
  210. # Move the Location to a different Site; the trigger updates _site and pulls the new
  211. # site's region/group through in the same statement.
  212. location.site = site_b
  213. location.save()
  214. termination.refresh_from_db()
  215. self.assertEqual(termination._site, site_b)
  216. self.assertEqual(termination._region, region_b)
  217. self.assertEqual(termination._site_group, group_b)
  218. def test_bulk_update_of_site_propagates_to_termination(self):
  219. """
  220. A QuerySet.update() bypasses post_save (the old handler never fired for it); the
  221. DB trigger fires regardless, which is the behavior this change introduces.
  222. """
  223. region_a = Region.objects.create(name='Region A', slug='region-a')
  224. region_b = Region.objects.create(name='Region B', slug='region-b')
  225. site = Site.objects.create(name='Site', slug='site', region=region_a)
  226. termination = CircuitTermination.objects.create(
  227. circuit=self.circuit, term_side='A', termination=site,
  228. )
  229. self.assertEqual(termination._region, region_a)
  230. Site.objects.filter(pk=site.pk).update(region=region_b)
  231. termination.refresh_from_db()
  232. self.assertEqual(termination._region, region_b)
  233. class CircuitTerminationChangeLoggingTestCase(TestCase):
  234. """
  235. Circuit.termination_a/termination_z are maintained by CircuitTermination.save(). Writing them
  236. with a queryset update() emitted no post_save, and so no ObjectChange. (#23134)
  237. """
  238. @classmethod
  239. def setUpTestData(cls):
  240. cls.user = User.objects.create_user(username='testuser', password='pw')
  241. provider = Provider.objects.create(name='Provider 1', slug='provider-1')
  242. circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
  243. cls.sites = (
  244. Site.objects.create(name='Site 1', slug='site-1'),
  245. Site.objects.create(name='Site 2', slug='site-2'),
  246. )
  247. cls.circuits = (
  248. Circuit.objects.create(cid='Circuit 1', provider=provider, type=circuit_type),
  249. Circuit.objects.create(cid='Circuit 2', provider=provider, type=circuit_type),
  250. )
  251. def _tracked(self, func):
  252. request = RequestFactory().get('/')
  253. request.id = uuid.uuid4()
  254. request.user = self.user
  255. with event_tracking(request):
  256. return func()
  257. def _termination_change(self, termination_pk, action):
  258. return ObjectChange.objects.get(
  259. changed_object_type=ContentType.objects.get_for_model(CircuitTermination),
  260. changed_object_id=termination_pk,
  261. action=action,
  262. )
  263. def _circuit_changes(self, circuit):
  264. return ObjectChange.objects.filter(
  265. changed_object_type=ContentType.objects.get_for_model(Circuit),
  266. changed_object_id=circuit.pk,
  267. action=ObjectChangeActionChoices.ACTION_UPDATE,
  268. ).order_by('pk')
  269. @tag('regression') # Ref: #23134
  270. def test_creation_records_circuit_update(self):
  271. termination = self._tracked(lambda: CircuitTermination.objects.create(
  272. circuit=self.circuits[0], term_side='A', termination=self.sites[0],
  273. ))
  274. changes = self._circuit_changes(self.circuits[0])
  275. self.assertEqual(changes.count(), 1)
  276. self.assertIsNone(changes[0].prechange_data['termination_a'])
  277. self.assertEqual(changes[0].postchange_data['termination_a'], termination.pk)
  278. # The pointer references the termination's PK, so the create must be recorded first
  279. termination_create = self._termination_change(
  280. termination.pk, ObjectChangeActionChoices.ACTION_CREATE
  281. )
  282. self.assertLess(termination_create.pk, changes[0].pk)
  283. @tag('regression') # Ref: #23134
  284. def test_second_termination_snapshots_current_state(self):
  285. # The A pointer is already committed when the Z termination is created
  286. termination_a = self._tracked(lambda: CircuitTermination.objects.create(
  287. circuit=self.circuits[0], term_side='A', termination=self.sites[0],
  288. ))
  289. ObjectChange.objects.all().delete()
  290. termination_z = self._tracked(lambda: CircuitTermination.objects.create(
  291. circuit=self.circuits[0], term_side='Z', termination=self.sites[1],
  292. ))
  293. changes = self._circuit_changes(self.circuits[0])
  294. self.assertEqual(changes.count(), 1)
  295. self.assertEqual(changes[0].prechange_data['termination_a'], termination_a.pk)
  296. self.assertIsNone(changes[0].prechange_data['termination_z'])
  297. self.assertEqual(changes[0].postchange_data['termination_a'], termination_a.pk)
  298. self.assertEqual(changes[0].postchange_data['termination_z'], termination_z.pk)
  299. @tag('regression') # Ref: #23134
  300. def test_circuit_change_records_both_circuits(self):
  301. termination = self._tracked(lambda: CircuitTermination.objects.create(
  302. circuit=self.circuits[0], term_side='A', termination=self.sites[0],
  303. ))
  304. ObjectChange.objects.all().delete()
  305. def _move():
  306. termination.snapshot()
  307. termination.circuit = self.circuits[1]
  308. termination.save()
  309. self._tracked(_move)
  310. # The old circuit's pointer is cleared
  311. old_changes = self._circuit_changes(self.circuits[0])
  312. self.assertEqual(old_changes.count(), 1)
  313. self.assertEqual(old_changes[0].prechange_data['termination_a'], termination.pk)
  314. self.assertIsNone(old_changes[0].postchange_data['termination_a'])
  315. # The new circuit's pointer is set
  316. new_changes = self._circuit_changes(self.circuits[1])
  317. self.assertEqual(new_changes.count(), 1)
  318. self.assertIsNone(new_changes[0].prechange_data['termination_a'])
  319. self.assertEqual(new_changes[0].postchange_data['termination_a'], termination.pk)
  320. @tag('regression') # Ref: #23134
  321. def test_term_side_change_records_circuit_updates(self):
  322. termination = self._tracked(lambda: CircuitTermination.objects.create(
  323. circuit=self.circuits[0], term_side='A', termination=self.sites[0],
  324. ))
  325. ObjectChange.objects.all().delete()
  326. def _flip():
  327. termination.snapshot()
  328. termination.term_side = 'Z'
  329. termination.save()
  330. self._tracked(_flip)
  331. # The old pointer is cleared, then the new one is set
  332. changes = self._circuit_changes(self.circuits[0])
  333. self.assertEqual(changes.count(), 2)
  334. self.assertEqual(changes[0].prechange_data['termination_a'], termination.pk)
  335. self.assertIsNone(changes[0].postchange_data['termination_a'])
  336. self.assertIsNone(changes[1].prechange_data['termination_z'])
  337. self.assertEqual(changes[1].postchange_data['termination_z'], termination.pk)
  338. @tag('regression') # Ref: #23134
  339. def test_creation_leaves_another_terminations_pointer_alone(self):
  340. # A pointer referencing a different termination must never be cleared
  341. termination_a = self._tracked(lambda: CircuitTermination.objects.create(
  342. circuit=self.circuits[0], term_side='A', termination=self.sites[0],
  343. ))
  344. ObjectChange.objects.all().delete()
  345. def _create():
  346. termination = CircuitTermination(
  347. circuit=self.circuits[0], term_side='A', termination=self.sites[1],
  348. )
  349. termination.term_side = 'Z'
  350. termination.save()
  351. return termination
  352. termination_z = self._tracked(_create)
  353. self.circuits[0].refresh_from_db()
  354. self.assertEqual(self.circuits[0].termination_a_id, termination_a.pk)
  355. self.assertEqual(self.circuits[0].termination_z_id, termination_z.pk)
  356. changes = self._circuit_changes(self.circuits[0])
  357. self.assertEqual(changes.count(), 1)
  358. self.assertEqual(changes[0].postchange_data['termination_a'], termination_a.pk)
  359. @tag('regression') # Ref: #23134
  360. def test_noop_resave_records_no_circuit_update(self):
  361. termination = self._tracked(lambda: CircuitTermination.objects.create(
  362. circuit=self.circuits[0], term_side='A', termination=self.sites[0],
  363. ))
  364. ObjectChange.objects.all().delete()
  365. termination.snapshot()
  366. self._tracked(termination.save)
  367. self.assertFalse(self._circuit_changes(self.circuits[0]).exists())
  368. @tag('regression') # Ref: #23134
  369. def test_circuit_change_via_update_fields_records_circuit_update(self):
  370. # save(update_fields=...) takes its own branch when deciding what is being persisted
  371. termination = self._tracked(lambda: CircuitTermination.objects.create(
  372. circuit=self.circuits[0], term_side='A', termination=self.sites[0],
  373. ))
  374. ObjectChange.objects.all().delete()
  375. def _move():
  376. termination.snapshot()
  377. termination.circuit = self.circuits[1]
  378. termination.save(update_fields=('circuit',))
  379. self._tracked(_move)
  380. old_changes = self._circuit_changes(self.circuits[0])
  381. self.assertEqual(old_changes.count(), 1)
  382. self.assertIsNone(old_changes[0].postchange_data['termination_a'])
  383. new_changes = self._circuit_changes(self.circuits[1])
  384. self.assertEqual(new_changes.count(), 1)
  385. self.assertEqual(new_changes[0].postchange_data['termination_a'], termination.pk)
  386. @tag('regression') # Ref: #23134
  387. def test_deletion_records_circuit_update(self):
  388. termination = self._tracked(lambda: CircuitTermination.objects.create(
  389. circuit=self.circuits[0], term_side='A', termination=self.sites[0],
  390. ))
  391. ObjectChange.objects.all().delete()
  392. termination_pk = termination.pk
  393. self._tracked(termination.delete)
  394. self.circuits[0].refresh_from_db()
  395. self.assertIsNone(self.circuits[0].termination_a_id)
  396. changes = self._circuit_changes(self.circuits[0])
  397. self.assertEqual(changes.count(), 1)
  398. self.assertEqual(changes[0].prechange_data['termination_a'], termination_pk)
  399. self.assertIsNone(changes[0].postchange_data['termination_a'])
  400. # The pointer clear must precede the DELETE, so that a consumer replaying in reverse
  401. # restores the termination before the record which references it
  402. termination_delete = self._termination_change(
  403. termination_pk, ObjectChangeActionChoices.ACTION_DELETE
  404. )
  405. self.assertLess(changes[0].pk, termination_delete.pk)
  406. @tag('regression') # Ref: #23134
  407. def test_bulk_deletion_records_circuit_update(self):
  408. # NetBox's bulk delete views iterate obj.delete() rather than calling queryset.delete()
  409. termination = self._tracked(lambda: CircuitTermination.objects.create(
  410. circuit=self.circuits[0], term_side='A', termination=self.sites[0],
  411. ))
  412. ObjectChange.objects.all().delete()
  413. termination_pk = termination.pk
  414. def _bulk_delete():
  415. for obj in CircuitTermination.objects.filter(pk=termination_pk):
  416. obj.delete()
  417. self._tracked(_bulk_delete)
  418. changes = self._circuit_changes(self.circuits[0])
  419. self.assertEqual(changes.count(), 1)
  420. self.assertEqual(changes[0].prechange_data['termination_a'], termination_pk)
  421. self.assertIsNone(changes[0].postchange_data['termination_a'])
  422. @tag('regression') # Ref: #23134
  423. def test_deletion_resolves_the_pointer_from_the_database(self):
  424. # The pointer cleared is the one which references this termination, not the one named by
  425. # a stale in-memory term_side
  426. termination_a = self._tracked(lambda: CircuitTermination.objects.create(
  427. circuit=self.circuits[0], term_side='A', termination=self.sites[0],
  428. ))
  429. termination_z = self._tracked(lambda: CircuitTermination.objects.create(
  430. circuit=self.circuits[0], term_side='Z', termination=self.sites[1],
  431. ))
  432. ObjectChange.objects.all().delete()
  433. termination_z_pk = termination_z.pk
  434. termination_z.term_side = 'A'
  435. self._tracked(termination_z.delete)
  436. self.circuits[0].refresh_from_db()
  437. self.assertEqual(self.circuits[0].termination_a_id, termination_a.pk)
  438. self.assertIsNone(self.circuits[0].termination_z_id)
  439. changes = self._circuit_changes(self.circuits[0])
  440. self.assertEqual(changes.count(), 1)
  441. self.assertEqual(changes[0].prechange_data['termination_z'], termination_z_pk)
  442. self.assertIsNone(changes[0].postchange_data['termination_z'])
  443. self.assertEqual(changes[0].postchange_data['termination_a'], termination_a.pk)
  444. @tag('regression') # Ref: #23134
  445. def test_cascade_deletion_records_circuit_update(self):
  446. # Deleting the terminating Site reaches the termination through the collector, which does
  447. # not call delete(). Both sides go in one record.
  448. termination_a = self._tracked(lambda: CircuitTermination.objects.create(
  449. circuit=self.circuits[0], term_side='A', termination=self.sites[0],
  450. ))
  451. termination_z = self._tracked(lambda: CircuitTermination.objects.create(
  452. circuit=self.circuits[0], term_side='Z', termination=self.sites[0],
  453. ))
  454. ObjectChange.objects.all().delete()
  455. termination_a_pk, termination_z_pk = termination_a.pk, termination_z.pk
  456. self._tracked(self.sites[0].delete)
  457. self.circuits[0].refresh_from_db()
  458. self.assertIsNone(self.circuits[0].termination_a_id)
  459. self.assertIsNone(self.circuits[0].termination_z_id)
  460. changes = self._circuit_changes(self.circuits[0])
  461. self.assertEqual(changes.count(), 1)
  462. self.assertEqual(changes[0].prechange_data['termination_a'], termination_a_pk)
  463. self.assertEqual(changes[0].prechange_data['termination_z'], termination_z_pk)
  464. self.assertIsNone(changes[0].postchange_data['termination_a'])
  465. self.assertIsNone(changes[0].postchange_data['termination_z'])
  466. # The clear must precede the DELETEs which the cascade emits for the terminations
  467. termination_delete = self._termination_change(
  468. termination_a_pk, ObjectChangeActionChoices.ACTION_DELETE
  469. )
  470. self.assertLess(changes[0].pk, termination_delete.pk)
  471. def test_circuit_deletion_records_no_pointer_update(self):
  472. self._tracked(lambda: CircuitTermination.objects.create(
  473. circuit=self.circuits[0], term_side='A', termination=self.sites[0],
  474. ))
  475. ObjectChange.objects.all().delete()
  476. self._tracked(self.circuits[0].delete)
  477. self.assertFalse(self._circuit_changes(self.circuits[0]).exists())
  478. @tag('regression') # Ref: #23134
  479. def test_pointer_update_persists_populated_custom_field_defaults(self):
  480. # The pointer is written with update_fields, but CustomFieldsMixin.save() populates defaults
  481. # into custom_field_data, which the change log serializes. Both must reach the database.
  482. custom_field = CustomField.objects.create(
  483. name='probe_field',
  484. type=CustomFieldTypeChoices.TYPE_TEXT,
  485. default='default-value',
  486. status=CustomFieldStatusChoices.STATUS_ACTIVE,
  487. )
  488. custom_field.object_types.set([ContentType.objects.get_for_model(Circuit)])
  489. CustomField.objects.clear_cache()
  490. Circuit.objects.filter(pk=self.circuits[0].pk).update(custom_field_data={})
  491. ObjectChange.objects.all().delete()
  492. self._tracked(lambda: CircuitTermination.objects.create(
  493. circuit=self.circuits[0], term_side='A', termination=self.sites[0],
  494. ))
  495. self.circuits[0].refresh_from_db()
  496. self.assertEqual(self.circuits[0].custom_field_data, {'probe_field': 'default-value'})
  497. changes = self._circuit_changes(self.circuits[0])
  498. self.assertEqual(changes.count(), 1)
  499. self.assertEqual(
  500. changes[0].postchange_data['custom_fields'], self.circuits[0].custom_field_data
  501. )
  502. @tag('regression') # Ref: #23134
  503. def test_pointer_update_leaves_complete_custom_field_data_alone(self):
  504. # With no default to populate, the pointer write must not overwrite a concurrent edit
  505. custom_field = CustomField.objects.create(
  506. name='probe_field',
  507. type=CustomFieldTypeChoices.TYPE_TEXT,
  508. default='default-value',
  509. status=CustomFieldStatusChoices.STATUS_ACTIVE,
  510. )
  511. custom_field.object_types.set([ContentType.objects.get_for_model(Circuit)])
  512. CustomField.objects.clear_cache()
  513. CircuitTermination.objects.create(circuit=self.circuits[0], term_side='A', termination=self.sites[0])
  514. circuit = Circuit.objects.get(pk=self.circuits[0].pk)
  515. Circuit.objects.filter(pk=circuit.pk).update(custom_field_data={'probe_field': 'concurrent'})
  516. _set_circuit_terminations(circuit, {'termination_a': None}, using='default')
  517. circuit.refresh_from_db()
  518. self.assertIsNone(circuit.termination_a_id)
  519. self.assertEqual(circuit.custom_field_data, {'probe_field': 'concurrent'})