Correct way to save nested formsets in Django - python

I have a 3-level Test model I want to present as nested formsets. Each Test has multiple Results, and each Result can have multiple Lines. I am following Yergler's method for creating nested formsets, along with this SO question that updates Yergler's code for more recent Django version (I'm on 1.4)
I am running into trouble because I want to use FormSet's "extra" parameter to include an extra Line in the formset. The ForeignKey for each Line must point to the Result that the Line belongs to, but cannot be changed by the user, so I use a HiddenInput field to contain the Result in each of the FormSet's Lines.
This leads to "missing required field" validation errors because the result field is always filled out (in add_fields), but the text and severity may not (if the user chose not to enter another line). I do not know the correct way to handle this situation. I think that I don't need to include the initial result value in add_fields, and that there must be a better way that actually works.
Update below towards bottom of this question
I will gladly add more detail if necessary.
The code of my custom formset:
LineFormSet = modelformset_factory(
Line,
form=LineForm,
formset=BaseLineFormSet,
extra=1)
class BaseResultFormSet(BaseInlineFormSet):
def __init__(self, *args, **kwargs):
super(BaseResultFormSet, self).__init__(*args, **kwargs)
def is_valid(self):
result = super(BaseResultFormSet, self).is_valid()
for form in self.forms:
if hasattr(form, 'nested'):
for n in form.nested:
n.data = form.data
if form.is_bound:
n.is_bound = True
for nform in n:
nform.data = form.data
if form.is_bound:
nform.is_bound = True
# make sure each nested formset is valid as well
result = result and n.is_valid()
return result
def save_all(self, commit=True):
objects = self.save(commit=False)
if commit:
for o in objects:
o.save()
if not commit:
self.save_m2m()
for form in set(self.initial_forms + self.saved_forms):
for nested in form.nested:
nested.save(commit=commit)
def add_fields(self, form, index):
# Call super's first
super(BaseResultFormSet, self).add_fields(form, index)
try:
instance = self.get_queryset()[index]
pk_value = instance.pk
except IndexError:
instance=None
pk_value = hash(form.prefix)
q = Line.objects.filter(result=pk_value)
form.nested = [
LineFormSet(
queryset = q, #data=self.data, instance = instance, prefix = 'LINES_%s' % pk_value)]
prefix = 'lines-%s' % pk_value,
initial = [
{'result': instance,}
]
)]
Test Model
class Test(models.Model):
id = models.AutoField(primary_key=True, blank=False, null=False)
attempt = models.ForeignKey(Attempt, blank=False, null=False)
alarm = models.ForeignKey(Alarm, blank=False, null=False)
trigger = models.CharField(max_length=64)
tested = models.BooleanField(blank=False, default=True)
Result Model
class Result(models.Model):
id = models.AutoField(primary_key=True)
test = models.ForeignKey(Test)
location = models.CharField(max_length=16, choices=locations)
was_audible = models.CharField('Audible?', max_length=8, choices=audible, default=None, blank=True)
Line Model
class Line(models.Model):
id = models.AutoField(primary_key=True)
result = models.ForeignKey(Result, blank=False, null=False)
text = models.CharField(max_length=64)
severity = models.CharField(max_length=4, choices=severities, default=None)
Update
Last night I added this to my LineForm(ModelForm) class:
def save(self, commit=True):
saved_instance = None
if not(len(self.changed_data) == 1 and 'result' in self.changed_data):
saved_instance = super(LineForm, self).save(commit=commit)
return saved_instance
It ignores the requests to save if only the result (a HiddenInput) is filled out. I haven't run into any problems with this approach yet, but I haven't tried adding new forms.

When I used extra on formsets in similar situation I ended up having to include all the required fields from the model in the form, as HiddenInputs. A bit ugly but it worked, curious if anyone has a hack-around.
edit
I was confused when I wrote above, I'd just been working on formsets using extra with initial to pre-fill the extra forms and also I hadn't fully got all the details of your questions.
If I understand correctly, where you instantiate the LineFormSets in add_fields each of those will point to the same Result instance?
In this case you don't really want to supply result in initial due to the problems you're having. Instead you could remove that field from the LineForm model-form altogether and customise the LineFormSet class something like:
class LineFormSet(forms.BaseModelFormSet):
# whatever other code you have in it already
# ...
# ...
def __init__(self, result, *args, **kwargs):
super(LineFormSet, self).__init__(*args, **kwargs)
self.result = result
def save_new(self, form, commit=True):
instance = form.save(commit=False)
instance.result = self.result
if commit:
instance.save()
return instance
def save_existing(self, form, instance, commit=True):
return self.save_new(form, commit)
(this should be ok in Django 1.3 and 1.4, not sure other versions)
so the relevant part of your add_fields method would look like:
form.nested = [
LineFormSet(
result = instance,
queryset = q, #data=self.data, instance = instance, prefix = 'LINES_%s' % pk_value)]
prefix = 'lines-%s' % pk_value,
)]

Related

Check for unique constraint inside the save method

I have a model on which the unique_together parameter is not working. The reason is that most of the times the "client_repr" variable is set on the save() method.
If someone creates a task with the same ('client_repr', 'task_type'... ) combination the model won't detect it because the "client_repr" value is null until the end of the save() method.
How can i call for a unique constraint verification inside the save() method?
class Task(models.Model):
client = models.ForeignKey(Client, related_name = 'tasks', on_delete = models.CASCADE)
b_client = models.ForeignKey(BClient, related_name = 'tasks', on_delete = models.CASCADE, null = True, blank = True)
client_repr = models.CharField(max_length = 100, null = True, blank = True)
task_type = models.CharField(max_length = 100)
task_description = models.CharField(max_length = 100)
department = models.ForeignKey(Department, related_name = 'tasks', on_delete = models.CASCADE)
extra_fields = models.ManyToManyField(ExtraField, blank = True)
spot = models.BooleanField(default = False)
class Meta:
unique_together = (('client_repr', 'task_type', 'task_description', 'department'), )
def __str__(self):
return ' | '.join([f'{self.client_repr}', f'{self.task_description}', f'{self.task_type}'])
def save(self, *args, **kwargs):
if not self.b_client:
self.client_repr = str(self.client)
else:
self.client_repr = str(self.b_client)
super().save(*args, **kwargs)
I know i could just make a search (ex: if Task.objects.get(...): ) but is it the most django-pythonic way?
Option 1: Using clean()
It's not directly what you asked, but it's generally more django-friendly and does not require weird things like overriding save()
class Task(Model):
def clean():
# your code starts here
if not self.b_client:
self.client_repr = str(self.client)
else:
self.client_repr = str(self.b_client)
# your code ends here
Since this custom clean() is called before django calls validate_unique(), it should fulfill your requirements.
See the details in the official documentation.
Option 2: Continue doing everything in save()
To check unique constraints, you can do the following:
from django.core.exceptions import ValidationError
def save(self, *args, **kwargs):
... # your code that automatically sets some fields
try:
self.validate_unique()
# self.full_clean() # <- alternatively, can use this to validate **everything**, see my comments below
except ValidationError:
# failed
# that's up to you what to do in this case
# you cannot just re-raise the ValidationError because Django doesn't expect ValidationError happening inside of save()
super().save(*args, **kwargs)
Note:
doing only self.validate_unique() does not guarantee, that the updated earlier in save() values are good and don't violate something else
self.full_clean() is safer, but will be slightly slower (how slow - depends on the validators you have)
Documentation
Django documentation says:
There are three steps involved in validating a model:
Validate the model fields - Model.clean_fields()
Validate the model as a whole - Model.clean()
Validate the field uniqueness - Model.validate_unique()
All three steps are performed when you call a model’s full_clean() method.

"... matching query does not exist." error, but object clearly does exist

I have a lesson model:
class Lesson(models.Model):
list_name = models.CharField(max_length=50, primary_key=True)
def __str__(self):
return self.list_name
A sound model:
class Sound(models.Model):
sound_hash = models.CharField(max_length=40, primary_key=True, editable=False)
lessons = models.ManyToManyField(Lesson, verbose_name="associated lessons", related_name="words")
And a test_pair model:
class TestPair(models.Model):
master_sound = models.ForeignKey(Sound, related_name="used_as_master")
user_sound = models.ForeignKey(Sound, related_name="used_as_user")
My form looks something like this:
class SoundTestPairForm(ModelForm):
user_sounds = forms.ModelMultipleChoiceField(Sound.objects.all())
class Meta:
model = TestPair
fields = ['master_sound']
def __init__(self, *args, **kwargs):
super(SoundTestPairForm, self).__init__(*args, **kwargs)
self.fields['master_sound'] = forms.CharField()
self.fields['master_sound'].widget.attrs['readonly'] = 'readonly'
def clean(self):
cleaned_data = super(SoundTestPairForm, self).clean()
if 'user_sounds' not in cleaned_data.keys():
raise forms.ValidationError('You must select at least one sound to be compared')
cleaned_data['master_sound'] = Sound.objects.get(pk=self.fields['master_sound'])
return cleaned_data
This is throwing a DoesNotExist error. The traceback points to this line: cleaned_data['master_sound'] = Sound.objects.get(pk=self.fields['master_sound'])
The local vars are as follows:
self
<SoundTestPairForm bound=True, valid=True, fields=(master_sound;associated_test;associated_lesson;user_sounds)>
cleaned_data
{'associated_lesson': u'pooooooooooooooooooooooop',
'associated_test': u'cats a',
'master_sound': u'ad27ec5e0d048ddbb17d0cef0c7b9d4406a2c33',
'user_sounds': [<Sound: Pants>]}
But when I go to python manage.py shell, and import my model:
Sound.objects.get(pk=u'ad27ec5e0d048ddbb17d0cef0c7b9d4406a2c33')
<Sound: oombah>
It hits the appropriate object.
This is something I've been dealing with for a long time, and it's super frustrating. I try to switch my logic around and ultimately it ends up throwing a DoesNotExist error, regardless of what I try.
Does anyone have any ideas?
I think you should replace:
Sound.objects.get(pk=self.fields['master_sound'])
with:
Sound.objects.get(pk=cleaned_data['master_sound'])
When the Form is valid, cleaned_data will include a key and value for all its fields and you can see that in your question as well (The local vars paragraph).
In general speaking you should do validation in the individual clean_<fieldname> methods. So:
def clean_master_sound(self):
master_sound_key = self.cleaned_data.get('master_sound')
try:
master_sound=Sound.objects.get(pk=master_sound_key)
except Sound.DoesNotExist:
raise forms.ValidationError("Sound with id: %s does not exist", master_sound_key)
return master_sound

Django-powered library checkout system

I am working on a library system to manage certain items in our office, I don't need a full-blown integrated library system so I decided to hand roll one with Django.
Below is a simplified version of my model:
class ItemObjects(models.Model):
# Static Variables
IN_STATUS = 'Available'
OUT_STATUS = 'Checked out'
MISSING = 'Missing'
STATUS_CHOICES = (
(IN_STATUS, 'Available'),
(OUT_STATUS, 'Checked out'),
(MISSING, 'Missing'),
)
# Fields
slug = models.SlugField(unique=True)
date_added = models.DateField(auto_now_add=True)
last_checkin = models.DateTimeField(editable=False, null=True)
last_checkout = models.DateTimeField(editable=False, null=True)
last_activity = models.DateTimeField(editable=False, null=True)
status = models.CharField(choices=STATUS_CHOICES, default=IN_STATUS, max_length=25)
who_has = models.OneToOneField(User, blank=True, null=True)
times_out = models.PositiveIntegerField(default=0, editable=False)
notes = models.CharField(blank=True, max_length=500)
history = models.TextField(blank=True, editable=False)
pending_checkin = models.BooleanField(default=False)
pending_transfer = models.BooleanField(default=False)
At first I was using a method on ItemObject to process checking out an item to a user and who_has was an EmailField because I couldn't get a CharfField to populate with the logged in user's name, but I figured using a OneToOneField is probably closer to the "right" way to do this.. While who_has was an EmailField, the following method worked:
def check_out_itemobject(self, user):
user_profile = user.get_profile()
if self.status == 'Available' and self.who_has == '':
self.status = 'Checked out'
self.who_has = user.email
self.last_checkout = datetime.datetime.now()
self.last_activity = datetime.datetime.now()
self.times_out += 1
if self.history == '':
self.history += "%s" % user_profile.full_name
else:
self.history += ", %s" % user_profile.full_name
if user_profile.history == '':
user_profile.history += self.title
else:
user_profile.history += ", %s" % self.title
else:
return False # Not sure is this is "right"
user_profile.save()
super(ItemObjects, self).save()
Now that I am using a OneToOneField this doesn't work, so I started looking at using a subclass of ModelForm but none of the cases I saw here on SO seemed to apply for what I am trying to do; my form would be a button, and that's it. Here are some of the questions I looked at:
Django: saving multiple modelforms simultaneously (complex case)
(Django) (Foreign Key Issues) model.person_id May not be NULL
django update modelform
So was I on the right track with a sort of altered save() method, or would a ModelForm subclass be the way to go?
EDIT/UPDATE: Many thanks to #ChrisPratt!
So I am trying to get Chris Pratt's suggestion for showing ItemHistory to work, but when I try to render it on a page I get an AttributeError that states "'User' object has no attribute 'timestamp'". So my question is, why is it complaining about a User object when last_activity is an attribute on the ItemObject object ?
My view:
#login_required
def item_detail(request, slug):
item = get_object_or_404(Item, slug=slug)
i_history = item.last_activity
user = request.user
return render_to_response('items/item_detail.html',
{ 'item' : item,
'i_history': i_history,
'user' : user })
I do not see why a User object is coming up at this point.
EDIT2: Nevermind, history is clearly a M2M field whose target is User. That's why!
Assuming users will log in and check out books to themselves, then what you most likely want is a ForeignKey to User. A book will only have one User at any given time, but presumably Users could check out other items as well. If there is some limit, even if the limit is actually one per user, it would be better to validate this in the model's clean method. Something like:
def clean(self):
if self.who_has and self.who_has.itemobject_set.count() >= LIMIT:
raise ValidationError('You have already checked out your maximum amount of items.')
Now, you checkout method has a number of issues. First, status should be a defined set of choices, not just random strings.
class ItemObject(models.Model):
AVAILABLE = 1
CHECKED_OUT = 2
STATUS_CHOICES = (
(AVAILABLE, 'Available'),
(CHECKED_OUT, 'Checked Out'),
)
...
status = models.PositiveIntegerField(choices=STATUS_CHOICES, default=AVAILABLE)
Then, you can run your checks like:
if self.status == self.STATUS_AVAILABLE:
self.status = self.STATUS_CHECKED_OUT
You could use strings and a CharField instead if you like, as well. The key is to decouple the static text from your code, which allows much greater flexibility in your app going forward.
Next, history needs to be a ManyToManyField. Right now, your "history" is only who last checked the item out or what the last item the user checked out was, and as a result is pretty useless.
class ItemObject(models.Model):
...
history = models.ManyToManyField(User, through='ItemHistory', related_name='item_history', blank=True)
class ItemHistory(models.Model):
CHECKED_OUT = 1
RETURNED = 2
ACTIVITY_CHOICES = (
(CHECKED_OUT, 'Checked Out'),
(RETURNED, 'Returned'),
)
item = models.ForeignKey(ItemObject)
user = models.ForeignKey(User)
activity = models.PostiveIntegerField(choices=ACTIVITY_CHOICES)
timestamp = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-timestamp'] # latest first
Which then allows you to get full histories:
some_item.history.all()
some_user.item_history.all()
To add a new history, you would do:
ItemHistory.objects.create(item=some_item, user=some_user, activity=ItemHistory.CHECKED_OUT)
The auto_now_add attribute ensures that the timestamp is automatically set when the relationship is created.
You could then actually get rid of the last_checkout and last_activity fields entirely and use something like the following:
class ItemObject(models.Model):
...
def _last_checkout(self):
try:
return self.history.filter(activity=ItemHistory.CHECKED_OUT)[0].timestamp
except IndexError:
return None
last_checkout = property(_last_checkout)
def _last_activity(self):
try:
return self.history.all()[0].timestamp
except IndexError:
return None
last_activity = property(_last_activity)
And, you can then use them as normal:
some_item.last_checkout
Finally, your checkout method is not an override of save so it's not appropriate to call super(ItemObject, self).save(). Just use self.save() instead.

How to override save() method of modelform class and added missing information?

I just started to learn Django and I had a question.
I'm trying to automatically add the missing information, when saving form data. I get to change/add the desired "cleaned_data" information by overriding save() method of modelform class, but changes are not recorded in the database. Actually, how to write the modified information? This is code:
def save(self, commit = True, *args, **kwargs):
temp = ServiceMethods(url = self.cleaned_data.get('url'), wsdl_url = self.cleaned_data.get('wsdl_url'))
if not temp.get_wsdl_url():
temp.make_wsdl_url()
if temp.get_wsdl_url():
temp.make_wsdl()
self.cleaned_data['wsdl_url'] = temp.get_wsdl_url()
self.cleaned_data['wsdl_description'] = temp.get_wsdl_description()
super(ServiceForm, self).save(commit = commit, *args, **kwargs)
And model:
class Services(models.Model):
name = models.CharField('Имя', max_length=256)
url = models.URLField('Ссылка', unique = True)
wsdl_url = models.URLField('Ссылка на WSDL-документ', blank=True)
description = models.TextField('Описание сервиса',blank=True)
wsdl_description = models.TextField('WSDL описание', blank=True, editable=False)
added = models.DateTimeField('Добавлено', auto_now_add=True)
TIA
Try setting the data on self.instance instead of in self.cleaned_data, and let me know if that works.

Overriding the save method in Django ModelForm

I'm having trouble overriding a ModelForm save method. This is the error I'm receiving:
Exception Type: TypeError
Exception Value: save() got an unexpected keyword argument 'commit'
My intentions are to have a form submit many values for 3 fields, to then create an object for each combination of those fields, and to save each of those objects. Helpful nudge in the right direction would be ace.
File models.py
class CallResultType(models.Model):
id = models.AutoField(db_column='icontact_result_code_type_id', primary_key=True)
callResult = models.ForeignKey('CallResult', db_column='icontact_result_code_id')
campaign = models.ForeignKey('Campaign', db_column='icampaign_id')
callType = models.ForeignKey('CallType', db_column='icall_type_id')
agent = models.BooleanField(db_column='bagent', default=True)
teamLeader = models.BooleanField(db_column='bTeamLeader', default=True)
active = models.BooleanField(db_column='bactive', default=True)
File forms.py
from django.forms import ModelForm, ModelMultipleChoiceField
from callresults.models import *
class CallResultTypeForm(ModelForm):
callResult = ModelMultipleChoiceField(queryset=CallResult.objects.all())
campaign = ModelMultipleChoiceField(queryset=Campaign.objects.all())
callType = ModelMultipleChoiceField(queryset=CallType.objects.all())
def save(self, force_insert=False, force_update=False):
for cr in self.callResult:
for c in self.campain:
for ct in self.callType:
m = CallResultType(self) # this line is probably wrong
m.callResult = cr
m.campaign = c
m.calltype = ct
m.save()
class Meta:
model = CallResultType
File admin.py
class CallResultTypeAdmin(admin.ModelAdmin):
form = CallResultTypeForm
In your save you have to have the argument commit. If anything overrides your form, or wants to modify what it's saving, it will do save(commit=False), modify the output, and then save it itself.
Also, your ModelForm should return the model it's saving. Usually a ModelForm's save will look something like:
def save(self, commit=True):
m = super(CallResultTypeForm, self).save(commit=False)
# do custom stuff
if commit:
m.save()
return m
Read up on the save method.
Finally, a lot of this ModelForm won't work just because of the way you are accessing things. Instead of self.callResult, you need to use self.fields['callResult'].
UPDATE: In response to your answer:
Aside: Why not just use ManyToManyFields in the Model so you don't have to do this? Seems like you're storing redundant data and making more work for yourself (and me :P).
from django.db.models import AutoField
def copy_model_instance(obj):
"""
Create a copy of a model instance.
M2M relationships are currently not handled, i.e. they are not copied. (Fortunately, you don't have any in this case)
See also Django #4027. From http://blog.elsdoerfer.name/2008/09/09/making-a-copy-of-a-model-instance/
"""
initial = dict([(f.name, getattr(obj, f.name)) for f in obj._meta.fields if not isinstance(f, AutoField) and not f in obj._meta.parents.values()])
return obj.__class__(**initial)
class CallResultTypeForm(ModelForm):
callResult = ModelMultipleChoiceField(queryset=CallResult.objects.all())
campaign = ModelMultipleChoiceField(queryset=Campaign.objects.all())
callType = ModelMultipleChoiceField(queryset=CallType.objects.all())
def save(self, commit=True, *args, **kwargs):
m = super(CallResultTypeForm, self).save(commit=False, *args, **kwargs)
results = []
for cr in self.callResult:
for c in self.campain:
for ct in self.callType:
m_new = copy_model_instance(m)
m_new.callResult = cr
m_new.campaign = c
m_new.calltype = ct
if commit:
m_new.save()
results.append(m_new)
return results
This allows for inheritance of CallResultTypeForm, just in case that's ever necessary.

Categories