test_api.py 323 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879488048814882488348844885488648874888488948904891489248934894489548964897489848994900490149024903490449054906490749084909491049114912491349144915491649174918491949204921492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014501550165017501850195020502150225023502450255026502750285029503050315032503350345035503650375038503950405041504250435044504550465047504850495050505150525053505450555056505750585059506050615062506350645065506650675068506950705071507250735074507550765077507850795080508150825083508450855086508750885089509050915092509350945095509650975098509951005101510251035104510551065107510851095110511151125113511451155116511751185119512051215122512351245125512651275128512951305131513251335134513551365137513851395140514151425143514451455146514751485149515051515152515351545155515651575158515951605161516251635164516551665167516851695170517151725173517451755176517751785179518051815182518351845185518651875188518951905191519251935194519551965197519851995200520152025203520452055206520752085209521052115212521352145215521652175218521952205221522252235224522552265227522852295230523152325233523452355236523752385239524052415242524352445245524652475248524952505251525252535254525552565257525852595260526152625263526452655266526752685269527052715272527352745275527652775278527952805281528252835284528552865287528852895290529152925293529452955296529752985299530053015302530353045305530653075308530953105311531253135314531553165317531853195320532153225323532453255326532753285329533053315332533353345335533653375338533953405341534253435344534553465347534853495350535153525353535453555356535753585359536053615362536353645365536653675368536953705371537253735374537553765377537853795380538153825383538453855386538753885389539053915392539353945395539653975398539954005401540254035404540554065407540854095410541154125413541454155416541754185419542054215422542354245425542654275428542954305431543254335434543554365437543854395440544154425443544454455446544754485449545054515452545354545455545654575458545954605461546254635464546554665467546854695470547154725473547454755476547754785479548054815482548354845485548654875488548954905491549254935494549554965497549854995500550155025503550455055506550755085509551055115512551355145515551655175518551955205521552255235524552555265527552855295530553155325533553455355536553755385539554055415542554355445545554655475548554955505551555255535554555555565557555855595560556155625563556455655566556755685569557055715572557355745575557655775578557955805581558255835584558555865587558855895590559155925593559455955596559755985599560056015602560356045605560656075608560956105611561256135614561556165617561856195620562156225623562456255626562756285629563056315632563356345635563656375638563956405641564256435644564556465647564856495650565156525653565456555656565756585659566056615662566356645665566656675668566956705671567256735674567556765677567856795680568156825683568456855686568756885689569056915692569356945695569656975698569957005701570257035704570557065707570857095710571157125713571457155716571757185719572057215722572357245725572657275728572957305731573257335734573557365737573857395740574157425743574457455746574757485749575057515752575357545755575657575758575957605761576257635764576557665767576857695770577157725773577457755776577757785779578057815782578357845785578657875788578957905791579257935794579557965797579857995800580158025803580458055806580758085809581058115812581358145815581658175818581958205821582258235824582558265827582858295830583158325833583458355836583758385839584058415842584358445845584658475848584958505851585258535854585558565857585858595860586158625863586458655866586758685869587058715872587358745875587658775878587958805881588258835884588558865887588858895890589158925893589458955896589758985899590059015902590359045905590659075908590959105911591259135914591559165917591859195920592159225923592459255926592759285929593059315932593359345935593659375938593959405941594259435944594559465947594859495950595159525953595459555956595759585959596059615962596359645965596659675968596959705971597259735974597559765977597859795980598159825983598459855986598759885989599059915992599359945995599659975998599960006001600260036004600560066007600860096010601160126013601460156016601760186019602060216022602360246025602660276028602960306031603260336034603560366037603860396040604160426043604460456046604760486049605060516052605360546055605660576058605960606061606260636064606560666067606860696070607160726073607460756076607760786079608060816082608360846085608660876088608960906091609260936094609560966097609860996100610161026103610461056106610761086109611061116112611361146115611661176118611961206121612261236124612561266127612861296130613161326133613461356136613761386139614061416142614361446145614661476148614961506151615261536154615561566157615861596160616161626163616461656166616761686169617061716172617361746175617661776178617961806181618261836184618561866187618861896190619161926193619461956196619761986199620062016202620362046205620662076208620962106211621262136214621562166217621862196220622162226223622462256226622762286229623062316232623362346235623662376238623962406241624262436244624562466247624862496250625162526253625462556256625762586259626062616262626362646265626662676268626962706271627262736274627562766277627862796280628162826283628462856286628762886289629062916292629362946295629662976298629963006301630263036304630563066307630863096310631163126313631463156316631763186319632063216322632363246325632663276328632963306331633263336334633563366337633863396340634163426343634463456346634763486349635063516352635363546355635663576358635963606361636263636364636563666367636863696370637163726373637463756376637763786379638063816382638363846385638663876388638963906391639263936394639563966397639863996400640164026403640464056406640764086409641064116412641364146415641664176418641964206421642264236424642564266427642864296430643164326433643464356436643764386439644064416442644364446445644664476448644964506451645264536454645564566457645864596460646164626463646464656466646764686469647064716472647364746475647664776478647964806481648264836484648564866487648864896490649164926493649464956496649764986499650065016502650365046505650665076508650965106511651265136514651565166517651865196520652165226523652465256526652765286529653065316532653365346535653665376538653965406541654265436544654565466547654865496550655165526553655465556556655765586559656065616562656365646565656665676568656965706571657265736574657565766577657865796580658165826583658465856586658765886589659065916592659365946595659665976598659966006601660266036604660566066607660866096610661166126613661466156616661766186619662066216622662366246625662666276628662966306631663266336634663566366637663866396640664166426643664466456646664766486649665066516652665366546655665666576658665966606661666266636664666566666667666866696670667166726673667466756676667766786679668066816682668366846685668666876688668966906691669266936694669566966697669866996700670167026703670467056706670767086709671067116712671367146715671667176718671967206721672267236724672567266727672867296730673167326733673467356736673767386739674067416742674367446745674667476748674967506751675267536754675567566757675867596760676167626763676467656766676767686769677067716772677367746775677667776778677967806781678267836784678567866787678867896790679167926793679467956796679767986799680068016802680368046805680668076808680968106811681268136814681568166817681868196820682168226823682468256826682768286829683068316832683368346835683668376838683968406841684268436844684568466847684868496850685168526853685468556856685768586859686068616862686368646865686668676868686968706871687268736874687568766877687868796880688168826883688468856886688768886889689068916892689368946895689668976898689969006901690269036904690569066907690869096910691169126913691469156916691769186919692069216922692369246925692669276928692969306931693269336934693569366937693869396940694169426943694469456946694769486949695069516952695369546955695669576958695969606961696269636964696569666967696869696970697169726973697469756976697769786979698069816982698369846985698669876988698969906991699269936994699569966997699869997000700170027003700470057006700770087009701070117012701370147015701670177018701970207021702270237024702570267027702870297030703170327033703470357036703770387039704070417042704370447045704670477048704970507051705270537054705570567057705870597060706170627063706470657066706770687069707070717072707370747075707670777078707970807081708270837084708570867087708870897090709170927093709470957096709770987099710071017102710371047105710671077108710971107111711271137114711571167117711871197120712171227123712471257126712771287129713071317132713371347135713671377138713971407141714271437144714571467147714871497150715171527153715471557156715771587159716071617162716371647165716671677168716971707171717271737174717571767177717871797180718171827183718471857186718771887189719071917192719371947195719671977198719972007201720272037204720572067207720872097210721172127213721472157216721772187219722072217222722372247225722672277228722972307231723272337234723572367237723872397240724172427243724472457246724772487249725072517252725372547255725672577258725972607261726272637264726572667267726872697270727172727273727472757276727772787279728072817282728372847285728672877288728972907291729272937294729572967297729872997300730173027303730473057306730773087309731073117312731373147315731673177318731973207321732273237324732573267327732873297330733173327333733473357336733773387339734073417342734373447345734673477348734973507351735273537354735573567357735873597360736173627363736473657366736773687369737073717372737373747375737673777378737973807381738273837384738573867387738873897390739173927393739473957396739773987399740074017402740374047405740674077408740974107411741274137414741574167417741874197420742174227423742474257426742774287429743074317432743374347435743674377438743974407441744274437444744574467447744874497450745174527453745474557456745774587459746074617462746374647465746674677468746974707471747274737474747574767477747874797480748174827483748474857486748774887489749074917492749374947495749674977498749975007501750275037504750575067507750875097510751175127513751475157516751775187519752075217522752375247525752675277528
  1. import copy
  2. import json
  3. import os
  4. import tempfile
  5. from contextlib import contextmanager
  6. from unittest.mock import patch
  7. from django.conf import settings
  8. from django.contrib.contenttypes.models import ContentType
  9. from django.contrib.contenttypes.prefetch import GenericPrefetch
  10. from django.db import connection
  11. from django.db.models import FETCH_PEERS, prefetch_related_objects
  12. from django.test import override_settings, tag
  13. from django.test.utils import CaptureQueriesContext
  14. from django.urls import reverse
  15. from django.utils.translation import gettext as _
  16. from rest_framework import status
  17. from rest_framework.test import APIRequestFactory
  18. from circuits.choices import VirtualCircuitTerminationRoleChoices
  19. from circuits.models import Provider, ProviderNetwork, VirtualCircuit, VirtualCircuitTermination, VirtualCircuitType
  20. from core.choices import ObjectChangeActionChoices
  21. from core.models import ObjectChange, ObjectType
  22. from dcim.api.serializers import InterfaceSerializer
  23. from dcim.api.serializers_.nested import NestedDeviceBaySerializer, NestedDeviceSerializer
  24. from dcim.api.views import CableTerminationsPrefetch, DeviceViewSet, InterfaceViewSet
  25. from dcim.choices import *
  26. from dcim.constants import *
  27. from dcim.graphql.types import _CABLE_TERMINATION_MODELS
  28. from dcim.models import *
  29. from extras.models import ConfigContext, ConfigTemplate, ExportTemplate, Tag
  30. from ipam.choices import FHRPGroupProtocolChoices, VLANQinQRoleChoices
  31. from ipam.models import ASN, RIR, VLAN, VRF, FHRPGroup, FHRPGroupAssignment, IPAddress
  32. from netbox.api.serializers import GenericObjectSerializer
  33. from netbox.config import get_config
  34. from tenancy.models import Tenant
  35. from users.constants import TOKEN_PREFIX
  36. from users.models import ObjectPermission, Token, User
  37. from utilities.testing import (
  38. APITestCase,
  39. APIViewTestCases,
  40. GraphQLFilterTest,
  41. GraphQLQueryTest,
  42. create_test_device,
  43. create_test_image,
  44. create_test_nat_ip_pair,
  45. create_test_virtualmachine,
  46. disable_logging,
  47. disable_warnings,
  48. )
  49. from virtualization.models import Cluster, ClusterType, VMInterface
  50. from vpn.choices import L2VPNTypeChoices
  51. from vpn.models import L2VPN, L2VPNTermination
  52. from wireless.choices import WirelessChannelChoices
  53. from wireless.models import WirelessLAN
  54. class AppTestCase(APITestCase):
  55. def test_root(self):
  56. url = reverse('dcim-api:api-root')
  57. response = self.client.get('{}?format=api'.format(url), **self.header)
  58. self.assertEqual(response.status_code, 200)
  59. def assert_list_query_count_is_constant(test, url, get_params, objects, check_results):
  60. """
  61. Assert that listing the first of the given objects runs as many queries as listing all of them. `get_params`
  62. returns the query parameters selecting the given objects, and `check_results` validates the results listed.
  63. """
  64. def get_query_count(objs):
  65. with CaptureQueriesContext(connection) as queries:
  66. response = test.client.get(url, get_params(objs), **test.header)
  67. test.assertHttpStatus(response, status.HTTP_200_OK)
  68. check_results(objs, response.data['results'])
  69. # Token authentication refreshes last_used once a minute
  70. return sum('"users_token"' not in query['sql'] for query in queries.captured_queries)
  71. # Warm per-process caches (e.g. ContentType)
  72. get_query_count(objects)
  73. test.assertEqual(get_query_count(objects[:1]), get_query_count(objects))
  74. class Mixins:
  75. class ComponentTraceMixin(APITestCase):
  76. peer_termination_type = None
  77. def test_trace(self):
  78. """
  79. Test tracing a device component's attached cable.
  80. """
  81. obj = self.model.objects.first()
  82. peer_device = Device.objects.create(
  83. site=Site.objects.first(),
  84. device_type=DeviceType.objects.first(),
  85. role=DeviceRole.objects.first(),
  86. name='Peer Device'
  87. )
  88. if self.peer_termination_type is None:
  89. raise NotImplementedError(_("Test case must set peer_termination_type"))
  90. peer_obj = self.peer_termination_type.objects.create(
  91. device=peer_device,
  92. name='Peer Termination'
  93. )
  94. cable = Cable(a_terminations=[obj], b_terminations=[peer_obj], label='Cable 1')
  95. cable.save()
  96. self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
  97. url = reverse(f'dcim-api:{self.model._meta.model_name}-trace', kwargs={'pk': obj.pk})
  98. response = self.client.get(url, **self.header)
  99. self.assertHttpStatus(response, status.HTTP_200_OK)
  100. self.assertEqual(len(response.data), 1)
  101. segment1 = response.data[0]
  102. self.assertEqual(segment1[0][0]['name'], obj.name)
  103. self.assertEqual(segment1[1]['label'], cable.label)
  104. self.assertEqual(segment1[2][0]['name'], peer_obj.name)
  105. class LinkPeersMixin(APITestCase):
  106. peer_termination_type = None
  107. def test_list_objects_link_peers_query_count(self):
  108. """
  109. Listing cabled components loads their link peers, and the peers' devices and cables, with the same
  110. number of queries however many components are listed.
  111. """
  112. if self.peer_termination_type is None:
  113. raise NotImplementedError(_("Test case must set peer_termination_type"))
  114. self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
  115. objects = list(self.model.objects.order_by('pk')[:3])
  116. peer_device = create_test_device('Peer Device', site=Site.objects.first())
  117. peer_names = {}
  118. for index, obj in enumerate(objects):
  119. peer = self.peer_termination_type.objects.create(device=peer_device, name=f'Peer {index}')
  120. Cable(a_terminations=[obj], b_terminations=[peer]).save()
  121. peer_names[obj.pk] = peer.name
  122. def check_results(objs, results):
  123. link_peers = {result['id']: [peer['name'] for peer in result['link_peers']] for result in results}
  124. self.assertEqual(link_peers, {obj.pk: [peer_names[obj.pk]] for obj in objs})
  125. assert_list_query_count_is_constant(
  126. self,
  127. self._get_list_url(),
  128. lambda objs: {'id': [obj.pk for obj in objs], 'fields': 'id,link_peers'},
  129. objects,
  130. check_results,
  131. )
  132. class FieldPrefetchMixin(APITestCase):
  133. viewset = None
  134. # Narrowed requests compared in addition to the default, brief and single-field ones
  135. field_prefetch_cases = ()
  136. # Literal reference lookups (the static prefetches before the declarations) loaded unconditionally
  137. eager_prefetches = ()
  138. @staticmethod
  139. def lookup_path(lookup):
  140. return getattr(lookup, 'prefetch_to', lookup)
  141. @contextmanager
  142. def capture_querysets(self, viewset):
  143. """Collect the querysets returned by the viewset's get_queryset()."""
  144. querysets = []
  145. get_queryset = viewset.get_queryset
  146. def spy(view):
  147. querysets.append(get_queryset(view))
  148. return querysets[-1]
  149. with patch.object(viewset, 'get_queryset', spy):
  150. yield querysets
  151. def assert_lookups(self, querysets, expected):
  152. """Assert each queryset leads with exactly these lookups, as these objects, and no other declared one."""
  153. declared = [lookup for lookups in self.viewset.field_prefetches.values() for lookup in lookups]
  154. self.assertTrue(querysets)
  155. for queryset in querysets:
  156. lookups = queryset._prefetch_related_lookups
  157. paths = [self.lookup_path(lookup) for lookup in lookups]
  158. self.assertEqual(paths[:len(expected)], [self.lookup_path(lookup) for lookup in expected], paths)
  159. self.assertTrue(all(a is b for a, b in zip(lookups, expected)), paths)
  160. tail = [lookup for lookup in lookups[len(expected):] if any(lookup is d for d in declared)]
  161. self.assertFalse(tail, paths)
  162. def test_list_objects_field_prefetches(self):
  163. """Narrowed responses keep the reference content within its queries, and brief ones skip unread lookups."""
  164. field_prefetches = self.viewset.field_prefetches
  165. declared = [lookup for lookups in field_prefetches.values() for lookup in lookups]
  166. fields = self.viewset.serializer_class.Meta.fields
  167. # The map names serializer fields only, and its lookups no longer sit on the class queryset
  168. self.assertLessEqual(set().union(*field_prefetches), set(fields))
  169. static = {self.lookup_path(lookup) for lookup in self.viewset.queryset._prefetch_related_lookups}
  170. self.assertFalse(static.intersection(self.lookup_path(lookup) for lookup in declared))
  171. self.add_permissions(f'{self.model._meta.app_label}.view_{self.model._meta.model_name}')
  172. scopes = self._create_field_prefetch_scopes()
  173. url = self._get_list_url()
  174. # Runtime bound: skip fields named after model columns or forward relations that start no lookup
  175. heads = {self.lookup_path(lookup).split('__')[0] for lookup in (*self.eager_prefetches, *declared)}
  176. skipped = {
  177. field.name for field in self.model._meta.get_fields()
  178. if (field.concrete or (field.many_to_many and not field.auto_created)) and field.name not in heads
  179. }
  180. cases = (
  181. '', 'brief=1', *self.field_prefetch_cases,
  182. *(f'fields=id,{name}' for name in fields if name not in skipped),
  183. )
  184. def get(query, eager):
  185. queryset, prefetches = self.viewset.queryset, field_prefetches
  186. if eager:
  187. queryset, prefetches = queryset.prefetch_related(*self.eager_prefetches), {}
  188. with (
  189. patch.object(self.viewset, 'queryset', queryset),
  190. patch.object(self.viewset, 'field_prefetches', prefetches),
  191. CaptureQueriesContext(connection) as queries,
  192. ):
  193. response = self.client.get(f'{url}?{query}', **self.header)
  194. # Token authentication refreshes last_used once a minute
  195. count = sum('"users_token"' not in entry['sql'] for entry in queries.captured_queries)
  196. self.assertHttpStatus(response, status.HTTP_200_OK)
  197. return response.json(), count
  198. # Warm per-process caches (e.g. ContentType) for every related type
  199. self.client.get(f'{url}?{scopes[-1]}', **self.header)
  200. for params in cases:
  201. with self.subTest(params=params):
  202. # Some cases take paths of their own, such as config context without its annotation
  203. self.client.get(f'{url}?{scopes[-1]}&{params}', **self.header)
  204. savings = set()
  205. for scope in scopes:
  206. expected, eager_count = get(f'{scope}&{params}', eager=True)
  207. data, count = get(f'{scope}&{params}', eager=False)
  208. self.assertEqual(data, expected)
  209. self.assertLessEqual(count, eager_count)
  210. savings.add(eager_count - count)
  211. self.assertEqual(len(savings), 1, f'A field reads a lookup it does not declare: {savings}')
  212. if not params:
  213. self.assertEqual(savings, {0})
  214. elif params == 'brief=1':
  215. self.assertGreater(savings.pop(), 0)
  216. class RegionTestCase(APIViewTestCases.APIViewTestCase):
  217. model = Region
  218. brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'site_count', 'slug', 'url']
  219. create_data = [
  220. {
  221. 'name': 'Region 4',
  222. 'slug': 'region-4',
  223. 'comments': 'this is region 4, not region 5',
  224. },
  225. {
  226. 'name': 'Region 5',
  227. 'slug': 'region-5',
  228. },
  229. {
  230. 'name': 'Region 6',
  231. 'slug': 'region-6',
  232. },
  233. ]
  234. bulk_update_data = {
  235. 'description': 'New description',
  236. 'comments': 'New comments',
  237. }
  238. bulk_update_invalid_data = {
  239. 'owner': 99999,
  240. }
  241. @classmethod
  242. def setUpTestData(cls):
  243. Region.objects.create(name='Region 1', slug='region-1')
  244. Region.objects.create(name='Region 2', slug='region-2', comments='what in the world is happening?')
  245. Region.objects.create(name='Region 3', slug='region-3')
  246. class SiteGroupTestCase(APIViewTestCases.APIViewTestCase):
  247. model = SiteGroup
  248. brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'site_count', 'slug', 'url']
  249. create_data = [
  250. {
  251. 'name': 'Site Group 4',
  252. 'slug': 'site-group-4',
  253. 'comments': '',
  254. },
  255. {
  256. 'name': 'Site Group 5',
  257. 'slug': 'site-group-5',
  258. 'comments': 'not actually empty',
  259. },
  260. {
  261. 'name': 'Site Group 6',
  262. 'slug': 'site-group-6',
  263. 'comments': 'Do I really exist?',
  264. },
  265. ]
  266. bulk_update_data = {
  267. 'description': 'New description',
  268. 'comments': 'I do exist!',
  269. }
  270. bulk_update_invalid_data = {
  271. 'owner': 99999,
  272. }
  273. @classmethod
  274. def setUpTestData(cls):
  275. SiteGroup.objects.create(name='Site Group 1', slug='site-group-1')
  276. SiteGroup.objects.create(name='Site Group 2', slug='site-group-2', comments='')
  277. SiteGroup.objects.create(name='Site Group 3', slug='site-group-3', comments='Hi!')
  278. class SiteTestCase(APIViewTestCases.APIViewTestCase):
  279. model = Site
  280. brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
  281. bulk_update_data = {
  282. 'status': 'planned',
  283. }
  284. bulk_update_invalid_data = {
  285. 'status': 'not-a-valid-status',
  286. }
  287. graphql_filter_tests = (
  288. GraphQLFilterTest(
  289. name='tenant__name__exact',
  290. filters='tenant: {name: {exact: "Tenant 1"}}',
  291. expected=lambda qs: qs.filter(tenant__name='Tenant 1'),
  292. permissions=('tenancy.view_tenant',),
  293. ),
  294. )
  295. def assert_nested_locations_active(self, data):
  296. site_data = data.get('site') or {}
  297. location_names = sorted(location['name'] for location in site_data.get('locations', []))
  298. self.assertEqual(location_names, ['Site1 Active A', 'Site1 Active B'])
  299. @classmethod
  300. def setUpTestData(cls):
  301. regions = (
  302. Region.objects.create(name='Region 1', slug='region-1'),
  303. Region.objects.create(name='Region 2', slug='region-2'),
  304. )
  305. groups = (
  306. SiteGroup.objects.create(name='Site Group 1', slug='site-group-1'),
  307. SiteGroup.objects.create(name='Site Group 2', slug='site-group-2'),
  308. )
  309. tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
  310. # Site 1's tenant activates the dynamic tenant prefetch (+1 in api_list_objects baseline).
  311. sites = (
  312. Site(region=regions[0], group=groups[0], tenant=tenant, name='Site 1', slug='site-1'),
  313. Site(region=regions[0], group=groups[0], name='Site 2', slug='site-2'),
  314. Site(region=regions[0], group=groups[0], name='Site 3', slug='site-3'),
  315. )
  316. Site.objects.bulk_create(sites)
  317. nested_site = Site.objects.get(slug='site-1')
  318. cls.nested_site_pk = nested_site.pk
  319. Location.objects.create(
  320. site=nested_site, name='Site1 Active A', slug='site1-active-a',
  321. status=LocationStatusChoices.STATUS_ACTIVE,
  322. )
  323. Location.objects.create(
  324. site=nested_site, name='Site1 Active B', slug='site1-active-b',
  325. status=LocationStatusChoices.STATUS_ACTIVE,
  326. )
  327. Location.objects.create(
  328. site=nested_site, name='Site1 Planned', slug='site1-planned',
  329. status=LocationStatusChoices.STATUS_PLANNED,
  330. )
  331. rir = RIR.objects.create(name='RFC 6996', is_private=True)
  332. asns = [
  333. ASN(asn=65000 + i, rir=rir) for i in range(8)
  334. ]
  335. ASN.objects.bulk_create(asns)
  336. cls.create_data = [
  337. {
  338. 'name': 'Site 4',
  339. 'slug': 'site-4',
  340. 'region': regions[1].pk,
  341. 'group': groups[1].pk,
  342. 'status': SiteStatusChoices.STATUS_ACTIVE,
  343. 'asns': [asns[0].pk, asns[1].pk],
  344. 'tenant': tenant.pk,
  345. },
  346. {
  347. 'name': 'Site 5',
  348. 'slug': 'site-5',
  349. 'region': regions[1].pk,
  350. 'group': groups[1].pk,
  351. 'status': SiteStatusChoices.STATUS_ACTIVE,
  352. 'asns': [asns[2].pk, asns[3].pk],
  353. },
  354. {
  355. 'name': 'Site 6',
  356. 'slug': 'site-6',
  357. 'region': regions[1].pk,
  358. 'group': groups[1].pk,
  359. 'status': SiteStatusChoices.STATUS_ACTIVE,
  360. 'asns': [asns[4].pk, asns[5].pk],
  361. },
  362. ]
  363. cls.graphql_query_tests = (
  364. GraphQLQueryTest(
  365. name='nested_locations_by_status',
  366. query=(
  367. '{ site(id: ' + str(cls.nested_site_pk) + ') { '
  368. 'locations(filters: {status: {exact: STATUS_ACTIVE}}) { name } '
  369. '} }'
  370. ),
  371. assert_result=cls.assert_nested_locations_active,
  372. permissions=('dcim.view_location',),
  373. ),
  374. )
  375. def test_add_tags(self):
  376. """
  377. Add tags to an existing object via the add_tags field.
  378. """
  379. site = Site.objects.first()
  380. tags = Tag.objects.bulk_create((
  381. Tag(name='Alpha', slug='alpha'),
  382. Tag(name='Bravo', slug='bravo'),
  383. Tag(name='Charlie', slug='charlie'),
  384. ))
  385. site.tags.set([tags[0], tags[1]])
  386. # Grant change permission
  387. obj_perm = ObjectPermission(name='Test permission', actions=['change'])
  388. obj_perm.save()
  389. obj_perm.users.add(self.user)
  390. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  391. url = self._get_detail_url(site)
  392. data = {
  393. 'add_tags': [{'name': 'Charlie'}],
  394. }
  395. response = self.client.patch(url, data, format='json', **self.header)
  396. self.assertHttpStatus(response, status.HTTP_200_OK)
  397. # Verify all three tags are now assigned
  398. tag_names = sorted(site.tags.values_list('name', flat=True))
  399. self.assertEqual(tag_names, ['Alpha', 'Bravo', 'Charlie'])
  400. # Verify add_tags and remove_tags are not in the response
  401. self.assertNotIn('add_tags', response.data)
  402. self.assertNotIn('remove_tags', response.data)
  403. self.assertIn('tags', response.data)
  404. def test_remove_tags(self):
  405. """
  406. Remove tags from an existing object via the remove_tags field.
  407. """
  408. site = Site.objects.first()
  409. tags = Tag.objects.bulk_create((
  410. Tag(name='Alpha', slug='alpha'),
  411. Tag(name='Bravo', slug='bravo'),
  412. Tag(name='Charlie', slug='charlie'),
  413. ))
  414. site.tags.set(tags)
  415. # Grant change permission
  416. obj_perm = ObjectPermission(name='Test permission', actions=['change'])
  417. obj_perm.save()
  418. obj_perm.users.add(self.user)
  419. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  420. url = self._get_detail_url(site)
  421. data = {
  422. 'remove_tags': [{'name': 'Charlie'}],
  423. }
  424. response = self.client.patch(url, data, format='json', **self.header)
  425. self.assertHttpStatus(response, status.HTTP_200_OK)
  426. # Verify only Alpha and Bravo remain
  427. tag_names = sorted(site.tags.values_list('name', flat=True))
  428. self.assertEqual(tag_names, ['Alpha', 'Bravo'])
  429. def test_remove_tags_not_assigned(self):
  430. """
  431. Removing a tag that is not assigned should not raise an error.
  432. """
  433. site = Site.objects.first()
  434. tags = Tag.objects.bulk_create((
  435. Tag(name='Alpha', slug='alpha'),
  436. Tag(name='Bravo', slug='bravo'),
  437. Tag(name='Charlie', slug='charlie'),
  438. ))
  439. site.tags.set([tags[0], tags[1]])
  440. # Grant change permission
  441. obj_perm = ObjectPermission(name='Test permission', actions=['change'])
  442. obj_perm.save()
  443. obj_perm.users.add(self.user)
  444. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  445. url = self._get_detail_url(site)
  446. data = {
  447. 'remove_tags': [{'name': 'Charlie'}],
  448. }
  449. response = self.client.patch(url, data, format='json', **self.header)
  450. self.assertHttpStatus(response, status.HTTP_200_OK)
  451. # Tags should be unchanged
  452. tag_names = sorted(site.tags.values_list('name', flat=True))
  453. self.assertEqual(tag_names, ['Alpha', 'Bravo'])
  454. def test_add_and_remove_tags(self):
  455. """
  456. Add and remove tags in the same request.
  457. """
  458. site = Site.objects.first()
  459. tags = Tag.objects.bulk_create((
  460. Tag(name='Alpha', slug='alpha'),
  461. Tag(name='Bravo', slug='bravo'),
  462. Tag(name='Charlie', slug='charlie'),
  463. ))
  464. site.tags.set([tags[0], tags[1]])
  465. # Grant change permission
  466. obj_perm = ObjectPermission(name='Test permission', actions=['change'])
  467. obj_perm.save()
  468. obj_perm.users.add(self.user)
  469. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  470. url = self._get_detail_url(site)
  471. data = {
  472. 'add_tags': [{'name': 'Charlie'}],
  473. 'remove_tags': [{'name': 'Alpha'}],
  474. }
  475. response = self.client.patch(url, data, format='json', **self.header)
  476. self.assertHttpStatus(response, status.HTTP_200_OK)
  477. # Verify Bravo and Charlie remain
  478. tag_names = sorted(site.tags.values_list('name', flat=True))
  479. self.assertEqual(tag_names, ['Bravo', 'Charlie'])
  480. def test_tags_with_add_tags_error(self):
  481. """
  482. Specifying tags together with add_tags or remove_tags should raise a validation error.
  483. """
  484. site = Site.objects.first()
  485. Tag.objects.bulk_create((
  486. Tag(name='Alpha', slug='alpha'),
  487. Tag(name='Bravo', slug='bravo'),
  488. ))
  489. # Grant change permission
  490. obj_perm = ObjectPermission(name='Test permission', actions=['change'])
  491. obj_perm.save()
  492. obj_perm.users.add(self.user)
  493. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  494. url = self._get_detail_url(site)
  495. data = {
  496. 'tags': [{'name': 'Alpha'}],
  497. 'add_tags': [{'name': 'Bravo'}],
  498. }
  499. response = self.client.patch(url, data, format='json', **self.header)
  500. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  501. def test_create_with_add_tags(self):
  502. """
  503. Create a new object using add_tags.
  504. """
  505. Tag.objects.bulk_create((
  506. Tag(name='Alpha', slug='alpha'),
  507. Tag(name='Bravo', slug='bravo'),
  508. ))
  509. obj_perm = ObjectPermission(name='Test permission', actions=['add'])
  510. obj_perm.save()
  511. obj_perm.users.add(self.user)
  512. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  513. data = {
  514. 'name': 'Site 10',
  515. 'slug': 'site-10',
  516. 'add_tags': [{'name': 'Alpha'}, {'name': 'Bravo'}],
  517. }
  518. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  519. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  520. site = Site.objects.get(pk=response.data['id'])
  521. tag_names = sorted(site.tags.values_list('name', flat=True))
  522. self.assertEqual(tag_names, ['Alpha', 'Bravo'])
  523. def test_create_with_remove_tags_error(self):
  524. """
  525. Using remove_tags when creating a new object should raise a validation error.
  526. """
  527. Tag.objects.bulk_create((
  528. Tag(name='Alpha', slug='alpha'),
  529. ))
  530. obj_perm = ObjectPermission(name='Test permission', actions=['add'])
  531. obj_perm.save()
  532. obj_perm.users.add(self.user)
  533. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  534. data = {
  535. 'name': 'Site 10',
  536. 'slug': 'site-10',
  537. 'remove_tags': [{'name': 'Alpha'}],
  538. }
  539. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  540. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  541. def test_add_and_remove_same_tag_error(self):
  542. """
  543. Including the same tag in both add_tags and remove_tags should raise a validation error.
  544. """
  545. site = Site.objects.first()
  546. Tag.objects.bulk_create((
  547. Tag(name='Alpha', slug='alpha'),
  548. Tag(name='Bravo', slug='bravo'),
  549. ))
  550. obj_perm = ObjectPermission(name='Test permission', actions=['change'])
  551. obj_perm.save()
  552. obj_perm.users.add(self.user)
  553. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  554. url = self._get_detail_url(site)
  555. data = {
  556. 'add_tags': [{'name': 'Alpha'}, {'name': 'Bravo'}],
  557. 'remove_tags': [{'name': 'Alpha'}],
  558. }
  559. response = self.client.patch(url, data, format='json', **self.header)
  560. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  561. def test_bulk_update_objects_non_list_body(self):
  562. """
  563. PATCH a list endpoint with a body which is not a list. The response should identify the
  564. problem with the request as a whole, as there are no entries to report against.
  565. """
  566. obj_perm = ObjectPermission(name='Test permission', actions=['change'])
  567. obj_perm.save()
  568. obj_perm.users.add(self.user)
  569. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  570. site = Site.objects.get(slug='site-1')
  571. response = self.client.patch(
  572. self._get_list_url(), {'id': site.pk, 'description': 'x'}, format='json', **self.header
  573. )
  574. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  575. self.assertIn('detail', response.data)
  576. self.assertNotIn('errors', response.data)
  577. site.refresh_from_db()
  578. self.assertEqual(site.description, '')
  579. # A non-list body is described by its type, so that the client can see what was sent
  580. self.assertEqual(response.data['detail'], 'Expected a list of objects, but got dict.')
  581. # A multipart body reaches the bulk action as a QueryDict, which must be reported as the
  582. # dict the client submitted rather than by that internal class name
  583. response = self.client.patch(
  584. self._get_list_url(), {'id': site.pk, 'description': 'x'}, format='multipart', **self.header
  585. )
  586. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  587. self.assertEqual(response.data['detail'], 'Expected a list of objects, but got dict.')
  588. site.refresh_from_db()
  589. self.assertEqual(site.description, '')
  590. def test_bulk_write_objects_empty_body(self):
  591. """
  592. Address a list endpoint with no body at all. An absent body reaches the bulk actions as an
  593. empty dict, so it must not be reported as having "got dict" -- there is no object to describe.
  594. """
  595. obj_perm = ObjectPermission(name='Test permission', actions=['change', 'delete'])
  596. obj_perm.save()
  597. obj_perm.users.add(self.user)
  598. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  599. initial_count = Site.objects.count()
  600. for method in ('patch', 'put', 'delete'):
  601. with self.subTest(method=method):
  602. response = getattr(self.client, method)(self._get_list_url(), **self.header)
  603. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  604. self.assertEqual(
  605. response.data['detail'], 'Expected a list of objects, but no data was submitted.'
  606. )
  607. self.assertNotIn('errors', response.data)
  608. # An explicitly submitted empty object is indistinguishable, and reads the same way
  609. response = self.client.patch(self._get_list_url(), {}, format='json', **self.header)
  610. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  611. self.assertEqual(
  612. response.data['detail'], 'Expected a list of objects, but no data was submitted.'
  613. )
  614. self.assertEqual(Site.objects.count(), initial_count, 'No objects should have been deleted')
  615. def test_bulk_update_objects_non_numeric_id(self):
  616. """
  617. PATCH a set of objects where one entry carries a non-numeric ID. The failure must be
  618. correlated by position, in the same structured form as every other bulk error.
  619. """
  620. obj_perm = ObjectPermission(name='Test permission', actions=['change'])
  621. obj_perm.save()
  622. obj_perm.users.add(self.user)
  623. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  624. site = Site.objects.get(slug='site-1')
  625. data = [{'id': site.pk, 'description': 'x'}, {'id': 'not-a-number', 'description': 'y'}]
  626. response = self.client.patch(self._get_list_url(), data, format='json', **self.header)
  627. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  628. self.assertEqual(len(response.data['errors']), 1)
  629. self.assertEqual(response.data['errors'][0]['index'], 1)
  630. self.assertIn('id', response.data['errors'][0]['errors'])
  631. # The valid entry must not have been applied
  632. site.refresh_from_db()
  633. self.assertEqual(site.description, '')
  634. def test_bulk_write_objects_null_entry(self):
  635. """
  636. Address a list endpoint with a list containing a null entry. A null fails before any field
  637. is considered, so its error arrives as a bare list of messages; it must still be reported
  638. as a mapping keyed by field name, as the schema declares.
  639. """
  640. obj_perm = ObjectPermission(name='Test permission', actions=['change', 'delete'])
  641. obj_perm.save()
  642. obj_perm.users.add(self.user)
  643. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  644. site = Site.objects.get(slug='site-1')
  645. initial_count = Site.objects.count()
  646. for method in ('patch', 'put', 'delete'):
  647. with self.subTest(method=method):
  648. data = [{'id': site.pk, 'description': 'x'}, None]
  649. response = getattr(self.client, method)(
  650. self._get_list_url(), data, format='json', **self.header
  651. )
  652. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  653. self.assertEqual(len(response.data['errors']), 1)
  654. self.assertEqual(response.data['errors'][0]['index'], 1)
  655. # Reported under the key which every other non-field error uses
  656. entry_errors = response.data['errors'][0]['errors']
  657. self.assertIsInstance(entry_errors, dict)
  658. self.assertIn('__all__', entry_errors)
  659. # The valid entry must not have been applied
  660. site.refresh_from_db()
  661. self.assertEqual(site.description, '')
  662. self.assertEqual(Site.objects.count(), initial_count, 'No objects should have been deleted')
  663. def test_bulk_update_objects_duplicate_id_invalid_entry(self):
  664. """
  665. PATCH a set of objects in which one object is named twice, once with invalid data and once
  666. with valid data. The invalid entry must not be discarded in favor of the valid one.
  667. """
  668. obj_perm = ObjectPermission(name='Test permission', actions=['change'])
  669. obj_perm.save()
  670. obj_perm.users.add(self.user)
  671. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  672. site = Site.objects.get(slug='site-1')
  673. data = [
  674. {'id': site.pk, 'name': ''}, # Invalid: name is required
  675. {'id': site.pk, 'name': 'Renamed Site'},
  676. ]
  677. response = self.client.patch(self._get_list_url(), data, format='json', **self.header)
  678. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  679. self.assertEqual([e['id'] for e in response.data['errors']], [site.pk])
  680. # The valid entry must not have been applied
  681. site.refresh_from_db()
  682. self.assertEqual(site.name, 'Site 1')
  683. def test_bulk_delete_objects_duplicate_id_changelog_message(self):
  684. """
  685. DELETE a set of objects in which one object is named twice with differing changelog
  686. messages. The request must be rejected rather than recording only one of the messages.
  687. """
  688. obj_perm = ObjectPermission(name='Test permission', actions=['delete'])
  689. obj_perm.save()
  690. obj_perm.users.add(self.user)
  691. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  692. site = Site.objects.get(slug='site-1')
  693. data = [
  694. {'id': site.pk, 'changelog_message': 'First message'},
  695. {'id': site.pk, 'changelog_message': 'Second message'},
  696. ]
  697. response = self.client.delete(self._get_list_url(), data, format='json', **self.header)
  698. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  699. self.assertEqual([e['id'] for e in response.data['errors']], [site.pk])
  700. self.assertTrue(Site.objects.filter(pk=site.pk).exists())
  701. def test_bulk_create_objects_conflicting(self):
  702. """
  703. POST a set of objects in which two conflict with one another. Objects are created one at a
  704. time, so the second must fail validation against the first rather than passing validation
  705. and then raising an IntegrityError on save.
  706. """
  707. obj_perm = ObjectPermission(name='Test permission', actions=['add'])
  708. obj_perm.save()
  709. obj_perm.users.add(self.user)
  710. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  711. initial_count = self._get_queryset().count()
  712. data = [
  713. {'name': 'Site 10', 'slug': 'site-10'},
  714. {'name': 'Site 11', 'slug': 'site-11'},
  715. {'name': 'Site 10', 'slug': 'site-10'}, # Duplicates the first item
  716. ]
  717. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  718. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  719. self.assertEqual(self._get_queryset().count(), initial_count)
  720. # Only the third item failed; the first two were provisionally created and rolled back
  721. self.assertEqual(len(response.data['errors']), 1)
  722. self.assertEqual(response.data['errors'][0]['index'], 2)
  723. self.assertIn('slug', response.data['errors'][0]['errors'])
  724. def test_bulk_delete_objects_protected(self):
  725. """
  726. DELETE a set of objects where one has a protected FK dependency. Verify the structured
  727. per-object error response and that no objects are deleted (atomic rollback).
  728. """
  729. obj_perm = ObjectPermission(name='Test permission', actions=['delete'])
  730. obj_perm.save()
  731. obj_perm.users.add(self.user)
  732. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  733. # Site 1 has no dependent Device; Site 2 gets one (Device FK is on_delete=PROTECT)
  734. site1 = Site.objects.get(slug='site-1')
  735. site2 = Site.objects.get(slug='site-2')
  736. create_test_device('Protected Device', site=site2)
  737. data = [{'id': site1.pk}, {'id': site2.pk}]
  738. response = self.client.delete(self._get_list_url(), data, format='json', **self.header)
  739. self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
  740. self.assertIn('detail', response.data)
  741. self.assertIn('errors', response.data)
  742. self.assertEqual(len(response.data['errors']), 1)
  743. # Site 2 (has Device) should be the only entry, since Site 1 succeeded
  744. self.assertEqual(response.data['errors'][0]['id'], site2.pk)
  745. self.assertIn('errors', response.data['errors'][0])
  746. # Verify that no sites were actually deleted (transaction rolled back)
  747. self.assertTrue(Site.objects.filter(pk=site1.pk).exists(), 'Site 1 should not have been deleted')
  748. self.assertTrue(Site.objects.filter(pk=site2.pk).exists(), 'Site 2 should not have been deleted')
  749. def test_bulk_update_objects_unpermitted(self):
  750. """
  751. PATCH a set of objects where the requesting user's object-level permissions exclude one of
  752. them. The excluded object must be reported rather than silently omitted from the response.
  753. """
  754. site1 = Site.objects.get(slug='site-1')
  755. site2 = Site.objects.get(slug='site-2')
  756. obj_perm = ObjectPermission(name='Test permission', actions=['change'], constraints={'slug': 'site-1'})
  757. obj_perm.save()
  758. obj_perm.users.add(self.user)
  759. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  760. data = [
  761. {'id': site1.pk, 'description': 'Permitted'},
  762. {'id': site2.pk, 'description': 'Not permitted'},
  763. ]
  764. response = self.client.patch(self._get_list_url(), data, format='json', **self.header)
  765. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  766. self.assertIn('detail', response.data)
  767. self.assertEqual(len(response.data['errors']), 1)
  768. self.assertEqual(response.data['errors'][0]['id'], site2.pk)
  769. # Neither site may have been updated, including the one the user is permitted to change
  770. site1.refresh_from_db()
  771. site2.refresh_from_db()
  772. self.assertEqual(site1.description, '', 'Site 1 should not have been updated')
  773. self.assertEqual(site2.description, '', 'Site 2 should not have been updated')
  774. def test_bulk_delete_objects_unpermitted(self):
  775. """
  776. DELETE a set of objects where the requesting user's object-level permissions exclude one of
  777. them. The excluded object must be reported rather than the request reporting success.
  778. """
  779. site1 = Site.objects.get(slug='site-1')
  780. site2 = Site.objects.get(slug='site-2')
  781. obj_perm = ObjectPermission(name='Test permission', actions=['delete'], constraints={'slug': 'site-1'})
  782. obj_perm.save()
  783. obj_perm.users.add(self.user)
  784. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  785. data = [{'id': site1.pk}, {'id': site2.pk}]
  786. response = self.client.delete(self._get_list_url(), data, format='json', **self.header)
  787. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  788. self.assertIn('detail', response.data)
  789. self.assertEqual(len(response.data['errors']), 1)
  790. self.assertEqual(response.data['errors'][0]['id'], site2.pk)
  791. # Neither site may have been deleted, including the one the user is permitted to delete
  792. self.assertTrue(Site.objects.filter(pk=site1.pk).exists(), 'Site 1 should not have been deleted')
  793. self.assertTrue(Site.objects.filter(pk=site2.pk).exists(), 'Site 2 should not have been deleted')
  794. def test_bulk_update_objects_abort_request(self):
  795. """
  796. PATCH a set of objects where a signal receiver raises AbortRequest for more than one of
  797. them. Each failure must be correlated to its own object (proving the batch continues past
  798. the first abort) and no object may be modified.
  799. """
  800. # This tag may only be assigned to Regions, so assigning it to a Site raises AbortRequest
  801. # from extras.signals.validate_assigned_tags.
  802. restricted_tag = Tag.objects.create(name='Regions Only', slug='regions-only')
  803. restricted_tag.object_types.set([ObjectType.objects.get_for_model(Region)])
  804. site1 = Site.objects.get(slug='site-1')
  805. site2 = Site.objects.get(slug='site-2')
  806. obj_perm = ObjectPermission(name='Test permission', actions=['change'])
  807. obj_perm.save()
  808. obj_perm.users.add(self.user)
  809. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  810. self.add_permissions('extras.view_tag')
  811. data = [
  812. {'id': site1.pk, 'tags': [{'name': 'Regions Only'}]},
  813. {'id': site2.pk, 'tags': [{'name': 'Regions Only'}]},
  814. ]
  815. response = self.client.patch(self._get_list_url(), data, format='json', **self.header)
  816. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  817. self.assertIn('detail', response.data)
  818. self.assertEqual([e['id'] for e in response.data['errors']], [site1.pk, site2.pk])
  819. for error in response.data['errors']:
  820. # Reported as a non-field error, in the same list-of-messages form as field errors
  821. self.assertIsInstance(error['errors']['__all__'], list)
  822. # Neither site may have been tagged (whole batch rolled back)
  823. self.assertFalse(site1.tags.exists(), 'Site 1 should not have been tagged')
  824. self.assertFalse(site2.tags.exists(), 'Site 2 should not have been tagged')
  825. def test_bulk_delete_objects_abort_request(self):
  826. """
  827. DELETE a set of objects where a protection rule blocks more than one of them. Each failure
  828. must be correlated to its own object and no object may be deleted. A protection rule is a
  829. rejection of the request rather than a conflict with the state of the database, so this
  830. reports 400 -- as the single-object endpoint does for the same rule.
  831. """
  832. site1 = Site.objects.get(slug='site-1')
  833. site2 = Site.objects.get(slug='site-2')
  834. obj_perm = ObjectPermission(name='Test permission', actions=['delete'])
  835. obj_perm.save()
  836. obj_perm.users.add(self.user)
  837. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  838. # Neither site has a description, so the rule blocks both deletions via AbortRequest raised
  839. # from core.signals.handle_deleted_object.
  840. protection_rules = {'dcim.site': [{'description': {'required': True}}]}
  841. data = [{'id': site1.pk}, {'id': site2.pk}]
  842. with override_settings(PROTECTION_RULES=protection_rules):
  843. response = self.client.delete(self._get_list_url(), data, format='json', **self.header)
  844. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  845. self.assertIn('detail', response.data)
  846. self.assertEqual([e['id'] for e in response.data['errors']], [site1.pk, site2.pk])
  847. for error in response.data['errors']:
  848. self.assertIsInstance(error['errors']['__all__'], list)
  849. # Neither site may have been deleted
  850. self.assertTrue(Site.objects.filter(pk=site1.pk).exists(), 'Site 1 should not have been deleted')
  851. self.assertTrue(Site.objects.filter(pk=site2.pk).exists(), 'Site 2 should not have been deleted')
  852. def test_bulk_update_objects_permission_constraint(self):
  853. """
  854. PATCH a set of objects where the update would move one of them outside the requesting user's
  855. object-level permissions. The offending object must be named, rather than the whole batch
  856. failing with an opaque 403, and nothing may be modified.
  857. """
  858. site1 = Site.objects.get(slug='site-1')
  859. site2 = Site.objects.get(slug='site-2')
  860. Site.objects.filter(pk__in=(site1.pk, site2.pk)).update(status=SiteStatusChoices.STATUS_ACTIVE)
  861. # Only active sites may be changed, so setting Site 2's status to "planned" saves the object
  862. # and then fails _validate_objects(), which perform_update() reports as PermissionDenied.
  863. obj_perm = ObjectPermission(
  864. name='Test permission',
  865. actions=['change'],
  866. constraints={'status': SiteStatusChoices.STATUS_ACTIVE},
  867. )
  868. obj_perm.save()
  869. obj_perm.users.add(self.user)
  870. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  871. data = [
  872. {'id': site1.pk, 'description': 'Permitted'},
  873. {'id': site2.pk, 'status': SiteStatusChoices.STATUS_PLANNED},
  874. ]
  875. with disable_warnings('django.request'):
  876. response = self.client.patch(self._get_list_url(), data, format='json', **self.header)
  877. # Still a 403, as the single-object endpoint returns, but now correlated
  878. self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
  879. self.assertIn('detail', response.data)
  880. self.assertEqual([e['id'] for e in response.data['errors']], [site2.pk])
  881. self.assertIsInstance(response.data['errors'][0]['errors']['__all__'], list)
  882. # Neither site may have been modified, including the permitted one
  883. site1.refresh_from_db()
  884. site2.refresh_from_db()
  885. self.assertEqual(site1.description, '', 'Site 1 should not have been updated')
  886. self.assertEqual(site2.status, SiteStatusChoices.STATUS_ACTIVE, 'Site 2 should not have been updated')
  887. def test_bulk_update_objects_permission_constraint_and_validation_error(self):
  888. """
  889. PATCH a set of objects where one entry is invalid and another is refused by object-level
  890. permissions. Both must be reported, and the authorization failure must determine the status
  891. code: it is the failure which would remain were the invalid entry corrected.
  892. """
  893. site1 = Site.objects.get(slug='site-1')
  894. site2 = Site.objects.get(slug='site-2')
  895. Site.objects.filter(pk__in=(site1.pk, site2.pk)).update(status=SiteStatusChoices.STATUS_ACTIVE)
  896. obj_perm = ObjectPermission(
  897. name='Test permission',
  898. actions=['change'],
  899. constraints={'status': SiteStatusChoices.STATUS_ACTIVE},
  900. )
  901. obj_perm.save()
  902. obj_perm.users.add(self.user)
  903. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  904. data = [
  905. {'id': site1.pk, 'status': 'not-a-valid-status'}, # Fails validation (400)
  906. {'id': site2.pk, 'status': SiteStatusChoices.STATUS_PLANNED}, # Not permitted (403)
  907. ]
  908. with disable_warnings('django.request'):
  909. response = self.client.patch(self._get_list_url(), data, format='json', **self.header)
  910. self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
  911. self.assertEqual([e['id'] for e in response.data['errors']], [site1.pk, site2.pk])
  912. site1.refresh_from_db()
  913. site2.refresh_from_db()
  914. self.assertEqual(site1.status, SiteStatusChoices.STATUS_ACTIVE)
  915. self.assertEqual(site2.status, SiteStatusChoices.STATUS_ACTIVE)
  916. def test_bulk_create_objects_permission_constraint(self):
  917. """
  918. POST a set of objects where one falls outside the requesting user's object-level permissions.
  919. The offending object must be correlated by its position, and nothing may be created.
  920. """
  921. obj_perm = ObjectPermission(
  922. name='Test permission',
  923. actions=['add'],
  924. constraints={'status': SiteStatusChoices.STATUS_ACTIVE},
  925. )
  926. obj_perm.save()
  927. obj_perm.users.add(self.user)
  928. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  929. initial_count = self._get_queryset().count()
  930. data = [
  931. {'name': 'Site 20', 'slug': 'site-20', 'status': SiteStatusChoices.STATUS_ACTIVE},
  932. {'name': 'Site 21', 'slug': 'site-21', 'status': SiteStatusChoices.STATUS_PLANNED},
  933. ]
  934. with disable_warnings('django.request'):
  935. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  936. self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
  937. self.assertEqual([e['index'] for e in response.data['errors']], [1])
  938. self.assertIsInstance(response.data['errors'][0]['errors']['__all__'], list)
  939. self.assertEqual(
  940. self._get_queryset().count(), initial_count,
  941. 'No objects should be created when any sibling is not permitted',
  942. )
  943. def test_bulk_delete_objects_conflict_and_abort_request(self):
  944. """
  945. DELETE a set of objects where one is blocked by a dependent object and another by a
  946. protection rule. Both failures must be reported, and the dependency conflict must determine
  947. the status code: it is the failure which would remain were the request itself corrected.
  948. """
  949. site1 = Site.objects.get(slug='site-1')
  950. site2 = Site.objects.get(slug='site-2')
  951. obj_perm = ObjectPermission(name='Test permission', actions=['delete'])
  952. obj_perm.save()
  953. obj_perm.users.add(self.user)
  954. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  955. # Site 1 is blocked by a dependent Device (ProtectedError); Site 2 is blocked by the
  956. # protection rule below, as it has no description (AbortRequest). Site 1 is given a
  957. # description so that only one of the two failure modes applies to it.
  958. create_test_device('Protected Device', site=site1)
  959. site1.description = 'Has a description'
  960. site1.save()
  961. protection_rules = {'dcim.site': [{'description': {'required': True}}]}
  962. data = [{'id': site1.pk}, {'id': site2.pk}]
  963. with override_settings(PROTECTION_RULES=protection_rules):
  964. response = self.client.delete(self._get_list_url(), data, format='json', **self.header)
  965. self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
  966. self.assertEqual([e['id'] for e in response.data['errors']], [site1.pk, site2.pk])
  967. for error in response.data['errors']:
  968. self.assertIsInstance(error['errors']['__all__'], list)
  969. # Neither site may have been deleted
  970. self.assertTrue(Site.objects.filter(pk=site1.pk).exists(), 'Site 1 should not have been deleted')
  971. self.assertTrue(Site.objects.filter(pk=site2.pk).exists(), 'Site 2 should not have been deleted')
  972. class LocationTestCase(APIViewTestCases.APIViewTestCase):
  973. model = Location
  974. brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
  975. bulk_update_data = {
  976. 'description': 'New description',
  977. }
  978. bulk_update_invalid_data = {
  979. 'status': 'not-a-valid-status',
  980. }
  981. user_permissions = ('dcim.view_site',)
  982. graphql_filter_tests = (
  983. GraphQLFilterTest(
  984. name='status__in_list',
  985. filters='status: {in_list: [STATUS_PLANNED, STATUS_STAGING]}',
  986. expected=lambda qs: qs.filter(status__in=[
  987. LocationStatusChoices.STATUS_PLANNED,
  988. LocationStatusChoices.STATUS_STAGING,
  989. ]),
  990. ),
  991. )
  992. @classmethod
  993. def setUpTestData(cls):
  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. parent_locations = (
  1000. Location.objects.create(
  1001. site=sites[0],
  1002. name='Parent Location 1',
  1003. slug='parent-location-1',
  1004. status=LocationStatusChoices.STATUS_ACTIVE,
  1005. comments='First!'
  1006. ),
  1007. Location.objects.create(
  1008. site=sites[1],
  1009. name='Parent Location 2',
  1010. slug='parent-location-2',
  1011. status=LocationStatusChoices.STATUS_ACTIVE,
  1012. comments='Second!'
  1013. ),
  1014. )
  1015. Location.objects.create(
  1016. site=sites[0],
  1017. name='Location 1',
  1018. slug='location-1',
  1019. parent=parent_locations[0],
  1020. status=LocationStatusChoices.STATUS_ACTIVE,
  1021. comments='Third!'
  1022. )
  1023. Location.objects.create(
  1024. site=sites[0],
  1025. name='Location 2',
  1026. slug='location-2',
  1027. parent=parent_locations[0],
  1028. status=LocationStatusChoices.STATUS_ACTIVE,
  1029. )
  1030. Location.objects.create(
  1031. site=sites[0],
  1032. name='Location 3',
  1033. slug='location-3',
  1034. parent=parent_locations[0],
  1035. status=LocationStatusChoices.STATUS_ACTIVE,
  1036. )
  1037. Location.objects.create(
  1038. site=sites[0],
  1039. name='GraphQL Planned Location',
  1040. slug='graphql-planned-location',
  1041. parent=parent_locations[0],
  1042. status=LocationStatusChoices.STATUS_PLANNED,
  1043. )
  1044. Location.objects.create(
  1045. site=sites[0],
  1046. name='GraphQL Staging Location',
  1047. slug='graphql-staging-location',
  1048. parent=parent_locations[0],
  1049. status=LocationStatusChoices.STATUS_STAGING,
  1050. )
  1051. cls.create_data = [
  1052. {
  1053. 'name': 'Test Location 4',
  1054. 'slug': 'test-location-4',
  1055. 'site': sites[1].pk,
  1056. 'parent': parent_locations[1].pk,
  1057. 'status': LocationStatusChoices.STATUS_PLANNED,
  1058. 'comments': '',
  1059. },
  1060. {
  1061. 'name': 'Test Location 5',
  1062. 'slug': 'test-location-5',
  1063. 'site': sites[1].pk,
  1064. 'parent': parent_locations[1].pk,
  1065. 'status': LocationStatusChoices.STATUS_PLANNED,
  1066. 'comments': 'Somebody should check on this location',
  1067. },
  1068. {
  1069. 'name': 'Test Location 6',
  1070. 'slug': 'test-location-6',
  1071. 'site': sites[1].pk,
  1072. # Omit parent to test uniqueness constraint
  1073. 'status': LocationStatusChoices.STATUS_PLANNED,
  1074. },
  1075. ]
  1076. class RackGroupTestCase(APIViewTestCases.APIViewTestCase):
  1077. model = RackGroup
  1078. brief_fields = ['description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
  1079. create_data = [
  1080. {
  1081. 'name': 'Rack Group 4',
  1082. 'slug': 'rack-group-4',
  1083. },
  1084. {
  1085. 'name': 'Rack Group 5',
  1086. 'slug': 'rack-group-5',
  1087. },
  1088. {
  1089. 'name': 'Rack Group 6',
  1090. 'slug': 'rack-group-6',
  1091. },
  1092. ]
  1093. bulk_update_data = {
  1094. 'description': 'New description',
  1095. }
  1096. bulk_update_invalid_data = {
  1097. 'owner': 99999,
  1098. }
  1099. @classmethod
  1100. def setUpTestData(cls):
  1101. rack_groups = (
  1102. RackGroup(name='Rack Group 1', slug='rack-group-1'),
  1103. RackGroup(name='Rack Group 2', slug='rack-group-2'),
  1104. RackGroup(name='Rack Group 3', slug='rack-group-3'),
  1105. )
  1106. RackGroup.objects.bulk_create(rack_groups)
  1107. class RackRoleTestCase(APIViewTestCases.APIViewTestCase):
  1108. model = RackRole
  1109. brief_fields = ['description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
  1110. create_data = [
  1111. {
  1112. 'name': 'Rack Role 4',
  1113. 'slug': 'rack-role-4',
  1114. 'color': 'ffff00',
  1115. },
  1116. {
  1117. 'name': 'Rack Role 5',
  1118. 'slug': 'rack-role-5',
  1119. 'color': 'ffff00',
  1120. },
  1121. {
  1122. 'name': 'Rack Role 6',
  1123. 'slug': 'rack-role-6',
  1124. 'color': 'ffff00',
  1125. },
  1126. ]
  1127. bulk_update_data = {
  1128. 'description': 'New description',
  1129. }
  1130. bulk_update_invalid_data = {
  1131. 'color': 'not-a-color',
  1132. }
  1133. @classmethod
  1134. def setUpTestData(cls):
  1135. rack_roles = (
  1136. RackRole(name='Rack Role 1', slug='rack-role-1', color='ff0000'),
  1137. RackRole(name='Rack Role 2', slug='rack-role-2', color='00ff00'),
  1138. RackRole(name='Rack Role 3', slug='rack-role-3', color='0000ff'),
  1139. )
  1140. RackRole.objects.bulk_create(rack_roles)
  1141. class RackTypeTestCase(APIViewTestCases.APIViewTestCase):
  1142. model = RackType
  1143. brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'rack_count', 'slug', 'url']
  1144. bulk_update_data = {
  1145. 'description': 'new description',
  1146. 'cooling_capability': RackCoolingCapabilityChoices.CAPABILITY_HYBRID,
  1147. 'cooling_capacity': 50,
  1148. }
  1149. bulk_update_invalid_data = {
  1150. 'form_factor': 'not-a-valid-form-factor',
  1151. }
  1152. user_permissions = ('dcim.view_manufacturer',)
  1153. @classmethod
  1154. def setUpTestData(cls):
  1155. manufacturers = (
  1156. Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
  1157. Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
  1158. )
  1159. Manufacturer.objects.bulk_create(manufacturers)
  1160. rack_types = (
  1161. RackType(
  1162. manufacturer=manufacturers[0],
  1163. model='Rack Type 1',
  1164. slug='rack-type-1',
  1165. form_factor=RackFormFactorChoices.TYPE_CABINET,
  1166. ),
  1167. RackType(
  1168. manufacturer=manufacturers[0],
  1169. model='Rack Type 2',
  1170. slug='rack-type-2',
  1171. form_factor=RackFormFactorChoices.TYPE_CABINET,
  1172. ),
  1173. RackType(
  1174. manufacturer=manufacturers[0],
  1175. model='Rack Type 3',
  1176. slug='rack-type-3',
  1177. form_factor=RackFormFactorChoices.TYPE_CABINET,
  1178. ),
  1179. )
  1180. RackType.objects.bulk_create(rack_types)
  1181. cls.create_data = [
  1182. {
  1183. 'manufacturer': manufacturers[1].pk,
  1184. 'model': 'Rack Type 4',
  1185. 'slug': 'rack-type-4',
  1186. 'form_factor': RackFormFactorChoices.TYPE_CABINET,
  1187. 'cooling_capability': RackCoolingCapabilityChoices.CAPABILITY_LIQUID_ONLY,
  1188. 'cooling_capacity': 80,
  1189. },
  1190. {
  1191. 'manufacturer': manufacturers[1].pk,
  1192. 'model': 'Rack Type 5',
  1193. 'slug': 'rack-type-5',
  1194. 'form_factor': RackFormFactorChoices.TYPE_CABINET,
  1195. 'cooling_capability': RackCoolingCapabilityChoices.CAPABILITY_HYBRID,
  1196. },
  1197. {
  1198. 'manufacturer': manufacturers[1].pk,
  1199. 'model': 'Rack Type 6',
  1200. 'slug': 'rack-type-6',
  1201. 'form_factor': RackFormFactorChoices.TYPE_CABINET,
  1202. },
  1203. ]
  1204. def test_form_factor_required(self):
  1205. """
  1206. form_factor must be reported as required by OPTIONS, and a POST omitting it
  1207. must be rejected with a normal "required" validation error rather than a
  1208. model-level "cannot be blank" error.
  1209. """
  1210. self.add_permissions('dcim.add_racktype')
  1211. response = self.client.options(self._get_list_url(), **self.header)
  1212. self.assertHttpStatus(response, status.HTTP_200_OK)
  1213. self.assertTrue(response.data['actions']['POST']['form_factor']['required'])
  1214. manufacturer = Manufacturer.objects.first()
  1215. data = {
  1216. 'manufacturer': manufacturer.pk,
  1217. 'model': 'Rack Type Missing Form Factor',
  1218. 'slug': 'rack-type-missing-form-factor',
  1219. }
  1220. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  1221. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1222. self.assertEqual(response.data['form_factor'][0].code, 'required')
  1223. class RackTestCase(APIViewTestCases.APIViewTestCase):
  1224. model = Rack
  1225. brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'url']
  1226. bulk_update_data = {
  1227. 'status': 'planned',
  1228. }
  1229. bulk_update_invalid_data = {
  1230. 'status': 'not-a-valid-status',
  1231. }
  1232. user_permissions = ('dcim.view_site', )
  1233. @classmethod
  1234. def setUpTestData(cls):
  1235. sites = (
  1236. Site(name='Site 1', slug='site-1'),
  1237. Site(name='Site 2', slug='site-2'),
  1238. )
  1239. Site.objects.bulk_create(sites)
  1240. locations = (
  1241. Location.objects.create(site=sites[0], name='Location 1', slug='location-1'),
  1242. Location.objects.create(site=sites[1], name='Location 2', slug='location-2'),
  1243. )
  1244. rack_groups = (
  1245. RackGroup(name='Rack Group 1', slug='rack-group-1'),
  1246. RackGroup(name='Rack Group 2', slug='rack-group-2'),
  1247. )
  1248. RackGroup.objects.bulk_create(rack_groups)
  1249. rack_roles = (
  1250. RackRole(name='Rack Role 1', slug='rack-role-1', color='ff0000'),
  1251. RackRole(name='Rack Role 2', slug='rack-role-2', color='00ff00'),
  1252. )
  1253. RackRole.objects.bulk_create(rack_roles)
  1254. racks = (
  1255. Rack(site=sites[0], location=locations[0], group=rack_groups[0], role=rack_roles[0], name='Rack 1'),
  1256. Rack(site=sites[0], location=locations[0], group=rack_groups[0], role=rack_roles[0], name='Rack 2'),
  1257. Rack(site=sites[0], location=locations[0], group=rack_groups[0], role=rack_roles[0], name='Rack 3'),
  1258. )
  1259. Rack.objects.bulk_create(racks)
  1260. cls.create_data = [
  1261. {
  1262. 'name': 'Test Rack 4',
  1263. 'site': sites[1].pk,
  1264. 'location': locations[1].pk,
  1265. 'group': rack_groups[1].pk,
  1266. 'role': rack_roles[1].pk,
  1267. },
  1268. {
  1269. 'name': 'Test Rack 5',
  1270. 'site': sites[1].pk,
  1271. 'location': locations[1].pk,
  1272. 'group': rack_groups[1].pk,
  1273. 'role': rack_roles[1].pk,
  1274. },
  1275. {
  1276. 'name': 'Test Rack 6',
  1277. 'site': sites[1].pk,
  1278. 'location': locations[1].pk,
  1279. 'group': rack_groups[1].pk,
  1280. 'role': rack_roles[1].pk,
  1281. },
  1282. ]
  1283. def test_ordering_by_count_without_field(self):
  1284. """Ordering by a count left out of the response still sorts by it."""
  1285. self.add_permissions('dcim.view_rack')
  1286. rack = Rack.objects.get(name='Rack 3')
  1287. create_test_device('Device 1', site=rack.site, rack=rack)
  1288. response = self.client.get(
  1289. f'{self._get_list_url()}?ordering=-device_count,name&fields=id,name', **self.header
  1290. )
  1291. self.assertHttpStatus(response, status.HTTP_200_OK)
  1292. self.assertEqual([row['name'] for row in response.data['results']], ['Rack 3', 'Rack 1', 'Rack 2'])
  1293. self.assertNotIn('device_count', response.data['results'][0])
  1294. def test_get_rack_elevation(self):
  1295. """
  1296. GET a single rack elevation.
  1297. """
  1298. rack = Rack.objects.first()
  1299. self.add_permissions('dcim.view_rack')
  1300. url = reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk})
  1301. # Retrieve all units
  1302. response = self.client.get(url, **self.header)
  1303. self.assertEqual(response.data['count'], 84)
  1304. # Search for specific units
  1305. response = self.client.get(f'{url}?q=3', **self.header)
  1306. self.assertEqual(response.data['count'], 26)
  1307. response = self.client.get(f'{url}?q=U3', **self.header)
  1308. self.assertEqual(response.data['count'], 22)
  1309. response = self.client.get(f'{url}?q=U10', **self.header)
  1310. self.assertEqual(response.data['count'], 2)
  1311. def test_get_rack_elevation_description_is_occupying_device_name(self):
  1312. """
  1313. Verify occupied rack units include the occupying device in their description.
  1314. """
  1315. rack = Rack.objects.first()
  1316. self.add_permissions('dcim.view_rack', 'dcim.view_device')
  1317. url = reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk})
  1318. device = create_test_device(
  1319. name='Device A',
  1320. site=rack.site,
  1321. rack=rack,
  1322. position=40,
  1323. face=DeviceFaceChoices.FACE_FRONT,
  1324. )
  1325. # Retrieve all units
  1326. response = self.client.get(url, **self.header)
  1327. self.assertHttpStatus(response, status.HTTP_200_OK)
  1328. occupied_unit = next(unit for unit in response.data['results'] if unit['name'] == 'U40')
  1329. self.assertEqual(occupied_unit['device']['id'], device.pk)
  1330. self.assertEqual(occupied_unit['description'], f'{device}')
  1331. unoccupied_unit = next(unit for unit in response.data['results'] if unit['name'] == 'U39')
  1332. self.assertEqual(unoccupied_unit['device'], None)
  1333. self.assertEqual(unoccupied_unit['description'], None)
  1334. def test_get_rack_elevation_svg(self):
  1335. """
  1336. GET a single rack elevation in SVG format.
  1337. """
  1338. rack = Rack.objects.first()
  1339. self.add_permissions('dcim.view_rack')
  1340. url = '{}?render=svg'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
  1341. response = self.client.get(url, **self.header)
  1342. self.assertHttpStatus(response, status.HTTP_200_OK)
  1343. self.assertEqual(response.get('Content-Type'), 'image/svg+xml')
  1344. def test_get_rack_elevation_svg_highlight(self):
  1345. """
  1346. Highlighting devices in an SVG rack elevation supports only exact matches on permitted fields.
  1347. """
  1348. rack = Rack.objects.first()
  1349. tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1', description='SECRET')
  1350. device1 = create_test_device('Device 1', site=rack.site, rack=rack, position=1, face='front', tenant=tenant)
  1351. create_test_device('Device 2', site=rack.site, rack=rack, position=10, face='front')
  1352. self.add_permissions('dcim.view_rack', 'dcim.view_device')
  1353. url = reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk})
  1354. def is_highlighted(*params):
  1355. query = '&'.join(f'highlight={p}' for p in params)
  1356. response = self.client.get(f'{url}?render=svg&{query}', **self.header)
  1357. self.assertHttpStatus(response, status.HTTP_200_OK)
  1358. return 'slot shaded' in response.content.decode()
  1359. # Supported attributes
  1360. self.assertTrue(is_highlighted(f'id:{device1.pk}'))
  1361. self.assertTrue(is_highlighted('name:Device 1'))
  1362. self.assertFalse(is_highlighted('name:Nonexistent'))
  1363. # Related fields and lookup expressions must be ignored
  1364. self.assertFalse(is_highlighted('tenant__description__startswith:S'))
  1365. self.assertFalse(is_highlighted('tenant__description:SECRET'))
  1366. self.assertFalse(is_highlighted('name__startswith:Device 1'))
  1367. self.assertFalse(is_highlighted(f'tenant_id:{tenant.pk}'))
  1368. # Malformed and invalid values must be ignored
  1369. self.assertFalse(is_highlighted('id'))
  1370. self.assertFalse(is_highlighted('id:foo'))
  1371. self.assertFalse(is_highlighted('name:%00'))
  1372. self.assertTrue(is_highlighted('id:foo', 'name:Device 1'))
  1373. self.assertTrue(is_highlighted('name:%00', 'name:Device 1'))
  1374. class RackReservationTestCase(APIViewTestCases.APIViewTestCase):
  1375. model = RackReservation
  1376. brief_fields = ['description', 'display', 'id', 'status', 'units', 'url', 'user']
  1377. bulk_update_data = {
  1378. 'description': 'New description',
  1379. }
  1380. bulk_update_invalid_data = {
  1381. 'status': 'not-a-valid-status',
  1382. }
  1383. user_permissions = ('dcim.view_rack', 'users.view_user')
  1384. @classmethod
  1385. def setUpTestData(cls):
  1386. user = User.objects.create(username='user1', is_active=True)
  1387. site = Site.objects.create(name='Test Site 1', slug='test-site-1')
  1388. racks = (
  1389. Rack(site=site, name='Rack 1'),
  1390. Rack(site=site, name='Rack 2'),
  1391. )
  1392. Rack.objects.bulk_create(racks)
  1393. rack_reservations = (
  1394. RackReservation(
  1395. rack=racks[0],
  1396. units=[1, 2, 3],
  1397. user=user,
  1398. description='Reservation #1',
  1399. ),
  1400. RackReservation(
  1401. rack=racks[0],
  1402. units=[4, 5, 6],
  1403. user=user,
  1404. description='Reservation #2'
  1405. ),
  1406. RackReservation(
  1407. rack=racks[0],
  1408. units=[7, 8, 9],
  1409. user=user,
  1410. description='Reservation #3',
  1411. ),
  1412. )
  1413. RackReservation.objects.bulk_create(rack_reservations)
  1414. cls.create_data = [
  1415. {
  1416. 'rack': racks[1].pk,
  1417. 'units': [10, 11, 12],
  1418. 'status': RackReservationStatusChoices.STATUS_ACTIVE,
  1419. 'user': user.pk,
  1420. 'description': 'Reservation #4',
  1421. },
  1422. {
  1423. 'rack': racks[1].pk,
  1424. 'units': [13, 14, 15],
  1425. 'status': RackReservationStatusChoices.STATUS_PENDING,
  1426. 'user': user.pk,
  1427. 'description': 'Reservation #5',
  1428. },
  1429. {
  1430. 'rack': racks[1].pk,
  1431. 'units': [16, 17, 18],
  1432. 'status': RackReservationStatusChoices.STATUS_STALE,
  1433. 'user': user.pk,
  1434. 'description': 'Reservation #6',
  1435. },
  1436. ]
  1437. def test_unit_count(self):
  1438. """unit_count should reflect the number of units in the reservation."""
  1439. url = reverse('dcim-api:rackreservation-list')
  1440. self.add_permissions('dcim.view_rackreservation')
  1441. response = self.client.get(url, **self.header)
  1442. self.assertHttpStatus(response, 200)
  1443. for result in response.data['results']:
  1444. self.assertEqual(result['unit_count'], len(result['units']))
  1445. class ManufacturerTestCase(APIViewTestCases.APIViewTestCase):
  1446. model = Manufacturer
  1447. brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
  1448. create_data = [
  1449. {
  1450. 'name': 'Manufacturer 4',
  1451. 'slug': 'manufacturer-4',
  1452. },
  1453. {
  1454. 'name': 'Manufacturer 5',
  1455. 'slug': 'manufacturer-5',
  1456. },
  1457. {
  1458. 'name': 'Manufacturer 6',
  1459. 'slug': 'manufacturer-6',
  1460. },
  1461. ]
  1462. bulk_update_data = {
  1463. 'description': 'New description',
  1464. }
  1465. bulk_update_invalid_data = {
  1466. 'owner': 99999,
  1467. }
  1468. @classmethod
  1469. def setUpTestData(cls):
  1470. manufacturers = (
  1471. Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
  1472. Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
  1473. Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
  1474. )
  1475. Manufacturer.objects.bulk_create(manufacturers)
  1476. class DeviceTypeTestCase(APIViewTestCases.APIViewTestCase):
  1477. model = DeviceType
  1478. brief_fields = ['description', 'device_count', 'display', 'id', 'manufacturer', 'model', 'slug', 'url']
  1479. bulk_update_data = {
  1480. 'part_number': 'ABC123',
  1481. 'end_of_life': '2030-01-01',
  1482. }
  1483. bulk_update_invalid_data = {
  1484. 'airflow': 'not-a-valid-airflow',
  1485. }
  1486. user_permissions = ('dcim.view_manufacturer', )
  1487. @classmethod
  1488. def setUpTestData(cls):
  1489. manufacturers = (
  1490. Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
  1491. Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
  1492. )
  1493. Manufacturer.objects.bulk_create(manufacturers)
  1494. device_types = (
  1495. DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'),
  1496. DeviceType(manufacturer=manufacturers[0], model='Device Type 2', slug='device-type-2'),
  1497. DeviceType(manufacturer=manufacturers[0], model='Device Type 3', slug='device-type-3'),
  1498. )
  1499. DeviceType.objects.bulk_create(device_types)
  1500. cls.create_data = [
  1501. {
  1502. 'manufacturer': manufacturers[1].pk,
  1503. 'model': 'Device Type 4',
  1504. 'slug': 'device-type-4',
  1505. 'u_height': 0,
  1506. },
  1507. {
  1508. 'manufacturer': manufacturers[1].pk,
  1509. 'model': 'Device Type 5',
  1510. 'slug': 'device-type-5',
  1511. 'u_height': 0.5,
  1512. 'end_of_life': '2035-06-30',
  1513. },
  1514. {
  1515. 'manufacturer': manufacturers[1].pk,
  1516. 'model': 'Device Type 6',
  1517. 'slug': 'device-type-6',
  1518. 'u_height': 1,
  1519. },
  1520. ]
  1521. def test_create_object_with_images(self):
  1522. """
  1523. Create a DeviceType with front/rear images via a multipart request.
  1524. """
  1525. self.add_permissions('dcim.add_devicetype')
  1526. # A nested field is read from "<field>.id" in a multipart request
  1527. data = {
  1528. 'manufacturer.id': Manufacturer.objects.first().pk,
  1529. 'model': 'Device Type 7',
  1530. 'slug': 'device-type-7',
  1531. 'front_image': create_test_image('front.png'),
  1532. 'rear_image': create_test_image('rear.png'),
  1533. }
  1534. with tempfile.TemporaryDirectory() as media_root, override_settings(MEDIA_ROOT=media_root):
  1535. response = self.client.post(self._get_list_url(), data, format='multipart', **self.header)
  1536. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1537. device_type = DeviceType.objects.get(pk=response.data['id'])
  1538. self.assertTrue(os.path.exists(device_type.front_image.path))
  1539. self.assertTrue(os.path.exists(device_type.rear_image.path))
  1540. def test_update_object_image(self):
  1541. """
  1542. Replace a DeviceType's front image via a multipart request, which should delete the original file.
  1543. """
  1544. self.add_permissions('dcim.change_devicetype')
  1545. with tempfile.TemporaryDirectory() as media_root, override_settings(MEDIA_ROOT=media_root):
  1546. device_type = DeviceType.objects.first()
  1547. device_type.front_image = create_test_image('front.png')
  1548. device_type.rear_image = create_test_image('rear.png')
  1549. device_type.save()
  1550. front_image_path = device_type.front_image.path
  1551. rear_image_path = device_type.rear_image.path
  1552. data = {
  1553. 'front_image': create_test_image('front2.png'),
  1554. }
  1555. with self.captureOnCommitCallbacks(execute=True):
  1556. response = self.client.patch(
  1557. self._get_detail_url(device_type), data, format='multipart', **self.header
  1558. )
  1559. self.assertHttpStatus(response, status.HTTP_200_OK)
  1560. device_type.refresh_from_db()
  1561. self.assertFalse(os.path.exists(front_image_path))
  1562. self.assertTrue(os.path.exists(device_type.front_image.path))
  1563. self.assertNotEqual(device_type.front_image.path, front_image_path)
  1564. self.assertTrue(os.path.exists(rear_image_path))
  1565. class ModuleTypeTestCase(APIViewTestCases.APIViewTestCase):
  1566. model = ModuleType
  1567. brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'module_count', 'profile', 'url']
  1568. bulk_update_data = {
  1569. 'part_number': 'ABC123',
  1570. 'end_of_life': '2030-01-01',
  1571. }
  1572. bulk_update_invalid_data = {
  1573. 'airflow': 'not-a-valid-airflow',
  1574. }
  1575. user_permissions = ('dcim.view_manufacturer', )
  1576. @classmethod
  1577. def setUpTestData(cls):
  1578. manufacturers = (
  1579. Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
  1580. Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
  1581. )
  1582. Manufacturer.objects.bulk_create(manufacturers)
  1583. module_types = (
  1584. ModuleType(manufacturer=manufacturers[0], model='Module Type 1'),
  1585. ModuleType(manufacturer=manufacturers[0], model='Module Type 2'),
  1586. ModuleType(manufacturer=manufacturers[0], model='Module Type 3'),
  1587. )
  1588. ModuleType.objects.bulk_create(module_types)
  1589. module_bay_types = (
  1590. ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 1', slug='module-bay-type-1'),
  1591. ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 2', slug='module-bay-type-2'),
  1592. )
  1593. ModuleBayType.objects.bulk_create(module_bay_types)
  1594. for module_type in module_types:
  1595. module_type.module_bay_types.set(module_bay_types)
  1596. cls.create_data = [
  1597. {
  1598. 'manufacturer': manufacturers[1].pk,
  1599. 'model': 'Module Type 4',
  1600. 'module_bay_types': [module_bay_types[0].pk, module_bay_types[1].pk],
  1601. },
  1602. {
  1603. 'manufacturer': manufacturers[1].pk,
  1604. 'model': 'Module Type 5',
  1605. 'end_of_life': '2035-06-30',
  1606. 'module_bay_types': [module_bay_types[0].pk],
  1607. },
  1608. {
  1609. 'manufacturer': manufacturers[1].pk,
  1610. 'model': 'Module Type 6',
  1611. },
  1612. ]
  1613. class ModuleTypeProfileTestCase(APIViewTestCases.APIViewTestCase):
  1614. model = ModuleTypeProfile
  1615. brief_fields = ['description', 'display', 'id', 'name', 'url']
  1616. SCHEMAS = [
  1617. {
  1618. "properties": {
  1619. "foo": {
  1620. "type": "string"
  1621. }
  1622. }
  1623. },
  1624. {
  1625. "properties": {
  1626. "foo": {
  1627. "type": "integer"
  1628. }
  1629. }
  1630. },
  1631. {
  1632. "properties": {
  1633. "foo": {
  1634. "type": "boolean"
  1635. }
  1636. }
  1637. },
  1638. ]
  1639. create_data = [
  1640. {
  1641. 'name': 'Module Type Profile 4',
  1642. 'schema': SCHEMAS[0],
  1643. },
  1644. {
  1645. 'name': 'Module Type Profile 5',
  1646. 'schema': SCHEMAS[1],
  1647. },
  1648. {
  1649. 'name': 'Module Type Profile 6',
  1650. 'schema': SCHEMAS[2],
  1651. },
  1652. ]
  1653. bulk_update_data = {
  1654. 'description': 'New description',
  1655. 'comments': 'New comments',
  1656. }
  1657. bulk_update_invalid_data = {
  1658. 'owner': 99999,
  1659. }
  1660. @classmethod
  1661. def setUpTestData(cls):
  1662. module_type_profiles = (
  1663. ModuleTypeProfile(
  1664. name='Module Type Profile 1',
  1665. schema=cls.SCHEMAS[0]
  1666. ),
  1667. ModuleTypeProfile(
  1668. name='Module Type Profile 2',
  1669. schema=cls.SCHEMAS[1]
  1670. ),
  1671. ModuleTypeProfile(
  1672. name='Module Type Profile 3',
  1673. schema=cls.SCHEMAS[2]
  1674. ),
  1675. )
  1676. ModuleTypeProfile.objects.bulk_create(module_type_profiles)
  1677. class ModuleBayTypeTestCase(APIViewTestCases.APIViewTestCase):
  1678. model = ModuleBayType
  1679. brief_fields = ['color', 'description', 'display', 'id', 'manufacturer', 'name', 'slug', 'url']
  1680. bulk_update_data = {
  1681. 'description': 'New description',
  1682. }
  1683. bulk_update_invalid_data = {
  1684. 'color': 'not-a-color',
  1685. }
  1686. @classmethod
  1687. def setUpTestData(cls):
  1688. manufacturers = (
  1689. Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
  1690. Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
  1691. )
  1692. Manufacturer.objects.bulk_create(manufacturers)
  1693. module_bay_types = (
  1694. ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 1', slug='module-bay-type-1'),
  1695. ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 2', slug='module-bay-type-2'),
  1696. ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 3', slug='module-bay-type-3'),
  1697. )
  1698. ModuleBayType.objects.bulk_create(module_bay_types)
  1699. cls.create_data = [
  1700. {
  1701. 'manufacturer': manufacturers[1].pk,
  1702. 'name': 'Module Bay Type 4',
  1703. 'slug': 'module-bay-type-4',
  1704. },
  1705. {
  1706. 'manufacturer': manufacturers[1].pk,
  1707. 'name': 'Module Bay Type 5',
  1708. 'slug': 'module-bay-type-5',
  1709. },
  1710. {
  1711. 'manufacturer': manufacturers[1].pk,
  1712. 'name': 'Module Bay Type 6',
  1713. 'slug': 'module-bay-type-6',
  1714. },
  1715. ]
  1716. class ConsolePortTemplateTestCase(APIViewTestCases.APIViewTestCase):
  1717. model = ConsolePortTemplate
  1718. brief_fields = ['description', 'display', 'id', 'name', 'url']
  1719. bulk_update_data = {
  1720. 'description': 'New description',
  1721. }
  1722. bulk_update_invalid_data = {
  1723. 'type': 'not-a-valid-type',
  1724. }
  1725. @classmethod
  1726. def setUpTestData(cls):
  1727. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  1728. devicetype = DeviceType.objects.create(
  1729. manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
  1730. )
  1731. moduletype = ModuleType.objects.create(
  1732. manufacturer=manufacturer, model='Module Type 1'
  1733. )
  1734. console_port_templates = (
  1735. ConsolePortTemplate(device_type=devicetype, name='Console Port Template 1'),
  1736. ConsolePortTemplate(device_type=devicetype, name='Console Port Template 2'),
  1737. ConsolePortTemplate(device_type=devicetype, name='Console Port Template 3'),
  1738. )
  1739. ConsolePortTemplate.objects.bulk_create(console_port_templates)
  1740. cls.create_data = [
  1741. {
  1742. 'device_type': devicetype.pk,
  1743. 'name': 'Console Port Template 4',
  1744. },
  1745. {
  1746. 'device_type': devicetype.pk,
  1747. 'name': 'Console Port Template 5',
  1748. },
  1749. {
  1750. 'module_type': moduletype.pk,
  1751. 'name': 'Console Port Template 6',
  1752. },
  1753. {
  1754. 'module_type': moduletype.pk,
  1755. 'name': 'Console Port Template 7',
  1756. },
  1757. ]
  1758. class ConsoleServerPortTemplateTestCase(APIViewTestCases.APIViewTestCase):
  1759. model = ConsoleServerPortTemplate
  1760. brief_fields = ['description', 'display', 'id', 'name', 'url']
  1761. bulk_update_data = {
  1762. 'description': 'New description',
  1763. }
  1764. bulk_update_invalid_data = {
  1765. 'type': 'not-a-valid-type',
  1766. }
  1767. @classmethod
  1768. def setUpTestData(cls):
  1769. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  1770. devicetype = DeviceType.objects.create(
  1771. manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
  1772. )
  1773. moduletype = ModuleType.objects.create(
  1774. manufacturer=manufacturer, model='Module Type 1'
  1775. )
  1776. console_server_port_templates = (
  1777. ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 1'),
  1778. ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 2'),
  1779. ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 3'),
  1780. )
  1781. ConsoleServerPortTemplate.objects.bulk_create(console_server_port_templates)
  1782. cls.create_data = [
  1783. {
  1784. 'device_type': devicetype.pk,
  1785. 'name': 'Console Server Port Template 4',
  1786. },
  1787. {
  1788. 'device_type': devicetype.pk,
  1789. 'name': 'Console Server Port Template 5',
  1790. },
  1791. {
  1792. 'module_type': moduletype.pk,
  1793. 'name': 'Console Server Port Template 6',
  1794. },
  1795. {
  1796. 'module_type': moduletype.pk,
  1797. 'name': 'Console Server Port Template 7',
  1798. },
  1799. ]
  1800. class PowerPortTemplateTestCase(APIViewTestCases.APIViewTestCase):
  1801. model = PowerPortTemplate
  1802. brief_fields = ['description', 'display', 'id', 'name', 'url']
  1803. bulk_update_data = {
  1804. 'description': 'New description',
  1805. }
  1806. bulk_update_invalid_data = {
  1807. 'type': 'not-a-valid-type',
  1808. }
  1809. @classmethod
  1810. def setUpTestData(cls):
  1811. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  1812. devicetype = DeviceType.objects.create(
  1813. manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
  1814. )
  1815. moduletype = ModuleType.objects.create(
  1816. manufacturer=manufacturer, model='Module Type 1'
  1817. )
  1818. power_port_templates = (
  1819. PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
  1820. PowerPortTemplate(device_type=devicetype, name='Power Port Template 2'),
  1821. PowerPortTemplate(device_type=devicetype, name='Power Port Template 3'),
  1822. )
  1823. PowerPortTemplate.objects.bulk_create(power_port_templates)
  1824. cls.create_data = [
  1825. {
  1826. 'device_type': devicetype.pk,
  1827. 'name': 'Power Port Template 4',
  1828. },
  1829. {
  1830. 'device_type': devicetype.pk,
  1831. 'name': 'Power Port Template 5',
  1832. },
  1833. {
  1834. 'module_type': moduletype.pk,
  1835. 'name': 'Power Port Template 6',
  1836. },
  1837. {
  1838. 'module_type': moduletype.pk,
  1839. 'name': 'Power Port Template 7',
  1840. },
  1841. ]
  1842. class PowerOutletTemplateTestCase(APIViewTestCases.APIViewTestCase):
  1843. model = PowerOutletTemplate
  1844. brief_fields = ['description', 'display', 'id', 'name', 'url']
  1845. bulk_update_data = {
  1846. 'description': 'New description',
  1847. }
  1848. bulk_update_invalid_data = {
  1849. 'type': 'not-a-valid-type',
  1850. }
  1851. user_permissions = ('dcim.view_devicetype', )
  1852. @classmethod
  1853. def setUpTestData(cls):
  1854. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  1855. devicetype = DeviceType.objects.create(
  1856. manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
  1857. )
  1858. moduletype = ModuleType.objects.create(
  1859. manufacturer=manufacturer, model='Module Type 1'
  1860. )
  1861. power_port_templates = (
  1862. PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
  1863. PowerPortTemplate(device_type=devicetype, name='Power Port Template 2'),
  1864. )
  1865. PowerPortTemplate.objects.bulk_create(power_port_templates)
  1866. power_outlet_templates = (
  1867. PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 1'),
  1868. PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 2'),
  1869. PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 3'),
  1870. )
  1871. PowerOutletTemplate.objects.bulk_create(power_outlet_templates)
  1872. cls.create_data = [
  1873. {
  1874. 'device_type': devicetype.pk,
  1875. 'name': 'Power Outlet Template 4',
  1876. 'power_port': power_port_templates[0].pk,
  1877. },
  1878. {
  1879. 'device_type': devicetype.pk,
  1880. 'name': 'Power Outlet Template 5',
  1881. 'power_port': power_port_templates[1].pk,
  1882. },
  1883. {
  1884. 'device_type': devicetype.pk,
  1885. 'name': 'Power Outlet Template 6',
  1886. 'power_port': None,
  1887. },
  1888. {
  1889. 'module_type': moduletype.pk,
  1890. 'name': 'Power Outlet Template 7',
  1891. },
  1892. {
  1893. 'module_type': moduletype.pk,
  1894. 'name': 'Power Outlet Template 8',
  1895. },
  1896. ]
  1897. class InterfaceTemplateTestCase(APIViewTestCases.APIViewTestCase):
  1898. model = InterfaceTemplate
  1899. brief_fields = ['description', 'display', 'id', 'name', 'url']
  1900. bulk_update_data = {
  1901. 'description': 'New description',
  1902. }
  1903. bulk_update_invalid_data = {
  1904. 'poe_mode': 'not-a-valid-poe-mode',
  1905. }
  1906. @classmethod
  1907. def setUpTestData(cls):
  1908. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  1909. devicetype = DeviceType.objects.create(
  1910. manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
  1911. )
  1912. moduletype = ModuleType.objects.create(
  1913. manufacturer=manufacturer, model='Module Type 1'
  1914. )
  1915. interface_templates = (
  1916. InterfaceTemplate(device_type=devicetype, name='Interface Template 1', type='1000base-t'),
  1917. InterfaceTemplate(device_type=devicetype, name='Interface Template 2', type='1000base-t'),
  1918. # Interface Template 3 is channelized, so that channel subinterface templates may be bound to it
  1919. InterfaceTemplate(device_type=devicetype, name='Interface Template 3', type='1000base-t', channels=4),
  1920. )
  1921. InterfaceTemplate.objects.bulk_create(interface_templates)
  1922. channelized_parent = interface_templates[2]
  1923. cls.create_data = [
  1924. {
  1925. 'device_type': devicetype.pk,
  1926. 'name': 'Interface Template 4',
  1927. 'type': '1000base-t',
  1928. },
  1929. {
  1930. 'device_type': devicetype.pk,
  1931. 'name': 'Interface Template 5',
  1932. 'type': '1000base-t',
  1933. },
  1934. {
  1935. 'module_type': moduletype.pk,
  1936. 'name': 'Interface Template 6',
  1937. 'type': '1000base-t',
  1938. },
  1939. {
  1940. 'module_type': moduletype.pk,
  1941. 'name': 'Interface Template 7',
  1942. 'type': '1000base-t',
  1943. },
  1944. {
  1945. # A channelized parent template
  1946. 'device_type': devicetype.pk,
  1947. 'name': 'Interface Template 8',
  1948. 'type': InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS,
  1949. 'channels': 4,
  1950. },
  1951. {
  1952. # A channel subinterface template bound to a channelized parent
  1953. 'device_type': devicetype.pk,
  1954. 'name': 'Interface Template 9',
  1955. 'type': InterfaceTypeChoices.TYPE_CHANNEL,
  1956. 'parent': channelized_parent.pk,
  1957. 'channel_id': 1,
  1958. },
  1959. ]
  1960. class FrontPortTemplateTestCase(APIViewTestCases.APIViewTestCase):
  1961. model = FrontPortTemplate
  1962. brief_fields = ['description', 'display', 'id', 'name', 'url']
  1963. bulk_update_data = {
  1964. 'description': 'New description',
  1965. }
  1966. bulk_update_invalid_data = {
  1967. 'color': 'not-a-color',
  1968. }
  1969. user_permissions = ('dcim.view_rearporttemplate', )
  1970. @classmethod
  1971. def setUpTestData(cls):
  1972. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  1973. devicetype = DeviceType.objects.create(
  1974. manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
  1975. )
  1976. moduletype = ModuleType.objects.create(
  1977. manufacturer=manufacturer, model='Module Type 1'
  1978. )
  1979. rear_port_templates = (
  1980. RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C),
  1981. RearPortTemplate(device_type=devicetype, name='Rear Port Template 2', type=PortTypeChoices.TYPE_8P8C),
  1982. RearPortTemplate(device_type=devicetype, name='Rear Port Template 3', type=PortTypeChoices.TYPE_8P8C),
  1983. RearPortTemplate(device_type=devicetype, name='Rear Port Template 4', type=PortTypeChoices.TYPE_8P8C),
  1984. RearPortTemplate(device_type=devicetype, name='Rear Port Template 5', type=PortTypeChoices.TYPE_8P8C),
  1985. RearPortTemplate(device_type=devicetype, name='Rear Port Template 6', type=PortTypeChoices.TYPE_8P8C),
  1986. )
  1987. RearPortTemplate.objects.bulk_create(rear_port_templates)
  1988. front_port_templates = (
  1989. FrontPortTemplate(device_type=devicetype, name='Front Port Template 1', type=PortTypeChoices.TYPE_8P8C),
  1990. FrontPortTemplate(device_type=devicetype, name='Front Port Template 2', type=PortTypeChoices.TYPE_8P8C),
  1991. FrontPortTemplate(module_type=moduletype, name='Front Port Template 3', type=PortTypeChoices.TYPE_8P8C),
  1992. )
  1993. FrontPortTemplate.objects.bulk_create(front_port_templates)
  1994. PortTemplateMapping.objects.bulk_create([
  1995. PortTemplateMapping(
  1996. device_type=devicetype,
  1997. front_port=front_port_templates[0],
  1998. rear_port=rear_port_templates[0],
  1999. ),
  2000. PortTemplateMapping(
  2001. device_type=devicetype,
  2002. front_port=front_port_templates[1],
  2003. rear_port=rear_port_templates[1],
  2004. ),
  2005. PortTemplateMapping(
  2006. module_type=moduletype,
  2007. front_port=front_port_templates[2],
  2008. rear_port=rear_port_templates[2],
  2009. ),
  2010. ])
  2011. cls.create_data = [
  2012. {
  2013. 'device_type': devicetype.pk,
  2014. 'name': 'Front Port Template 3',
  2015. 'type': PortTypeChoices.TYPE_8P8C,
  2016. 'rear_ports': [
  2017. {
  2018. 'position': 1,
  2019. 'rear_port': rear_port_templates[3].pk,
  2020. 'rear_port_position': 1,
  2021. },
  2022. ],
  2023. },
  2024. {
  2025. 'device_type': devicetype.pk,
  2026. 'name': 'Front Port Template 4',
  2027. 'type': PortTypeChoices.TYPE_8P8C,
  2028. 'rear_ports': [
  2029. {
  2030. 'position': 1,
  2031. 'rear_port': rear_port_templates[4].pk,
  2032. 'rear_port_position': 1,
  2033. },
  2034. ],
  2035. },
  2036. {
  2037. 'module_type': moduletype.pk,
  2038. 'name': 'Front Port Template 7',
  2039. 'type': PortTypeChoices.TYPE_8P8C,
  2040. 'rear_ports': [
  2041. {
  2042. 'position': 1,
  2043. 'rear_port': rear_port_templates[5].pk,
  2044. 'rear_port_position': 1,
  2045. },
  2046. ],
  2047. },
  2048. ]
  2049. cls.update_data = {
  2050. 'type': PortTypeChoices.TYPE_LC,
  2051. 'rear_ports': [
  2052. {
  2053. 'position': 1,
  2054. 'rear_port': rear_port_templates[3].pk,
  2055. 'rear_port_position': 1,
  2056. },
  2057. ],
  2058. }
  2059. def test_update_object(self):
  2060. super().test_update_object()
  2061. # Check that the port mapping was updated after modifying the front port template
  2062. front_port_template = FrontPortTemplate.objects.get(name='Front Port Template 1')
  2063. rear_port_template = RearPortTemplate.objects.get(name='Rear Port Template 4')
  2064. self.assertTrue(
  2065. PortTemplateMapping.objects.filter(
  2066. front_port=front_port_template,
  2067. front_port_position=1,
  2068. rear_port=rear_port_template,
  2069. rear_port_position=1,
  2070. ).exists()
  2071. )
  2072. class RearPortTemplateTestCase(APIViewTestCases.APIViewTestCase):
  2073. model = RearPortTemplate
  2074. brief_fields = ['description', 'display', 'id', 'name', 'url']
  2075. bulk_update_data = {
  2076. 'description': 'New description',
  2077. }
  2078. bulk_update_invalid_data = {
  2079. 'color': 'not-a-color',
  2080. }
  2081. @classmethod
  2082. def setUpTestData(cls):
  2083. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  2084. devicetype = DeviceType.objects.create(
  2085. manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
  2086. )
  2087. moduletype = ModuleType.objects.create(
  2088. manufacturer=manufacturer, model='Module Type 1'
  2089. )
  2090. front_port_templates = (
  2091. FrontPortTemplate(device_type=devicetype, name='Front Port Template 1', type=PortTypeChoices.TYPE_8P8C),
  2092. FrontPortTemplate(device_type=devicetype, name='Front Port Template 2', type=PortTypeChoices.TYPE_8P8C),
  2093. FrontPortTemplate(module_type=moduletype, name='Front Port Template 3', type=PortTypeChoices.TYPE_8P8C),
  2094. FrontPortTemplate(module_type=moduletype, name='Front Port Template 4', type=PortTypeChoices.TYPE_8P8C),
  2095. FrontPortTemplate(module_type=moduletype, name='Front Port Template 5', type=PortTypeChoices.TYPE_8P8C),
  2096. FrontPortTemplate(module_type=moduletype, name='Front Port Template 6', type=PortTypeChoices.TYPE_8P8C),
  2097. )
  2098. FrontPortTemplate.objects.bulk_create(front_port_templates)
  2099. rear_port_templates = (
  2100. RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C),
  2101. RearPortTemplate(device_type=devicetype, name='Rear Port Template 2', type=PortTypeChoices.TYPE_8P8C),
  2102. RearPortTemplate(device_type=devicetype, name='Rear Port Template 3', type=PortTypeChoices.TYPE_8P8C),
  2103. )
  2104. RearPortTemplate.objects.bulk_create(rear_port_templates)
  2105. PortTemplateMapping.objects.bulk_create([
  2106. PortTemplateMapping(
  2107. device_type=devicetype,
  2108. front_port=front_port_templates[0],
  2109. rear_port=rear_port_templates[0],
  2110. ),
  2111. PortTemplateMapping(
  2112. device_type=devicetype,
  2113. front_port=front_port_templates[1],
  2114. rear_port=rear_port_templates[1],
  2115. ),
  2116. PortTemplateMapping(
  2117. module_type=moduletype,
  2118. front_port=front_port_templates[2],
  2119. rear_port=rear_port_templates[2],
  2120. ),
  2121. ])
  2122. cls.create_data = [
  2123. {
  2124. 'device_type': devicetype.pk,
  2125. 'name': 'Rear Port Template 4',
  2126. 'type': PortTypeChoices.TYPE_8P8C,
  2127. 'front_ports': [
  2128. {
  2129. 'position': 1,
  2130. 'front_port': front_port_templates[3].pk,
  2131. 'front_port_position': 1,
  2132. },
  2133. ],
  2134. },
  2135. {
  2136. 'device_type': devicetype.pk,
  2137. 'name': 'Rear Port Template 5',
  2138. 'type': PortTypeChoices.TYPE_8P8C,
  2139. 'front_ports': [
  2140. {
  2141. 'position': 1,
  2142. 'front_port': front_port_templates[4].pk,
  2143. 'front_port_position': 1,
  2144. },
  2145. ],
  2146. },
  2147. {
  2148. 'module_type': moduletype.pk,
  2149. 'name': 'Rear Port Template 6',
  2150. 'type': PortTypeChoices.TYPE_8P8C,
  2151. 'front_ports': [
  2152. {
  2153. 'position': 1,
  2154. 'front_port': front_port_templates[5].pk,
  2155. 'front_port_position': 1,
  2156. },
  2157. ],
  2158. },
  2159. ]
  2160. cls.update_data = {
  2161. 'type': PortTypeChoices.TYPE_LC,
  2162. 'front_ports': [
  2163. {
  2164. 'position': 1,
  2165. 'front_port': front_port_templates[3].pk,
  2166. 'front_port_position': 1,
  2167. },
  2168. ],
  2169. }
  2170. def test_update_object(self):
  2171. super().test_update_object()
  2172. # Check that the port mapping was updated after modifying the rear port template
  2173. front_port_template = FrontPortTemplate.objects.get(name='Front Port Template 4')
  2174. rear_port_template = RearPortTemplate.objects.get(name='Rear Port Template 1')
  2175. self.assertTrue(
  2176. PortTemplateMapping.objects.filter(
  2177. front_port=front_port_template,
  2178. front_port_position=1,
  2179. rear_port=rear_port_template,
  2180. rear_port_position=1,
  2181. ).exists()
  2182. )
  2183. class ModuleBayTemplateTestCase(APIViewTestCases.APIViewTestCase):
  2184. model = ModuleBayTemplate
  2185. brief_fields = ['description', 'display', 'enabled', 'id', 'name', 'url']
  2186. bulk_update_data = {
  2187. 'description': 'New description',
  2188. }
  2189. bulk_update_invalid_data = {
  2190. 'device_type': 99999,
  2191. }
  2192. user_permissions = ('dcim.view_devicetype', )
  2193. @classmethod
  2194. def setUpTestData(cls):
  2195. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  2196. devicetype = DeviceType.objects.create(
  2197. manufacturer=manufacturer,
  2198. model='Device Type 1',
  2199. slug='device-type-1',
  2200. subdevice_role=SubdeviceRoleChoices.ROLE_PARENT
  2201. )
  2202. module_bay_templates = (
  2203. ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 1', enabled=True),
  2204. ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 2', enabled=False),
  2205. ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 3', enabled=True),
  2206. )
  2207. ModuleBayTemplate.objects.bulk_create(module_bay_templates)
  2208. module_bay_types = (
  2209. ModuleBayType(manufacturer=manufacturer, name='Module Bay Type 1', slug='module-bay-type-1'),
  2210. ModuleBayType(manufacturer=manufacturer, name='Module Bay Type 2', slug='module-bay-type-2'),
  2211. )
  2212. ModuleBayType.objects.bulk_create(module_bay_types)
  2213. for module_bay_template in module_bay_templates:
  2214. module_bay_template.module_bay_types.set(module_bay_types)
  2215. cls.create_data = [
  2216. {
  2217. 'device_type': devicetype.pk,
  2218. 'name': 'Module Bay Template 4',
  2219. 'enabled': False,
  2220. 'module_bay_types': [module_bay_types[0].pk, module_bay_types[1].pk],
  2221. },
  2222. {
  2223. 'device_type': devicetype.pk,
  2224. 'name': 'Module Bay Template 5',
  2225. 'module_bay_types': [module_bay_types[0].pk],
  2226. },
  2227. {
  2228. 'device_type': devicetype.pk,
  2229. 'name': 'Module Bay Template 6',
  2230. },
  2231. ]
  2232. class DeviceBayTemplateTestCase(APIViewTestCases.APIViewTestCase):
  2233. model = DeviceBayTemplate
  2234. brief_fields = ['description', 'display', 'enabled', 'id', 'name', 'url']
  2235. bulk_update_data = {
  2236. 'description': 'New description',
  2237. }
  2238. bulk_update_invalid_data = {
  2239. 'device_type': 99999,
  2240. }
  2241. user_permissions = ('dcim.view_devicetype', )
  2242. @classmethod
  2243. def setUpTestData(cls):
  2244. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  2245. devicetype = DeviceType.objects.create(
  2246. manufacturer=manufacturer,
  2247. model='Device Type 1',
  2248. slug='device-type-1',
  2249. subdevice_role=SubdeviceRoleChoices.ROLE_PARENT
  2250. )
  2251. device_bay_templates = (
  2252. DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 1', enabled=True),
  2253. DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 2', enabled=False),
  2254. DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 3', enabled=True),
  2255. )
  2256. DeviceBayTemplate.objects.bulk_create(device_bay_templates)
  2257. cls.create_data = [
  2258. {
  2259. 'device_type': devicetype.pk,
  2260. 'name': 'Device Bay Template 4',
  2261. 'enabled': False,
  2262. },
  2263. {
  2264. 'device_type': devicetype.pk,
  2265. 'name': 'Device Bay Template 5',
  2266. },
  2267. {
  2268. 'device_type': devicetype.pk,
  2269. 'name': 'Device Bay Template 6',
  2270. },
  2271. ]
  2272. class InventoryItemTemplateTestCase(APIViewTestCases.APIViewTestCase):
  2273. model = InventoryItemTemplate
  2274. brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'url']
  2275. bulk_update_data = {
  2276. 'description': 'New description',
  2277. }
  2278. bulk_update_invalid_data = {
  2279. 'role': 99999,
  2280. }
  2281. user_permissions = ('dcim.view_devicetype', 'dcim.view_manufacturer',)
  2282. @classmethod
  2283. def setUpTestData(cls):
  2284. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  2285. devicetype = DeviceType.objects.create(
  2286. manufacturer=manufacturer,
  2287. model='Device Type 1',
  2288. slug='device-type-1'
  2289. )
  2290. role = InventoryItemRole.objects.create(name='Inventory Item Role 1', slug='inventory-item-role-1')
  2291. inventory_item_templates = (
  2292. InventoryItemTemplate(
  2293. device_type=devicetype, name='Inventory Item Template 1', manufacturer=manufacturer, role=role
  2294. ),
  2295. InventoryItemTemplate(
  2296. device_type=devicetype, name='Inventory Item Template 2', manufacturer=manufacturer, role=role
  2297. ),
  2298. InventoryItemTemplate(
  2299. device_type=devicetype, name='Inventory Item Template 3', manufacturer=manufacturer, role=role
  2300. ),
  2301. InventoryItemTemplate(
  2302. device_type=devicetype, name='Inventory Item Template 4', manufacturer=manufacturer, role=role
  2303. ),
  2304. )
  2305. for item in inventory_item_templates:
  2306. item.save()
  2307. cls.create_data = [
  2308. {
  2309. 'device_type': devicetype.pk,
  2310. 'name': 'Inventory Item Template 5',
  2311. 'manufacturer': manufacturer.pk,
  2312. 'role': role.pk,
  2313. 'parent': inventory_item_templates[3].pk,
  2314. },
  2315. {
  2316. 'device_type': devicetype.pk,
  2317. 'name': 'Inventory Item Template 6',
  2318. 'manufacturer': manufacturer.pk,
  2319. 'role': role.pk,
  2320. 'parent': inventory_item_templates[3].pk,
  2321. },
  2322. {
  2323. 'device_type': devicetype.pk,
  2324. 'name': 'Inventory Item Template 7',
  2325. 'manufacturer': manufacturer.pk,
  2326. 'role': role.pk,
  2327. 'parent': inventory_item_templates[3].pk,
  2328. },
  2329. ]
  2330. class DeviceRoleTestCase(APIViewTestCases.APIViewTestCase):
  2331. model = DeviceRole
  2332. brief_fields = [
  2333. '_depth', 'description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'
  2334. ]
  2335. create_data = [
  2336. {
  2337. 'name': 'Device Role 4',
  2338. 'slug': 'device-role-4',
  2339. 'color': 'ffff00',
  2340. },
  2341. {
  2342. 'name': 'Device Role 5',
  2343. 'slug': 'device-role-5',
  2344. 'color': 'ffff00',
  2345. },
  2346. {
  2347. 'name': 'Device Role 6',
  2348. 'slug': 'device-role-6',
  2349. 'color': 'ffff00',
  2350. },
  2351. ]
  2352. bulk_update_data = {
  2353. 'description': 'New description',
  2354. }
  2355. bulk_update_invalid_data = {
  2356. 'color': 'not-a-color',
  2357. }
  2358. @classmethod
  2359. def setUpTestData(cls):
  2360. DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
  2361. DeviceRole.objects.create(name='Device Role 2', slug='device-role-2', color='00ff00')
  2362. DeviceRole.objects.create(name='Device Role 3', slug='device-role-3', color='0000ff')
  2363. class PlatformTestCase(APIViewTestCases.APIViewTestCase):
  2364. model = Platform
  2365. brief_fields = [
  2366. '_depth', 'description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count',
  2367. ]
  2368. create_data = [
  2369. {
  2370. 'name': 'Platform 4',
  2371. 'slug': 'platform-4',
  2372. },
  2373. {
  2374. 'name': 'Platform 5',
  2375. 'slug': 'platform-5',
  2376. },
  2377. {
  2378. 'name': 'Platform 6',
  2379. 'slug': 'platform-6',
  2380. },
  2381. ]
  2382. bulk_update_data = {
  2383. 'description': 'New description',
  2384. }
  2385. bulk_update_invalid_data = {
  2386. 'owner': 99999,
  2387. }
  2388. @classmethod
  2389. def setUpTestData(cls):
  2390. platforms = (
  2391. Platform(name='Platform 1', slug='platform-1'),
  2392. Platform(name='Platform 2', slug='platform-2'),
  2393. Platform(name='Platform 3', slug='platform-3'),
  2394. )
  2395. for platform in platforms:
  2396. platform.save()
  2397. class DeviceTestCase(Mixins.FieldPrefetchMixin, APIViewTestCases.APIViewTestCase):
  2398. model = Device
  2399. viewset = DeviceViewSet
  2400. field_prefetch_cases = (
  2401. 'omit=parent_device', 'fields=id,parent_device&omit=parent_device', 'brief=1&omit=display',
  2402. 'brief=1&fields=id,config_context',
  2403. )
  2404. eager_prefetches = ('device_type__manufacturer', 'parent_bay')
  2405. brief_fields = ['description', 'display', 'id', 'name', 'url']
  2406. bulk_update_data = {
  2407. 'status': 'failed',
  2408. }
  2409. bulk_update_invalid_data = {
  2410. 'status': 'not-a-valid-status',
  2411. }
  2412. user_permissions = (
  2413. 'dcim.view_site', 'dcim.view_rack', 'dcim.view_location', 'dcim.view_devicerole', 'dcim.view_devicetype',
  2414. )
  2415. @classmethod
  2416. def setUpTestData(cls):
  2417. sites = (
  2418. Site(name='Site 1', slug='site-1'),
  2419. Site(name='Site 2', slug='site-2'),
  2420. )
  2421. Site.objects.bulk_create(sites)
  2422. racks = (
  2423. Rack(name='Rack 1', site=sites[0]),
  2424. Rack(name='Rack 2', site=sites[1]),
  2425. )
  2426. Rack.objects.bulk_create(racks)
  2427. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  2428. device_types = (
  2429. DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
  2430. DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=2),
  2431. )
  2432. DeviceType.objects.bulk_create(device_types)
  2433. roles = (
  2434. DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000'),
  2435. DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00'),
  2436. )
  2437. for role in roles:
  2438. role.save()
  2439. cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
  2440. clusters = (
  2441. Cluster(name='Cluster 1', type=cluster_type),
  2442. Cluster(name='Cluster 2', type=cluster_type),
  2443. )
  2444. Cluster.objects.bulk_create(clusters)
  2445. devices = (
  2446. Device(
  2447. device_type=device_types[0],
  2448. role=roles[0],
  2449. name='Device 1',
  2450. site=sites[0],
  2451. rack=racks[0],
  2452. cluster=clusters[0],
  2453. local_context_data={'A': 1}
  2454. ),
  2455. Device(
  2456. device_type=device_types[0],
  2457. role=roles[0],
  2458. name='Device 2',
  2459. site=sites[0],
  2460. rack=racks[0],
  2461. cluster=clusters[0],
  2462. local_context_data={'B': 2}
  2463. ),
  2464. Device(
  2465. device_type=device_types[0],
  2466. role=roles[0],
  2467. name='Device 3',
  2468. site=sites[0],
  2469. rack=racks[0],
  2470. cluster=clusters[0],
  2471. local_context_data={'C': 3}
  2472. ),
  2473. )
  2474. Device.objects.bulk_create(devices)
  2475. cls.create_data = [
  2476. {
  2477. 'device_type': device_types[1].pk,
  2478. 'role': roles[1].pk,
  2479. 'name': 'Test Device 4',
  2480. 'site': sites[1].pk,
  2481. 'rack': racks[1].pk,
  2482. 'cluster': clusters[1].pk,
  2483. },
  2484. {
  2485. 'device_type': device_types[1].pk,
  2486. 'role': roles[1].pk,
  2487. 'name': 'Test Device 5',
  2488. 'site': sites[1].pk,
  2489. 'rack': racks[1].pk,
  2490. 'cluster': clusters[1].pk,
  2491. },
  2492. {
  2493. 'device_type': device_types[1].pk,
  2494. 'role': roles[1].pk,
  2495. 'name': 'Test Device 6',
  2496. 'site': sites[1].pk,
  2497. 'rack': racks[1].pk,
  2498. 'cluster': clusters[1].pk,
  2499. },
  2500. ]
  2501. def _create_field_prefetch_scopes(self):
  2502. """Return filters for one and for two sites of parent, installed child and unnamed devices."""
  2503. device = Device.objects.get(name='Device 1')
  2504. parent_type = DeviceType.objects.create(
  2505. manufacturer=device.device_type.manufacturer, model='Unit Parent Type', slug='unit-parent-type',
  2506. subdevice_role=SubdeviceRoleChoices.ROLE_PARENT,
  2507. )
  2508. child_type = DeviceType.objects.create(
  2509. manufacturer=device.device_type.manufacturer, model='Unit Child Type', slug='unit-child-type',
  2510. subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, u_height=0,
  2511. )
  2512. filters = []
  2513. for i in range(2):
  2514. site = Site.objects.create(name=f'Unit Site {i}', slug=f'unit-site-{i}')
  2515. parent = Device.objects.create(device_type=parent_type, role=device.role, site=site, name=f'Parent {i}')
  2516. child = Device.objects.create(device_type=child_type, role=device.role, site=site, name=f'Child {i}')
  2517. DeviceBay.objects.create(device=parent, name='Bay 1', installed_device=child)
  2518. # Unnamed, so its display reads the device type and manufacturer
  2519. create_test_device(None, site=site)
  2520. filters.append(f'site_id={site.pk}')
  2521. return [filters[0], '&'.join(filters)]
  2522. def test_config_context_read_without_copy(self):
  2523. """
  2524. REST and GraphQL read cached config context without copying it.
  2525. """
  2526. self.add_permissions('dcim.view_device')
  2527. Device.objects.filter(name='Device 1').update(_config_context_data={'A': 'cached'})
  2528. Device.objects.filter(name='Device 2').update(_config_context_data={})
  2529. # Device 3 has no cache and renders its local context data
  2530. expected = {'Device 1': {'A': 'cached'}, 'Device 2': {}, 'Device 3': {'C': 3}}
  2531. query = '{ device_list { name config_context } }'
  2532. with patch('extras.models.configs.copy', wraps=copy) as copy_module:
  2533. rest = self.client.get(self._get_list_url(), **self.header)
  2534. graphql = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header)
  2535. copy_module.deepcopy.assert_not_called()
  2536. self.assertHttpStatus(rest, status.HTTP_200_OK)
  2537. self.assertEqual({row['name']: row['config_context'] for row in rest.data['results']}, expected)
  2538. self.assertHttpStatus(graphql, status.HTTP_200_OK)
  2539. data = graphql.json()
  2540. self.assertNotIn('errors', data)
  2541. self.assertEqual({row['name']: row['config_context'] for row in data['data']['device_list']}, expected)
  2542. @tag('regression') # Issue #23448
  2543. def test_config_context_graphql_selection(self):
  2544. """
  2545. GraphQL annotates config context for devices with no cache however config_context is selected, including
  2546. through fragments and nested relations, without a query per device.
  2547. """
  2548. self.add_permissions('dcim.view_device', 'dcim.view_interface')
  2549. ConfigContext.objects.create(name='Config Context 1', weight=100, data={'foo': 123})
  2550. for device in Device.objects.all():
  2551. Interface.objects.create(device=device, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
  2552. Device.objects.update(_config_context_data=None)
  2553. expected = {device.name: device.render_config_context() for device in Device.objects.all()}
  2554. fragment = 'fragment F on DeviceType { name config_context }'
  2555. queries = {
  2556. 'field': '{ device_list { name config_context } }',
  2557. 'inline fragment': '{ device_list { ... on DeviceType { name config_context } } }',
  2558. 'named fragment': f'{fragment} query {{ device_list {{ ...F }} }}',
  2559. 'nested field': '{ interface_list { device { name config_context } } }',
  2560. 'nested inline fragment': '{ interface_list { device { ... on DeviceType { name config_context } } } }',
  2561. 'nested named fragment': f'{fragment} query {{ interface_list {{ device {{ ...F }} }} }}',
  2562. }
  2563. for shape, query in queries.items():
  2564. with self.subTest(shape=shape):
  2565. with CaptureQueriesContext(connection) as captured:
  2566. response = self.client.post(
  2567. reverse('graphql'), data={'query': query}, format='json', **self.header
  2568. )
  2569. self.assertHttpStatus(response, status.HTTP_200_OK)
  2570. data = response.json()
  2571. self.assertNotIn('errors', data)
  2572. if shape.startswith('nested'):
  2573. rows = [row['device'] for row in data['data']['interface_list']]
  2574. else:
  2575. rows = data['data']['device_list']
  2576. self.assertEqual({row['name']: row['config_context'] for row in rows}, expected)
  2577. # Rendered by the annotation in a single query, not one fallback query per device
  2578. sql = [captured_query['sql'] for captured_query in captured.captured_queries]
  2579. self.assertEqual(len([s for s in sql if '"extras_configcontext"' in s]), 1)
  2580. # Not annotated when config_context is not selected
  2581. with CaptureQueriesContext(connection) as captured:
  2582. response = self.client.post(
  2583. reverse('graphql'), data={'query': '{ device_list { name } }'}, format='json', **self.header
  2584. )
  2585. self.assertHttpStatus(response, status.HTTP_200_OK)
  2586. self.assertNotIn('errors', response.json())
  2587. for captured_query in captured.captured_queries:
  2588. self.assertNotIn('"config_context_data"', captured_query['sql'])
  2589. def test_config_context_included_by_default_in_list_view(self):
  2590. """
  2591. Check that config context data is included by default in the devices list.
  2592. """
  2593. self.add_permissions('dcim.view_device')
  2594. url = reverse('dcim-api:device-list') + '?slug=device-with-context-data'
  2595. response = self.client.get(url, **self.header)
  2596. self.assertEqual(response.data['results'][0].get('config_context', {}).get('A'), 1)
  2597. def test_config_context_not_loaded_when_omitted(self):
  2598. """
  2599. List and detail responses which omit config_context neither annotate it nor load the cached context.
  2600. """
  2601. self.add_permissions('dcim.view_device')
  2602. device = Device.objects.get(name='Device 1')
  2603. cases = (
  2604. (self._get_list_url(), {'fields': 'id,name'}),
  2605. (self._get_list_url(), {'omit': 'config_context'}),
  2606. (self._get_list_url(), {'brief': 1}),
  2607. (self._get_detail_url(device), {'fields': 'id,name'}),
  2608. )
  2609. for url, params in cases:
  2610. with self.subTest(url=url, params=params):
  2611. with CaptureQueriesContext(connection) as queries:
  2612. response = self.client.get(url, params, **self.header)
  2613. self.assertHttpStatus(response, status.HTTP_200_OK)
  2614. sql = '\n'.join(query['sql'] for query in queries.captured_queries)
  2615. self.assertNotIn('"config_context_data"', sql)
  2616. self.assertNotIn('"dcim_device"."_config_context_data"', sql)
  2617. def test_config_context_loaded_when_requested(self):
  2618. """
  2619. Responses which include config_context read warm devices from the cache and cold devices from the
  2620. annotation, without a query per device.
  2621. """
  2622. self.add_permissions('dcim.view_device')
  2623. ConfigContext.objects.create(name='Config Context 1', weight=100, data={'foo': 123})
  2624. Device.objects.filter(name='Device 1').update(_config_context_data={'foo': 'cached'})
  2625. # fields takes precedence over omit, and both over brief
  2626. for params in (
  2627. {},
  2628. {'fields': 'name,config_context'},
  2629. {'fields': 'name,config_context', 'omit': 'config_context'},
  2630. {'brief': 1, 'fields': 'name,config_context'},
  2631. {'brief': 1, 'omit': 'comments'},
  2632. ):
  2633. with self.subTest(params=params):
  2634. with CaptureQueriesContext(connection) as queries:
  2635. response = self.client.get(self._get_list_url(), params, **self.header)
  2636. self.assertHttpStatus(response, status.HTTP_200_OK)
  2637. contexts = {row['name']: row['config_context'] for row in response.data['results']}
  2638. self.assertEqual(contexts, {
  2639. 'Device 1': {'foo': 'cached'},
  2640. 'Device 2': {'foo': 123, 'B': 2},
  2641. 'Device 3': {'foo': 123, 'C': 3},
  2642. })
  2643. statements = [
  2644. query['sql'] for query in queries.captured_queries if '"extras_configcontext"' in query['sql']
  2645. ]
  2646. self.assertEqual(len(statements), 1)
  2647. self.assertTrue(statements[0].startswith('SELECT "dcim_device"'))
  2648. refreshes = [
  2649. query['sql'] for query in queries.captured_queries
  2650. if query['sql'].startswith('SELECT "dcim_device"."id", "dcim_device"."_config_context_data" FROM')
  2651. ]
  2652. self.assertEqual(refreshes, [])
  2653. def test_export_template_loads_config_context(self):
  2654. """
  2655. An API export renders model instances, so it loads config context even when the request omits the field.
  2656. """
  2657. self.add_permissions('dcim.view_device', 'extras.view_exporttemplate')
  2658. ConfigContext.objects.create(name='Config Context 1', weight=100, data={'foo': 123})
  2659. Device.objects.filter(name='Device 1').update(_config_context_data={'foo': 'cached'})
  2660. export_template = ExportTemplate.objects.create(
  2661. name='Config Contexts',
  2662. template_code=(
  2663. '{% for device in queryset %}{{ device.name }}={{ device.get_config_context().foo }},{% endfor %}'
  2664. ),
  2665. )
  2666. export_template.object_types.set([ObjectType.objects.get_for_model(Device)])
  2667. with CaptureQueriesContext(connection) as queries:
  2668. response = self.client.get(
  2669. self._get_list_url(), {'export': export_template.name, 'omit': 'config_context'}, **self.header
  2670. )
  2671. self.assertHttpStatus(response, status.HTTP_200_OK)
  2672. self.assertEqual(response.content.decode(), 'Device 1=cached,Device 2=123,Device 3=123,')
  2673. statements = [
  2674. query['sql'] for query in queries.captured_queries if '"extras_configcontext"' in query['sql']
  2675. ]
  2676. self.assertEqual(len(statements), 1)
  2677. self.assertTrue(statements[0].startswith('SELECT "dcim_device"'))
  2678. refreshes = [
  2679. query['sql'] for query in queries.captured_queries
  2680. if query['sql'].startswith('SELECT "dcim_device"."id", "dcim_device"."_config_context_data" FROM')
  2681. ]
  2682. self.assertEqual(refreshes, [])
  2683. def test_bulk_update_keeps_config_context_cache_loaded(self):
  2684. """
  2685. A bulk update which omits config_context loads the cache with the devices, not with a query per device.
  2686. """
  2687. self.add_permissions('dcim.change_device')
  2688. data = [{'id': device.pk, 'description': 'New description'} for device in Device.objects.all()]
  2689. with CaptureQueriesContext(connection) as queries:
  2690. response = self.client.patch(
  2691. f'{self._get_list_url()}?omit=config_context', data, format='json', **self.header
  2692. )
  2693. self.assertHttpStatus(response, status.HTTP_200_OK)
  2694. # The change log snapshot and full_clean() would fetch a deferred cache once per device
  2695. refreshes = [
  2696. query['sql'] for query in queries.captured_queries
  2697. if query['sql'].startswith('SELECT "dcim_device"."id", "dcim_device"."_config_context_data" FROM')
  2698. ]
  2699. self.assertEqual(refreshes, [])
  2700. def test_unique_name_per_site_constraint(self):
  2701. """
  2702. Check that creating a device with a duplicate name within a site fails.
  2703. """
  2704. device = Device.objects.first()
  2705. data = {
  2706. 'device_type': device.device_type.pk,
  2707. 'role': device.role.pk,
  2708. 'site': device.site.pk,
  2709. 'name': device.name,
  2710. }
  2711. self.add_permissions('dcim.add_device')
  2712. url = reverse('dcim-api:device-list')
  2713. response = self.client.post(url, data, format='json', **self.header)
  2714. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  2715. def test_rack_fit(self):
  2716. """
  2717. Check that creating multiple devices with overlapping position fails.
  2718. """
  2719. device = Device.objects.first()
  2720. device_type = DeviceType.objects.all()[1]
  2721. data = [
  2722. {
  2723. 'device_type': device_type.pk,
  2724. 'role': device.role.pk,
  2725. 'site': device.site.pk,
  2726. 'name': 'Test Device 7',
  2727. 'rack': device.rack.pk,
  2728. 'face': 'front',
  2729. 'position': 1
  2730. },
  2731. {
  2732. 'device_type': device_type.pk,
  2733. 'role': device.role.pk,
  2734. 'site': device.site.pk,
  2735. 'name': 'Test Device 8',
  2736. 'rack': device.rack.pk,
  2737. 'face': 'front',
  2738. 'position': 2
  2739. }
  2740. ]
  2741. self.add_permissions('dcim.add_device')
  2742. url = reverse('dcim-api:device-list')
  2743. response = self.client.post(url, data, format='json', **self.header)
  2744. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  2745. def test_create_device_replicates_components(self):
  2746. """
  2747. Creating a device without specifying replicate_components should instantiate its device type's components.
  2748. """
  2749. self.add_permissions('dcim.add_device')
  2750. device_type = DeviceType.objects.get(slug='device-type-1')
  2751. InterfaceTemplate.objects.create(device_type=device_type, name='eth0', type='1000base-t')
  2752. data = {
  2753. 'device_type': device_type.pk,
  2754. 'role': DeviceRole.objects.first().pk,
  2755. 'site': Site.objects.first().pk,
  2756. 'name': 'Replication Test Device',
  2757. }
  2758. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  2759. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  2760. self.assertNotIn('replicate_components', response.data)
  2761. device = Device.objects.get(pk=response.data['id'])
  2762. self.assertTrue(device.interfaces.filter(name='eth0').exists())
  2763. def test_create_device_without_replicating_components(self):
  2764. """
  2765. Creating a device with replicate_components=False should skip instantiation of its device type's components.
  2766. """
  2767. self.add_permissions('dcim.add_device', 'extras.view_tag')
  2768. device_type = DeviceType.objects.get(slug='device-type-1')
  2769. InterfaceTemplate.objects.create(device_type=device_type, name='eth0', type='1000base-t')
  2770. tag = Tag.objects.create(name='Tag 1', slug='tag-1')
  2771. data = [
  2772. {
  2773. 'device_type': device_type.pk,
  2774. 'role': DeviceRole.objects.first().pk,
  2775. 'site': Site.objects.first().pk,
  2776. 'name': f'No Replication Test Device {i}',
  2777. 'tags': [{'name': tag.name}],
  2778. 'replicate_components': False,
  2779. }
  2780. for i in range(1, 3)
  2781. ]
  2782. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  2783. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  2784. for device in Device.objects.filter(pk__in=[d['id'] for d in response.data]):
  2785. self.assertFalse(device.interfaces.exists())
  2786. self.assertEqual(list(device.tags.all()), [tag])
  2787. def test_list_objects_reuse_parent_device_serializers(self):
  2788. """Installed child devices share one nested parent serializer pair per request."""
  2789. self.add_permissions('dcim.view_device')
  2790. device = Device.objects.get(name='Device 1')
  2791. manufacturer = device.device_type.manufacturer
  2792. parent_type = DeviceType.objects.create(
  2793. manufacturer=manufacturer, model='Parent Type', slug='parent-type',
  2794. subdevice_role=SubdeviceRoleChoices.ROLE_PARENT,
  2795. )
  2796. child_type = DeviceType.objects.create(
  2797. manufacturer=manufacturer, model='Child Type', slug='child-type',
  2798. subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, u_height=0,
  2799. )
  2800. parent = Device.objects.create(device_type=parent_type, role=device.role, site=device.site, name='Parent')
  2801. children = []
  2802. for i in (1, 2):
  2803. child = Device.objects.create(
  2804. device_type=child_type, role=device.role, site=device.site, name=f'Child {i}'
  2805. )
  2806. DeviceBay.objects.create(device=parent, name=f'Bay {i}', installed_device=child)
  2807. children.append(child.pk)
  2808. url = reverse('dcim-api:device-list')
  2809. def get(ids, host):
  2810. with (
  2811. patch.object(
  2812. NestedDeviceSerializer, '__init__', autospec=True, side_effect=NestedDeviceSerializer.__init__
  2813. ) as device_init,
  2814. patch.object(
  2815. NestedDeviceBaySerializer, '__init__', autospec=True, side_effect=NestedDeviceBaySerializer.__init__
  2816. ) as bay_init,
  2817. ):
  2818. response = self.client.get(
  2819. url, {'id': ids, 'fields': 'name,parent_device'}, HTTP_HOST=host, **self.header
  2820. )
  2821. self.assertHttpStatus(response, status.HTTP_200_OK)
  2822. constructions = (device_init.call_count, bay_init.call_count)
  2823. return constructions, {row['name']: row['parent_device'] for row in response.data['results']}
  2824. one, _ = get(children[:1], 'a.example.com')
  2825. three, parents = get([*children, device.pk], 'b.example.com')
  2826. self.assertEqual(one, three)
  2827. self.assertIsNone(parents['Device 1'])
  2828. for i in (1, 2):
  2829. self.assertEqual(parents[f'Child {i}']['id'], parent.pk)
  2830. self.assertEqual(parents[f'Child {i}']['device_bay']['name'], f'Bay {i}')
  2831. self.assertTrue(parents[f'Child {i}']['url'].startswith('http://b.example.com/'))
  2832. def test_render_config(self):
  2833. configtemplate = ConfigTemplate.objects.create(
  2834. name='Config Template 1',
  2835. template_code='Config for device {{ device.name }}'
  2836. )
  2837. device = Device.objects.first()
  2838. device.config_template = configtemplate
  2839. device.save()
  2840. self.add_permissions('dcim.render_config_device', 'dcim.view_device')
  2841. url = reverse('dcim-api:device-render-config', kwargs={'pk': device.pk})
  2842. response = self.client.post(url, {}, format='json', **self.header)
  2843. self.assertHttpStatus(response, status.HTTP_200_OK)
  2844. self.assertEqual(response.data['content'], f'Config for device {device.name}')
  2845. def test_render_config_leaves_config_context_cache_unchanged(self):
  2846. """
  2847. A config template which mutates nested context data leaves the cached config context unchanged.
  2848. """
  2849. configtemplate = ConfigTemplate.objects.create(
  2850. name='Config Template 1',
  2851. template_code="{{ servers.append('192.0.2.2') or '' }}{{ servers|join(',') }}"
  2852. )
  2853. device = Device.objects.first()
  2854. device.config_template = configtemplate
  2855. device.save()
  2856. Device.objects.filter(pk=device.pk).update(_config_context_data={'servers': ['192.0.2.1']})
  2857. self.add_permissions('dcim.render_config_device', 'dcim.view_device')
  2858. url = reverse('dcim-api:device-render-config', kwargs={'pk': device.pk})
  2859. with patch.object(
  2860. Device, 'get_config_context', autospec=True, side_effect=Device.get_config_context
  2861. ) as get_config_context:
  2862. response = self.client.post(url, {}, format='json', **self.header)
  2863. self.assertHttpStatus(response, status.HTTP_200_OK)
  2864. self.assertEqual(response.data['content'], '192.0.2.1,192.0.2.2')
  2865. # A leak would only show on the view's in-memory instance
  2866. instance = get_config_context.call_args.args[0]
  2867. self.assertEqual(instance._config_context_data, {'servers': ['192.0.2.1']})
  2868. def test_render_config_without_permission(self):
  2869. configtemplate = ConfigTemplate.objects.create(
  2870. name='Config Template 1',
  2871. template_code='Config for device {{ device.name }}'
  2872. )
  2873. device = Device.objects.first()
  2874. device.config_template = configtemplate
  2875. device.save()
  2876. # No permissions added - user has no render_config permission
  2877. url = reverse('dcim-api:device-render-config', kwargs={'pk': device.pk})
  2878. response = self.client.post(url, {}, format='json', **self.header)
  2879. self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
  2880. def test_render_config_token_write_enabled(self):
  2881. configtemplate = ConfigTemplate.objects.create(
  2882. name='Config Template 1',
  2883. template_code='Config for device {{ device.name }}'
  2884. )
  2885. device = Device.objects.first()
  2886. device.config_template = configtemplate
  2887. device.save()
  2888. self.add_permissions('dcim.render_config_device', 'dcim.view_device')
  2889. url = reverse('dcim-api:device-render-config', kwargs={'pk': device.pk})
  2890. # Request without token auth should fail with PermissionDenied
  2891. response = self.client.post(url, {}, format='json')
  2892. self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
  2893. # Create token with write_enabled=False
  2894. token = Token.objects.create(version=2, user=self.user, write_enabled=False)
  2895. token_header = f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'
  2896. # Request with write-disabled token should fail
  2897. response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
  2898. self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
  2899. # Enable write and retry
  2900. token.write_enabled = True
  2901. token.save()
  2902. response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
  2903. self.assertHttpStatus(response, status.HTTP_200_OK)
  2904. def test_list_object_includes_nat_inside_on_primary_ip(self):
  2905. device = create_test_device('natted-device')
  2906. interface = Interface.objects.create(device=device, name='eth0', type='other')
  2907. real_ip, nat_ip = create_test_nat_ip_pair(
  2908. real_address='10.0.0.10/32',
  2909. nat_address='198.51.100.10/32',
  2910. inside_interface=interface,
  2911. )
  2912. device.primary_ip4 = nat_ip
  2913. device.save()
  2914. self.add_permissions('dcim.view_device', 'ipam.view_ipaddress')
  2915. response = self.client.get(f'{self._get_list_url()}?id={device.pk}', **self.header)
  2916. self.assertHttpStatus(response, status.HTTP_200_OK)
  2917. result = response.data['results'][0]
  2918. for field in ('primary_ip', 'primary_ip4'):
  2919. self.assertEqual(result[field]['address'], str(nat_ip.address))
  2920. self.assertEqual(result[field]['nat_inside']['address'], str(real_ip.address))
  2921. self.assertEqual(result[field]['nat_outside'], [])
  2922. def test_get_object_includes_nat_outside_on_primary_ip(self):
  2923. device = create_test_device('real-ip-device')
  2924. interface = Interface.objects.create(device=device, name='eth0', type='other')
  2925. real_ip, nat_ip = create_test_nat_ip_pair(
  2926. real_address='10.0.0.11/32',
  2927. nat_address='198.51.100.11/32',
  2928. inside_interface=interface,
  2929. )
  2930. device.primary_ip4 = real_ip
  2931. device.save()
  2932. self.add_permissions('dcim.view_device', 'ipam.view_ipaddress')
  2933. response = self.client.get(
  2934. self._get_detail_url(device),
  2935. **self.header,
  2936. )
  2937. self.assertHttpStatus(response, status.HTTP_200_OK)
  2938. for field in ('primary_ip', 'primary_ip4'):
  2939. self.assertEqual(response.data[field]['address'], str(real_ip.address))
  2940. self.assertIsNone(response.data[field]['nat_inside'])
  2941. self.assertCountEqual(
  2942. [ip['address'] for ip in response.data[field]['nat_outside']],
  2943. [str(nat_ip.address)],
  2944. )
  2945. def test_get_object_includes_nat_on_oob_ip(self):
  2946. device = create_test_device('oob-nat-device')
  2947. interface = Interface.objects.create(device=device, name='oob0', type='other')
  2948. real_ip, nat_ip = create_test_nat_ip_pair(
  2949. real_address='10.0.0.12/32',
  2950. nat_address='198.51.100.12/32',
  2951. inside_interface=interface,
  2952. )
  2953. device.oob_ip = nat_ip
  2954. device.save()
  2955. self.add_permissions('dcim.view_device', 'ipam.view_ipaddress')
  2956. response = self.client.get(
  2957. self._get_detail_url(device),
  2958. **self.header,
  2959. )
  2960. self.assertHttpStatus(response, status.HTTP_200_OK)
  2961. self.assertEqual(response.data['oob_ip']['address'], str(nat_ip.address))
  2962. self.assertEqual(response.data['oob_ip']['nat_inside']['address'], str(real_ip.address))
  2963. self.assertEqual(response.data['oob_ip']['nat_outside'], [])
  2964. def test_get_object_includes_dns_name_on_primary_ip(self):
  2965. device = create_test_device('dns-device')
  2966. interfaces = (
  2967. Interface.objects.create(device=device, name='eth0', type='other'),
  2968. Interface.objects.create(device=device, name='eth1', type='other'),
  2969. )
  2970. ip4 = IPAddress(address='192.0.2.10/32', dns_name='device4.example.com')
  2971. ip4.assigned_object = interfaces[0]
  2972. ip4.save()
  2973. ip6 = IPAddress(address='2001:db8::10/128', dns_name='device6.example.com')
  2974. ip6.assigned_object = interfaces[1]
  2975. ip6.save()
  2976. device.primary_ip4 = ip4
  2977. device.primary_ip6 = ip6
  2978. device.save()
  2979. self.add_permissions('dcim.view_device', 'ipam.view_ipaddress')
  2980. response = self.client.get(
  2981. f'{self._get_detail_url(device)}?exclude=config_context',
  2982. **self.header,
  2983. )
  2984. self.assertHttpStatus(response, status.HTTP_200_OK)
  2985. self.assertEqual(response.data['primary_ip4']['dns_name'], 'device4.example.com')
  2986. self.assertEqual(response.data['primary_ip6']['dns_name'], 'device6.example.com')
  2987. self.assertIn(
  2988. response.data['primary_ip']['dns_name'],
  2989. ('device4.example.com', 'device6.example.com'),
  2990. )
  2991. def test_get_object_includes_dns_name_on_oob_ip(self):
  2992. device = create_test_device('dns-oob-device')
  2993. interface = Interface.objects.create(device=device, name='oob0', type='other')
  2994. ip = IPAddress(address='192.0.2.20/32', dns_name='oob.example.com')
  2995. ip.assigned_object = interface
  2996. ip.save()
  2997. device.oob_ip = ip
  2998. device.save()
  2999. self.add_permissions('dcim.view_device', 'ipam.view_ipaddress')
  3000. response = self.client.get(
  3001. f'{self._get_detail_url(device)}?exclude=config_context',
  3002. **self.header,
  3003. )
  3004. self.assertHttpStatus(response, status.HTTP_200_OK)
  3005. self.assertEqual(response.data['oob_ip']['dns_name'], 'oob.example.com')
  3006. @tag('regression') # Issue #23274
  3007. def test_create_rejects_ips_of_other_devices(self):
  3008. """Creating a device with a primary or OOB IP on another device's interface returns HTTP 400."""
  3009. device = create_test_device('ip-owner-device')
  3010. interface = Interface.objects.create(device=device, name='eth0', type='other')
  3011. ip4 = IPAddress.objects.create(address='192.0.2.1/24', assigned_object=interface)
  3012. ip6 = IPAddress.objects.create(address='2001:db8::1/64', assigned_object=interface)
  3013. self.add_permissions('dcim.add_device')
  3014. for field, ip in (('primary_ip4', ip4), ('primary_ip6', ip6), ('oob_ip', ip4)):
  3015. with self.subTest(field=field):
  3016. data = {
  3017. 'device_type': device.device_type.pk,
  3018. 'role': device.role.pk,
  3019. 'site': device.site.pk,
  3020. 'name': f'new-device-{field}',
  3021. field: ip.pk,
  3022. }
  3023. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  3024. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  3025. self.assertIn(field, response.data)
  3026. def test_render_config_with_config_template_id(self):
  3027. default_template = ConfigTemplate.objects.create(
  3028. name='Default Template',
  3029. template_code='Default config for {{ device.name }}'
  3030. )
  3031. override_template = ConfigTemplate.objects.create(
  3032. name='Override Template',
  3033. template_code='Override config for {{ device.name }}'
  3034. )
  3035. device = Device.objects.first()
  3036. device.config_template = default_template
  3037. device.save()
  3038. self.add_permissions('dcim.render_config_device', 'dcim.view_device', 'extras.view_configtemplate')
  3039. url = reverse('dcim-api:device-render-config', kwargs={'pk': device.pk})
  3040. # Render with override template
  3041. response = self.client.post(url, {'config_template_id': override_template.pk}, format='json', **self.header)
  3042. self.assertHttpStatus(response, status.HTTP_200_OK)
  3043. self.assertEqual(response.data['content'], f'Override config for {device.name}')
  3044. # Render with nonexistent config_template_id
  3045. response = self.client.post(url, {'config_template_id': 999999}, format='json', **self.header)
  3046. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  3047. # Render with non-integer config_template_id
  3048. response = self.client.post(url, {'config_template_id': 'abc'}, format='json', **self.header)
  3049. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  3050. # Without view_configtemplate permission, override template should not be accessible
  3051. self.remove_permissions('extras.view_configtemplate')
  3052. response = self.client.post(url, {'config_template_id': override_template.pk}, format='json', **self.header)
  3053. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  3054. def test_bulk_create_objects_validation_error(self):
  3055. """
  3056. POST a set of Device objects where the first passes and the second fails validation. The
  3057. response should report only the failed object, and no objects should be created despite
  3058. the first item passing (atomic rollback).
  3059. """
  3060. obj_perm = ObjectPermission(name='Test permission', actions=['add'])
  3061. obj_perm.save()
  3062. obj_perm.users.add(self.user)
  3063. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  3064. initial_count = self._get_queryset().count()
  3065. # First item is valid; second is empty (missing required fields) and will fail
  3066. response = self.client.post(
  3067. self._get_list_url(),
  3068. [self.create_data[0], {}],
  3069. format='json',
  3070. **self.header,
  3071. )
  3072. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  3073. self.assertEqual(
  3074. self._get_queryset().count(), initial_count,
  3075. 'No objects should be created when any sibling fails validation',
  3076. )
  3077. self.assertIn('detail', response.data)
  3078. self.assertIn('errors', response.data)
  3079. self.assertEqual(len(response.data['errors']), 1)
  3080. # Second item failed validation — first item succeeded so it's omitted
  3081. self.assertEqual(response.data['errors'][0]['index'], 1)
  3082. self.assertIn('errors', response.data['errors'][0])
  3083. def test_bulk_create_objects_abort_request(self):
  3084. """
  3085. POST a set of Device objects where a signal receiver raises AbortRequest for more than one
  3086. of them. Each failure must be correlated to its position in the request (proving the batch
  3087. continues past the first abort) and no object may be created.
  3088. """
  3089. # This tag may only be assigned to Regions, so assigning it to a Device raises AbortRequest
  3090. # from extras.signals.validate_assigned_tags.
  3091. restricted_tag = Tag.objects.create(name='Regions Only', slug='regions-only')
  3092. restricted_tag.object_types.set([ObjectType.objects.get_for_model(Region)])
  3093. obj_perm = ObjectPermission(name='Test permission', actions=['add'])
  3094. obj_perm.save()
  3095. obj_perm.users.add(self.user)
  3096. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  3097. self.add_permissions('extras.view_tag')
  3098. initial_count = self._get_queryset().count()
  3099. data = [
  3100. {**self.create_data[0], 'tags': [{'name': 'Regions Only'}]},
  3101. {**self.create_data[1], 'tags': [{'name': 'Regions Only'}]},
  3102. ]
  3103. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  3104. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  3105. self.assertIn('detail', response.data)
  3106. self.assertEqual([e['index'] for e in response.data['errors']], [0, 1])
  3107. for error in response.data['errors']:
  3108. self.assertIsInstance(error['errors']['__all__'], list)
  3109. self.assertEqual(
  3110. self._get_queryset().count(), initial_count,
  3111. 'No objects should be created when any sibling is aborted',
  3112. )
  3113. def test_field_prefetches_follow_requested_fields(self):
  3114. """List and detail responses carry the declared prefetches of exactly the fields they include."""
  3115. self.add_permissions('dcim.view_device')
  3116. manufacturer = list(DeviceViewSet.field_prefetches[('display', 'config_context')])
  3117. parent = list(DeviceViewSet.field_prefetches[('parent_device',)])
  3118. # fields takes precedence over omit, and both over brief
  3119. cases = {
  3120. '': manufacturer + parent,
  3121. 'brief=1': manufacturer,
  3122. 'fields=id,name': [],
  3123. 'fields=id,display': manufacturer,
  3124. 'fields=id,config_context': manufacturer,
  3125. 'fields=id,parent_device': parent,
  3126. 'omit=parent_device': manufacturer,
  3127. 'fields=id,parent_device&omit=parent_device': parent,
  3128. 'brief=1&omit=display': manufacturer + parent,
  3129. 'brief=1&fields=id': [],
  3130. }
  3131. for url in (self._get_list_url(), self._get_detail_url(Device.objects.get(name='Device 1'))):
  3132. for params, expected in cases.items():
  3133. with self.subTest(url=url, params=params):
  3134. with self.capture_querysets(DeviceViewSet) as querysets:
  3135. response = self.client.get(f'{url}?{params}', **self.header)
  3136. self.assertHttpStatus(response, status.HTTP_200_OK)
  3137. self.assert_lookups(querysets, expected)
  3138. self.assertEqual(DeviceViewSet.queryset._prefetch_related_lookups, ())
  3139. class ModuleTestCase(APIViewTestCases.APIViewTestCase):
  3140. model = Module
  3141. brief_fields = ['description', 'device', 'display', 'id', 'module_bay', 'module_type', 'url']
  3142. bulk_update_data = {
  3143. 'serial': '1234ABCD',
  3144. }
  3145. bulk_update_invalid_data = {
  3146. 'status': 'not-a-valid-status',
  3147. }
  3148. user_permissions = (
  3149. 'dcim.view_modulebay', 'dcim.view_moduletype', 'dcim.view_moduletypeprofile', 'dcim.view_device'
  3150. )
  3151. @classmethod
  3152. def setUpTestData(cls):
  3153. manufacturer = Manufacturer.objects.create(name='Generic', slug='generic')
  3154. profiles = (
  3155. ModuleTypeProfile(name='Test CPU'),
  3156. ModuleTypeProfile(name='Test Hard disk'),
  3157. )
  3158. ModuleTypeProfile.objects.bulk_create(profiles)
  3159. device = create_test_device('Test Device 1')
  3160. module_types = (
  3161. ModuleType(manufacturer=manufacturer, model='Module Type 1', profile=profiles[0]),
  3162. ModuleType(manufacturer=manufacturer, model='Module Type 2', profile=profiles[1]),
  3163. ModuleType(manufacturer=manufacturer, model='Module Type 3'),
  3164. )
  3165. ModuleType.objects.bulk_create(module_types)
  3166. module_bays = (
  3167. ModuleBay(device=device, name='Module Bay 1'),
  3168. ModuleBay(device=device, name='Module Bay 2'),
  3169. ModuleBay(device=device, name='Module Bay 3'),
  3170. ModuleBay(device=device, name='Module Bay 4'),
  3171. ModuleBay(device=device, name='Module Bay 5'),
  3172. ModuleBay(device=device, name='Module Bay 6'),
  3173. )
  3174. for module_bay in module_bays:
  3175. module_bay.save()
  3176. modules = (
  3177. Module(device=device, module_bay=module_bays[0], module_type=module_types[0]),
  3178. Module(device=device, module_bay=module_bays[1], module_type=module_types[1]),
  3179. Module(device=device, module_bay=module_bays[2], module_type=module_types[2]),
  3180. )
  3181. Module.objects.bulk_create(modules)
  3182. cls.create_data = [
  3183. {
  3184. 'device': device.pk,
  3185. 'module_bay': module_bays[3].pk,
  3186. 'module_type': module_types[0].pk,
  3187. 'status': ModuleStatusChoices.STATUS_ACTIVE,
  3188. 'serial': 'ABC123',
  3189. 'asset_tag': 'Foo1',
  3190. },
  3191. {
  3192. 'device': device.pk,
  3193. 'module_bay': module_bays[4].pk,
  3194. 'module_type': module_types[1].pk,
  3195. 'status': ModuleStatusChoices.STATUS_ACTIVE,
  3196. 'serial': 'DEF456',
  3197. 'asset_tag': 'Foo2',
  3198. },
  3199. {
  3200. 'device': device.pk,
  3201. 'module_bay': module_bays[5].pk,
  3202. 'module_type': module_types[2].pk,
  3203. 'status': ModuleStatusChoices.STATUS_ACTIVE,
  3204. 'serial': 'GHI789',
  3205. 'asset_tag': 'Foo3',
  3206. },
  3207. ]
  3208. cls.update_data = {
  3209. 'device': device.pk,
  3210. 'module_bay': module_bays[3].pk,
  3211. 'module_type': module_types[0].pk,
  3212. 'status': 'active',
  3213. 'serial': 'ABC123',
  3214. }
  3215. def test_is_bay_compatible_flag(self):
  3216. """
  3217. is_bay_compatible should be True when no bay types are set, and False when the
  3218. bay's types and the module type's types are both set but share no common members.
  3219. """
  3220. self.add_permissions('dcim.view_module')
  3221. manufacturer = Manufacturer.objects.get(name='Generic')
  3222. device = create_test_device('Compat Test Device')
  3223. bay_type_a = ModuleBayType.objects.create(manufacturer=manufacturer, name='Bay Type A', slug='bay-type-a')
  3224. bay_type_b = ModuleBayType.objects.create(manufacturer=manufacturer, name='Bay Type B', slug='bay-type-b')
  3225. module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Compat Module Type')
  3226. module_type.module_bay_types.set([bay_type_a])
  3227. compatible_bay = ModuleBay.objects.create(device=device, name='Compatible Bay')
  3228. compatible_bay.module_bay_types.set([bay_type_a])
  3229. incompatible_bay = ModuleBay.objects.create(device=device, name='Incompatible Bay')
  3230. incompatible_bay.module_bay_types.set([bay_type_b])
  3231. unconstrained_bay = ModuleBay.objects.create(device=device, name='Unconstrained Bay')
  3232. compatible_module = Module.objects.create(
  3233. device=device, module_bay=compatible_bay, module_type=module_type
  3234. )
  3235. incompatible_module = Module.objects.create(
  3236. device=device, module_bay=incompatible_bay, module_type=module_type
  3237. )
  3238. unconstrained_module = Module.objects.create(
  3239. device=device, module_bay=unconstrained_bay, module_type=module_type
  3240. )
  3241. url = reverse('dcim-api:module-detail', kwargs={'pk': compatible_module.pk})
  3242. response = self.client.get(url, **self.header)
  3243. self.assertHttpStatus(response, 200)
  3244. self.assertTrue(response.data['is_bay_compatible'])
  3245. url = reverse('dcim-api:module-detail', kwargs={'pk': incompatible_module.pk})
  3246. response = self.client.get(url, **self.header)
  3247. self.assertHttpStatus(response, 200)
  3248. self.assertFalse(response.data['is_bay_compatible'])
  3249. url = reverse('dcim-api:module-detail', kwargs={'pk': unconstrained_module.pk})
  3250. response = self.client.get(url, **self.header)
  3251. self.assertHttpStatus(response, 200)
  3252. self.assertTrue(response.data['is_bay_compatible'])
  3253. def test_replicate_components(self):
  3254. """
  3255. Installing a module with replicate_components=True (the default) should create
  3256. components from the module type's templates on the parent device.
  3257. """
  3258. self.add_permissions('dcim.add_module')
  3259. manufacturer = Manufacturer.objects.get(name='Generic')
  3260. device = create_test_device('Device for Replication Test')
  3261. module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Replication Test Module Type')
  3262. InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
  3263. module_bay = ModuleBay.objects.create(device=device, name='Replication Bay')
  3264. url = reverse('dcim-api:module-list')
  3265. data = {
  3266. 'device': device.pk,
  3267. 'module_bay': module_bay.pk,
  3268. 'module_type': module_type.pk,
  3269. 'replicate_components': True,
  3270. }
  3271. response = self.client.post(url, data, format='json', **self.header)
  3272. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  3273. self.assertTrue(device.interfaces.filter(name='eth0').exists())
  3274. def test_no_replicate_components(self):
  3275. """
  3276. Installing a module with replicate_components=False should NOT create components
  3277. from the module type's templates.
  3278. """
  3279. self.add_permissions('dcim.add_module')
  3280. manufacturer = Manufacturer.objects.get(name='Generic')
  3281. device = create_test_device('Device for No Replication Test')
  3282. module_type = ModuleType.objects.create(manufacturer=manufacturer, model='No Replication Test Module Type')
  3283. InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
  3284. module_bay = ModuleBay.objects.create(device=device, name='No Replication Bay')
  3285. url = reverse('dcim-api:module-list')
  3286. data = {
  3287. 'device': device.pk,
  3288. 'module_bay': module_bay.pk,
  3289. 'module_type': module_type.pk,
  3290. 'replicate_components': False,
  3291. }
  3292. response = self.client.post(url, data, format='json', **self.header)
  3293. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  3294. self.assertFalse(device.interfaces.filter(name='eth0').exists())
  3295. def test_adopt_components(self):
  3296. """
  3297. Installing a module with adopt_components=True should assign existing unattached
  3298. device components to the new module.
  3299. """
  3300. self.add_permissions('dcim.add_module')
  3301. manufacturer = Manufacturer.objects.get(name='Generic')
  3302. device = create_test_device('Device for Adopt Test')
  3303. module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Adopt Test Module Type')
  3304. InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
  3305. module_bay = ModuleBay.objects.create(device=device, name='Adopt Bay')
  3306. existing_iface = Interface.objects.create(device=device, name='eth0', type='1000base-t')
  3307. url = reverse('dcim-api:module-list')
  3308. data = {
  3309. 'device': device.pk,
  3310. 'module_bay': module_bay.pk,
  3311. 'module_type': module_type.pk,
  3312. 'adopt_components': True,
  3313. 'replicate_components': False,
  3314. }
  3315. response = self.client.post(url, data, format='json', **self.header)
  3316. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  3317. existing_iface.refresh_from_db()
  3318. self.assertIsNotNone(existing_iface.module)
  3319. def test_adopt_components_changelog(self):
  3320. """
  3321. Adopted components should be change-logged with prechange data and have last_updated bumped.
  3322. """
  3323. self.add_permissions('dcim.add_module')
  3324. manufacturer = Manufacturer.objects.get(name='Generic')
  3325. device = create_test_device('Device for Adopt Changelog Test')
  3326. module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Adopt Changelog Module Type')
  3327. InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
  3328. ModuleBayTemplate.objects.create(module_type=module_type, name='Sub Bay')
  3329. module_bay = ModuleBay.objects.create(device=device, name='Adopt Changelog Bay')
  3330. # Adopted bays are also re-parented under the module's bay
  3331. expected_diffs = [
  3332. (Interface.objects.create(device=device, name='eth0', type='1000base-t'), {'module': None}),
  3333. (ModuleBay.objects.create(device=device, name='Sub Bay'), {'module': None, 'parent': None}),
  3334. ]
  3335. original_last_updated = {component.pk: component.last_updated for component, _ in expected_diffs}
  3336. url = reverse('dcim-api:module-list')
  3337. data = {
  3338. 'device': device.pk,
  3339. 'module_bay': module_bay.pk,
  3340. 'module_type': module_type.pk,
  3341. 'adopt_components': True,
  3342. 'replicate_components': False,
  3343. }
  3344. response = self.client.post(url, data, format='json', **self.header)
  3345. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  3346. module_pk = response.data['id']
  3347. for component, expected_pre in expected_diffs:
  3348. component.refresh_from_db()
  3349. self.assertEqual(component.module_id, module_pk)
  3350. self.assertGreater(component.last_updated, original_last_updated[component.pk])
  3351. objectchange = ObjectChange.objects.get(
  3352. changed_object_type=ObjectType.objects.get_for_model(component),
  3353. changed_object_id=component.pk,
  3354. action=ObjectChangeActionChoices.ACTION_UPDATE,
  3355. )
  3356. self.assertIsNotNone(objectchange.prechange_data)
  3357. self.assertIsNone(objectchange.prechange_data['module'])
  3358. self.assertEqual(objectchange.postchange_data['module'], module_pk)
  3359. self.assertEqual(objectchange.diff()['pre'], expected_pre)
  3360. # Trigger-maintained ltree columns must reflect the post-adoption location
  3361. if isinstance(component, ModuleBay):
  3362. self.assertEqual(objectchange.postchange_data['path'], str(component.path))
  3363. self.assertEqual(objectchange.postchange_data['sort_path'], component.sort_path)
  3364. def test_install_module_interface_parent_bridge_changelog(self):
  3365. """
  3366. Interface parent/bridge assignments made during module installation must record prechange data.
  3367. """
  3368. self.add_permissions('dcim.add_module')
  3369. manufacturer = Manufacturer.objects.get(name='Generic')
  3370. device = create_test_device('Device for Interface Parent Changelog Test')
  3371. module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Parent Changelog Module Type')
  3372. eth0 = InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
  3373. InterfaceTemplate.objects.create(module_type=module_type, name='eth0.100', type='virtual', parent=eth0)
  3374. InterfaceTemplate.objects.create(module_type=module_type, name='br0', type='bridge')
  3375. InterfaceTemplate.objects.create(
  3376. module_type=module_type, name='eth1', type='1000base-t', bridge=InterfaceTemplate.objects.get(name='br0')
  3377. )
  3378. module_bay = ModuleBay.objects.create(device=device, name='Parent Changelog Bay')
  3379. url = reverse('dcim-api:module-list')
  3380. data = {
  3381. 'device': device.pk,
  3382. 'module_bay': module_bay.pk,
  3383. 'module_type': module_type.pk,
  3384. }
  3385. response = self.client.post(url, data, format='json', **self.header)
  3386. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  3387. for name, field in (('eth0.100', 'parent'), ('eth1', 'bridge')):
  3388. interface = Interface.objects.get(device=device, name=name)
  3389. objectchange = ObjectChange.objects.get(
  3390. changed_object_type=ObjectType.objects.get_for_model(Interface),
  3391. changed_object_id=interface.pk,
  3392. action=ObjectChangeActionChoices.ACTION_UPDATE,
  3393. )
  3394. self.assertIsNotNone(objectchange.prechange_data)
  3395. self.assertIsNone(objectchange.prechange_data[field])
  3396. self.assertEqual(objectchange.postchange_data[field], getattr(interface, f'{field}_id'))
  3397. def test_replicate_components_conflict(self):
  3398. """
  3399. Installing a module with replicate_components=True when a component with the same name
  3400. already exists should return a validation error.
  3401. """
  3402. self.add_permissions('dcim.add_module')
  3403. manufacturer = Manufacturer.objects.get(name='Generic')
  3404. device = create_test_device('Device for Conflict Test')
  3405. module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Conflict Test Module Type')
  3406. InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
  3407. module_bay = ModuleBay.objects.create(device=device, name='Conflict Bay')
  3408. Interface.objects.create(device=device, name='eth0', type='1000base-t')
  3409. url = reverse('dcim-api:module-list')
  3410. data = {
  3411. 'device': device.pk,
  3412. 'module_bay': module_bay.pk,
  3413. 'module_type': module_type.pk,
  3414. 'replicate_components': True,
  3415. }
  3416. response = self.client.post(url, data, format='json', **self.header)
  3417. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  3418. def test_adopt_components_already_owned(self):
  3419. """
  3420. Installing a module with adopt_components=True when an existing component already
  3421. belongs to another module should return a validation error.
  3422. """
  3423. self.add_permissions('dcim.add_module')
  3424. manufacturer = Manufacturer.objects.get(name='Generic')
  3425. device = create_test_device('Device for Adopt Owned Test')
  3426. owner_module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Owner Module Type')
  3427. module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Adopt Owned Test Module Type')
  3428. InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
  3429. owner_bay = ModuleBay.objects.create(device=device, name='Owner Bay')
  3430. target_bay = ModuleBay.objects.create(device=device, name='Adopt Owned Bay')
  3431. # Install a module that owns the interface
  3432. owner_module = Module.objects.create(device=device, module_bay=owner_bay, module_type=owner_module_type)
  3433. Interface.objects.create(device=device, name='eth0', type='1000base-t', module=owner_module)
  3434. url = reverse('dcim-api:module-list')
  3435. data = {
  3436. 'device': device.pk,
  3437. 'module_bay': target_bay.pk,
  3438. 'module_type': module_type.pk,
  3439. 'adopt_components': True,
  3440. }
  3441. response = self.client.post(url, data, format='json', **self.header)
  3442. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  3443. def test_patch_ignores_replicate_and_adopt(self):
  3444. """
  3445. PATCH requests that include replicate_components or adopt_components should not
  3446. trigger component replication or adoption (these fields are create-only).
  3447. """
  3448. self.add_permissions('dcim.change_module')
  3449. manufacturer = Manufacturer.objects.get(name='Generic')
  3450. device = create_test_device('Device for PATCH Test')
  3451. module_type = ModuleType.objects.create(manufacturer=manufacturer, model='PATCH Test Module Type')
  3452. InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
  3453. module_bay = ModuleBay.objects.create(device=device, name='PATCH Bay')
  3454. # Create the module without replication so we can verify PATCH doesn't trigger it
  3455. module = Module(device=device, module_bay=module_bay, module_type=module_type)
  3456. module._disable_replication = True
  3457. module.save()
  3458. url = reverse('dcim-api:module-detail', kwargs={'pk': module.pk})
  3459. data = {
  3460. 'replicate_components': True,
  3461. 'adopt_components': True,
  3462. 'serial': 'PATCHED',
  3463. }
  3464. response = self.client.patch(url, data, format='json', **self.header)
  3465. self.assertHttpStatus(response, status.HTTP_200_OK)
  3466. self.assertEqual(response.data['serial'], 'PATCHED')
  3467. # No interfaces should have been created by the PATCH
  3468. self.assertFalse(device.interfaces.exists())
  3469. def test_adopt_and_replicate_components(self):
  3470. """
  3471. Installing a module with both adopt_components=True and replicate_components=True
  3472. should adopt existing unowned components and create new components for templates
  3473. that have no matching existing component.
  3474. """
  3475. self.add_permissions('dcim.add_module')
  3476. manufacturer = Manufacturer.objects.get(name='Generic')
  3477. device = create_test_device('Device for Adopt+Replicate Test')
  3478. module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Adopt+Replicate Test Module Type')
  3479. InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
  3480. InterfaceTemplate.objects.create(module_type=module_type, name='eth1', type='1000base-t')
  3481. module_bay = ModuleBay.objects.create(device=device, name='Adopt+Replicate Bay')
  3482. # eth0 already exists (unowned); eth1 does not
  3483. existing_iface = Interface.objects.create(device=device, name='eth0', type='1000base-t')
  3484. url = reverse('dcim-api:module-list')
  3485. data = {
  3486. 'device': device.pk,
  3487. 'module_bay': module_bay.pk,
  3488. 'module_type': module_type.pk,
  3489. 'adopt_components': True,
  3490. 'replicate_components': True,
  3491. }
  3492. response = self.client.post(url, data, format='json', **self.header)
  3493. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  3494. # eth0 should have been adopted (now owned by the new module)
  3495. existing_iface.refresh_from_db()
  3496. self.assertIsNotNone(existing_iface.module)
  3497. # eth1 should have been created
  3498. self.assertTrue(device.interfaces.filter(name='eth1').exists())
  3499. @tag('regression') # Issue #23478
  3500. def test_module_token_no_position(self):
  3501. """
  3502. Installing a module whose type has a template with a MODULE_TOKEN placeholder into a
  3503. module bay with no position defined should return a validation error.
  3504. """
  3505. self.add_permissions('dcim.add_module')
  3506. manufacturer = Manufacturer.objects.get(name='Generic')
  3507. device = create_test_device('Device for Token No-Position Test')
  3508. url = reverse('dcim-api:module-list')
  3509. # One placeholder in each field that installation resolves, each on its own module type
  3510. templates = (
  3511. (InterfaceTemplate, {'name': f'{MODULE_TOKEN}-eth0', 'type': '1000base-t'}),
  3512. (InterfaceTemplate, {'name': 'eth0', 'label': f'Port {MODULE_TOKEN}', 'type': '1000base-t'}),
  3513. (ModuleBayTemplate, {'name': f'Bay {MODULE_TOKEN}'}),
  3514. (ModuleBayTemplate, {'name': 'Bay', 'label': f'Bay {MODULE_TOKEN}'}),
  3515. (ModuleBayTemplate, {'name': 'Bay', 'position': MODULE_TOKEN}),
  3516. )
  3517. for i, (template_model, attrs) in enumerate(templates, start=1):
  3518. with self.subTest(template=template_model.__name__, attrs=attrs):
  3519. module_type = ModuleType.objects.create(
  3520. manufacturer=manufacturer, model=f'Token No-Position Module Type {i}'
  3521. )
  3522. template_model.objects.create(module_type=module_type, **attrs)
  3523. module_bay = ModuleBay.objects.create(device=device, name=f'No-Position Bay {i}')
  3524. data = {
  3525. 'device': device.pk,
  3526. 'module_bay': module_bay.pk,
  3527. 'module_type': module_type.pk,
  3528. }
  3529. response = self.client.post(url, data, format='json', **self.header)
  3530. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  3531. self.assertIn('no position defined', str(response.data.get('__all__')))
  3532. self.assertFalse(Module.objects.filter(module_bay=module_bay).exists())
  3533. @tag('regression') # Issue #23478
  3534. def test_module_token_depth_mismatch(self):
  3535. """
  3536. Installing a module whose template has more MODULE_TOKEN placeholders in a field than the
  3537. depth of the module bay tree should return a validation error.
  3538. """
  3539. self.add_permissions('dcim.add_module')
  3540. manufacturer = Manufacturer.objects.get(name='Generic')
  3541. device = create_test_device('Device for Token Depth Mismatch Test')
  3542. url = reverse('dcim-api:module-list')
  3543. # Two placeholders in each field that installation resolves, while every bay is one level deep
  3544. tokens = f'{MODULE_TOKEN}/{MODULE_TOKEN}'
  3545. templates = (
  3546. (InterfaceTemplate, {'name': f'{MODULE_TOKEN}-{MODULE_TOKEN}-eth0', 'type': '1000base-t'}),
  3547. (InterfaceTemplate, {'name': 'eth0', 'label': tokens, 'type': '1000base-t'}),
  3548. (ModuleBayTemplate, {'name': tokens}),
  3549. (ModuleBayTemplate, {'name': 'Bay', 'label': tokens}),
  3550. (ModuleBayTemplate, {'name': 'Bay', 'position': tokens}),
  3551. )
  3552. for i, (template_model, attrs) in enumerate(templates, start=1):
  3553. with self.subTest(template=template_model.__name__, attrs=attrs):
  3554. module_type = ModuleType.objects.create(
  3555. manufacturer=manufacturer, model=f'Token Depth Mismatch Module Type {i}'
  3556. )
  3557. template_model.objects.create(module_type=module_type, **attrs)
  3558. module_bay = ModuleBay.objects.create(device=device, name=f'Depth 1 Bay {i}', position='1')
  3559. data = {
  3560. 'device': device.pk,
  3561. 'module_bay': module_bay.pk,
  3562. 'module_type': module_type.pk,
  3563. }
  3564. response = self.client.post(url, data, format='json', **self.header)
  3565. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  3566. # Rejected by validation, not by the AbortRequest that component creation would raise
  3567. self.assertIn('2 placeholders given', str(response.data.get('__all__')))
  3568. self.assertFalse(Module.objects.filter(module_bay=module_bay).exists())
  3569. @tag('regression') # Issue #23478
  3570. def test_module_token_empty_ancestor_position(self):
  3571. """One placeholder per level needs every bay position, a single placeholder only the immediate one."""
  3572. self.add_permissions('dcim.add_module')
  3573. manufacturer = Manufacturer.objects.get(name='Generic')
  3574. device = create_test_device('Device for Token Ancestor Test')
  3575. parent_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token Ancestor Parent Type')
  3576. parent_bay = ModuleBay.objects.create(device=device, name='Parent Bay')
  3577. parent_module = Module.objects.create(device=device, module_bay=parent_bay, module_type=parent_type)
  3578. child_bay = ModuleBay.objects.create(device=device, module=parent_module, name='Child Bay', position='2')
  3579. url = reverse('dcim-api:module-list')
  3580. level_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token Ancestor Level Type')
  3581. InterfaceTemplate.objects.create(
  3582. module_type=level_type, name='eth0', label=f'{MODULE_TOKEN}/{MODULE_TOKEN}', type='1000base-t'
  3583. )
  3584. data = {
  3585. 'device': device.pk,
  3586. 'module_bay': child_bay.pk,
  3587. 'module_type': level_type.pk,
  3588. }
  3589. response = self.client.post(url, data, format='json', **self.header)
  3590. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  3591. self.assertIn('no position defined', str(response.data.get('__all__')))
  3592. leaf_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token Ancestor Leaf Type')
  3593. InterfaceTemplate.objects.create(
  3594. module_type=leaf_type, name='eth0', label=f'Port {MODULE_TOKEN}', type='1000base-t'
  3595. )
  3596. data['module_type'] = leaf_type.pk
  3597. response = self.client.post(url, data, format='json', **self.header)
  3598. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  3599. module = Module.objects.get(pk=response.data['id'])
  3600. self.assertEqual(module.interfaces.get().label, 'Port 2')
  3601. def test_module_token_resolves_in_label_and_module_bay(self):
  3602. """Placeholders in a label and in each module bay template field resolve against a nested bay."""
  3603. self.add_permissions('dcim.add_module')
  3604. manufacturer = Manufacturer.objects.get(name='Generic')
  3605. device = create_test_device('Device for Token Resolution Test')
  3606. parent_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token Resolution Parent Type')
  3607. parent_bay = ModuleBay.objects.create(device=device, name='Parent Bay', position='1')
  3608. parent_module = Module.objects.create(device=device, module_bay=parent_bay, module_type=parent_type)
  3609. child_bay = ModuleBay.objects.create(device=device, module=parent_module, name='Child Bay', position='2')
  3610. module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token Resolution Module Type')
  3611. InterfaceTemplate.objects.create(
  3612. module_type=module_type, name='eth0', label=f'Port {MODULE_TOKEN}/{MODULE_TOKEN}', type='1000base-t'
  3613. )
  3614. ModuleBayTemplate.objects.create(
  3615. module_type=module_type,
  3616. name=f'SFP {MODULE_TOKEN}',
  3617. label=f'{MODULE_TOKEN}/{MODULE_TOKEN}',
  3618. position=f'{MODULE_TOKEN}/1',
  3619. )
  3620. url = reverse('dcim-api:module-list')
  3621. data = {
  3622. 'device': device.pk,
  3623. 'module_bay': child_bay.pk,
  3624. 'module_type': module_type.pk,
  3625. }
  3626. response = self.client.post(url, data, format='json', **self.header)
  3627. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  3628. module = Module.objects.get(pk=response.data['id'])
  3629. self.assertEqual(module.interfaces.get().label, 'Port 1/2')
  3630. module_bay = module.modulebays.get()
  3631. self.assertEqual((module_bay.name, module_bay.label, module_bay.position), ('SFP 2', '1/2', '2/1'))
  3632. def test_module_bay_template_names_repeat_across_modules(self):
  3633. """Child module bays from one template may share a name across modules on a device."""
  3634. self.add_permissions('dcim.add_module')
  3635. manufacturer = Manufacturer.objects.get(name='Generic')
  3636. device = create_test_device('Device for Repeated Child Bay Test')
  3637. module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Repeated Child Bay Module Type')
  3638. ModuleBayTemplate.objects.create(module_type=module_type, name='SFP 1')
  3639. url = reverse('dcim-api:module-list')
  3640. for name in ('Repeated Bay 1', 'Repeated Bay 2'):
  3641. module_bay = ModuleBay.objects.create(device=device, name=name)
  3642. data = {
  3643. 'device': device.pk,
  3644. 'module_bay': module_bay.pk,
  3645. 'module_type': module_type.pk,
  3646. }
  3647. response = self.client.post(url, data, format='json', **self.header)
  3648. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  3649. self.assertEqual(device.modulebays.filter(name='SFP 1').count(), 2)
  3650. def test_list_objects_by_profile_id(self):
  3651. profiles = ModuleTypeProfile.objects.filter(name__startswith='Test').order_by('name')
  3652. self.add_permissions('dcim.view_module')
  3653. response = self.client.get(self._get_list_url(), {'profile_id': [profiles[0].pk]}, **self.header)
  3654. self.assertHttpStatus(response, status.HTTP_200_OK)
  3655. self.assertEqual(len(response.data['results']), 1)
  3656. response = self.client.get(self._get_list_url(), {'profile_id': [profiles[1].pk]}, **self.header)
  3657. self.assertHttpStatus(response, status.HTTP_200_OK)
  3658. self.assertEqual(len(response.data['results']), 1)
  3659. response = self.client.get(
  3660. self._get_list_url(),
  3661. {'profile_id': [settings.FILTERS_NULL_CHOICE_VALUE]},
  3662. **self.header,
  3663. )
  3664. self.assertHttpStatus(response, status.HTTP_200_OK)
  3665. self.assertEqual(len(response.data['results']), 1)
  3666. def test_list_objects_by_profile(self):
  3667. profiles = ModuleTypeProfile.objects.filter(name__startswith='Test').order_by('name')
  3668. self.add_permissions('dcim.view_module')
  3669. response = self.client.get(self._get_list_url(), {'profile': [profiles[0].name]}, **self.header)
  3670. self.assertHttpStatus(response, status.HTTP_200_OK)
  3671. self.assertEqual(len(response.data['results']), 1)
  3672. response = self.client.get(self._get_list_url(), {'profile': [profiles[1].name]}, **self.header)
  3673. self.assertHttpStatus(response, status.HTTP_200_OK)
  3674. self.assertEqual(len(response.data['results']), 1)
  3675. def test_patch_module_bay_derives_device(self):
  3676. self.add_permissions('dcim.change_module')
  3677. module = Module.objects.order_by('pk').first()
  3678. device_b = create_test_device('Module Move Device B')
  3679. bay_b = ModuleBay.objects.create(device=device_b, name='Module Move Bay B1')
  3680. url = reverse('dcim-api:module-detail', kwargs={'pk': module.pk})
  3681. response = self.client.patch(url, {'module_bay': bay_b.pk}, format='json', **self.header)
  3682. self.assertHttpStatus(response, status.HTTP_200_OK)
  3683. module.refresh_from_db()
  3684. self.assertEqual(module.device, device_b)
  3685. self.assertEqual(module.module_bay, bay_b)
  3686. def test_patch_device_and_module_bay_mismatch_fails(self):
  3687. self.add_permissions('dcim.change_module')
  3688. module = Module.objects.order_by('pk').first()
  3689. device_b = create_test_device('Module Move Device B')
  3690. same_device_bay = ModuleBay.objects.create(device=module.device, name='Module Move Bay A9')
  3691. url = reverse('dcim-api:module-detail', kwargs={'pk': module.pk})
  3692. response = self.client.patch(
  3693. url, {'device': device_b.pk, 'module_bay': same_device_bay.pk}, format='json', **self.header
  3694. )
  3695. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  3696. def test_patch_module_type_with_move_fails(self):
  3697. self.add_permissions('dcim.change_module')
  3698. module = Module.objects.order_by('pk').first()
  3699. empty_bay = ModuleBay.objects.filter(
  3700. device=module.device, installed_module__isnull=True
  3701. ).first()
  3702. other_type = ModuleType.objects.exclude(pk=module.module_type_id).first()
  3703. url = reverse('dcim-api:module-detail', kwargs={'pk': module.pk})
  3704. response = self.client.patch(
  3705. url, {'module_bay': empty_bay.pk, 'module_type': other_type.pk}, format='json', **self.header
  3706. )
  3707. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  3708. self.assertIn('module_type', response.data)
  3709. def test_patch_occupied_bay_fails(self):
  3710. self.add_permissions('dcim.change_module')
  3711. module_1, module_2 = Module.objects.order_by('pk')[:2]
  3712. url = reverse('dcim-api:module-detail', kwargs={'pk': module_1.pk})
  3713. response = self.client.patch(
  3714. url, {'module_bay': module_2.module_bay_id}, format='json', **self.header
  3715. )
  3716. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  3717. self.assertIn('module_bay', response.data)
  3718. def test_patch_cross_device_move_blocked_by_ip_address(self):
  3719. self.add_permissions('dcim.change_module')
  3720. module = Module.objects.order_by('pk').first()
  3721. interface = Interface.objects.create(
  3722. device=module.device, module=module, name='Move Test Interface 1',
  3723. type=InterfaceTypeChoices.TYPE_1GE_FIXED,
  3724. )
  3725. IPAddress.objects.create(address='192.0.2.10/32', assigned_object=interface)
  3726. device_b = create_test_device('Module Move Device B')
  3727. bay_b = ModuleBay.objects.create(device=device_b, name='Module Move Bay B1')
  3728. url = reverse('dcim-api:module-detail', kwargs={'pk': module.pk})
  3729. response = self.client.patch(url, {'module_bay': bay_b.pk}, format='json', **self.header)
  3730. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  3731. def test_create_with_conflicting_cooling_component_fails(self):
  3732. """
  3733. A cooling component name collision must be reported as a validation error rather
  3734. than raising an IntegrityError from the replication insert. See netbox#15289.
  3735. """
  3736. self.add_permissions('dcim.add_module')
  3737. device = create_test_device('Cooling Conflict Device')
  3738. module_bay = ModuleBay.objects.create(device=device, name='Cooling Conflict Bay')
  3739. module_type = ModuleType.objects.create(
  3740. manufacturer=Manufacturer.objects.first(), model='Cooled API Type'
  3741. )
  3742. CoolingIntakeTemplate.objects.create(module_type=module_type, name='Intake 1')
  3743. CoolingIntake.objects.create(device=device, name='Intake 1')
  3744. response = self.client.post(reverse('dcim-api:module-list'), {
  3745. 'device': device.pk,
  3746. 'module_bay': module_bay.pk,
  3747. 'module_type': module_type.pk,
  3748. 'status': 'active',
  3749. }, format='json', **self.header)
  3750. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  3751. self.assertIn('Intake 1', str(response.data))
  3752. def test_patch_cross_device_move_blocked_by_split_cooling_relation(self):
  3753. self.add_permissions('dcim.change_module')
  3754. module = Module.objects.order_by('pk').first()
  3755. intake = CoolingIntake.objects.create(
  3756. device=module.device, module=module, name='Move Test Intake 1'
  3757. )
  3758. CoolingOutflow.objects.create(
  3759. device=module.device, name='Move Test Chassis Outflow', cooling_intake=intake
  3760. )
  3761. device_b = create_test_device('Module Move Device B')
  3762. bay_b = ModuleBay.objects.create(device=device_b, name='Module Move Bay B1')
  3763. url = reverse('dcim-api:module-detail', kwargs={'pk': module.pk})
  3764. response = self.client.patch(url, {'module_bay': bay_b.pk}, format='json', **self.header)
  3765. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  3766. class ConsolePortTestCase(Mixins.ComponentTraceMixin, Mixins.LinkPeersMixin, APIViewTestCases.APIViewTestCase):
  3767. model = ConsolePort
  3768. brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
  3769. bulk_update_data = {
  3770. 'description': 'New description',
  3771. }
  3772. bulk_update_invalid_data = {
  3773. 'type': 'not-a-valid-type',
  3774. }
  3775. peer_termination_type = ConsoleServerPort
  3776. user_permissions = ('dcim.view_device', )
  3777. @classmethod
  3778. def setUpTestData(cls):
  3779. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  3780. devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  3781. site = Site.objects.create(name='Site 1', slug='site-1')
  3782. role = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
  3783. device = Device.objects.create(device_type=devicetype, role=role, name='Device 1', site=site)
  3784. console_ports = (
  3785. ConsolePort(device=device, name='Console Port 1'),
  3786. ConsolePort(device=device, name='Console Port 2'),
  3787. ConsolePort(device=device, name='Console Port 3'),
  3788. )
  3789. ConsolePort.objects.bulk_create(console_ports)
  3790. cls.create_data = [
  3791. {
  3792. 'device': device.pk,
  3793. 'name': 'Console Port 4',
  3794. 'speed': 9600,
  3795. },
  3796. {
  3797. 'device': device.pk,
  3798. 'name': 'Console Port 5',
  3799. 'speed': 115200,
  3800. },
  3801. {
  3802. 'device': device.pk,
  3803. 'name': 'Console Port 6',
  3804. 'speed': None,
  3805. },
  3806. ]
  3807. class ConsoleServerPortTestCase(Mixins.ComponentTraceMixin, Mixins.LinkPeersMixin, APIViewTestCases.APIViewTestCase):
  3808. model = ConsoleServerPort
  3809. brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
  3810. bulk_update_data = {
  3811. 'description': 'New description',
  3812. }
  3813. bulk_update_invalid_data = {
  3814. 'type': 'not-a-valid-type',
  3815. }
  3816. peer_termination_type = ConsolePort
  3817. user_permissions = ('dcim.view_device', )
  3818. @classmethod
  3819. def setUpTestData(cls):
  3820. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  3821. devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  3822. site = Site.objects.create(name='Site 1', slug='site-1')
  3823. role = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
  3824. device = Device.objects.create(device_type=devicetype, role=role, name='Device 1', site=site)
  3825. console_server_ports = (
  3826. ConsoleServerPort(device=device, name='Console Server Port 1'),
  3827. ConsoleServerPort(device=device, name='Console Server Port 2'),
  3828. ConsoleServerPort(device=device, name='Console Server Port 3'),
  3829. )
  3830. ConsoleServerPort.objects.bulk_create(console_server_ports)
  3831. cls.create_data = [
  3832. {
  3833. 'device': device.pk,
  3834. 'name': 'Console Server Port 4',
  3835. 'speed': 9600,
  3836. },
  3837. {
  3838. 'device': device.pk,
  3839. 'name': 'Console Server Port 5',
  3840. 'speed': 115200,
  3841. },
  3842. {
  3843. 'device': device.pk,
  3844. 'name': 'Console Server Port 6',
  3845. 'speed': None,
  3846. },
  3847. ]
  3848. class PowerPortTestCase(Mixins.ComponentTraceMixin, Mixins.LinkPeersMixin, APIViewTestCases.APIViewTestCase):
  3849. model = PowerPort
  3850. brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
  3851. bulk_update_data = {
  3852. 'description': 'New description',
  3853. }
  3854. bulk_update_invalid_data = {
  3855. 'type': 'not-a-valid-type',
  3856. }
  3857. peer_termination_type = PowerOutlet
  3858. user_permissions = ('dcim.view_device', )
  3859. @classmethod
  3860. def setUpTestData(cls):
  3861. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  3862. devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  3863. site = Site.objects.create(name='Site 1', slug='site-1')
  3864. role = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
  3865. device = Device.objects.create(device_type=devicetype, role=role, name='Device 1', site=site)
  3866. power_ports = (
  3867. PowerPort(device=device, name='Power Port 1'),
  3868. PowerPort(device=device, name='Power Port 2'),
  3869. PowerPort(device=device, name='Power Port 3'),
  3870. )
  3871. PowerPort.objects.bulk_create(power_ports)
  3872. cls.create_data = [
  3873. {
  3874. 'device': device.pk,
  3875. 'name': 'Power Port 4',
  3876. },
  3877. {
  3878. 'device': device.pk,
  3879. 'name': 'Power Port 5',
  3880. },
  3881. {
  3882. 'device': device.pk,
  3883. 'name': 'Power Port 6',
  3884. },
  3885. ]
  3886. class PowerOutletTestCase(Mixins.ComponentTraceMixin, Mixins.LinkPeersMixin, APIViewTestCases.APIViewTestCase):
  3887. model = PowerOutlet
  3888. brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
  3889. bulk_update_data = {
  3890. 'description': 'New description',
  3891. }
  3892. bulk_update_invalid_data = {
  3893. 'status': 'not-a-valid-status',
  3894. }
  3895. peer_termination_type = PowerPort
  3896. user_permissions = ('dcim.view_device', )
  3897. @classmethod
  3898. def setUpTestData(cls):
  3899. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  3900. devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  3901. site = Site.objects.create(name='Site 1', slug='site-1')
  3902. role = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
  3903. device = Device.objects.create(device_type=devicetype, role=role, name='Device 1', site=site)
  3904. power_ports = (
  3905. PowerPort(device=device, name='Power Port 1'),
  3906. PowerPort(device=device, name='Power Port 2'),
  3907. )
  3908. PowerPort.objects.bulk_create(power_ports)
  3909. power_outlets = (
  3910. PowerOutlet(device=device, name='Power Outlet 1'),
  3911. PowerOutlet(device=device, name='Power Outlet 2'),
  3912. PowerOutlet(device=device, name='Power Outlet 3'),
  3913. )
  3914. PowerOutlet.objects.bulk_create(power_outlets)
  3915. cls.create_data = [
  3916. {
  3917. 'device': device.pk,
  3918. 'name': 'Power Outlet 4',
  3919. 'power_port': power_ports[0].pk,
  3920. },
  3921. {
  3922. 'device': device.pk,
  3923. 'name': 'Power Outlet 5',
  3924. 'power_port': power_ports[1].pk,
  3925. },
  3926. {
  3927. 'device': device.pk,
  3928. 'name': 'Power Outlet 6',
  3929. 'power_port': None,
  3930. },
  3931. ]
  3932. class InterfaceTestCase(Mixins.FieldPrefetchMixin, Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
  3933. model = Interface
  3934. viewset = InterfaceViewSet
  3935. field_prefetch_cases = (
  3936. 'fields=id,name,device', 'omit=link_peers,link_peers_type', 'fields=id,link_peers&omit=link_peers',
  3937. 'brief=1&omit=l2vpn_termination',
  3938. )
  3939. eager_prefetches = (
  3940. GenericPrefetch('cable__terminations__termination', [Interface.objects.select_related('device', 'cable')]),
  3941. GenericPrefetch('_path__path_objects', [Interface.objects.select_related('device', 'cable')]),
  3942. 'virtual_circuit_termination',
  3943. 'l2vpn_terminations',
  3944. )
  3945. brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
  3946. bulk_update_data = {
  3947. 'description': 'New description',
  3948. }
  3949. bulk_update_invalid_data = {
  3950. 'poe_mode': 'not-a-valid-poe-mode',
  3951. }
  3952. peer_termination_type = Interface
  3953. user_permissions = ('dcim.view_device', )
  3954. @classmethod
  3955. def setUpTestData(cls):
  3956. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  3957. devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  3958. site = Site.objects.create(name='Site 1', slug='site-1')
  3959. role = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
  3960. device = Device.objects.create(device_type=devicetype, role=role, name='Device 1', site=site)
  3961. interfaces = (
  3962. Interface(device=device, name='Interface 1', type='1000base-t'),
  3963. Interface(device=device, name='Interface 2', type='1000base-t'),
  3964. # Interface 3 is channelized, so that channel subinterfaces may be bound to it
  3965. Interface(device=device, name='Interface 3', type='1000base-t', channels=4),
  3966. )
  3967. Interface.objects.bulk_create(interfaces)
  3968. channelized_parent = interfaces[2]
  3969. vdcs = (
  3970. VirtualDeviceContext(name='VDC 1', identifier=1, device=device),
  3971. VirtualDeviceContext(name='VDC 2', identifier=2, device=device)
  3972. )
  3973. VirtualDeviceContext.objects.bulk_create(vdcs)
  3974. for interface in interfaces:
  3975. interface.vdcs.set(vdcs)
  3976. vlans = (
  3977. VLAN(name='VLAN 1', vid=1),
  3978. VLAN(name='VLAN 2', vid=2),
  3979. VLAN(name='VLAN 3', vid=3),
  3980. VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
  3981. )
  3982. VLAN.objects.bulk_create(vlans)
  3983. wireless_lans = (
  3984. WirelessLAN(ssid='WLAN1'),
  3985. WirelessLAN(ssid='WLAN2'),
  3986. )
  3987. WirelessLAN.objects.bulk_create(wireless_lans)
  3988. vrfs = (
  3989. VRF(name='VRF 1'),
  3990. VRF(name='VRF 2'),
  3991. VRF(name='VRF 3'),
  3992. )
  3993. VRF.objects.bulk_create(vrfs)
  3994. cls.create_data = [
  3995. {
  3996. 'device': device.pk,
  3997. 'name': 'Interface 4',
  3998. 'type': 'other',
  3999. 'mode': InterfaceModeChoices.MODE_TAGGED,
  4000. 'speed': 16_000_000_000,
  4001. 'duplex': 'full',
  4002. 'vrf': vrfs[0].pk,
  4003. 'poe_mode': InterfacePoEModeChoices.MODE_PD,
  4004. 'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF,
  4005. 'tagged_vlans': [vlans[0].pk, vlans[1].pk],
  4006. 'untagged_vlan': vlans[2].pk,
  4007. },
  4008. {
  4009. 'device': device.pk,
  4010. 'name': 'Interface 5',
  4011. 'type': '1000base-t',
  4012. 'mode': InterfaceModeChoices.MODE_TAGGED,
  4013. 'bridge': interfaces[0].pk,
  4014. 'speed': 100000,
  4015. 'duplex': 'half',
  4016. 'vrf': vrfs[1].pk,
  4017. 'tagged_vlans': [vlans[0].pk, vlans[1].pk],
  4018. 'untagged_vlan': vlans[2].pk,
  4019. },
  4020. {
  4021. 'device': device.pk,
  4022. 'vdcs': [vdcs[0].pk],
  4023. 'name': 'Interface 6',
  4024. 'type': 'virtual',
  4025. 'mode': InterfaceModeChoices.MODE_TAGGED,
  4026. 'parent': interfaces[1].pk,
  4027. 'vrf': vrfs[2].pk,
  4028. 'tagged_vlans': [vlans[0].pk, vlans[1].pk],
  4029. 'untagged_vlan': vlans[2].pk,
  4030. },
  4031. {
  4032. 'device': device.pk,
  4033. 'vdcs': [vdcs[1].pk],
  4034. 'name': 'Interface 7',
  4035. 'type': InterfaceTypeChoices.TYPE_80211A,
  4036. 'mode': InterfaceModeChoices.MODE_Q_IN_Q,
  4037. 'tx_power': 10,
  4038. 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
  4039. 'rf_channel': WirelessChannelChoices.CHANNEL_5G_32,
  4040. 'qinq_svlan': vlans[3].pk,
  4041. },
  4042. {
  4043. 'device': device.pk,
  4044. 'vdcs': [vdcs[1].pk],
  4045. 'name': 'Interface 8',
  4046. 'type': InterfaceTypeChoices.TYPE_80211A,
  4047. 'mode': InterfaceModeChoices.MODE_Q_IN_Q,
  4048. 'tx_power': 10,
  4049. 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
  4050. 'rf_channel': "",
  4051. 'qinq_svlan': vlans[3].pk,
  4052. },
  4053. {
  4054. # A channelized parent interface
  4055. 'device': device.pk,
  4056. 'name': 'Interface 9',
  4057. 'type': InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS,
  4058. 'channels': 4,
  4059. },
  4060. {
  4061. # A channel subinterface bound to a channelized parent
  4062. 'device': device.pk,
  4063. 'name': 'Interface 10',
  4064. 'type': InterfaceTypeChoices.TYPE_CHANNEL,
  4065. 'parent': channelized_parent.pk,
  4066. 'channel_id': 1,
  4067. },
  4068. ]
  4069. def _perform_interface_test_with_invalid_data(self, mode: str = None, invalid_data: dict = {}):
  4070. device = Device.objects.first()
  4071. data = {
  4072. 'device': device.pk,
  4073. 'name': 'Interface 1',
  4074. 'type': InterfaceTypeChoices.TYPE_1GE_FIXED,
  4075. }
  4076. data.update({'mode': mode})
  4077. data.update(invalid_data)
  4078. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  4079. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  4080. content = json.loads(response.content)
  4081. for key in invalid_data.keys():
  4082. self.assertIn(key, content)
  4083. self.assertIsNone(content.get('data'))
  4084. def _create_field_prefetch_scopes(self):
  4085. """Return filters for one and for two devices whose interfaces carry every declared prefetch relation."""
  4086. provider = Provider.objects.create(name='Provider 1', slug='provider-1')
  4087. provider_network = ProviderNetwork.objects.create(provider=provider, name='Provider Network 1')
  4088. circuit_type = VirtualCircuitType.objects.create(name='Virtual Circuit Type 1', slug='virtual-circuit-type-1')
  4089. l2vpn = L2VPN.objects.create(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN)
  4090. fhrp_group = FHRPGroup.objects.create(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=1)
  4091. filters = []
  4092. for i in range(2):
  4093. device = create_test_device(f'Unit Device {i}')
  4094. peer = create_test_device(f'Unit Peer {i}')
  4095. panel = create_test_device(f'Unit Panel {i}')
  4096. # a: cabled, b: cabled through a patch panel, c: virtual circuit, d: L2VPN, e: IPs and FHRP, f: none
  4097. interfaces = {
  4098. name: Interface.objects.create(device=device, name=name, type=InterfaceTypeChoices.TYPE_1GE_FIXED)
  4099. for name in 'abdef'
  4100. }
  4101. interfaces['c'] = Interface.objects.create(device=device, name='c', type=InterfaceTypeChoices.TYPE_VIRTUAL)
  4102. peers = {
  4103. name: Interface.objects.create(device=peer, name=name, type=InterfaceTypeChoices.TYPE_1GE_FIXED)
  4104. for name in 'ab'
  4105. }
  4106. peers['c'] = Interface.objects.create(device=peer, name='c', type=InterfaceTypeChoices.TYPE_VIRTUAL)
  4107. front_port = FrontPort.objects.create(device=panel, name='b', type=PortTypeChoices.TYPE_8P8C)
  4108. rear_port = RearPort.objects.create(device=panel, name='b', type=PortTypeChoices.TYPE_8P8C)
  4109. PortMapping.objects.create(device=panel, front_port=front_port, rear_port=rear_port)
  4110. for a_termination, b_termination in (
  4111. (interfaces['a'], peers['a']),
  4112. (interfaces['b'], front_port),
  4113. (rear_port, peers['b']),
  4114. ):
  4115. Cable(a_terminations=[a_termination], b_terminations=[b_termination]).save()
  4116. virtual_circuit = VirtualCircuit.objects.create(
  4117. provider_network=provider_network, cid=f'Virtual Circuit {i}', type=circuit_type
  4118. )
  4119. for termination in (interfaces['c'], peers['c']):
  4120. VirtualCircuitTermination.objects.create(
  4121. virtual_circuit=virtual_circuit, role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
  4122. interface=termination,
  4123. )
  4124. L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=interfaces['d'])
  4125. for j in (1, 2):
  4126. IPAddress.objects.create(address=f'192.0.2.{10 * i + j}/32', assigned_object=interfaces['e'])
  4127. FHRPGroupAssignment.objects.create(group=fhrp_group, interface=interfaces['e'], priority=100)
  4128. filters.append(f'device_id={device.pk}')
  4129. return [filters[0], '&'.join(filters)]
  4130. def test_ordering_by_count_annotation(self):
  4131. """Ordering by a count annotation sorts the response whether or not it renders the count."""
  4132. self.add_permissions('dcim.view_interface', 'extras.view_exporttemplate')
  4133. interfaces = {name: Interface.objects.get(name=name) for name in ('Interface 2', 'Interface 3')}
  4134. IPAddress.objects.bulk_create((
  4135. IPAddress(address='192.0.2.1/24', assigned_object=interfaces['Interface 2']),
  4136. IPAddress(address='192.0.2.2/24', assigned_object=interfaces['Interface 2']),
  4137. IPAddress(address='192.0.2.3/24', assigned_object=interfaces['Interface 3']),
  4138. ))
  4139. export_template = ExportTemplate.objects.create(
  4140. name='Interfaces', template_code='{% for interface in queryset %}{{ interface.name }},{% endfor %}'
  4141. )
  4142. export_template.object_types.set([ObjectType.objects.get_for_model(Interface)])
  4143. url = f'{self._get_list_url()}?ordering=-count_ipaddresses'
  4144. # The count's source isn't a valid ordering field
  4145. response = self.client.get(f'{self._get_list_url()}?ordering=-_count_ipaddresses', **self.header)
  4146. self.assertHttpStatus(response, status.HTTP_200_OK)
  4147. self.assertEqual(
  4148. [row['name'] for row in response.data['results']], ['Interface 1', 'Interface 2', 'Interface 3']
  4149. )
  4150. # Responses without the count annotate it for the ordering alone
  4151. for params in ('', '&fields=id,name,count_ipaddresses', '&brief=1', '&fields=id,name', '&export=Interfaces'):
  4152. with self.subTest(params=params):
  4153. response = self.client.get(url + params, **self.header)
  4154. self.assertHttpStatus(response, status.HTTP_200_OK)
  4155. if 'export' in params:
  4156. self.assertEqual(response.content.decode(), 'Interface 2,Interface 3,Interface 1,')
  4157. else:
  4158. self.assertEqual(
  4159. [row['name'] for row in response.data['results']],
  4160. ['Interface 2', 'Interface 3', 'Interface 1'],
  4161. )
  4162. self.assertEqual(
  4163. 'count_ipaddresses' in response.data['results'][0],
  4164. params in ('', '&fields=id,name,count_ipaddresses'),
  4165. )
  4166. def test_bulk_delete_child_interfaces(self):
  4167. interface1 = Interface.objects.get(name='Interface 1')
  4168. device = interface1.device
  4169. self.add_permissions('dcim.delete_interface')
  4170. # Create a child interface
  4171. child = Interface.objects.create(
  4172. device=device,
  4173. name='Interface 1A',
  4174. type=InterfaceTypeChoices.TYPE_VIRTUAL,
  4175. parent=interface1
  4176. )
  4177. self.assertEqual(device.interfaces.count(), 4)
  4178. # Attempt to delete only the parent interface
  4179. url = self._get_detail_url(interface1)
  4180. with disable_logging():
  4181. self.client.delete(url, **self.header)
  4182. self.assertEqual(device.interfaces.count(), 4) # Parent was not deleted
  4183. # Attempt to bulk delete parent & child together
  4184. data = [
  4185. {"id": interface1.pk},
  4186. {"id": child.pk},
  4187. ]
  4188. self.client.delete(self._get_list_url(), data, format='json', **self.header)
  4189. self.assertEqual(device.interfaces.count(), 2) # Child & parent were both deleted
  4190. def test_create_child_interfaces_mode_invalid_data(self):
  4191. """
  4192. POST data to test interface mode check and invalid tagged/untagged VLANS.
  4193. """
  4194. self.add_permissions('dcim.add_interface')
  4195. vlans = VLAN.objects.all()[0:3]
  4196. # Routed mode, untagged, tagged and qinq service vlan
  4197. invalid_data = {
  4198. 'untagged_vlan': vlans[0].pk,
  4199. 'tagged_vlans': [vlans[1].pk, vlans[2].pk],
  4200. 'qinq_svlan': vlans[2].pk
  4201. }
  4202. self._perform_interface_test_with_invalid_data(None, invalid_data)
  4203. # Routed mode, untagged and tagged vlan
  4204. invalid_data = {
  4205. 'untagged_vlan': vlans[0].pk,
  4206. 'tagged_vlans': [vlans[1].pk, vlans[2].pk],
  4207. }
  4208. self._perform_interface_test_with_invalid_data(None, invalid_data)
  4209. # Routed mode, untagged vlan
  4210. invalid_data = {
  4211. 'untagged_vlan': vlans[0].pk,
  4212. }
  4213. self._perform_interface_test_with_invalid_data(None, invalid_data)
  4214. invalid_data = {
  4215. 'tagged_vlans': [vlans[1].pk, vlans[2].pk],
  4216. }
  4217. # Routed mode, qinq service vlan
  4218. self._perform_interface_test_with_invalid_data(None, invalid_data)
  4219. # Access mode, tagged vlans
  4220. self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_ACCESS, invalid_data)
  4221. # All tagged mode, tagged vlans
  4222. self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data)
  4223. invalid_data = {
  4224. 'qinq_svlan': vlans[0].pk,
  4225. }
  4226. # Routed mode, qinq service vlan
  4227. self._perform_interface_test_with_invalid_data(None, invalid_data)
  4228. # Access mode, qinq service vlan
  4229. self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_ACCESS, invalid_data)
  4230. # Tagged mode, qinq service vlan
  4231. self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED, invalid_data)
  4232. # Tagged-all mode, qinq service vlan
  4233. self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data)
  4234. def test_list_objects_reuse_peer_serializers(self):
  4235. """Link peers and connected endpoints share one nested serializer each per request."""
  4236. self.add_permissions('dcim.view_interface')
  4237. device = Device.objects.get(name='Device 1')
  4238. peer_device = Device.objects.create(
  4239. device_type=device.device_type, role=device.role, site=device.site, name='Peer Device'
  4240. )
  4241. peers = {}
  4242. for name in ('Interface 1', 'Interface 2'):
  4243. peer = Interface.objects.create(device=peer_device, name=f'Peer {name}', type='1000base-t')
  4244. Cable(a_terminations=[Interface.objects.get(device=device, name=name)], b_terminations=[peer]).save()
  4245. peers[name] = peer.pk
  4246. url = reverse('dcim-api:interface-list')
  4247. def get(params, host):
  4248. with patch.object(
  4249. InterfaceSerializer, '__init__', autospec=True, side_effect=InterfaceSerializer.__init__
  4250. ) as init:
  4251. response = self.client.get(url, {
  4252. 'device_id': device.pk, 'fields': 'name,link_peers,connected_endpoints', **params,
  4253. }, HTTP_HOST=host, **self.header)
  4254. self.assertHttpStatus(response, status.HTTP_200_OK)
  4255. return init.call_count, {interface['name']: interface for interface in response.data['results']}
  4256. one, _ = get({'name': 'Interface 1'}, 'a.example.com')
  4257. three, interfaces = get({}, 'b.example.com')
  4258. self.assertEqual(one, three)
  4259. for name, peer_id in peers.items():
  4260. self.assertEqual([peer['id'] for peer in interfaces[name]['link_peers']], [peer_id])
  4261. self.assertEqual([peer['id'] for peer in interfaces[name]['connected_endpoints']], [peer_id])
  4262. self.assertTrue(interfaces[name]['link_peers'][0]['url'].startswith('http://b.example.com/'))
  4263. self.assertEqual(interfaces['Interface 3']['link_peers'], [])
  4264. self.assertIsNone(interfaces['Interface 3']['connected_endpoints'])
  4265. def test_mac_address_create(self):
  4266. """
  4267. Creating an interface with mac_address creates the primary MACAddress in one request.
  4268. """
  4269. self.add_permissions('dcim.add_interface', 'dcim.add_macaddress')
  4270. device = Device.objects.first()
  4271. data = {
  4272. 'device': device.pk,
  4273. 'name': 'Interface MAC Create',
  4274. 'type': '1000base-t',
  4275. 'mac_address': 'AA:BB:CC:DD:EE:FF',
  4276. }
  4277. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  4278. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  4279. iface = Interface.objects.get(pk=response.data['id'])
  4280. self.assertIsNotNone(iface.primary_mac_address)
  4281. self.assertEqual(str(iface.primary_mac_address.mac_address).upper(), 'AA:BB:CC:DD:EE:FF')
  4282. self.assertEqual(iface.primary_mac_address.assigned_object, iface)
  4283. def test_mac_address_update(self):
  4284. """
  4285. Patching mac_address creates/updates the primary MACAddress in one request.
  4286. """
  4287. self.add_permissions('dcim.change_interface', 'dcim.add_macaddress', 'dcim.change_macaddress')
  4288. iface = Interface.objects.first()
  4289. url = self._get_detail_url(iface)
  4290. # Set a new primary MAC via mac_address shortcut
  4291. response = self.client.patch(url, {'mac_address': '11:22:33:44:55:66'}, format='json', **self.header)
  4292. self.assertHttpStatus(response, status.HTTP_200_OK)
  4293. iface.refresh_from_db()
  4294. self.assertIsNotNone(iface.primary_mac_address)
  4295. self.assertEqual(str(iface.primary_mac_address.mac_address).upper(), '11:22:33:44:55:66')
  4296. # Update the MAC to a new value
  4297. response = self.client.patch(url, {'mac_address': 'AA:BB:CC:DD:EE:FF'}, format='json', **self.header)
  4298. self.assertHttpStatus(response, status.HTTP_200_OK)
  4299. iface.refresh_from_db()
  4300. self.assertEqual(str(iface.primary_mac_address.mac_address).upper(), 'AA:BB:CC:DD:EE:FF')
  4301. # Clear the primary MAC by sending null
  4302. response = self.client.patch(url, {'mac_address': None}, format='json', **self.header)
  4303. self.assertHttpStatus(response, status.HTTP_200_OK)
  4304. iface.refresh_from_db()
  4305. self.assertIsNone(iface.primary_mac_address)
  4306. def test_mac_address_invalid(self):
  4307. """
  4308. Sending an invalid MAC address string returns a 400 error.
  4309. """
  4310. self.add_permissions('dcim.add_interface', 'dcim.add_macaddress')
  4311. device = Device.objects.first()
  4312. data = {
  4313. 'device': device.pk,
  4314. 'name': 'Interface MAC Bad',
  4315. 'type': '1000base-t',
  4316. 'mac_address': 'not-a-mac',
  4317. }
  4318. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  4319. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  4320. self.assertIn('mac_address', response.data)
  4321. def test_mac_address_find_or_create(self):
  4322. """
  4323. Patching mac_address with a MAC that already exists on the interface promotes it to primary
  4324. without creating a duplicate MACAddress record.
  4325. """
  4326. self.add_permissions('dcim.change_interface', 'dcim.add_macaddress', 'dcim.change_macaddress')
  4327. iface = Interface.objects.first()
  4328. # Pre-create two MACs assigned to this interface
  4329. mac1 = MACAddress.objects.create(mac_address='CC:DD:EE:FF:00:01', assigned_object=iface)
  4330. mac2 = MACAddress.objects.create(mac_address='CC:DD:EE:FF:00:02', assigned_object=iface)
  4331. iface.primary_mac_address = mac1
  4332. iface.save()
  4333. mac_count_before = iface.mac_addresses.count()
  4334. url = self._get_detail_url(iface)
  4335. # PATCH with mac2's address — should promote mac2, not create a new record
  4336. response = self.client.patch(url, {'mac_address': 'CC:DD:EE:FF:00:02'}, format='json', **self.header)
  4337. self.assertHttpStatus(response, status.HTTP_200_OK)
  4338. iface.refresh_from_db()
  4339. self.assertEqual(iface.primary_mac_address.pk, mac2.pk)
  4340. self.assertEqual(iface.mac_addresses.count(), mac_count_before)
  4341. def test_channel_binding_survives_combined_mac_address_update(self):
  4342. """
  4343. PATCHing channel_id/parent and mac_address together must not let the mac_address shortcut's
  4344. second instance.save() (see MACAddressShortcutMixin.update()) write the interface's
  4345. pre-propagation, stale in-memory cable_id/_path back over the cable and path just mirrored
  4346. from its newly assigned parent by update_channelized_cable_paths() -- and, on detach, must
  4347. not resurrect the stale in-memory values that update() clears via a queryset .update()
  4348. rather than a save() on this same instance.
  4349. """
  4350. self.add_permissions('dcim.change_interface', 'dcim.add_macaddress')
  4351. device = Device.objects.first()
  4352. channelized_parent = Interface.objects.get(device=device, name='Interface 3')
  4353. far_end = Interface.objects.create(device=device, name='Far End', type='1000base-t')
  4354. cable = Cable(
  4355. profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
  4356. a_terminations=[channelized_parent],
  4357. b_terminations=[far_end],
  4358. )
  4359. cable.full_clean()
  4360. cable.save()
  4361. child = Interface.objects.get(device=device, name='Interface 1')
  4362. url = self._get_detail_url(child)
  4363. # Attach: bind the channel and set mac_address in the same request.
  4364. data = {
  4365. 'parent': channelized_parent.pk,
  4366. 'channel_id': 1,
  4367. 'type': 'channel',
  4368. 'mac_address': 'AA:BB:CC:DD:EE:01',
  4369. }
  4370. response = self.client.patch(url, data, format='json', **self.header)
  4371. self.assertHttpStatus(response, status.HTTP_200_OK)
  4372. child.refresh_from_db()
  4373. self.assertEqual(
  4374. child.cable_id, cable.pk,
  4375. "the mac_address update's second save() clobbered the cable just mirrored from the parent",
  4376. )
  4377. self.assertEqual(child.cable_positions, [1])
  4378. self.assertIsNotNone(
  4379. child._path_id, "the mac_address update's second save() clobbered the path traced on attach",
  4380. )
  4381. self.assertIsNotNone(child.primary_mac_address)
  4382. self.assertEqual(str(child.primary_mac_address.mac_address).upper(), 'AA:BB:CC:DD:EE:01')
  4383. # Detach: clear the channel binding and change mac_address again in the same request.
  4384. data = {
  4385. 'parent': None,
  4386. 'channel_id': None,
  4387. 'type': '1000base-t',
  4388. 'mac_address': 'AA:BB:CC:DD:EE:02',
  4389. }
  4390. response = self.client.patch(url, data, format='json', **self.header)
  4391. self.assertHttpStatus(response, status.HTTP_200_OK)
  4392. child.refresh_from_db()
  4393. self.assertIsNone(
  4394. child.cable_id, "the mac_address update's second save() resurrected the cleared cable on detach",
  4395. )
  4396. self.assertIsNone(
  4397. child._path_id, "the mac_address update's second save() resurrected the cleared path on detach",
  4398. )
  4399. self.assertEqual(str(child.primary_mac_address.mac_address).upper(), 'AA:BB:CC:DD:EE:02')
  4400. def test_mac_address_conflicts_with_primary_mac_address(self):
  4401. """
  4402. Supplying both the mac_address shortcut and primary_mac_address in one request is rejected
  4403. rather than letting one silently win.
  4404. """
  4405. self.add_permissions('dcim.change_interface', 'dcim.add_macaddress', 'dcim.change_macaddress')
  4406. iface = Interface.objects.first()
  4407. mac = MACAddress.objects.create(mac_address='DD:EE:FF:00:11:22', assigned_object=iface)
  4408. url = self._get_detail_url(iface)
  4409. response = self.client.patch(
  4410. url,
  4411. {'mac_address': 'DD:EE:FF:00:11:33', 'primary_mac_address': {'mac_address': str(mac.mac_address)}},
  4412. format='json',
  4413. **self.header
  4414. )
  4415. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  4416. def test_mac_address_conflicts_with_explicit_null_primary(self):
  4417. """
  4418. The mac_address shortcut alongside an explicit primary_mac_address=null is a conflict (set vs
  4419. clear) and is rejected, not silently resolved in the shortcut's favor.
  4420. """
  4421. self.add_permissions('dcim.change_interface', 'dcim.add_macaddress')
  4422. iface = Interface.objects.first()
  4423. url = self._get_detail_url(iface)
  4424. response = self.client.patch(
  4425. url,
  4426. {'mac_address': 'DD:EE:FF:00:11:44', 'primary_mac_address': None},
  4427. format='json',
  4428. **self.header
  4429. )
  4430. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  4431. def test_mac_address_agreeing_with_primary_mac_address_is_accepted(self):
  4432. """
  4433. A read-modify-write round-trip echoes both readable fields with matching values. When the
  4434. shortcut and primary_mac_address designate the same MAC, the request is accepted.
  4435. """
  4436. self.add_permissions(
  4437. 'dcim.change_interface', 'dcim.add_macaddress', 'dcim.change_macaddress', 'dcim.view_macaddress'
  4438. )
  4439. iface = Interface.objects.first()
  4440. mac = MACAddress.objects.create(mac_address='DD:EE:FF:00:11:66', assigned_object=iface)
  4441. iface.primary_mac_address = mac
  4442. iface.save()
  4443. url = self._get_detail_url(iface)
  4444. response = self.client.patch(
  4445. url,
  4446. {
  4447. 'mac_address': str(mac.mac_address),
  4448. 'primary_mac_address': {'mac_address': str(mac.mac_address)},
  4449. 'description': 'round-trip edit',
  4450. },
  4451. format='json',
  4452. **self.header
  4453. )
  4454. self.assertHttpStatus(response, status.HTTP_200_OK)
  4455. iface.refresh_from_db()
  4456. self.assertEqual(iface.primary_mac_address_id, mac.pk)
  4457. self.assertEqual(iface.description, 'round-trip edit')
  4458. def test_null_mac_fields_round_trip_accepted(self):
  4459. """
  4460. An interface with no primary MAC round-trips both fields as null; echoing both back is accepted.
  4461. """
  4462. self.add_permissions('dcim.change_interface')
  4463. iface = Interface.objects.first()
  4464. iface.primary_mac_address = None
  4465. iface.save()
  4466. url = self._get_detail_url(iface)
  4467. response = self.client.patch(
  4468. url,
  4469. {'mac_address': None, 'primary_mac_address': None, 'description': 'null round-trip'},
  4470. format='json',
  4471. **self.header
  4472. )
  4473. self.assertHttpStatus(response, status.HTTP_200_OK)
  4474. def test_combined_update_changelog_does_not_reattribute_other_fields(self):
  4475. """
  4476. A combined PATCH of an unrelated field plus the mac_address shortcut produces two ObjectChange
  4477. rows (the fields save, then the primary-MAC save). The MAC change's row must record the state
  4478. after the field save as its prechange, so the unrelated field edit isn't re-reported as part of
  4479. the MAC change.
  4480. """
  4481. self.add_permissions('dcim.change_interface', 'dcim.add_macaddress')
  4482. iface = Interface.objects.first()
  4483. iface.description = 'original'
  4484. iface.primary_mac_address = None
  4485. iface.save()
  4486. url = self._get_detail_url(iface)
  4487. response = self.client.patch(
  4488. url,
  4489. {'description': 'updated', 'mac_address': 'AA:BB:CC:DD:EE:10'},
  4490. format='json',
  4491. **self.header
  4492. )
  4493. self.assertHttpStatus(response, status.HTTP_200_OK)
  4494. changes = ObjectChange.objects.filter(
  4495. action=ObjectChangeActionChoices.ACTION_UPDATE,
  4496. changed_object_type=ContentType.objects.get_for_model(Interface),
  4497. changed_object_id=iface.pk,
  4498. ).order_by('pk')
  4499. # The MAC change is the row whose postchange records the new primary MAC.
  4500. mac_change = changes.filter(postchange_data__primary_mac_address__isnull=False).last()
  4501. self.assertIsNotNone(mac_change)
  4502. # Its prechange must reflect the already-saved description, so the field edit isn't re-attributed.
  4503. self.assertEqual(mac_change.prechange_data['description'], 'updated')
  4504. def test_primary_mac_address_must_belong_to_interface(self):
  4505. """
  4506. Setting primary_mac_address (bypassing the shortcut op) to a MAC not assigned to this interface
  4507. is rejected on update, so the primary MAC can't dangle outside the interface's own MAC set.
  4508. """
  4509. self.add_permissions('dcim.change_interface', 'dcim.change_macaddress')
  4510. iface = Interface.objects.first()
  4511. unassigned = MACAddress.objects.create(mac_address='DD:EE:FF:00:11:55')
  4512. url = self._get_detail_url(iface)
  4513. response = self.client.patch(
  4514. url,
  4515. {'primary_mac_address': {'mac_address': str(unassigned.mac_address)}},
  4516. format='json',
  4517. **self.header
  4518. )
  4519. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  4520. iface.refresh_from_db()
  4521. self.assertIsNone(iface.primary_mac_address)
  4522. def test_create_with_unassigned_primary_mac_address(self):
  4523. """
  4524. Creating an interface with a nested, as-yet-unassigned primary_mac_address is allowed: the
  4525. clean() invariant is skipped while adding, and the post_save signal assigns the MAC to the new
  4526. interface. This pins the create-path carve-out against a future signal regression.
  4527. """
  4528. self.add_permissions(
  4529. 'dcim.add_interface', 'dcim.add_macaddress', 'dcim.change_macaddress', 'dcim.view_macaddress'
  4530. )
  4531. device = Device.objects.first()
  4532. unassigned = MACAddress.objects.create(mac_address='DD:EE:FF:00:11:77')
  4533. data = {
  4534. 'device': device.pk,
  4535. 'name': 'Interface With Primary MAC',
  4536. 'type': '1000base-t',
  4537. 'primary_mac_address': {'mac_address': str(unassigned.mac_address)},
  4538. }
  4539. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  4540. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  4541. iface = Interface.objects.get(pk=response.data['id'])
  4542. self.assertEqual(iface.primary_mac_address_id, unassigned.pk)
  4543. # The signal assigned the MAC to the new interface, so it isn't a dangling primary.
  4544. unassigned.refresh_from_db()
  4545. self.assertEqual(unassigned.assigned_object, iface)
  4546. @override_settings(CUSTOM_VALIDATORS={'dcim.macaddress': [{'mac_address': {'regex': '^AA:'}}]})
  4547. def test_mac_address_custom_validation_returns_400(self):
  4548. """
  4549. A MAC that fails a custom validator on creation returns a 400, not a 500 (the model
  4550. ValidationError raised inside the serializer is translated to a DRF error).
  4551. """
  4552. self.add_permissions('dcim.add_interface', 'dcim.add_macaddress')
  4553. device = Device.objects.first()
  4554. data = {
  4555. 'device': device.pk,
  4556. 'name': 'Interface Custom Validation',
  4557. 'type': '1000base-t',
  4558. 'mac_address': 'BB:CC:DD:EE:FF:00',
  4559. }
  4560. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  4561. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  4562. def test_field_prefetches_follow_requested_fields(self):
  4563. """List and detail responses prefetch the declarations of included fields, counts annotated instead."""
  4564. self.add_permissions('dcim.view_interface')
  4565. declared = InterfaceViewSet.field_prefetches
  4566. declared_before = dict(declared)
  4567. peers = list(declared[('link_peers', 'link_peers_type')])
  4568. endpoints = list(declared[('connected_endpoints', 'connected_endpoints_type')])
  4569. path = list(declared[('connected_endpoints_reachable',)])
  4570. l2vpn = list(declared[('l2vpn_termination',)])
  4571. counts = {'_count_ipaddresses', '_count_fhrp_groups'}
  4572. # fields takes precedence over omit, and both over brief
  4573. cases = {
  4574. '': (peers + endpoints + path + l2vpn, counts),
  4575. 'brief=1': ([], set()),
  4576. 'fields=id,link_peers_type': (peers, set()),
  4577. 'fields=id,connected_endpoints_type': (endpoints, set()),
  4578. 'fields=id,connected_endpoints_reachable': (path, set()),
  4579. 'fields=id,l2vpn_termination': (l2vpn, set()),
  4580. 'fields=id,count_ipaddresses': ([], {'_count_ipaddresses'}),
  4581. 'fields=id,count_fhrp_groups': ([], {'_count_fhrp_groups'}),
  4582. 'fields=count_ipaddresses': ([], {'_count_ipaddresses'}),
  4583. 'omit=link_peers,link_peers_type': (endpoints + path + l2vpn, counts),
  4584. 'omit=count_ipaddresses': (peers + endpoints + path + l2vpn, {'_count_fhrp_groups'}),
  4585. 'fields=id,link_peers&omit=link_peers': (peers, set()),
  4586. 'brief=1&omit=l2vpn_termination': (peers + endpoints + path, counts),
  4587. 'brief=1&fields=id': ([], set()),
  4588. }
  4589. for url in (self._get_list_url(), self._get_detail_url(Interface.objects.get(name='Interface 1'))):
  4590. for params, (expected, annotations) in cases.items():
  4591. with self.subTest(url=url, params=params):
  4592. with self.capture_querysets(InterfaceViewSet) as querysets:
  4593. response = self.client.get(f'{url}?{params}', **self.header)
  4594. self.assertHttpStatus(response, status.HTTP_200_OK)
  4595. self.assert_lookups(querysets, expected)
  4596. for queryset in querysets:
  4597. self.assertEqual(set(queryset.query.annotations), annotations)
  4598. self.assertEqual(InterfaceViewSet.queryset._prefetch_related_lookups, ())
  4599. # Reads narrow their own field set, never the class declarations
  4600. self.assertEqual(InterfaceViewSet.field_prefetches, declared_before)
  4601. def test_field_prefetches_keep_class_lookups(self):
  4602. """Lookups on the class queryset stay ahead of the declared ones, and a subclass inherits the declarations."""
  4603. self.add_permissions('dcim.view_interface')
  4604. url = self._get_list_url()
  4605. peers = list(InterfaceViewSet.field_prefetches[('link_peers', 'link_peers_type')])
  4606. queryset = InterfaceViewSet.queryset.prefetch_related('wireless_lans')
  4607. with (
  4608. patch.object(InterfaceViewSet, 'queryset', queryset),
  4609. self.capture_querysets(InterfaceViewSet) as querysets,
  4610. ):
  4611. response = self.client.get(f'{url}?fields=id,link_peers_type', **self.header)
  4612. self.assertHttpStatus(response, status.HTTP_200_OK)
  4613. self.assert_lookups(querysets, [*queryset._prefetch_related_lookups, *peers])
  4614. # A class lookup on a counted relation stays when its count is annotated
  4615. queryset = InterfaceViewSet.queryset.prefetch_related('ip_addresses')
  4616. with (
  4617. patch.object(InterfaceViewSet, 'queryset', queryset),
  4618. self.capture_querysets(InterfaceViewSet) as querysets,
  4619. ):
  4620. response = self.client.get(f'{url}?fields=id,count_ipaddresses', **self.header)
  4621. self.assertHttpStatus(response, status.HTTP_200_OK)
  4622. self.assert_lookups(querysets, list(queryset._prefetch_related_lookups))
  4623. for captured in querysets:
  4624. self.assertEqual(set(captured.query.annotations), {'_count_ipaddresses'})
  4625. class PluginInterfaceViewSet(InterfaceViewSet):
  4626. pass
  4627. with self.capture_querysets(PluginInterfaceViewSet) as querysets:
  4628. response = PluginInterfaceViewSet.as_view({'get': 'list'})(
  4629. APIRequestFactory().get(url, {'fields': 'id,link_peers_type'}, **self.header)
  4630. )
  4631. self.assertHttpStatus(response, status.HTTP_200_OK)
  4632. self.assert_lookups(querysets, peers)
  4633. def test_field_prefetches_keep_shared_declarations(self):
  4634. """A declaration a count shares with another requested field stays when the count is annotated."""
  4635. self.add_permissions('dcim.view_interface')
  4636. shared = ('ip_addresses',)
  4637. field_prefetches = {**InterfaceViewSet.field_prefetches, ('count_ipaddresses', 'description'): shared}
  4638. with (
  4639. patch.object(InterfaceViewSet, 'field_prefetches', field_prefetches),
  4640. self.capture_querysets(InterfaceViewSet) as querysets,
  4641. ):
  4642. response = self.client.get(
  4643. f'{self._get_list_url()}?fields=id,description,count_ipaddresses', **self.header
  4644. )
  4645. self.assertHttpStatus(response, status.HTTP_200_OK)
  4646. self.assert_lookups(querysets, list(shared))
  4647. for queryset in querysets:
  4648. self.assertEqual(set(queryset.query.annotations), {'_count_ipaddresses'})
  4649. def test_field_prefetches_for_other_reads(self):
  4650. """Exports and writes carry every declaration and annotate no count, the trace action its path nodes."""
  4651. self.add_permissions('dcim.view_interface', 'dcim.change_interface', 'extras.view_exporttemplate')
  4652. declared = [lookup for lookups in InterfaceViewSet.field_prefetches.values() for lookup in lookups]
  4653. interface = Interface.objects.get(name='Interface 1')
  4654. export_template = ExportTemplate.objects.create(
  4655. name='Interfaces', template_code='{% for interface in queryset %}{{ interface.name }},{% endfor %}'
  4656. )
  4657. export_template.object_types.set([ObjectType.objects.get_for_model(Interface)])
  4658. # Exports and writes load every declaration, whatever fields the response selects
  4659. with self.capture_querysets(InterfaceViewSet) as querysets:
  4660. for params in ('fields=id&export=Interfaces', 'export=Interfaces'):
  4661. response = self.client.get(f'{self._get_list_url()}?{params}', **self.header)
  4662. self.assertHttpStatus(response, status.HTTP_200_OK)
  4663. for params in ('fields=id', 'fields=id,count_ipaddresses'):
  4664. response = self.client.patch(
  4665. f'{self._get_detail_url(interface)}?{params}', {'description': 'New'}, format='json', **self.header
  4666. )
  4667. self.assertHttpStatus(response, status.HTTP_200_OK)
  4668. self.assert_lookups(querysets, declared)
  4669. self.assertFalse([queryset.query.annotations for queryset in querysets if queryset.query.annotations])
  4670. # The trace action loads the path nodes as the connected endpoint fields do, whatever fields are selected
  4671. peer = Interface.objects.create(device=interface.device, name='Peer', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
  4672. Cable(a_terminations=[interface], b_terminations=[peer]).save()
  4673. url = reverse('dcim-api:interface-trace', kwargs={'pk': interface.pk})
  4674. def trace(params, queryset, field_prefetches):
  4675. with (
  4676. patch.object(InterfaceViewSet, 'queryset', queryset),
  4677. patch.object(InterfaceViewSet, 'field_prefetches', field_prefetches),
  4678. CaptureQueriesContext(connection) as queries,
  4679. ):
  4680. response = self.client.get(f'{url}?{params}', **self.header)
  4681. self.assertHttpStatus(response, status.HTTP_200_OK)
  4682. return response.json(), sum('"users_token"' not in entry['sql'] for entry in queries.captured_queries)
  4683. self.client.get(url, **self.header)
  4684. queryset = InterfaceViewSet.queryset
  4685. expected, eager_count = trace('', queryset.prefetch_related(*self.eager_prefetches), {})
  4686. unprefetched, unprefetched_count = trace('', queryset, {})
  4687. self.assertEqual(unprefetched, expected)
  4688. for params in ('', 'fields=id'):
  4689. with self.subTest(params=params):
  4690. data, count = trace(params, queryset, InterfaceViewSet.field_prefetches)
  4691. self.assertEqual(data, expected)
  4692. self.assertLessEqual(count, eager_count)
  4693. self.assertLess(count, unprefetched_count)
  4694. def test_count_ipaddresses_and_fhrp_groups(self):
  4695. """Counts come from annotations of the interface's object type, never from loaded related objects."""
  4696. self.add_permissions('dcim.view_interface')
  4697. interface = Interface.objects.get(name='Interface 1')
  4698. group = FHRPGroup.objects.create(
  4699. pk=interface.pk, protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=1
  4700. )
  4701. other_group = FHRPGroup.objects.create(
  4702. pk=interface.pk + 1, protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=2
  4703. )
  4704. vm_interface = VMInterface.objects.create(
  4705. pk=interface.pk, virtual_machine=create_test_virtualmachine('Virtual Machine 1'), name='eth0'
  4706. )
  4707. IPAddress.objects.bulk_create((
  4708. IPAddress(address='192.0.2.1/24', assigned_object=interface),
  4709. IPAddress(address='2001:db8::1/64', assigned_object=interface),
  4710. IPAddress(address='192.0.2.2/24', assigned_object=vm_interface),
  4711. IPAddress(address='192.0.2.254/24', assigned_object=group),
  4712. ))
  4713. FHRPGroupAssignment.objects.bulk_create((
  4714. FHRPGroupAssignment(group=group, interface=interface, priority=100),
  4715. # Two on the VM interface, so a count of the wrong object type cannot match
  4716. FHRPGroupAssignment(group=group, interface=vm_interface, priority=100),
  4717. FHRPGroupAssignment(group=other_group, interface=vm_interface, priority=100),
  4718. ))
  4719. with CaptureQueriesContext(connection) as queries:
  4720. response = self.client.get(self._get_list_url(), **self.header)
  4721. self.assertHttpStatus(response, status.HTTP_200_OK)
  4722. counts = {
  4723. row['name']: (row['count_ipaddresses'], row['count_fhrp_groups']) for row in response.data['results']
  4724. }
  4725. self.assertEqual(counts, {'Interface 1': (2, 1), 'Interface 2': (0, 0), 'Interface 3': (0, 0)})
  4726. # Only the interface statement reads the related tables, through its two count subqueries
  4727. statements = [
  4728. query['sql'] for query in queries.captured_queries
  4729. if '"ipam_ipaddress"' in query['sql'] or '"ipam_fhrpgroupassignment"' in query['sql']
  4730. ]
  4731. self.assertEqual(len(statements), 1)
  4732. self.assertTrue(statements[0].startswith('SELECT "dcim_interface"'))
  4733. # Unannotated instances count through the relations
  4734. self.assertEqual((interface.count_ipaddresses, interface.count_fhrp_groups), (2, 1))
  4735. def test_count_event_payloads_use_write_prefetches(self):
  4736. """Write and delete event payloads take both counts from the write prefetches, whatever fields are selected."""
  4737. self.add_permissions('dcim.view_interface', 'dcim.change_interface', 'dcim.delete_interface')
  4738. interfaces = Interface.objects.filter(name__in=('Interface 1', 'Interface 2')).order_by('name')
  4739. interface1, interface2 = interfaces
  4740. group = FHRPGroup.objects.create(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=1)
  4741. IPAddress.objects.bulk_create((
  4742. IPAddress(address='192.0.2.1/24', assigned_object=interface1),
  4743. IPAddress(address='192.0.2.2/24', assigned_object=interface1),
  4744. IPAddress(address='192.0.2.3/24', assigned_object=interface2),
  4745. ))
  4746. FHRPGroupAssignment.objects.create(group=group, interface=interface1, priority=100)
  4747. def payload_counts(events):
  4748. return {
  4749. event['data']['name']: (event['data']['count_ipaddresses'], event['data']['count_fhrp_groups'])
  4750. for event in events if isinstance(event['object'], Interface)
  4751. }
  4752. def fallback_counts(queries):
  4753. # The count properties' per-interface fallback
  4754. return [
  4755. query['sql'] for query in queries.captured_queries
  4756. if query['sql'].startswith('SELECT COUNT(*)') and (
  4757. '"ipam_ipaddress"."assigned_object_id"' in query['sql']
  4758. or '"ipam_fhrpgroupassignment"."interface_id"' in query['sql']
  4759. )
  4760. ]
  4761. writes = (
  4762. ('single', self._get_detail_url(interface1), {'description': 'New'}, {'Interface 1': (2, 1)}),
  4763. (
  4764. 'bulk', self._get_list_url(),
  4765. [{'id': interface.pk, 'description': 'Bulk'} for interface in interfaces],
  4766. {'Interface 1': (2, 1), 'Interface 2': (1, 0)},
  4767. ),
  4768. )
  4769. for label, url, data, expected in writes:
  4770. with self.subTest(write=label):
  4771. with (
  4772. patch('netbox.context_managers.flush_events') as flush_events,
  4773. CaptureQueriesContext(connection) as queries,
  4774. ):
  4775. response = self.client.patch(f'{url}?fields=id,description', data, format='json', **self.header)
  4776. self.assertHttpStatus(response, status.HTTP_200_OK)
  4777. (events,) = flush_events.call_args.args
  4778. # Update payloads serialize on first access
  4779. counts = payload_counts(events)
  4780. self.assertEqual(counts, expected)
  4781. self.assertEqual(fallback_counts(queries), [])
  4782. # Delete payloads serialize before the rows go, within the request
  4783. with (
  4784. patch('netbox.context_managers.flush_events') as flush_events,
  4785. CaptureQueriesContext(connection) as queries,
  4786. ):
  4787. response = self.client.delete(f'{self._get_detail_url(interface1)}?fields=id', **self.header)
  4788. self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
  4789. (events,) = flush_events.call_args.args
  4790. self.assertEqual(payload_counts(events), {'Interface 1': (2, 1)})
  4791. self.assertEqual(fallback_counts(queries), [])
  4792. def test_pagination_count_excludes_count_subqueries(self):
  4793. """The pagination count of a DISTINCT-filtered list leaves out the count subqueries."""
  4794. self.add_permissions('dcim.view_interface')
  4795. with CaptureQueriesContext(connection) as queries:
  4796. response = self.client.get(self._get_list_url(), {'name': 'Interface 1'}, **self.header)
  4797. self.assertHttpStatus(response, status.HTTP_200_OK)
  4798. self.assertEqual(response.data['count'], 1)
  4799. (count_sql,) = [
  4800. query['sql'] for query in queries.captured_queries if query['sql'].startswith('SELECT COUNT(*)')
  4801. ]
  4802. # The name filter must make the list DISTINCT for this test to mean anything
  4803. self.assertIn('DISTINCT', count_sql)
  4804. self.assertNotIn('"ipam_ipaddress"', count_sql)
  4805. self.assertNotIn('"ipam_fhrpgroupassignment"', count_sql)
  4806. class FrontPortTestCase(Mixins.LinkPeersMixin, APIViewTestCases.APIViewTestCase):
  4807. model = FrontPort
  4808. brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
  4809. bulk_update_data = {
  4810. 'description': 'New description',
  4811. }
  4812. bulk_update_invalid_data = {
  4813. 'color': 'not-a-color',
  4814. }
  4815. peer_termination_type = Interface
  4816. user_permissions = ('dcim.view_device', 'dcim.view_rearport')
  4817. @classmethod
  4818. def setUpTestData(cls):
  4819. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  4820. devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  4821. site = Site.objects.create(name='Site 1', slug='site-1')
  4822. role = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
  4823. device = Device.objects.create(device_type=devicetype, role=role, name='Device 1', site=site)
  4824. rear_ports = (
  4825. RearPort(device=device, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C),
  4826. RearPort(device=device, name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
  4827. RearPort(device=device, name='Rear Port 3', type=PortTypeChoices.TYPE_8P8C),
  4828. RearPort(device=device, name='Rear Port 4', type=PortTypeChoices.TYPE_8P8C),
  4829. RearPort(device=device, name='Rear Port 5', type=PortTypeChoices.TYPE_8P8C),
  4830. RearPort(device=device, name='Rear Port 6', type=PortTypeChoices.TYPE_8P8C),
  4831. )
  4832. RearPort.objects.bulk_create(rear_ports)
  4833. front_ports = (
  4834. FrontPort(device=device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C),
  4835. FrontPort(device=device, name='Front Port 2', type=PortTypeChoices.TYPE_8P8C),
  4836. FrontPort(device=device, name='Front Port 3', type=PortTypeChoices.TYPE_8P8C),
  4837. )
  4838. FrontPort.objects.bulk_create(front_ports)
  4839. PortMapping.objects.bulk_create([
  4840. PortMapping(device=device, front_port=front_ports[0], rear_port=rear_ports[0]),
  4841. PortMapping(device=device, front_port=front_ports[1], rear_port=rear_ports[1]),
  4842. PortMapping(device=device, front_port=front_ports[2], rear_port=rear_ports[2]),
  4843. ])
  4844. cls.create_data = [
  4845. {
  4846. 'device': device.pk,
  4847. 'name': 'Front Port 4',
  4848. 'type': PortTypeChoices.TYPE_8P8C,
  4849. 'rear_ports': [
  4850. {
  4851. 'position': 1,
  4852. 'rear_port': rear_ports[3].pk,
  4853. 'rear_port_position': 1,
  4854. },
  4855. ],
  4856. },
  4857. {
  4858. 'device': device.pk,
  4859. 'name': 'Front Port 5',
  4860. 'type': PortTypeChoices.TYPE_8P8C,
  4861. 'rear_ports': [
  4862. {
  4863. 'position': 1,
  4864. 'rear_port': rear_ports[4].pk,
  4865. 'rear_port_position': 1,
  4866. },
  4867. ],
  4868. },
  4869. {
  4870. 'device': device.pk,
  4871. 'name': 'Front Port 6',
  4872. 'type': PortTypeChoices.TYPE_8P8C,
  4873. 'rear_ports': [
  4874. {
  4875. 'position': 1,
  4876. 'rear_port': rear_ports[5].pk,
  4877. 'rear_port_position': 1,
  4878. },
  4879. ],
  4880. },
  4881. ]
  4882. cls.update_data = {
  4883. 'type': PortTypeChoices.TYPE_LC,
  4884. 'rear_ports': [
  4885. {
  4886. 'position': 1,
  4887. 'rear_port': rear_ports[3].pk,
  4888. 'rear_port_position': 1,
  4889. },
  4890. ],
  4891. }
  4892. def test_update_object(self):
  4893. super().test_update_object()
  4894. # Check that the port mapping was updated after modifying the front port
  4895. front_port = FrontPort.objects.get(name='Front Port 1')
  4896. rear_port = RearPort.objects.get(name='Rear Port 4')
  4897. self.assertTrue(
  4898. PortMapping.objects.filter(
  4899. front_port=front_port,
  4900. front_port_position=1,
  4901. rear_port=rear_port,
  4902. rear_port_position=1,
  4903. ).exists()
  4904. )
  4905. @tag('regression') # Issue #18991
  4906. def test_front_port_paths(self):
  4907. device = Device.objects.first()
  4908. interface1 = Interface.objects.create(device=device, name='Interface 1')
  4909. rear_port = RearPort.objects.create(device=device, name='Rear Port 10', type=PortTypeChoices.TYPE_8P8C)
  4910. front_port = FrontPort.objects.create(device=device, name='Front Port 10', type=PortTypeChoices.TYPE_8P8C)
  4911. PortMapping.objects.create(device=device, front_port=front_port, rear_port=rear_port)
  4912. Cable.objects.create(a_terminations=[interface1], b_terminations=[front_port])
  4913. self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
  4914. url = reverse(f'dcim-api:{self.model._meta.model_name}-paths', kwargs={'pk': front_port.pk})
  4915. response = self.client.get(url, **self.header)
  4916. self.assertHttpStatus(response, status.HTTP_200_OK)
  4917. class RearPortTestCase(Mixins.LinkPeersMixin, APIViewTestCases.APIViewTestCase):
  4918. model = RearPort
  4919. brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
  4920. bulk_update_data = {
  4921. 'description': 'New description',
  4922. }
  4923. bulk_update_invalid_data = {
  4924. 'color': 'not-a-color',
  4925. }
  4926. peer_termination_type = Interface
  4927. user_permissions = ('dcim.view_device', )
  4928. @classmethod
  4929. def setUpTestData(cls):
  4930. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  4931. devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  4932. site = Site.objects.create(name='Site 1', slug='site-1')
  4933. role = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
  4934. device = Device.objects.create(device_type=devicetype, role=role, name='Device 1', site=site)
  4935. front_ports = (
  4936. FrontPort(device=device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C),
  4937. FrontPort(device=device, name='Front Port 2', type=PortTypeChoices.TYPE_8P8C),
  4938. FrontPort(device=device, name='Front Port 3', type=PortTypeChoices.TYPE_8P8C),
  4939. FrontPort(device=device, name='Front Port 4', type=PortTypeChoices.TYPE_8P8C),
  4940. FrontPort(device=device, name='Front Port 5', type=PortTypeChoices.TYPE_8P8C),
  4941. FrontPort(device=device, name='Front Port 6', type=PortTypeChoices.TYPE_8P8C),
  4942. )
  4943. FrontPort.objects.bulk_create(front_ports)
  4944. rear_ports = (
  4945. RearPort(device=device, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C),
  4946. RearPort(device=device, name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
  4947. RearPort(device=device, name='Rear Port 3', type=PortTypeChoices.TYPE_8P8C),
  4948. )
  4949. RearPort.objects.bulk_create(rear_ports)
  4950. cls.create_data = [
  4951. {
  4952. 'device': device.pk,
  4953. 'name': 'Rear Port 4',
  4954. 'type': PortTypeChoices.TYPE_8P8C,
  4955. 'front_ports': [
  4956. {
  4957. 'position': 1,
  4958. 'front_port': front_ports[3].pk,
  4959. 'front_port_position': 1,
  4960. },
  4961. ],
  4962. },
  4963. {
  4964. 'device': device.pk,
  4965. 'name': 'Rear Port 5',
  4966. 'type': PortTypeChoices.TYPE_8P8C,
  4967. 'front_ports': [
  4968. {
  4969. 'position': 1,
  4970. 'front_port': front_ports[4].pk,
  4971. 'front_port_position': 1,
  4972. },
  4973. ],
  4974. },
  4975. {
  4976. 'device': device.pk,
  4977. 'name': 'Rear Port 6',
  4978. 'type': PortTypeChoices.TYPE_8P8C,
  4979. 'front_ports': [
  4980. {
  4981. 'position': 1,
  4982. 'front_port': front_ports[5].pk,
  4983. 'front_port_position': 1,
  4984. },
  4985. ],
  4986. },
  4987. ]
  4988. cls.update_data = {
  4989. 'type': PortTypeChoices.TYPE_LC,
  4990. 'front_ports': [
  4991. {
  4992. 'position': 1,
  4993. 'front_port': front_ports[3].pk,
  4994. 'front_port_position': 1,
  4995. },
  4996. ],
  4997. }
  4998. def test_update_object(self):
  4999. super().test_update_object()
  5000. # Check that the port mapping was updated after modifying the rear port
  5001. front_port = FrontPort.objects.get(name='Front Port 4')
  5002. rear_port = RearPort.objects.get(name='Rear Port 1')
  5003. self.assertTrue(
  5004. PortMapping.objects.filter(
  5005. front_port=front_port,
  5006. front_port_position=1,
  5007. rear_port=rear_port,
  5008. rear_port_position=1,
  5009. ).exists()
  5010. )
  5011. @tag('regression') # Issue #18991
  5012. def test_rear_port_paths(self):
  5013. device = Device.objects.first()
  5014. interface1 = Interface.objects.create(device=device, name='Interface 1')
  5015. rear_port = RearPort.objects.create(
  5016. device=device,
  5017. name='Rear Port 10',
  5018. type=PortTypeChoices.TYPE_8P8C,
  5019. )
  5020. Cable.objects.create(a_terminations=[interface1], b_terminations=[rear_port])
  5021. self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
  5022. url = reverse(f'dcim-api:{self.model._meta.model_name}-paths', kwargs={'pk': rear_port.pk})
  5023. response = self.client.get(url, **self.header)
  5024. self.assertHttpStatus(response, status.HTTP_200_OK)
  5025. def test_paths_reuse_node_serializers(self):
  5026. """The paths through a rear port share one nested serializer per node type."""
  5027. device = Device.objects.first()
  5028. rear_ports = {}
  5029. for positions in (1, 2):
  5030. rear_port = RearPort.objects.create(
  5031. device=device, name=f'Path Rear Port {positions}', type=PortTypeChoices.TYPE_8P8C, positions=positions
  5032. )
  5033. for position in range(1, positions + 1):
  5034. front_port = FrontPort.objects.create(
  5035. device=device, name=f'Path Front Port {positions}-{position}', type=PortTypeChoices.TYPE_8P8C
  5036. )
  5037. PortMapping.objects.create(
  5038. device=device, front_port=front_port, rear_port=rear_port, rear_port_position=position
  5039. )
  5040. interface = Interface.objects.create(device=device, name=f'Path Interface {positions}-{position}')
  5041. Cable.objects.create(a_terminations=[interface], b_terminations=[front_port])
  5042. rear_ports[positions] = rear_port.pk
  5043. self.add_permissions('dcim.view_rearport')
  5044. def get(positions, host):
  5045. url = reverse('dcim-api:rearport-paths', kwargs={'pk': rear_ports[positions]})
  5046. with patch.object(
  5047. InterfaceSerializer, '__init__', autospec=True, side_effect=InterfaceSerializer.__init__
  5048. ) as init:
  5049. response = self.client.get(url, HTTP_HOST=host, **self.header)
  5050. self.assertHttpStatus(response, status.HTTP_200_OK)
  5051. return init.call_count, response.data
  5052. one, _ = get(1, 'a.example.com')
  5053. two, paths = get(2, 'b.example.com')
  5054. self.assertEqual(one, two)
  5055. self.assertEqual(
  5056. sorted(path['path'][0][0]['name'] for path in paths),
  5057. ['Path Interface 2-1', 'Path Interface 2-2'],
  5058. )
  5059. for path in paths:
  5060. self.assertEqual(path['path'][-1][0]['id'], rear_ports[2])
  5061. self.assertTrue(path['path'][0][0]['url'].startswith('http://b.example.com/'))
  5062. class ModuleBayTestCase(APIViewTestCases.APIViewTestCase):
  5063. model = ModuleBay
  5064. brief_fields = ['_occupied', 'description', 'display', 'enabled', 'id', 'installed_module', 'name', 'url']
  5065. bulk_update_data = {
  5066. 'description': 'New description',
  5067. }
  5068. bulk_update_invalid_data = {
  5069. 'owner': 99999,
  5070. }
  5071. user_permissions = ('dcim.view_device', )
  5072. @classmethod
  5073. def setUpTestData(cls):
  5074. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  5075. site = Site.objects.create(name='Site 1', slug='site-1')
  5076. role = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
  5077. device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  5078. device = Device.objects.create(device_type=device_type, role=role, name='Device 1', site=site)
  5079. module_bays = (
  5080. ModuleBay(device=device, name='Device Bay 1', enabled=True),
  5081. ModuleBay(device=device, name='Device Bay 2', enabled=False),
  5082. ModuleBay(device=device, name='Device Bay 3', enabled=True),
  5083. )
  5084. for module_bay in module_bays:
  5085. module_bay.save()
  5086. module_bay_types = (
  5087. ModuleBayType(manufacturer=manufacturer, name='Module Bay Type 1', slug='module-bay-type-1'),
  5088. ModuleBayType(manufacturer=manufacturer, name='Module Bay Type 2', slug='module-bay-type-2'),
  5089. )
  5090. ModuleBayType.objects.bulk_create(module_bay_types)
  5091. for module_bay in module_bays:
  5092. module_bay.module_bay_types.set(module_bay_types)
  5093. cls.create_data = [
  5094. {
  5095. 'device': device.pk,
  5096. 'name': 'Device Bay 4',
  5097. 'enabled': False,
  5098. 'module_bay_types': [module_bay_types[0].pk, module_bay_types[1].pk],
  5099. },
  5100. {
  5101. 'device': device.pk,
  5102. 'name': 'Device Bay 5',
  5103. 'module_bay_types': [module_bay_types[0].pk],
  5104. },
  5105. {
  5106. 'device': device.pk,
  5107. 'name': 'Device Bay 6',
  5108. },
  5109. ]
  5110. def test_is_module_compatible_flag(self):
  5111. """
  5112. is_module_compatible should be True when no bay types restrict the bay, and False
  5113. when the bay's types and the installed module type's types share no common members.
  5114. """
  5115. self.add_permissions('dcim.view_modulebay', 'dcim.view_module', 'dcim.view_moduletype')
  5116. manufacturer = Manufacturer.objects.create(
  5117. name='Compat Manufacturer', slug='compat-manufacturer'
  5118. )
  5119. device = create_test_device('Compat Bay Test Device')
  5120. bay_type_a = ModuleBayType.objects.create(
  5121. manufacturer=manufacturer, name='Compat Bay Type A', slug='compat-bay-type-a'
  5122. )
  5123. bay_type_b = ModuleBayType.objects.create(
  5124. manufacturer=manufacturer, name='Compat Bay Type B', slug='compat-bay-type-b'
  5125. )
  5126. module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Compat MT')
  5127. module_type.module_bay_types.set([bay_type_a])
  5128. compatible_bay = ModuleBay.objects.create(device=device, name='Compat Bay C')
  5129. compatible_bay.module_bay_types.set([bay_type_a])
  5130. Module.objects.create(device=device, module_bay=compatible_bay, module_type=module_type)
  5131. incompatible_bay = ModuleBay.objects.create(device=device, name='Compat Bay I')
  5132. incompatible_bay.module_bay_types.set([bay_type_b])
  5133. Module.objects.create(device=device, module_bay=incompatible_bay, module_type=module_type)
  5134. empty_bay = ModuleBay.objects.create(device=device, name='Compat Bay E')
  5135. url = reverse('dcim-api:modulebay-detail', kwargs={'pk': compatible_bay.pk})
  5136. response = self.client.get(url, **self.header)
  5137. self.assertHttpStatus(response, 200)
  5138. self.assertTrue(response.data['is_module_compatible'])
  5139. url = reverse('dcim-api:modulebay-detail', kwargs={'pk': incompatible_bay.pk})
  5140. response = self.client.get(url, **self.header)
  5141. self.assertHttpStatus(response, 200)
  5142. self.assertFalse(response.data['is_module_compatible'])
  5143. url = reverse('dcim-api:modulebay-detail', kwargs={'pk': empty_bay.pk})
  5144. response = self.client.get(url, **self.header)
  5145. self.assertHttpStatus(response, 200)
  5146. self.assertTrue(response.data['is_module_compatible'])
  5147. def test_module_bay_types_write(self):
  5148. """
  5149. module_bay_types accepts a list of primary keys, renders as nested objects, is cleared by an
  5150. empty list, and rejects an unknown primary key without altering the existing assignment.
  5151. """
  5152. self.add_permissions('dcim.view_modulebay', 'dcim.change_modulebay')
  5153. bay_type = ModuleBayType.objects.first()
  5154. module_bay = self._get_queryset().first()
  5155. url = self._get_detail_url(module_bay)
  5156. self.assertEqual(module_bay.module_bay_types.count(), 2)
  5157. # Assigning by primary key replaces the existing set rather than adding to it
  5158. response = self.client.patch(url, {'module_bay_types': [bay_type.pk]}, format='json', **self.header)
  5159. self.assertHttpStatus(response, status.HTTP_200_OK)
  5160. self.assertListEqual([mbt.pk for mbt in module_bay.module_bay_types.all()], [bay_type.pk])
  5161. # The response renders nested objects, not bare primary keys
  5162. self.assertIsInstance(response.data['module_bay_types'][0], dict)
  5163. self.assertEqual(response.data['module_bay_types'][0]['id'], bay_type.pk)
  5164. self.assertEqual(response.data['module_bay_types'][0]['name'], bay_type.name)
  5165. # An unknown primary key is rejected without altering the existing assignment
  5166. bad_pk = ModuleBayType.objects.order_by('pk').last().pk + 1
  5167. response = self.client.patch(url, {'module_bay_types': [bad_pk]}, format='json', **self.header)
  5168. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  5169. self.assertIn('module_bay_types', response.data)
  5170. self.assertListEqual([mbt.pk for mbt in module_bay.module_bay_types.all()], [bay_type.pk])
  5171. # An empty list clears the assignment
  5172. response = self.client.patch(url, {'module_bay_types': []}, format='json', **self.header)
  5173. self.assertHttpStatus(response, status.HTTP_200_OK)
  5174. self.assertEqual(module_bay.module_bay_types.count(), 0)
  5175. @tag('regression') # Issue #23323
  5176. def test_installed_module_is_read_only(self):
  5177. """Creating or updating a module bay ignores installed_module."""
  5178. module_bay = ModuleBay.objects.get(name='Device Bay 1')
  5179. other_bay = ModuleBay.objects.get(name='Device Bay 3')
  5180. module_type = ModuleType.objects.create(manufacturer=Manufacturer.objects.first(), model='Module Type 1')
  5181. module = Module.objects.create(device=module_bay.device, module_bay=module_bay, module_type=module_type)
  5182. other_module = Module.objects.create(device=other_bay.device, module_bay=other_bay, module_type=module_type)
  5183. self.add_permissions('dcim.add_modulebay', 'dcim.change_modulebay')
  5184. for installed_module in (None, other_module.pk, -1):
  5185. with self.subTest(action='create', installed_module=installed_module):
  5186. data = {
  5187. 'device': module_bay.device.pk,
  5188. 'name': f'Module Bay {installed_module}',
  5189. 'installed_module': installed_module,
  5190. }
  5191. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  5192. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  5193. self.assertIsNone(response.data['installed_module'])
  5194. with self.subTest(action='update', installed_module=installed_module):
  5195. data = {'installed_module': installed_module}
  5196. response = self.client.patch(self._get_detail_url(module_bay), data, format='json', **self.header)
  5197. self.assertHttpStatus(response, status.HTTP_200_OK)
  5198. self.assertEqual(response.data['installed_module']['id'], module.pk)
  5199. module.refresh_from_db()
  5200. other_module.refresh_from_db()
  5201. self.assertEqual(module.module_bay, module_bay)
  5202. self.assertEqual(other_module.module_bay, other_bay)
  5203. class DeviceBayTestCase(APIViewTestCases.APIViewTestCase):
  5204. model = DeviceBay
  5205. brief_fields = ['_occupied', 'description', 'device', 'display', 'enabled', 'id', 'name', 'url']
  5206. bulk_update_data = {
  5207. 'description': 'New description',
  5208. }
  5209. bulk_update_invalid_data = {
  5210. 'owner': 99999,
  5211. }
  5212. user_permissions = ('dcim.view_device', )
  5213. @classmethod
  5214. def setUpTestData(cls):
  5215. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  5216. site = Site.objects.create(name='Site 1', slug='site-1')
  5217. role = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
  5218. device_types = (
  5219. DeviceType(
  5220. manufacturer=manufacturer,
  5221. model='Device Type 1',
  5222. slug='device-type-1',
  5223. subdevice_role=SubdeviceRoleChoices.ROLE_PARENT
  5224. ),
  5225. DeviceType(
  5226. manufacturer=manufacturer,
  5227. model='Device Type 2',
  5228. slug='device-type-2',
  5229. subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
  5230. ),
  5231. )
  5232. DeviceType.objects.bulk_create(device_types)
  5233. devices = (
  5234. Device(device_type=device_types[0], role=role, name='Device 1', site=site),
  5235. Device(device_type=device_types[1], role=role, name='Device 2', site=site),
  5236. Device(device_type=device_types[1], role=role, name='Device 3', site=site),
  5237. Device(device_type=device_types[1], role=role, name='Device 4', site=site),
  5238. )
  5239. Device.objects.bulk_create(devices)
  5240. device_bays = (
  5241. DeviceBay(device=devices[0], name='Device Bay 1', enabled=True),
  5242. DeviceBay(device=devices[0], name='Device Bay 2', enabled=False),
  5243. DeviceBay(device=devices[0], name='Device Bay 3', enabled=True),
  5244. )
  5245. DeviceBay.objects.bulk_create(device_bays)
  5246. cls.create_data = [
  5247. {
  5248. 'device': devices[0].pk,
  5249. 'name': 'Device Bay 4',
  5250. 'installed_device': devices[1].pk,
  5251. },
  5252. {
  5253. 'device': devices[0].pk,
  5254. 'name': 'Device Bay 5',
  5255. 'installed_device': devices[2].pk,
  5256. },
  5257. {
  5258. 'device': devices[0].pk,
  5259. 'name': 'Device Bay 6',
  5260. 'installed_device': devices[3].pk,
  5261. },
  5262. ]
  5263. class InventoryItemTestCase(APIViewTestCases.APIViewTestCase):
  5264. model = InventoryItem
  5265. brief_fields = ['_depth', 'description', 'device', 'display', 'id', 'name', 'url']
  5266. bulk_update_data = {
  5267. 'description': 'New description',
  5268. }
  5269. bulk_update_invalid_data = {
  5270. 'status': 'not-a-valid-status',
  5271. }
  5272. user_permissions = ('dcim.view_device', 'dcim.view_manufacturer')
  5273. @classmethod
  5274. def setUpTestData(cls):
  5275. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  5276. devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  5277. site = Site.objects.create(name='Site 1', slug='site-1')
  5278. role = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
  5279. device = Device.objects.create(device_type=devicetype, role=role, name='Device 1', site=site)
  5280. roles = (
  5281. InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1'),
  5282. InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2'),
  5283. )
  5284. InventoryItemRole.objects.bulk_create(roles)
  5285. interfaces = (
  5286. Interface(device=device, name='Interface 1'),
  5287. Interface(device=device, name='Interface 2'),
  5288. Interface(device=device, name='Interface 3'),
  5289. )
  5290. Interface.objects.bulk_create(interfaces)
  5291. InventoryItem.objects.create(
  5292. device=device, name='Inventory Item 1', role=roles[0], manufacturer=manufacturer, component=interfaces[0]
  5293. )
  5294. InventoryItem.objects.create(
  5295. device=device, name='Inventory Item 2', role=roles[0], manufacturer=manufacturer, component=interfaces[1]
  5296. )
  5297. InventoryItem.objects.create(
  5298. device=device, name='Inventory Item 3', role=roles[0], manufacturer=manufacturer, component=interfaces[2]
  5299. )
  5300. cls.create_data = [
  5301. {
  5302. 'device': device.pk,
  5303. 'name': 'Inventory Item 4',
  5304. 'role': roles[1].pk,
  5305. 'manufacturer': manufacturer.pk,
  5306. 'component_type': 'dcim.interface',
  5307. 'component_id': interfaces[0].pk,
  5308. },
  5309. {
  5310. 'device': device.pk,
  5311. 'name': 'Inventory Item 5',
  5312. 'role': roles[1].pk,
  5313. 'manufacturer': manufacturer.pk,
  5314. 'component_type': 'dcim.interface',
  5315. 'component_id': interfaces[1].pk,
  5316. },
  5317. {
  5318. 'device': device.pk,
  5319. 'name': 'Inventory Item 6',
  5320. 'role': roles[1].pk,
  5321. 'manufacturer': manufacturer.pk,
  5322. 'component_type': 'dcim.interface',
  5323. 'component_id': interfaces[2].pk,
  5324. },
  5325. ]
  5326. class InventoryItemRoleTestCase(APIViewTestCases.APIViewTestCase):
  5327. model = InventoryItemRole
  5328. brief_fields = ['description', 'display', 'id', 'inventoryitem_count', 'name', 'slug', 'url']
  5329. create_data = [
  5330. {
  5331. 'name': 'Inventory Item Role 4',
  5332. 'slug': 'inventory-item-role-4',
  5333. 'color': 'ffff00',
  5334. },
  5335. {
  5336. 'name': 'Inventory Item Role 5',
  5337. 'slug': 'inventory-item-role-5',
  5338. 'color': 'ffff00',
  5339. },
  5340. {
  5341. 'name': 'Inventory Item Role 6',
  5342. 'slug': 'inventory-item-role-6',
  5343. 'color': 'ffff00',
  5344. },
  5345. ]
  5346. bulk_update_data = {
  5347. 'description': 'New description',
  5348. }
  5349. bulk_update_invalid_data = {
  5350. 'color': 'not-a-color',
  5351. }
  5352. @classmethod
  5353. def setUpTestData(cls):
  5354. roles = (
  5355. InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1', color='ff0000'),
  5356. InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2', color='00ff00'),
  5357. InventoryItemRole(name='Inventory Item Role 3', slug='inventory-item-role-3', color='0000ff'),
  5358. )
  5359. InventoryItemRole.objects.bulk_create(roles)
  5360. class CableBundleTestCase(APIViewTestCases.APIViewTestCase):
  5361. model = CableBundle
  5362. brief_fields = ['description', 'display', 'id', 'name', 'url']
  5363. create_data = [
  5364. {'name': 'Cable Bundle 4'},
  5365. {'name': 'Cable Bundle 5'},
  5366. {'name': 'Cable Bundle 6'},
  5367. ]
  5368. bulk_update_data = {
  5369. 'description': 'New description',
  5370. }
  5371. bulk_update_invalid_data = {
  5372. 'owner': 99999,
  5373. }
  5374. @classmethod
  5375. def setUpTestData(cls):
  5376. cable_bundles = (
  5377. CableBundle(name='Cable Bundle 1'),
  5378. CableBundle(name='Cable Bundle 2'),
  5379. CableBundle(name='Cable Bundle 3'),
  5380. )
  5381. CableBundle.objects.bulk_create(cable_bundles)
  5382. def test_cable_count(self):
  5383. """cable_count annotation is returned correctly in the API response."""
  5384. self.add_permissions('dcim.view_cablebundle')
  5385. bundle = CableBundle.objects.first()
  5386. site = Site.objects.create(name='CB Test Site', slug='cb-test-site')
  5387. manufacturer = Manufacturer.objects.create(name='CB Manufacturer', slug='cb-manufacturer')
  5388. device_type = DeviceType.objects.create(
  5389. manufacturer=manufacturer, model='CB Device Type', slug='cb-device-type'
  5390. )
  5391. role = DeviceRole.objects.create(name='CB Role', slug='cb-role', color='ff0000')
  5392. devices = (
  5393. Device(device_type=device_type, role=role, name='CB Device 1', site=site),
  5394. Device(device_type=device_type, role=role, name='CB Device 2', site=site),
  5395. )
  5396. Device.objects.bulk_create(devices)
  5397. interfaces = (
  5398. Interface(device=devices[0], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
  5399. Interface(device=devices[0], name='eth1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
  5400. Interface(device=devices[1], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
  5401. Interface(device=devices[1], name='eth1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
  5402. )
  5403. Interface.objects.bulk_create(interfaces)
  5404. for a, b in [(interfaces[0], interfaces[2]), (interfaces[1], interfaces[3])]:
  5405. cable = Cable(a_terminations=[a], b_terminations=[b], bundle=bundle)
  5406. cable.save()
  5407. url = reverse('dcim-api:cablebundle-detail', kwargs={'pk': bundle.pk})
  5408. response = self.client.get(url, **self.header)
  5409. self.assertHttpStatus(response, status.HTTP_200_OK)
  5410. self.assertEqual(response.data['cable_count'], 2)
  5411. class CableTestCase(APIViewTestCases.APIViewTestCase):
  5412. model = Cable
  5413. brief_fields = ['description', 'display', 'id', 'label', 'url']
  5414. bulk_update_data = {
  5415. 'length': 100,
  5416. 'length_unit': 'm',
  5417. }
  5418. bulk_update_invalid_data = {
  5419. 'status': 'not-a-valid-status',
  5420. }
  5421. # TODO: Allow updating cable terminations
  5422. test_update_object = None
  5423. def model_to_dict(self, *args, **kwargs):
  5424. data = super().model_to_dict(*args, **kwargs)
  5425. # Serialize termination objects
  5426. if 'a_terminations' in data:
  5427. data['a_terminations'] = GenericObjectSerializer(data['a_terminations'], many=True).data
  5428. if 'b_terminations' in data:
  5429. data['b_terminations'] = GenericObjectSerializer(data['b_terminations'], many=True).data
  5430. return data
  5431. @classmethod
  5432. def setUpTestData(cls):
  5433. site = Site.objects.create(name='Site 1', slug='site-1')
  5434. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  5435. devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  5436. role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
  5437. devices = (
  5438. Device(device_type=devicetype, role=role, name='Device 1', site=site),
  5439. Device(device_type=devicetype, role=role, name='Device 2', site=site),
  5440. )
  5441. Device.objects.bulk_create(devices)
  5442. interfaces = []
  5443. for device in devices:
  5444. for i in range(0, 10):
  5445. interfaces.append(Interface(device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name=f'eth{i}'))
  5446. Interface.objects.bulk_create(interfaces)
  5447. cables = (
  5448. Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[10]], label='Cable 1'),
  5449. Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[11]], label='Cable 2'),
  5450. Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[12]], label='Cable 3'),
  5451. )
  5452. for cable in cables:
  5453. cable.save()
  5454. cls.create_data = [
  5455. {
  5456. 'a_terminations': [{
  5457. 'object_type': 'dcim.interface',
  5458. 'object_id': interfaces[4].pk,
  5459. }],
  5460. 'b_terminations': [{
  5461. 'object_type': 'dcim.interface',
  5462. 'object_id': interfaces[14].pk,
  5463. }],
  5464. 'label': 'Cable 4',
  5465. 'profile': CableProfileChoices.SINGLE_1C1P,
  5466. },
  5467. {
  5468. 'a_terminations': [{
  5469. 'object_type': 'dcim.interface',
  5470. 'object_id': interfaces[5].pk,
  5471. }],
  5472. 'b_terminations': [{
  5473. 'object_type': 'dcim.interface',
  5474. 'object_id': interfaces[15].pk,
  5475. }],
  5476. 'label': 'Cable 5',
  5477. 'profile': CableProfileChoices.SINGLE_1C1P,
  5478. },
  5479. {
  5480. 'a_terminations': [{
  5481. 'object_type': 'dcim.interface',
  5482. 'object_id': interfaces[6].pk,
  5483. }],
  5484. 'b_terminations': [{
  5485. 'object_type': 'dcim.interface',
  5486. 'object_id': interfaces[16].pk,
  5487. }],
  5488. 'label': 'Cable 6',
  5489. # No profile (legacy behavior)
  5490. },
  5491. ]
  5492. def test_list_objects_terminations_query_count(self):
  5493. """
  5494. Listing cables loads their terminating objects, and those objects' devices and cables, with the same number
  5495. of queries however many cables are listed.
  5496. """
  5497. self.add_permissions('dcim.view_cable')
  5498. def check_results(cables, results):
  5499. self.assertEqual(len(results), len(cables))
  5500. for result in results:
  5501. for termination in (*result['a_terminations'], *result['b_terminations']):
  5502. self.assertEqual(termination['object']['cable']['id'], result['id'])
  5503. self.assertIn(termination['object']['device']['name'], ('Device 1', 'Device 2'))
  5504. assert_list_query_count_is_constant(
  5505. self,
  5506. self._get_list_url(),
  5507. lambda cables: {'id': [cable.pk for cable in cables]},
  5508. list(Cable.objects.order_by('pk')[:3]),
  5509. check_results,
  5510. )
  5511. def test_terminations_prefetch_leaves_shared_queryset_unchanged(self):
  5512. """
  5513. CableTerminationsPrefetch, which is shared across requests, gives Django a new queryset to add routing hints
  5514. to, and the prefetched terminations use the FETCH_PEERS fetch mode.
  5515. """
  5516. cable_prefetch = CableTerminationsPrefetch('terminations')
  5517. cables = list(Cable.objects.all())
  5518. prefetch_related_objects(cables, cable_prefetch)
  5519. self.assertEqual(cable_prefetch.queryset._hints, {})
  5520. self.assertIs(cables[0].terminations.all()[0]._state.fetch_mode, FETCH_PEERS)
  5521. # The two-level lookup used by the port viewsets
  5522. port_prefetch = CableTerminationsPrefetch()
  5523. interfaces = list(Interface.objects.filter(cable__isnull=False))
  5524. prefetch_related_objects(interfaces, port_prefetch)
  5525. self.assertEqual(port_prefetch.queryset._hints, {})
  5526. self.assertIs(interfaces[0].cable.terminations.all()[0]._state.fetch_mode, FETCH_PEERS)
  5527. def test_repeated_put_does_not_accumulate_paths(self):
  5528. """
  5529. Repeating an identical PUT must leave the cable with the two paths its terminations trace.
  5530. """
  5531. self.add_permissions('dcim.change_cable')
  5532. cable = Cable.objects.get(label='Cable 1')
  5533. interface_a = Interface.objects.get(cable=cable, cable_end=CableEndChoices.SIDE_A)
  5534. interface_b = Interface.objects.get(cable=cable, cable_end=CableEndChoices.SIDE_B)
  5535. data = {
  5536. 'status': cable.status,
  5537. 'a_terminations': [{'object_type': 'dcim.interface', 'object_id': interface_a.pk}],
  5538. 'b_terminations': [{'object_type': 'dcim.interface', 'object_id': interface_b.pk}],
  5539. }
  5540. for attempt in range(3):
  5541. with self.subTest(attempt=attempt):
  5542. response = self.client.put(self._get_detail_url(cable), data, format='json', **self.header)
  5543. self.assertHttpStatus(response, status.HTTP_200_OK)
  5544. for interface in (interface_a, interface_b):
  5545. self.assertTrue(Interface.objects.get(pk=interface.pk)._path.is_complete)
  5546. self.assertEqual(CablePath.objects.filter(_nodes__contains=cable).count(), 2)
  5547. @tag('regression') # Issue #23094
  5548. def test_patch_clearing_an_end_keeps_the_other_end(self):
  5549. """
  5550. A PATCH with an empty termination list must detach that end only and keep the other end's row.
  5551. """
  5552. self.add_permissions('dcim.change_cable')
  5553. for label, attr, cleared_side, kept_side in (
  5554. ('Cable 1', 'a_terminations', CableEndChoices.SIDE_A, CableEndChoices.SIDE_B),
  5555. ('Cable 2', 'b_terminations', CableEndChoices.SIDE_B, CableEndChoices.SIDE_A),
  5556. ):
  5557. with self.subTest(attr=attr):
  5558. cable = Cable.objects.get(label=label)
  5559. cleared = Interface.objects.get(cable=cable, cable_end=cleared_side)
  5560. kept = Interface.objects.get(cable=cable, cable_end=kept_side)
  5561. kept_row_pk = CableTermination.objects.get(cable=cable, cable_end=kept_side).pk
  5562. response = self.client.patch(self._get_detail_url(cable), {attr: []}, format='json', **self.header)
  5563. self.assertHttpStatus(response, status.HTTP_200_OK)
  5564. self.assertEqual(
  5565. list(
  5566. CableTermination.objects.filter(cable=cable)
  5567. .values_list('pk', 'cable_end', 'termination_id')
  5568. ),
  5569. [(kept_row_pk, kept_side, kept.pk)]
  5570. )
  5571. cleared.refresh_from_db()
  5572. self.assertIsNone(cleared.cable)
  5573. self.assertIsNone(cleared._path_id)
  5574. kept.refresh_from_db()
  5575. self.assertEqual(kept.cable, cable)
  5576. self.assertFalse(kept._path.is_complete)
  5577. def test_update_without_terminations_preserves_them(self):
  5578. """PATCHing a scalar field leaves both cable ends unchanged."""
  5579. cable = Cable.objects.get(label='Cable 1')
  5580. a_terminations, b_terminations = cable.a_terminations, cable.b_terminations
  5581. self.add_permissions('dcim.change_cable')
  5582. response = self.client.patch(
  5583. self._get_detail_url(cable), {'label': 'Cable 1 renamed'}, format='json', **self.header
  5584. )
  5585. self.assertHttpStatus(response, status.HTTP_200_OK)
  5586. cable = Cable.objects.get(pk=cable.pk)
  5587. self.assertEqual(cable.a_terminations, a_terminations)
  5588. self.assertEqual(cable.b_terminations, b_terminations)
  5589. def test_update_terminations_adds_to_a_multi_termination_end(self):
  5590. """PATCHing the complete termination list keeps the existing rows and adds the new termination."""
  5591. device1, device2 = Device.objects.order_by('name')
  5592. interfaces = [Interface.objects.get(device=device2, name=name) for name in ('eth7', 'eth8', 'eth9')]
  5593. cable = Cable(
  5594. a_terminations=[Interface.objects.get(device=device1, name='eth7')],
  5595. b_terminations=interfaces[:2],
  5596. label='Cable 7'
  5597. )
  5598. cable.save()
  5599. rows = CableTermination.objects.filter(cable=cable, cable_end=CableEndChoices.SIDE_B)
  5600. existing_rows = set(rows.values_list('pk', flat=True))
  5601. self.add_permissions('dcim.change_cable')
  5602. data = {
  5603. 'b_terminations': [
  5604. {'object_type': 'dcim.interface', 'object_id': interface.pk} for interface in interfaces
  5605. ],
  5606. }
  5607. response = self.client.patch(self._get_detail_url(cable), data, format='json', **self.header)
  5608. self.assertHttpStatus(response, status.HTTP_200_OK)
  5609. self.assertCountEqual(Cable.objects.get(pk=cable.pk).b_terminations, interfaces)
  5610. self.assertTrue(existing_rows.issubset(rows.values_list('pk', flat=True)))
  5611. def test_update_terminations_replaces_a_multi_termination_end(self):
  5612. """PATCHing a shorter termination list removes the omitted termination."""
  5613. device1, device2 = Device.objects.order_by('name')
  5614. interfaces = [Interface.objects.get(device=device2, name=name) for name in ('eth7', 'eth8')]
  5615. cable = Cable(
  5616. a_terminations=[Interface.objects.get(device=device1, name='eth7')],
  5617. b_terminations=interfaces,
  5618. label='Cable 7'
  5619. )
  5620. cable.save()
  5621. self.add_permissions('dcim.change_cable')
  5622. data = {
  5623. 'b_terminations': [{'object_type': 'dcim.interface', 'object_id': interfaces[0].pk}],
  5624. }
  5625. response = self.client.patch(self._get_detail_url(cable), data, format='json', **self.header)
  5626. self.assertHttpStatus(response, status.HTTP_200_OK)
  5627. self.assertEqual(Cable.objects.get(pk=cable.pk).b_terminations, [interfaces[0]])
  5628. def test_graphql_cable_termination_cached_filters(self):
  5629. """
  5630. Validate filtering cables by cached CableTermination relations via GraphQL:
  5631. cable_list(filters: { terminations: { <relation>: {...}, DISTINCT: true } })
  5632. Also asserts deduplication when both ends match (cable between two interfaces
  5633. on the same device/rack/location/site).
  5634. """
  5635. self.add_permissions(
  5636. 'dcim.view_cable',
  5637. 'dcim.view_device',
  5638. 'dcim.view_interface',
  5639. 'dcim.view_rack',
  5640. 'dcim.view_location',
  5641. 'dcim.view_site',
  5642. )
  5643. # Reuse existing fixtures from setUpTestData()
  5644. devicetype = DeviceType.objects.get(slug='device-type-1')
  5645. role = DeviceRole.objects.get(slug='device-role-1')
  5646. # Create an isolated topology for this test
  5647. site_a = Site.objects.create(name='GQL Site A', slug='gql-site-a')
  5648. site_b = Site.objects.create(name='GQL Site B', slug='gql-site-b')
  5649. location_a = Location.objects.create(
  5650. site=site_a,
  5651. name='GQL Location A',
  5652. slug='gql-location-a',
  5653. status=LocationStatusChoices.STATUS_ACTIVE,
  5654. )
  5655. location_b = Location.objects.create(
  5656. site=site_b,
  5657. name='GQL Location B',
  5658. slug='gql-location-b',
  5659. status=LocationStatusChoices.STATUS_ACTIVE,
  5660. )
  5661. rack_a = Rack.objects.create(site=site_a, location=location_a, name='GQL Rack A', u_height=42)
  5662. rack_b = Rack.objects.create(site=site_b, location=location_b, name='GQL Rack B', u_height=42)
  5663. device_a = Device.objects.create(
  5664. device_type=devicetype,
  5665. role=role,
  5666. name='GQL Device A',
  5667. site=site_a,
  5668. location=location_a,
  5669. rack=rack_a,
  5670. )
  5671. device_b = Device.objects.create(
  5672. device_type=devicetype,
  5673. role=role,
  5674. name='GQL Device B',
  5675. site=site_b,
  5676. location=location_b,
  5677. rack=rack_b,
  5678. )
  5679. a0 = Interface.objects.create(device=device_a, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name='eth0')
  5680. a1 = Interface.objects.create(device=device_a, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name='eth1')
  5681. a2 = Interface.objects.create(device=device_a, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name='eth2')
  5682. b0 = Interface.objects.create(device=device_b, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name='eth0')
  5683. # Both ends on Device A (duplication risk without DISTINCT)
  5684. cable_same_device = Cable(a_terminations=[a0], b_terminations=[a1], label='GQL Cable Same Device')
  5685. cable_same_device.save()
  5686. # Cross to Device B
  5687. cable_cross = Cable(a_terminations=[a2], b_terminations=[b0], label='GQL Cable Cross')
  5688. cable_cross.save()
  5689. expected_a = {str(cable_same_device.pk), str(cable_cross.pk)}
  5690. expected_b = {str(cable_cross.pk)}
  5691. url = reverse('graphql')
  5692. test_cases = (
  5693. # Device (ID + name)
  5694. (f'device: {{ id: {{ exact: "{device_a.pk}" }} }}', expected_a),
  5695. (f'device: {{ name: {{ exact: "{device_a.name}" }} }}', expected_a),
  5696. (f'device: {{ id: {{ exact: "{device_b.pk}" }} }}', expected_b),
  5697. (f'device: {{ name: {{ exact: "{device_b.name}" }} }}', expected_b),
  5698. # Rack (ID + name)
  5699. (f'rack: {{ id: {{ exact: "{rack_a.pk}" }} }}', expected_a),
  5700. (f'rack: {{ name: {{ exact: "{rack_a.name}" }} }}', expected_a),
  5701. (f'rack: {{ id: {{ exact: "{rack_b.pk}" }} }}', expected_b),
  5702. (f'rack: {{ name: {{ exact: "{rack_b.name}" }} }}', expected_b),
  5703. # Location (ID + name)
  5704. (f'location: {{ id: {{ exact: "{location_a.pk}" }} }}', expected_a),
  5705. (f'location: {{ name: {{ exact: "{location_a.name}" }} }}', expected_a),
  5706. (f'location: {{ id: {{ exact: "{location_b.pk}" }} }}', expected_b),
  5707. (f'location: {{ name: {{ exact: "{location_b.name}" }} }}', expected_b),
  5708. # Site (ID + slug)
  5709. (f'site: {{ id: {{ exact: "{site_a.pk}" }} }}', expected_a),
  5710. (f'site: {{ slug: {{ exact: "{site_a.slug}" }} }}', expected_a),
  5711. (f'site: {{ id: {{ exact: "{site_b.pk}" }} }}', expected_b),
  5712. (f'site: {{ slug: {{ exact: "{site_b.slug}" }} }}', expected_b),
  5713. )
  5714. for inner_filter, expected in test_cases:
  5715. with self.subTest(filter=inner_filter):
  5716. query = f"""{{
  5717. cable_list(filters: {{ terminations: {{ {inner_filter} DISTINCT: true }} }})
  5718. {{ id }}
  5719. }}"""
  5720. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  5721. self.assertHttpStatus(response, status.HTTP_200_OK)
  5722. data = response.json()
  5723. self.assertNotIn('errors', data)
  5724. rows = data['data']['cable_list']
  5725. ids = [row['id'] for row in rows]
  5726. # Ensure DISTINCT is actually effective (no duplicate cables when both ends match)
  5727. self.assertEqual(len(ids), len(set(ids)), f'Duplicate cables returned for: {inner_filter}')
  5728. self.assertSetEqual(set(ids), expected)
  5729. def test_graphql_cable_terminations_query_count(self):
  5730. """
  5731. Resolving CableType.a_terminations and CableType.b_terminations must take a constant number
  5732. of queries, regardless of how many cables (and hence terminations) are returned.
  5733. Also exercises selecting both cable ends in a single query: each end must be prefetched
  5734. under its own attribute, as two prefetches of the same relation cannot be merged.
  5735. """
  5736. self.add_permissions(
  5737. 'dcim.view_cable',
  5738. 'dcim.view_device',
  5739. 'dcim.view_devicerole',
  5740. 'dcim.view_devicetype',
  5741. 'dcim.view_interface',
  5742. 'dcim.view_platform',
  5743. )
  5744. # Reuse existing fixtures from setUpTestData()
  5745. site = Site.objects.get(slug='site-1')
  5746. devicetype = DeviceType.objects.get(slug='device-type-1')
  5747. role = DeviceRole.objects.get(slug='device-role-1')
  5748. # Create an isolated topology of cables between two devices
  5749. devices = (
  5750. Device(device_type=devicetype, role=role, name='GQL Count Device A', site=site),
  5751. Device(device_type=devicetype, role=role, name='GQL Count Device B', site=site),
  5752. )
  5753. Device.objects.bulk_create(devices)
  5754. interfaces = []
  5755. for device in devices:
  5756. for i in range(0, 8):
  5757. interfaces.append(
  5758. Interface(device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name=f'gql{i}')
  5759. )
  5760. Interface.objects.bulk_create(interfaces)
  5761. expected_terminations = {}
  5762. for i in range(0, 8):
  5763. cable = Cable(
  5764. a_terminations=[interfaces[i]],
  5765. b_terminations=[interfaces[i + 8]],
  5766. label=f'GQL Count Cable {i}',
  5767. )
  5768. cable.save()
  5769. expected_terminations[str(cable.pk)] = (interfaces[i].pk, interfaces[i + 8].pk)
  5770. url = reverse('graphql')
  5771. termination_fields = """
  5772. ... on InterfaceType {
  5773. id
  5774. name
  5775. device { id name platform { id } role { id } device_type { id } }
  5776. }
  5777. """
  5778. def build_query(limit):
  5779. return f"""{{
  5780. cable_list(
  5781. filters: {{ label: {{ contains: "GQL Count Cable " }} }},
  5782. pagination: {{ limit: {limit} }}
  5783. ) {{
  5784. id
  5785. a_terminations {{ {termination_fields} }}
  5786. b_terminations {{ {termination_fields} }}
  5787. }}
  5788. }}"""
  5789. # Warm per-process caches (e.g. ContentType) so they are not counted below
  5790. self.client.post(url, data={'query': build_query(1)}, format='json', **self.header)
  5791. query_counts = {}
  5792. for limit in (2, 8):
  5793. with CaptureQueriesContext(connection) as queries:
  5794. response = self.client.post(
  5795. url, data={'query': build_query(limit)}, format='json', **self.header
  5796. )
  5797. self.assertHttpStatus(response, status.HTTP_200_OK)
  5798. data = response.json()
  5799. self.assertNotIn('errors', data)
  5800. rows = data['data']['cable_list']
  5801. self.assertEqual(len(rows), limit)
  5802. # Both ends must resolve to the expected interfaces
  5803. for row in rows:
  5804. interface_a, interface_b = expected_terminations[row['id']]
  5805. self.assertEqual([t['id'] for t in row['a_terminations']], [str(interface_a)])
  5806. self.assertEqual([t['id'] for t in row['b_terminations']], [str(interface_b)])
  5807. query_counts[limit] = len(queries.captured_queries)
  5808. self.assertEqual(
  5809. query_counts[2],
  5810. query_counts[8],
  5811. f"Query count scales with the number of cables returned: {query_counts}"
  5812. )
  5813. def test_graphql_cable_termination_models(self):
  5814. """
  5815. The GraphQL prefetch hint for a cable termination enumerates the terminating models
  5816. explicitly; a model missing from that list silently falls back to an unoptimized query
  5817. rather than raising, so guard against drift from CABLE_TERMINATION_MODELS.
  5818. """
  5819. self.assertSetEqual(
  5820. {(model._meta.app_label, model._meta.model_name) for model in _CABLE_TERMINATION_MODELS},
  5821. {(ot.app_label, ot.model) for ot in ObjectType.objects.filter(CABLE_TERMINATION_MODELS)},
  5822. )
  5823. class CableTerminationTestCase(
  5824. APIViewTestCases.GetObjectViewTestCase,
  5825. APIViewTestCases.ListObjectsViewTestCase,
  5826. ):
  5827. model = CableTermination
  5828. brief_fields = [
  5829. 'cable', 'cable_end', 'connector', 'display', 'id', 'positions', 'termination_id', 'termination_type', 'url',
  5830. ]
  5831. @classmethod
  5832. def setUpTestData(cls):
  5833. device1 = create_test_device('Device 1')
  5834. device2 = create_test_device('Device 2')
  5835. interfaces = []
  5836. for device in (device1, device2):
  5837. for i in range(0, 10):
  5838. interfaces.append(Interface(device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name=f'eth{i}'))
  5839. Interface.objects.bulk_create(interfaces)
  5840. cables = (
  5841. Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[10]], label='Cable 1'),
  5842. Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[11]], label='Cable 2'),
  5843. Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[12]], label='Cable 3'),
  5844. )
  5845. for cable in cables:
  5846. cable.save()
  5847. def test_list_objects_query_count(self):
  5848. """
  5849. Listing cable terminations loads their terminating objects, and those objects' devices and cables, with the
  5850. same number of queries however many terminations are listed.
  5851. """
  5852. self.add_permissions('dcim.view_cabletermination')
  5853. def check_results(cables, results):
  5854. self.assertEqual(len(results), 2 * len(cables))
  5855. for result in results:
  5856. self.assertEqual(result['termination']['cable']['id'], result['cable'])
  5857. self.assertIn(result['termination']['device']['name'], ('Device 1', 'Device 2'))
  5858. assert_list_query_count_is_constant(
  5859. self,
  5860. self._get_list_url(),
  5861. lambda cables: {'cable_id': [cable.pk for cable in cables]},
  5862. list(Cable.objects.order_by('pk')[:3]),
  5863. check_results,
  5864. )
  5865. class ConnectedDeviceTestCase(APITestCase):
  5866. @classmethod
  5867. def setUpTestData(cls):
  5868. site = Site.objects.create(name='Site 1', slug='site-1')
  5869. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  5870. devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  5871. role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
  5872. devices = (
  5873. Device(device_type=devicetype, role=role, name='TestDevice1', site=site),
  5874. Device(device_type=devicetype, role=role, name='TestDevice2', site=site),
  5875. )
  5876. Device.objects.bulk_create(devices)
  5877. interfaces = (
  5878. Interface(device=devices[0], name='eth0'),
  5879. Interface(device=devices[1], name='eth0'),
  5880. Interface(device=devices[0], name='eth1'), # Not connected
  5881. )
  5882. Interface.objects.bulk_create(interfaces)
  5883. cable = Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[1]])
  5884. cable.save()
  5885. def test_get_connected_device(self):
  5886. self.add_permissions('dcim.view_device', 'dcim.view_interface')
  5887. url = reverse('dcim-api:connected-device-list')
  5888. url_params = '?peer_device=TestDevice1&peer_interface=eth0'
  5889. response = self.client.get(url + url_params, **self.header)
  5890. self.assertHttpStatus(response, status.HTTP_200_OK)
  5891. self.assertEqual(response.data['name'], 'TestDevice2')
  5892. url_params = '?peer_device=TestDevice1&peer_interface=eth1'
  5893. response = self.client.get(url + url_params, **self.header)
  5894. self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
  5895. class VirtualChassisTestCase(APIViewTestCases.APIViewTestCase):
  5896. model = VirtualChassis
  5897. brief_fields = ['description', 'display', 'id', 'master', 'member_count', 'name', 'url']
  5898. @classmethod
  5899. def setUpTestData(cls):
  5900. site = Site.objects.create(name='Test Site', slug='test-site')
  5901. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  5902. devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type', slug='device-type')
  5903. role = DeviceRole.objects.create(name='Device Role', slug='device-role', color='ff0000')
  5904. devices = (
  5905. Device(name='Device 1', device_type=devicetype, role=role, site=site),
  5906. Device(name='Device 2', device_type=devicetype, role=role, site=site),
  5907. Device(name='Device 3', device_type=devicetype, role=role, site=site),
  5908. Device(name='Device 4', device_type=devicetype, role=role, site=site),
  5909. Device(name='Device 5', device_type=devicetype, role=role, site=site),
  5910. Device(name='Device 6', device_type=devicetype, role=role, site=site),
  5911. Device(name='Device 7', device_type=devicetype, role=role, site=site),
  5912. Device(name='Device 8', device_type=devicetype, role=role, site=site),
  5913. Device(name='Device 9', device_type=devicetype, role=role, site=site),
  5914. Device(name='Device 10', device_type=devicetype, role=role, site=site),
  5915. Device(name='Device 11', device_type=devicetype, role=role, site=site),
  5916. Device(name='Device 12', device_type=devicetype, role=role, site=site),
  5917. )
  5918. Device.objects.bulk_create(devices)
  5919. # Create 12 interfaces per device
  5920. interfaces = []
  5921. for i, device in enumerate(devices):
  5922. for j in range(0, 13):
  5923. interfaces.append(
  5924. # Interface name starts with parent device's position in VC; e.g. 1/1, 1/2, 1/3...
  5925. Interface(device=device, name=f'{i % 3 + 1}/{j}', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
  5926. )
  5927. Interface.objects.bulk_create(interfaces)
  5928. # Create three VirtualChassis with three members each
  5929. virtual_chassis = (
  5930. VirtualChassis(name='Virtual Chassis 1', master=devices[0], domain='domain-1'),
  5931. VirtualChassis(name='Virtual Chassis 2', master=devices[3], domain='domain-2'),
  5932. VirtualChassis(name='Virtual Chassis 3', master=devices[6], domain='domain-3'),
  5933. )
  5934. VirtualChassis.objects.bulk_create(virtual_chassis)
  5935. Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis[0], vc_position=1)
  5936. Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis[0], vc_position=2)
  5937. Device.objects.filter(pk=devices[2].pk).update(virtual_chassis=virtual_chassis[0], vc_position=3)
  5938. Device.objects.filter(pk=devices[3].pk).update(virtual_chassis=virtual_chassis[1], vc_position=1)
  5939. Device.objects.filter(pk=devices[4].pk).update(virtual_chassis=virtual_chassis[1], vc_position=2)
  5940. Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=virtual_chassis[1], vc_position=3)
  5941. Device.objects.filter(pk=devices[6].pk).update(virtual_chassis=virtual_chassis[2], vc_position=1)
  5942. Device.objects.filter(pk=devices[7].pk).update(virtual_chassis=virtual_chassis[2], vc_position=2)
  5943. Device.objects.filter(pk=devices[8].pk).update(virtual_chassis=virtual_chassis[2], vc_position=3)
  5944. cls.update_data = {
  5945. 'name': 'Virtual Chassis X',
  5946. 'domain': 'domain-x',
  5947. 'master': devices[1].pk,
  5948. }
  5949. cls.create_data = [
  5950. {
  5951. 'name': 'Virtual Chassis 4',
  5952. 'domain': 'domain-4',
  5953. },
  5954. {
  5955. 'name': 'Virtual Chassis 5',
  5956. 'domain': 'domain-5',
  5957. },
  5958. {
  5959. 'name': 'Virtual Chassis 6',
  5960. 'domain': 'domain-6',
  5961. },
  5962. ]
  5963. cls.bulk_update_data = {
  5964. 'domain': 'newdomain',
  5965. 'master': None
  5966. }
  5967. cls.bulk_update_invalid_data = {
  5968. 'owner': 99999,
  5969. }
  5970. @tag('regression') # Ref: #23212
  5971. def test_remove_member_blocked_by_cross_chassis_lag(self):
  5972. self.add_permissions('dcim.change_device')
  5973. master = Device.objects.get(name='Device 1')
  5974. member = Device.objects.get(name='Device 2')
  5975. Interface.objects.filter(device=member, name='2/1').update(
  5976. lag=Interface.objects.create(device=master, name='lag0', type=InterfaceTypeChoices.TYPE_LAG)
  5977. )
  5978. url = reverse('dcim-api:device-detail', kwargs={'pk': member.pk})
  5979. data = {'virtual_chassis': None, 'vc_position': None}
  5980. response = self.client.patch(url, data, format='json', **self.header)
  5981. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  5982. self.assertIn('virtual_chassis', response.data)
  5983. member.refresh_from_db()
  5984. self.assertEqual(member.virtual_chassis.name, 'Virtual Chassis 1')
  5985. @tag('regression') # Ref: #23486
  5986. def test_create_with_master_records_prechange_data(self):
  5987. self.add_permissions('dcim.add_virtualchassis')
  5988. master = Device.objects.get(name='Device 10')
  5989. data = {'name': 'Virtual Chassis 4', 'master': master.pk}
  5990. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  5991. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  5992. master.refresh_from_db()
  5993. self.assertEqual(master.virtual_chassis.pk, response.data['id'])
  5994. self.assertEqual(master.vc_position, 1)
  5995. change = ObjectChange.objects.get(
  5996. changed_object_type=ContentType.objects.get_for_model(Device),
  5997. changed_object_id=master.pk,
  5998. )
  5999. self.assertEqual(change.action, ObjectChangeActionChoices.ACTION_UPDATE)
  6000. self.assertIsNone(change.prechange_data['virtual_chassis'])
  6001. self.assertIsNone(change.prechange_data['vc_position'])
  6002. self.assertEqual(change.postchange_data['virtual_chassis'], response.data['id'])
  6003. self.assertEqual(change.postchange_data['vc_position'], 1)
  6004. class PowerPanelTestCase(APIViewTestCases.APIViewTestCase):
  6005. model = PowerPanel
  6006. brief_fields = ['description', 'display', 'id', 'name', 'powerfeed_count', 'url']
  6007. user_permissions = ('dcim.view_site', )
  6008. @classmethod
  6009. def setUpTestData(cls):
  6010. sites = (
  6011. Site.objects.create(name='Site 1', slug='site-1'),
  6012. Site.objects.create(name='Site 2', slug='site-2'),
  6013. )
  6014. locations = (
  6015. Location.objects.create(name='Location 1', slug='location-1', site=sites[0]),
  6016. Location.objects.create(name='Location 2', slug='location-2', site=sites[0]),
  6017. Location.objects.create(name='Location 3', slug='location-3', site=sites[0]),
  6018. Location.objects.create(name='Location 4', slug='location-3', site=sites[1]),
  6019. )
  6020. power_panels = (
  6021. PowerPanel(site=sites[0], location=locations[0], name='Power Panel 1'),
  6022. PowerPanel(site=sites[0], location=locations[1], name='Power Panel 2'),
  6023. PowerPanel(site=sites[0], location=locations[2], name='Power Panel 3'),
  6024. )
  6025. PowerPanel.objects.bulk_create(power_panels)
  6026. cls.create_data = [
  6027. {
  6028. 'name': 'Power Panel 4',
  6029. 'site': sites[0].pk,
  6030. 'location': locations[0].pk,
  6031. },
  6032. {
  6033. 'name': 'Power Panel 5',
  6034. 'site': sites[0].pk,
  6035. 'location': locations[1].pk,
  6036. },
  6037. {
  6038. 'name': 'Power Panel 6',
  6039. 'site': sites[0].pk,
  6040. 'location': locations[2].pk,
  6041. },
  6042. ]
  6043. cls.bulk_update_data = {
  6044. 'site': sites[1].pk,
  6045. 'location': locations[3].pk
  6046. }
  6047. cls.bulk_update_invalid_data = {
  6048. 'owner': 99999,
  6049. }
  6050. class PowerFeedTestCase(Mixins.LinkPeersMixin, APIViewTestCases.APIViewTestCase):
  6051. model = PowerFeed
  6052. brief_fields = ['_occupied', 'cable', 'description', 'display', 'id', 'name', 'url']
  6053. bulk_update_data = {
  6054. 'status': 'planned',
  6055. }
  6056. bulk_update_invalid_data = {
  6057. 'status': 'not-a-valid-status',
  6058. }
  6059. peer_termination_type = PowerPort
  6060. user_permissions = ('dcim.view_powerpanel', )
  6061. @classmethod
  6062. def setUpTestData(cls):
  6063. site = Site.objects.create(name='Site 1', slug='site-1')
  6064. location = Location.objects.create(site=site, name='Location 1', slug='location-1')
  6065. rackrole = RackRole.objects.create(name='Rack Role 1', slug='rack-role-1', color='ff0000')
  6066. racks = (
  6067. Rack(site=site, location=location, role=rackrole, name='Rack 1'),
  6068. Rack(site=site, location=location, role=rackrole, name='Rack 2'),
  6069. Rack(site=site, location=location, role=rackrole, name='Rack 3'),
  6070. Rack(site=site, location=location, role=rackrole, name='Rack 4'),
  6071. )
  6072. Rack.objects.bulk_create(racks)
  6073. power_panels = (
  6074. PowerPanel(site=site, location=location, name='Power Panel 1'),
  6075. PowerPanel(site=site, location=location, name='Power Panel 2'),
  6076. )
  6077. PowerPanel.objects.bulk_create(power_panels)
  6078. PRIMARY = PowerFeedTypeChoices.TYPE_PRIMARY
  6079. REDUNDANT = PowerFeedTypeChoices.TYPE_REDUNDANT
  6080. power_feeds = (
  6081. PowerFeed(power_panel=power_panels[0], rack=racks[0], name='Power Feed 1A', type=PRIMARY),
  6082. PowerFeed(power_panel=power_panels[1], rack=racks[0], name='Power Feed 1B', type=REDUNDANT),
  6083. PowerFeed(power_panel=power_panels[0], rack=racks[1], name='Power Feed 2A', type=PRIMARY),
  6084. PowerFeed(power_panel=power_panels[1], rack=racks[1], name='Power Feed 2B', type=REDUNDANT),
  6085. PowerFeed(power_panel=power_panels[0], rack=racks[2], name='Power Feed 3A', type=PRIMARY),
  6086. PowerFeed(power_panel=power_panels[1], rack=racks[2], name='Power Feed 3B', type=REDUNDANT),
  6087. )
  6088. PowerFeed.objects.bulk_create(power_feeds)
  6089. cls.create_data = [
  6090. {
  6091. 'name': 'Power Feed 4A',
  6092. 'power_panel': power_panels[0].pk,
  6093. 'rack': racks[3].pk,
  6094. 'type': PRIMARY,
  6095. },
  6096. {
  6097. 'name': 'Power Feed 4B',
  6098. 'power_panel': power_panels[1].pk,
  6099. 'rack': racks[3].pk,
  6100. 'type': REDUNDANT,
  6101. },
  6102. ]
  6103. class CoolingIntakeTemplateTestCase(APIViewTestCases.APIViewTestCase):
  6104. model = CoolingIntakeTemplate
  6105. brief_fields = ['description', 'display', 'id', 'name', 'url']
  6106. bulk_update_data = {
  6107. 'description': 'New description',
  6108. }
  6109. bulk_update_invalid_data = {
  6110. 'type': 'not-a-valid-type',
  6111. }
  6112. @classmethod
  6113. def setUpTestData(cls):
  6114. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  6115. devicetype = DeviceType.objects.create(
  6116. manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
  6117. )
  6118. moduletype = ModuleType.objects.create(
  6119. manufacturer=manufacturer, model='Module Type 1'
  6120. )
  6121. cooling_intake_templates = (
  6122. CoolingIntakeTemplate(device_type=devicetype, name='Cooling Port Template 1'),
  6123. CoolingIntakeTemplate(device_type=devicetype, name='Cooling Port Template 2'),
  6124. CoolingIntakeTemplate(device_type=devicetype, name='Cooling Port Template 3'),
  6125. )
  6126. CoolingIntakeTemplate.objects.bulk_create(cooling_intake_templates)
  6127. cls.create_data = [
  6128. {
  6129. 'device_type': devicetype.pk,
  6130. 'name': 'Cooling Port Template 4',
  6131. 'type': CoolingConnectorTypeChoices.TYPE_UQD,
  6132. },
  6133. {
  6134. 'device_type': devicetype.pk,
  6135. 'name': 'Cooling Port Template 5',
  6136. },
  6137. {
  6138. 'module_type': moduletype.pk,
  6139. 'name': 'Cooling Port Template 6',
  6140. },
  6141. {
  6142. 'module_type': moduletype.pk,
  6143. 'name': 'Cooling Port Template 7',
  6144. },
  6145. ]
  6146. class CoolingOutflowTemplateTestCase(APIViewTestCases.APIViewTestCase):
  6147. model = CoolingOutflowTemplate
  6148. brief_fields = ['description', 'display', 'id', 'name', 'url']
  6149. bulk_update_data = {
  6150. 'description': 'New description',
  6151. }
  6152. bulk_update_invalid_data = {
  6153. 'type': 'not-a-valid-type',
  6154. }
  6155. user_permissions = ('dcim.view_devicetype', )
  6156. @classmethod
  6157. def setUpTestData(cls):
  6158. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  6159. devicetype = DeviceType.objects.create(
  6160. manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
  6161. )
  6162. moduletype = ModuleType.objects.create(
  6163. manufacturer=manufacturer, model='Module Type 1'
  6164. )
  6165. cooling_intake_templates = (
  6166. CoolingIntakeTemplate(device_type=devicetype, name='Cooling Port Template 1'),
  6167. CoolingIntakeTemplate(device_type=devicetype, name='Cooling Port Template 2'),
  6168. )
  6169. CoolingIntakeTemplate.objects.bulk_create(cooling_intake_templates)
  6170. cooling_outflow_templates = (
  6171. CoolingOutflowTemplate(device_type=devicetype, name='Cooling Outlet Template 1'),
  6172. CoolingOutflowTemplate(device_type=devicetype, name='Cooling Outlet Template 2'),
  6173. CoolingOutflowTemplate(device_type=devicetype, name='Cooling Outlet Template 3'),
  6174. )
  6175. CoolingOutflowTemplate.objects.bulk_create(cooling_outflow_templates)
  6176. cls.create_data = [
  6177. {
  6178. 'device_type': devicetype.pk,
  6179. 'name': 'Cooling Outlet Template 4',
  6180. 'type': CoolingConnectorTypeChoices.TYPE_UQD,
  6181. 'cooling_intake': cooling_intake_templates[0].pk,
  6182. },
  6183. {
  6184. 'device_type': devicetype.pk,
  6185. 'name': 'Cooling Outlet Template 5',
  6186. 'cooling_intake': cooling_intake_templates[1].pk,
  6187. },
  6188. {
  6189. 'device_type': devicetype.pk,
  6190. 'name': 'Cooling Outlet Template 6',
  6191. 'cooling_intake': None,
  6192. },
  6193. {
  6194. 'module_type': moduletype.pk,
  6195. 'name': 'Cooling Outlet Template 7',
  6196. },
  6197. {
  6198. 'module_type': moduletype.pk,
  6199. 'name': 'Cooling Outlet Template 8',
  6200. },
  6201. ]
  6202. class CoolingIntakeTestCase(APIViewTestCases.APIViewTestCase):
  6203. model = CoolingIntake
  6204. brief_fields = ['description', 'device', 'display', 'id', 'name', 'url']
  6205. bulk_update_data = {
  6206. 'description': 'New description',
  6207. }
  6208. bulk_update_invalid_data = {
  6209. 'type': 'not-a-valid-type',
  6210. }
  6211. user_permissions = ('dcim.view_device', )
  6212. @classmethod
  6213. def setUpTestData(cls):
  6214. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  6215. devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  6216. site = Site.objects.create(name='Site 1', slug='site-1')
  6217. role = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
  6218. device = Device.objects.create(device_type=devicetype, role=role, name='Device 1', site=site)
  6219. cooling_outflow = CoolingOutflow.objects.create(device=device, name='Cooling Outlet 1')
  6220. cooling_intakes = (
  6221. CoolingIntake(device=device, name='Cooling Port 1'),
  6222. CoolingIntake(device=device, name='Cooling Port 2'),
  6223. CoolingIntake(device=device, name='Cooling Port 3'),
  6224. )
  6225. CoolingIntake.objects.bulk_create(cooling_intakes)
  6226. cls.create_data = [
  6227. {
  6228. 'device': device.pk,
  6229. 'name': 'Cooling Port 4',
  6230. 'type': CoolingConnectorTypeChoices.TYPE_UQD,
  6231. 'cooling_outflow': cooling_outflow.pk,
  6232. },
  6233. {
  6234. 'device': device.pk,
  6235. 'name': 'Cooling Port 5',
  6236. 'type': CoolingConnectorTypeChoices.TYPE_QDC,
  6237. },
  6238. {
  6239. 'device': device.pk,
  6240. 'name': 'Cooling Port 6',
  6241. },
  6242. ]
  6243. class CoolingOutflowTestCase(APIViewTestCases.APIViewTestCase):
  6244. model = CoolingOutflow
  6245. brief_fields = ['description', 'device', 'display', 'id', 'name', 'url']
  6246. bulk_update_data = {
  6247. 'description': 'New description',
  6248. }
  6249. bulk_update_invalid_data = {
  6250. 'type': 'not-a-valid-type',
  6251. }
  6252. user_permissions = ('dcim.view_device', )
  6253. @classmethod
  6254. def setUpTestData(cls):
  6255. manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
  6256. devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  6257. site = Site.objects.create(name='Site 1', slug='site-1')
  6258. role = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
  6259. device = Device.objects.create(device_type=devicetype, role=role, name='Device 1', site=site)
  6260. cooling_intakes = (
  6261. CoolingIntake(device=device, name='Cooling Port 1'),
  6262. CoolingIntake(device=device, name='Cooling Port 2'),
  6263. )
  6264. CoolingIntake.objects.bulk_create(cooling_intakes)
  6265. cooling_outflows = (
  6266. CoolingOutflow(device=device, name='Cooling Outlet 1'),
  6267. CoolingOutflow(device=device, name='Cooling Outlet 2'),
  6268. CoolingOutflow(device=device, name='Cooling Outlet 3'),
  6269. )
  6270. CoolingOutflow.objects.bulk_create(cooling_outflows)
  6271. cls.create_data = [
  6272. {
  6273. 'device': device.pk,
  6274. 'name': 'Cooling Outlet 4',
  6275. 'type': CoolingConnectorTypeChoices.TYPE_UQD,
  6276. 'cooling_intake': cooling_intakes[0].pk,
  6277. },
  6278. {
  6279. 'device': device.pk,
  6280. 'name': 'Cooling Outlet 5',
  6281. 'cooling_intake': cooling_intakes[1].pk,
  6282. },
  6283. {
  6284. 'device': device.pk,
  6285. 'name': 'Cooling Outlet 6',
  6286. 'cooling_intake': None,
  6287. },
  6288. ]
  6289. class CoolingSourceTestCase(APIViewTestCases.APIViewTestCase):
  6290. model = CoolingSource
  6291. brief_fields = ['coolingfeed_count', 'description', 'display', 'id', 'name', 'url']
  6292. user_permissions = ('dcim.view_site', )
  6293. @classmethod
  6294. def setUpTestData(cls):
  6295. sites = (
  6296. Site.objects.create(name='Site 1', slug='site-1'),
  6297. Site.objects.create(name='Site 2', slug='site-2'),
  6298. )
  6299. locations = (
  6300. Location.objects.create(name='Location 1', slug='location-1', site=sites[0]),
  6301. Location.objects.create(name='Location 2', slug='location-2', site=sites[0]),
  6302. Location.objects.create(name='Location 3', slug='location-3', site=sites[0]),
  6303. Location.objects.create(name='Location 4', slug='location-3', site=sites[1]),
  6304. )
  6305. cooling_sources = (
  6306. CoolingSource(
  6307. site=sites[0], location=locations[0], name='Cooling Source 1',
  6308. type=CoolingSourceTypeChoices.TYPE_CHILLER
  6309. ),
  6310. CoolingSource(
  6311. site=sites[0], location=locations[1], name='Cooling Source 2',
  6312. type=CoolingSourceTypeChoices.TYPE_COOLING_TOWER
  6313. ),
  6314. CoolingSource(
  6315. site=sites[0], location=locations[2], name='Cooling Source 3',
  6316. type=CoolingSourceTypeChoices.TYPE_DRY_COOLER
  6317. ),
  6318. )
  6319. CoolingSource.objects.bulk_create(cooling_sources)
  6320. cls.create_data = [
  6321. {
  6322. 'name': 'Cooling Source 4',
  6323. 'site': sites[0].pk,
  6324. 'location': locations[0].pk,
  6325. 'type': CoolingSourceTypeChoices.TYPE_CHILLER,
  6326. },
  6327. {
  6328. 'name': 'Cooling Source 5',
  6329. 'site': sites[0].pk,
  6330. 'location': locations[1].pk,
  6331. 'type': CoolingSourceTypeChoices.TYPE_CHILLER,
  6332. },
  6333. {
  6334. 'name': 'Cooling Source 6',
  6335. 'site': sites[0].pk,
  6336. 'location': locations[2].pk,
  6337. 'type': CoolingSourceTypeChoices.TYPE_CHILLER,
  6338. },
  6339. ]
  6340. cls.bulk_update_data = {
  6341. 'site': sites[1].pk,
  6342. 'location': locations[3].pk
  6343. }
  6344. cls.bulk_update_invalid_data = {
  6345. 'status': 'not-a-valid-status',
  6346. }
  6347. class CoolingFeedTestCase(APIViewTestCases.APIViewTestCase):
  6348. model = CoolingFeed
  6349. brief_fields = ['description', 'display', 'id', 'name', 'url']
  6350. bulk_update_data = {
  6351. 'status': 'planned',
  6352. }
  6353. bulk_update_invalid_data = {
  6354. 'status': 'not-a-valid-status',
  6355. }
  6356. user_permissions = ('dcim.view_coolingsource', )
  6357. @classmethod
  6358. def setUpTestData(cls):
  6359. site = Site.objects.create(name='Site 1', slug='site-1')
  6360. location = Location.objects.create(site=site, name='Location 1', slug='location-1')
  6361. rackrole = RackRole.objects.create(name='Rack Role 1', slug='rack-role-1', color='ff0000')
  6362. racks = (
  6363. Rack(site=site, location=location, role=rackrole, name='Rack 1'),
  6364. Rack(site=site, location=location, role=rackrole, name='Rack 2'),
  6365. Rack(site=site, location=location, role=rackrole, name='Rack 3'),
  6366. Rack(site=site, location=location, role=rackrole, name='Rack 4'),
  6367. )
  6368. Rack.objects.bulk_create(racks)
  6369. cooling_sources = (
  6370. CoolingSource(
  6371. site=site, location=location, name='Cooling Source 1', type=CoolingSourceTypeChoices.TYPE_CHILLER
  6372. ),
  6373. CoolingSource(
  6374. site=site, location=location, name='Cooling Source 2', type=CoolingSourceTypeChoices.TYPE_CHILLER
  6375. ),
  6376. )
  6377. CoolingSource.objects.bulk_create(cooling_sources)
  6378. cooling_feeds = (
  6379. CoolingFeed(cooling_source=cooling_sources[0], rack=racks[0], name='Cooling Feed 1A'),
  6380. CoolingFeed(cooling_source=cooling_sources[1], rack=racks[0], name='Cooling Feed 1B'),
  6381. CoolingFeed(cooling_source=cooling_sources[0], rack=racks[1], name='Cooling Feed 2A'),
  6382. CoolingFeed(cooling_source=cooling_sources[1], rack=racks[1], name='Cooling Feed 2B'),
  6383. CoolingFeed(cooling_source=cooling_sources[0], rack=racks[2], name='Cooling Feed 3A'),
  6384. CoolingFeed(cooling_source=cooling_sources[1], rack=racks[2], name='Cooling Feed 3B'),
  6385. )
  6386. CoolingFeed.objects.bulk_create(cooling_feeds)
  6387. cls.create_data = [
  6388. {
  6389. 'name': 'Cooling Feed 4A',
  6390. 'cooling_source': cooling_sources[0].pk,
  6391. 'rack': racks[3].pk,
  6392. },
  6393. {
  6394. 'name': 'Cooling Feed 4B',
  6395. 'cooling_source': cooling_sources[1].pk,
  6396. 'rack': racks[3].pk,
  6397. },
  6398. {
  6399. 'name': 'Cooling Feed 4C',
  6400. 'cooling_source': cooling_sources[0].pk,
  6401. 'rack': racks[3].pk,
  6402. },
  6403. ]
  6404. class VirtualDeviceContextTestCase(APIViewTestCases.APIViewTestCase):
  6405. model = VirtualDeviceContext
  6406. brief_fields = ['description', 'device', 'display', 'id', 'identifier', 'name', 'url']
  6407. bulk_update_data = {
  6408. 'status': 'planned',
  6409. }
  6410. bulk_update_invalid_data = {
  6411. 'status': 'not-a-valid-status',
  6412. }
  6413. @classmethod
  6414. def setUpTestData(cls):
  6415. site = Site.objects.create(name='Test Site', slug='test-site')
  6416. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  6417. devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type', slug='device-type')
  6418. role = DeviceRole.objects.create(name='Device Role', slug='device-role', color='ff0000')
  6419. devices = (
  6420. Device(name='Device 1', device_type=devicetype, role=role, site=site),
  6421. Device(name='Device 2', device_type=devicetype, role=role, site=site),
  6422. Device(name='Device 3', device_type=devicetype, role=role, site=site),
  6423. )
  6424. Device.objects.bulk_create(devices)
  6425. vdcs = (
  6426. VirtualDeviceContext(device=devices[1], name='VDC 1', identifier=1, status='active'),
  6427. VirtualDeviceContext(device=devices[1], name='VDC 2', identifier=2, status='active'),
  6428. VirtualDeviceContext(device=devices[2], name='VDC 1', identifier=1, status='active'),
  6429. VirtualDeviceContext(device=devices[2], name='VDC 2', identifier=2, status='active'),
  6430. VirtualDeviceContext(device=devices[2], name='VDC 3', identifier=3, status='active'),
  6431. VirtualDeviceContext(device=devices[2], name='VDC 4', identifier=4, status='active'),
  6432. VirtualDeviceContext(device=devices[2], name='VDC 5', identifier=5, status='active'),
  6433. )
  6434. VirtualDeviceContext.objects.bulk_create(vdcs)
  6435. cls.create_data = [
  6436. {
  6437. 'device': devices[0].pk,
  6438. 'status': 'active',
  6439. 'name': 'VDC 1',
  6440. 'identifier': 1,
  6441. },
  6442. {
  6443. 'device': devices[0].pk,
  6444. 'status': 'active',
  6445. 'name': 'VDC 2',
  6446. 'identifier': 2,
  6447. },
  6448. {
  6449. 'device': devices[1].pk,
  6450. 'status': 'active',
  6451. 'name': 'VDC 3',
  6452. # Omit identifier to test uniqueness constraint
  6453. },
  6454. ]
  6455. def test_get_object_includes_nat_on_primary_ip(self):
  6456. device = create_test_device('vdc-nat-device')
  6457. interfaces = (
  6458. Interface.objects.create(device=device, name='eth0', type='other'),
  6459. Interface.objects.create(device=device, name='eth1', type='other'),
  6460. )
  6461. real_ip4, nat_ip4 = create_test_nat_ip_pair(
  6462. real_address='10.0.2.10/32',
  6463. nat_address='198.51.100.30/32',
  6464. inside_interface=interfaces[0],
  6465. )
  6466. real_ip6, nat_ip6 = create_test_nat_ip_pair(
  6467. real_address='2001:db8:2::10/128',
  6468. nat_address='2001:db8:2::20/128',
  6469. inside_interface=interfaces[1],
  6470. )
  6471. vdc = VirtualDeviceContext.objects.create(
  6472. name='VDC NAT', identifier=98, device=device, status='active'
  6473. )
  6474. vdc.primary_ip4 = nat_ip4
  6475. vdc.primary_ip6 = real_ip6
  6476. vdc.save()
  6477. self.add_permissions('dcim.view_virtualdevicecontext', 'ipam.view_ipaddress')
  6478. response = self.client.get(self._get_detail_url(vdc), **self.header)
  6479. self.assertHttpStatus(response, status.HTTP_200_OK)
  6480. self.assertEqual(response.data['primary_ip4']['nat_inside']['address'], str(real_ip4.address))
  6481. self.assertEqual(response.data['primary_ip4']['nat_outside'], [])
  6482. self.assertIsNone(response.data['primary_ip6']['nat_inside'])
  6483. self.assertCountEqual(
  6484. [ip['address'] for ip in response.data['primary_ip6']['nat_outside']],
  6485. [str(nat_ip6.address)],
  6486. )
  6487. def test_get_object_includes_dns_name_on_primary_ip(self):
  6488. device = create_test_device('vdc-dns-device')
  6489. interfaces = (
  6490. Interface.objects.create(device=device, name='eth0', type='other'),
  6491. Interface.objects.create(device=device, name='eth1', type='other'),
  6492. )
  6493. ip4 = IPAddress(address='192.0.2.30/32', dns_name='vdc4.example.com')
  6494. ip4.assigned_object = interfaces[0]
  6495. ip4.save()
  6496. ip6 = IPAddress(address='2001:db8::30/128', dns_name='vdc6.example.com')
  6497. ip6.assigned_object = interfaces[1]
  6498. ip6.save()
  6499. vdc = VirtualDeviceContext.objects.create(
  6500. name='VDC DNS', identifier=99, device=device, status='active'
  6501. )
  6502. vdc.primary_ip4 = ip4
  6503. vdc.primary_ip6 = ip6
  6504. vdc.save()
  6505. self.add_permissions('dcim.view_virtualdevicecontext', 'ipam.view_ipaddress')
  6506. response = self.client.get(self._get_detail_url(vdc), **self.header)
  6507. self.assertHttpStatus(response, status.HTTP_200_OK)
  6508. self.assertEqual(response.data['primary_ip4']['dns_name'], 'vdc4.example.com')
  6509. self.assertEqual(response.data['primary_ip6']['dns_name'], 'vdc6.example.com')
  6510. self.assertIn(
  6511. response.data['primary_ip']['dns_name'],
  6512. ('vdc4.example.com', 'vdc6.example.com'),
  6513. )
  6514. class MACAddressTestCase(APIViewTestCases.APIViewTestCase):
  6515. model = MACAddress
  6516. brief_fields = ['description', 'display', 'id', 'mac_address', 'url']
  6517. bulk_update_data = {
  6518. 'description': 'New description',
  6519. }
  6520. bulk_update_invalid_data = {
  6521. 'owner': 99999,
  6522. }
  6523. @classmethod
  6524. def setUpTestData(cls):
  6525. device = create_test_device(name='Device 1')
  6526. interfaces = (
  6527. Interface(device=device, name='Interface 1', type='1000base-t'),
  6528. Interface(device=device, name='Interface 2', type='1000base-t'),
  6529. Interface(device=device, name='Interface 3', type='1000base-t'),
  6530. Interface(device=device, name='Interface 4', type='1000base-t'),
  6531. Interface(device=device, name='Interface 5', type='1000base-t'),
  6532. )
  6533. Interface.objects.bulk_create(interfaces)
  6534. mac_addresses = (
  6535. MACAddress(mac_address='00:00:00:00:00:01', assigned_object=interfaces[0]),
  6536. MACAddress(mac_address='00:00:00:00:00:02', assigned_object=interfaces[1]),
  6537. MACAddress(mac_address='00:00:00:00:00:03', assigned_object=interfaces[2]),
  6538. )
  6539. MACAddress.objects.bulk_create(mac_addresses)
  6540. cls.create_data = [
  6541. {
  6542. 'mac_address': '00:00:00:00:00:04',
  6543. 'assigned_object_type': 'dcim.interface',
  6544. 'assigned_object_id': interfaces[3].pk,
  6545. },
  6546. {
  6547. 'mac_address': '00:00:00:00:00:05',
  6548. 'assigned_object_type': 'dcim.interface',
  6549. 'assigned_object_id': interfaces[4].pk,
  6550. },
  6551. {
  6552. 'mac_address': '00:00:00:00:00:06',
  6553. },
  6554. ]
  6555. def test_is_primary_field(self):
  6556. """
  6557. The read-only is_primary field should reflect whether the MAC address is the primary on its interface.
  6558. """
  6559. self.add_permissions('dcim.view_macaddress')
  6560. primary_mac = MACAddress.objects.get(mac_address='00:00:00:00:00:01')
  6561. non_primary_mac = MACAddress.objects.get(mac_address='00:00:00:00:00:02')
  6562. # Designate one MAC address as the primary on its interface
  6563. interface = primary_mac.assigned_object
  6564. interface.primary_mac_address = primary_mac
  6565. interface.save()
  6566. url = reverse('dcim-api:macaddress-detail', kwargs={'pk': primary_mac.pk})
  6567. response = self.client.get(url, **self.header)
  6568. self.assertTrue(response.data['is_primary'])
  6569. url = reverse('dcim-api:macaddress-detail', kwargs={'pk': non_primary_mac.pk})
  6570. response = self.client.get(url, **self.header)
  6571. self.assertFalse(response.data['is_primary'])
  6572. class MACPrefixTestCase(APIViewTestCases.APIViewTestCase):
  6573. model = MACPrefix
  6574. brief_fields = ['description', 'display', 'id', 'name', 'prefix', 'prefix_length', 'url']
  6575. bulk_update_data = {
  6576. 'description': 'New description',
  6577. }
  6578. bulk_update_invalid_data = {
  6579. 'prefix_length': 99,
  6580. }
  6581. @classmethod
  6582. def setUpTestData(cls):
  6583. MACPrefix.objects.create(prefix='BC:24:11:00:00:00', prefix_length=24, name='Prefix 1')
  6584. MACPrefix.objects.create(prefix='BC:24:12:00:00:00', prefix_length=24, name='Prefix 2')
  6585. MACPrefix.objects.create(prefix='BC:24:13:00:00:00', prefix_length=24, name='Prefix 3')
  6586. cls.create_data = [
  6587. {
  6588. 'prefix': 'BC:24:14:00:00:00',
  6589. 'prefix_length': 24,
  6590. 'name': 'Prefix 4',
  6591. },
  6592. {
  6593. 'prefix': 'BC:24:14:50:00:00',
  6594. 'prefix_length': 28,
  6595. 'enforce_unique': True,
  6596. },
  6597. {
  6598. 'prefix': 'BC:24:15:00:00:00',
  6599. },
  6600. ]
  6601. def test_read_only_fields(self):
  6602. self.add_permissions('dcim.view_macprefix')
  6603. mac_prefix = MACPrefix.objects.create(prefix='BC:24:16:00:00:00', prefix_length=46)
  6604. MACAddress.objects.bulk_create((
  6605. MACAddress(mac_address='BC:24:16:00:00:01'),
  6606. MACAddress(mac_address='BC:24:16:00:00:01'),
  6607. MACAddress(mac_address='BC:24:16:00:00:02'),
  6608. ))
  6609. response = self.client.get(self._get_detail_url(mac_prefix), **self.header)
  6610. self.assertHttpStatus(response, status.HTTP_200_OK)
  6611. self.assertEqual(response.data['last_address'], 'BC:24:16:00:00:03')
  6612. self.assertEqual(response.data['size'], 4)
  6613. self.assertEqual(response.data['mac_address_count'], 2)
  6614. self.assertEqual(response.data['utilization'], 50)
  6615. def test_create_normalizes_prefix(self):
  6616. self.add_permissions('dcim.add_macprefix')
  6617. data = {'prefix': 'BC:24:16:AA:BB:CC', 'prefix_length': 24}
  6618. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  6619. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  6620. self.assertEqual(response.data['prefix'], 'BC:24:16:00:00:00')
  6621. # A prefix whose host bits differ from an existing prefix is a duplicate
  6622. data = {'prefix': 'BC:24:11:AA:BB:CC', 'prefix_length': 24}
  6623. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  6624. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  6625. def test_list_available_mac_addresses(self):
  6626. mac_prefix = MACPrefix.objects.create(prefix='BC:24:16:00:00:00', prefix_length=45)
  6627. MACAddress.objects.create(mac_address='BC:24:16:00:00:01')
  6628. url = reverse('dcim-api:macprefix-available-mac-addresses', kwargs={'pk': mac_prefix.pk})
  6629. self.add_permissions('dcim.view_macprefix', 'dcim.view_macaddress')
  6630. response = self.client.get(url, **self.header)
  6631. self.assertHttpStatus(response, status.HTTP_200_OK)
  6632. self.assertEqual(
  6633. [mac['mac_address'] for mac in response.data],
  6634. ['BC:24:16:00:00:00', 'BC:24:16:00:00:02', 'BC:24:16:00:00:03', 'BC:24:16:00:00:04',
  6635. 'BC:24:16:00:00:05', 'BC:24:16:00:00:06', 'BC:24:16:00:00:07']
  6636. )
  6637. # The number of results is limited
  6638. response = self.client.get(url, {'limit': 2}, **self.header)
  6639. self.assertEqual(len(response.data), 2)
  6640. # An invalid limit falls back to the default
  6641. response = self.client.get(url, {'limit': -1}, **self.header)
  6642. self.assertHttpStatus(response, status.HTTP_200_OK)
  6643. self.assertEqual(len(response.data), 7)
  6644. # A large prefix returns a single page of results
  6645. mac_prefix = MACPrefix.objects.get(name='Prefix 1')
  6646. url = reverse('dcim-api:macprefix-available-mac-addresses', kwargs={'pk': mac_prefix.pk})
  6647. response = self.client.get(url, **self.header)
  6648. self.assertEqual(len(response.data), get_config().PAGINATE_COUNT)
  6649. def test_create_single_available_mac_address(self):
  6650. mac_prefix = MACPrefix.objects.create(prefix='BC:24:16:00:00:00', prefix_length=47)
  6651. url = reverse('dcim-api:macprefix-available-mac-addresses', kwargs={'pk': mac_prefix.pk})
  6652. self.add_permissions('dcim.view_macprefix', 'dcim.add_macaddress')
  6653. # Create both available MAC addresses with individual requests
  6654. for i in range(2):
  6655. data = {'description': f'MAC address {i}'}
  6656. response = self.client.post(url, data, format='json', **self.header)
  6657. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  6658. self.assertEqual(response.data['mac_address'], f'BC:24:16:00:00:0{i}')
  6659. self.assertEqual(response.data['description'], data['description'])
  6660. # Try to create one more MAC address
  6661. response = self.client.post(url, {}, format='json', **self.header)
  6662. self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
  6663. self.assertIn('detail', response.data)
  6664. def test_create_multiple_available_mac_addresses(self):
  6665. mac_prefix = MACPrefix.objects.create(prefix='BC:24:16:00:00:00', prefix_length=46)
  6666. url = reverse('dcim-api:macprefix-available-mac-addresses', kwargs={'pk': mac_prefix.pk})
  6667. self.add_permissions('dcim.view_macprefix', 'dcim.add_macaddress')
  6668. # Try to create five MAC addresses (only four are available)
  6669. data = [{'description': f'MAC address {i}'} for i in range(5)]
  6670. response = self.client.post(url, data, format='json', **self.header)
  6671. self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
  6672. # Create all four available MAC addresses in a single request
  6673. response = self.client.post(url, data[:4], format='json', **self.header)
  6674. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  6675. self.assertEqual(
  6676. [mac['mac_address'] for mac in response.data],
  6677. ['BC:24:16:00:00:00', 'BC:24:16:00:00:01', 'BC:24:16:00:00:02', 'BC:24:16:00:00:03']
  6678. )