Django admin inline calculated field won't save - django

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.

Related

Django conditional field display on form

I am trying to make a simple form, that conditionally shows the website input field based on the value of another database field (that is not on the form) status. For the sake of this process the status field is not editable by the user, just by the admin. Both fields are in the same table: profile.
After working at this for a while I copped-out and just did the conditional hiding and showing on the template. But, I realise this is the unsophisticated method, and would like to improve it.
What I tried so far in forms.py:
class WebsiteForm(forms.ModelForm):
class Meta:
model = Profile
fields = (
'e-mail',
'website',
)
if Profile.status == 'personal' :
exclude = ('website',)
This method in forms.py works effectively, in that I can conditionally show and hide the field if I use test comparitors in the if statement like:
if 1 == 1:
or
if 1 != 1:
But, I cannot get an effective test using the field Profile.status, the value in the field seems to be unavailable at the point the if test in forms.py is performed.
If I use print(Profile.status) I get the following output in the terminal: user__profile__status__isnull, so I think this means that I am at least testing the correct field in the database. Although I am also noting that this output only shows at initialisation of runserver, not when the form page is accessed.
One final point, the user is authenticated and editing their own record.
Any help very much appreciated.
After a lot of trial and even more error, and some wide-ranging searching, I found the answer via the documentation at https://ccbv.co.uk/.
Essentially the path I decided to take was to use a different form for the respective fields that I wanted to use (I'm sure there are other solutions out there that add or subtract fields from the views). This involved changing the form_class with get_form_class:
# views.py
class telephone_view(UpdateView):
template_name = 'account/telephone.html'
#no need to define "form_class" here
#form_class = TelephoneForm
success_url = '/accounts/telephone/'
def get_form_class(self):
if self.request.user.profile.status == 'managed':
messages.success(self.request, _('you got the managed form'))
return TelephoneFormExtended
else:
messages.success(self.request, _('you got the other form'))
return TelephoneFormLight
def get_object(self, queryset=None):
return Profile.get_or_create_for_user(self.request.user)
def form_valid(self, form):
messages.success(self.request, _('Your telephone setting was updated'))
return super(telephone_view, self).form_valid(form)
#method_decorator(login_required)
def dispatch(self, *args, **kwargs):
return super(telephone_view, self).dispatch(*args, **kwargs)
After working it out for myself I also found this answer which does the same thing:
Updateview with dynamic form_class

Wagtail: How to validate m2m inline model?

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

Django signals - kwargs['update_fields'] is always None on model update via django admin

I have a signal inside my django app where I would like to check if a certain field in my model has been updated, so I can then proceed and do something.
My model looks like this...
class Product(models.Model):
name = models.CharField(max_length=100)
price = models.PositiveIntegerField()
tax_rate = models.PositiveIntegerField()
display_price = models.PositiveInteger()
inputed_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL)
updated_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL)
My signal looks like this...
#receiver(post_save, sender=Product)
def update_model(sender, **kwargs):
instance = kwargs['instance']
if 'tax_rate' in kwargs['update_fields']:
# do something
This returns the error None is not an iterable. I have read the django signal documentation regarding the update_fields and it says The set of fields to update as passed to Model.save(), or None if update_fields wasn’t passed to save().
I should mention that I am working inside django admin here so what I hoped would happen is, I could create an instance of my Product model in django admin and then later if the value of tax_rate or price were updated, I could check for those and update the list_price accordingly. However, kwargs['update_fields'] always returns None.
What am I getting wrong? Or is there some other way I could achieve that result inside django admin?
Updated section
Now, say I introduce a field called inputed_by in my product model, that points to the user model and I want that field populated when the model is first saved. Then another field updated_by that stores the user who last updated the model. At the same time I wish to check whether either or both the tax_rate or price has been updated.
Inside my model admin I have the following method...
def save_model(self, request, obj, form, change):
update_fields = []
if not obj.pk:
obj.inputed_by = request.user
elif change:
obj.updated_by = request.user
if form.initial['tax_rate'] != form.cleaned_data['tax_rate']:
update_fields.append('tax_rate')
if form.initial['price'] != form.cleaned_data['price']:
update_fields.append('price')
obj.save(update_fields=update_fields)
super().save_model(request, obj, form, change)
My signal now looks like this...
#receiver(post_save, sender=Product, dispatch_uid="update_display_price")
def update_display_price(sender, **kwargs):
created = kwargs['created']
instance = kwargs['instance']
updated = kwargs['update_fields']
checklist = ['tax_rate', 'price']
# Prints out the frozenset containing the updated fields and then below that `The update_fields is None`
print(f'The update_fields is {updated}')
if created:
instance.display_price = instance.price+instance.tax_rate
instance.save()
elif set(checklist).issubset(updated):
instance.display_price = instance.price+instance.tax_rate
instance.save()
I get the error 'NoneType' object is not iterable
The error seems to come from the line set(checklist).issubset(updated). I've tried running that line specifically inside the python shell and it yields the desired results. What's wrong this time?
The set of fields should be passed to Model.save() to make them available in update_fields.
Like this
model.save(update_fields=['tax_rate'])
If you are creating something from django admin and getting always None it means that update_fields has not been passed to model's save method. And because of that it will always be None.
If you check ModelAdmin class and save_model method you'll see that call happens without update_fields keyword argument.
It will work if you write your own save_model.
The code below will solve your problem:
class ProductAdmin(admin.ModelAdmin):
...
def save_model(self, request, obj, form, change):
update_fields = []
# True if something changed in model
# Note that change is False at the very first time
if change:
if form.initial['tax_rate'] != form.cleaned_data['tax_rate']:
update_fields.append('tax_rate')
obj.save(update_fields=update_fields)
Now you'll be able to test memberships in update_model.
To add to Davit Tovmasyan's post. I made a more universal version that covers any field change using a for loop:
class ProductAdmin(admin.ModelAdmin):
...
def save_model(self, request, obj, form, change):
update_fields = []
for key, value in form.cleaned_data.items():
# True if something changed in model
if value != form.initial[key]:
update_fields.append(key)
obj.save(update_fields=update_fields)
EDIT: WARNING This isnt actually a full solution. Doesnt seem to work for object creation, only changes. I will try to figure out the full solution soon.
I wanted to add an alternative that relies on the pre_save signal to get the previous version of the instance you're evaluating (from this SO answer):
#receiver(pre_save, sender=Product)
def pre_update_model(sender, **kwargs):
# check if the updated fields exist and if you're not creating a new object
if not kwargs['update_fields'] and kwargs['instance'].id:
# Save it so it can be used in post_save
kwargs['instance'].old = User.objects.get(id=kwargs['instance'].id)
#receiver(post_save, sender=Product)
def update_model(sender, **kwargs):
instance = kwargs['instance']
# Add updated_fields, from old instance, so the method logic remains unchanged
if not kwargs['update_fields'] and hasattr(instance, 'old'):
kwargs['update_fields'] = []
if (kwargs['update_fields'].instance.tax_rate !=
kwargs['update_fields'].instance.old.tax_rate):
kwargs['update_fields'].append('tax_rate')
if 'tax_rate' in kwargs['update_fields']:
comparing to the accepted answer
Disadvantages
Extra query on every save that doesn't have update_fields (if you're not opening Django Admin to the world, this shouldn't be problematic)
Advantages
Don't need to override any method or class
You only need to implement the logic for the fields you want to evaluate, and they are in the same method, so no excuse for mistakes ;)
If you're doing this for many classes, you should probably look at other solutions (but the accepted answer is also not perfect for that!)
You can do this.
def save_model(self, request, obj, form, change):
if change:
obj.save(update_fields=form.changed_data)
else:
super().save_model(request, obj, form, change)

How to reset "DELETE" param to False in django's custom formset's clean method

I made custom formset with custom clean method in it. It's look like:
class MyFormsetBase(forms.models.BaseModelFormSet):
def __init__(self, *args, **kwargs):
....
super(AdvOrderBidFormsetBase, self).__init__(*args, **kwargs)
def clean(self):
....
if error:
raise forms.ValidationError('some validation error')
When I deleting some form(with wrong paramaeters) from formset and press submit button(form has DELETE field value == True) I get error = True in my clean method so I get ValidationError and return to form window. But the DELETE field value in form doesn't reset. And next time when I will submit my form I will get same ValidationError.
So, could I somehow change value of DELETE field in my custom clean method?
Even though this question is quite old, I recently ran into the same problem. I was able to reset the DELETE field by tweaking with the form.data dictionary. Not a pretty solution, but it works:
class MyFormset(BaseModelFormSet):
def clean(self):
for form in self.deleted_forms:
#iterating over only the forms marked for being delete
if <YOUR_CONDITION>:
form.data = form.data.copy() # form.data is immutable unless we copy it
form.data.pop('{prefix}-DELETE'.format(form.prefix)) # Delete the correct 'DELETE' field from the POST data
form.cleaned_data.pop('DELETE') # Also remove the field from cleaned data as the formset checks this field for deletion
What exactly do you return in your clean method?
If the "DELETE field value" attribute (or whatever you mean by this phrase) is still contained in your cleaned data, it will persist.
To remove it, delete it from your cleaned data.
Similar issues are reported here, maybe this helps Clearing Django form fields on form validation error?

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]