test_api.py 249 KB

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