test_forms.py 80 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865
  1. from unittest.mock import patch
  2. from django import forms
  3. from django.template.loader import render_to_string
  4. from django.test import TestCase
  5. from circuits.models import CircuitTermination
  6. from dcim.choices import (
  7. CableEndChoices,
  8. CableProfileChoices,
  9. DeviceFaceChoices,
  10. DeviceStatusChoices,
  11. InterfaceModeChoices,
  12. InterfaceTypeChoices,
  13. LinkStatusChoices,
  14. PortTypeChoices,
  15. PowerOutletStatusChoices,
  16. VirtualDeviceContextStatusChoices,
  17. )
  18. from dcim.forms import *
  19. from dcim.models import *
  20. from dcim.tests.test_module_moves import fail_after
  21. from ipam.models import ASN, RIR, VLAN
  22. from utilities.exceptions import AbortRequest
  23. from utilities.forms.rendering import M2MAddRemoveFields
  24. from utilities.testing import create_test_device
  25. from virtualization.models import Cluster, ClusterGroup, ClusterType
  26. def get_id(model, slug):
  27. return model.objects.get(slug=slug).id
  28. class PowerOutletFormTestCase(TestCase):
  29. @classmethod
  30. def setUpTestData(cls):
  31. cls.site = site = Site.objects.create(name='Site 1', slug='site-1')
  32. cls.manufacturer = manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  33. cls.role = role = DeviceRole.objects.create(
  34. name='Device Role 1', slug='device-role-1', color='ff0000'
  35. )
  36. cls.device_type = device_type = DeviceType.objects.create(
  37. manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1
  38. )
  39. cls.rack = rack = Rack.objects.create(name='Rack 1', site=site)
  40. cls.device = Device.objects.create(
  41. name='Device 1', device_type=device_type, role=role, site=site, rack=rack, position=1
  42. )
  43. def test_status_is_required(self):
  44. form = PowerOutletForm(data={
  45. 'device': self.device,
  46. 'module': None,
  47. 'name': 'New Enabled Outlet',
  48. })
  49. self.assertFalse(form.is_valid())
  50. self.assertIn('status', form.errors)
  51. def test_status_must_be_defined_choice(self):
  52. form = PowerOutletForm(data={
  53. 'device': self.device,
  54. 'module': None,
  55. 'name': 'New Enabled Outlet',
  56. 'status': 'this isn\'t a defined choice',
  57. })
  58. self.assertFalse(form.is_valid())
  59. self.assertIn('status', form.errors)
  60. self.assertTrue(form.errors['status'][-1].startswith('Select a valid choice.'))
  61. def test_status_recognizes_choices(self):
  62. for index, choice in enumerate(PowerOutletStatusChoices.CHOICES):
  63. form = PowerOutletForm(data={
  64. 'device': self.device,
  65. 'module': None,
  66. 'name': f'New Enabled Outlet {index + 1}',
  67. 'status': choice[0],
  68. })
  69. self.assertEqual({}, form.errors)
  70. self.assertTrue(form.is_valid())
  71. instance = form.save()
  72. self.assertEqual(instance.status, choice[0])
  73. class DeviceTestCase(TestCase):
  74. @classmethod
  75. def setUpTestData(cls):
  76. site = Site.objects.create(name='Site 1', slug='site-1')
  77. rack = Rack.objects.create(name='Rack 1', site=site)
  78. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  79. device_type = DeviceType.objects.create(
  80. manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1
  81. )
  82. role = DeviceRole.objects.create(
  83. name='Device Role 1', slug='device-role-1', color='ff0000'
  84. )
  85. Platform.objects.create(name='Platform 1', slug='platform-1')
  86. Device.objects.create(
  87. name='Device 1', device_type=device_type, role=role, site=site, rack=rack, position=1
  88. )
  89. cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
  90. cluster_group = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1')
  91. Cluster.objects.create(name='Cluster 1', type=cluster_type, group=cluster_group)
  92. def test_racked_device(self):
  93. form = DeviceForm(data={
  94. 'name': 'New Device',
  95. 'role': DeviceRole.objects.first().pk,
  96. 'tenant': None,
  97. 'manufacturer': Manufacturer.objects.first().pk,
  98. 'device_type': DeviceType.objects.first().pk,
  99. 'site': Site.objects.first().pk,
  100. 'rack': Rack.objects.first().pk,
  101. 'face': DeviceFaceChoices.FACE_FRONT,
  102. 'position': 2,
  103. 'platform': Platform.objects.first().pk,
  104. 'status': DeviceStatusChoices.STATUS_ACTIVE,
  105. })
  106. self.assertTrue(form.is_valid())
  107. self.assertTrue(form.save())
  108. def test_replicate_components(self):
  109. device_type = DeviceType.objects.first()
  110. InterfaceTemplate.objects.create(device_type=device_type, name='eth0', type='1000base-t')
  111. data = {
  112. 'role': DeviceRole.objects.first().pk,
  113. 'device_type': device_type.pk,
  114. 'site': Site.objects.first().pk,
  115. 'status': DeviceStatusChoices.STATUS_ACTIVE,
  116. }
  117. form = DeviceForm(data={**data, 'name': 'Device 2', 'replicate_components': True})
  118. self.assertTrue(form.is_valid(), form.errors)
  119. self.assertTrue(form.save().interfaces.filter(name='eth0').exists())
  120. form = DeviceForm(data={**data, 'name': 'Device 3', 'replicate_components': False})
  121. self.assertTrue(form.is_valid(), form.errors)
  122. self.assertFalse(form.save().interfaces.exists())
  123. # The replicate_components field is not available when editing an existing device
  124. form = DeviceForm(instance=Device.objects.get(name='Device 1'))
  125. self.assertNotIn('replicate_components', form.fields)
  126. def test_import_replicate_components(self):
  127. device_type = DeviceType.objects.first()
  128. InterfaceTemplate.objects.create(device_type=device_type, name='eth0', type='1000base-t')
  129. data = {
  130. 'role': DeviceRole.objects.first().name,
  131. 'manufacturer': device_type.manufacturer.name,
  132. 'device_type': device_type.model,
  133. 'site': Site.objects.first().name,
  134. 'status': DeviceStatusChoices.STATUS_ACTIVE,
  135. }
  136. # Components are replicated by default
  137. form = DeviceImportForm(data={**data, 'name': 'Device 2'})
  138. self.assertTrue(form.is_valid(), form.errors)
  139. self.assertTrue(form.save().interfaces.filter(name='eth0').exists())
  140. # A blank value (e.g. an empty CSV cell) is treated as omitted
  141. for i, value in enumerate(('', None), start=4):
  142. form = DeviceImportForm(data={**data, 'name': f'Device {i}', 'replicate_components': value})
  143. self.assertTrue(form.is_valid(), form.errors)
  144. self.assertTrue(form.save().interfaces.filter(name='eth0').exists())
  145. form = DeviceImportForm(data={**data, 'name': 'Device 3', 'replicate_components': 'false'})
  146. self.assertTrue(form.is_valid(), form.errors)
  147. self.assertFalse(form.save().interfaces.exists())
  148. def test_racked_device_occupied(self):
  149. form = DeviceForm(data={
  150. 'name': 'test',
  151. 'role': DeviceRole.objects.first().pk,
  152. 'tenant': None,
  153. 'manufacturer': Manufacturer.objects.first().pk,
  154. 'device_type': DeviceType.objects.first().pk,
  155. 'site': Site.objects.first().pk,
  156. 'rack': Rack.objects.first().pk,
  157. 'face': DeviceFaceChoices.FACE_FRONT,
  158. 'position': 1,
  159. 'platform': Platform.objects.first().pk,
  160. 'status': DeviceStatusChoices.STATUS_ACTIVE,
  161. })
  162. self.assertFalse(form.is_valid())
  163. self.assertIn('position', form.errors)
  164. def test_non_racked_device(self):
  165. form = DeviceForm(data={
  166. 'name': 'New Device',
  167. 'role': DeviceRole.objects.first().pk,
  168. 'tenant': None,
  169. 'manufacturer': Manufacturer.objects.first().pk,
  170. 'device_type': DeviceType.objects.first().pk,
  171. 'site': Site.objects.first().pk,
  172. 'rack': None,
  173. 'face': None,
  174. 'position': None,
  175. 'platform': Platform.objects.first().pk,
  176. 'status': DeviceStatusChoices.STATUS_ACTIVE,
  177. })
  178. self.assertTrue(form.is_valid())
  179. self.assertTrue(form.save())
  180. def test_vc_member_removal_blocked_by_cross_chassis_lag(self):
  181. device1 = Device.objects.get(name='Device 1')
  182. device2 = Device.objects.create(
  183. name='Device 2', device_type=device1.device_type, role=device1.role, site=device1.site
  184. )
  185. vc = VirtualChassis.objects.create(name='VC 1', master=device1)
  186. Device.objects.filter(pk=device1.pk).update(virtual_chassis=vc, vc_position=1)
  187. Device.objects.filter(pk=device2.pk).update(virtual_chassis=vc, vc_position=2)
  188. lag = Interface.objects.create(device=device1, name='lag0', type=InterfaceTypeChoices.TYPE_LAG)
  189. Interface.objects.create(device=device2, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED, lag=lag)
  190. device2.refresh_from_db()
  191. form = DeviceForm(instance=device2, data={
  192. 'name': device2.name,
  193. 'role': device2.role.pk,
  194. 'device_type': device2.device_type.pk,
  195. 'site': device2.site.pk,
  196. 'status': DeviceStatusChoices.STATUS_ACTIVE,
  197. 'virtual_chassis': None,
  198. 'vc_position': None,
  199. })
  200. self.assertFalse(form.is_valid())
  201. self.assertIn('virtual_chassis', form.errors)
  202. def test_non_racked_device_with_face(self):
  203. form = DeviceForm(data={
  204. 'name': 'New Device',
  205. 'role': DeviceRole.objects.first().pk,
  206. 'tenant': None,
  207. 'manufacturer': Manufacturer.objects.first().pk,
  208. 'device_type': DeviceType.objects.first().pk,
  209. 'site': Site.objects.first().pk,
  210. 'rack': None,
  211. 'face': DeviceFaceChoices.FACE_REAR,
  212. 'platform': None,
  213. 'status': DeviceStatusChoices.STATUS_ACTIVE,
  214. })
  215. self.assertFalse(form.is_valid())
  216. self.assertIn('face', form.errors)
  217. def test_non_racked_device_with_position(self):
  218. form = DeviceForm(data={
  219. 'name': 'New Device',
  220. 'role': DeviceRole.objects.first().pk,
  221. 'tenant': None,
  222. 'manufacturer': Manufacturer.objects.first().pk,
  223. 'device_type': DeviceType.objects.first().pk,
  224. 'site': Site.objects.first().pk,
  225. 'rack': None,
  226. 'position': 10,
  227. 'platform': None,
  228. 'status': DeviceStatusChoices.STATUS_ACTIVE,
  229. })
  230. self.assertFalse(form.is_valid())
  231. self.assertIn('position', form.errors)
  232. class ModuleTypeFormTestCase(TestCase):
  233. @classmethod
  234. def setUpTestData(cls):
  235. cls.manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  236. cls.profile = ModuleTypeProfile.objects.create(
  237. name='Module Type Profile 1',
  238. schema={
  239. 'properties': {
  240. 'media': {
  241. 'title': 'Media',
  242. 'type': 'array',
  243. 'items': {
  244. 'type': 'string',
  245. 'enum': ['copper', 'sfp', 'qsfp28'],
  246. },
  247. },
  248. },
  249. },
  250. )
  251. def test_enum_array_attribute_uses_multiselect_field(self):
  252. form = ModuleTypeForm(data={
  253. 'manufacturer': self.manufacturer.pk,
  254. 'model': 'Module Type 1',
  255. 'profile': self.profile.pk,
  256. 'attr_media': ['copper', 'qsfp28'],
  257. })
  258. self.assertIsInstance(form.fields['attr_media'], forms.MultipleChoiceField)
  259. self.assertEqual(
  260. list(form.fields['attr_media'].choices),
  261. [
  262. ('copper', 'copper'),
  263. ('sfp', 'sfp'),
  264. ('qsfp28', 'qsfp28'),
  265. ],
  266. )
  267. with patch('utilities.forms.fields.dynamic.get_action_url', return_value='/'):
  268. self.assertTrue(form.is_valid(), form.errors)
  269. module_type = form.save()
  270. self.assertEqual(module_type.attribute_data, {'media': ['copper', 'qsfp28']})
  271. def test_zero_bound_attribute_is_enforced_by_the_form(self):
  272. profile = ModuleTypeProfile.objects.create(
  273. name='Module Type Profile 2',
  274. schema={
  275. 'properties': {
  276. 'offset': {
  277. 'title': 'Offset',
  278. 'type': 'number',
  279. 'minimum': 0,
  280. 'maximum': 0,
  281. },
  282. },
  283. },
  284. )
  285. form = ModuleTypeForm(data={
  286. 'manufacturer': self.manufacturer.pk,
  287. 'model': 'Module Type 2',
  288. 'profile': profile.pk,
  289. 'attr_offset': -5,
  290. })
  291. self.assertEqual(form.fields['attr_offset'].min_value, 0)
  292. self.assertEqual(form.fields['attr_offset'].max_value, 0)
  293. with patch('utilities.forms.fields.dynamic.get_action_url', return_value='/'):
  294. self.assertFalse(form.is_valid())
  295. self.assertIn('attr_offset', form.errors)
  296. def test_numeric_enum_attribute_is_saved_as_a_number(self):
  297. profile = ModuleTypeProfile.objects.create(
  298. name='Module Type Profile 3',
  299. schema={
  300. 'properties': {
  301. 'slots': {
  302. 'title': 'Slots',
  303. 'type': 'integer',
  304. 'enum': [0, 1, 2],
  305. },
  306. },
  307. },
  308. )
  309. for value in (0, 1):
  310. with self.subTest(value=value):
  311. form = ModuleTypeForm(data={
  312. 'manufacturer': self.manufacturer.pk,
  313. 'model': f'Module Type {value}',
  314. 'profile': profile.pk,
  315. 'attr_slots': str(value),
  316. })
  317. with patch('utilities.forms.fields.dynamic.get_action_url', return_value='/'):
  318. self.assertTrue(form.is_valid(), form.errors)
  319. module_type = form.save()
  320. self.assertEqual(module_type.attribute_data, {'slots': value})
  321. class ModuleTypeProfileDescriptionRenderingTestCase(TestCase):
  322. """
  323. A profile schema property's description is rendered as the attribute field's help text via the
  324. `safe` filter, so markup outside HTML_ALLOWED_TAGS must not reach the DOM as a live element.
  325. Verified end to end because the sanitization and the `safe` filter that makes it necessary sit
  326. in different layers.
  327. """
  328. @classmethod
  329. def setUpTestData(cls):
  330. cls.manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  331. cls.profile = ModuleTypeProfile.objects.create(
  332. name='Disk',
  333. schema={
  334. 'properties': {
  335. 'capacity': {
  336. 'type': 'integer',
  337. 'title': 'Capacity (GB)',
  338. 'description': 'Gross disk size <iframe src="https://example.com"></iframe>',
  339. },
  340. },
  341. },
  342. )
  343. def test_help_text_is_rendered_without_disallowed_markup(self):
  344. form = ModuleTypeForm(data={
  345. 'manufacturer': self.manufacturer.pk,
  346. 'model': 'Module Type 1',
  347. 'profile': self.profile.pk,
  348. 'attr_capacity': 500,
  349. })
  350. rendered = render_to_string('form_helpers/render_field.html', {'field': form['attr_capacity']})
  351. self.assertInHTML(
  352. '<span class="form-text" id="id_attr_capacity_helptext">'
  353. '<div class="rendered-markdown"><p>Gross disk size</p></div></span>',
  354. rendered,
  355. )
  356. class ModuleBayTemplateImportFormTestCase(TestCase):
  357. def test_module_bay_types_prefers_manufacturer_specific_match_over_global(self):
  358. """A name shared by a global and a manufacturer-scoped type resolves to the scoped one."""
  359. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  360. global_type = ModuleBayType.objects.create(name='SFP28', slug='sfp28-global')
  361. scoped_type = ModuleBayType.objects.create(
  362. name='SFP28', slug='sfp28-scoped', manufacturer=manufacturer,
  363. )
  364. device_type = DeviceType.objects.create(
  365. manufacturer=manufacturer, model='Device Type 1', slug='device-type-1',
  366. )
  367. form = ModuleBayTemplateImportForm({
  368. 'device_type': device_type.pk,
  369. 'name': 'Module Bay 1',
  370. 'module_bay_types': ['SFP28'],
  371. })
  372. self.assertTrue(form.is_valid(), form.errors)
  373. module_bay_template = form.save()
  374. self.assertEqual(
  375. list(module_bay_template.module_bay_types.all()), [scoped_type],
  376. )
  377. self.assertNotIn(global_type, module_bay_template.module_bay_types.all())
  378. def test_module_bay_types_unknown_name_raises_error(self):
  379. device_type = DeviceType.objects.create(
  380. manufacturer=Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1'),
  381. model='Device Type 1',
  382. slug='device-type-1',
  383. )
  384. form = ModuleBayTemplateImportForm({
  385. 'device_type': device_type.pk,
  386. 'name': 'Module Bay 1',
  387. 'module_bay_types': ['Nonexistent'],
  388. })
  389. self.assertFalse(form.is_valid())
  390. self.assertEqual(
  391. form.errors.as_data()['module_bay_types'][0].code, 'invalid_choice',
  392. )
  393. def test_module_bay_types_prefers_manufacturer_specific_match_over_global_for_module_type(self):
  394. """Same disambiguation, but for a module bay template nested under a ModuleType."""
  395. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  396. global_type = ModuleBayType.objects.create(name='SFP28', slug='sfp28-global')
  397. scoped_type = ModuleBayType.objects.create(
  398. name='SFP28', slug='sfp28-scoped', manufacturer=manufacturer,
  399. )
  400. module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
  401. form = ModuleBayTemplateImportForm({
  402. 'module_type': module_type.pk,
  403. 'name': 'Module Bay 1',
  404. 'module_bay_types': ['SFP28'],
  405. })
  406. self.assertTrue(form.is_valid(), form.errors)
  407. module_bay_template = form.save()
  408. self.assertEqual(
  409. list(module_bay_template.module_bay_types.all()), [scoped_type],
  410. )
  411. self.assertNotIn(global_type, module_bay_template.module_bay_types.all())
  412. def test_enabled_honors_explicit_false(self):
  413. device_type = DeviceType.objects.create(
  414. manufacturer=Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1'),
  415. model='Device Type 1',
  416. slug='device-type-1',
  417. )
  418. form = ModuleBayTemplateImportForm({
  419. 'device_type': device_type.pk,
  420. 'name': 'Module Bay 1',
  421. 'enabled': False,
  422. })
  423. self.assertTrue(form.is_valid(), form.errors)
  424. self.assertFalse(form.save().enabled)
  425. def test_import_export_round_trip_preserves_module_bay_types(self):
  426. """to_yaml() then re-import through this form preserves module bay types."""
  427. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  428. bay_type_a = ModuleBayType.objects.create(name='SFP28', slug='sfp28')
  429. bay_type_b = ModuleBayType.objects.create(name='QSFP28', slug='qsfp28')
  430. device_type = DeviceType.objects.create(
  431. manufacturer=manufacturer, model='Device Type 1', slug='device-type-1',
  432. )
  433. original = ModuleBayTemplate.objects.create(device_type=device_type, name='Module Bay 1')
  434. original.module_bay_types.set([bay_type_a, bay_type_b])
  435. exported = original.to_yaml()
  436. form = ModuleBayTemplateImportForm({
  437. 'device_type': device_type.pk,
  438. 'name': 'Module Bay 2',
  439. 'module_bay_types': exported['module_bay_types'],
  440. })
  441. self.assertTrue(form.is_valid(), form.errors)
  442. reimported = form.save()
  443. self.assertEqual(
  444. set(reimported.module_bay_types.values_list('name', flat=True)),
  445. set(original.module_bay_types.values_list('name', flat=True)),
  446. )
  447. def test_module_bay_types_name_belonging_only_to_other_manufacturers_is_unresolvable(self):
  448. """
  449. A name that exists only for manufacturers other than the device type's own (and isn't
  450. global) must not resolve at all -- module_bay_types is scoped to the device type's own
  451. manufacturer plus global types, with no cross-manufacturer fallback.
  452. """
  453. juniper = Manufacturer.objects.create(name='Juniper', slug='juniper')
  454. cisco = Manufacturer.objects.create(name='Cisco', slug='cisco')
  455. ModuleBayType.objects.create(name='SFP28', slug='sfp28-cisco', manufacturer=cisco)
  456. device_type = DeviceType.objects.create(
  457. manufacturer=juniper, model='Juniper Device Type', slug='juniper-device-type',
  458. )
  459. form = ModuleBayTemplateImportForm({
  460. 'device_type': device_type.pk,
  461. 'name': 'Module Bay 1',
  462. 'module_bay_types': ['SFP28'],
  463. })
  464. self.assertFalse(form.is_valid())
  465. self.assertEqual(
  466. form.errors.as_data()['module_bay_types'][0].code, 'invalid_choice',
  467. )
  468. def test_module_bay_types_resolution_is_independent_of_field_order(self):
  469. """
  470. Resolution must not depend on the parent type having been cleaned first, so declaring
  471. module_bay_types ahead of device_type/module_type must not change the outcome.
  472. """
  473. class ReorderedImportForm(ModuleBayTemplateImportForm):
  474. class Meta(ModuleBayTemplateImportForm.Meta):
  475. fields = [
  476. 'module_bay_types', 'device_type', 'module_type', 'name', 'label', 'position',
  477. 'enabled', 'description',
  478. ]
  479. self.assertEqual(list(ReorderedImportForm().fields)[0], 'module_bay_types')
  480. juniper = Manufacturer.objects.create(name='Juniper', slug='juniper')
  481. cisco = Manufacturer.objects.create(name='Cisco', slug='cisco')
  482. global_type = ModuleBayType.objects.create(name='SFP28', slug='sfp28-global')
  483. juniper_type = ModuleBayType.objects.create(name='SFP28', slug='sfp28-juniper', manufacturer=juniper)
  484. cisco_type = ModuleBayType.objects.create(name='QSFP28', slug='qsfp28-cisco', manufacturer=cisco)
  485. device_type = DeviceType.objects.create(
  486. manufacturer=juniper, model='Juniper Device Type', slug='juniper-device-type',
  487. )
  488. # The device type's own manufacturer still wins over the global type of the same name
  489. form = ReorderedImportForm({
  490. 'device_type': device_type.pk,
  491. 'name': 'Module Bay 1',
  492. 'module_bay_types': ['SFP28'],
  493. })
  494. self.assertTrue(form.is_valid(), form.errors)
  495. module_bay_template = form.save()
  496. self.assertEqual(list(module_bay_template.module_bay_types.all()), [juniper_type])
  497. self.assertNotIn(global_type, module_bay_template.module_bay_types.all())
  498. # ...and another manufacturer's bay type is still rejected rather than resolved to
  499. form = ReorderedImportForm({
  500. 'device_type': device_type.pk,
  501. 'name': 'Module Bay 2',
  502. 'module_bay_types': [cisco_type.name],
  503. })
  504. self.assertFalse(form.is_valid())
  505. self.assertEqual(
  506. form.errors.as_data()['module_bay_types'][0].code, 'invalid_choice',
  507. )
  508. class ModuleFormTestCase(TestCase):
  509. @classmethod
  510. def setUpTestData(cls):
  511. cls.device = create_test_device('Module Form Device A')
  512. cls.device_b = create_test_device('Module Form Device B')
  513. cls.bay_a = ModuleBay.objects.create(device=cls.device, name='Bay A')
  514. cls.bay_b = ModuleBay.objects.create(device=cls.device, name='Bay B')
  515. cls.bay_c = ModuleBay.objects.create(device=cls.device_b, name='Bay C')
  516. manufacturer = Manufacturer.objects.create(
  517. name='Module Form Manufacturer', slug='module-form-manufacturer'
  518. )
  519. cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Form Type')
  520. cls.module = Module.objects.create(
  521. device=cls.device, module_bay=cls.bay_a, module_type=cls.module_type
  522. )
  523. def test_module_device_is_editable_on_edit(self):
  524. form = ModuleForm(instance=self.module)
  525. self.assertFalse(form.fields['device'].disabled)
  526. self.assertTrue(form.fields['replicate_components'].disabled)
  527. self.assertTrue(form.fields['adopt_components'].disabled)
  528. def test_module_form_moves_module_to_empty_bay(self):
  529. form = ModuleForm(
  530. data={
  531. 'device': self.device.pk,
  532. 'module_bay': self.bay_b.pk,
  533. 'module_type': self.module_type.pk,
  534. 'status': 'active',
  535. },
  536. instance=self.module,
  537. )
  538. self.assertTrue(form.is_valid(), form.errors)
  539. form.save()
  540. self.module.refresh_from_db()
  541. self.assertEqual(self.module.module_bay, self.bay_b)
  542. def test_module_form_rejects_occupied_bay(self):
  543. Module.objects.create(device=self.device, module_bay=self.bay_b, module_type=self.module_type)
  544. form = ModuleForm(
  545. data={
  546. 'device': self.device.pk,
  547. 'module_bay': self.bay_b.pk,
  548. 'module_type': self.module_type.pk,
  549. 'status': 'active',
  550. },
  551. instance=self.module,
  552. )
  553. self.assertFalse(form.is_valid())
  554. self.assertIn('module_bay', form.errors)
  555. def test_module_form_moves_module_to_different_device(self):
  556. interface = Interface.objects.create(
  557. device=self.device, module=self.module, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  558. )
  559. form = ModuleForm(
  560. data={
  561. 'device': self.device_b.pk,
  562. 'module_bay': self.bay_c.pk,
  563. 'module_type': self.module_type.pk,
  564. 'status': 'active',
  565. },
  566. instance=self.module,
  567. )
  568. self.assertTrue(form.is_valid(), form.errors)
  569. form.save()
  570. self.module.refresh_from_db()
  571. self.assertEqual(self.module.device, self.device_b)
  572. self.assertEqual(self.module.module_bay, self.bay_c)
  573. interface.refresh_from_db()
  574. self.assertEqual(interface.device, self.device_b)
  575. def test_module_create_into_cyclic_hierarchy_is_rejected(self):
  576. # CREATE into a cyclic hierarchy (bypassing clean() via .update()) must be a form error.
  577. other_module = Module.objects.create(
  578. device=self.device, module_bay=self.bay_b, module_type=self.module_type
  579. )
  580. child_bay_1 = ModuleBay.objects.create(device=self.device, module=self.module, name='Child Bay 1')
  581. child_bay_2 = ModuleBay.objects.create(device=self.device, module=other_module, name='Child Bay 2')
  582. Module.objects.filter(pk=self.module.pk).update(module_bay=child_bay_2)
  583. Module.objects.filter(pk=other_module.pk).update(module_bay=child_bay_1)
  584. form = ModuleForm(
  585. data={
  586. 'device': self.device.pk,
  587. 'module_bay': child_bay_1.pk,
  588. 'module_type': self.module_type.pk,
  589. 'status': 'active',
  590. 'replicate_components': True,
  591. },
  592. )
  593. with fail_after(15):
  594. self.assertFalse(form.is_valid())
  595. self.assertIn('contains a cycle', str(form.errors))
  596. def test_module_form_reports_conflicting_cooling_component(self):
  597. """
  598. A cooling component name collision must surface as a form error rather than an
  599. IntegrityError raised from the replication insert. See netbox#15289.
  600. """
  601. cooled_type = ModuleType.objects.create(
  602. manufacturer=self.module_type.manufacturer, model='Cooled Form Type'
  603. )
  604. CoolingIntakeTemplate.objects.create(module_type=cooled_type, name='Intake 1')
  605. CoolingOutflowTemplate.objects.create(module_type=cooled_type, name='Outflow 1')
  606. CoolingIntake.objects.create(device=self.device, name='Intake 1')
  607. form = ModuleForm(
  608. data={
  609. 'device': self.device.pk,
  610. 'module_bay': self.bay_b.pk,
  611. 'module_type': cooled_type.pk,
  612. 'status': 'active',
  613. 'replicate_components': True,
  614. },
  615. )
  616. self.assertFalse(form.is_valid())
  617. self.assertIn('Intake 1', str(form.errors))
  618. def test_module_form_adopts_existing_cooling_component(self):
  619. cooled_type = ModuleType.objects.create(
  620. manufacturer=self.module_type.manufacturer, model='Adoptable Cooled Type'
  621. )
  622. CoolingIntakeTemplate.objects.create(module_type=cooled_type, name='Intake 1')
  623. intake = CoolingIntake.objects.create(device=self.device, name='Intake 1')
  624. form = ModuleForm(
  625. data={
  626. 'device': self.device.pk,
  627. 'module_bay': self.bay_b.pk,
  628. 'module_type': cooled_type.pk,
  629. 'status': 'active',
  630. 'replicate_components': True,
  631. 'adopt_components': True,
  632. },
  633. )
  634. self.assertTrue(form.is_valid(), form.errors)
  635. module = form.save()
  636. intake.refresh_from_db()
  637. self.assertEqual(intake.module, module)
  638. def test_module_form_rejects_module_token_without_bay_position(self):
  639. """Replication and adoption reject a {module} token in any resolved field when the bay has no position."""
  640. interface_type = InterfaceTypeChoices.TYPE_1GE_FIXED
  641. templates = (
  642. (InterfaceTemplate, {'name': 'eth{module}', 'type': interface_type}),
  643. (InterfaceTemplate, {'name': 'eth0', 'label': 'Port {module}', 'type': interface_type}),
  644. (ModuleBayTemplate, {'name': 'Bay {module}'}),
  645. (ModuleBayTemplate, {'name': 'Bay', 'label': 'Bay {module}'}),
  646. (ModuleBayTemplate, {'name': 'Bay', 'position': '{module}'}),
  647. )
  648. for i, (template_model, attrs) in enumerate(templates, start=1):
  649. module_type = ModuleType.objects.create(
  650. manufacturer=self.module_type.manufacturer, model=f'Token No-Position Form Type {i}'
  651. )
  652. template_model.objects.create(module_type=module_type, **attrs)
  653. for flags in ({'replicate_components': True}, {'adopt_components': True}):
  654. with self.subTest(template=template_model.__name__, attrs=attrs, flags=flags):
  655. form = ModuleForm(data={
  656. 'device': self.device.pk,
  657. 'module_bay': self.bay_b.pk,
  658. 'module_type': module_type.pk,
  659. 'status': 'active',
  660. **flags,
  661. })
  662. self.assertFalse(form.is_valid())
  663. self.assertIn('no position defined', str(form.errors))
  664. def test_module_form_rejects_module_token_depth_mismatch(self):
  665. """Replication and adoption reject more {module} tokens in a resolved field than the bay depth."""
  666. module_bay = ModuleBay.objects.create(device=self.device, name='Bay D', position='1')
  667. interface_type = InterfaceTypeChoices.TYPE_1GE_FIXED
  668. tokens = '{module}/{module}'
  669. templates = (
  670. (InterfaceTemplate, {'name': f'eth{tokens}', 'type': interface_type}),
  671. (InterfaceTemplate, {'name': 'eth0', 'label': tokens, 'type': interface_type}),
  672. (ModuleBayTemplate, {'name': tokens}),
  673. (ModuleBayTemplate, {'name': 'Bay', 'label': tokens}),
  674. (ModuleBayTemplate, {'name': 'Bay', 'position': tokens}),
  675. )
  676. for i, (template_model, attrs) in enumerate(templates, start=1):
  677. module_type = ModuleType.objects.create(
  678. manufacturer=self.module_type.manufacturer, model=f'Token Depth Form Type {i}'
  679. )
  680. template_model.objects.create(module_type=module_type, **attrs)
  681. for flags in ({'replicate_components': True}, {'adopt_components': True}):
  682. with self.subTest(template=template_model.__name__, attrs=attrs, flags=flags):
  683. form = ModuleForm(data={
  684. 'device': self.device.pk,
  685. 'module_bay': module_bay.pk,
  686. 'module_type': module_type.pk,
  687. 'status': 'active',
  688. **flags,
  689. })
  690. self.assertFalse(form.is_valid())
  691. self.assertIn('2 placeholders given', str(form.errors))
  692. def test_module_form_adopt_only_creates_no_components(self):
  693. """Adoption without replication adopts matching components and creates no others."""
  694. adopt_type = ModuleType.objects.create(
  695. manufacturer=self.module_type.manufacturer, model='Adopt Only Form Type'
  696. )
  697. InterfaceTemplate.objects.create(module_type=adopt_type, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
  698. InterfaceTemplate.objects.create(module_type=adopt_type, name='eth1', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
  699. interface = Interface.objects.create(
  700. device=self.device, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  701. )
  702. form = ModuleForm(data={
  703. 'device': self.device.pk,
  704. 'module_bay': self.bay_b.pk,
  705. 'module_type': adopt_type.pk,
  706. 'status': 'active',
  707. 'adopt_components': True,
  708. })
  709. self.assertTrue(form.is_valid(), form.errors)
  710. module = form.save()
  711. interface.refresh_from_db()
  712. self.assertEqual(interface.module, module)
  713. self.assertFalse(self.device.interfaces.filter(name='eth1').exists())
  714. def test_module_form_adopt_only_rejects_component_of_another_module(self):
  715. """Adoption without replication refuses a component that already belongs to another module."""
  716. adopt_type = ModuleType.objects.create(
  717. manufacturer=self.module_type.manufacturer, model='Owned Adopt Form Type'
  718. )
  719. InterfaceTemplate.objects.create(module_type=adopt_type, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
  720. Interface.objects.create(
  721. device=self.device, module=self.module, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  722. )
  723. form = ModuleForm(data={
  724. 'device': self.device.pk,
  725. 'module_bay': self.bay_b.pk,
  726. 'module_type': adopt_type.pk,
  727. 'status': 'active',
  728. 'adopt_components': True,
  729. })
  730. self.assertFalse(form.is_valid())
  731. self.assertIn('already belongs to a module', str(form.errors))
  732. def test_module_form_skips_template_checks_without_component_operations(self):
  733. """Without replication or adoption, templates are neither checked nor instantiated."""
  734. token_type = ModuleType.objects.create(
  735. manufacturer=self.module_type.manufacturer, model='No Operations Form Type'
  736. )
  737. InterfaceTemplate.objects.create(
  738. module_type=token_type, name='eth0', label='Port {module}', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  739. )
  740. form = ModuleForm(data={
  741. 'device': self.device.pk,
  742. 'module_bay': self.bay_b.pk,
  743. 'module_type': token_type.pk,
  744. 'status': 'active',
  745. })
  746. self.assertTrue(form.is_valid(), form.errors)
  747. module = form.save()
  748. self.assertFalse(module.interfaces.exists())
  749. def test_module_import_form_rejects_module_token_without_bay_position(self):
  750. """Bulk import applies the module form's placeholder checks."""
  751. token_type = ModuleType.objects.create(
  752. manufacturer=self.module_type.manufacturer, model='Token Import Type'
  753. )
  754. InterfaceTemplate.objects.create(
  755. module_type=token_type, name='eth0', label='Port {module}', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  756. )
  757. form = ModuleImportForm(data={
  758. 'device': self.device.name,
  759. 'module_bay': self.bay_b.name,
  760. 'module_type': token_type.model,
  761. 'status': 'active',
  762. })
  763. self.assertFalse(form.is_valid())
  764. self.assertIn('no position defined', str(form.errors))
  765. class VCPositionTokenFormTestCase(TestCase):
  766. @classmethod
  767. def setUpTestData(cls):
  768. Site.objects.create(name='Site VC 1', slug='site-vc-1')
  769. manufacturer = Manufacturer.objects.create(name='Manufacturer VC 1', slug='manufacturer-vc-1')
  770. device_type = DeviceType.objects.create(
  771. manufacturer=manufacturer, model='Device Type VC 1', slug='device-type-vc-1'
  772. )
  773. DeviceRole.objects.create(name='Device Role VC 1', slug='device-role-vc-1', color='ff0000')
  774. InterfaceTemplate.objects.create(
  775. device_type=device_type,
  776. name='ge-{vc_position:0}/0/0',
  777. type='1000base-t',
  778. )
  779. VirtualChassis.objects.create(name='VC 1')
  780. def test_device_creation_in_vc_resolves_vc_position(self):
  781. form = DeviceForm(data={
  782. 'name': 'Device VC Form 1',
  783. 'role': DeviceRole.objects.first().pk,
  784. 'tenant': None,
  785. 'manufacturer': Manufacturer.objects.first().pk,
  786. 'device_type': DeviceType.objects.first().pk,
  787. 'site': Site.objects.first().pk,
  788. 'rack': None,
  789. 'face': None,
  790. 'position': None,
  791. 'platform': None,
  792. 'status': DeviceStatusChoices.STATUS_ACTIVE,
  793. 'replicate_components': True,
  794. 'virtual_chassis': VirtualChassis.objects.first().pk,
  795. 'vc_position': 2,
  796. })
  797. self.assertTrue(form.is_valid())
  798. device = form.save()
  799. self.assertTrue(device.interfaces.filter(name='ge-2/0/0').exists())
  800. def test_device_creation_not_in_vc_uses_fallback(self):
  801. form = DeviceForm(data={
  802. 'name': 'Device VC Form 2',
  803. 'role': DeviceRole.objects.first().pk,
  804. 'tenant': None,
  805. 'manufacturer': Manufacturer.objects.first().pk,
  806. 'device_type': DeviceType.objects.first().pk,
  807. 'site': Site.objects.first().pk,
  808. 'rack': None,
  809. 'face': None,
  810. 'position': None,
  811. 'platform': None,
  812. 'status': DeviceStatusChoices.STATUS_ACTIVE,
  813. 'replicate_components': True,
  814. })
  815. self.assertTrue(form.is_valid())
  816. device = form.save()
  817. self.assertTrue(device.interfaces.filter(name='ge-0/0/0').exists())
  818. def test_device_creation_duplicate_name_conflict(self):
  819. # With conflict
  820. device_type = DeviceType.objects.first()
  821. # to generate conflicts create an interface that will exist
  822. InterfaceTemplate.objects.create(
  823. device_type=device_type,
  824. name='ge-0/0/0',
  825. type='1000base-t',
  826. )
  827. form = DeviceForm(data={
  828. 'name': 'Device VC Form 3',
  829. 'role': DeviceRole.objects.first().pk,
  830. 'tenant': None,
  831. 'manufacturer': Manufacturer.objects.first().pk,
  832. 'device_type': device_type.pk,
  833. 'site': Site.objects.first().pk,
  834. 'rack': None,
  835. 'face': None,
  836. 'position': None,
  837. 'platform': None,
  838. 'status': DeviceStatusChoices.STATUS_ACTIVE,
  839. 'replicate_components': True,
  840. })
  841. self.assertTrue(form.is_valid())
  842. with self.assertRaises(AbortRequest):
  843. form.save()
  844. class FrontPortTestCase(TestCase):
  845. @classmethod
  846. def setUpTestData(cls):
  847. cls.device = create_test_device('Panel Device 1')
  848. cls.rear_ports = (
  849. RearPort(name='RearPort1', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
  850. RearPort(name='RearPort2', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
  851. RearPort(name='RearPort3', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
  852. RearPort(name='RearPort4', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
  853. )
  854. RearPort.objects.bulk_create(cls.rear_ports)
  855. cls.rear_port_templates = (
  856. RearPortTemplate(name='RearPort1', device_type=cls.device.device_type, type=PortTypeChoices.TYPE_8P8C),
  857. RearPortTemplate(name='RearPort2', device_type=cls.device.device_type, type=PortTypeChoices.TYPE_8P8C),
  858. RearPortTemplate(name='RearPort3', device_type=cls.device.device_type, type=PortTypeChoices.TYPE_8P8C),
  859. RearPortTemplate(name='RearPort4', device_type=cls.device.device_type, type=PortTypeChoices.TYPE_8P8C),
  860. )
  861. RearPortTemplate.objects.bulk_create(cls.rear_port_templates)
  862. def test_front_port_label_count_valid(self):
  863. """
  864. Test that generating an equal number of names and labels passes form validation.
  865. """
  866. front_port_data = {
  867. 'device': self.device.pk,
  868. 'name': 'FrontPort[1-4]',
  869. 'label': 'Port[1-4]',
  870. 'type': PortTypeChoices.TYPE_8P8C,
  871. 'positions': 1,
  872. 'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports],
  873. }
  874. form = FrontPortCreateForm(front_port_data)
  875. self.assertTrue(form.is_valid())
  876. def test_front_port_label_count_mismatch(self):
  877. """
  878. Check that attempting to generate a differing number of names and labels results in a validation error.
  879. """
  880. bad_front_port_data = {
  881. 'device': self.device.pk,
  882. 'name': 'FrontPort[1-4]',
  883. 'label': 'Port[1-2]',
  884. 'type': PortTypeChoices.TYPE_8P8C,
  885. 'positions': 1,
  886. 'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports],
  887. }
  888. form = FrontPortCreateForm(bad_front_port_data)
  889. self.assertFalse(form.is_valid())
  890. self.assertIn('label', form.errors)
  891. def test_front_port_position_count_valid(self):
  892. """
  893. Test that generating front ports with multiple positions each passes form validation.
  894. """
  895. front_port_data = {
  896. 'device': self.device.pk,
  897. 'name': 'FrontPort[1-2]',
  898. 'type': PortTypeChoices.TYPE_8P8C,
  899. 'positions': 2,
  900. 'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports],
  901. }
  902. form = FrontPortCreateForm(front_port_data)
  903. self.assertTrue(form.is_valid(), form.errors)
  904. def test_front_port_position_count_mismatch(self):
  905. """
  906. Check that the mismatch error reports the total number of front port positions, not the port count.
  907. """
  908. bad_front_port_data = {
  909. 'device': self.device.pk,
  910. 'name': 'FrontPort[1-2]',
  911. 'type': PortTypeChoices.TYPE_8P8C,
  912. 'positions': 2,
  913. 'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports[:2]],
  914. }
  915. form = FrontPortCreateForm(bad_front_port_data)
  916. self.assertFalse(form.is_valid())
  917. self.assertIn(
  918. 'The total number of front port positions (4) must match the selected number of rear port '
  919. 'positions (2).',
  920. form.errors['rear_ports']
  921. )
  922. def test_front_port_template_position_count_mismatch(self):
  923. """
  924. Check that the front port template form reports the same corrected position total.
  925. """
  926. bad_front_port_template_data = {
  927. 'device_type': self.device.device_type.pk,
  928. 'name': 'FrontPort[1-2]',
  929. 'type': PortTypeChoices.TYPE_8P8C,
  930. 'positions': 2,
  931. 'rear_ports': [f'{rear_port_template.pk}:1' for rear_port_template in self.rear_port_templates[:2]],
  932. }
  933. form = FrontPortTemplateCreateForm(bad_front_port_template_data)
  934. self.assertFalse(form.is_valid())
  935. self.assertIn(
  936. 'The total number of front port positions (4) must match the selected number of rear port '
  937. 'positions (2).',
  938. form.errors['rear_ports']
  939. )
  940. def test_front_port_missing_rear_ports(self):
  941. """
  942. Check that omitting the rear port selection reports a field error rather than raising an exception.
  943. """
  944. bad_front_port_data = {
  945. 'device': self.device.pk,
  946. 'name': 'FrontPort[1-2]',
  947. 'type': PortTypeChoices.TYPE_8P8C,
  948. 'positions': 1,
  949. }
  950. form = FrontPortCreateForm(bad_front_port_data)
  951. self.assertFalse(form.is_valid())
  952. self.assertIn('rear_ports', form.errors)
  953. def test_front_port_invalid_positions(self):
  954. """
  955. Check that a non-numeric position count reports a field error rather than raising an exception.
  956. """
  957. bad_front_port_data = {
  958. 'device': self.device.pk,
  959. 'name': 'FrontPort[1-2]',
  960. 'type': PortTypeChoices.TYPE_8P8C,
  961. 'positions': 'two',
  962. 'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports[:2]],
  963. }
  964. form = FrontPortCreateForm(bad_front_port_data)
  965. self.assertFalse(form.is_valid())
  966. self.assertIn('positions', form.errors)
  967. def test_front_port_template_missing_rear_ports(self):
  968. """
  969. Check that the front port template form also reports a field error rather than raising an exception.
  970. """
  971. bad_front_port_template_data = {
  972. 'device_type': self.device.device_type.pk,
  973. 'name': 'FrontPort[1-2]',
  974. 'type': PortTypeChoices.TYPE_8P8C,
  975. 'positions': 1,
  976. }
  977. form = FrontPortTemplateCreateForm(bad_front_port_template_data)
  978. self.assertFalse(form.is_valid())
  979. self.assertIn('rear_ports', form.errors)
  980. def test_front_port_invalid_label_range(self):
  981. """
  982. Check that an inverted label range reports a field error rather than raising an exception.
  983. """
  984. bad_front_port_data = {
  985. 'device': self.device.pk,
  986. 'name': 'FrontPort[1-2]',
  987. 'label': 'Port[2-1]',
  988. 'type': PortTypeChoices.TYPE_8P8C,
  989. 'positions': 1,
  990. 'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports[:2]],
  991. }
  992. form = FrontPortCreateForm(bad_front_port_data)
  993. self.assertFalse(form.is_valid())
  994. self.assertIn('label', form.errors)
  995. class InterfaceTestCase(TestCase):
  996. @classmethod
  997. def setUpTestData(cls):
  998. cls.device = create_test_device('Device 1')
  999. cls.vlans = (
  1000. VLAN(name='VLAN 1', vid=1),
  1001. VLAN(name='VLAN 2', vid=2),
  1002. VLAN(name='VLAN 3', vid=3),
  1003. )
  1004. VLAN.objects.bulk_create(cls.vlans)
  1005. cls.interface = Interface.objects.create(
  1006. device=cls.device,
  1007. name='Interface 1',
  1008. type=InterfaceTypeChoices.TYPE_1GE_GBIC,
  1009. mode=InterfaceModeChoices.MODE_TAGGED,
  1010. )
  1011. def test_interface_label_count_valid(self):
  1012. """
  1013. Test that generating an equal number of names and labels passes form validation.
  1014. """
  1015. interface_data = {
  1016. 'device': self.device.pk,
  1017. 'name': 'eth[0-9]',
  1018. 'label': 'Interface[0-9]',
  1019. 'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
  1020. }
  1021. form = InterfaceCreateForm(interface_data)
  1022. self.assertTrue(form.is_valid())
  1023. def test_interface_label_count_mismatch(self):
  1024. """
  1025. Check that attempting to generate a differing number of names and labels results in a validation error.
  1026. """
  1027. bad_interface_data = {
  1028. 'device': self.device.pk,
  1029. 'name': 'eth[0-9]',
  1030. 'label': 'Interface[0-1]',
  1031. 'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
  1032. }
  1033. form = InterfaceCreateForm(bad_interface_data)
  1034. self.assertFalse(form.is_valid())
  1035. self.assertIn('label', form.errors)
  1036. def test_create_interface_mode_valid_data(self):
  1037. """
  1038. Test that saving valid interface mode and tagged/untagged vlans works properly
  1039. """
  1040. # Validate access mode
  1041. data = {
  1042. 'device': self.device.pk,
  1043. 'name': 'ethernet1/1',
  1044. 'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
  1045. 'mode': InterfaceModeChoices.MODE_ACCESS,
  1046. 'untagged_vlan': self.vlans[0].pk
  1047. }
  1048. form = InterfaceCreateForm(data)
  1049. self.assertTrue(form.is_valid())
  1050. # Validate tagged vlans
  1051. data = {
  1052. 'device': self.device.pk,
  1053. 'name': 'ethernet1/2',
  1054. 'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
  1055. 'mode': InterfaceModeChoices.MODE_TAGGED,
  1056. 'untagged_vlan': self.vlans[0].pk,
  1057. 'tagged_vlans': [self.vlans[1].pk, self.vlans[2].pk]
  1058. }
  1059. form = InterfaceCreateForm(data)
  1060. self.assertTrue(form.is_valid())
  1061. # Validate tagged vlans
  1062. data = {
  1063. 'device': self.device.pk,
  1064. 'name': 'ethernet1/3',
  1065. 'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
  1066. 'mode': InterfaceModeChoices.MODE_TAGGED_ALL,
  1067. 'untagged_vlan': self.vlans[0].pk,
  1068. }
  1069. form = InterfaceCreateForm(data)
  1070. self.assertTrue(form.is_valid())
  1071. def test_create_interface_mode_access_invalid_data(self):
  1072. """
  1073. Test that saving invalid interface mode and tagged/untagged vlans works properly
  1074. """
  1075. data = {
  1076. 'device': self.device.pk,
  1077. 'name': 'ethernet1/4',
  1078. 'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
  1079. 'mode': InterfaceModeChoices.MODE_ACCESS,
  1080. 'untagged_vlan': self.vlans[0].pk,
  1081. 'tagged_vlans': [self.vlans[1].pk, self.vlans[2].pk]
  1082. }
  1083. form = InterfaceCreateForm(data)
  1084. self.assertTrue(form.is_valid())
  1085. self.assertIn('untagged_vlan', form.cleaned_data.keys())
  1086. self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
  1087. self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
  1088. def test_edit_interface_mode_access_invalid_data(self):
  1089. """
  1090. Test that saving invalid interface mode and tagged/untagged vlans works properly
  1091. """
  1092. data = {
  1093. 'device': self.device.pk,
  1094. 'name': 'Ethernet 1/5',
  1095. 'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
  1096. 'mode': InterfaceModeChoices.MODE_ACCESS,
  1097. 'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
  1098. }
  1099. form = InterfaceForm(data, instance=self.interface)
  1100. self.assertTrue(form.is_valid())
  1101. self.assertIn('untagged_vlan', form.cleaned_data.keys())
  1102. self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
  1103. self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
  1104. def test_create_interface_mode_tagged_all_invalid_data(self):
  1105. """
  1106. Test that saving invalid interface mode and tagged/untagged vlans works properly
  1107. """
  1108. data = {
  1109. 'device': self.device.pk,
  1110. 'name': 'ethernet1/6',
  1111. 'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
  1112. 'mode': InterfaceModeChoices.MODE_TAGGED_ALL,
  1113. 'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
  1114. }
  1115. form = InterfaceCreateForm(data)
  1116. self.assertTrue(form.is_valid())
  1117. self.assertIn('untagged_vlan', form.cleaned_data.keys())
  1118. self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
  1119. self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
  1120. def test_edit_interface_mode_tagged_all_invalid_data(self):
  1121. """
  1122. Test that saving invalid interface mode and tagged/untagged vlans works properly
  1123. """
  1124. data = {
  1125. 'device': self.device.pk,
  1126. 'name': 'Ethernet 1/7',
  1127. 'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
  1128. 'mode': InterfaceModeChoices.MODE_TAGGED_ALL,
  1129. 'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
  1130. }
  1131. form = InterfaceForm(data)
  1132. self.assertTrue(form.is_valid())
  1133. self.assertIn('untagged_vlan', form.cleaned_data.keys())
  1134. self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
  1135. self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
  1136. def test_create_interface_mode_routed_invalid_data(self):
  1137. """
  1138. Test that saving invalid interface mode (routed) and tagged/untagged vlans works properly
  1139. """
  1140. data = {
  1141. 'device': self.device.pk,
  1142. 'name': 'ethernet1/6',
  1143. 'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
  1144. 'mode': None,
  1145. 'untagged_vlan': self.vlans[0].pk,
  1146. 'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
  1147. }
  1148. form = InterfaceCreateForm(data)
  1149. self.assertTrue(form.is_valid())
  1150. self.assertNotIn('untagged_vlan', form.cleaned_data.keys())
  1151. self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
  1152. self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
  1153. def test_edit_interface_mode_routed_invalid_data(self):
  1154. """
  1155. Test that saving invalid interface mode (routed) and tagged/untagged vlans works properly
  1156. """
  1157. data = {
  1158. 'device': self.device.pk,
  1159. 'name': 'Ethernet 1/7',
  1160. 'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
  1161. 'mode': None,
  1162. 'untagged_vlan': self.vlans[0].pk,
  1163. 'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
  1164. }
  1165. form = InterfaceForm(data)
  1166. self.assertTrue(form.is_valid())
  1167. self.assertNotIn('untagged_vlan', form.cleaned_data.keys())
  1168. self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
  1169. self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
  1170. def test_child_interface_preselects_every_parent_vdc(self):
  1171. """A child interface's form pre-selects every virtual device context of its parent."""
  1172. vdcs = (
  1173. VirtualDeviceContext(
  1174. device=self.device,
  1175. name='VDC 1',
  1176. identifier=1,
  1177. status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE
  1178. ),
  1179. VirtualDeviceContext(
  1180. device=self.device,
  1181. name='VDC 2',
  1182. identifier=2,
  1183. status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE
  1184. ),
  1185. )
  1186. VirtualDeviceContext.objects.bulk_create(vdcs)
  1187. self.interface.vdcs.set(vdcs)
  1188. form = InterfaceForm(initial={'device': self.device.pk, 'parent': self.interface.pk})
  1189. self.assertCountEqual([vdc.pk for vdc in form['vdcs'].field.queryset], [vdc.pk for vdc in vdcs])
  1190. class CableTestCase(TestCase):
  1191. @classmethod
  1192. def setUpTestData(cls):
  1193. cls.site = Site.objects.create(name='Site 1', slug='site-1')
  1194. cls.device_a = create_test_device('Device A', site=cls.site)
  1195. cls.device_b = create_test_device('Device B', site=cls.site)
  1196. cls.device_c = create_test_device('Device C', site=cls.site)
  1197. cls.interfaces_a = (
  1198. Interface(device=cls.device_a, name='et-0/0/0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
  1199. Interface(device=cls.device_a, name='et-0/0/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
  1200. )
  1201. cls.interfaces_b = (
  1202. Interface(device=cls.device_b, name='et-0/0/0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
  1203. Interface(device=cls.device_b, name='et-0/0/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
  1204. Interface(device=cls.device_b, name='et-0/0/2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
  1205. )
  1206. cls.interface_c = Interface(device=cls.device_c, name='et-0/0/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
  1207. Interface.objects.bulk_create([*cls.interfaces_a, *cls.interfaces_b, cls.interface_c])
  1208. cls.power_panel = PowerPanel.objects.create(site=cls.site, name='Power Panel 1')
  1209. cls.power_feeds = (
  1210. PowerFeed(power_panel=cls.power_panel, name='Power Feed 1'),
  1211. PowerFeed(power_panel=cls.power_panel, name='Power Feed 2'),
  1212. )
  1213. PowerFeed.objects.bulk_create(cls.power_feeds)
  1214. cls.power_ports = (
  1215. PowerPort(device=cls.device_b, name='Power Port 1'),
  1216. PowerPort(device=cls.device_b, name='Power Port 2'),
  1217. )
  1218. PowerPort.objects.bulk_create(cls.power_ports)
  1219. def _make_edit_form(self):
  1220. cable = Cable(
  1221. a_terminations=[self.interfaces_a[0]],
  1222. b_terminations=[self.interfaces_b[1], self.interface_c],
  1223. )
  1224. cable.save()
  1225. return get_cable_form(Interface, Interface)(instance=Cable.objects.get(pk=cable.pk))
  1226. def test_invalid_side_designation_raises_value_error(self):
  1227. """_clean_side rejects a side other than 'a' or 'b' with ValueError."""
  1228. form = CableImportForm.__new__(CableImportForm)
  1229. with self.assertRaisesMessage(ValueError, "Invalid side designation: c"):
  1230. form._clean_side('c')
  1231. def test_import_single_termination_cable(self):
  1232. """A single-value cell per side resolves one termination per side."""
  1233. form = CableImportForm(data={
  1234. 'side_a_site': 'Site 1',
  1235. 'side_a_device': 'Device A',
  1236. 'side_a_type': 'dcim.interface',
  1237. 'side_a_name': 'et-0/0/0',
  1238. 'side_b_site': 'Site 1',
  1239. 'side_b_device': 'Device B',
  1240. 'side_b_type': 'dcim.interface',
  1241. 'side_b_name': 'et-0/0/0',
  1242. 'status': LinkStatusChoices.STATUS_CONNECTED,
  1243. })
  1244. self.assertTrue(form.is_valid(), form.errors)
  1245. cable = form.save()
  1246. self.assertEqual(cable.a_terminations, [self.interfaces_a[0]])
  1247. self.assertEqual(cable.b_terminations, [self.interfaces_b[0]])
  1248. def test_import_multiple_terminations_single_parent(self):
  1249. """A single parent value is reused for all comma-separated termination names."""
  1250. form = CableImportForm(data={
  1251. 'side_a_device': 'Device A',
  1252. 'side_a_type': 'dcim.interface',
  1253. 'side_a_name': 'et-0/0/0',
  1254. 'side_b_device': 'Device B',
  1255. 'side_b_type': 'dcim.interface',
  1256. 'side_b_name': 'et-0/0/1, et-0/0/2',
  1257. 'status': LinkStatusChoices.STATUS_CONNECTED,
  1258. 'profile': CableProfileChoices.BREAKOUT_1C2P_2C1P,
  1259. })
  1260. self.assertTrue(form.is_valid(), form.errors)
  1261. cable = form.save()
  1262. self.assertEqual(cable.a_terminations, [self.interfaces_a[0]])
  1263. self.assertEqual(cable.b_terminations, [self.interfaces_b[1], self.interfaces_b[2]])
  1264. def test_import_multiple_terminations_multiple_parents_preserves_order(self):
  1265. """Pairwise parent/name lists resolve in submitted order, driving connector assignment."""
  1266. form = CableImportForm(data={
  1267. 'side_a_device': 'Device A',
  1268. 'side_a_type': 'dcim.interface',
  1269. 'side_a_name': 'et-0/0/0',
  1270. 'side_b_device': 'Device C,Device B',
  1271. 'side_b_type': 'dcim.interface',
  1272. 'side_b_name': 'et-0/0/1,et-0/0/1',
  1273. 'status': LinkStatusChoices.STATUS_CONNECTED,
  1274. 'profile': CableProfileChoices.BREAKOUT_1C2P_2C1P,
  1275. })
  1276. self.assertTrue(form.is_valid(), form.errors)
  1277. cable = form.save()
  1278. self.assertEqual(cable.b_terminations, [self.interface_c, self.interfaces_b[1]])
  1279. cable_terminations = CableTermination.objects.filter(
  1280. cable=cable, cable_end=CableEndChoices.SIDE_B
  1281. ).order_by('connector')
  1282. self.assertEqual([ct.termination for ct in cable_terminations], [self.interface_c, self.interfaces_b[1]])
  1283. self.assertEqual([ct.connector for ct in cable_terminations], [1, 2])
  1284. def test_import_multiple_terminations_parent_count_mismatch(self):
  1285. """A parent list that is neither one value nor one per termination name is rejected."""
  1286. form = CableImportForm(data={
  1287. 'side_a_device': 'Device A',
  1288. 'side_a_type': 'dcim.interface',
  1289. 'side_a_name': 'et-0/0/0',
  1290. 'side_b_device': 'Device B,Device C',
  1291. 'side_b_type': 'dcim.interface',
  1292. 'side_b_name': 'et-0/0/1,et-0/0/1,et-0/0/2',
  1293. 'status': LinkStatusChoices.STATUS_CONNECTED,
  1294. })
  1295. self.assertFalse(form.is_valid())
  1296. self.assertIn('Must specify either one device', str(form.errors.get('side_b_name')))
  1297. def test_import_multiple_terminations_duplicate_termination(self):
  1298. """The same termination cannot be listed twice on one cable end."""
  1299. form = CableImportForm(data={
  1300. 'side_a_device': 'Device A',
  1301. 'side_a_type': 'dcim.interface',
  1302. 'side_a_name': 'et-0/0/0',
  1303. 'side_b_device': 'Device B',
  1304. 'side_b_type': 'dcim.interface',
  1305. 'side_b_name': 'et-0/0/1,et-0/0/1',
  1306. 'status': LinkStatusChoices.STATUS_CONNECTED,
  1307. })
  1308. self.assertFalse(form.is_valid())
  1309. self.assertIn('Duplicate termination', str(form.errors.get('side_b_name')))
  1310. def test_import_terminations_exceeding_profile_capacity(self):
  1311. """A side carrying more terminations than its profile permits reports against that side's column."""
  1312. form = CableImportForm(data={
  1313. 'side_a_device': 'Device A',
  1314. 'side_a_type': 'dcim.interface',
  1315. 'side_a_name': 'et-0/0/0',
  1316. 'side_b_device': 'Device B',
  1317. 'side_b_type': 'dcim.interface',
  1318. 'side_b_name': 'et-0/0/0,et-0/0/1,et-0/0/2',
  1319. 'status': LinkStatusChoices.STATUS_CONNECTED,
  1320. 'profile': CableProfileChoices.BREAKOUT_1C2P_2C1P,
  1321. })
  1322. self.assertFalse(form.is_valid())
  1323. self.assertIn('only 2 are permitted', str(form.errors.get('side_b_name')))
  1324. def test_import_terminations_exceeding_profile_capacity_side_a(self):
  1325. """The same applies to side A, whose profile capacity is often lower than side B's."""
  1326. form = CableImportForm(data={
  1327. 'side_a_device': 'Device A',
  1328. 'side_a_type': 'dcim.interface',
  1329. 'side_a_name': 'et-0/0/0,et-0/0/1',
  1330. 'side_b_device': 'Device B',
  1331. 'side_b_type': 'dcim.interface',
  1332. 'side_b_name': 'et-0/0/0',
  1333. 'status': LinkStatusChoices.STATUS_CONNECTED,
  1334. 'profile': CableProfileChoices.BREAKOUT_1C2P_2C1P,
  1335. })
  1336. self.assertFalse(form.is_valid())
  1337. self.assertIn('only 1 are permitted', str(form.errors.get('side_a_name')))
  1338. def test_import_multiple_terminations_empty_name(self):
  1339. """A trailing comma produces an empty termination name and is rejected."""
  1340. form = CableImportForm(data={
  1341. 'side_a_device': 'Device A',
  1342. 'side_a_type': 'dcim.interface',
  1343. 'side_a_name': 'et-0/0/0',
  1344. 'side_b_device': 'Device B',
  1345. 'side_b_type': 'dcim.interface',
  1346. 'side_b_name': 'et-0/0/1,',
  1347. 'status': LinkStatusChoices.STATUS_CONNECTED,
  1348. })
  1349. self.assertFalse(form.is_valid())
  1350. self.assertIn('Empty termination names', str(form.errors.get('side_b_name')))
  1351. def test_import_multiple_terminations_connected_termination(self):
  1352. """An already-cabled termination in a multi-value list is rejected."""
  1353. cable = Cable(a_terminations=[self.interfaces_a[1]], b_terminations=[self.interfaces_b[1]])
  1354. cable.save()
  1355. form = CableImportForm(data={
  1356. 'side_a_device': 'Device A',
  1357. 'side_a_type': 'dcim.interface',
  1358. 'side_a_name': 'et-0/0/0',
  1359. 'side_b_device': 'Device B',
  1360. 'side_b_type': 'dcim.interface',
  1361. 'side_b_name': 'et-0/0/1,et-0/0/2',
  1362. 'status': LinkStatusChoices.STATUS_CONNECTED,
  1363. })
  1364. self.assertFalse(form.is_valid())
  1365. self.assertIn('already connected', str(form.errors.get('side_b_name')))
  1366. def test_import_multiple_terminations_power_feeds(self):
  1367. """Multiple power feeds import from a single broadcast power panel."""
  1368. form = CableImportForm(data={
  1369. 'side_a_power_panel': 'Power Panel 1',
  1370. 'side_a_type': 'dcim.powerfeed',
  1371. 'side_a_name': 'Power Feed 1,Power Feed 2',
  1372. 'side_b_device': 'Device B',
  1373. 'side_b_type': 'dcim.powerport',
  1374. 'side_b_name': 'Power Port 1,Power Port 2',
  1375. 'status': LinkStatusChoices.STATUS_CONNECTED,
  1376. })
  1377. self.assertTrue(form.is_valid(), form.errors)
  1378. cable = form.save()
  1379. self.assertEqual(cable.a_terminations, list(self.power_feeds))
  1380. self.assertEqual(cable.b_terminations, list(self.power_ports))
  1381. def test_import_multiple_terminations_repeated_parent_values(self):
  1382. """A repeated parent in a pairwise list resolves per position, not deduplicated."""
  1383. form = CableImportForm(data={
  1384. 'side_a_device': 'Device A',
  1385. 'side_a_type': 'dcim.interface',
  1386. 'side_a_name': 'et-0/0/0',
  1387. 'side_b_device': 'Device B,Device C,Device B',
  1388. 'side_b_type': 'dcim.interface',
  1389. 'side_b_name': 'et-0/0/1,et-0/0/1,et-0/0/2',
  1390. 'status': LinkStatusChoices.STATUS_CONNECTED,
  1391. })
  1392. self.assertTrue(form.is_valid(), form.errors)
  1393. cable = form.save()
  1394. self.assertEqual(
  1395. cable.b_terminations,
  1396. [self.interfaces_b[1], self.interface_c, self.interfaces_b[2]]
  1397. )
  1398. def test_import_multiple_terminations_native_lists(self):
  1399. """Native list values (JSON/YAML import) resolve like comma-separated cells."""
  1400. form = CableImportForm(data={
  1401. 'side_a_device': 'Device A',
  1402. 'side_a_type': 'dcim.interface',
  1403. 'side_a_name': 'et-0/0/0',
  1404. 'side_b_device': ['Device C', 'Device B'],
  1405. 'side_b_type': 'dcim.interface',
  1406. 'side_b_name': ['et-0/0/1', 'et-0/0/1'],
  1407. 'status': LinkStatusChoices.STATUS_CONNECTED,
  1408. 'profile': CableProfileChoices.BREAKOUT_1C2P_2C1P,
  1409. })
  1410. self.assertTrue(form.is_valid(), form.errors)
  1411. cable = form.save()
  1412. self.assertEqual(cable.b_terminations, [self.interface_c, self.interfaces_b[1]])
  1413. def test_import_multiple_terminations_unknown_parent(self):
  1414. """An unknown parent in a multi-value cell errors on the parent field only."""
  1415. form = CableImportForm(data={
  1416. 'side_a_device': 'Device A',
  1417. 'side_a_type': 'dcim.interface',
  1418. 'side_a_name': 'et-0/0/0',
  1419. 'side_b_device': 'Device B,Device X',
  1420. 'side_b_type': 'dcim.interface',
  1421. 'side_b_name': 'et-0/0/1,et-0/0/2',
  1422. 'status': LinkStatusChoices.STATUS_CONNECTED,
  1423. })
  1424. self.assertFalse(form.is_valid())
  1425. self.assertIn('Object not found: Device X', str(form.errors.get('side_b_device')))
  1426. self.assertNotIn('side_b_name', form.errors)
  1427. def test_import_multiple_terminations_missing_parent(self):
  1428. """A device component termination type without a device value is rejected."""
  1429. form = CableImportForm(data={
  1430. 'side_a_device': 'Device A',
  1431. 'side_a_type': 'dcim.interface',
  1432. 'side_a_name': 'et-0/0/0',
  1433. 'side_b_type': 'dcim.interface',
  1434. 'side_b_name': 'et-0/0/1,et-0/0/2',
  1435. 'status': LinkStatusChoices.STATUS_CONNECTED,
  1436. })
  1437. self.assertFalse(form.is_valid())
  1438. self.assertIn('Must specify a device', str(form.errors.get('side_b_name')))
  1439. def test_import_unsupported_termination_type(self):
  1440. """Termination types without a supported parent field are rejected."""
  1441. form = CableImportForm(data={
  1442. 'side_a_device': 'Device A',
  1443. 'side_a_type': 'dcim.interface',
  1444. 'side_a_name': 'et-0/0/0',
  1445. 'side_b_device': 'Device B',
  1446. 'side_b_type': 'circuits.circuittermination',
  1447. 'side_b_name': 'Termination X',
  1448. 'status': LinkStatusChoices.STATUS_CONNECTED,
  1449. })
  1450. self.assertFalse(form.is_valid())
  1451. self.assertIn('Bulk import does not support', str(form.errors.get('side_b_name')))
  1452. def test_import_unknown_termination_type(self):
  1453. """An unresolvable termination type errors on the type field only."""
  1454. form = CableImportForm(data={
  1455. 'side_a_device': 'Device A',
  1456. 'side_a_type': 'dcim.interface',
  1457. 'side_a_name': 'et-0/0/0',
  1458. 'side_b_device': 'Device B',
  1459. 'side_b_type': 'dcim.nosuchmodel',
  1460. 'side_b_name': 'et-0/0/1',
  1461. 'status': LinkStatusChoices.STATUS_CONNECTED,
  1462. })
  1463. self.assertFalse(form.is_valid())
  1464. self.assertIn('side_b_type', form.errors)
  1465. self.assertNotIn('side_b_name', form.errors)
  1466. def test_import_multiple_terminations_unknown_name(self):
  1467. """An unknown termination name in a multi-value list is rejected."""
  1468. form = CableImportForm(data={
  1469. 'side_a_device': 'Device A',
  1470. 'side_a_type': 'dcim.interface',
  1471. 'side_a_name': 'et-0/0/0',
  1472. 'side_b_device': 'Device B',
  1473. 'side_b_type': 'dcim.interface',
  1474. 'side_b_name': 'et-0/0/1,et-0/0/9',
  1475. 'status': LinkStatusChoices.STATUS_CONNECTED,
  1476. })
  1477. self.assertFalse(form.is_valid())
  1478. self.assertIn('side termination not found', str(form.errors.get('side_b_name')))
  1479. def test_import_multiple_terminations_ambiguous_parent(self):
  1480. """A parent name matching multiple objects errors on the parent field."""
  1481. site_2 = Site.objects.create(name='Site 2', slug='site-2')
  1482. create_test_device('Device D', site=self.site)
  1483. create_test_device('Device D', site=site_2)
  1484. form = CableImportForm(data={
  1485. 'side_a_device': 'Device A',
  1486. 'side_a_type': 'dcim.interface',
  1487. 'side_a_name': 'et-0/0/0',
  1488. 'side_b_device': 'Device D',
  1489. 'side_b_type': 'dcim.interface',
  1490. 'side_b_name': 'et-0/0/1',
  1491. 'status': LinkStatusChoices.STATUS_CONNECTED,
  1492. })
  1493. self.assertFalse(form.is_valid())
  1494. self.assertIn('is not a unique value', str(form.errors.get('side_b_device')))
  1495. self.assertNotIn('side_b_name', form.errors)
  1496. def test_import_multiple_terminations_site_filtered_parent_queryset(self):
  1497. """Parent resolution honors side_x_site queryset filtering for multi-value parents."""
  1498. site_2 = Site.objects.create(name='Site 2', slug='site-2')
  1499. device_x = create_test_device('Device X', site=site_2)
  1500. Interface.objects.create(device=device_x, name='et-0/0/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
  1501. form = CableImportForm(data={
  1502. 'side_a_site': 'Site 1',
  1503. 'side_a_device': 'Device A',
  1504. 'side_a_type': 'dcim.interface',
  1505. 'side_a_name': 'et-0/0/0',
  1506. 'side_b_site': 'Site 1',
  1507. 'side_b_device': 'Device B,Device X',
  1508. 'side_b_type': 'dcim.interface',
  1509. 'side_b_name': 'et-0/0/1,et-0/0/1',
  1510. 'status': LinkStatusChoices.STATUS_CONNECTED,
  1511. })
  1512. self.assertFalse(form.is_valid())
  1513. self.assertIn('Object not found: Device X', str(form.errors.get('side_b_device')))
  1514. self.assertNotIn('side_b_name', form.errors)
  1515. def test_import_ambiguous_vc_component(self):
  1516. """A component name found on multiple VC members produces a form error."""
  1517. vc = VirtualChassis.objects.create(name='Virtual Chassis 1')
  1518. master = create_test_device('VC Master', site=self.site, virtual_chassis=vc, vc_position=1)
  1519. member_2 = create_test_device('VC Member 2', site=self.site, virtual_chassis=vc, vc_position=2)
  1520. member_3 = create_test_device('VC Member 3', site=self.site, virtual_chassis=vc, vc_position=3)
  1521. vc.master = master
  1522. vc.save()
  1523. Interface.objects.create(device=member_2, name='vc-eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
  1524. Interface.objects.create(device=member_3, name='vc-eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
  1525. form = CableImportForm(data={
  1526. 'side_a_device': 'Device A',
  1527. 'side_a_type': 'dcim.interface',
  1528. 'side_a_name': 'et-0/0/0',
  1529. 'side_b_device': 'VC Master',
  1530. 'side_b_type': 'dcim.interface',
  1531. 'side_b_name': 'vc-eth0',
  1532. 'status': LinkStatusChoices.STATUS_CONNECTED,
  1533. })
  1534. self.assertFalse(form.is_valid())
  1535. self.assertIn('side termination not unique', str(form.errors.get('side_b_name')))
  1536. def test_edit_form_selects_every_device_terminating_an_end(self):
  1537. """The edit form selects every device terminating an end of the cable."""
  1538. form = self._make_edit_form()
  1539. self.assertCountEqual(
  1540. [device.pk for device in form['termination_b_device'].field.queryset],
  1541. [self.device_b.pk, self.device_c.pk]
  1542. )
  1543. def test_edit_form_names_the_device_of_each_termination(self):
  1544. """Each termination option names its device, so identically named interfaces stay distinct."""
  1545. html = str(self._make_edit_form()['b_terminations'])
  1546. for interface in (self.interfaces_b[1], self.interface_c):
  1547. self.assertInHTML(
  1548. f'<option value="{interface.pk}" data-parent="{interface.device}" selected>et-0/0/1</option>', html
  1549. )
  1550. def test_termination_fields_preserve_their_selection(self):
  1551. """Only the termination fields keep their selection when a parent selector changes."""
  1552. for termination_type, parent in (
  1553. (Interface, 'device'),
  1554. (PowerFeed, 'powerpanel'),
  1555. (CircuitTermination, 'circuit'),
  1556. ):
  1557. fields = get_cable_form(termination_type, termination_type)().fields
  1558. with self.subTest(termination_type=termination_type.__name__):
  1559. self.assertIn('data-preserve-selection', fields['b_terminations'].widget.attrs)
  1560. self.assertNotIn('data-preserve-selection', fields[f'termination_b_{parent}'].widget.attrs)
  1561. def test_edit_form_saves_terminations_outside_the_device_filter(self):
  1562. """Saving keeps every selected termination and its row, even when its device is not in the Device filter."""
  1563. cable = self._make_edit_form().instance
  1564. rows = CableTermination.objects.filter(cable=cable, cable_end=CableEndChoices.SIDE_B)
  1565. existing_rows = set(rows.values_list('pk', flat=True))
  1566. device_d = create_test_device('Device D', site=self.site)
  1567. interface_d = Interface.objects.create(
  1568. device=device_d, name='et-0/0/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  1569. )
  1570. b_terminations = [self.interfaces_b[1], self.interface_c, interface_d]
  1571. form = get_cable_form(Interface, Interface)(instance=Cable.objects.get(pk=cable.pk), data={
  1572. 'a_terminations_type': 'dcim.interface',
  1573. 'b_terminations_type': 'dcim.interface',
  1574. 'termination_a_device': [self.device_a.pk],
  1575. 'termination_b_device': [device_d.pk],
  1576. 'a_terminations': [self.interfaces_a[0].pk],
  1577. 'b_terminations': [interface.pk for interface in b_terminations],
  1578. 'status': LinkStatusChoices.STATUS_CONNECTED,
  1579. })
  1580. self.assertTrue(form.is_valid(), form.errors)
  1581. form.save()
  1582. self.assertCountEqual(Cable.objects.get(pk=cable.pk).b_terminations, b_terminations)
  1583. self.assertTrue(existing_rows.issubset(rows.values_list('pk', flat=True)))
  1584. class SiteFormTestCase(TestCase):
  1585. """
  1586. Tests for M2MAddRemoveFields using Site ASN assignments as the test case.
  1587. Covers both simple mode (single multi-select field) and add/remove mode (dual fields).
  1588. """
  1589. @classmethod
  1590. def setUpTestData(cls):
  1591. cls.rir = RIR.objects.create(name='RIR 1', slug='rir-1')
  1592. # Create 110 ASNs: 100 to pre-assign (triggering add/remove mode) plus 10 extras
  1593. ASN.objects.bulk_create([ASN(asn=i, rir=cls.rir) for i in range(1, 111)])
  1594. cls.asns = list(ASN.objects.order_by('asn'))
  1595. def _site_data(self, **kwargs):
  1596. data = {'name': 'Test Site', 'slug': 'test-site', 'status': 'active'}
  1597. data.update(kwargs)
  1598. return data
  1599. def test_new_site_uses_simple_mode(self):
  1600. """A form for a new site uses the single 'asns' field (simple mode)."""
  1601. form = SiteForm(data=self._site_data())
  1602. self.assertIn('asns', form.fields)
  1603. self.assertNotIn('add_asns', form.fields)
  1604. self.assertNotIn('remove_asns', form.fields)
  1605. def test_existing_site_below_threshold_uses_simple_mode(self):
  1606. """A form for an existing site with fewer than THRESHOLD ASNs uses simple mode."""
  1607. site = Site.objects.create(name='Site 1', slug='site-1')
  1608. site.asns.set(self.asns[:5])
  1609. form = SiteForm(instance=site)
  1610. self.assertIn('asns', form.fields)
  1611. self.assertNotIn('add_asns', form.fields)
  1612. self.assertNotIn('remove_asns', form.fields)
  1613. def test_existing_site_at_threshold_uses_add_remove_mode(self):
  1614. """A form for an existing site with THRESHOLD or more ASNs uses add/remove mode."""
  1615. site = Site.objects.create(name='Site 2', slug='site-2')
  1616. site.asns.set(self.asns[:M2MAddRemoveFields.THRESHOLD])
  1617. form = SiteForm(instance=site)
  1618. self.assertNotIn('asns', form.fields)
  1619. self.assertIn('add_asns', form.fields)
  1620. self.assertIn('remove_asns', form.fields)
  1621. def test_simple_mode_assigns_asns_on_create(self):
  1622. """Saving a new site via simple mode assigns the selected ASNs."""
  1623. asn_pks = [asn.pk for asn in self.asns[:3]]
  1624. form = SiteForm(data=self._site_data(asns=asn_pks))
  1625. self.assertTrue(form.is_valid(), form.errors)
  1626. site = form.save()
  1627. self.assertEqual(set(site.asns.values_list('pk', flat=True)), set(asn_pks))
  1628. def test_simple_mode_replaces_asns_on_edit(self):
  1629. """Saving an existing site via simple mode replaces the current ASN assignments."""
  1630. site = Site.objects.create(name='Site 3', slug='site-3')
  1631. site.asns.set(self.asns[:3])
  1632. new_asn_pks = [asn.pk for asn in self.asns[3:6]]
  1633. form = SiteForm(
  1634. data=self._site_data(name='Site 3', slug='site-3', asns=new_asn_pks),
  1635. instance=site
  1636. )
  1637. self.assertTrue(form.is_valid(), form.errors)
  1638. site = form.save()
  1639. self.assertEqual(set(site.asns.values_list('pk', flat=True)), set(new_asn_pks))
  1640. def test_add_remove_mode_adds_asns(self):
  1641. """In add/remove mode, specifying 'add_asns' appends to current assignments."""
  1642. site = Site.objects.create(name='Site 4', slug='site-4')
  1643. site.asns.set(self.asns[:M2MAddRemoveFields.THRESHOLD])
  1644. new_asn_pks = [asn.pk for asn in self.asns[M2MAddRemoveFields.THRESHOLD:]]
  1645. form = SiteForm(
  1646. data=self._site_data(name='Site 4', slug='site-4', add_asns=new_asn_pks),
  1647. instance=site
  1648. )
  1649. self.assertTrue(form.is_valid(), form.errors)
  1650. site = form.save()
  1651. self.assertEqual(site.asns.count(), len(self.asns))
  1652. def test_add_remove_mode_removes_asns(self):
  1653. """In add/remove mode, specifying 'remove_asns' drops those assignments."""
  1654. site = Site.objects.create(name='Site 5', slug='site-5')
  1655. site.asns.set(self.asns[:M2MAddRemoveFields.THRESHOLD])
  1656. remove_pks = [asn.pk for asn in self.asns[:5]]
  1657. form = SiteForm(
  1658. data=self._site_data(name='Site 5', slug='site-5', remove_asns=remove_pks),
  1659. instance=site
  1660. )
  1661. self.assertTrue(form.is_valid(), form.errors)
  1662. site = form.save()
  1663. self.assertEqual(site.asns.count(), M2MAddRemoveFields.THRESHOLD - 5)
  1664. self.assertFalse(site.asns.filter(pk__in=remove_pks).exists())
  1665. def test_add_remove_mode_simultaneous_add_and_remove(self):
  1666. """In add/remove mode, add and remove operations are applied together."""
  1667. site = Site.objects.create(name='Site 6', slug='site-6')
  1668. site.asns.set(self.asns[:M2MAddRemoveFields.THRESHOLD])
  1669. add_pks = [asn.pk for asn in self.asns[M2MAddRemoveFields.THRESHOLD:M2MAddRemoveFields.THRESHOLD + 3]]
  1670. remove_pks = [asn.pk for asn in self.asns[:3]]
  1671. form = SiteForm(
  1672. data=self._site_data(name='Site 6', slug='site-6', add_asns=add_pks, remove_asns=remove_pks),
  1673. instance=site
  1674. )
  1675. self.assertTrue(form.is_valid(), form.errors)
  1676. site = form.save()
  1677. self.assertEqual(site.asns.count(), M2MAddRemoveFields.THRESHOLD)
  1678. self.assertTrue(site.asns.filter(pk__in=add_pks).count() == 3)
  1679. self.assertFalse(site.asns.filter(pk__in=remove_pks).exists())