2
0

test_customfields.py 85 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061
  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. def test_get_custom_fields(self):
  821. TYPES = {
  822. CustomFieldTypeChoices.TYPE_TEXT: 'string',
  823. CustomFieldTypeChoices.TYPE_LONGTEXT: 'string',
  824. CustomFieldTypeChoices.TYPE_INTEGER: 'integer',
  825. CustomFieldTypeChoices.TYPE_DECIMAL: 'decimal',
  826. CustomFieldTypeChoices.TYPE_BOOLEAN: 'boolean',
  827. CustomFieldTypeChoices.TYPE_DATE: 'string',
  828. CustomFieldTypeChoices.TYPE_DATETIME: 'string',
  829. CustomFieldTypeChoices.TYPE_URL: 'string',
  830. CustomFieldTypeChoices.TYPE_JSON: 'object',
  831. CustomFieldTypeChoices.TYPE_SELECT: 'string',
  832. CustomFieldTypeChoices.TYPE_MULTISELECT: 'array',
  833. CustomFieldTypeChoices.TYPE_OBJECT: 'object',
  834. CustomFieldTypeChoices.TYPE_MULTIOBJECT: 'array',
  835. }
  836. self.add_permissions('extras.view_customfield')
  837. url = reverse('extras-api:customfield-list')
  838. response = self.client.get(url, **self.header)
  839. self.assertEqual(response.data['count'], len(TYPES))
  840. # Validate data types
  841. for customfield in response.data['results']:
  842. cf_type = customfield['type']['value']
  843. self.assertEqual(customfield['data_type'], TYPES[cf_type])
  844. def test_get_single_object_without_custom_field_data(self):
  845. """
  846. Validate that custom fields are present on an object even if it has no values defined.
  847. """
  848. site1 = Site.objects.get(name='Site 1')
  849. url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk})
  850. self.add_permissions('dcim.view_site')
  851. response = self.client.get(url, **self.header)
  852. self.assertEqual(response.data['name'], site1.name)
  853. self.assertEqual(response.data['custom_fields'], {
  854. 'text_field': None,
  855. 'longtext_field': None,
  856. 'integer_field': None,
  857. 'decimal_field': None,
  858. 'boolean_field': None,
  859. 'date_field': None,
  860. 'datetime_field': None,
  861. 'url_field': None,
  862. 'json_field': None,
  863. 'select_field': None,
  864. 'multiselect_field': None,
  865. 'object_field': None,
  866. 'multiobject_field': None,
  867. })
  868. def test_get_single_object_with_custom_field_data(self):
  869. """
  870. Validate that custom fields are present and correctly set for an object with values defined.
  871. """
  872. site2 = Site.objects.get(name='Site 2')
  873. site2_cfvs = site2.cf
  874. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  875. self.add_permissions('dcim.view_site')
  876. response = self.client.get(url, **self.header)
  877. self.assertEqual(response.data['name'], site2.name)
  878. self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field'])
  879. self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_field'])
  880. self.assertEqual(response.data['custom_fields']['integer_field'], site2_cfvs['integer_field'])
  881. self.assertEqual(response.data['custom_fields']['decimal_field'], site2_cfvs['decimal_field'])
  882. self.assertEqual(response.data['custom_fields']['boolean_field'], site2_cfvs['boolean_field'])
  883. self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field'])
  884. self.assertEqual(response.data['custom_fields']['datetime_field'], site2_cfvs['datetime_field'])
  885. self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field'])
  886. self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field'])
  887. self.assertEqual(response.data['custom_fields']['select_field'], site2_cfvs['select_field'])
  888. self.assertEqual(response.data['custom_fields']['multiselect_field'], site2_cfvs['multiselect_field'])
  889. self.assertEqual(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field'].pk)
  890. self.assertEqual(
  891. [obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
  892. [obj.pk for obj in site2_cfvs['multiobject_field']]
  893. )
  894. def test_create_single_object_with_defaults(self):
  895. """
  896. Create a new site with no specified custom field values and check that it received the default values.
  897. """
  898. cf_defaults = {
  899. cf.name: cf.default for cf in CustomField.objects.all()
  900. }
  901. data = {
  902. 'name': 'Site 3',
  903. 'slug': 'site-3',
  904. }
  905. url = reverse('dcim-api:site-list')
  906. self.add_permissions('dcim.add_site')
  907. response = self.client.post(url, data, format='json', **self.header)
  908. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  909. # Validate response data
  910. response_cf = response.data['custom_fields']
  911. self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
  912. self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
  913. self.assertEqual(response_cf['integer_field'], cf_defaults['integer_field'])
  914. self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field'])
  915. self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
  916. self.assertEqual(response_cf['date_field'].isoformat(), cf_defaults['date_field'])
  917. self.assertEqual(response_cf['datetime_field'].isoformat(), cf_defaults['datetime_field'])
  918. self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
  919. self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
  920. self.assertEqual(response_cf['select_field'], cf_defaults['select_field'])
  921. self.assertEqual(response_cf['multiselect_field'], cf_defaults['multiselect_field'])
  922. self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field'])
  923. self.assertEqual(
  924. [obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
  925. cf_defaults['multiobject_field']
  926. )
  927. # Validate database data
  928. site = Site.objects.get(pk=response.data['id'])
  929. self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field'])
  930. self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
  931. self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field'])
  932. self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field'])
  933. self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
  934. self.assertEqual(site.custom_field_data['date_field'], cf_defaults['date_field'])
  935. self.assertEqual(site.custom_field_data['datetime_field'], cf_defaults['datetime_field'])
  936. self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
  937. self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
  938. self.assertEqual(site.custom_field_data['select_field'], cf_defaults['select_field'])
  939. self.assertEqual(site.custom_field_data['multiselect_field'], cf_defaults['multiselect_field'])
  940. self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field'])
  941. self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field'])
  942. def test_create_single_object_with_values(self):
  943. """
  944. Create a single new site with a value for each type of custom field.
  945. """
  946. data = {
  947. 'name': 'Site 3',
  948. 'slug': 'site-3',
  949. 'custom_fields': {
  950. 'text_field': 'bar',
  951. 'longtext_field': 'blah blah blah',
  952. 'integer_field': 456,
  953. 'decimal_field': 456.78,
  954. 'boolean_field': True,
  955. 'date_field': datetime.date(2020, 1, 2),
  956. 'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0),
  957. 'url_field': 'http://example.com/2',
  958. 'json_field': '{"foo": 1, "bar": 2}',
  959. 'select_field': 'bar',
  960. 'multiselect_field': ['bar', 'baz'],
  961. 'object_field': VLAN.objects.get(vid=2).pk,
  962. 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
  963. },
  964. }
  965. url = reverse('dcim-api:site-list')
  966. self.add_permissions('dcim.add_site')
  967. response = self.client.post(url, data, format='json', **self.header)
  968. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  969. # Validate response data
  970. response_cf = response.data['custom_fields']
  971. data_cf = data['custom_fields']
  972. self.assertEqual(response_cf['text_field'], data_cf['text_field'])
  973. self.assertEqual(response_cf['longtext_field'], data_cf['longtext_field'])
  974. self.assertEqual(response_cf['integer_field'], data_cf['integer_field'])
  975. self.assertEqual(response_cf['decimal_field'], data_cf['decimal_field'])
  976. self.assertEqual(response_cf['boolean_field'], data_cf['boolean_field'])
  977. self.assertEqual(response_cf['date_field'], data_cf['date_field'])
  978. self.assertEqual(response_cf['datetime_field'], data_cf['datetime_field'])
  979. self.assertEqual(response_cf['url_field'], data_cf['url_field'])
  980. self.assertEqual(response_cf['json_field'], data_cf['json_field'])
  981. self.assertEqual(response_cf['select_field'], data_cf['select_field'])
  982. self.assertEqual(response_cf['multiselect_field'], data_cf['multiselect_field'])
  983. self.assertEqual(response_cf['object_field']['id'], data_cf['object_field'])
  984. self.assertEqual(
  985. [obj['id'] for obj in response_cf['multiobject_field']],
  986. data_cf['multiobject_field']
  987. )
  988. # Validate database data
  989. site = Site.objects.get(pk=response.data['id'])
  990. self.assertEqual(site.custom_field_data['text_field'], data_cf['text_field'])
  991. self.assertEqual(site.custom_field_data['longtext_field'], data_cf['longtext_field'])
  992. self.assertEqual(site.custom_field_data['integer_field'], data_cf['integer_field'])
  993. self.assertEqual(site.custom_field_data['decimal_field'], data_cf['decimal_field'])
  994. self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field'])
  995. self.assertEqual(site.cf['date_field'], data_cf['date_field'])
  996. self.assertEqual(site.cf['datetime_field'], data_cf['datetime_field'])
  997. self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field'])
  998. self.assertEqual(site.custom_field_data['json_field'], data_cf['json_field'])
  999. self.assertEqual(site.custom_field_data['select_field'], data_cf['select_field'])
  1000. self.assertEqual(site.custom_field_data['multiselect_field'], data_cf['multiselect_field'])
  1001. self.assertEqual(site.custom_field_data['object_field'], data_cf['object_field'])
  1002. self.assertEqual(site.custom_field_data['multiobject_field'], data_cf['multiobject_field'])
  1003. def test_create_multiple_objects_with_defaults(self):
  1004. """
  1005. Create three new sites with no specified custom field values and check that each received
  1006. the default custom field values.
  1007. """
  1008. cf_defaults = {
  1009. cf.name: cf.default for cf in CustomField.objects.all()
  1010. }
  1011. data = (
  1012. {
  1013. 'name': 'Site 3',
  1014. 'slug': 'site-3',
  1015. },
  1016. {
  1017. 'name': 'Site 4',
  1018. 'slug': 'site-4',
  1019. },
  1020. {
  1021. 'name': 'Site 5',
  1022. 'slug': 'site-5',
  1023. },
  1024. )
  1025. url = reverse('dcim-api:site-list')
  1026. self.add_permissions('dcim.add_site')
  1027. response = self.client.post(url, data, format='json', **self.header)
  1028. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1029. self.assertEqual(len(response.data), len(data))
  1030. for i, obj in enumerate(data):
  1031. # Validate response data
  1032. response_cf = response.data[i]['custom_fields']
  1033. self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
  1034. self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
  1035. self.assertEqual(response_cf['integer_field'], cf_defaults['integer_field'])
  1036. self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field'])
  1037. self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
  1038. self.assertEqual(response_cf['date_field'].isoformat(), cf_defaults['date_field'])
  1039. self.assertEqual(response_cf['datetime_field'].isoformat(), cf_defaults['datetime_field'])
  1040. self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
  1041. self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
  1042. self.assertEqual(response_cf['select_field'], cf_defaults['select_field'])
  1043. self.assertEqual(response_cf['multiselect_field'], cf_defaults['multiselect_field'])
  1044. self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field'])
  1045. self.assertEqual(
  1046. [obj['id'] for obj in response_cf['multiobject_field']],
  1047. cf_defaults['multiobject_field']
  1048. )
  1049. # Validate database data
  1050. site = Site.objects.get(pk=response.data[i]['id'])
  1051. self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field'])
  1052. self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
  1053. self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field'])
  1054. self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field'])
  1055. self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
  1056. self.assertEqual(site.custom_field_data['date_field'], cf_defaults['date_field'])
  1057. self.assertEqual(site.custom_field_data['datetime_field'], cf_defaults['datetime_field'])
  1058. self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
  1059. self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
  1060. self.assertEqual(site.custom_field_data['select_field'], cf_defaults['select_field'])
  1061. self.assertEqual(site.custom_field_data['multiselect_field'], cf_defaults['multiselect_field'])
  1062. self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field'])
  1063. self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field'])
  1064. def test_create_multiple_objects_with_values(self):
  1065. """
  1066. Create a three new sites, each with custom fields defined.
  1067. """
  1068. custom_field_data = {
  1069. 'text_field': 'bar',
  1070. 'longtext_field': 'abcdefghij',
  1071. 'integer_field': 456,
  1072. 'decimal_field': 456.78,
  1073. 'boolean_field': True,
  1074. 'date_field': datetime.date(2020, 1, 2),
  1075. 'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0),
  1076. 'url_field': 'http://example.com/2',
  1077. 'json_field': '{"foo": 1, "bar": 2}',
  1078. 'select_field': 'bar',
  1079. 'multiselect_field': ['bar', 'baz'],
  1080. 'object_field': VLAN.objects.get(vid=2).pk,
  1081. 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
  1082. }
  1083. data = (
  1084. {
  1085. 'name': 'Site 3',
  1086. 'slug': 'site-3',
  1087. 'custom_fields': custom_field_data,
  1088. },
  1089. {
  1090. 'name': 'Site 4',
  1091. 'slug': 'site-4',
  1092. 'custom_fields': custom_field_data,
  1093. },
  1094. {
  1095. 'name': 'Site 5',
  1096. 'slug': 'site-5',
  1097. 'custom_fields': custom_field_data,
  1098. },
  1099. )
  1100. url = reverse('dcim-api:site-list')
  1101. self.add_permissions('dcim.add_site')
  1102. response = self.client.post(url, data, format='json', **self.header)
  1103. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1104. self.assertEqual(len(response.data), len(data))
  1105. for i, obj in enumerate(data):
  1106. # Validate response data
  1107. response_cf = response.data[i]['custom_fields']
  1108. self.assertEqual(response_cf['text_field'], custom_field_data['text_field'])
  1109. self.assertEqual(response_cf['longtext_field'], custom_field_data['longtext_field'])
  1110. self.assertEqual(response_cf['integer_field'], custom_field_data['integer_field'])
  1111. self.assertEqual(response_cf['decimal_field'], custom_field_data['decimal_field'])
  1112. self.assertEqual(response_cf['boolean_field'], custom_field_data['boolean_field'])
  1113. self.assertEqual(response_cf['date_field'], custom_field_data['date_field'])
  1114. self.assertEqual(response_cf['datetime_field'], custom_field_data['datetime_field'])
  1115. self.assertEqual(response_cf['url_field'], custom_field_data['url_field'])
  1116. self.assertEqual(response_cf['json_field'], custom_field_data['json_field'])
  1117. self.assertEqual(response_cf['select_field'], custom_field_data['select_field'])
  1118. self.assertEqual(response_cf['multiselect_field'], custom_field_data['multiselect_field'])
  1119. self.assertEqual(response_cf['object_field']['id'], custom_field_data['object_field'])
  1120. self.assertEqual(
  1121. [obj['id'] for obj in response_cf['multiobject_field']],
  1122. custom_field_data['multiobject_field']
  1123. )
  1124. # Validate database data
  1125. site = Site.objects.get(pk=response.data[i]['id'])
  1126. self.assertEqual(site.custom_field_data['text_field'], custom_field_data['text_field'])
  1127. self.assertEqual(site.custom_field_data['longtext_field'], custom_field_data['longtext_field'])
  1128. self.assertEqual(site.custom_field_data['integer_field'], custom_field_data['integer_field'])
  1129. self.assertEqual(site.custom_field_data['decimal_field'], custom_field_data['decimal_field'])
  1130. self.assertEqual(site.custom_field_data['boolean_field'], custom_field_data['boolean_field'])
  1131. self.assertEqual(site.cf['date_field'], custom_field_data['date_field'])
  1132. self.assertEqual(site.cf['datetime_field'], custom_field_data['datetime_field'])
  1133. self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field'])
  1134. self.assertEqual(site.custom_field_data['json_field'], custom_field_data['json_field'])
  1135. self.assertEqual(site.custom_field_data['select_field'], custom_field_data['select_field'])
  1136. self.assertEqual(site.custom_field_data['multiselect_field'], custom_field_data['multiselect_field'])
  1137. self.assertEqual(site.custom_field_data['object_field'], custom_field_data['object_field'])
  1138. self.assertEqual(site.custom_field_data['multiobject_field'], custom_field_data['multiobject_field'])
  1139. def test_update_single_object_with_values(self):
  1140. """
  1141. Update an object with existing custom field values. Ensure that only the updated custom field values are
  1142. modified.
  1143. """
  1144. site2 = Site.objects.get(name='Site 2')
  1145. original_cfvs = {**site2.cf}
  1146. data = {
  1147. 'custom_fields': {
  1148. 'text_field': 'ABCD',
  1149. 'integer_field': 1234,
  1150. },
  1151. }
  1152. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1153. self.add_permissions('dcim.change_site')
  1154. response = self.client.patch(url, data, format='json', **self.header)
  1155. self.assertHttpStatus(response, status.HTTP_200_OK)
  1156. # Validate response data
  1157. response_cf = response.data['custom_fields']
  1158. self.assertEqual(response_cf['text_field'], data['custom_fields']['text_field'])
  1159. self.assertEqual(response_cf['longtext_field'], original_cfvs['longtext_field'])
  1160. self.assertEqual(response_cf['integer_field'], data['custom_fields']['integer_field'])
  1161. self.assertEqual(response_cf['decimal_field'], original_cfvs['decimal_field'])
  1162. self.assertEqual(response_cf['boolean_field'], original_cfvs['boolean_field'])
  1163. self.assertEqual(response_cf['date_field'], original_cfvs['date_field'])
  1164. self.assertEqual(response_cf['datetime_field'], original_cfvs['datetime_field'])
  1165. self.assertEqual(response_cf['url_field'], original_cfvs['url_field'])
  1166. self.assertEqual(response_cf['json_field'], original_cfvs['json_field'])
  1167. self.assertEqual(response_cf['select_field'], original_cfvs['select_field'])
  1168. self.assertEqual(response_cf['multiselect_field'], original_cfvs['multiselect_field'])
  1169. self.assertEqual(response_cf['object_field']['id'], original_cfvs['object_field'].pk)
  1170. self.assertListEqual(
  1171. [obj['id'] for obj in response_cf['multiobject_field']],
  1172. [obj.pk for obj in original_cfvs['multiobject_field']]
  1173. )
  1174. # Validate database data
  1175. site2 = Site.objects.get(pk=site2.pk)
  1176. self.assertEqual(site2.cf['text_field'], data['custom_fields']['text_field'])
  1177. self.assertEqual(site2.cf['longtext_field'], original_cfvs['longtext_field'])
  1178. self.assertEqual(site2.cf['integer_field'], data['custom_fields']['integer_field'])
  1179. self.assertEqual(site2.cf['decimal_field'], original_cfvs['decimal_field'])
  1180. self.assertEqual(site2.cf['boolean_field'], original_cfvs['boolean_field'])
  1181. self.assertEqual(site2.cf['date_field'], original_cfvs['date_field'])
  1182. self.assertEqual(site2.cf['datetime_field'], original_cfvs['datetime_field'])
  1183. self.assertEqual(site2.cf['url_field'], original_cfvs['url_field'])
  1184. self.assertEqual(site2.cf['json_field'], original_cfvs['json_field'])
  1185. self.assertEqual(site2.cf['select_field'], original_cfvs['select_field'])
  1186. self.assertEqual(site2.cf['multiselect_field'], original_cfvs['multiselect_field'])
  1187. self.assertEqual(site2.cf['object_field'], original_cfvs['object_field'])
  1188. self.assertListEqual(
  1189. list(site2.cf['multiobject_field']),
  1190. list(original_cfvs['multiobject_field'])
  1191. )
  1192. @tag('regression')
  1193. def test_update_single_object_rejects_unknown_custom_fields(self):
  1194. site2 = Site.objects.get(name='Site 2')
  1195. original_cf_data = {**site2.custom_field_data}
  1196. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1197. self.add_permissions('dcim.change_site')
  1198. data = {
  1199. 'custom_fields': {
  1200. 'text_field': 'valid',
  1201. 'thisfieldshouldntexist': 'random text here',
  1202. },
  1203. }
  1204. response = self.client.patch(url, data, format='json', **self.header)
  1205. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1206. self.assertIn('custom_fields', response.data)
  1207. self.assertIn('thisfieldshouldntexist', response.data['custom_fields'])
  1208. # Ensure the object was not modified
  1209. site2.refresh_from_db()
  1210. self.assertEqual(site2.custom_field_data, original_cf_data)
  1211. @tag('regression')
  1212. def test_update_single_object_prunes_stale_custom_field_data_from_database_and_postchange_data(self):
  1213. stale_key = 'thisfieldshouldntexist'
  1214. stale_value = 'random text here'
  1215. updated_text_value = 'ABCD'
  1216. site2 = Site.objects.get(name='Site 2')
  1217. original_text_value = site2.custom_field_data['text_field']
  1218. object_type = ObjectType.objects.get_for_model(Site)
  1219. # Seed stale custom field data directly in the database to mimic a polluted row.
  1220. Site.objects.filter(pk=site2.pk).update(
  1221. custom_field_data={
  1222. **site2.custom_field_data,
  1223. stale_key: stale_value,
  1224. }
  1225. )
  1226. site2.refresh_from_db()
  1227. self.assertIn(stale_key, site2.custom_field_data)
  1228. existing_change_ids = set(
  1229. ObjectChange.objects.filter(
  1230. changed_object_type=object_type,
  1231. changed_object_id=site2.pk,
  1232. ).values_list('pk', flat=True)
  1233. )
  1234. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1235. self.add_permissions('dcim.change_site')
  1236. data = {
  1237. 'custom_fields': {
  1238. 'text_field': updated_text_value,
  1239. },
  1240. }
  1241. response = self.client.patch(url, data, format='json', **self.header)
  1242. self.assertHttpStatus(response, status.HTTP_200_OK)
  1243. site2.refresh_from_db()
  1244. self.assertEqual(site2.cf['text_field'], updated_text_value)
  1245. self.assertNotIn(stale_key, site2.custom_field_data)
  1246. object_changes = ObjectChange.objects.filter(
  1247. changed_object_type=object_type,
  1248. changed_object_id=site2.pk,
  1249. ).exclude(pk__in=existing_change_ids)
  1250. self.assertEqual(object_changes.count(), 1)
  1251. object_change = object_changes.get()
  1252. self.assertEqual(object_change.prechange_data['custom_fields']['text_field'], original_text_value)
  1253. self.assertEqual(object_change.postchange_data['custom_fields']['text_field'], updated_text_value)
  1254. self.assertNotIn(stale_key, object_change.postchange_data['custom_fields'])
  1255. def test_specify_related_object_by_attr(self):
  1256. site1 = Site.objects.get(name='Site 1')
  1257. vlans = VLAN.objects.all()[:3]
  1258. url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk})
  1259. self.add_permissions('dcim.change_site', 'ipam.view_vlan')
  1260. # Set related objects by PK
  1261. data = {
  1262. 'custom_fields': {
  1263. 'object_field': vlans[0].pk,
  1264. 'multiobject_field': [vlans[1].pk, vlans[2].pk],
  1265. },
  1266. }
  1267. response = self.client.patch(url, data, format='json', **self.header)
  1268. self.assertHttpStatus(response, status.HTTP_200_OK)
  1269. self.assertEqual(
  1270. response.data['custom_fields']['object_field']['id'],
  1271. vlans[0].pk
  1272. )
  1273. self.assertListEqual(
  1274. [obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
  1275. [vlans[1].pk, vlans[2].pk]
  1276. )
  1277. # Set related objects by name
  1278. data = {
  1279. 'custom_fields': {
  1280. 'object_field': {
  1281. 'name': vlans[0].name,
  1282. },
  1283. 'multiobject_field': [
  1284. {
  1285. 'name': vlans[1].name
  1286. },
  1287. {
  1288. 'name': vlans[2].name
  1289. },
  1290. ],
  1291. },
  1292. }
  1293. response = self.client.patch(url, data, format='json', **self.header)
  1294. self.assertHttpStatus(response, status.HTTP_200_OK)
  1295. self.assertEqual(
  1296. response.data['custom_fields']['object_field']['id'],
  1297. vlans[0].pk
  1298. )
  1299. self.assertListEqual(
  1300. [obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
  1301. [vlans[1].pk, vlans[2].pk]
  1302. )
  1303. # Clear related objects
  1304. data = {
  1305. 'custom_fields': {
  1306. 'object_field': None,
  1307. 'multiobject_field': [],
  1308. },
  1309. }
  1310. response = self.client.patch(url, data, format='json', **self.header)
  1311. self.assertHttpStatus(response, status.HTTP_200_OK)
  1312. self.assertIsNone(response.data['custom_fields']['object_field'])
  1313. self.assertListEqual(response.data['custom_fields']['multiobject_field'], [])
  1314. def test_minimum_maximum_values_validation(self):
  1315. site2 = Site.objects.get(name='Site 2')
  1316. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1317. self.add_permissions('dcim.change_site')
  1318. cf_integer = CustomField.objects.get(name='integer_field')
  1319. cf_integer.validation_minimum = 10
  1320. cf_integer.validation_maximum = 20
  1321. cf_integer.save()
  1322. data = {'custom_fields': {'integer_field': 9}}
  1323. response = self.client.patch(url, data, format='json', **self.header)
  1324. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1325. data = {'custom_fields': {'integer_field': 21}}
  1326. response = self.client.patch(url, data, format='json', **self.header)
  1327. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1328. data = {'custom_fields': {'integer_field': 15}}
  1329. response = self.client.patch(url, data, format='json', **self.header)
  1330. self.assertHttpStatus(response, status.HTTP_200_OK)
  1331. def test_regex_validation(self):
  1332. site2 = Site.objects.get(name='Site 2')
  1333. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1334. self.add_permissions('dcim.change_site')
  1335. cf_text = CustomField.objects.get(name='text_field')
  1336. cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters
  1337. cf_text.save()
  1338. data = {'custom_fields': {'text_field': 'ABC123'}}
  1339. response = self.client.patch(url, data, format='json', **self.header)
  1340. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1341. data = {'custom_fields': {'text_field': 'abc'}}
  1342. response = self.client.patch(url, data, format='json', **self.header)
  1343. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1344. data = {'custom_fields': {'text_field': 'ABC'}}
  1345. response = self.client.patch(url, data, format='json', **self.header)
  1346. self.assertHttpStatus(response, status.HTTP_200_OK)
  1347. def test_url_regex_validation(self):
  1348. """
  1349. Test that validation_regex is applied to URL custom fields (fixes #20498).
  1350. """
  1351. site2 = Site.objects.get(name='Site 2')
  1352. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1353. self.add_permissions('dcim.change_site')
  1354. cf_url = CustomField.objects.get(name='url_field')
  1355. cf_url.validation_regex = r'^https://' # Require HTTPS
  1356. cf_url.save()
  1357. # Test invalid URL (http instead of https)
  1358. data = {'custom_fields': {'url_field': 'http://example.com'}}
  1359. response = self.client.patch(url, data, format='json', **self.header)
  1360. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1361. # Test valid URL (https)
  1362. data = {'custom_fields': {'url_field': 'https://example.com'}}
  1363. response = self.client.patch(url, data, format='json', **self.header)
  1364. self.assertHttpStatus(response, status.HTTP_200_OK)
  1365. def test_json_schema_validation(self):
  1366. site2 = Site.objects.get(name='Site 2')
  1367. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1368. self.add_permissions('dcim.change_site')
  1369. cf_json = CustomField.objects.get(name='json_field')
  1370. cf_json.validation_schema = {
  1371. 'type': 'object',
  1372. 'properties': {
  1373. 'name': {'type': 'string'},
  1374. 'age': {'type': 'integer'},
  1375. },
  1376. 'required': ['name'],
  1377. }
  1378. cf_json.save()
  1379. # Invalid: missing required 'name' property
  1380. data = {'custom_fields': {'json_field': {'age': 25}}}
  1381. response = self.client.patch(url, data, format='json', **self.header)
  1382. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1383. # Invalid: 'age' is not an integer
  1384. data = {'custom_fields': {'json_field': {'name': 'test', 'age': 'not_an_int'}}}
  1385. response = self.client.patch(url, data, format='json', **self.header)
  1386. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1387. # Valid: conforms to schema
  1388. data = {'custom_fields': {'json_field': {'name': 'test', 'age': 25}}}
  1389. response = self.client.patch(url, data, format='json', **self.header)
  1390. self.assertHttpStatus(response, status.HTTP_200_OK)
  1391. # Valid: null value (schema not enforced on empty)
  1392. data = {'custom_fields': {'json_field': None}}
  1393. response = self.client.patch(url, data, format='json', **self.header)
  1394. self.assertHttpStatus(response, status.HTTP_200_OK)
  1395. def test_uniqueness_validation(self):
  1396. # Create a unique custom field
  1397. cf_text = CustomField.objects.get(name='text_field')
  1398. cf_text.unique = True
  1399. cf_text.save()
  1400. # Set a value on site 1
  1401. site1 = Site.objects.get(name='Site 1')
  1402. site1.custom_field_data['text_field'] = 'ABC123'
  1403. site1.save()
  1404. site2 = Site.objects.get(name='Site 2')
  1405. url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
  1406. self.add_permissions('dcim.change_site')
  1407. data = {'custom_fields': {'text_field': 'ABC123'}}
  1408. response = self.client.patch(url, data, format='json', **self.header)
  1409. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1410. data = {'custom_fields': {'text_field': 'DEF456'}}
  1411. response = self.client.patch(url, data, format='json', **self.header)
  1412. self.assertHttpStatus(response, status.HTTP_200_OK)
  1413. class CustomFieldImportTestCase(TestCase):
  1414. user_permissions = (
  1415. 'dcim.view_site',
  1416. 'dcim.add_site',
  1417. )
  1418. @classmethod
  1419. def setUpTestData(cls):
  1420. # Create a set of custom field choices
  1421. choice_set = CustomFieldChoiceSet.objects.create(
  1422. name='Custom Field Choice Set 1',
  1423. extra_choices=(
  1424. ('a', 'Option A'),
  1425. ('b', 'Option B'),
  1426. ('c', 'Option C'),
  1427. )
  1428. )
  1429. custom_fields = (
  1430. CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT),
  1431. CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT),
  1432. CustomField(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER),
  1433. CustomField(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL),
  1434. CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN),
  1435. CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE),
  1436. CustomField(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME),
  1437. CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
  1438. CustomField(name='json', type=CustomFieldTypeChoices.TYPE_JSON),
  1439. CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choice_set=choice_set),
  1440. CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choice_set=choice_set),
  1441. )
  1442. for cf in custom_fields:
  1443. cf.save()
  1444. cf.object_types.set([ObjectType.objects.get_for_model(Site)])
  1445. def test_import(self):
  1446. """
  1447. Import a Site in CSV format, including a value for each CustomField.
  1448. """
  1449. data = (
  1450. (
  1451. 'name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_decimal', 'cf_boolean', 'cf_date',
  1452. 'cf_datetime', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect',
  1453. ),
  1454. (
  1455. 'Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01',
  1456. '2020-01-01 12:00:00', 'http://example.com/1', '{"foo": 123}', 'a', '"a,b"',
  1457. ),
  1458. (
  1459. 'Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', 'False', '2020-01-02',
  1460. '2020-01-02 12:00:00', 'http://example.com/2', '{"bar": 456}', 'b', '"b,c"',
  1461. ),
  1462. ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', '', '', ''),
  1463. )
  1464. csv_data = '\n'.join(','.join(row) for row in data)
  1465. response = self.client.post(reverse('dcim:site_bulk_import'), {
  1466. 'data': csv_data,
  1467. 'format': ImportFormatChoices.CSV,
  1468. 'csv_delimiter': CSVDelimiterChoices.AUTO,
  1469. })
  1470. self.assertEqual(response.status_code, 302)
  1471. self.assertEqual(Site.objects.count(), 3)
  1472. # Validate data for site 1
  1473. site1 = Site.objects.get(name='Site 1')
  1474. self.assertEqual(len(site1.custom_field_data), 11)
  1475. self.assertEqual(site1.custom_field_data['text'], 'ABC')
  1476. self.assertEqual(site1.custom_field_data['longtext'], 'Foo')
  1477. self.assertEqual(site1.custom_field_data['integer'], 123)
  1478. self.assertEqual(site1.custom_field_data['decimal'], 123.45)
  1479. self.assertEqual(site1.custom_field_data['boolean'], True)
  1480. self.assertEqual(site1.cf['date'].isoformat(), '2020-01-01')
  1481. self.assertEqual(site1.cf['datetime'].isoformat(), '2020-01-01T12:00:00+00:00')
  1482. self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
  1483. self.assertEqual(site1.custom_field_data['json'], {"foo": 123})
  1484. self.assertEqual(site1.custom_field_data['select'], 'a')
  1485. self.assertEqual(site1.custom_field_data['multiselect'], ['a', 'b'])
  1486. # Validate data for site 2
  1487. site2 = Site.objects.get(name='Site 2')
  1488. self.assertEqual(len(site2.custom_field_data), 11)
  1489. self.assertEqual(site2.custom_field_data['text'], 'DEF')
  1490. self.assertEqual(site2.custom_field_data['longtext'], 'Bar')
  1491. self.assertEqual(site2.custom_field_data['integer'], 456)
  1492. self.assertEqual(site2.custom_field_data['decimal'], 456.78)
  1493. self.assertEqual(site2.custom_field_data['boolean'], False)
  1494. self.assertEqual(site2.cf['date'].isoformat(), '2020-01-02')
  1495. self.assertEqual(site2.cf['datetime'].isoformat(), '2020-01-02T12:00:00+00:00')
  1496. self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
  1497. self.assertEqual(site2.custom_field_data['json'], {"bar": 456})
  1498. self.assertEqual(site2.custom_field_data['select'], 'b')
  1499. self.assertEqual(site2.custom_field_data['multiselect'], ['b', 'c'])
  1500. # No custom field data should be set for site 3
  1501. site3 = Site.objects.get(name='Site 3')
  1502. self.assertFalse(any(site3.custom_field_data.values()))
  1503. def test_import_missing_required(self):
  1504. """
  1505. Attempt to import an object missing a required custom field.
  1506. """
  1507. # Set one of our CustomFields to required
  1508. CustomField.objects.filter(name='text').update(required=True)
  1509. form_data = {
  1510. 'name': 'Site 1',
  1511. 'slug': 'site-1',
  1512. }
  1513. form = SiteImportForm(data=form_data)
  1514. self.assertFalse(form.is_valid())
  1515. self.assertIn('cf_text', form.errors)
  1516. def test_import_invalid_choice(self):
  1517. """
  1518. Attempt to import an object with an invalid choice selection.
  1519. """
  1520. form_data = {
  1521. 'name': 'Site 1',
  1522. 'slug': 'site-1',
  1523. 'cf_select': 'Choice X'
  1524. }
  1525. form = SiteImportForm(data=form_data)
  1526. self.assertFalse(form.is_valid())
  1527. self.assertIn('cf_select', form.errors)
  1528. class CustomFieldModelTestCase(TestCase):
  1529. @classmethod
  1530. def setUpTestData(cls):
  1531. cf1 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='foo')
  1532. cf1.save()
  1533. cf1.object_types.set([ObjectType.objects.get_for_model(Site)])
  1534. cf2 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='bar')
  1535. cf2.save()
  1536. cf2.object_types.set([ObjectType.objects.get_for_model(Rack)])
  1537. def test_cf_data(self):
  1538. """
  1539. Check that custom field data is present on the instance immediately after being set and after being fetched
  1540. from the database.
  1541. """
  1542. site = Site(name='Test Site', slug='test-site')
  1543. # Check custom field data on new instance
  1544. site.custom_field_data['foo'] = 'abc'
  1545. self.assertEqual(site.cf['foo'], 'abc')
  1546. # Check custom field data from database
  1547. site.save()
  1548. site = Site.objects.get(name='Test Site')
  1549. self.assertEqual(site.cf['foo'], 'abc')
  1550. def test_invalid_data(self):
  1551. """
  1552. Any invalid or stale custom field data should be removed from the instance.
  1553. """
  1554. site = Site(name='Test Site', slug='test-site')
  1555. # Set custom field data
  1556. site.custom_field_data['foo'] = 'abc'
  1557. site.custom_field_data['bar'] = 'def'
  1558. site.clean()
  1559. self.assertIn('foo', site.custom_field_data)
  1560. self.assertNotIn('bar', site.custom_field_data)
  1561. def test_missing_required_field(self):
  1562. """
  1563. Check that a ValidationError is raised if any required custom fields are not present.
  1564. """
  1565. cf3 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='baz', required=True)
  1566. cf3.save()
  1567. cf3.object_types.set([ObjectType.objects.get_for_model(Site)])
  1568. site = Site(name='Test Site', slug='test-site')
  1569. # Set custom field data with a required field omitted
  1570. site.custom_field_data['foo'] = 'abc'
  1571. with self.assertRaises(ValidationError):
  1572. site.clean()
  1573. site.custom_field_data['baz'] = 'def'
  1574. site.clean()
  1575. class CustomFieldModelFilterTestCase(TestCase):
  1576. queryset = Site.objects.all()
  1577. filterset = SiteFilterSet
  1578. @classmethod
  1579. def setUpTestData(cls):
  1580. object_type = ObjectType.objects.get_for_model(Site)
  1581. manufacturers = Manufacturer.objects.bulk_create((
  1582. Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
  1583. Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
  1584. Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
  1585. Manufacturer(name='Manufacturer 4', slug='manufacturer-4'),
  1586. ))
  1587. choice_set = CustomFieldChoiceSet.objects.create(
  1588. name='Custom Field Choice Set 1',
  1589. extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'))
  1590. )
  1591. # Integer filtering
  1592. cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER)
  1593. cf.save()
  1594. cf.object_types.set([object_type])
  1595. # Decimal filtering
  1596. cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_DECIMAL)
  1597. cf.save()
  1598. cf.object_types.set([object_type])
  1599. # Boolean filtering
  1600. cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
  1601. cf.save()
  1602. cf.object_types.set([object_type])
  1603. # Exact text filtering
  1604. cf = CustomField(
  1605. name='cf4',
  1606. type=CustomFieldTypeChoices.TYPE_TEXT,
  1607. filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
  1608. )
  1609. cf.save()
  1610. cf.object_types.set([object_type])
  1611. # Loose text filtering
  1612. cf = CustomField(
  1613. name='cf5',
  1614. type=CustomFieldTypeChoices.TYPE_TEXT,
  1615. filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
  1616. )
  1617. cf.save()
  1618. cf.object_types.set([object_type])
  1619. # Date filtering
  1620. cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_DATE)
  1621. cf.save()
  1622. cf.object_types.set([object_type])
  1623. # Exact URL filtering
  1624. cf = CustomField(
  1625. name='cf7',
  1626. type=CustomFieldTypeChoices.TYPE_URL,
  1627. filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
  1628. )
  1629. cf.save()
  1630. cf.object_types.set([object_type])
  1631. # Loose URL filtering
  1632. cf = CustomField(
  1633. name='cf8',
  1634. type=CustomFieldTypeChoices.TYPE_URL,
  1635. filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
  1636. )
  1637. cf.save()
  1638. cf.object_types.set([object_type])
  1639. # Selection filtering
  1640. cf = CustomField(
  1641. name='cf9',
  1642. type=CustomFieldTypeChoices.TYPE_SELECT,
  1643. choice_set=choice_set
  1644. )
  1645. cf.save()
  1646. cf.object_types.set([object_type])
  1647. # Multiselect filtering
  1648. cf = CustomField(
  1649. name='cf10',
  1650. type=CustomFieldTypeChoices.TYPE_MULTISELECT,
  1651. choice_set=choice_set
  1652. )
  1653. cf.save()
  1654. cf.object_types.set([object_type])
  1655. # Object filtering
  1656. cf = CustomField(
  1657. name='cf11',
  1658. type=CustomFieldTypeChoices.TYPE_OBJECT,
  1659. related_object_type=ObjectType.objects.get_for_model(Manufacturer)
  1660. )
  1661. cf.save()
  1662. cf.object_types.set([object_type])
  1663. # Multi-object filtering
  1664. cf = CustomField(
  1665. name='cf12',
  1666. type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
  1667. related_object_type=ObjectType.objects.get_for_model(Manufacturer)
  1668. )
  1669. cf.save()
  1670. cf.object_types.set([object_type])
  1671. Site.objects.bulk_create([
  1672. Site(name='Site 1', slug='site-1', custom_field_data={
  1673. 'cf1': 100,
  1674. 'cf2': 100.1,
  1675. 'cf3': True,
  1676. 'cf4': 'foo',
  1677. 'cf5': 'foo',
  1678. 'cf6': '2016-06-26',
  1679. 'cf7': 'http://a.example.com',
  1680. 'cf8': 'http://a.example.com',
  1681. 'cf9': 'A',
  1682. 'cf10': ['A', 'B'],
  1683. 'cf11': manufacturers[0].pk,
  1684. 'cf12': [manufacturers[0].pk, manufacturers[3].pk],
  1685. }),
  1686. Site(name='Site 2', slug='site-2', custom_field_data={
  1687. 'cf1': 200,
  1688. 'cf2': 200.2,
  1689. 'cf3': True,
  1690. 'cf4': 'foobar',
  1691. 'cf5': 'foobar',
  1692. 'cf6': '2016-06-27',
  1693. 'cf7': 'http://b.example.com',
  1694. 'cf8': 'http://b.example.com',
  1695. 'cf9': 'B',
  1696. 'cf10': ['B', 'C'],
  1697. 'cf11': manufacturers[1].pk,
  1698. 'cf12': [manufacturers[1].pk, manufacturers[3].pk],
  1699. }),
  1700. Site(name='Site 3', slug='site-3', custom_field_data={
  1701. 'cf1': 300,
  1702. 'cf2': 300.3,
  1703. 'cf3': False,
  1704. 'cf4': 'bar',
  1705. 'cf5': 'bar',
  1706. 'cf6': '2016-06-28',
  1707. 'cf7': 'http://c.example.com',
  1708. 'cf8': 'http://c.example.com',
  1709. 'cf9': 'C',
  1710. 'cf10': None,
  1711. 'cf11': manufacturers[2].pk,
  1712. 'cf12': [manufacturers[2].pk, manufacturers[3].pk],
  1713. }),
  1714. Site(name='Site 4', slug='site-4'),
  1715. ])
  1716. def test_filter_integer(self):
  1717. self.assertEqual(self.filterset({'cf_cf1': [100, 200]}, self.queryset).qs.count(), 2)
  1718. self.assertEqual(self.filterset({'cf_cf1__n': [200]}, self.queryset).qs.count(), 2)
  1719. self.assertEqual(self.filterset({'cf_cf1__gt': [200]}, self.queryset).qs.count(), 1)
  1720. self.assertEqual(self.filterset({'cf_cf1__gte': [200]}, self.queryset).qs.count(), 2)
  1721. self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1)
  1722. self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2)
  1723. self.assertEqual(self.filterset({'cf_cf1__empty': True}, self.queryset).qs.count(), 1)
  1724. def test_filter_decimal(self):
  1725. self.assertEqual(self.filterset({'cf_cf2': [100.1, 200.2]}, self.queryset).qs.count(), 2)
  1726. self.assertEqual(self.filterset({'cf_cf2__n': [200.2]}, self.queryset).qs.count(), 2)
  1727. self.assertEqual(self.filterset({'cf_cf2__gt': [200.2]}, self.queryset).qs.count(), 1)
  1728. self.assertEqual(self.filterset({'cf_cf2__gte': [200.2]}, self.queryset).qs.count(), 2)
  1729. self.assertEqual(self.filterset({'cf_cf2__lt': [200.2]}, self.queryset).qs.count(), 1)
  1730. self.assertEqual(self.filterset({'cf_cf2__lte': [200.2]}, self.queryset).qs.count(), 2)
  1731. self.assertEqual(self.filterset({'cf_cf2__empty': True}, self.queryset).qs.count(), 1)
  1732. def test_filter_boolean(self):
  1733. self.assertEqual(self.filterset({'cf_cf3': True}, self.queryset).qs.count(), 2)
  1734. self.assertEqual(self.filterset({'cf_cf3': False}, self.queryset).qs.count(), 1)
  1735. def test_filter_text_strict(self):
  1736. self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 1)
  1737. self.assertEqual(self.filterset({'cf_cf4__n': ['foo']}, self.queryset).qs.count(), 2)
  1738. self.assertEqual(self.filterset({'cf_cf4__ic': ['foo']}, self.queryset).qs.count(), 2)
  1739. self.assertEqual(self.filterset({'cf_cf4__nic': ['foo']}, self.queryset).qs.count(), 1)
  1740. self.assertEqual(self.filterset({'cf_cf4__isw': ['foo']}, self.queryset).qs.count(), 2)
  1741. self.assertEqual(self.filterset({'cf_cf4__nisw': ['foo']}, self.queryset).qs.count(), 1)
  1742. self.assertEqual(self.filterset({'cf_cf4__iew': ['bar']}, self.queryset).qs.count(), 2)
  1743. self.assertEqual(self.filterset({'cf_cf4__niew': ['bar']}, self.queryset).qs.count(), 1)
  1744. self.assertEqual(self.filterset({'cf_cf4__ie': ['FOO']}, self.queryset).qs.count(), 1)
  1745. self.assertEqual(self.filterset({'cf_cf4__nie': ['FOO']}, self.queryset).qs.count(), 2)
  1746. self.assertEqual(self.filterset({'cf_cf4__empty': True}, self.queryset).qs.count(), 1)
  1747. def test_filter_text_loose(self):
  1748. self.assertEqual(self.filterset({'cf_cf5': ['foo']}, self.queryset).qs.count(), 2)
  1749. def test_filter_date(self):
  1750. self.assertEqual(self.filterset({'cf_cf6': ['2016-06-26', '2016-06-27']}, self.queryset).qs.count(), 2)
  1751. self.assertEqual(self.filterset({'cf_cf6__n': ['2016-06-27']}, self.queryset).qs.count(), 2)
  1752. self.assertEqual(self.filterset({'cf_cf6__gt': ['2016-06-27']}, self.queryset).qs.count(), 1)
  1753. self.assertEqual(self.filterset({'cf_cf6__gte': ['2016-06-27']}, self.queryset).qs.count(), 2)
  1754. self.assertEqual(self.filterset({'cf_cf6__lt': ['2016-06-27']}, self.queryset).qs.count(), 1)
  1755. self.assertEqual(self.filterset({'cf_cf6__lte': ['2016-06-27']}, self.queryset).qs.count(), 2)
  1756. self.assertEqual(self.filterset({'cf_cf6__empty': True}, self.queryset).qs.count(), 1)
  1757. def test_filter_url_strict(self):
  1758. self.assertEqual(
  1759. self.filterset({'cf_cf7': ['http://a.example.com', 'http://b.example.com']}, self.queryset).qs.count(),
  1760. 2
  1761. )
  1762. self.assertEqual(self.filterset({'cf_cf7__n': ['http://b.example.com']}, self.queryset).qs.count(), 2)
  1763. self.assertEqual(self.filterset({'cf_cf7__ic': ['b']}, self.queryset).qs.count(), 1)
  1764. self.assertEqual(self.filterset({'cf_cf7__nic': ['b']}, self.queryset).qs.count(), 2)
  1765. self.assertEqual(self.filterset({'cf_cf7__isw': ['http://']}, self.queryset).qs.count(), 3)
  1766. self.assertEqual(self.filterset({'cf_cf7__nisw': ['http://']}, self.queryset).qs.count(), 0)
  1767. self.assertEqual(self.filterset({'cf_cf7__iew': ['.com']}, self.queryset).qs.count(), 3)
  1768. self.assertEqual(self.filterset({'cf_cf7__niew': ['.com']}, self.queryset).qs.count(), 0)
  1769. self.assertEqual(self.filterset({'cf_cf7__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1)
  1770. self.assertEqual(self.filterset({'cf_cf7__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2)
  1771. self.assertEqual(self.filterset({'cf_cf7__empty': True}, self.queryset).qs.count(), 1)
  1772. def test_filter_url_loose(self):
  1773. self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3)
  1774. def test_filter_select(self):
  1775. self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
  1776. self.assertEqual(self.filterset({'cf_cf9__empty': True}, self.queryset).qs.count(), 1)
  1777. def test_filter_multiselect(self):
  1778. self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1)
  1779. self.assertEqual(self.filterset({'cf_cf10': ['A', 'C']}, self.queryset).qs.count(), 2)
  1780. self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 1) # Contains a literal null
  1781. self.assertEqual(self.filterset({'cf_cf10__empty': True}, self.queryset).qs.count(), 2)
  1782. def test_filter_object(self):
  1783. manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
  1784. self.assertEqual(
  1785. self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(),
  1786. 2
  1787. )
  1788. self.assertEqual(self.filterset({'cf_cf11__empty': True}, self.queryset).qs.count(), 1)
  1789. def test_filter_multiobject(self):
  1790. manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
  1791. self.assertEqual(
  1792. self.filterset({'cf_cf12': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(),
  1793. 2
  1794. )
  1795. self.assertEqual(
  1796. self.filterset({'cf_cf12': [manufacturer_ids[3]]}, self.queryset).qs.count(),
  1797. 3
  1798. )
  1799. self.assertEqual(self.filterset({'cf_cf12__empty': True}, self.queryset).qs.count(), 1)