Dynamically adding many to many relationships in the save method in Django - django

My Content model has a many-to-many relationship to the Tag model. When I save a Content object, I want to add the relationships dynamically. Im doing this the following way.
# models.py
def tag_content(content_id):
obj = Content.objects.get(pk=content_id)
print obj # Checking
obj.tags = [1, 2, 3] # Adding the relationships using the Tag IDs
class Tag(models.Model):
name = models.CharField(max_length=255)
class Content(models.Model):
title = models.CharField(max_length=255)
is_tagged = models.BooleanField(default=False)
tags = models.ManyToManyField(Tag, blank=True)
def save(self, *args, **kwargs):
super(Content, self).save(*args, **kwargs)
if not self.is_tagged:
tag_content(self.pk) # calling the tagging method
In other words, when a Content object is saved, its tags field is related to 3 different Tag object models. Just to let you know, I do have the Tags with pks = 1, 2, and 3 in database.
However, this simply doesn't work. The save method calls the tag_content method, since the print obj statement works. However, the many-to-many field is not set and remains empty. The funny thing is, If I run the following commands in shell, the tags field is set perfectly.
# python manage.py shell
from myapp.models import *
obj = Content.objects.get(pk=1)
tag_content(obj.pk)
So how come the shell version works, but the other one doesn't? Any help is appreciated.

You can't work on an m2m relationship in a custom save method because of the way Django writes those relationships to the database. When saving a model instance with an m2m relationship, Django first writes the object then goes in again and writes the appropriate m2m relationships. Since the m2m stuff comes "second," trying to work with the relationship in a custom save fails.
The solution is to use a post-save signal. Remove the custom save stuff and add this below your model definitions, making sure to import receiver and post_save:
#receiver(post_save, sender = Content)
def update_m2m_relationships_on_save(sender, **kwargs):
if not kwargs['instance'].is_tagged:
tag_content(kwargs['instance'].pk)
Your tag_content function should probably swap is_tagged to True and then save the instance; if that boolean is never flipped then this could just run in an endless loop. You can also just pass in the object instead of passing in the pk:
def tag_content(thing_to_tag):
thing_to_tag.tags.add([1,2,3])
thing_to_tag.is_tagged = True
thing_to_tag.save()
return thing_to_tag
Note the use of .add(), which is important when adding to an m2m relationship.

Related

Django, How to enforce model Foreinkeys to have same value

In Django I want to enforce that a model which has two foreinkeys of differents models which has the same type of field to be the same, in example:
class Model1(models.Model):
f1 = models.CharField(max_length=48)
class Model2(models.Model):
f1 = models.CharField(max_length=48)
class Model3(models.Model):
field1 = models.ForeignKey(Model1)
field2 = models.ForeignKey(Model2)
I want that creation of objects of Model3 would be made only if f1 field of Model1 and Model2 are the same. (Edit: please note that each model has other fields that are not relavant to the question)
I'm fairly certain this isn't possible at a db level. However, you could enforce it at the code level fairly easily through a pre-save signal. pre-save signals in django allow you to perform an operation/check before a model is actually saved to the database.
See the below example for a potential solution.
from django.db.models.signals import pre_save
#receiver(pre_save)
def pre_save_handler(sender, instance, *args, **kwargs):
if instance.field1.f1 != instance.field2.f2:
raise Exception('Cannot save models, because their values do not match!')
Here we are checking if the fields have the same value, and if they don't an exception is thrown, keeping the record from saving.

How to exclude django model fields during a save?

I've got a fairly complicated Django model that includes some fields that should only be saved under certain circumstances. As a simple example,
from django.db import models
class MyModel(models.Model):
name = models.CharField(max_length=200)
counter = models.IntegerField(default=0)
def increment_counter(self):
self.counter = models.F('counter') + 1
self.save(update_fields=['counter'])
Here I'm using F expressions to avoid race conditions while incrementing the counter. I'll generally never want to save the value of counter outside of the increment_counter function, as that would potentially undo an increment called from another thread or process.
So the question is, what's the best way to exclude certain fields by default in the model's save function? I've tried the following
def save(self, **kwargs):
if update_fields not in kwargs:
update_fields = set(self._meta.get_all_field_names())
update_fields.difference_update({
'counter',
})
kwargs['update_fields'] = tuple(update_fields)
super().save(**kwargs)
but that results in ValueError: The following fields do not exist in this model or are m2m fields: id. I could of course just add id and any m2m fields in the difference update, but that then starts to seem like an unmaintainable mess, especially once other models start to reference this one, which will add additional names in self._meta.get_all_field_names() that need to be excluded from update_fields.
For what it's worth, I mostly need this functionality for interacting with the django admin site; every other place in the code could relatively easily call model_obj.save() with the correct update_fields.
I ended up using the following:
from django.db import models
class MyModel(models.Model):
name = models.CharField(max_length=200)
counter = models.IntegerField(default=0)
default_save_fields = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.default_save_fields is None:
# This block should only get called for the first object loaded
default_save_fields = {
f.name for f in self._meta.get_fields()
if f.concrete and not f.many_to_many and not f.auto_created
}
default_save_fields.difference_update({
'counter',
})
self.__class__.default_save_fields = tuple(default_save_fields)
def increment_counter(self):
self.counter = models.F('counter') + 1
self.save(update_fields=['counter'])
def save(self, **kwargs):
if self.id is not None and 'update_fields' not in kwargs:
# If self.id is None (meaning the object has yet to be saved)
# then do a normal update with all fields.
# Otherwise, make sure `update_fields` is in kwargs.
kwargs['update_fields'] = self.default_save_fields
super().save(**kwargs)
This seems to work for my more complicated model which is referenced in other models as a ForeignKey, although there might be some edge cases that it doesn't cover.
I created a mixin class to make it easy to add to a model, inspired by clwainwright's answer. Though it uses a second mixin class to track which fields have been changed, inspired by this answer.
https://gitlab.com/snippets/1746711

Efficiently delete orphaned m2m objects/tags in Django

I have two models - Photo and Tag - which are connected via a ManyToManyField.
class Photo(models.Model):
tags = models.ManyToManyField(Tag)
class Tag(models.Model):
lang = models.CharField(max_length=2)
name_es = models.CharField(max_length=40)
name_en = models.CharField(max_length=40)
Every once in a while, we get orphaned tags, that are not referenced any more by any photo. Is there an efficient way of deleting those tags? I know about this answer:
Django: delete M2M orphan entries?
And our solution looks like this at the moment:
for tag in Tag.objects.all():
if not tag.photo_set.select_related(): tag.delete()
However, with increasing database, the runtime of this script is becoming distressingly high :-P Is there an efficient way of getting a list of all tag IDs from the tags table and then a list of all tag IDs from the many-to-many table to create an intersection list?
Try sub-query w/ intermediate table
qs = Tag.objects.exclude(pk__in=Book.tags.through.objects.values('tag'))
# then you could
qs.delete()
# or if you need to trigger signal per item
for x in qs:
x.delete()
We've had to further improve performance of this task, so I modified okm's solution a bit:
all_tag_pks = Tag.objects.values_list('pk', flat=True)
used_tag_pks = Photo.tags.through.objects.values_list('tag', flat=True)
Tag.objects.filter(pk__in=list(set(all_tag_pks) - set(used_tag_pks))).delete()
By that, the query to the database gets a lot smaller and faster.
I found a way to do this "in real time":
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
class Photo(models.Model):
tags = models.ManyToManyField(Tag)
class Tag(models.Model):
lang = models.CharField(max_length=2)
#receiver(m2m_changed, sender=Photo.tags.through)
def delete_orphean_dateranges(sender, **kwargs):
# when something is removed from the m2m:
if kwargs['action'] == 'post_remove':
Tag.objects.filter(pk__in=kwargs['pk_set'], photo_set=None).delete()
# select removed tags and check if they are not linked
# to any Photo, and delete it
This way, each time you edit the Photo's m2m when deleting a tag from the m2m,
this function is called.

Django "Enter a list of values" form error when rendering a ManyToManyField as a Textarea

I'm trying to learn Django and I've ran into some confusing points. I'm currently having trouble creating a movie using a form. The idea of the form is to give the user any field he'd like to fill out. Any field that the user fills out will be updated in its respective sql table (empty fields will be ignored). But, the form keeps giving me the error "Enter a list of values" when I submit the form. To address this, I thought stuffing the data from the form into a list and then returning that list would solve this.
The first idea was to override the clean() in my ModelForm. However, because the form fails the is_valid() check in my views, the cleaned_data variable in clean() doesn't contain anything. Next, I tried to override the to_python(). However, to_python() doesn't seem to be called.
If I put __metaclass__ = models.SubfieldBase in the respective model, I receive the runtime error
"TypeError: Error when calling the
metaclass bases
metaclass conflict: the metaclass of a derived class must be a
(non-strict) subclass of the
metaclasses of all its bases"
My approach doesn't seem to work. I'm not sure how to get around the 'Enter a list of values" error! Any advice?
Here is the relevant code (updated):
models.py
""" Idea:
A movie consists of many equipments, actors, and lighting techniques. It also has a rank for the particular movie, as well as a title.
A Theater consists of many movies.
A nation consists of many theaters.
"""
from django.db import models
from django.contrib.auth.models import User
class EquipmentModel(models.Model):
equip = models.CharField(max_length=20)
# user = models.ForeignKey(User)
class ActorModel(models.Model):
actor = models.CharField(max_length=20)
# user = models.ForeignKey(User)
class LightModel(models.Model):
light = models.CharField(max_length=20)
# user = models.ForeignKey(User)
class MovieModel(models.Model):
# __metaclass__ = models.SubfieldBase
rank = models.DecimalField(max_digits=5000, decimal_places=3)
title = models.CharField(max_length=20)
equipments = models.ManyToManyField(EquipmentModel, blank=True, null=True)
actors = models.ManyToManyField(ActorModel, blank=True, null=True)
lights = models.ManyToManyField(LightModel, blank=True, null=True)
class TheaterModel(models.Model):
movies = models.ForeignKey(MovieModel)
class NationModel(models.Model):
theaters = models.ForeignKey(TheaterModel)
=====================================
forms.py
"""
These Modelforms tie in the models from models.py
Users will be able to write to any of the fields in MovieModel when creating a movie.
Users may leave any field blank (empty fields should be ignored, ie: no updates to database).
"""
from django import forms
from models import MovieModel
from django.forms.widgets import Textarea
class MovieModelForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super(MovieModelForm, self).__init__(*args, **kwargs)
self.fields["actors"].widget = Textarea()
self.fields["equipments"].widget = Textarea()
self.fields["lights"].widget = Textarea()
def clean_actors(self):
data = self.cleaned_data.get('actors')
print 'cleaning actors'
return [data]
class Meta:
model = MovieModel
=============================================
views.py
""" This will display the form used to create a MovieModel """
from django.shortcuts import render_to_response
from django.template import RequestContext
from forms import MovieModelForm
def add_movie(request):
if request.method == "POST":
form = MovieModelForm(request.POST)
if form.is_valid():
new_moviemodel = form.save()
return HttpResponseRedirect('/data/')
else:
form = MovieModelForm()
return render_to_response('add_movie_form.html', {form:form,}, context_instance=RequestContext(request))
The probable problem is that the list of values provided in the text area can not be normalized into a list of Models.
See the ModelMultipleChoiceField documentation.
The field is expecting a list of valid IDs, but is probably receiving a list of text values, which django has no way of converting to the actual model instances. The to_python will be failing within the form field, not within the form itself. Therefore, the values never even reach the form.
Is there something wrong with using the built in ModelMultipleChoiceField? It will provide the easiest approach, but will require your users to scan a list of available actors (I'm using the actors field as the example here).
Before I show an example of how I'd attempt to do what you want, I must ask; how do you want to handle actors that have been entered that don't yet exist in your database? You can either create them if they exist, or you can fail. You need to make a decision on this.
# only showing the actor example, you can use something like this for other fields too
class MovieModelForm(forms.ModelForm):
actors_list = fields.CharField(required=False, widget=forms.Textarea())
class Meta:
model = MovieModel
exclude = ('actors',)
def clean_actors_list(self):
data = self.cleaned_data
actors_list = data.get('actors_list', None)
if actors_list is not None:
for actor_name in actors_list.split(','):
try:
actor = Actor.objects.get(actor=actor_name)
except Actor.DoesNotExist:
if FAIL_ON_NOT_EXIST: # decide if you want this behaviour or to create it
raise forms.ValidationError('Actor %s does not exist' % actor_name)
else: # create it if it doesnt exist
Actor(actor=actor_name).save()
return actors_list
def save(self, commit=True):
mminstance = super(MovieModelForm, self).save(commit=commit)
actors_list = self.cleaned_data.get('actors_list', None)
if actors_list is not None:
for actor_name in actors_list.split(","):
actor = Actor.objects.get(actor=actor_name)
mminstance.actors.add(actor)
mminstance.save()
return mminstance
The above is all untested code, but something approaching this should work if you really want to use a Textarea for a ModelMultipleChoiceField. If you do go down this route, and you discover errors in my code above, please either edit my answer, or provide a comment so I can. Good luck.
Edit:
The other option is to create a field that understands a comma separated list of values, but behaves in a similar way to ModelMultipleChoiceField. Looking at the source code for ModelMultipleChoiceField, it inhertis from ModelChoiceField, which DOES allow you to define which value on the model is used to normalize.
## removed code because it's no longer relevant. See Last Edit ##
Edit:
Wow, I really should have checked the django trac to see if this was already fixed. It is. See the following ticket for information. Essentially, they've done the same thing I have. They've made ModelMutipleChoiceField respect the to_field_name argument. This is only applicable for django 1.3!
The problem is, the regular ModelMultipleChoiceField will see the comma separated string, and fail because it isn't a List or Tuple. So, our job becomes a little more difficult, because we have to change the string to a list or tuple, before the regular clean method can run.
class ModelCommaSeparatedChoiceField(ModelMultipleChoiceField):
widget = Textarea
def clean(self, value):
if value is not None:
value = [item.strip() for item in value.split(",")] # remove padding
return super(ModelCommaSeparatedChoiceField, self).clean(value)
So, now your form should look like this:
class MovieModelForm(forms.ModelForm):
actors = ModelCommaSeparatedChoiceField(
required=False,
queryset=Actor.objects.filter(),
to_field_name='actor')
equipments = ModelCommaSeparatedChoiceField(
required=False,
queryset=Equipment.objects.filter(),
to_field_name='equip')
lights = ModelCommaSeparatedChoiceField(
required=False,
queryset=Light.objects.filter(),
to_field_name='light')
class Meta:
model = MovieModel
to_python AFAIK is a method for fields, not forms.
clean() occurs after individual field cleaning, so your ModelMultipleChoiceFields clean() methods are raising validation errors and thus cleaned_data does not contain anything.
You haven't provided examples for what kind of data is being input, but the answer lies in form field cleaning.
http://docs.djangoproject.com/en/dev/ref/forms/validation/#cleaning-a-specific-field-attribute
You need to write validation specific to that field that either returns the correct data in the format your field is expecting, or raises a ValidationError so your view can re-render the form with error messages.
update: You're probably missing the ModelForm __init__ -- see if that fixes it.
class MovieModelForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super(MovieModelForm, self).__init__(*args, **kwargs)
self.fields["actors"].widget = Textarea()
def clean_actors(self):
data = self.cleaned_data.get('actors')
# validate incoming data. Convert the raw incoming string
# to a list of ids this field is expecting.
# if invalid, raise forms.ValidationError("Error MSG")
return data.split(',') # just an example if data was '1,3,4'

Django admin handling one to many relation

I have a django model as following
class Project(models.Model)
name=models.CharField(max_length=200)
class Application(models.Model)
proj=models.ForeignKey(Project, null=True, blank=True)
I need to modify the admin form of the project to be able to assign multiple applications to the project, so in the admin.py I have created a ModelAdmin class for the project as following
class ProjectAdmin(ModelAdmin)
form=projectForm
project_apps=[]
and the project form as following
class ProjectForm(forms.ModelForm):
class Meta:
model = Project
project_apps =forms.ModelMultipleChoiceField(queryset=Application.objects.all(),required=False,)
def __init__(self, *args, **kwargs):
super(ProjectForm, self).__init__(*args, **kwargs)
if self.instance.id is not None:
selected_items = [ values[0] for values in Application.objects.filter(project=self.instance) ]
self.fields['project_apps'].initial = selected_items
def save(self,commit=True):
super(ProjectForm,self).save(commit)
return self.instance
by doing this I have a multiple select in the create/edit project form.
what I need is to override the save method to save a reference for the project in the selected applications?
how can I get the selected applications ????
Not entirely sure what you're trying to do, but maybe this?
def save(self,commit=True):
kwargs.pop('commit') # We're overriding this with commit = False
super(ProjectForm,self).save(commit)
if self.instance:
for a in self.cleaned_data['project_apps']:
a.proj = self.instance
a.save()
return self.instance
Now, I can't remember if in this case, self.cleaned_data['project_apps'] will actually contain a list of Application objects or not. I suspect it will, but if not this function will take care of that:
def clean_project_apps(self):
app_list = self.cleaned_data['project_apps']
result = []
for a in app_list:
try:
result.append(Application.objects.get(pk=a)
except Application.DoesNotExist:
raise forms.ValidationError("Invalid application record") # to be safe
return result
All in all I think this form is a bad idea though, because basically what is happening here is you're displaying all of the application records which doesn't make sense, since most of them will be associated with other projects.
Oh oh oh!!! Just noticed you wanted this to show up in a Multiple Select list!
You're (probably) doing it wrong
A multiple select means this isn't a one-to-many relationship. It's a many-to-many relationship.
This is what you want to do, easy peasy, doesn't require any custom forms or anything.
class Project(models.Model)
name=models.CharField(max_length=200)
project_apps = models.ManyToMany('Application', null=True, blank=True)
class Application(models.Model)
# nothing here (NO foreign key, you want more than one App/Proj and vice versa)
Indicating that this is a many-to-many field in Project will automagically create the multiple select box in admin. Ta da!