bulk_import.py 85 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483
  1. from django import forms
  2. from django.contrib.contenttypes.models import ContentType
  3. from django.contrib.postgres.forms.array import SimpleArrayField
  4. from django.core.exceptions import NON_FIELD_ERRORS, MultipleObjectsReturned, ObjectDoesNotExist
  5. from django.utils.functional import lazy
  6. from django.utils.html import format_html
  7. from django.utils.safestring import SafeString, mark_safe
  8. from django.utils.translation import gettext_lazy as _
  9. from dcim.choices import *
  10. from dcim.constants import *
  11. from dcim.models import *
  12. from dcim.utils import reconcile_port_mappings
  13. from extras.models import ConfigTemplate
  14. from ipam.choices import VLANQinQRoleChoices
  15. from ipam.models import VLAN, VRF, IPAddress, VLANGroup
  16. from netbox.choices import *
  17. from netbox.forms import (
  18. NestedGroupModelImportForm,
  19. NetBoxModelImportForm,
  20. OrganizationalModelImportForm,
  21. OwnerCSVMixin,
  22. PrimaryModelImportForm,
  23. )
  24. from tenancy.models import Tenant
  25. from utilities.forms.fields import (
  26. CSVChoiceField,
  27. CSVContentTypeField,
  28. CSVModelChoiceField,
  29. CSVModelMultipleChoiceField,
  30. CSVTypedChoiceField,
  31. SlugField,
  32. )
  33. from virtualization.models import Cluster, VirtualMachine, VMInterface
  34. from wireless.choices import WirelessRoleChoices
  35. from .common import ModuleCommonForm
  36. __all__ = (
  37. 'CableBundleImportForm',
  38. 'CableImportForm',
  39. 'ConsolePortImportForm',
  40. 'ConsoleServerPortImportForm',
  41. 'CoolingFeedImportForm',
  42. 'CoolingIntakeImportForm',
  43. 'CoolingOutflowImportForm',
  44. 'CoolingSourceImportForm',
  45. 'DeviceBayImportForm',
  46. 'DeviceImportForm',
  47. 'DeviceRoleImportForm',
  48. 'DeviceTypeImportForm',
  49. 'FrontPortImportForm',
  50. 'InterfaceImportForm',
  51. 'InventoryItemImportForm',
  52. 'InventoryItemRoleImportForm',
  53. 'LocationImportForm',
  54. 'MACAddressImportForm',
  55. 'ManufacturerImportForm',
  56. 'ModuleBayImportForm',
  57. 'ModuleBayTypeImportForm',
  58. 'ModuleImportForm',
  59. 'ModuleTypeImportForm',
  60. 'ModuleTypeProfileImportForm',
  61. 'PlatformImportForm',
  62. 'PowerFeedImportForm',
  63. 'PowerOutletImportForm',
  64. 'PowerPanelImportForm',
  65. 'PowerPortImportForm',
  66. 'RackGroupImportForm',
  67. 'RackImportForm',
  68. 'RackReservationImportForm',
  69. 'RackRoleImportForm',
  70. 'RackTypeImportForm',
  71. 'RearPortImportForm',
  72. 'RegionImportForm',
  73. 'SiteGroupImportForm',
  74. 'SiteImportForm',
  75. 'VirtualChassisImportForm',
  76. 'VirtualDeviceContextImportForm'
  77. )
  78. # A lazily evaluated format_html(), for help text which must not resolve its translated content until
  79. # the field is rendered. Unlike mark_safe(), this escapes the interpolated arguments.
  80. format_html_lazy = lazy(format_html, SafeString)
  81. class RegionImportForm(NestedGroupModelImportForm):
  82. parent = CSVModelChoiceField(
  83. label=_('Parent'),
  84. queryset=Region.objects.all(),
  85. required=False,
  86. to_field_name='name',
  87. help_text=_('Name of parent region')
  88. )
  89. class Meta:
  90. model = Region
  91. fields = ('name', 'slug', 'parent', 'description', 'owner', 'comments', 'tags')
  92. class SiteGroupImportForm(NestedGroupModelImportForm):
  93. parent = CSVModelChoiceField(
  94. label=_('Parent'),
  95. queryset=SiteGroup.objects.all(),
  96. required=False,
  97. to_field_name='name',
  98. help_text=_('Name of parent site group')
  99. )
  100. class Meta:
  101. model = SiteGroup
  102. fields = ('name', 'slug', 'parent', 'description', 'owner', 'comments', 'tags')
  103. class SiteImportForm(PrimaryModelImportForm):
  104. status = CSVChoiceField(
  105. label=_('Status'),
  106. choices=SiteStatusChoices,
  107. help_text=_('Operational status')
  108. )
  109. region = CSVModelChoiceField(
  110. label=_('Region'),
  111. queryset=Region.objects.all(),
  112. required=False,
  113. to_field_name='name',
  114. help_text=_('Assigned region')
  115. )
  116. group = CSVModelChoiceField(
  117. label=_('Group'),
  118. queryset=SiteGroup.objects.all(),
  119. required=False,
  120. to_field_name='name',
  121. help_text=_('Assigned group')
  122. )
  123. tenant = CSVModelChoiceField(
  124. label=_('Tenant'),
  125. queryset=Tenant.objects.all(),
  126. required=False,
  127. to_field_name='name',
  128. help_text=_('Assigned tenant')
  129. )
  130. class Meta:
  131. model = Site
  132. fields = (
  133. 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description',
  134. 'physical_address', 'shipping_address', 'latitude', 'longitude', 'owner', 'comments', 'tags'
  135. )
  136. help_texts = {
  137. 'time_zone': mark_safe(
  138. '{} (<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">{}</a>)'.format(
  139. _('Time zone'), _('available options')
  140. )
  141. )
  142. }
  143. class LocationImportForm(NestedGroupModelImportForm):
  144. site = CSVModelChoiceField(
  145. label=_('Site'),
  146. queryset=Site.objects.all(),
  147. to_field_name='name',
  148. help_text=_('Assigned site')
  149. )
  150. parent = CSVModelChoiceField(
  151. label=_('Parent'),
  152. queryset=Location.objects.all(),
  153. required=False,
  154. to_field_name='name',
  155. help_text=_('Parent location'),
  156. error_messages={
  157. 'invalid_choice': _('Location not found.'),
  158. }
  159. )
  160. status = CSVChoiceField(
  161. label=_('Status'),
  162. choices=LocationStatusChoices,
  163. help_text=_('Operational status')
  164. )
  165. tenant = CSVModelChoiceField(
  166. label=_('Tenant'),
  167. queryset=Tenant.objects.all(),
  168. required=False,
  169. to_field_name='name',
  170. help_text=_('Assigned tenant')
  171. )
  172. class Meta:
  173. model = Location
  174. fields = (
  175. 'site', 'parent', 'name', 'slug', 'status', 'tenant', 'facility', 'description', 'owner', 'comments',
  176. 'tags',
  177. )
  178. def __init__(self, data=None, *args, **kwargs):
  179. super().__init__(data, *args, **kwargs)
  180. if data:
  181. # Limit location queryset by assigned site
  182. params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
  183. self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
  184. class RackGroupImportForm(OrganizationalModelImportForm):
  185. class Meta:
  186. model = RackGroup
  187. fields = ('name', 'slug', 'description', 'owner', 'comments', 'tags')
  188. class RackRoleImportForm(OrganizationalModelImportForm):
  189. class Meta:
  190. model = RackRole
  191. fields = ('name', 'slug', 'color', 'description', 'owner', 'comments', 'tags')
  192. class RackTypeImportForm(PrimaryModelImportForm):
  193. manufacturer = forms.ModelChoiceField(
  194. label=_('Manufacturer'),
  195. queryset=Manufacturer.objects.all(),
  196. to_field_name='name',
  197. help_text=_('The manufacturer of this rack type')
  198. )
  199. form_factor = CSVChoiceField(
  200. label=_('Type'),
  201. choices=RackFormFactorChoices,
  202. help_text=_('Form factor')
  203. )
  204. starting_unit = forms.IntegerField(
  205. required=False,
  206. min_value=1,
  207. help_text=_('The lowest-numbered position in the rack')
  208. )
  209. width = forms.ChoiceField(
  210. label=_('Width'),
  211. choices=RackWidthChoices,
  212. help_text=_('Rail-to-rail width (in inches)')
  213. )
  214. outer_unit = CSVChoiceField(
  215. label=_('Outer unit'),
  216. choices=RackDimensionUnitChoices,
  217. required=False,
  218. help_text=_('Unit for outer dimensions')
  219. )
  220. weight_unit = CSVChoiceField(
  221. label=_('Weight unit'),
  222. choices=WeightUnitChoices,
  223. required=False,
  224. help_text=_('Unit for rack weights')
  225. )
  226. cooling_capability = CSVChoiceField(
  227. label=_('Cooling capability'),
  228. choices=RackCoolingCapabilityChoices,
  229. required=False,
  230. help_text=_('Cooling capability')
  231. )
  232. class Meta:
  233. model = RackType
  234. fields = (
  235. 'manufacturer', 'model', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units',
  236. 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight',
  237. 'weight_unit', 'cooling_capability', 'cooling_capacity', 'description', 'owner', 'comments', 'tags',
  238. )
  239. def __init__(self, data=None, *args, **kwargs):
  240. super().__init__(data, *args, **kwargs)
  241. class RackImportForm(PrimaryModelImportForm):
  242. site = CSVModelChoiceField(
  243. label=_('Site'),
  244. queryset=Site.objects.all(),
  245. to_field_name='name'
  246. )
  247. location = CSVModelChoiceField(
  248. label=_('Location'),
  249. queryset=Location.objects.all(),
  250. required=False,
  251. to_field_name='name'
  252. )
  253. tenant = CSVModelChoiceField(
  254. label=_('Tenant'),
  255. queryset=Tenant.objects.all(),
  256. required=False,
  257. to_field_name='name',
  258. help_text=_('Name of assigned tenant')
  259. )
  260. group = CSVModelChoiceField(
  261. label=_('Rack group'),
  262. queryset=RackGroup.objects.all(),
  263. required=False,
  264. to_field_name='name',
  265. help_text=_('Name of assigned group')
  266. )
  267. status = CSVChoiceField(
  268. label=_('Status'),
  269. choices=RackStatusChoices,
  270. help_text=_('Operational status')
  271. )
  272. role = CSVModelChoiceField(
  273. label=_('Role'),
  274. queryset=RackRole.objects.all(),
  275. required=False,
  276. to_field_name='name',
  277. help_text=_('Name of assigned role')
  278. )
  279. rack_type = CSVModelChoiceField(
  280. label=_('Rack type'),
  281. queryset=RackType.objects.all(),
  282. to_field_name='model',
  283. required=False,
  284. help_text=_('Rack type model')
  285. )
  286. form_factor = CSVChoiceField(
  287. label=_('Type'),
  288. choices=RackFormFactorChoices,
  289. required=False,
  290. help_text=_('Form factor')
  291. )
  292. width = forms.ChoiceField(
  293. label=_('Width'),
  294. choices=RackWidthChoices,
  295. required=False,
  296. help_text=_('Rail-to-rail width (in inches)')
  297. )
  298. u_height = forms.IntegerField(
  299. required=False,
  300. label=_('Height (U)')
  301. )
  302. outer_unit = CSVChoiceField(
  303. label=_('Outer unit'),
  304. choices=RackDimensionUnitChoices,
  305. required=False,
  306. help_text=_('Unit for outer dimensions')
  307. )
  308. airflow = CSVChoiceField(
  309. label=_('Airflow'),
  310. choices=RackAirflowChoices,
  311. required=False,
  312. help_text=_('Airflow direction')
  313. )
  314. cooling_capability = CSVChoiceField(
  315. label=_('Cooling capability'),
  316. choices=RackCoolingCapabilityChoices,
  317. required=False,
  318. help_text=_('Cooling capability')
  319. )
  320. weight_unit = CSVChoiceField(
  321. label=_('Weight unit'),
  322. choices=WeightUnitChoices,
  323. required=False,
  324. help_text=_('Unit for rack weights')
  325. )
  326. class Meta:
  327. model = Rack
  328. fields = (
  329. 'site', 'location', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'rack_type', 'form_factor',
  330. 'serial', 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
  331. 'outer_unit', 'mounting_depth', 'airflow', 'cooling_capability', 'cooling_capacity', 'weight',
  332. 'max_weight', 'weight_unit', 'description', 'owner', 'comments', 'tags',
  333. )
  334. def __init__(self, data=None, *args, **kwargs):
  335. super().__init__(data, *args, **kwargs)
  336. if data:
  337. # Limit location queryset by assigned site
  338. params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
  339. self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
  340. def clean(self):
  341. super().clean()
  342. # width & u_height must be set if not specifying a rack type on import
  343. if not self.instance.pk:
  344. if not self.cleaned_data.get('rack_type') and not self.cleaned_data.get('width'):
  345. raise forms.ValidationError(_("Width must be set if not specifying a rack type."))
  346. if not self.cleaned_data.get('rack_type') and not self.cleaned_data.get('u_height'):
  347. raise forms.ValidationError(_("U height must be set if not specifying a rack type."))
  348. class RackReservationImportForm(PrimaryModelImportForm):
  349. site = CSVModelChoiceField(
  350. label=_('Site'),
  351. queryset=Site.objects.all(),
  352. to_field_name='name',
  353. help_text=_('Parent site')
  354. )
  355. location = CSVModelChoiceField(
  356. label=_('Location'),
  357. queryset=Location.objects.all(),
  358. to_field_name='name',
  359. required=False,
  360. help_text=_("Rack's location (if any)")
  361. )
  362. rack = CSVModelChoiceField(
  363. label=_('Rack'),
  364. queryset=Rack.objects.all(),
  365. to_field_name='name',
  366. help_text=_('Rack')
  367. )
  368. units = SimpleArrayField(
  369. label=_('Units'),
  370. base_field=forms.IntegerField(),
  371. required=True,
  372. help_text=_('Comma-separated list of individual unit numbers')
  373. )
  374. status = CSVChoiceField(
  375. label=_('Status'),
  376. choices=RackReservationStatusChoices,
  377. help_text=_('Operational status')
  378. )
  379. tenant = CSVModelChoiceField(
  380. label=_('Tenant'),
  381. queryset=Tenant.objects.all(),
  382. required=False,
  383. to_field_name='name',
  384. help_text=_('Assigned tenant')
  385. )
  386. class Meta:
  387. model = RackReservation
  388. fields = ('site', 'location', 'rack', 'units', 'status', 'tenant', 'description', 'owner', 'comments', 'tags')
  389. def __init__(self, data=None, *args, **kwargs):
  390. super().__init__(data, *args, **kwargs)
  391. if data:
  392. # Limit location queryset by assigned site
  393. params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
  394. self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
  395. # Limit rack queryset by assigned site and group
  396. params = {
  397. f"site__{self.fields['site'].to_field_name}": data.get('site'),
  398. f"location__{self.fields['location'].to_field_name}": data.get('location'),
  399. }
  400. self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
  401. class ManufacturerImportForm(OrganizationalModelImportForm):
  402. class Meta:
  403. model = Manufacturer
  404. fields = ('name', 'slug', 'description', 'owner', 'comments', 'tags')
  405. class DeviceTypeImportForm(PrimaryModelImportForm):
  406. manufacturer = CSVModelChoiceField(
  407. label=_('Manufacturer'),
  408. queryset=Manufacturer.objects.all(),
  409. to_field_name='name',
  410. help_text=_('The manufacturer which produces this device type')
  411. )
  412. default_platform = CSVModelChoiceField(
  413. label=_('Default platform'),
  414. queryset=Platform.objects.all(),
  415. to_field_name='name',
  416. required=False,
  417. help_text=_('The default platform for devices of this type (optional)')
  418. )
  419. weight = forms.DecimalField(
  420. label=_('Weight'),
  421. required=False,
  422. help_text=_('Device weight'),
  423. )
  424. weight_unit = CSVChoiceField(
  425. label=_('Weight unit'),
  426. choices=WeightUnitChoices,
  427. required=False,
  428. help_text=_('Unit for device weight')
  429. )
  430. class Meta:
  431. model = DeviceType
  432. fields = [
  433. 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization',
  434. 'is_full_depth', 'subdevice_role', 'airflow', 'cooling_method', 'description', 'weight', 'weight_unit',
  435. 'end_of_life', 'owner', 'comments', 'tags',
  436. ]
  437. class ModuleBayTypeImportForm(PrimaryModelImportForm):
  438. manufacturer = CSVModelChoiceField(
  439. label=_('Manufacturer'),
  440. queryset=Manufacturer.objects.all(),
  441. to_field_name='name',
  442. required=False,
  443. )
  444. class Meta:
  445. model = ModuleBayType
  446. fields = [
  447. 'name', 'slug', 'manufacturer', 'color', 'description', 'owner', 'comments', 'tags',
  448. ]
  449. class ModuleTypeProfileImportForm(PrimaryModelImportForm):
  450. class Meta:
  451. model = ModuleTypeProfile
  452. fields = [
  453. 'name', 'description', 'schema', 'owner', 'comments', 'tags',
  454. ]
  455. class ModuleTypeImportForm(PrimaryModelImportForm):
  456. profile = forms.ModelChoiceField(
  457. label=_('Profile'),
  458. queryset=ModuleTypeProfile.objects.all(),
  459. to_field_name='name',
  460. required=False
  461. )
  462. manufacturer = forms.ModelChoiceField(
  463. label=_('Manufacturer'),
  464. queryset=Manufacturer.objects.all(),
  465. to_field_name='name'
  466. )
  467. airflow = CSVChoiceField(
  468. label=_('Airflow'),
  469. choices=ModuleAirflowChoices,
  470. required=False,
  471. help_text=_('Airflow direction')
  472. )
  473. cooling_method = CSVChoiceField(
  474. label=_('Cooling method'),
  475. choices=CoolingMethodChoices,
  476. required=False,
  477. help_text=_('Cooling method')
  478. )
  479. weight = forms.DecimalField(
  480. label=_('Weight'),
  481. required=False,
  482. help_text=_('Module weight'),
  483. )
  484. weight_unit = CSVChoiceField(
  485. label=_('Weight unit'),
  486. choices=WeightUnitChoices,
  487. required=False,
  488. help_text=_('Unit for module weight')
  489. )
  490. attribute_data = forms.JSONField(
  491. label=_('Attributes'),
  492. required=False,
  493. help_text=_('Attribute values for the assigned profile, passed as a dictionary')
  494. )
  495. class Meta:
  496. model = ModuleType
  497. fields = [
  498. 'manufacturer', 'model', 'part_number', 'description', 'cooling_method', 'airflow', 'weight', 'weight_unit',
  499. 'end_of_life', 'profile', 'attribute_data', 'owner', 'comments', 'tags',
  500. ]
  501. def clean(self):
  502. super().clean()
  503. # Attribute data may be included only if a profile is specified
  504. if self.cleaned_data.get('attribute_data') and not self.cleaned_data.get('profile'):
  505. raise forms.ValidationError(_("Profile must be specified if attribute data is provided."))
  506. # Default attribute_data to an empty dictionary if a profile is specified (to enforce schema validation)
  507. if self.cleaned_data.get('profile') and not self.cleaned_data.get('attribute_data'):
  508. self.cleaned_data['attribute_data'] = {}
  509. class DeviceRoleImportForm(NestedGroupModelImportForm):
  510. parent = CSVModelChoiceField(
  511. label=_('Parent'),
  512. queryset=DeviceRole.objects.all(),
  513. required=False,
  514. to_field_name='name',
  515. help_text=_('Parent Device Role'),
  516. error_messages={
  517. 'invalid_choice': _('Device role not found.'),
  518. }
  519. )
  520. config_template = CSVModelChoiceField(
  521. label=_('Config template'),
  522. queryset=ConfigTemplate.objects.all(),
  523. to_field_name='name',
  524. required=False,
  525. help_text=_('Config template')
  526. )
  527. class Meta:
  528. model = DeviceRole
  529. fields = (
  530. 'name', 'slug', 'parent', 'color', 'vm_role', 'config_template', 'description', 'owner', 'comments', 'tags'
  531. )
  532. class PlatformImportForm(NestedGroupModelImportForm):
  533. parent = CSVModelChoiceField(
  534. label=_('Parent'),
  535. queryset=Platform.objects.all(),
  536. required=False,
  537. to_field_name='name',
  538. help_text=_('Parent platform'),
  539. error_messages={
  540. 'invalid_choice': _('Platform not found.'),
  541. }
  542. )
  543. manufacturer = CSVModelChoiceField(
  544. label=_('Manufacturer'),
  545. queryset=Manufacturer.objects.all(),
  546. required=False,
  547. to_field_name='name',
  548. help_text=_('Limit platform assignments to this manufacturer')
  549. )
  550. config_template = CSVModelChoiceField(
  551. label=_('Config template'),
  552. queryset=ConfigTemplate.objects.all(),
  553. to_field_name='name',
  554. required=False,
  555. help_text=_('Config template')
  556. )
  557. class Meta:
  558. model = Platform
  559. fields = (
  560. 'name', 'slug', 'parent', 'manufacturer', 'config_template', 'description', 'owner', 'comments', 'tags',
  561. )
  562. class BaseDeviceImportForm(PrimaryModelImportForm):
  563. role = CSVModelChoiceField(
  564. label=_('Device role'),
  565. queryset=DeviceRole.objects.all(),
  566. to_field_name='name',
  567. help_text=_('Assigned role')
  568. )
  569. tenant = CSVModelChoiceField(
  570. label=_('Tenant'),
  571. queryset=Tenant.objects.all(),
  572. required=False,
  573. to_field_name='name',
  574. help_text=_('Assigned tenant')
  575. )
  576. manufacturer = CSVModelChoiceField(
  577. label=_('Manufacturer'),
  578. queryset=Manufacturer.objects.all(),
  579. to_field_name='name',
  580. help_text=_('Device type manufacturer')
  581. )
  582. device_type = CSVModelChoiceField(
  583. label=_('Device type'),
  584. queryset=DeviceType.objects.all(),
  585. to_field_name='model',
  586. help_text=_('Device type model')
  587. )
  588. platform = CSVModelChoiceField(
  589. label=_('Platform'),
  590. queryset=Platform.objects.all(),
  591. required=False,
  592. to_field_name='name',
  593. help_text=_('Assigned platform')
  594. )
  595. status = CSVChoiceField(
  596. label=_('Status'),
  597. choices=DeviceStatusChoices,
  598. help_text=_('Operational status')
  599. )
  600. virtual_chassis = CSVModelChoiceField(
  601. label=_('Virtual chassis'),
  602. queryset=VirtualChassis.objects.all(),
  603. to_field_name='name',
  604. required=False,
  605. help_text=_('Virtual chassis')
  606. )
  607. cluster = CSVModelChoiceField(
  608. label=_('Cluster'),
  609. queryset=Cluster.objects.all(),
  610. to_field_name='name',
  611. required=False,
  612. help_text=_('Virtualization cluster')
  613. )
  614. class Meta:
  615. fields = []
  616. model = Device
  617. def __init__(self, data=None, *args, **kwargs):
  618. super().__init__(data, *args, **kwargs)
  619. if data:
  620. # Limit device type queryset by manufacturer
  621. params = {f"manufacturer__{self.fields['manufacturer'].to_field_name}": data.get('manufacturer')}
  622. self.fields['device_type'].queryset = self.fields['device_type'].queryset.filter(**params)
  623. class DeviceImportForm(BaseDeviceImportForm):
  624. site = CSVModelChoiceField(
  625. label=_('Site'),
  626. queryset=Site.objects.all(),
  627. to_field_name='name',
  628. help_text=_('Assigned site')
  629. )
  630. location = CSVModelChoiceField(
  631. label=_('Location'),
  632. queryset=Location.objects.all(),
  633. to_field_name='name',
  634. required=False,
  635. help_text=_("Assigned location (if any)")
  636. )
  637. rack = CSVModelChoiceField(
  638. label=_('Rack'),
  639. queryset=Rack.objects.all(),
  640. to_field_name='name',
  641. required=False,
  642. help_text=_("Assigned rack (if any)")
  643. )
  644. face = CSVChoiceField(
  645. label=_('Face'),
  646. choices=DeviceFaceChoices,
  647. required=False,
  648. help_text=_('Mounted rack face')
  649. )
  650. parent = CSVModelChoiceField(
  651. label=_('Parent'),
  652. queryset=Device.objects.all(),
  653. to_field_name='name',
  654. required=False,
  655. help_text=_('Parent device (for child devices)')
  656. )
  657. device_bay = CSVModelChoiceField(
  658. label=_('Device bay'),
  659. queryset=DeviceBay.objects.all(),
  660. to_field_name='name',
  661. required=False,
  662. help_text=_('Device bay in which this device is installed (for child devices)')
  663. )
  664. airflow = CSVChoiceField(
  665. label=_('Airflow'),
  666. choices=DeviceAirflowChoices,
  667. required=False,
  668. help_text=_('Airflow direction')
  669. )
  670. cooling_method = CSVChoiceField(
  671. label=_('Cooling method'),
  672. choices=CoolingMethodChoices,
  673. required=False,
  674. help_text=_('Cooling method')
  675. )
  676. config_template = CSVModelChoiceField(
  677. label=_('Config template'),
  678. queryset=ConfigTemplate.objects.all(),
  679. to_field_name='name',
  680. required=False,
  681. help_text=_('Config template')
  682. )
  683. replicate_components = forms.BooleanField(
  684. label=_('Replicate components'),
  685. required=False,
  686. help_text=_('Automatically populate components associated with this device type (enabled by default)')
  687. )
  688. class Meta(BaseDeviceImportForm.Meta):
  689. fields = [
  690. 'name', 'role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
  691. 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent', 'device_bay', 'airflow',
  692. 'cooling_method', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'description',
  693. 'config_template', 'owner', 'comments', 'replicate_components', 'tags',
  694. ]
  695. def __init__(self, data=None, *args, **kwargs):
  696. super().__init__(data, *args, **kwargs)
  697. if data:
  698. # Limit location queryset by assigned site
  699. params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
  700. self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
  701. self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
  702. # Limit rack queryset by assigned site and location
  703. params = {
  704. f"site__{self.fields['site'].to_field_name}": data.get('site'),
  705. }
  706. if location := data.get('location'):
  707. params.update({
  708. f"location__{self.fields['location'].to_field_name}": location,
  709. })
  710. self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
  711. # Limit platform queryset by manufacturer
  712. params = {f"manufacturer__{self.fields['manufacturer'].to_field_name}": data.get('manufacturer')}
  713. self.fields['platform'].queryset = self.fields['platform'].queryset.filter(
  714. Q(**params) | Q(manufacturer=None)
  715. )
  716. # Limit device bay queryset by parent device
  717. if parent := data.get('parent'):
  718. params = {f"device__{self.fields['parent'].to_field_name}": parent}
  719. self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params)
  720. def clean(self):
  721. super().clean()
  722. # Inherit site and rack from parent device
  723. if parent := self.cleaned_data.get('parent'):
  724. self.instance.site = parent.site
  725. self.instance.rack = parent.rack
  726. # Set parent_bay reverse relationship
  727. if device_bay := self.cleaned_data.get('device_bay'):
  728. self.instance.parent_bay = device_bay
  729. # Disable component replication for a new device if requested
  730. if not self.instance.pk and not self.cleaned_data.get('replicate_components'):
  731. self.instance._disable_replication = True
  732. def clean_replicate_components(self):
  733. # Default to True when replicate_components is omitted or left blank in the uploaded data
  734. if self.data.get('replicate_components') in (None, ''):
  735. return True
  736. return self.cleaned_data['replicate_components']
  737. class ModuleImportForm(ModuleCommonForm, PrimaryModelImportForm):
  738. device = CSVModelChoiceField(
  739. label=_('Device'),
  740. queryset=Device.objects.all(),
  741. to_field_name='name',
  742. help_text=_('The device in which this module is installed')
  743. )
  744. module_bay = CSVModelChoiceField(
  745. label=_('Module bay'),
  746. queryset=ModuleBay.objects.all(),
  747. to_field_name='name',
  748. help_text=_('The module bay in which this module is installed')
  749. )
  750. module_type = CSVModelChoiceField(
  751. label=_('Module type'),
  752. queryset=ModuleType.objects.all(),
  753. to_field_name='model',
  754. help_text=_('The type of module')
  755. )
  756. status = CSVChoiceField(
  757. label=_('Status'),
  758. choices=ModuleStatusChoices,
  759. help_text=_('Operational status')
  760. )
  761. replicate_components = forms.BooleanField(
  762. label=_('Replicate components'),
  763. required=False,
  764. help_text=_('Automatically populate components associated with this module type (enabled by default)')
  765. )
  766. adopt_components = forms.BooleanField(
  767. label=_('Adopt components'),
  768. required=False,
  769. help_text=_('Adopt already existing components')
  770. )
  771. class Meta:
  772. model = Module
  773. fields = (
  774. 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'status', 'description', 'owner', 'comments',
  775. 'replicate_components', 'adopt_components', 'tags',
  776. )
  777. def __init__(self, data=None, *args, **kwargs):
  778. super().__init__(data, *args, **kwargs)
  779. if data:
  780. # Limit module_bay queryset by assigned device
  781. params = {f"device__{self.fields['device'].to_field_name}": data.get('device')}
  782. self.fields['module_bay'].queryset = self.fields['module_bay'].queryset.filter(**params)
  783. def clean_replicate_components(self):
  784. # Make sure replicate_components is True when it's not included in the uploaded data
  785. if 'replicate_components' not in self.data:
  786. return True
  787. return self.cleaned_data['replicate_components']
  788. #
  789. # Device components
  790. #
  791. class ConsolePortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
  792. device = CSVModelChoiceField(
  793. label=_('Device'),
  794. queryset=Device.objects.all(),
  795. to_field_name='name'
  796. )
  797. type = CSVChoiceField(
  798. label=_('Type'),
  799. choices=ConsolePortTypeChoices,
  800. required=False,
  801. help_text=_('Port type')
  802. )
  803. speed = CSVTypedChoiceField(
  804. label=_('Speed'),
  805. choices=ConsolePortSpeedChoices,
  806. coerce=int,
  807. empty_value=None,
  808. required=False,
  809. help_text=_('Port speed in bps')
  810. )
  811. class Meta:
  812. model = ConsolePort
  813. fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'owner', 'tags')
  814. class ConsoleServerPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
  815. device = CSVModelChoiceField(
  816. label=_('Device'),
  817. queryset=Device.objects.all(),
  818. to_field_name='name'
  819. )
  820. type = CSVChoiceField(
  821. label=_('Type'),
  822. choices=ConsolePortTypeChoices,
  823. required=False,
  824. help_text=_('Port type')
  825. )
  826. speed = CSVTypedChoiceField(
  827. label=_('Speed'),
  828. choices=ConsolePortSpeedChoices,
  829. coerce=int,
  830. empty_value=None,
  831. required=False,
  832. help_text=_('Port speed in bps')
  833. )
  834. class Meta:
  835. model = ConsoleServerPort
  836. fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'owner', 'tags')
  837. class PowerPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
  838. device = CSVModelChoiceField(
  839. label=_('Device'),
  840. queryset=Device.objects.all(),
  841. to_field_name='name'
  842. )
  843. type = CSVChoiceField(
  844. label=_('Type'),
  845. choices=PowerPortTypeChoices,
  846. required=False,
  847. help_text=_('Port type')
  848. )
  849. class Meta:
  850. model = PowerPort
  851. fields = (
  852. 'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description',
  853. 'owner', 'tags',
  854. )
  855. class PowerOutletImportForm(OwnerCSVMixin, NetBoxModelImportForm):
  856. device = CSVModelChoiceField(
  857. label=_('Device'),
  858. queryset=Device.objects.all(),
  859. to_field_name='name'
  860. )
  861. type = CSVChoiceField(
  862. label=_('Type'),
  863. choices=PowerOutletTypeChoices,
  864. required=False,
  865. help_text=_('Outlet type')
  866. )
  867. power_port = CSVModelChoiceField(
  868. label=_('Power port'),
  869. queryset=PowerPort.objects.all(),
  870. required=False,
  871. to_field_name='name',
  872. help_text=_('Local power port which feeds this outlet')
  873. )
  874. feed_leg = CSVChoiceField(
  875. label=_('Feed leg'),
  876. choices=PowerOutletFeedLegChoices,
  877. required=False,
  878. help_text=_('Electrical phase (for three-phase circuits)')
  879. )
  880. class Meta:
  881. model = PowerOutlet
  882. fields = (
  883. 'device', 'name', 'label', 'type', 'color', 'mark_connected', 'power_port', 'feed_leg', 'description',
  884. 'owner', 'tags',
  885. )
  886. def __init__(self, *args, **kwargs):
  887. super().__init__(*args, **kwargs)
  888. # Limit PowerPort choices to those belonging to this device (or VC master)
  889. if self.is_bound and 'device' in self.data:
  890. try:
  891. device = self.fields['device'].to_python(self.data['device'])
  892. except forms.ValidationError:
  893. device = None
  894. else:
  895. try:
  896. device = self.instance.device
  897. except Device.DoesNotExist:
  898. device = None
  899. if device:
  900. self.fields['power_port'].queryset = PowerPort.objects.filter(
  901. device__in=[device, device.get_vc_master()]
  902. )
  903. else:
  904. self.fields['power_port'].queryset = PowerPort.objects.none()
  905. class CoolingIntakeImportForm(OwnerCSVMixin, NetBoxModelImportForm):
  906. device = CSVModelChoiceField(
  907. label=_('Device'),
  908. queryset=Device.objects.all(),
  909. to_field_name='name'
  910. )
  911. type = CSVChoiceField(
  912. label=_('Type'),
  913. choices=CoolingConnectorTypeChoices,
  914. required=False,
  915. help_text=_('Physical connector type')
  916. )
  917. diameter_unit = CSVChoiceField(
  918. label=_('Diameter unit'),
  919. choices=DiameterUnitChoices,
  920. required=False,
  921. help_text=_('Diameter unit')
  922. )
  923. max_flow_unit = CSVChoiceField(
  924. label=_('Max flow unit'),
  925. choices=FlowRateUnitChoices,
  926. required=False,
  927. help_text=_('Unit for maximum flow')
  928. )
  929. cooling_outflow_device = CSVModelChoiceField(
  930. label=_('Cooling outflow device'),
  931. queryset=Device.objects.all(),
  932. required=False,
  933. to_field_name='name',
  934. help_text=_('Device bearing the upstream cooling outflow (defaults to this intake\'s device)')
  935. )
  936. cooling_outflow = CSVModelChoiceField(
  937. label=_('Cooling outflow'),
  938. queryset=CoolingOutflow.objects.all(),
  939. required=False,
  940. to_field_name='name',
  941. help_text=_('Upstream cooling outflow which feeds this intake')
  942. )
  943. class Meta:
  944. model = CoolingIntake
  945. fields = (
  946. 'device', 'name', 'label', 'type', 'diameter', 'diameter_unit', 'max_flow',
  947. 'max_flow_unit', 'cooling_outflow_device', 'cooling_outflow', 'description', 'owner', 'tags',
  948. )
  949. def __init__(self, *args, **kwargs):
  950. super().__init__(*args, **kwargs)
  951. # The supplying outflow typically belongs to an upstream device (e.g. a CDU or manifold), so scope the
  952. # CoolingOutflow choices to cooling_outflow_device where given, falling back to this intake's own device.
  953. if self.is_bound and 'device' in self.data:
  954. try:
  955. device = self.fields['device'].to_python(self.data['device'])
  956. except forms.ValidationError:
  957. device = None
  958. else:
  959. try:
  960. device = self.instance.device
  961. except Device.DoesNotExist:
  962. device = None
  963. outflow_device = None
  964. if self.is_bound and self.data.get('cooling_outflow_device'):
  965. try:
  966. outflow_device = self.fields['cooling_outflow_device'].to_python(
  967. self.data['cooling_outflow_device']
  968. )
  969. except forms.ValidationError:
  970. outflow_device = None
  971. if outflow_device:
  972. self.fields['cooling_outflow'].queryset = CoolingOutflow.objects.filter(device=outflow_device)
  973. elif device:
  974. self.fields['cooling_outflow'].queryset = CoolingOutflow.objects.filter(
  975. device__in=[device, device.get_vc_master()]
  976. )
  977. else:
  978. self.fields['cooling_outflow'].queryset = CoolingOutflow.objects.none()
  979. class CoolingOutflowImportForm(OwnerCSVMixin, NetBoxModelImportForm):
  980. device = CSVModelChoiceField(
  981. label=_('Device'),
  982. queryset=Device.objects.all(),
  983. to_field_name='name'
  984. )
  985. type = CSVChoiceField(
  986. label=_('Type'),
  987. choices=CoolingConnectorTypeChoices,
  988. required=False,
  989. help_text=_('Physical connector type')
  990. )
  991. diameter_unit = CSVChoiceField(
  992. label=_('Diameter unit'),
  993. choices=DiameterUnitChoices,
  994. required=False,
  995. help_text=_('Diameter unit')
  996. )
  997. cooling_intake = CSVModelChoiceField(
  998. label=_('Cooling intake'),
  999. queryset=CoolingIntake.objects.all(),
  1000. required=False,
  1001. to_field_name='name',
  1002. help_text=_('Local cooling intake which feeds this outflow')
  1003. )
  1004. class Meta:
  1005. model = CoolingOutflow
  1006. fields = (
  1007. 'device', 'name', 'label', 'type', 'diameter', 'diameter_unit',
  1008. 'cooling_intake', 'description', 'owner', 'tags',
  1009. )
  1010. def __init__(self, *args, **kwargs):
  1011. super().__init__(*args, **kwargs)
  1012. # Limit CoolingIntake choices to those belonging to this device (or VC master)
  1013. if self.is_bound and 'device' in self.data:
  1014. try:
  1015. device = self.fields['device'].to_python(self.data['device'])
  1016. except forms.ValidationError:
  1017. device = None
  1018. else:
  1019. try:
  1020. device = self.instance.device
  1021. except Device.DoesNotExist:
  1022. device = None
  1023. if device:
  1024. self.fields['cooling_intake'].queryset = CoolingIntake.objects.filter(
  1025. device__in=[device, device.get_vc_master()]
  1026. )
  1027. else:
  1028. self.fields['cooling_intake'].queryset = CoolingIntake.objects.none()
  1029. class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
  1030. device = CSVModelChoiceField(
  1031. label=_('Device'),
  1032. queryset=Device.objects.all(),
  1033. to_field_name='name'
  1034. )
  1035. parent = CSVModelChoiceField(
  1036. label=_('Parent'),
  1037. queryset=Interface.objects.all(),
  1038. required=False,
  1039. to_field_name='name',
  1040. help_text=_('Parent interface')
  1041. )
  1042. bridge = CSVModelChoiceField(
  1043. label=_('Bridge'),
  1044. queryset=Interface.objects.all(),
  1045. required=False,
  1046. to_field_name='name',
  1047. help_text=_('Bridged interface')
  1048. )
  1049. lag = CSVModelChoiceField(
  1050. label=_('Lag'),
  1051. queryset=Interface.objects.all(),
  1052. required=False,
  1053. to_field_name='name',
  1054. help_text=_('Parent LAG interface')
  1055. )
  1056. vdcs = CSVModelMultipleChoiceField(
  1057. label=_('Vdcs'),
  1058. queryset=VirtualDeviceContext.objects.all(),
  1059. required=False,
  1060. to_field_name='name',
  1061. help_text=mark_safe(
  1062. _('VDC names separated by commas, encased with double quotes. Example:') + ' <code>"vdc1,vdc2,vdc3"</code>'
  1063. )
  1064. )
  1065. type = CSVChoiceField(
  1066. label=_('Type'),
  1067. choices=InterfaceTypeChoices,
  1068. help_text=_('Physical medium')
  1069. )
  1070. duplex = CSVChoiceField(
  1071. label=_('Duplex'),
  1072. choices=InterfaceDuplexChoices,
  1073. required=False
  1074. )
  1075. poe_mode = CSVChoiceField(
  1076. label=_('Poe mode'),
  1077. choices=InterfacePoEModeChoices,
  1078. required=False,
  1079. help_text=_('PoE mode')
  1080. )
  1081. poe_type = CSVChoiceField(
  1082. label=_('Poe type'),
  1083. choices=InterfacePoETypeChoices,
  1084. required=False,
  1085. help_text=_('PoE type')
  1086. )
  1087. lag_mode = CSVChoiceField(
  1088. label=_('LAG mode'),
  1089. choices=InterfaceLAGModeChoices,
  1090. required=False,
  1091. help_text=_('LAG mode')
  1092. )
  1093. lag_hash_policy = CSVChoiceField(
  1094. label=_('LAG hash policy'),
  1095. choices=InterfaceLAGHashPolicyChoices,
  1096. required=False,
  1097. help_text=_('LAG hash policy')
  1098. )
  1099. mode = CSVChoiceField(
  1100. label=_('Mode'),
  1101. choices=InterfaceModeChoices,
  1102. required=False,
  1103. help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)'),
  1104. )
  1105. vlan_group = CSVModelChoiceField(
  1106. label=_('VLAN group'),
  1107. queryset=VLANGroup.objects.all(),
  1108. required=False,
  1109. to_field_name='name',
  1110. help_text=_('Filter VLANs available for assignment by group'),
  1111. )
  1112. untagged_vlan = CSVModelChoiceField(
  1113. label=_('Untagged VLAN'),
  1114. queryset=VLAN.objects.all(),
  1115. required=False,
  1116. to_field_name='vid',
  1117. help_text=_('Assigned untagged VLAN ID (filtered by VLAN group)'),
  1118. )
  1119. tagged_vlans = CSVModelMultipleChoiceField(
  1120. label=_('Tagged VLANs'),
  1121. queryset=VLAN.objects.all(),
  1122. required=False,
  1123. to_field_name='vid',
  1124. help_text=mark_safe(
  1125. _(
  1126. 'Assigned tagged VLAN IDs separated by commas, encased with double quotes '
  1127. '(filtered by VLAN group). Example:'
  1128. )
  1129. + ' <code>"100,200,300"</code>'
  1130. ),
  1131. )
  1132. qinq_svlan = CSVModelChoiceField(
  1133. label=_('Q-in-Q Service VLAN'),
  1134. queryset=VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
  1135. required=False,
  1136. to_field_name='vid',
  1137. help_text=_('Assigned Q-in-Q Service VLAN ID (filtered by VLAN group)'),
  1138. )
  1139. vrf = CSVModelChoiceField(
  1140. label=_('VRF'),
  1141. queryset=VRF.objects.all(),
  1142. required=False,
  1143. to_field_name='rd',
  1144. help_text=_('Assigned VRF')
  1145. )
  1146. rf_role = CSVChoiceField(
  1147. label=_('Rf role'),
  1148. choices=WirelessRoleChoices,
  1149. required=False,
  1150. help_text=_('Wireless role (AP/station)')
  1151. )
  1152. class Meta:
  1153. model = Interface
  1154. fields = (
  1155. 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'channels', 'channel_id', 'speed', 'duplex',
  1156. 'enabled', 'mark_connected', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
  1157. 'lag_mode', 'lag_hash_policy', 'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf',
  1158. 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'owner', 'tags'
  1159. )
  1160. def __init__(self, data=None, *args, **kwargs):
  1161. super().__init__(data, *args, **kwargs)
  1162. if data:
  1163. # Limit choices for parent, bridge, and LAG interfaces to the assigned device
  1164. if device := data.get('device'):
  1165. params = {
  1166. f"device__{self.fields['device'].to_field_name}": device
  1167. }
  1168. self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
  1169. self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params)
  1170. self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params)
  1171. self.fields['vdcs'].queryset = self.fields['vdcs'].queryset.filter(**params)
  1172. # Limit choices for VLANs to the assigned VLAN group
  1173. if vlan_group := data.get('vlan_group'):
  1174. params = {f"group__{self.fields['vlan_group'].to_field_name}": vlan_group}
  1175. self.fields['untagged_vlan'].queryset = self.fields['untagged_vlan'].queryset.filter(**params)
  1176. self.fields['tagged_vlans'].queryset = self.fields['tagged_vlans'].queryset.filter(**params)
  1177. self.fields['qinq_svlan'].queryset = self.fields['qinq_svlan'].queryset.filter(**params)
  1178. def clean_enabled(self):
  1179. # Make sure enabled is True when it's not included in the uploaded data
  1180. if 'enabled' not in self.data:
  1181. return True
  1182. return self.cleaned_data['enabled']
  1183. def clean_vdcs(self):
  1184. for vdc in self.cleaned_data['vdcs']:
  1185. if vdc.device != self.cleaned_data['device']:
  1186. raise forms.ValidationError(
  1187. _("VDC {vdc} is not assigned to device {device}").format(
  1188. vdc=vdc, device=self.cleaned_data['device']
  1189. )
  1190. )
  1191. return self.cleaned_data['vdcs']
  1192. class FrontPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
  1193. device = CSVModelChoiceField(
  1194. label=_('Device'),
  1195. queryset=Device.objects.all(),
  1196. to_field_name='name'
  1197. )
  1198. type = CSVChoiceField(
  1199. label=_('Type'),
  1200. choices=PortTypeChoices,
  1201. help_text=_('Physical medium classification')
  1202. )
  1203. rear_port = CSVModelChoiceField(
  1204. label=_('Rear port'),
  1205. queryset=RearPort.objects.all(),
  1206. to_field_name='name',
  1207. help_text=_('Corresponding rear port (mapped to the front port\'s first position)')
  1208. )
  1209. rear_port_position = forms.IntegerField(
  1210. label=_('Rear port position'),
  1211. required=False,
  1212. help_text=_('Mapped position on the corresponding rear port (defaults to 1)')
  1213. )
  1214. class Meta:
  1215. model = FrontPort
  1216. fields = (
  1217. 'device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'rear_port',
  1218. 'rear_port_position', 'description', 'owner', 'tags'
  1219. )
  1220. def __init__(self, *args, **kwargs):
  1221. super().__init__(*args, **kwargs)
  1222. # Limit RearPort choices to those belonging to this device (or VC master)
  1223. if self.is_bound and 'device' in self.data:
  1224. try:
  1225. device = self.fields['device'].to_python(self.data['device'])
  1226. except forms.ValidationError:
  1227. device = None
  1228. else:
  1229. try:
  1230. device = self.instance.device
  1231. except Device.DoesNotExist:
  1232. device = None
  1233. if device:
  1234. self.fields['rear_port'].queryset = RearPort.objects.filter(
  1235. device__in=[device, device.get_vc_master()]
  1236. )
  1237. else:
  1238. self.fields['rear_port'].queryset = RearPort.objects.none()
  1239. def clean(self):
  1240. super().clean()
  1241. rear_port = self.cleaned_data.get('rear_port')
  1242. rear_port_position = self.cleaned_data.get('rear_port_position') or 1
  1243. if not rear_port:
  1244. return
  1245. # Validate the rear port position against the selected rear port
  1246. if rear_port_position > rear_port.positions:
  1247. raise forms.ValidationError({
  1248. 'rear_port_position': _(
  1249. "Invalid rear port position ({rear_port_position}): Rear port {name} has only {positions} "
  1250. "positions."
  1251. ).format(
  1252. rear_port_position=rear_port_position,
  1253. name=rear_port.name,
  1254. positions=rear_port.positions
  1255. )
  1256. })
  1257. # Ensure the target rear port position isn't already occupied. reconcile_port_mappings() creates the
  1258. # mapping via create() (bypassing validate_unique()), so without this check a collision would surface
  1259. # as an uncaught IntegrityError (HTTP 500) rather than a row-level validation error.
  1260. occupied = PortMapping.objects.filter(
  1261. rear_port=rear_port, rear_port_position=rear_port_position
  1262. ).exclude(front_port=self.instance.pk)
  1263. if occupied.exists():
  1264. raise forms.ValidationError({
  1265. 'rear_port_position': _(
  1266. "Rear port {name} position {rear_port_position} is already occupied."
  1267. ).format(
  1268. name=rear_port.name,
  1269. rear_port_position=rear_port_position
  1270. )
  1271. })
  1272. def _save_m2m(self):
  1273. super()._save_m2m()
  1274. # Map the front port's first position to the specified rear port & position
  1275. if rear_port := self.cleaned_data.get('rear_port'):
  1276. reconcile_port_mappings(
  1277. PortMapping,
  1278. parent_field='front_port',
  1279. parent=self.instance,
  1280. desired=[{
  1281. 'front_port_position': 1,
  1282. 'rear_port_id': rear_port.pk,
  1283. 'rear_port_position': self.cleaned_data.get('rear_port_position') or 1,
  1284. }],
  1285. )
  1286. class RearPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
  1287. device = CSVModelChoiceField(
  1288. label=_('Device'),
  1289. queryset=Device.objects.all(),
  1290. to_field_name='name'
  1291. )
  1292. type = CSVChoiceField(
  1293. label=_('Type'),
  1294. help_text=_('Physical medium classification'),
  1295. choices=PortTypeChoices,
  1296. )
  1297. class Meta:
  1298. model = RearPort
  1299. fields = (
  1300. 'device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description', 'owner', 'tags',
  1301. )
  1302. class ModuleBayImportForm(OwnerCSVMixin, NetBoxModelImportForm):
  1303. device = CSVModelChoiceField(
  1304. label=_('Device'),
  1305. queryset=Device.objects.all(),
  1306. to_field_name='name'
  1307. )
  1308. class Meta:
  1309. model = ModuleBay
  1310. fields = ('device', 'name', 'label', 'position', 'enabled', 'description', 'owner', 'tags')
  1311. def clean_enabled(self):
  1312. # Make sure enabled is True when it's not included in the uploaded data
  1313. if 'enabled' not in self.data:
  1314. return True
  1315. return self.cleaned_data['enabled']
  1316. class DeviceBayImportForm(OwnerCSVMixin, NetBoxModelImportForm):
  1317. device = CSVModelChoiceField(
  1318. label=_('Device'),
  1319. queryset=Device.objects.all(),
  1320. to_field_name='name'
  1321. )
  1322. installed_device = CSVModelChoiceField(
  1323. label=_('Installed device'),
  1324. queryset=Device.objects.all(),
  1325. required=False,
  1326. to_field_name='name',
  1327. help_text=_('Child device installed within this bay'),
  1328. error_messages={
  1329. 'invalid_choice': _('Child device not found.'),
  1330. }
  1331. )
  1332. class Meta:
  1333. model = DeviceBay
  1334. fields = ('device', 'name', 'label', 'enabled', 'installed_device', 'description', 'owner', 'tags')
  1335. def __init__(self, *args, **kwargs):
  1336. super().__init__(*args, **kwargs)
  1337. # Limit installed device choices to devices of the correct type and location
  1338. if self.is_bound and 'device' in self.data:
  1339. try:
  1340. device = self.fields['device'].to_python(self.data['device'])
  1341. except forms.ValidationError:
  1342. device = None
  1343. else:
  1344. try:
  1345. device = self.instance.device
  1346. except Device.DoesNotExist:
  1347. device = None
  1348. if device:
  1349. self.fields['installed_device'].queryset = Device.objects.filter(
  1350. site=device.site,
  1351. rack=device.rack,
  1352. parent_bay__isnull=True,
  1353. device_type__u_height=0,
  1354. device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
  1355. ).exclude(pk=device.pk)
  1356. else:
  1357. self.fields['installed_device'].queryset = Device.objects.none()
  1358. def clean_enabled(self):
  1359. # Make sure enabled is True when it's not included in the uploaded data
  1360. if 'enabled' not in self.data:
  1361. return True
  1362. return self.cleaned_data['enabled']
  1363. class InventoryItemImportForm(OwnerCSVMixin, NetBoxModelImportForm):
  1364. device = CSVModelChoiceField(
  1365. label=_('Device'),
  1366. queryset=Device.objects.all(),
  1367. to_field_name='name'
  1368. )
  1369. role = CSVModelChoiceField(
  1370. label=_('Role'),
  1371. queryset=InventoryItemRole.objects.all(),
  1372. to_field_name='name',
  1373. required=False
  1374. )
  1375. manufacturer = CSVModelChoiceField(
  1376. label=_('Manufacturer'),
  1377. queryset=Manufacturer.objects.all(),
  1378. to_field_name='name',
  1379. required=False
  1380. )
  1381. parent = CSVModelChoiceField(
  1382. label=_('Parent'),
  1383. queryset=Device.objects.all(),
  1384. to_field_name='name',
  1385. required=False,
  1386. help_text=_('Parent inventory item')
  1387. )
  1388. component_type = CSVContentTypeField(
  1389. label=_('Component type'),
  1390. queryset=ContentType.objects.all(),
  1391. limit_choices_to=MODULAR_COMPONENT_MODELS,
  1392. required=False,
  1393. help_text=_('Component Type')
  1394. )
  1395. component_name = forms.CharField(
  1396. label=_('Component name'),
  1397. required=False,
  1398. help_text=_('Component Name')
  1399. )
  1400. status = CSVChoiceField(
  1401. label=_('Status'),
  1402. choices=InventoryItemStatusChoices,
  1403. help_text=_('Operational status')
  1404. )
  1405. class Meta:
  1406. model = InventoryItem
  1407. fields = (
  1408. 'device', 'name', 'label', 'status', 'role', 'manufacturer', 'parent', 'part_id', 'serial', 'asset_tag',
  1409. 'discovered', 'description', 'owner', 'tags', 'component_type', 'component_name',
  1410. )
  1411. def __init__(self, *args, **kwargs):
  1412. super().__init__(*args, **kwargs)
  1413. # Limit parent choices to inventory items belonging to this device
  1414. device = None
  1415. if self.is_bound and 'device' in self.data:
  1416. try:
  1417. device = self.fields['device'].to_python(self.data['device'])
  1418. except forms.ValidationError:
  1419. pass
  1420. if device:
  1421. self.fields['parent'].queryset = InventoryItem.objects.filter(device=device)
  1422. else:
  1423. self.fields['parent'].queryset = InventoryItem.objects.none()
  1424. def clean(self):
  1425. super().clean()
  1426. cleaned_data = self.cleaned_data
  1427. component_type = cleaned_data.get('component_type')
  1428. component_name = cleaned_data.get('component_name')
  1429. device = self.cleaned_data.get("device")
  1430. if component_type:
  1431. if device is None:
  1432. cleaned_data.pop('component_type', None)
  1433. if component_name is None:
  1434. cleaned_data.pop('component_type', None)
  1435. raise forms.ValidationError(
  1436. _("Component name must be specified when component type is specified")
  1437. )
  1438. if all([device, component_name]):
  1439. try:
  1440. model = component_type.model_class()
  1441. self.instance.component = model.objects.get(device=device, name=component_name)
  1442. except ObjectDoesNotExist:
  1443. cleaned_data.pop('component_type', None)
  1444. cleaned_data.pop('component_name', None)
  1445. raise forms.ValidationError(
  1446. _("Component not found: {device} - {component_name}").format(
  1447. device=device, component_name=component_name
  1448. )
  1449. )
  1450. else:
  1451. cleaned_data.pop('component_type', None)
  1452. if not component_name:
  1453. raise forms.ValidationError(
  1454. _("Component name must be specified when component type is specified")
  1455. )
  1456. else:
  1457. if component_name:
  1458. raise forms.ValidationError(
  1459. _("Component type must be specified when component name is specified")
  1460. )
  1461. return cleaned_data
  1462. #
  1463. # Device component roles
  1464. #
  1465. class InventoryItemRoleImportForm(OrganizationalModelImportForm):
  1466. slug = SlugField()
  1467. class Meta:
  1468. model = InventoryItemRole
  1469. fields = ('name', 'slug', 'color', 'description', 'owner', 'comments')
  1470. #
  1471. # Addressing
  1472. #
  1473. class MACAddressImportForm(PrimaryModelImportForm):
  1474. device = CSVModelChoiceField(
  1475. label=_('Device'),
  1476. queryset=Device.objects.all(),
  1477. required=False,
  1478. to_field_name='name',
  1479. help_text=_('Parent device of assigned interface (if any)')
  1480. )
  1481. virtual_machine = CSVModelChoiceField(
  1482. label=_('Virtual machine'),
  1483. queryset=VirtualMachine.objects.all(),
  1484. required=False,
  1485. to_field_name='name',
  1486. help_text=_('Parent VM of assigned interface (if any)')
  1487. )
  1488. interface = CSVModelChoiceField(
  1489. label=_('Interface'),
  1490. queryset=Interface.objects.none(), # Can also refer to VMInterface
  1491. required=False,
  1492. to_field_name='name',
  1493. help_text=_('Assigned interface')
  1494. )
  1495. is_primary = forms.BooleanField(
  1496. label=_('Is primary'),
  1497. help_text=_('Make this the primary MAC address for the assigned interface'),
  1498. required=False
  1499. )
  1500. class Meta:
  1501. model = MACAddress
  1502. fields = [
  1503. 'mac_address', 'device', 'virtual_machine', 'interface', 'is_primary', 'description', 'owner', 'comments',
  1504. 'tags',
  1505. ]
  1506. def __init__(self, data=None, *args, **kwargs):
  1507. super().__init__(data, *args, **kwargs)
  1508. if data:
  1509. # Limit interface queryset by assigned device
  1510. if data.get('device'):
  1511. self.fields['interface'].queryset = Interface.objects.filter(
  1512. **{f"device__{self.fields['device'].to_field_name}": data['device']}
  1513. )
  1514. # Limit interface queryset by assigned device
  1515. elif data.get('virtual_machine'):
  1516. self.fields['interface'].queryset = VMInterface.objects.filter(
  1517. **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
  1518. )
  1519. def clean(self):
  1520. super().clean()
  1521. device = self.cleaned_data.get('device')
  1522. virtual_machine = self.cleaned_data.get('virtual_machine')
  1523. interface = self.cleaned_data.get('interface')
  1524. # Validate interface assignment
  1525. if interface and not device and not virtual_machine:
  1526. raise forms.ValidationError({
  1527. "interface": _("Must specify the parent device or VM when assigning an interface")
  1528. })
  1529. def save(self, *args, **kwargs):
  1530. # Set interface assignment
  1531. if interface := self.cleaned_data.get('interface'):
  1532. self.instance.assigned_object = interface
  1533. instance = super().save(*args, **kwargs)
  1534. # Assign the MAC address as primary for its interface, if designated as such
  1535. if interface and self.cleaned_data['is_primary'] and self.instance.pk:
  1536. interface.snapshot()
  1537. interface.primary_mac_address = self.instance
  1538. interface.save()
  1539. return instance
  1540. #
  1541. # Cables
  1542. #
  1543. class CableBundleImportForm(PrimaryModelImportForm):
  1544. class Meta:
  1545. model = CableBundle
  1546. fields = ('name', 'description', 'owner', 'comments', 'tags')
  1547. class CableImportForm(PrimaryModelImportForm):
  1548. # Cable.clean() reports termination errors (e.g. cable profile violations) against the model's
  1549. # a_terminations/b_terminations attributes, which have no corresponding fields on this form.
  1550. # Map them onto the columns which define each side's terminations.
  1551. TERMINATION_ERROR_FIELDS = {
  1552. 'a_terminations': 'side_a_name',
  1553. 'b_terminations': 'side_b_name',
  1554. }
  1555. # Columns which take effect only by resolving a side's terminations, and are therefore
  1556. # meaningless without that side's name column.
  1557. TERMINATION_DEPENDENT_COLUMNS = ('site', 'device', 'power_panel', 'type')
  1558. # Termination A
  1559. side_a_site = CSVModelChoiceField(
  1560. label=_('Side A site'),
  1561. queryset=Site.objects.all(),
  1562. required=False,
  1563. to_field_name='name',
  1564. help_text=_('Site of parent device A (if any). Restricts the devices & power panels which may be matched.'),
  1565. )
  1566. side_a_device = CSVModelMultipleChoiceField(
  1567. label=_('Side A device'),
  1568. queryset=Device.objects.all(),
  1569. required=False,
  1570. to_field_name='name',
  1571. help_text=format_html_lazy(
  1572. '{} <code>{}</code>',
  1573. _('Device name(s) for device component terminations. Separate multiple values with commas, '
  1574. 'encased with double quotes. Example:'),
  1575. '"device1,device2"'
  1576. )
  1577. )
  1578. side_a_power_panel = CSVModelMultipleChoiceField(
  1579. label=_('Side A power panel'),
  1580. queryset=PowerPanel.objects.all(),
  1581. required=False,
  1582. to_field_name='name',
  1583. help_text=format_html_lazy(
  1584. '{} <code>{}</code>',
  1585. _('Power panel name(s) for power feed terminations. Separate multiple values with commas, '
  1586. 'encased with double quotes. Example:'),
  1587. '"panel1,panel2"'
  1588. )
  1589. )
  1590. side_a_type = CSVContentTypeField(
  1591. label=_('Side A type'),
  1592. queryset=ContentType.objects.all(),
  1593. limit_choices_to=CABLE_TERMINATION_MODELS,
  1594. help_text=_('Termination type')
  1595. )
  1596. side_a_name = forms.CharField(
  1597. label=_('Side A name'),
  1598. help_text=format_html_lazy(
  1599. '{} <code>{}</code>',
  1600. _('Termination name(s). Separate multiple values with commas, encased with double quotes. '
  1601. 'Example:'),
  1602. '"eth0,eth1"'
  1603. )
  1604. )
  1605. # Termination B
  1606. side_b_site = CSVModelChoiceField(
  1607. label=_('Side B site'),
  1608. queryset=Site.objects.all(),
  1609. required=False,
  1610. to_field_name='name',
  1611. help_text=_('Site of parent device B (if any). Restricts the devices & power panels which may be matched.'),
  1612. )
  1613. side_b_device = CSVModelMultipleChoiceField(
  1614. label=_('Side B device'),
  1615. queryset=Device.objects.all(),
  1616. required=False,
  1617. to_field_name='name',
  1618. help_text=format_html_lazy(
  1619. '{} <code>{}</code>',
  1620. _('Device name(s) for device component terminations. Separate multiple values with commas, '
  1621. 'encased with double quotes. Example:'),
  1622. '"device1,device2"'
  1623. )
  1624. )
  1625. side_b_power_panel = CSVModelMultipleChoiceField(
  1626. label=_('Side B power panel'),
  1627. queryset=PowerPanel.objects.all(),
  1628. required=False,
  1629. to_field_name='name',
  1630. help_text=format_html_lazy(
  1631. '{} <code>{}</code>',
  1632. _('Power panel name(s) for power feed terminations. Separate multiple values with commas, '
  1633. 'encased with double quotes. Example:'),
  1634. '"panel1,panel2"'
  1635. )
  1636. )
  1637. side_b_type = CSVContentTypeField(
  1638. label=_('Side B type'),
  1639. queryset=ContentType.objects.all(),
  1640. limit_choices_to=CABLE_TERMINATION_MODELS,
  1641. help_text=_('Termination type')
  1642. )
  1643. side_b_name = forms.CharField(
  1644. label=_('Side B name'),
  1645. help_text=format_html_lazy(
  1646. '{} <code>{}</code>',
  1647. _('Termination name(s). Separate multiple values with commas, encased with double quotes. '
  1648. 'Example:'),
  1649. '"eth0,eth1"'
  1650. )
  1651. )
  1652. # Cable attributes
  1653. status = CSVChoiceField(
  1654. label=_('Status'),
  1655. choices=LinkStatusChoices,
  1656. required=False,
  1657. help_text=_('Connection status')
  1658. )
  1659. profile = CSVChoiceField(
  1660. label=_('Profile'),
  1661. choices=CableProfileChoices,
  1662. required=False,
  1663. help_text=_('Cable connection profile')
  1664. )
  1665. type = CSVChoiceField(
  1666. label=_('Type'),
  1667. choices=CableTypeChoices,
  1668. required=False,
  1669. help_text=_('Physical medium classification')
  1670. )
  1671. tenant = CSVModelChoiceField(
  1672. label=_('Tenant'),
  1673. queryset=Tenant.objects.all(),
  1674. required=False,
  1675. to_field_name='name',
  1676. help_text=_('Assigned tenant')
  1677. )
  1678. bundle = CSVModelChoiceField(
  1679. label=_('Bundle'),
  1680. queryset=CableBundle.objects.all(),
  1681. required=False,
  1682. to_field_name='name',
  1683. help_text=_('Cable bundle name'),
  1684. )
  1685. length_unit = CSVChoiceField(
  1686. label=_('Length unit'),
  1687. choices=CableLengthUnitChoices,
  1688. required=False,
  1689. help_text=_('Length unit')
  1690. )
  1691. color = forms.CharField(
  1692. label=_('Color'),
  1693. required=False,
  1694. max_length=16,
  1695. help_text=_('Color name (e.g. "Red") or hex code (e.g. "f44336")')
  1696. )
  1697. class Meta:
  1698. model = Cable
  1699. fields = [
  1700. 'side_a_site', 'side_a_device', 'side_a_power_panel', 'side_a_type', 'side_a_name',
  1701. 'side_b_site', 'side_b_device', 'side_b_power_panel', 'side_b_type', 'side_b_name',
  1702. 'type', 'status', 'profile', 'tenant', 'bundle', 'label', 'color', 'length', 'length_unit',
  1703. 'description', 'owner', 'comments', 'tags',
  1704. ]
  1705. def __init__(self, data=None, *args, **kwargs):
  1706. super().__init__(data, *args, **kwargs)
  1707. if data:
  1708. # Limit choices for side_a_device to the assigned side_a_site
  1709. if side_a_site := data.get('side_a_site'):
  1710. side_a_parent_params = {f'site__{self.fields['side_a_site'].to_field_name}': side_a_site}
  1711. self.fields['side_a_device'].queryset = self.fields['side_a_device'].queryset.filter(
  1712. **side_a_parent_params
  1713. )
  1714. self.fields['side_a_power_panel'].queryset = self.fields['side_a_power_panel'].queryset.filter(
  1715. **side_a_parent_params
  1716. )
  1717. # Limit choices for side_b_device to the assigned side_b_site
  1718. if side_b_site := data.get('side_b_site'):
  1719. side_b_parent_params = {f'site__{self.fields['side_b_site'].to_field_name}': side_b_site}
  1720. self.fields['side_b_device'].queryset = self.fields['side_b_device'].queryset.filter(
  1721. **side_b_parent_params
  1722. )
  1723. self.fields['side_b_power_panel'].queryset = self.fields['side_b_power_panel'].queryset.filter(
  1724. **side_b_parent_params
  1725. )
  1726. def _update_errors(self, errors):
  1727. # Remap any termination errors raised by Cable.clean() onto the relevant import column.
  1728. # Without this, the base form reports them as prefixed non-field errors, as neither
  1729. # a_terminations nor b_terminations exists as a field on this form. Errors are dispatched
  1730. # per key so that both sides can fall back to a non-field error without colliding.
  1731. if hasattr(errors, 'error_dict'):
  1732. remapped = {}
  1733. for name, error_list in errors.error_dict.items():
  1734. mapped_name = self._map_termination_field(name) or NON_FIELD_ERRORS
  1735. remapped.setdefault(mapped_name, []).extend(error_list)
  1736. errors = forms.ValidationError(remapped)
  1737. super()._update_errors(errors)
  1738. def _map_termination_field(self, field):
  1739. """
  1740. Return the import column against which a model-level termination error should be reported,
  1741. or None (i.e. a non-field error) if that column is not present on the form. Columns absent
  1742. from an update record are removed from the form by BulkImportView, so a cable profile
  1743. violation can be reported against a side whose terminations were not being modified.
  1744. """
  1745. if field not in self.TERMINATION_ERROR_FIELDS:
  1746. return field
  1747. mapped_field = self.TERMINATION_ERROR_FIELDS[field]
  1748. return mapped_field if mapped_field in self.fields else None
  1749. @staticmethod
  1750. def _split_side_values(value):
  1751. """
  1752. Split a side_* cell into an ordered list of values, preserving duplicates and empty
  1753. entries. Accepts a comma-separated string (CSV) or a native list (JSON/YAML).
  1754. """
  1755. if value in (None, ''):
  1756. return []
  1757. if not isinstance(value, (list, tuple)):
  1758. value = str(value).split(',')
  1759. return ['' if item is None else str(item).strip() for item in value]
  1760. def _check_companion_column(self, side, field_name):
  1761. """
  1762. Verify that a column needed to resolve a side's terminations is present on the form.
  1763. When updating an existing object, BulkImportView removes every field which does not appear
  1764. in the record. A record which redefines a side's termination names must therefore also
  1765. include the columns identifying their type and parent, otherwise the names cannot be
  1766. resolved and the update would appear to succeed while changing nothing.
  1767. """
  1768. if field_name not in self.fields:
  1769. raise forms.ValidationError(
  1770. _(
  1771. "Side {side_upper}: The {column} column must be included when modifying terminations"
  1772. ).format(side_upper=side.upper(), column=field_name)
  1773. )
  1774. def _resolve_side_parent_objects(self, field_name):
  1775. """
  1776. Resolve a side's parent objects from the raw submitted values, preserving their order.
  1777. CSVModelMultipleChoiceField cleans to an unordered queryset, which cannot be used to pair
  1778. each parent with its corresponding termination name by position. Resolution errors are
  1779. reported on the parent field itself.
  1780. """
  1781. if field_name not in self.cleaned_data:
  1782. # The parent field has already raised its own validation error
  1783. return None
  1784. field = self.fields[field_name]
  1785. to_field_name = field.to_field_name or 'pk'
  1786. parents = []
  1787. for value in self._split_side_values(self.data.get(field_name)):
  1788. try:
  1789. parents.append(field.queryset.get(**{to_field_name: value}))
  1790. except ObjectDoesNotExist:
  1791. self.add_error(field_name, _("Object not found: {value}").format(value=value))
  1792. return None
  1793. except MultipleObjectsReturned:
  1794. self.add_error(
  1795. field_name,
  1796. _('"{value}" is not a unique value for this field; multiple objects were found').format(
  1797. value=value
  1798. )
  1799. )
  1800. return None
  1801. return parents
  1802. @staticmethod
  1803. def _get_device_component_termination(model, device, name):
  1804. """
  1805. Resolve a device component by its device and name. If the device is a virtual chassis
  1806. master and the component is not found on it, search all virtual chassis members.
  1807. """
  1808. queryset = model.objects.filter(device=device, name=name)
  1809. if (
  1810. device.virtual_chassis and
  1811. device.virtual_chassis.master == device and
  1812. not queryset.exists()
  1813. ):
  1814. queryset = model.objects.filter(device__in=device.virtual_chassis.members.all(), name=name)
  1815. return queryset.get()
  1816. def _clean_side(self, side):
  1817. """
  1818. Derive a Cable's A/B termination objects.
  1819. :param side: 'a' or 'b'
  1820. """
  1821. if side not in ('a', 'b'):
  1822. raise ValueError(_("Invalid side designation: {side}").format(side=side))
  1823. content_type = self.cleaned_data.get(f'side_{side}_type')
  1824. # Native list values (JSON/YAML) bypass the CharField; strings use its cleaned value
  1825. names = self.data.get(f'side_{side}_name')
  1826. if not isinstance(names, (list, tuple)):
  1827. names = self.cleaned_data.get(f'side_{side}_name')
  1828. names = self._split_side_values(names)
  1829. if not names:
  1830. return None
  1831. if not content_type:
  1832. # BulkImportView removes any field absent from an update record, so a missing termination
  1833. # type here means the column was omitted rather than left blank. Reject it: silently
  1834. # ignoring the submitted names would report a successful update which changed nothing.
  1835. self._check_companion_column(side, f'side_{side}_type')
  1836. return None
  1837. if '' in names:
  1838. raise forms.ValidationError(
  1839. _("Side {side_upper}: Empty termination names are not permitted").format(side_upper=side.upper())
  1840. )
  1841. model = content_type.model_class()
  1842. # Identify the parent field for the termination type. PowerFeed terminations reference a
  1843. # PowerPanel; all other supported types reference a Device.
  1844. if content_type.model == 'powerfeed':
  1845. parent_field_name = f'side_{side}_power_panel'
  1846. parent_label = _('power panel')
  1847. elif any(field.name == 'device' for field in model._meta.fields):
  1848. parent_field_name = f'side_{side}_device'
  1849. parent_label = _('device')
  1850. else:
  1851. raise forms.ValidationError(
  1852. _("Bulk import does not support {type} terminations").format(type=content_type)
  1853. )
  1854. self._check_companion_column(side, parent_field_name)
  1855. parents = self._resolve_side_parent_objects(parent_field_name)
  1856. if parents is None:
  1857. # The parent field has already raised its own validation error
  1858. return None
  1859. if not parents:
  1860. raise forms.ValidationError(
  1861. _("Side {side_upper}: Must specify a {parent} for the selected termination type").format(
  1862. side_upper=side.upper(), parent=parent_label
  1863. )
  1864. )
  1865. if len(parents) == 1:
  1866. parents = parents * len(names)
  1867. elif len(parents) != len(names):
  1868. raise forms.ValidationError(
  1869. _(
  1870. "Side {side_upper}: Must specify either one {parent} for all terminations or one {parent} "
  1871. "per termination name"
  1872. ).format(side_upper=side.upper(), parent=parent_label)
  1873. )
  1874. terminations = []
  1875. for parent, name in zip(parents, names):
  1876. try:
  1877. if content_type.model == 'powerfeed':
  1878. termination_object = model.objects.get(power_panel=parent, name=name)
  1879. else:
  1880. termination_object = self._get_device_component_termination(model, parent, name)
  1881. except ObjectDoesNotExist:
  1882. raise forms.ValidationError(
  1883. _("{side_upper} side termination not found: {parent} {name}").format(
  1884. side_upper=side.upper(), parent=parent, name=name
  1885. )
  1886. )
  1887. except MultipleObjectsReturned:
  1888. raise forms.ValidationError(
  1889. _("{side_upper} side termination not unique: {parent} {name}").format(
  1890. side_upper=side.upper(), parent=parent, name=name
  1891. )
  1892. )
  1893. if termination_object.cable is not None and termination_object.cable != self.instance:
  1894. raise forms.ValidationError(
  1895. _("Side {side_upper}: {parent} {termination_object} is already connected").format(
  1896. side_upper=side.upper(), parent=parent, termination_object=termination_object
  1897. )
  1898. )
  1899. terminations.append(termination_object)
  1900. if len({termination.pk for termination in terminations}) != len(terminations):
  1901. raise forms.ValidationError(
  1902. _("Side {side_upper}: Duplicate termination specified").format(side_upper=side.upper())
  1903. )
  1904. setattr(self.instance, f'{side}_terminations', terminations)
  1905. return terminations
  1906. def _clean_color(self, color):
  1907. """
  1908. Derive a colors hex code
  1909. :param color: color as hex or color name
  1910. """
  1911. color_parsed = color.strip().lower()
  1912. for hex_code, label in ColorChoices.CHOICES:
  1913. if color.lower() == label.lower():
  1914. color_parsed = hex_code
  1915. if len(color_parsed) > 6:
  1916. raise forms.ValidationError(
  1917. _(f"{color} did not match any used color name and was longer than six characters: invalid hex.")
  1918. )
  1919. return color_parsed
  1920. def clean(self):
  1921. cleaned_data = super().clean()
  1922. # Termination resolution is driven by clean_side_<x>_name(), which Django never calls for a
  1923. # field BulkImportView has removed. An update record which supplies a side's supporting
  1924. # columns but omits its name column would therefore have those columns silently discarded
  1925. # and report an update which changed nothing; reject it instead. This cannot trigger on
  1926. # creation, where the name columns are always present (and required).
  1927. for side in ('a', 'b'):
  1928. if f'side_{side}_name' in self.fields:
  1929. continue
  1930. if supplied := [
  1931. column for column in self.TERMINATION_DEPENDENT_COLUMNS
  1932. if f'side_{side}_{column}' in self.fields
  1933. ]:
  1934. self.add_error(None, _(
  1935. "Side {side_upper}: The side_{side}_name column must be included when modifying "
  1936. "terminations (found {columns})"
  1937. ).format(
  1938. side_upper=side.upper(),
  1939. side=side,
  1940. columns=', '.join(f'side_{side}_{column}' for column in supplied),
  1941. ))
  1942. return cleaned_data
  1943. def clean_side_a_name(self):
  1944. return self._clean_side('a')
  1945. def clean_side_b_name(self):
  1946. return self._clean_side('b')
  1947. def clean_length_unit(self):
  1948. # Avoid trying to save as NULL
  1949. length_unit = self.cleaned_data.get('length_unit', None)
  1950. return length_unit if length_unit is not None else ''
  1951. def clean_color(self):
  1952. color = self.cleaned_data.get('color', None)
  1953. return self._clean_color(color) if color is not None else ''
  1954. #
  1955. # Virtual chassis
  1956. #
  1957. class VirtualChassisImportForm(PrimaryModelImportForm):
  1958. master = CSVModelChoiceField(
  1959. label=_('Master'),
  1960. queryset=Device.objects.all(),
  1961. to_field_name='name',
  1962. required=False,
  1963. help_text=_('Master device')
  1964. )
  1965. class Meta:
  1966. model = VirtualChassis
  1967. fields = ('name', 'domain', 'master', 'description', 'owner', 'comments', 'tags')
  1968. #
  1969. # Power
  1970. #
  1971. class PowerPanelImportForm(PrimaryModelImportForm):
  1972. site = CSVModelChoiceField(
  1973. label=_('Site'),
  1974. queryset=Site.objects.all(),
  1975. to_field_name='name',
  1976. help_text=_('Name of parent site')
  1977. )
  1978. location = CSVModelChoiceField(
  1979. label=_('Location'),
  1980. queryset=Location.objects.all(),
  1981. required=False,
  1982. to_field_name='name'
  1983. )
  1984. class Meta:
  1985. model = PowerPanel
  1986. fields = ('site', 'location', 'name', 'description', 'owner', 'comments', 'tags')
  1987. def __init__(self, data=None, *args, **kwargs):
  1988. super().__init__(data, *args, **kwargs)
  1989. if data:
  1990. # Limit group queryset by assigned site
  1991. params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
  1992. self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
  1993. class PowerFeedImportForm(PrimaryModelImportForm):
  1994. site = CSVModelChoiceField(
  1995. label=_('Site'),
  1996. queryset=Site.objects.all(),
  1997. to_field_name='name',
  1998. help_text=_('Assigned site')
  1999. )
  2000. power_panel = CSVModelChoiceField(
  2001. label=_('Power panel'),
  2002. queryset=PowerPanel.objects.all(),
  2003. to_field_name='name',
  2004. help_text=_('Upstream power panel')
  2005. )
  2006. location = CSVModelChoiceField(
  2007. label=_('Location'),
  2008. queryset=Location.objects.all(),
  2009. to_field_name='name',
  2010. required=False,
  2011. help_text=_("Rack's location (if any)")
  2012. )
  2013. rack = CSVModelChoiceField(
  2014. label=_('Rack'),
  2015. queryset=Rack.objects.all(),
  2016. to_field_name='name',
  2017. required=False,
  2018. help_text=_('Rack')
  2019. )
  2020. tenant = CSVModelChoiceField(
  2021. queryset=Tenant.objects.all(),
  2022. to_field_name='name',
  2023. required=False,
  2024. help_text=_('Assigned tenant')
  2025. )
  2026. status = CSVChoiceField(
  2027. label=_('Status'),
  2028. choices=PowerFeedStatusChoices,
  2029. help_text=_('Operational status')
  2030. )
  2031. type = CSVChoiceField(
  2032. label=_('Type'),
  2033. choices=PowerFeedTypeChoices,
  2034. help_text=_('Primary or redundant')
  2035. )
  2036. supply = CSVChoiceField(
  2037. label=_('Supply'),
  2038. choices=PowerFeedSupplyChoices,
  2039. help_text=_('Supply type (AC/DC)')
  2040. )
  2041. phase = CSVChoiceField(
  2042. label=_('Phase'),
  2043. choices=PowerFeedPhaseChoices,
  2044. help_text=_('Single or three-phase')
  2045. )
  2046. class Meta:
  2047. model = PowerFeed
  2048. fields = (
  2049. 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase',
  2050. 'voltage', 'amperage', 'max_utilization', 'tenant', 'description', 'owner', 'comments', 'tags',
  2051. )
  2052. def __init__(self, data=None, *args, **kwargs):
  2053. super().__init__(data, *args, **kwargs)
  2054. if data:
  2055. # Limit power_panel queryset by site
  2056. params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
  2057. self.fields['power_panel'].queryset = self.fields['power_panel'].queryset.filter(**params)
  2058. # Limit location queryset by site
  2059. params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
  2060. self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
  2061. # Limit rack queryset by site and group
  2062. params = {
  2063. f"site__{self.fields['site'].to_field_name}": data.get('site'),
  2064. f"location__{self.fields['location'].to_field_name}": data.get('location'),
  2065. }
  2066. self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
  2067. class CoolingSourceImportForm(PrimaryModelImportForm):
  2068. site = CSVModelChoiceField(
  2069. label=_('Site'),
  2070. queryset=Site.objects.all(),
  2071. to_field_name='name',
  2072. help_text=_('Name of parent site')
  2073. )
  2074. location = CSVModelChoiceField(
  2075. label=_('Location'),
  2076. queryset=Location.objects.all(),
  2077. required=False,
  2078. to_field_name='name'
  2079. )
  2080. type = CSVChoiceField(
  2081. label=_('Type'),
  2082. choices=CoolingSourceTypeChoices,
  2083. help_text=_('Cooling source type')
  2084. )
  2085. status = CSVChoiceField(
  2086. label=_('Status'),
  2087. choices=CoolingSourceStatusChoices,
  2088. help_text=_('Operational status')
  2089. )
  2090. fluid_type = CSVChoiceField(
  2091. label=_('Fluid type'),
  2092. choices=FluidTypeChoices,
  2093. required=False,
  2094. help_text=_('Coolant fluid type')
  2095. )
  2096. class Meta:
  2097. model = CoolingSource
  2098. fields = (
  2099. 'site', 'location', 'name', 'type', 'status', 'fluid_type', 'cooling_capacity', 'description', 'owner',
  2100. 'comments', 'tags',
  2101. )
  2102. def __init__(self, data=None, *args, **kwargs):
  2103. super().__init__(data, *args, **kwargs)
  2104. if data:
  2105. # Limit location queryset by assigned site
  2106. params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
  2107. self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
  2108. class CoolingFeedImportForm(PrimaryModelImportForm):
  2109. site = CSVModelChoiceField(
  2110. label=_('Site'),
  2111. queryset=Site.objects.all(),
  2112. to_field_name='name',
  2113. help_text=_('Assigned site')
  2114. )
  2115. cooling_source = CSVModelChoiceField(
  2116. label=_('Cooling source'),
  2117. queryset=CoolingSource.objects.all(),
  2118. to_field_name='name',
  2119. help_text=_('Upstream cooling source')
  2120. )
  2121. location = CSVModelChoiceField(
  2122. label=_('Location'),
  2123. queryset=Location.objects.all(),
  2124. to_field_name='name',
  2125. required=False,
  2126. help_text=_("Rack's location (if any)")
  2127. )
  2128. rack = CSVModelChoiceField(
  2129. label=_('Rack'),
  2130. queryset=Rack.objects.all(),
  2131. to_field_name='name',
  2132. required=False,
  2133. help_text=_('Rack')
  2134. )
  2135. tenant = CSVModelChoiceField(
  2136. queryset=Tenant.objects.all(),
  2137. to_field_name='name',
  2138. required=False,
  2139. help_text=_('Assigned tenant')
  2140. )
  2141. status = CSVChoiceField(
  2142. label=_('Status'),
  2143. choices=CoolingFeedStatusChoices,
  2144. help_text=_('Operational status')
  2145. )
  2146. max_flow_unit = CSVChoiceField(
  2147. label=_('Max flow unit'),
  2148. choices=FlowRateUnitChoices,
  2149. required=False,
  2150. help_text=_('Unit for maximum flow')
  2151. )
  2152. class Meta:
  2153. model = CoolingFeed
  2154. fields = (
  2155. 'site', 'cooling_source', 'location', 'rack', 'name', 'status',
  2156. 'cooling_capacity', 'max_flow', 'max_flow_unit', 'tenant', 'description', 'owner',
  2157. 'comments', 'tags',
  2158. )
  2159. def __init__(self, data=None, *args, **kwargs):
  2160. super().__init__(data, *args, **kwargs)
  2161. if data:
  2162. # Limit cooling_source queryset by site
  2163. params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
  2164. self.fields['cooling_source'].queryset = self.fields['cooling_source'].queryset.filter(**params)
  2165. # Limit location queryset by site
  2166. params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
  2167. self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
  2168. # Limit rack queryset by site and group
  2169. params = {
  2170. f"site__{self.fields['site'].to_field_name}": data.get('site'),
  2171. f"location__{self.fields['location'].to_field_name}": data.get('location'),
  2172. }
  2173. self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
  2174. class VirtualDeviceContextImportForm(PrimaryModelImportForm):
  2175. device = CSVModelChoiceField(
  2176. label=_('Device'),
  2177. queryset=Device.objects.all(),
  2178. to_field_name='name',
  2179. help_text=_('Assigned role')
  2180. )
  2181. tenant = CSVModelChoiceField(
  2182. label=_('Tenant'),
  2183. queryset=Tenant.objects.all(),
  2184. required=False,
  2185. to_field_name='name',
  2186. help_text=_('Assigned tenant')
  2187. )
  2188. status = CSVChoiceField(
  2189. label=_('Status'),
  2190. choices=VirtualDeviceContextStatusChoices,
  2191. )
  2192. primary_ip4 = CSVModelChoiceField(
  2193. label=_('Primary IPv4'),
  2194. queryset=IPAddress.objects.all(),
  2195. required=False,
  2196. to_field_name='address',
  2197. help_text=_('IPv4 address with mask, e.g. 1.2.3.4/24')
  2198. )
  2199. primary_ip6 = CSVModelChoiceField(
  2200. label=_('Primary IPv6'),
  2201. queryset=IPAddress.objects.all(),
  2202. required=False,
  2203. to_field_name='address',
  2204. help_text=_('IPv6 address with prefix length, e.g. 2001:db8::1/64')
  2205. )
  2206. class Meta:
  2207. fields = [
  2208. 'name', 'device', 'status', 'tenant', 'identifier', 'owner', 'comments', 'primary_ip4', 'primary_ip6',
  2209. ]
  2210. model = VirtualDeviceContext
  2211. def __init__(self, data=None, *args, **kwargs):
  2212. super().__init__(data, *args, **kwargs)
  2213. if data:
  2214. # Limit primary_ip4/ip6 querysets by assigned device
  2215. params = {f"interface__device__{self.fields['device'].to_field_name}": data.get('device')}
  2216. self.fields['primary_ip4'].queryset = self.fields['primary_ip4'].queryset.filter(**params)
  2217. self.fields['primary_ip6'].queryset = self.fields['primary_ip6'].queryset.filter(**params)