forms.py 79 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853
  1. import re
  2. from django import forms
  3. from django.contrib.auth.models import User
  4. from django.contrib.contenttypes.models import ContentType
  5. from django.contrib.postgres.forms.array import SimpleArrayField
  6. from django.core.exceptions import ObjectDoesNotExist
  7. from django.db.models import Count, Q
  8. from mptt.forms import TreeNodeChoiceField
  9. from taggit.forms import TagField
  10. from timezone_field import TimeZoneFormField
  11. from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
  12. from ipam.models import IPAddress, VLAN, VLANGroup
  13. from tenancy.forms import TenancyForm
  14. from tenancy.models import Tenant
  15. from utilities.forms import (
  16. AnnotatedMultipleChoiceField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
  17. BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ColorSelect, CommentField, ComponentForm,
  18. ConfirmationForm, ContentTypeSelect, CSVChoiceField, ExpandableNameField, FilterChoiceField,
  19. FilterTreeNodeMultipleChoiceField, FlexibleModelChoiceField, JSONField, Livesearch, SelectWithPK, SmallTextarea,
  20. SlugField, BOOLEAN_WITH_BLANK_CHOICES, COLOR_CHOICES,
  21. )
  22. from virtualization.models import Cluster, ClusterGroup
  23. from .constants import *
  24. from .models import (
  25. Cable, DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
  26. Device, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer,
  27. InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
  28. RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
  29. )
  30. DEVICE_BY_PK_RE = r'{\d+\}'
  31. INTERFACE_MODE_HELP_TEXT = """
  32. Access: One untagged VLAN<br />
  33. Tagged: One untagged VLAN and/or one or more tagged VLANs<br />
  34. Tagged All: Implies all VLANs are available (w/optional untagged VLAN)
  35. """
  36. def get_device_by_name_or_pk(name):
  37. """
  38. Attempt to retrieve a device by either its name or primary key ('{pk}').
  39. """
  40. if re.match(DEVICE_BY_PK_RE, name):
  41. pk = name.strip('{}')
  42. device = Device.objects.get(pk=pk)
  43. else:
  44. device = Device.objects.get(name=name)
  45. return device
  46. class BulkRenameForm(forms.Form):
  47. """
  48. An extendable form to be used for renaming device components in bulk.
  49. """
  50. find = forms.CharField()
  51. replace = forms.CharField()
  52. #
  53. # Regions
  54. #
  55. class RegionForm(BootstrapMixin, forms.ModelForm):
  56. slug = SlugField()
  57. class Meta:
  58. model = Region
  59. fields = [
  60. 'parent', 'name', 'slug',
  61. ]
  62. class RegionCSVForm(forms.ModelForm):
  63. parent = forms.ModelChoiceField(
  64. queryset=Region.objects.all(),
  65. required=False,
  66. to_field_name='name',
  67. help_text='Name of parent region',
  68. error_messages={
  69. 'invalid_choice': 'Region not found.',
  70. }
  71. )
  72. class Meta:
  73. model = Region
  74. fields = Region.csv_headers
  75. help_texts = {
  76. 'name': 'Region name',
  77. 'slug': 'URL-friendly slug',
  78. }
  79. class RegionFilterForm(BootstrapMixin, forms.Form):
  80. model = Site
  81. q = forms.CharField(
  82. required=False,
  83. label='Search'
  84. )
  85. #
  86. # Sites
  87. #
  88. class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
  89. region = TreeNodeChoiceField(
  90. queryset=Region.objects.all(),
  91. required=False
  92. )
  93. slug = SlugField()
  94. comments = CommentField()
  95. tags = TagField(
  96. required=False
  97. )
  98. class Meta:
  99. model = Site
  100. fields = [
  101. 'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
  102. 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
  103. 'contact_email', 'comments', 'tags',
  104. ]
  105. widgets = {
  106. 'physical_address': SmallTextarea(
  107. attrs={
  108. 'rows': 3,
  109. }
  110. ),
  111. 'shipping_address': SmallTextarea(
  112. attrs={
  113. 'rows': 3,
  114. }
  115. ),
  116. }
  117. help_texts = {
  118. 'name': "Full name of the site",
  119. 'facility': "Data center provider and facility (e.g. Equinix NY7)",
  120. 'asn': "BGP autonomous system number",
  121. 'time_zone': "Local time zone",
  122. 'description': "Short description (will appear in sites list)",
  123. 'physical_address': "Physical location of the building (e.g. for GPS)",
  124. 'shipping_address': "If different from the physical address",
  125. 'latitude': "Latitude in decimal format (xx.yyyyyy)",
  126. 'longitude': "Longitude in decimal format (xx.yyyyyy)"
  127. }
  128. class SiteCSVForm(forms.ModelForm):
  129. status = CSVChoiceField(
  130. choices=SITE_STATUS_CHOICES,
  131. required=False,
  132. help_text='Operational status'
  133. )
  134. region = forms.ModelChoiceField(
  135. queryset=Region.objects.all(),
  136. required=False,
  137. to_field_name='name',
  138. help_text='Name of assigned region',
  139. error_messages={
  140. 'invalid_choice': 'Region not found.',
  141. }
  142. )
  143. tenant = forms.ModelChoiceField(
  144. queryset=Tenant.objects.all(),
  145. required=False,
  146. to_field_name='name',
  147. help_text='Name of assigned tenant',
  148. error_messages={
  149. 'invalid_choice': 'Tenant not found.',
  150. }
  151. )
  152. class Meta:
  153. model = Site
  154. fields = Site.csv_headers
  155. help_texts = {
  156. 'name': 'Site name',
  157. 'slug': 'URL-friendly slug',
  158. 'asn': '32-bit autonomous system number',
  159. }
  160. class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
  161. pk = forms.ModelMultipleChoiceField(
  162. queryset=Site.objects.all(),
  163. widget=forms.MultipleHiddenInput
  164. )
  165. status = forms.ChoiceField(
  166. choices=add_blank_choice(SITE_STATUS_CHOICES),
  167. required=False,
  168. initial=''
  169. )
  170. region = TreeNodeChoiceField(
  171. queryset=Region.objects.all(),
  172. required=False
  173. )
  174. tenant = forms.ModelChoiceField(
  175. queryset=Tenant.objects.all(),
  176. required=False
  177. )
  178. asn = forms.IntegerField(
  179. min_value=1,
  180. max_value=4294967295,
  181. required=False,
  182. label='ASN'
  183. )
  184. description = forms.CharField(
  185. max_length=100,
  186. required=False
  187. )
  188. time_zone = TimeZoneFormField(
  189. choices=add_blank_choice(TimeZoneFormField().choices),
  190. required=False
  191. )
  192. class Meta:
  193. nullable_fields = [
  194. 'region', 'tenant', 'asn', 'description', 'time_zone',
  195. ]
  196. class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
  197. model = Site
  198. q = forms.CharField(
  199. required=False,
  200. label='Search'
  201. )
  202. status = AnnotatedMultipleChoiceField(
  203. choices=SITE_STATUS_CHOICES,
  204. annotate=Site.objects.all(),
  205. annotate_field='status',
  206. required=False
  207. )
  208. region = FilterTreeNodeMultipleChoiceField(
  209. queryset=Region.objects.annotate(filter_count=Count('sites')),
  210. to_field_name='slug',
  211. required=False,
  212. )
  213. tenant = FilterChoiceField(
  214. queryset=Tenant.objects.annotate(filter_count=Count('sites')),
  215. to_field_name='slug',
  216. null_label='-- None --'
  217. )
  218. #
  219. # Rack groups
  220. #
  221. class RackGroupForm(BootstrapMixin, forms.ModelForm):
  222. slug = SlugField()
  223. class Meta:
  224. model = RackGroup
  225. fields = [
  226. 'site', 'name', 'slug',
  227. ]
  228. class RackGroupCSVForm(forms.ModelForm):
  229. site = forms.ModelChoiceField(
  230. queryset=Site.objects.all(),
  231. to_field_name='name',
  232. help_text='Name of parent site',
  233. error_messages={
  234. 'invalid_choice': 'Site not found.',
  235. }
  236. )
  237. class Meta:
  238. model = RackGroup
  239. fields = RackGroup.csv_headers
  240. help_texts = {
  241. 'name': 'Name of rack group',
  242. 'slug': 'URL-friendly slug',
  243. }
  244. class RackGroupFilterForm(BootstrapMixin, forms.Form):
  245. site = FilterChoiceField(
  246. queryset=Site.objects.annotate(
  247. filter_count=Count('rack_groups')
  248. ),
  249. to_field_name='slug'
  250. )
  251. #
  252. # Rack roles
  253. #
  254. class RackRoleForm(BootstrapMixin, forms.ModelForm):
  255. slug = SlugField()
  256. class Meta:
  257. model = RackRole
  258. fields = [
  259. 'name', 'slug', 'color',
  260. ]
  261. class RackRoleCSVForm(forms.ModelForm):
  262. slug = SlugField()
  263. class Meta:
  264. model = RackRole
  265. fields = RackRole.csv_headers
  266. help_texts = {
  267. 'name': 'Name of rack role',
  268. 'color': 'RGB color in hexadecimal (e.g. 00ff00)'
  269. }
  270. #
  271. # Racks
  272. #
  273. class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm):
  274. group = ChainedModelChoiceField(
  275. queryset=RackGroup.objects.all(),
  276. chains=(
  277. ('site', 'site'),
  278. ),
  279. required=False,
  280. widget=APISelect(
  281. api_url='/api/dcim/rack-groups/?site_id={{site}}',
  282. )
  283. )
  284. comments = CommentField()
  285. tags = TagField(
  286. required=False
  287. )
  288. class Meta:
  289. model = Rack
  290. fields = [
  291. 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', 'asset_tag',
  292. 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'tags',
  293. ]
  294. help_texts = {
  295. 'site': "The site at which the rack exists",
  296. 'name': "Organizational rack name",
  297. 'facility_id': "The unique rack ID assigned by the facility",
  298. 'u_height': "Height in rack units",
  299. }
  300. widgets = {
  301. 'site': forms.Select(
  302. attrs={
  303. 'filter-for': 'group',
  304. }
  305. ),
  306. }
  307. class RackCSVForm(forms.ModelForm):
  308. site = forms.ModelChoiceField(
  309. queryset=Site.objects.all(),
  310. to_field_name='name',
  311. help_text='Name of parent site',
  312. error_messages={
  313. 'invalid_choice': 'Site not found.',
  314. }
  315. )
  316. group_name = forms.CharField(
  317. help_text='Name of rack group',
  318. required=False
  319. )
  320. tenant = forms.ModelChoiceField(
  321. queryset=Tenant.objects.all(),
  322. required=False,
  323. to_field_name='name',
  324. help_text='Name of assigned tenant',
  325. error_messages={
  326. 'invalid_choice': 'Tenant not found.',
  327. }
  328. )
  329. status = CSVChoiceField(
  330. choices=RACK_STATUS_CHOICES,
  331. required=False,
  332. help_text='Operational status'
  333. )
  334. role = forms.ModelChoiceField(
  335. queryset=RackRole.objects.all(),
  336. required=False,
  337. to_field_name='name',
  338. help_text='Name of assigned role',
  339. error_messages={
  340. 'invalid_choice': 'Role not found.',
  341. }
  342. )
  343. type = CSVChoiceField(
  344. choices=RACK_TYPE_CHOICES,
  345. required=False,
  346. help_text='Rack type'
  347. )
  348. width = forms.ChoiceField(
  349. choices=(
  350. (RACK_WIDTH_19IN, '19'),
  351. (RACK_WIDTH_23IN, '23'),
  352. ),
  353. help_text='Rail-to-rail width (in inches)'
  354. )
  355. outer_unit = CSVChoiceField(
  356. choices=RACK_DIMENSION_UNIT_CHOICES,
  357. required=False,
  358. help_text='Unit for outer dimensions'
  359. )
  360. class Meta:
  361. model = Rack
  362. fields = Rack.csv_headers
  363. help_texts = {
  364. 'name': 'Rack name',
  365. 'u_height': 'Height in rack units',
  366. }
  367. def clean(self):
  368. super().clean()
  369. site = self.cleaned_data.get('site')
  370. group_name = self.cleaned_data.get('group_name')
  371. name = self.cleaned_data.get('name')
  372. facility_id = self.cleaned_data.get('facility_id')
  373. # Validate rack group
  374. if group_name:
  375. try:
  376. self.instance.group = RackGroup.objects.get(site=site, name=group_name)
  377. except RackGroup.DoesNotExist:
  378. raise forms.ValidationError("Rack group {} not found for site {}".format(group_name, site))
  379. # Validate uniqueness of rack name within group
  380. if Rack.objects.filter(group=self.instance.group, name=name).exists():
  381. raise forms.ValidationError(
  382. "A rack named {} already exists within group {}".format(name, group_name)
  383. )
  384. # Validate uniqueness of facility ID within group
  385. if facility_id and Rack.objects.filter(group=self.instance.group, facility_id=facility_id).exists():
  386. raise forms.ValidationError(
  387. "A rack with the facility ID {} already exists within group {}".format(facility_id, group_name)
  388. )
  389. class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
  390. pk = forms.ModelMultipleChoiceField(
  391. queryset=Rack.objects.all(),
  392. widget=forms.MultipleHiddenInput
  393. )
  394. site = forms.ModelChoiceField(
  395. queryset=Site.objects.all(),
  396. required=False
  397. )
  398. group = forms.ModelChoiceField(
  399. queryset=RackGroup.objects.all(),
  400. required=False
  401. )
  402. tenant = forms.ModelChoiceField(
  403. queryset=Tenant.objects.all(),
  404. required=False
  405. )
  406. status = forms.ChoiceField(
  407. choices=add_blank_choice(RACK_STATUS_CHOICES),
  408. required=False,
  409. initial=''
  410. )
  411. role = forms.ModelChoiceField(
  412. queryset=RackRole.objects.all(),
  413. required=False
  414. )
  415. serial = forms.CharField(
  416. max_length=50,
  417. required=False,
  418. label='Serial Number'
  419. )
  420. asset_tag = forms.CharField(
  421. max_length=50,
  422. required=False
  423. )
  424. type = forms.ChoiceField(
  425. choices=add_blank_choice(RACK_TYPE_CHOICES),
  426. required=False
  427. )
  428. width = forms.ChoiceField(
  429. choices=add_blank_choice(RACK_WIDTH_CHOICES),
  430. required=False
  431. )
  432. u_height = forms.IntegerField(
  433. required=False,
  434. label='Height (U)'
  435. )
  436. desc_units = forms.NullBooleanField(
  437. required=False,
  438. widget=BulkEditNullBooleanSelect,
  439. label='Descending units'
  440. )
  441. outer_width = forms.IntegerField(
  442. required=False,
  443. min_value=1
  444. )
  445. outer_depth = forms.IntegerField(
  446. required=False,
  447. min_value=1
  448. )
  449. outer_unit = forms.ChoiceField(
  450. choices=add_blank_choice(RACK_DIMENSION_UNIT_CHOICES),
  451. required=False
  452. )
  453. comments = CommentField(
  454. widget=SmallTextarea
  455. )
  456. class Meta:
  457. nullable_fields = [
  458. 'group', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
  459. ]
  460. class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
  461. model = Rack
  462. q = forms.CharField(
  463. required=False,
  464. label='Search'
  465. )
  466. site = FilterChoiceField(
  467. queryset=Site.objects.annotate(
  468. filter_count=Count('racks')
  469. ),
  470. to_field_name='slug'
  471. )
  472. group_id = FilterChoiceField(
  473. queryset=RackGroup.objects.select_related(
  474. 'site'
  475. ).annotate(
  476. filter_count=Count('racks')
  477. ),
  478. label='Rack group',
  479. null_label='-- None --'
  480. )
  481. tenant = FilterChoiceField(
  482. queryset=Tenant.objects.annotate(
  483. filter_count=Count('racks')
  484. ),
  485. to_field_name='slug',
  486. null_label='-- None --'
  487. )
  488. status = AnnotatedMultipleChoiceField(
  489. choices=RACK_STATUS_CHOICES,
  490. annotate=Rack.objects.all(),
  491. annotate_field='status',
  492. required=False
  493. )
  494. role = FilterChoiceField(
  495. queryset=RackRole.objects.annotate(
  496. filter_count=Count('racks')
  497. ),
  498. to_field_name='slug',
  499. null_label='-- None --'
  500. )
  501. #
  502. # Rack reservations
  503. #
  504. class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
  505. units = SimpleArrayField(
  506. base_field=forms.IntegerField(),
  507. widget=ArrayFieldSelectMultiple(
  508. attrs={
  509. 'size': 10,
  510. }
  511. )
  512. )
  513. user = forms.ModelChoiceField(
  514. queryset=User.objects.order_by(
  515. 'username'
  516. )
  517. )
  518. class Meta:
  519. model = RackReservation
  520. fields = [
  521. 'units', 'user', 'tenant_group', 'tenant', 'description',
  522. ]
  523. def __init__(self, *args, **kwargs):
  524. super().__init__(*args, **kwargs)
  525. # Populate rack unit choices
  526. self.fields['units'].widget.choices = self._get_unit_choices()
  527. def _get_unit_choices(self):
  528. rack = self.instance.rack
  529. reserved_units = []
  530. for resv in rack.reservations.exclude(pk=self.instance.pk):
  531. for u in resv.units:
  532. reserved_units.append(u)
  533. unit_choices = [(u, {'label': str(u), 'disabled': u in reserved_units}) for u in rack.units]
  534. return unit_choices
  535. class RackReservationFilterForm(BootstrapMixin, forms.Form):
  536. q = forms.CharField(
  537. required=False,
  538. label='Search'
  539. )
  540. site = FilterChoiceField(
  541. queryset=Site.objects.annotate(
  542. filter_count=Count('racks__reservations')
  543. ),
  544. to_field_name='slug'
  545. )
  546. group_id = FilterChoiceField(
  547. queryset=RackGroup.objects.select_related(
  548. 'site'
  549. ).annotate(
  550. filter_count=Count('racks__reservations')
  551. ),
  552. label='Rack group',
  553. null_label='-- None --'
  554. )
  555. tenant = FilterChoiceField(
  556. queryset=Tenant.objects.annotate(
  557. filter_count=Count('rackreservations')
  558. ),
  559. to_field_name='slug',
  560. null_label='-- None --'
  561. )
  562. class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm):
  563. pk = forms.ModelMultipleChoiceField(
  564. queryset=RackReservation.objects.all(),
  565. widget=forms.MultipleHiddenInput()
  566. )
  567. user = forms.ModelChoiceField(
  568. queryset=User.objects.order_by(
  569. 'username'
  570. ),
  571. required=False
  572. )
  573. tenant = forms.ModelChoiceField(
  574. queryset=Tenant.objects.all(),
  575. required=False
  576. )
  577. description = forms.CharField(
  578. max_length=100,
  579. required=False
  580. )
  581. class Meta:
  582. nullable_fields = []
  583. #
  584. # Manufacturers
  585. #
  586. class ManufacturerForm(BootstrapMixin, forms.ModelForm):
  587. slug = SlugField()
  588. class Meta:
  589. model = Manufacturer
  590. fields = [
  591. 'name', 'slug',
  592. ]
  593. class ManufacturerCSVForm(forms.ModelForm):
  594. class Meta:
  595. model = Manufacturer
  596. fields = Manufacturer.csv_headers
  597. help_texts = {
  598. 'name': 'Manufacturer name',
  599. 'slug': 'URL-friendly slug',
  600. }
  601. #
  602. # Device types
  603. #
  604. class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
  605. slug = SlugField(
  606. slug_source='model'
  607. )
  608. tags = TagField(
  609. required=False
  610. )
  611. class Meta:
  612. model = DeviceType
  613. fields = [
  614. 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments',
  615. 'tags',
  616. ]
  617. class DeviceTypeCSVForm(forms.ModelForm):
  618. manufacturer = forms.ModelChoiceField(
  619. queryset=Manufacturer.objects.all(),
  620. required=True,
  621. to_field_name='name',
  622. help_text='Manufacturer name',
  623. error_messages={
  624. 'invalid_choice': 'Manufacturer not found.',
  625. }
  626. )
  627. subdevice_role = CSVChoiceField(
  628. choices=SUBDEVICE_ROLE_CHOICES,
  629. required=False,
  630. help_text='Parent/child status'
  631. )
  632. class Meta:
  633. model = DeviceType
  634. fields = DeviceType.csv_headers
  635. help_texts = {
  636. 'model': 'Model name',
  637. 'slug': 'URL-friendly slug',
  638. }
  639. class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
  640. pk = forms.ModelMultipleChoiceField(
  641. queryset=DeviceType.objects.all(),
  642. widget=forms.MultipleHiddenInput()
  643. )
  644. manufacturer = forms.ModelChoiceField(
  645. queryset=Manufacturer.objects.all(),
  646. required=False
  647. )
  648. u_height = forms.IntegerField(
  649. min_value=1,
  650. required=False
  651. )
  652. is_full_depth = forms.NullBooleanField(
  653. required=False,
  654. widget=BulkEditNullBooleanSelect(),
  655. label='Is full depth'
  656. )
  657. class Meta:
  658. nullable_fields = []
  659. class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
  660. model = DeviceType
  661. q = forms.CharField(
  662. required=False,
  663. label='Search'
  664. )
  665. manufacturer = FilterChoiceField(
  666. queryset=Manufacturer.objects.annotate(
  667. filter_count=Count('device_types')
  668. ),
  669. to_field_name='slug'
  670. )
  671. subdevice_role = forms.NullBooleanField(
  672. required=False,
  673. label='Subdevice role',
  674. widget=forms.Select(
  675. choices=add_blank_choice(SUBDEVICE_ROLE_CHOICES)
  676. )
  677. )
  678. console_ports = forms.NullBooleanField(
  679. required=False,
  680. label='Has console ports',
  681. widget=forms.Select(
  682. choices=BOOLEAN_WITH_BLANK_CHOICES
  683. )
  684. )
  685. console_server_ports = forms.NullBooleanField(
  686. required=False,
  687. label='Has console server ports',
  688. widget=forms.Select(
  689. choices=BOOLEAN_WITH_BLANK_CHOICES
  690. )
  691. )
  692. power_ports = forms.NullBooleanField(
  693. required=False,
  694. label='Has power ports',
  695. widget=forms.Select(
  696. choices=BOOLEAN_WITH_BLANK_CHOICES
  697. )
  698. )
  699. power_outlets = forms.NullBooleanField(
  700. required=False,
  701. label='Has power outlets',
  702. widget=forms.Select(
  703. choices=BOOLEAN_WITH_BLANK_CHOICES
  704. )
  705. )
  706. interfaces = forms.NullBooleanField(
  707. required=False,
  708. label='Has interfaces',
  709. widget=forms.Select(
  710. choices=BOOLEAN_WITH_BLANK_CHOICES
  711. )
  712. )
  713. pass_through_ports = forms.NullBooleanField(
  714. required=False,
  715. label='Has pass-through ports',
  716. widget=forms.Select(
  717. choices=BOOLEAN_WITH_BLANK_CHOICES
  718. )
  719. )
  720. #
  721. # Device component templates
  722. #
  723. class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
  724. class Meta:
  725. model = ConsolePortTemplate
  726. fields = [
  727. 'device_type', 'name',
  728. ]
  729. widgets = {
  730. 'device_type': forms.HiddenInput(),
  731. }
  732. class ConsolePortTemplateCreateForm(ComponentForm):
  733. name_pattern = ExpandableNameField(
  734. label='Name'
  735. )
  736. class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
  737. class Meta:
  738. model = ConsoleServerPortTemplate
  739. fields = [
  740. 'device_type', 'name',
  741. ]
  742. widgets = {
  743. 'device_type': forms.HiddenInput(),
  744. }
  745. class ConsoleServerPortTemplateCreateForm(ComponentForm):
  746. name_pattern = ExpandableNameField(
  747. label='Name'
  748. )
  749. class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
  750. class Meta:
  751. model = PowerPortTemplate
  752. fields = [
  753. 'device_type', 'name',
  754. ]
  755. widgets = {
  756. 'device_type': forms.HiddenInput(),
  757. }
  758. class PowerPortTemplateCreateForm(ComponentForm):
  759. name_pattern = ExpandableNameField(
  760. label='Name'
  761. )
  762. class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
  763. class Meta:
  764. model = PowerOutletTemplate
  765. fields = [
  766. 'device_type', 'name',
  767. ]
  768. widgets = {
  769. 'device_type': forms.HiddenInput(),
  770. }
  771. class PowerOutletTemplateCreateForm(ComponentForm):
  772. name_pattern = ExpandableNameField(
  773. label='Name'
  774. )
  775. class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
  776. class Meta:
  777. model = InterfaceTemplate
  778. fields = [
  779. 'device_type', 'name', 'form_factor', 'mgmt_only',
  780. ]
  781. widgets = {
  782. 'device_type': forms.HiddenInput(),
  783. }
  784. class InterfaceTemplateCreateForm(ComponentForm):
  785. name_pattern = ExpandableNameField(
  786. label='Name'
  787. )
  788. form_factor = forms.ChoiceField(
  789. choices=IFACE_FF_CHOICES
  790. )
  791. mgmt_only = forms.BooleanField(
  792. required=False,
  793. label='Management only'
  794. )
  795. class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
  796. pk = forms.ModelMultipleChoiceField(
  797. queryset=InterfaceTemplate.objects.all(),
  798. widget=forms.MultipleHiddenInput()
  799. )
  800. form_factor = forms.ChoiceField(
  801. choices=add_blank_choice(IFACE_FF_CHOICES),
  802. required=False
  803. )
  804. mgmt_only = forms.NullBooleanField(
  805. required=False,
  806. widget=BulkEditNullBooleanSelect,
  807. label='Management only'
  808. )
  809. class Meta:
  810. nullable_fields = []
  811. class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
  812. class Meta:
  813. model = FrontPortTemplate
  814. fields = [
  815. 'device_type', 'name', 'type', 'rear_port', 'rear_port_position',
  816. ]
  817. widgets = {
  818. 'device_type': forms.HiddenInput(),
  819. }
  820. class FrontPortTemplateCreateForm(ComponentForm):
  821. name_pattern = ExpandableNameField(
  822. label='Name'
  823. )
  824. type = forms.ChoiceField(
  825. choices=PORT_TYPE_CHOICES
  826. )
  827. rear_port_set = forms.MultipleChoiceField(
  828. choices=[],
  829. label='Rear ports',
  830. help_text='Select one rear port assignment for each front port being created.'
  831. )
  832. def __init__(self, *args, **kwargs):
  833. super().__init__(*args, **kwargs)
  834. # Determine which rear port positions are occupied. These will be excluded from the list of available mappings.
  835. occupied_port_positions = [
  836. (front_port.rear_port_id, front_port.rear_port_position)
  837. for front_port in self.parent.frontport_templates.all()
  838. ]
  839. # Populate rear port choices
  840. choices = []
  841. rear_ports = RearPortTemplate.objects.filter(device_type=self.parent)
  842. for rear_port in rear_ports:
  843. for i in range(1, rear_port.positions + 1):
  844. if (rear_port.pk, i) not in occupied_port_positions:
  845. choices.append(
  846. ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
  847. )
  848. self.fields['rear_port_set'].choices = choices
  849. def clean(self):
  850. # Validate that the number of ports being created equals the number of selected (rear port, position) tuples
  851. front_port_count = len(self.cleaned_data['name_pattern'])
  852. rear_port_count = len(self.cleaned_data['rear_port_set'])
  853. if front_port_count != rear_port_count:
  854. raise forms.ValidationError({
  855. 'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments '
  856. 'were selected. These counts must match.'.format(front_port_count, rear_port_count)
  857. })
  858. def get_iterative_data(self, iteration):
  859. # Assign rear port and position from selected set
  860. rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
  861. return {
  862. 'rear_port': int(rear_port),
  863. 'rear_port_position': int(position),
  864. }
  865. class RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
  866. class Meta:
  867. model = RearPortTemplate
  868. fields = [
  869. 'device_type', 'name', 'type', 'positions',
  870. ]
  871. widgets = {
  872. 'device_type': forms.HiddenInput(),
  873. }
  874. class RearPortTemplateCreateForm(ComponentForm):
  875. name_pattern = ExpandableNameField(
  876. label='Name'
  877. )
  878. type = forms.ChoiceField(
  879. choices=PORT_TYPE_CHOICES
  880. )
  881. positions = forms.IntegerField(
  882. min_value=1,
  883. max_value=64,
  884. initial=1,
  885. help_text='The number of front ports which may be mapped to each rear port'
  886. )
  887. class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
  888. class Meta:
  889. model = DeviceBayTemplate
  890. fields = [
  891. 'device_type', 'name',
  892. ]
  893. widgets = {
  894. 'device_type': forms.HiddenInput(),
  895. }
  896. class DeviceBayTemplateCreateForm(ComponentForm):
  897. name_pattern = ExpandableNameField(
  898. label='Name'
  899. )
  900. #
  901. # Device roles
  902. #
  903. class DeviceRoleForm(BootstrapMixin, forms.ModelForm):
  904. slug = SlugField()
  905. class Meta:
  906. model = DeviceRole
  907. fields = [
  908. 'name', 'slug', 'color', 'vm_role',
  909. ]
  910. class DeviceRoleCSVForm(forms.ModelForm):
  911. slug = SlugField()
  912. class Meta:
  913. model = DeviceRole
  914. fields = DeviceRole.csv_headers
  915. help_texts = {
  916. 'name': 'Name of device role',
  917. 'color': 'RGB color in hexadecimal (e.g. 00ff00)'
  918. }
  919. #
  920. # Platforms
  921. #
  922. class PlatformForm(BootstrapMixin, forms.ModelForm):
  923. slug = SlugField()
  924. class Meta:
  925. model = Platform
  926. fields = [
  927. 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args',
  928. ]
  929. widgets = {
  930. 'napalm_args': SmallTextarea(),
  931. }
  932. class PlatformCSVForm(forms.ModelForm):
  933. slug = SlugField()
  934. manufacturer = forms.ModelChoiceField(
  935. queryset=Manufacturer.objects.all(),
  936. required=False,
  937. to_field_name='name',
  938. help_text='Manufacturer name',
  939. error_messages={
  940. 'invalid_choice': 'Manufacturer not found.',
  941. }
  942. )
  943. class Meta:
  944. model = Platform
  945. fields = Platform.csv_headers
  946. help_texts = {
  947. 'name': 'Platform name',
  948. }
  949. #
  950. # Devices
  951. #
  952. class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
  953. site = forms.ModelChoiceField(
  954. queryset=Site.objects.all(),
  955. widget=forms.Select(
  956. attrs={
  957. 'filter-for': 'rack',
  958. }
  959. )
  960. )
  961. rack = ChainedModelChoiceField(
  962. queryset=Rack.objects.all(),
  963. chains=(
  964. ('site', 'site'),
  965. ),
  966. required=False,
  967. widget=APISelect(
  968. api_url='/api/dcim/racks/?site_id={{site}}',
  969. display_field='display_name',
  970. attrs={
  971. 'filter-for': 'position',
  972. }
  973. )
  974. )
  975. position = forms.TypedChoiceField(
  976. required=False,
  977. empty_value=None,
  978. help_text="The lowest-numbered unit occupied by the device",
  979. widget=APISelect(
  980. api_url='/api/dcim/racks/{{rack}}/units/?face={{face}}',
  981. disabled_indicator='device'
  982. )
  983. )
  984. manufacturer = forms.ModelChoiceField(
  985. queryset=Manufacturer.objects.all(),
  986. widget=forms.Select(
  987. attrs={
  988. 'filter-for': 'device_type',
  989. }
  990. )
  991. )
  992. device_type = ChainedModelChoiceField(
  993. queryset=DeviceType.objects.all(),
  994. chains=(
  995. ('manufacturer', 'manufacturer'),
  996. ),
  997. label='Device type',
  998. widget=APISelect(
  999. api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}',
  1000. display_field='model'
  1001. )
  1002. )
  1003. cluster_group = forms.ModelChoiceField(
  1004. queryset=ClusterGroup.objects.all(),
  1005. required=False,
  1006. widget=forms.Select(
  1007. attrs={'filter-for': 'cluster', 'nullable': 'true'}
  1008. )
  1009. )
  1010. cluster = ChainedModelChoiceField(
  1011. queryset=Cluster.objects.all(),
  1012. chains=(
  1013. ('group', 'cluster_group'),
  1014. ),
  1015. required=False,
  1016. widget=APISelect(
  1017. api_url='/api/virtualization/clusters/?group_id={{cluster_group}}',
  1018. )
  1019. )
  1020. comments = CommentField()
  1021. tags = TagField(required=False)
  1022. local_context_data = JSONField(required=False)
  1023. class Meta:
  1024. model = Device
  1025. fields = [
  1026. 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face',
  1027. 'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', 'cluster', 'tenant_group', 'tenant',
  1028. 'comments', 'tags', 'local_context_data'
  1029. ]
  1030. help_texts = {
  1031. 'device_role': "The function this device serves",
  1032. 'serial': "Chassis serial number",
  1033. 'local_context_data': "Local config context data overwrites all source contexts in the final rendered "
  1034. "config context",
  1035. }
  1036. widgets = {
  1037. 'face': forms.Select(
  1038. attrs={
  1039. 'filter-for': 'position',
  1040. }
  1041. ),
  1042. }
  1043. def __init__(self, *args, **kwargs):
  1044. # Initialize helper selectors
  1045. instance = kwargs.get('instance')
  1046. # Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field
  1047. if instance and hasattr(instance, 'device_type'):
  1048. initial = kwargs.get('initial', {}).copy()
  1049. initial['manufacturer'] = instance.device_type.manufacturer
  1050. kwargs['initial'] = initial
  1051. super().__init__(*args, **kwargs)
  1052. if self.instance.pk:
  1053. # Compile list of choices for primary IPv4 and IPv6 addresses
  1054. for family in [4, 6]:
  1055. ip_choices = [(None, '---------')]
  1056. # Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member
  1057. interface_ids = self.instance.vc_interfaces.values('pk')
  1058. # Collect interface IPs
  1059. interface_ips = IPAddress.objects.select_related('interface').filter(
  1060. family=family, interface_id__in=interface_ids
  1061. )
  1062. if interface_ips:
  1063. ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
  1064. ip_choices.append(('Interface IPs', ip_list))
  1065. # Collect NAT IPs
  1066. nat_ips = IPAddress.objects.select_related('nat_inside').filter(
  1067. family=family, nat_inside__interface__in=interface_ids
  1068. )
  1069. if nat_ips:
  1070. ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips]
  1071. ip_choices.append(('NAT IPs', ip_list))
  1072. self.fields['primary_ip{}'.format(family)].choices = ip_choices
  1073. # If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device
  1074. # can be flipped from one face to another.
  1075. self.fields['position'].widget.attrs['api-url'] += '&exclude={}'.format(self.instance.pk)
  1076. # Limit platform by manufacturer
  1077. self.fields['platform'].queryset = Platform.objects.filter(
  1078. Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer)
  1079. )
  1080. else:
  1081. # An object that doesn't exist yet can't have any IPs assigned to it
  1082. self.fields['primary_ip4'].choices = []
  1083. self.fields['primary_ip4'].widget.attrs['readonly'] = True
  1084. self.fields['primary_ip6'].choices = []
  1085. self.fields['primary_ip6'].widget.attrs['readonly'] = True
  1086. # Rack position
  1087. pk = self.instance.pk if self.instance.pk else None
  1088. try:
  1089. if self.is_bound and self.data.get('rack') and str(self.data.get('face')):
  1090. position_choices = Rack.objects.get(pk=self.data['rack']) \
  1091. .get_rack_units(face=self.data.get('face'), exclude=pk)
  1092. elif self.initial.get('rack') and str(self.initial.get('face')):
  1093. position_choices = Rack.objects.get(pk=self.initial['rack']) \
  1094. .get_rack_units(face=self.initial.get('face'), exclude=pk)
  1095. else:
  1096. position_choices = []
  1097. except Rack.DoesNotExist:
  1098. position_choices = []
  1099. self.fields['position'].choices = [('', '---------')] + [
  1100. (p['id'], {
  1101. 'label': p['name'],
  1102. 'disabled': bool(p['device'] and p['id'] != self.initial.get('position')),
  1103. }) for p in position_choices
  1104. ]
  1105. # Disable rack assignment if this is a child device installed in a parent device
  1106. if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
  1107. self.fields['site'].disabled = True
  1108. self.fields['rack'].disabled = True
  1109. self.initial['site'] = self.instance.parent_bay.device.site_id
  1110. self.initial['rack'] = self.instance.parent_bay.device.rack_id
  1111. class BaseDeviceCSVForm(forms.ModelForm):
  1112. device_role = forms.ModelChoiceField(
  1113. queryset=DeviceRole.objects.all(),
  1114. to_field_name='name',
  1115. help_text='Name of assigned role',
  1116. error_messages={
  1117. 'invalid_choice': 'Invalid device role.',
  1118. }
  1119. )
  1120. tenant = forms.ModelChoiceField(
  1121. queryset=Tenant.objects.all(),
  1122. required=False,
  1123. to_field_name='name',
  1124. help_text='Name of assigned tenant',
  1125. error_messages={
  1126. 'invalid_choice': 'Tenant not found.',
  1127. }
  1128. )
  1129. manufacturer = forms.ModelChoiceField(
  1130. queryset=Manufacturer.objects.all(),
  1131. to_field_name='name',
  1132. help_text='Device type manufacturer',
  1133. error_messages={
  1134. 'invalid_choice': 'Invalid manufacturer.',
  1135. }
  1136. )
  1137. model_name = forms.CharField(
  1138. help_text='Device type model name'
  1139. )
  1140. platform = forms.ModelChoiceField(
  1141. queryset=Platform.objects.all(),
  1142. required=False,
  1143. to_field_name='name',
  1144. help_text='Name of assigned platform',
  1145. error_messages={
  1146. 'invalid_choice': 'Invalid platform.',
  1147. }
  1148. )
  1149. status = CSVChoiceField(
  1150. choices=DEVICE_STATUS_CHOICES,
  1151. help_text='Operational status'
  1152. )
  1153. class Meta:
  1154. fields = []
  1155. model = Device
  1156. help_texts = {
  1157. 'name': 'Device name',
  1158. }
  1159. def clean(self):
  1160. super().clean()
  1161. manufacturer = self.cleaned_data.get('manufacturer')
  1162. model_name = self.cleaned_data.get('model_name')
  1163. # Validate device type
  1164. if manufacturer and model_name:
  1165. try:
  1166. self.instance.device_type = DeviceType.objects.get(manufacturer=manufacturer, model=model_name)
  1167. except DeviceType.DoesNotExist:
  1168. raise forms.ValidationError("Device type {} {} not found".format(manufacturer, model_name))
  1169. class DeviceCSVForm(BaseDeviceCSVForm):
  1170. site = forms.ModelChoiceField(
  1171. queryset=Site.objects.all(),
  1172. to_field_name='name',
  1173. help_text='Name of parent site',
  1174. error_messages={
  1175. 'invalid_choice': 'Invalid site name.',
  1176. }
  1177. )
  1178. rack_group = forms.CharField(
  1179. required=False,
  1180. help_text='Parent rack\'s group (if any)'
  1181. )
  1182. rack_name = forms.CharField(
  1183. required=False,
  1184. help_text='Name of parent rack'
  1185. )
  1186. face = CSVChoiceField(
  1187. choices=RACK_FACE_CHOICES,
  1188. required=False,
  1189. help_text='Mounted rack face'
  1190. )
  1191. cluster = forms.ModelChoiceField(
  1192. queryset=Cluster.objects.all(),
  1193. to_field_name='name',
  1194. required=False,
  1195. help_text='Virtualization cluster',
  1196. error_messages={
  1197. 'invalid_choice': 'Invalid cluster name.',
  1198. }
  1199. )
  1200. class Meta(BaseDeviceCSVForm.Meta):
  1201. fields = [
  1202. 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
  1203. 'site', 'rack_group', 'rack_name', 'position', 'face', 'cluster', 'comments',
  1204. ]
  1205. def clean(self):
  1206. super().clean()
  1207. site = self.cleaned_data.get('site')
  1208. rack_group = self.cleaned_data.get('rack_group')
  1209. rack_name = self.cleaned_data.get('rack_name')
  1210. # Validate rack
  1211. if site and rack_group and rack_name:
  1212. try:
  1213. self.instance.rack = Rack.objects.get(site=site, group__name=rack_group, name=rack_name)
  1214. except Rack.DoesNotExist:
  1215. raise forms.ValidationError("Rack {} not found in site {} group {}".format(rack_name, site, rack_group))
  1216. elif site and rack_name:
  1217. try:
  1218. self.instance.rack = Rack.objects.get(site=site, group__isnull=True, name=rack_name)
  1219. except Rack.DoesNotExist:
  1220. raise forms.ValidationError("Rack {} not found in site {} (no group)".format(rack_name, site))
  1221. class ChildDeviceCSVForm(BaseDeviceCSVForm):
  1222. parent = FlexibleModelChoiceField(
  1223. queryset=Device.objects.all(),
  1224. to_field_name='name',
  1225. help_text='Name or ID of parent device',
  1226. error_messages={
  1227. 'invalid_choice': 'Parent device not found.',
  1228. }
  1229. )
  1230. device_bay_name = forms.CharField(
  1231. help_text='Name of device bay',
  1232. )
  1233. cluster = forms.ModelChoiceField(
  1234. queryset=Cluster.objects.all(),
  1235. to_field_name='name',
  1236. required=False,
  1237. help_text='Virtualization cluster',
  1238. error_messages={
  1239. 'invalid_choice': 'Invalid cluster name.',
  1240. }
  1241. )
  1242. class Meta(BaseDeviceCSVForm.Meta):
  1243. fields = [
  1244. 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
  1245. 'parent', 'device_bay_name', 'cluster', 'comments',
  1246. ]
  1247. def clean(self):
  1248. super().clean()
  1249. parent = self.cleaned_data.get('parent')
  1250. device_bay_name = self.cleaned_data.get('device_bay_name')
  1251. # Validate device bay
  1252. if parent and device_bay_name:
  1253. try:
  1254. self.instance.parent_bay = DeviceBay.objects.get(device=parent, name=device_bay_name)
  1255. # Inherit site and rack from parent device
  1256. self.instance.site = parent.site
  1257. self.instance.rack = parent.rack
  1258. except DeviceBay.DoesNotExist:
  1259. raise forms.ValidationError("Parent device/bay ({} {}) not found".format(parent, device_bay_name))
  1260. class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
  1261. pk = forms.ModelMultipleChoiceField(
  1262. queryset=Device.objects.all(),
  1263. widget=forms.MultipleHiddenInput()
  1264. )
  1265. device_type = forms.ModelChoiceField(
  1266. queryset=DeviceType.objects.all(),
  1267. required=False,
  1268. label='Type'
  1269. )
  1270. device_role = forms.ModelChoiceField(
  1271. queryset=DeviceRole.objects.all(),
  1272. required=False,
  1273. label='Role'
  1274. )
  1275. tenant = forms.ModelChoiceField(
  1276. queryset=Tenant.objects.all(),
  1277. required=False
  1278. )
  1279. platform = forms.ModelChoiceField(
  1280. queryset=Platform.objects.all(),
  1281. required=False
  1282. )
  1283. status = forms.ChoiceField(
  1284. choices=add_blank_choice(DEVICE_STATUS_CHOICES),
  1285. required=False,
  1286. initial=''
  1287. )
  1288. serial = forms.CharField(
  1289. max_length=50,
  1290. required=False,
  1291. label='Serial Number'
  1292. )
  1293. class Meta:
  1294. nullable_fields = [
  1295. 'tenant', 'platform', 'serial',
  1296. ]
  1297. class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
  1298. model = Device
  1299. q = forms.CharField(
  1300. required=False,
  1301. label='Search'
  1302. )
  1303. region = FilterTreeNodeMultipleChoiceField(
  1304. queryset=Region.objects.all(),
  1305. to_field_name='slug',
  1306. required=False,
  1307. )
  1308. site = FilterChoiceField(
  1309. queryset=Site.objects.annotate(
  1310. filter_count=Count('devices')
  1311. ),
  1312. to_field_name='slug',
  1313. )
  1314. rack_group_id = FilterChoiceField(
  1315. queryset=RackGroup.objects.select_related(
  1316. 'site'
  1317. ).annotate(
  1318. filter_count=Count('racks__devices')
  1319. ),
  1320. label='Rack group',
  1321. )
  1322. rack_id = FilterChoiceField(
  1323. queryset=Rack.objects.annotate(
  1324. filter_count=Count('devices')
  1325. ),
  1326. label='Rack',
  1327. null_label='-- None --',
  1328. )
  1329. role = FilterChoiceField(
  1330. queryset=DeviceRole.objects.annotate(
  1331. filter_count=Count('devices')
  1332. ),
  1333. to_field_name='slug',
  1334. )
  1335. tenant = FilterChoiceField(
  1336. queryset=Tenant.objects.annotate(
  1337. filter_count=Count('devices')
  1338. ),
  1339. to_field_name='slug',
  1340. null_label='-- None --',
  1341. )
  1342. manufacturer_id = FilterChoiceField(
  1343. queryset=Manufacturer.objects.all(),
  1344. label='Manufacturer'
  1345. )
  1346. device_type_id = FilterChoiceField(
  1347. queryset=DeviceType.objects.select_related(
  1348. 'manufacturer'
  1349. ).order_by(
  1350. 'model'
  1351. ).annotate(
  1352. filter_count=Count('instances'),
  1353. ),
  1354. label='Model',
  1355. )
  1356. platform = FilterChoiceField(
  1357. queryset=Platform.objects.annotate(
  1358. filter_count=Count('devices')
  1359. ),
  1360. to_field_name='slug',
  1361. null_label='-- None --',
  1362. )
  1363. status = AnnotatedMultipleChoiceField(
  1364. choices=DEVICE_STATUS_CHOICES,
  1365. annotate=Device.objects.all(),
  1366. annotate_field='status',
  1367. required=False
  1368. )
  1369. mac_address = forms.CharField(
  1370. required=False,
  1371. label='MAC address'
  1372. )
  1373. has_primary_ip = forms.NullBooleanField(
  1374. required=False,
  1375. label='Has a primary IP',
  1376. widget=forms.Select(
  1377. choices=BOOLEAN_WITH_BLANK_CHOICES
  1378. )
  1379. )
  1380. console_ports = forms.NullBooleanField(
  1381. required=False,
  1382. label='Has console ports',
  1383. widget=forms.Select(
  1384. choices=BOOLEAN_WITH_BLANK_CHOICES
  1385. )
  1386. )
  1387. console_server_ports = forms.NullBooleanField(
  1388. required=False,
  1389. label='Has console server ports',
  1390. widget=forms.Select(
  1391. choices=BOOLEAN_WITH_BLANK_CHOICES
  1392. )
  1393. )
  1394. power_ports = forms.NullBooleanField(
  1395. required=False,
  1396. label='Has power ports',
  1397. widget=forms.Select(
  1398. choices=BOOLEAN_WITH_BLANK_CHOICES
  1399. )
  1400. )
  1401. power_outlets = forms.NullBooleanField(
  1402. required=False,
  1403. label='Has power outlets',
  1404. widget=forms.Select(
  1405. choices=BOOLEAN_WITH_BLANK_CHOICES
  1406. )
  1407. )
  1408. interfaces = forms.NullBooleanField(
  1409. required=False,
  1410. label='Has interfaces',
  1411. widget=forms.Select(
  1412. choices=BOOLEAN_WITH_BLANK_CHOICES
  1413. )
  1414. )
  1415. pass_through_ports = forms.NullBooleanField(
  1416. required=False,
  1417. label='Has pass-through ports',
  1418. widget=forms.Select(
  1419. choices=BOOLEAN_WITH_BLANK_CHOICES
  1420. )
  1421. )
  1422. #
  1423. # Bulk device component creation
  1424. #
  1425. class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form):
  1426. pk = forms.ModelMultipleChoiceField(
  1427. queryset=Device.objects.all(),
  1428. widget=forms.MultipleHiddenInput()
  1429. )
  1430. name_pattern = ExpandableNameField(
  1431. label='Name'
  1432. )
  1433. class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm):
  1434. form_factor = forms.ChoiceField(
  1435. choices=IFACE_FF_CHOICES
  1436. )
  1437. enabled = forms.BooleanField(
  1438. required=False,
  1439. initial=True
  1440. )
  1441. mtu = forms.IntegerField(
  1442. required=False,
  1443. min_value=1,
  1444. max_value=32767,
  1445. label='MTU'
  1446. )
  1447. mgmt_only = forms.BooleanField(
  1448. required=False,
  1449. label='Management only'
  1450. )
  1451. description = forms.CharField(
  1452. max_length=100,
  1453. required=False
  1454. )
  1455. #
  1456. # Console ports
  1457. #
  1458. class ConsolePortForm(BootstrapMixin, forms.ModelForm):
  1459. tags = TagField(
  1460. required=False
  1461. )
  1462. class Meta:
  1463. model = ConsolePort
  1464. fields = [
  1465. 'device', 'name', 'tags',
  1466. ]
  1467. widgets = {
  1468. 'device': forms.HiddenInput(),
  1469. }
  1470. class ConsolePortCreateForm(ComponentForm):
  1471. name_pattern = ExpandableNameField(
  1472. label='Name'
  1473. )
  1474. tags = TagField(
  1475. required=False
  1476. )
  1477. #
  1478. # Console server ports
  1479. #
  1480. class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
  1481. tags = TagField(
  1482. required=False
  1483. )
  1484. class Meta:
  1485. model = ConsoleServerPort
  1486. fields = [
  1487. 'device', 'name', 'tags',
  1488. ]
  1489. widgets = {
  1490. 'device': forms.HiddenInput(),
  1491. }
  1492. class ConsoleServerPortCreateForm(ComponentForm):
  1493. name_pattern = ExpandableNameField(
  1494. label='Name'
  1495. )
  1496. tags = TagField(
  1497. required=False
  1498. )
  1499. class ConsoleServerPortBulkRenameForm(BulkRenameForm):
  1500. pk = forms.ModelMultipleChoiceField(
  1501. queryset=ConsoleServerPort.objects.all(),
  1502. widget=forms.MultipleHiddenInput()
  1503. )
  1504. class ConsoleServerPortBulkDisconnectForm(ConfirmationForm):
  1505. pk = forms.ModelMultipleChoiceField(
  1506. queryset=ConsoleServerPort.objects.all(),
  1507. widget=forms.MultipleHiddenInput()
  1508. )
  1509. #
  1510. # Power ports
  1511. #
  1512. class PowerPortForm(BootstrapMixin, forms.ModelForm):
  1513. tags = TagField(
  1514. required=False
  1515. )
  1516. class Meta:
  1517. model = PowerPort
  1518. fields = [
  1519. 'device', 'name', 'tags',
  1520. ]
  1521. widgets = {
  1522. 'device': forms.HiddenInput(),
  1523. }
  1524. class PowerPortCreateForm(ComponentForm):
  1525. name_pattern = ExpandableNameField(
  1526. label='Name'
  1527. )
  1528. tags = TagField(
  1529. required=False
  1530. )
  1531. #
  1532. # Power outlets
  1533. #
  1534. class PowerOutletForm(BootstrapMixin, forms.ModelForm):
  1535. tags = TagField(
  1536. required=False
  1537. )
  1538. class Meta:
  1539. model = PowerOutlet
  1540. fields = [
  1541. 'device', 'name', 'tags',
  1542. ]
  1543. widgets = {
  1544. 'device': forms.HiddenInput(),
  1545. }
  1546. class PowerOutletCreateForm(ComponentForm):
  1547. name_pattern = ExpandableNameField(
  1548. label='Name'
  1549. )
  1550. tags = TagField(
  1551. required=False
  1552. )
  1553. class PowerOutletBulkRenameForm(BulkRenameForm):
  1554. pk = forms.ModelMultipleChoiceField(
  1555. queryset=PowerOutlet.objects.all(),
  1556. widget=forms.MultipleHiddenInput
  1557. )
  1558. class PowerOutletBulkDisconnectForm(ConfirmationForm):
  1559. pk = forms.ModelMultipleChoiceField(
  1560. queryset=PowerOutlet.objects.all(),
  1561. widget=forms.MultipleHiddenInput
  1562. )
  1563. #
  1564. # Interfaces
  1565. #
  1566. class InterfaceForm(BootstrapMixin, forms.ModelForm):
  1567. tags = TagField(
  1568. required=False
  1569. )
  1570. class Meta:
  1571. model = Interface
  1572. fields = [
  1573. 'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description',
  1574. 'mode', 'untagged_vlan', 'tagged_vlans', 'tags',
  1575. ]
  1576. widgets = {
  1577. 'device': forms.HiddenInput(),
  1578. }
  1579. labels = {
  1580. 'mode': '802.1Q Mode',
  1581. }
  1582. help_texts = {
  1583. 'mode': INTERFACE_MODE_HELP_TEXT,
  1584. }
  1585. def __init__(self, *args, **kwargs):
  1586. super().__init__(*args, **kwargs)
  1587. # Limit LAG choices to interfaces belonging to this device (or VC master)
  1588. if self.is_bound:
  1589. device = Device.objects.get(pk=self.data['device'])
  1590. self.fields['lag'].queryset = Interface.objects.filter(
  1591. device__in=[device, device.get_vc_master()], form_factor=IFACE_FF_LAG
  1592. )
  1593. else:
  1594. device = self.instance.device
  1595. self.fields['lag'].queryset = Interface.objects.filter(
  1596. device__in=[self.instance.device, self.instance.device.get_vc_master()], form_factor=IFACE_FF_LAG
  1597. )
  1598. def clean(self):
  1599. super().clean()
  1600. # Validate VLAN assignments
  1601. tagged_vlans = self.cleaned_data['tagged_vlans']
  1602. # Untagged interfaces cannot be assigned tagged VLANs
  1603. if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans:
  1604. raise forms.ValidationError({
  1605. 'mode': "An access interface cannot have tagged VLANs assigned."
  1606. })
  1607. # Remove all tagged VLAN assignments from "tagged all" interfaces
  1608. elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL:
  1609. self.cleaned_data['tagged_vlans'] = []
  1610. class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm):
  1611. vlans = forms.MultipleChoiceField(
  1612. choices=[],
  1613. label='VLANs',
  1614. widget=forms.SelectMultiple(
  1615. attrs={
  1616. 'size': 20,
  1617. }
  1618. )
  1619. )
  1620. tagged = forms.BooleanField(
  1621. required=False,
  1622. initial=True
  1623. )
  1624. class Meta:
  1625. model = Interface
  1626. fields = []
  1627. def __init__(self, *args, **kwargs):
  1628. super().__init__(*args, **kwargs)
  1629. if self.instance.mode == IFACE_MODE_ACCESS:
  1630. self.initial['tagged'] = False
  1631. # Find all VLANs already assigned to the interface for exclusion from the list
  1632. assigned_vlans = [v.pk for v in self.instance.tagged_vlans.all()]
  1633. if self.instance.untagged_vlan is not None:
  1634. assigned_vlans.append(self.instance.untagged_vlan.pk)
  1635. # Compile VLAN choices
  1636. vlan_choices = []
  1637. # Add non-grouped global VLANs
  1638. global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans)
  1639. vlan_choices.append((
  1640. 'Global', [(vlan.pk, vlan) for vlan in global_vlans])
  1641. )
  1642. # Add grouped global VLANs
  1643. for group in VLANGroup.objects.filter(site=None):
  1644. global_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans)
  1645. vlan_choices.append(
  1646. (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
  1647. )
  1648. site = getattr(self.instance.parent, 'site', None)
  1649. if site is not None:
  1650. # Add non-grouped site VLANs
  1651. site_vlans = VLAN.objects.filter(site=site, group=None).exclude(pk__in=assigned_vlans)
  1652. vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
  1653. # Add grouped site VLANs
  1654. for group in VLANGroup.objects.filter(site=site):
  1655. site_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans)
  1656. vlan_choices.append((
  1657. '{} / {}'.format(group.site.name, group.name),
  1658. [(vlan.pk, vlan) for vlan in site_group_vlans]
  1659. ))
  1660. self.fields['vlans'].choices = vlan_choices
  1661. def clean(self):
  1662. super().clean()
  1663. # Only untagged VLANs permitted on an access interface
  1664. if self.instance.mode == IFACE_MODE_ACCESS and len(self.cleaned_data['vlans']) > 1:
  1665. raise forms.ValidationError("Only one VLAN may be assigned to an access interface.")
  1666. # 'tagged' is required if more than one VLAN is selected
  1667. if not self.cleaned_data['tagged'] and len(self.cleaned_data['vlans']) > 1:
  1668. raise forms.ValidationError("Only one untagged VLAN may be selected.")
  1669. def save(self, *args, **kwargs):
  1670. if self.cleaned_data['tagged']:
  1671. for vlan in self.cleaned_data['vlans']:
  1672. self.instance.tagged_vlans.add(vlan)
  1673. else:
  1674. self.instance.untagged_vlan_id = self.cleaned_data['vlans'][0]
  1675. return super().save(*args, **kwargs)
  1676. class InterfaceCreateForm(ComponentForm, forms.Form):
  1677. name_pattern = ExpandableNameField(
  1678. label='Name'
  1679. )
  1680. form_factor = forms.ChoiceField(
  1681. choices=IFACE_FF_CHOICES
  1682. )
  1683. enabled = forms.BooleanField(
  1684. required=False
  1685. )
  1686. lag = forms.ModelChoiceField(
  1687. queryset=Interface.objects.all(),
  1688. required=False,
  1689. label='Parent LAG'
  1690. )
  1691. mtu = forms.IntegerField(
  1692. required=False,
  1693. min_value=1,
  1694. max_value=32767,
  1695. label='MTU'
  1696. )
  1697. mac_address = forms.CharField(
  1698. required=False,
  1699. label='MAC Address'
  1700. )
  1701. mgmt_only = forms.BooleanField(
  1702. required=False,
  1703. label='Management only',
  1704. help_text='This interface is used only for out-of-band management'
  1705. )
  1706. description = forms.CharField(
  1707. max_length=100,
  1708. required=False
  1709. )
  1710. mode = forms.ChoiceField(
  1711. choices=add_blank_choice(IFACE_MODE_CHOICES),
  1712. required=False
  1713. )
  1714. tags = TagField(
  1715. required=False
  1716. )
  1717. def __init__(self, *args, **kwargs):
  1718. # Set interfaces enabled by default
  1719. kwargs['initial'] = kwargs.get('initial', {}).copy()
  1720. kwargs['initial'].update({'enabled': True})
  1721. super().__init__(*args, **kwargs)
  1722. # Limit LAG choices to interfaces belonging to this device (or its VC master)
  1723. if self.parent is not None:
  1724. self.fields['lag'].queryset = Interface.objects.filter(
  1725. device__in=[self.parent, self.parent.get_vc_master()], form_factor=IFACE_FF_LAG
  1726. )
  1727. else:
  1728. self.fields['lag'].queryset = Interface.objects.none()
  1729. class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
  1730. pk = forms.ModelMultipleChoiceField(
  1731. queryset=Interface.objects.all(),
  1732. widget=forms.MultipleHiddenInput()
  1733. )
  1734. form_factor = forms.ChoiceField(
  1735. choices=add_blank_choice(IFACE_FF_CHOICES),
  1736. required=False
  1737. )
  1738. enabled = forms.NullBooleanField(
  1739. required=False,
  1740. widget=BulkEditNullBooleanSelect()
  1741. )
  1742. lag = forms.ModelChoiceField(
  1743. queryset=Interface.objects.all(),
  1744. required=False,
  1745. label='Parent LAG'
  1746. )
  1747. mtu = forms.IntegerField(
  1748. required=False,
  1749. min_value=1,
  1750. max_value=32767,
  1751. label='MTU'
  1752. )
  1753. mgmt_only = forms.NullBooleanField(
  1754. required=False,
  1755. widget=BulkEditNullBooleanSelect(),
  1756. label='Management only'
  1757. )
  1758. description = forms.CharField(
  1759. max_length=100,
  1760. required=False
  1761. )
  1762. mode = forms.ChoiceField(
  1763. choices=add_blank_choice(IFACE_MODE_CHOICES),
  1764. required=False
  1765. )
  1766. class Meta:
  1767. nullable_fields = [
  1768. 'lag', 'mtu', 'description', 'mode',
  1769. ]
  1770. def __init__(self, *args, **kwargs):
  1771. super().__init__(*args, **kwargs)
  1772. # Limit LAG choices to interfaces which belong to the parent device (or VC master)
  1773. device = self.parent_obj
  1774. if device is not None:
  1775. self.fields['lag'].queryset = Interface.objects.filter(
  1776. device__in=[device, device.get_vc_master()],
  1777. form_factor=IFACE_FF_LAG
  1778. )
  1779. else:
  1780. self.fields['lag'].choices = []
  1781. class InterfaceBulkRenameForm(BulkRenameForm):
  1782. pk = forms.ModelMultipleChoiceField(
  1783. queryset=Interface.objects.all(),
  1784. widget=forms.MultipleHiddenInput()
  1785. )
  1786. class InterfaceBulkDisconnectForm(ConfirmationForm):
  1787. pk = forms.ModelMultipleChoiceField(
  1788. queryset=Interface.objects.all(),
  1789. widget=forms.MultipleHiddenInput()
  1790. )
  1791. #
  1792. # Front pass-through ports
  1793. #
  1794. class FrontPortForm(BootstrapMixin, forms.ModelForm):
  1795. tags = TagField(
  1796. required=False
  1797. )
  1798. class Meta:
  1799. model = FrontPort
  1800. fields = [
  1801. 'device', 'name', 'type', 'rear_port', 'rear_port_position', 'description', 'tags',
  1802. ]
  1803. widgets = {
  1804. 'device': forms.HiddenInput(),
  1805. }
  1806. # TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic
  1807. class FrontPortCreateForm(ComponentForm):
  1808. name_pattern = ExpandableNameField(
  1809. label='Name'
  1810. )
  1811. type = forms.ChoiceField(
  1812. choices=PORT_TYPE_CHOICES
  1813. )
  1814. rear_port_set = forms.MultipleChoiceField(
  1815. choices=[],
  1816. label='Rear ports',
  1817. help_text='Select one rear port assignment for each front port being created.'
  1818. )
  1819. description = forms.CharField(
  1820. required=False
  1821. )
  1822. def __init__(self, *args, **kwargs):
  1823. super().__init__(*args, **kwargs)
  1824. # Determine which rear port positions are occupied. These will be excluded from the list of available mappings.
  1825. occupied_port_positions = [
  1826. (front_port.rear_port_id, front_port.rear_port_position)
  1827. for front_port in self.parent.frontports.all()
  1828. ]
  1829. # Populate rear port choices
  1830. choices = []
  1831. rear_ports = RearPort.objects.filter(device=self.parent)
  1832. for rear_port in rear_ports:
  1833. for i in range(1, rear_port.positions + 1):
  1834. if (rear_port.pk, i) not in occupied_port_positions:
  1835. choices.append(
  1836. ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
  1837. )
  1838. self.fields['rear_port_set'].choices = choices
  1839. def clean(self):
  1840. # Validate that the number of ports being created equals the number of selected (rear port, position) tuples
  1841. front_port_count = len(self.cleaned_data['name_pattern'])
  1842. rear_port_count = len(self.cleaned_data['rear_port_set'])
  1843. if front_port_count != rear_port_count:
  1844. raise forms.ValidationError({
  1845. 'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments '
  1846. 'were selected. These counts must match.'.format(front_port_count, rear_port_count)
  1847. })
  1848. def get_iterative_data(self, iteration):
  1849. # Assign rear port and position from selected set
  1850. rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
  1851. return {
  1852. 'rear_port': int(rear_port),
  1853. 'rear_port_position': int(position),
  1854. }
  1855. class FrontPortBulkRenameForm(BulkRenameForm):
  1856. pk = forms.ModelMultipleChoiceField(
  1857. queryset=FrontPort.objects.all(),
  1858. widget=forms.MultipleHiddenInput
  1859. )
  1860. class FrontPortBulkDisconnectForm(ConfirmationForm):
  1861. pk = forms.ModelMultipleChoiceField(
  1862. queryset=FrontPort.objects.all(),
  1863. widget=forms.MultipleHiddenInput
  1864. )
  1865. #
  1866. # Rear pass-through ports
  1867. #
  1868. class RearPortForm(BootstrapMixin, forms.ModelForm):
  1869. tags = TagField(
  1870. required=False
  1871. )
  1872. class Meta:
  1873. model = RearPort
  1874. fields = [
  1875. 'device', 'name', 'type', 'positions', 'description', 'tags',
  1876. ]
  1877. widgets = {
  1878. 'device': forms.HiddenInput(),
  1879. }
  1880. class RearPortCreateForm(ComponentForm):
  1881. name_pattern = ExpandableNameField(
  1882. label='Name'
  1883. )
  1884. type = forms.ChoiceField(
  1885. choices=PORT_TYPE_CHOICES
  1886. )
  1887. positions = forms.IntegerField(
  1888. min_value=1,
  1889. max_value=64,
  1890. initial=1,
  1891. help_text='The number of front ports which may be mapped to each rear port'
  1892. )
  1893. description = forms.CharField(
  1894. required=False
  1895. )
  1896. class RearPortBulkRenameForm(BulkRenameForm):
  1897. pk = forms.ModelMultipleChoiceField(
  1898. queryset=RearPort.objects.all(),
  1899. widget=forms.MultipleHiddenInput
  1900. )
  1901. class RearPortBulkDisconnectForm(ConfirmationForm):
  1902. pk = forms.ModelMultipleChoiceField(
  1903. queryset=RearPort.objects.all(),
  1904. widget=forms.MultipleHiddenInput
  1905. )
  1906. #
  1907. # Cables
  1908. #
  1909. class CableCreateForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
  1910. termination_b_site = forms.ModelChoiceField(
  1911. queryset=Site.objects.all(),
  1912. label='Site',
  1913. required=False,
  1914. widget=forms.Select(
  1915. attrs={
  1916. 'filter-for': 'termination_b_rack',
  1917. }
  1918. )
  1919. )
  1920. termination_b_rack = ChainedModelChoiceField(
  1921. queryset=Rack.objects.all(),
  1922. chains=(
  1923. ('site', 'termination_b_site'),
  1924. ),
  1925. label='Rack',
  1926. required=False,
  1927. widget=APISelect(
  1928. api_url='/api/dcim/racks/?site_id={{termination_b_site}}',
  1929. attrs={
  1930. 'filter-for': 'termination_b_device',
  1931. 'nullable': 'true',
  1932. }
  1933. )
  1934. )
  1935. termination_b_device = ChainedModelChoiceField(
  1936. queryset=Device.objects.all(),
  1937. chains=(
  1938. ('site', 'termination_b_site'),
  1939. ('rack', 'termination_b_rack'),
  1940. ),
  1941. label='Device',
  1942. required=False,
  1943. widget=APISelect(
  1944. api_url='/api/dcim/devices/?site_id={{termination_b_site}}&rack_id={{termination_b_rack}}',
  1945. display_field='display_name',
  1946. attrs={
  1947. 'filter-for': 'termination_b_id',
  1948. }
  1949. )
  1950. )
  1951. livesearch = forms.CharField(
  1952. required=False,
  1953. label='Device',
  1954. widget=Livesearch(
  1955. query_key='q',
  1956. query_url='dcim-api:device-list',
  1957. field_to_update='termination_b_device'
  1958. )
  1959. )
  1960. termination_b_type = forms.ModelChoiceField(
  1961. queryset=ContentType.objects.all(),
  1962. label='Type',
  1963. widget=ContentTypeSelect(
  1964. attrs={
  1965. 'filter-for': 'termination_b_id',
  1966. }
  1967. )
  1968. )
  1969. termination_b_id = forms.IntegerField(
  1970. label='Name',
  1971. widget=APISelect(
  1972. api_url='/api/dcim/{{termination_b_type}}s/?device_id={{termination_b_device}}',
  1973. disabled_indicator='cable',
  1974. url_conditional_append={
  1975. 'termination_b_type__interface': '&type=physical',
  1976. }
  1977. )
  1978. )
  1979. class Meta:
  1980. model = Cable
  1981. fields = [
  1982. 'termination_b_site', 'termination_b_rack', 'termination_b_device', 'livesearch', 'termination_b_type',
  1983. 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit',
  1984. ]
  1985. def __init__(self, *args, **kwargs):
  1986. super().__init__(*args, **kwargs)
  1987. # Define available types for endpoint B based on the type of endpoint A
  1988. termination_a_type = self.instance.termination_a._meta.model_name
  1989. self.fields['termination_b_type'].queryset = ContentType.objects.filter(
  1990. model__in=COMPATIBLE_TERMINATION_TYPES.get(termination_a_type)
  1991. ).exclude(
  1992. model='circuittermination'
  1993. )
  1994. class CableForm(BootstrapMixin, forms.ModelForm):
  1995. class Meta:
  1996. model = Cable
  1997. fields = [
  1998. 'type', 'status', 'label', 'color', 'length', 'length_unit',
  1999. ]
  2000. class CableCSVForm(forms.ModelForm):
  2001. # Termination A
  2002. side_a_device = FlexibleModelChoiceField(
  2003. queryset=Device.objects.all(),
  2004. to_field_name='name',
  2005. help_text='Side A device name or ID',
  2006. error_messages={
  2007. 'invalid_choice': 'Side A device not found',
  2008. }
  2009. )
  2010. side_a_type = forms.ModelChoiceField(
  2011. queryset=ContentType.objects.all(),
  2012. limit_choices_to={
  2013. 'model__in': CABLE_TERMINATION_TYPES,
  2014. },
  2015. to_field_name='model',
  2016. help_text='Side A type'
  2017. )
  2018. side_a_name = forms.CharField(
  2019. help_text='Side A component'
  2020. )
  2021. # Termination B
  2022. side_b_device = FlexibleModelChoiceField(
  2023. queryset=Device.objects.all(),
  2024. to_field_name='name',
  2025. help_text='Side B device name or ID',
  2026. error_messages={
  2027. 'invalid_choice': 'Side B device not found',
  2028. }
  2029. )
  2030. side_b_type = forms.ModelChoiceField(
  2031. queryset=ContentType.objects.all(),
  2032. limit_choices_to={
  2033. 'model__in': CABLE_TERMINATION_TYPES,
  2034. },
  2035. to_field_name='model',
  2036. help_text='Side B type'
  2037. )
  2038. side_b_name = forms.CharField(
  2039. help_text='Side B component'
  2040. )
  2041. # Cable attributes
  2042. status = CSVChoiceField(
  2043. choices=CONNECTION_STATUS_CHOICES,
  2044. required=False,
  2045. help_text='Connection status'
  2046. )
  2047. type = CSVChoiceField(
  2048. choices=CABLE_TYPE_CHOICES,
  2049. required=False,
  2050. help_text='Cable type'
  2051. )
  2052. length_unit = CSVChoiceField(
  2053. choices=CABLE_LENGTH_UNIT_CHOICES,
  2054. required=False,
  2055. help_text='Length unit'
  2056. )
  2057. class Meta:
  2058. model = Cable
  2059. fields = [
  2060. 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type',
  2061. 'status', 'label', 'color', 'length', 'length_unit',
  2062. ]
  2063. help_texts = {
  2064. 'color': 'RGB color in hexadecimal (e.g. 00ff00)'
  2065. }
  2066. # TODO: Merge the clean() methods for either end
  2067. def clean_side_a_name(self):
  2068. device = self.cleaned_data.get('side_a_device')
  2069. content_type = self.cleaned_data.get('side_a_type')
  2070. name = self.cleaned_data.get('side_a_name')
  2071. if not device or not content_type or not name:
  2072. return None
  2073. model = content_type.model_class()
  2074. try:
  2075. termination_object = model.objects.get(
  2076. device=device,
  2077. name=name
  2078. )
  2079. if termination_object.cable is not None:
  2080. raise forms.ValidationError(
  2081. "Side A: {} {} is already connected".format(device, termination_object)
  2082. )
  2083. except ObjectDoesNotExist:
  2084. raise forms.ValidationError(
  2085. "A side termination not found: {} {}".format(device, name)
  2086. )
  2087. self.instance.termination_a = termination_object
  2088. return termination_object
  2089. def clean_side_b_name(self):
  2090. device = self.cleaned_data.get('side_b_device')
  2091. content_type = self.cleaned_data.get('side_b_type')
  2092. name = self.cleaned_data.get('side_b_name')
  2093. if not device or not content_type or not name:
  2094. return None
  2095. model = content_type.model_class()
  2096. try:
  2097. termination_object = model.objects.get(
  2098. device=device,
  2099. name=name
  2100. )
  2101. if termination_object.cable is not None:
  2102. raise forms.ValidationError(
  2103. "Side B: {} {} is already connected".format(device, termination_object)
  2104. )
  2105. except ObjectDoesNotExist:
  2106. raise forms.ValidationError(
  2107. "B side termination not found: {} {}".format(device, name)
  2108. )
  2109. self.instance.termination_b = termination_object
  2110. return termination_object
  2111. def clean_length_unit(self):
  2112. # Avoid trying to save as NULL
  2113. length_unit = self.cleaned_data.get('length_unit', None)
  2114. return length_unit if length_unit is not None else ''
  2115. class CableBulkEditForm(BootstrapMixin, BulkEditForm):
  2116. pk = forms.ModelMultipleChoiceField(
  2117. queryset=Cable.objects.all(),
  2118. widget=forms.MultipleHiddenInput
  2119. )
  2120. type = forms.ChoiceField(
  2121. choices=add_blank_choice(CABLE_TYPE_CHOICES),
  2122. required=False,
  2123. initial=''
  2124. )
  2125. status = forms.ChoiceField(
  2126. choices=add_blank_choice(CONNECTION_STATUS_CHOICES),
  2127. required=False,
  2128. initial=''
  2129. )
  2130. label = forms.CharField(
  2131. max_length=100,
  2132. required=False
  2133. )
  2134. color = forms.CharField(
  2135. max_length=6,
  2136. required=False,
  2137. widget=ColorSelect()
  2138. )
  2139. length = forms.IntegerField(
  2140. min_value=1,
  2141. required=False
  2142. )
  2143. length_unit = forms.ChoiceField(
  2144. choices=add_blank_choice(CABLE_LENGTH_UNIT_CHOICES),
  2145. required=False,
  2146. initial=''
  2147. )
  2148. class Meta:
  2149. nullable_fields = [
  2150. 'type', 'status', 'label', 'color', 'length',
  2151. ]
  2152. def clean(self):
  2153. # Validate length/unit
  2154. length = self.cleaned_data.get('length')
  2155. length_unit = self.cleaned_data.get('length_unit')
  2156. if length and not length_unit:
  2157. raise forms.ValidationError({
  2158. 'length_unit': "Must specify a unit when setting length"
  2159. })
  2160. class CableFilterForm(BootstrapMixin, forms.Form):
  2161. model = Cable
  2162. q = forms.CharField(
  2163. required=False,
  2164. label='Search'
  2165. )
  2166. type = AnnotatedMultipleChoiceField(
  2167. choices=CABLE_TYPE_CHOICES,
  2168. annotate=Cable.objects.all(),
  2169. annotate_field='type',
  2170. required=False
  2171. )
  2172. color = AnnotatedMultipleChoiceField(
  2173. choices=COLOR_CHOICES,
  2174. annotate=Cable.objects.all(),
  2175. annotate_field='color',
  2176. required=False
  2177. )
  2178. #
  2179. # Device bays
  2180. #
  2181. class DeviceBayForm(BootstrapMixin, forms.ModelForm):
  2182. tags = TagField(
  2183. required=False
  2184. )
  2185. class Meta:
  2186. model = DeviceBay
  2187. fields = [
  2188. 'device', 'name', 'tags',
  2189. ]
  2190. widgets = {
  2191. 'device': forms.HiddenInput(),
  2192. }
  2193. class DeviceBayCreateForm(ComponentForm):
  2194. name_pattern = ExpandableNameField(
  2195. label='Name'
  2196. )
  2197. tags = TagField(
  2198. required=False
  2199. )
  2200. class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
  2201. installed_device = forms.ModelChoiceField(
  2202. queryset=Device.objects.all(),
  2203. label='Child Device',
  2204. help_text="Child devices must first be created and assigned to the site/rack of the parent device."
  2205. )
  2206. def __init__(self, device_bay, *args, **kwargs):
  2207. super().__init__(*args, **kwargs)
  2208. self.fields['installed_device'].queryset = Device.objects.filter(
  2209. site=device_bay.device.site,
  2210. rack=device_bay.device.rack,
  2211. parent_bay__isnull=True,
  2212. device_type__u_height=0,
  2213. device_type__subdevice_role=SUBDEVICE_ROLE_CHILD
  2214. ).exclude(pk=device_bay.device.pk)
  2215. class DeviceBayBulkRenameForm(BulkRenameForm):
  2216. pk = forms.ModelMultipleChoiceField(
  2217. queryset=DeviceBay.objects.all(),
  2218. widget=forms.MultipleHiddenInput()
  2219. )
  2220. #
  2221. # Connections
  2222. #
  2223. class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
  2224. site = forms.ModelChoiceField(
  2225. queryset=Site.objects.all(),
  2226. required=False,
  2227. to_field_name='slug'
  2228. )
  2229. device = forms.CharField(
  2230. required=False,
  2231. label='Device name'
  2232. )
  2233. class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
  2234. site = forms.ModelChoiceField(
  2235. queryset=Site.objects.all(),
  2236. required=False,
  2237. to_field_name='slug'
  2238. )
  2239. device = forms.CharField(
  2240. required=False,
  2241. label='Device name'
  2242. )
  2243. class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
  2244. site = forms.ModelChoiceField(
  2245. queryset=Site.objects.all(),
  2246. required=False,
  2247. to_field_name='slug'
  2248. )
  2249. device = forms.CharField(
  2250. required=False,
  2251. label='Device name'
  2252. )
  2253. #
  2254. # Inventory items
  2255. #
  2256. class InventoryItemForm(BootstrapMixin, forms.ModelForm):
  2257. tags = TagField(
  2258. required=False
  2259. )
  2260. class Meta:
  2261. model = InventoryItem
  2262. fields = [
  2263. 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags',
  2264. ]
  2265. class InventoryItemCSVForm(forms.ModelForm):
  2266. device = FlexibleModelChoiceField(
  2267. queryset=Device.objects.all(),
  2268. to_field_name='name',
  2269. help_text='Device name or ID',
  2270. error_messages={
  2271. 'invalid_choice': 'Device not found.',
  2272. }
  2273. )
  2274. manufacturer = forms.ModelChoiceField(
  2275. queryset=Manufacturer.objects.all(),
  2276. to_field_name='name',
  2277. required=False,
  2278. help_text='Manufacturer name',
  2279. error_messages={
  2280. 'invalid_choice': 'Invalid manufacturer.',
  2281. }
  2282. )
  2283. class Meta:
  2284. model = InventoryItem
  2285. fields = InventoryItem.csv_headers
  2286. class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm):
  2287. pk = forms.ModelMultipleChoiceField(
  2288. queryset=InventoryItem.objects.all(),
  2289. widget=forms.MultipleHiddenInput()
  2290. )
  2291. manufacturer = forms.ModelChoiceField(
  2292. queryset=Manufacturer.objects.all(),
  2293. required=False
  2294. )
  2295. part_id = forms.CharField(
  2296. max_length=50,
  2297. required=False,
  2298. label='Part ID'
  2299. )
  2300. description = forms.CharField(
  2301. max_length=100,
  2302. required=False
  2303. )
  2304. class Meta:
  2305. nullable_fields = [
  2306. 'manufacturer', 'part_id', 'description',
  2307. ]
  2308. class InventoryItemFilterForm(BootstrapMixin, forms.Form):
  2309. model = InventoryItem
  2310. q = forms.CharField(
  2311. required=False,
  2312. label='Search'
  2313. )
  2314. device = forms.CharField(
  2315. required=False,
  2316. label='Device name'
  2317. )
  2318. manufacturer = FilterChoiceField(
  2319. queryset=Manufacturer.objects.annotate(
  2320. filter_count=Count('inventory_items')
  2321. ),
  2322. to_field_name='slug',
  2323. null_label='-- None --'
  2324. )
  2325. #
  2326. # Virtual chassis
  2327. #
  2328. class DeviceSelectionForm(forms.Form):
  2329. pk = forms.ModelMultipleChoiceField(
  2330. queryset=Device.objects.all(),
  2331. widget=forms.MultipleHiddenInput()
  2332. )
  2333. class VirtualChassisForm(BootstrapMixin, forms.ModelForm):
  2334. tags = TagField(
  2335. required=False
  2336. )
  2337. class Meta:
  2338. model = VirtualChassis
  2339. fields = [
  2340. 'master', 'domain', 'tags',
  2341. ]
  2342. widgets = {
  2343. 'master': SelectWithPK(),
  2344. }
  2345. class BaseVCMemberFormSet(forms.BaseModelFormSet):
  2346. def clean(self):
  2347. super().clean()
  2348. # Check for duplicate VC position values
  2349. vc_position_list = []
  2350. for form in self.forms:
  2351. vc_position = form.cleaned_data.get('vc_position')
  2352. if vc_position:
  2353. if vc_position in vc_position_list:
  2354. error_msg = 'A virtual chassis member already exists in position {}.'.format(vc_position)
  2355. form.add_error('vc_position', error_msg)
  2356. vc_position_list.append(vc_position)
  2357. class DeviceVCMembershipForm(forms.ModelForm):
  2358. class Meta:
  2359. model = Device
  2360. fields = [
  2361. 'vc_position', 'vc_priority',
  2362. ]
  2363. labels = {
  2364. 'vc_position': 'Position',
  2365. 'vc_priority': 'Priority',
  2366. }
  2367. def __init__(self, validate_vc_position=False, *args, **kwargs):
  2368. super().__init__(*args, **kwargs)
  2369. # Require VC position (only required when the Device is a VirtualChassis member)
  2370. self.fields['vc_position'].required = True
  2371. # Validation of vc_position is optional. This is only required when adding a new member to an existing
  2372. # VirtualChassis. Otherwise, vc_position validation is handled by BaseVCMemberFormSet.
  2373. self.validate_vc_position = validate_vc_position
  2374. def clean_vc_position(self):
  2375. vc_position = self.cleaned_data['vc_position']
  2376. if self.validate_vc_position:
  2377. conflicting_members = Device.objects.filter(
  2378. virtual_chassis=self.instance.virtual_chassis,
  2379. vc_position=vc_position
  2380. )
  2381. if conflicting_members.exists():
  2382. raise forms.ValidationError(
  2383. 'A virtual chassis member already exists in position {}.'.format(vc_position)
  2384. )
  2385. return vc_position
  2386. class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
  2387. site = forms.ModelChoiceField(
  2388. queryset=Site.objects.all(),
  2389. label='Site',
  2390. required=False,
  2391. widget=forms.Select(
  2392. attrs={
  2393. 'filter-for': 'rack',
  2394. }
  2395. )
  2396. )
  2397. rack = ChainedModelChoiceField(
  2398. queryset=Rack.objects.all(),
  2399. chains=(
  2400. ('site', 'site'),
  2401. ),
  2402. label='Rack',
  2403. required=False,
  2404. widget=APISelect(
  2405. api_url='/api/dcim/racks/?site_id={{site}}',
  2406. attrs={
  2407. 'filter-for': 'device',
  2408. 'nullable': 'true',
  2409. }
  2410. )
  2411. )
  2412. device = ChainedModelChoiceField(
  2413. queryset=Device.objects.filter(
  2414. virtual_chassis__isnull=True
  2415. ),
  2416. chains=(
  2417. ('site', 'site'),
  2418. ('rack', 'rack'),
  2419. ),
  2420. label='Device',
  2421. widget=APISelect(
  2422. api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
  2423. display_field='display_name',
  2424. disabled_indicator='virtual_chassis'
  2425. )
  2426. )
  2427. def clean_device(self):
  2428. device = self.cleaned_data['device']
  2429. if device.virtual_chassis is not None:
  2430. raise forms.ValidationError(
  2431. "Device {} is already assigned to a virtual chassis.".format(device)
  2432. )
  2433. return device
  2434. class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
  2435. model = VirtualChassis
  2436. q = forms.CharField(
  2437. required=False,
  2438. label='Search'
  2439. )
  2440. site = FilterChoiceField(
  2441. queryset=Site.objects.all(),
  2442. to_field_name='slug',
  2443. )
  2444. tenant = FilterChoiceField(
  2445. queryset=Tenant.objects.all(),
  2446. to_field_name='slug',
  2447. null_label='-- None --',
  2448. )