Django admin save not sending post_remove action with m2m_changed signal - django

I'm trying to get a many to many model to update when I save a related model. This should be possible using the m2m_changed signal (and it works! but not in the admin?) e.g.
# i want the references field to update when related model is saved.
# so just call count_references
class Tag(models.Model):
"""Group everything into categories"""
# stuff stuff stuff
references = models.IntegerField(default=0, editable=False)
def count_references(self):
# just add up references each time to save headaches
self.references = 0
# search for reverse managers
sets = re.compile('^\w+_set$')
for rel_set in [method for method in dir(self) if sets.match(method)]:
self.references += getattr(self, rel_set).count()
self.save()
class Entry(models.Model):
"""Blog entry"""
# stuff stuff stuff
tags = models.ManyToManyField('Tag', blank=True)
# this will call count_references when entry adds or removes tags
#receiver(m2m_changed, sender=Entry.tags.through)
def update_tag_ref_count(sender, instance, action, reverse, model, pk_set, **kwargs):
print action
if not reverse and action == 'post_add' or action == 'post_remove':
for tag_pk in pk_set:
print tag_pk
Tag.objects.get(pk=tag_pk).count_references()
print Tag.objects.get(pk=tag_pk).references
Everything works perfectly when run in the shell. e.g. with a tests.py like so:
t = Tag.objects.all()[0]
s = Snippet.objects.all()[0]
s.tags.remove(t)
s.save()
s.tags.add(t)
s.save()
I get the following (where 'test' is the tag name being printed):
pre_remove
post_remove
test
0
pre_add
post_add
test
1
perfect! And when I add a tag to an entry in the admin I get the following (between HTTP stuff):
pre_clear
post_clear
pre_add
post_add
test
1
still good! not sure what pre/post_clear was called for... and when I remove:
pre_clear
post_clear
argh! pre/post_remove is not called! pre/post_clear is useless as well as it doesn't provide any primary keys. this feels like a bug in the admin implementation. any suggestions?
Update: Bug #16073 filed and accepted.

(Creating this as a community wiki to close out this as an "unanswered" question.)
This is a bug in Django. OP filed a ticket at https://code.djangoproject.com/ticket/16073.

Related

Access Django through model extra fields in m2m_changed signal handler

Let's say I have the following Django models, which represents a sorted relationship between a Parent and Child:
class Parent(models.Model):
name = models.CharField(max_length=50)
children = models.ManyToManyField("Child", through="ParentChild")
class Child(models.Model):
name = models.CharField(max_length=50)
class ParentChild(models.Model):
class Meta:
constraints = [
models.UniqueConstraint(fields=["parent", "child"], name="uc_parent_child"),
models.UniqueConstraint(fields=["parent", "sort_number"], name="uc_parent_child"),
]
parent = models.ForeignKey(Parent, on_delete=models.CASCADE)
child = models.ForeignKey(Child, on_delete=models.CASCADE)
sort_number = models.IntegerField()
def save(self, *args, **kwargs):
exising_sort_numbers = self.parent.parentchild_set.values_list(
"sort_number", flat=True
)
if self.sort_number in exising_sort_numbers:
raise Exception(f"Duplicate sort number: {self.sort_number}")
super().save(*args, **kwargs)
Now if I create the relationships using the through model, I get the exception for a duplicate sort_number:
ParentChild.objects.create(parent=parent, child=child1, sort_number=0)
ParentChild.objects.create(parent=parent, child=child2, sort_number=0) # raises Exception
However, if I create the relationships using the .add method, I don't get the exception:
parent.children.add(child1, through_defaults={"sort_number": 0})
parent.children.add(child2, through_defaults={"sort_number": 0}) # does NOT raise Exception
I know using the .add method doesn't call the .save method on the through model so I need to use the m2m_change signal to run this logic. But I'm not sure how to get the sort_number within this signal. Here's the code I have for the signal so far:
#receiver(m2m_changed, sender=Parent.children.through)
def validate_something(sender, instance, action, reverse, model, pk_set, **kwargs):
if action == "pre_add":
for pk in pk_set:
child = model.objects.get(pk=pk)
exising_sort_numbers = instance.parentchild_set.values_list(
"sort_number", flat=True
)
# where's sort_number specified in through_defaults ???
Any idea how I can get this value and perform the "pre_add" validation or is this not possible?
You have this constraint - models.UniqueConstraint(fields=["parent", "sort_number"], name="uc_parent_child"), which means that you can't have more than one relation with the same parent and sort_number. There's even an extra check in ParentChild's save method to further enforce this. Makes sense to have an exception thrown when you try to create such a relation.
Also, the constraint name needs to be unique. I tried the code and couldn't make migrations as is.
If you do what you are trying to, you'll get that exception again when saving.
Instead of trying to hack around the constraint, you should either change/remove it or adapt your code to work with it - don't try to create instance which will violate it.
As to your specific question,the instance you get in validate_something is Parent and there's no direct access to the intermediary instance or it's defaults. You also can't query the intermediary instance, because it does not exist yet.
For any googlers that might be looking for a way to handle through model fields, you can't get it in pre_add as #4140tm said, since the record doesn't exist yet. But you can work around it on post_add with some effort:
#receiver(m2m_changed, sender=Parent.children.through)
def validate_something(sender, instance, action, reverse, model, pk_set, **kwargs):
# notice this is not 'pre_add':
if action == "post_add":
# just for clarity:
parent = instance
child_model = model
through_model = sender
# > OP: where's sort_number specified in through_defaults ???
# here it is:
existing_sort_numbers = [pc.sort_number \
for pc in through_model.objects.filter(parent=parent.id) \
if pc.child not in pk_set]
# now just work around (rollback, raise exception, etc):
for pk in pk_set:
added_child = child_model.objects.get(id=pk)
# goes on dirty work...

How do I accept multiple choices for a Charfield from CheckboxSelectMultiple widget in Django?

I'm trying to build a form in Django 1.11, where I have a set of checkboxes on a watch list to allow a user to set multiple alerts on an item they want to receive notifications on later. However, I'm not sure how to represent multiple options on a field like this.
Here's my model code:
class Watchlist(models.Model):
CLOSES_IN_2_WEEKS, CLOSES_IN_1_WEEKS, CLOSES_IN_3_DAYS, CLOSES_TOMORROW = (
"CLOSES_IN_2_WEEKS",
"CLOSES_IN_1_WEEKS",
"CLOSES_IN_3_DAYS",
"CLOSES_TOMORROW"
)
ALERT_OPTIONS = (
(CLOSES_IN_2_WEEKS, "Closes in 2 weeks",),
(CLOSES_IN_1_WEEKS, "Closes in 1 weeks",),
(CLOSES_IN_3_DAYS, "Closes in 3 days",),
(CLOSES_TOMORROW, "Closes tomorrow",),
)
# I want to store more than one option
alert_options = models.CharField(max_length=255, blank=True)
def save(self):
"""
If this is submitted create 1 alert:
"CLOSES_IN_1_WEEKS"
If this submitted, create 3 alerts:
"CLOSES_IN_2_WEEKS",
"CLOSES_IN_1_WEEKS",
"CLOSES_IN_3_DAYS",
"""
# split the submitted text values, to create them
# yes, this should probably be a formset. I wasn't sure how I'd
# handle the logic of showing 4 optional alerts, and only creating models
# on the form submission, and remembering to delete them when a user
# unchecked the choices in the form below
And here's the form I'm using, below. I'm hooking into the __init__ method to pre-populate the form with possible choices.
class WatchlistForm(forms.ModelForm):
alert_options = forms.ChoiceField(
choices=[],
label="Alert Options",
required=False,
widget=forms.CheckboxSelectMultiple(),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["alert_options"].choices = WatchlistForm.ALERT_OPTIONS
def clean_alert_options(self):
# I'm dropping into ipython to look around here
import ipdb; ipdb.set_trace()
return data["alert_options"]
class Meta:
model = WatchlistForm
fields = ("alert_options")
This currently works fine for a single checked checkbox, but once I have more than one, only the last choice is selected, and I can't quite figure out how to access it.
How can I capture all the choices here, rather than just the one?
I'm aware I probably should be using a formset here. The thing is it wasn't obvious to me how to create a set of pre-populated formset options to represent some active and some inactive alert choices.
Using a test to show what I'm going for
If it helps, I'm trying to save the information so it would be stored like so - I've added some pseudo code based on me using pytest in a test suite.
def test_form_accepts_multiple_alert_values(self, db, verified_user):
form_data = {
"user_id": verified_user.id,
"alert_options": "CLOSES_IN_2_WEEKS CLOSES_IN_3_DAYS",
}
submission = forms.WatchlistForm(form_data)
instance = submission.save()
assert instance.alert_options == "CLOSES_IN_2_WEEKS CLOSES_IN_3_DAYS",

django ManyToManyField and on_delete

ForeignKeys on django have the attribute on_delete to specify the behavior when the referenced object is deleted. Is there any way to get something similar for ManyToManyField?
Suppose I have the following model
class House(models.Model):
owners = models.ManyToManyField(Person)
The default behavior is to cascade, so if I delete a person that happens to own a house, it just vanishes from owners (that is, obviously, it no longer owns any houses). What I'd like to have is that if a person is an owner, it can not be deleted. That is, I want on_delete=models.PROTECT. Is this possible?
I know internally ManyToManyField is translated to another model with two ForeignKeys (in this case one to house and one to person), so it should be possible to achieve this. Any ideas how to? I'd like to avoid setting the through attribute to a new model, because this would result in a new table (I'd like to keep the old one).
Edit: I've tracked where django creates the appropriate m2m model:
def create_many_to_many_intermediary_model(field, klass):
from django.db import models
# ...
# Construct and return the new class.
return type(name, (models.Model,), {
'Meta': meta,
'__module__': klass.__module__,
from_: models.ForeignKey(klass,
related_name='%s+' % name,
db_tablespace=field.db_tablespace),
to: models.ForeignKey(to_model,
related_name='%s+' % name,
db_tablespace=field.db_tablespace)
})
The relevant line is
to: models.ForeignKey(to_model,
related_name='%s+' % name,
db_tablespace=field.db_tablespace)
I'd like it to be
to: models.ForeignKey(to_model,
related_name='%s+' % name,
db_tablespace=field.db_tablespace,
on_delete=models.PROTECT)
Any way to do this other than monkey patching the whole thing and creating a new class for ManyToManyField?
I think the smartest thing to do is use an explicit through table. I realise that you've stated you would prefer not to "because this would result in a new table (I'd like to keep the old one)."
I suspect your concern is over losing the data you have. If you're using South, you can easily "convert" your existing, automatic intermediate table to an explicit one OR, you can create a completely new one, then migrate your existing data to the new table before dropping your old one.
Both of these methods are explained here: Adding a "through" table to django field and migrating with South?
Considering the change you'd like to make to its definition, I'd probably go with the option of creating a new table, then migrating your data over. Test to make sure all your data is still there (and that your change does what you want), then drop the old intermediate table.
Considering that these tables will both only hold 3 integers per row, this is likely to be a very manageable exercise even if you have a lot of houses and owners.
If I understand you want, this is similar to what I need some time ago.
Your problem: you need to protect a record that is used in another table from accidental deletion.
I solved it from this way (tested on Django 2 and Django 3).
Imagine, you have:
TABLE1 and TABLE 2, and they are under M2M relationship where TABLE1 has ManyToManyField.
I put the main keys to you understand at uppercase, you will need to adjust to what you want.
Look at views.py that use the exists() method and rise the exception are crucial.
models.py
class TABLE1(models.Model):
FIELD_M2M = models.ManyToManyField(
TABLE2,
blank=False,
related_name='FIELD_M2M',
)
#put here your code
models.py
class TABLE2(models.Model):
#Put here your code
views.py
# Delete
#login_required
def delete(request, pk=None):
try: # Delete register selected
if TABLE1.objects.filter(FIELD_M2M=pk).exists():
raise IntegrityError
register_to_delete = get_object_or_404(TABLE2, pk=pk)
# register_to_delete.register_to_delete.clear() // Uncomment this, if you need broken relationship M2M before delete
register_to_delete.delete()
except IntegrityError:
message = "The register couldn't be deleted!"
messages.info(request, message)
That is a ugly solution, but it works.
Posting my own solution as requested by #Andrew Fount. Quite an ugly hack just to change a single line.
from django.db.models import ManyToManyField
from django.db.models.fields.related import ReverseManyRelatedObjectsDescriptor, add_lazy_relation, create_many_to_many_intermediary_model, RECURSIVE_RELATIONSHIP_CONSTANT
from django.utils import six
from django.utils.functional import curry
def create_many_to_many_protected_intermediary_model(field, klass):
from django.db import models
managed = True
if isinstance(field.rel.to, six.string_types) and field.rel.to != RECURSIVE_RELATIONSHIP_CONSTANT:
to_model = field.rel.to
to = to_model.split('.')[-1]
def set_managed(field, model, cls):
field.rel.through._meta.managed = model._meta.managed or cls._meta.managed
add_lazy_relation(klass, field, to_model, set_managed)
elif isinstance(field.rel.to, six.string_types):
to = klass._meta.object_name
to_model = klass
managed = klass._meta.managed
else:
to = field.rel.to._meta.object_name
to_model = field.rel.to
managed = klass._meta.managed or to_model._meta.managed
name = '%s_%s' % (klass._meta.object_name, field.name)
if field.rel.to == RECURSIVE_RELATIONSHIP_CONSTANT or to == klass._meta.object_name:
from_ = 'from_%s' % to.lower()
to = 'to_%s' % to.lower()
else:
from_ = klass._meta.object_name.lower()
to = to.lower()
meta = type('Meta', (object,), {
'db_table': field._get_m2m_db_table(klass._meta),
'managed': managed,
'auto_created': klass,
'app_label': klass._meta.app_label,
'db_tablespace': klass._meta.db_tablespace,
'unique_together': (from_, to),
'verbose_name': '%(from)s-%(to)s relationship' % {'from': from_, 'to': to},
'verbose_name_plural': '%(from)s-%(to)s relationships' % {'from': from_, 'to': to},
})
# Construct and return the new class.
return type(name, (models.Model,), {
'Meta': meta,
'__module__': klass.__module__,
from_: models.ForeignKey(klass, related_name='%s+' % name, db_tablespace=field.db_tablespace),
### THIS IS THE ONLY LINE CHANGED
to: models.ForeignKey(to_model, related_name='%s+' % name, db_tablespace=field.db_tablespace, on_delete=models.PROTECT)
### END OF THIS IS THE ONLY LINE CHANGED
})
class ManyToManyProtectedField(ManyToManyField):
def contribute_to_class(self, cls, name):
# To support multiple relations to self, it's useful to have a non-None
# related name on symmetrical relations for internal reasons. The
# concept doesn't make a lot of sense externally ("you want me to
# specify *what* on my non-reversible relation?!"), so we set it up
# automatically. The funky name reduces the chance of an accidental
# clash.
if self.rel.symmetrical and (self.rel.to == "self" or self.rel.to == cls._meta.object_name):
self.rel.related_name = "%s_rel_+" % name
super(ManyToManyField, self).contribute_to_class(cls, name)
# The intermediate m2m model is not auto created if:
# 1) There is a manually specified intermediate, or
# 2) The class owning the m2m field is abstract.
# 3) The class owning the m2m field has been swapped out.
if not self.rel.through and not cls._meta.abstract and not cls._meta.swapped:
self.rel.through = create_many_to_many_protected_intermediary_model(self, cls)
# Add the descriptor for the m2m relation
setattr(cls, self.name, ReverseManyRelatedObjectsDescriptor(self))
# Set up the accessor for the m2m table name for the relation
self.m2m_db_table = curry(self._get_m2m_db_table, cls._meta)
# Populate some necessary rel arguments so that cross-app relations
# work correctly.
if isinstance(self.rel.through, six.string_types):
def resolve_through_model(field, model, cls):
field.rel.through = model
add_lazy_relation(cls, self, self.rel.through, resolve_through_model)

django post_save signal sends outdated inline formsets

Consider the following:
class OrderForm(models.Model):
title = models.CharField(max_length=100)
desc = models.TextField()
class OrderFormLine(models.Model):
order = models.ForeignKey(OrderForm)
lagel = models.CharField(max_length=100)
qty = models.IntegerField(...)
price = models.FloatField(...)
Now I want to send an email with the orderform details whenever someone creates one or modify one.
No rocket science so far .. let's just use a post_save signal;
post_save.connect(email_notify, sender=OrderForm)
But there's one tiny problem, the OrderForm object passed to email_notify is updated with the new data as expected, but not the related OrderFormLine items.
I've tried to override the save methods in the admin AND in the model, I've tried to save the object, the form and its relation before passing it to my notification handler, nothing works.
I'm aware that I could attach the post_save signal to the OrderItem model, but then the email would be sent for each items.
Help I'm on the brink of madness.
UPDATE:
Found a simple and reliable solution
Short story:
def email_notify_orderform(sender, **kwargs):
instance = kwargs['instance']
ct = ContentType.objects.get_for_model(OrderForm)
if ct.id == instance.content_type.id:
print instance.is_addition()
print instance.is_change()
print instance.is_deletion()
print instance.change_message
print instance.action_time
print instance.get_edited_object().total() # BINGO !
post_save.connect(email_notify_orderform, sender=LogEntry)
The basic problem is that when the main objects post_save signal is sent, the inlines have not been saved yet: the parent model always gets saved first. So, it's not that it's sending old data; in fact it's the current state of the data.
The simplest solution is to create a custom signal and have that signal sent at a place where the inlines have been saved. The save_formset method on ModelAdmin is your hook.

Saving ForeignKey objects in Django

I'm completely stumped as to why this isn't working:
flight = Flight.objects.get(pk=flight_id)
print "old", flight.route.pk ## `route` is a ForeignKey field to model Route
print "new", new_route.pk
flight.route=new_route # new_route is a newly created Route object
flight.save()
print "db", Flight.objects.get(pk=flight_id).route.pk
this is the output:
old 4800
new 7617
db 4800
Is there some special way I need to call save() on the flight to get it to actually save?
edit: my models look like this:
class Flight(models.Model):
route = models.ForeignKey(Route, blank=True, null=True, related_name="flight")
class Route(models.model):
# a bunch of CharFields and IntegerFields
Has the new_route been saved? Assuming pk would not return a result, but unable to test.
Otherwise see http://code.djangoproject.com/ticket/8892
OK I just figured it out, I recently moved my custom save function from the bottom of the class definition to the top, and I forgot the last line that calls super(Flight, self).save(*args, **kwargs)