Django - How to save m2m data via post_save signal?
Asked Answered
I

3

14

(Django 1.1) I have a Project model that keeps track of its members using a m2m field. It looks like this:

class Project(models.Model):
    members = models.ManyToManyField(User)
    sales_rep = models.ForeignKey(User)
    sales_mgr = models.ForeignKey(User)
    project_mgr = models.ForeignKey(User)
    ... (more FK user fields) ...

When the project is created, the selected sales_rep, sales_mgr, project_mgr, etc Users are added to members to make it easier to keep track of project permissions. This approach has worked very well so far.

The issue I am dealing with now is how to update the project's membership when one of the User FK fields is updated via the admin. I've tried various solutions to this problem, but the cleanest approach seemed to be a post_save signal like the following:

def update_members(instance, created, **kwargs):
    """
    Signal to update project members
    """
    if not created: #Created projects are handled differently
        instance.members.clear()

        members_list = []
        if instance.sales_rep:
            members_list.append(instance.sales_rep)
        if instance.sales_mgr:
            members_list.append(instance.sales_mgr)
        if instance.project_mgr:
            members_list.append(instance.project_mgr)

        for m in members_list:
            instance.members.add(m)
signals.post_save.connect(update_members, sender=Project)  

However, the Project still has the same members even if I change one of the fields via the admin! I have had success updating members m2m fields using my own views in other projects, but I never had to make it play nice with the admin as well.

Is there another approach I should take other than a post_save signal to update membership? Thanks in advance for your help!

UPDATE:

Just to clarify, the post_save signal works correctly when I save my own form in the front end (old members are removed, and new ones added). However, the post_save signal does NOT work correctly when I save the project via the admin (members stay the same).

I think Peter Rowell's diagnosis is correct in this situation. If I remove the "members" field from the admin form the post_save signal works correctly. When the field is included, it saves the old members based on the values present in the form at the time of the save. No matter what changes I make to the members m2m field when project is saved (whether it be a signal or custom save method), it will always be overwritten by the members that were present in the form prior to the save. Thanks for pointing that out!

Inebriate answered 13/12, 2010 at 18:50 Comment(4)
I don't know if this is your problem, but I have a gut feeling that you may be running into an artifact of how the forms code updates m2m info. Basically they first save the main object, then they set the m2m values by first clearing all of them, and then setting them based on the values present in the form. This happens after the save() on the main object, so anything you do in the save() or based on the post_save signal is first done, and then undone. This is in django.forms.models.save_instance(). It would be nice if there were an after_form_save signal.Transmittal
Thanks, Peter! I believe your diagnosis is correct. I updated my original post to include this information.Inebriate
Peter is right. I had the same problem and found a workaround, but it's not a neat as a 'after_form_save' signal: #3653085Bengal
Saved my bacon on this! I was banging my head for the last 30 minutes on this.. Thanks!Phalanger
H
7

Having had the same problem, my solution is to use the m2m_changed signal. You can use it in two places, as in the following example.

The admin upon saving will proceed to:

  • save the model fields
  • emit the post_save signal
  • for each m2m:
    • emit pre_clear
    • clear the relation
    • emit post_clear
    • emit pre_add
    • populate again
    • emit post_add

Here you have a simple example that changes the content of the saved data before actually saving it.

class MyModel(models.Model):

    m2mfield = ManyToManyField(OtherModel)

    @staticmethod
    def met(sender, instance, action, reverse, model, pk_set, **kwargs):
        if action == 'pre_add':
            # here you can modify things, for instance
            pk_set.intersection_update([1,2,3]) 
            # only save relations to objects 1, 2 and 3, ignoring the others
        elif action == 'post_add':
            print pk_set
            # should contain at most 1, 2 and 3

m2m_changed.connect(receiver=MyModel.met, sender=MyModel.m2mfield.through)

You can also listen to pre_remove, post_remove, pre_clear and post_clear. In my case I am using them to filter one list ('active things') within the contents of another ('enabled things') independent of the order in which lists are saved:

def clean_services(sender, instance, action, reverse, model, pk_set, **kwargs):
    """ Ensures that the active services are a subset of the enabled ones.
    """
    if action == 'pre_add' and sender == Account.active_services.through:
        # remove from the selection the disabled ones
        pk_set.intersection_update(instance.enabled_services.values_list('id', flat=True))
    elif action == 'pre_clear' and sender == Account.enabled_services.through:
        # clear everything
        instance._cache_active_services = list(instance.active_services.values_list('id', flat=True))
        instance.active_services.clear()
    elif action == 'post_add' and sender == Account.enabled_services.through:
        _cache_active_services = getattr(instance, '_cache_active_services', None)
        if _cache_active_services:
            instance.active_services.add(*list(instance.enabled_services.filter(id__in=_cache_active_services)))
            delattr(instance, '_cache_active_services')
    elif action == 'pre_remove' and sender == Account.enabled_services.through:
        # de-default any service we are disabling
        instance.active_services.remove(*list(instance.active_services.filter(id__in=pk_set)))

If the "enabled" ones are updated (cleared/removed + added back, like in admin) then the "active" ones are cached and cleared in the first pass ('pre_clear') and then added back from the cache after the second pass ('post_add').

The trick was to update one list on the m2m_changed signals of the other.

Harrovian answered 31/12, 2010 at 17:15 Comment(0)
D
4

I can't see anything wrong with your code, but I'm confused as to why you think the admin should work any different from any other app.

However, I must say I think your model structure is wrong. I think you need to get rid of all those ForeignKey fields, and just have a ManyToMany - but use a through table to keep track of the roles.

class Project(models.Model):
    members = models.ManyToManyField(User, through='ProjectRole')

class ProjectRole(models.Model):
    ROLES = (
       ('SR', 'Sales Rep'),
       ('SM', 'Sales Manager'),
       ('PM', 'Project Manager'),
    )
    project = models.ForeignKey(Project)
    user = models.ForeignKey(User)
    role = models.CharField(max_length=2, choices=ROLES)
Daly answered 13/12, 2010 at 20:37 Comment(1)
I agree that the model structure should be improved, but I'm working with an older implementation and trying to make the most of it. At this time, I'm not ready to migrate the system to this new structure, but I will keep your suggestion in mind for the future. Thanks.Inebriate
J
0

I've stuck on situation, when I needed to find latest item from set of items, that connected to model via m2m_field.

Following Saverio's answer, following code solved my issue:

def update_item(sender, instance, action, **kwargs):
    if action == 'post_add':
        instance.related_field = instance.m2m_field.all().order_by('-datetime')[0]
        instance.save()

m2m_changed.connect(update_item, sender=MyCoolModel.m2m_field.through)
Jansen answered 20/2, 2012 at 11:23 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.