Validate Django model data by calculating the sum of multiple rows - django

I have two models:
class User(models.Model):
name = models.CharField(max_length=32)
class Referral(models.Model):
referring_user = models.ForeignKey(User, related_name="referrals")
referred_user = models.ForeignKey(User, related_name="referrers")
percentage = models.PositiveIntegerField()
The idea is that every user has n referrers, and should have at least one. Each referrer has a percentage value which should add up to 100% when added to the other referrers.
So User "Alice" might have referrers "Bob" (50%) and "Cynthia" (50%), and User "Donald" might have one referrer: "Erin" (100%).
The problem I have is with validation. Is there a way (preferably one that plays nice with the Django admin using admin.TabularInline) that I can have validation reject the saving of a User if the sum of Refferrals != 100%?
Ideally I want this to happen at the form/admin level and not by overriding User.save(), but at this point I don't know where to start. Most of Django's validation code appears to be atomic, and validation across multiple rows is not something I've done in Django before.

After Jerry Meng suggested I look into the data property and not cleaned_data, I started poking around admin.ModelAdmin to see how I might access that method. I found get_form which appears to return a form class, so I overrode that method to capture the returning class, subclass it, and override .clean() in there.
Once inside, I looped over self.data, using a regex to find the relevant fields and then literally did the math.
import re
from django import forms
from django.contrib import admin
class UserAdmin(admin.ModelAdmin):
# ...
def get_form(self, request, obj=None, **kwargs):
parent = admin.ModelAdmin.get_form(self, request, obj=None, **kwargs)
class PercentageSummingForm(parent):
def clean(self):
cleaned_data = parent.clean(self)
total_percentage = 0
regex = re.compile(r"^referrers-(\d+)-percentage$")
for k, v in self.data.items():
match = re.match(regex, k)
if match:
try:
total_percentage += int(v)
except ValueError:
raise forms.ValidationError(
"Percentage values must be integers"
)
if not total_percentage == 100:
raise forms.ValidationError(
"Percentage values must add up to 100"
)
return cleaned_data
return PercentageSummingForm

As per the Django docs, clean() is the official function to implement for your purposes. You could imagine a function that looks like this:
from django.core.exceptions import ValidationError
def clean(self):
total_percentage = 0
for referrer in self.referrers.all():
total_percentage += referrer.percentage
if total_percentage !== 100:
raise ValidationError("Referrer percentage does not equal 100")

Related

Django - Admin forms data generation with fields which are not in model

I have a specific scenario and I am unable to figure out how to approach this problem, some direction will be great help:
I have a model:
class RollNumber(models.Model):
r_no_prefix = models.CharField(max_length=10, verbose_name='Roll number
suffix')
r_no= models.IntegerField(verbose_name='Number')
r_no_suffix = models.CharField(max_length=10, verbose_name='Roll number
prefix')
def __unicode__(self):
return '%s -%s-%s' % (self.r_no_prefix,self.r_no,self.r_no_suffix)
No, I want to generate these Roll numbers in bulk by asking the user to input the following in a form which is not having any of the above model fields.
Number of roll numbers you want to generate: ____________
Roll number prefix: ________________
Roll number suffix: ________________
[SUBMIT][CANCEL]
The submission of above form should be able to generate the number of rollnumbers and create records in RollNumber table in bulk.
If I try to use this form again, if should get the last number and then start the sequence from there. Considering the that user may have deleted some of the roll number records.
Don't use a model form, use a simple form and create the objects in a loop. Something like this:
from django import forms
from models import RollNumber
class RollForm(forms.Form):
times_to_roll = forms.IntegerField()
prefix = forms.IntegerField()
suffix = forms.IntegerField()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
rolls = RollNumber.objects.all()
if not rolls.exists():
return
last_roll = rolls.order_by('pk')[-1]
self.fields['prefix'].initial = last_roll.prefix
self.fields['suffix'].initial = last_roll.suffix
def is_valid(self, *args, **kwargs):
if not super(RollForm, self).is_valid(*args, **kwargs):
return False
for x in range(self.cleaned_data.times_to_roll):
RollNumber.objects.create(...)
return True

Django: limit models.ForeignKey results

I have an order model:
class Order(models.Model):
profile = models.ForeignKey(Profile, null=True, blank=True)
Which returns all possible profiles for an order, which is not necessary and slowing down the loading of the admin order page.
I want the profile returned to simply be the profile of the user who placed the order. I've tried changing it to:
class Order(models.Model):
profile = models.ForeignKey(Profile, null=True, blank=True, limit_choices_to={'order': 99999})
which returns the correct profile for order number 99999, but how can I get this dynamically. The Order model is not aware of the 'self', but the order number is contained in the URL.
What is the best way to do this?
If you are using the Django Admin, you can override the method formfield_for_foreignkey on your ModelAdmin class to modify the queryset for the profile field dinamically based on a GET parameter for instance (as you have access to request inside the method.
Look at this example from the docs (adapted for your case):
class MyModelAdmin(admin.ModelAdmin):
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "profile":
# You can get the order number from the GET parameter:
# order = request.GET.get('order')
# or a fixed number:
order = '12345'
kwargs["queryset"] = Profile.objects.filter(order=order)
return super(MyModelAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)
Reference in the docs: https://docs.djangoproject.com/en/1.9/ref/contrib/admin/#django.contrib.admin.ModelAdmin.formfield_for_foreignkey
I took another look at this and it seems to be working, although it seems like a bit of hack! The problem was that the order number doesn't seem to exist in the request so I am parsing the URL requested. I put this in my order admin:
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "profile":
try:
order = int(filter(str.isdigit, str(request.path_info)))
except:
order = request.GET.get('order')
kwargs["queryset"] = Profile.objects.filter(order=order)
return super(OrderAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)
The line that filters the url string for an integer works for the change order page of the admin, but didn't work for the order page overview, so I added the try/except. Any suggestions/improvements welcome!
I assume from the context you're referring to the display on the Django Admin page. If you set
raw_id_fields = {'my_foreign_key'}
you will get a number, text description of related model (from str) and a nice popup box to open an instance of the admin page for your related models.
You could alternatively use
list_select_related = True
to get the same behaivour you have now but with a couple of order of magnitudes lower number of queries.

Setting Age Restrictions with django-user-accounts

I want to set an age restriction on the account Sign up Process implemented by django-user-accounts. I have added a field to the SignupForm like the example in the docs. In my customized view I have the following:
import user_accounts_custom.forms
from profiles.models import ArtistProfile, UserProfile
from datetime import date
import math
class SignupView(SignupView):
form_class = user_accounts_custom.forms.SignupForm
def create_user(self, form, commit=True, **kwargs):
old_enough = self.birthday_check(form)
if old_enough:
return super(SignupView, self).create_user(self, form,
commit=True, **kwargs)
else:
return super(SignupView, self).create_user(self, form,
commit=False, **kwargs)
def birthday_check(self, form):
birthdate = form.cleaned_data["birthdate"]
fraud_detect = abs(date.today() - birthdate)
if ( (fraud_detect.days / 365.0) < 13 ):
# WHAT ABOUT THE BABIES!!!!
return False
else:
return True
Setting commit to False is giving me a type error further in the create_user method on the SignupView instance because it attempts to return a user object but, like I wanted, it didn't create one. I want to send an HttpResponseForbidden object or a message but I'm not sure how to implement it here given the context. The other option I am considering is using a dummy user object (specifically my Anonymous User object) and simply redirecting without creating an account; I'm not sure which path is simplest.
This answer helped me solve the problem, here is how I implemented it:
def clean(self):
cleaned_data = super(SignupForm, self).clean()
bday = self.cleaned_data["birthdate"]
fraud_detect = abs(date.today() - bday)
if ( (fraud_detect.days / 365.0) < 13 ):
# WHAT ABOUT THE BABIES!!!!
raise forms.ValidationError("Sorry, you cannot create an account.",
code="too_young",
)
return cleaned_data
The trick was to intercept the clean() method in the forms.py I created to customize django-user-accounts.
Some additional links to help with validation (NOTE: these links go to django version 1.6):
Form and Field
Validation
Validators

Model Admin Search - Override the search string

I have a phone number field in a ModelForm which users can search for in admin. The problem is that they are lazy and don't want to enter in the dashes in the phone numbers.
If I search for '555-555-5555' all the objects with that phone number will return
If I search '5555555555', I get zero results.
Is there anyway to override or just alter the search string that gets submitted? If so I planned on doing something like
if search_string.isdigit() and len(search_string) == 10:
search_string = '-'.join(
(search_string[:3],search_string[3:6],search_string[6:])
)
I see in Django 1.6 there is a get_search_results method that might be useful but I'm running on 1.4
Was able to achieve this by overriding get_changelist within my ModelAdmin. Found a useful blog post that led me to the answer: Override ModelAdmin ChangeList
def get_changelist(self, request, **kwargs):
# Allow users to not have to enter in '-' when searching by phone #
from django.contrib.admin.views.main import ChangeList
class NewChangeList(ChangeList):
def get_query_set(self, *args, **kwargs):
query = self.query
if query.isdigit() and len(query) == 10:
self.query = '-'.join((query[:3], query[3:6], query[6:]))
return super(NewChangeList, self).get_query_set(*args, **kwargs)
return NewChangeList

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]