test_customfields.py 181 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243
  1. import datetime
  2. import json
  3. import uuid
  4. from collections import defaultdict
  5. from contextlib import contextmanager
  6. from decimal import Decimal
  7. from unittest.mock import patch
  8. import django_filters
  9. from django.core.exceptions import ValidationError
  10. from django.db import DEFAULT_DB_ALIAS, connection, connections, transaction
  11. from django.db.models import QuerySet
  12. from django.db.models.signals import pre_delete
  13. from django.test import RequestFactory, override_settings, tag
  14. from django.test.utils import CaptureQueriesContext
  15. from django.urls import reverse
  16. from rest_framework import status
  17. from core.choices import ObjectChangeActionChoices
  18. from core.models import Job, ObjectChange, ObjectType
  19. from dcim.choices import InterfaceTypeChoices
  20. from dcim.filtersets import SiteFilterSet
  21. from dcim.forms import SiteImportForm
  22. from dcim.models import Cable, Device, Interface, Manufacturer, Module, ModuleBay, ModuleType, Rack, Site
  23. from dcim.tables import SiteTable
  24. from extras.api.customfields import CustomFieldListSerializer
  25. from extras.choices import *
  26. from extras.constants import CUSTOMFIELD_JOB_TIMEOUT
  27. from extras.filters import MissingKeyAwareFilterMixin, missing_key_aware_filter_factory
  28. from extras.jobs import (
  29. CustomFieldProvisioningJob,
  30. CustomFieldPurgeJob,
  31. provision_custom_field,
  32. purge_custom_field,
  33. )
  34. from extras.models import CustomField, CustomFieldChoiceSet, ImageAttachment, JournalEntry, Tag
  35. from ipam.api.serializers import VLANSerializer
  36. from ipam.models import VLAN, Service, ServiceTemplate
  37. from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
  38. from netbox.context import query_cache
  39. from netbox.context_managers import event_tracking
  40. from netbox.tables.columns import CustomFieldColumn
  41. from netbox.tests.dummy_plugin.models import DummyNetBoxModel
  42. from users.models import Token
  43. from utilities.exceptions import AbortRequest
  44. from utilities.filters import MultiValueCharFilter, MultiValueContentTypeFilter
  45. from utilities.testing import APITestCase, TestCase, create_test_device, create_test_virtualmachine
  46. from virtualization.models import VirtualMachine, VMInterface
  47. def get_primary_table_queries(queries, model):
  48. """Return the SQL of captured queries that read from the model's table as the primary relation."""
  49. table = connection.ops.quote_name(model._meta.db_table)
  50. return [q['sql'] for q in queries if f'FROM {table}' in q['sql']]
  51. class CustomFieldTestCase(TestCase):
  52. @classmethod
  53. def setUpTestData(cls):
  54. Site.objects.bulk_create([
  55. Site(name='Site A', slug='site-a'),
  56. Site(name='Site B', slug='site-b'),
  57. Site(name='Site C', slug='site-c'),
  58. ])
  59. cls.object_type = ObjectType.objects.get_for_model(Site)
  60. def test_invalid_name(self):
  61. """
  62. Try creating a CustomField with an invalid name.
  63. """
  64. with self.assertRaises(ValidationError):
  65. # Invalid character
  66. CustomField(name='?', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean()
  67. with self.assertRaises(ValidationError):
  68. # Double underscores not permitted
  69. CustomField(name='foo__bar', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean()
  70. def test_text_field(self):
  71. value = 'Foobar!'
  72. # Create a custom field & check that no initial data is written
  73. cf = CustomField.objects.create(
  74. name='text_field',
  75. type=CustomFieldTypeChoices.TYPE_TEXT,
  76. required=False
  77. )
  78. cf.object_types.set([self.object_type])
  79. instance = Site.objects.first()
  80. self.assertNotIn(cf.name, instance.custom_field_data)
  81. # Assign a value and check that it is saved
  82. instance.custom_field_data[cf.name] = value
  83. instance.save()
  84. instance.refresh_from_db()
  85. self.assertEqual(instance.custom_field_data[cf.name], value)
  86. # Delete the stored value and check that it is now null
  87. instance.custom_field_data.pop(cf.name)
  88. instance.save()
  89. instance.refresh_from_db()
  90. self.assertIsNone(instance.custom_field_data.get(cf.name))
  91. def test_nulls_first_ordering(self):
  92. """
  93. Verify that CustomFieldColumn.order() places null values first or last according to the
  94. custom field's nulls_first attribute.
  95. """
  96. cf = CustomField.objects.create(
  97. name='order_field',
  98. type=CustomFieldTypeChoices.TYPE_INTEGER,
  99. required=False
  100. )
  101. cf.object_types.set([self.object_type])
  102. # Assign values to two of the three sites, leaving the third null
  103. site_a = Site.objects.get(name='Site A')
  104. site_a.custom_field_data[cf.name] = 1
  105. site_a.save()
  106. site_b = Site.objects.get(name='Site B')
  107. site_b.custom_field_data[cf.name] = 2
  108. site_b.save()
  109. site_c = Site.objects.get(name='Site C') # no value (null)
  110. column = CustomFieldColumn(cf)
  111. # nulls_first=True (default): null value sorts before populated values when ascending
  112. cf.nulls_first = True
  113. queryset, _ = column.order(Site.objects.all(), is_descending=False)
  114. self.assertEqual(list(queryset), [site_c, site_a, site_b])
  115. # nulls_first=False: null value sorts after populated values when ascending
  116. cf.nulls_first = False
  117. queryset, _ = column.order(Site.objects.all(), is_descending=False)
  118. self.assertEqual(list(queryset), [site_a, site_b, site_c])
  119. # Null placement is independent of sort direction: nulls_first=True keeps the null value
  120. # first even when sorting descending
  121. cf.nulls_first = True
  122. queryset, _ = column.order(Site.objects.all(), is_descending=True)
  123. self.assertEqual(list(queryset), [site_c, site_b, site_a])
  124. # nulls_first=False keeps the null value last even when sorting descending
  125. cf.nulls_first = False
  126. queryset, _ = column.order(Site.objects.all(), is_descending=True)
  127. self.assertEqual(list(queryset), [site_b, site_a, site_c])
  128. def test_longtext_field(self):
  129. value = 'A' * 256
  130. # Create a custom field & check that no initial data is written
  131. cf = CustomField.objects.create(
  132. name='longtext_field',
  133. type=CustomFieldTypeChoices.TYPE_LONGTEXT,
  134. required=False
  135. )
  136. cf.object_types.set([self.object_type])
  137. instance = Site.objects.first()
  138. self.assertNotIn(cf.name, instance.custom_field_data)
  139. # Assign a value and check that it is saved
  140. instance.custom_field_data[cf.name] = value
  141. instance.save()
  142. instance.refresh_from_db()
  143. self.assertEqual(instance.custom_field_data[cf.name], value)
  144. # Delete the stored value and check that it is now null
  145. instance.custom_field_data.pop(cf.name)
  146. instance.save()
  147. instance.refresh_from_db()
  148. self.assertIsNone(instance.custom_field_data.get(cf.name))
  149. def test_integer_field(self):
  150. # Create a custom field & check that no initial data is written
  151. cf = CustomField.objects.create(
  152. name='integer_field',
  153. type=CustomFieldTypeChoices.TYPE_INTEGER,
  154. required=False
  155. )
  156. cf.object_types.set([self.object_type])
  157. instance = Site.objects.first()
  158. self.assertNotIn(cf.name, instance.custom_field_data)
  159. for value in (123456, 0, -123456):
  160. # Assign a value and check that it is saved
  161. instance.custom_field_data[cf.name] = value
  162. instance.save()
  163. instance.refresh_from_db()
  164. self.assertEqual(instance.custom_field_data[cf.name], value)
  165. # Delete the stored value and check that it is now null
  166. instance.custom_field_data.pop(cf.name)
  167. instance.save()
  168. instance.refresh_from_db()
  169. self.assertIsNone(instance.custom_field_data.get(cf.name))
  170. def test_decimal_field(self):
  171. # Create a custom field & check that no initial data is written
  172. cf = CustomField.objects.create(
  173. name='decimal_field',
  174. type=CustomFieldTypeChoices.TYPE_DECIMAL,
  175. required=False
  176. )
  177. cf.object_types.set([self.object_type])
  178. instance = Site.objects.first()
  179. self.assertNotIn(cf.name, instance.custom_field_data)
  180. for value in (123456.54, 0, -123456.78):
  181. # Assign a value and check that it is saved
  182. instance.custom_field_data[cf.name] = value
  183. instance.save()
  184. instance.refresh_from_db()
  185. self.assertEqual(instance.custom_field_data[cf.name], value)
  186. # Delete the stored value and check that it is now null
  187. instance.custom_field_data.pop(cf.name)
  188. instance.save()
  189. instance.refresh_from_db()
  190. self.assertIsNone(instance.custom_field_data.get(cf.name))
  191. def test_boolean_field(self):
  192. # Create a custom field & check that no initial data is written
  193. cf = CustomField.objects.create(
  194. name='boolean_field',
  195. type=CustomFieldTypeChoices.TYPE_INTEGER,
  196. required=False
  197. )
  198. cf.object_types.set([self.object_type])
  199. instance = Site.objects.first()
  200. self.assertNotIn(cf.name, instance.custom_field_data)
  201. for value in (True, False):
  202. # Assign a value and check that it is saved
  203. instance.custom_field_data[cf.name] = value
  204. instance.save()
  205. instance.refresh_from_db()
  206. self.assertEqual(instance.custom_field_data[cf.name], value)
  207. # Delete the stored value and check that it is now null
  208. instance.custom_field_data.pop(cf.name)
  209. instance.save()
  210. instance.refresh_from_db()
  211. self.assertIsNone(instance.custom_field_data.get(cf.name))
  212. def test_date_field(self):
  213. value = datetime.date(2016, 6, 23)
  214. # Create a custom field & check that no initial data is written
  215. cf = CustomField.objects.create(
  216. name='date_field',
  217. type=CustomFieldTypeChoices.TYPE_DATE,
  218. required=False
  219. )
  220. cf.object_types.set([self.object_type])
  221. instance = Site.objects.first()
  222. self.assertNotIn(cf.name, instance.custom_field_data)
  223. # Assign a value and check that it is saved
  224. instance.custom_field_data[cf.name] = cf.serialize(value)
  225. instance.save()
  226. instance.refresh_from_db()
  227. self.assertEqual(instance.cf[cf.name], value)
  228. # Delete the stored value and check that it is now null
  229. instance.custom_field_data.pop(cf.name)
  230. instance.save()
  231. instance.refresh_from_db()
  232. self.assertIsNone(instance.custom_field_data.get(cf.name))
  233. def test_datetime_field(self):
  234. value = datetime.datetime(2016, 6, 23, 9, 45, 0)
  235. # Create a custom field & check that no initial data is written
  236. cf = CustomField.objects.create(
  237. name='date_field',
  238. type=CustomFieldTypeChoices.TYPE_DATETIME,
  239. required=False
  240. )
  241. cf.object_types.set([self.object_type])
  242. instance = Site.objects.first()
  243. self.assertNotIn(cf.name, instance.custom_field_data)
  244. # Assign a value and check that it is saved
  245. instance.custom_field_data[cf.name] = cf.serialize(value)
  246. instance.save()
  247. instance.refresh_from_db()
  248. self.assertEqual(instance.cf[cf.name], value)
  249. # Delete the stored value and check that it is now null
  250. instance.custom_field_data.pop(cf.name)
  251. instance.save()
  252. instance.refresh_from_db()
  253. self.assertIsNone(instance.custom_field_data.get(cf.name))
  254. def test_url_field(self):
  255. value = 'http://example.com/'
  256. # Create a custom field & check that no initial data is written
  257. cf = CustomField.objects.create(
  258. name='url_field',
  259. type=CustomFieldTypeChoices.TYPE_URL,
  260. required=False
  261. )
  262. cf.object_types.set([self.object_type])
  263. instance = Site.objects.first()
  264. self.assertNotIn(cf.name, instance.custom_field_data)
  265. # Assign a value and check that it is saved
  266. instance.custom_field_data[cf.name] = value
  267. instance.save()
  268. instance.refresh_from_db()
  269. self.assertEqual(instance.custom_field_data[cf.name], value)
  270. # Delete the stored value and check that it is now null
  271. instance.custom_field_data.pop(cf.name)
  272. instance.save()
  273. instance.refresh_from_db()
  274. self.assertIsNone(instance.custom_field_data.get(cf.name))
  275. def test_json_field(self):
  276. value = '{"foo": 1, "bar": 2}'
  277. # Create a custom field & check that no initial data is written
  278. cf = CustomField.objects.create(
  279. name='json_field',
  280. type=CustomFieldTypeChoices.TYPE_JSON,
  281. required=False
  282. )
  283. cf.object_types.set([self.object_type])
  284. instance = Site.objects.first()
  285. self.assertNotIn(cf.name, instance.custom_field_data)
  286. # Assign a value and check that it is saved
  287. instance.custom_field_data[cf.name] = value
  288. instance.save()
  289. instance.refresh_from_db()
  290. self.assertEqual(instance.custom_field_data[cf.name], value)
  291. # Delete the stored value and check that it is now null
  292. instance.custom_field_data.pop(cf.name)
  293. instance.save()
  294. instance.refresh_from_db()
  295. self.assertIsNone(instance.custom_field_data.get(cf.name))
  296. @tag('regression')
  297. def test_json_field_falsy_defaults(self):
  298. """Test that falsy JSON default values are properly handled"""
  299. falsy_test_cases = [
  300. ({}, 'empty_dict'),
  301. ([], 'empty_array'),
  302. (0, 'zero'),
  303. (False, 'false_bool'),
  304. ("", 'empty_string'),
  305. ]
  306. for default, suffix in falsy_test_cases:
  307. with self.subTest(default=default, suffix=suffix):
  308. cf = CustomField.objects.create(
  309. name=f'json_falsy_{suffix}',
  310. type=CustomFieldTypeChoices.TYPE_JSON,
  311. default=default,
  312. required=False
  313. )
  314. cf.object_types.set([self.object_type])
  315. instance = Site.objects.create(name=f'Test Site {suffix}', slug=f'test-site-{suffix}')
  316. self.assertIsNotNone(instance.custom_field_data)
  317. self.assertIn(cf.name, instance.custom_field_data)
  318. instance.refresh_from_db()
  319. stored = instance.custom_field_data[cf.name]
  320. self.assertEqual(stored, default)
  321. @tag('regression')
  322. def test_json_field_falsy_to_form_field(self):
  323. """Test form field generation preserves falsy defaults"""
  324. falsy_test_cases = (
  325. ({}, json.dumps({}), 'empty_dict'),
  326. ([], json.dumps([]), 'empty_array'),
  327. (0, json.dumps(0), 'zero'),
  328. (False, json.dumps(False), 'false_bool'),
  329. ("", '""', 'empty_string'),
  330. )
  331. for default, expected, suffix in falsy_test_cases:
  332. with self.subTest(default=default, expected=expected, suffix=suffix):
  333. cf = CustomField.objects.create(
  334. name=f'json_falsy_{suffix}',
  335. type=CustomFieldTypeChoices.TYPE_JSON,
  336. default=default,
  337. required=False
  338. )
  339. cf.object_types.set([self.object_type])
  340. form_field = cf.to_form_field(set_initial=True)
  341. self.assertEqual(form_field.initial, expected)
  342. def test_select_field(self):
  343. CHOICES = (
  344. ('a', 'Option A'),
  345. ('b', 'Option B'),
  346. ('c', 'Option C'),
  347. )
  348. value = 'a'
  349. # Create a set of custom field choices
  350. choice_set = CustomFieldChoiceSet.objects.create(
  351. name='Custom Field Choice Set 1',
  352. extra_choices=CHOICES
  353. )
  354. # Create a custom field & check that no initial data is written
  355. cf = CustomField.objects.create(
  356. name='select_field',
  357. type=CustomFieldTypeChoices.TYPE_SELECT,
  358. required=False,
  359. choice_set=choice_set
  360. )
  361. cf.object_types.set([self.object_type])
  362. instance = Site.objects.first()
  363. self.assertNotIn(cf.name, instance.custom_field_data)
  364. # Assign a value and check that it is saved
  365. instance.custom_field_data[cf.name] = value
  366. instance.save()
  367. instance.refresh_from_db()
  368. self.assertEqual(instance.custom_field_data[cf.name], value)
  369. # Delete the stored value and check that it is now null
  370. instance.custom_field_data.pop(cf.name)
  371. instance.save()
  372. instance.refresh_from_db()
  373. self.assertIsNone(instance.custom_field_data.get(cf.name))
  374. def test_multiselect_field(self):
  375. CHOICES = (
  376. ('a', 'Option A'),
  377. ('b', 'Option B'),
  378. ('c', 'Option C'),
  379. )
  380. value = ['a', 'b']
  381. # Create a set of custom field choices
  382. choice_set = CustomFieldChoiceSet.objects.create(
  383. name='Custom Field Choice Set 1',
  384. extra_choices=CHOICES
  385. )
  386. # Create a custom field & check that no initial data is written
  387. cf = CustomField.objects.create(
  388. name='multiselect_field',
  389. type=CustomFieldTypeChoices.TYPE_MULTISELECT,
  390. required=False,
  391. choice_set=choice_set
  392. )
  393. cf.object_types.set([self.object_type])
  394. instance = Site.objects.first()
  395. self.assertNotIn(cf.name, instance.custom_field_data)
  396. # Assign a value and check that it is saved
  397. instance.custom_field_data[cf.name] = value
  398. instance.save()
  399. instance.refresh_from_db()
  400. self.assertEqual(instance.custom_field_data[cf.name], value)
  401. # Delete the stored value and check that it is now null
  402. instance.custom_field_data.pop(cf.name)
  403. instance.save()
  404. instance.refresh_from_db()
  405. self.assertIsNone(instance.custom_field_data.get(cf.name))
  406. def test_choice_set_colors(self):
  407. choice_set = CustomFieldChoiceSet(
  408. name='Test Choice Set',
  409. extra_choices=(
  410. ('a', 'Option A'),
  411. ('b', 'Option B'),
  412. ),
  413. choice_colors={
  414. 'a': CustomFieldChoiceColorChoices.RED,
  415. 'b': CustomFieldChoiceColorChoices.GREEN,
  416. },
  417. )
  418. choice_set.full_clean()
  419. self.assertEqual(
  420. choice_set.colors,
  421. {
  422. 'a': CustomFieldChoiceColorChoices.RED,
  423. 'b': CustomFieldChoiceColorChoices.GREEN,
  424. },
  425. )
  426. def test_choice_set_invalid_color_mapping_value(self):
  427. choice_set = CustomFieldChoiceSet(
  428. name='Test Choice Set',
  429. extra_choices=(
  430. ('a', 'Option A'),
  431. ('b', 'Option B'),
  432. ),
  433. choice_colors={'c': CustomFieldChoiceColorChoices.RED},
  434. )
  435. with self.assertRaises(ValidationError) as cm:
  436. choice_set.full_clean()
  437. self.assertIn('choice_colors', cm.exception.message_dict)
  438. def test_choice_set_invalid_color_value(self):
  439. choice_set = CustomFieldChoiceSet(
  440. name='Test Choice Set',
  441. extra_choices=(
  442. ('a', 'Option A'),
  443. ('b', 'Option B'),
  444. ),
  445. choice_colors={'a': 'magenta'},
  446. )
  447. with self.assertRaises(ValidationError) as cm:
  448. choice_set.full_clean()
  449. self.assertIn('choice_colors', cm.exception.message_dict)
  450. def test_choice_set_invalid_color_mapping_structure(self):
  451. choice_set = CustomFieldChoiceSet(
  452. name='Test Choice Set',
  453. extra_choices=(
  454. ('a', 'Option A'),
  455. ('b', 'Option B'),
  456. ),
  457. choice_colors=['a:red'],
  458. )
  459. with self.assertRaises(ValidationError) as cm:
  460. choice_set.full_clean()
  461. self.assertIn('choice_colors', cm.exception.message_dict)
  462. @tag('regression')
  463. def test_choice_set_with_base_choices_validates_without_error(self):
  464. """Regression test for #22325: base-only choice sets must validate."""
  465. for base in ('IATA', 'ISO_3166', 'UN_LOCODE'):
  466. with self.subTest(base=base):
  467. choice_set = CustomFieldChoiceSet(name=f'Test {base}', base_choices=base, order_alphabetically=True)
  468. choice_set.full_clean() # must not raise
  469. choice_set.save() # must not raise (extra_choices is None)
  470. def test_remove_selected_choice(self):
  471. """
  472. Removing a ChoiceSet choice that is referenced by an object should raise
  473. a ValidationError exception.
  474. """
  475. CHOICES = (
  476. ('a', 'Option A'),
  477. ('b', 'Option B'),
  478. ('c', 'Option C'),
  479. ('d', 'Option D'),
  480. )
  481. # Create a set of custom field choices
  482. choice_set = CustomFieldChoiceSet.objects.create(
  483. name='Custom Field Choice Set 1',
  484. extra_choices=CHOICES
  485. )
  486. # Create a select custom field
  487. cf = CustomField.objects.create(
  488. name='select_field',
  489. type=CustomFieldTypeChoices.TYPE_SELECT,
  490. required=False,
  491. choice_set=choice_set
  492. )
  493. cf.object_types.set([self.object_type])
  494. # Create a multi-select custom field
  495. cf_multiselect = CustomField.objects.create(
  496. name='multiselect_field',
  497. type=CustomFieldTypeChoices.TYPE_MULTISELECT,
  498. required=False,
  499. choice_set=choice_set
  500. )
  501. cf_multiselect.object_types.set([self.object_type])
  502. # Assign a choice for both custom fields on an object
  503. instance = Site.objects.first()
  504. instance.custom_field_data[cf.name] = 'a'
  505. instance.custom_field_data[cf_multiselect.name] = ['b', 'c']
  506. instance.save()
  507. # Attempting to delete a selected choice should fail
  508. with self.assertRaises(ValidationError):
  509. choice_set.extra_choices = (
  510. ('b', 'Option B'),
  511. ('c', 'Option C'),
  512. ('d', 'Option D'),
  513. )
  514. choice_set.full_clean()
  515. # Attempting to delete either of the multi-select choices should fail
  516. with self.assertRaises(ValidationError):
  517. choice_set.extra_choices = (
  518. ('a', 'Option A'),
  519. ('b', 'Option B'),
  520. ('d', 'Option D'),
  521. )
  522. choice_set.full_clean()
  523. # Removing a non-selected choice should succeed
  524. choice_set.extra_choices = (
  525. ('a', 'Option A'),
  526. ('b', 'Option B'),
  527. ('c', 'Option C'),
  528. )
  529. choice_set.full_clean()
  530. def test_object_field(self):
  531. value = VLAN.objects.create(name='VLAN 1', vid=1).pk
  532. # Create a custom field & check that no initial data is written
  533. cf = CustomField.objects.create(
  534. name='object_field',
  535. type=CustomFieldTypeChoices.TYPE_OBJECT,
  536. related_object_type=ObjectType.objects.get_for_model(VLAN),
  537. required=False
  538. )
  539. cf.object_types.set([self.object_type])
  540. instance = Site.objects.first()
  541. self.assertNotIn(cf.name, instance.custom_field_data)
  542. # Assign a value and check that it is saved
  543. instance.custom_field_data[cf.name] = value
  544. instance.save()
  545. instance.refresh_from_db()
  546. self.assertEqual(instance.custom_field_data[cf.name], value)
  547. # Delete the stored value and check that it is now null
  548. instance.custom_field_data.pop(cf.name)
  549. instance.save()
  550. instance.refresh_from_db()
  551. self.assertIsNone(instance.custom_field_data.get(cf.name))
  552. def test_multiobject_field(self):
  553. vlans = (
  554. VLAN(name='VLAN 1', vid=1),
  555. VLAN(name='VLAN 2', vid=2),
  556. VLAN(name='VLAN 3', vid=3),
  557. )
  558. VLAN.objects.bulk_create(vlans)
  559. value = [vlan.pk for vlan in vlans]
  560. # Create a custom field & check that no initial data is written
  561. cf = CustomField.objects.create(
  562. name='object_field',
  563. type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
  564. related_object_type=ObjectType.objects.get_for_model(VLAN),
  565. required=False
  566. )
  567. cf.object_types.set([self.object_type])
  568. instance = Site.objects.first()
  569. self.assertNotIn(cf.name, instance.custom_field_data)
  570. # Assign a value and check that it is saved
  571. instance.custom_field_data[cf.name] = value
  572. instance.save()
  573. instance.refresh_from_db()
  574. self.assertEqual(instance.custom_field_data[cf.name], value)
  575. # Delete the stored value and check that it is now null
  576. instance.custom_field_data.pop(cf.name)
  577. instance.save()
  578. instance.refresh_from_db()
  579. self.assertIsNone(instance.custom_field_data.get(cf.name))
  580. def test_rename_customfield(self):
  581. obj_type = ObjectType.objects.get_for_model(Site)
  582. FIELD_DATA = 'abc'
  583. # Create a custom field
  584. cf = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='field1')
  585. cf.save()
  586. cf.object_types.set([obj_type])
  587. # Assign custom field data to an object
  588. site = Site.objects.create(
  589. name='Site 1',
  590. slug='site-1',
  591. custom_field_data={'field1': FIELD_DATA}
  592. )
  593. site.refresh_from_db()
  594. self.assertEqual(site.custom_field_data['field1'], FIELD_DATA)
  595. # Rename the custom field
  596. cf.name = 'field2'
  597. cf.save()
  598. # Check that custom field data on the object has been updated
  599. site.refresh_from_db()
  600. self.assertNotIn('field1', site.custom_field_data)
  601. self.assertEqual(site.custom_field_data['field2'], FIELD_DATA)
  602. @override_settings(BULK_UPDATE_CHUNK_SIZE=2)
  603. def test_batched_object_data_updates(self):
  604. """
  605. Provisioning, renaming, and removing custom field data is applied in batches. Use a small
  606. batch size to ensure the data on every object is updated across multiple batches.
  607. BULK_UPDATE_CHUNK_SIZE doubles as the threshold above which an update is handed to a
  608. background job, so overriding it this low also puts provisioning and removal onto the
  609. deferred path; the jobs are run here in place of the worker which would ordinarily do so.
  610. """
  611. # The existing sites (created in setUpTestData) span multiple batches of size 2
  612. site_count = Site.objects.count()
  613. self.assertGreater(site_count, 2)
  614. # Provisioning: a default value is populated onto every existing object
  615. cf = CustomField.objects.create(
  616. name='batched_field',
  617. type=CustomFieldTypeChoices.TYPE_TEXT,
  618. default='foo'
  619. )
  620. cf.object_types.set([self.object_type])
  621. self.assertTrue(provision_custom_field(cf.pk, [self.object_type.pk]))
  622. self.assertEqual(
  623. Site.objects.filter(custom_field_data__batched_field='foo').count(),
  624. site_count
  625. )
  626. # Renaming: the key is renamed on every existing object, preserving its value. This is
  627. # always applied inline, so no job is involved.
  628. cf.refresh_from_db()
  629. cf.name = 'renamed_field'
  630. cf.save()
  631. self.assertEqual(
  632. Site.objects.filter(custom_field_data__renamed_field='foo').count(),
  633. site_count
  634. )
  635. self.assertEqual(
  636. Site.objects.filter(custom_field_data__has_key='batched_field').count(),
  637. 0
  638. )
  639. # Removal: deleting the field strips the key from every existing object
  640. cf.delete()
  641. self.assertTrue(purge_custom_field(cf.pk))
  642. self.assertEqual(
  643. Site.objects.filter(custom_field_data__has_key='renamed_field').count(),
  644. 0
  645. )
  646. def test_provisioning_writes_nothing_without_a_default(self):
  647. """
  648. A field with no default has no value to record, so creating one must not touch any object.
  649. """
  650. cf = CustomField.objects.create(
  651. name='unset_field',
  652. type=CustomFieldTypeChoices.TYPE_TEXT
  653. )
  654. with CaptureQueriesContext(connection) as queries:
  655. cf.object_types.set([self.object_type])
  656. # No object data is written at all -- the cost of adding a field no longer scales with the
  657. # number of objects it applies to
  658. self.assertFalse([
  659. q['sql'] for q in queries.captured_queries
  660. if q['sql'].lstrip().upper().startswith('UPDATE "DCIM_SITE"'.upper())
  661. ])
  662. self.assertEqual(Site.objects.filter(custom_field_data__has_key='unset_field').count(), 0)
  663. for site in Site.objects.all():
  664. self.assertEqual(site.custom_field_data, {})
  665. self.assertIsNone(site.cf['unset_field'])
  666. def test_provisioning_applies_a_default_immediately(self):
  667. """
  668. A default value, by contrast, must be recorded on every existing object as soon as the
  669. field is created -- it has to be filterable straight away, so it cannot be deferred.
  670. """
  671. cf = CustomField.objects.create(
  672. name='defaulted_field',
  673. type=CustomFieldTypeChoices.TYPE_TEXT,
  674. default='bar'
  675. )
  676. cf.object_types.set([self.object_type])
  677. self.assertEqual(
  678. Site.objects.filter(custom_field_data__defaulted_field='bar').count(),
  679. Site.objects.count()
  680. )
  681. def test_rename_touches_only_objects_holding_a_value(self):
  682. """
  683. Renaming rewrites the key only where a value is actually stored. This is what keeps a
  684. rename cheap now that objects are no longer provisioned with a placeholder each.
  685. """
  686. cf = CustomField.objects.create(
  687. name='sparse_field',
  688. type=CustomFieldTypeChoices.TYPE_TEXT
  689. )
  690. cf.object_types.set([self.object_type])
  691. site = Site.objects.first()
  692. site.custom_field_data['sparse_field'] = 'value'
  693. site.save()
  694. cf.name = 'sparse_renamed'
  695. cf.save()
  696. self.assertEqual(
  697. list(
  698. Site.objects.filter(custom_field_data__has_key='sparse_renamed')
  699. .values_list('pk', flat=True)
  700. ),
  701. [site.pk]
  702. )
  703. self.assertEqual(Site.objects.filter(custom_field_data__has_key='sparse_field').count(), 0)
  704. site.refresh_from_db()
  705. self.assertEqual(site.custom_field_data['sparse_renamed'], 'value')
  706. def test_removal_from_object_type_purges_data(self):
  707. """
  708. Unassigning a field from an object type removes its data from those objects.
  709. """
  710. cf = CustomField.objects.create(
  711. name='unassigned_field',
  712. type=CustomFieldTypeChoices.TYPE_TEXT,
  713. default='baz'
  714. )
  715. cf.object_types.set([self.object_type])
  716. self.assertEqual(
  717. Site.objects.filter(custom_field_data__has_key='unassigned_field').count(),
  718. Site.objects.count()
  719. )
  720. cf.object_types.remove(self.object_type)
  721. self.assertEqual(
  722. Site.objects.filter(custom_field_data__has_key='unassigned_field').count(),
  723. 0
  724. )
  725. def test_clearing_object_types_purges_data(self):
  726. """
  727. clear() unassigns every object type at once and reports no pk_set, so it must be handled
  728. before the fact. Its data is removed just as remove()'s is.
  729. """
  730. cf = CustomField.objects.create(
  731. name='cleared_field',
  732. type=CustomFieldTypeChoices.TYPE_TEXT,
  733. default='baz'
  734. )
  735. cf.object_types.set([self.object_type])
  736. self.assertEqual(
  737. Site.objects.filter(custom_field_data__has_key='cleared_field').count(),
  738. Site.objects.count()
  739. )
  740. cf.object_types.clear()
  741. self.assertEqual(
  742. Site.objects.filter(custom_field_data__has_key='cleared_field').count(),
  743. 0
  744. )
  745. def test_batch_update_excludes_rows_which_no_longer_match(self):
  746. """
  747. A caller's filters must constrain the UPDATE as well as the selection of each batch.
  748. rename_object_data() builds a jsonb_set() expression which evaluates to NULL for a row not
  749. holding the key being renamed, so a row which loses it between the two statements would
  750. otherwise have its entire custom_field_data column nulled out.
  751. """
  752. cf = CustomField.objects.create(
  753. name='drifting_field',
  754. type=CustomFieldTypeChoices.TYPE_TEXT
  755. )
  756. cf.object_types.set([self.object_type])
  757. sites = list(Site.objects.order_by('pk'))
  758. holder, bystander = sites[0], sites[-1]
  759. Site.objects.filter(pk=holder.pk).update(custom_field_data={'drifting_field': 'value'})
  760. Site.objects.filter(pk=bystander.pk).update(custom_field_data={'other': 'untouched'})
  761. # Simulate a concurrent write: the batch selection yields a pk which no longer satisfies
  762. # the has_key filter by the time the UPDATE is issued.
  763. select_pks = QuerySet.values_list
  764. injected = []
  765. def inject_stale_pk(self, *args, **kwargs):
  766. result = select_pks(self, *args, **kwargs)
  767. if self.model is Site and args == ('pk',) and kwargs.get('flat') and not injected:
  768. injected.append(bystander.pk)
  769. return [*result, bystander.pk]
  770. return result
  771. with patch.object(QuerySet, 'values_list', inject_stale_pk):
  772. cf.name = 'drifted_field'
  773. cf.save()
  774. self.assertEqual(injected, [bystander.pk], "the stale pk was never injected")
  775. # The renamed value landed, and the bystander was left entirely alone
  776. holder.refresh_from_db()
  777. self.assertEqual(holder.custom_field_data, {'drifted_field': 'value'})
  778. bystander.refresh_from_db()
  779. self.assertEqual(bystander.custom_field_data, {'other': 'untouched'})
  780. @staticmethod
  781. def order_sites_by(*aliases):
  782. """
  783. Order a SiteTable by the given column aliases and return the underlying QuerySet.
  784. """
  785. table = SiteTable(Site.objects.all())
  786. table.order_by = aliases
  787. return table.data.data
  788. def test_table_ordering_breaks_ties_by_primary_key(self):
  789. """
  790. Rows tying on the sort value -- every object holding no value ties on both sort keys --
  791. must still be totally ordered, or paginated results may skip or repeat rows between
  792. page requests.
  793. """
  794. cf = CustomField.objects.create(
  795. name='sort_field',
  796. type=CustomFieldTypeChoices.TYPE_INTEGER
  797. )
  798. cf.object_types.set([self.object_type])
  799. # None of these hold a value for the field, so all of them tie
  800. Site.objects.bulk_create([
  801. Site(name=f'Tied Site {i}', slug=f'tied-site-{i}') for i in range(1, 11)
  802. ])
  803. for alias in ('cf_sort_field', '-cf_sort_field'):
  804. ordered = self.order_sites_by(alias)
  805. self.assertEqual(
  806. ordered.query.order_by[-1],
  807. 'pk',
  808. "the primary key must be applied as the final sort key"
  809. )
  810. # Paging through the results must yield each object exactly once
  811. expected = [site.pk for site in ordered]
  812. paginated = []
  813. for offset in range(0, len(expected), 4):
  814. paginated.extend(site.pk for site in ordered[offset:offset + 4])
  815. self.assertEqual(paginated, expected)
  816. def test_table_ordering_tolerates_a_repeated_sort_alias(self):
  817. """
  818. The sort parameter is read with getlist(), so the same custom field column can appear in
  819. the ordering more than once, applying the same annotation to the queryset twice.
  820. """
  821. cf = CustomField.objects.create(
  822. name='sort_field',
  823. type=CustomFieldTypeChoices.TYPE_INTEGER
  824. )
  825. cf.object_types.set([self.object_type])
  826. table = SiteTable(Site.objects.all())
  827. table.order_by = ['cf_sort_field', '-cf_sort_field']
  828. self.assertEqual(len(list(table.rows)), Site.objects.count())
  829. def test_default_value_validation(self):
  830. choiceset = CustomFieldChoiceSet.objects.create(
  831. name="Test Choice Set",
  832. extra_choices=(
  833. ('choice1', 'Choice 1'),
  834. ('choice2', 'Choice 2'),
  835. )
  836. )
  837. site = Site.objects.create(name='Site 1', slug='site-1')
  838. object_type = ObjectType.objects.get_for_model(Site)
  839. # Text
  840. CustomField(name='test', type='text', required=True, default="Default text").full_clean()
  841. # Integer
  842. CustomField(name='test', type='integer', required=True, default=1).full_clean()
  843. with self.assertRaises(ValidationError):
  844. CustomField(name='test', type='integer', required=True, default='xxx').full_clean()
  845. # Boolean
  846. CustomField(name='test', type='boolean', required=True, default=True).full_clean()
  847. with self.assertRaises(ValidationError):
  848. CustomField(name='test', type='boolean', required=True, default='xxx').full_clean()
  849. # Date
  850. CustomField(name='test', type='date', required=True, default="2023-02-25").full_clean()
  851. with self.assertRaises(ValidationError):
  852. CustomField(name='test', type='date', required=True, default='xxx').full_clean()
  853. # Datetime
  854. CustomField(name='test', type='datetime', required=True, default="2023-02-25 02:02:02").full_clean()
  855. with self.assertRaises(ValidationError):
  856. CustomField(name='test', type='datetime', required=True, default='xxx').full_clean()
  857. # URL
  858. CustomField(name='test', type='url', required=True, default="https://www.netbox.dev").full_clean()
  859. # JSON
  860. CustomField(name='test', type='json', required=True, default='{"test": "object"}').full_clean()
  861. # Selection
  862. CustomField(name='test', type='select', required=True, choice_set=choiceset, default='choice1').full_clean()
  863. with self.assertRaises(ValidationError):
  864. CustomField(name='test', type='select', required=True, choice_set=choiceset, default='xxx').full_clean()
  865. # Multi-select
  866. CustomField(
  867. name='test',
  868. type='multiselect',
  869. required=True,
  870. choice_set=choiceset,
  871. default=['choice1'] # Single default choice
  872. ).full_clean()
  873. CustomField(
  874. name='test',
  875. type='multiselect',
  876. required=True,
  877. choice_set=choiceset,
  878. default=['choice1', 'choice2'] # Multiple default choices
  879. ).full_clean()
  880. with self.assertRaises(ValidationError):
  881. CustomField(
  882. name='test',
  883. type='multiselect',
  884. required=True,
  885. choice_set=choiceset,
  886. default=['xxx']
  887. ).full_clean()
  888. # Object
  889. CustomField(
  890. name='test',
  891. type='object',
  892. required=True,
  893. related_object_type=object_type,
  894. default=site.pk
  895. ).full_clean()
  896. with (self.assertRaises(ValidationError)):
  897. CustomField(
  898. name='test',
  899. type='object',
  900. required=True,
  901. related_object_type=object_type,
  902. default="xxx"
  903. ).full_clean()
  904. # Multi-object
  905. CustomField(
  906. name='test',
  907. type='multiobject',
  908. required=True,
  909. related_object_type=object_type,
  910. default=[site.pk]
  911. ).full_clean()
  912. with self.assertRaises(ValidationError):
  913. CustomField(
  914. name='test',
  915. type='multiobject',
  916. required=True,
  917. related_object_type=object_type,
  918. default=["xxx"]
  919. ).full_clean()
  920. def test_validation_schema_only_for_json_type(self):
  921. schema = {
  922. 'type': 'object',
  923. 'properties': {
  924. 'name': {'type': 'string'},
  925. },
  926. }
  927. # Valid: schema on a JSON field
  928. CustomField(name='test', type=CustomFieldTypeChoices.TYPE_JSON, validation_schema=schema).full_clean()
  929. # Invalid: schema on a non-JSON field
  930. with self.assertRaises(ValidationError):
  931. CustomField(name='test', type=CustomFieldTypeChoices.TYPE_TEXT, validation_schema=schema).full_clean()
  932. with self.assertRaises(ValidationError):
  933. CustomField(name='test', type=CustomFieldTypeChoices.TYPE_INTEGER, validation_schema=schema).full_clean()
  934. def test_json_schema_default_validation(self):
  935. schema = {
  936. 'type': 'object',
  937. 'properties': {
  938. 'name': {'type': 'string'},
  939. },
  940. 'required': ['name'],
  941. }
  942. # Valid default
  943. CustomField(
  944. name='test', type=CustomFieldTypeChoices.TYPE_JSON,
  945. validation_schema=schema, default={'name': 'test'}
  946. ).full_clean()
  947. # Invalid default (missing required 'name')
  948. with self.assertRaises(ValidationError):
  949. CustomField(
  950. name='test', type=CustomFieldTypeChoices.TYPE_JSON,
  951. validation_schema=schema, default={'age': 25}
  952. ).full_clean()
  953. class CustomFieldManagerTestCase(TestCase):
  954. @classmethod
  955. def setUpTestData(cls):
  956. object_type = ObjectType.objects.get_for_model(Site)
  957. custom_field = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo')
  958. custom_field.save()
  959. custom_field.object_types.set([object_type])
  960. def test_get_for_model(self):
  961. self.assertEqual(len(CustomField.objects.get_for_model(Site)), 1)
  962. self.assertEqual(len(CustomField.objects.get_for_model(VirtualMachine)), 0)
  963. def test_get_for_model_caches_models_with_no_custom_fields(self):
  964. """
  965. A model with no custom fields assigned must be served from the request cache like any other.
  966. An empty list is falsy, so testing the cached value for truthiness would treat it as a miss
  967. and re-query on every call.
  968. """
  969. token = query_cache.set(defaultdict(dict))
  970. self.addCleanup(query_cache.reset, token)
  971. # Site has one custom field assigned, VirtualMachine none
  972. for model in (Site, VirtualMachine):
  973. CustomField.objects.get_for_model(model) # Prime the cache
  974. with self.assertNumQueries(0):
  975. CustomField.objects.get_for_model(model)
  976. def test_get_defaults_for_model_is_cached(self):
  977. """
  978. Every save of a custom-field-bearing object resolves the model's defaults, so the lookup
  979. must be served from the request cache rather than re-queried each time. As above, a model
  980. with no defaults caches an empty dict, which must not be mistaken for a miss.
  981. """
  982. token = query_cache.set(defaultdict(dict))
  983. self.addCleanup(query_cache.reset, token)
  984. # Site has a field with a default, VirtualMachine none
  985. for model in (Site, VirtualMachine):
  986. CustomField.objects.get_defaults_for_model(model)
  987. with self.assertNumQueries(0):
  988. CustomField.objects.get_defaults_for_model(model)
  989. def test_get_defaults_for_model_shares_the_field_cache(self):
  990. """
  991. The two lookups differ only in the statuses they select, so resolving a model's defaults must
  992. be served from the fields get_for_model() has already fetched rather than re-querying them.
  993. """
  994. token = query_cache.set(defaultdict(dict))
  995. self.addCleanup(query_cache.reset, token)
  996. CustomField.objects.get_for_model(Site) # Prime the field cache
  997. with self.assertNumQueries(0):
  998. self.assertEqual(CustomField.objects.get_defaults_for_model(Site), {'text_field': 'foo'})
  999. def test_get_defaults_for_model_returns_a_copy(self):
  1000. """
  1001. Callers assign the returned dict directly to an object's custom field data and then mutate
  1002. it in place, so each must receive its own copy. Sharing the cached dict -- or the list held
  1003. by a multiple-value field's default -- would let one object's value alter another's.
  1004. """
  1005. custom_field = CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default=['foo'])
  1006. custom_field.save()
  1007. custom_field.object_types.set([ObjectType.objects.get_for_model(Site)])
  1008. token = query_cache.set(defaultdict(dict))
  1009. self.addCleanup(query_cache.reset, token)
  1010. defaults = CustomField.objects.get_defaults_for_model(Site)
  1011. defaults['text_field'] = 'bar'
  1012. defaults['json_field'].append('bar')
  1013. self.assertEqual(
  1014. CustomField.objects.get_defaults_for_model(Site),
  1015. {'text_field': 'foo', 'json_field': ['foo']}
  1016. )
  1017. def test_creating_a_field_clears_the_cache(self):
  1018. """
  1019. The cache spans a whole request -- and a whole script or job run -- so a field created
  1020. partway through one must not be hidden by what was read before it. get_defaults_for_model()
  1021. is the lookup which matters most here: Device and Module component instantiation resolves
  1022. the defaults of every component model it creates.
  1023. """
  1024. token = query_cache.set(defaultdict(dict))
  1025. self.addCleanup(query_cache.reset, token)
  1026. CustomField.objects.get_for_model(VirtualMachine) # Prime the cache while none is assigned
  1027. cf = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='vm_field', default='bar')
  1028. cf.save()
  1029. cf.object_types.set([ObjectType.objects.get_for_model(VirtualMachine)])
  1030. self.assertEqual([f.pk for f in CustomField.objects.get_for_model(VirtualMachine)], [cf.pk])
  1031. self.assertEqual(CustomField.objects.get_defaults_for_model(VirtualMachine), {'vm_field': 'bar'})
  1032. def test_assigning_an_object_type_clears_the_cache(self):
  1033. token = query_cache.set(defaultdict(dict))
  1034. self.addCleanup(query_cache.reset, token)
  1035. CustomField.objects.get_for_model(VirtualMachine) # Prime the cache while none is assigned
  1036. cf = CustomField.objects.get(name='text_field')
  1037. cf.object_types.add(ObjectType.objects.get_for_model(VirtualMachine))
  1038. self.assertEqual([f.pk for f in CustomField.objects.get_for_model(VirtualMachine)], [cf.pk])
  1039. def test_unassigning_an_object_type_clears_the_cache(self):
  1040. token = query_cache.set(defaultdict(dict))
  1041. self.addCleanup(query_cache.reset, token)
  1042. CustomField.objects.get_for_model(Site) # Prime the cache while the field is assigned
  1043. cf = CustomField.objects.get(name='text_field')
  1044. cf.object_types.remove(ObjectType.objects.get_for_model(Site))
  1045. self.assertEqual(CustomField.objects.get_for_model(Site), [])
  1046. self.assertEqual(CustomField.objects.get_defaults_for_model(Site), {})
  1047. def test_changing_a_default_clears_the_cache(self):
  1048. """
  1049. A field's default reaches the objects created after it is changed, so a change made partway
  1050. through a request must not be served from the value cached before it.
  1051. """
  1052. token = query_cache.set(defaultdict(dict))
  1053. self.addCleanup(query_cache.reset, token)
  1054. self.assertEqual(CustomField.objects.get_defaults_for_model(Site), {'text_field': 'foo'})
  1055. cf = CustomField.objects.get(name='text_field')
  1056. cf.default = 'bar'
  1057. cf.save()
  1058. self.assertEqual(CustomField.objects.get_defaults_for_model(Site), {'text_field': 'bar'})
  1059. def test_repeated_saves_do_not_requery_custom_fields(self):
  1060. """
  1061. A bulk import creates thousands of objects within one request; resolving the defaults afresh
  1062. for each would add a query per object (see CustomFieldsMixin.save()).
  1063. """
  1064. token = query_cache.set(defaultdict(dict))
  1065. self.addCleanup(query_cache.reset, token)
  1066. Site.objects.create(name='Site 1', slug='site-1') # Prime the caches
  1067. with CaptureQueriesContext(connection) as ctx:
  1068. for i in range(2, 5):
  1069. Site.objects.create(name=f'Site {i}', slug=f'site-{i}')
  1070. custom_field_queries = [q for q in ctx.captured_queries if 'extras_customfield' in q['sql']]
  1071. self.assertEqual(custom_field_queries, [])
  1072. self.assertEqual(Site.objects.filter(custom_field_data__text_field='foo').count(), 4)
  1073. class CustomFieldAPITestCase(APITestCase):
  1074. @classmethod
  1075. def setUpTestData(cls):
  1076. object_type = ObjectType.objects.get_for_model(Site)
  1077. # Create some VLANs
  1078. vlans = (
  1079. VLAN(name='VLAN 1', vid=1),
  1080. VLAN(name='VLAN 2', vid=2),
  1081. VLAN(name='VLAN 3', vid=3),
  1082. VLAN(name='VLAN 4', vid=4),
  1083. VLAN(name='VLAN 5', vid=5),
  1084. )
  1085. VLAN.objects.bulk_create(vlans)
  1086. # Create a set of custom field choices
  1087. choice_set = CustomFieldChoiceSet.objects.create(
  1088. name='Custom Field Choice Set 1',
  1089. extra_choices=(('foo', 'Foo'), ('bar', 'Bar'), ('baz', 'Baz'))
  1090. )
  1091. custom_fields = (
  1092. CustomField(
  1093. type=CustomFieldTypeChoices.TYPE_TEXT,
  1094. name='text_field',
  1095. default='foo'
  1096. ),
  1097. CustomField(
  1098. type=CustomFieldTypeChoices.TYPE_LONGTEXT,
  1099. name='longtext_field',
  1100. default='ABC'
  1101. ),
  1102. CustomField(
  1103. type=CustomFieldTypeChoices.TYPE_INTEGER,
  1104. name='integer_field',
  1105. default=123
  1106. ),
  1107. CustomField(
  1108. type=CustomFieldTypeChoices.TYPE_DECIMAL,
  1109. name='decimal_field',
  1110. default=123.45
  1111. ),
  1112. CustomField(
  1113. type=CustomFieldTypeChoices.TYPE_BOOLEAN,
  1114. name='boolean_field',
  1115. default=False
  1116. ),
  1117. CustomField(
  1118. type=CustomFieldTypeChoices.TYPE_DATE,
  1119. name='date_field',
  1120. default='2020-01-01'
  1121. ),
  1122. CustomField(
  1123. type=CustomFieldTypeChoices.TYPE_DATETIME,
  1124. name='datetime_field',
  1125. default='2020-01-01T01:23:45'
  1126. ),
  1127. CustomField(
  1128. type=CustomFieldTypeChoices.TYPE_URL,
  1129. name='url_field',
  1130. default='http://example.com/1'
  1131. ),
  1132. CustomField(
  1133. type=CustomFieldTypeChoices.TYPE_JSON,
  1134. name='json_field',
  1135. default='{"x": "y"}'
  1136. ),
  1137. CustomField(
  1138. type=CustomFieldTypeChoices.TYPE_SELECT,
  1139. name='select_field',
  1140. default='foo',
  1141. choice_set=choice_set
  1142. ),
  1143. CustomField(
  1144. type=CustomFieldTypeChoices.TYPE_MULTISELECT,
  1145. name='multiselect_field',
  1146. default=['foo'],
  1147. choice_set=choice_set,
  1148. ),
  1149. CustomField(
  1150. type=CustomFieldTypeChoices.TYPE_OBJECT,
  1151. name='object_field',
  1152. related_object_type=ObjectType.objects.get_for_model(VLAN),
  1153. default=vlans[0].pk,
  1154. ),
  1155. CustomField(
  1156. type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
  1157. name='multiobject_field',
  1158. related_object_type=ObjectType.objects.get_for_model(VLAN),
  1159. default=[vlans[0].pk, vlans[1].pk],
  1160. ),
  1161. )
  1162. for cf in custom_fields:
  1163. cf.save()
  1164. cf.object_types.set([object_type])
  1165. # Create some sites *after* creating the custom fields. This ensures that
  1166. # default values are not set for the assigned objects.
  1167. sites = (
  1168. Site(name='Site 1', slug='site-1'),
  1169. Site(name='Site 2', slug='site-2'),
  1170. )
  1171. Site.objects.bulk_create(sites)
  1172. # Assign custom field values for site 2
  1173. sites[1].custom_field_data = {
  1174. custom_fields[0].name: 'bar',
  1175. custom_fields[1].name: 'DEF',
  1176. custom_fields[2].name: 456,
  1177. custom_fields[3].name: Decimal('456.78'),
  1178. custom_fields[4].name: True,
  1179. custom_fields[5].name: '2020-01-02',
  1180. custom_fields[6].name: '2020-01-02 12:00:00',
  1181. custom_fields[7].name: 'http://example.com/2',
  1182. custom_fields[8].name: '{"foo": 1, "bar": 2}',
  1183. custom_fields[9].name: 'bar',
  1184. custom_fields[10].name: ['bar', 'baz'],
  1185. custom_fields[11].name: vlans[1].pk,
  1186. custom_fields[12].name: [vlans[2].pk, vlans[3].pk],
  1187. }
  1188. sites[1].save()
  1189. # Labels for the choice set created in setUpTestData, used to build the expected
  1190. # API representation of selection custom fields ({'value': ..., 'label': ...}).
  1191. CHOICE_LABELS = {'foo': 'Foo', 'bar': 'Bar', 'baz': 'Baz'}
  1192. @classmethod
  1193. def _select(cls, value):
  1194. """Return the expected API representation of a single selection choice."""
  1195. if value is None:
  1196. return None
  1197. return {'value': value, 'label': cls.CHOICE_LABELS[value]}
  1198. @classmethod
  1199. def _multiselect(cls, values):
  1200. """Return the expected API representation of a multiple selection value."""
  1201. if values is None:
  1202. return None
  1203. return [cls._select(v) for v in values]
  1204. def test_get_custom_fields(self):
  1205. TYPES = {
  1206. CustomFieldTypeChoices.TYPE_TEXT: 'string',
  1207. CustomFieldTypeChoices.TYPE_LONGTEXT: 'string',
  1208. CustomFieldTypeChoices.TYPE_INTEGER: 'integer',
  1209. CustomFieldTypeChoices.TYPE_DECIMAL: 'decimal',
  1210. CustomFieldTypeChoices.TYPE_BOOLEAN: 'boolean',
  1211. CustomFieldTypeChoices.TYPE_DATE: 'string',
  1212. CustomFieldTypeChoices.TYPE_DATETIME: 'string',
  1213. CustomFieldTypeChoices.TYPE_URL: 'string',
  1214. CustomFieldTypeChoices.TYPE_JSON: 'object',
  1215. CustomFieldTypeChoices.TYPE_SELECT: 'string',
  1216. CustomFieldTypeChoices.TYPE_MULTISELECT: 'array',
  1217. CustomFieldTypeChoices.TYPE_OBJECT: 'object',
  1218. CustomFieldTypeChoices.TYPE_MULTIOBJECT: 'array',
  1219. }
  1220. self.add_permissions('extras.view_customfield')
  1221. url = reverse('extras-api:customfield-list')
  1222. response = self.client.get(url, **self.header)
  1223. self.assertEqual(response.data['count'], len(TYPES))
  1224. # Validate data types
  1225. for customfield in response.data['results']:
  1226. cf_type = customfield['type']['value']
  1227. self.assertEqual(customfield['data_type'], TYPES[cf_type])
  1228. def test_get_single_object_without_custom_field_data(self):
  1229. """
  1230. Validate that custom fields are present on an object even if it has no values defined.
  1231. """
  1232. site1 = Site.objects.get(name='Site 1')
  1233. url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk})
  1234. self.add_permissions('dcim.view_site')
  1235. response = self.client.get(url, **self.header)
  1236. self.assertEqual(response.data['name'], site1.name)
  1237. self.assertEqual(response.data['custom_fields'], {
  1238. 'text_field': None,
  1239. 'longtext_field': None,
  1240. 'integer_field': None,
  1241. 'decimal_field': None,
  1242. 'boolean_field': None,
  1243. 'date_field': None,
  1244. 'datetime_field': None,
  1245. 'url_field': None,
  1246. 'json_field': None,
  1247. 'select_field': None,
  1248. 'multiselect_field': None,
  1249. 'object_field': None,
  1250. 'multiobject_field': None,
  1251. })
  1252. def test_get_single_object_with_custom_field_data(self):
  1253. """
  1254. Validate that custom fields are present and correctly set for an object with values defined.
  1255. """
  1256. site2 = Site.objects.get(name='Site 2')
  1257. site2_cfvs = site2.cf
  1258. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1259. self.add_permissions('dcim.view_site')
  1260. response = self.client.get(url, **self.header)
  1261. self.assertEqual(response.data['name'], site2.name)
  1262. self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field'])
  1263. self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_field'])
  1264. self.assertEqual(response.data['custom_fields']['integer_field'], site2_cfvs['integer_field'])
  1265. self.assertEqual(response.data['custom_fields']['decimal_field'], site2_cfvs['decimal_field'])
  1266. self.assertEqual(response.data['custom_fields']['boolean_field'], site2_cfvs['boolean_field'])
  1267. self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field'])
  1268. self.assertEqual(response.data['custom_fields']['datetime_field'], site2_cfvs['datetime_field'])
  1269. self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field'])
  1270. self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field'])
  1271. self.assertEqual(response.data['custom_fields']['select_field'], self._select(site2_cfvs['select_field']))
  1272. self.assertEqual(
  1273. response.data['custom_fields']['multiselect_field'],
  1274. self._multiselect(site2_cfvs['multiselect_field'])
  1275. )
  1276. self.assertEqual(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field'].pk)
  1277. self.assertEqual(
  1278. [obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
  1279. [obj.pk for obj in site2_cfvs['multiobject_field']]
  1280. )
  1281. def test_list_objects_reuse_object_field_serializers(self):
  1282. """Object and multi-object values share one nested serializer each per request."""
  1283. vlans = {vlan.name: vlan for vlan in VLAN.objects.all()}
  1284. site2 = Site.objects.get(name='Site 2')
  1285. site3 = Site.objects.create(name='Site 3', slug='site-3', custom_field_data={
  1286. 'object_field': vlans['VLAN 1'].pk,
  1287. 'multiobject_field': [vlans['VLAN 4'].pk, vlans['VLAN 5'].pk],
  1288. })
  1289. url = reverse('dcim-api:site-list')
  1290. self.add_permissions('dcim.view_site')
  1291. def get(ids, host):
  1292. with patch.object(VLANSerializer, '__init__', autospec=True, side_effect=VLANSerializer.__init__) as init:
  1293. response = self.client.get(
  1294. url, {'id': ids, 'fields': 'name,custom_fields'}, HTTP_HOST=host, **self.header
  1295. )
  1296. self.assertHttpStatus(response, status.HTTP_200_OK)
  1297. return init.call_count, {site['name']: site['custom_fields'] for site in response.data['results']}
  1298. one, _ = get([site2.pk], 'a.example.com')
  1299. two, custom_fields = get([site2.pk, site3.pk], 'b.example.com')
  1300. self.assertEqual(one, two)
  1301. expected = {
  1302. 'Site 2': ('VLAN 2', ['VLAN 3', 'VLAN 4']),
  1303. 'Site 3': ('VLAN 1', ['VLAN 4', 'VLAN 5']),
  1304. }
  1305. for name, (object_value, multiobject_values) in expected.items():
  1306. self.assertEqual(custom_fields[name]['object_field']['id'], vlans[object_value].pk)
  1307. self.assertEqual(
  1308. [vlan['id'] for vlan in custom_fields[name]['multiobject_field']],
  1309. [vlans[value].pk for value in multiobject_values],
  1310. )
  1311. self.assertTrue(custom_fields['Site 3']['object_field']['url'].startswith('http://b.example.com/'))
  1312. self.assertNotIn('custom_fields', custom_fields['Site 3']['object_field'])
  1313. def test_list_objects_use_list_serializer_for_multiobject_values(self):
  1314. """Each multi-object value goes through the list serializer of its target."""
  1315. vlans = {vlan.name: vlan for vlan in VLAN.objects.all()}
  1316. site2 = Site.objects.get(name='Site 2')
  1317. site3 = Site.objects.create(name='Site 3', slug='site-3', custom_field_data={
  1318. 'multiobject_field': [vlans['VLAN 4'].pk, vlans['VLAN 5'].pk],
  1319. })
  1320. self.add_permissions('dcim.view_site')
  1321. values = []
  1322. class RecordingListSerializer(CustomFieldListSerializer):
  1323. def to_representation(self, data):
  1324. values.append([vlan.pk for vlan in data])
  1325. return super().to_representation(data)
  1326. with patch.object(VLANSerializer.Meta, 'list_serializer_class', RecordingListSerializer, create=True):
  1327. response = self.client.get(
  1328. reverse('dcim-api:site-list'), {'id': [site2.pk, site3.pk], 'fields': 'name,custom_fields'},
  1329. **self.header
  1330. )
  1331. self.assertHttpStatus(response, status.HTTP_200_OK)
  1332. self.assertEqual(values, [
  1333. [vlans['VLAN 3'].pk, vlans['VLAN 4'].pk],
  1334. [vlans['VLAN 4'].pk, vlans['VLAN 5'].pk],
  1335. ])
  1336. def test_list_objects_prefetch_object_field_target_relations(self):
  1337. """Interface targets, their devices and their cables load in one query each."""
  1338. site_type = ObjectType.objects.get_for_model(Site)
  1339. interface_type = ObjectType.objects.get_for_model(Interface)
  1340. for cf_type, name in (
  1341. (CustomFieldTypeChoices.TYPE_OBJECT, 'interface_field'),
  1342. (CustomFieldTypeChoices.TYPE_MULTIOBJECT, 'interfaces_field'),
  1343. ):
  1344. cf = CustomField.objects.create(type=cf_type, name=name, related_object_type=interface_type)
  1345. cf.object_types.set([site_type])
  1346. devices = [create_test_device(f'Device {i}') for i in range(1, 5)]
  1347. interfaces = [
  1348. Interface.objects.create(device=device, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
  1349. for device in devices
  1350. ]
  1351. peer_device = create_test_device('Peer Device')
  1352. cables = []
  1353. for i, interface in enumerate(interfaces[:2]):
  1354. peer = Interface.objects.create(
  1355. device=peer_device, name=f'eth{i}', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  1356. )
  1357. cable = Cable(a_terminations=[interface], b_terminations=[peer])
  1358. cable.save()
  1359. cables.append(cable)
  1360. sites = (
  1361. Site.objects.create(name='Site 3', slug='site-3', custom_field_data={
  1362. 'interface_field': interfaces[0].pk,
  1363. 'interfaces_field': [interfaces[1].pk, interfaces[2].pk],
  1364. }),
  1365. Site.objects.create(name='Site 4', slug='site-4', custom_field_data={
  1366. 'interface_field': interfaces[3].pk,
  1367. 'interfaces_field': [interfaces[2].pk, interfaces[0].pk],
  1368. }),
  1369. )
  1370. self.add_permissions('dcim.view_site')
  1371. with CaptureQueriesContext(connection) as ctx:
  1372. response = self.client.get(
  1373. reverse('dcim-api:site-list'), {'id': [site.pk for site in sites], 'fields': 'name,custom_fields'},
  1374. **self.header
  1375. )
  1376. self.assertHttpStatus(response, status.HTTP_200_OK)
  1377. for model in (Interface, Device, Cable):
  1378. queries = get_primary_table_queries(ctx.captured_queries, model)
  1379. self.assertEqual(len(queries), 1, f'{model.__name__} rows must be fetched once, got {queries}')
  1380. # Relations outside the brief representation stay unloaded
  1381. self.assertEqual(get_primary_table_queries(ctx.captured_queries, Tag), [])
  1382. def summary(value):
  1383. return value['device']['id'], value['cable'] and value['cable']['id']
  1384. self.assertEqual(
  1385. {
  1386. site['name']: (
  1387. summary(site['custom_fields']['interface_field']),
  1388. [summary(value) for value in site['custom_fields']['interfaces_field']],
  1389. )
  1390. for site in response.data['results']
  1391. },
  1392. {
  1393. 'Site 3': ((devices[0].pk, cables[0].pk), [(devices[1].pk, cables[1].pk), (devices[2].pk, None)]),
  1394. 'Site 4': ((devices[3].pk, None), [(devices[2].pk, None), (devices[0].pk, cables[0].pk)]),
  1395. },
  1396. )
  1397. def test_list_objects_fetch_object_field_targets_once_per_model(self):
  1398. """Object and multi-object fields referencing one model share one target query."""
  1399. vlans = {vlan.name: vlan for vlan in VLAN.objects.all()}
  1400. missing_pk = max(vlan.pk for vlan in vlans.values()) + 1
  1401. site2 = Site.objects.get(name='Site 2')
  1402. site3 = Site.objects.create(name='Site 3', slug='site-3', custom_field_data={
  1403. 'object_field': missing_pk,
  1404. 'multiobject_field': [vlans['VLAN 5'].pk, missing_pk, vlans['VLAN 2'].pk, vlans['VLAN 5'].pk],
  1405. })
  1406. self.add_permissions('dcim.view_site')
  1407. with CaptureQueriesContext(connection) as ctx:
  1408. response = self.client.get(
  1409. reverse('dcim-api:site-list'), {'id': [site2.pk, site3.pk], 'fields': 'name,custom_fields'},
  1410. **self.header
  1411. )
  1412. self.assertHttpStatus(response, status.HTTP_200_OK)
  1413. vlan_queries = get_primary_table_queries(ctx.captured_queries, VLAN)
  1414. self.assertEqual(len(vlan_queries), 1, f'VLAN targets must be fetched once, got {vlan_queries}')
  1415. custom_fields = {site['name']: site['custom_fields'] for site in response.data['results']}
  1416. self.assertEqual(custom_fields['Site 2']['object_field']['id'], vlans['VLAN 2'].pk)
  1417. self.assertEqual(
  1418. [vlan['id'] for vlan in custom_fields['Site 2']['multiobject_field']],
  1419. [vlans['VLAN 3'].pk, vlans['VLAN 4'].pk],
  1420. )
  1421. # A missing target renders as null and drops out of a list, which keeps its order and duplicates
  1422. self.assertIsNone(custom_fields['Site 3']['object_field'])
  1423. self.assertEqual(
  1424. [vlan['id'] for vlan in custom_fields['Site 3']['multiobject_field']],
  1425. [vlans['VLAN 5'].pk, vlans['VLAN 2'].pk, vlans['VLAN 5'].pk],
  1426. )
  1427. def test_list_objects_without_object_field_target_serializer(self):
  1428. """Empty and dangling references to a model without a REST serializer render as null."""
  1429. cf = CustomField.objects.create(
  1430. type=CustomFieldTypeChoices.TYPE_OBJECT,
  1431. name='dummy_field',
  1432. related_object_type=ObjectType.objects.get_for_model(DummyNetBoxModel),
  1433. )
  1434. cf.object_types.set([ObjectType.objects.get_for_model(Site)])
  1435. missing_pk = max(DummyNetBoxModel.objects.values_list('pk', flat=True), default=0) + 1
  1436. Site.objects.create(name='Site 3', slug='site-3', custom_field_data={'dummy_field': missing_pk})
  1437. self.add_permissions('dcim.view_site')
  1438. response = self.client.get(reverse('dcim-api:site-list'), {'fields': 'name,custom_fields'}, **self.header)
  1439. self.assertHttpStatus(response, status.HTTP_200_OK)
  1440. self.assertEqual(
  1441. {site['name']: site['custom_fields']['dummy_field'] for site in response.data['results']},
  1442. {'Site 1': None, 'Site 2': None, 'Site 3': None},
  1443. )
  1444. def test_get_object_selection_field_representation(self):
  1445. """
  1446. Selection custom fields are rendered as an object exposing both the stored value and its
  1447. human-friendly label on read access (see #20897).
  1448. """
  1449. site2 = Site.objects.get(name='Site 2')
  1450. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1451. self.add_permissions('dcim.view_site')
  1452. response = self.client.get(url, **self.header)
  1453. # A single selection value is rendered as a {value, label} object
  1454. self.assertEqual(response.data['custom_fields']['select_field'], {
  1455. 'value': 'bar',
  1456. 'label': 'Bar',
  1457. })
  1458. # A multiple selection value is rendered as a list of {value, label} objects
  1459. self.assertEqual(response.data['custom_fields']['multiselect_field'], [
  1460. {'value': 'bar', 'label': 'Bar'},
  1461. {'value': 'baz', 'label': 'Baz'},
  1462. ])
  1463. def test_get_object_selection_field_unresolved_label(self):
  1464. """
  1465. A stored selection value with no matching choice falls back to using the raw value as its label.
  1466. """
  1467. site2 = Site.objects.get(name='Site 2')
  1468. site2.custom_field_data['select_field'] = 'stale'
  1469. site2.save()
  1470. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1471. self.add_permissions('dcim.view_site')
  1472. response = self.client.get(url, **self.header)
  1473. self.assertEqual(response.data['custom_fields']['select_field'], {
  1474. 'value': 'stale',
  1475. 'label': 'stale',
  1476. })
  1477. def test_graphql_selection_field_representation_matches_rest(self):
  1478. site2 = Site.objects.get(name='Site 2')
  1479. self.add_permissions('dcim.view_site')
  1480. query = f'{{ site(id: {site2.pk}) {{ custom_fields }} }}'
  1481. response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header)
  1482. self.assertHttpStatus(response, status.HTTP_200_OK)
  1483. data = json.loads(response.content)
  1484. self.assertNotIn('errors', data)
  1485. custom_fields = data['data']['site']['custom_fields']
  1486. self.assertEqual(custom_fields['select_field'], self._select('bar'))
  1487. self.assertEqual(custom_fields['multiselect_field'], self._multiselect(['bar', 'baz']))
  1488. def test_graphql_selection_field_unresolved_label(self):
  1489. site2 = Site.objects.get(name='Site 2')
  1490. site2.custom_field_data['select_field'] = 'stale'
  1491. site2.save()
  1492. self.add_permissions('dcim.view_site')
  1493. query = f'{{ site(id: {site2.pk}) {{ custom_fields }} }}'
  1494. response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header)
  1495. self.assertHttpStatus(response, status.HTTP_200_OK)
  1496. data = json.loads(response.content)
  1497. self.assertNotIn('errors', data)
  1498. self.assertEqual(data['data']['site']['custom_fields']['select_field'], {
  1499. 'value': 'stale',
  1500. 'label': 'stale',
  1501. })
  1502. def test_graphql_non_selection_fields_pass_through_unchanged(self):
  1503. site2 = Site.objects.get(name='Site 2')
  1504. self.add_permissions('dcim.view_site')
  1505. query = f'{{ site(id: {site2.pk}) {{ custom_fields }} }}'
  1506. response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header)
  1507. self.assertHttpStatus(response, status.HTTP_200_OK)
  1508. data = json.loads(response.content)
  1509. self.assertNotIn('errors', data)
  1510. custom_fields = data['data']['site']['custom_fields']
  1511. self.assertEqual(custom_fields['text_field'], 'bar')
  1512. self.assertEqual(custom_fields['integer_field'], 456)
  1513. self.assertEqual(custom_fields['boolean_field'], True)
  1514. def test_graphql_selection_field_list_query_is_not_n_plus_one(self):
  1515. self.add_permissions('dcim.view_site')
  1516. Site.objects.bulk_create([Site(name=f'Site {i}', slug=f'site-{i}') for i in range(3, 13)])
  1517. query = '{ site_list { custom_fields } }'
  1518. with CaptureQueriesContext(connection) as ctx:
  1519. response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header)
  1520. self.assertHttpStatus(response, status.HTTP_200_OK)
  1521. data = json.loads(response.content)
  1522. self.assertNotIn('errors', data)
  1523. self.assertEqual(len(data['data']['site_list']), 12)
  1524. # The capture window also holds one-off request queries, so assert on per-table counts, not the total.
  1525. site_queries = get_primary_table_queries(ctx.captured_queries, Site)
  1526. self.assertEqual(
  1527. len(site_queries), 1,
  1528. f'custom_field_data must be fetched by the site list query itself, got {site_queries}'
  1529. )
  1530. custom_field_queries = get_primary_table_queries(ctx.captured_queries, CustomField)
  1531. self.assertEqual(
  1532. len(custom_field_queries), 1,
  1533. f'custom field definitions must be fetched once per request, got {custom_field_queries}'
  1534. )
  1535. def test_get_for_model_select_related_choice_set(self):
  1536. query_cache.set(None)
  1537. custom_fields = list(CustomField.objects.get_for_model(Site))
  1538. with self.assertNumQueries(0):
  1539. resolved = {cf.name: cf.resolve_selection_value(cf.default) for cf in custom_fields}
  1540. self.assertEqual(resolved['select_field'], self._select('foo'))
  1541. self.assertEqual(resolved['multiselect_field'], self._multiselect(['foo']))
  1542. @tag('regression')
  1543. def test_update_selection_field_rejects_read_format(self):
  1544. """
  1545. Selection fields are written by passing the raw value; submitting the {value, label} read
  1546. representation must be rejected with a clean 400, not a 500 (see #20897).
  1547. """
  1548. site2 = Site.objects.get(name='Site 2')
  1549. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1550. self.add_permissions('dcim.change_site')
  1551. # A single selection submitted as an object is rejected
  1552. response = self.client.patch(
  1553. url, {'custom_fields': {'select_field': {'value': 'foo', 'label': 'Foo'}}}, format='json', **self.header
  1554. )
  1555. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1556. # A multiple selection submitted as a list of objects is rejected (must not raise a TypeError/500)
  1557. response = self.client.patch(
  1558. url,
  1559. {'custom_fields': {'multiselect_field': [{'value': 'foo', 'label': 'Foo'}]}},
  1560. format='json',
  1561. **self.header
  1562. )
  1563. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1564. # The stored values are unchanged
  1565. site2.refresh_from_db()
  1566. self.assertEqual(site2.custom_field_data['select_field'], 'bar')
  1567. self.assertEqual(site2.custom_field_data['multiselect_field'], ['bar', 'baz'])
  1568. def test_create_single_object_with_defaults(self):
  1569. """
  1570. Create a new site with no specified custom field values and check that it received the default values.
  1571. """
  1572. cf_defaults = {
  1573. cf.name: cf.default for cf in CustomField.objects.all()
  1574. }
  1575. data = {
  1576. 'name': 'Site 3',
  1577. 'slug': 'site-3',
  1578. }
  1579. url = reverse('dcim-api:site-list')
  1580. self.add_permissions('dcim.add_site')
  1581. response = self.client.post(url, data, format='json', **self.header)
  1582. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1583. # Validate response data
  1584. response_cf = response.data['custom_fields']
  1585. self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
  1586. self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
  1587. self.assertEqual(response_cf['integer_field'], cf_defaults['integer_field'])
  1588. self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field'])
  1589. self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
  1590. self.assertEqual(response_cf['date_field'].isoformat(), cf_defaults['date_field'])
  1591. self.assertEqual(response_cf['datetime_field'].isoformat(), cf_defaults['datetime_field'])
  1592. self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
  1593. self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
  1594. self.assertEqual(response_cf['select_field'], self._select(cf_defaults['select_field']))
  1595. self.assertEqual(response_cf['multiselect_field'], self._multiselect(cf_defaults['multiselect_field']))
  1596. self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field'])
  1597. self.assertEqual(
  1598. [obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
  1599. cf_defaults['multiobject_field']
  1600. )
  1601. # Validate database data
  1602. site = Site.objects.get(pk=response.data['id'])
  1603. self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field'])
  1604. self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
  1605. self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field'])
  1606. self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field'])
  1607. self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
  1608. self.assertEqual(site.custom_field_data['date_field'], cf_defaults['date_field'])
  1609. self.assertEqual(site.custom_field_data['datetime_field'], cf_defaults['datetime_field'])
  1610. self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
  1611. self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
  1612. self.assertEqual(site.custom_field_data['select_field'], cf_defaults['select_field'])
  1613. self.assertEqual(site.custom_field_data['multiselect_field'], cf_defaults['multiselect_field'])
  1614. self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field'])
  1615. self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field'])
  1616. def test_create_single_object_with_values(self):
  1617. """
  1618. Create a single new site with a value for each type of custom field.
  1619. """
  1620. data = {
  1621. 'name': 'Site 3',
  1622. 'slug': 'site-3',
  1623. 'custom_fields': {
  1624. 'text_field': 'bar',
  1625. 'longtext_field': 'blah blah blah',
  1626. 'integer_field': 456,
  1627. 'decimal_field': 456.78,
  1628. 'boolean_field': True,
  1629. 'date_field': datetime.date(2020, 1, 2),
  1630. 'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0),
  1631. 'url_field': 'http://example.com/2',
  1632. 'json_field': '{"foo": 1, "bar": 2}',
  1633. 'select_field': 'bar',
  1634. 'multiselect_field': ['bar', 'baz'],
  1635. 'object_field': VLAN.objects.get(vid=2).pk,
  1636. 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
  1637. },
  1638. }
  1639. url = reverse('dcim-api:site-list')
  1640. self.add_permissions('dcim.add_site')
  1641. response = self.client.post(url, data, format='json', **self.header)
  1642. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1643. # Validate response data
  1644. response_cf = response.data['custom_fields']
  1645. data_cf = data['custom_fields']
  1646. self.assertEqual(response_cf['text_field'], data_cf['text_field'])
  1647. self.assertEqual(response_cf['longtext_field'], data_cf['longtext_field'])
  1648. self.assertEqual(response_cf['integer_field'], data_cf['integer_field'])
  1649. self.assertEqual(response_cf['decimal_field'], data_cf['decimal_field'])
  1650. self.assertEqual(response_cf['boolean_field'], data_cf['boolean_field'])
  1651. self.assertEqual(response_cf['date_field'], data_cf['date_field'])
  1652. self.assertEqual(response_cf['datetime_field'], data_cf['datetime_field'])
  1653. self.assertEqual(response_cf['url_field'], data_cf['url_field'])
  1654. self.assertEqual(response_cf['json_field'], data_cf['json_field'])
  1655. self.assertEqual(response_cf['select_field'], self._select(data_cf['select_field']))
  1656. self.assertEqual(response_cf['multiselect_field'], self._multiselect(data_cf['multiselect_field']))
  1657. self.assertEqual(response_cf['object_field']['id'], data_cf['object_field'])
  1658. self.assertEqual(
  1659. [obj['id'] for obj in response_cf['multiobject_field']],
  1660. data_cf['multiobject_field']
  1661. )
  1662. # Validate database data
  1663. site = Site.objects.get(pk=response.data['id'])
  1664. self.assertEqual(site.custom_field_data['text_field'], data_cf['text_field'])
  1665. self.assertEqual(site.custom_field_data['longtext_field'], data_cf['longtext_field'])
  1666. self.assertEqual(site.custom_field_data['integer_field'], data_cf['integer_field'])
  1667. self.assertEqual(site.custom_field_data['decimal_field'], data_cf['decimal_field'])
  1668. self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field'])
  1669. self.assertEqual(site.cf['date_field'], data_cf['date_field'])
  1670. self.assertEqual(site.cf['datetime_field'], data_cf['datetime_field'])
  1671. self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field'])
  1672. self.assertEqual(site.custom_field_data['json_field'], data_cf['json_field'])
  1673. self.assertEqual(site.custom_field_data['select_field'], data_cf['select_field'])
  1674. self.assertEqual(site.custom_field_data['multiselect_field'], data_cf['multiselect_field'])
  1675. self.assertEqual(site.custom_field_data['object_field'], data_cf['object_field'])
  1676. self.assertEqual(site.custom_field_data['multiobject_field'], data_cf['multiobject_field'])
  1677. def test_create_multiple_objects_with_defaults(self):
  1678. """
  1679. Create three new sites with no specified custom field values and check that each received
  1680. the default custom field values.
  1681. """
  1682. cf_defaults = {
  1683. cf.name: cf.default for cf in CustomField.objects.all()
  1684. }
  1685. data = (
  1686. {
  1687. 'name': 'Site 3',
  1688. 'slug': 'site-3',
  1689. },
  1690. {
  1691. 'name': 'Site 4',
  1692. 'slug': 'site-4',
  1693. },
  1694. {
  1695. 'name': 'Site 5',
  1696. 'slug': 'site-5',
  1697. },
  1698. )
  1699. url = reverse('dcim-api:site-list')
  1700. self.add_permissions('dcim.add_site')
  1701. response = self.client.post(url, data, format='json', **self.header)
  1702. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1703. self.assertEqual(len(response.data), len(data))
  1704. for i, obj in enumerate(data):
  1705. # Validate response data
  1706. response_cf = response.data[i]['custom_fields']
  1707. self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
  1708. self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
  1709. self.assertEqual(response_cf['integer_field'], cf_defaults['integer_field'])
  1710. self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field'])
  1711. self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
  1712. self.assertEqual(response_cf['date_field'].isoformat(), cf_defaults['date_field'])
  1713. self.assertEqual(response_cf['datetime_field'].isoformat(), cf_defaults['datetime_field'])
  1714. self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
  1715. self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
  1716. self.assertEqual(response_cf['select_field'], self._select(cf_defaults['select_field']))
  1717. self.assertEqual(response_cf['multiselect_field'], self._multiselect(cf_defaults['multiselect_field']))
  1718. self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field'])
  1719. self.assertEqual(
  1720. [obj['id'] for obj in response_cf['multiobject_field']],
  1721. cf_defaults['multiobject_field']
  1722. )
  1723. # Validate database data
  1724. site = Site.objects.get(pk=response.data[i]['id'])
  1725. self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field'])
  1726. self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
  1727. self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field'])
  1728. self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field'])
  1729. self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
  1730. self.assertEqual(site.custom_field_data['date_field'], cf_defaults['date_field'])
  1731. self.assertEqual(site.custom_field_data['datetime_field'], cf_defaults['datetime_field'])
  1732. self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
  1733. self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
  1734. self.assertEqual(site.custom_field_data['select_field'], cf_defaults['select_field'])
  1735. self.assertEqual(site.custom_field_data['multiselect_field'], cf_defaults['multiselect_field'])
  1736. self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field'])
  1737. self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field'])
  1738. def test_create_multiple_objects_with_values(self):
  1739. """
  1740. Create a three new sites, each with custom fields defined.
  1741. """
  1742. custom_field_data = {
  1743. 'text_field': 'bar',
  1744. 'longtext_field': 'abcdefghij',
  1745. 'integer_field': 456,
  1746. 'decimal_field': 456.78,
  1747. 'boolean_field': True,
  1748. 'date_field': datetime.date(2020, 1, 2),
  1749. 'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0),
  1750. 'url_field': 'http://example.com/2',
  1751. 'json_field': '{"foo": 1, "bar": 2}',
  1752. 'select_field': 'bar',
  1753. 'multiselect_field': ['bar', 'baz'],
  1754. 'object_field': VLAN.objects.get(vid=2).pk,
  1755. 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
  1756. }
  1757. data = (
  1758. {
  1759. 'name': 'Site 3',
  1760. 'slug': 'site-3',
  1761. 'custom_fields': custom_field_data,
  1762. },
  1763. {
  1764. 'name': 'Site 4',
  1765. 'slug': 'site-4',
  1766. 'custom_fields': custom_field_data,
  1767. },
  1768. {
  1769. 'name': 'Site 5',
  1770. 'slug': 'site-5',
  1771. 'custom_fields': custom_field_data,
  1772. },
  1773. )
  1774. url = reverse('dcim-api:site-list')
  1775. self.add_permissions('dcim.add_site')
  1776. response = self.client.post(url, data, format='json', **self.header)
  1777. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1778. self.assertEqual(len(response.data), len(data))
  1779. for i, obj in enumerate(data):
  1780. # Validate response data
  1781. response_cf = response.data[i]['custom_fields']
  1782. self.assertEqual(response_cf['text_field'], custom_field_data['text_field'])
  1783. self.assertEqual(response_cf['longtext_field'], custom_field_data['longtext_field'])
  1784. self.assertEqual(response_cf['integer_field'], custom_field_data['integer_field'])
  1785. self.assertEqual(response_cf['decimal_field'], custom_field_data['decimal_field'])
  1786. self.assertEqual(response_cf['boolean_field'], custom_field_data['boolean_field'])
  1787. self.assertEqual(response_cf['date_field'], custom_field_data['date_field'])
  1788. self.assertEqual(response_cf['datetime_field'], custom_field_data['datetime_field'])
  1789. self.assertEqual(response_cf['url_field'], custom_field_data['url_field'])
  1790. self.assertEqual(response_cf['json_field'], custom_field_data['json_field'])
  1791. self.assertEqual(response_cf['select_field'], self._select(custom_field_data['select_field']))
  1792. self.assertEqual(
  1793. response_cf['multiselect_field'],
  1794. self._multiselect(custom_field_data['multiselect_field'])
  1795. )
  1796. self.assertEqual(response_cf['object_field']['id'], custom_field_data['object_field'])
  1797. self.assertEqual(
  1798. [obj['id'] for obj in response_cf['multiobject_field']],
  1799. custom_field_data['multiobject_field']
  1800. )
  1801. # Validate database data
  1802. site = Site.objects.get(pk=response.data[i]['id'])
  1803. self.assertEqual(site.custom_field_data['text_field'], custom_field_data['text_field'])
  1804. self.assertEqual(site.custom_field_data['longtext_field'], custom_field_data['longtext_field'])
  1805. self.assertEqual(site.custom_field_data['integer_field'], custom_field_data['integer_field'])
  1806. self.assertEqual(site.custom_field_data['decimal_field'], custom_field_data['decimal_field'])
  1807. self.assertEqual(site.custom_field_data['boolean_field'], custom_field_data['boolean_field'])
  1808. self.assertEqual(site.cf['date_field'], custom_field_data['date_field'])
  1809. self.assertEqual(site.cf['datetime_field'], custom_field_data['datetime_field'])
  1810. self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field'])
  1811. self.assertEqual(site.custom_field_data['json_field'], custom_field_data['json_field'])
  1812. self.assertEqual(site.custom_field_data['select_field'], custom_field_data['select_field'])
  1813. self.assertEqual(site.custom_field_data['multiselect_field'], custom_field_data['multiselect_field'])
  1814. self.assertEqual(site.custom_field_data['object_field'], custom_field_data['object_field'])
  1815. self.assertEqual(site.custom_field_data['multiobject_field'], custom_field_data['multiobject_field'])
  1816. def test_update_single_object_with_values(self):
  1817. """
  1818. Update an object with existing custom field values. Ensure that only the updated custom field values are
  1819. modified.
  1820. """
  1821. site2 = Site.objects.get(name='Site 2')
  1822. original_cfvs = {**site2.cf}
  1823. data = {
  1824. 'custom_fields': {
  1825. 'text_field': 'ABCD',
  1826. 'integer_field': 1234,
  1827. },
  1828. }
  1829. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1830. self.add_permissions('dcim.change_site')
  1831. response = self.client.patch(url, data, format='json', **self.header)
  1832. self.assertHttpStatus(response, status.HTTP_200_OK)
  1833. # Validate response data
  1834. response_cf = response.data['custom_fields']
  1835. self.assertEqual(response_cf['text_field'], data['custom_fields']['text_field'])
  1836. self.assertEqual(response_cf['longtext_field'], original_cfvs['longtext_field'])
  1837. self.assertEqual(response_cf['integer_field'], data['custom_fields']['integer_field'])
  1838. self.assertEqual(response_cf['decimal_field'], original_cfvs['decimal_field'])
  1839. self.assertEqual(response_cf['boolean_field'], original_cfvs['boolean_field'])
  1840. self.assertEqual(response_cf['date_field'], original_cfvs['date_field'])
  1841. self.assertEqual(response_cf['datetime_field'], original_cfvs['datetime_field'])
  1842. self.assertEqual(response_cf['url_field'], original_cfvs['url_field'])
  1843. self.assertEqual(response_cf['json_field'], original_cfvs['json_field'])
  1844. self.assertEqual(response_cf['select_field'], self._select(original_cfvs['select_field']))
  1845. self.assertEqual(response_cf['multiselect_field'], self._multiselect(original_cfvs['multiselect_field']))
  1846. self.assertEqual(response_cf['object_field']['id'], original_cfvs['object_field'].pk)
  1847. self.assertListEqual(
  1848. [obj['id'] for obj in response_cf['multiobject_field']],
  1849. [obj.pk for obj in original_cfvs['multiobject_field']]
  1850. )
  1851. # Validate database data
  1852. site2 = Site.objects.get(pk=site2.pk)
  1853. self.assertEqual(site2.cf['text_field'], data['custom_fields']['text_field'])
  1854. self.assertEqual(site2.cf['longtext_field'], original_cfvs['longtext_field'])
  1855. self.assertEqual(site2.cf['integer_field'], data['custom_fields']['integer_field'])
  1856. self.assertEqual(site2.cf['decimal_field'], original_cfvs['decimal_field'])
  1857. self.assertEqual(site2.cf['boolean_field'], original_cfvs['boolean_field'])
  1858. self.assertEqual(site2.cf['date_field'], original_cfvs['date_field'])
  1859. self.assertEqual(site2.cf['datetime_field'], original_cfvs['datetime_field'])
  1860. self.assertEqual(site2.cf['url_field'], original_cfvs['url_field'])
  1861. self.assertEqual(site2.cf['json_field'], original_cfvs['json_field'])
  1862. self.assertEqual(site2.cf['select_field'], original_cfvs['select_field'])
  1863. self.assertEqual(site2.cf['multiselect_field'], original_cfvs['multiselect_field'])
  1864. self.assertEqual(site2.cf['object_field'], original_cfvs['object_field'])
  1865. self.assertListEqual(
  1866. list(site2.cf['multiobject_field']),
  1867. list(original_cfvs['multiobject_field'])
  1868. )
  1869. @tag('regression')
  1870. def test_update_single_object_with_object_values_using_custom_validation(self):
  1871. """
  1872. Object and multi-object custom fields accept objects whose serializers implement validate() (#23422).
  1873. """
  1874. device = create_test_device('Device 1')
  1875. module_type = ModuleType.objects.create(manufacturer=device.device_type.manufacturer, model='Module Type 1')
  1876. module_bay = ModuleBay.objects.create(device=device, name='Module Bay 1')
  1877. targets = (
  1878. Interface.objects.create(device=device, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
  1879. Module.objects.create(device=device, module_bay=module_bay, module_type=module_type),
  1880. ImageAttachment.objects.create(
  1881. object_type=ObjectType.objects.get_for_model(Device),
  1882. object_id=device.pk,
  1883. image='image-attachments/image.png',
  1884. image_height=100,
  1885. image_width=100,
  1886. ),
  1887. JournalEntry.objects.create(assigned_object=device, comments='Journal entry'),
  1888. Service.objects.create(parent=device, name='Service 1', port_mappings=['tcp/22']),
  1889. ServiceTemplate.objects.create(name='Service Template 1', port_mappings=['tcp/22']),
  1890. Token.objects.create(user=self.user),
  1891. self.user,
  1892. VMInterface.objects.create(virtual_machine=create_test_virtualmachine('VM 1'), name='eth0'),
  1893. )
  1894. site = Site.objects.get(name='Site 1')
  1895. site_type = ObjectType.objects.get_for_model(Site)
  1896. url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
  1897. self.add_permissions('dcim.change_site')
  1898. for obj in targets:
  1899. name = obj._meta.model_name
  1900. with self.subTest(model=name):
  1901. for cf_type, cf_name in (
  1902. (CustomFieldTypeChoices.TYPE_OBJECT, f'{name}_object'),
  1903. (CustomFieldTypeChoices.TYPE_MULTIOBJECT, f'{name}_objects'),
  1904. ):
  1905. cf = CustomField.objects.create(
  1906. name=cf_name, type=cf_type, related_object_type=ObjectType.objects.get_for_model(obj)
  1907. )
  1908. cf.object_types.set([site_type])
  1909. # Reference the object both by attributes (which requires view permission) and by PK
  1910. self.add_permissions(f'{obj._meta.app_label}.view_{name}')
  1911. data = {'custom_fields': {f'{name}_object': {'pk': obj.pk}, f'{name}_objects': [obj.pk]}}
  1912. response = self.client.patch(url, data, format='json', **self.header)
  1913. self.assertHttpStatus(response, status.HTTP_200_OK)
  1914. site.refresh_from_db()
  1915. self.assertEqual(site.custom_field_data[f'{name}_object'], obj.pk)
  1916. self.assertEqual(site.custom_field_data[f'{name}_objects'], [obj.pk])
  1917. def test_update_single_object_rejects_unknown_object_values(self):
  1918. """Object and multi-object custom fields reject references which match no object."""
  1919. site2 = Site.objects.get(name='Site 2')
  1920. original_cf_data = {**site2.custom_field_data}
  1921. missing_pk = VLAN.objects.order_by('-pk').values_list('pk', flat=True).first() + 1
  1922. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1923. self.add_permissions('dcim.change_site', 'ipam.view_vlan')
  1924. for field_name, value in (
  1925. ('object_field', missing_pk),
  1926. ('object_field', {'name': 'VLAN 99'}),
  1927. ('multiobject_field', [missing_pk]),
  1928. ('multiobject_field', [{'name': 'VLAN 99'}]),
  1929. ):
  1930. with self.subTest(field=field_name, value=value):
  1931. response = self.client.patch(url, {'custom_fields': {field_name: value}}, format='json', **self.header)
  1932. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1933. self.assertIn('custom_fields', response.data)
  1934. site2.refresh_from_db()
  1935. self.assertEqual(site2.custom_field_data, original_cf_data)
  1936. @tag('regression')
  1937. def test_update_single_object_rejects_unknown_custom_fields(self):
  1938. site2 = Site.objects.get(name='Site 2')
  1939. original_cf_data = {**site2.custom_field_data}
  1940. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1941. self.add_permissions('dcim.change_site')
  1942. data = {
  1943. 'custom_fields': {
  1944. 'text_field': 'valid',
  1945. 'thisfieldshouldntexist': 'random text here',
  1946. },
  1947. }
  1948. response = self.client.patch(url, data, format='json', **self.header)
  1949. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1950. self.assertIn('custom_fields', response.data)
  1951. self.assertIn('thisfieldshouldntexist', response.data['custom_fields'])
  1952. # Ensure the object was not modified
  1953. site2.refresh_from_db()
  1954. self.assertEqual(site2.custom_field_data, original_cf_data)
  1955. @tag('regression')
  1956. def test_update_single_object_prunes_stale_custom_field_data_from_database_and_postchange_data(self):
  1957. stale_key = 'thisfieldshouldntexist'
  1958. stale_value = 'random text here'
  1959. updated_text_value = 'ABCD'
  1960. site2 = Site.objects.get(name='Site 2')
  1961. original_text_value = site2.custom_field_data['text_field']
  1962. object_type = ObjectType.objects.get_for_model(Site)
  1963. # Seed stale custom field data directly in the database to mimic a polluted row.
  1964. Site.objects.filter(pk=site2.pk).update(
  1965. custom_field_data={
  1966. **site2.custom_field_data,
  1967. stale_key: stale_value,
  1968. }
  1969. )
  1970. site2.refresh_from_db()
  1971. self.assertIn(stale_key, site2.custom_field_data)
  1972. existing_change_ids = set(
  1973. ObjectChange.objects.filter(
  1974. changed_object_type=object_type,
  1975. changed_object_id=site2.pk,
  1976. ).values_list('pk', flat=True)
  1977. )
  1978. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1979. self.add_permissions('dcim.change_site')
  1980. data = {
  1981. 'custom_fields': {
  1982. 'text_field': updated_text_value,
  1983. },
  1984. }
  1985. response = self.client.patch(url, data, format='json', **self.header)
  1986. self.assertHttpStatus(response, status.HTTP_200_OK)
  1987. site2.refresh_from_db()
  1988. self.assertEqual(site2.cf['text_field'], updated_text_value)
  1989. self.assertNotIn(stale_key, site2.custom_field_data)
  1990. object_changes = ObjectChange.objects.filter(
  1991. changed_object_type=object_type,
  1992. changed_object_id=site2.pk,
  1993. ).exclude(pk__in=existing_change_ids)
  1994. self.assertEqual(object_changes.count(), 1)
  1995. object_change = object_changes.get()
  1996. self.assertEqual(object_change.prechange_data['custom_fields']['text_field'], original_text_value)
  1997. self.assertEqual(object_change.postchange_data['custom_fields']['text_field'], updated_text_value)
  1998. self.assertNotIn(stale_key, object_change.postchange_data['custom_fields'])
  1999. def test_specify_related_object_by_attr(self):
  2000. site1 = Site.objects.get(name='Site 1')
  2001. vlans = VLAN.objects.all()[:3]
  2002. url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk})
  2003. self.add_permissions('dcim.change_site', 'ipam.view_vlan')
  2004. # Set related objects by PK
  2005. data = {
  2006. 'custom_fields': {
  2007. 'object_field': vlans[0].pk,
  2008. 'multiobject_field': [vlans[1].pk, vlans[2].pk],
  2009. },
  2010. }
  2011. response = self.client.patch(url, data, format='json', **self.header)
  2012. self.assertHttpStatus(response, status.HTTP_200_OK)
  2013. self.assertEqual(
  2014. response.data['custom_fields']['object_field']['id'],
  2015. vlans[0].pk
  2016. )
  2017. self.assertListEqual(
  2018. [obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
  2019. [vlans[1].pk, vlans[2].pk]
  2020. )
  2021. # Set related objects by name
  2022. data = {
  2023. 'custom_fields': {
  2024. 'object_field': {
  2025. 'name': vlans[0].name,
  2026. },
  2027. 'multiobject_field': [
  2028. {
  2029. 'name': vlans[1].name
  2030. },
  2031. {
  2032. 'name': vlans[2].name
  2033. },
  2034. ],
  2035. },
  2036. }
  2037. response = self.client.patch(url, data, format='json', **self.header)
  2038. self.assertHttpStatus(response, status.HTTP_200_OK)
  2039. self.assertEqual(
  2040. response.data['custom_fields']['object_field']['id'],
  2041. vlans[0].pk
  2042. )
  2043. self.assertListEqual(
  2044. [obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
  2045. [vlans[1].pk, vlans[2].pk]
  2046. )
  2047. # Clear related objects
  2048. data = {
  2049. 'custom_fields': {
  2050. 'object_field': None,
  2051. 'multiobject_field': [],
  2052. },
  2053. }
  2054. response = self.client.patch(url, data, format='json', **self.header)
  2055. self.assertHttpStatus(response, status.HTTP_200_OK)
  2056. self.assertIsNone(response.data['custom_fields']['object_field'])
  2057. self.assertListEqual(response.data['custom_fields']['multiobject_field'], [])
  2058. def test_minimum_maximum_values_validation(self):
  2059. site2 = Site.objects.get(name='Site 2')
  2060. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  2061. self.add_permissions('dcim.change_site')
  2062. cf_integer = CustomField.objects.get(name='integer_field')
  2063. cf_integer.validation_minimum = 10
  2064. cf_integer.validation_maximum = 20
  2065. cf_integer.save()
  2066. data = {'custom_fields': {'integer_field': 9}}
  2067. response = self.client.patch(url, data, format='json', **self.header)
  2068. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  2069. data = {'custom_fields': {'integer_field': 21}}
  2070. response = self.client.patch(url, data, format='json', **self.header)
  2071. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  2072. data = {'custom_fields': {'integer_field': 15}}
  2073. response = self.client.patch(url, data, format='json', **self.header)
  2074. self.assertHttpStatus(response, status.HTTP_200_OK)
  2075. def test_regex_validation(self):
  2076. site2 = Site.objects.get(name='Site 2')
  2077. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  2078. self.add_permissions('dcim.change_site')
  2079. cf_text = CustomField.objects.get(name='text_field')
  2080. cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters
  2081. cf_text.save()
  2082. data = {'custom_fields': {'text_field': 'ABC123'}}
  2083. response = self.client.patch(url, data, format='json', **self.header)
  2084. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  2085. data = {'custom_fields': {'text_field': 'abc'}}
  2086. response = self.client.patch(url, data, format='json', **self.header)
  2087. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  2088. data = {'custom_fields': {'text_field': 'ABC'}}
  2089. response = self.client.patch(url, data, format='json', **self.header)
  2090. self.assertHttpStatus(response, status.HTTP_200_OK)
  2091. def test_url_regex_validation(self):
  2092. """
  2093. Test that validation_regex is applied to URL custom fields (fixes #20498).
  2094. """
  2095. site2 = Site.objects.get(name='Site 2')
  2096. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  2097. self.add_permissions('dcim.change_site')
  2098. cf_url = CustomField.objects.get(name='url_field')
  2099. cf_url.validation_regex = r'^https://' # Require HTTPS
  2100. cf_url.save()
  2101. # Test invalid URL (http instead of https)
  2102. data = {'custom_fields': {'url_field': 'http://example.com'}}
  2103. response = self.client.patch(url, data, format='json', **self.header)
  2104. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  2105. # Test valid URL (https)
  2106. data = {'custom_fields': {'url_field': 'https://example.com'}}
  2107. response = self.client.patch(url, data, format='json', **self.header)
  2108. self.assertHttpStatus(response, status.HTTP_200_OK)
  2109. def test_url_scheme_validation(self):
  2110. """
  2111. Test that URL custom field values must use a scheme permitted by ALLOWED_URL_SCHEMES (fixes
  2112. #22640), and that a schemeless value is normalized to an absolute URL (assume_scheme='https'),
  2113. consistent with the UI.
  2114. """
  2115. site2 = Site.objects.get(name='Site 2')
  2116. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  2117. self.add_permissions('dcim.change_site')
  2118. # A dangerous scheme (e.g. javascript:) must be rejected
  2119. data = {'custom_fields': {'url_field': 'javascript:alert(1)'}}
  2120. response = self.client.patch(url, data, format='json', **self.header)
  2121. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  2122. # A well-formed URL using a scheme outside ALLOWED_URL_SCHEMES must be rejected
  2123. data = {'custom_fields': {'url_field': 'gopher://example.com'}}
  2124. response = self.client.patch(url, data, format='json', **self.header)
  2125. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  2126. # An allowed scheme must be accepted
  2127. data = {'custom_fields': {'url_field': 'https://example.com'}}
  2128. response = self.client.patch(url, data, format='json', **self.header)
  2129. self.assertHttpStatus(response, status.HTTP_200_OK)
  2130. # A schemeless value must be accepted and normalized to https, matching the UI
  2131. data = {'custom_fields': {'url_field': 'example.com'}}
  2132. response = self.client.patch(url, data, format='json', **self.header)
  2133. self.assertHttpStatus(response, status.HTTP_200_OK)
  2134. site2.refresh_from_db()
  2135. self.assertEqual(site2.custom_field_data['url_field'], 'https://example.com')
  2136. def test_json_schema_validation(self):
  2137. site2 = Site.objects.get(name='Site 2')
  2138. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  2139. self.add_permissions('dcim.change_site')
  2140. cf_json = CustomField.objects.get(name='json_field')
  2141. cf_json.validation_schema = {
  2142. 'type': 'object',
  2143. 'properties': {
  2144. 'name': {'type': 'string'},
  2145. 'age': {'type': 'integer'},
  2146. },
  2147. 'required': ['name'],
  2148. }
  2149. cf_json.save()
  2150. # Invalid: missing required 'name' property
  2151. data = {'custom_fields': {'json_field': {'age': 25}}}
  2152. response = self.client.patch(url, data, format='json', **self.header)
  2153. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  2154. # Invalid: 'age' is not an integer
  2155. data = {'custom_fields': {'json_field': {'name': 'test', 'age': 'not_an_int'}}}
  2156. response = self.client.patch(url, data, format='json', **self.header)
  2157. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  2158. # Valid: conforms to schema
  2159. data = {'custom_fields': {'json_field': {'name': 'test', 'age': 25}}}
  2160. response = self.client.patch(url, data, format='json', **self.header)
  2161. self.assertHttpStatus(response, status.HTTP_200_OK)
  2162. # Valid: null value (schema not enforced on empty)
  2163. data = {'custom_fields': {'json_field': None}}
  2164. response = self.client.patch(url, data, format='json', **self.header)
  2165. self.assertHttpStatus(response, status.HTTP_200_OK)
  2166. def test_uniqueness_validation(self):
  2167. # Create a unique custom field
  2168. cf_text = CustomField.objects.get(name='text_field')
  2169. cf_text.unique = True
  2170. cf_text.save()
  2171. # Set a value on site 1
  2172. site1 = Site.objects.get(name='Site 1')
  2173. site1.custom_field_data['text_field'] = 'ABC123'
  2174. site1.save()
  2175. site2 = Site.objects.get(name='Site 2')
  2176. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  2177. self.add_permissions('dcim.change_site')
  2178. data = {'custom_fields': {'text_field': 'ABC123'}}
  2179. response = self.client.patch(url, data, format='json', **self.header)
  2180. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  2181. data = {'custom_fields': {'text_field': 'DEF456'}}
  2182. response = self.client.patch(url, data, format='json', **self.header)
  2183. self.assertHttpStatus(response, status.HTTP_200_OK)
  2184. class CustomFieldImportTestCase(TestCase):
  2185. user_permissions = (
  2186. 'dcim.view_site',
  2187. 'dcim.add_site',
  2188. )
  2189. @classmethod
  2190. def setUpTestData(cls):
  2191. # Create a set of custom field choices
  2192. choice_set = CustomFieldChoiceSet.objects.create(
  2193. name='Custom Field Choice Set 1',
  2194. extra_choices=(
  2195. ('a', 'Option A'),
  2196. ('b', 'Option B'),
  2197. ('c', 'Option C'),
  2198. )
  2199. )
  2200. custom_fields = (
  2201. CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT),
  2202. CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT),
  2203. CustomField(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER),
  2204. CustomField(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL),
  2205. CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN),
  2206. CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE),
  2207. CustomField(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME),
  2208. CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
  2209. CustomField(name='json', type=CustomFieldTypeChoices.TYPE_JSON),
  2210. CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choice_set=choice_set),
  2211. CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choice_set=choice_set),
  2212. )
  2213. for cf in custom_fields:
  2214. cf.save()
  2215. cf.object_types.set([ObjectType.objects.get_for_model(Site)])
  2216. def test_import(self):
  2217. """
  2218. Import a Site in CSV format, including a value for each CustomField.
  2219. """
  2220. data = (
  2221. (
  2222. 'name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_decimal', 'cf_boolean', 'cf_date',
  2223. 'cf_datetime', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect',
  2224. ),
  2225. (
  2226. 'Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01',
  2227. '2020-01-01 12:00:00', 'http://example.com/1', '{"foo": 123}', 'a', '"a,b"',
  2228. ),
  2229. (
  2230. 'Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', 'False', '2020-01-02',
  2231. '2020-01-02 12:00:00', 'http://example.com/2', '{"bar": 456}', 'b', '"b,c"',
  2232. ),
  2233. ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', '', '', ''),
  2234. )
  2235. csv_data = '\n'.join(','.join(row) for row in data)
  2236. response = self.client.post(reverse('dcim:site_bulk_import'), {
  2237. 'data': csv_data,
  2238. 'format': ImportFormatChoices.CSV,
  2239. 'csv_delimiter': CSVDelimiterChoices.AUTO,
  2240. })
  2241. self.assertEqual(response.status_code, 302)
  2242. self.assertEqual(Site.objects.count(), 3)
  2243. # Validate data for site 1
  2244. site1 = Site.objects.get(name='Site 1')
  2245. self.assertEqual(len(site1.custom_field_data), 11)
  2246. self.assertEqual(site1.custom_field_data['text'], 'ABC')
  2247. self.assertEqual(site1.custom_field_data['longtext'], 'Foo')
  2248. self.assertEqual(site1.custom_field_data['integer'], 123)
  2249. self.assertEqual(site1.custom_field_data['decimal'], 123.45)
  2250. self.assertEqual(site1.custom_field_data['boolean'], True)
  2251. self.assertEqual(site1.cf['date'].isoformat(), '2020-01-01')
  2252. self.assertEqual(site1.cf['datetime'].isoformat(), '2020-01-01T12:00:00+00:00')
  2253. self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
  2254. self.assertEqual(site1.custom_field_data['json'], {"foo": 123})
  2255. self.assertEqual(site1.custom_field_data['select'], 'a')
  2256. self.assertEqual(site1.custom_field_data['multiselect'], ['a', 'b'])
  2257. # Validate data for site 2
  2258. site2 = Site.objects.get(name='Site 2')
  2259. self.assertEqual(len(site2.custom_field_data), 11)
  2260. self.assertEqual(site2.custom_field_data['text'], 'DEF')
  2261. self.assertEqual(site2.custom_field_data['longtext'], 'Bar')
  2262. self.assertEqual(site2.custom_field_data['integer'], 456)
  2263. self.assertEqual(site2.custom_field_data['decimal'], 456.78)
  2264. self.assertEqual(site2.custom_field_data['boolean'], False)
  2265. self.assertEqual(site2.cf['date'].isoformat(), '2020-01-02')
  2266. self.assertEqual(site2.cf['datetime'].isoformat(), '2020-01-02T12:00:00+00:00')
  2267. self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
  2268. self.assertEqual(site2.custom_field_data['json'], {"bar": 456})
  2269. self.assertEqual(site2.custom_field_data['select'], 'b')
  2270. self.assertEqual(site2.custom_field_data['multiselect'], ['b', 'c'])
  2271. # No custom field data should be set for site 3
  2272. site3 = Site.objects.get(name='Site 3')
  2273. self.assertFalse(any(site3.custom_field_data.values()))
  2274. def test_import_missing_required(self):
  2275. """
  2276. Attempt to import an object missing a required custom field.
  2277. """
  2278. # Set one of our CustomFields to required
  2279. CustomField.objects.filter(name='text').update(required=True)
  2280. form_data = {
  2281. 'name': 'Site 1',
  2282. 'slug': 'site-1',
  2283. }
  2284. form = SiteImportForm(data=form_data)
  2285. self.assertFalse(form.is_valid())
  2286. self.assertIn('cf_text', form.errors)
  2287. def test_import_invalid_choice(self):
  2288. """
  2289. Attempt to import an object with an invalid choice selection.
  2290. """
  2291. form_data = {
  2292. 'name': 'Site 1',
  2293. 'slug': 'site-1',
  2294. 'cf_select': 'Choice X'
  2295. }
  2296. form = SiteImportForm(data=form_data)
  2297. self.assertFalse(form.is_valid())
  2298. self.assertIn('cf_select', form.errors)
  2299. class CustomFieldModelTestCase(TestCase):
  2300. @classmethod
  2301. def setUpTestData(cls):
  2302. cf1 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='foo')
  2303. cf1.save()
  2304. cf1.object_types.set([ObjectType.objects.get_for_model(Site)])
  2305. cf2 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='bar')
  2306. cf2.save()
  2307. cf2.object_types.set([ObjectType.objects.get_for_model(Rack)])
  2308. def test_cf_data(self):
  2309. """
  2310. Check that custom field data is present on the instance immediately after being set and after being fetched
  2311. from the database.
  2312. """
  2313. site = Site(name='Test Site', slug='test-site')
  2314. # Check custom field data on new instance
  2315. site.custom_field_data['foo'] = 'abc'
  2316. self.assertEqual(site.cf['foo'], 'abc')
  2317. # Check custom field data from database
  2318. site.save()
  2319. site = Site.objects.get(name='Test Site')
  2320. self.assertEqual(site.cf['foo'], 'abc')
  2321. def test_invalid_data(self):
  2322. """
  2323. Any invalid or stale custom field data should be removed from the instance.
  2324. """
  2325. site = Site(name='Test Site', slug='test-site')
  2326. # Set custom field data
  2327. site.custom_field_data['foo'] = 'abc'
  2328. site.custom_field_data['bar'] = 'def'
  2329. site.clean()
  2330. self.assertIn('foo', site.custom_field_data)
  2331. self.assertNotIn('bar', site.custom_field_data)
  2332. def test_missing_required_field(self):
  2333. """
  2334. Check that a ValidationError is raised if any required custom fields are not present.
  2335. """
  2336. cf3 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='baz', required=True)
  2337. cf3.save()
  2338. cf3.object_types.set([ObjectType.objects.get_for_model(Site)])
  2339. site = Site(name='Test Site', slug='test-site')
  2340. # Set custom field data with a required field omitted
  2341. site.custom_field_data['foo'] = 'abc'
  2342. with self.assertRaises(ValidationError):
  2343. site.clean()
  2344. site.custom_field_data['baz'] = 'def'
  2345. site.clean()
  2346. def test_required_field_enforced_on_existing_objects(self):
  2347. """
  2348. Adding a required custom field invalidates the objects which already exist, whether they
  2349. carry no key for it -- the normal state now that empty values are not provisioned -- or an
  2350. explicit null. Both are rejected, as they were before: every object then held a materialized
  2351. null, which CustomField.validate() rejects for a required field.
  2352. """
  2353. site = Site.objects.create(name='Test Site', slug='test-site')
  2354. cf = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='req', required=True)
  2355. cf.save()
  2356. cf.object_types.set([ObjectType.objects.get_for_model(Site)])
  2357. # No value was provisioned onto the existing object
  2358. site.refresh_from_db()
  2359. self.assertNotIn('req', site.custom_field_data)
  2360. with self.assertRaises(ValidationError):
  2361. site.clean()
  2362. # An explicit null is rejected identically
  2363. site.custom_field_data['req'] = None
  2364. with self.assertRaises(ValidationError):
  2365. site.clean()
  2366. site.custom_field_data['req'] = 'value'
  2367. site.clean()
  2368. class MissingKeyAwareFilterTestCase(TestCase):
  2369. """
  2370. MissingKeyAwareFilterMixin reimplements MultipleChoiceFilter.filter() for the negated case, so
  2371. it may only be mixed into a class which inherits that method unmodified and which does not
  2372. filter conjoined. Both constraints are enforced, as violating either would yield a wrong result
  2373. set rather than an error.
  2374. """
  2375. def test_factory_rejects_a_class_which_defines_filter(self):
  2376. # MultiValueContentTypeFilter overrides filter() to match on app label and model
  2377. with self.assertRaises(TypeError):
  2378. missing_key_aware_filter_factory(MultiValueContentTypeFilter)
  2379. # BooleanFilter does not inherit MultipleChoiceFilter.filter() at all
  2380. with self.assertRaises(TypeError):
  2381. missing_key_aware_filter_factory(django_filters.BooleanFilter)
  2382. def test_factory_accepts_a_class_which_inherits_filter(self):
  2383. filter_class = missing_key_aware_filter_factory(MultiValueCharFilter)
  2384. self.assertTrue(issubclass(filter_class, MissingKeyAwareFilterMixin))
  2385. self.assertTrue(issubclass(filter_class, MultiValueCharFilter))
  2386. # The factory is cached, so a class yields a single stable subclass
  2387. self.assertIs(filter_class, missing_key_aware_filter_factory(MultiValueCharFilter))
  2388. def test_conjoined_filtering_is_rejected(self):
  2389. filter_class = missing_key_aware_filter_factory(MultiValueCharFilter)
  2390. filter_class(field_name='custom_field_data__foo')
  2391. filter_class(field_name='custom_field_data__foo', conjoined=False)
  2392. with self.assertRaises(TypeError):
  2393. filter_class(field_name='custom_field_data__foo', conjoined=True)
  2394. def test_every_supported_custom_field_type_satisfies_the_constraints(self):
  2395. """
  2396. The filter classes CustomField.to_filter() selects must all remain admissible.
  2397. """
  2398. for cf_type in CustomFieldTypeChoices.values():
  2399. with self.subTest(cf_type):
  2400. cf = CustomField(name='test', type=cf_type)
  2401. # Raises TypeError if the selected filter class violates a constraint
  2402. cf.to_filter()
  2403. cf.to_filter(lookup_expr='empty')
  2404. class CustomFieldModelFilterTestCase(TestCase):
  2405. queryset = Site.objects.all()
  2406. filterset = SiteFilterSet
  2407. @classmethod
  2408. def setUpTestData(cls):
  2409. object_type = ObjectType.objects.get_for_model(Site)
  2410. manufacturers = Manufacturer.objects.bulk_create((
  2411. Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
  2412. Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
  2413. Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
  2414. Manufacturer(name='Manufacturer 4', slug='manufacturer-4'),
  2415. ))
  2416. choice_set = CustomFieldChoiceSet.objects.create(
  2417. name='Custom Field Choice Set 1',
  2418. extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'))
  2419. )
  2420. # Integer filtering
  2421. cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER)
  2422. cf.save()
  2423. cf.object_types.set([object_type])
  2424. # Decimal filtering
  2425. cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_DECIMAL)
  2426. cf.save()
  2427. cf.object_types.set([object_type])
  2428. # Boolean filtering
  2429. cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
  2430. cf.save()
  2431. cf.object_types.set([object_type])
  2432. # Exact text filtering
  2433. cf = CustomField(
  2434. name='cf4',
  2435. type=CustomFieldTypeChoices.TYPE_TEXT,
  2436. filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
  2437. )
  2438. cf.save()
  2439. cf.object_types.set([object_type])
  2440. # Loose text filtering
  2441. cf = CustomField(
  2442. name='cf5',
  2443. type=CustomFieldTypeChoices.TYPE_TEXT,
  2444. filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
  2445. )
  2446. cf.save()
  2447. cf.object_types.set([object_type])
  2448. # Date filtering
  2449. cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_DATE)
  2450. cf.save()
  2451. cf.object_types.set([object_type])
  2452. # Exact URL filtering
  2453. cf = CustomField(
  2454. name='cf7',
  2455. type=CustomFieldTypeChoices.TYPE_URL,
  2456. filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
  2457. )
  2458. cf.save()
  2459. cf.object_types.set([object_type])
  2460. # Loose URL filtering
  2461. cf = CustomField(
  2462. name='cf8',
  2463. type=CustomFieldTypeChoices.TYPE_URL,
  2464. filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
  2465. )
  2466. cf.save()
  2467. cf.object_types.set([object_type])
  2468. # Selection filtering
  2469. cf = CustomField(
  2470. name='cf9',
  2471. type=CustomFieldTypeChoices.TYPE_SELECT,
  2472. choice_set=choice_set
  2473. )
  2474. cf.save()
  2475. cf.object_types.set([object_type])
  2476. # Multiselect filtering
  2477. cf = CustomField(
  2478. name='cf10',
  2479. type=CustomFieldTypeChoices.TYPE_MULTISELECT,
  2480. choice_set=choice_set
  2481. )
  2482. cf.save()
  2483. cf.object_types.set([object_type])
  2484. # Object filtering
  2485. cf = CustomField(
  2486. name='cf11',
  2487. type=CustomFieldTypeChoices.TYPE_OBJECT,
  2488. related_object_type=ObjectType.objects.get_for_model(Manufacturer)
  2489. )
  2490. cf.save()
  2491. cf.object_types.set([object_type])
  2492. # Multi-object filtering
  2493. cf = CustomField(
  2494. name='cf12',
  2495. type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
  2496. related_object_type=ObjectType.objects.get_for_model(Manufacturer)
  2497. )
  2498. cf.save()
  2499. cf.object_types.set([object_type])
  2500. Site.objects.bulk_create([
  2501. Site(name='Site 1', slug='site-1', custom_field_data={
  2502. 'cf1': 100,
  2503. 'cf2': 100.1,
  2504. 'cf3': True,
  2505. 'cf4': 'foo',
  2506. 'cf5': 'foo',
  2507. 'cf6': '2016-06-26',
  2508. 'cf7': 'http://a.example.com',
  2509. 'cf8': 'http://a.example.com',
  2510. 'cf9': 'A',
  2511. 'cf10': ['A', 'B'],
  2512. 'cf11': manufacturers[0].pk,
  2513. 'cf12': [manufacturers[0].pk, manufacturers[3].pk],
  2514. }),
  2515. Site(name='Site 2', slug='site-2', custom_field_data={
  2516. 'cf1': 200,
  2517. 'cf2': 200.2,
  2518. 'cf3': True,
  2519. 'cf4': 'foobar',
  2520. 'cf5': 'foobar',
  2521. 'cf6': '2016-06-27',
  2522. 'cf7': 'http://b.example.com',
  2523. 'cf8': 'http://b.example.com',
  2524. 'cf9': 'B',
  2525. 'cf10': ['B', 'C'],
  2526. 'cf11': manufacturers[1].pk,
  2527. 'cf12': [manufacturers[1].pk, manufacturers[3].pk],
  2528. }),
  2529. Site(name='Site 3', slug='site-3', custom_field_data={
  2530. 'cf1': 300,
  2531. 'cf2': 300.3,
  2532. 'cf3': False,
  2533. 'cf4': 'bar',
  2534. 'cf5': 'bar',
  2535. 'cf6': '2016-06-28',
  2536. 'cf7': 'http://c.example.com',
  2537. 'cf8': 'http://c.example.com',
  2538. 'cf9': 'C',
  2539. 'cf10': None,
  2540. 'cf11': manufacturers[2].pk,
  2541. 'cf12': [manufacturers[2].pk, manufacturers[3].pk],
  2542. }),
  2543. # Carries no custom field data at all. Negated lookups ("is not x") match it, as they
  2544. # do an object holding an explicit null; see MissingKeyAwareFilterMixin.
  2545. Site(name='Site 4', slug='site-4'),
  2546. ])
  2547. def test_filter_integer(self):
  2548. self.assertEqual(self.filterset({'cf_cf1': [100, 200]}, self.queryset).qs.count(), 2)
  2549. self.assertEqual(self.filterset({'cf_cf1__n': [200]}, self.queryset).qs.count(), 3)
  2550. self.assertEqual(self.filterset({'cf_cf1__gt': [200]}, self.queryset).qs.count(), 1)
  2551. self.assertEqual(self.filterset({'cf_cf1__gte': [200]}, self.queryset).qs.count(), 2)
  2552. self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1)
  2553. self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2)
  2554. self.assertEqual(self.filterset({'cf_cf1__empty': True}, self.queryset).qs.count(), 1)
  2555. def test_filter_decimal(self):
  2556. self.assertEqual(self.filterset({'cf_cf2': [100.1, 200.2]}, self.queryset).qs.count(), 2)
  2557. self.assertEqual(self.filterset({'cf_cf2__n': [200.2]}, self.queryset).qs.count(), 3)
  2558. self.assertEqual(self.filterset({'cf_cf2__gt': [200.2]}, self.queryset).qs.count(), 1)
  2559. self.assertEqual(self.filterset({'cf_cf2__gte': [200.2]}, self.queryset).qs.count(), 2)
  2560. self.assertEqual(self.filterset({'cf_cf2__lt': [200.2]}, self.queryset).qs.count(), 1)
  2561. self.assertEqual(self.filterset({'cf_cf2__lte': [200.2]}, self.queryset).qs.count(), 2)
  2562. self.assertEqual(self.filterset({'cf_cf2__empty': True}, self.queryset).qs.count(), 1)
  2563. def test_filter_boolean(self):
  2564. self.assertEqual(self.filterset({'cf_cf3': True}, self.queryset).qs.count(), 2)
  2565. self.assertEqual(self.filterset({'cf_cf3': False}, self.queryset).qs.count(), 1)
  2566. # False is a value, so only the object carrying no value for the field is empty
  2567. self.assertEqual(self.filterset({'cf_cf3__empty': True}, self.queryset).qs.count(), 1)
  2568. self.assertEqual(self.filterset({'cf_cf3__empty': False}, self.queryset).qs.count(), 3)
  2569. def test_filter_boolean_empty_null(self):
  2570. # An explicit null is empty, just as a missing key is
  2571. Site.objects.create(name='Site 5', slug='site-5', custom_field_data={'cf3': None})
  2572. self.assertEqual(self.filterset({'cf_cf3__empty': True}, self.queryset).qs.count(), 2)
  2573. self.assertEqual(self.filterset({'cf_cf3__empty': False}, self.queryset).qs.count(), 3)
  2574. def test_filter_text_strict(self):
  2575. self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 1)
  2576. self.assertEqual(self.filterset({'cf_cf4__n': ['foo']}, self.queryset).qs.count(), 3)
  2577. self.assertEqual(self.filterset({'cf_cf4__ic': ['foo']}, self.queryset).qs.count(), 2)
  2578. self.assertEqual(self.filterset({'cf_cf4__nic': ['foo']}, self.queryset).qs.count(), 2)
  2579. self.assertEqual(self.filterset({'cf_cf4__isw': ['foo']}, self.queryset).qs.count(), 2)
  2580. self.assertEqual(self.filterset({'cf_cf4__nisw': ['foo']}, self.queryset).qs.count(), 2)
  2581. self.assertEqual(self.filterset({'cf_cf4__iew': ['bar']}, self.queryset).qs.count(), 2)
  2582. self.assertEqual(self.filterset({'cf_cf4__niew': ['bar']}, self.queryset).qs.count(), 2)
  2583. self.assertEqual(self.filterset({'cf_cf4__ie': ['FOO']}, self.queryset).qs.count(), 1)
  2584. self.assertEqual(self.filterset({'cf_cf4__nie': ['FOO']}, self.queryset).qs.count(), 3)
  2585. self.assertEqual(self.filterset({'cf_cf4__empty': True}, self.queryset).qs.count(), 1)
  2586. def test_filter_text_loose(self):
  2587. self.assertEqual(self.filterset({'cf_cf5': ['foo']}, self.queryset).qs.count(), 2)
  2588. def test_filter_date(self):
  2589. self.assertEqual(self.filterset({'cf_cf6': ['2016-06-26', '2016-06-27']}, self.queryset).qs.count(), 2)
  2590. self.assertEqual(self.filterset({'cf_cf6__n': ['2016-06-27']}, self.queryset).qs.count(), 3)
  2591. self.assertEqual(self.filterset({'cf_cf6__gt': ['2016-06-27']}, self.queryset).qs.count(), 1)
  2592. self.assertEqual(self.filterset({'cf_cf6__gte': ['2016-06-27']}, self.queryset).qs.count(), 2)
  2593. self.assertEqual(self.filterset({'cf_cf6__lt': ['2016-06-27']}, self.queryset).qs.count(), 1)
  2594. self.assertEqual(self.filterset({'cf_cf6__lte': ['2016-06-27']}, self.queryset).qs.count(), 2)
  2595. self.assertEqual(self.filterset({'cf_cf6__empty': True}, self.queryset).qs.count(), 1)
  2596. def test_filter_url_strict(self):
  2597. self.assertEqual(
  2598. self.filterset({'cf_cf7': ['http://a.example.com', 'http://b.example.com']}, self.queryset).qs.count(),
  2599. 2
  2600. )
  2601. self.assertEqual(self.filterset({'cf_cf7__n': ['http://b.example.com']}, self.queryset).qs.count(), 3)
  2602. self.assertEqual(self.filterset({'cf_cf7__ic': ['b']}, self.queryset).qs.count(), 1)
  2603. self.assertEqual(self.filterset({'cf_cf7__nic': ['b']}, self.queryset).qs.count(), 3)
  2604. self.assertEqual(self.filterset({'cf_cf7__isw': ['http://']}, self.queryset).qs.count(), 3)
  2605. self.assertEqual(self.filterset({'cf_cf7__nisw': ['http://']}, self.queryset).qs.count(), 1)
  2606. self.assertEqual(self.filterset({'cf_cf7__iew': ['.com']}, self.queryset).qs.count(), 3)
  2607. self.assertEqual(self.filterset({'cf_cf7__niew': ['.com']}, self.queryset).qs.count(), 1)
  2608. self.assertEqual(self.filterset({'cf_cf7__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1)
  2609. self.assertEqual(self.filterset({'cf_cf7__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 3)
  2610. self.assertEqual(self.filterset({'cf_cf7__empty': True}, self.queryset).qs.count(), 1)
  2611. def test_filter_url_loose(self):
  2612. self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3)
  2613. def test_filter_negation_matches_unset_values(self):
  2614. """
  2615. A negated lookup must match an object which holds no value for the field, whether that is
  2616. recorded as an explicit null or by the absence of the key; see MissingKeyAwareFilterMixin.
  2617. """
  2618. no_key = Site.objects.get(slug='site-4')
  2619. explicit_null = Site.objects.create(name='Site 5', slug='site-5', custom_field_data={
  2620. 'cf1': None,
  2621. 'cf4': None,
  2622. 'cf6': None,
  2623. 'cf7': None,
  2624. 'cf10': None,
  2625. })
  2626. for filter_name, value in (
  2627. ('cf_cf1__n', 100),
  2628. ('cf_cf4__n', 'foo'),
  2629. ('cf_cf4__nic', 'foo'),
  2630. ('cf_cf4__nisw', 'foo'),
  2631. ('cf_cf4__niew', 'bar'),
  2632. ('cf_cf4__nie', 'FOO'),
  2633. ('cf_cf6__n', '2016-06-26'),
  2634. ('cf_cf7__n', 'http://a.example.com'),
  2635. ('cf_cf7__nic', 'a'),
  2636. ('cf_cf7__nisw', 'http://'),
  2637. ('cf_cf7__niew', '.com'),
  2638. ('cf_cf10__n', 'A'),
  2639. ):
  2640. with self.subTest(filter_name):
  2641. pks = set(
  2642. self.filterset({filter_name: [value]}, self.queryset).qs.values_list('pk', flat=True)
  2643. )
  2644. self.assertIn(no_key.pk, pks, "an object carrying no key must match")
  2645. self.assertIn(explicit_null.pk, pks, "an object holding a null must match")
  2646. def test_filter_null_sentinel_matches_unset_values(self):
  2647. """
  2648. The null sentinel (FILTERS_NULL_CHOICE_VALUE) asks for the objects holding no value, which
  2649. must include those carrying no key as well as those holding an explicit null. Negating it
  2650. must therefore return exactly the objects which do hold a value -- and in particular must
  2651. not return the ones it is being asked to exclude.
  2652. Only string-backed field types are exercised: a numeric or date field rejects 'null' during
  2653. form validation ("Enter a whole number"), so the sentinel never reaches the filter at all.
  2654. That is a property of multivalue_field_factory() and is unaffected by this behavior.
  2655. """
  2656. no_key = Site.objects.get(slug='site-4')
  2657. explicit_null = Site.objects.create(name='Site 5', slug='site-5', custom_field_data={
  2658. 'cf4': None,
  2659. 'cf7': None,
  2660. 'cf9': None,
  2661. })
  2662. has_value = set(
  2663. Site.objects.filter(slug__in=('site-1', 'site-2', 'site-3')).values_list('pk', flat=True)
  2664. )
  2665. for filter_name in ('cf_cf4', 'cf_cf7', 'cf_cf9'):
  2666. with self.subTest(filter_name):
  2667. pks = set(
  2668. self.filterset({filter_name: ['null']}, self.queryset).qs.values_list('pk', flat=True)
  2669. )
  2670. self.assertEqual(pks, {no_key.pk, explicit_null.pk})
  2671. pks = set(
  2672. self.filterset({f'{filter_name}__n': ['null']}, self.queryset)
  2673. .qs.values_list('pk', flat=True)
  2674. )
  2675. self.assertEqual(pks, has_value)
  2676. def test_filter_null_sentinel_combined_with_a_value(self):
  2677. """
  2678. The sentinel may be passed alongside real values, in which case it widens the match rather
  2679. than replacing it. Under negation the valueless objects are then excluded, as they are among
  2680. the values being negated.
  2681. """
  2682. no_key = Site.objects.get(slug='site-4')
  2683. site_1 = Site.objects.get(slug='site-1')
  2684. pks = set(
  2685. self.filterset({'cf_cf4': ['foo', 'null']}, self.queryset).qs.values_list('pk', flat=True)
  2686. )
  2687. self.assertIn(site_1.pk, pks, "an object holding the value must match")
  2688. self.assertIn(no_key.pk, pks, "an object holding no value must match")
  2689. pks = set(
  2690. self.filterset({'cf_cf4__n': ['foo', 'null']}, self.queryset).qs.values_list('pk', flat=True)
  2691. )
  2692. self.assertNotIn(site_1.pk, pks, "an object holding the value must be excluded")
  2693. self.assertNotIn(no_key.pk, pks, "an object holding no value must be excluded")
  2694. def test_filter_select(self):
  2695. self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
  2696. self.assertEqual(self.filterset({'cf_cf9__empty': True}, self.queryset).qs.count(), 1)
  2697. def test_filter_multiselect(self):
  2698. self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1)
  2699. self.assertEqual(self.filterset({'cf_cf10': ['A', 'C']}, self.queryset).qs.count(), 2)
  2700. # Negation excludes the objects whose array holds the value, not those whose array equals it
  2701. self.assertEqual(
  2702. set(self.filterset({'cf_cf10__n': ['A']}, self.queryset).qs.values_list('slug', flat=True)),
  2703. {'site-2', 'site-3', 'site-4'}
  2704. )
  2705. self.assertEqual(
  2706. set(self.filterset({'cf_cf10__n': ['A', 'C']}, self.queryset).qs.values_list('slug', flat=True)),
  2707. {'site-3', 'site-4'}
  2708. )
  2709. # Matches both the object holding a literal null and the one carrying no key, as `empty` does
  2710. self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 2)
  2711. self.assertEqual(self.filterset({'cf_cf10__empty': True}, self.queryset).qs.count(), 2)
  2712. def test_filter_object(self):
  2713. manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
  2714. self.assertEqual(
  2715. self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(),
  2716. 2
  2717. )
  2718. self.assertEqual(self.filterset({'cf_cf11__empty': True}, self.queryset).qs.count(), 1)
  2719. def test_filter_multiobject(self):
  2720. manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
  2721. self.assertEqual(
  2722. self.filterset({'cf_cf12': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(),
  2723. 2
  2724. )
  2725. self.assertEqual(
  2726. self.filterset({'cf_cf12': [manufacturer_ids[3]]}, self.queryset).qs.count(),
  2727. 3
  2728. )
  2729. self.assertEqual(self.filterset({'cf_cf12__empty': True}, self.queryset).qs.count(), 1)
  2730. @contextmanager
  2731. def hold_data_lock(custom_field):
  2732. """
  2733. Hold a custom field's data lock on a connection of its own, as a running background job does.
  2734. A separate connection is what makes the lock observable: it is held for the duration of a job,
  2735. which spans many transactions, so a test cannot take it on the connection it is testing.
  2736. """
  2737. lock_key = CustomField.data_lock_key(custom_field.pk)
  2738. connection = connections.create_connection(DEFAULT_DB_ALIAS)
  2739. try:
  2740. with connection.cursor() as cursor:
  2741. cursor.execute('SELECT pg_try_advisory_lock(%s, %s)', lock_key)
  2742. if not cursor.fetchone()[0]:
  2743. raise RuntimeError(f"Failed to acquire the data lock for {custom_field}")
  2744. released = False
  2745. try:
  2746. yield
  2747. finally:
  2748. # Closing the connection releases the lock asynchronously, so the next deletion can race it
  2749. with connection.cursor() as cursor:
  2750. cursor.execute('SELECT pg_advisory_unlock(%s, %s)', lock_key)
  2751. released = cursor.fetchone()[0]
  2752. # Outside the finally, so a failing body is reported as itself
  2753. if not released:
  2754. raise RuntimeError(f"Failed to release the data lock for {custom_field}")
  2755. finally:
  2756. connection.close()
  2757. @override_settings(BULK_UPDATE_CHUNK_SIZE=1)
  2758. class DeferredCustomFieldDataTestCase(TestCase):
  2759. """
  2760. Where too many objects are affected to update within the request, provisioning and purging
  2761. custom field data is handed to a background job and the field is not live until it completes.
  2762. BULK_UPDATE_CHUNK_SIZE (which doubles as the threshold for deferral) is overridden down so that
  2763. the two objects below force the deferred path. It cannot be set to zero, which the setting
  2764. rejects, and which would also empty every batch so that the jobs updated nothing.
  2765. """
  2766. @classmethod
  2767. def setUpTestData(cls):
  2768. Site.objects.bulk_create([
  2769. Site(name='Site A', slug='site-a'),
  2770. Site(name='Site B', slug='site-b'),
  2771. ])
  2772. cls.object_type = ObjectType.objects.get_for_model(Site)
  2773. def create_field(self, name='field1', **kwargs):
  2774. cf = CustomField.objects.create(name=name, type=CustomFieldTypeChoices.TYPE_TEXT, **kwargs)
  2775. cf.object_types.set([self.object_type])
  2776. cf.refresh_from_db()
  2777. return cf
  2778. #
  2779. # Provisioning
  2780. #
  2781. def test_provisioning_is_deferred(self):
  2782. cf = self.create_field(default='foo')
  2783. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_PROVISIONING)
  2784. # No object data has been written yet
  2785. self.assertEqual(Site.objects.filter(custom_field_data__has_key='field1').count(), 0)
  2786. def test_field_is_not_live_while_provisioning(self):
  2787. cf = self.create_field(default='foo')
  2788. site = Site.objects.first()
  2789. self.assertNotIn(cf, CustomField.objects.get_for_model(Site))
  2790. self.assertNotIn('field1', site.cf)
  2791. self.assertNotIn('field1', {f.name for f in site.get_custom_fields()})
  2792. # It is still reachable where a caller asks for that status, as get_defaults_for_model() does
  2793. self.assertIn(cf, CustomField.objects.get_for_model(
  2794. Site, statuses=(CustomFieldStatusChoices.STATUS_PROVISIONING,)
  2795. ))
  2796. def test_new_objects_receive_default_while_provisioning(self):
  2797. """
  2798. A field is provisioned precisely because it carries a default, so an object created while
  2799. the backfill runs must still receive that default -- the job backfills only what predates
  2800. the field.
  2801. """
  2802. self.create_field(default='foo')
  2803. site = Site.objects.create(name='Site C', slug='site-c')
  2804. site.refresh_from_db()
  2805. self.assertEqual(site.custom_field_data['field1'], 'foo')
  2806. def test_stored_data_survives_validation_while_provisioning(self):
  2807. """
  2808. A field which is not live is not validated, and neither is its stored data pruned as stale.
  2809. Saving an object through full_clean() -- as the edit form, the REST API and bulk edit all do
  2810. -- while the backfill runs must leave the stored value alone rather than reverting it to the
  2811. field's default.
  2812. """
  2813. self.create_field(default='foo')
  2814. site = Site.objects.first()
  2815. # Written via the queryset so that the setup does not itself depend on save()
  2816. Site.objects.filter(pk=site.pk).update(custom_field_data={'field1': 'bar'})
  2817. site.refresh_from_db()
  2818. site.full_clean()
  2819. site.save()
  2820. site.refresh_from_db()
  2821. self.assertEqual(site.custom_field_data['field1'], 'bar')
  2822. def test_required_field_is_not_enforced_while_provisioning(self):
  2823. """
  2824. The field is not live, so an object which the backfill has yet to reach must still validate.
  2825. """
  2826. self.create_field(default='foo', required=True)
  2827. site = Site.objects.first()
  2828. self.assertNotIn('field1', site.custom_field_data)
  2829. site.full_clean() # Must not raise
  2830. def test_provisioning_job_backfills_and_activates(self):
  2831. cf = self.create_field(default='foo')
  2832. self.assertTrue(provision_custom_field(cf.pk, [self.object_type.pk]))
  2833. cf.refresh_from_db()
  2834. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE)
  2835. self.assertEqual(Site.objects.filter(custom_field_data__field1='foo').count(), 2)
  2836. self.assertIn(cf, CustomField.objects.get_for_model(Site))
  2837. def test_provisioning_job_commits_each_batch(self):
  2838. """
  2839. One transaction spanning the whole backfill would hold a row lock on every object it had
  2840. rewritten until it finished, for as long as CUSTOMFIELD_JOB_TIMEOUT allows the job to run
  2841. (see CustomField._update_object_data()).
  2842. """
  2843. cf = self.create_field(default='foo')
  2844. with patch.object(CustomField, '_update_object_data') as update:
  2845. provision_custom_field(cf.pk, [self.object_type.pk])
  2846. update.assert_called()
  2847. for call in update.call_args_list:
  2848. self.assertTrue(call.kwargs['commit_per_batch'])
  2849. def test_provisioning_job_does_not_activate_a_field_marked_for_deletion(self):
  2850. """
  2851. The field's status is rechecked as it is brought live, so that a deletion which landed while
  2852. the backfill ran -- as one could were the job's lock lost with its connection -- is not
  2853. undone by it.
  2854. """
  2855. cf = self.create_field(default='foo')
  2856. def mark_deleting(*args, **kwargs):
  2857. CustomField.objects.filter(pk=cf.pk).update(status=CustomFieldStatusChoices.STATUS_DELETING)
  2858. with patch.object(CustomField, 'populate_initial_data', side_effect=mark_deleting):
  2859. self.assertFalse(provision_custom_field(cf.pk, [self.object_type.pk]))
  2860. cf.refresh_from_db()
  2861. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_DELETING)
  2862. def test_provisioning_job_is_idempotent(self):
  2863. cf = self.create_field(default='foo')
  2864. provision_custom_field(cf.pk, [self.object_type.pk])
  2865. # A second run finds the field no longer awaiting provisioning and does nothing
  2866. self.assertFalse(provision_custom_field(cf.pk, [self.object_type.pk]))
  2867. def test_provisioning_job_overrides_the_default_timeout(self):
  2868. """
  2869. The job is enqueued precisely because the work exceeds what a request can absorb, so it must
  2870. not inherit RQ's default timeout, which is of the same order (see CUSTOMFIELD_JOB_TIMEOUT).
  2871. """
  2872. with patch.object(CustomFieldProvisioningJob, 'enqueue') as enqueue:
  2873. with self.captureOnCommitCallbacks(execute=True):
  2874. cf = self.create_field(default='foo')
  2875. enqueue.assert_called_once()
  2876. self.assertEqual(enqueue.call_args.kwargs['job_timeout'], CUSTOMFIELD_JOB_TIMEOUT)
  2877. self.assertEqual(enqueue.call_args.kwargs['custom_field_pk'], cf.pk)
  2878. def test_provisioning_job_is_enqueued(self):
  2879. """
  2880. The Job record itself must be valid: a custom field cannot be assigned to a Job as its
  2881. object, so the field is identified by primary key instead (see CustomFieldDataJob).
  2882. """
  2883. with patch('core.models.jobs.django_rq') as django_rq:
  2884. with self.captureOnCommitCallbacks(execute=True):
  2885. cf = self.create_field(default='foo')
  2886. job = Job.objects.get(name__startswith=CustomFieldProvisioningJob.name)
  2887. self.assertIsNone(job.object_type)
  2888. self.assertIn(str(cf), job.name)
  2889. self.assertEqual(
  2890. django_rq.get_queue.return_value.enqueue.call_args.kwargs['custom_field_pk'], cf.pk
  2891. )
  2892. def test_provisioning_job_forwards_its_object_types(self):
  2893. """
  2894. Only the caller which deferred the work knows which assignments are the new ones, so the
  2895. types are carried by the job: run() has to pass them to the backfill rather than swallowing
  2896. them, or the field would go live having provisioned nothing.
  2897. """
  2898. cf = self.create_field(default='foo')
  2899. CustomFieldProvisioningJob(Job()).run(custom_field_pk=cf.pk, object_type_pks=[self.object_type.pk])
  2900. cf.refresh_from_db()
  2901. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE)
  2902. self.assertEqual(Site.objects.filter(custom_field_data__field1='foo').count(), 2)
  2903. def test_provisioning_is_scoped_to_the_new_object_types(self):
  2904. """
  2905. Assigning a further object type provisions only that type. The job cannot work this out for
  2906. itself once the assignment is made, so the types are carried to it.
  2907. """
  2908. cf = self.create_field()
  2909. cf.default = 'foo'
  2910. cf.save()
  2911. rack_type = ObjectType.objects.get_for_model(Rack)
  2912. site = Site.objects.first()
  2913. Rack.objects.bulk_create([
  2914. Rack(name='Rack 1', site=site),
  2915. Rack(name='Rack 2', site=site),
  2916. ])
  2917. with patch.object(CustomFieldProvisioningJob, 'enqueue') as enqueue:
  2918. with self.captureOnCommitCallbacks(execute=True):
  2919. cf.object_types.add(rack_type)
  2920. enqueue.assert_called_once()
  2921. self.assertEqual(enqueue.call_args.kwargs['object_type_pks'], [rack_type.pk])
  2922. def test_deferral_weighs_only_the_new_object_types(self):
  2923. """
  2924. A field already assigned to a large table stays inline when assigned a small one: the tables
  2925. provisioned previously are not rewritten, so their size is beside the point.
  2926. """
  2927. cf = self.create_field()
  2928. cf.default = 'foo'
  2929. cf.save()
  2930. # No racks exist, so there is nothing to defer even though the two sites exceed the limit
  2931. cf.object_types.add(ObjectType.objects.get_for_model(Rack))
  2932. cf.refresh_from_db()
  2933. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE)
  2934. def test_provisioning_preserves_stored_values(self):
  2935. """
  2936. An object which already holds a value for the field is left as it is: the backfill supplies
  2937. the default only where no value has been recorded.
  2938. """
  2939. cf = self.create_field(default='foo')
  2940. Site.objects.update(custom_field_data={'field1': 'bar'})
  2941. self.assertTrue(provision_custom_field(cf.pk, [self.object_type.pk]))
  2942. cf.refresh_from_db()
  2943. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE)
  2944. self.assertEqual(Site.objects.filter(custom_field_data__field1='bar').count(), 2)
  2945. def test_field_without_default_is_not_deferred(self):
  2946. """
  2947. A field with no default has nothing to provision, so it goes live immediately regardless of
  2948. how many objects it applies to.
  2949. """
  2950. cf = self.create_field()
  2951. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE)
  2952. def test_field_without_default_enqueues_nothing(self):
  2953. """
  2954. The decision rests with provision_data() rather than its caller, so a field with no default
  2955. must not reach the point of sizing its object types, let alone of handing a job the no-op of
  2956. writing a null to each of them.
  2957. """
  2958. cf = self.create_field()
  2959. with (
  2960. patch.object(CustomField, '_exceeds_inline_limit') as exceeds_limit,
  2961. patch.object(CustomFieldProvisioningJob, 'enqueue') as enqueue,
  2962. ):
  2963. with self.captureOnCommitCallbacks(execute=True):
  2964. cf.provision_data([self.object_type])
  2965. exceeds_limit.assert_not_called()
  2966. enqueue.assert_not_called()
  2967. def test_assignment_is_refused_while_provisioning(self):
  2968. """
  2969. A second deferral would hand its job only the object types newly assigned, and whichever of
  2970. the two jobs ran first would bring the field live -- leaving the other to find a field it no
  2971. longer matched, and its own types unprovisioned. The assignment is refused instead, as every
  2972. other change to a field which is not live is (see CustomField.clean()).
  2973. """
  2974. cf = self.create_field(default='foo')
  2975. rack_type = ObjectType.objects.get_for_model(Rack)
  2976. with patch.object(CustomFieldProvisioningJob, 'enqueue') as enqueue:
  2977. with self.assertRaises(AbortRequest):
  2978. # Contained in a savepoint: the assignment is rolled back by the refusal, which
  2979. # would otherwise leave the test's own transaction needing one
  2980. with transaction.atomic():
  2981. cf.object_types.add(rack_type)
  2982. enqueue.assert_not_called()
  2983. cf.refresh_from_db()
  2984. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_PROVISIONING)
  2985. self.assertNotIn(rack_type.pk, cf.object_types.values_list('pk', flat=True))
  2986. def test_assignment_is_refused_while_deleting(self):
  2987. """
  2988. The refusal precedes the check for a default: a field whose data is being purged must not
  2989. take on further object types either, whether or not it has anything to provision on them.
  2990. """
  2991. cf = self.create_field()
  2992. cf.delete()
  2993. rack_type = ObjectType.objects.get_for_model(Rack)
  2994. with self.assertRaises(AbortRequest):
  2995. with transaction.atomic():
  2996. cf.object_types.add(rack_type)
  2997. cf.refresh_from_db()
  2998. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_DELETING)
  2999. self.assertNotIn(rack_type.pk, cf.object_types.values_list('pk', flat=True))
  3000. def test_assignment_is_refused_against_the_stored_status(self):
  3001. """
  3002. The status is read from the database rather than taken from the instance in hand, which a
  3003. job may have taken offline (or brought live) since it was fetched.
  3004. """
  3005. cf = self.create_field()
  3006. # Marked directly, leaving the instance in hand still reporting the field as live
  3007. CustomField.objects.filter(pk=cf.pk).update(status=CustomFieldStatusChoices.STATUS_PROVISIONING)
  3008. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE)
  3009. with self.assertRaises(AbortRequest):
  3010. with transaction.atomic():
  3011. cf.object_types.add(ObjectType.objects.get_for_model(Rack))
  3012. def test_assignment_to_a_removed_field_is_refused(self):
  3013. """
  3014. The row may be gone by the time the status is read under its lock, the field having been
  3015. deleted since the instance in hand was fetched. The assignment is reported as refused rather
  3016. than failing on the absent status.
  3017. """
  3018. cf = self.create_field()
  3019. rack_type = ObjectType.objects.get_for_model(Rack)
  3020. # Removed directly, leaving the instance in hand still reporting the field as live
  3021. CustomField.objects.filter(pk=cf.pk).delete()
  3022. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE)
  3023. with self.assertRaises(AbortRequest):
  3024. with transaction.atomic():
  3025. cf.provision_data([rack_type])
  3026. def test_unassignment_is_refused_while_provisioning(self):
  3027. """
  3028. A field being provisioned must not be unassigned from an object type either: the job carries
  3029. the object types it was given, and would write its defaults into objects behind the removal.
  3030. """
  3031. cf = self.create_field(default='foo')
  3032. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_PROVISIONING)
  3033. with self.assertRaises(AbortRequest):
  3034. with transaction.atomic():
  3035. cf.object_types.remove(self.object_type)
  3036. self.assertIn(self.object_type.pk, cf.object_types.values_list('pk', flat=True))
  3037. def test_unassignment_is_refused_while_deleting(self):
  3038. cf = self.create_field()
  3039. cf.delete()
  3040. with self.assertRaises(AbortRequest):
  3041. with transaction.atomic():
  3042. cf.object_types.remove(self.object_type)
  3043. self.assertIn(self.object_type.pk, cf.object_types.values_list('pk', flat=True))
  3044. def test_clearing_object_types_is_refused_while_provisioning(self):
  3045. """
  3046. clear() is handled ahead of the removal rather than after it, as remove() is, so the refusal
  3047. reaches the caller with the assignments still in place.
  3048. """
  3049. cf = self.create_field(default='foo')
  3050. with self.assertRaises(AbortRequest):
  3051. with transaction.atomic():
  3052. cf.object_types.clear()
  3053. self.assertIn(self.object_type.pk, cf.object_types.values_list('pk', flat=True))
  3054. def test_unassignment_is_refused_against_the_stored_status(self):
  3055. """
  3056. The status is read from the database rather than taken from the instance in hand, which a
  3057. job may have taken offline since it was fetched.
  3058. """
  3059. cf = self.create_field()
  3060. # Marked directly, leaving the instance in hand still reporting the field as live
  3061. CustomField.objects.filter(pk=cf.pk).update(status=CustomFieldStatusChoices.STATUS_PROVISIONING)
  3062. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE)
  3063. with self.assertRaises(AbortRequest):
  3064. with transaction.atomic():
  3065. cf.object_types.remove(self.object_type)
  3066. def test_unassignment_is_permitted_once_the_field_is_live(self):
  3067. cf = self.create_field(default='foo')
  3068. Site.objects.update(custom_field_data={'field1': 'foo'})
  3069. self.assertTrue(provision_custom_field(cf.pk, [self.object_type.pk]))
  3070. cf.refresh_from_db()
  3071. cf.object_types.remove(self.object_type) # Must not raise
  3072. self.assertEqual(Site.objects.filter(custom_field_data__has_key='field1').count(), 0)
  3073. def test_provisioning_job_skips_object_types_since_unassigned(self):
  3074. """
  3075. The job provisions only those of its object types which the field still carries. remove_data()
  3076. refuses an unassignment while the field is being provisioned, so this covers a change made
  3077. through the m2m table directly, which emits no signal for either to act on.
  3078. """
  3079. cf = self.create_field(default='foo')
  3080. rack_type = ObjectType.objects.get_for_model(Rack)
  3081. object_type_pks = [self.object_type.pk, rack_type.pk]
  3082. # Unassigned without the signal handler's involvement
  3083. CustomField.object_types.through.objects.filter(
  3084. customfield=cf, contenttype=self.object_type
  3085. ).delete()
  3086. self.assertTrue(provision_custom_field(cf.pk, object_type_pks))
  3087. self.assertEqual(Site.objects.filter(custom_field_data__has_key='field1').count(), 0)
  3088. def test_assignment_is_permitted_once_the_field_is_live(self):
  3089. """
  3090. The refusal lasts only as long as the pending update: once the job has brought the field
  3091. live, further object types are assigned as usual.
  3092. """
  3093. cf = self.create_field(default='foo')
  3094. self.assertTrue(provision_custom_field(cf.pk, [self.object_type.pk]))
  3095. rack_type = ObjectType.objects.get_for_model(Rack)
  3096. cf.refresh_from_db()
  3097. cf.object_types.add(rack_type) # Must not raise
  3098. self.assertIn(rack_type.pk, cf.object_types.values_list('pk', flat=True))
  3099. #
  3100. # Request cache
  3101. #
  3102. def test_taking_a_field_offline_clears_the_cached_fields(self):
  3103. """
  3104. The fields cached for a request span the whole of it -- and the whole of a script or job run
  3105. -- so a field taken offline partway through one must not be served from what was cached
  3106. before it.
  3107. """
  3108. token = query_cache.set(defaultdict(dict))
  3109. self.addCleanup(query_cache.reset, token)
  3110. CustomField.objects.get_for_model(Site) # Prime the cache while no field is assigned
  3111. cf = self.create_field(default='foo')
  3112. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_PROVISIONING)
  3113. # Not live, but its default still reaches the objects created while it is provisioned
  3114. self.assertEqual(CustomField.objects.get_for_model(Site), [])
  3115. self.assertEqual(CustomField.objects.get_defaults_for_model(Site), {'field1': 'foo'})
  3116. def test_marking_a_field_for_deletion_clears_the_cached_fields(self):
  3117. cf = self.create_field()
  3118. token = query_cache.set(defaultdict(dict))
  3119. self.addCleanup(query_cache.reset, token)
  3120. self.assertEqual([f.pk for f in CustomField.objects.get_for_model(Site)], [cf.pk])
  3121. cf.delete()
  3122. self.assertEqual(CustomField.objects.get_for_model(Site), [])
  3123. def test_bringing_a_field_live_clears_the_cached_fields(self):
  3124. cf = self.create_field(default='foo')
  3125. token = query_cache.set(defaultdict(dict))
  3126. self.addCleanup(query_cache.reset, token)
  3127. self.assertEqual(CustomField.objects.get_for_model(Site), []) # Not live while provisioning
  3128. self.assertTrue(provision_custom_field(cf.pk, [self.object_type.pk]))
  3129. self.assertEqual([f.pk for f in CustomField.objects.get_for_model(Site)], [cf.pk])
  3130. #
  3131. # Deletion
  3132. #
  3133. def test_deletion_is_deferred(self):
  3134. cf = self.create_field()
  3135. Site.objects.update(custom_field_data={'field1': 'foo'})
  3136. cf.delete()
  3137. cf = CustomField.objects.get(pk=cf.pk)
  3138. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_DELETING)
  3139. # The stored data is left for the purge job
  3140. self.assertEqual(Site.objects.filter(custom_field_data__has_key='field1').count(), 2)
  3141. def test_deletion_is_deferred_even_without_stored_data(self):
  3142. """
  3143. The deferral decision weighs every row of the assigned types, not just those which hold a
  3144. value, so a field holding no data on an over-limit table is still deferred. Deliberate: the
  3145. probe cannot count the rows holding a key without a sequential scan (see
  3146. _exceeds_inline_limit()), and the purge job it hands off to has nothing to do.
  3147. """
  3148. cf = self.create_field()
  3149. self.assertEqual(Site.objects.filter(custom_field_data__has_key='field1').count(), 0)
  3150. cf.delete()
  3151. cf = CustomField.objects.get(pk=cf.pk)
  3152. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_DELETING)
  3153. self.assertTrue(purge_custom_field(cf.pk))
  3154. self.assertFalse(CustomField.objects.filter(pk=cf.pk).exists())
  3155. def test_field_is_not_live_while_deleting(self):
  3156. cf = self.create_field()
  3157. Site.objects.update(custom_field_data={'field1': 'foo'})
  3158. cf.delete()
  3159. site = Site.objects.first()
  3160. self.assertNotIn(cf, CustomField.objects.get_for_model(Site))
  3161. self.assertNotIn('field1', site.cf)
  3162. self.assertNotIn('field1', {f.name for f in site.get_custom_fields()})
  3163. def test_stored_data_is_pruned_while_deleting(self):
  3164. """
  3165. The converse of a field being provisioned: one on its way out has no claim on the data, so an
  3166. object saved before the purge job reaches it sheds the value as stale.
  3167. """
  3168. cf = self.create_field()
  3169. Site.objects.update(custom_field_data={'field1': 'foo'})
  3170. cf.delete()
  3171. site = Site.objects.first()
  3172. site.full_clean()
  3173. site.save()
  3174. site.refresh_from_db()
  3175. self.assertNotIn('field1', site.custom_field_data)
  3176. def test_purge_job_removes_data_and_field(self):
  3177. cf = self.create_field()
  3178. Site.objects.update(custom_field_data={'field1': 'foo'})
  3179. cf.delete()
  3180. self.assertTrue(purge_custom_field(cf.pk))
  3181. self.assertFalse(CustomField.objects.filter(pk=cf.pk).exists())
  3182. self.assertEqual(Site.objects.filter(custom_field_data__has_key='field1').count(), 0)
  3183. def test_purge_job_commits_each_batch(self):
  3184. cf = self.create_field()
  3185. Site.objects.update(custom_field_data={'field1': 'foo'})
  3186. cf.delete()
  3187. with patch.object(CustomField, '_update_object_data') as update:
  3188. purge_custom_field(cf.pk)
  3189. update.assert_called()
  3190. for call in update.call_args_list:
  3191. self.assertTrue(call.kwargs['commit_per_batch'])
  3192. def test_purge_job_is_idempotent(self):
  3193. cf = self.create_field()
  3194. cf.delete()
  3195. purge_custom_field(cf.pk)
  3196. # A second run finds the field already gone and does nothing
  3197. self.assertFalse(purge_custom_field(cf.pk))
  3198. def test_deleting_twice_does_not_repeat_the_deletion(self):
  3199. cf = self.create_field()
  3200. cf.delete()
  3201. cf.delete()
  3202. self.assertTrue(CustomField.objects.filter(pk=cf.pk).exists())
  3203. def test_deletion_is_decided_against_the_stored_status(self):
  3204. """
  3205. The status is read from the database rather than taken from the instance in hand, which a
  3206. concurrent deletion may have marked since it was fetched. Acting on the stale copy would
  3207. dispatch the deletion signals a second time, recording a second change and firing the
  3208. deletion's event rules again for a field already gone.
  3209. """
  3210. cf = self.create_field()
  3211. # Retained, as a deletion clears the primary key of the instance it was called on
  3212. pk = cf.pk
  3213. # Marked directly, leaving the instance in hand still reporting the field as live
  3214. CustomField.objects.filter(pk=pk).update(status=CustomFieldStatusChoices.STATUS_DELETING)
  3215. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE)
  3216. request = RequestFactory().get('/')
  3217. request.id = uuid.uuid4()
  3218. request.user = self.user
  3219. with event_tracking(request):
  3220. deleted = cf.delete()
  3221. self.assertEqual(deleted, (0, {}))
  3222. self.assertFalse(
  3223. ObjectChange.objects.filter(
  3224. changed_object_type=ObjectType.objects.get_for_model(CustomField),
  3225. changed_object_id=pk,
  3226. action=ObjectChangeActionChoices.ACTION_DELETE,
  3227. ).exists()
  3228. )
  3229. def test_deleting_a_field_already_removed_is_a_no_op(self):
  3230. """
  3231. The row may be gone by the time the status is read under its lock, a concurrent request
  3232. having deleted the field outright. Nothing remains to delete, to report, or to purge.
  3233. """
  3234. cf = self.create_field()
  3235. # Retained, as a deletion clears the primary key of the instance it was called on
  3236. pk = cf.pk
  3237. # Removed directly, leaving the instance in hand still reporting the field as live
  3238. CustomField.objects.filter(pk=pk).delete()
  3239. request = RequestFactory().get('/')
  3240. request.id = uuid.uuid4()
  3241. request.user = self.user
  3242. with patch.object(CustomFieldPurgeJob, 'enqueue') as enqueue:
  3243. with event_tracking(request), self.captureOnCommitCallbacks(execute=True):
  3244. deleted = cf.delete()
  3245. self.assertEqual(deleted, (0, {}))
  3246. enqueue.assert_not_called()
  3247. # The deletion signals belong to the request which removed the row, not to this one
  3248. self.assertFalse(
  3249. ObjectChange.objects.filter(
  3250. changed_object_type=ObjectType.objects.get_for_model(CustomField),
  3251. changed_object_id=pk,
  3252. action=ObjectChangeActionChoices.ACTION_DELETE,
  3253. ).exists()
  3254. )
  3255. def test_deleting_a_field_already_pending_deletion_enqueues_a_fresh_purge_job(self):
  3256. """
  3257. delete() is the only route to a purge job, so a field left pending deletion by a job which
  3258. never ran must be given another when its deletion is retried. It could not otherwise be
  3259. removed at all, and would hold its name against a replacement indefinitely.
  3260. """
  3261. cf = self.create_field()
  3262. cf.delete()
  3263. with patch.object(CustomFieldPurgeJob, 'enqueue') as enqueue:
  3264. with self.captureOnCommitCallbacks(execute=True):
  3265. cf.delete()
  3266. enqueue.assert_called_once()
  3267. self.assertEqual(enqueue.call_args.kwargs['custom_field_pk'], cf.pk)
  3268. def test_deleting_a_field_being_purged_is_refused(self):
  3269. """
  3270. A running purge job needs no help, and a second job would only wait on its lock, occupying a
  3271. worker for as long as the first ran (see CUSTOMFIELD_JOB_TIMEOUT). The retry is refused as
  3272. the deletion of a live field would be: returning quietly would have the caller report a
  3273. deletion which did not happen, the field remaining exactly as it was.
  3274. """
  3275. cf = self.create_field()
  3276. # Marked directly rather than by delete(), which would take the lock on this connection and
  3277. # hold it for the remainder of the test transaction, leaving none for the job to hold
  3278. CustomField.objects.filter(pk=cf.pk).update(status=CustomFieldStatusChoices.STATUS_DELETING)
  3279. cf.refresh_from_db()
  3280. with patch.object(CustomFieldPurgeJob, 'enqueue') as enqueue:
  3281. with hold_data_lock(cf):
  3282. with self.assertRaises(AbortRequest):
  3283. cf.delete()
  3284. enqueue.assert_not_called()
  3285. cf.refresh_from_db()
  3286. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_DELETING)
  3287. def test_deleting_a_field_being_purged_is_reported_to_the_user(self):
  3288. """
  3289. The refusal reaches the user as an error, rather than the unqualified success the delete view
  3290. reports for any deletion which does not raise.
  3291. """
  3292. cf = self.create_field()
  3293. CustomField.objects.filter(pk=cf.pk).update(status=CustomFieldStatusChoices.STATUS_DELETING)
  3294. cf.refresh_from_db()
  3295. self.add_permissions('extras.view_customfield', 'extras.delete_customfield')
  3296. with hold_data_lock(cf):
  3297. response = self.client.post(
  3298. reverse('extras:customfield_delete', kwargs={'pk': cf.pk}),
  3299. data={'confirm': True},
  3300. follow=True,
  3301. )
  3302. self.assertEqual(
  3303. [str(m) for m in response.context['messages']],
  3304. [f"Custom field '{cf.name}' is being updated by a background job and cannot be deleted "
  3305. f"until that job has completed."]
  3306. )
  3307. self.assertTrue(CustomField.objects.filter(pk=cf.pk).exists())
  3308. def test_aborted_deletion_leaves_the_field_intact(self):
  3309. """
  3310. A receiver rejecting the deletion (e.g. handle_deleted_object() raising AbortRequest for a
  3311. failed protection rule) must leave the field live, rather than marked for a purge which
  3312. would destroy the very data the rule protected.
  3313. """
  3314. cf = self.create_field()
  3315. Site.objects.update(custom_field_data={'field1': 'foo'})
  3316. def reject(sender, instance, **kwargs):
  3317. raise AbortRequest("Deletion is prevented by a protection rule")
  3318. pre_delete.connect(reject, sender=CustomField)
  3319. try:
  3320. with self.assertRaises(AbortRequest):
  3321. cf.delete()
  3322. finally:
  3323. pre_delete.disconnect(reject, sender=CustomField)
  3324. cf = CustomField.objects.get(pk=cf.pk)
  3325. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE)
  3326. self.assertIn(cf, CustomField.objects.get_for_model(Site))
  3327. self.assertEqual(Site.objects.filter(custom_field_data__field1='foo').count(), 2)
  3328. def test_purge_job_overrides_the_default_timeout(self):
  3329. cf = self.create_field()
  3330. Site.objects.update(custom_field_data={'field1': 'foo'})
  3331. with patch.object(CustomFieldPurgeJob, 'enqueue') as enqueue:
  3332. with self.captureOnCommitCallbacks(execute=True):
  3333. cf.delete()
  3334. enqueue.assert_called_once()
  3335. self.assertEqual(enqueue.call_args.kwargs['job_timeout'], CUSTOMFIELD_JOB_TIMEOUT)
  3336. self.assertEqual(enqueue.call_args.kwargs['custom_field_pk'], cf.pk)
  3337. def test_purge_job_is_enqueued(self):
  3338. cf = self.create_field()
  3339. Site.objects.update(custom_field_data={'field1': 'foo'})
  3340. with patch('core.models.jobs.django_rq') as django_rq:
  3341. with self.captureOnCommitCallbacks(execute=True):
  3342. cf.delete()
  3343. job = Job.objects.get(name__startswith=CustomFieldPurgeJob.name)
  3344. self.assertIsNone(job.object_type)
  3345. self.assertIn(str(cf), job.name)
  3346. self.assertEqual(
  3347. django_rq.get_queue.return_value.enqueue.call_args.kwargs['custom_field_pk'], cf.pk
  3348. )
  3349. def test_deletion_records_a_change(self):
  3350. """
  3351. The change log must report the deletion where the user performed it, rather than when the
  3352. row is eventually removed in a worker (where there is no request to attribute it to).
  3353. """
  3354. cf = self.create_field()
  3355. request = RequestFactory().get('/')
  3356. request.id = uuid.uuid4()
  3357. request.user = self.user
  3358. with event_tracking(request):
  3359. cf.delete()
  3360. self.assertTrue(
  3361. ObjectChange.objects.filter(
  3362. changed_object_type=ObjectType.objects.get_for_model(CustomField),
  3363. changed_object_id=cf.pk,
  3364. action=ObjectChangeActionChoices.ACTION_DELETE,
  3365. ).exists()
  3366. )
  3367. def test_deletion_is_scoped_to_the_write_database(self):
  3368. """
  3369. The commit hook must be registered against the connection the marking was written on, or the
  3370. purge job can be enqueued before -- or without -- the field being durably marked.
  3371. """
  3372. cf = self.create_field()
  3373. with patch('extras.models.customfields.transaction.on_commit') as on_commit:
  3374. cf.delete()
  3375. # transaction.on_commit is patched on the shared module, so hooks registered by unrelated
  3376. # machinery during the delete (deferred search indexing, for one) are captured here too.
  3377. # Select the hook which enqueues the purge job rather than assuming it is the only one.
  3378. calls = [
  3379. call for call in on_commit.call_args_list
  3380. if 'CustomField.delete' in getattr(call.args[0], '__qualname__', '')
  3381. ]
  3382. self.assertEqual(len(calls), 1)
  3383. self.assertEqual(calls[0].kwargs['using'], DEFAULT_DB_ALIAS)
  3384. #
  3385. # Name reservation
  3386. #
  3387. def test_name_is_reserved_while_deleting(self):
  3388. """
  3389. A field pending deletion holds its name, so that a new field cannot inherit the values still
  3390. stored against it.
  3391. """
  3392. cf = self.create_field()
  3393. Site.objects.update(custom_field_data={'field1': 'foo'})
  3394. cf.delete()
  3395. replacement = CustomField(name='field1', type=CustomFieldTypeChoices.TYPE_TEXT)
  3396. with self.assertRaises(ValidationError):
  3397. replacement.full_clean()
  3398. def test_rename_onto_reserved_name_is_rejected(self):
  3399. cf = self.create_field()
  3400. Site.objects.update(custom_field_data={'field1': 'foo'})
  3401. cf.delete()
  3402. other = self.create_field(name='field2')
  3403. other.name = 'field1'
  3404. with self.assertRaises(ValidationError):
  3405. other.full_clean()
  3406. def test_name_is_released_once_purged(self):
  3407. cf = self.create_field()
  3408. cf.delete()
  3409. purge_custom_field(cf.pk)
  3410. replacement = CustomField(name='field1', type=CustomFieldTypeChoices.TYPE_TEXT)
  3411. replacement.full_clean() # Should not raise
  3412. #
  3413. # Modification and deletion guards
  3414. #
  3415. def test_pending_field_cannot_be_modified(self):
  3416. cf = self.create_field(default='foo')
  3417. cf.label = 'Changed'
  3418. with self.assertRaises(ValidationError):
  3419. cf.full_clean()
  3420. def test_deletion_claims_the_data_lock_without_waiting(self):
  3421. """
  3422. A job holds the field's data lock for the duration of its bulk update, so a deletion which
  3423. waited on it would occupy a worker for as long as the job ran (see CUSTOMFIELD_JOB_TIMEOUT).
  3424. """
  3425. cf = self.create_field()
  3426. with CaptureQueriesContext(connection) as queries:
  3427. cf.delete()
  3428. self.assertTrue(
  3429. any('pg_try_advisory_xact_lock' in query['sql'] for query in queries),
  3430. "Deletion did not claim the field's data lock without waiting"
  3431. )
  3432. def test_deletion_holds_the_data_lock_until_its_transaction_ends(self):
  3433. """
  3434. Where the caller supplies its own transaction -- BulkDeleteView, and every REST API deletion
  3435. -- the field's new status is still uncommitted when delete() returns. Releasing the lock
  3436. there would let a provisioning job read the field as live and set it live again.
  3437. """
  3438. cf = self.create_field()
  3439. with transaction.atomic():
  3440. cf.delete()
  3441. # delete() has returned and its own atomic block has exited, but the enclosing transaction
  3442. # has yet to commit, so the lock must still be held
  3443. with self.assertRaisesMessage(RuntimeError, "Failed to acquire the data lock"):
  3444. with hold_data_lock(cf):
  3445. pass
  3446. def test_deletion_is_refused_while_a_job_holds_the_data_lock(self):
  3447. """
  3448. Failing to take the lock aborts the deletion cleanly, rather than surfacing a database error,
  3449. and must leave the field exactly as it was.
  3450. """
  3451. cf = self.create_field()
  3452. with hold_data_lock(cf):
  3453. with self.assertRaises(AbortRequest):
  3454. cf.delete()
  3455. cf.refresh_from_db()
  3456. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE)
  3457. cf.delete() # Released: the deletion now proceeds
  3458. cf.refresh_from_db()
  3459. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_DELETING)
  3460. def test_stranded_field_can_be_deleted(self):
  3461. """
  3462. The refusal is on the lock, not on the status: a field left mid-provisioning by a job which
  3463. never ran holds no lock, and must remain deletable.
  3464. """
  3465. cf = self.create_field(default='foo')
  3466. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_PROVISIONING)
  3467. cf.delete()
  3468. cf.refresh_from_db()
  3469. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_DELETING)
  3470. class InlineCustomFieldDataTestCase(TestCase):
  3471. """
  3472. Where few enough objects are affected, provisioning and purging remain synchronous: the field is
  3473. live (or gone) as soon as the request completes, with no background job involved.
  3474. """
  3475. @classmethod
  3476. def setUpTestData(cls):
  3477. Site.objects.create(name='Site A', slug='site-a')
  3478. cls.object_type = ObjectType.objects.get_for_model(Site)
  3479. def test_provisioning_is_inline(self):
  3480. cf = CustomField.objects.create(
  3481. name='field1', type=CustomFieldTypeChoices.TYPE_TEXT, default='foo'
  3482. )
  3483. cf.object_types.set([self.object_type])
  3484. cf.refresh_from_db()
  3485. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE)
  3486. self.assertEqual(Site.objects.filter(custom_field_data__field1='foo').count(), 1)
  3487. def test_inline_provisioning_is_atomic(self):
  3488. """
  3489. The request path answers to an enclosing transaction, which owns the commit: committing each
  3490. batch there would silently do nothing, and a failure part-way must leave nothing behind.
  3491. """
  3492. with patch.object(CustomField, '_update_object_data') as update:
  3493. cf = CustomField.objects.create(
  3494. name='field1', type=CustomFieldTypeChoices.TYPE_TEXT, default='foo'
  3495. )
  3496. cf.object_types.set([self.object_type])
  3497. update.assert_called()
  3498. for call in update.call_args_list:
  3499. self.assertFalse(call.kwargs['commit_per_batch'])
  3500. def test_default_added_later_is_not_backfilled(self):
  3501. """
  3502. A default added to a field which already exists is not backfilled, and assigning a further
  3503. object type must not backfill it either: only the newly assigned type is provisioned. The
  3504. sites below would otherwise acquire a value they were documented never to receive.
  3505. """
  3506. cf = CustomField.objects.create(name='field1', type=CustomFieldTypeChoices.TYPE_TEXT)
  3507. cf.object_types.set([self.object_type])
  3508. self.assertEqual(Site.objects.first().custom_field_data, {})
  3509. cf.default = 'foo'
  3510. cf.save()
  3511. self.assertEqual(Site.objects.first().custom_field_data, {})
  3512. rack = Rack.objects.create(name='Rack 1', site=Site.objects.first())
  3513. cf.object_types.add(ObjectType.objects.get_for_model(Rack))
  3514. # The newly assigned type is provisioned; the one assigned before the default is not
  3515. rack.refresh_from_db()
  3516. self.assertEqual(rack.custom_field_data['field1'], 'foo')
  3517. self.assertEqual(Site.objects.first().custom_field_data, {})
  3518. def test_provisioning_preserves_existing_values(self):
  3519. """
  3520. Values stored against a type assigned previously must survive a further assignment.
  3521. """
  3522. cf = CustomField.objects.create(
  3523. name='field1', type=CustomFieldTypeChoices.TYPE_TEXT, default='foo'
  3524. )
  3525. cf.object_types.set([self.object_type])
  3526. Site.objects.update(custom_field_data={'field1': 'bar'})
  3527. cf.object_types.add(ObjectType.objects.get_for_model(Rack))
  3528. self.assertEqual(Site.objects.first().custom_field_data['field1'], 'bar')
  3529. def test_provisioning_preserves_cleared_values(self):
  3530. """
  3531. A cleared value is stored as a JSON null rather than an absent key, and must survive
  3532. reprovisioning just as a set value does.
  3533. """
  3534. cf = CustomField.objects.create(
  3535. name='field1', type=CustomFieldTypeChoices.TYPE_TEXT, default='foo'
  3536. )
  3537. cf.object_types.set([self.object_type])
  3538. Site.objects.update(custom_field_data={'field1': None})
  3539. cf.object_types.add(ObjectType.objects.get_for_model(Rack))
  3540. self.assertIsNone(Site.objects.first().custom_field_data['field1'])
  3541. def test_deletion_is_inline(self):
  3542. cf = CustomField.objects.create(name='field1', type=CustomFieldTypeChoices.TYPE_TEXT)
  3543. cf.object_types.set([self.object_type])
  3544. Site.objects.update(custom_field_data={'field1': 'foo'})
  3545. cf.delete()
  3546. self.assertFalse(CustomField.objects.filter(pk=cf.pk).exists())
  3547. self.assertEqual(Site.objects.filter(custom_field_data__has_key='field1').count(), 0)
  3548. @override_settings(BULK_UPDATE_CHUNK_SIZE=None)
  3549. class UnchunkedCustomFieldDataTestCase(TestCase):
  3550. """
  3551. Setting BULK_UPDATE_CHUNK_SIZE to None disables chunking, so a bulk update is issued as a single
  3552. unbounded statement. There is then no batch size for the deferral threshold to test against, and
  3553. an unbounded JSONB rewrite is exactly what must not run inside a request -- so any affected
  3554. object sends the work to a background job, which issues that one statement under a timeout
  3555. generous enough to survive it.
  3556. """
  3557. @classmethod
  3558. def setUpTestData(cls):
  3559. Site.objects.create(name='Site A', slug='site-a')
  3560. cls.object_type = ObjectType.objects.get_for_model(Site)
  3561. @staticmethod
  3562. def _count_updates(queries, model):
  3563. """
  3564. Count the UPDATE statements issued against the given model's table, ignoring those the job
  3565. makes to the custom field row itself (marking it active).
  3566. """
  3567. table = model._meta.db_table
  3568. return len([
  3569. q for q in queries
  3570. if q['sql'].strip().upper().startswith('UPDATE') and table in q['sql']
  3571. ])
  3572. def test_provisioning_is_deferred(self):
  3573. """
  3574. A single object is enough: with chunking disabled there is no bound on the statement the
  3575. request would otherwise issue.
  3576. """
  3577. cf = CustomField.objects.create(
  3578. name='field1', type=CustomFieldTypeChoices.TYPE_TEXT, default='foo'
  3579. )
  3580. cf.object_types.set([self.object_type])
  3581. cf.refresh_from_db()
  3582. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_PROVISIONING)
  3583. self.assertEqual(Site.objects.filter(custom_field_data__has_key='field1').count(), 0)
  3584. def test_provisioning_job_backfills_in_a_single_statement(self):
  3585. cf = CustomField.objects.create(
  3586. name='field1', type=CustomFieldTypeChoices.TYPE_TEXT, default='foo'
  3587. )
  3588. cf.object_types.set([self.object_type])
  3589. with CaptureQueriesContext(connection) as queries:
  3590. self.assertTrue(provision_custom_field(cf.pk, [self.object_type.pk]))
  3591. # One statement covers the table, rather than one per batch
  3592. self.assertEqual(self._count_updates(queries.captured_queries, Site), 1)
  3593. cf.refresh_from_db()
  3594. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE)
  3595. self.assertEqual(Site.objects.filter(custom_field_data__field1='foo').count(), 1)
  3596. def test_field_affecting_no_objects_stays_inline(self):
  3597. """
  3598. A limit of zero still leaves the probe testing for a single row, so a field which rewrites
  3599. nothing goes live in the request rather than waiting on a job with no work to do.
  3600. """
  3601. cf = CustomField.objects.create(
  3602. name='field1', type=CustomFieldTypeChoices.TYPE_TEXT, default='foo'
  3603. )
  3604. # No racks exist, so there is nothing to rewrite
  3605. cf.object_types.set([ObjectType.objects.get_for_model(Rack)])
  3606. cf.refresh_from_db()
  3607. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE)
  3608. def test_deletion_is_deferred(self):
  3609. cf = CustomField.objects.create(name='field1', type=CustomFieldTypeChoices.TYPE_TEXT)
  3610. cf.object_types.set([self.object_type])
  3611. Site.objects.update(custom_field_data={'field1': 'foo'})
  3612. cf.delete()
  3613. cf.refresh_from_db()
  3614. self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_DELETING)
  3615. self.assertTrue(purge_custom_field(cf.pk))
  3616. self.assertFalse(CustomField.objects.filter(pk=cf.pk).exists())
  3617. self.assertEqual(Site.objects.filter(custom_field_data__has_key='field1').count(), 0)