Magnus Teekivi
Magnus Teekivi

Reputation: 493

unique_together with a field from a foreign key in a through table for a ManyToMany relation

I am developing a Django 2.0 project app. It has a (non-working) models.py file, which looks something like this:

from django.db import models
from django.utils import timezone


class Computer(models.Model):
    name = models.CharField(max_length=25)

    def __str__(self):
        return "Computer {}".format(self.name)


class Software(models.Model):
    name = models.CharField(max_length=25)
    description = models.CharField(max_length=1024, blank=True)

    def __str__(self):
        return self.name


class SoftwareVersion(models.Model):
    software = models.ForeignKey(Software, on_delete=models.CASCADE, related_name="versions")
    version = models.CharField(max_length=100)
    released_at = models.DateTimeField(default=timezone.now)

    def __str__(self):
        return "{} {}".format(self.software, self.version)


class ComputerSoftwareBundle(models.Model):
    computer = models.ForeignKey(Computer, on_delete=models.CASCADE, related_name="bundles")
    installed_at = models.DateTimeField(default=timezone.now)
    versions = models.ManyToManyField(SoftwareVersion, through="BundleSoftwareVersion", related_name="bundles")


class BundleSoftwareVersion(models.Model):
    bundle = models.ForeignKey(ComputerSoftwareBundle, on_delete=models.CASCADE)
    version = models.ForeignKey(SoftwareVersion, on_delete=models.CASCADE)

    class Meta:
        unique_together = (("bundle", "version__software"),)

The app tracks software bundles currently or previously installed on computers. The thing here is that a bundle should not contain more than one version of the same software. Also, SoftwareVersion should contain a reference to Software, because the same version string has a different meaning for different pieces of software.

The code does not work as described in this Stackoverflow answer. I left the unique_together line in to illustrate what I am trying to achieve.

I've tried to work around this limitation of Django (not being able to use fields referred to via a foreign key in unique_together) by overriding the save and validate_unique methods in BundleSoftwareVersion but that did not work out completely well. Here's the implementation I have tried:

class BundleSoftwareVersion(models.Model):
    bundle = models.ForeignKey(ComputerSoftwareBundle, on_delete=models.CASCADE)
    version = models.ForeignKey(SoftwareVersion, on_delete=models.CASCADE)

    def save(self, *args, **kwargs):
        self.validate_unique()
        super().save(*args, **kwargs)

    def validate_unique(self, exclude=None):
        super().validate_unique(exclude)

        bundle_versions = BundleSoftwareVersion.objects.filter(bundle=self.bundle,
                    version__software=self.version.software)
        count = len(bundle_versions)
        if not self.pk:
            # if this instance is not stored in the database,
            # we need to increment the count to take this instance
            # into account
            count += 1
        if count > 1:
            raise ValidationError("There already is an instance of software '{}' in this bundle.".format(self.version.software))

I have thus far tried out these models via the admin site. The checks work when changing an existing ComputerSoftwareBundle (the admin site displays a message next to the offending entry), but adding results in an uncaught exception.

Is there a better way to enforce this kind of uniqueness?

Upvotes: 0

Views: 217

Answers (1)

Magnus Teekivi
Magnus Teekivi

Reputation: 493

I have come up with a workaround:

class BundleSoftwareVersion(models.Model):
    bundle = models.ForeignKey(ComputerSoftwareBundle, on_delete=models.CASCADE)
    version = models.ForeignKey(SoftwareVersion, on_delete=models.CASCADE)
    _software = models.ForeignKey(Software, on_delete=models.CASCADE, null=True, editable=False)

    class Meta:
        unique_together = (("bundle", "_software"),)

    def save(self, *args, **kwargs):
        self._software = self.version.software
        super().save(*args, **kwargs)

As you can see, I now have a helper field _software which is used in unique_together and into which the self.version.software is stored on each save.

So far, I have experienced one downside with this approach: trying to save a ComputerSoftwareBundle containing duplicate software instances results in an error page for IntegrityError being displayed instead of an error message within the form.

I would appreciate suggestions on how to fix this downside, or even suggestions for a different approach altogether.

Upvotes: 1

Related Questions