Coverage for utilities/testing/views.py: 0%

631 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1import csv 

2 

3from django.conf import settings 

4from django.contrib.contenttypes.models import ContentType 

5from django.core.exceptions import ObjectDoesNotExist 

6from django.http import QueryDict 

7from django.test import override_settings 

8from django.urls import reverse 

9from django.utils.translation import gettext as _ 

10 

11from core.choices import ObjectChangeActionChoices 

12from core.models import ObjectChange, ObjectType 

13from netbox.choices import CSVDelimiterChoices, ImportFormatChoices 

14from netbox.models.features import ChangeLoggingMixin, CustomFieldsMixin 

15from users.models import ObjectPermission 

16 

17from .base import ModelTestCase 

18from .query_counts import assert_expected_query_count 

19from .utils import add_custom_field_data, disable_warnings, get_random_string, post_data 

20 

21__all__ = ( 

22 'ModelViewTestCase', 

23 'ViewTestCases', 

24) 

25 

26 

27# 

28# UI Tests 

29# 

30 

31class ModelViewTestCase(ModelTestCase): 

32 """ 

33 Base TestCase for model views. Subclass to test individual views. 

34 """ 

35 def _get_base_url(self): 

36 """ 

37 Return the base format for a URL for the test's model. Override this to test for a model which belongs 

38 to a different app (e.g. testing Interfaces within the virtualization app). 

39 """ 

40 return '{}:{}_{{}}'.format( 

41 self.model._meta.app_label, 

42 self.model._meta.model_name 

43 ) 

44 

45 def _get_url(self, action, instance=None): 

46 """ 

47 Return the URL name for a specific action and optionally a specific instance 

48 """ 

49 url_format = self._get_base_url() 

50 

51 # If no instance was provided, assume we don't need a unique identifier 

52 if instance is None: 

53 return reverse(url_format.format(action)) 

54 

55 return reverse(url_format.format(action), kwargs={'pk': instance.pk}) 

56 

57 

58class ViewTestCases: 

59 """ 

60 We keep any TestCases with test_* methods inside a class to prevent unittest from trying to run them. 

61 """ 

62 class GetObjectViewTestCase(ModelViewTestCase): 

63 """ 

64 Retrieve a single instance. 

65 """ 

66 @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False) 

67 def test_get_object_anonymous(self): 

68 # Make the request as an unauthenticated user 

69 self.client.logout() 

70 ct = ContentType.objects.get_for_model(self.model) 

71 if (ct.app_label, ct.model) in settings.EXEMPT_EXCLUDE_MODELS: 

72 # Models listed in EXEMPT_EXCLUDE_MODELS should not be accessible to anonymous users 

73 with disable_warnings('django.request'): 

74 response = self.client.get(self._get_queryset().first().get_absolute_url()) 

75 self.assertHttpStatus(response, 302) 

76 else: 

77 response = self.client.get(self._get_queryset().first().get_absolute_url()) 

78 self.assertHttpStatus(response, 200) 

79 

80 def test_get_object_without_permission(self): 

81 instance = self._get_queryset().first() 

82 

83 # Try GET without permission 

84 with disable_warnings('django.request'): 

85 self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 403) 

86 

87 def test_get_object_with_permission(self): 

88 instance = self._get_queryset().first() 

89 

90 # Add model-level permission 

91 obj_perm = ObjectPermission( 

92 name='Test permission', 

93 actions=['view'] 

94 ) 

95 obj_perm.save() 

96 obj_perm.users.add(self.user) 

97 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

98 

99 # Try GET with model-level permission 

100 self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 200) 

101 

102 def test_get_object_with_constrained_permission(self): 

103 instance1, instance2 = self._get_queryset().all()[:2] 

104 

105 # Add object-level permission 

106 obj_perm = ObjectPermission( 

107 name='Test permission', 

108 constraints={'pk': instance1.pk}, 

109 actions=['view'] 

110 ) 

111 obj_perm.save() 

112 obj_perm.users.add(self.user) 

113 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

114 

115 # Try GET to permitted object 

116 self.assertHttpStatus(self.client.get(instance1.get_absolute_url()), 200) 

117 

118 # Try GET to non-permitted object 

119 self.assertHttpStatus(self.client.get(instance2.get_absolute_url()), 404) 

120 

121 class GetObjectChangelogViewTestCase(ModelViewTestCase): 

122 """ 

123 View the changelog for an instance. 

124 """ 

125 @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) 

126 def test_get_object_changelog(self): 

127 url = self._get_url('changelog', self._get_queryset().first()) 

128 response = self.client.get(url) 

129 self.assertHttpStatus(response, 200) 

130 

131 class CreateObjectViewTestCase(ModelViewTestCase): 

132 """ 

133 Create a single new instance. 

134 

135 :form_data: Data to be used when creating a new object. 

136 """ 

137 form_data = {} 

138 validation_excluded_fields = [] 

139 

140 def test_create_object_without_permission(self): 

141 

142 # Try GET without permission 

143 with disable_warnings('django.request'): 

144 self.assertHttpStatus(self.client.get(self._get_url('add')), 403) 

145 

146 # Try POST without permission 

147 request = { 

148 'path': self._get_url('add'), 

149 'data': post_data(self.form_data), 

150 } 

151 response = self.client.post(**request) 

152 with disable_warnings('django.request'): 

153 self.assertHttpStatus(response, 403) 

154 

155 @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) 

156 def test_create_object_with_permission(self): 

157 # Assign unconstrained permission 

158 obj_perm = ObjectPermission( 

159 name='Test permission', 

160 actions=['add'] 

161 ) 

162 obj_perm.save() 

163 obj_perm.users.add(self.user) 

164 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

165 

166 # Try GET with model-level permission 

167 self.assertHttpStatus(self.client.get(self._get_url('add')), 200) 

168 

169 # Add custom field data if the model supports it 

170 if issubclass(self.model, CustomFieldsMixin): 

171 add_custom_field_data(self.form_data, self.model) 

172 

173 # If supported, add a changelog message 

174 if issubclass(self.model, ChangeLoggingMixin): 

175 if 'changelog_message' not in self.form_data: 

176 self.form_data['changelog_message'] = get_random_string(10) 

177 

178 # Try POST with model-level permission 

179 initial_count = self._get_queryset().count() 

180 request = { 

181 'path': self._get_url('add'), 

182 'data': post_data(self.form_data), 

183 } 

184 self.assertHttpStatus(self.client.post(**request), 302) 

185 self.assertEqual(initial_count + 1, self._get_queryset().count()) 

186 instance = self._get_queryset().order_by('pk').last() 

187 self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) 

188 

189 # Verify ObjectChange creation 

190 if issubclass(self.model, ChangeLoggingMixin): 

191 objectchanges = ObjectChange.objects.filter( 

192 changed_object_type=ContentType.objects.get_for_model(instance), 

193 changed_object_id=instance.pk 

194 ) 

195 self.assertEqual(len(objectchanges), 1) 

196 self.assertObjectChange(objectchanges[0], action=ObjectChangeActionChoices.ACTION_CREATE, 

197 message=self.form_data['changelog_message']) 

198 

199 @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) 

200 def test_create_object_with_constrained_permission(self): 

201 

202 # Assign constrained permission 

203 obj_perm = ObjectPermission( 

204 name='Test permission', 

205 constraints={'pk': 0}, # Dummy permission to deny all 

206 actions=['add'] 

207 ) 

208 obj_perm.save() 

209 obj_perm.users.add(self.user) 

210 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

211 

212 # Try GET with object-level permission 

213 self.assertHttpStatus(self.client.get(self._get_url('add')), 200) 

214 

215 # Try to create an object (not permitted) 

216 initial_count = self._get_queryset().count() 

217 request = { 

218 'path': self._get_url('add'), 

219 'data': post_data(self.form_data), 

220 } 

221 self.assertHttpStatus(self.client.post(**request), 200) 

222 self.assertEqual(initial_count, self._get_queryset().count()) # Check that no object was created 

223 

224 # Update the ObjectPermission to allow creation 

225 obj_perm.constraints = {'pk__gt': 0} 

226 obj_perm.save() 

227 

228 # Try to create an object (permitted) 

229 request = { 

230 'path': self._get_url('add'), 

231 'data': post_data(self.form_data), 

232 } 

233 self.assertHttpStatus(self.client.post(**request), 302) 

234 self.assertEqual(initial_count + 1, self._get_queryset().count()) 

235 instance = self._get_queryset().order_by('pk').last() 

236 self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) 

237 

238 class EditObjectViewTestCase(ModelViewTestCase): 

239 """ 

240 Edit a single existing instance. 

241 

242 :form_data: Data to be used when updating the first existing object. 

243 """ 

244 form_data = {} 

245 validation_excluded_fields = [] 

246 

247 def test_edit_object_without_permission(self): 

248 instance = self._get_queryset().first() 

249 

250 # Try GET without permission 

251 with disable_warnings('django.request'): 

252 self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 403) 

253 

254 # Try POST without permission 

255 request = { 

256 'path': self._get_url('edit', instance), 

257 'data': post_data(self.form_data), 

258 } 

259 with disable_warnings('django.request'): 

260 self.assertHttpStatus(self.client.post(**request), 403) 

261 

262 @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) 

263 def test_edit_object_with_permission(self): 

264 instance = self._get_queryset().first() 

265 

266 # Assign model-level permission 

267 obj_perm = ObjectPermission( 

268 name='Test permission', 

269 actions=['change'] 

270 ) 

271 obj_perm.save() 

272 obj_perm.users.add(self.user) 

273 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

274 

275 # Try GET with model-level permission 

276 self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200) 

277 

278 # Add custom field data if the model supports it 

279 if issubclass(self.model, CustomFieldsMixin): 

280 add_custom_field_data(self.form_data, self.model) 

281 

282 # If supported, add a changelog message 

283 if issubclass(self.model, ChangeLoggingMixin): 

284 if 'changelog_message' not in self.form_data: 

285 self.form_data['changelog_message'] = get_random_string(10) 

286 

287 # Try POST with model-level permission 

288 request = { 

289 'path': self._get_url('edit', instance), 

290 'data': post_data(self.form_data), 

291 } 

292 self.assertHttpStatus(self.client.post(**request), 302) 

293 instance = self._get_queryset().get(pk=instance.pk) 

294 self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) 

295 

296 # Verify ObjectChange creation 

297 if issubclass(self.model, ChangeLoggingMixin): 

298 objectchanges = ObjectChange.objects.filter( 

299 changed_object_type=ContentType.objects.get_for_model(instance), 

300 changed_object_id=instance.pk 

301 ) 

302 self.assertEqual(len(objectchanges), 1) 

303 self.assertObjectChange(objectchanges[0], action=ObjectChangeActionChoices.ACTION_UPDATE, 

304 message=self.form_data['changelog_message']) 

305 

306 @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) 

307 def test_edit_object_with_constrained_permission(self): 

308 instance1, instance2 = self._get_queryset().all()[:2] 

309 

310 # Assign constrained permission 

311 obj_perm = ObjectPermission( 

312 name='Test permission', 

313 constraints={'pk': instance1.pk}, 

314 actions=['change'] 

315 ) 

316 obj_perm.save() 

317 obj_perm.users.add(self.user) 

318 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

319 

320 # Try GET with a permitted object 

321 self.assertHttpStatus(self.client.get(self._get_url('edit', instance1)), 200) 

322 

323 # Try GET with a non-permitted object 

324 self.assertHttpStatus(self.client.get(self._get_url('edit', instance2)), 404) 

325 

326 # Try to edit a permitted object 

327 request = { 

328 'path': self._get_url('edit', instance1), 

329 'data': post_data(self.form_data), 

330 } 

331 self.assertHttpStatus(self.client.post(**request), 302) 

332 instance = self._get_queryset().get(pk=instance1.pk) 

333 self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) 

334 

335 # Try to edit a non-permitted object 

336 request = { 

337 'path': self._get_url('edit', instance2), 

338 'data': post_data(self.form_data), 

339 } 

340 self.assertHttpStatus(self.client.post(**request), 404) 

341 

342 class DeleteObjectViewTestCase(ModelViewTestCase): 

343 """ 

344 Delete a single instance. 

345 """ 

346 def test_delete_object_without_permission(self): 

347 instance = self._get_queryset().first() 

348 

349 # Try GET without permission 

350 with disable_warnings('django.request'): 

351 self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 403) 

352 

353 # Try POST without permission 

354 request = { 

355 'path': self._get_url('delete', instance), 

356 'data': post_data({'confirm': True}), 

357 } 

358 with disable_warnings('django.request'): 

359 self.assertHttpStatus(self.client.post(**request), 403) 

360 

361 @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) 

362 def test_delete_object_with_permission(self): 

363 instance = self._get_queryset().first() 

364 form_data = {'confirm': True} 

365 

366 # Assign model-level permission 

367 obj_perm = ObjectPermission( 

368 name='Test permission', 

369 actions=['delete'] 

370 ) 

371 obj_perm.save() 

372 obj_perm.users.add(self.user) 

373 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

374 

375 # Try GET with model-level permission 

376 self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 200) 

377 

378 # If supported, add a changelog message 

379 if issubclass(self.model, ChangeLoggingMixin): 

380 form_data['changelog_message'] = get_random_string(10) 

381 

382 # Try POST with model-level permission 

383 request = { 

384 'path': self._get_url('delete', instance), 

385 'data': post_data(form_data), 

386 } 

387 self.assertHttpStatus(self.client.post(**request), 302) 

388 with self.assertRaises(ObjectDoesNotExist): 

389 self._get_queryset().get(pk=instance.pk) 

390 

391 # Verify ObjectChange creation 

392 if issubclass(self.model, ChangeLoggingMixin): 

393 objectchanges = ObjectChange.objects.filter( 

394 changed_object_type=ContentType.objects.get_for_model(instance), 

395 changed_object_id=instance.pk 

396 ) 

397 self.assertEqual(len(objectchanges), 1) 

398 self.assertObjectChange(objectchanges[0], action=ObjectChangeActionChoices.ACTION_DELETE, 

399 message=form_data['changelog_message']) 

400 

401 @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) 

402 def test_delete_object_with_constrained_permission(self): 

403 instance1, instance2 = self._get_queryset().all()[:2] 

404 

405 # Assign object-level permission 

406 obj_perm = ObjectPermission( 

407 name='Test permission', 

408 constraints={'pk': instance1.pk}, 

409 actions=['delete'] 

410 ) 

411 obj_perm.save() 

412 obj_perm.users.add(self.user) 

413 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

414 

415 # Try GET with a permitted object 

416 self.assertHttpStatus(self.client.get(self._get_url('delete', instance1)), 200) 

417 

418 # Try GET with a non-permitted object 

419 self.assertHttpStatus(self.client.get(self._get_url('delete', instance2)), 404) 

420 

421 # Try to delete a permitted object 

422 request = { 

423 'path': self._get_url('delete', instance1), 

424 'data': post_data({'confirm': True}), 

425 } 

426 self.assertHttpStatus(self.client.post(**request), 302) 

427 with self.assertRaises(ObjectDoesNotExist): 

428 self._get_queryset().get(pk=instance1.pk) 

429 

430 # Try to delete a non-permitted object 

431 request = { 

432 'path': self._get_url('delete', instance2), 

433 'data': post_data({'confirm': True}), 

434 } 

435 self.assertHttpStatus(self.client.post(**request), 404) 

436 self.assertTrue(self._get_queryset().filter(pk=instance2.pk).exists()) 

437 

438 class ListObjectsViewTestCase(ModelViewTestCase): 

439 """ 

440 Retrieve multiple instances. 

441 """ 

442 @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False) 

443 def test_list_objects_anonymous(self): 

444 # Make the request as an unauthenticated user 

445 self.client.logout() 

446 ct = ContentType.objects.get_for_model(self.model) 

447 if (ct.app_label, ct.model) in settings.EXEMPT_EXCLUDE_MODELS: 

448 # Models listed in EXEMPT_EXCLUDE_MODELS should not be accessible to anonymous users 

449 with disable_warnings('django.request'): 

450 response = self.client.get(self._get_url('list')) 

451 self.assertHttpStatus(response, 302) 

452 else: 

453 response = self.client.get(self._get_url('list')) 

454 self.assertHttpStatus(response, 200) 

455 

456 def test_list_objects_without_permission(self): 

457 

458 # Try GET without permission 

459 with disable_warnings('django.request'): 

460 self.assertHttpStatus(self.client.get(self._get_url('list')), 403) 

461 

462 def test_list_objects_with_permission(self): 

463 

464 # Add model-level permission 

465 obj_perm = ObjectPermission( 

466 name='Test permission', 

467 actions=['view'] 

468 ) 

469 obj_perm.save() 

470 obj_perm.users.add(self.user) 

471 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

472 

473 # Try GET with model-level permission 

474 with assert_expected_query_count(self, 'list_objects_with_permission'): 

475 response = self.client.get(self._get_url('list')) 

476 self.assertHttpStatus(response, 200) 

477 

478 def test_list_objects_with_constrained_permission(self): 

479 instance1, instance2 = self._get_queryset().all()[:2] 

480 

481 # Add object-level permission 

482 obj_perm = ObjectPermission( 

483 name='Test permission', 

484 constraints={'pk': instance1.pk}, 

485 actions=['view'] 

486 ) 

487 obj_perm.save() 

488 obj_perm.users.add(self.user) 

489 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

490 

491 # Try GET with object-level permission 

492 response = self.client.get(self._get_url('list')) 

493 self.assertHttpStatus(response, 200) 

494 content = str(response.content) 

495 self.assertIn(instance1.get_absolute_url(), content) 

496 self.assertNotIn(instance2.get_absolute_url(), content) 

497 

498 def test_export_objects(self): 

499 url = self._get_url('list') 

500 

501 # Add model-level permission 

502 obj_perm = ObjectPermission( 

503 name='Test permission', 

504 actions=['view'] 

505 ) 

506 obj_perm.save() 

507 obj_perm.users.add(self.user) 

508 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

509 

510 # Test default CSV export 

511 response = self.client.get(f'{url}?export') 

512 self.assertHttpStatus(response, 200) 

513 self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8') 

514 

515 # Test table-based export 

516 response = self.client.get(f'{url}?export=table') 

517 self.assertHttpStatus(response, 200) 

518 self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8') 

519 

520 @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False) 

521 def test_export_objects_anonymous(self): 

522 # Ensure we are logged out. 

523 self.client.logout() 

524 

525 # Some models (e.g. the users model) always require to be logged in, so we skip them here. 

526 ct = ContentType.objects.get_for_model(self.model) 

527 if (ct.app_label, ct.model) in settings.EXEMPT_EXCLUDE_MODELS: 

528 return 

529 

530 url = self._get_url('list') 

531 

532 # Test default CSV (or sometimes YAML) export 

533 response = self.client.get(f'{url}?export') 

534 self.assertHttpStatus(response, 200) 

535 if hasattr(self.model, 'to_yaml'): 

536 self.assertEqual(response.get('Content-Type'), 'text/yaml') 

537 else: 

538 self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8') 

539 

540 # Test table-based export 

541 response = self.client.get(f'{url}?export=table') 

542 self.assertHttpStatus(response, 200) 

543 self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8') 

544 

545 class CreateMultipleObjectsViewTestCase(ModelViewTestCase): 

546 """ 

547 Create multiple instances using a single form. Expects the creation of three new instances by default. 

548 

549 :bulk_create_count: The number of objects expected to be created (default: 3). 

550 :bulk_create_data: A dictionary of data to be used for bulk object creation. 

551 """ 

552 bulk_create_count = 3 

553 bulk_create_data = {} 

554 validation_excluded_fields = [] 

555 

556 def test_create_multiple_objects_without_permission(self): 

557 request = { 

558 'path': self._get_url('add'), 

559 'data': post_data(self.bulk_create_data), 

560 } 

561 

562 # Try POST without permission 

563 with disable_warnings('django.request'): 

564 self.assertHttpStatus(self.client.post(**request), 403) 

565 

566 def test_create_multiple_objects_with_permission(self): 

567 initial_count = self._get_queryset().count() 

568 request = { 

569 'path': self._get_url('add'), 

570 'data': post_data(self.bulk_create_data), 

571 } 

572 

573 # Assign non-constrained permission 

574 obj_perm = ObjectPermission( 

575 name='Test permission', 

576 actions=['add'], 

577 ) 

578 obj_perm.save() 

579 obj_perm.users.add(self.user) 

580 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

581 

582 # Bulk create objects 

583 response = self.client.post(**request) 

584 self.assertHttpStatus(response, 302) 

585 self.assertEqual(initial_count + self.bulk_create_count, self._get_queryset().count()) 

586 for instance in self._get_queryset().order_by('-pk')[:self.bulk_create_count]: 

587 self.assertInstanceEqual(instance, self.bulk_create_data, exclude=self.validation_excluded_fields) 

588 

589 def test_create_multiple_objects_with_constrained_permission(self): 

590 initial_count = self._get_queryset().count() 

591 request = { 

592 'path': self._get_url('add'), 

593 'data': post_data(self.bulk_create_data), 

594 } 

595 

596 # Assign constrained permission 

597 obj_perm = ObjectPermission( 

598 name='Test permission', 

599 actions=['add'], 

600 constraints={'pk': 0} # Dummy constraint to deny all 

601 ) 

602 obj_perm.save() 

603 obj_perm.users.add(self.user) 

604 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

605 

606 # Attempt to make the request with unmet constraints 

607 self.assertHttpStatus(self.client.post(**request), 200) 

608 self.assertEqual(self._get_queryset().count(), initial_count) 

609 

610 # Update the ObjectPermission to allow creation 

611 obj_perm.constraints = {'pk__gt': 0} # Dummy constraint to allow all 

612 obj_perm.save() 

613 

614 response = self.client.post(**request) 

615 self.assertHttpStatus(response, 302) 

616 self.assertEqual(initial_count + self.bulk_create_count, self._get_queryset().count()) 

617 for instance in self._get_queryset().order_by('-pk')[: self.bulk_create_count]: 

618 self.assertInstanceEqual(instance, self.bulk_create_data, exclude=self.validation_excluded_fields) 

619 

620 def test_create_multiple_objects_addanother(self): 

621 """The "Create & Add Another" redirect retains the parent the objects were created under.""" 

622 parent_fields = ('device', 'module', 'device_type', 'module_type', 'virtual_machine') 

623 parents = { 

624 field: getattr(value, 'pk', value) for field, value in self.bulk_create_data.items() 

625 if field in parent_fields and value 

626 } 

627 if not parents: 

628 self.skipTest("bulk_create_data declares no parent assignment") 

629 

630 # Assign non-constrained permission 

631 obj_perm = ObjectPermission( 

632 name='Test permission', 

633 actions=['add'], 

634 ) 

635 obj_perm.save() 

636 obj_perm.users.add(self.user) 

637 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

638 

639 response = self.client.post( 

640 path=self._get_url('add'), 

641 data=post_data({**self.bulk_create_data, '_addanother': True}), 

642 ) 

643 self.assertHttpStatus(response, 302) 

644 

645 path, _sep, query = response['Location'].partition('?') 

646 self.assertEqual(path, self._get_url('add')) 

647 params = QueryDict(query) 

648 for field, value in parents.items(): 

649 self.assertEqual(params.getlist(field), [str(value)]) 

650 

651 class BulkImportObjectsViewTestCase(ModelViewTestCase): 

652 """ 

653 Create multiple instances from imported data. 

654 

655 :csv_data: CSV data for bulk import testing. Supports two formats: 

656 

657 1. Tuple/list format (backwards compatible): 

658 csv_data = ( 

659 "name,slug,description", 

660 "Object 1,object-1,First object", 

661 "Object 2,object-2,Second object", 

662 ) 

663 

664 2. Dictionary format for multiple scenarios: 

665 csv_data = { 

666 'default': ( 

667 "name,slug,description", 

668 "Object 1,object-1,First object", 

669 ), 

670 'with_optional_fields': ( 

671 "name,slug,description,comments", 

672 "Object 2,object-2,Second object,With comments", 

673 ) 

674 } 

675 

676 When using dictionary format, test_bulk_import_objects_with_permission() 

677 runs each scenario as a separate subtest with clear output: 

678 

679 test_bulk_import_objects_with_permission (scenario=default) ... ok 

680 test_bulk_import_objects_with_permission (scenario=with_optional_fields) ... ok 

681 """ 

682 

683 csv_data = () 

684 

685 def get_scenarios(self): 

686 return self.csv_data.keys() if isinstance(self.csv_data, dict) else ['default'] 

687 

688 def _get_csv_data(self, scenario_name='default'): 

689 """ 

690 Get CSV data for testing. Supports both tuple/list and dictionary formats. 

691 """ 

692 if isinstance(self.csv_data, dict): 

693 if scenario_name not in self.csv_data: 

694 available = ', '.join(self.csv_data.keys()) 

695 raise ValueError(f"Scenario '{scenario_name}' not found in csv_data. Available: {available}") 

696 return '\n'.join(self.csv_data[scenario_name]) 

697 if isinstance(self.csv_data, (tuple, list)): 

698 return '\n'.join(self.csv_data) 

699 raise TypeError(f'csv_data must be a tuple, list, or dictionary, got {type(self.csv_data)}') 

700 

701 def _get_update_csv_data(self): 

702 return self.csv_update_data, '\n'.join(self.csv_update_data) 

703 

704 def test_bulk_import_objects_without_permission(self): 

705 data = { 

706 'data': self._get_csv_data(), 

707 'format': ImportFormatChoices.CSV, 

708 'csv_delimiter': CSVDelimiterChoices.AUTO, 

709 } 

710 

711 # Test GET without permission 

712 with disable_warnings('django.request'): 

713 self.assertHttpStatus(self.client.get(self._get_url('bulk_import')), 403) 

714 

715 # Try POST without permission 

716 response = self.client.post(self._get_url('bulk_import'), data) 

717 with disable_warnings('django.request'): 

718 self.assertHttpStatus(response, 403) 

719 

720 @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) 

721 def test_bulk_import_objects_with_permission(self, post_import_callback=None): 

722 # Assign model-level permission once for all scenarios 

723 obj_perm = ObjectPermission(name='Test permission', actions=['add']) 

724 obj_perm.save() 

725 obj_perm.users.add(self.user) 

726 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

727 

728 # Try GET with model-level permission (only once) 

729 self.assertHttpStatus(self.client.get(self._get_url('bulk_import')), 200) 

730 

731 # Test each scenario 

732 for scenario_name in self.get_scenarios(): 

733 with self.cleanupSubTest(scenario=scenario_name): 

734 self._test_bulk_import_with_permission_scenario(scenario_name) 

735 

736 if post_import_callback: 

737 post_import_callback(scenario_name) 

738 

739 def _test_bulk_import_with_permission_scenario(self, scenario_name): 

740 """ 

741 Helper method to test a single bulk import scenario. 

742 """ 

743 initial_count = self._get_queryset().count() 

744 

745 # Get CSV data for this scenario 

746 scenario_data = self._get_csv_data(scenario_name) 

747 expected_new_objects = len(scenario_data.splitlines()) - 1 

748 

749 data = { 

750 'data': scenario_data, 

751 'format': ImportFormatChoices.CSV, 

752 'csv_delimiter': CSVDelimiterChoices.AUTO, 

753 } 

754 

755 # If supported, add a changelog message 

756 if issubclass(self.model, ChangeLoggingMixin): 

757 data['changelog_message'] = get_random_string(10) 

758 

759 # Test POST with permission 

760 response = self.client.post(self._get_url('bulk_import'), data) 

761 self.assertHttpStatus(response, 302) 

762 

763 # Verify object count increase 

764 self.assertEqual(self._get_queryset().count(), initial_count + expected_new_objects) 

765 

766 # Verify ObjectChange creation 

767 if issubclass(self.model, ChangeLoggingMixin): 

768 request_id = response.headers.get('X-Request-ID') 

769 self.assertIsNotNone(request_id, 'Unable to determine request ID from response') 

770 objectchanges = ObjectChange.objects.filter( 

771 changed_object_type=ContentType.objects.get_for_model(self.model), 

772 request_id=request_id, 

773 action=ObjectChangeActionChoices.ACTION_CREATE, 

774 ) 

775 self.assertEqual(len(objectchanges), expected_new_objects) 

776 

777 for oc in objectchanges: 

778 self.assertObjectChange(oc, action=ObjectChangeActionChoices.ACTION_CREATE, 

779 message=data['changelog_message']) 

780 

781 @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) 

782 def test_bulk_update_objects_without_change_permission(self): 

783 # Bulk import rows carrying an object ID update existing objects. This must require the 'change' 

784 # permission, matching the REST API; the 'add' permission alone must not permit updates. 

785 if not hasattr(self, 'csv_update_data'): 

786 raise NotImplementedError(_("The test must define csv_update_data.")) 

787 

788 initial_count = self._get_queryset().count() 

789 array, csv_data = self._get_update_csv_data() 

790 data = { 

791 'format': ImportFormatChoices.CSV, 

792 'data': csv_data, 

793 'csv_delimiter': CSVDelimiterChoices.AUTO, 

794 } 

795 

796 # Assign only the 'add' permission 

797 obj_perm = ObjectPermission( 

798 name='Test permission', 

799 actions=['add'] 

800 ) 

801 obj_perm.save() 

802 obj_perm.users.add(self.user) 

803 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

804 

805 # Take a snapshot of the objects targeted for update 

806 reader = csv.DictReader(array, delimiter=',') 

807 check_data = list(reader) 

808 before = { 

809 line['id']: self.model.objects.get(id=line['id']) 

810 for line in check_data 

811 } 

812 

813 # The import must be rejected with a permissions error (form re-rendered) and no object modified 

814 response = self.client.post(self._get_url('bulk_import'), data) 

815 self.assertHttpStatus(response, 200) 

816 self.assertContains(response, 'Remove the ID column to create new objects instead.') 

817 self.assertEqual(initial_count, self._get_queryset().count()) 

818 for line in check_data: 

819 obj = self.model.objects.get(id=line['id']) 

820 for attr in line: 

821 if attr == 'id': 

822 continue 

823 # Skip relational fields (FK/M2M), consistent with test_bulk_update_objects_with_permission 

824 if self.model._meta.get_field(attr).is_relation: 

825 continue 

826 self.assertEqual(getattr(obj, attr), getattr(before[line['id']], attr)) 

827 

828 @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) 

829 def test_bulk_update_objects_with_permission(self): 

830 if not hasattr(self, 'csv_update_data'): 

831 raise NotImplementedError(_("The test must define csv_update_data.")) 

832 

833 initial_count = self._get_queryset().count() 

834 array, csv_data = self._get_update_csv_data() 

835 data = { 

836 'format': ImportFormatChoices.CSV, 

837 'data': csv_data, 

838 'csv_delimiter': CSVDelimiterChoices.AUTO, 

839 } 

840 

841 # Updating existing objects requires both 'add' (to reach the view) and 'change' (to update) 

842 obj_perm = ObjectPermission( 

843 name='Test permission', 

844 actions=['add', 'change'] 

845 ) 

846 obj_perm.save() 

847 obj_perm.users.add(self.user) 

848 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

849 

850 # Test POST with permission 

851 self.assertHttpStatus(self.client.post(self._get_url('bulk_import'), data), 302) 

852 self.assertEqual(initial_count, self._get_queryset().count()) 

853 

854 # Verify that each object was actually updated to match the value specified in the CSV 

855 reader = csv.DictReader(array, delimiter=',') 

856 check_data = list(reader) 

857 for line in check_data: 

858 obj = self.model.objects.get(id=line["id"]) 

859 for attr, expected in line.items(): 

860 if attr == "id": 

861 continue 

862 field = self.model._meta.get_field(attr) 

863 # Skip relational fields (FK/M2M): the CSV value can't be mapped to a comparable attribute 

864 if field.is_relation: 

865 continue 

866 actual = getattr(obj, attr) 

867 # Only verify simple scalar values against the raw CSV string; skip lists and other complex 

868 # representations that the import form transforms (e.g. choice-set extra_choices). 

869 if not isinstance(actual, (str, int, float)): 

870 continue 

871 # Compare case-insensitively to tolerate values normalized on save (e.g. MAC addresses) 

872 self.assertEqual(str(actual).lower(), str(expected).lower()) 

873 

874 @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) 

875 def test_bulk_import_objects_with_constrained_permission(self, post_import_callback=None): 

876 # Assign constrained permission (deny all initially) 

877 obj_perm = ObjectPermission( 

878 name='Test permission', 

879 constraints={'pk': 0}, # Dummy permission to deny all 

880 actions=['add'], 

881 ) 

882 obj_perm.save() 

883 obj_perm.users.add(self.user) 

884 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

885 

886 # Test each scenario with constrained permissions 

887 for scenario_name in self.get_scenarios(): 

888 with self.cleanupSubTest(scenario=scenario_name): 

889 self._test_bulk_import_constrained_scenario(scenario_name, obj_perm) 

890 

891 if post_import_callback: 

892 post_import_callback(scenario_name) 

893 

894 def _test_bulk_import_constrained_scenario(self, scenario_name, obj_perm): 

895 """ 

896 Helper method to test a single bulk import scenario with constrained permissions. 

897 """ 

898 initial_count = self._get_queryset().count() 

899 

900 # Get CSV data for this scenario 

901 scenario_data = self._get_csv_data(scenario_name) 

902 expected_new_objects = len(scenario_data.splitlines()) - 1 

903 

904 data = { 

905 'data': scenario_data, 

906 'format': ImportFormatChoices.CSV, 

907 'csv_delimiter': CSVDelimiterChoices.AUTO, 

908 } 

909 

910 # Attempt to import non-permitted objects (should fail) 

911 self.assertHttpStatus(self.client.post(self._get_url('bulk_import'), data), 200) 

912 self.assertEqual(self._get_queryset().count(), initial_count) 

913 

914 # Update permission constraints to allow all 

915 obj_perm.constraints = {'pk__gt': 0} # Dummy permission to allow all 

916 obj_perm.save() 

917 

918 # Import permitted objects (should succeed) 

919 self.assertHttpStatus(self.client.post(self._get_url('bulk_import'), data), 302) 

920 self.assertEqual(self._get_queryset().count(), initial_count + expected_new_objects) 

921 

922 class BulkEditObjectsViewTestCase(ModelViewTestCase): 

923 """ 

924 Edit multiple instances. 

925 

926 :bulk_edit_data: A dictionary of data to be used when bulk editing a set of objects. This data should differ 

927 from that used for initial object creation within setUpTestData(). 

928 """ 

929 bulk_edit_data = {} 

930 

931 def test_bulk_edit_objects_without_permission(self): 

932 pk_list = self._get_queryset().values_list('pk', flat=True)[:3] 

933 data = { 

934 'pk': pk_list, 

935 '_apply': True, # Form button 

936 } 

937 

938 # Test GET without permission 

939 with disable_warnings('django.request'): 

940 self.assertHttpStatus(self.client.get(self._get_url('bulk_edit')), 403) 

941 

942 # Try POST without permission 

943 with disable_warnings('django.request'): 

944 self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 403) 

945 

946 @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) 

947 def test_bulk_edit_objects_with_permission(self): 

948 pk_list = list(self._get_queryset().values_list('pk', flat=True)[:3]) 

949 data = { 

950 'pk': pk_list, 

951 '_apply': True, # Form button 

952 } 

953 

954 # If supported, add a changelog message 

955 if issubclass(self.model, ChangeLoggingMixin): 

956 data['changelog_message'] = get_random_string(10) 

957 

958 # Append the form data to the request 

959 data.update(post_data(self.bulk_edit_data)) 

960 

961 # Assign model-level permission 

962 obj_perm = ObjectPermission( 

963 name='Test permission', 

964 actions=['view', 'change'] 

965 ) 

966 obj_perm.save() 

967 obj_perm.users.add(self.user) 

968 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

969 

970 # Try POST with model-level permission 

971 response = self.client.post(self._get_url('bulk_edit'), data) 

972 self.assertHttpStatus(response, 302) 

973 for i, instance in enumerate(self._get_queryset().filter(pk__in=pk_list)): 

974 self.assertInstanceEqual(instance, self.bulk_edit_data) 

975 

976 # Verify ObjectChange creation 

977 if issubclass(self.model, ChangeLoggingMixin): 

978 request_id = response.headers.get('X-Request-ID') 

979 self.assertIsNotNone(request_id, "Unable to determine request ID from response") 

980 objectchanges = ObjectChange.objects.filter( 

981 changed_object_type=ContentType.objects.get_for_model(self.model), 

982 changed_object_id__in=pk_list 

983 ) 

984 self.assertEqual(len(objectchanges), len(pk_list)) 

985 for oc in objectchanges: 

986 self.assertObjectChange(oc, action=ObjectChangeActionChoices.ACTION_UPDATE, 

987 message=data['changelog_message']) 

988 

989 @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) 

990 def test_bulk_edit_objects_with_constrained_permission(self): 

991 pk_list = list(self._get_queryset().values_list('pk', flat=True)[:3]) 

992 data = { 

993 'pk': pk_list, 

994 '_apply': True, # Form button 

995 } 

996 

997 # Append the form data to the request 

998 data.update(post_data(self.bulk_edit_data)) 

999 

1000 # Dynamically determine a constraint that will *not* be matched by the updated objects. 

1001 attr_name = list(self.bulk_edit_data.keys())[0] 

1002 field = self.model._meta.get_field(attr_name) 

1003 value = field.value_from_object(self._get_queryset().first()) 

1004 

1005 # Assign constrained permission 

1006 obj_perm = ObjectPermission( 

1007 name='Test permission', 

1008 constraints={attr_name: value}, 

1009 actions=['view', 'change'] 

1010 ) 

1011 obj_perm.save() 

1012 obj_perm.users.add(self.user) 

1013 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

1014 

1015 # Attempt to bulk edit permitted objects into a non-permitted state 

1016 response = self.client.post(self._get_url('bulk_edit'), data) 

1017 self.assertHttpStatus(response, 200) 

1018 

1019 # Update permission constraints 

1020 obj_perm.constraints = {'pk__gt': 0} 

1021 obj_perm.save() 

1022 

1023 # Bulk edit permitted objects 

1024 self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302) 

1025 for i, instance in enumerate(self._get_queryset().filter(pk__in=pk_list)): 

1026 self.assertInstanceEqual(instance, self.bulk_edit_data) 

1027 

1028 class BulkDeleteObjectsViewTestCase(ModelViewTestCase): 

1029 """ 

1030 Delete multiple instances. 

1031 """ 

1032 def test_bulk_delete_objects_without_permission(self): 

1033 pk_list = self._get_queryset().values_list('pk', flat=True)[:3] 

1034 data = { 

1035 'pk': pk_list, 

1036 'confirm': True, 

1037 '_confirm': True, # Form button 

1038 } 

1039 

1040 # Test GET without permission 

1041 with disable_warnings('django.request'): 

1042 self.assertHttpStatus(self.client.get(self._get_url('bulk_delete')), 403) 

1043 

1044 # Try POST without permission 

1045 with disable_warnings('django.request'): 

1046 self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 403) 

1047 

1048 def test_bulk_delete_objects_with_permission(self): 

1049 pk_list = list(self._get_queryset().values_list('pk', flat=True))[:3] 

1050 data = { 

1051 'pk': pk_list, 

1052 'confirm': True, 

1053 '_confirm': True, # Form button 

1054 } 

1055 

1056 # If supported, add a changelog message 

1057 if issubclass(self.model, ChangeLoggingMixin): 

1058 data['changelog_message'] = get_random_string(10) 

1059 

1060 # Assign unconstrained permission 

1061 obj_perm = ObjectPermission( 

1062 name='Test permission', 

1063 actions=['delete'] 

1064 ) 

1065 obj_perm.save() 

1066 obj_perm.users.add(self.user) 

1067 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

1068 

1069 # Try POST with model-level permission 

1070 response = self.client.post(self._get_url('bulk_delete'), data) 

1071 self.assertHttpStatus(response, 302) 

1072 self.assertFalse(self._get_queryset().filter(pk__in=pk_list).exists()) 

1073 

1074 # Verify ObjectChange creation 

1075 if issubclass(self.model, ChangeLoggingMixin): 

1076 objectchanges = ObjectChange.objects.filter( 

1077 changed_object_type=ContentType.objects.get_for_model(self.model), 

1078 changed_object_id__in=pk_list 

1079 ) 

1080 self.assertEqual(len(objectchanges), len(pk_list)) 

1081 for oc in objectchanges: 

1082 self.assertObjectChange(oc, action=ObjectChangeActionChoices.ACTION_DELETE, 

1083 message=data['changelog_message']) 

1084 

1085 def test_bulk_delete_objects_with_constrained_permission(self): 

1086 pk_list = self._get_queryset().values_list('pk', flat=True) 

1087 data = { 

1088 'pk': pk_list, 

1089 'confirm': True, 

1090 '_confirm': True, # Form button 

1091 } 

1092 

1093 # Assign constrained permission 

1094 obj_perm = ObjectPermission( 

1095 name='Test permission', 

1096 constraints={'pk': 0}, # Dummy permission to deny all 

1097 actions=['delete'] 

1098 ) 

1099 obj_perm.save() 

1100 obj_perm.users.add(self.user) 

1101 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

1102 

1103 # Attempt to bulk delete non-permitted objects 

1104 initial_count = self._get_queryset().count() 

1105 self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) 

1106 self.assertEqual(self._get_queryset().count(), initial_count) 

1107 

1108 # Update permission constraints 

1109 obj_perm.constraints = {'pk__gt': 0} # Dummy permission to allow all 

1110 obj_perm.save() 

1111 

1112 # Bulk delete permitted objects 

1113 self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) 

1114 self.assertEqual(self._get_queryset().count(), 0) 

1115 

1116 class BulkRenameObjectsViewTestCase(ModelViewTestCase): 

1117 """ 

1118 Rename multiple instances. 

1119 """ 

1120 rename_data = { 

1121 'find': '^(.*)$', 

1122 'replace': '\\1X', # Append an X to the original value 

1123 'use_regex': True, 

1124 } 

1125 

1126 def test_bulk_rename_objects_without_permission(self): 

1127 pk_list = self._get_queryset().values_list('pk', flat=True)[:3] 

1128 data = { 

1129 'pk': pk_list, 

1130 '_apply': True, # Form button 

1131 } 

1132 data.update(self.rename_data) 

1133 

1134 # Test GET without permission 

1135 with disable_warnings('django.request'): 

1136 self.assertHttpStatus(self.client.get(self._get_url('bulk_rename')), 403) 

1137 

1138 # Try POST without permission 

1139 with disable_warnings('django.request'): 

1140 self.assertHttpStatus(self.client.post(self._get_url('bulk_rename'), data), 403) 

1141 

1142 @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) 

1143 def test_bulk_rename_objects_with_permission(self): 

1144 objects = self._get_queryset().all()[:3] 

1145 pk_list = [obj.pk for obj in objects] 

1146 data = { 

1147 'pk': pk_list, 

1148 '_apply': True, # Form button 

1149 'field_names': ['name'], 

1150 } 

1151 data.update(self.rename_data) 

1152 

1153 # Assign model-level permission 

1154 obj_perm = ObjectPermission( 

1155 name='Test permission', 

1156 actions=['change'] 

1157 ) 

1158 obj_perm.save() 

1159 obj_perm.users.add(self.user) 

1160 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

1161 

1162 # Try POST with model-level permission 

1163 self.assertHttpStatus(self.client.post(self._get_url('bulk_rename'), data), 302) 

1164 for i, instance in enumerate(self._get_queryset().filter(pk__in=pk_list)): 

1165 self.assertEqual(instance.name, f'{objects[i].name}X') 

1166 

1167 @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) 

1168 def test_bulk_rename_objects_with_changelog_message(self): 

1169 if not issubclass(self.model, ChangeLoggingMixin): 

1170 self.skipTest("Model does not support change logging") 

1171 objects = self._get_queryset().all()[:3] 

1172 pk_list = [obj.pk for obj in objects] 

1173 data = { 

1174 'pk': pk_list, 

1175 '_apply': True, 

1176 'changelog_message': 'Bulk rename test message', 

1177 'field_names': ['name'], 

1178 } 

1179 data.update(self.rename_data) 

1180 

1181 # Assign model-level permission 

1182 obj_perm = ObjectPermission( 

1183 name='Test permission', 

1184 actions=['change'] 

1185 ) 

1186 obj_perm.save() 

1187 obj_perm.users.add(self.user) 

1188 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

1189 

1190 self.assertHttpStatus(self.client.post(self._get_url('bulk_rename'), data), 302) 

1191 

1192 # Verify changelog message was recorded on each renamed object 

1193 object_type = ObjectType.objects.get_for_model(self.model) 

1194 for pk in pk_list: 

1195 oc = ObjectChange.objects.filter( 

1196 changed_object_type=object_type, 

1197 changed_object_id=pk, 

1198 action=ObjectChangeActionChoices.ACTION_UPDATE, 

1199 ).order_by('-time').first() 

1200 self.assertIsNotNone(oc) 

1201 self.assertEqual(oc.message, 'Bulk rename test message') 

1202 

1203 @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) 

1204 def test_bulk_rename_objects_with_constrained_permission(self): 

1205 objects = self._get_queryset().all()[:3] 

1206 pk_list = [obj.pk for obj in objects] 

1207 data = { 

1208 'pk': pk_list, 

1209 '_apply': True, # Form button 

1210 'field_names': ['name'], 

1211 } 

1212 data.update(self.rename_data) 

1213 

1214 # Assign constrained permission 

1215 obj_perm = ObjectPermission( 

1216 name='Test permission', 

1217 constraints={'name__regex': '[^X]$'}, 

1218 actions=['change'] 

1219 ) 

1220 obj_perm.save() 

1221 obj_perm.users.add(self.user) 

1222 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

1223 

1224 # Attempt to bulk edit permitted objects into a non-permitted state 

1225 response = self.client.post(self._get_url('bulk_rename'), data) 

1226 self.assertHttpStatus(response, 200) 

1227 

1228 # Update permission constraints 

1229 obj_perm.constraints = {'pk__gt': 0} 

1230 obj_perm.save() 

1231 

1232 # Bulk rename permitted objects 

1233 self.assertHttpStatus(self.client.post(self._get_url('bulk_rename'), data), 302) 

1234 for i, instance in enumerate(self._get_queryset().filter(pk__in=pk_list)): 

1235 self.assertEqual(instance.name, f'{objects[i].name}X') 

1236 

1237 @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) 

1238 def test_bulk_rename_label_field(self): 

1239 """When field_names=['label'] is submitted, labels (not names) are updated.""" 

1240 if 'label' not in {f.name for f in self.model._meta.fields}: 

1241 self.skipTest("Model does not have a label field") 

1242 

1243 objects = self._get_queryset().all()[:3] 

1244 pk_list = [obj.pk for obj in objects] 

1245 original_labels = [obj.label for obj in objects] 

1246 data = { 

1247 'pk': pk_list, 

1248 'field_names': ['label'], 

1249 '_apply': True, 

1250 } 

1251 data.update(self.rename_data) 

1252 

1253 obj_perm = ObjectPermission(name='Test permission', actions=['change']) 

1254 obj_perm.save() 

1255 obj_perm.users.add(self.user) 

1256 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

1257 

1258 self.assertHttpStatus(self.client.post(self._get_url('bulk_rename'), data), 302) 

1259 for i, instance in enumerate(self._get_queryset().filter(pk__in=pk_list)): 

1260 self.assertEqual(instance.label, f'{original_labels[i]}X') 

1261 self.assertEqual(instance.name, objects[i].name) 

1262 

1263 @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) 

1264 def test_bulk_rename_name_and_label_fields(self): 

1265 """When field_names=['name', 'label'] is submitted, both fields are updated simultaneously.""" 

1266 if 'label' not in {f.name for f in self.model._meta.fields}: 

1267 self.skipTest("Model does not have a label field") 

1268 

1269 objects = self._get_queryset().all()[:3] 

1270 pk_list = [obj.pk for obj in objects] 

1271 original_names = [obj.name for obj in objects] 

1272 original_labels = [obj.label for obj in objects] 

1273 data = { 

1274 'pk': pk_list, 

1275 'field_names': ['name', 'label'], 

1276 '_apply': True, 

1277 } 

1278 data.update(self.rename_data) 

1279 

1280 obj_perm = ObjectPermission(name='Test permission', actions=['change']) 

1281 obj_perm.save() 

1282 obj_perm.users.add(self.user) 

1283 obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) 

1284 

1285 self.assertHttpStatus(self.client.post(self._get_url('bulk_rename'), data), 302) 

1286 for i, instance in enumerate(self._get_queryset().filter(pk__in=pk_list)): 

1287 self.assertEqual(instance.name, f'{original_names[i]}X') 

1288 self.assertEqual(instance.label, f'{original_labels[i]}X') 

1289 

1290 class PrimaryObjectViewTestCase( 

1291 GetObjectViewTestCase, 

1292 GetObjectChangelogViewTestCase, 

1293 CreateObjectViewTestCase, 

1294 EditObjectViewTestCase, 

1295 DeleteObjectViewTestCase, 

1296 ListObjectsViewTestCase, 

1297 BulkImportObjectsViewTestCase, 

1298 BulkEditObjectsViewTestCase, 

1299 BulkDeleteObjectsViewTestCase, 

1300 ): 

1301 """ 

1302 TestCase suitable for testing all standard View functions for primary objects 

1303 """ 

1304 maxDiff = None 

1305 

1306 class OrganizationalObjectViewTestCase( 

1307 GetObjectViewTestCase, 

1308 GetObjectChangelogViewTestCase, 

1309 CreateObjectViewTestCase, 

1310 EditObjectViewTestCase, 

1311 DeleteObjectViewTestCase, 

1312 ListObjectsViewTestCase, 

1313 BulkImportObjectsViewTestCase, 

1314 BulkEditObjectsViewTestCase, 

1315 BulkDeleteObjectsViewTestCase, 

1316 ): 

1317 """ 

1318 TestCase suitable for all organizational objects 

1319 """ 

1320 maxDiff = None 

1321 

1322 class AdminModelViewTestCase( 

1323 GetObjectViewTestCase, 

1324 CreateObjectViewTestCase, 

1325 EditObjectViewTestCase, 

1326 DeleteObjectViewTestCase, 

1327 ListObjectsViewTestCase, 

1328 BulkImportObjectsViewTestCase, 

1329 BulkEditObjectsViewTestCase, 

1330 BulkDeleteObjectsViewTestCase, 

1331 ): 

1332 """ 

1333 TestCase suitable for testing all standard View functions for objects which inherit from AdminModel. 

1334 """ 

1335 maxDiff = None 

1336 

1337 class DeviceComponentTemplateViewTestCase( 

1338 EditObjectViewTestCase, 

1339 DeleteObjectViewTestCase, 

1340 CreateMultipleObjectsViewTestCase, 

1341 BulkEditObjectsViewTestCase, 

1342 BulkRenameObjectsViewTestCase, 

1343 BulkDeleteObjectsViewTestCase, 

1344 ): 

1345 """ 

1346 TestCase suitable for testing device component template models (ConsolePortTemplates, InterfaceTemplates, etc.) 

1347 """ 

1348 maxDiff = None 

1349 

1350 class DeviceComponentViewTestCase( 

1351 GetObjectViewTestCase, 

1352 GetObjectChangelogViewTestCase, 

1353 EditObjectViewTestCase, 

1354 DeleteObjectViewTestCase, 

1355 ListObjectsViewTestCase, 

1356 CreateMultipleObjectsViewTestCase, 

1357 BulkImportObjectsViewTestCase, 

1358 BulkEditObjectsViewTestCase, 

1359 BulkRenameObjectsViewTestCase, 

1360 BulkDeleteObjectsViewTestCase, 

1361 ): 

1362 """ 

1363 TestCase suitable for testing device component models (ConsolePorts, Interfaces, etc.) 

1364 """ 

1365 maxDiff = None