test_customfields.py 89 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154
  1. import datetime
  2. import json
  3. from decimal import Decimal
  4. from unittest.mock import patch
  5. from django.core.exceptions import ValidationError
  6. from django.test import tag
  7. from django.urls import reverse
  8. from rest_framework import status
  9. from core.models import ObjectChange, ObjectType
  10. from dcim.filtersets import SiteFilterSet
  11. from dcim.forms import SiteImportForm
  12. from dcim.models import Manufacturer, Rack, Site
  13. from extras.choices import *
  14. from extras.models import CustomField, CustomFieldChoiceSet
  15. from ipam.models import VLAN
  16. from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
  17. from utilities.testing import APITestCase, TestCase
  18. from virtualization.models import VirtualMachine
  19. class CustomFieldTestCase(TestCase):
  20. @classmethod
  21. def setUpTestData(cls):
  22. Site.objects.bulk_create([
  23. Site(name='Site A', slug='site-a'),
  24. Site(name='Site B', slug='site-b'),
  25. Site(name='Site C', slug='site-c'),
  26. ])
  27. cls.object_type = ObjectType.objects.get_for_model(Site)
  28. def test_invalid_name(self):
  29. """
  30. Try creating a CustomField with an invalid name.
  31. """
  32. with self.assertRaises(ValidationError):
  33. # Invalid character
  34. CustomField(name='?', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean()
  35. with self.assertRaises(ValidationError):
  36. # Double underscores not permitted
  37. CustomField(name='foo__bar', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean()
  38. def test_text_field(self):
  39. value = 'Foobar!'
  40. # Create a custom field & check that initial value is null
  41. cf = CustomField.objects.create(
  42. name='text_field',
  43. type=CustomFieldTypeChoices.TYPE_TEXT,
  44. required=False
  45. )
  46. cf.object_types.set([self.object_type])
  47. instance = Site.objects.first()
  48. self.assertIsNone(instance.custom_field_data[cf.name])
  49. # Assign a value and check that it is saved
  50. instance.custom_field_data[cf.name] = value
  51. instance.save()
  52. instance.refresh_from_db()
  53. self.assertEqual(instance.custom_field_data[cf.name], value)
  54. # Delete the stored value and check that it is now null
  55. instance.custom_field_data.pop(cf.name)
  56. instance.save()
  57. instance.refresh_from_db()
  58. self.assertIsNone(instance.custom_field_data.get(cf.name))
  59. def test_longtext_field(self):
  60. value = 'A' * 256
  61. # Create a custom field & check that initial value is null
  62. cf = CustomField.objects.create(
  63. name='longtext_field',
  64. type=CustomFieldTypeChoices.TYPE_LONGTEXT,
  65. required=False
  66. )
  67. cf.object_types.set([self.object_type])
  68. instance = Site.objects.first()
  69. self.assertIsNone(instance.custom_field_data[cf.name])
  70. # Assign a value and check that it is saved
  71. instance.custom_field_data[cf.name] = value
  72. instance.save()
  73. instance.refresh_from_db()
  74. self.assertEqual(instance.custom_field_data[cf.name], value)
  75. # Delete the stored value and check that it is now null
  76. instance.custom_field_data.pop(cf.name)
  77. instance.save()
  78. instance.refresh_from_db()
  79. self.assertIsNone(instance.custom_field_data.get(cf.name))
  80. def test_integer_field(self):
  81. # Create a custom field & check that initial value is null
  82. cf = CustomField.objects.create(
  83. name='integer_field',
  84. type=CustomFieldTypeChoices.TYPE_INTEGER,
  85. required=False
  86. )
  87. cf.object_types.set([self.object_type])
  88. instance = Site.objects.first()
  89. self.assertIsNone(instance.custom_field_data[cf.name])
  90. for value in (123456, 0, -123456):
  91. # Assign a value and check that it is saved
  92. instance.custom_field_data[cf.name] = value
  93. instance.save()
  94. instance.refresh_from_db()
  95. self.assertEqual(instance.custom_field_data[cf.name], value)
  96. # Delete the stored value and check that it is now null
  97. instance.custom_field_data.pop(cf.name)
  98. instance.save()
  99. instance.refresh_from_db()
  100. self.assertIsNone(instance.custom_field_data.get(cf.name))
  101. def test_decimal_field(self):
  102. # Create a custom field & check that initial value is null
  103. cf = CustomField.objects.create(
  104. name='decimal_field',
  105. type=CustomFieldTypeChoices.TYPE_DECIMAL,
  106. required=False
  107. )
  108. cf.object_types.set([self.object_type])
  109. instance = Site.objects.first()
  110. self.assertIsNone(instance.custom_field_data[cf.name])
  111. for value in (123456.54, 0, -123456.78):
  112. # Assign a value and check that it is saved
  113. instance.custom_field_data[cf.name] = value
  114. instance.save()
  115. instance.refresh_from_db()
  116. self.assertEqual(instance.custom_field_data[cf.name], value)
  117. # Delete the stored value and check that it is now null
  118. instance.custom_field_data.pop(cf.name)
  119. instance.save()
  120. instance.refresh_from_db()
  121. self.assertIsNone(instance.custom_field_data.get(cf.name))
  122. def test_boolean_field(self):
  123. # Create a custom field & check that initial value is null
  124. cf = CustomField.objects.create(
  125. name='boolean_field',
  126. type=CustomFieldTypeChoices.TYPE_INTEGER,
  127. required=False
  128. )
  129. cf.object_types.set([self.object_type])
  130. instance = Site.objects.first()
  131. self.assertIsNone(instance.custom_field_data[cf.name])
  132. for value in (True, False):
  133. # Assign a value and check that it is saved
  134. instance.custom_field_data[cf.name] = value
  135. instance.save()
  136. instance.refresh_from_db()
  137. self.assertEqual(instance.custom_field_data[cf.name], value)
  138. # Delete the stored value and check that it is now null
  139. instance.custom_field_data.pop(cf.name)
  140. instance.save()
  141. instance.refresh_from_db()
  142. self.assertIsNone(instance.custom_field_data.get(cf.name))
  143. def test_date_field(self):
  144. value = datetime.date(2016, 6, 23)
  145. # Create a custom field & check that initial value is null
  146. cf = CustomField.objects.create(
  147. name='date_field',
  148. type=CustomFieldTypeChoices.TYPE_DATE,
  149. required=False
  150. )
  151. cf.object_types.set([self.object_type])
  152. instance = Site.objects.first()
  153. self.assertIsNone(instance.custom_field_data[cf.name])
  154. # Assign a value and check that it is saved
  155. instance.custom_field_data[cf.name] = cf.serialize(value)
  156. instance.save()
  157. instance.refresh_from_db()
  158. self.assertEqual(instance.cf[cf.name], value)
  159. # Delete the stored value and check that it is now null
  160. instance.custom_field_data.pop(cf.name)
  161. instance.save()
  162. instance.refresh_from_db()
  163. self.assertIsNone(instance.custom_field_data.get(cf.name))
  164. def test_datetime_field(self):
  165. value = datetime.datetime(2016, 6, 23, 9, 45, 0)
  166. # Create a custom field & check that initial value is null
  167. cf = CustomField.objects.create(
  168. name='date_field',
  169. type=CustomFieldTypeChoices.TYPE_DATETIME,
  170. required=False
  171. )
  172. cf.object_types.set([self.object_type])
  173. instance = Site.objects.first()
  174. self.assertIsNone(instance.custom_field_data[cf.name])
  175. # Assign a value and check that it is saved
  176. instance.custom_field_data[cf.name] = cf.serialize(value)
  177. instance.save()
  178. instance.refresh_from_db()
  179. self.assertEqual(instance.cf[cf.name], value)
  180. # Delete the stored value and check that it is now null
  181. instance.custom_field_data.pop(cf.name)
  182. instance.save()
  183. instance.refresh_from_db()
  184. self.assertIsNone(instance.custom_field_data.get(cf.name))
  185. def test_url_field(self):
  186. value = 'http://example.com/'
  187. # Create a custom field & check that initial value is null
  188. cf = CustomField.objects.create(
  189. name='url_field',
  190. type=CustomFieldTypeChoices.TYPE_URL,
  191. required=False
  192. )
  193. cf.object_types.set([self.object_type])
  194. instance = Site.objects.first()
  195. self.assertIsNone(instance.custom_field_data[cf.name])
  196. # Assign a value and check that it is saved
  197. instance.custom_field_data[cf.name] = value
  198. instance.save()
  199. instance.refresh_from_db()
  200. self.assertEqual(instance.custom_field_data[cf.name], value)
  201. # Delete the stored value and check that it is now null
  202. instance.custom_field_data.pop(cf.name)
  203. instance.save()
  204. instance.refresh_from_db()
  205. self.assertIsNone(instance.custom_field_data.get(cf.name))
  206. def test_json_field(self):
  207. value = '{"foo": 1, "bar": 2}'
  208. # Create a custom field & check that initial value is null
  209. cf = CustomField.objects.create(
  210. name='json_field',
  211. type=CustomFieldTypeChoices.TYPE_JSON,
  212. required=False
  213. )
  214. cf.object_types.set([self.object_type])
  215. instance = Site.objects.first()
  216. self.assertIsNone(instance.custom_field_data[cf.name])
  217. # Assign a value and check that it is saved
  218. instance.custom_field_data[cf.name] = value
  219. instance.save()
  220. instance.refresh_from_db()
  221. self.assertEqual(instance.custom_field_data[cf.name], value)
  222. # Delete the stored value and check that it is now null
  223. instance.custom_field_data.pop(cf.name)
  224. instance.save()
  225. instance.refresh_from_db()
  226. self.assertIsNone(instance.custom_field_data.get(cf.name))
  227. @tag('regression')
  228. def test_json_field_falsy_defaults(self):
  229. """Test that falsy JSON default values are properly handled"""
  230. falsy_test_cases = [
  231. ({}, 'empty_dict'),
  232. ([], 'empty_array'),
  233. (0, 'zero'),
  234. (False, 'false_bool'),
  235. ("", 'empty_string'),
  236. ]
  237. for default, suffix in falsy_test_cases:
  238. with self.subTest(default=default, suffix=suffix):
  239. cf = CustomField.objects.create(
  240. name=f'json_falsy_{suffix}',
  241. type=CustomFieldTypeChoices.TYPE_JSON,
  242. default=default,
  243. required=False
  244. )
  245. cf.object_types.set([self.object_type])
  246. instance = Site.objects.create(name=f'Test Site {suffix}', slug=f'test-site-{suffix}')
  247. self.assertIsNotNone(instance.custom_field_data)
  248. self.assertIn(cf.name, instance.custom_field_data)
  249. instance.refresh_from_db()
  250. stored = instance.custom_field_data[cf.name]
  251. self.assertEqual(stored, default)
  252. @tag('regression')
  253. def test_json_field_falsy_to_form_field(self):
  254. """Test form field generation preserves falsy defaults"""
  255. falsy_test_cases = (
  256. ({}, json.dumps({}), 'empty_dict'),
  257. ([], json.dumps([]), 'empty_array'),
  258. (0, json.dumps(0), 'zero'),
  259. (False, json.dumps(False), 'false_bool'),
  260. ("", '""', 'empty_string'),
  261. )
  262. for default, expected, suffix in falsy_test_cases:
  263. with self.subTest(default=default, expected=expected, suffix=suffix):
  264. cf = CustomField.objects.create(
  265. name=f'json_falsy_{suffix}',
  266. type=CustomFieldTypeChoices.TYPE_JSON,
  267. default=default,
  268. required=False
  269. )
  270. cf.object_types.set([self.object_type])
  271. form_field = cf.to_form_field(set_initial=True)
  272. self.assertEqual(form_field.initial, expected)
  273. def test_select_field(self):
  274. CHOICES = (
  275. ('a', 'Option A'),
  276. ('b', 'Option B'),
  277. ('c', 'Option C'),
  278. )
  279. value = 'a'
  280. # Create a set of custom field choices
  281. choice_set = CustomFieldChoiceSet.objects.create(
  282. name='Custom Field Choice Set 1',
  283. extra_choices=CHOICES
  284. )
  285. # Create a custom field & check that initial value is null
  286. cf = CustomField.objects.create(
  287. name='select_field',
  288. type=CustomFieldTypeChoices.TYPE_SELECT,
  289. required=False,
  290. choice_set=choice_set
  291. )
  292. cf.object_types.set([self.object_type])
  293. instance = Site.objects.first()
  294. self.assertIsNone(instance.custom_field_data[cf.name])
  295. # Assign a value and check that it is saved
  296. instance.custom_field_data[cf.name] = value
  297. instance.save()
  298. instance.refresh_from_db()
  299. self.assertEqual(instance.custom_field_data[cf.name], value)
  300. # Delete the stored value and check that it is now null
  301. instance.custom_field_data.pop(cf.name)
  302. instance.save()
  303. instance.refresh_from_db()
  304. self.assertIsNone(instance.custom_field_data.get(cf.name))
  305. def test_multiselect_field(self):
  306. CHOICES = (
  307. ('a', 'Option A'),
  308. ('b', 'Option B'),
  309. ('c', 'Option C'),
  310. )
  311. value = ['a', 'b']
  312. # Create a set of custom field choices
  313. choice_set = CustomFieldChoiceSet.objects.create(
  314. name='Custom Field Choice Set 1',
  315. extra_choices=CHOICES
  316. )
  317. # Create a custom field & check that initial value is null
  318. cf = CustomField.objects.create(
  319. name='multiselect_field',
  320. type=CustomFieldTypeChoices.TYPE_MULTISELECT,
  321. required=False,
  322. choice_set=choice_set
  323. )
  324. cf.object_types.set([self.object_type])
  325. instance = Site.objects.first()
  326. self.assertIsNone(instance.custom_field_data[cf.name])
  327. # Assign a value and check that it is saved
  328. instance.custom_field_data[cf.name] = value
  329. instance.save()
  330. instance.refresh_from_db()
  331. self.assertEqual(instance.custom_field_data[cf.name], value)
  332. # Delete the stored value and check that it is now null
  333. instance.custom_field_data.pop(cf.name)
  334. instance.save()
  335. instance.refresh_from_db()
  336. self.assertIsNone(instance.custom_field_data.get(cf.name))
  337. def test_choice_set_colors(self):
  338. choice_set = CustomFieldChoiceSet(
  339. name='Test Choice Set',
  340. extra_choices=(
  341. ('a', 'Option A'),
  342. ('b', 'Option B'),
  343. ),
  344. choice_colors={
  345. 'a': CustomFieldChoiceColorChoices.RED,
  346. 'b': CustomFieldChoiceColorChoices.GREEN,
  347. },
  348. )
  349. choice_set.full_clean()
  350. self.assertEqual(
  351. choice_set.colors,
  352. {
  353. 'a': CustomFieldChoiceColorChoices.RED,
  354. 'b': CustomFieldChoiceColorChoices.GREEN,
  355. },
  356. )
  357. def test_choice_set_invalid_color_mapping_value(self):
  358. choice_set = CustomFieldChoiceSet(
  359. name='Test Choice Set',
  360. extra_choices=(
  361. ('a', 'Option A'),
  362. ('b', 'Option B'),
  363. ),
  364. choice_colors={'c': CustomFieldChoiceColorChoices.RED},
  365. )
  366. with self.assertRaises(ValidationError) as cm:
  367. choice_set.full_clean()
  368. self.assertIn('choice_colors', cm.exception.message_dict)
  369. def test_choice_set_invalid_color_value(self):
  370. choice_set = CustomFieldChoiceSet(
  371. name='Test Choice Set',
  372. extra_choices=(
  373. ('a', 'Option A'),
  374. ('b', 'Option B'),
  375. ),
  376. choice_colors={'a': 'magenta'},
  377. )
  378. with self.assertRaises(ValidationError) as cm:
  379. choice_set.full_clean()
  380. self.assertIn('choice_colors', cm.exception.message_dict)
  381. def test_choice_set_invalid_color_mapping_structure(self):
  382. choice_set = CustomFieldChoiceSet(
  383. name='Test Choice Set',
  384. extra_choices=(
  385. ('a', 'Option A'),
  386. ('b', 'Option B'),
  387. ),
  388. choice_colors=['a:red'],
  389. )
  390. with self.assertRaises(ValidationError) as cm:
  391. choice_set.full_clean()
  392. self.assertIn('choice_colors', cm.exception.message_dict)
  393. @tag('regression')
  394. def test_choice_set_with_base_choices_validates_without_error(self):
  395. """Regression test for #22325: base-only choice sets must validate."""
  396. for base in ('IATA', 'ISO_3166', 'UN_LOCODE'):
  397. with self.subTest(base=base):
  398. choice_set = CustomFieldChoiceSet(name=f'Test {base}', base_choices=base, order_alphabetically=True)
  399. choice_set.full_clean() # must not raise
  400. choice_set.save() # must not raise (extra_choices is None)
  401. def test_remove_selected_choice(self):
  402. """
  403. Removing a ChoiceSet choice that is referenced by an object should raise
  404. a ValidationError exception.
  405. """
  406. CHOICES = (
  407. ('a', 'Option A'),
  408. ('b', 'Option B'),
  409. ('c', 'Option C'),
  410. ('d', 'Option D'),
  411. )
  412. # Create a set of custom field choices
  413. choice_set = CustomFieldChoiceSet.objects.create(
  414. name='Custom Field Choice Set 1',
  415. extra_choices=CHOICES
  416. )
  417. # Create a select custom field
  418. cf = CustomField.objects.create(
  419. name='select_field',
  420. type=CustomFieldTypeChoices.TYPE_SELECT,
  421. required=False,
  422. choice_set=choice_set
  423. )
  424. cf.object_types.set([self.object_type])
  425. # Create a multi-select custom field
  426. cf_multiselect = CustomField.objects.create(
  427. name='multiselect_field',
  428. type=CustomFieldTypeChoices.TYPE_MULTISELECT,
  429. required=False,
  430. choice_set=choice_set
  431. )
  432. cf_multiselect.object_types.set([self.object_type])
  433. # Assign a choice for both custom fields on an object
  434. instance = Site.objects.first()
  435. instance.custom_field_data[cf.name] = 'a'
  436. instance.custom_field_data[cf_multiselect.name] = ['b', 'c']
  437. instance.save()
  438. # Attempting to delete a selected choice should fail
  439. with self.assertRaises(ValidationError):
  440. choice_set.extra_choices = (
  441. ('b', 'Option B'),
  442. ('c', 'Option C'),
  443. ('d', 'Option D'),
  444. )
  445. choice_set.full_clean()
  446. # Attempting to delete either of the multi-select choices should fail
  447. with self.assertRaises(ValidationError):
  448. choice_set.extra_choices = (
  449. ('a', 'Option A'),
  450. ('b', 'Option B'),
  451. ('d', 'Option D'),
  452. )
  453. choice_set.full_clean()
  454. # Removing a non-selected choice should succeed
  455. choice_set.extra_choices = (
  456. ('a', 'Option A'),
  457. ('b', 'Option B'),
  458. ('c', 'Option C'),
  459. )
  460. choice_set.full_clean()
  461. def test_object_field(self):
  462. value = VLAN.objects.create(name='VLAN 1', vid=1).pk
  463. # Create a custom field & check that initial value is null
  464. cf = CustomField.objects.create(
  465. name='object_field',
  466. type=CustomFieldTypeChoices.TYPE_OBJECT,
  467. related_object_type=ObjectType.objects.get_for_model(VLAN),
  468. required=False
  469. )
  470. cf.object_types.set([self.object_type])
  471. instance = Site.objects.first()
  472. self.assertIsNone(instance.custom_field_data[cf.name])
  473. # Assign a value and check that it is saved
  474. instance.custom_field_data[cf.name] = value
  475. instance.save()
  476. instance.refresh_from_db()
  477. self.assertEqual(instance.custom_field_data[cf.name], value)
  478. # Delete the stored value and check that it is now null
  479. instance.custom_field_data.pop(cf.name)
  480. instance.save()
  481. instance.refresh_from_db()
  482. self.assertIsNone(instance.custom_field_data.get(cf.name))
  483. def test_multiobject_field(self):
  484. vlans = (
  485. VLAN(name='VLAN 1', vid=1),
  486. VLAN(name='VLAN 2', vid=2),
  487. VLAN(name='VLAN 3', vid=3),
  488. )
  489. VLAN.objects.bulk_create(vlans)
  490. value = [vlan.pk for vlan in vlans]
  491. # Create a custom field & check that initial value is null
  492. cf = CustomField.objects.create(
  493. name='object_field',
  494. type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
  495. related_object_type=ObjectType.objects.get_for_model(VLAN),
  496. required=False
  497. )
  498. cf.object_types.set([self.object_type])
  499. instance = Site.objects.first()
  500. self.assertIsNone(instance.custom_field_data[cf.name])
  501. # Assign a value and check that it is saved
  502. instance.custom_field_data[cf.name] = value
  503. instance.save()
  504. instance.refresh_from_db()
  505. self.assertEqual(instance.custom_field_data[cf.name], value)
  506. # Delete the stored value and check that it is now null
  507. instance.custom_field_data.pop(cf.name)
  508. instance.save()
  509. instance.refresh_from_db()
  510. self.assertIsNone(instance.custom_field_data.get(cf.name))
  511. def test_rename_customfield(self):
  512. obj_type = ObjectType.objects.get_for_model(Site)
  513. FIELD_DATA = 'abc'
  514. # Create a custom field
  515. cf = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='field1')
  516. cf.save()
  517. cf.object_types.set([obj_type])
  518. # Assign custom field data to an object
  519. site = Site.objects.create(
  520. name='Site 1',
  521. slug='site-1',
  522. custom_field_data={'field1': FIELD_DATA}
  523. )
  524. site.refresh_from_db()
  525. self.assertEqual(site.custom_field_data['field1'], FIELD_DATA)
  526. # Rename the custom field
  527. cf.name = 'field2'
  528. cf.save()
  529. # Check that custom field data on the object has been updated
  530. site.refresh_from_db()
  531. self.assertNotIn('field1', site.custom_field_data)
  532. self.assertEqual(site.custom_field_data['field2'], FIELD_DATA)
  533. @patch('extras.models.customfields.CUSTOMFIELD_DATA_BATCH_SIZE', 2)
  534. def test_batched_object_data_updates(self):
  535. """
  536. Provisioning, renaming, and removing custom field data is applied in batches. Use a small
  537. batch size to ensure the data on every object is updated across multiple batches.
  538. """
  539. # The existing sites (created in setUpTestData) span multiple batches of size 2
  540. site_count = Site.objects.count()
  541. self.assertGreater(site_count, 2)
  542. # Provisioning: a default value is populated onto every existing object
  543. cf = CustomField.objects.create(
  544. name='batched_field',
  545. type=CustomFieldTypeChoices.TYPE_TEXT,
  546. default='foo'
  547. )
  548. cf.object_types.set([self.object_type])
  549. self.assertEqual(
  550. Site.objects.filter(custom_field_data__batched_field='foo').count(),
  551. site_count
  552. )
  553. # Renaming: the key is renamed on every existing object, preserving its value
  554. cf.name = 'renamed_field'
  555. cf.save()
  556. self.assertEqual(
  557. Site.objects.filter(custom_field_data__renamed_field='foo').count(),
  558. site_count
  559. )
  560. self.assertEqual(
  561. Site.objects.filter(custom_field_data__has_key='batched_field').count(),
  562. 0
  563. )
  564. # Removal: the key is stripped from every existing object when the field is deleted
  565. cf.delete()
  566. self.assertEqual(
  567. Site.objects.filter(custom_field_data__has_key='renamed_field').count(),
  568. 0
  569. )
  570. def test_default_value_validation(self):
  571. choiceset = CustomFieldChoiceSet.objects.create(
  572. name="Test Choice Set",
  573. extra_choices=(
  574. ('choice1', 'Choice 1'),
  575. ('choice2', 'Choice 2'),
  576. )
  577. )
  578. site = Site.objects.create(name='Site 1', slug='site-1')
  579. object_type = ObjectType.objects.get_for_model(Site)
  580. # Text
  581. CustomField(name='test', type='text', required=True, default="Default text").full_clean()
  582. # Integer
  583. CustomField(name='test', type='integer', required=True, default=1).full_clean()
  584. with self.assertRaises(ValidationError):
  585. CustomField(name='test', type='integer', required=True, default='xxx').full_clean()
  586. # Boolean
  587. CustomField(name='test', type='boolean', required=True, default=True).full_clean()
  588. with self.assertRaises(ValidationError):
  589. CustomField(name='test', type='boolean', required=True, default='xxx').full_clean()
  590. # Date
  591. CustomField(name='test', type='date', required=True, default="2023-02-25").full_clean()
  592. with self.assertRaises(ValidationError):
  593. CustomField(name='test', type='date', required=True, default='xxx').full_clean()
  594. # Datetime
  595. CustomField(name='test', type='datetime', required=True, default="2023-02-25 02:02:02").full_clean()
  596. with self.assertRaises(ValidationError):
  597. CustomField(name='test', type='datetime', required=True, default='xxx').full_clean()
  598. # URL
  599. CustomField(name='test', type='url', required=True, default="https://www.netbox.dev").full_clean()
  600. # JSON
  601. CustomField(name='test', type='json', required=True, default='{"test": "object"}').full_clean()
  602. # Selection
  603. CustomField(name='test', type='select', required=True, choice_set=choiceset, default='choice1').full_clean()
  604. with self.assertRaises(ValidationError):
  605. CustomField(name='test', type='select', required=True, choice_set=choiceset, default='xxx').full_clean()
  606. # Multi-select
  607. CustomField(
  608. name='test',
  609. type='multiselect',
  610. required=True,
  611. choice_set=choiceset,
  612. default=['choice1'] # Single default choice
  613. ).full_clean()
  614. CustomField(
  615. name='test',
  616. type='multiselect',
  617. required=True,
  618. choice_set=choiceset,
  619. default=['choice1', 'choice2'] # Multiple default choices
  620. ).full_clean()
  621. with self.assertRaises(ValidationError):
  622. CustomField(
  623. name='test',
  624. type='multiselect',
  625. required=True,
  626. choice_set=choiceset,
  627. default=['xxx']
  628. ).full_clean()
  629. # Object
  630. CustomField(
  631. name='test',
  632. type='object',
  633. required=True,
  634. related_object_type=object_type,
  635. default=site.pk
  636. ).full_clean()
  637. with (self.assertRaises(ValidationError)):
  638. CustomField(
  639. name='test',
  640. type='object',
  641. required=True,
  642. related_object_type=object_type,
  643. default="xxx"
  644. ).full_clean()
  645. # Multi-object
  646. CustomField(
  647. name='test',
  648. type='multiobject',
  649. required=True,
  650. related_object_type=object_type,
  651. default=[site.pk]
  652. ).full_clean()
  653. with self.assertRaises(ValidationError):
  654. CustomField(
  655. name='test',
  656. type='multiobject',
  657. required=True,
  658. related_object_type=object_type,
  659. default=["xxx"]
  660. ).full_clean()
  661. def test_validation_schema_only_for_json_type(self):
  662. schema = {
  663. 'type': 'object',
  664. 'properties': {
  665. 'name': {'type': 'string'},
  666. },
  667. }
  668. # Valid: schema on a JSON field
  669. CustomField(name='test', type=CustomFieldTypeChoices.TYPE_JSON, validation_schema=schema).full_clean()
  670. # Invalid: schema on a non-JSON field
  671. with self.assertRaises(ValidationError):
  672. CustomField(name='test', type=CustomFieldTypeChoices.TYPE_TEXT, validation_schema=schema).full_clean()
  673. with self.assertRaises(ValidationError):
  674. CustomField(name='test', type=CustomFieldTypeChoices.TYPE_INTEGER, validation_schema=schema).full_clean()
  675. def test_json_schema_default_validation(self):
  676. schema = {
  677. 'type': 'object',
  678. 'properties': {
  679. 'name': {'type': 'string'},
  680. },
  681. 'required': ['name'],
  682. }
  683. # Valid default
  684. CustomField(
  685. name='test', type=CustomFieldTypeChoices.TYPE_JSON,
  686. validation_schema=schema, default={'name': 'test'}
  687. ).full_clean()
  688. # Invalid default (missing required 'name')
  689. with self.assertRaises(ValidationError):
  690. CustomField(
  691. name='test', type=CustomFieldTypeChoices.TYPE_JSON,
  692. validation_schema=schema, default={'age': 25}
  693. ).full_clean()
  694. class CustomFieldManagerTestCase(TestCase):
  695. @classmethod
  696. def setUpTestData(cls):
  697. object_type = ObjectType.objects.get_for_model(Site)
  698. custom_field = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo')
  699. custom_field.save()
  700. custom_field.object_types.set([object_type])
  701. def test_get_for_model(self):
  702. self.assertEqual(CustomField.objects.get_for_model(Site).count(), 1)
  703. self.assertEqual(CustomField.objects.get_for_model(VirtualMachine).count(), 0)
  704. class CustomFieldAPITestCase(APITestCase):
  705. @classmethod
  706. def setUpTestData(cls):
  707. object_type = ObjectType.objects.get_for_model(Site)
  708. # Create some VLANs
  709. vlans = (
  710. VLAN(name='VLAN 1', vid=1),
  711. VLAN(name='VLAN 2', vid=2),
  712. VLAN(name='VLAN 3', vid=3),
  713. VLAN(name='VLAN 4', vid=4),
  714. VLAN(name='VLAN 5', vid=5),
  715. )
  716. VLAN.objects.bulk_create(vlans)
  717. # Create a set of custom field choices
  718. choice_set = CustomFieldChoiceSet.objects.create(
  719. name='Custom Field Choice Set 1',
  720. extra_choices=(('foo', 'Foo'), ('bar', 'Bar'), ('baz', 'Baz'))
  721. )
  722. custom_fields = (
  723. CustomField(
  724. type=CustomFieldTypeChoices.TYPE_TEXT,
  725. name='text_field',
  726. default='foo'
  727. ),
  728. CustomField(
  729. type=CustomFieldTypeChoices.TYPE_LONGTEXT,
  730. name='longtext_field',
  731. default='ABC'
  732. ),
  733. CustomField(
  734. type=CustomFieldTypeChoices.TYPE_INTEGER,
  735. name='integer_field',
  736. default=123
  737. ),
  738. CustomField(
  739. type=CustomFieldTypeChoices.TYPE_DECIMAL,
  740. name='decimal_field',
  741. default=123.45
  742. ),
  743. CustomField(
  744. type=CustomFieldTypeChoices.TYPE_BOOLEAN,
  745. name='boolean_field',
  746. default=False
  747. ),
  748. CustomField(
  749. type=CustomFieldTypeChoices.TYPE_DATE,
  750. name='date_field',
  751. default='2020-01-01'
  752. ),
  753. CustomField(
  754. type=CustomFieldTypeChoices.TYPE_DATETIME,
  755. name='datetime_field',
  756. default='2020-01-01T01:23:45'
  757. ),
  758. CustomField(
  759. type=CustomFieldTypeChoices.TYPE_URL,
  760. name='url_field',
  761. default='http://example.com/1'
  762. ),
  763. CustomField(
  764. type=CustomFieldTypeChoices.TYPE_JSON,
  765. name='json_field',
  766. default='{"x": "y"}'
  767. ),
  768. CustomField(
  769. type=CustomFieldTypeChoices.TYPE_SELECT,
  770. name='select_field',
  771. default='foo',
  772. choice_set=choice_set
  773. ),
  774. CustomField(
  775. type=CustomFieldTypeChoices.TYPE_MULTISELECT,
  776. name='multiselect_field',
  777. default=['foo'],
  778. choice_set=choice_set,
  779. ),
  780. CustomField(
  781. type=CustomFieldTypeChoices.TYPE_OBJECT,
  782. name='object_field',
  783. related_object_type=ObjectType.objects.get_for_model(VLAN),
  784. default=vlans[0].pk,
  785. ),
  786. CustomField(
  787. type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
  788. name='multiobject_field',
  789. related_object_type=ObjectType.objects.get_for_model(VLAN),
  790. default=[vlans[0].pk, vlans[1].pk],
  791. ),
  792. )
  793. for cf in custom_fields:
  794. cf.save()
  795. cf.object_types.set([object_type])
  796. # Create some sites *after* creating the custom fields. This ensures that
  797. # default values are not set for the assigned objects.
  798. sites = (
  799. Site(name='Site 1', slug='site-1'),
  800. Site(name='Site 2', slug='site-2'),
  801. )
  802. Site.objects.bulk_create(sites)
  803. # Assign custom field values for site 2
  804. sites[1].custom_field_data = {
  805. custom_fields[0].name: 'bar',
  806. custom_fields[1].name: 'DEF',
  807. custom_fields[2].name: 456,
  808. custom_fields[3].name: Decimal('456.78'),
  809. custom_fields[4].name: True,
  810. custom_fields[5].name: '2020-01-02',
  811. custom_fields[6].name: '2020-01-02 12:00:00',
  812. custom_fields[7].name: 'http://example.com/2',
  813. custom_fields[8].name: '{"foo": 1, "bar": 2}',
  814. custom_fields[9].name: 'bar',
  815. custom_fields[10].name: ['bar', 'baz'],
  816. custom_fields[11].name: vlans[1].pk,
  817. custom_fields[12].name: [vlans[2].pk, vlans[3].pk],
  818. }
  819. sites[1].save()
  820. # Labels for the choice set created in setUpTestData, used to build the expected
  821. # API representation of selection custom fields ({'value': ..., 'label': ...}).
  822. CHOICE_LABELS = {'foo': 'Foo', 'bar': 'Bar', 'baz': 'Baz'}
  823. @classmethod
  824. def _select(cls, value):
  825. """Return the expected API representation of a single selection choice."""
  826. if value is None:
  827. return None
  828. return {'value': value, 'label': cls.CHOICE_LABELS[value]}
  829. @classmethod
  830. def _multiselect(cls, values):
  831. """Return the expected API representation of a multiple selection value."""
  832. if values is None:
  833. return None
  834. return [cls._select(v) for v in values]
  835. def test_get_custom_fields(self):
  836. TYPES = {
  837. CustomFieldTypeChoices.TYPE_TEXT: 'string',
  838. CustomFieldTypeChoices.TYPE_LONGTEXT: 'string',
  839. CustomFieldTypeChoices.TYPE_INTEGER: 'integer',
  840. CustomFieldTypeChoices.TYPE_DECIMAL: 'decimal',
  841. CustomFieldTypeChoices.TYPE_BOOLEAN: 'boolean',
  842. CustomFieldTypeChoices.TYPE_DATE: 'string',
  843. CustomFieldTypeChoices.TYPE_DATETIME: 'string',
  844. CustomFieldTypeChoices.TYPE_URL: 'string',
  845. CustomFieldTypeChoices.TYPE_JSON: 'object',
  846. CustomFieldTypeChoices.TYPE_SELECT: 'string',
  847. CustomFieldTypeChoices.TYPE_MULTISELECT: 'array',
  848. CustomFieldTypeChoices.TYPE_OBJECT: 'object',
  849. CustomFieldTypeChoices.TYPE_MULTIOBJECT: 'array',
  850. }
  851. self.add_permissions('extras.view_customfield')
  852. url = reverse('extras-api:customfield-list')
  853. response = self.client.get(url, **self.header)
  854. self.assertEqual(response.data['count'], len(TYPES))
  855. # Validate data types
  856. for customfield in response.data['results']:
  857. cf_type = customfield['type']['value']
  858. self.assertEqual(customfield['data_type'], TYPES[cf_type])
  859. def test_get_single_object_without_custom_field_data(self):
  860. """
  861. Validate that custom fields are present on an object even if it has no values defined.
  862. """
  863. site1 = Site.objects.get(name='Site 1')
  864. url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk})
  865. self.add_permissions('dcim.view_site')
  866. response = self.client.get(url, **self.header)
  867. self.assertEqual(response.data['name'], site1.name)
  868. self.assertEqual(response.data['custom_fields'], {
  869. 'text_field': None,
  870. 'longtext_field': None,
  871. 'integer_field': None,
  872. 'decimal_field': None,
  873. 'boolean_field': None,
  874. 'date_field': None,
  875. 'datetime_field': None,
  876. 'url_field': None,
  877. 'json_field': None,
  878. 'select_field': None,
  879. 'multiselect_field': None,
  880. 'object_field': None,
  881. 'multiobject_field': None,
  882. })
  883. def test_get_single_object_with_custom_field_data(self):
  884. """
  885. Validate that custom fields are present and correctly set for an object with values defined.
  886. """
  887. site2 = Site.objects.get(name='Site 2')
  888. site2_cfvs = site2.cf
  889. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  890. self.add_permissions('dcim.view_site')
  891. response = self.client.get(url, **self.header)
  892. self.assertEqual(response.data['name'], site2.name)
  893. self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field'])
  894. self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_field'])
  895. self.assertEqual(response.data['custom_fields']['integer_field'], site2_cfvs['integer_field'])
  896. self.assertEqual(response.data['custom_fields']['decimal_field'], site2_cfvs['decimal_field'])
  897. self.assertEqual(response.data['custom_fields']['boolean_field'], site2_cfvs['boolean_field'])
  898. self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field'])
  899. self.assertEqual(response.data['custom_fields']['datetime_field'], site2_cfvs['datetime_field'])
  900. self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field'])
  901. self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field'])
  902. self.assertEqual(response.data['custom_fields']['select_field'], self._select(site2_cfvs['select_field']))
  903. self.assertEqual(
  904. response.data['custom_fields']['multiselect_field'],
  905. self._multiselect(site2_cfvs['multiselect_field'])
  906. )
  907. self.assertEqual(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field'].pk)
  908. self.assertEqual(
  909. [obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
  910. [obj.pk for obj in site2_cfvs['multiobject_field']]
  911. )
  912. def test_get_object_selection_field_representation(self):
  913. """
  914. Selection custom fields are rendered as an object exposing both the stored value and its
  915. human-friendly label on read access (see #20897).
  916. """
  917. site2 = Site.objects.get(name='Site 2')
  918. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  919. self.add_permissions('dcim.view_site')
  920. response = self.client.get(url, **self.header)
  921. # A single selection value is rendered as a {value, label} object
  922. self.assertEqual(response.data['custom_fields']['select_field'], {
  923. 'value': 'bar',
  924. 'label': 'Bar',
  925. })
  926. # A multiple selection value is rendered as a list of {value, label} objects
  927. self.assertEqual(response.data['custom_fields']['multiselect_field'], [
  928. {'value': 'bar', 'label': 'Bar'},
  929. {'value': 'baz', 'label': 'Baz'},
  930. ])
  931. def test_get_object_selection_field_unresolved_label(self):
  932. """
  933. A stored selection value with no matching choice falls back to using the raw value as its label.
  934. """
  935. site2 = Site.objects.get(name='Site 2')
  936. site2.custom_field_data['select_field'] = 'stale'
  937. site2.save()
  938. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  939. self.add_permissions('dcim.view_site')
  940. response = self.client.get(url, **self.header)
  941. self.assertEqual(response.data['custom_fields']['select_field'], {
  942. 'value': 'stale',
  943. 'label': 'stale',
  944. })
  945. @tag('regression')
  946. def test_update_selection_field_rejects_read_format(self):
  947. """
  948. Selection fields are written by passing the raw value; submitting the {value, label} read
  949. representation must be rejected with a clean 400, not a 500 (see #20897).
  950. """
  951. site2 = Site.objects.get(name='Site 2')
  952. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  953. self.add_permissions('dcim.change_site')
  954. # A single selection submitted as an object is rejected
  955. response = self.client.patch(
  956. url, {'custom_fields': {'select_field': {'value': 'foo', 'label': 'Foo'}}}, format='json', **self.header
  957. )
  958. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  959. # A multiple selection submitted as a list of objects is rejected (must not raise a TypeError/500)
  960. response = self.client.patch(
  961. url,
  962. {'custom_fields': {'multiselect_field': [{'value': 'foo', 'label': 'Foo'}]}},
  963. format='json',
  964. **self.header
  965. )
  966. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  967. # The stored values are unchanged
  968. site2.refresh_from_db()
  969. self.assertEqual(site2.custom_field_data['select_field'], 'bar')
  970. self.assertEqual(site2.custom_field_data['multiselect_field'], ['bar', 'baz'])
  971. def test_create_single_object_with_defaults(self):
  972. """
  973. Create a new site with no specified custom field values and check that it received the default values.
  974. """
  975. cf_defaults = {
  976. cf.name: cf.default for cf in CustomField.objects.all()
  977. }
  978. data = {
  979. 'name': 'Site 3',
  980. 'slug': 'site-3',
  981. }
  982. url = reverse('dcim-api:site-list')
  983. self.add_permissions('dcim.add_site')
  984. response = self.client.post(url, data, format='json', **self.header)
  985. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  986. # Validate response data
  987. response_cf = response.data['custom_fields']
  988. self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
  989. self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
  990. self.assertEqual(response_cf['integer_field'], cf_defaults['integer_field'])
  991. self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field'])
  992. self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
  993. self.assertEqual(response_cf['date_field'].isoformat(), cf_defaults['date_field'])
  994. self.assertEqual(response_cf['datetime_field'].isoformat(), cf_defaults['datetime_field'])
  995. self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
  996. self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
  997. self.assertEqual(response_cf['select_field'], self._select(cf_defaults['select_field']))
  998. self.assertEqual(response_cf['multiselect_field'], self._multiselect(cf_defaults['multiselect_field']))
  999. self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field'])
  1000. self.assertEqual(
  1001. [obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
  1002. cf_defaults['multiobject_field']
  1003. )
  1004. # Validate database data
  1005. site = Site.objects.get(pk=response.data['id'])
  1006. self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field'])
  1007. self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
  1008. self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field'])
  1009. self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field'])
  1010. self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
  1011. self.assertEqual(site.custom_field_data['date_field'], cf_defaults['date_field'])
  1012. self.assertEqual(site.custom_field_data['datetime_field'], cf_defaults['datetime_field'])
  1013. self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
  1014. self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
  1015. self.assertEqual(site.custom_field_data['select_field'], cf_defaults['select_field'])
  1016. self.assertEqual(site.custom_field_data['multiselect_field'], cf_defaults['multiselect_field'])
  1017. self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field'])
  1018. self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field'])
  1019. def test_create_single_object_with_values(self):
  1020. """
  1021. Create a single new site with a value for each type of custom field.
  1022. """
  1023. data = {
  1024. 'name': 'Site 3',
  1025. 'slug': 'site-3',
  1026. 'custom_fields': {
  1027. 'text_field': 'bar',
  1028. 'longtext_field': 'blah blah blah',
  1029. 'integer_field': 456,
  1030. 'decimal_field': 456.78,
  1031. 'boolean_field': True,
  1032. 'date_field': datetime.date(2020, 1, 2),
  1033. 'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0),
  1034. 'url_field': 'http://example.com/2',
  1035. 'json_field': '{"foo": 1, "bar": 2}',
  1036. 'select_field': 'bar',
  1037. 'multiselect_field': ['bar', 'baz'],
  1038. 'object_field': VLAN.objects.get(vid=2).pk,
  1039. 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
  1040. },
  1041. }
  1042. url = reverse('dcim-api:site-list')
  1043. self.add_permissions('dcim.add_site', 'ipam.view_vlan')
  1044. response = self.client.post(url, data, format='json', **self.header)
  1045. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1046. # Validate response data
  1047. response_cf = response.data['custom_fields']
  1048. data_cf = data['custom_fields']
  1049. self.assertEqual(response_cf['text_field'], data_cf['text_field'])
  1050. self.assertEqual(response_cf['longtext_field'], data_cf['longtext_field'])
  1051. self.assertEqual(response_cf['integer_field'], data_cf['integer_field'])
  1052. self.assertEqual(response_cf['decimal_field'], data_cf['decimal_field'])
  1053. self.assertEqual(response_cf['boolean_field'], data_cf['boolean_field'])
  1054. self.assertEqual(response_cf['date_field'], data_cf['date_field'])
  1055. self.assertEqual(response_cf['datetime_field'], data_cf['datetime_field'])
  1056. self.assertEqual(response_cf['url_field'], data_cf['url_field'])
  1057. self.assertEqual(response_cf['json_field'], data_cf['json_field'])
  1058. self.assertEqual(response_cf['select_field'], self._select(data_cf['select_field']))
  1059. self.assertEqual(response_cf['multiselect_field'], self._multiselect(data_cf['multiselect_field']))
  1060. self.assertEqual(response_cf['object_field']['id'], data_cf['object_field'])
  1061. self.assertEqual(
  1062. [obj['id'] for obj in response_cf['multiobject_field']],
  1063. data_cf['multiobject_field']
  1064. )
  1065. # Validate database data
  1066. site = Site.objects.get(pk=response.data['id'])
  1067. self.assertEqual(site.custom_field_data['text_field'], data_cf['text_field'])
  1068. self.assertEqual(site.custom_field_data['longtext_field'], data_cf['longtext_field'])
  1069. self.assertEqual(site.custom_field_data['integer_field'], data_cf['integer_field'])
  1070. self.assertEqual(site.custom_field_data['decimal_field'], data_cf['decimal_field'])
  1071. self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field'])
  1072. self.assertEqual(site.cf['date_field'], data_cf['date_field'])
  1073. self.assertEqual(site.cf['datetime_field'], data_cf['datetime_field'])
  1074. self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field'])
  1075. self.assertEqual(site.custom_field_data['json_field'], data_cf['json_field'])
  1076. self.assertEqual(site.custom_field_data['select_field'], data_cf['select_field'])
  1077. self.assertEqual(site.custom_field_data['multiselect_field'], data_cf['multiselect_field'])
  1078. self.assertEqual(site.custom_field_data['object_field'], data_cf['object_field'])
  1079. self.assertEqual(site.custom_field_data['multiobject_field'], data_cf['multiobject_field'])
  1080. def test_create_multiple_objects_with_defaults(self):
  1081. """
  1082. Create three new sites with no specified custom field values and check that each received
  1083. the default custom field values.
  1084. """
  1085. cf_defaults = {
  1086. cf.name: cf.default for cf in CustomField.objects.all()
  1087. }
  1088. data = (
  1089. {
  1090. 'name': 'Site 3',
  1091. 'slug': 'site-3',
  1092. },
  1093. {
  1094. 'name': 'Site 4',
  1095. 'slug': 'site-4',
  1096. },
  1097. {
  1098. 'name': 'Site 5',
  1099. 'slug': 'site-5',
  1100. },
  1101. )
  1102. url = reverse('dcim-api:site-list')
  1103. self.add_permissions('dcim.add_site')
  1104. response = self.client.post(url, data, format='json', **self.header)
  1105. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1106. self.assertEqual(len(response.data), len(data))
  1107. for i, obj in enumerate(data):
  1108. # Validate response data
  1109. response_cf = response.data[i]['custom_fields']
  1110. self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
  1111. self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
  1112. self.assertEqual(response_cf['integer_field'], cf_defaults['integer_field'])
  1113. self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field'])
  1114. self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
  1115. self.assertEqual(response_cf['date_field'].isoformat(), cf_defaults['date_field'])
  1116. self.assertEqual(response_cf['datetime_field'].isoformat(), cf_defaults['datetime_field'])
  1117. self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
  1118. self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
  1119. self.assertEqual(response_cf['select_field'], self._select(cf_defaults['select_field']))
  1120. self.assertEqual(response_cf['multiselect_field'], self._multiselect(cf_defaults['multiselect_field']))
  1121. self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field'])
  1122. self.assertEqual(
  1123. [obj['id'] for obj in response_cf['multiobject_field']],
  1124. cf_defaults['multiobject_field']
  1125. )
  1126. # Validate database data
  1127. site = Site.objects.get(pk=response.data[i]['id'])
  1128. self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field'])
  1129. self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
  1130. self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field'])
  1131. self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field'])
  1132. self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
  1133. self.assertEqual(site.custom_field_data['date_field'], cf_defaults['date_field'])
  1134. self.assertEqual(site.custom_field_data['datetime_field'], cf_defaults['datetime_field'])
  1135. self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
  1136. self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
  1137. self.assertEqual(site.custom_field_data['select_field'], cf_defaults['select_field'])
  1138. self.assertEqual(site.custom_field_data['multiselect_field'], cf_defaults['multiselect_field'])
  1139. self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field'])
  1140. self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field'])
  1141. def test_create_multiple_objects_with_values(self):
  1142. """
  1143. Create a three new sites, each with custom fields defined.
  1144. """
  1145. custom_field_data = {
  1146. 'text_field': 'bar',
  1147. 'longtext_field': 'abcdefghij',
  1148. 'integer_field': 456,
  1149. 'decimal_field': 456.78,
  1150. 'boolean_field': True,
  1151. 'date_field': datetime.date(2020, 1, 2),
  1152. 'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0),
  1153. 'url_field': 'http://example.com/2',
  1154. 'json_field': '{"foo": 1, "bar": 2}',
  1155. 'select_field': 'bar',
  1156. 'multiselect_field': ['bar', 'baz'],
  1157. 'object_field': VLAN.objects.get(vid=2).pk,
  1158. 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
  1159. }
  1160. data = (
  1161. {
  1162. 'name': 'Site 3',
  1163. 'slug': 'site-3',
  1164. 'custom_fields': custom_field_data,
  1165. },
  1166. {
  1167. 'name': 'Site 4',
  1168. 'slug': 'site-4',
  1169. 'custom_fields': custom_field_data,
  1170. },
  1171. {
  1172. 'name': 'Site 5',
  1173. 'slug': 'site-5',
  1174. 'custom_fields': custom_field_data,
  1175. },
  1176. )
  1177. url = reverse('dcim-api:site-list')
  1178. self.add_permissions('dcim.add_site', 'ipam.view_vlan')
  1179. response = self.client.post(url, data, format='json', **self.header)
  1180. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1181. self.assertEqual(len(response.data), len(data))
  1182. for i, obj in enumerate(data):
  1183. # Validate response data
  1184. response_cf = response.data[i]['custom_fields']
  1185. self.assertEqual(response_cf['text_field'], custom_field_data['text_field'])
  1186. self.assertEqual(response_cf['longtext_field'], custom_field_data['longtext_field'])
  1187. self.assertEqual(response_cf['integer_field'], custom_field_data['integer_field'])
  1188. self.assertEqual(response_cf['decimal_field'], custom_field_data['decimal_field'])
  1189. self.assertEqual(response_cf['boolean_field'], custom_field_data['boolean_field'])
  1190. self.assertEqual(response_cf['date_field'], custom_field_data['date_field'])
  1191. self.assertEqual(response_cf['datetime_field'], custom_field_data['datetime_field'])
  1192. self.assertEqual(response_cf['url_field'], custom_field_data['url_field'])
  1193. self.assertEqual(response_cf['json_field'], custom_field_data['json_field'])
  1194. self.assertEqual(response_cf['select_field'], self._select(custom_field_data['select_field']))
  1195. self.assertEqual(
  1196. response_cf['multiselect_field'],
  1197. self._multiselect(custom_field_data['multiselect_field'])
  1198. )
  1199. self.assertEqual(response_cf['object_field']['id'], custom_field_data['object_field'])
  1200. self.assertEqual(
  1201. [obj['id'] for obj in response_cf['multiobject_field']],
  1202. custom_field_data['multiobject_field']
  1203. )
  1204. # Validate database data
  1205. site = Site.objects.get(pk=response.data[i]['id'])
  1206. self.assertEqual(site.custom_field_data['text_field'], custom_field_data['text_field'])
  1207. self.assertEqual(site.custom_field_data['longtext_field'], custom_field_data['longtext_field'])
  1208. self.assertEqual(site.custom_field_data['integer_field'], custom_field_data['integer_field'])
  1209. self.assertEqual(site.custom_field_data['decimal_field'], custom_field_data['decimal_field'])
  1210. self.assertEqual(site.custom_field_data['boolean_field'], custom_field_data['boolean_field'])
  1211. self.assertEqual(site.cf['date_field'], custom_field_data['date_field'])
  1212. self.assertEqual(site.cf['datetime_field'], custom_field_data['datetime_field'])
  1213. self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field'])
  1214. self.assertEqual(site.custom_field_data['json_field'], custom_field_data['json_field'])
  1215. self.assertEqual(site.custom_field_data['select_field'], custom_field_data['select_field'])
  1216. self.assertEqual(site.custom_field_data['multiselect_field'], custom_field_data['multiselect_field'])
  1217. self.assertEqual(site.custom_field_data['object_field'], custom_field_data['object_field'])
  1218. self.assertEqual(site.custom_field_data['multiobject_field'], custom_field_data['multiobject_field'])
  1219. def test_update_single_object_with_values(self):
  1220. """
  1221. Update an object with existing custom field values. Ensure that only the updated custom field values are
  1222. modified.
  1223. """
  1224. site2 = Site.objects.get(name='Site 2')
  1225. original_cfvs = {**site2.cf}
  1226. data = {
  1227. 'custom_fields': {
  1228. 'text_field': 'ABCD',
  1229. 'integer_field': 1234,
  1230. },
  1231. }
  1232. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1233. self.add_permissions('dcim.change_site')
  1234. response = self.client.patch(url, data, format='json', **self.header)
  1235. self.assertHttpStatus(response, status.HTTP_200_OK)
  1236. # Validate response data
  1237. response_cf = response.data['custom_fields']
  1238. self.assertEqual(response_cf['text_field'], data['custom_fields']['text_field'])
  1239. self.assertEqual(response_cf['longtext_field'], original_cfvs['longtext_field'])
  1240. self.assertEqual(response_cf['integer_field'], data['custom_fields']['integer_field'])
  1241. self.assertEqual(response_cf['decimal_field'], original_cfvs['decimal_field'])
  1242. self.assertEqual(response_cf['boolean_field'], original_cfvs['boolean_field'])
  1243. self.assertEqual(response_cf['date_field'], original_cfvs['date_field'])
  1244. self.assertEqual(response_cf['datetime_field'], original_cfvs['datetime_field'])
  1245. self.assertEqual(response_cf['url_field'], original_cfvs['url_field'])
  1246. self.assertEqual(response_cf['json_field'], original_cfvs['json_field'])
  1247. self.assertEqual(response_cf['select_field'], self._select(original_cfvs['select_field']))
  1248. self.assertEqual(response_cf['multiselect_field'], self._multiselect(original_cfvs['multiselect_field']))
  1249. self.assertEqual(response_cf['object_field']['id'], original_cfvs['object_field'].pk)
  1250. self.assertListEqual(
  1251. [obj['id'] for obj in response_cf['multiobject_field']],
  1252. [obj.pk for obj in original_cfvs['multiobject_field']]
  1253. )
  1254. # Validate database data
  1255. site2 = Site.objects.get(pk=site2.pk)
  1256. self.assertEqual(site2.cf['text_field'], data['custom_fields']['text_field'])
  1257. self.assertEqual(site2.cf['longtext_field'], original_cfvs['longtext_field'])
  1258. self.assertEqual(site2.cf['integer_field'], data['custom_fields']['integer_field'])
  1259. self.assertEqual(site2.cf['decimal_field'], original_cfvs['decimal_field'])
  1260. self.assertEqual(site2.cf['boolean_field'], original_cfvs['boolean_field'])
  1261. self.assertEqual(site2.cf['date_field'], original_cfvs['date_field'])
  1262. self.assertEqual(site2.cf['datetime_field'], original_cfvs['datetime_field'])
  1263. self.assertEqual(site2.cf['url_field'], original_cfvs['url_field'])
  1264. self.assertEqual(site2.cf['json_field'], original_cfvs['json_field'])
  1265. self.assertEqual(site2.cf['select_field'], original_cfvs['select_field'])
  1266. self.assertEqual(site2.cf['multiselect_field'], original_cfvs['multiselect_field'])
  1267. self.assertEqual(site2.cf['object_field'], original_cfvs['object_field'])
  1268. self.assertListEqual(
  1269. list(site2.cf['multiobject_field']),
  1270. list(original_cfvs['multiobject_field'])
  1271. )
  1272. @tag('regression')
  1273. def test_update_single_object_rejects_unknown_custom_fields(self):
  1274. site2 = Site.objects.get(name='Site 2')
  1275. original_cf_data = {**site2.custom_field_data}
  1276. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1277. self.add_permissions('dcim.change_site')
  1278. data = {
  1279. 'custom_fields': {
  1280. 'text_field': 'valid',
  1281. 'thisfieldshouldntexist': 'random text here',
  1282. },
  1283. }
  1284. response = self.client.patch(url, data, format='json', **self.header)
  1285. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1286. self.assertIn('custom_fields', response.data)
  1287. self.assertIn('thisfieldshouldntexist', response.data['custom_fields'])
  1288. # Ensure the object was not modified
  1289. site2.refresh_from_db()
  1290. self.assertEqual(site2.custom_field_data, original_cf_data)
  1291. @tag('regression')
  1292. def test_update_single_object_prunes_stale_custom_field_data_from_database_and_postchange_data(self):
  1293. stale_key = 'thisfieldshouldntexist'
  1294. stale_value = 'random text here'
  1295. updated_text_value = 'ABCD'
  1296. site2 = Site.objects.get(name='Site 2')
  1297. original_text_value = site2.custom_field_data['text_field']
  1298. object_type = ObjectType.objects.get_for_model(Site)
  1299. # Seed stale custom field data directly in the database to mimic a polluted row.
  1300. Site.objects.filter(pk=site2.pk).update(
  1301. custom_field_data={
  1302. **site2.custom_field_data,
  1303. stale_key: stale_value,
  1304. }
  1305. )
  1306. site2.refresh_from_db()
  1307. self.assertIn(stale_key, site2.custom_field_data)
  1308. existing_change_ids = set(
  1309. ObjectChange.objects.filter(
  1310. changed_object_type=object_type,
  1311. changed_object_id=site2.pk,
  1312. ).values_list('pk', flat=True)
  1313. )
  1314. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1315. self.add_permissions('dcim.change_site')
  1316. data = {
  1317. 'custom_fields': {
  1318. 'text_field': updated_text_value,
  1319. },
  1320. }
  1321. response = self.client.patch(url, data, format='json', **self.header)
  1322. self.assertHttpStatus(response, status.HTTP_200_OK)
  1323. site2.refresh_from_db()
  1324. self.assertEqual(site2.cf['text_field'], updated_text_value)
  1325. self.assertNotIn(stale_key, site2.custom_field_data)
  1326. object_changes = ObjectChange.objects.filter(
  1327. changed_object_type=object_type,
  1328. changed_object_id=site2.pk,
  1329. ).exclude(pk__in=existing_change_ids)
  1330. self.assertEqual(object_changes.count(), 1)
  1331. object_change = object_changes.get()
  1332. self.assertEqual(object_change.prechange_data['custom_fields']['text_field'], original_text_value)
  1333. self.assertEqual(object_change.postchange_data['custom_fields']['text_field'], updated_text_value)
  1334. self.assertNotIn(stale_key, object_change.postchange_data['custom_fields'])
  1335. def test_specify_related_object_by_attr(self):
  1336. site1 = Site.objects.get(name='Site 1')
  1337. vlans = VLAN.objects.all()[:3]
  1338. url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk})
  1339. self.add_permissions('dcim.change_site', 'ipam.view_vlan')
  1340. # Set related objects by PK
  1341. data = {
  1342. 'custom_fields': {
  1343. 'object_field': vlans[0].pk,
  1344. 'multiobject_field': [vlans[1].pk, vlans[2].pk],
  1345. },
  1346. }
  1347. response = self.client.patch(url, data, format='json', **self.header)
  1348. self.assertHttpStatus(response, status.HTTP_200_OK)
  1349. self.assertEqual(
  1350. response.data['custom_fields']['object_field']['id'],
  1351. vlans[0].pk
  1352. )
  1353. self.assertListEqual(
  1354. [obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
  1355. [vlans[1].pk, vlans[2].pk]
  1356. )
  1357. # Set related objects by name
  1358. data = {
  1359. 'custom_fields': {
  1360. 'object_field': {
  1361. 'name': vlans[0].name,
  1362. },
  1363. 'multiobject_field': [
  1364. {
  1365. 'name': vlans[1].name
  1366. },
  1367. {
  1368. 'name': vlans[2].name
  1369. },
  1370. ],
  1371. },
  1372. }
  1373. response = self.client.patch(url, data, format='json', **self.header)
  1374. self.assertHttpStatus(response, status.HTTP_200_OK)
  1375. self.assertEqual(
  1376. response.data['custom_fields']['object_field']['id'],
  1377. vlans[0].pk
  1378. )
  1379. self.assertListEqual(
  1380. [obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
  1381. [vlans[1].pk, vlans[2].pk]
  1382. )
  1383. # Clear related objects
  1384. data = {
  1385. 'custom_fields': {
  1386. 'object_field': None,
  1387. 'multiobject_field': [],
  1388. },
  1389. }
  1390. response = self.client.patch(url, data, format='json', **self.header)
  1391. self.assertHttpStatus(response, status.HTTP_200_OK)
  1392. self.assertIsNone(response.data['custom_fields']['object_field'])
  1393. self.assertListEqual(response.data['custom_fields']['multiobject_field'], [])
  1394. def test_minimum_maximum_values_validation(self):
  1395. site2 = Site.objects.get(name='Site 2')
  1396. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1397. self.add_permissions('dcim.change_site')
  1398. cf_integer = CustomField.objects.get(name='integer_field')
  1399. cf_integer.validation_minimum = 10
  1400. cf_integer.validation_maximum = 20
  1401. cf_integer.save()
  1402. data = {'custom_fields': {'integer_field': 9}}
  1403. response = self.client.patch(url, data, format='json', **self.header)
  1404. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1405. data = {'custom_fields': {'integer_field': 21}}
  1406. response = self.client.patch(url, data, format='json', **self.header)
  1407. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1408. data = {'custom_fields': {'integer_field': 15}}
  1409. response = self.client.patch(url, data, format='json', **self.header)
  1410. self.assertHttpStatus(response, status.HTTP_200_OK)
  1411. def test_regex_validation(self):
  1412. site2 = Site.objects.get(name='Site 2')
  1413. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1414. self.add_permissions('dcim.change_site')
  1415. cf_text = CustomField.objects.get(name='text_field')
  1416. cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters
  1417. cf_text.save()
  1418. data = {'custom_fields': {'text_field': 'ABC123'}}
  1419. response = self.client.patch(url, data, format='json', **self.header)
  1420. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1421. data = {'custom_fields': {'text_field': 'abc'}}
  1422. response = self.client.patch(url, data, format='json', **self.header)
  1423. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1424. data = {'custom_fields': {'text_field': 'ABC'}}
  1425. response = self.client.patch(url, data, format='json', **self.header)
  1426. self.assertHttpStatus(response, status.HTTP_200_OK)
  1427. def test_url_regex_validation(self):
  1428. """
  1429. Test that validation_regex is applied to URL custom fields (fixes #20498).
  1430. """
  1431. site2 = Site.objects.get(name='Site 2')
  1432. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1433. self.add_permissions('dcim.change_site')
  1434. cf_url = CustomField.objects.get(name='url_field')
  1435. cf_url.validation_regex = r'^https://' # Require HTTPS
  1436. cf_url.save()
  1437. # Test invalid URL (http instead of https)
  1438. data = {'custom_fields': {'url_field': 'http://example.com'}}
  1439. response = self.client.patch(url, data, format='json', **self.header)
  1440. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1441. # Test valid URL (https)
  1442. data = {'custom_fields': {'url_field': 'https://example.com'}}
  1443. response = self.client.patch(url, data, format='json', **self.header)
  1444. self.assertHttpStatus(response, status.HTTP_200_OK)
  1445. def test_json_schema_validation(self):
  1446. site2 = Site.objects.get(name='Site 2')
  1447. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1448. self.add_permissions('dcim.change_site')
  1449. cf_json = CustomField.objects.get(name='json_field')
  1450. cf_json.validation_schema = {
  1451. 'type': 'object',
  1452. 'properties': {
  1453. 'name': {'type': 'string'},
  1454. 'age': {'type': 'integer'},
  1455. },
  1456. 'required': ['name'],
  1457. }
  1458. cf_json.save()
  1459. # Invalid: missing required 'name' property
  1460. data = {'custom_fields': {'json_field': {'age': 25}}}
  1461. response = self.client.patch(url, data, format='json', **self.header)
  1462. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1463. # Invalid: 'age' is not an integer
  1464. data = {'custom_fields': {'json_field': {'name': 'test', 'age': 'not_an_int'}}}
  1465. response = self.client.patch(url, data, format='json', **self.header)
  1466. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1467. # Valid: conforms to schema
  1468. data = {'custom_fields': {'json_field': {'name': 'test', 'age': 25}}}
  1469. response = self.client.patch(url, data, format='json', **self.header)
  1470. self.assertHttpStatus(response, status.HTTP_200_OK)
  1471. # Valid: null value (schema not enforced on empty)
  1472. data = {'custom_fields': {'json_field': None}}
  1473. response = self.client.patch(url, data, format='json', **self.header)
  1474. self.assertHttpStatus(response, status.HTTP_200_OK)
  1475. def test_uniqueness_validation(self):
  1476. # Create a unique custom field
  1477. cf_text = CustomField.objects.get(name='text_field')
  1478. cf_text.unique = True
  1479. cf_text.save()
  1480. # Set a value on site 1
  1481. site1 = Site.objects.get(name='Site 1')
  1482. site1.custom_field_data['text_field'] = 'ABC123'
  1483. site1.save()
  1484. site2 = Site.objects.get(name='Site 2')
  1485. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1486. self.add_permissions('dcim.change_site')
  1487. data = {'custom_fields': {'text_field': 'ABC123'}}
  1488. response = self.client.patch(url, data, format='json', **self.header)
  1489. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1490. data = {'custom_fields': {'text_field': 'DEF456'}}
  1491. response = self.client.patch(url, data, format='json', **self.header)
  1492. self.assertHttpStatus(response, status.HTTP_200_OK)
  1493. class CustomFieldImportTestCase(TestCase):
  1494. user_permissions = (
  1495. 'dcim.view_site',
  1496. 'dcim.add_site',
  1497. )
  1498. @classmethod
  1499. def setUpTestData(cls):
  1500. # Create a set of custom field choices
  1501. choice_set = CustomFieldChoiceSet.objects.create(
  1502. name='Custom Field Choice Set 1',
  1503. extra_choices=(
  1504. ('a', 'Option A'),
  1505. ('b', 'Option B'),
  1506. ('c', 'Option C'),
  1507. )
  1508. )
  1509. custom_fields = (
  1510. CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT),
  1511. CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT),
  1512. CustomField(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER),
  1513. CustomField(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL),
  1514. CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN),
  1515. CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE),
  1516. CustomField(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME),
  1517. CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
  1518. CustomField(name='json', type=CustomFieldTypeChoices.TYPE_JSON),
  1519. CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choice_set=choice_set),
  1520. CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choice_set=choice_set),
  1521. )
  1522. for cf in custom_fields:
  1523. cf.save()
  1524. cf.object_types.set([ObjectType.objects.get_for_model(Site)])
  1525. def test_import(self):
  1526. """
  1527. Import a Site in CSV format, including a value for each CustomField.
  1528. """
  1529. data = (
  1530. (
  1531. 'name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_decimal', 'cf_boolean', 'cf_date',
  1532. 'cf_datetime', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect',
  1533. ),
  1534. (
  1535. 'Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01',
  1536. '2020-01-01 12:00:00', 'http://example.com/1', '{"foo": 123}', 'a', '"a,b"',
  1537. ),
  1538. (
  1539. 'Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', 'False', '2020-01-02',
  1540. '2020-01-02 12:00:00', 'http://example.com/2', '{"bar": 456}', 'b', '"b,c"',
  1541. ),
  1542. ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', '', '', ''),
  1543. )
  1544. csv_data = '\n'.join(','.join(row) for row in data)
  1545. response = self.client.post(reverse('dcim:site_bulk_import'), {
  1546. 'data': csv_data,
  1547. 'format': ImportFormatChoices.CSV,
  1548. 'csv_delimiter': CSVDelimiterChoices.AUTO,
  1549. })
  1550. self.assertEqual(response.status_code, 302)
  1551. self.assertEqual(Site.objects.count(), 3)
  1552. # Validate data for site 1
  1553. site1 = Site.objects.get(name='Site 1')
  1554. self.assertEqual(len(site1.custom_field_data), 11)
  1555. self.assertEqual(site1.custom_field_data['text'], 'ABC')
  1556. self.assertEqual(site1.custom_field_data['longtext'], 'Foo')
  1557. self.assertEqual(site1.custom_field_data['integer'], 123)
  1558. self.assertEqual(site1.custom_field_data['decimal'], 123.45)
  1559. self.assertEqual(site1.custom_field_data['boolean'], True)
  1560. self.assertEqual(site1.cf['date'].isoformat(), '2020-01-01')
  1561. self.assertEqual(site1.cf['datetime'].isoformat(), '2020-01-01T12:00:00+00:00')
  1562. self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
  1563. self.assertEqual(site1.custom_field_data['json'], {"foo": 123})
  1564. self.assertEqual(site1.custom_field_data['select'], 'a')
  1565. self.assertEqual(site1.custom_field_data['multiselect'], ['a', 'b'])
  1566. # Validate data for site 2
  1567. site2 = Site.objects.get(name='Site 2')
  1568. self.assertEqual(len(site2.custom_field_data), 11)
  1569. self.assertEqual(site2.custom_field_data['text'], 'DEF')
  1570. self.assertEqual(site2.custom_field_data['longtext'], 'Bar')
  1571. self.assertEqual(site2.custom_field_data['integer'], 456)
  1572. self.assertEqual(site2.custom_field_data['decimal'], 456.78)
  1573. self.assertEqual(site2.custom_field_data['boolean'], False)
  1574. self.assertEqual(site2.cf['date'].isoformat(), '2020-01-02')
  1575. self.assertEqual(site2.cf['datetime'].isoformat(), '2020-01-02T12:00:00+00:00')
  1576. self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
  1577. self.assertEqual(site2.custom_field_data['json'], {"bar": 456})
  1578. self.assertEqual(site2.custom_field_data['select'], 'b')
  1579. self.assertEqual(site2.custom_field_data['multiselect'], ['b', 'c'])
  1580. # No custom field data should be set for site 3
  1581. site3 = Site.objects.get(name='Site 3')
  1582. self.assertFalse(any(site3.custom_field_data.values()))
  1583. def test_import_missing_required(self):
  1584. """
  1585. Attempt to import an object missing a required custom field.
  1586. """
  1587. # Set one of our CustomFields to required
  1588. CustomField.objects.filter(name='text').update(required=True)
  1589. form_data = {
  1590. 'name': 'Site 1',
  1591. 'slug': 'site-1',
  1592. }
  1593. form = SiteImportForm(data=form_data)
  1594. self.assertFalse(form.is_valid())
  1595. self.assertIn('cf_text', form.errors)
  1596. def test_import_invalid_choice(self):
  1597. """
  1598. Attempt to import an object with an invalid choice selection.
  1599. """
  1600. form_data = {
  1601. 'name': 'Site 1',
  1602. 'slug': 'site-1',
  1603. 'cf_select': 'Choice X'
  1604. }
  1605. form = SiteImportForm(data=form_data)
  1606. self.assertFalse(form.is_valid())
  1607. self.assertIn('cf_select', form.errors)
  1608. class CustomFieldModelTestCase(TestCase):
  1609. @classmethod
  1610. def setUpTestData(cls):
  1611. cf1 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='foo')
  1612. cf1.save()
  1613. cf1.object_types.set([ObjectType.objects.get_for_model(Site)])
  1614. cf2 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='bar')
  1615. cf2.save()
  1616. cf2.object_types.set([ObjectType.objects.get_for_model(Rack)])
  1617. def test_cf_data(self):
  1618. """
  1619. Check that custom field data is present on the instance immediately after being set and after being fetched
  1620. from the database.
  1621. """
  1622. site = Site(name='Test Site', slug='test-site')
  1623. # Check custom field data on new instance
  1624. site.custom_field_data['foo'] = 'abc'
  1625. self.assertEqual(site.cf['foo'], 'abc')
  1626. # Check custom field data from database
  1627. site.save()
  1628. site = Site.objects.get(name='Test Site')
  1629. self.assertEqual(site.cf['foo'], 'abc')
  1630. def test_invalid_data(self):
  1631. """
  1632. Any invalid or stale custom field data should be removed from the instance.
  1633. """
  1634. site = Site(name='Test Site', slug='test-site')
  1635. # Set custom field data
  1636. site.custom_field_data['foo'] = 'abc'
  1637. site.custom_field_data['bar'] = 'def'
  1638. site.clean()
  1639. self.assertIn('foo', site.custom_field_data)
  1640. self.assertNotIn('bar', site.custom_field_data)
  1641. def test_missing_required_field(self):
  1642. """
  1643. Check that a ValidationError is raised if any required custom fields are not present.
  1644. """
  1645. cf3 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='baz', required=True)
  1646. cf3.save()
  1647. cf3.object_types.set([ObjectType.objects.get_for_model(Site)])
  1648. site = Site(name='Test Site', slug='test-site')
  1649. # Set custom field data with a required field omitted
  1650. site.custom_field_data['foo'] = 'abc'
  1651. with self.assertRaises(ValidationError):
  1652. site.clean()
  1653. site.custom_field_data['baz'] = 'def'
  1654. site.clean()
  1655. class CustomFieldModelFilterTestCase(TestCase):
  1656. queryset = Site.objects.all()
  1657. filterset = SiteFilterSet
  1658. @classmethod
  1659. def setUpTestData(cls):
  1660. object_type = ObjectType.objects.get_for_model(Site)
  1661. manufacturers = Manufacturer.objects.bulk_create((
  1662. Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
  1663. Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
  1664. Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
  1665. Manufacturer(name='Manufacturer 4', slug='manufacturer-4'),
  1666. ))
  1667. choice_set = CustomFieldChoiceSet.objects.create(
  1668. name='Custom Field Choice Set 1',
  1669. extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'))
  1670. )
  1671. # Integer filtering
  1672. cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER)
  1673. cf.save()
  1674. cf.object_types.set([object_type])
  1675. # Decimal filtering
  1676. cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_DECIMAL)
  1677. cf.save()
  1678. cf.object_types.set([object_type])
  1679. # Boolean filtering
  1680. cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
  1681. cf.save()
  1682. cf.object_types.set([object_type])
  1683. # Exact text filtering
  1684. cf = CustomField(
  1685. name='cf4',
  1686. type=CustomFieldTypeChoices.TYPE_TEXT,
  1687. filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
  1688. )
  1689. cf.save()
  1690. cf.object_types.set([object_type])
  1691. # Loose text filtering
  1692. cf = CustomField(
  1693. name='cf5',
  1694. type=CustomFieldTypeChoices.TYPE_TEXT,
  1695. filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
  1696. )
  1697. cf.save()
  1698. cf.object_types.set([object_type])
  1699. # Date filtering
  1700. cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_DATE)
  1701. cf.save()
  1702. cf.object_types.set([object_type])
  1703. # Exact URL filtering
  1704. cf = CustomField(
  1705. name='cf7',
  1706. type=CustomFieldTypeChoices.TYPE_URL,
  1707. filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
  1708. )
  1709. cf.save()
  1710. cf.object_types.set([object_type])
  1711. # Loose URL filtering
  1712. cf = CustomField(
  1713. name='cf8',
  1714. type=CustomFieldTypeChoices.TYPE_URL,
  1715. filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
  1716. )
  1717. cf.save()
  1718. cf.object_types.set([object_type])
  1719. # Selection filtering
  1720. cf = CustomField(
  1721. name='cf9',
  1722. type=CustomFieldTypeChoices.TYPE_SELECT,
  1723. choice_set=choice_set
  1724. )
  1725. cf.save()
  1726. cf.object_types.set([object_type])
  1727. # Multiselect filtering
  1728. cf = CustomField(
  1729. name='cf10',
  1730. type=CustomFieldTypeChoices.TYPE_MULTISELECT,
  1731. choice_set=choice_set
  1732. )
  1733. cf.save()
  1734. cf.object_types.set([object_type])
  1735. # Object filtering
  1736. cf = CustomField(
  1737. name='cf11',
  1738. type=CustomFieldTypeChoices.TYPE_OBJECT,
  1739. related_object_type=ObjectType.objects.get_for_model(Manufacturer)
  1740. )
  1741. cf.save()
  1742. cf.object_types.set([object_type])
  1743. # Multi-object filtering
  1744. cf = CustomField(
  1745. name='cf12',
  1746. type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
  1747. related_object_type=ObjectType.objects.get_for_model(Manufacturer)
  1748. )
  1749. cf.save()
  1750. cf.object_types.set([object_type])
  1751. Site.objects.bulk_create([
  1752. Site(name='Site 1', slug='site-1', custom_field_data={
  1753. 'cf1': 100,
  1754. 'cf2': 100.1,
  1755. 'cf3': True,
  1756. 'cf4': 'foo',
  1757. 'cf5': 'foo',
  1758. 'cf6': '2016-06-26',
  1759. 'cf7': 'http://a.example.com',
  1760. 'cf8': 'http://a.example.com',
  1761. 'cf9': 'A',
  1762. 'cf10': ['A', 'B'],
  1763. 'cf11': manufacturers[0].pk,
  1764. 'cf12': [manufacturers[0].pk, manufacturers[3].pk],
  1765. }),
  1766. Site(name='Site 2', slug='site-2', custom_field_data={
  1767. 'cf1': 200,
  1768. 'cf2': 200.2,
  1769. 'cf3': True,
  1770. 'cf4': 'foobar',
  1771. 'cf5': 'foobar',
  1772. 'cf6': '2016-06-27',
  1773. 'cf7': 'http://b.example.com',
  1774. 'cf8': 'http://b.example.com',
  1775. 'cf9': 'B',
  1776. 'cf10': ['B', 'C'],
  1777. 'cf11': manufacturers[1].pk,
  1778. 'cf12': [manufacturers[1].pk, manufacturers[3].pk],
  1779. }),
  1780. Site(name='Site 3', slug='site-3', custom_field_data={
  1781. 'cf1': 300,
  1782. 'cf2': 300.3,
  1783. 'cf3': False,
  1784. 'cf4': 'bar',
  1785. 'cf5': 'bar',
  1786. 'cf6': '2016-06-28',
  1787. 'cf7': 'http://c.example.com',
  1788. 'cf8': 'http://c.example.com',
  1789. 'cf9': 'C',
  1790. 'cf10': None,
  1791. 'cf11': manufacturers[2].pk,
  1792. 'cf12': [manufacturers[2].pk, manufacturers[3].pk],
  1793. }),
  1794. Site(name='Site 4', slug='site-4'),
  1795. ])
  1796. def test_filter_integer(self):
  1797. self.assertEqual(self.filterset({'cf_cf1': [100, 200]}, self.queryset).qs.count(), 2)
  1798. self.assertEqual(self.filterset({'cf_cf1__n': [200]}, self.queryset).qs.count(), 2)
  1799. self.assertEqual(self.filterset({'cf_cf1__gt': [200]}, self.queryset).qs.count(), 1)
  1800. self.assertEqual(self.filterset({'cf_cf1__gte': [200]}, self.queryset).qs.count(), 2)
  1801. self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1)
  1802. self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2)
  1803. self.assertEqual(self.filterset({'cf_cf1__empty': True}, self.queryset).qs.count(), 1)
  1804. def test_filter_decimal(self):
  1805. self.assertEqual(self.filterset({'cf_cf2': [100.1, 200.2]}, self.queryset).qs.count(), 2)
  1806. self.assertEqual(self.filterset({'cf_cf2__n': [200.2]}, self.queryset).qs.count(), 2)
  1807. self.assertEqual(self.filterset({'cf_cf2__gt': [200.2]}, self.queryset).qs.count(), 1)
  1808. self.assertEqual(self.filterset({'cf_cf2__gte': [200.2]}, self.queryset).qs.count(), 2)
  1809. self.assertEqual(self.filterset({'cf_cf2__lt': [200.2]}, self.queryset).qs.count(), 1)
  1810. self.assertEqual(self.filterset({'cf_cf2__lte': [200.2]}, self.queryset).qs.count(), 2)
  1811. self.assertEqual(self.filterset({'cf_cf2__empty': True}, self.queryset).qs.count(), 1)
  1812. def test_filter_boolean(self):
  1813. self.assertEqual(self.filterset({'cf_cf3': True}, self.queryset).qs.count(), 2)
  1814. self.assertEqual(self.filterset({'cf_cf3': False}, self.queryset).qs.count(), 1)
  1815. def test_filter_text_strict(self):
  1816. self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 1)
  1817. self.assertEqual(self.filterset({'cf_cf4__n': ['foo']}, self.queryset).qs.count(), 2)
  1818. self.assertEqual(self.filterset({'cf_cf4__ic': ['foo']}, self.queryset).qs.count(), 2)
  1819. self.assertEqual(self.filterset({'cf_cf4__nic': ['foo']}, self.queryset).qs.count(), 1)
  1820. self.assertEqual(self.filterset({'cf_cf4__isw': ['foo']}, self.queryset).qs.count(), 2)
  1821. self.assertEqual(self.filterset({'cf_cf4__nisw': ['foo']}, self.queryset).qs.count(), 1)
  1822. self.assertEqual(self.filterset({'cf_cf4__iew': ['bar']}, self.queryset).qs.count(), 2)
  1823. self.assertEqual(self.filterset({'cf_cf4__niew': ['bar']}, self.queryset).qs.count(), 1)
  1824. self.assertEqual(self.filterset({'cf_cf4__ie': ['FOO']}, self.queryset).qs.count(), 1)
  1825. self.assertEqual(self.filterset({'cf_cf4__nie': ['FOO']}, self.queryset).qs.count(), 2)
  1826. self.assertEqual(self.filterset({'cf_cf4__empty': True}, self.queryset).qs.count(), 1)
  1827. def test_filter_text_loose(self):
  1828. self.assertEqual(self.filterset({'cf_cf5': ['foo']}, self.queryset).qs.count(), 2)
  1829. def test_filter_date(self):
  1830. self.assertEqual(self.filterset({'cf_cf6': ['2016-06-26', '2016-06-27']}, self.queryset).qs.count(), 2)
  1831. self.assertEqual(self.filterset({'cf_cf6__n': ['2016-06-27']}, self.queryset).qs.count(), 2)
  1832. self.assertEqual(self.filterset({'cf_cf6__gt': ['2016-06-27']}, self.queryset).qs.count(), 1)
  1833. self.assertEqual(self.filterset({'cf_cf6__gte': ['2016-06-27']}, self.queryset).qs.count(), 2)
  1834. self.assertEqual(self.filterset({'cf_cf6__lt': ['2016-06-27']}, self.queryset).qs.count(), 1)
  1835. self.assertEqual(self.filterset({'cf_cf6__lte': ['2016-06-27']}, self.queryset).qs.count(), 2)
  1836. self.assertEqual(self.filterset({'cf_cf6__empty': True}, self.queryset).qs.count(), 1)
  1837. def test_filter_url_strict(self):
  1838. self.assertEqual(
  1839. self.filterset({'cf_cf7': ['http://a.example.com', 'http://b.example.com']}, self.queryset).qs.count(),
  1840. 2
  1841. )
  1842. self.assertEqual(self.filterset({'cf_cf7__n': ['http://b.example.com']}, self.queryset).qs.count(), 2)
  1843. self.assertEqual(self.filterset({'cf_cf7__ic': ['b']}, self.queryset).qs.count(), 1)
  1844. self.assertEqual(self.filterset({'cf_cf7__nic': ['b']}, self.queryset).qs.count(), 2)
  1845. self.assertEqual(self.filterset({'cf_cf7__isw': ['http://']}, self.queryset).qs.count(), 3)
  1846. self.assertEqual(self.filterset({'cf_cf7__nisw': ['http://']}, self.queryset).qs.count(), 0)
  1847. self.assertEqual(self.filterset({'cf_cf7__iew': ['.com']}, self.queryset).qs.count(), 3)
  1848. self.assertEqual(self.filterset({'cf_cf7__niew': ['.com']}, self.queryset).qs.count(), 0)
  1849. self.assertEqual(self.filterset({'cf_cf7__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1)
  1850. self.assertEqual(self.filterset({'cf_cf7__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2)
  1851. self.assertEqual(self.filterset({'cf_cf7__empty': True}, self.queryset).qs.count(), 1)
  1852. def test_filter_url_loose(self):
  1853. self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3)
  1854. def test_filter_select(self):
  1855. self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
  1856. self.assertEqual(self.filterset({'cf_cf9__empty': True}, self.queryset).qs.count(), 1)
  1857. def test_filter_multiselect(self):
  1858. self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1)
  1859. self.assertEqual(self.filterset({'cf_cf10': ['A', 'C']}, self.queryset).qs.count(), 2)
  1860. self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 1) # Contains a literal null
  1861. self.assertEqual(self.filterset({'cf_cf10__empty': True}, self.queryset).qs.count(), 2)
  1862. def test_filter_object(self):
  1863. manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
  1864. self.assertEqual(
  1865. self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(),
  1866. 2
  1867. )
  1868. self.assertEqual(self.filterset({'cf_cf11__empty': True}, self.queryset).qs.count(), 1)
  1869. def test_filter_multiobject(self):
  1870. manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
  1871. self.assertEqual(
  1872. self.filterset({'cf_cf12': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(),
  1873. 2
  1874. )
  1875. self.assertEqual(
  1876. self.filterset({'cf_cf12': [manufacturer_ids[3]]}, self.queryset).qs.count(),
  1877. 3
  1878. )
  1879. self.assertEqual(self.filterset({'cf_cf12__empty': True}, self.queryset).qs.count(), 1)