Wagtail: How to validate m2m inline model? - django

I have a custom form inheriting from WagtailAdminPageForm and I want to validate an m2m model field (ClusterableModel). Right now I am using the clean() method on my form class.
def clean(self):
cleaned_data = super().clean()
my_field_total_sum = 0
for form in self.formsets['my_field'].forms:
if form.is_valid():
cleaned_form_data = form.clean()
my_field_total_sum += cleaned_form_data.get('my_value')
if total_sum > 100:
form.add_error('my_value', 'More then 100 is not allowed')
return cleaned_data
This works fine until I add and/or remove some inline panels in the admin interface and save my page as a draft and try to validate again. Because self.formsets['my_field'].forms still contains already removed forms and never gets reset to the actual amount of inline panels in the admin.
So, is it possible to (re-)set self.formsets['my_field'].forms to the actual amount inline panels visible in the admin interface? Or should I validate elsewhere anyways?

After more digging I found the answer. Checking for the "DELETE" key in a forms cleaned_data dict is the key. So my clean method from above needs to look like this:
def clean(self):
cleaned_data = super().clean()
my_field_total_sum = 0
for form in self.formsets['my_field'].forms:
# simply check, if "DELETE" is not true
if form.is_valid() and not form.cleaned_data.get("DELETE"):
cleaned_form_data = form.clean()
my_field_total_sum += cleaned_form_data.get('my_value')
# this check needs to be outside the loop ;-)
if my_field_total_sum > 100:
form.add_error('my_value', 'More then 100 is not allowed')
return cleaned_data

Related

I am confused what form_valid() or form_invalid() accomplished

I just started to use class based views instead of functions so sorry if this is an obvious question but what does form_valid() and form_invalid() do in a class that inherits from FormView? Django says that:
"This method is called when valid form data has been POSTed. It should
return an HttpResponse"
But what I don't get is how is it validated? I have additional checks I need to perform on the form before I can save the data (such as when signing up, making sure the two passwords entered are the same). How would I be able to add more checks if I want to?
Here's the code I've been working with
forms.py
class SignupForm(forms.Form):
email = forms.EmailField()
alias = forms.CharField(max_length=15)
password = forms.CharField(max_length=128, widget=forms.PasswordInput)
confirm_pass = forms.CharField(max_length=128, widget=forms.PasswordInput)
# used to verify if the passwords match
def pwd_match(self):
if self.is_valid():
cd = self.cleaned_data
if cd.get('password') == cd.get('confirm_pass'):
return True
else:
return False
def fields_correct(self):
if self.pwd_match() and self.is_valid():
return True
return False
views.py
class SignupFormView(AjaxFormMixin, FormView):
form_class = SignupForm
template_name = 'forum/signup.html'
success_url = reverse_lazy('home')
def form_valid(self, form):
# here I want to make sure that the passwords match and then save the user
Thanks ahead of time!
You have two options here,
1- you can add a clean method to your form for the extra validation you need to do. So in your forms, you will have something like,
def clean(self):
cleaned_data = super().clean()
# do some validation on your fields
...
2- you can add the extra logic in your is_valid function in the view and return the user to the same page with an error message. A little messy but in some cases, you need to hack around.
Hope this helps!
This is not what form_valid does. That method is called once the form has been successfully validated.
Whether you use a function-based or a class-based view, this kind of thing belongs in the form. You should remove your pwd_match and fields_correct method, and instead define a single clean method:
def pwd_match(self):
cd = self.cleaned_data
if cd.get('password') != cd.get('confirm_pass'):
raise forms.ValidationError('passwords do not match')
return cd

Django admin inline calculated field won't save

One of the fields in my inline needs to be calculated. I overrode the BaseInLineFormSet clean method and can do the calculation there and apparently set the field's value there but it doesn't get saved to the DB, and consequently is also not displayed. The field defaults to zero, btw, in case that matters. Here's a hard-coded version:
class EmployeeAssignmentInLineFormSet(BaseInlineFormSet):
def clean(self):
super(EmployeeAssignmentInLineFormSet, self).clean()
self.cleaned_data[0]['cost'] = 5000
I also tried overriding save_formset in the ModelAdmin - same result:
def save_formset(self, request, form, formset, change):
formset.cleaned_data[0]['cost'] = 5000
formset.save()
When I set the value just in clean I can see it's been set when it gets to save_formset, but it still ends up being zero in the DB. Am I in the wrong place or what?
I don't think a ModelFormSet has a cleaned_data attribute like that
https://github.com/django/django/blob/master/django/forms/models.py#L623
I'm not sure why you're not getting an exception from your code above in that case, but it looks to me like you should try instead:
def clean(self):
super(EmployeeAssignmentInLineFormSet, self).clean()
self.forms[0].cleaned_data['cost'] = 5000
Doesn't seem to be much interest in this but for the sake of completeness, this is what I ended up doing. All my gazillion attempts at trying to save the field when the record is saved failed. Simply putting something into cleaned_data doesn't work. I tried overriding clean, save_model, save_related, save_formset - no joy. So now I'm in the Inline options:
readonly_fields = ('get_cost', )
fields = ('project', 'start_date', 'end_date', 'effort', 'role', 'get_cost')
def get_cost(self, obj):
if obj.effort and obj.cost == 0.0:
obj.cost = obj.employee._calculate_cost(obj.effort)
obj.save()
return obj.cost
get_cost.short_description = 'Cost'
Need the conditional of course to avoid saving the cost every time the inline formset is loaded. Seems odd to save the field after the rest of the record has been saved, but it works.

Django modelform remove "required" attribute based on other field choice

I have ModelForm with several fields. Some of fields are required, some not. Also I have Select field with different choices, and I want to make some of fields "required" or not based on this Select field choice.
I tried in clean() method of Form
def clean(self):
cleaned_data = self.cleaned_data
some_field = cleaned_data.get("some_field")
if some_field == 'some_value':
self.fields['other_field'].required = False
return cleaned_data
but it doesn't work
See the Django documentation on Cleaning and validating fields that depend on each other. The standard practice would be to perform the following handling instead:
def clean(self):
cleaned_data = self.cleaned_data
some_field = cleaned_data.get("some_field")
if some_field == 'some_value':
# 'other_field' is conditionally required.
if not cleaned_data['other_field']:
raise forms.ValidationError("'Other_field' is required.")
return cleaned_data
You have the right idea but the problem is that the individual field validations have already run before the form clean. You have a couple options. You could make the field not required and handle the logic of when it is required in your form.clean. Or you could leave the field as required and remove the validation errors it might raise in the clean.
def clean(self):
cleaned_data = self.cleaned_data
some_field = cleaned_data.get("some_field")
if some_field == 'some_value':
if 'other_field' in self.errors:
del self.errors['other_field']
cleaned_data['other_field'] = None
return cleaned_data
This has some problems in that it removes all errors, not just missing/required errors. There is also a problem with the cleaned_data. You now have a required field which isn't in the cleaned_data which is why I've added it as None. The rest of your application will have to handle this case. It might seem odd to have a required field which doesn't have a value.
If you like to print error message for required field in common way, you can do this:
def clean(self):
cleaned_data = super(PasswordChangeForm, self).clean()
token = cleaned_data.get('token')
old_password = cleaned_data.get('old_password')
if not token and not old_password:
self._errors['old_password'] = self.error_class([self.fields['old_password'].error_messages['required']])
I found a possible solution.
You can use your condition before form.is_valid() and include your change in your field.
if form.data['tid_id'] is None: # my condition
form.fields['prs_razonsocial'].required = True
form.fields['prs_nombres'].required = False
form.fields['prs_apellidos'].required = False
if form.is_valid():
...

Validating delete on django-admin inline forms

I am trying to perform a validation such that you cannot delete a user if he's an admin. I'd therefore like to check and raise an error if there's a user who's an admin and has been marked for deletion.
This is my inline ModelForm
class UserGroupsForm(forms.ModelForm):
class Meta:
model = UserGroups
def clean(self):
delete_checked = self.fields['DELETE'].widget.value_from_datadict(
self.data, self.files, self.add_prefix('DELETE'))
if bool(delete_checked):
#if user is admin of group x
raise forms.ValidationError('You cannot delete a user that is the group administrator')
return self.cleaned_data
The if bool(delete_checked): condition returns true and stuff inside the if block gets executed but for some reason this validation error is never raised. Could someone please explain to me why?
Better yet if there's another better way to do this please let me know
The solution I found was to clean in the InlineFormSet instead of the ModelForm
class UserGroupsInlineFormset(forms.models.BaseInlineFormSet):
def clean(self):
delete_checked = False
for form in self.forms:
try:
if form.cleaned_data:
if form.cleaned_data['DELETE']:
delete_checked = True
except AttributeError:
pass
if delete_checked:
raise forms.ValidationError(u'You cannot delete a user that is the group administrator')
Although #domino's answer may work for now, the "kinda" recommended approach is to use formset's self._should_delete_form(form) function together with self.can_delete.
There's also the issue of calling super().clean() to perform standard builtin validation. So the final code may look like:
class UserGroupsInlineFormset(forms.models.BaseInlineFormSet):
def clean(self):
super().clean()
if any(self.errors):
return # Don't bother validating the formset unless each form is valid on its own
for form in self.forms:
if self.can_delete and self._should_delete_form(form):
if <...form.instance.is_admin...>:
raise ValidationError('...')
Adding to domino's Answer:
In some other scenarios, Sometimes user wants to delete and add object in the same time, so in this case delete should be fine!
Optimized version of code:
class RequiredImageInlineFormset(forms.models.BaseInlineFormSet):
""" Makes inline fields required """
def clean(self):
# get forms that actually have valid data
count = 0
delete_checked = 0
for form in self.forms:
try:
if form.cleaned_data:
count += 1
if form.cleaned_data['DELETE']:
delete_checked += 1
if not form.cleaned_data['DELETE']:
delete_checked -= 1
except AttributeError:
# annoyingly, if a subform is invalid Django explicity raises
# an AttributeError for cleaned_data
pass
# Case no images uploaded
if count < 1:
raise forms.ValidationError(
'At least one image is required.')
# Case one image added and another deleted
if delete_checked > 0 and ProductImage.objects.filter(product=self.instance).count() == 1:
raise forms.ValidationError(
"At least one image is required.")

Inline Form Validation in Django

I would like to make an entire inline formset within an admin change form compulsory. So in my current scenario when I hit save on an Invoice form (in Admin) the inline Order form is blank. I'd like to stop people creating invoices with no orders associated.
Anyone know an easy way to do that?
Normal validation like (required=True) on the model field doesn't appear to work in this instance.
The best way to do this is to define a custom formset, with a clean method that validates that at least one invoice order exists.
class InvoiceOrderInlineFormset(forms.models.BaseInlineFormSet):
def clean(self):
# get forms that actually have valid data
count = 0
for form in self.forms:
try:
if form.cleaned_data:
count += 1
except AttributeError:
# annoyingly, if a subform is invalid Django explicity raises
# an AttributeError for cleaned_data
pass
if count < 1:
raise forms.ValidationError('You must have at least one order')
class InvoiceOrderInline(admin.StackedInline):
formset = InvoiceOrderInlineFormset
class InvoiceAdmin(admin.ModelAdmin):
inlines = [InvoiceOrderInline]
Daniel's answer is excellent and it worked for me on one project, but then I realized due to the way Django forms work, if you are using can_delete and check the delete box while saving, it's possible to validate w/o any orders (in this case).
I spent a while trying to figure out how to prevent that from happening. The first situation was easy - don't include the forms that are going to get deleted in the count. The second situation was trickier...if all the delete boxes are checked, then clean wasn't being called.
The code isn't exactly straightforward, unfortunately. The clean method is called from full_clean which is called when the error property is accessed. This property is not accessed when a subform is being deleted, so full_clean is never called. I'm no Django expert, so this might be a terrible way of doing it, but it seems to work.
Here's the modified class:
class InvoiceOrderInlineFormset(forms.models.BaseInlineFormSet):
def is_valid(self):
return super(InvoiceOrderInlineFormset, self).is_valid() and \
not any([bool(e) for e in self.errors])
def clean(self):
# get forms that actually have valid data
count = 0
for form in self.forms:
try:
if form.cleaned_data and not form.cleaned_data.get('DELETE', False):
count += 1
except AttributeError:
# annoyingly, if a subform is invalid Django explicity raises
# an AttributeError for cleaned_data
pass
if count < 1:
raise forms.ValidationError('You must have at least one order')
class MandatoryInlineFormSet(BaseInlineFormSet):
def is_valid(self):
return super(MandatoryInlineFormSet, self).is_valid() and \
not any([bool(e) for e in self.errors])
def clean(self):
# get forms that actually have valid data
count = 0
for form in self.forms:
try:
if form.cleaned_data and not form.cleaned_data.get('DELETE', False):
count += 1
except AttributeError:
# annoyingly, if a subform is invalid Django explicity raises
# an AttributeError for cleaned_data
pass
if count < 1:
raise forms.ValidationError('You must have at least one of these.')
class MandatoryTabularInline(admin.TabularInline):
formset = MandatoryInlineFormSet
class MandatoryStackedInline(admin.StackedInline):
formset = MandatoryInlineFormSet
class CommentInlineFormSet( MandatoryInlineFormSet ):
def clean_rating(self,form):
"""
rating must be 0..5 by .5 increments
"""
rating = float( form.cleaned_data['rating'] )
if rating < 0 or rating > 5:
raise ValidationError("rating must be between 0-5")
if ( rating / 0.5 ) != int( rating / 0.5 ):
raise ValidationError("rating must have .0 or .5 decimal")
def clean( self ):
super(CommentInlineFormSet, self).clean()
for form in self.forms:
self.clean_rating(form)
class CommentInline( MandatoryTabularInline ):
formset = CommentInlineFormSet
model = Comment
extra = 1
#Daniel Roseman solution is fine but i have some modification with some less code to do this same.
class RequiredFormSet(forms.models.BaseInlineFormSet):
def __init__(self, *args, **kwargs):
super(RequiredFormSet, self).__init__(*args, **kwargs)
self.forms[0].empty_permitted = False
class InvoiceOrderInline(admin.StackedInline):
model = InvoiceOrder
formset = RequiredFormSet
class InvoiceAdmin(admin.ModelAdmin):
inlines = [InvoiceOrderInline]
try this it also works :)
The situation became a little bit better but still needs some work around. Django provides validate_min and min_num attributes nowadays, and if min_num is taken from Inline during formset instantiation, validate_min can be only passed as init formset argument. So my solution looks something like this:
class MinValidatedInlineMixIn:
validate_min = True
def get_formset(self, *args, **kwargs):
return super().get_formset(validate_min=self.validate_min, *args, **kwargs)
class InvoiceOrderInline(MinValidatedInlineMixIn, admin.StackedInline):
model = InvoiceOrder
min_num = 1
validate_min = True
class InvoiceAdmin(admin.ModelAdmin):
inlines = [InvoiceOrderInline]