Thank you for your help! It seems to be working. I’ll share some code showing how I used this in practice to implement the ability to drag&drop ordered items in my application, just in case this is useful to somebody in the future.
Let’s start from the abstract models I used:
class TrackFieldsMixin(models.Model):
"""
Abstract model used to track changes to a model's fields before
writing those changes to the db
"""
TRACKED_FIELDS = [] # list of field names
class Meta:
abstract = True
@classmethod
def from_db(cls, db, field_names, values):
instance = super().from_db(db, field_names, values)
for fieldname in cls.TRACKED_FIELDS:
setattr(instance, f"_old_{fieldname}", getattr(instance, fieldname))
return instance
class OrderableModel(TrackFieldsMixin):
ORDER_WITH_RESPECT_TO_FIELD = "" # field name
TRACKED_FIELDS = ["_ordering"]
_ordering = models.PositiveIntegerField()
class Meta:
abstract = True
def save(self, force_no_swap=False, *args, **kwargs):
if self.pk is None: # creating new instance
# automatically populate the ordering field based on the ordering of siblings
setattr(self, "_ordering", self.get_ordering_position())
if self._old__ordering != self._ordering and not force_no_swap:
target_ordering, self._ordering = self._ordering, self._old__ordering
while target_ordering != self._ordering:
to_be_swapped = (
self.get_next()
if target_ordering > self._ordering
else self.get_previous()
)
if to_be_swapped is not None:
self.swap_ordering_with(to_be_swapped)
else:
super().save(*args, **kwargs)
def get_siblings(self):
return type(self).objects.filter(
**{
self.ORDER_WITH_RESPECT_TO_FIELD: getattr(
self, self.ORDER_WITH_RESPECT_TO_FIELD
)
},
)
def get_adjacent(self, step):
delta = step
siblings = self.get_siblings()
for _ in range(0, len(siblings)):
try:
return siblings.get(_ordering=self._ordering + delta)
except type(self).DoesNotExist:
delta += step
return None
def get_next(self):
return self.get_adjacent(1)
def get_previous(self):
return self.get_adjacent(-1)
def swap_ordering_with(self, other):
if not isinstance(other, type(self)) or getattr(
self, self.ORDER_WITH_RESPECT_TO_FIELD
) != getattr(other, self.ORDER_WITH_RESPECT_TO_FIELD):
raise ValidationError("Cannot swap with " + str(other))
with transaction.atomic():
self._ordering, other._ordering = other._ordering, self._ordering
other.save(force_no_swap=True)
self.save(force_no_swap=True)
def get_ordering_position(self):
# filter to get parent
filter_kwarg = {
self.ORDER_WITH_RESPECT_TO_FIELD: getattr(
self, self.ORDER_WITH_RESPECT_TO_FIELD
)
}
# get all model instances that reference the same parent
siblings = type(self).objects.filter(**filter_kwarg)
max_ordering = siblings.aggregate(max_ordering=Max("_ordering"))["max_ordering"]
return max_ordering + 1 if max_ordering is not None else 0
The TrackFieldsMixin
, which @KenWhitesell helped me create in another past thread of mine, allows to track changes for some fields. This data can be used, for example, before saving a model instance to see if a specific field was modified.
OrderableModel
is meant to be used with models that have a many-to-one relationship with another model where the ordering on the “many”-part matters.
For example, in my app I have a Question
and a Choice
model. I want to keep track of the ordering of choices for a question, and I want users to be able to change the order of choices in the UI via drag&drop.
What happens is the following: the user drops a choice to a new location on the UI, a PATCH request is issued to the server to change the _ordering
of the choice, which inherits from OrderableModel
.
The change to the field _ordering
is detected using the TrackFieldsMixin
-provided machinery, and the swapping happens.
If choice had _ordering=3
and the request sets it to 1, it will first be swapped with the one that has _ordering=2
(assuming no deletions happened, otherwise it’ll be the first one that has a lower value–or higher, if it was moved upwards), and then with the one that has value 1.
Lastly, the choice model looks like this:
class Choice(OrderableModel):
question = models.ForeignKey(
Question,
related_name="choices",
on_delete=models.CASCADE,
)
# other fields
ORDER_WITH_RESPECT_TO_FIELD = "question"
class Meta:
ordering = ["question_id", "_ordering"]
constraints = [
models.UniqueConstraint(
fields=["question_id", "_ordering"],
name="same_question_unique_ordering",
deferrable=models.Deferrable.DEFERRED,
),
]
How’s this look? I have only tested it in the shell and so far it seems to be working. I’ll write a few test cases. I have noticed that, if I try to edit the ordering field in the admin, it won’t work, probably because the deferred option is ignored during form validation.
I have yet to test this using the REST API of my app, so I still don’t quite know if it works with nested transactions and all. I have a little bit of fear that something might break if I use this with the real API, but I’ll test and see. Until then, if you have any suggestions, they’re very welcome.
Thank you!