| 204 | class TestEditingInlineViews(TestCase): |
| 205 | """ |
| 206 | Make sure that concurrent editing does not result in an error for the user |
| 207 | |
| 208 | Refs #15574 |
| 209 | |
| 210 | These tests simulate two test clients by submitting slightly different data |
| 211 | twice. This would be equivalent to working with two tabs open in a browser, |
| 212 | or two different users working separately. Individually each of these forms |
| 213 | is valid but the second one is invalid when submitted one after the other. |
| 214 | """ |
| 215 | fixtures = ['admin-views-users.xml'] |
| 216 | |
| 217 | def setUp(self): |
| 218 | result = self.client.login(username='super', password='secret') |
| 219 | self.failUnlessEqual(result, True) |
| 220 | # Set up a thing to be modified in the test |
| 221 | self.thing = Thing.objects.create(description="Parent object") |
| 222 | self.thing_item_0 = ThingItem.objects.create(description="Item #0", thing=self.thing) |
| 223 | self.thing_item_1 = ThingItem.objects.create(description="Item #1", thing=self.thing) |
| 224 | |
| 225 | def tearDown(self): |
| 226 | self.client.logout() |
| 227 | self.thing_item_0.delete() |
| 228 | self.thing_item_1.delete() |
| 229 | self.thing.delete() |
| 230 | |
| 231 | def test_concurrent_editing_first(self): |
| 232 | """ |
| 233 | Test concurrent editing when a user deletes the first inline in a list; |
| 234 | second user is simply editing the same Thing object. |
| 235 | """ |
| 236 | data = { |
| 237 | 'description': [u'A new description #2'], |
| 238 | 'thingitem_set-MAX_NUM_FORMS': [u'0'], |
| 239 | 'thingitem_set-TOTAL_FORMS': [u'2'], |
| 240 | 'thingitem_set-INITIAL_FORMS': [u'2'], |
| 241 | 'thingitem_set-0-DELETE': [u''], |
| 242 | 'thingitem_set-0-id': [u'%s' % (self.thing_item_0.pk,)], |
| 243 | 'thingitem_set-0-thing': [u'%s' % (self.thing.pk,)], |
| 244 | 'thingitem_set-0-description': [u'New item #0 description'], |
| 245 | 'thingitem_set-1-DELETE': [u''], |
| 246 | 'thingitem_set-1-id': [u'%s' % (self.thing_item_1.pk,)], |
| 247 | 'thingitem_set-1-thing': [u'%s' % (self.thing.pk,)], |
| 248 | 'thingitem_set-1-description': [u'Deleted item #1 description'], |
| 249 | } |
| 250 | self._do_test_concurrent_editing_views(deleted_idx=0, data=data) |
| 251 | self.assertEqual(self.thing.thingitem_set.count(), 1) |
| 252 | |
| 253 | def test_concurrent_editing_first_delete_twice(self): |
| 254 | """ |
| 255 | Test concurrent editing when a user deletes the first inline in a list; |
| 256 | second user is also deleting the same inline. |
| 257 | """ |
| 258 | data = { |
| 259 | 'description': [u'A new description #3'], |
| 260 | 'thingitem_set-MAX_NUM_FORMS': [u'0'], |
| 261 | 'thingitem_set-TOTAL_FORMS': [u'2'], |
| 262 | 'thingitem_set-INITIAL_FORMS': [u'2'], |
| 263 | 'thingitem_set-0-DELETE': [u'on'], |
| 264 | 'thingitem_set-0-id': [u'%s' % (self.thing_item_0.pk,)], |
| 265 | 'thingitem_set-0-thing': [u'%s' % (self.thing.pk,)], |
| 266 | 'thingitem_set-0-description': [u'New item #0 description'], |
| 267 | 'thingitem_set-1-DELETE': [u''], |
| 268 | 'thingitem_set-1-id': [u'%s' % (self.thing_item_1.pk,)], |
| 269 | 'thingitem_set-1-thing': [u'%s' % (self.thing.pk,)], |
| 270 | 'thingitem_set-1-description': [u'Deleted item #1 description'], |
| 271 | } |
| 272 | self._do_test_concurrent_editing_views(deleted_idx=0, data=data) |
| 273 | self.assertEqual(self.thing.thingitem_set.count(), 1) |
| 274 | |
| 275 | def test_concurrent_editing_first_add_another(self): |
| 276 | """ |
| 277 | Test concurrent editing when a user deletes the first inline in a list; |
| 278 | second user is adding another inline on the same change_view. |
| 279 | """ |
| 280 | data = { |
| 281 | 'description': [u'A new description #3'], |
| 282 | 'thingitem_set-MAX_NUM_FORMS': [u'0'], |
| 283 | 'thingitem_set-TOTAL_FORMS': [u'2'], |
| 284 | 'thingitem_set-INITIAL_FORMS': [u'2'], |
| 285 | 'thingitem_set-0-DELETE': [u'on'], |
| 286 | 'thingitem_set-0-id': [u'%s' % (self.thing_item_0.pk,)], |
| 287 | 'thingitem_set-0-thing': [u'%s' % (self.thing.pk,)], |
| 288 | 'thingitem_set-0-description': [u'New item #0 description'], |
| 289 | 'thingitem_set-1-DELETE': [u'on'], |
| 290 | 'thingitem_set-1-id': [u'%s' % (self.thing_item_1.pk,)], |
| 291 | 'thingitem_set-1-thing': [u'%s' % (self.thing.pk,)], |
| 292 | 'thingitem_set-1-description': [u'Deleted item #1 description'], |
| 293 | } |
| 294 | self._do_test_concurrent_editing_views(deleted_idx=0, data=data) |
| 295 | self.assertEqual(self.thing.thingitem_set.count(), 0) |
| 296 | |
| 297 | def test_concurrent_editing_first_delete_different(self): |
| 298 | """ |
| 299 | Test concurrent editing when a user deletes the first inline in a list; |
| 300 | second user is also deleting another inline on the same change_view. |
| 301 | """ |
| 302 | data = { |
| 303 | 'description': [u'A new description #3'], |
| 304 | 'thingitem_set-MAX_NUM_FORMS': [u'0'], |
| 305 | 'thingitem_set-TOTAL_FORMS': [u'3'], |
| 306 | 'thingitem_set-INITIAL_FORMS': [u'2'], |
| 307 | 'thingitem_set-0-DELETE': [u''], |
| 308 | 'thingitem_set-0-id': [u'%s' % (self.thing_item_0.pk,)], |
| 309 | 'thingitem_set-0-thing': [u'%s' % (self.thing.pk,)], |
| 310 | 'thingitem_set-0-description': [u'New item #0 description'], |
| 311 | 'thingitem_set-1-DELETE': [u''], |
| 312 | 'thingitem_set-1-id': [u'%s' % (self.thing_item_1.pk,)], |
| 313 | 'thingitem_set-1-thing': [u'%s' % (self.thing.pk,)], |
| 314 | 'thingitem_set-1-description': [u'Deleted item #1 description'], |
| 315 | 'thingitem_set-2-DELETE': [u''], |
| 316 | 'thingitem_set-2-id': [u''], |
| 317 | 'thingitem_set-2-thing': [u'%s' % (self.thing.pk,)], |
| 318 | 'thingitem_set-2-description': [u'New item #2 description'], |
| 319 | } |
| 320 | self._do_test_concurrent_editing_views(deleted_idx=0, data=data) |
| 321 | self.assertEqual(self.thing.thingitem_set.count(), 2) |
| 322 | |
| 323 | def test_concurrent_editing_last(self): |
| 324 | """ |
| 325 | Test concurrent editing when a user deletes the last inline in a list; |
| 326 | second user is simply editing the same Thing object. |
| 327 | """ |
| 328 | data = { |
| 329 | 'description': [u'A new description #2'], |
| 330 | 'thingitem_set-MAX_NUM_FORMS': [u'0'], |
| 331 | 'thingitem_set-TOTAL_FORMS': [u'2'], |
| 332 | 'thingitem_set-INITIAL_FORMS': [u'2'], |
| 333 | 'thingitem_set-0-DELETE': [u''], |
| 334 | 'thingitem_set-0-id': [u'%s' % (self.thing_item_0.pk,)], |
| 335 | 'thingitem_set-0-thing': [u'%s' % (self.thing.pk,)], |
| 336 | 'thingitem_set-0-description': [u'New item #0 description'], |
| 337 | 'thingitem_set-1-DELETE': [u''], |
| 338 | 'thingitem_set-1-id': [u'%s' % (self.thing_item_1.pk,)], |
| 339 | 'thingitem_set-1-thing': [u'%s' % (self.thing.pk,)], |
| 340 | 'thingitem_set-1-description': [u'Deleted item #1 description'], |
| 341 | } |
| 342 | self._do_test_concurrent_editing_views(deleted_idx=1, data=data) |
| 343 | self.assertEqual(self.thing.thingitem_set.count(), 1) |
| 344 | |
| 345 | def test_concurrent_editing_last_delete_twice(self): |
| 346 | """ |
| 347 | Test concurrent editing when a user deletes the last inline in a list; |
| 348 | second user is also deleting the same inline. |
| 349 | """ |
| 350 | data = { |
| 351 | 'description': [u'A new description #3'], |
| 352 | 'thingitem_set-MAX_NUM_FORMS': [u'0'], |
| 353 | 'thingitem_set-TOTAL_FORMS': [u'2'], |
| 354 | 'thingitem_set-INITIAL_FORMS': [u'2'], |
| 355 | 'thingitem_set-0-DELETE': [u''], |
| 356 | 'thingitem_set-0-id': [u'%s' % (self.thing_item_0.pk,)], |
| 357 | 'thingitem_set-0-thing': [u'%s' % (self.thing.pk,)], |
| 358 | 'thingitem_set-0-description': [u'New item #0 description'], |
| 359 | 'thingitem_set-1-DELETE': [u'on'], |
| 360 | 'thingitem_set-1-id': [u'%s' % (self.thing_item_1.pk,)], |
| 361 | 'thingitem_set-1-thing': [u'%s' % (self.thing.pk,)], |
| 362 | 'thingitem_set-1-description': [u'Deleted item #1 description'], |
| 363 | } |
| 364 | self._do_test_concurrent_editing_views(deleted_idx=1, data=data) |
| 365 | self.assertEqual(self.thing.thingitem_set.count(), 1) |
| 366 | |
| 367 | def test_concurrent_editing_last_add_another(self): |
| 368 | """ |
| 369 | Test concurrent editing when a user deletes the last inline in a list; |
| 370 | second user is adding another inline on the same change_view. |
| 371 | """ |
| 372 | data = { |
| 373 | 'description': [u'A new description #3'], |
| 374 | 'thingitem_set-MAX_NUM_FORMS': [u'0'], |
| 375 | 'thingitem_set-TOTAL_FORMS': [u'2'], |
| 376 | 'thingitem_set-INITIAL_FORMS': [u'2'], |
| 377 | 'thingitem_set-0-DELETE': [u'on'], |
| 378 | 'thingitem_set-0-id': [u'%s' % (self.thing_item_0.pk,)], |
| 379 | 'thingitem_set-0-thing': [u'%s' % (self.thing.pk,)], |
| 380 | 'thingitem_set-0-description': [u'New item #0 description'], |
| 381 | 'thingitem_set-1-DELETE': [u'on'], |
| 382 | 'thingitem_set-1-id': [u'%s' % (self.thing_item_1.pk,)], |
| 383 | 'thingitem_set-1-thing': [u'%s' % (self.thing.pk,)], |
| 384 | 'thingitem_set-1-description': [u'Deleted item #1 description'], |
| 385 | } |
| 386 | self._do_test_concurrent_editing_views(deleted_idx=1, data=data) |
| 387 | self.assertEqual(self.thing.thingitem_set.count(), 0) |
| 388 | |
| 389 | def test_concurrent_editing_last_delete_different(self): |
| 390 | """ |
| 391 | Test concurrent editing when a user deletes the last inline in a list; |
| 392 | second user is also deleting another inline on the same change_view. |
| 393 | """ |
| 394 | data = { |
| 395 | 'description': [u'A new description #3'], |
| 396 | 'thingitem_set-MAX_NUM_FORMS': [u'0'], |
| 397 | 'thingitem_set-TOTAL_FORMS': [u'3'], |
| 398 | 'thingitem_set-INITIAL_FORMS': [u'2'], |
| 399 | 'thingitem_set-0-DELETE': [u''], |
| 400 | 'thingitem_set-0-id': [u'%s' % (self.thing_item_0.pk,)], |
| 401 | 'thingitem_set-0-thing': [u'%s' % (self.thing.pk,)], |
| 402 | 'thingitem_set-0-description': [u'New item #0 description'], |
| 403 | 'thingitem_set-1-DELETE': [u''], |
| 404 | 'thingitem_set-1-id': [u'%s' % (self.thing_item_1.pk,)], |
| 405 | 'thingitem_set-1-thing': [u'%s' % (self.thing.pk,)], |
| 406 | 'thingitem_set-1-description': [u'Deleted item #1 description'], |
| 407 | 'thingitem_set-2-DELETE': [u''], |
| 408 | 'thingitem_set-2-id': [u''], |
| 409 | 'thingitem_set-2-thing': [u'%s' % (self.thing.pk,)], |
| 410 | 'thingitem_set-2-description': [u'New item #2 description'], |
| 411 | } |
| 412 | self._do_test_concurrent_editing_views(deleted_idx=1, data=data) |
| 413 | self.assertEqual(self.thing.thingitem_set.count(), 2) |
| 414 | |
| 415 | def _do_test_concurrent_editing_views(self, deleted_idx=0, data=None): |
| 416 | thingitem_set_0_DELETE = u'' |
| 417 | thingitem_set_1_DELETE = u'' |
| 418 | |
| 419 | if deleted_idx == 0: |
| 420 | thingitem_set_0_DELETE = u'on' |
| 421 | elif deleted_idx == 1: |
| 422 | thingitem_set_1_DELETE = u'on' |
| 423 | |
| 424 | first_form_data = { |
| 425 | 'description': [u'A new description'], |
| 426 | 'thingitem_set-MAX_NUM_FORMS': [u'0'], |
| 427 | 'thingitem_set-TOTAL_FORMS': [u'2'], |
| 428 | 'thingitem_set-INITIAL_FORMS': [u'2'], |
| 429 | 'thingitem_set-0-DELETE': [thingitem_set_0_DELETE], |
| 430 | 'thingitem_set-0-id': [u'%s' % (self.thing_item_0.pk,)], |
| 431 | 'thingitem_set-0-thing': [u'%s' % (self.thing.pk,)], |
| 432 | 'thingitem_set-0-description': [u'New item #0 description'], |
| 433 | 'thingitem_set-1-DELETE': [thingitem_set_1_DELETE], |
| 434 | 'thingitem_set-1-id': [u'%s' % (self.thing_item_1.pk,)], |
| 435 | 'thingitem_set-1-thing': [u'%s' % (self.thing.pk,)], |
| 436 | 'thingitem_set-1-description': [u'Deleted item #1 description'], |
| 437 | } |
| 438 | edit_url = 'admin:%s_%s_change' %(self.thing._meta.app_label, self.thing._meta.module_name) |
| 439 | view_url = 'admin:%s_%s_changelist' %(self.thing._meta.app_label, self.thing._meta.module_name) |
| 440 | |
| 441 | # Sanity check |
| 442 | self.assertEqual(self.thing.thingitem_set.count(), 2) |
| 443 | |
| 444 | # Submit first form |
| 445 | response = self.client.post(reverse(edit_url, args=[self.thing.pk]), first_form_data) |
| 446 | |
| 447 | # Check our first edit was accepted |
| 448 | self.thing = Thing.objects.get(pk=self.thing.pk) |
| 449 | self.assertRedirects(response, reverse(view_url), 302, 200) |
| 450 | self.assertEqual(self.thing.thingitem_set.count(), 1) |
| 451 | self.assertEqual(self.thing.description, first_form_data['description'][0]) |
| 452 | |
| 453 | # Submit the second form (this represents either the same user submitting |
| 454 | # another tab that he had loaded before, or a second user). |
| 455 | response = self.client.post(reverse(edit_url, args=[self.thing.pk]), data) |
| 456 | |
| 457 | self.assertContains(response, 'Please correct the error below.') |
| 458 | |
| 459 | total_forms = int(data['thingitem_set-TOTAL_FORMS'][0]) - 1 |
| 460 | initial_forms = int(data['thingitem_set-INITIAL_FORMS'][0]) - 1 |
| 461 | |
| 462 | invalid_form_data = { |
| 463 | 'description': data['description'], |
| 464 | 'thingitem_set-MAX_NUM_FORMS': [u'0'], |
| 465 | 'thingitem_set-TOTAL_FORMS': [str(total_forms)], |
| 466 | 'thingitem_set-INITIAL_FORMS': [str(initial_forms)], |
| 467 | } |
| 468 | for key, value in data.iteritems(): |
| 469 | current_form_id = key.split('-') |
| 470 | if len(current_form_id) < 3: |
| 471 | continue |
| 472 | current_form_id = int(current_form_id[1]) |
| 473 | if deleted_idx == 0 and '-0-' in key: |
| 474 | continue |
| 475 | if deleted_idx == 1 and '-1-' in key: |
| 476 | continue |
| 477 | new_form_id = current_form_id |
| 478 | if current_form_id > 0: |
| 479 | new_form_id -= 1 |
| 480 | new_key = key.replace(str(current_form_id), str(new_form_id)) |
| 481 | invalid_form_data[new_key] = data[key] |
| 482 | |
| 483 | if deleted_idx == 0: |
| 484 | preserved_item_id = self.thing_item_1.pk |
| 485 | deleted_item_id = self.thing_item_0.pk |
| 486 | elif deleted_idx == 1: |
| 487 | preserved_item_id = self.thing_item_0.pk |
| 488 | deleted_item_id = self.thing_item_1.pk |
| 489 | |
| 490 | self.assertContains(response, 'name="thingitem_set-TOTAL_FORMS" value="%s"' % total_forms) |
| 491 | self.assertContains(response, 'name="thingitem_set-INITIAL_FORMS" value="%s"' % initial_forms) |
| 492 | self.assertContains(response, '<ul class="errorlist"><li>Some object on this formset was already deleted by another transaction. Please re-submit the form.</li>') |
| 493 | |
| 494 | if initial_forms > 0: |
| 495 | self.assertContains(response, 'name="thingitem_set-0-id" value="%s" id="id_thingitem_set-0-id"' % preserved_item_id) |
| 496 | self.assertNotContains(response, '-id" value="%s" id="id_thingitem_set' % deleted_item_id) |
| 497 | self.assertNotContains(response, 'name="thingitem_set-%s-id"' % str(total_forms + 1)) |
| 498 | |
| 499 | response = self.client.post(reverse(edit_url, args=[self.thing.pk]), invalid_form_data) |
| 500 | |
| 501 | # Check our second edit was handled |
| 502 | self.thing = Thing.objects.get(pk=self.thing.pk) |
| 503 | self.assertRedirects(response, reverse(view_url), 302, 200) |
| 504 | self.assertEqual(self.thing.description, data['description'][0]) |
| 505 | |
| 506 | |