Django admin does not call object's save method early enough - python

I have two apps in Django where one app's model (ScopeItem) on its instance creation must create an instance of the other app's model as well (Workflow); i.e. the ScopeItem contains it's workflow.
This works nicely when tried from the shell. Creating a new ScopeItem creates a Workflow and stores it in the ScopeItem. In admin I get an error, that the workflow attribute is required. The attribute is not filled in and the model definition requires it to be set. The overwritten save method though does this. Hence my question is, how to call save before the check in admin happens?
If I pick an existing Workflow instance in admin and save (successfully then), then I can see that my save method is called later and a new Workflow is created and attached to the ScopeItem instance. It is just called too late.
I am aware that I could allow empty workflow attributes in a ScopeItem or merge the ScopeItem and the Workflow class to avoid the issue with admin. Both would cause trouble later though and I like to avoid such hacks.
Also I do not want to duplicate code in save_item. Just calling save from there apparently does not cut it.
Here is the code from scopeitems/models.py:
class ScopeItem(models.Model):
title = models.CharField(max_length=64)
description = models.CharField(max_length=4000, null=True)
workflow = models.ForeignKey(Workflow)
def save(self, *args, **kwargs):
if not self.id:
workflow = Workflow(
description='ScopeItem %s workflow' % self.title,
status=Workflow.PENDING)
workflow.save()
self.workflow = workflow
super(ScopeItem, self).save(*args, **kwargs)
And workflow/models.py:
from django.utils.timezone import now
class Workflow(models.Model):
PENDING = 0
APPROVED = 1
CANCELLED = 2
STATUS_CHOICES = (
(PENDING, 'Pending'),
(APPROVED, 'Done'),
(CANCELLED, 'Cancelled'),
)
description = models.CharField(max_length=4000)
status = models.IntegerField(choices=STATUS_CHOICES)
approval_date = models.DateTimeField('date approved', null=True)
creation_date = models.DateTimeField('date created')
update_date = models.DateTimeField('date updated')
def save(self, *args, **kwargs):
if not self.id:
self.creation_date = now()
self.update_date = now()
super(Workflow, self).save(*args, **kwargs)
In scopeitems/admin.py I have:
from django.contrib import admin
from .models import ScopeItem
from workflow.models import Workflow
class ScopeItemAdmin(admin.ModelAdmin):
list_display = ('title', 'description', 'status')
list_filter = ('workflow__status', )
search_fields = ['title', 'description']
def save_model(self, request, obj, form, change):
obj.save()
def status(self, obj):
return Workflow.STATUS_CHOICES[obj.workflow.status][1]
admin.site.register(ScopeItem, ScopeItemAdmin)

You could set the field blank=True on workflow.
You said you don't want to allow "empty workflow attributes in a ScopeItem." Setting blank=True is purely validation-related. Thus, on the backend workflow will still be NOT NULL. From the Django docs:
If a field has blank=True, form validation will allow entry of an empty value.
Referring to your example you should be able to use:
workflow = models.ForeignKey(Workflow, blank=True)

You need to exclude the field from the form used in the admin, so that it won't be validated.
class ScopeItemForm(forms.ModelForm):
class Meta:
exclude = ('workflow',)
model = ScopeItem
class ScopeItemAdmin(admin.ModelAdmin):
form = ScopeItemForm
...
admin.site.register(ScopeItem, ScopeItemAdmin)

#Daniel Roseman's answer is correct as long as you don't need to edit the workflow field in admin at any time. If you do need to edit it then you'll need to write a custom clean() method on the admin form.
forms.py
class ScopeItemAdminForm(forms.ModelForm):
class Meta:
model = ScopeItem
def clean(self):
cleaned_data = super(ScopeItemAdminForm, self).clean()
if 'pk' not in self.instance:
workflow = Workflow(
description='ScopeItem %s workflow' % self.title,
status=Workflow.PENDING)
workflow.save()
self.workflow = workflow
return cleaned_data
admin.py
class ScopeItemAdmin(admin.ModelAdmin):
form = ScopeItemAdminForm
...
admin.site.register(ScopeItem, ScopeItemAdmin)

Answering my own question:
As #pcoronel suggested, the workflow attribute in ScopeItem must have blank=True set to get out of the form in the first place.
Overwriting the form's clean method as suggested by #hellsgate was also needed to create and store the new Workflow.
To prevent code duplication I added a function to workflow/models.py:
def create_workflow(title="N/A"):
workflow = Workflow(
description='ScopeItem %s workflow' % title,
status=Workflow.PENDING)
workflow.save()
return workflow
This makes the ScopeItemAdminForm look like this:
class ScopeItemAdminForm(forms.ModelForm):
class Meta:
model = ScopeItem
def clean(self):
cleaned_data = super(ScopeItemAdminForm, self).clean()
cleaned_data['workflow'] = create_workflow(cleaned_data['title'])
return cleaned_data
Additionally I changed the save method in scopeitems/models.py to:
def save(self, *args, **kwargs):
if not self.id:
if not self.workflow:
self.workflow = create_workflow(self.title)
super(ScopeItem, self).save(*args, **kwargs)

Related

Django form not populating with POST data

SOLUTION AT THE BOTTOM
Problem: Django form populating with list of objects rather than values
Summary: I have 2 models Entities and Breaks. Breaks has a FK relationship to the entity_id (not the PK) on the Entities model.
I want to generate an empty form for all the fields of Breaks. Generating a basic form populates all the empty fields, but for the FK it generates a dropdown list of all objects of the Entities table. This is not helpful so I have excluded this in the ModelForm below and tried to replace with a list of all the entity_ids of the Entities table. This form renders as expected.
class BreakForm(ModelForm):
class Meta:
model = Breaks
#fields = '__all__'
exclude = ('entity',)
def __init__(self, *args, **kwargs):
super(BreakForm, self).__init__(*args, **kwargs)
self.fields['entity_id'] = ModelChoiceField(queryset=Entities.objects.all().values_list('entity_id', flat=True))
The below FormView is the cbv called by the URL. As the below stands if I populate the form, and for the FK column entity_id choose one of the values, the form will not submit. By that field on the form template the following message appears Select a valid choice. That choice is not one of the available choices.
class ContactFormView(FormView):
template_name = "breaks/test/breaks_form.html"
form_class = BreakForm
My initial thoughts were either that the datatype of this field (string/integer) was wrong or that Django needed the PK of the row in the Entities table (for whatever reason).
So I added a post function to the FormView and could see that the request.body was populating correctly. However I can't work out how to populate this into the ModelForm and save to the database, or overcome the issue mentioned above.
Addendum:
Models added below:
class Entity(models.Model):
pk_securities = models.AutoField(primary_key=True)
entity_id = models.CharField(unique=True)
entity_description = models.CharField(blank=True, null=True)
class Meta:
managed = False
db_table = 'entities'
class Breaks(models.Model):
pk_break = models.AutoField(primary_key=True)
date = models.DateField(blank=True, null=True)
entity = models.ForeignKey(Entity, on_delete= models.CASCADE, to_field='entity_id')
commentary = models.CharField(blank=True, null=True)
active = models.BooleanField()
def get_absolute_url(self):
return reverse(
"item-update", args=[str(self.pk_break)]
)
def __str__(self):
return f"{self.pk_break}"
class Meta:
managed = False
db_table = 'breaks'
SOLUTION
Firstly I got this working by adding the following to the Entity Model class. However I didn't like this as it would have consequences elsewhere.
def __str__(self):
return f"{self.entity_id}"
I found this SO thread on the topic. The accepted answer is fantastic and the comments to it are helpful.
The solution is to subclass ModelChoiceField and override the label_from_instance
class EntityChoiceField(ModelChoiceField):
def label_from_instance(self, obj):
return obj.entity_id
I think your problem is two fold, first is not rendering the dropdown correctly and second is form is not saving. For first problem, you do not need to do any changes in ModelChoiceField queryset, instead, add to_field_name:
class BreakForm(ModelForm):
class Meta:
model = Breaks
#fields = '__all__'
def __init__(self, *args, **kwargs):
super(BreakForm, self).__init__(*args, **kwargs)
self.fields['entity_id'] = ModelChoiceField(queryset=Entities.objects.all(), to_field_name='entity_id')
Secondly, if you want to save the form, instead of FormView, use CreateView:
class ContactFormView(CreateView):
template_name = "breaks/test/breaks_form.html"
form_class = BreakForm
model = Breaks
In Django, the request object passed as parameter to your view has an attribute called "method" where the type of the request is set, and all data passed via POST can be accessed via the request. POST dictionary. The view will display the result of the login form posted through the loggedin. html.

Unable to update/delete related model in the save() method of a model

I have two Django models:
from django.db import models
class Policy(models.Model):
status = models.IntegerField()
def save(self, *args, **kwargs):
quote = self.documents.get(document_type=DocumentType.quote)
if self.status == 0:
quote.delete()
elif self.status == 1:
new_quote_content = create_new_quote()
quote.s3_file.save(quote.name, File(new_quote_content))
super().save(*args, *kwargs)
class Document(models.Model):
policy = models.ForeignKey(
to=Policy,
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="documents",
)
s3_file = models.FileField(
storage=S3Storage(aws_s3_bucket_name="policy-documents"),
upload_to=get_document_s3_key,
max_length=255,
)
I want to delete/update the document when the policy status is updated and I've overriden the save() method in Policy to do it. However, neither the doc deletion nor the doc's FieldFile update works in the save() method. If I move them to outside the save() method, everything works.
Does someone understand what's the issue here?
It is not calling the super method to save the model. To override a model it has to be something like this as given in the documentation of Django.
from django.db import models
class Blog(models.Model):
name = models.CharField(max_length=100)
tagline = models.TextField()
def save(self, *args, **kwargs):
do_something()
super().save(*args, **kwargs) # Call the "real" save() method.
do_something_else()
The save() was being called from a Policy ModelAdmin with a inlined Document form set. After it was run, Django executed the ModelAdmin's save_related() method, which saved the Document form set data, overwriting the Document changes I had just saved in save(). I solved it by overriding save_related() and deleting/updating the document after form.save_m2m() and form.save_formset().

Django limit_choices_to on ManyToMany related with Inline

I would like to use limit_choices_to to reduce the set of choices for the Django admin of Model with a ManyToMany Field when using an Inline.
Interestingly, the reason why I want to limit choices is for performance as I want to use a property of a parent model in the __str__ method of my related model class, and not limiting the choices causes prohibitively many SQL queries.
The following works
class ParentOfA(models.Model):
name = models.CharField(max_length=50, null=True)
class A(models.Model):
parent = models.ForeignKey(ParentOfA)
def __str__(self):
return "%s" % self.parent
class B(models.Model):
a = models.ManyToManyField(A, limit_choices_to={"a__name":'parent name'})
If I don't use an Inline in the admin form for B (following the example in the docs).
E.g.
#admin.register(B)
class BAdmin(admin.ModelAdmin):
pass
However, with the inline the limit_choices_to has no effect:
class BInline(admin.TabularInline):
model = B.A.through
#admin.register(B)
class BAdmin(admin.ModelAdmin):
inline = (BInline,)
Any suggestions?
I don't know why limit_choices_to doesn't work for the inline field, I am having the same problem. While this should not be necessary, you can limit the queryset of a field in an inline using this answer: https://stackoverflow.com/a/4236159/1302095
This is an old answer to an old question, so I'm not sure if there are better ways in newer versions of django. I am using 1.8.3 and it works, which is what matters to me!
Code pasted here for reference:
class RoomInline(admin.TabularInline):
model = Room
def formfield_for_foreignkey(self, db_field, request=None, **kwargs):
field = super(RoomInline, self).formfield_for_foreignkey(db_field, request, **kwargs)
if db_field.name == 'inside_room':
if request._obj_ is not None:
field.queryset = field.queryset.filter(building__exact = request._obj_)
else:
field.queryset = field.queryset.none()
return field
class BuildingAdmin(admin.ModelAdmin):
inlines = (RoomInline,)
def get_form(self, request, obj=None, **kwargs):
# just save obj reference for future processing in Inline
request._obj_ = obj
return super(BuildingAdmin, self).get_form(request, obj, **kwargs)

Django Rest Framework ModelSerializer Set attribute on create

When creating an object initially I use the currently logged-in user to assign the model field 'owner'.
The model:
class Account(models.Model):
id = models.AutoField(primary_key=True)
owner = models.ForeignKey(User)
name = models.CharField(max_length=32, unique=True)
description = models.CharField(max_length=250, blank=True)
Serializer to set owner:
class AccountSerializer(serializers.ModelSerializer):
class Meta:
model = models.Account
fields = ('name', 'description')
def restore_object(self, attrs, instance=None):
instance = super().restore_object(attrs, instance)
request = self.context.get('request', None)
setattr(instance, 'owner', request.user)
return instance
It is possible for a different user in my system to update another's Account object, but the ownership should remain with the original user. Obviously the above breaks this as the ownership would get overwritten upon update with the currently logged in user.
So I've updated it like this:
class AccountSerializer(serializers.ModelSerializer):
class Meta:
model = models.Account
fields = ('name', 'description')
def restore_object(self, attrs, instance=None):
new_instance = False
if not instance:
new_instance = True
instance = super().restore_object(attrs, instance)
# Only set the owner if this is a new instance
if new_instance:
request = self.context.get('request', None)
setattr(instance, 'owner', request.user)
return instance
Is this the recommended way to do something like this? I can't see any other way, but I have very limited experience so far.
Thanks
From reviewing #zaphod100.10's answer. Alternatively, in the view code (with custom restore_object method in above serializer removed):
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.DATA, files=request.FILES)
if serializer.is_valid():
serializer.object.owner = request.user
self.pre_save(serializer.object)
self.object = serializer.save(force_insert=True)
self.post_save(self.object, created=True)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED,
headers=headers)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
Basically you want the owner to be set on creation and not on subsequent updates. For this I think you should set the owner in the POST view. I think it is more logical and robust that way. Update is done via PUT view so your data should always be correct since no way on updation the owner can be changed if the owner is not editable on PUT.
For making the views you can use DRF's generic class based views. Use the RetrieveUpdateDeleteView as it is. For ListCreateView override the post method. Use a django model form for validating the data and creating an account instance.
You will have to copy the request.DATA dict and insert 'owner' as the current user.
The code for the POST method can be:
def post(self, request, *args, **kwargs):
data = deepcopy(request.DATA)
data['owner'] = request.user
form = AccountForm(data=data)
if form.is_valid():
instance = form.save(commit=false)
instance.save()
return Response(dict(id=instance.pk), status=status.HTTP_201_CREATED)
return Response(form.errors, status=status.HTTP_400_BAD_REQUEST)
Potential other option using pre_save which I think seems to be intended for just this kind of thing.
class AccountList(generics.ListCreateAPIView):
serializer_class = serializers.AccountSerializer
permission_classes = (permissions.IsAuthenticated)
def get_queryset(self):
"""
This view should return a list of all the accounts
for the currently authenticated user.
"""
user = self.request.user
return models.Account.objects.filter(owner=user)
def pre_save(self, obj):
"""
Set the owner of the object to the currently logged in user as this
field is not populated by the serializer as the user can not set it
"""
# Throw a 404 error if there is no authenticated user to use although
# in my case this is assured more properly by the permission_class
# specified above, but this could be any criteria.
if not self.request.user.is_authenticated():
raise Http404()
# In the case of ListCreateAPIView this is not necessary, but
# if doing this on RetrieveUpdateDestroyAPIView then this may
# be an update, but if it doesn't exist will be a create. In the
# case of the update, we don't wish to overwrite the owner.
# obj.owner will not exist so the way to test if the owner is
# already assigned for a ForeignKey relation is to check for
# the owner_id attribute
if not obj.owner_id:
setattr(obj, 'owner', self.request.user)
I think this is the purpose of pre_save and it is quite concise.
Responsibilities should be split here, as the serializer/view only receives/clean the data and make sure all the needed data is provided, then it should be the model responsibility to set the owner field accordingly. It's important to separate these two goals as the model might be updated from elsewhere (like from an admin form).
views.py
class AccountCreateView(generics.CreateAPIView):
serializer_class = serializers.AccountSerializer
permission_classes = (permissions.IsAuthenticated,)
def post(self, request, *args, **kwargs):
# only need this
request.data['owner'] = request.user.id
return super(AccountCreateView, self).post(request, *args, **kwargs)
models.py
class Account(models.Model):
# The id field is provided by django models.
# id = models.AutoField(primary_key=True)
# you may want to name the reverse relation with 'related_name' param.
owner = models.ForeignKey(User, related_name='accounts')
name = models.CharField(max_length=32, unique=True)
description = models.CharField(max_length=250, blank=True)
def save(self, *args, **kwargs):
if not self.id:
# only triggers on creation
super(Account, self).save(*args, **kwargs)
# when updating, remove the "owner" field from the list
super(Account, self).save(update_fields=['name', 'description'], *args, **kwargs)

How to pass initial parameter to django's ModelForm instance?

The particular case I have is like this:
I have a Transaction model, with fields: from, to (both are ForeignKeys to auth.User model) and amount. In my form, I'd like to present the user 2 fields to fill in: amount and from (to will be automaticly set to current user in a view function).
Default widget to present a ForeignKey is a select-box. But what I want to get there, is limit the choices to the user.peers queryset members only (so people can only register transactions with their peers and don't get flooded with all system users).
I tried to change the ModelForm to something like this:
class AddTransaction(forms.ModelForm):
from = ModelChoiceField(user.peers)
amount = forms.CharField(label = 'How much?')
class Meta:
model = models.Transaction
But it seems I have to pass the queryset of choices for ModelChoiceField right here - where I don't have an access to the web request.user object.
How can I limit the choices in a form to the user-dependent ones?
Use the following method (hopefully it's clear enough):
class BackupForm(ModelForm):
"""Form for adding and editing backups."""
def __init__(self, *args, **kwargs):
systemid = kwargs.pop('systemid')
super(BackupForm, self).__init__(*args, **kwargs)
self.fields['units'] = forms.ModelMultipleChoiceField(
required=False,
queryset=Unit.objects.filter(system__id=systemid),
widget=forms.SelectMultiple(attrs={'title': _("Add unit")}))
class Meta:
model = Backup
exclude = ('system',)
Create forms like this:
form_backup = BackupForm(request.POST,
instance=Backup,
systemid=system.id)
form_backup = BackupForm(initial=form_backup_defaults,
systemid=system.id)
Hope that helps! Let me know if you need me to explain more in depth.
I ran into this problem as well, and this was my solution:
class ChangeEmailForm(forms.ModelForm):
def __init__(self, user, *args, **kwargs):
self.user = user
super(ChangeEmailForm, self).__init__(*args, **kwargs)
self.fields['email'].initial = user.email
class Meta:
model = User
fields = ('email',)
def save(self, commit=True):
self.user.email = self.cleaned_data['email']
if commit:
self.user.save()
return self.user
Pass the user into the __init__ of the form, and then call super(…). Then set self.fields['from'].queryset to user.peers

Categories

Resources