Howdy;
We’re using Django 5.2 and trying to implement a “soft delete” style boolean flag on a through-model, and wanting the name and related_name lookups from the two models on either side of the through-model to respect the flag by default.
The flag is a boolean field called active on the through table for the m2m field. Here’s a simplified set of models. A Staff member can work for many Bosses, and a Boss can have many Staff
# models.py
class Boss(models.Model):
name = models.CharField(max_length=255)
class Staff(models.Model):
name = models.CharField(max_length=255)
bosses = models.ManyToManyField(
Boss, related_name="staff", through="StaffToBoss"
)
class StaffToBoss(models.Model):
staff = models.ForeignKey(Staff, on_delete=models.CASCADE)
boss = models.ForeignKey(Boss, on_delete=models.CASCADE)
active = models.BooleanField(default=False)
#views.py
bossypants = Boss.objects.get(id=1) # assume this is legit
bossypants.staff.all()
staffy = Staff.objects.get(id=1)
staffy.bosses.all()
By default both reverse relationships (.staff.all() and .bosses.all()) will return all records whether active is True or False. What I want to do is override the behavior for .all() to filter for active=True
I’ve tried searching and attempted various approaches with Managers and Queryset overrides for the through-table, but those overrides don’t affect the .ManyRelatedManager responsible for the related lookups.
Any help would be great, or being told definitely it’s just not possible (and perhaps why) would also be appreciated.
Thanks!
Hey @massover thanks for the response.
Very cool – base_manager is a new angle of attack I hadn’t seen, and it gives me some new options to dig into or possibly the default_manager Meta overrides.
I found this thread which really gets sooooo close to my situation – except their deleted flag (my version of active) isn’t on their through-model, so their manager overrides aren’t where I need them.
I need to deactivate the relationship (the through table), not the instances on either side of it. I tried changing the base_manager and default_manager to operate on the through table, but no luck yet.
At this point I’d settle just adding a new method, e.g.
bossypants.staff.active()
but I imagine if I could figure that out I’d be able to adjust the behavior of .all()
I’ll keep poking, and I appreciate your response!
At a minimun, you could do this as a query filtering on the through table directly. In other words, if you have an instance of Boss named boss, and you want all the Staff associated with boss where active == True, you could write your query as:
staff_list = Staff.objects.filter(stafftoboss__boss=boss, stafftoboss_active=True)
(And the converse, where you have a Staff and you want all related Boss.)
I think that the reason your through-model Manager/QuerySet overrides don’t take effect is that the reverse and forward M2M accessors don’t use the through model’s manager at all. `staffy.bosses.all()` builds its query on the **target** model (`Boss`) using that model’s manager class, joining *through* `StaffToBoss` — so any manager you attach to `StaffToBoss` is bypassed. `base_manager_name` won’t help either, for the same reason, and a custom manager on `Boss`/`Staff` would break plain `Boss.objects.all()` since `active` lives on the join table.
The only clean way to change `.all()` itself is to override the descriptor so the *related manager* adds the filter:
from functools import cached_property
from django.db.models.fields.related_descriptors import ManyToManyDescriptor
class ActiveM2MDescriptor(ManyToManyDescriptor):
@cached_property
def related_manager_cls(self):
base = super().related_manager_cls
through = self.rel.through.\_meta.model_name # "stafftoboss"
class ActiveRelatedManager(base):
def get_queryset(self):
return super().get_queryset().filter(\*\*{f"{through}\__active": True})
return ActiveRelatedManager
Then swap the descriptor onto both sides after the field is set up (e.g. via a `ManyToManyField` subclass that assigns `ActiveM2MDescriptor` in `contribute_to_class`, for both the forward and reverse accessors). The lookup `stafftoboss__active=True` works from both `Boss` and `Staff` since the reverse relation name to the through table is the same on each side.
Caveat worth flagging: this changes read behavior of .all() only. add()/set()/remove() and prefetch still operate on all rows — which is usually what you want for a soft delete, but it does mean `.all()` no longer mirrors what’s physically in the table, so anyone reading the code later may be surprised.
If hiding rows behind `.all()` feels too implicit, the lower-magic alternative is to leave `.all()` honest and add explicit helpers like `active_bosses` / `active_staff` that do `.filter(stafftoboss__active=True)`
@KenWhitesell Thanks for that example, I had in fact resigned myself to just adding methods on the primary models, e.g.
def active_staff(self) -> models.QuerySet:
# figuring this out took an embarrassing amount of time
return self.staff.filter(stafftoboss__active=True)
@louzolo123
Wow! Amazing and after some tweaks, I think I have something working? Or at least the initial tests are working. Here’s what it looks like:
from functools import cached_property
from django.db import models
from django.db.models.fields.related_descriptors import ManyToManyDescriptor
class ActiveManyToManyMDescriptor(ManyToManyDescriptor):
@cached_property
def related_manager_cls(self):
base = super().related_manager_cls
through = self.rel.through._meta.model_name # "stafftoboss"
class ActiveRelatedManager(base):
def _get_active_queryset(self, active: bool):
return super().get_queryset().filter(**{f"{through}__active": active})
def active(self):
return self._get_active_queryset(True)
def inactive(self):
return self._get_active_queryset(False)
return ActiveRelatedManager
# https://github.com/django/django/blob/stable/5.2.x/django/db/models/fields/related.py#L1957C9-L1957C55
class ActiveManyToManyField(models.ManyToManyField):
"""
Subclass the django m2m field and re-assign accessor classes
to subclassed ActiveManyToManyMDescriptor class to use
apply filtering methods
"""
def contribute_to_class(self, cls, name, **kwargs):
super().contribute_to_class(cls, name, **kwargs)
# Add the descriptor for the m2m relation.
setattr(cls, self.name, ActiveManyToManyMDescriptor(self.remote_field, reverse=False))
def contribute_to_related_class(self, cls, related):
super().contribute_to_related_class(cls, related)
# only set this if necessary based on the super()'s logic
if getattr(cls, related.accessor_name):
setattr(
cls,
related.accessor_name,
ActiveManyToManyMDescriptor(self.remote_field, reverse=True),
)
@louzolo123 Note the ActiveManyToManyField with the overrides and reassignment of the ActiveManyToManyMDescriptor
Is this what you were recommending? For now I’m sticking with the active() and inactive() rather than overriding .all() but all the tests seem to work so far.
Thanks so much for the help everyone. Messier than I hoped, but very satisfying to see that it’s doable.