如何在Django中删除未关联的多对多关系?

1 投票
3 回答
1921 浏览
提问于 2025-04-18 00:14

我们有一个标签系统,用户可以通过定义的标签来过滤他们的文件。

下面是模型的设置:

class Tags(models.Model):
    name = models.CharField(max_length=100)
    user = models.ForeignKey(User)

class Files(models.Model):
    user = models.ForeignKey(User)
    name = models.CharField(max_length=100)
    tags = models.ManyToManyField(Tags, null=True, blank=True)

现在,因为标签不是必需的,当我们从文件中移除标签时,这些标签不会被删除。这就导致我们的数据库里保存了一堆标签,而我们想要清理这些标签。

我尝试过重新定义文件模型的保存方法,以及清理方法。

我还尝试在文件模型上连接一个 m2m_changed 信号:https://docs.djangoproject.com/en/dev/ref/signals/#m2m-changed

我最后尝试的是一个 pre_save 信号:https://docs.djangoproject.com/en/dev/ref/signals/#pre-save

我原本打算遍历这些标签,删除那些没有关联文件的标签,但使用这些方法我无法可靠地判断(也就是说,我最终会删除一些标签,这些标签虽然现在没有关联,但马上就要被关联了,因为 m2m_changed 会因为不同的操作多次触发)。

这是我认为可行的方案:

def handle_tags (sender, instance, *args, **kwargs) :
    action = kwargs.get('action')
    if action == 'post_clear':
        # search through users tags... I guess?
        tags = Tags.objects.filter(user=instance.user)
        for tag in tags:
            if not tag.files_set.exists():
                tag.delete()
    return

m2m_changed.connect(handle_tags, sender=Files.tags.through)

但是,正如我所说的,这样会在标签被添加之前就删除它(如果它被添加了,我们显然不想删除它)。

3 个回答

-1

我觉得你想得有点复杂了。只需要为Tag定义一个related_name,然后处理File的post_save信号就可以了。

class Files(models.Model):
    user = models.ForeignKey(User)
    name = models.CharField(max_length=100)
    tags = models.ManyToManyField(Tags, null=True, blank=True, related_name='files')


def clean_empty_tags(sender, instance, *args, **kwargs):
     Tags.objects.filter(user=instance.user, files=None).delete()

post_save.connect(clean_empty_tags, sender=Files)
0

简单总结一下,希望能更清楚一些:

from django.db.models.signals import m2m_changed
from django.dispatch import receiver

class Tags(models.Model):
    name = models.CharField(max_length=100)
    user = models.ForeignKey(User)

class Files(models.Model):
    user = models.ForeignKey(User)
    name = models.CharField(max_length=100)
    tags = models.ManyToManyField(Tags, null=True, blank=True)


@receiver(m2m_changed, sender=Files.tags.through)
def delete_orphean_dateranges(sender, **kwargs):
    if kwargs['action'] == 'post_remove':
        Tags.objects.filter(pk__in=kwargs['pk_set'], files_set=None).delete()

post_remove 确保在一个标签和文件断开关联时,会触发相应的回调函数。

3

你在使用 m2m_changed 信号时走在正确的方向上。

你的问题是,当你响应 post_clear 信号时,标签已经被删除了,所以你无法像那样访问它们。

其实你需要在标签被删除之前就执行你的方法,这就意味着要处理 pre_clear 信号。

可以这样做:

@receiver(m2m_changed, sender=Files.tags.through)
def handle_tags(sender, **kwargs):

    action = kwargs['action']

    if action == "pre_clear":
        tags_pk_set = kwargs['instance'].tags.values_list('pk')
    elif action == "pre_remove":
        tags_pk_set = kwargs.get('pk_set')
    else:
        return

    # I'm using Count() just so I don't have to iterate over the tag objects
    annotated_tags = Tags.objects.annotate(n_files=Count('files'))
    unreferenced = annotated_tags.filter(pk__in=tags_pk_set).filter(n_files=1)
    unreferenced.delete()

我还添加了对 pre_remove 信号的处理,你可以用 pk_set 参数来获取将要被删除的实际标签。

更新

当然,之前的监听器在删除文件时不会删除那些没有被引用的标签,因为它只处理 Tags 模型的 pre_clearpre_remove 信号。为了实现你想要的效果,你还应该处理 Files 模型的 pre_delete 信号。

在下面的代码中,我添加了一个工具函数 remove_tags_if_orphan,这是 handle_tags 的稍微修改版,还有一个新的处理器 handle_file_deletion,用于删除那些在文件被删除后会变得没有引用的标签。

def remove_tags_if_orphan(tags_pk_set):
    """Removes tags in tags_pk_set if they're associated with only 1 File."""

    annotated_tags = Tags.objects.annotate(n_files=Count('files'))
    unreferenced = annotated_tags.filter(pk__in=tags_pk_set).filter(n_files=1)
    unreferenced.delete()


# This will clean unassociated Tags when clearing or removing Tags from a File
@receiver(m2m_changed, sender=Files.tags.through)
def handle_tags(sender, **kwargs):
    action = kwargs['action']
    if action == "pre_clear":
        tags_pk_set = kwargs['instance'].tags.values_list('pk')
    elif action == "pre_remove":
        tags_pk_set = kwargs.get('pk_set')
    else:
        return
    remove_tags_if_orphan(tags_pk_set)


# This will clean unassociated Tags when deleting/bulk-deleting File objects
@receiver(pre_delete, sender=Files)
def handle_file_deletion(sender, **kwargs):
    associated_tags = kwargs['instance'].tags.values_list('pk')
    remove_tags_if_orphan(associated_tags)

希望这样能让事情更清楚。

撰写回答