Sunday, January 15, 2023

Re: Admin validation activated just once after final call to save_model()

I had a similar problem that I've worked through. I recommend solutions
at three levels:

1. The UI - Make it more intuitive for the race admins to rank the results. Rather than give them an integer input field, use something like django-admin-sortable2[b] to allow the race admin to drag-n-drop to reorder the results.
This ensures that the user can't accidentally try to set two results as
first place, for example.

2. The change list page ModelFormSet validation - The Django admin change list page uses a ModelFormSet for list_editable. You can override the formset[a] and implement the `clean` method[c] to validate that the results are in strict order from 1 to N. This ensures that *if* the user violates the ordering rules, the results won't get saved and the page will display a nice error.

3. The database via transaction - Django's database transaction support allows automatic rollback if an exception is raised via `atomic`[d]. Override the change list page's ModelFormSet save method and wrap the individual form.save calls in a `with transaction.atomic()`. After the saves are done, check to make sure the results are in a strict order from 1 to N and raise an exception otherwise to rollback the changes.

You can choose to use any or all of the methods above together.

[a] https://docs.djangoproject.com/en/4.1/ref/contrib/admin/#django.contrib.admin.ModelAdmin.get_changelist_formset
[b] https://django-admin-sortable2.readthedocs.io/en/latest/
[c] https://docs.djangoproject.com/en/4.1/topics/forms/formsets/#custom-formset-validation
[d] https://docs.djangoproject.com/en/4.1/topics/db/transactions/#controlling-transactions-explicitly

On Sun, Jan 15, 2023 at 07:51:40AM -0800, David Wallace wrote:
> I am attempting to modify a page on my Django admin so that a trusted race
> administrator can manually rearrange a leader board of race results. The
> results go from 1 to N competitors in a race, and are mutually exclusive.
> Draw results are not allowed, so race administrators apply often complex
> and arcane tie breaking rules to avoid draws, which is the reason why they
> require the facility to manually re-arrange results.
>
> I have a model and validators here:
> ```
> def number_of_ranks(value):
> number_of_results = Rank.objects.all().count()
> if value > number_of_results:
> raise ValidationError(
> _('%(value)s is bigger than number of results which is
> %(number_of_results)s'),
> params={'value': value, 'number_of_results': number_of_results},
> )
>
>
> class Rank(models.Model):
>
> result = models.PositiveIntegerField(validators=[
> MinValueValidator(1),
> number_of_ranks]) # 1st, 2nd, 3rd = 1,2,3 etc - may be
> assigned by administrator
> team = models.ForeignKey( # team that achieved this
> result
> 'team',
> related_name='team_in_rank',
> on_delete=models.PROTECT
> )
> ```
> and an admin page for Rank here:
> ```
> @admin.register(Rank)
>
> class RankAdmin(admin.ModelAdmin):
> list_display = ('result', 'team',)
> sortable_by = ('')
> ordering = ('result',)
> list_editable = ('result',)
> list_display_links = None
>
> def has_add_permission(self, request):
> return False
>
> def has_delete_permission(self, request):
> return False
>
> def save_model(self, request, obj, form, change):
> print('-------------save_model called------------')
> print(request)
> print('================================')
> print('All objects and results BEFORE change')
> print([(rank, rank.result) for rank in Rank.objects.all()])
> print('================================')
> print('Changed object and changed result')
> print(obj, obj.result)
> print('================================')
> super().save_model(request, obj, form, change)
> print('All objects and results AFTER change')
> print([(rank, rank.result) for rank in Rank.objects.all()])
> ```
> This achieves some of what I want. That is
>
> 1. The admin can neither remove or add teams in the race, nor change the
> names of any of the teams, only results.
> 2. Any result must be in the range 1 to N competitors.
> 3. The display of the teams is arranged in order of result. 1 on the top
> row and N on the Nth row.
>
> The over-ride of save_model() achieves nothing functional but is merely an
> effort to discover something using print() calls about the validation and
> save sequence. It seems to indicate to me that there is a separate and
> individual call made to save_model() for each and every record that is
> changed.
>
> So that if I have four teams initially
> 1 Red, 2 Blue, 3 Yellow, 4 Green
> and the admin wants to change this to
> 1 Blue, 2 Red, 3 Yellow, 4 Green
> then that triggers two separate calls to save_model(): eg
> 1 Red, 2 Blue, 3 Yellow, 4 Green -> 1 Red, 1 Blue, 3 Yellow, 4 Green
> and
> 1 Red, 1 Blue, 3 Yellow, 4 Green -> 1 Blue, 2 Red, 3 Yellow, 4 Green
> (the actual stages can vary - the reds could change first)
>
> What I want to be able to do is perform a final validation only after the
> final record is changed so that I can check that every record has a unique
> result in the range 1..N - i.e. there are no duplicated results. But in the
> intermediate call(s) to save_model() there will inevitably be at least one
> duplicated result - just one if the admin exchanges two results, but
> potentially more if he attempts a more complicated reshuffle.
>
> So I want to hook into / over-ride a procedure which is called just once
> when triggered by pressing the Save button, after all the separate
> save_model() calls are complete. I haven't been able to discover from the
> docs how to do this, or even if it can be done. Possibly my approach to
> this is wrong, either entirely or in part?
>
> Any advice would be helpful.
> Thanks
>
>
> --
> You received this message because you are subscribed to the Google Groups "Django users" group.
> To unsubscribe from this group and stop receiving emails from it, send an email to django-users+unsubscribe@googlegroups.com.
> To view this discussion on the web visit https://groups.google.com/d/msgid/django-users/399790a5-af0e-479b-a0fd-acf6ca3bfd59n%40googlegroups.com.

--
You received this message because you are subscribed to the Google Groups "Django users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to django-users+unsubscribe@googlegroups.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/django-users/20230115204936.GA22965%40fattuba.com.

No comments:

Post a Comment