test_models.py 28 KB

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