test_models.py 203 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962
  1. import os
  2. import tempfile
  3. import uuid
  4. from decimal import Decimal
  5. from unittest.mock import patch
  6. from django.core.exceptions import ValidationError
  7. from django.db import connection, transaction
  8. from django.db.models import ProtectedError
  9. from django.db.models.signals import post_save
  10. from django.test import RequestFactory, TestCase, override_settings, tag
  11. from django.test.utils import CaptureQueriesContext
  12. from django.utils.text import slugify
  13. from circuits.models import *
  14. from core.choices import ObjectChangeActionChoices
  15. from core.models import ObjectChange, ObjectType
  16. from dcim.choices import *
  17. from dcim.models import *
  18. from dcim.utils import resolve_module_placeholder
  19. from extras.events import serialize_for_event
  20. from extras.models import CustomField
  21. from ipam.models import IPAddress, Prefix
  22. from netbox.choices import DiameterUnitChoices, FlowRateUnitChoices, WeightUnitChoices
  23. from netbox.context_managers import event_tracking
  24. from tenancy.models import Tenant
  25. from users.models import User
  26. from utilities.data import drange
  27. from utilities.exceptions import AbortRequest
  28. from utilities.testing import create_test_device, create_test_image
  29. from virtualization.models import Cluster, ClusterType
  30. class MACAddressTestCase(TestCase):
  31. @classmethod
  32. def setUpTestData(cls):
  33. site = Site.objects.create(name='Test Site 1', slug='test-site-1')
  34. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  35. device_type = DeviceType.objects.create(
  36. manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
  37. )
  38. device_role = DeviceRole.objects.create(name='Test Role 1', slug='test-role-1')
  39. device = Device.objects.create(
  40. name='Device 1', device_type=device_type, role=device_role, site=site,
  41. )
  42. cls.interface = Interface.objects.create(
  43. device=device,
  44. name='Interface 1',
  45. type=InterfaceTypeChoices.TYPE_1GE_FIXED,
  46. mgmt_only=True
  47. )
  48. cls.mac_a = MACAddress.objects.create(mac_address='1234567890ab', assigned_object=cls.interface)
  49. cls.mac_b = MACAddress.objects.create(mac_address='1234567890ba', assigned_object=cls.interface)
  50. cls.interface.primary_mac_address = cls.mac_a
  51. cls.interface.save()
  52. @tag('regression') # Ref: #23617
  53. def test_primary_mac_assignment_on_interface_creation_records_prechange(self):
  54. mac = MACAddress.objects.create(mac_address='1234567890cd')
  55. request = RequestFactory().get('/')
  56. request.id = uuid.uuid4()
  57. request.user = User.objects.create_user(username='testuser')
  58. with event_tracking(request):
  59. interface = Interface.objects.create(
  60. device=self.interface.device, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED,
  61. primary_mac_address=mac
  62. )
  63. change = ObjectChange.objects.get(
  64. changed_object_type=ObjectType.objects.get_for_model(MACAddress),
  65. changed_object_id=mac.pk,
  66. )
  67. self.assertIsNone(change.prechange_data['assigned_object_id'])
  68. self.assertEqual(change.postchange_data['assigned_object_id'], interface.pk)
  69. self.assertFalse(hasattr(mac, '_prechange_snapshot'))
  70. @tag('regression') # Ref: #23617
  71. def test_primary_mac_assignment_keeps_existing_snapshot(self):
  72. mac = MACAddress.objects.create(mac_address='1234567890ce')
  73. request = RequestFactory().get('/')
  74. request.id = uuid.uuid4()
  75. request.user = User.objects.create_user(username='testuser')
  76. with event_tracking(request):
  77. mac.snapshot()
  78. mac.description = 'updated'
  79. Interface.objects.create(
  80. device=self.interface.device, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED,
  81. primary_mac_address=mac
  82. )
  83. change = ObjectChange.objects.get(
  84. changed_object_type=ObjectType.objects.get_for_model(MACAddress),
  85. changed_object_id=mac.pk,
  86. )
  87. self.assertEqual(change.prechange_data['description'], '')
  88. self.assertEqual(change.postchange_data['description'], 'updated')
  89. @tag('regression')
  90. def test_clean_will_not_allow_removal_of_assigned_object_if_primary(self):
  91. self.mac_a.assigned_object = None
  92. with self.assertRaisesMessage(ValidationError, 'Cannot unassign MAC Address while'):
  93. self.mac_a.clean()
  94. @tag('regression')
  95. def test_clean_will_allow_removal_of_assigned_object_if_not_primary(self):
  96. self.mac_b.assigned_object = None
  97. self.mac_b.clean()
  98. def test_set_primary_mac_address_assigns(self):
  99. self.interface.set_primary_mac_address(self.mac_b)
  100. self.interface.refresh_from_db()
  101. self.assertEqual(self.interface.primary_mac_address_id, self.mac_b.pk)
  102. def test_set_primary_mac_address_clears(self):
  103. self.interface.set_primary_mac_address(None)
  104. self.interface.refresh_from_db()
  105. self.assertIsNone(self.interface.primary_mac_address_id)
  106. def test_set_primary_mac_address_noop_when_already_primary(self):
  107. # mac_a is already primary; re-setting it changes nothing and doesn't error.
  108. self.interface.set_primary_mac_address(self.mac_a)
  109. self.interface.refresh_from_db()
  110. self.assertEqual(self.interface.primary_mac_address_id, self.mac_a.pk)
  111. def test_set_primary_mac_address_from_value_finds_existing(self):
  112. # A value already present on the interface is promoted, not duplicated.
  113. count_before = self.interface.mac_addresses.count()
  114. self.interface.set_primary_mac_address_from_value(str(self.mac_b.mac_address))
  115. self.interface.refresh_from_db()
  116. self.assertEqual(self.interface.primary_mac_address_id, self.mac_b.pk)
  117. self.assertEqual(self.interface.mac_addresses.count(), count_before)
  118. def test_set_primary_mac_address_from_value_creates(self):
  119. count_before = self.interface.mac_addresses.count()
  120. self.interface.set_primary_mac_address_from_value('aabbccddeeff')
  121. self.interface.refresh_from_db()
  122. self.assertEqual(self.interface.mac_addresses.count(), count_before + 1)
  123. self.assertEqual(str(self.interface.primary_mac_address.mac_address).lower(), 'aa:bb:cc:dd:ee:ff')
  124. def test_set_primary_mac_address_rejects_foreign_mac(self):
  125. # A MAC assigned to a different interface can't be made primary here.
  126. other = Interface.objects.create(
  127. device=self.interface.device,
  128. name='Interface 2',
  129. type=InterfaceTypeChoices.TYPE_1GE_FIXED,
  130. )
  131. foreign_mac = MACAddress.objects.create(mac_address='ffeeddccbbaa', assigned_object=other)
  132. with self.assertRaises(ValidationError):
  133. self.interface.set_primary_mac_address(foreign_mac)
  134. def test_clean_rejects_unassigned_primary_mac_on_update(self):
  135. # An existing interface can't point its primary at a MAC that isn't assigned to it.
  136. unassigned = MACAddress.objects.create(mac_address='aabbccdd0099')
  137. self.interface.primary_mac_address = unassigned
  138. with self.assertRaises(ValidationError):
  139. self.interface.full_clean()
  140. def test_clean_allows_unassigned_primary_mac_on_create(self):
  141. # On create the MAC is assigned by a post_save signal after clean(), so an as-yet-unassigned
  142. # primary MAC must pass validation on a new (adding) instance.
  143. mac = MACAddress.objects.create(mac_address='aabbccdd00aa')
  144. new_iface = Interface(
  145. device=self.interface.device,
  146. name='Interface Create Heal',
  147. type=InterfaceTypeChoices.TYPE_1GE_FIXED,
  148. primary_mac_address=mac,
  149. )
  150. new_iface.full_clean() # must not raise
  151. class LocationTestCase(TestCase):
  152. def test_change_location_site(self):
  153. """
  154. Check that all child Locations and Racks get updated when a Location is moved to a new Site. Topology:
  155. Site A
  156. - Location A1
  157. - Location A2
  158. - Rack 2
  159. - Device 2
  160. - Rack 1
  161. - Device 1
  162. """
  163. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  164. device_type = DeviceType.objects.create(
  165. manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
  166. )
  167. role = DeviceRole.objects.create(
  168. name='Device Role 1', slug='device-role-1', color='ff0000'
  169. )
  170. site_a = Site.objects.create(name='Site A', slug='site-a')
  171. site_b = Site.objects.create(name='Site B', slug='site-b')
  172. location_a1 = Location(site=site_a, name='Location A1', slug='location-a1')
  173. location_a1.save()
  174. location_a2 = Location(site=site_a, parent=location_a1, name='Location A2', slug='location-a2')
  175. location_a2.save()
  176. rack1 = Rack.objects.create(site=site_a, location=location_a1, name='Rack 1')
  177. rack2 = Rack.objects.create(site=site_a, location=location_a2, name='Rack 2')
  178. device1 = Device.objects.create(
  179. site=site_a,
  180. location=location_a1,
  181. name='Device 1',
  182. device_type=device_type,
  183. role=role
  184. )
  185. device2 = Device.objects.create(
  186. site=site_a,
  187. location=location_a2,
  188. name='Device 2',
  189. device_type=device_type,
  190. role=role
  191. )
  192. powerpanel1 = PowerPanel.objects.create(site=site_a, location=location_a1, name='Power Panel 1')
  193. # Move Location A1 to Site B
  194. location_a1.site = site_b
  195. location_a1.save()
  196. # Check that all objects within Location A1 now belong to Site B
  197. self.assertEqual(Location.objects.get(pk=location_a1.pk).site, site_b)
  198. self.assertEqual(Location.objects.get(pk=location_a2.pk).site, site_b)
  199. self.assertEqual(Rack.objects.get(pk=rack1.pk).site, site_b)
  200. self.assertEqual(Rack.objects.get(pk=rack2.pk).site, site_b)
  201. self.assertEqual(Device.objects.get(pk=device1.pk).site, site_b)
  202. self.assertEqual(Device.objects.get(pk=device2.pk).site, site_b)
  203. self.assertEqual(PowerPanel.objects.get(pk=powerpanel1.pk).site, site_b)
  204. class DeviceTypeTestCase(TestCase):
  205. def test_component_template_counts(self):
  206. """
  207. DeviceType component template counters should track the addition and removal of templates.
  208. """
  209. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  210. device_type = DeviceType.objects.create(
  211. manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
  212. )
  213. # Counters should start at zero
  214. self.assertEqual(device_type.interface_template_count, 0)
  215. self.assertEqual(device_type.console_port_template_count, 0)
  216. self.assertEqual(device_type.module_bay_template_count, 0)
  217. self.assertEqual(device_type.device_bay_template_count, 0)
  218. # Adding templates should increment the relevant counters
  219. InterfaceTemplate.objects.create(device_type=device_type, name='Interface 1')
  220. InterfaceTemplate.objects.create(device_type=device_type, name='Interface 2')
  221. ConsolePortTemplate.objects.create(device_type=device_type, name='Console 1')
  222. ModuleBayTemplate.objects.create(device_type=device_type, name='Module Bay 1')
  223. DeviceBayTemplate.objects.create(device_type=device_type, name='Device Bay 1')
  224. device_type.refresh_from_db()
  225. self.assertEqual(device_type.interface_template_count, 2)
  226. self.assertEqual(device_type.console_port_template_count, 1)
  227. self.assertEqual(device_type.module_bay_template_count, 1)
  228. self.assertEqual(device_type.device_bay_template_count, 1)
  229. # Deleting a template should decrement the counter
  230. InterfaceTemplate.objects.get(device_type=device_type, name='Interface 1').delete()
  231. device_type.refresh_from_db()
  232. self.assertEqual(device_type.interface_template_count, 1)
  233. class DeviceTypeImageTestCase(TestCase):
  234. """
  235. Test the handling of DeviceType front/rear image files.
  236. """
  237. def setUp(self):
  238. media_root = self.enterContext(tempfile.TemporaryDirectory())
  239. self.enterContext(override_settings(MEDIA_ROOT=media_root))
  240. self.manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  241. def _create_device_type(self, model='Device Type 1', **images):
  242. """
  243. Create a DeviceType with an uploaded image for each image field mapped to a filename.
  244. """
  245. return DeviceType.objects.create(
  246. manufacturer=self.manufacturer,
  247. model=model,
  248. slug=slugify(model),
  249. **{field_name: create_test_image(filename) for field_name, filename in images.items()},
  250. )
  251. def _save(self, device_type, **kwargs):
  252. # Execute the on-commit deletion of replaced image files, which TestCase otherwise discards
  253. with self.captureOnCommitCallbacks(execute=True):
  254. device_type.save(**kwargs)
  255. def test_create_with_images(self):
  256. device_type = self._create_device_type(front_image='front.png', rear_image='rear.png')
  257. self.assertTrue(os.path.exists(device_type.front_image.path))
  258. self.assertTrue(os.path.exists(device_type.rear_image.path))
  259. def test_replace_and_clear_images(self):
  260. device_type = self._create_device_type(front_image='front1.png', rear_image='rear.png')
  261. front_image_path = device_type.front_image.path
  262. rear_image_path = device_type.rear_image.path
  263. # Replacing an image should delete the original file
  264. device_type = DeviceType.objects.get(pk=device_type.pk)
  265. device_type.front_image = create_test_image('front2.png')
  266. self._save(device_type)
  267. self.assertFalse(os.path.exists(front_image_path))
  268. self.assertTrue(os.path.exists(device_type.front_image.path))
  269. # Clearing an image should delete its file
  270. device_type.rear_image = None
  271. self._save(device_type)
  272. self.assertFalse(os.path.exists(rear_image_path))
  273. def test_replace_image_on_same_instance(self):
  274. """
  275. Replacing an image repeatedly on the instance it was created with should delete each prior file.
  276. """
  277. device_type = self._create_device_type(front_image='front1.png')
  278. first_image_path = device_type.front_image.path
  279. device_type.front_image = create_test_image('front2.png')
  280. self._save(device_type)
  281. second_image_path = device_type.front_image.path
  282. self.assertFalse(os.path.exists(first_image_path))
  283. self.assertTrue(os.path.exists(second_image_path))
  284. device_type.front_image = create_test_image('front3.png')
  285. self._save(device_type)
  286. self.assertFalse(os.path.exists(second_image_path))
  287. self.assertTrue(os.path.exists(device_type.front_image.path))
  288. def test_replace_initial_image_path_before_first_save(self):
  289. """
  290. Replacing an image path assigned to a new DeviceType before its first save should not delete that file, which
  291. here belongs to another DeviceType.
  292. """
  293. device_type1 = self._create_device_type(front_image='front1.png')
  294. shared_image_path = device_type1.front_image.path
  295. device_type2 = DeviceType(
  296. manufacturer=self.manufacturer,
  297. model='Device Type 2',
  298. slug='device-type-2',
  299. front_image=device_type1.front_image.name,
  300. )
  301. device_type2.front_image = create_test_image('front2.png')
  302. self._save(device_type2)
  303. self.assertTrue(os.path.exists(shared_image_path))
  304. self.assertTrue(os.path.exists(device_type2.front_image.path))
  305. def test_update_fields_omitting_image(self):
  306. """
  307. Saving with update_fields which omits an image field should not delete its stored file, which a later full
  308. save then replaces.
  309. """
  310. device_type = self._create_device_type(front_image='front1.png')
  311. original_image_path = device_type.front_image.path
  312. device_type = DeviceType.objects.get(pk=device_type.pk)
  313. device_type.front_image = create_test_image('front2.png')
  314. device_type.description = 'New description'
  315. self._save(device_type, update_fields=['description'])
  316. self.assertTrue(os.path.exists(original_image_path))
  317. device_type.refresh_from_db(fields=['front_image'])
  318. self.assertEqual(device_type.front_image.path, original_image_path)
  319. device_type.front_image = create_test_image('front2.png')
  320. self._save(device_type)
  321. self.assertFalse(os.path.exists(original_image_path))
  322. self.assertTrue(os.path.exists(device_type.front_image.path))
  323. def test_update_fields_iterator(self):
  324. """
  325. Replacing an image via a save with iterator-valued update_fields should delete the original file.
  326. """
  327. device_type = self._create_device_type(front_image='front1.png')
  328. original_image_path = device_type.front_image.path
  329. device_type = DeviceType.objects.get(pk=device_type.pk)
  330. device_type.front_image = create_test_image('front2.png')
  331. self._save(device_type, update_fields=iter(['front_image']))
  332. self.assertFalse(os.path.exists(original_image_path))
  333. self.assertTrue(os.path.exists(device_type.front_image.path))
  334. def test_deferred_images(self):
  335. """
  336. Saving a DeviceType with deferred image fields should neither load them nor delete their stored files.
  337. """
  338. device_type = self._create_device_type(front_image='front.png', rear_image='rear.png')
  339. front_image_path = device_type.front_image.path
  340. rear_image_path = device_type.rear_image.path
  341. device_type = DeviceType.objects.defer('front_image', 'rear_image').get(pk=device_type.pk)
  342. device_type.description = 'New description'
  343. self._save(device_type)
  344. self.assertTrue({'front_image', 'rear_image'}.issubset(device_type.get_deferred_fields()))
  345. self.assertTrue(os.path.exists(front_image_path))
  346. self.assertTrue(os.path.exists(rear_image_path))
  347. def test_replace_deferred_image(self):
  348. """
  349. Replacing an image which was deferred when the DeviceType was loaded should delete the original file.
  350. """
  351. device_type = self._create_device_type(front_image='front1.png')
  352. original_image_path = device_type.front_image.path
  353. device_type = DeviceType.objects.defer('front_image').get(pk=device_type.pk)
  354. device_type.front_image = create_test_image('front2.png')
  355. self._save(device_type)
  356. self.assertFalse(os.path.exists(original_image_path))
  357. self.assertTrue(os.path.exists(device_type.front_image.path))
  358. def test_replace_image_on_stale_instance(self):
  359. """
  360. Replacing an image on an instance loaded before another change to that image should delete the file currently
  361. stored, not the one originally loaded.
  362. """
  363. device_type = self._create_device_type(front_image='front1.png')
  364. stale_instance = DeviceType.objects.get(pk=device_type.pk)
  365. device_type.front_image = create_test_image('front2.png')
  366. self._save(device_type)
  367. second_image_path = device_type.front_image.path
  368. stale_instance.front_image = create_test_image('front3.png')
  369. self._save(stale_instance)
  370. self.assertFalse(os.path.exists(second_image_path))
  371. self.assertTrue(os.path.exists(stale_instance.front_image.path))
  372. def test_replace_image_rolled_back(self):
  373. """
  374. Replacing an image within a transaction which is rolled back should not delete the original file.
  375. """
  376. device_type = self._create_device_type(front_image='front1.png')
  377. original_image_path = device_type.front_image.path
  378. device_type.front_image = create_test_image('front2.png')
  379. with self.captureOnCommitCallbacks(execute=True), self.assertRaises(RuntimeError):
  380. with transaction.atomic():
  381. device_type.save()
  382. raise RuntimeError('Roll back the transaction')
  383. self.assertTrue(os.path.exists(original_image_path))
  384. device_type.refresh_from_db()
  385. self.assertEqual(device_type.front_image.path, original_image_path)
  386. def test_replace_image_storage_error(self):
  387. """
  388. A failure to delete a replaced image file should be logged rather than raised, as the change itself has
  389. already been committed.
  390. """
  391. device_type = self._create_device_type(front_image='front1.png')
  392. original_image_name = device_type.front_image.name
  393. # A non-robust on-commit callback would re-raise the OSError here
  394. device_type.front_image = create_test_image('front2.png')
  395. with patch(
  396. 'dcim.models.devices.default_storage.delete', side_effect=OSError('Storage unavailable')
  397. ) as mock_delete:
  398. self._save(device_type)
  399. mock_delete.assert_called_once_with(original_image_name)
  400. device_type.refresh_from_db()
  401. self.assertTrue(os.path.exists(device_type.front_image.path))
  402. self.assertTrue(device_type.front_image.name.endswith('front2.png'))
  403. class ModuleTypeTestCase(TestCase):
  404. def test_component_template_counts(self):
  405. """
  406. ModuleType component template counters should track the addition and removal of templates.
  407. """
  408. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  409. module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
  410. # Counters should start at zero
  411. self.assertEqual(module_type.interface_template_count, 0)
  412. self.assertEqual(module_type.console_port_template_count, 0)
  413. self.assertEqual(module_type.module_bay_template_count, 0)
  414. # Adding templates should increment the relevant counters
  415. InterfaceTemplate.objects.create(module_type=module_type, name='Interface 1')
  416. InterfaceTemplate.objects.create(module_type=module_type, name='Interface 2')
  417. ConsolePortTemplate.objects.create(module_type=module_type, name='Console 1')
  418. ModuleBayTemplate.objects.create(module_type=module_type, name='Module Bay 1')
  419. module_type.refresh_from_db()
  420. self.assertEqual(module_type.interface_template_count, 2)
  421. self.assertEqual(module_type.console_port_template_count, 1)
  422. self.assertEqual(module_type.module_bay_template_count, 1)
  423. # Deleting a template should decrement the counter
  424. InterfaceTemplate.objects.get(module_type=module_type, name='Interface 1').delete()
  425. module_type.refresh_from_db()
  426. self.assertEqual(module_type.interface_template_count, 1)
  427. def test_module_bay_template_to_yaml_includes_module_bay_types(self):
  428. """
  429. ModuleBayTemplate.to_yaml() should export its assigned module bay types by name.
  430. """
  431. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  432. module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
  433. bay_type = ModuleBayType.objects.create(name='SFP28', slug='sfp28')
  434. module_bay_template = ModuleBayTemplate.objects.create(module_type=module_type, name='Module Bay 1')
  435. module_bay_template.module_bay_types.set([bay_type])
  436. data = module_bay_template.to_yaml()
  437. self.assertEqual(data['module_bay_types'], ['SFP28'])
  438. def test_module_bay_template_to_yaml_orders_module_bay_types(self):
  439. """
  440. Multiple module bay types should export in ModuleBayType's own ordering
  441. (manufacturer, name), independent of assignment order.
  442. """
  443. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  444. module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
  445. bay_type_b = ModuleBayType.objects.create(name='QSFP28', slug='qsfp28')
  446. bay_type_a = ModuleBayType.objects.create(name='SFP28', slug='sfp28')
  447. module_bay_template = ModuleBayTemplate.objects.create(module_type=module_type, name='Module Bay 1')
  448. module_bay_template.module_bay_types.set([bay_type_b, bay_type_a])
  449. data = module_bay_template.to_yaml()
  450. self.assertEqual(data['module_bay_types'], ['QSFP28', 'SFP28'])
  451. def test_attributes(self):
  452. """
  453. ModuleType.attributes should normalize iterable values into strings for presentation.
  454. """
  455. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  456. profile = ModuleTypeProfile.objects.create(
  457. name='Module Type Profile 1',
  458. schema={
  459. 'properties': {
  460. 'media': {
  461. 'title': 'Media',
  462. 'type': 'array',
  463. 'items': {'type': 'string'},
  464. },
  465. 'enabled': {
  466. 'title': 'Enabled',
  467. 'type': 'boolean',
  468. },
  469. },
  470. },
  471. )
  472. module_type = ModuleType.objects.create(
  473. manufacturer=manufacturer,
  474. model='Module Type 1',
  475. profile=profile,
  476. attribute_data={
  477. 'media': ['sfp', 'qsfp28'],
  478. 'enabled': True,
  479. },
  480. )
  481. self.assertEqual(
  482. module_type.attributes,
  483. {
  484. 'Enabled': True,
  485. 'Media': 'sfp, qsfp28',
  486. },
  487. )
  488. class RackTypeTestCase(TestCase):
  489. @classmethod
  490. def setUpTestData(cls):
  491. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  492. RackType.objects.create(
  493. manufacturer=manufacturer,
  494. model='RackType 1',
  495. slug='rack-type-1',
  496. width=11,
  497. u_height=22,
  498. starting_unit=3,
  499. desc_units=True,
  500. outer_width=444,
  501. outer_depth=5,
  502. outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER,
  503. weight=66,
  504. weight_unit=WeightUnitChoices.UNIT_POUND,
  505. max_weight=7777,
  506. mounting_depth=8,
  507. cooling_capability=RackCoolingCapabilityChoices.CAPABILITY_LIQUID_ONLY,
  508. cooling_capacity=80,
  509. )
  510. def test_rack_creation(self):
  511. rack_type = RackType.objects.first()
  512. sites = (
  513. Site(name='Site 1', slug='site-1'),
  514. )
  515. Site.objects.bulk_create(sites)
  516. locations = (
  517. Location(name='Location 1', slug='location-1', site=sites[0]),
  518. )
  519. for location in locations:
  520. location.save()
  521. rack = Rack.objects.create(
  522. name='Rack 1',
  523. facility_id='A101',
  524. site=sites[0],
  525. location=locations[0],
  526. rack_type=rack_type,
  527. )
  528. self.assertEqual(rack.width, rack_type.width)
  529. self.assertEqual(rack.u_height, rack_type.u_height)
  530. self.assertEqual(rack.starting_unit, rack_type.starting_unit)
  531. self.assertEqual(rack.desc_units, rack_type.desc_units)
  532. self.assertEqual(rack.outer_width, rack_type.outer_width)
  533. self.assertEqual(rack.outer_depth, rack_type.outer_depth)
  534. self.assertEqual(rack.outer_unit, rack_type.outer_unit)
  535. self.assertEqual(rack.weight, rack_type.weight)
  536. self.assertEqual(rack.weight_unit, rack_type.weight_unit)
  537. self.assertEqual(rack.max_weight, rack_type.max_weight)
  538. self.assertEqual(rack.mounting_depth, rack_type.mounting_depth)
  539. # Cooling capability/capacity are inherited from the rack type
  540. self.assertEqual(rack.cooling_capability, rack_type.cooling_capability)
  541. self.assertEqual(rack.cooling_capacity, rack_type.cooling_capacity)
  542. class RackTestCase(TestCase):
  543. @classmethod
  544. def setUpTestData(cls):
  545. sites = (
  546. Site(name='Site 1', slug='site-1'),
  547. Site(name='Site 2', slug='site-2'),
  548. )
  549. Site.objects.bulk_create(sites)
  550. locations = (
  551. Location(name='Location 1', slug='location-1', site=sites[0]),
  552. Location(name='Location 2', slug='location-2', site=sites[1]),
  553. )
  554. for location in locations:
  555. location.save()
  556. Rack.objects.create(
  557. name='Rack 1',
  558. facility_id='A101',
  559. site=sites[0],
  560. location=locations[0],
  561. u_height=42
  562. )
  563. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  564. device_types = (
  565. DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1),
  566. DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=0),
  567. DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3', u_height=0.5),
  568. )
  569. DeviceType.objects.bulk_create(device_types)
  570. DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
  571. def test_rack_device_outside_height(self):
  572. site = Site.objects.first()
  573. rack = Rack.objects.first()
  574. device1 = Device(
  575. name='Device 1',
  576. device_type=DeviceType.objects.first(),
  577. role=DeviceRole.objects.first(),
  578. site=site,
  579. rack=rack,
  580. position=43,
  581. face=DeviceFaceChoices.FACE_FRONT,
  582. )
  583. device1.save()
  584. with self.assertRaises(ValidationError):
  585. rack.clean()
  586. def test_location_site(self):
  587. site1 = Site.objects.get(name='Site 1')
  588. location2 = Location.objects.get(name='Location 2')
  589. rack2 = Rack(
  590. name='Rack 2',
  591. site=site1,
  592. location=location2,
  593. u_height=42
  594. )
  595. rack2.save()
  596. with self.assertRaises(ValidationError):
  597. rack2.clean()
  598. def test_mount_single_device(self):
  599. site = Site.objects.first()
  600. rack = Rack.objects.first()
  601. device1 = Device(
  602. name='TestSwitch1',
  603. device_type=DeviceType.objects.first(),
  604. role=DeviceRole.objects.first(),
  605. site=site,
  606. rack=rack,
  607. position=10.0,
  608. face=DeviceFaceChoices.FACE_REAR,
  609. )
  610. device1.save()
  611. # Validate rack height
  612. self.assertEqual(list(rack.units), list(drange(42.5, 0.5, -0.5)))
  613. # Validate inventory (front face)
  614. rack1_inventory_front = {
  615. u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT)
  616. }
  617. self.assertEqual(rack1_inventory_front[10.0]['device'], device1)
  618. self.assertEqual(rack1_inventory_front[10.5]['device'], device1)
  619. del rack1_inventory_front[10.0]
  620. del rack1_inventory_front[10.5]
  621. for u in rack1_inventory_front.values():
  622. self.assertIsNone(u['device'])
  623. # Validate inventory (rear face)
  624. rack1_inventory_rear = {
  625. u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR)
  626. }
  627. self.assertEqual(rack1_inventory_rear[10.0]['device'], device1)
  628. self.assertEqual(rack1_inventory_rear[10.5]['device'], device1)
  629. del rack1_inventory_rear[10.0]
  630. del rack1_inventory_rear[10.5]
  631. for u in rack1_inventory_rear.values():
  632. self.assertIsNone(u['device'])
  633. def test_mount_zero_ru(self):
  634. """
  635. Check that a 0RU device can be mounted in a rack with no face/position.
  636. """
  637. site = Site.objects.first()
  638. rack = Rack.objects.first()
  639. Device(
  640. name='Device 1',
  641. role=DeviceRole.objects.first(),
  642. device_type=DeviceType.objects.first(),
  643. site=site,
  644. rack=rack
  645. ).save()
  646. def test_mount_half_u_devices(self):
  647. """
  648. Check that two 0.5U devices can be mounted in the same rack unit.
  649. """
  650. rack = Rack.objects.first()
  651. attrs = {
  652. 'device_type': DeviceType.objects.get(u_height=0.5),
  653. 'role': DeviceRole.objects.first(),
  654. 'site': Site.objects.first(),
  655. 'rack': rack,
  656. 'face': DeviceFaceChoices.FACE_FRONT,
  657. }
  658. Device(name='Device 1', position=1, **attrs).save()
  659. Device(name='Device 2', position=1.5, **attrs).save()
  660. self.assertEqual(len(rack.get_available_units()), rack.u_height * 2 - 3)
  661. def test_change_rack_site(self):
  662. """
  663. Check that child Devices get updated when a Rack is moved to a new Site.
  664. """
  665. site_a = Site.objects.create(name='Site A', slug='site-a')
  666. site_b = Site.objects.create(name='Site B', slug='site-b')
  667. # Create Rack1 in Site A
  668. rack1 = Rack.objects.create(site=site_a, name='Rack 1')
  669. # Create Device1 in Rack1
  670. device1 = Device.objects.create(
  671. site=site_a,
  672. rack=rack1,
  673. device_type=DeviceType.objects.first(),
  674. role=DeviceRole.objects.first()
  675. )
  676. # Move Rack1 to Site B
  677. rack1.site = site_b
  678. rack1.save()
  679. # Check that Device1 is now assigned to Site B
  680. self.assertEqual(Device.objects.get(pk=device1.pk).site, site_b)
  681. def test_utilization(self):
  682. site = Site.objects.first()
  683. rack = Rack.objects.first()
  684. Device(
  685. name='Device 1',
  686. role=DeviceRole.objects.first(),
  687. device_type=DeviceType.objects.first(),
  688. site=site,
  689. rack=rack,
  690. position=1
  691. ).save()
  692. rack.refresh_from_db()
  693. self.assertEqual(rack.get_utilization(), 1 / 42 * 100)
  694. # create device excluded from utilization calculations
  695. dt = DeviceType.objects.create(
  696. manufacturer=Manufacturer.objects.first(),
  697. model='Device Type 4',
  698. slug='device-type-4',
  699. u_height=1,
  700. exclude_from_utilization=True
  701. )
  702. Device(
  703. name='Device 2',
  704. role=DeviceRole.objects.first(),
  705. device_type=dt,
  706. site=site,
  707. rack=rack,
  708. position=5
  709. ).save()
  710. rack.refresh_from_db()
  711. self.assertEqual(rack.get_utilization(), 1 / 42 * 100)
  712. class DeviceTestCase(TestCase):
  713. @classmethod
  714. def setUpTestData(cls):
  715. Site.objects.create(name='Test Site 1', slug='test-site-1')
  716. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  717. device_type = DeviceType.objects.create(
  718. manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
  719. )
  720. roles = (
  721. DeviceRole(name='Test Role 1', slug='test-role-1'),
  722. DeviceRole(name='Test Role 2', slug='test-role-2'),
  723. )
  724. for role in roles:
  725. role.save()
  726. # Create a CustomField with a default value & assign it to all component models
  727. cf1 = CustomField.objects.create(name='cf1', default='foo')
  728. cf1.object_types.set(
  729. ObjectType.objects.filter(app_label='dcim', model__in=[
  730. 'consoleport',
  731. 'consoleserverport',
  732. 'powerport',
  733. 'poweroutlet',
  734. 'interface',
  735. 'rearport',
  736. 'frontport',
  737. 'modulebay',
  738. 'devicebay',
  739. 'inventoryitem',
  740. ])
  741. )
  742. # Create DeviceType components
  743. ConsolePortTemplate(
  744. device_type=device_type,
  745. name='Console Port 1'
  746. ).save()
  747. ConsoleServerPortTemplate(
  748. device_type=device_type,
  749. name='Console Server Port 1'
  750. ).save()
  751. powerport = PowerPortTemplate(
  752. device_type=device_type,
  753. name='Power Port 1',
  754. maximum_draw=1000,
  755. allocated_draw=500
  756. )
  757. powerport.save()
  758. PowerOutletTemplate(
  759. device_type=device_type,
  760. name='Power Outlet 1',
  761. power_port=powerport,
  762. feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
  763. ).save()
  764. InterfaceTemplate(
  765. device_type=device_type,
  766. name='Interface 1',
  767. type=InterfaceTypeChoices.TYPE_1GE_FIXED,
  768. mgmt_only=True
  769. ).save()
  770. rearport = RearPortTemplate(
  771. device_type=device_type,
  772. name='Rear Port 1',
  773. type=PortTypeChoices.TYPE_8P8C,
  774. positions=8
  775. )
  776. rearport.save()
  777. frontport = FrontPortTemplate(
  778. device_type=device_type,
  779. name='Front Port 1',
  780. type=PortTypeChoices.TYPE_8P8C,
  781. )
  782. frontport.save()
  783. PortTemplateMapping.objects.create(
  784. device_type=device_type,
  785. front_port=frontport,
  786. rear_port=rearport,
  787. rear_port_position=2,
  788. )
  789. ModuleBayTemplate(
  790. device_type=device_type,
  791. name='Module Bay 1'
  792. ).save()
  793. DeviceBayTemplate(
  794. device_type=device_type,
  795. name='Device Bay 1'
  796. ).save()
  797. InventoryItemTemplate(
  798. device_type=device_type,
  799. name='Inventory Item 1'
  800. ).save()
  801. def test_device_creation(self):
  802. """
  803. Ensure that all Device components are copied automatically from the DeviceType.
  804. """
  805. device = Device(
  806. site=Site.objects.first(),
  807. device_type=DeviceType.objects.first(),
  808. role=DeviceRole.objects.first(),
  809. name='Test Device 1'
  810. )
  811. device.save()
  812. consoleport = ConsolePort.objects.get(
  813. device=device,
  814. name='Console Port 1'
  815. )
  816. self.assertEqual(consoleport.cf['cf1'], 'foo')
  817. consoleserverport = ConsoleServerPort.objects.get(
  818. device=device,
  819. name='Console Server Port 1'
  820. )
  821. self.assertEqual(consoleserverport.cf['cf1'], 'foo')
  822. powerport = PowerPort.objects.get(
  823. device=device,
  824. name='Power Port 1',
  825. maximum_draw=1000,
  826. allocated_draw=500
  827. )
  828. self.assertEqual(powerport.cf['cf1'], 'foo')
  829. poweroutlet = PowerOutlet.objects.get(
  830. device=device,
  831. name='Power Outlet 1',
  832. power_port=powerport,
  833. feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A,
  834. status=PowerOutletStatusChoices.STATUS_ENABLED,
  835. )
  836. self.assertEqual(poweroutlet.cf['cf1'], 'foo')
  837. interface = Interface.objects.get(
  838. device=device,
  839. name='Interface 1',
  840. type=InterfaceTypeChoices.TYPE_1GE_FIXED,
  841. mgmt_only=True
  842. )
  843. self.assertEqual(interface.cf['cf1'], 'foo')
  844. rearport = RearPort.objects.get(
  845. device=device,
  846. name='Rear Port 1',
  847. type=PortTypeChoices.TYPE_8P8C,
  848. positions=8
  849. )
  850. self.assertEqual(rearport.cf['cf1'], 'foo')
  851. frontport = FrontPort.objects.get(
  852. device=device,
  853. name='Front Port 1',
  854. type=PortTypeChoices.TYPE_8P8C,
  855. positions=1
  856. )
  857. self.assertEqual(frontport.cf['cf1'], 'foo')
  858. self.assertTrue(PortMapping.objects.filter(front_port=frontport, rear_port=rearport).exists())
  859. modulebay = ModuleBay.objects.get(
  860. device=device,
  861. name='Module Bay 1'
  862. )
  863. self.assertEqual(modulebay.cf['cf1'], 'foo')
  864. devicebay = DeviceBay.objects.get(
  865. device=device,
  866. name='Device Bay 1'
  867. )
  868. self.assertEqual(devicebay.cf['cf1'], 'foo')
  869. inventoryitem = InventoryItem.objects.get(
  870. device=device,
  871. name='Inventory Item 1'
  872. )
  873. self.assertEqual(inventoryitem.cf['cf1'], 'foo')
  874. def test_multiple_unnamed_devices(self):
  875. device1 = Device(
  876. site=Site.objects.first(),
  877. device_type=DeviceType.objects.first(),
  878. role=DeviceRole.objects.first(),
  879. name=None
  880. )
  881. device1.save()
  882. device2 = Device(
  883. site=device1.site,
  884. device_type=device1.device_type,
  885. role=device1.role,
  886. name=None
  887. )
  888. device2.full_clean()
  889. device2.save()
  890. self.assertEqual(Device.objects.filter(name__isnull=True).count(), 2)
  891. def test_device_name_case_sensitivity(self):
  892. device1 = Device(
  893. site=Site.objects.first(),
  894. device_type=DeviceType.objects.first(),
  895. role=DeviceRole.objects.first(),
  896. name='device 1'
  897. )
  898. device1.save()
  899. device2 = Device(
  900. site=device1.site,
  901. device_type=device1.device_type,
  902. role=device1.role,
  903. name='DEVICE 1'
  904. )
  905. # Uniqueness validation for name should ignore case
  906. with self.assertRaises(ValidationError):
  907. device2.full_clean()
  908. def test_device_duplicate_names(self):
  909. device1 = Device(
  910. site=Site.objects.first(),
  911. device_type=DeviceType.objects.first(),
  912. role=DeviceRole.objects.first(),
  913. name='Test Device 1'
  914. )
  915. device1.save()
  916. device2 = Device(
  917. site=device1.site,
  918. device_type=device1.device_type,
  919. role=device1.role,
  920. name=device1.name
  921. )
  922. # Two devices assigned to the same Site and no Tenant should fail validation
  923. with self.assertRaises(ValidationError):
  924. device2.full_clean()
  925. tenant = Tenant.objects.create(name='Test Tenant 1', slug='test-tenant-1')
  926. device1.tenant = tenant
  927. device1.save()
  928. device2.tenant = tenant
  929. # Two devices assigned to the same Site and the same Tenant should fail validation
  930. with self.assertRaises(ValidationError):
  931. device2.full_clean()
  932. device2.tenant = None
  933. # Two devices assigned to the same Site and different Tenants should pass validation
  934. device2.full_clean()
  935. device2.save()
  936. def test_empty_asset_tag_coerced_to_null_on_clean(self):
  937. """
  938. An empty string assigned to a unique nullable CharField (e.g. asset_tag) must be coerced
  939. to None on save so that multiple objects can be saved without violating the unique
  940. constraint. Test that this is done on clean().
  941. """
  942. common_kwargs = {
  943. 'site': Site.objects.first(),
  944. 'device_type': DeviceType.objects.first(),
  945. 'role': DeviceRole.objects.first(),
  946. }
  947. device1 = Device(name='Device 1', asset_tag='', **common_kwargs)
  948. device1.clean()
  949. self.assertIsNone(device1.asset_tag)
  950. def test_empty_asset_tag_coerced_to_null_on_save(self):
  951. """
  952. An empty string assigned to a unique nullable CharField (e.g. asset_tag) must be coerced
  953. to None on save so that multiple objects can be saved without violating the unique
  954. constraint. Test that this is done on save().
  955. """
  956. common_kwargs = {
  957. 'site': Site.objects.first(),
  958. 'device_type': DeviceType.objects.first(),
  959. 'role': DeviceRole.objects.first(),
  960. }
  961. device1 = Device(name='Device 1', asset_tag='', **common_kwargs)
  962. device1.save()
  963. device2 = Device(name='Device 2', asset_tag='', **common_kwargs)
  964. device2.save()
  965. device1.refresh_from_db()
  966. device2.refresh_from_db()
  967. self.assertIsNone(device1.asset_tag)
  968. self.assertIsNone(device2.asset_tag)
  969. def test_device_label(self):
  970. device1 = Device(
  971. site=Site.objects.first(),
  972. device_type=DeviceType.objects.first(),
  973. role=DeviceRole.objects.first(),
  974. name=None,
  975. )
  976. self.assertEqual(device1.label, None)
  977. device1.name = 'Test Device 1'
  978. self.assertEqual(device1.label, 'Test Device 1')
  979. virtual_chassis = VirtualChassis.objects.create(name='VC 1')
  980. device2 = Device(
  981. site=Site.objects.first(),
  982. device_type=DeviceType.objects.first(),
  983. role=DeviceRole.objects.first(),
  984. name=None,
  985. virtual_chassis=virtual_chassis,
  986. vc_position=2,
  987. )
  988. self.assertEqual(device2.label, 'VC 1:2')
  989. device2.name = 'Test Device 2'
  990. self.assertEqual(device2.label, 'Test Device 2')
  991. def test_device_mismatched_site_cluster(self):
  992. cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
  993. Cluster.objects.create(name='Cluster 1', type=cluster_type)
  994. sites = (
  995. Site(name='Site 1', slug='site-1'),
  996. Site(name='Site 2', slug='site-2'),
  997. )
  998. Site.objects.bulk_create(sites)
  999. clusters = (
  1000. Cluster(name='Cluster 1', type=cluster_type, scope=sites[0]),
  1001. Cluster(name='Cluster 2', type=cluster_type, scope=sites[1]),
  1002. Cluster(name='Cluster 3', type=cluster_type, scope=None),
  1003. )
  1004. for cluster in clusters:
  1005. cluster.save()
  1006. device_type = DeviceType.objects.first()
  1007. device_role = DeviceRole.objects.first()
  1008. # Device with site only should pass
  1009. Device(
  1010. name='device1',
  1011. site=sites[0],
  1012. device_type=device_type,
  1013. role=device_role
  1014. ).full_clean()
  1015. # Device with site, cluster non-site should pass
  1016. Device(
  1017. name='device1',
  1018. site=sites[0],
  1019. device_type=device_type,
  1020. role=device_role,
  1021. cluster=clusters[2]
  1022. ).full_clean()
  1023. # Device with mismatched site & cluster should fail
  1024. with self.assertRaises(ValidationError):
  1025. Device(
  1026. name='device1',
  1027. site=sites[0],
  1028. device_type=device_type,
  1029. role=device_role,
  1030. cluster=clusters[1]
  1031. ).full_clean()
  1032. @tag('regression') # Ref: #22717
  1033. def test_device_mismatched_location_cluster(self):
  1034. """
  1035. A cluster scoped to a different location than the device must be rejected
  1036. with a field validation error naming that location.
  1037. """
  1038. site = Site.objects.create(name='Site 1', slug='site-1')
  1039. locations = (
  1040. Location(site=site, name='Location A', slug='location-a'),
  1041. Location(site=site, name='Location B', slug='location-b'),
  1042. )
  1043. for location in locations:
  1044. location.save()
  1045. cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
  1046. cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type, scope=locations[0])
  1047. device_type = DeviceType.objects.first()
  1048. device_role = DeviceRole.objects.first()
  1049. # Device in the cluster's location should pass
  1050. Device(
  1051. name='device1',
  1052. site=site,
  1053. location=locations[0],
  1054. device_type=device_type,
  1055. role=device_role,
  1056. cluster=cluster
  1057. ).full_clean()
  1058. # Device in a different location of the same site should fail
  1059. with self.assertRaisesMessage(
  1060. ValidationError,
  1061. 'The assigned cluster belongs to a different location (Location A)'
  1062. ):
  1063. Device(
  1064. name='device1',
  1065. site=site,
  1066. location=locations[1],
  1067. device_type=device_type,
  1068. role=device_role,
  1069. cluster=cluster
  1070. ).full_clean()
  1071. @tag('regression') # Ref: #23274
  1072. def test_vc_interfaces_unsaved_device(self):
  1073. """An unsaved device outside a virtual chassis matches no interfaces and runs no query."""
  1074. device = create_test_device('Device 1')
  1075. Interface.objects.create(device=device, name='eth0', type=InterfaceTypeChoices.TYPE_OTHER)
  1076. unsaved = Device(name='Device 2', site=device.site, device_type=device.device_type, role=device.role)
  1077. for if_master in (True, False):
  1078. with self.subTest(if_master=if_master), self.assertNumQueries(0):
  1079. self.assertEqual(list(unsaved.vc_interfaces(if_master=if_master)), [])
  1080. @tag('regression') # Ref: #23274
  1081. def test_unsaved_device_rejects_ips_of_other_devices(self):
  1082. """An unsaved device rejects a primary or OOB IP assigned to another device's interface."""
  1083. device = create_test_device('Device 1')
  1084. interface = Interface.objects.create(device=device, name='eth0', type=InterfaceTypeChoices.TYPE_OTHER)
  1085. ip4 = IPAddress.objects.create(address='192.0.2.1/24', assigned_object=interface)
  1086. ip6 = IPAddress.objects.create(address='2001:db8::1/64', assigned_object=interface)
  1087. nat_ip4 = IPAddress.objects.create(address='198.51.100.1/24', nat_inside=ip4)
  1088. for field, ip in (('primary_ip4', ip4), ('primary_ip4', nat_ip4), ('primary_ip6', ip6), ('oob_ip', ip4)):
  1089. with self.subTest(field=field, ip=ip):
  1090. unsaved = Device(
  1091. name='Device 2', site=device.site, device_type=device.device_type, role=device.role,
  1092. **{field: ip}
  1093. )
  1094. with self.assertRaises(ValidationError) as cm:
  1095. unsaved.full_clean()
  1096. self.assertIn(field, cm.exception.message_dict)
  1097. @tag('regression') # Ref: #23274
  1098. def test_unsaved_device_accepts_ips_of_virtual_chassis_peers(self):
  1099. """An unsaved virtual chassis member accepts a peer's IP unless its interface is management-only."""
  1100. virtual_chassis = VirtualChassis.objects.create(name='Virtual Chassis 1')
  1101. peer = create_test_device('Device 1', virtual_chassis=virtual_chassis, vc_position=1)
  1102. interface = Interface.objects.create(device=peer, name='eth0', type=InterfaceTypeChoices.TYPE_OTHER)
  1103. mgmt_interface = Interface.objects.create(
  1104. device=peer, name='mgmt0', type=InterfaceTypeChoices.TYPE_OTHER, mgmt_only=True
  1105. )
  1106. ip4 = IPAddress.objects.create(address='192.0.2.1/24', assigned_object=interface)
  1107. ip6 = IPAddress.objects.create(address='2001:db8::1/64', assigned_object=interface)
  1108. mgmt_ip4 = IPAddress.objects.create(address='192.0.2.2/24', assigned_object=mgmt_interface)
  1109. common_kwargs = {
  1110. 'name': 'Device 2',
  1111. 'site': peer.site,
  1112. 'device_type': peer.device_type,
  1113. 'role': peer.role,
  1114. 'virtual_chassis': virtual_chassis,
  1115. 'vc_position': 2,
  1116. }
  1117. for field, ip in (('primary_ip4', ip4), ('primary_ip6', ip6), ('oob_ip', ip4)):
  1118. with self.subTest(field=field):
  1119. Device(**common_kwargs, **{field: ip}).full_clean()
  1120. with self.assertRaises(ValidationError) as cm:
  1121. Device(**common_kwargs, primary_ip4=mgmt_ip4).full_clean()
  1122. self.assertIn('primary_ip4', cm.exception.message_dict)
  1123. class DeviceBayTestCase(TestCase):
  1124. @classmethod
  1125. def setUpTestData(cls):
  1126. site = Site.objects.create(name='Test Site 1', slug='test-site-1')
  1127. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  1128. # Parent device type must support device bays (is_parent_device=True)
  1129. parent_device_type = DeviceType.objects.create(
  1130. manufacturer=manufacturer,
  1131. model='Parent Device Type',
  1132. slug='parent-device-type',
  1133. subdevice_role=SubdeviceRoleChoices.ROLE_PARENT
  1134. )
  1135. # Child device type for installation
  1136. child_device_type = DeviceType.objects.create(
  1137. manufacturer=manufacturer,
  1138. model='Child Device Type',
  1139. slug='child-device-type',
  1140. u_height=0,
  1141. subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
  1142. )
  1143. device_role = DeviceRole.objects.create(name='Test Role 1', slug='test-role-1')
  1144. cls.parent_device = Device.objects.create(
  1145. name='Parent Device',
  1146. device_type=parent_device_type,
  1147. role=device_role,
  1148. site=site
  1149. )
  1150. cls.child_device = Device.objects.create(
  1151. name='Child Device',
  1152. device_type=child_device_type,
  1153. role=device_role,
  1154. site=site
  1155. )
  1156. cls.child_device_2 = Device.objects.create(
  1157. name='Child Device 2',
  1158. device_type=child_device_type,
  1159. role=device_role,
  1160. site=site
  1161. )
  1162. def test_cannot_install_device_in_disabled_bay(self):
  1163. """
  1164. Test that a device cannot be installed into a disabled DeviceBay.
  1165. """
  1166. # Create a disabled device bay with a device being installed
  1167. device_bay = DeviceBay(
  1168. device=self.parent_device,
  1169. name='Disabled Bay',
  1170. enabled=False,
  1171. installed_device=self.child_device
  1172. )
  1173. with self.assertRaises(ValidationError) as cm:
  1174. device_bay.clean()
  1175. self.assertIn('installed_device', cm.exception.message_dict)
  1176. self.assertIn('disabled device bay', str(cm.exception.message_dict['installed_device']))
  1177. @tag('regression') # Ref: #23617
  1178. def test_moving_parent_device_records_child_prechange(self):
  1179. DeviceBay.objects.create(device=self.parent_device, name='Bay 1', installed_device=self.child_device)
  1180. old_site = self.parent_device.site
  1181. new_site = Site.objects.create(name='Test Site 2', slug='test-site-2')
  1182. request = RequestFactory().get('/')
  1183. request.id = uuid.uuid4()
  1184. request.user = User.objects.create_user(username='testuser')
  1185. with event_tracking(request):
  1186. parent = Device.objects.get(pk=self.parent_device.pk)
  1187. parent.snapshot()
  1188. parent.site = new_site
  1189. parent.save()
  1190. change = ObjectChange.objects.get(
  1191. changed_object_type=ObjectType.objects.get_for_model(Device),
  1192. changed_object_id=self.child_device.pk,
  1193. action=ObjectChangeActionChoices.ACTION_UPDATE,
  1194. )
  1195. self.assertEqual(change.prechange_data['site'], old_site.pk)
  1196. self.assertEqual(change.postchange_data['site'], new_site.pk)
  1197. def test_can_disable_bay_with_existing_device(self):
  1198. """
  1199. Test that disabling a bay that already has a device installed does NOT raise an error
  1200. (same installed_device_id).
  1201. """
  1202. # First, create an enabled device bay with a device installed
  1203. device_bay = DeviceBay.objects.create(
  1204. device=self.parent_device,
  1205. name='Bay To Disable',
  1206. enabled=True,
  1207. installed_device=self.child_device
  1208. )
  1209. # Now disable the bay while keeping the same installed device
  1210. device_bay.enabled = False
  1211. # This should NOT raise a ValidationError
  1212. device_bay.clean()
  1213. device_bay.save()
  1214. device_bay.refresh_from_db()
  1215. self.assertFalse(device_bay.enabled)
  1216. self.assertEqual(device_bay.installed_device, self.child_device)
  1217. def test_cannot_change_installed_device_in_disabled_bay(self):
  1218. """
  1219. Test that changing the installed device in a disabled bay raises a ValidationError.
  1220. """
  1221. # Create an enabled device bay with a device installed
  1222. device_bay = DeviceBay.objects.create(
  1223. device=self.parent_device,
  1224. name='Bay With Device',
  1225. enabled=True,
  1226. installed_device=self.child_device
  1227. )
  1228. # Disable the bay and try to change the installed device
  1229. device_bay.enabled = False
  1230. device_bay.installed_device = self.child_device_2
  1231. with self.assertRaises(ValidationError) as cm:
  1232. device_bay.clean()
  1233. self.assertIn('installed_device', cm.exception.message_dict)
  1234. class ModuleBayTestCase(TestCase):
  1235. @classmethod
  1236. def setUpTestData(cls):
  1237. site = Site.objects.create(name='Test Site 1', slug='test-site-1')
  1238. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  1239. device_type = DeviceType.objects.create(
  1240. manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
  1241. )
  1242. device_role = DeviceRole.objects.create(name='Test Role 1', slug='test-role-1')
  1243. # Create a CustomField with a default value & assign it to all component models
  1244. location = Location.objects.create(name='Location 1', slug='location-1', site=site)
  1245. rack = Rack.objects.create(name='Rack 1', site=site)
  1246. device = Device.objects.create(
  1247. name='Device 1', device_type=device_type, role=device_role, site=site, location=location, rack=rack
  1248. )
  1249. module_bays = (
  1250. ModuleBay(device=device, name='Module Bay 1', label='A', description='First'),
  1251. ModuleBay(device=device, name='Module Bay 2', label='B', description='Second'),
  1252. ModuleBay(device=device, name='Module Bay 3', label='C', description='Third'),
  1253. )
  1254. for module_bay in module_bays:
  1255. module_bay.save()
  1256. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  1257. module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
  1258. modules = (
  1259. Module(device=device, module_bay=module_bays[0], module_type=module_type),
  1260. Module(device=device, module_bay=module_bays[1], module_type=module_type),
  1261. Module(device=device, module_bay=module_bays[2], module_type=module_type),
  1262. )
  1263. # M3 -> MB3 -> M2 -> MB2 -> M1 -> MB1
  1264. Module.objects.bulk_create(modules)
  1265. module_bays[1].module = modules[0]
  1266. module_bays[1].clean()
  1267. module_bays[1].save()
  1268. module_bays[2].module = modules[1]
  1269. module_bays[2].clean()
  1270. module_bays[2].save()
  1271. def test_module_bay_recursion(self):
  1272. module_bay_1 = ModuleBay.objects.get(name='Module Bay 1')
  1273. module_bay_3 = ModuleBay.objects.get(name='Module Bay 3')
  1274. module_1 = Module.objects.get(module_bay=module_bay_1)
  1275. module_3 = Module.objects.get(module_bay=module_bay_3)
  1276. # Confirm error if ModuleBay recurses
  1277. with self.assertRaises(ValidationError):
  1278. module_bay_1.module = module_3
  1279. module_bay_1.clean()
  1280. module_bay_1.save()
  1281. # Confirm error if Module recurses (empty target bay, so the occupied-bay check cannot mask it)
  1282. module_bay_4 = ModuleBay.objects.create(device=module_1.device, name='Module Bay 4', module=module_3)
  1283. with self.assertRaisesMessage(ValidationError, 'cannot belong to a module installed within it'):
  1284. module_1.module_bay = module_bay_4
  1285. module_1.clean()
  1286. module_1.save()
  1287. @tag('regression') # #22146
  1288. def test_module_bay_ordering_after_recreate(self):
  1289. """
  1290. Module bays must remain in name order after a delete-and-recreate cycle,
  1291. even though MPTT no longer renumbers tree_ids on root insertion.
  1292. """
  1293. device_type = DeviceType.objects.first()
  1294. device_role = DeviceRole.objects.first()
  1295. site = Site.objects.first()
  1296. device = Device.objects.create(
  1297. name='Ordering Test Device',
  1298. device_type=device_type,
  1299. role=device_role,
  1300. site=site,
  1301. )
  1302. for name in ('Bay 1', 'Bay 2', 'Bay 3', 'Bay 4'):
  1303. ModuleBay.objects.create(device=device, name=name)
  1304. ModuleBay.objects.get(device=device, name='Bay 3').delete()
  1305. ModuleBay.objects.create(device=device, name='Bay 3')
  1306. names = list(ModuleBay.objects.filter(device=device).values_list('name', flat=True))
  1307. self.assertEqual(names, ['Bay 1', 'Bay 2', 'Bay 3', 'Bay 4'])
  1308. @tag('regression') # #22146
  1309. def test_module_bay_natural_ordering(self):
  1310. """
  1311. Module bays must be returned in natural (numeric-aware) order, e.g.
  1312. "Bay 2" before "Bay 10".
  1313. """
  1314. device_type = DeviceType.objects.first()
  1315. device_role = DeviceRole.objects.first()
  1316. site = Site.objects.first()
  1317. device = Device.objects.create(
  1318. name='Natural Sort Device',
  1319. device_type=device_type,
  1320. role=device_role,
  1321. site=site,
  1322. )
  1323. # Insert in non-natural order to confirm sort is not insertion-driven.
  1324. for name in ('Bay 10', 'Bay 1', 'Bay 2', 'Bay 11'):
  1325. ModuleBay.objects.create(device=device, name=name)
  1326. names = list(ModuleBay.objects.filter(device=device).values_list('name', flat=True))
  1327. self.assertEqual(names, ['Bay 1', 'Bay 2', 'Bay 10', 'Bay 11'])
  1328. @tag('regression') # #22146
  1329. def test_child_module_bay_ordering(self):
  1330. """
  1331. Child module bays inside a module must be returned in name order even
  1332. when inserted out of order.
  1333. """
  1334. device_type = DeviceType.objects.first()
  1335. device_role = DeviceRole.objects.first()
  1336. site = Site.objects.first()
  1337. device = Device.objects.create(
  1338. name='Child Ordering Device',
  1339. device_type=device_type,
  1340. role=device_role,
  1341. site=site,
  1342. )
  1343. root_bay = ModuleBay.objects.create(device=device, name='Bay 1')
  1344. manufacturer = Manufacturer.objects.first()
  1345. module_type = ModuleType.objects.create(
  1346. manufacturer=manufacturer, model='Child Ordering Type'
  1347. )
  1348. module = Module.objects.create(
  1349. device=device, module_bay=root_bay, module_type=module_type
  1350. )
  1351. # Insert children out of name order.
  1352. for name in ('Bay 1.1', 'Bay 1.3', 'Bay 1.2'):
  1353. ModuleBay.objects.create(device=device, module=module, name=name)
  1354. names = list(ModuleBay.objects.filter(device=device).values_list('name', flat=True))
  1355. self.assertEqual(names, ['Bay 1', 'Bay 1.1', 'Bay 1.2', 'Bay 1.3'])
  1356. @tag('regression') # #22146
  1357. def test_root_module_bay_rename_preserves_paths(self):
  1358. """
  1359. Renaming a root module bay must not rewrite any tree's path. Renaming
  1360. touches only sort_path (the display-ordering column), so every bay's
  1361. path — including the renamed bay's own — must be unchanged afterward.
  1362. """
  1363. device_type = DeviceType.objects.first()
  1364. device_role = DeviceRole.objects.first()
  1365. site = Site.objects.first()
  1366. device = Device.objects.create(
  1367. name='Rename Path Device',
  1368. device_type=device_type,
  1369. role=device_role,
  1370. site=site,
  1371. )
  1372. for name in ('Bay 1', 'Bay 2', 'Bay 3', 'Bay 4'):
  1373. ModuleBay.objects.create(device=device, name=name)
  1374. paths_before = {
  1375. bay.pk: str(bay.path)
  1376. for bay in ModuleBay.objects.filter(device=device)
  1377. }
  1378. bay = ModuleBay.objects.get(device=device, name='Bay 2')
  1379. bay.name = 'Bay 99'
  1380. bay.save()
  1381. paths_after = {
  1382. bay.pk: str(bay.path)
  1383. for bay in ModuleBay.objects.filter(device=device)
  1384. }
  1385. self.assertEqual(paths_after, paths_before)
  1386. @tag('regression') # #22146
  1387. def test_root_module_bay_rename_updates_display_order(self):
  1388. """
  1389. Even though renaming a root module bay does not rewrite its path,
  1390. the manager's _root_name annotation must reflect the new name so the
  1391. display ordering is correct.
  1392. """
  1393. device_type = DeviceType.objects.first()
  1394. device_role = DeviceRole.objects.first()
  1395. site = Site.objects.first()
  1396. device = Device.objects.create(
  1397. name='Rename Order Device',
  1398. device_type=device_type,
  1399. role=device_role,
  1400. site=site,
  1401. )
  1402. for name in ('Bay 1', 'Bay 2', 'Bay 3'):
  1403. ModuleBay.objects.create(device=device, name=name)
  1404. bay = ModuleBay.objects.get(device=device, name='Bay 1')
  1405. bay.name = 'Bay 4'
  1406. bay.save()
  1407. names = list(ModuleBay.objects.filter(device=device).values_list('name', flat=True))
  1408. self.assertEqual(names, ['Bay 2', 'Bay 3', 'Bay 4'])
  1409. @tag('regression') # #22146
  1410. def test_child_module_bay_rename_preserves_intra_tree_ordering(self):
  1411. """
  1412. Renaming a *child* module bay must still trigger MPTT's intra-tree
  1413. reorder, so siblings appear in name order after the rename. The
  1414. rename-bypass only covers root bays.
  1415. """
  1416. device_type = DeviceType.objects.first()
  1417. device_role = DeviceRole.objects.first()
  1418. site = Site.objects.first()
  1419. device = Device.objects.create(
  1420. name='Child Rename Device',
  1421. device_type=device_type,
  1422. role=device_role,
  1423. site=site,
  1424. )
  1425. root_bay = ModuleBay.objects.create(device=device, name='Bay 1')
  1426. manufacturer = Manufacturer.objects.first()
  1427. module_type = ModuleType.objects.create(
  1428. manufacturer=manufacturer, model='Child Rename Type'
  1429. )
  1430. module = Module.objects.create(
  1431. device=device, module_bay=root_bay, module_type=module_type
  1432. )
  1433. for name in ('Bay 1.1', 'Bay 1.2', 'Bay 1.3'):
  1434. ModuleBay.objects.create(device=device, module=module, name=name)
  1435. child = ModuleBay.objects.get(device=device, name='Bay 1.1')
  1436. child.name = 'Bay 1.4'
  1437. child.save()
  1438. names = list(ModuleBay.objects.filter(device=device).values_list('name', flat=True))
  1439. self.assertEqual(names, ['Bay 1', 'Bay 1.2', 'Bay 1.3', 'Bay 1.4'])
  1440. @tag('regression') # #22146
  1441. def test_root_to_child_transition_still_relocates(self):
  1442. """
  1443. Promoting an existing root module bay to a child (by assigning a
  1444. module) must still flow through MPTT's normal move logic. The
  1445. rename-bypass must not suppress legitimate parent changes.
  1446. """
  1447. device_type = DeviceType.objects.first()
  1448. device_role = DeviceRole.objects.first()
  1449. site = Site.objects.first()
  1450. device = Device.objects.create(
  1451. name='Root To Child Device',
  1452. device_type=device_type,
  1453. role=device_role,
  1454. site=site,
  1455. )
  1456. host_bay = ModuleBay.objects.create(device=device, name='Host Bay')
  1457. movable_bay = ModuleBay.objects.create(device=device, name='Movable Bay')
  1458. manufacturer = Manufacturer.objects.first()
  1459. module_type = ModuleType.objects.create(
  1460. manufacturer=manufacturer, model='Root To Child Type'
  1461. )
  1462. host_module = Module.objects.create(
  1463. device=device, module_bay=host_bay, module_type=module_type
  1464. )
  1465. movable_bay.module = host_module
  1466. movable_bay.save()
  1467. movable_bay.refresh_from_db()
  1468. host_bay.refresh_from_db()
  1469. self.assertEqual(movable_bay.parent_id, host_bay.pk)
  1470. # The trigger cascade must have re-rooted the moved bay into host_bay's
  1471. # tree: its path is now a strict descendant of host_bay's path.
  1472. self.assertTrue(str(movable_bay.path).startswith(f'{host_bay.path}.'))
  1473. @tag('regression') # #22251
  1474. def test_moving_module_reparents_child_module_bays(self):
  1475. """
  1476. When a module is moved to a different module bay, each child ModuleBay
  1477. (a bay that belongs to the module) must have its parent updated to the
  1478. new host bay. Without the fix the children stay parented to the old bay
  1479. even though Module.module_bay_id has changed.
  1480. """
  1481. device_type = DeviceType.objects.first()
  1482. device_role = DeviceRole.objects.first()
  1483. site = Site.objects.first()
  1484. device = Device.objects.create(
  1485. name='Move Module Device',
  1486. device_type=device_type,
  1487. role=device_role,
  1488. site=site,
  1489. )
  1490. bay_a = ModuleBay.objects.create(device=device, name='Bay A')
  1491. bay_b = ModuleBay.objects.create(device=device, name='Bay B')
  1492. manufacturer = Manufacturer.objects.first()
  1493. module_type = ModuleType.objects.create(
  1494. manufacturer=manufacturer, model='Move Module Type'
  1495. )
  1496. module = Module.objects.create(
  1497. device=device, module_bay=bay_a, module_type=module_type
  1498. )
  1499. child_1 = ModuleBay.objects.create(device=device, module=module, name='Child Bay 1')
  1500. child_2 = ModuleBay.objects.create(device=device, module=module, name='Child Bay 2')
  1501. self.assertEqual(child_1.parent_id, bay_a.pk)
  1502. self.assertEqual(child_2.parent_id, bay_a.pk)
  1503. # Move the module to bay_b.
  1504. module.module_bay = bay_b
  1505. module.save()
  1506. child_1.refresh_from_db()
  1507. child_2.refresh_from_db()
  1508. self.assertEqual(child_1.parent_id, bay_b.pk)
  1509. self.assertEqual(child_2.parent_id, bay_b.pk)
  1510. # Children must be re-rooted under bay_b in the ltree hierarchy.
  1511. bay_b.refresh_from_db()
  1512. self.assertTrue(str(child_1.path).startswith(f'{bay_b.path}.'))
  1513. self.assertTrue(str(child_2.path).startswith(f'{bay_b.path}.'))
  1514. @tag('regression') # #22251
  1515. def test_moving_module_reparents_grandchild_module_bays(self):
  1516. """
  1517. When a module is moved, grandchild ModuleBays (bays inside a module
  1518. that is itself installed inside a child bay of the moved module) must
  1519. also land in the new ltree subtree. The trigger cascade moves subtrees
  1520. atomically, so calling save() only on direct children is sufficient —
  1521. this test documents and preserves that invariant for future tree-backend
  1522. changes.
  1523. """
  1524. device_type = DeviceType.objects.first()
  1525. device_role = DeviceRole.objects.first()
  1526. site = Site.objects.first()
  1527. device = Device.objects.create(
  1528. name='Grandchild Move Device',
  1529. device_type=device_type,
  1530. role=device_role,
  1531. site=site,
  1532. )
  1533. bay_a = ModuleBay.objects.create(device=device, name='Bay A')
  1534. bay_b = ModuleBay.objects.create(device=device, name='Bay B')
  1535. manufacturer = Manufacturer.objects.first()
  1536. module_type = ModuleType.objects.create(
  1537. manufacturer=manufacturer, model='Grandchild Move Type'
  1538. )
  1539. # Depth-1: module installed in bay_a, with one child bay.
  1540. module_1 = Module.objects.create(device=device, module_bay=bay_a, module_type=module_type)
  1541. child_bay = ModuleBay.objects.create(device=device, module=module_1, name='Child Bay')
  1542. # Depth-2: module installed in child_bay, with one grandchild bay.
  1543. module_2 = Module.objects.create(device=device, module_bay=child_bay, module_type=module_type)
  1544. grandchild_bay = ModuleBay.objects.create(device=device, module=module_2, name='Grandchild Bay')
  1545. self.assertEqual(child_bay.parent_id, bay_a.pk)
  1546. self.assertEqual(grandchild_bay.parent_id, child_bay.pk)
  1547. bay_a.refresh_from_db()
  1548. self.assertTrue(str(grandchild_bay.path).startswith(f'{bay_a.path}.'))
  1549. # Move the top-level module to bay_b.
  1550. module_1.module_bay = bay_b
  1551. module_1.save()
  1552. child_bay.refresh_from_db()
  1553. grandchild_bay.refresh_from_db()
  1554. bay_b.refresh_from_db()
  1555. self.assertEqual(child_bay.parent_id, bay_b.pk)
  1556. self.assertTrue(str(child_bay.path).startswith(f'{bay_b.path}.'))
  1557. # Grandchild's direct parent (child_bay) is unchanged; only tree placement moves.
  1558. self.assertEqual(grandchild_bay.parent_id, child_bay.pk)
  1559. self.assertTrue(str(grandchild_bay.path).startswith(f'{bay_b.path}.'))
  1560. def test_single_module_token(self):
  1561. device_type = DeviceType.objects.first()
  1562. device_role = DeviceRole.objects.first()
  1563. site = Site.objects.first()
  1564. location = Location.objects.first()
  1565. rack = Rack.objects.first()
  1566. # Create DeviceType components
  1567. ConsolePortTemplate.objects.create(
  1568. device_type=device_type,
  1569. name='{module}',
  1570. label='{module}',
  1571. )
  1572. ModuleBayTemplate.objects.create(
  1573. device_type=device_type,
  1574. name='Module Bay 1'
  1575. )
  1576. device = Device.objects.create(
  1577. name='Device 2',
  1578. device_type=device_type,
  1579. role=device_role,
  1580. site=site,
  1581. location=location,
  1582. rack=rack
  1583. )
  1584. device.consoleports.first()
  1585. @tag('regression') # #19918
  1586. def test_nested_module_bay_label_resolution(self):
  1587. """Test that nested module bay labels properly resolve {module} placeholders"""
  1588. manufacturer = Manufacturer.objects.first()
  1589. site = Site.objects.first()
  1590. device_role = DeviceRole.objects.first()
  1591. # Create device type with module bay template (position='A')
  1592. device_type = DeviceType.objects.create(
  1593. manufacturer=manufacturer,
  1594. model='Device with Bays',
  1595. slug='device-with-bays'
  1596. )
  1597. ModuleBayTemplate.objects.create(
  1598. device_type=device_type,
  1599. name='Bay A',
  1600. position='A'
  1601. )
  1602. # Create module type with nested bay template using {module} placeholder
  1603. module_type = ModuleType.objects.create(
  1604. manufacturer=manufacturer,
  1605. model='Module with Nested Bays'
  1606. )
  1607. ModuleBayTemplate.objects.create(
  1608. module_type=module_type,
  1609. name='SFP {module}-21',
  1610. label='{module}-21',
  1611. position='21'
  1612. )
  1613. # Create device and install module
  1614. device = Device.objects.create(
  1615. name='Test Device',
  1616. device_type=device_type,
  1617. role=device_role,
  1618. site=site
  1619. )
  1620. module_bay = device.modulebays.get(name='Bay A')
  1621. module = Module.objects.create(
  1622. device=device,
  1623. module_bay=module_bay,
  1624. module_type=module_type
  1625. )
  1626. # Verify nested bay label resolves {module} to parent position
  1627. nested_bay = module.modulebays.get(name='SFP A-21')
  1628. self.assertEqual(nested_bay.label, 'A-21')
  1629. @tag('regression') # #21418
  1630. def test_module_install_nests_module_bay_parent(self):
  1631. """
  1632. A module bay instantiated when a module is installed must be nested under the
  1633. installing module's bay. bulk_create() bypasses ModuleBay.save(), so the parent
  1634. is assigned in ModuleBayTemplate.instantiate(); without it the bay would be left
  1635. a root with a top-level ltree path.
  1636. """
  1637. manufacturer = Manufacturer.objects.first()
  1638. site = Site.objects.first()
  1639. device_role = DeviceRole.objects.first()
  1640. device_type = DeviceType.objects.create(
  1641. manufacturer=manufacturer, model='Chassis with Bay', slug='chassis-with-bay'
  1642. )
  1643. ModuleBayTemplate.objects.create(device_type=device_type, name='Bay A')
  1644. module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module with Sub-bay')
  1645. ModuleBayTemplate.objects.create(module_type=module_type, name='Sub-bay 1')
  1646. device = Device.objects.create(
  1647. name='Nested Bay Parent Device', device_type=device_type, role=device_role, site=site
  1648. )
  1649. parent_bay = device.modulebays.get(name='Bay A')
  1650. module = Module.objects.create(device=device, module_bay=parent_bay, module_type=module_type)
  1651. nested_bay = module.modulebays.get(name='Sub-bay 1')
  1652. self.assertEqual(nested_bay.parent, parent_bay)
  1653. # The ltree path/level must reflect the nesting, not a root placement.
  1654. self.assertEqual(nested_bay.level, parent_bay.level + 1)
  1655. self.assertTrue(str(nested_bay.path).startswith(f'{parent_bay.path}.'))
  1656. @tag('regression') # #20467
  1657. def test_nested_module_bay_position_resolution(self):
  1658. """Test that {module} in a module bay template's position field is resolved when the module is installed."""
  1659. manufacturer = Manufacturer.objects.first()
  1660. site = Site.objects.first()
  1661. device_role = DeviceRole.objects.first()
  1662. device_type = DeviceType.objects.create(
  1663. manufacturer=manufacturer,
  1664. model='Device with Position Test',
  1665. slug='device-with-position-test'
  1666. )
  1667. ModuleBayTemplate.objects.create(
  1668. device_type=device_type,
  1669. name='Slot 1',
  1670. position='1'
  1671. )
  1672. module_type = ModuleType.objects.create(
  1673. manufacturer=manufacturer,
  1674. model='Module with Position Placeholder'
  1675. )
  1676. ModuleBayTemplate.objects.create(
  1677. module_type=module_type,
  1678. name='Sub-bay {module}-1',
  1679. position='{module}-1'
  1680. )
  1681. device = Device.objects.create(
  1682. name='Position Test Device',
  1683. device_type=device_type,
  1684. role=device_role,
  1685. site=site
  1686. )
  1687. module_bay = device.modulebays.get(name='Slot 1')
  1688. module = Module.objects.create(
  1689. device=device,
  1690. module_bay=module_bay,
  1691. module_type=module_type
  1692. )
  1693. nested_bay = module.modulebays.get(name='Sub-bay 1-1')
  1694. self.assertEqual(nested_bay.position, '1-1')
  1695. #
  1696. # Position inheritance tests (#19796)
  1697. #
  1698. def test_position_inheritance_depth_2(self):
  1699. """
  1700. A module bay with position '{module}/2' under a parent bay with position '1'
  1701. should resolve to position '1/2'. A single {module} in the interface template
  1702. should then resolve to '1/2'.
  1703. """
  1704. manufacturer = Manufacturer.objects.first()
  1705. site = Site.objects.first()
  1706. device_role = DeviceRole.objects.first()
  1707. device_type = DeviceType.objects.create(
  1708. manufacturer=manufacturer,
  1709. model='Chassis for Inheritance',
  1710. slug='chassis-for-inheritance'
  1711. )
  1712. ModuleBayTemplate.objects.create(
  1713. device_type=device_type,
  1714. name='Line card slot 1',
  1715. position='1'
  1716. )
  1717. line_card_type = ModuleType.objects.create(
  1718. manufacturer=manufacturer,
  1719. model='Line Card with Inherited Bays'
  1720. )
  1721. ModuleBayTemplate.objects.create(
  1722. module_type=line_card_type,
  1723. name='SFP bay {module}/1',
  1724. position='{module}/1'
  1725. )
  1726. ModuleBayTemplate.objects.create(
  1727. module_type=line_card_type,
  1728. name='SFP bay {module}/2',
  1729. position='{module}/2'
  1730. )
  1731. sfp_type = ModuleType.objects.create(
  1732. manufacturer=manufacturer,
  1733. model='SFP with Inherited Path'
  1734. )
  1735. InterfaceTemplate.objects.create(
  1736. module_type=sfp_type,
  1737. name='SFP {module}',
  1738. type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
  1739. )
  1740. device = Device.objects.create(
  1741. name='Inheritance Chassis',
  1742. device_type=device_type,
  1743. role=device_role,
  1744. site=site
  1745. )
  1746. lc_bay = device.modulebays.get(name='Line card slot 1')
  1747. line_card = Module.objects.create(
  1748. device=device,
  1749. module_bay=lc_bay,
  1750. module_type=line_card_type
  1751. )
  1752. sfp_bay = line_card.modulebays.get(name='SFP bay 1/2')
  1753. sfp_module = Module.objects.create(
  1754. device=device,
  1755. module_bay=sfp_bay,
  1756. module_type=sfp_type
  1757. )
  1758. interface = sfp_module.interfaces.first()
  1759. self.assertEqual(interface.name, 'SFP 1/2')
  1760. def test_position_inheritance_depth_3(self):
  1761. """
  1762. Position inheritance at depth 3: positions should chain through the tree.
  1763. """
  1764. manufacturer = Manufacturer.objects.first()
  1765. site = Site.objects.first()
  1766. device_role = DeviceRole.objects.first()
  1767. device_type = DeviceType.objects.create(
  1768. manufacturer=manufacturer,
  1769. model='Deep Chassis',
  1770. slug='deep-chassis'
  1771. )
  1772. ModuleBayTemplate.objects.create(
  1773. device_type=device_type,
  1774. name='Slot A',
  1775. position='A'
  1776. )
  1777. mid_type = ModuleType.objects.create(
  1778. manufacturer=manufacturer,
  1779. model='Mid Module'
  1780. )
  1781. ModuleBayTemplate.objects.create(
  1782. module_type=mid_type,
  1783. name='Sub {module}-1',
  1784. position='{module}-1'
  1785. )
  1786. leaf_type = ModuleType.objects.create(
  1787. manufacturer=manufacturer,
  1788. model='Leaf Module'
  1789. )
  1790. InterfaceTemplate.objects.create(
  1791. module_type=leaf_type,
  1792. name='Port {module}',
  1793. type=InterfaceTypeChoices.TYPE_1GE_FIXED
  1794. )
  1795. device = Device.objects.create(
  1796. name='Deep Device',
  1797. device_type=device_type,
  1798. role=device_role,
  1799. site=site
  1800. )
  1801. slot_a = device.modulebays.get(name='Slot A')
  1802. mid_module = Module.objects.create(
  1803. device=device,
  1804. module_bay=slot_a,
  1805. module_type=mid_type
  1806. )
  1807. sub_bay = mid_module.modulebays.get(name='Sub A-1')
  1808. self.assertEqual(sub_bay.position, 'A-1')
  1809. leaf_module = Module.objects.create(
  1810. device=device,
  1811. module_bay=sub_bay,
  1812. module_type=leaf_type
  1813. )
  1814. interface = leaf_module.interfaces.first()
  1815. self.assertEqual(interface.name, 'Port A-1')
  1816. def test_position_inheritance_custom_separator(self):
  1817. """
  1818. Users control the separator through the position field template.
  1819. Using '.' instead of '/' should work correctly.
  1820. """
  1821. manufacturer = Manufacturer.objects.first()
  1822. site = Site.objects.first()
  1823. device_role = DeviceRole.objects.first()
  1824. device_type = DeviceType.objects.create(
  1825. manufacturer=manufacturer,
  1826. model='Dot Separator Chassis',
  1827. slug='dot-separator-chassis'
  1828. )
  1829. ModuleBayTemplate.objects.create(
  1830. device_type=device_type,
  1831. name='Bay 1',
  1832. position='1'
  1833. )
  1834. card_type = ModuleType.objects.create(
  1835. manufacturer=manufacturer,
  1836. model='Card with Dot Separator'
  1837. )
  1838. ModuleBayTemplate.objects.create(
  1839. module_type=card_type,
  1840. name='Port {module}.1',
  1841. position='{module}.1'
  1842. )
  1843. sfp_type = ModuleType.objects.create(
  1844. manufacturer=manufacturer,
  1845. model='SFP Dot'
  1846. )
  1847. InterfaceTemplate.objects.create(
  1848. module_type=sfp_type,
  1849. name='eth{module}',
  1850. type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
  1851. )
  1852. device = Device.objects.create(
  1853. name='Dot Device',
  1854. device_type=device_type,
  1855. role=device_role,
  1856. site=site
  1857. )
  1858. bay = device.modulebays.get(name='Bay 1')
  1859. card = Module.objects.create(
  1860. device=device,
  1861. module_bay=bay,
  1862. module_type=card_type
  1863. )
  1864. port_bay = card.modulebays.get(name='Port 1.1')
  1865. sfp = Module.objects.create(
  1866. device=device,
  1867. module_bay=port_bay,
  1868. module_type=sfp_type
  1869. )
  1870. interface = sfp.interfaces.first()
  1871. self.assertEqual(interface.name, 'eth1.1')
  1872. def test_multi_token_backwards_compat(self):
  1873. """
  1874. Multi-token {module}/{module} at matching depth should still resolve
  1875. level-by-level (backwards compatibility).
  1876. """
  1877. manufacturer = Manufacturer.objects.first()
  1878. site = Site.objects.first()
  1879. device_role = DeviceRole.objects.first()
  1880. device_type = DeviceType.objects.create(
  1881. manufacturer=manufacturer,
  1882. model='Multi Token Chassis',
  1883. slug='multi-token-chassis'
  1884. )
  1885. ModuleBayTemplate.objects.create(
  1886. device_type=device_type,
  1887. name='Slot 1',
  1888. position='1'
  1889. )
  1890. card_type = ModuleType.objects.create(
  1891. manufacturer=manufacturer,
  1892. model='Card for Multi Token'
  1893. )
  1894. ModuleBayTemplate.objects.create(
  1895. module_type=card_type,
  1896. name='Port 1',
  1897. position='2'
  1898. )
  1899. iface_type = ModuleType.objects.create(
  1900. manufacturer=manufacturer,
  1901. model='Interface Module Multi Token'
  1902. )
  1903. InterfaceTemplate.objects.create(
  1904. module_type=iface_type,
  1905. name='Gi{module}/{module}',
  1906. type=InterfaceTypeChoices.TYPE_1GE_FIXED
  1907. )
  1908. device = Device.objects.create(
  1909. name='Multi Token Device',
  1910. device_type=device_type,
  1911. role=device_role,
  1912. site=site
  1913. )
  1914. slot = device.modulebays.get(name='Slot 1')
  1915. card = Module.objects.create(
  1916. device=device,
  1917. module_bay=slot,
  1918. module_type=card_type
  1919. )
  1920. port = card.modulebays.get(name='Port 1')
  1921. iface_module = Module.objects.create(
  1922. device=device,
  1923. module_bay=port,
  1924. module_type=iface_type
  1925. )
  1926. interface = iface_module.interfaces.first()
  1927. self.assertEqual(interface.name, 'Gi1/2')
  1928. @tag('regression') # #23478
  1929. def test_module_token_depth_mismatch_in_label_raises_abort_request(self):
  1930. """An unresolvable {module} label aborts a direct module install with AbortRequest."""
  1931. device = Device.objects.first()
  1932. module_bay = ModuleBay.objects.create(device=device, name='Token Label Bay', position='1')
  1933. module_type = ModuleType.objects.create(manufacturer=Manufacturer.objects.first(), model='Token Label Type')
  1934. InterfaceTemplate.objects.create(
  1935. module_type=module_type, name='eth0', label='{module}/{module}', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  1936. )
  1937. module = Module(device=device, module_bay=module_bay, module_type=module_type)
  1938. with self.assertRaises(AbortRequest) as cm:
  1939. module.save()
  1940. self.assertIn('2 placeholders given', cm.exception.message)
  1941. @tag('regression') # #23478
  1942. def test_module_token_without_bay_position_raises_abort_request(self):
  1943. """A {module} name in a bay without a position aborts a direct module install with AbortRequest."""
  1944. device = Device.objects.first()
  1945. module_bay = ModuleBay.objects.create(device=device, name='Token Name Bay')
  1946. module_type = ModuleType.objects.create(manufacturer=Manufacturer.objects.first(), model='Token Name Type')
  1947. InterfaceTemplate.objects.create(
  1948. module_type=module_type, name='eth{module}', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  1949. )
  1950. module = Module(device=device, module_bay=module_bay, module_type=module_type)
  1951. with self.assertRaises(AbortRequest) as cm:
  1952. module.save()
  1953. self.assertIn('no position defined', cm.exception.message)
  1954. @tag('regression') # #23478
  1955. def test_module_token_without_positions_raises_value_error(self):
  1956. """A {module} token with no bay position to resolve against raises ValueError."""
  1957. with self.assertRaises(ValueError) as cm:
  1958. resolve_module_placeholder('eth{module}', [])
  1959. self.assertIn('no position defined', str(cm.exception))
  1960. @tag('regression') # #20912
  1961. def test_module_bay_parent_cleared_when_module_removed(self):
  1962. """Test that the parent field is properly cleared when a module bay's module assignment is removed"""
  1963. device = Device.objects.first()
  1964. manufacturer = Manufacturer.objects.first()
  1965. module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Test Module Type')
  1966. bay1 = ModuleBay.objects.create(device=device, name='Test Bay 1')
  1967. bay2 = ModuleBay.objects.create(device=device, name='Test Bay 2')
  1968. # Install a module in bay1
  1969. module1 = Module.objects.create(device=device, module_bay=bay1, module_type=module_type)
  1970. # Assign bay2 to module1 and verify parent is now set to bay1 (module1's bay)
  1971. bay2.module = module1
  1972. bay2.save()
  1973. bay2.refresh_from_db()
  1974. self.assertEqual(bay2.parent, bay1)
  1975. self.assertEqual(bay2.module, module1)
  1976. # Clear the module assignment (return bay2 to device level) Verify parent is cleared
  1977. bay2.module = None
  1978. bay2.save()
  1979. bay2.refresh_from_db()
  1980. self.assertIsNone(bay2.parent)
  1981. self.assertIsNone(bay2.module)
  1982. def test_module_installation_creates_port_mappings(self):
  1983. """
  1984. Test that installing a module with front/rear port templates correctly
  1985. creates PortMapping instances for the device.
  1986. """
  1987. device = Device.objects.first()
  1988. manufacturer = Manufacturer.objects.first()
  1989. module_bay = ModuleBay.objects.create(device=device, name='Test Bay PortMapping 1')
  1990. # Create a module type with a rear port template
  1991. module_type_with_mappings = ModuleType.objects.create(
  1992. manufacturer=manufacturer,
  1993. model='Module Type With Mappings',
  1994. )
  1995. # Create a rear port template with 12 positions (splice)
  1996. rear_port_template = RearPortTemplate.objects.create(
  1997. module_type=module_type_with_mappings,
  1998. name='Rear Port 1',
  1999. type=PortTypeChoices.TYPE_SPLICE,
  2000. positions=12,
  2001. )
  2002. # Create 12 front port templates mapped to the rear port
  2003. front_port_templates = []
  2004. for i in range(1, 13):
  2005. front_port_template = FrontPortTemplate.objects.create(
  2006. module_type=module_type_with_mappings,
  2007. name=f'port {i}',
  2008. type=PortTypeChoices.TYPE_LC,
  2009. positions=1,
  2010. )
  2011. front_port_templates.append(front_port_template)
  2012. # Create port template mapping
  2013. PortTemplateMapping.objects.create(
  2014. device_type=None,
  2015. module_type=module_type_with_mappings,
  2016. front_port=front_port_template,
  2017. front_port_position=1,
  2018. rear_port=rear_port_template,
  2019. rear_port_position=i,
  2020. )
  2021. # Install the module
  2022. module = Module.objects.create(
  2023. device=device,
  2024. module_bay=module_bay,
  2025. module_type=module_type_with_mappings,
  2026. status=ModuleStatusChoices.STATUS_ACTIVE,
  2027. )
  2028. # Verify that front ports were created
  2029. front_ports = FrontPort.objects.filter(device=device, module=module)
  2030. self.assertEqual(front_ports.count(), 12)
  2031. # Verify that the rear port was created
  2032. rear_ports = RearPort.objects.filter(device=device, module=module)
  2033. self.assertEqual(rear_ports.count(), 1)
  2034. rear_port = rear_ports.first()
  2035. self.assertEqual(rear_port.positions, 12)
  2036. # Verify that port mappings were created
  2037. port_mappings = PortMapping.objects.filter(front_port__module=module)
  2038. self.assertEqual(port_mappings.count(), 12)
  2039. # Verify each mapping is correct
  2040. for i, front_port_template in enumerate(front_port_templates, start=1):
  2041. front_port = FrontPort.objects.get(
  2042. device=device,
  2043. name=front_port_template.name,
  2044. module=module,
  2045. )
  2046. # Check that a mapping exists for this front port
  2047. mapping = PortMapping.objects.get(
  2048. device=device,
  2049. front_port=front_port,
  2050. front_port_position=1,
  2051. )
  2052. self.assertEqual(mapping.rear_port, rear_port)
  2053. self.assertEqual(mapping.front_port_position, 1)
  2054. self.assertEqual(mapping.rear_port_position, i)
  2055. def test_module_installation_without_mappings(self):
  2056. """
  2057. Test that installing a module without port template mappings
  2058. doesn't create any PortMapping instances.
  2059. """
  2060. device = Device.objects.first()
  2061. manufacturer = Manufacturer.objects.first()
  2062. module_bay = ModuleBay.objects.create(device=device, name='Test Bay PortMapping 2')
  2063. # Create a module type without any port template mappings
  2064. module_type_no_mappings = ModuleType.objects.create(
  2065. manufacturer=manufacturer,
  2066. model='Module Type Without Mappings',
  2067. )
  2068. # Create a rear port template
  2069. RearPortTemplate.objects.create(
  2070. module_type=module_type_no_mappings,
  2071. name='Rear Port 1',
  2072. type=PortTypeChoices.TYPE_SPLICE,
  2073. positions=12,
  2074. )
  2075. # Create front port templates but DO NOT create PortTemplateMapping rows
  2076. for i in range(1, 13):
  2077. FrontPortTemplate.objects.create(
  2078. module_type=module_type_no_mappings,
  2079. name=f'port {i}',
  2080. type=PortTypeChoices.TYPE_LC,
  2081. positions=1,
  2082. )
  2083. # Install the module
  2084. module = Module.objects.create(
  2085. device=device,
  2086. module_bay=module_bay,
  2087. module_type=module_type_no_mappings,
  2088. status=ModuleStatusChoices.STATUS_ACTIVE,
  2089. )
  2090. # Verify no port mappings were created for this module
  2091. port_mappings = PortMapping.objects.filter(
  2092. device=device,
  2093. front_port__module=module,
  2094. front_port_position=1,
  2095. )
  2096. self.assertEqual(port_mappings.count(), 0)
  2097. self.assertEqual(FrontPort.objects.filter(module=module).count(), 12)
  2098. self.assertEqual(RearPort.objects.filter(module=module).count(), 1)
  2099. self.assertEqual(PortMapping.objects.filter(front_port__module=module).count(), 0)
  2100. def test_cannot_install_module_in_disabled_bay(self):
  2101. """
  2102. Test that a Module cannot be installed into a disabled ModuleBay.
  2103. """
  2104. device = Device.objects.first()
  2105. manufacturer = Manufacturer.objects.first()
  2106. module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Test Module Type Disabled')
  2107. # Create a disabled module bay
  2108. disabled_bay = ModuleBay.objects.create(device=device, name='Disabled Bay', enabled=False)
  2109. # Attempt to install a module into the disabled bay
  2110. module = Module(device=device, module_bay=disabled_bay, module_type=module_type)
  2111. with self.assertRaises(ValidationError) as cm:
  2112. module.clean()
  2113. self.assertIn('module_bay', cm.exception.message_dict)
  2114. self.assertIn('disabled module bay', str(cm.exception.message_dict['module_bay']))
  2115. class ModuleBayTypeCompatibilityTestCase(TestCase):
  2116. """Tests for bay type compatibility: Module.is_bay_compatible, ModuleType.get_incompatible_modules,
  2117. ModuleBay.is_module_compatible, and Module.clean() validation."""
  2118. @classmethod
  2119. def setUpTestData(cls):
  2120. site = Site.objects.create(name='Compat Site', slug='compat-site')
  2121. manufacturer = Manufacturer.objects.create(name='Compat Mfr', slug='compat-mfr')
  2122. device_type = DeviceType.objects.create(
  2123. manufacturer=manufacturer, model='Compat Device Type', slug='compat-dt'
  2124. )
  2125. device_role = DeviceRole.objects.create(name='Compat Role', slug='compat-role')
  2126. cls.device = Device.objects.create(
  2127. name='Compat Device', device_type=device_type, role=device_role, site=site
  2128. )
  2129. cls.bay_type_a = ModuleBayType.objects.create(
  2130. manufacturer=manufacturer, name='Bay Type A', slug='bay-type-a'
  2131. )
  2132. cls.bay_type_b = ModuleBayType.objects.create(
  2133. manufacturer=manufacturer, name='Bay Type B', slug='bay-type-b'
  2134. )
  2135. cls.module_type_a = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type A')
  2136. cls.module_type_a.module_bay_types.set([cls.bay_type_a])
  2137. cls.module_type_b = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type B')
  2138. cls.module_type_b.module_bay_types.set([cls.bay_type_b])
  2139. cls.module_type_any = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type Any')
  2140. def _make_bay(self, name, *bay_types):
  2141. bay = ModuleBay.objects.create(device=self.device, name=name)
  2142. if bay_types:
  2143. bay.module_bay_types.set(bay_types)
  2144. return bay
  2145. def _install(self, bay, module_type):
  2146. return Module.objects.create(device=self.device, module_bay=bay, module_type=module_type)
  2147. # --- Module.clean() validation ---
  2148. def test_clean_blocks_incompatible_install(self):
  2149. """Module.clean() raises ValidationError when bay and module type have disjoint type sets."""
  2150. bay = self._make_bay('Bay Compat 1', self.bay_type_b)
  2151. module = Module(device=self.device, module_bay=bay, module_type=self.module_type_a)
  2152. with self.assertRaises(ValidationError):
  2153. module.clean()
  2154. def test_clean_allows_compatible_install(self):
  2155. """Module.clean() passes when bay and module type share at least one bay type."""
  2156. bay = self._make_bay('Bay Compat 2', self.bay_type_a)
  2157. module = Module(device=self.device, module_bay=bay, module_type=self.module_type_a)
  2158. module.clean() # should not raise
  2159. def test_clean_allows_unconstrained_module_type(self):
  2160. """Module.clean() passes when the module type has no bay type constraints."""
  2161. bay = self._make_bay('Bay Compat 3', self.bay_type_a)
  2162. module = Module(device=self.device, module_bay=bay, module_type=self.module_type_any)
  2163. module.clean() # should not raise
  2164. def test_clean_allows_unconstrained_bay(self):
  2165. """Module.clean() passes when the bay has no bay type constraints."""
  2166. bay = self._make_bay('Bay Compat 4')
  2167. module = Module(device=self.device, module_bay=bay, module_type=self.module_type_a)
  2168. module.clean() # should not raise
  2169. # --- Module.is_bay_compatible ---
  2170. def test_is_bay_compatible_false_when_disjoint(self):
  2171. """Module.is_bay_compatible returns False when bay and module type sets are disjoint."""
  2172. bay = self._make_bay('Bay Compat 5', self.bay_type_b)
  2173. # Bypass clean() to create an incompatible installation for testing the property
  2174. module = Module.objects.create(device=self.device, module_bay=bay, module_type=self.module_type_a)
  2175. module.refresh_from_db()
  2176. self.assertFalse(module.is_bay_compatible)
  2177. def test_is_bay_compatible_true_when_overlapping(self):
  2178. """Module.is_bay_compatible returns True when bay and module type share a bay type."""
  2179. bay = self._make_bay('Bay Compat 6', self.bay_type_a)
  2180. module = self._install(bay, self.module_type_a)
  2181. self.assertTrue(module.is_bay_compatible)
  2182. def test_is_bay_compatible_true_when_module_type_unconstrained(self):
  2183. """Module.is_bay_compatible returns True when module type has no constraints."""
  2184. bay = self._make_bay('Bay Compat 7', self.bay_type_a)
  2185. module = self._install(bay, self.module_type_any)
  2186. self.assertTrue(module.is_bay_compatible)
  2187. def test_is_bay_compatible_true_when_bay_unconstrained(self):
  2188. """Module.is_bay_compatible returns True when bay has no constraints."""
  2189. bay = self._make_bay('Bay Compat 8')
  2190. module = self._install(bay, self.module_type_a)
  2191. self.assertTrue(module.is_bay_compatible)
  2192. # --- ModuleType.get_incompatible_modules ---
  2193. def test_get_incompatible_modules_returns_incompatible(self):
  2194. """ModuleType.get_incompatible_modules includes modules in bays with disjoint type sets."""
  2195. bay = self._make_bay('Bay Compat 9', self.bay_type_b)
  2196. module = Module.objects.create(device=self.device, module_bay=bay, module_type=self.module_type_a)
  2197. qs = self.module_type_a.get_incompatible_modules()
  2198. self.assertIn(module, qs)
  2199. def test_get_incompatible_modules_excludes_compatible(self):
  2200. """ModuleType.get_incompatible_modules excludes modules in bays with matching type sets."""
  2201. bay = self._make_bay('Bay Compat 10', self.bay_type_a)
  2202. module = self._install(bay, self.module_type_a)
  2203. qs = self.module_type_a.get_incompatible_modules()
  2204. self.assertNotIn(module, qs)
  2205. def test_get_incompatible_modules_excludes_unconstrained_bay(self):
  2206. """ModuleType.get_incompatible_modules excludes modules in unconstrained bays."""
  2207. bay = self._make_bay('Bay Compat 11')
  2208. module = self._install(bay, self.module_type_a)
  2209. qs = self.module_type_a.get_incompatible_modules()
  2210. self.assertNotIn(module, qs)
  2211. def test_get_incompatible_modules_empty_when_type_unconstrained(self):
  2212. """ModuleType.get_incompatible_modules returns empty queryset when type has no constraints."""
  2213. bay = self._make_bay('Bay Compat 12', self.bay_type_a)
  2214. self._install(bay, self.module_type_any)
  2215. qs = self.module_type_any.get_incompatible_modules()
  2216. self.assertFalse(qs.exists())
  2217. # --- ModuleBay.is_module_compatible ---
  2218. def test_bay_is_module_compatible_false_when_disjoint(self):
  2219. """ModuleBay.is_module_compatible returns False when bay and installed module sets are disjoint."""
  2220. bay = self._make_bay('Bay Compat 13', self.bay_type_b)
  2221. Module.objects.create(device=self.device, module_bay=bay, module_type=self.module_type_a)
  2222. bay.refresh_from_db()
  2223. self.assertFalse(bay.is_module_compatible)
  2224. def test_bay_is_module_compatible_true_when_overlapping(self):
  2225. """ModuleBay.is_module_compatible returns True when sets overlap."""
  2226. bay = self._make_bay('Bay Compat 14', self.bay_type_a)
  2227. self._install(bay, self.module_type_a)
  2228. bay.refresh_from_db()
  2229. self.assertTrue(bay.is_module_compatible)
  2230. def test_bay_is_module_compatible_true_when_no_module(self):
  2231. """ModuleBay.is_module_compatible returns True when nothing is installed."""
  2232. bay = self._make_bay('Bay Compat 15', self.bay_type_a)
  2233. self.assertTrue(bay.is_module_compatible)
  2234. def test_bay_is_module_compatible_true_when_bay_unconstrained(self):
  2235. """ModuleBay.is_module_compatible returns True when bay has no constraints."""
  2236. bay = self._make_bay('Bay Compat 16')
  2237. Module.objects.create(device=self.device, module_bay=bay, module_type=self.module_type_a)
  2238. bay.refresh_from_db()
  2239. self.assertTrue(bay.is_module_compatible)
  2240. class CableTestCase(TestCase):
  2241. @classmethod
  2242. def setUpTestData(cls):
  2243. site = Site.objects.create(name='Test Site 1', slug='test-site-1')
  2244. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  2245. devicetype = DeviceType.objects.create(
  2246. manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
  2247. )
  2248. role = DeviceRole.objects.create(
  2249. name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
  2250. )
  2251. device1 = Device.objects.create(
  2252. device_type=devicetype, role=role, name='TestDevice1', site=site
  2253. )
  2254. device2 = Device.objects.create(
  2255. device_type=devicetype, role=role, name='TestDevice2', site=site
  2256. )
  2257. interfaces = (
  2258. Interface(device=device1, name='eth0'),
  2259. Interface(device=device2, name='eth0'),
  2260. Interface(device=device2, name='eth1'),
  2261. )
  2262. Interface.objects.bulk_create(interfaces)
  2263. Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[1]]).save()
  2264. PowerPort.objects.create(device=device2, name='psu1')
  2265. patch_panel = Device.objects.create(
  2266. device_type=devicetype, role=role, name='TestPatchPanel', site=site
  2267. )
  2268. rear_ports = (
  2269. RearPort(device=patch_panel, name='RP1', type='8p8c'),
  2270. RearPort(device=patch_panel, name='RP2', type='8p8c', positions=2),
  2271. RearPort(device=patch_panel, name='RP3', type='8p8c', positions=3),
  2272. RearPort(device=patch_panel, name='RP4', type='8p8c', positions=3),
  2273. )
  2274. RearPort.objects.bulk_create(rear_ports)
  2275. front_ports = (
  2276. FrontPort(device=patch_panel, name='FP1', type='8p8c'),
  2277. FrontPort(device=patch_panel, name='FP2', type='8p8c'),
  2278. FrontPort(device=patch_panel, name='FP3', type='8p8c'),
  2279. FrontPort(device=patch_panel, name='FP4', type='8p8c'),
  2280. )
  2281. FrontPort.objects.bulk_create(front_ports)
  2282. PortMapping.objects.bulk_create([
  2283. PortMapping(device=patch_panel, front_port=front_ports[0], rear_port=rear_ports[0]),
  2284. PortMapping(device=patch_panel, front_port=front_ports[1], rear_port=rear_ports[1]),
  2285. PortMapping(device=patch_panel, front_port=front_ports[2], rear_port=rear_ports[2]),
  2286. PortMapping(device=patch_panel, front_port=front_ports[3], rear_port=rear_ports[3]),
  2287. ])
  2288. provider = Provider.objects.create(name='Provider 1', slug='provider-1')
  2289. provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider)
  2290. circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
  2291. circuit1 = Circuit.objects.create(provider=provider, type=circuittype, cid='1')
  2292. circuit2 = Circuit.objects.create(provider=provider, type=circuittype, cid='2')
  2293. CircuitTermination.objects.create(circuit=circuit1, termination=site, term_side='A')
  2294. CircuitTermination.objects.create(circuit=circuit1, termination=site, term_side='Z')
  2295. CircuitTermination.objects.create(circuit=circuit2, termination=provider_network, term_side='A')
  2296. def test_cable_creation(self):
  2297. """
  2298. When a new Cable is created, it must be cached on either termination point.
  2299. """
  2300. interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
  2301. interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
  2302. cable = Cable.objects.first()
  2303. self.assertEqual(interface1.cable, cable)
  2304. self.assertEqual(interface2.cable, cable)
  2305. self.assertEqual(interface1.cable_end, 'A')
  2306. self.assertEqual(interface2.cable_end, 'B')
  2307. self.assertEqual(interface1.link_peers, [interface2])
  2308. self.assertEqual(interface2.link_peers, [interface1])
  2309. def test_cable_deletion(self):
  2310. """
  2311. When a Cable is deleted, the `cable` field on its termination points must be nullified. The str() method
  2312. should still return the PK of the string even after being nullified.
  2313. """
  2314. interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
  2315. interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
  2316. cable = Cable.objects.first()
  2317. cable.delete()
  2318. self.assertIsNone(cable.pk)
  2319. self.assertNotEqual(str(cable), '#None')
  2320. interface1 = Interface.objects.get(pk=interface1.pk)
  2321. self.assertIsNone(interface1.cable)
  2322. self.assertListEqual(interface1.link_peers, [])
  2323. interface2 = Interface.objects.get(pk=interface2.pk)
  2324. self.assertIsNone(interface2.cable)
  2325. self.assertListEqual(interface2.link_peers, [])
  2326. def test_cable_validates_same_parent_object(self):
  2327. """
  2328. The clean method should ensure that all terminations at either end of a Cable belong to the same parent object.
  2329. """
  2330. interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
  2331. powerport1 = PowerPort.objects.get(device__name='TestDevice2', name='psu1')
  2332. cable = Cable(a_terminations=[interface1], b_terminations=[powerport1])
  2333. with self.assertRaises(ValidationError):
  2334. cable.clean()
  2335. def test_cable_validates_same_type(self):
  2336. """
  2337. The clean method should ensure that all terminations at either end of a Cable are of the same type.
  2338. """
  2339. interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
  2340. frontport1 = FrontPort.objects.get(device__name='TestPatchPanel', name='FP1')
  2341. rearport1 = RearPort.objects.get(device__name='TestPatchPanel', name='RP1')
  2342. cable = Cable(a_terminations=[frontport1, rearport1], b_terminations=[interface1])
  2343. with self.assertRaises(ValidationError):
  2344. cable.clean()
  2345. def test_cable_validates_compatible_types(self):
  2346. """
  2347. The clean method should have a check to ensure only compatible port types can be connected by a cable
  2348. """
  2349. interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
  2350. powerport1 = PowerPort.objects.get(device__name='TestDevice2', name='psu1')
  2351. # An interface cannot be connected to a power port, for example
  2352. cable = Cable(a_terminations=[interface1], b_terminations=[powerport1])
  2353. with self.assertRaises(ValidationError):
  2354. cable.clean()
  2355. def test_cable_cannot_terminate_to_a_provider_network_circuittermination(self):
  2356. """
  2357. Neither side of a cable can be terminated to a CircuitTermination which is attached to a ProviderNetwork
  2358. """
  2359. interface3 = Interface.objects.get(device__name='TestDevice2', name='eth1')
  2360. circuittermination3 = CircuitTermination.objects.get(circuit__cid='2', term_side='A')
  2361. cable = Cable(a_terminations=[interface3], b_terminations=[circuittermination3])
  2362. with self.assertRaises(ValidationError):
  2363. cable.clean()
  2364. def test_cable_cannot_terminate_to_a_virtual_interface(self):
  2365. """
  2366. A cable cannot terminate to a virtual interface
  2367. """
  2368. device1 = Device.objects.get(name='TestDevice1')
  2369. interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
  2370. virtual_interface = Interface(device=device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL)
  2371. cable = Cable(a_terminations=[interface2], b_terminations=[virtual_interface])
  2372. with self.assertRaises(ValidationError):
  2373. cable.clean()
  2374. def test_cable_cannot_terminate_to_a_wireless_interface(self):
  2375. """
  2376. A cable cannot terminate to a wireless interface
  2377. """
  2378. device1 = Device.objects.get(name='TestDevice1')
  2379. interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
  2380. wireless_interface = Interface(device=device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A)
  2381. cable = Cable(a_terminations=[interface2], b_terminations=[wireless_interface])
  2382. with self.assertRaises(ValidationError):
  2383. cable.clean()
  2384. @tag('regression')
  2385. def test_cable_cannot_terminate_to_a_cellular_interface(self):
  2386. """
  2387. A cable cannot terminate to a cellular interface
  2388. """
  2389. device1 = Device.objects.get(name='TestDevice1')
  2390. interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
  2391. cellular_interface = Interface(device=device1, name="W1", type=InterfaceTypeChoices.TYPE_LTE)
  2392. cable = Cable(a_terminations=[interface2], b_terminations=[cellular_interface])
  2393. with self.assertRaises(ValidationError):
  2394. cable.clean()
  2395. def test_cannot_cable_to_mark_connected(self):
  2396. """
  2397. Test that a cable cannot be connected to an interface marked as connected.
  2398. """
  2399. device1 = Device.objects.get(name='TestDevice1')
  2400. interface1 = Interface.objects.get(device__name='TestDevice2', name='eth1')
  2401. mark_connected_interface = Interface(device=device1, name='mark_connected1', mark_connected=True)
  2402. cable = Cable(a_terminations=[mark_connected_interface], b_terminations=[interface1])
  2403. with self.assertRaises(ValidationError):
  2404. cable.clean()
  2405. def test_partial_save_does_not_apply_an_unwritten_profile(self):
  2406. """
  2407. A save excluding profile must leave the terminations alone but keep the change pending.
  2408. """
  2409. cable = Cable.objects.first()
  2410. interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
  2411. termination_pks = set(CableTermination.objects.filter(cable=cable).values_list('pk', flat=True))
  2412. cable.profile = CableProfileChoices.SINGLE_1C1P
  2413. cable.save(update_fields=['label'])
  2414. interface1.refresh_from_db()
  2415. # Requery rather than refresh, so the pending profile stays on the instance under test
  2416. self.assertEqual(Cable.objects.get(pk=cable.pk).profile, '')
  2417. self.assertIsNone(interface1.cable_connector)
  2418. self.assertEqual(
  2419. set(CableTermination.objects.filter(cable=cable).values_list('pk', flat=True)),
  2420. termination_pks
  2421. )
  2422. # _orig_profile was not advanced, so the pending change still applies here
  2423. cable.save()
  2424. interface1.refresh_from_db()
  2425. self.assertEqual(Cable.objects.get(pk=cable.pk).profile, CableProfileChoices.SINGLE_1C1P)
  2426. self.assertEqual(interface1.cable_connector, 1)
  2427. def test_cable_profile_change_preserves_terminations(self):
  2428. """
  2429. When a Cable's profile is changed via save() without explicitly setting terminations (as happens during
  2430. bulk edit), the existing termination points must be preserved.
  2431. """
  2432. cable = Cable.objects.first()
  2433. interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
  2434. interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
  2435. # Verify initial state: cable has terminations and no profile
  2436. self.assertEqual(cable.profile, '')
  2437. self.assertEqual(CableTermination.objects.filter(cable=cable).count(), 2)
  2438. # Simulate what bulk edit does: load the cable from DB, set profile via setattr, and save.
  2439. # Crucially, do NOT set a_terminations or b_terminations on the instance.
  2440. cable_from_db = Cable.objects.get(pk=cable.pk)
  2441. cable_from_db.profile = CableProfileChoices.SINGLE_1C1P
  2442. cable_from_db.save()
  2443. # Verify terminations are preserved
  2444. self.assertEqual(CableTermination.objects.filter(cable=cable).count(), 2)
  2445. # Verify the correct interfaces are still terminated
  2446. cable_from_db.refresh_from_db()
  2447. a_terms = [ct.termination for ct in CableTermination.objects.filter(cable=cable, cable_end='A')]
  2448. b_terms = [ct.termination for ct in CableTermination.objects.filter(cable=cable, cable_end='B')]
  2449. self.assertEqual(a_terms, [interface1])
  2450. self.assertEqual(b_terms, [interface2])
  2451. def _create_multiposition_cable(self, count=4):
  2452. """
  2453. Create a cable with `count` terminations at either end, using the 4C1P trunk profile. Returns
  2454. the cable and its A & B terminating objects.
  2455. """
  2456. device1 = Device.objects.get(name='TestDevice1')
  2457. device2 = Device.objects.get(name='TestDevice2')
  2458. a_interfaces = [
  2459. Interface.objects.create(device=device1, name=f'trunk-a{i}') for i in range(count)
  2460. ]
  2461. b_interfaces = [
  2462. Interface.objects.create(device=device2, name=f'trunk-b{i}') for i in range(count)
  2463. ]
  2464. cable = Cable(
  2465. a_terminations=a_interfaces,
  2466. b_terminations=b_interfaces,
  2467. profile=CableProfileChoices.TRUNK_4C1P,
  2468. )
  2469. cable.save()
  2470. return cable, a_interfaces, b_interfaces
  2471. def _get_connectors(self, cable, cable_end):
  2472. return [
  2473. (ct.connector, ct.termination) for ct in cable.terminations.filter(cable_end=cable_end)
  2474. ]
  2475. def test_reordering_terminations_reassigns_connectors(self):
  2476. """
  2477. A Cable's terminations are assigned to connectors in the order given, so reordering them must
  2478. rewire the Cable even though its set of terminating objects is unchanged.
  2479. """
  2480. cable, a_interfaces, b_interfaces = self._create_multiposition_cable()
  2481. self.assertEqual(
  2482. self._get_connectors(cable, 'B'), list(enumerate(b_interfaces, start=1))
  2483. )
  2484. # Reverse the B side terminations
  2485. cable = Cable.objects.get(pk=cable.pk)
  2486. cable.b_terminations = list(reversed(b_interfaces))
  2487. cable.save()
  2488. self.assertEqual(
  2489. self._get_connectors(cable, 'B'), list(enumerate(reversed(b_interfaces), start=1))
  2490. )
  2491. # The A side, which was not modified, must be left alone
  2492. self.assertEqual(
  2493. self._get_connectors(cable, 'A'), list(enumerate(a_interfaces, start=1))
  2494. )
  2495. # The reordering must be reflected in the terminations' link peers
  2496. self.assertEqual(
  2497. Interface.objects.get(pk=a_interfaces[0].pk).link_peers, [b_interfaces[-1]]
  2498. )
  2499. def test_removing_a_termination_reassigns_connectors(self):
  2500. """
  2501. Removing a termination from the middle of a Cable's list must renumber the connectors of those
  2502. which follow it.
  2503. """
  2504. cable, a_interfaces, b_interfaces = self._create_multiposition_cable()
  2505. cable = Cable.objects.get(pk=cable.pk)
  2506. cable.b_terminations = [b_interfaces[0], b_interfaces[2], b_interfaces[3]]
  2507. cable.save()
  2508. self.assertEqual(
  2509. self._get_connectors(cable, 'B'),
  2510. [(1, b_interfaces[0]), (2, b_interfaces[2]), (3, b_interfaces[3])]
  2511. )
  2512. def test_appending_a_termination_preserves_connectors(self):
  2513. """
  2514. Appending a termination must not disturb the connectors already assigned to the terminations
  2515. which precede it.
  2516. """
  2517. cable, a_interfaces, b_interfaces = self._create_multiposition_cable(count=3)
  2518. original_cts = {ct.termination: ct.pk for ct in cable.terminations.filter(cable_end='B')}
  2519. new_interface = Interface.objects.create(device=Device.objects.get(name='TestDevice2'), name='trunk-b3')
  2520. cable = Cable.objects.get(pk=cable.pk)
  2521. cable.b_terminations = [*b_interfaces, new_interface]
  2522. cable.save()
  2523. self.assertEqual(
  2524. self._get_connectors(cable, 'B'), list(enumerate([*b_interfaces, new_interface], start=1))
  2525. )
  2526. # The existing CableTerminations must not have been recreated
  2527. for ct in cable.terminations.filter(cable_end='B'):
  2528. if ct.termination in original_cts:
  2529. self.assertEqual(ct.pk, original_cts[ct.termination])
  2530. @tag('regression') # #23094
  2531. def test_clearing_an_end_of_a_fresh_instance_removes_its_terminations(self):
  2532. """
  2533. Assigning an empty list to an end of a freshly loaded cable must delete that end's terminations only.
  2534. """
  2535. interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
  2536. interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
  2537. cable_pk = Cable.objects.first().pk
  2538. for attr, kept_side, cleared, kept in (
  2539. ('a_terminations', CableEndChoices.SIDE_B, interface1, interface2),
  2540. ('b_terminations', CableEndChoices.SIDE_A, interface2, interface1),
  2541. ):
  2542. with self.subTest(attr=attr):
  2543. cable = Cable.objects.get(pk=cable_pk)
  2544. kept_row_pk = CableTermination.objects.get(cable=cable, cable_end=kept_side).pk
  2545. setattr(cable, attr, [])
  2546. self.assertTrue(cable._terminations_modified)
  2547. cable.full_clean()
  2548. cable.save()
  2549. self.assertEqual(
  2550. list(CableTermination.objects.filter(cable=cable).values_list('pk', 'cable_end')),
  2551. [(kept_row_pk, kept_side)]
  2552. )
  2553. cleared.refresh_from_db()
  2554. self.assertIsNone(cleared.cable)
  2555. self.assertIsNone(cleared._path_id)
  2556. kept.refresh_from_db()
  2557. self.assertEqual(kept.cable, cable)
  2558. self.assertFalse(kept._path.is_complete)
  2559. # Reconnect the cleared end so the other side starts from a complete cable
  2560. cable = Cable.objects.get(pk=cable_pk)
  2561. setattr(cable, attr, [cleared])
  2562. cable.save()
  2563. # A profiled cable keeps the other end's rows and connectors
  2564. cable, a_interfaces, b_interfaces = self._create_multiposition_cable()
  2565. a_row_pks = set(cable.terminations.filter(cable_end=CableEndChoices.SIDE_A).values_list('pk', flat=True))
  2566. cable = Cable.objects.get(pk=cable.pk)
  2567. cable.b_terminations = []
  2568. cable.full_clean()
  2569. cable.save()
  2570. self.assertEqual(self._get_connectors(cable, 'B'), [])
  2571. self.assertEqual(self._get_connectors(cable, 'A'), list(enumerate(a_interfaces, start=1)))
  2572. self.assertEqual(
  2573. set(cable.terminations.filter(cable_end=CableEndChoices.SIDE_A).values_list('pk', flat=True)), a_row_pks
  2574. )
  2575. @tag('regression') # #23094
  2576. def test_reassigning_a_mutated_termination_list_flags_a_change(self):
  2577. """
  2578. Appending to a retrieved termination list and assigning it back must be applied on save.
  2579. """
  2580. cable = Cable.objects.first()
  2581. interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
  2582. interface3 = Interface.objects.create(device=interface1.device, name='eth1')
  2583. # Assigning warms the A end's cache and the save resets the flag
  2584. cable.a_terminations = [interface1]
  2585. cable.save()
  2586. self.assertFalse(cable._terminations_modified)
  2587. terminations = cable.a_terminations
  2588. terminations.append(interface3)
  2589. cable.a_terminations = terminations
  2590. self.assertTrue(cable._terminations_modified)
  2591. cable.full_clean()
  2592. cable.save()
  2593. self.assertEqual(
  2594. [ct.termination for ct in cable.terminations.filter(cable_end=CableEndChoices.SIDE_A)],
  2595. [interface1, interface3]
  2596. )
  2597. @tag('regression') # #23094
  2598. def test_assigned_termination_list_is_copied(self):
  2599. """
  2600. Mutating a list after assigning it to an end must not change the cable's terminations.
  2601. """
  2602. interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
  2603. interface3 = Interface.objects.get(device__name='TestDevice2', name='eth1')
  2604. cable = Cable.objects.first()
  2605. terminations = [interface1]
  2606. cable.a_terminations = terminations
  2607. terminations.append(interface3)
  2608. self.assertEqual(cable.a_terminations, [interface1])
  2609. @tag('regression') # #23094
  2610. def test_assigning_an_iterable_stores_a_list_of_its_terminations(self):
  2611. """
  2612. Single-pass iterables and querysets assigned to an end must be stored as a list of the resolved terminations.
  2613. """
  2614. interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
  2615. interface3 = Interface.objects.get(device__name='TestDevice2', name='eth1')
  2616. row_pk = CableTermination.objects.get(cable_end=CableEndChoices.SIDE_A).pk
  2617. for label, value, expected in (
  2618. ('iterator of objects', iter([interface1, interface3]), [interface1, interface3]),
  2619. ('iterator of IDs', iter([row_pk]), [interface1]),
  2620. ('queryset', Interface.objects.filter(pk=interface1.pk), [interface1]),
  2621. ):
  2622. with self.subTest(value=label):
  2623. cable = Cable.objects.first()
  2624. cable.a_terminations = value
  2625. self.assertIsInstance(cable._a_terminations, list)
  2626. self.assertEqual(cable.a_terminations, expected)
  2627. def test_assigning_terminations_to_a_fresh_instance_flags_a_change(self):
  2628. """
  2629. Assigning an end of a freshly loaded cable must flag a change, even when it repeats the stored terminations.
  2630. """
  2631. interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
  2632. interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
  2633. data = Cable.objects.first().serialize_object()
  2634. # Change replay writes the CableTermination rows first and relies on this to retrace the paths
  2635. for attr, interface in (('a_terminations', interface1), ('b_terminations', interface2)):
  2636. for label, value in (('objects', [interface]), ('IDs', data[attr])):
  2637. with self.subTest(attr=attr, value=label):
  2638. cable = Cable.objects.first()
  2639. setattr(cable, attr, value)
  2640. self.assertEqual(getattr(cable, attr), [interface])
  2641. self.assertTrue(cable._terminations_modified)
  2642. def test_reassigning_an_unchanged_end_on_a_warm_instance_does_not_flag_a_change(self):
  2643. """
  2644. Assigning the value an end already holds in memory must not flag a change or clear a pending one.
  2645. """
  2646. interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
  2647. interface3 = Interface.objects.get(device__name='TestDevice2', name='eth1')
  2648. # Assigning warms the A end's cache and the save resets the flag
  2649. cable = Cable.objects.first()
  2650. cable.a_terminations = [interface1]
  2651. cable.save()
  2652. self.assertFalse(cable._terminations_modified)
  2653. termination_pks = set(CableTermination.objects.filter(cable=cable).values_list('pk', flat=True))
  2654. path_pks = set(CablePath.objects.filter(_nodes__contains=cable).values_list('pk', flat=True))
  2655. cable.a_terminations = [interface1]
  2656. self.assertFalse(cable._terminations_modified)
  2657. cable.save()
  2658. self.assertEqual(
  2659. set(CableTermination.objects.filter(cable=cable).values_list('pk', flat=True)),
  2660. termination_pks
  2661. )
  2662. self.assertEqual(set(CablePath.objects.filter(_nodes__contains=cable).values_list('pk', flat=True)), path_pks)
  2663. cable.b_terminations = [interface3]
  2664. cable.a_terminations = [interface1]
  2665. self.assertTrue(cable._terminations_modified)
  2666. @tag('regression') # #21498
  2667. def test_path_refreshes_replaced_cablepath_reference(self):
  2668. """
  2669. An already-instantiated interface should refresh its denormalized
  2670. `_path` foreign key when the referenced CablePath row has been
  2671. replaced in the database.
  2672. """
  2673. stale_interface = Interface.objects.get(device__name='TestDevice1', name='eth0')
  2674. old_path = CablePath.objects.get(pk=stale_interface._path_id)
  2675. new_path = CablePath(
  2676. path=old_path.path,
  2677. is_active=old_path.is_active,
  2678. is_complete=old_path.is_complete,
  2679. is_split=old_path.is_split,
  2680. )
  2681. old_path_id = old_path.pk
  2682. old_path.delete()
  2683. new_path.save()
  2684. # The old CablePath no longer exists
  2685. self.assertFalse(CablePath.objects.filter(pk=old_path_id).exists())
  2686. # The already-instantiated interface still points to the deleted path
  2687. # until the accessor refreshes `_path` from the database.
  2688. self.assertEqual(stale_interface._path_id, old_path_id)
  2689. self.assertEqual(stale_interface.path.pk, new_path.pk)
  2690. @tag('regression') # #21498
  2691. def test_serialize_for_event_handles_stale_cablepath_reference_after_retermination(self):
  2692. """
  2693. Serializing an interface whose previously cached `_path` row has been
  2694. deleted during cable retermination must not raise.
  2695. """
  2696. stale_interface = Interface.objects.get(device__name='TestDevice2', name='eth0')
  2697. old_path_id = stale_interface._path_id
  2698. new_peer = Interface.objects.get(device__name='TestDevice2', name='eth1')
  2699. cable = stale_interface.cable
  2700. self.assertIsNotNone(cable)
  2701. self.assertIsNotNone(old_path_id)
  2702. self.assertEqual(stale_interface.cable_end, 'B')
  2703. cable.b_terminations = [new_peer]
  2704. cable.save()
  2705. # The old CablePath was deleted during retrace.
  2706. self.assertFalse(CablePath.objects.filter(pk=old_path_id).exists())
  2707. # The stale in-memory instance still holds the deleted FK value.
  2708. self.assertEqual(stale_interface._path_id, old_path_id)
  2709. # Serialization must not raise ObjectDoesNotExist. Because this interface
  2710. # was the former B-side termination, it is now disconnected.
  2711. data = serialize_for_event(stale_interface)
  2712. self.assertIsNone(data['connected_endpoints'])
  2713. self.assertIsNone(data['connected_endpoints_type'])
  2714. self.assertFalse(data['connected_endpoints_reachable'])
  2715. @tag('regression') # #21338
  2716. def test_path_refreshes_unset_cablepath_reference(self):
  2717. """
  2718. An endpoint instance saved during cable creation, before path tracing,
  2719. should resolve its path and connected endpoints.
  2720. The stale-instance preconditions rely on Cable.save() saving each
  2721. CableTermination (which re-saves the endpoint) before trace_paths
  2722. creates the CablePath records.
  2723. """
  2724. device = Device.objects.get(name='TestDevice2')
  2725. interface_a = Interface.objects.create(device=device, name='eth2')
  2726. interface_b = Interface.objects.create(device=device, name='eth3')
  2727. # Capture the instances handed to the event machinery on save
  2728. saved_instances = []
  2729. def capture(sender, instance, **kwargs):
  2730. saved_instances.append(instance)
  2731. post_save.connect(capture, sender=Interface)
  2732. try:
  2733. Cable(a_terminations=[interface_a], b_terminations=[interface_b]).save()
  2734. finally:
  2735. post_save.disconnect(capture, sender=Interface)
  2736. self.assertEqual(len(saved_instances), 2)
  2737. captured_a = next(i for i in saved_instances if i.pk == interface_a.pk)
  2738. captured_b = next(i for i in saved_instances if i.pk == interface_b.pk)
  2739. # The captured instances predate path tracing: cabled, but no path yet
  2740. self.assertIsNotNone(captured_a.cable_id)
  2741. self.assertIsNone(captured_a._path_id)
  2742. self.assertIsNone(captured_b._path_id)
  2743. # The accessor must repair the unset denormalized reference
  2744. self.assertIsNotNone(captured_a.path)
  2745. self.assertEqual(captured_a.connected_endpoints, [interface_b])
  2746. # Serialization as performed by the event queue must see the peer
  2747. data = serialize_for_event(captured_b)
  2748. self.assertEqual([endpoint['id'] for endpoint in data['connected_endpoints']], [interface_a.pk])
  2749. self.assertEqual([peer['id'] for peer in data['link_peers']], [interface_a.pk])
  2750. self.assertTrue(data['connected_endpoints_reachable'])
  2751. def test_path_returns_none_for_unsaved_endpoint(self):
  2752. """
  2753. An unsaved endpoint with a link assigned should report no path rather
  2754. than attempting a database refresh.
  2755. """
  2756. device = Device.objects.get(name='TestDevice1')
  2757. cable = Cable.objects.first()
  2758. interface = Interface(device=device, name='tmp', cable=cable)
  2759. self.assertIsNone(interface.path)
  2760. def test_cable_length_normalization_large_kilometer_value(self):
  2761. """
  2762. A large kilometer length must pass validation and fit in the normalized length field.
  2763. """
  2764. cable = Cable.objects.first()
  2765. cable.length = Decimal('1234')
  2766. cable.length_unit = CableLengthUnitChoices.UNIT_KILOMETER
  2767. cable.full_clean()
  2768. cable.save()
  2769. cable.refresh_from_db()
  2770. self.assertEqual(cable._abs_length, Decimal('1234000.0000'))
  2771. def test_cable_length_normalization_maximum_mile_value(self):
  2772. """
  2773. The maximum length value expressed in miles must fit in the normalized length field.
  2774. """
  2775. cable = Cable.objects.first()
  2776. cable.length = Decimal('999999.99')
  2777. cable.length_unit = CableLengthUnitChoices.UNIT_MILE
  2778. cable.full_clean()
  2779. cable.save()
  2780. cable.refresh_from_db()
  2781. self.assertEqual(cable._abs_length, Decimal('1609343983.9066'))
  2782. def test_partial_save_persists_normalized_length(self):
  2783. """
  2784. A save naming only length must persist the normalized length alongside it.
  2785. """
  2786. cable = Cable.objects.first()
  2787. cable.length = Decimal('1')
  2788. cable.length_unit = CableLengthUnitChoices.UNIT_METER
  2789. cable.save()
  2790. cable.length = Decimal('2')
  2791. cable.save(update_fields=['length'])
  2792. cable.refresh_from_db()
  2793. self.assertEqual(cable.length, Decimal('2.00'))
  2794. self.assertEqual(cable.length_unit, CableLengthUnitChoices.UNIT_METER)
  2795. self.assertEqual(cable._abs_length, Decimal('2.0000'))
  2796. def test_partial_save_persists_normalized_length_for_a_unit_change(self):
  2797. """
  2798. A save naming only length_unit must renormalize against the stored length.
  2799. """
  2800. cable = Cable.objects.first()
  2801. cable.length = Decimal('10')
  2802. cable.length_unit = CableLengthUnitChoices.UNIT_METER
  2803. cable.save()
  2804. cable.length_unit = CableLengthUnitChoices.UNIT_FOOT
  2805. cable.save(update_fields=['length_unit'])
  2806. cable.refresh_from_db()
  2807. self.assertEqual(cable.length, Decimal('10.00'))
  2808. self.assertEqual(cable.length_unit, CableLengthUnitChoices.UNIT_FOOT)
  2809. self.assertEqual(cable._abs_length, Decimal('3.0480'))
  2810. def test_partial_save_persists_normalized_length_for_both_source_fields(self):
  2811. """
  2812. A save naming both source fields must normalize from the values being written.
  2813. """
  2814. cable = Cable.objects.first()
  2815. cable.length = Decimal('2')
  2816. cable.length_unit = CableLengthUnitChoices.UNIT_KILOMETER
  2817. cable.save(update_fields=['length', 'length_unit'])
  2818. cable.refresh_from_db()
  2819. self.assertEqual(cable._abs_length, Decimal('2000.0000'))
  2820. def test_partial_save_normalizes_against_an_unwritten_length_unit(self):
  2821. """
  2822. A save naming only length must normalize against the stored unit, not an unwritten one.
  2823. """
  2824. cable = Cable.objects.first()
  2825. cable.length = Decimal('1')
  2826. cable.length_unit = CableLengthUnitChoices.UNIT_METER
  2827. cable.save()
  2828. cable.length = Decimal('2')
  2829. cable.length_unit = CableLengthUnitChoices.UNIT_KILOMETER
  2830. cable.save(update_fields=['length'])
  2831. cable.refresh_from_db()
  2832. self.assertEqual(cable.length_unit, CableLengthUnitChoices.UNIT_METER)
  2833. self.assertEqual(cable._abs_length, Decimal('2.0000'))
  2834. def test_partial_save_normalizes_against_an_unwritten_length(self):
  2835. """
  2836. A save naming only length_unit must normalize against the stored length, not an unwritten one.
  2837. """
  2838. cable = Cable.objects.first()
  2839. cable.length = Decimal('1')
  2840. cable.length_unit = CableLengthUnitChoices.UNIT_METER
  2841. cable.save()
  2842. cable.length = Decimal('2')
  2843. cable.length_unit = CableLengthUnitChoices.UNIT_CENTIMETER
  2844. cable.save(update_fields=['length_unit'])
  2845. cable.refresh_from_db()
  2846. self.assertEqual(cable.length, Decimal('1.00'))
  2847. self.assertEqual(cable.length_unit, CableLengthUnitChoices.UNIT_CENTIMETER)
  2848. self.assertEqual(cable._abs_length, Decimal('0.0100'))
  2849. def test_partial_save_normalizes_against_an_unwritten_cleared_length(self):
  2850. """
  2851. A save naming only length_unit must keep the unit when the excluded length is cleared in memory.
  2852. """
  2853. cable = Cable.objects.first()
  2854. cable.length = Decimal('1')
  2855. cable.length_unit = CableLengthUnitChoices.UNIT_METER
  2856. cable.save()
  2857. cable.length = None
  2858. cable.length_unit = CableLengthUnitChoices.UNIT_CENTIMETER
  2859. cable.save(update_fields=['length_unit'])
  2860. cable.refresh_from_db()
  2861. self.assertEqual(cable.length, Decimal('1.00'))
  2862. self.assertEqual(cable.length_unit, CableLengthUnitChoices.UNIT_CENTIMETER)
  2863. self.assertEqual(cable._abs_length, Decimal('0.0100'))
  2864. def test_partial_save_clearing_length_keeps_the_stored_unit(self):
  2865. """
  2866. A save naming only length must clear the normalized length without writing length_unit.
  2867. """
  2868. cable = Cable.objects.first()
  2869. cable.length = Decimal('1')
  2870. cable.length_unit = CableLengthUnitChoices.UNIT_METER
  2871. cable.save()
  2872. cable.length = None
  2873. cable.save(update_fields=['length'])
  2874. # The unit was not written, so the instance must still agree with the row
  2875. self.assertEqual(cable.length_unit, CableLengthUnitChoices.UNIT_METER)
  2876. cable.refresh_from_db()
  2877. self.assertIsNone(cable.length)
  2878. self.assertEqual(cable.length_unit, CableLengthUnitChoices.UNIT_METER)
  2879. self.assertIsNone(cable._abs_length)
  2880. def test_partial_save_leaves_an_unwritten_length_alone(self):
  2881. """
  2882. A save naming an unrelated field must not persist an in-memory length change.
  2883. """
  2884. cable = Cable.objects.first()
  2885. cable.length = Decimal('1')
  2886. cable.length_unit = CableLengthUnitChoices.UNIT_METER
  2887. cable.save()
  2888. cable.length = Decimal('99')
  2889. cable.label = 'Renamed'
  2890. cable.save(update_fields=['label'])
  2891. cable.refresh_from_db()
  2892. self.assertEqual(cable.label, 'Renamed')
  2893. self.assertEqual(cable.length, Decimal('1.00'))
  2894. self.assertEqual(cable._abs_length, Decimal('1.0000'))
  2895. def test_partial_save_normalizes_against_an_out_of_band_length(self):
  2896. """
  2897. A save naming only length_unit must read the stored length, not one cached on the instance.
  2898. """
  2899. cable = Cable.objects.first()
  2900. cable.length = Decimal('1')
  2901. cable.length_unit = CableLengthUnitChoices.UNIT_METER
  2902. cable.save()
  2903. Cable.objects.filter(pk=cable.pk).update(length=Decimal('7'))
  2904. cable.length_unit = CableLengthUnitChoices.UNIT_FOOT
  2905. cable.save(update_fields=['length_unit'])
  2906. cable.refresh_from_db()
  2907. self.assertEqual(cable.length, Decimal('7.00'))
  2908. self.assertEqual(cable._abs_length, Decimal('2.1336'))
  2909. def test_partial_save_clears_a_unit_written_without_a_stored_length(self):
  2910. """
  2911. A save naming only length_unit must drop the unit when the row holds no length.
  2912. """
  2913. cable = Cable.objects.first()
  2914. cable.length_unit = CableLengthUnitChoices.UNIT_METER
  2915. cable.save(update_fields=['length_unit'])
  2916. cable.refresh_from_db()
  2917. self.assertIsNone(cable.length)
  2918. self.assertIsNone(cable.length_unit)
  2919. self.assertIsNone(cable._abs_length)
  2920. def test_full_save_clears_the_unit_when_the_length_is_removed(self):
  2921. """
  2922. A full save with no length must clear the stored unit.
  2923. """
  2924. cable = Cable.objects.first()
  2925. cable.length = Decimal('1')
  2926. cable.length_unit = CableLengthUnitChoices.UNIT_METER
  2927. cable.save()
  2928. cable.length = None
  2929. cable.save()
  2930. cable.refresh_from_db()
  2931. self.assertIsNone(cable.length)
  2932. self.assertIsNone(cable.length_unit)
  2933. self.assertIsNone(cable._abs_length)
  2934. def test_partial_save_reads_the_stored_pair_only_for_an_excluded_field(self):
  2935. """
  2936. A save writing both source fields must normalize without reading the row back.
  2937. """
  2938. cable = Cable.objects.first()
  2939. cable.length = Decimal('1')
  2940. cable.length_unit = CableLengthUnitChoices.UNIT_METER
  2941. cable.save()
  2942. with CaptureQueriesContext(connection) as both_written:
  2943. cable.save(update_fields=['length', 'length_unit'])
  2944. with CaptureQueriesContext(connection) as one_written:
  2945. cable.save(update_fields=['length'])
  2946. def cable_reads(queries):
  2947. return [q for q in queries if q['sql'].startswith('SELECT') and 'FROM "dcim_cable"' in q['sql']]
  2948. self.assertEqual(cable_reads(both_written), [])
  2949. self.assertEqual(len(cable_reads(one_written)), 1)
  2950. class CableTerminationTestCase(TestCase):
  2951. def test_cache_related_objects_requires_resolvable_termination(self):
  2952. """cache_related_objects raises ValueError when the termination cannot be resolved."""
  2953. cable_termination = CableTermination(
  2954. termination_type=ObjectType.objects.get_for_model(Interface),
  2955. termination_id=0,
  2956. )
  2957. with self.assertRaises(ValueError):
  2958. cable_termination.cache_related_objects()
  2959. class CableDisconnectChangeLoggingTestCase(TestCase):
  2960. """
  2961. Deleting a Cable must change-log the disconnect on each terminating object. Its CableTerminations are
  2962. deleted by the cascade, so CableTermination.delete() -- which records the disconnect when a termination
  2963. is removed from a Cable -- never runs.
  2964. """
  2965. @classmethod
  2966. def setUpTestData(cls):
  2967. site = Site.objects.create(name='Test Site 1', slug='test-site-1')
  2968. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  2969. device_type = DeviceType.objects.create(
  2970. manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
  2971. )
  2972. role = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1')
  2973. cls.device1 = Device.objects.create(
  2974. name='Device 1', site=site, device_type=device_type, role=role
  2975. )
  2976. cls.device2 = Device.objects.create(
  2977. name='Device 2', site=site, device_type=device_type, role=role
  2978. )
  2979. cls.interface1 = Interface.objects.create(
  2980. device=cls.device1, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  2981. )
  2982. cls.interface2 = Interface.objects.create(
  2983. device=cls.device2, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  2984. )
  2985. cls.interface3 = Interface.objects.create(
  2986. device=cls.device2, name='eth1', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  2987. )
  2988. cls.rear_port = RearPort.objects.create(
  2989. device=cls.device2, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C, positions=4
  2990. )
  2991. cls.front_port = FrontPort.objects.create(
  2992. device=cls.device2, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, positions=4
  2993. )
  2994. cls.user = User.objects.create_user(username='testuser')
  2995. def _request(self):
  2996. request = RequestFactory().get('/')
  2997. request.id = uuid.uuid4()
  2998. request.user = self.user
  2999. return request
  3000. def _connect(self, termination_a, termination_b, **kwargs):
  3001. with event_tracking(self._request()):
  3002. cable = Cable(a_terminations=[termination_a], b_terminations=[termination_b], **kwargs)
  3003. cable.save()
  3004. return cable
  3005. def _updates(self, obj):
  3006. return ObjectChange.objects.filter(
  3007. changed_object_type=ObjectType.objects.get_for_model(obj),
  3008. changed_object_id=obj.pk,
  3009. action=ObjectChangeActionChoices.ACTION_UPDATE,
  3010. ).order_by('time')
  3011. @tag('regression') # Ref: #23617
  3012. def test_cable_creation_records_termination_prechange(self):
  3013. cable = self._connect(self.interface1, self.interface2)
  3014. # The update recording the terminations follows the creation of the Cable itself
  3015. change = self._updates(cable).get()
  3016. self.assertEqual(change.prechange_data['a_terminations'], [])
  3017. self.assertEqual(change.prechange_data['b_terminations'], [])
  3018. self.assertEqual(len(change.postchange_data['a_terminations']), 1)
  3019. self.assertEqual(len(change.postchange_data['b_terminations']), 1)
  3020. @tag('regression') # Ref: #23617
  3021. def test_resaving_new_cable_records_current_prechange(self):
  3022. # The creation snapshot must not be reused, so the re-save falls back to the database
  3023. with (
  3024. patch('core.signals.RUNNING_TESTS', False),
  3025. self.assertLogs('netbox.models.features', level='WARNING'),
  3026. event_tracking(self._request()),
  3027. ):
  3028. cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2])
  3029. cable.save()
  3030. cable.label = 'x'
  3031. cable.save()
  3032. change = self._updates(cable).last()
  3033. self.assertEqual(len(change.prechange_data['a_terminations']), 1)
  3034. self.assertEqual(len(change.prechange_data['b_terminations']), 1)
  3035. self.assertEqual(change.postchange_data['label'], 'x')
  3036. @tag('regression') # Ref: #23617
  3037. def test_resaving_new_cable_with_preset_pk_records_current_prechange(self):
  3038. # A new Cable with a preset pk (e.g. restored by deserialize_object()) must also discard its creation snapshot
  3039. with (
  3040. patch('core.signals.RUNNING_TESTS', False),
  3041. self.assertLogs('netbox.models.features', level='WARNING'),
  3042. event_tracking(self._request()),
  3043. ):
  3044. cable = Cable(pk=1000000, a_terminations=[self.interface1], b_terminations=[self.interface2])
  3045. cable.save()
  3046. cable.label = 'x'
  3047. cable.save()
  3048. change = self._updates(cable).last()
  3049. self.assertEqual(len(change.prechange_data['a_terminations']), 1)
  3050. self.assertEqual(len(change.prechange_data['b_terminations']), 1)
  3051. self.assertEqual(change.postchange_data['label'], 'x')
  3052. @tag('regression') # Ref: #23617
  3053. def test_termination_update_without_snapshot_records_prechange(self):
  3054. cable = self._connect(self.interface1, self.interface2)
  3055. old_b_termination = CableTermination.objects.get(cable=cable, cable_end=CableEndChoices.SIDE_B)
  3056. request = self._request()
  3057. with (
  3058. patch('core.signals.RUNNING_TESTS', False),
  3059. self.assertLogs('netbox.models.features', level='WARNING') as logs,
  3060. event_tracking(request),
  3061. ):
  3062. cable = Cable.objects.get(pk=cable.pk)
  3063. cable.label = 'x'
  3064. cable.b_terminations = [self.interface3]
  3065. cable.save()
  3066. change = self._updates(cable).get(request_id=request.id)
  3067. self.assertEqual(change.prechange_data['b_terminations'], [old_b_termination.pk])
  3068. self.assertEqual(change.prechange_data['label'], '')
  3069. self.assertNotEqual(change.postchange_data['b_terminations'], [old_b_termination.pk])
  3070. self.assertEqual(change.postchange_data['label'], 'x')
  3071. self.assertFalse(hasattr(cable, '_prechange_snapshot'))
  3072. self.assertEqual(len(logs.records), 1)
  3073. @tag('regression') # Ref: netbox-branching#631
  3074. def test_cable_deletion_records_disconnect(self):
  3075. cable = self._connect(self.interface1, self.interface2)
  3076. cable_pk = cable.pk
  3077. with event_tracking(self._request()):
  3078. Cable.objects.get(pk=cable_pk).delete()
  3079. for interface in (self.interface1, self.interface2):
  3080. # One update for the connect, one for the disconnect
  3081. self.assertEqual(self._updates(interface).count(), 2, f'No disconnect recorded for {interface}')
  3082. change = self._updates(interface).last()
  3083. # The cable attributes are cleared in the database before this is recorded, so the pre-change
  3084. # data is only correct if they were restored before the snapshot was taken
  3085. self.assertEqual(change.prechange_data['cable'], cable_pk)
  3086. self.assertIn(change.prechange_data['cable_end'], (CableEndChoices.SIDE_A, CableEndChoices.SIDE_B))
  3087. self.assertIsNone(change.postchange_data['cable'])
  3088. self.assertIsNone(change.postchange_data['cable_end'])
  3089. # The originating path is deleted along with the Cable, outside the changelog
  3090. self.assertIsNone(change.postchange_data['_path'])
  3091. self.assertIsNone(Interface.objects.get(pk=interface.pk).cable_id)
  3092. self.assertFalse(CablePath.objects.exists())
  3093. def test_profiled_cable_deletion_records_connector_and_positions(self):
  3094. cable = self._connect(self.interface1, self.rear_port, profile=CableProfileChoices.SINGLE_1C4P)
  3095. cable_pk = cable.pk
  3096. connector = Interface.objects.get(pk=self.interface1.pk).cable_connector
  3097. positions = Interface.objects.get(pk=self.interface1.pk).cable_positions
  3098. self.assertIsNotNone(connector)
  3099. self.assertTrue(positions)
  3100. with event_tracking(self._request()):
  3101. Cable.objects.get(pk=cable_pk).delete()
  3102. change = self._updates(self.interface1).last()
  3103. self.assertEqual(change.prechange_data['cable_connector'], connector)
  3104. self.assertEqual(change.prechange_data['cable_positions'], positions)
  3105. self.assertIsNone(change.postchange_data['cable_connector'])
  3106. self.assertIsNone(change.postchange_data['cable_positions'])
  3107. def test_queryset_deletion_records_disconnect(self):
  3108. # A queryset delete never calls Cable.delete(), so the tracking is done by CableQuerySet.delete()
  3109. cable = self._connect(self.interface1, self.interface2)
  3110. cable_pk = cable.pk
  3111. with event_tracking(self._request()):
  3112. Cable.objects.filter(pk=cable_pk).delete()
  3113. change = self._updates(self.interface1).last()
  3114. self.assertEqual(change.prechange_data['cable'], cable_pk)
  3115. self.assertIsNone(change.postchange_data['cable'])
  3116. def test_non_path_endpoint_termination(self):
  3117. # A front port carries no _path of its own; the disconnect is recorded the same way
  3118. cable = self._connect(self.interface1, self.front_port)
  3119. cable_pk = cable.pk
  3120. with event_tracking(self._request()):
  3121. Cable.objects.get(pk=cable_pk).delete()
  3122. change = self._updates(self.front_port).last()
  3123. self.assertEqual(change.prechange_data['cable'], cable_pk)
  3124. self.assertIsNone(change.postchange_data['cable'])
  3125. self.assertIsNone(FrontPort.objects.get(pk=self.front_port.pk).cable_id)
  3126. def test_termination_removal_records_disconnect(self):
  3127. # Removing a termination from a Cable (rather than deleting the Cable) is recorded by
  3128. # CableTermination.delete(); the disconnect must not be logged twice or logged as a no-op.
  3129. cable = self._connect(self.interface1, self.interface2)
  3130. cable_pk = cable.pk
  3131. request = self._request()
  3132. with event_tracking(request):
  3133. cable = Cable.objects.get(pk=cable_pk)
  3134. cable.snapshot()
  3135. cable.b_terminations = [self.interface3]
  3136. cable.save()
  3137. changes = self._updates(self.interface2).filter(request_id=request.id)
  3138. self.assertEqual(changes.count(), 1)
  3139. self.assertEqual(changes[0].prechange_data['cable'], cable_pk)
  3140. self.assertIsNone(changes[0].postchange_data['cable'])
  3141. def test_terminating_object_deletion_records_no_update(self):
  3142. # The cascade from deleting the terminating object itself reaches the same handler, but the object
  3143. # is on its way out: it must be left alone rather than resurrected as an update.
  3144. self._connect(self.interface1, self.interface2)
  3145. request = self._request()
  3146. with event_tracking(request):
  3147. Interface.objects.get(pk=self.interface1.pk).delete()
  3148. self.assertFalse(self._updates(self.interface1).filter(request_id=request.id).exists())
  3149. class VirtualDeviceContextTestCase(TestCase):
  3150. @classmethod
  3151. def setUpTestData(cls):
  3152. site = Site.objects.create(name='Test Site 1', slug='test-site-1')
  3153. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  3154. devicetype = DeviceType.objects.create(
  3155. manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
  3156. )
  3157. role = DeviceRole.objects.create(
  3158. name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
  3159. )
  3160. Device.objects.create(
  3161. device_type=devicetype, role=role, name='TestDevice1', site=site
  3162. )
  3163. def test_vdc_and_interface_creation(self):
  3164. device = Device.objects.first()
  3165. vdc = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active')
  3166. vdc.full_clean()
  3167. vdc.save()
  3168. interface = Interface(device=device, name='Eth1/1', type='10gbase-t')
  3169. interface.full_clean()
  3170. interface.save()
  3171. interface.vdcs.set([vdc])
  3172. def test_vdc_duplicate_name(self):
  3173. device = Device.objects.first()
  3174. vdc1 = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active')
  3175. vdc1.full_clean()
  3176. vdc1.save()
  3177. vdc2 = VirtualDeviceContext(device=device, name="VDC 1", identifier=2, status='active')
  3178. with self.assertRaises(ValidationError):
  3179. vdc2.full_clean()
  3180. def test_vdc_duplicate_identifier(self):
  3181. device = Device.objects.first()
  3182. vdc1 = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active')
  3183. vdc1.full_clean()
  3184. vdc1.save()
  3185. vdc2 = VirtualDeviceContext(device=device, name="VDC 2", identifier=1, status='active')
  3186. with self.assertRaises(ValidationError):
  3187. vdc2.full_clean()
  3188. @tag('regression') # Ref: #23275
  3189. def test_primary_ip_requires_device(self):
  3190. """A primary IP on the device's interface is valid only while that device is assigned."""
  3191. device = Device.objects.first()
  3192. interface = Interface.objects.create(device=device, name='Eth1/1', type='10gbase-t')
  3193. for family, address in ((4, '192.0.2.1/24'), (6, '2001:db8::1/64')):
  3194. field = f'primary_ip{family}'
  3195. ip = IPAddress.objects.create(address=address, assigned_object=interface)
  3196. with self.subTest(family=family):
  3197. VirtualDeviceContext(device=device, name='VDC 1', status='active', **{field: ip}).full_clean()
  3198. vdc = VirtualDeviceContext(name='VDC 1', status='active', **{field: ip})
  3199. with self.assertRaises(ValidationError) as cm:
  3200. vdc.full_clean()
  3201. self.assertIn(field, cm.exception.message_dict)
  3202. self.assertIn('must belong to an interface', str(cm.exception.message_dict[field]))
  3203. class VirtualChassisTestCase(TestCase):
  3204. @classmethod
  3205. def setUpTestData(cls):
  3206. site = Site.objects.create(name='Test Site 1', slug='test-site-1')
  3207. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  3208. devicetype = DeviceType.objects.create(
  3209. manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
  3210. )
  3211. role = DeviceRole.objects.create(
  3212. name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
  3213. )
  3214. Device.objects.create(
  3215. device_type=devicetype, role=role, name='TestDevice1', site=site
  3216. )
  3217. Device.objects.create(
  3218. device_type=devicetype, role=role, name='TestDevice2', site=site
  3219. )
  3220. def test_virtualchassis_deletion_clears_vc_position(self):
  3221. """
  3222. Test that when a VirtualChassis is deleted, member devices have their
  3223. vc_position and vc_priority fields set to None.
  3224. """
  3225. devices = Device.objects.all()
  3226. device1 = devices[0]
  3227. device2 = devices[1]
  3228. # Create a VirtualChassis with two member devices
  3229. vc = VirtualChassis.objects.create(name='Test VC', master=device1)
  3230. device1.virtual_chassis = vc
  3231. device1.vc_position = 1
  3232. device1.vc_priority = 10
  3233. device1.save()
  3234. device2.virtual_chassis = vc
  3235. device2.vc_position = 2
  3236. device2.vc_priority = 20
  3237. device2.save()
  3238. # Verify devices are members of the VC with positions set
  3239. device1.refresh_from_db()
  3240. device2.refresh_from_db()
  3241. self.assertEqual(device1.virtual_chassis, vc)
  3242. self.assertEqual(device1.vc_position, 1)
  3243. self.assertEqual(device1.vc_priority, 10)
  3244. self.assertEqual(device2.virtual_chassis, vc)
  3245. self.assertEqual(device2.vc_position, 2)
  3246. self.assertEqual(device2.vc_priority, 20)
  3247. # Delete the VirtualChassis
  3248. vc.delete()
  3249. # Verify devices have vc_position and vc_priority set to None
  3250. device1.refresh_from_db()
  3251. device2.refresh_from_db()
  3252. self.assertIsNone(device1.virtual_chassis)
  3253. self.assertIsNone(device1.vc_position)
  3254. self.assertIsNone(device1.vc_priority)
  3255. self.assertIsNone(device2.virtual_chassis)
  3256. self.assertIsNone(device2.vc_position)
  3257. self.assertIsNone(device2.vc_priority)
  3258. @tag('regression') # Ref: #22720
  3259. def test_virtualchassis_deletion_blocked_by_cross_chassis_lag(self):
  3260. """
  3261. Deleting a VirtualChassis whose members form a cross-chassis LAG must
  3262. raise ProtectedError exposing the blocking interfaces, leaving the VC
  3263. and its member assignments unchanged.
  3264. """
  3265. device1 = Device.objects.get(name='TestDevice1')
  3266. device2 = Device.objects.get(name='TestDevice2')
  3267. vc = VirtualChassis.objects.create(name='Test VC', master=device1)
  3268. device1.virtual_chassis = vc
  3269. device1.vc_position = 1
  3270. device1.vc_priority = 10
  3271. device1.save()
  3272. device2.virtual_chassis = vc
  3273. device2.vc_position = 2
  3274. device2.vc_priority = 20
  3275. device2.save()
  3276. lag = Interface.objects.create(device=device1, name='lag0', type=InterfaceTypeChoices.TYPE_LAG)
  3277. member_interface = Interface(
  3278. device=device2,
  3279. name='eth0',
  3280. type=InterfaceTypeChoices.TYPE_1GE_FIXED,
  3281. lag=lag,
  3282. )
  3283. # A cross-chassis LAG member is valid while both devices share the VC
  3284. member_interface.full_clean()
  3285. member_interface.save()
  3286. with self.assertRaises(ProtectedError) as cm:
  3287. vc.delete()
  3288. self.assertEqual(
  3289. cm.exception.args[0],
  3290. 'Unable to delete virtual chassis Test VC. One or more member interfaces form a cross-chassis LAG.'
  3291. )
  3292. self.assertEqual(set(cm.exception.protected_objects), {member_interface})
  3293. # The failed deletion must not clear the VC or its member assignments
  3294. self.assertTrue(VirtualChassis.objects.filter(pk=vc.pk).exists())
  3295. device1.refresh_from_db()
  3296. device2.refresh_from_db()
  3297. self.assertEqual(device1.virtual_chassis, vc)
  3298. self.assertEqual(device1.vc_position, 1)
  3299. self.assertEqual(device1.vc_priority, 10)
  3300. self.assertEqual(device2.virtual_chassis, vc)
  3301. self.assertEqual(device2.vc_position, 2)
  3302. self.assertEqual(device2.vc_priority, 20)
  3303. @tag('regression') # Ref: #23212
  3304. def test_member_removal_blocked_by_cross_chassis_lag(self):
  3305. """
  3306. Removing a device from a VirtualChassis must fail validation while any of its
  3307. interfaces form a LAG with another member, from either side of the LAG.
  3308. """
  3309. device1 = Device.objects.get(name='TestDevice1')
  3310. device2 = Device.objects.get(name='TestDevice2')
  3311. vc = VirtualChassis.objects.create(name='Test VC', master=device1)
  3312. Device.objects.filter(pk=device1.pk).update(virtual_chassis=vc, vc_position=1)
  3313. Device.objects.filter(pk=device2.pk).update(virtual_chassis=vc, vc_position=2)
  3314. device1.refresh_from_db()
  3315. device2.refresh_from_db()
  3316. lag = Interface.objects.create(device=device1, name='lag0', type=InterfaceTypeChoices.TYPE_LAG)
  3317. Interface.objects.create(
  3318. device=device2, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED, lag=lag
  3319. )
  3320. # Both devices remain valid while in the same VC
  3321. device1.full_clean()
  3322. device2.full_clean()
  3323. # Member interface side
  3324. device2.virtual_chassis = None
  3325. device2.vc_position = None
  3326. with self.assertRaises(ValidationError) as cm:
  3327. device2.full_clean()
  3328. self.assertIn('eth0', str(cm.exception.error_dict['virtual_chassis']))
  3329. # LAG interface side (refresh clears the cached vc_master_for so the master guard doesn't fire first)
  3330. VirtualChassis.objects.filter(pk=vc.pk).update(master=device2)
  3331. device1.refresh_from_db()
  3332. device1.virtual_chassis = None
  3333. device1.vc_position = None
  3334. with self.assertRaises(ValidationError) as cm:
  3335. device1.full_clean()
  3336. self.assertIn('eth0', str(cm.exception.error_dict['virtual_chassis']))
  3337. def test_member_removal_error_lists_at_most_five_interfaces(self):
  3338. """
  3339. The cross-chassis LAG error names at most five interfaces and summarizes the rest.
  3340. """
  3341. device1 = Device.objects.get(name='TestDevice1')
  3342. device2 = Device.objects.get(name='TestDevice2')
  3343. vc = VirtualChassis.objects.create(name='Test VC', master=device1)
  3344. Device.objects.filter(pk=device1.pk).update(virtual_chassis=vc, vc_position=1)
  3345. Device.objects.filter(pk=device2.pk).update(virtual_chassis=vc, vc_position=2)
  3346. device2.refresh_from_db()
  3347. lag = Interface.objects.create(device=device1, name='lag0', type=InterfaceTypeChoices.TYPE_LAG)
  3348. Interface.objects.bulk_create([
  3349. Interface(device=device2, name=f'eth{i}', type=InterfaceTypeChoices.TYPE_1GE_FIXED, lag=lag)
  3350. for i in range(7)
  3351. ])
  3352. device2.virtual_chassis = None
  3353. device2.vc_position = None
  3354. with self.assertRaises(ValidationError) as cm:
  3355. device2.full_clean()
  3356. message = str(cm.exception.error_dict['virtual_chassis'])
  3357. self.assertEqual(message.count('lag0'), 5)
  3358. self.assertIn('(and 2 more)', message)
  3359. def test_unrelated_edit_allowed_with_existing_cross_chassis_lag(self):
  3360. """
  3361. Pre-existing cross-chassis LAG data must not block edits that leave VC membership unchanged.
  3362. """
  3363. device1 = Device.objects.get(name='TestDevice1')
  3364. device2 = Device.objects.get(name='TestDevice2')
  3365. vc = VirtualChassis.objects.create(name='Test VC', master=device1)
  3366. Device.objects.filter(pk=device1.pk).update(virtual_chassis=vc, vc_position=1)
  3367. lag = Interface.objects.create(device=device1, name='lag0', type=InterfaceTypeChoices.TYPE_LAG)
  3368. Interface.objects.create(
  3369. device=device2, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED, lag=lag
  3370. )
  3371. device1.refresh_from_db()
  3372. device2.refresh_from_db()
  3373. device1.description = 'changed'
  3374. device1.full_clean()
  3375. device2.description = 'changed'
  3376. device2.full_clean()
  3377. def test_member_removal_allowed_with_local_lag(self):
  3378. """
  3379. A device may leave a VirtualChassis when its LAG members are all local.
  3380. """
  3381. device1 = Device.objects.get(name='TestDevice1')
  3382. device2 = Device.objects.get(name='TestDevice2')
  3383. vc = VirtualChassis.objects.create(name='Test VC', master=device1)
  3384. Device.objects.filter(pk=device1.pk).update(virtual_chassis=vc, vc_position=1)
  3385. Device.objects.filter(pk=device2.pk).update(virtual_chassis=vc, vc_position=2)
  3386. device2.refresh_from_db()
  3387. lag = Interface.objects.create(device=device2, name='lag0', type=InterfaceTypeChoices.TYPE_LAG)
  3388. Interface.objects.create(
  3389. device=device2, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED, lag=lag
  3390. )
  3391. device2.virtual_chassis = None
  3392. device2.vc_position = None
  3393. device2.full_clean()
  3394. def test_virtualchassis_duplicate_vc_position(self):
  3395. """
  3396. Test that two devices cannot be assigned to the same vc_position
  3397. within the same VirtualChassis.
  3398. """
  3399. devices = Device.objects.all()
  3400. device1 = devices[0]
  3401. device2 = devices[1]
  3402. # Create a VirtualChassis
  3403. vc = VirtualChassis.objects.create(name='Test VC')
  3404. # Assign first device to vc_position 1
  3405. device1.virtual_chassis = vc
  3406. device1.vc_position = 1
  3407. device1.full_clean()
  3408. device1.save()
  3409. # Try to assign second device to the same vc_position
  3410. device2.virtual_chassis = vc
  3411. device2.vc_position = 1
  3412. with self.assertRaises(ValidationError):
  3413. device2.full_clean()
  3414. class VCPositionTokenTestCase(TestCase):
  3415. @classmethod
  3416. def setUpTestData(cls):
  3417. Site.objects.create(name='Test Site 1', slug='test-site-1')
  3418. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  3419. DeviceType.objects.create(
  3420. manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
  3421. )
  3422. ModuleType.objects.create(
  3423. manufacturer=manufacturer, model='Test Module Type 1'
  3424. )
  3425. DeviceRole.objects.create(name='Test Role 1', slug='test-role-1')
  3426. def test_vc_position_token_in_vc(self):
  3427. site = Site.objects.first()
  3428. device_type = DeviceType.objects.first()
  3429. module_type = ModuleType.objects.first()
  3430. device_role = DeviceRole.objects.first()
  3431. InterfaceTemplate.objects.create(
  3432. module_type=module_type,
  3433. name='ge-{vc_position}/{module}/0',
  3434. type='1000base-t',
  3435. )
  3436. vc = VirtualChassis.objects.create(name='Test VC 1')
  3437. device = Device.objects.create(
  3438. name='Device VC 1', device_type=device_type, role=device_role,
  3439. site=site, virtual_chassis=vc, vc_position=8,
  3440. )
  3441. module_bay = ModuleBay.objects.create(device=device, name='Bay 1', position='1')
  3442. Module.objects.create(device=device, module_bay=module_bay, module_type=module_type)
  3443. interface = device.interfaces.get(name='ge-8/1/0')
  3444. self.assertEqual(interface.name, 'ge-8/1/0')
  3445. def test_vc_position_token_not_in_vc_default_fallback(self):
  3446. site = Site.objects.first()
  3447. device_type = DeviceType.objects.first()
  3448. module_type = ModuleType.objects.first()
  3449. device_role = DeviceRole.objects.first()
  3450. InterfaceTemplate.objects.create(
  3451. module_type=module_type,
  3452. name='ge-{vc_position}/{module}/0',
  3453. type='1000base-t',
  3454. )
  3455. device = Device.objects.create(
  3456. name='Device NoVC 1', device_type=device_type, role=device_role,
  3457. site=site,
  3458. )
  3459. module_bay = ModuleBay.objects.create(device=device, name='Bay 1', position='1')
  3460. Module.objects.create(device=device, module_bay=module_bay, module_type=module_type)
  3461. interface = device.interfaces.get(name='ge-0/1/0')
  3462. self.assertEqual(interface.name, 'ge-0/1/0')
  3463. def test_vc_position_token_explicit_fallback(self):
  3464. site = Site.objects.first()
  3465. device_type = DeviceType.objects.first()
  3466. module_type = ModuleType.objects.first()
  3467. device_role = DeviceRole.objects.first()
  3468. InterfaceTemplate.objects.create(
  3469. module_type=module_type,
  3470. name='ge-{vc_position:18}/{module}/0',
  3471. type='1000base-t',
  3472. )
  3473. device = Device.objects.create(
  3474. name='Device NoVC 2', device_type=device_type, role=device_role,
  3475. site=site,
  3476. )
  3477. module_bay = ModuleBay.objects.create(device=device, name='Bay 1', position='1')
  3478. Module.objects.create(device=device, module_bay=module_bay, module_type=module_type)
  3479. interface = device.interfaces.get(name='ge-18/1/0')
  3480. self.assertEqual(interface.name, 'ge-18/1/0')
  3481. def test_vc_position_token_explicit_fallback_ignored_when_in_vc(self):
  3482. site = Site.objects.first()
  3483. device_type = DeviceType.objects.first()
  3484. module_type = ModuleType.objects.first()
  3485. device_role = DeviceRole.objects.first()
  3486. InterfaceTemplate.objects.create(
  3487. module_type=module_type,
  3488. name='ge-{vc_position:99}/{module}/0',
  3489. type='1000base-t',
  3490. )
  3491. vc = VirtualChassis.objects.create(name='Test VC 2')
  3492. device = Device.objects.create(
  3493. name='Device VC 2', device_type=device_type, role=device_role,
  3494. site=site, virtual_chassis=vc, vc_position=2,
  3495. )
  3496. module_bay = ModuleBay.objects.create(device=device, name='Bay 1', position='1')
  3497. Module.objects.create(device=device, module_bay=module_bay, module_type=module_type)
  3498. interface = device.interfaces.get(name='ge-2/1/0')
  3499. self.assertEqual(interface.name, 'ge-2/1/0')
  3500. def test_vc_position_token_device_type_template(self):
  3501. site = Site.objects.first()
  3502. device_type = DeviceType.objects.first()
  3503. device_role = DeviceRole.objects.first()
  3504. InterfaceTemplate.objects.create(
  3505. device_type=device_type,
  3506. name='ge-{vc_position:0}/0/0',
  3507. type='1000base-t',
  3508. )
  3509. vc = VirtualChassis.objects.create(name='Test VC 3')
  3510. device = Device.objects.create(
  3511. name='Device VC 3', device_type=device_type, role=device_role,
  3512. site=site, virtual_chassis=vc, vc_position=3,
  3513. )
  3514. interface = device.interfaces.get(name='ge-3/0/0')
  3515. self.assertEqual(interface.name, 'ge-3/0/0')
  3516. def test_vc_position_token_device_type_template_not_in_vc(self):
  3517. site = Site.objects.first()
  3518. device_type = DeviceType.objects.first()
  3519. device_role = DeviceRole.objects.first()
  3520. InterfaceTemplate.objects.create(
  3521. device_type=device_type,
  3522. name='ge-{vc_position:0}/0/0',
  3523. type='1000base-t',
  3524. )
  3525. device = Device.objects.create(
  3526. name='Device NoVC 3', device_type=device_type, role=device_role,
  3527. site=site,
  3528. )
  3529. interface = device.interfaces.get(name='ge-0/0/0')
  3530. self.assertEqual(interface.name, 'ge-0/0/0')
  3531. def test_vc_position_token_label_resolution(self):
  3532. site = Site.objects.first()
  3533. device_type = DeviceType.objects.first()
  3534. module_type = ModuleType.objects.first()
  3535. device_role = DeviceRole.objects.first()
  3536. InterfaceTemplate.objects.create(
  3537. module_type=module_type,
  3538. name='ge-{vc_position}/{module}/0',
  3539. label='Member {vc_position:0} / Slot {module}',
  3540. type='1000base-t',
  3541. )
  3542. vc = VirtualChassis.objects.create(name='Test VC 4')
  3543. device = Device.objects.create(
  3544. name='Device VC 4', device_type=device_type, role=device_role,
  3545. site=site, virtual_chassis=vc, vc_position=2,
  3546. )
  3547. module_bay = ModuleBay.objects.create(device=device, name='Bay 1', position='1')
  3548. Module.objects.create(device=device, module_bay=module_bay, module_type=module_type)
  3549. interface = device.interfaces.get(name='ge-2/1/0')
  3550. self.assertEqual(interface.label, 'Member 2 / Slot 1')
  3551. @tag('regression') # Ref: #22707
  3552. def test_vc_position_token_interface_bridge_device_type_template(self):
  3553. site = Site.objects.first()
  3554. device_type = DeviceType.objects.first()
  3555. device_role = DeviceRole.objects.first()
  3556. bridge_template = InterfaceTemplate.objects.create(
  3557. device_type=device_type,
  3558. name='br-{vc_position}',
  3559. type='bridge',
  3560. )
  3561. InterfaceTemplate.objects.create(
  3562. device_type=device_type,
  3563. name='ge-{vc_position}/0/1',
  3564. type='1000base-t',
  3565. bridge=bridge_template,
  3566. )
  3567. vc = VirtualChassis.objects.create(name='Test VC 5')
  3568. device = Device.objects.create(
  3569. name='Device VC 5', device_type=device_type, role=device_role,
  3570. site=site, virtual_chassis=vc, vc_position=5,
  3571. )
  3572. interface = device.interfaces.get(name='ge-5/0/1')
  3573. self.assertEqual(interface.bridge, device.interfaces.get(name='br-5'))
  3574. @tag('regression') # Ref: #22707
  3575. def test_vc_position_token_port_mapping_device_type_template(self):
  3576. site = Site.objects.first()
  3577. device_type = DeviceType.objects.first()
  3578. device_role = DeviceRole.objects.first()
  3579. rear_port_template = RearPortTemplate.objects.create(
  3580. device_type=device_type,
  3581. name='rp-{vc_position}/1',
  3582. type=PortTypeChoices.TYPE_LC,
  3583. positions=1,
  3584. )
  3585. front_port_template = FrontPortTemplate.objects.create(
  3586. device_type=device_type,
  3587. name='fp-{vc_position}/1',
  3588. type=PortTypeChoices.TYPE_LC,
  3589. positions=1,
  3590. )
  3591. PortTemplateMapping.objects.create(
  3592. device_type=device_type,
  3593. front_port=front_port_template,
  3594. front_port_position=1,
  3595. rear_port=rear_port_template,
  3596. rear_port_position=1,
  3597. )
  3598. vc = VirtualChassis.objects.create(name='Test VC 6')
  3599. device = Device.objects.create(
  3600. name='Device VC 6', device_type=device_type, role=device_role,
  3601. site=site, virtual_chassis=vc, vc_position=6,
  3602. )
  3603. front_port = FrontPort.objects.get(device=device, name='fp-6/1')
  3604. rear_port = RearPort.objects.get(device=device, name='rp-6/1')
  3605. mapping = PortMapping.objects.get(device=device, front_port=front_port)
  3606. self.assertEqual(mapping.rear_port, rear_port)
  3607. self.assertEqual(mapping.front_port_position, 1)
  3608. self.assertEqual(mapping.rear_port_position, 1)
  3609. class SiteSignalTestCase(TestCase):
  3610. @tag('regression')
  3611. def test_edit_site_with_prefix_no_vrf(self):
  3612. site = Site.objects.create(name='Test Site', slug='test-site')
  3613. Prefix.objects.create(prefix='192.0.2.0/24', scope=site, vrf=None)
  3614. # Regression test for #21045: should not raise ValueError
  3615. site.save()
  3616. class PowerPortDrawTestCase(TestCase):
  3617. """
  3618. Tests for PowerPort.get_power_draw() power aggregation logic.
  3619. """
  3620. @classmethod
  3621. def setUpTestData(cls):
  3622. cls.site = Site.objects.create(name='Test Site', slug='test-site')
  3623. manufacturer = Manufacturer.objects.create(name='Generic', slug='generic')
  3624. device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device Type')
  3625. role = DeviceRole.objects.create(name='Test Role', slug='test-role')
  3626. cls.pdu = Device.objects.create(
  3627. device_type=device_type, role=role, site=cls.site, name='pdu'
  3628. )
  3629. cls.server = Device.objects.create(
  3630. device_type=device_type, role=role, site=cls.site, name='server'
  3631. )
  3632. def test_direct_draw_aggregation(self):
  3633. """
  3634. Sanity check: with one PowerOutlet chained directly to a downstream PSU PowerPort,
  3635. the upstream PowerPort should reflect the PSU's allocated/maximum draw.
  3636. [main] -- [outlet] --C-- [psu]
  3637. """
  3638. main = PowerPort.objects.create(device=self.pdu, name='main')
  3639. outlet = PowerOutlet.objects.create(device=self.pdu, name='outlet', power_port=main)
  3640. psu = PowerPort.objects.create(
  3641. device=self.server, name='psu', allocated_draw=200, maximum_draw=400
  3642. )
  3643. Cable(a_terminations=[outlet], b_terminations=[psu]).save()
  3644. draw = main.get_power_draw()
  3645. self.assertEqual(draw['allocated'], 200)
  3646. self.assertEqual(draw['maximum'], 400)
  3647. @tag('regression')
  3648. def test_recursive_draw_through_intermediate_powerport(self):
  3649. """
  3650. Regression test for #21949: A PDU modeled with internal fuses (intermediate PowerPorts in
  3651. auto mode) should still aggregate downstream PSU draw up to the main PowerPort.
  3652. [main] -- [feedback] --C-- [fuse] -- [outlet] --C-- [psu]
  3653. Both `main` and `fuse` are in auto mode (no allocated_draw/maximum_draw set). The draw
  3654. reported by `psu` must propagate through `fuse` and be reflected at `main`.
  3655. """
  3656. main = PowerPort.objects.create(device=self.pdu, name='main')
  3657. feedback = PowerOutlet.objects.create(device=self.pdu, name='feedback', power_port=main)
  3658. fuse = PowerPort.objects.create(device=self.pdu, name='fuse')
  3659. outlet = PowerOutlet.objects.create(device=self.pdu, name='outlet', power_port=fuse)
  3660. psu = PowerPort.objects.create(
  3661. device=self.server, name='psu', allocated_draw=150, maximum_draw=300
  3662. )
  3663. Cable(a_terminations=[feedback], b_terminations=[fuse]).save()
  3664. Cable(a_terminations=[outlet], b_terminations=[psu]).save()
  3665. fuse_draw = fuse.get_power_draw()
  3666. self.assertEqual(fuse_draw['allocated'], 150)
  3667. self.assertEqual(fuse_draw['maximum'], 300)
  3668. main_draw = main.get_power_draw()
  3669. self.assertEqual(main_draw['allocated'], 150)
  3670. self.assertEqual(main_draw['maximum'], 300)
  3671. def test_intermediate_manual_override_stops_recursion(self):
  3672. """
  3673. When an intermediate PowerPort has an explicit allocated_draw/maximum_draw, recursion should
  3674. stop there and the administratively defined values should be used.
  3675. """
  3676. main = PowerPort.objects.create(device=self.pdu, name='main')
  3677. feedback = PowerOutlet.objects.create(device=self.pdu, name='feedback', power_port=main)
  3678. fuse = PowerPort.objects.create(
  3679. device=self.pdu, name='fuse', allocated_draw=500, maximum_draw=1000
  3680. )
  3681. outlet = PowerOutlet.objects.create(device=self.pdu, name='outlet', power_port=fuse)
  3682. psu = PowerPort.objects.create(
  3683. device=self.server, name='psu', allocated_draw=150, maximum_draw=300
  3684. )
  3685. Cable(a_terminations=[feedback], b_terminations=[fuse]).save()
  3686. Cable(a_terminations=[outlet], b_terminations=[psu]).save()
  3687. main_draw = main.get_power_draw()
  3688. self.assertEqual(main_draw['allocated'], 500)
  3689. self.assertEqual(main_draw['maximum'], 1000)
  3690. def _connect_three_phase_feed(self, powerport):
  3691. """
  3692. Helper: attach `powerport` via cable to a newly-created three-phase PowerFeed.
  3693. """
  3694. power_panel = PowerPanel.objects.create(site=self.site, name='Panel')
  3695. power_feed = PowerFeed.objects.create(
  3696. power_panel=power_panel,
  3697. name='Feed',
  3698. phase=PowerFeedPhaseChoices.PHASE_3PHASE,
  3699. )
  3700. Cable(a_terminations=[powerport], b_terminations=[power_feed]).save()
  3701. @tag('regression')
  3702. def test_three_phase_per_leg_aggregation(self):
  3703. """
  3704. Regression test: per-leg totals for a main PowerPort connected to a three-phase PowerFeed
  3705. must be populated even when the full aggregation runs first. Previously, a shared visited
  3706. set caused downstream ports to be skipped during the per-leg passes, zeroing the legs.
  3707. [main] --C-- [3-phase PowerFeed]
  3708. ├── [outlet_A] (leg A) --C-- [portA] (allocated=100, maximum=200)
  3709. ├── [outlet_B] (leg B) --C-- [portB] (allocated=200, maximum=400)
  3710. └── [outlet_C] (leg C) --C-- [portC] (allocated=300, maximum=600)
  3711. """
  3712. main = PowerPort.objects.create(device=self.pdu, name='main')
  3713. self._connect_three_phase_feed(main)
  3714. leg_specs = [
  3715. (PowerOutletFeedLegChoices.FEED_LEG_A, 100, 200),
  3716. (PowerOutletFeedLegChoices.FEED_LEG_B, 200, 400),
  3717. (PowerOutletFeedLegChoices.FEED_LEG_C, 300, 600),
  3718. ]
  3719. for leg, allocated, maximum in leg_specs:
  3720. outlet = PowerOutlet.objects.create(
  3721. device=self.pdu, name=f'outlet_{leg}', power_port=main, feed_leg=leg
  3722. )
  3723. port = PowerPort.objects.create(
  3724. device=self.server, name=f'psu_{leg}',
  3725. allocated_draw=allocated, maximum_draw=maximum,
  3726. )
  3727. Cable(a_terminations=[outlet], b_terminations=[port]).save()
  3728. # Re-fetch to clear cached_property values populated before cable creation
  3729. main = PowerPort.objects.get(pk=main.pk)
  3730. draw = main.get_power_draw()
  3731. self.assertEqual(draw['allocated'], 600)
  3732. self.assertEqual(draw['maximum'], 1200)
  3733. legs_by_name = {leg['name']: leg for leg in draw['legs']}
  3734. self.assertEqual(legs_by_name['A']['allocated'], 100)
  3735. self.assertEqual(legs_by_name['A']['maximum'], 200)
  3736. self.assertEqual(legs_by_name['B']['allocated'], 200)
  3737. self.assertEqual(legs_by_name['B']['maximum'], 400)
  3738. self.assertEqual(legs_by_name['C']['allocated'], 300)
  3739. self.assertEqual(legs_by_name['C']['maximum'], 600)
  3740. @tag('regression')
  3741. def test_three_phase_per_leg_recursive_aggregation(self):
  3742. """
  3743. Regression test for #21949 on three-phase feeds: per-leg totals must aggregate through
  3744. intermediate auto-mode PowerPorts (the PDU-internal "fuse" pattern).
  3745. [main] --C-- [3-phase PowerFeed]
  3746. └── [feedback_A] (leg A) --C-- [fuse_A] (auto)
  3747. └── [outlet_A] (leg A) --C-- [psu_A] (allocated=100)
  3748. """
  3749. main = PowerPort.objects.create(device=self.pdu, name='main')
  3750. self._connect_three_phase_feed(main)
  3751. feedback = PowerOutlet.objects.create(
  3752. device=self.pdu, name='feedback_A', power_port=main,
  3753. feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A,
  3754. )
  3755. fuse = PowerPort.objects.create(device=self.pdu, name='fuse_A')
  3756. outlet = PowerOutlet.objects.create(
  3757. device=self.pdu, name='outlet_A', power_port=fuse,
  3758. feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A,
  3759. )
  3760. psu = PowerPort.objects.create(
  3761. device=self.server, name='psu_A', allocated_draw=100, maximum_draw=200
  3762. )
  3763. Cable(a_terminations=[feedback], b_terminations=[fuse]).save()
  3764. Cable(a_terminations=[outlet], b_terminations=[psu]).save()
  3765. # Re-fetch to clear cached_property values populated before cable creation
  3766. main = PowerPort.objects.get(pk=main.pk)
  3767. draw = main.get_power_draw()
  3768. self.assertEqual(draw['allocated'], 100)
  3769. self.assertEqual(draw['maximum'], 200)
  3770. legs_by_name = {leg['name']: leg for leg in draw['legs']}
  3771. self.assertEqual(legs_by_name['A']['allocated'], 100)
  3772. self.assertEqual(legs_by_name['A']['maximum'], 200)
  3773. self.assertEqual(legs_by_name['B']['allocated'], 0)
  3774. self.assertEqual(legs_by_name['C']['allocated'], 0)
  3775. class InventoryItemCycleTestCase(TestCase):
  3776. """
  3777. InventoryItem (ltree-backed, not the nested-group base) must reject assigning
  3778. self or a descendant as parent — behavior django-mptt previously enforced via
  3779. InvalidMove on save().
  3780. """
  3781. @classmethod
  3782. def setUpTestData(cls):
  3783. site = Site.objects.create(name='Site 1', slug='inv-site-1')
  3784. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='inv-mfr-1')
  3785. device_type = DeviceType.objects.create(
  3786. manufacturer=manufacturer, model='Device Type 1', slug='inv-dt-1'
  3787. )
  3788. role = DeviceRole.objects.create(name='Role 1', slug='inv-role-1')
  3789. cls.device = Device.objects.create(
  3790. name='Device 1', device_type=device_type, role=role, site=site
  3791. )
  3792. def test_cannot_assign_descendant_as_parent(self):
  3793. a = InventoryItem.objects.create(device=self.device, name='A')
  3794. b = InventoryItem.objects.create(device=self.device, name='B', parent=a)
  3795. c = InventoryItem.objects.create(device=self.device, name='C', parent=b)
  3796. a.parent = c
  3797. with self.assertRaises(ValidationError):
  3798. a.full_clean()
  3799. # The save()-level guard also rejects the cycle when clean() is bypassed.
  3800. with self.assertRaises(ValidationError):
  3801. a.save()
  3802. def test_cannot_assign_self_as_parent(self):
  3803. a = InventoryItem.objects.create(device=self.device, name='A')
  3804. a.parent = a
  3805. with self.assertRaises(ValidationError):
  3806. a.full_clean()
  3807. class InventoryItemTemplateCycleTestCase(TestCase):
  3808. """InventoryItemTemplate must likewise reject self/descendant as parent."""
  3809. @classmethod
  3810. def setUpTestData(cls):
  3811. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='iit-mfr-1')
  3812. cls.device_type = DeviceType.objects.create(
  3813. manufacturer=manufacturer, model='Device Type 1', slug='iit-dt-1'
  3814. )
  3815. def test_cannot_assign_descendant_as_parent(self):
  3816. a = InventoryItemTemplate.objects.create(device_type=self.device_type, name='A')
  3817. b = InventoryItemTemplate.objects.create(device_type=self.device_type, name='B', parent=a)
  3818. a.parent = b
  3819. with self.assertRaises(ValidationError):
  3820. a.full_clean()
  3821. with self.assertRaises(ValidationError):
  3822. a.save()
  3823. def test_cannot_assign_self_as_parent(self):
  3824. a = InventoryItemTemplate.objects.create(device_type=self.device_type, name='A')
  3825. a.parent = a
  3826. with self.assertRaises(ValidationError):
  3827. a.full_clean()
  3828. class CoolingComponentTestCase(TestCase):
  3829. @classmethod
  3830. def setUpTestData(cls):
  3831. cls.site = Site.objects.create(name='Site 1', slug='site-1')
  3832. cls.manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  3833. cls.role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
  3834. def test_cooling_method_inherited_from_device_type(self):
  3835. """
  3836. A new Device should inherit its cooling_method from the DeviceType when not explicitly set.
  3837. """
  3838. device_type = DeviceType.objects.create(
  3839. manufacturer=self.manufacturer,
  3840. model='Device Type 1',
  3841. slug='device-type-1',
  3842. cooling_method=CoolingMethodChoices.METHOD_LIQUID
  3843. )
  3844. device = Device.objects.create(
  3845. site=self.site,
  3846. device_type=device_type,
  3847. role=self.role,
  3848. name='Device 1'
  3849. )
  3850. self.assertEqual(device.cooling_method, CoolingMethodChoices.METHOD_LIQUID)
  3851. def test_cooling_method_not_overridden_when_set(self):
  3852. """
  3853. A new Device with an explicitly-set cooling_method should not be overridden by the DeviceType.
  3854. """
  3855. device_type = DeviceType.objects.create(
  3856. manufacturer=self.manufacturer,
  3857. model='Device Type 2',
  3858. slug='device-type-2',
  3859. cooling_method=CoolingMethodChoices.METHOD_LIQUID
  3860. )
  3861. device = Device.objects.create(
  3862. site=self.site,
  3863. device_type=device_type,
  3864. role=self.role,
  3865. name='Device 2',
  3866. cooling_method=CoolingMethodChoices.METHOD_AIR
  3867. )
  3868. self.assertEqual(device.cooling_method, CoolingMethodChoices.METHOD_AIR)
  3869. def test_device_creation_instantiates_cooling_components(self):
  3870. """
  3871. Creating a Device from a DeviceType with cooling component templates should auto-instantiate
  3872. matching CoolingIntake and CoolingOutflow components.
  3873. """
  3874. device_type = DeviceType.objects.create(
  3875. manufacturer=self.manufacturer,
  3876. model='Device Type 3',
  3877. slug='device-type-3'
  3878. )
  3879. cooling_intake_template = CoolingIntakeTemplate.objects.create(
  3880. device_type=device_type,
  3881. name='Cooling Port 1',
  3882. type=CoolingConnectorTypeChoices.TYPE_UQD,
  3883. diameter=Decimal('25'),
  3884. diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
  3885. max_flow=100,
  3886. max_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE
  3887. )
  3888. CoolingOutflowTemplate.objects.create(
  3889. device_type=device_type,
  3890. name='Cooling Outlet 1',
  3891. type=CoolingConnectorTypeChoices.TYPE_UQD,
  3892. diameter=Decimal('25'),
  3893. diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER
  3894. )
  3895. device = Device.objects.create(
  3896. site=self.site,
  3897. device_type=device_type,
  3898. role=self.role,
  3899. name='Device 3'
  3900. )
  3901. cooling_intake = CoolingIntake.objects.get(
  3902. device=device,
  3903. name='Cooling Port 1',
  3904. type=CoolingConnectorTypeChoices.TYPE_UQD,
  3905. diameter=Decimal('25'),
  3906. diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
  3907. max_flow=100,
  3908. max_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE
  3909. )
  3910. self.assertEqual(cooling_intake_template.max_flow, cooling_intake.max_flow)
  3911. CoolingOutflow.objects.get(
  3912. device=device,
  3913. name='Cooling Outlet 1',
  3914. type=CoolingConnectorTypeChoices.TYPE_UQD,
  3915. diameter=Decimal('25'),
  3916. diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER
  3917. )
  3918. def test_cooling_choice_colors_resolve(self):
  3919. """
  3920. ChoiceFieldColumn and ChoiceAttr both render a badge color by calling get_FOO_color() on the
  3921. instance, so every model exposing a colored cooling choice must implement the accessor.
  3922. """
  3923. for model in (Device, DeviceType, ModuleType):
  3924. with self.subTest(model=model.__name__):
  3925. instance = model(cooling_method=CoolingMethodChoices.METHOD_LIQUID)
  3926. self.assertEqual(
  3927. instance.get_cooling_method_color(),
  3928. CoolingMethodChoices.colors[CoolingMethodChoices.METHOD_LIQUID]
  3929. )
  3930. # An unset value has no color, which the consumers fall back on
  3931. self.assertIsNone(model().get_cooling_method_color())
  3932. for model in (Rack, RackType):
  3933. with self.subTest(model=model.__name__):
  3934. instance = model(cooling_capability=RackCoolingCapabilityChoices.CAPABILITY_HYBRID)
  3935. self.assertEqual(
  3936. instance.get_cooling_capability_color(),
  3937. RackCoolingCapabilityChoices.colors[RackCoolingCapabilityChoices.CAPABILITY_HYBRID]
  3938. )
  3939. self.assertIsNone(model().get_cooling_capability_color())
  3940. def test_measurements_below_minimum_rejected(self):
  3941. """
  3942. A populated diameter or flow rate must be positive and non-zero: anything below the smallest storable
  3943. value (0.01) should raise a ValidationError (via MinValueValidator) rather than a raw ValueError from
  3944. the unit conversion in save().
  3945. """
  3946. device_type = DeviceType.objects.create(
  3947. manufacturer=self.manufacturer, model='Device Type 8', slug='device-type-8'
  3948. )
  3949. device = Device.objects.create(
  3950. site=self.site, device_type=device_type, role=self.role, name='Device F'
  3951. )
  3952. cooling_source = CoolingSource.objects.create(
  3953. site=self.site, name='Cooling Source F', type=CoolingSourceTypeChoices.TYPE_CHILLER
  3954. )
  3955. for value in (Decimal('-5'), Decimal('0')):
  3956. with self.subTest(diameter=value):
  3957. cooling_intake = CoolingIntake(
  3958. device=device,
  3959. name='Cooling Port 1',
  3960. diameter=value,
  3961. diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
  3962. )
  3963. with self.assertRaises(ValidationError):
  3964. cooling_intake.full_clean()
  3965. with self.subTest(max_flow=value):
  3966. cooling_intake = CoolingIntake(
  3967. device=device,
  3968. name='Cooling Port 1',
  3969. max_flow=value,
  3970. max_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
  3971. )
  3972. with self.assertRaises(ValidationError):
  3973. cooling_intake.full_clean()
  3974. with self.subTest(feed_max_flow=value):
  3975. cooling_feed = CoolingFeed(
  3976. cooling_source=cooling_source,
  3977. name='Cooling Feed F',
  3978. max_flow=value,
  3979. max_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
  3980. )
  3981. with self.assertRaises(ValidationError):
  3982. cooling_feed.full_clean()
  3983. # The minimum itself is permitted
  3984. CoolingIntake(
  3985. device=device,
  3986. name='Cooling Port 1',
  3987. diameter=Decimal('0.01'),
  3988. diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
  3989. max_flow=Decimal('0.01'),
  3990. max_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
  3991. ).full_clean()
  3992. # Sub-unit values are permitted: fractional-inch fittings (e.g. 1/2" NPT) and cold plates rated below
  3993. # one gallon per minute are both commonplace.
  3994. CoolingIntake(
  3995. device=device,
  3996. name='Cooling Port 1',
  3997. diameter=Decimal('0.5'),
  3998. diameter_unit=DiameterUnitChoices.UNIT_INCH,
  3999. max_flow=Decimal('0.75'),
  4000. max_flow_unit=FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE,
  4001. ).full_clean()
  4002. CoolingFeed(
  4003. cooling_source=cooling_source,
  4004. name='Cooling Feed F',
  4005. max_flow=Decimal('0.5'),
  4006. max_flow_unit=FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE,
  4007. ).full_clean()
  4008. def test_parent_intake_resolved_on_device_instantiation(self):
  4009. """
  4010. A CoolingOutflowTemplate with a parent CoolingIntakeTemplate should resolve to the newly created
  4011. CoolingIntake on the same device. This depends on intakes being instantiated before outflows.
  4012. """
  4013. device_type = DeviceType.objects.create(
  4014. manufacturer=self.manufacturer, model='Device Type 12', slug='device-type-12'
  4015. )
  4016. cooling_intake_template = CoolingIntakeTemplate.objects.create(
  4017. device_type=device_type, name='Cooling Port 1'
  4018. )
  4019. CoolingOutflowTemplate.objects.create(
  4020. device_type=device_type, name='Cooling Outlet 1', cooling_intake=cooling_intake_template
  4021. )
  4022. # A second outflow with no parent must remain unassigned
  4023. CoolingOutflowTemplate.objects.create(device_type=device_type, name='Cooling Outlet 2')
  4024. device = Device.objects.create(
  4025. site=self.site, device_type=device_type, role=self.role, name='Device J'
  4026. )
  4027. cooling_intake = CoolingIntake.objects.get(device=device, name='Cooling Port 1')
  4028. self.assertEqual(
  4029. CoolingOutflow.objects.get(device=device, name='Cooling Outlet 1').cooling_intake,
  4030. cooling_intake
  4031. )
  4032. self.assertIsNone(CoolingOutflow.objects.get(device=device, name='Cooling Outlet 2').cooling_intake)
  4033. def test_parent_intake_resolved_on_module_instantiation(self):
  4034. """
  4035. The parent intake of a CoolingOutflowTemplate should likewise resolve when the components are
  4036. instantiated for a Module rather than a Device.
  4037. """
  4038. device_type = DeviceType.objects.create(
  4039. manufacturer=self.manufacturer, model='Device Type 13', slug='device-type-13'
  4040. )
  4041. device = Device.objects.create(
  4042. site=self.site, device_type=device_type, role=self.role, name='Device K'
  4043. )
  4044. module_bay = ModuleBay.objects.create(device=device, name='Module Bay 1')
  4045. module_type = ModuleType.objects.create(manufacturer=self.manufacturer, model='Module Type 1')
  4046. cooling_intake_template = CoolingIntakeTemplate.objects.create(
  4047. module_type=module_type, name='Cooling Port 1'
  4048. )
  4049. CoolingOutflowTemplate.objects.create(
  4050. module_type=module_type, name='Cooling Outlet 1', cooling_intake=cooling_intake_template
  4051. )
  4052. module = Module.objects.create(device=device, module_bay=module_bay, module_type=module_type)
  4053. cooling_intake = CoolingIntake.objects.get(module=module, name='Cooling Port 1')
  4054. cooling_outflow = CoolingOutflow.objects.get(module=module, name='Cooling Outlet 1')
  4055. self.assertEqual(cooling_outflow.cooling_intake, cooling_intake)
  4056. self.assertEqual(cooling_outflow.device, device)
  4057. def test_measurements_normalized_on_save(self):
  4058. """
  4059. Saving a component should populate the normalized _abs_* columns, converting from the selected
  4060. unit to the canonical unit (millimeters for diameter, liters per minute for flow).
  4061. """
  4062. device_type = DeviceType.objects.create(
  4063. manufacturer=self.manufacturer, model='Device Type 9', slug='device-type-9'
  4064. )
  4065. device = Device.objects.create(
  4066. site=self.site, device_type=device_type, role=self.role, name='Device G'
  4067. )
  4068. cooling_intake = CoolingIntake.objects.create(
  4069. device=device,
  4070. name='Cooling Port 1',
  4071. diameter=Decimal('1'),
  4072. diameter_unit=DiameterUnitChoices.UNIT_INCH,
  4073. max_flow=Decimal('10'),
  4074. max_flow_unit=FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE,
  4075. )
  4076. cooling_intake.refresh_from_db()
  4077. self.assertEqual(cooling_intake._abs_diameter, Decimal('25.4'))
  4078. self.assertEqual(cooling_intake._abs_max_flow, Decimal('37.8541'))
  4079. # The public aliases used by templates should mirror the underscore-prefixed columns
  4080. self.assertEqual(cooling_intake.abs_diameter, cooling_intake._abs_diameter)
  4081. self.assertEqual(cooling_intake.abs_max_flow, cooling_intake._abs_max_flow)
  4082. # Clearing a value should null both its unit and its normalized column
  4083. cooling_intake.diameter = None
  4084. cooling_intake.max_flow = None
  4085. cooling_intake.save()
  4086. cooling_intake.refresh_from_db()
  4087. self.assertIsNone(cooling_intake.diameter_unit)
  4088. self.assertIsNone(cooling_intake._abs_diameter)
  4089. self.assertIsNone(cooling_intake.max_flow_unit)
  4090. self.assertIsNone(cooling_intake._abs_max_flow)
  4091. def test_measurements_normalized_on_component_instantiation(self):
  4092. """
  4093. Components instantiated from templates are written via bulk_create, which bypasses save(); the
  4094. normalized _abs_* columns must still be populated.
  4095. """
  4096. device_type = DeviceType.objects.create(
  4097. manufacturer=self.manufacturer, model='Device Type 10', slug='device-type-10'
  4098. )
  4099. CoolingIntakeTemplate.objects.create(
  4100. device_type=device_type,
  4101. name='Cooling Port 1',
  4102. diameter=Decimal('1'),
  4103. diameter_unit=DiameterUnitChoices.UNIT_INCH,
  4104. max_flow=Decimal('6'),
  4105. max_flow_unit=FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR,
  4106. )
  4107. CoolingOutflowTemplate.objects.create(
  4108. device_type=device_type,
  4109. name='Cooling Outlet 1',
  4110. diameter=Decimal('2.5'),
  4111. diameter_unit=DiameterUnitChoices.UNIT_CENTIMETER,
  4112. )
  4113. device = Device.objects.create(
  4114. site=self.site, device_type=device_type, role=self.role, name='Device H'
  4115. )
  4116. cooling_intake = CoolingIntake.objects.get(device=device, name='Cooling Port 1')
  4117. self.assertEqual(cooling_intake._abs_diameter, Decimal('25.4'))
  4118. self.assertEqual(cooling_intake._abs_max_flow, Decimal('100'))
  4119. cooling_outflow = CoolingOutflow.objects.get(device=device, name='Cooling Outlet 1')
  4120. self.assertEqual(cooling_outflow._abs_diameter, Decimal('25'))
  4121. def test_measurement_unit_required(self):
  4122. """
  4123. Setting a measurement without its accompanying unit should raise a ValidationError.
  4124. """
  4125. device_type = DeviceType.objects.create(
  4126. manufacturer=self.manufacturer, model='Device Type 11', slug='device-type-11'
  4127. )
  4128. device = Device.objects.create(
  4129. site=self.site, device_type=device_type, role=self.role, name='Device I'
  4130. )
  4131. with self.assertRaises(ValidationError):
  4132. CoolingIntake(device=device, name='Cooling Port 1', diameter=Decimal('25')).full_clean()
  4133. with self.assertRaises(ValidationError):
  4134. CoolingIntake(device=device, name='Cooling Port 2', max_flow=Decimal('100')).full_clean()
  4135. with self.assertRaises(ValidationError):
  4136. CoolingOutflow(device=device, name='Cooling Outlet 1', diameter=Decimal('25')).full_clean()
  4137. def test_cooling_feed_flow_normalized(self):
  4138. """
  4139. CoolingFeed shares the flow-rate normalization applied to device components, and likewise requires
  4140. a unit whenever a flow rate is set.
  4141. """
  4142. cooling_source = CoolingSource.objects.create(
  4143. site=self.site,
  4144. name='Cooling Source 1',
  4145. type=CoolingSourceTypeChoices.TYPE_CHILLER,
  4146. )
  4147. cooling_feed = CoolingFeed.objects.create(
  4148. cooling_source=cooling_source,
  4149. name='Cooling Feed 1',
  4150. max_flow=Decimal('6'),
  4151. max_flow_unit=FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR,
  4152. )
  4153. cooling_feed.refresh_from_db()
  4154. self.assertEqual(cooling_feed._abs_max_flow, Decimal('100'))
  4155. self.assertEqual(cooling_feed.abs_max_flow, cooling_feed._abs_max_flow)
  4156. with self.assertRaises(ValidationError):
  4157. CoolingFeed(
  4158. cooling_source=cooling_source, name='Cooling Feed 2', max_flow=Decimal('100')
  4159. ).full_clean()
  4160. def test_cooling_outflow_clean_different_device(self):
  4161. """
  4162. CoolingOutflow.clean() should raise a ValidationError when its cooling_intake belongs to a
  4163. different device.
  4164. """
  4165. device_type = DeviceType.objects.create(
  4166. manufacturer=self.manufacturer,
  4167. model='Device Type 4',
  4168. slug='device-type-4'
  4169. )
  4170. device1 = Device.objects.create(
  4171. site=self.site, device_type=device_type, role=self.role, name='Device A'
  4172. )
  4173. device2 = Device.objects.create(
  4174. site=self.site, device_type=device_type, role=self.role, name='Device B'
  4175. )
  4176. cooling_intake = CoolingIntake.objects.create(device=device1, name='Cooling Port 1')
  4177. cooling_outflow = CoolingOutflow(device=device2, name='Cooling Outlet 1', cooling_intake=cooling_intake)
  4178. with self.assertRaises(ValidationError):
  4179. cooling_outflow.full_clean()
  4180. def test_cooling_source_location_site_mismatch(self):
  4181. """
  4182. CoolingSource.clean() should raise a ValidationError when its location belongs to a different site.
  4183. """
  4184. site2 = Site.objects.create(name='Site 2', slug='site-2')
  4185. location = Location.objects.create(name='Location 1', slug='location-1', site=site2)
  4186. cooling_source = CoolingSource(
  4187. site=self.site,
  4188. location=location,
  4189. name='Cooling Source 1',
  4190. type=CoolingSourceTypeChoices.TYPE_CHILLER,
  4191. status=CoolingSourceStatusChoices.STATUS_ACTIVE,
  4192. )
  4193. with self.assertRaises(ValidationError):
  4194. cooling_source.full_clean()
  4195. def test_cooling_feed_rack_site_mismatch(self):
  4196. """
  4197. CoolingFeed.clean() should raise a ValidationError when its rack is in a different site than the
  4198. cooling source.
  4199. """
  4200. site2 = Site.objects.create(name='Site 3', slug='site-3')
  4201. cooling_source = CoolingSource.objects.create(
  4202. site=self.site,
  4203. name='Cooling Source 3',
  4204. type=CoolingSourceTypeChoices.TYPE_CHILLER,
  4205. status=CoolingSourceStatusChoices.STATUS_ACTIVE,
  4206. )
  4207. rack = Rack.objects.create(name='Rack 1', site=site2, status=RackStatusChoices.STATUS_ACTIVE)
  4208. cooling_feed = CoolingFeed(
  4209. cooling_source=cooling_source,
  4210. rack=rack,
  4211. name='Cooling Feed 1',
  4212. status=CoolingFeedStatusChoices.STATUS_ACTIVE,
  4213. )
  4214. with self.assertRaises(ValidationError):
  4215. cooling_feed.full_clean()
  4216. def test_cooling_chain_valid(self):
  4217. """
  4218. A non-looping intake/outflow assignment should pass validation.
  4219. """
  4220. device_type = DeviceType.objects.create(
  4221. manufacturer=self.manufacturer, model='Device Type 5', slug='device-type-5'
  4222. )
  4223. device = Device.objects.create(
  4224. site=self.site, device_type=device_type, role=self.role, name='Device C'
  4225. )
  4226. cooling_outflow = CoolingOutflow.objects.create(device=device, name='Cooling Outlet 1')
  4227. cooling_intake = CoolingIntake(device=device, name='Cooling Port 1', cooling_outflow=cooling_outflow)
  4228. # Should not raise
  4229. cooling_intake.full_clean()
  4230. def test_cooling_intake_loop_rejected(self):
  4231. """
  4232. Closing the intake/outflow chain into a loop from the intake side should raise a ValidationError.
  4233. """
  4234. device_type = DeviceType.objects.create(
  4235. manufacturer=self.manufacturer, model='Device Type 6', slug='device-type-6'
  4236. )
  4237. device = Device.objects.create(
  4238. site=self.site, device_type=device_type, role=self.role, name='Device D'
  4239. )
  4240. cooling_intake = CoolingIntake.objects.create(device=device, name='Cooling Port 1')
  4241. cooling_outflow = CoolingOutflow.objects.create(
  4242. device=device, name='Cooling Outlet 1', cooling_intake=cooling_intake
  4243. )
  4244. # The outflow is fed by the intake; supplying that same intake from the outflow closes the loop
  4245. cooling_intake.cooling_outflow = cooling_outflow
  4246. with self.assertRaises(ValidationError):
  4247. cooling_intake.full_clean()
  4248. def test_cooling_outflow_loop_rejected(self):
  4249. """
  4250. Closing the intake/outflow chain into a loop from the outflow side should raise a ValidationError.
  4251. """
  4252. device_type = DeviceType.objects.create(
  4253. manufacturer=self.manufacturer, model='Device Type 7', slug='device-type-7'
  4254. )
  4255. device = Device.objects.create(
  4256. site=self.site, device_type=device_type, role=self.role, name='Device E'
  4257. )
  4258. cooling_outflow = CoolingOutflow.objects.create(device=device, name='Cooling Outlet 1')
  4259. cooling_intake = CoolingIntake.objects.create(
  4260. device=device, name='Cooling Port 1', cooling_outflow=cooling_outflow
  4261. )
  4262. # The intake is supplied by the outflow; feeding that same intake into the outflow closes the loop
  4263. cooling_outflow.cooling_intake = cooling_intake
  4264. with self.assertRaises(ValidationError):
  4265. cooling_outflow.full_clean()