test_api.py 255 KB

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