How to ForeignKey to a value two tables away?

Viewed 146

I have the following models:

class Ensemble(Group):
    name = models.CharField(max_length=100, null=True, blank=True)
    instrumentation = models.ForeignKey(Instrumentation -> Instrument, verbose_name=_('part'), related_name='ensemble_member', null=True, blank=True, on_delete=models.PROTECT)

class EnsembleMember(models.Model):
    person = models.ForeignKey(Person, verbose_name=_('member'), on_delete=models.PROTECT)
    instrument ???? = models.ForeignKey(Instrumentation, verbose_name=_('part'), related_name='ensemble_member', null=True, blank=True, on_delete=models.PROTECT) //This is the line in question
    ensemble = models.ForeignKey(Ensemble, verbose_name=_('ensemble'), related_name='ensemble_member', on_delete=models.PROTECT)

class Instrumentation(models.Model):
    name = models.CharField(max_length=100, null=True, blank=True)

class Instrument(MPTTModel):
    name = models.CharField(max_length=100, null=True, blank=True)
    category = models.CharField(max_length=100, null=True, blank=True)
    instrumentation = models.ManyToManyField(Instrumentation, verbose_name=_('instrumentation'), related_name='instrument', blank=True)

I would like to be able to able to link EnsembleMembers to only the instruments available in instrumentation under Ensemble. How would I create this ForeignKey relationship.

For example:

There are three instruments: Violin, Cello, and Piano

An instance of instrumentation with this three instruments is called "Piano Trio".

An ensemble called the "Beaux Arts Trio" is linked to the instrumentation "Piano Trio".

"Menahem Pressler" is an Ensemble Member and the pianist in the "Beaux Arts Trio".

I want to link this instrument to the "Piano". Piano is an allowable instrument to be linked because it is in the instrumentation linked to the Ensemble. How do I setup this last connection in the EnsembleMember model?

I'd like to have the following connections before Ensemble and Instrumentation.

Piano Trio (Instrumentation)  --> Beaux Arts Trio (Ensemble)
        ^                                    ^
        |                                    |
        |                                    |
Piano (Instrument)      --> Menahem Pressler (pianist, Ensemble Member)
Violin (Instrument)     --> Daniel Guilet (violinist, Ensemble Member)
Cello (Instrument)      --> Bernard Greenhouse (cellist, Ensemble Member)
3 Answers

I was trying to see if I could help you. Problems I encountered are mainly two.

  1. Ideally a working example one can cut and paste into django and run, e.g. what is Group and MPPTModel?
  2. Complexity is added using new terms/concepts, like ensemble, instrument, instrumentation, I didn't know anything of this, so first I had to understand what it is and how it is connected with each other.

Below is my try and how I interpret your question, you write - "I would like to be able to able to link EnsembleMembers to only the instruments available in instrumentation under Ensemble. How would I create this ForeignKey relationship."

i.e. you want to link an EnsembleMember, such as Mannahem Pressler, to one of the instruments you have predefined within Ensemble. That is, if the Ensemble is a Piano Trio, you have the piano and now you want to link an musician to this piano.

Out of the box, I don't get the edit button in Django Admin to change the musician name on the piano, however you can activate this button, please see this reference Django admin: how to enable add/edit buttons for many-to-many model relationships

if wrong, please comment:

  • the Esemble member Menahem Pressler plays the piano in Beaux Arts Trio
  • the ensemble Beaux Arts Trio has instrumentation Piano Trio
  • available instrumentation is Piano Trio which consist of Piano, Cello, Violin
  • Musicians/Person, on the list is Menahem Pressler
  • For Piano, one musician is currently on the list, Menahem Pressler

from django.db import models

class Person(models.Model):
    name = models.CharField(max_length=100, null=True, blank=True)
    def __str__(self):
        return f'{self.name}'

class Instrument(models.Model): #MPTTModel):
    name = models.CharField(max_length=100, null=True, blank=True)
    category = models.CharField(max_length=100, null=True, blank=True)
    person = models.ManyToManyField('Person', blank=True)

    def __str__(self):
        return f'{self.name}'

class Instrumentation(models.Model):
    name = models.CharField(max_length=100, null=True, blank=True)
    instrument = models.ManyToManyField('Instrument', blank=True)

    def __str__(self):
        return f'{self.name}'

class Ensemble(models.Model):  # Group):
    name = models.CharField(max_length=100, null=True, blank=True)
    instrumentation = models.ForeignKey(Instrumentation, null=True, blank=True, on_delete=models.PROTECT)

    def __str__(self):
        return f'{self.name}'

class EnsembleMember(models.Model):
    person = models.ForeignKey(Person, on_delete=models.PROTECT)
    instrument = models.ForeignKey(Instrument, null=True, blank=True, on_delete=models.PROTECT) # //This is the line in question
    ensemble = models.ForeignKey(Ensemble, on_delete=models.PROTECT)

    def __str__(self):
        return f'{self.person}'

How about this?

class Ensemble(Group):
    name = models.CharField(max_length=100, null=True, blank=True)
    instrumentation = models.ForeignKey(Instrumentation -> Instrument, verbose_name=_('part'), related_name='ensemble_member', null=True, blank=True, on_delete=models.PROTECT)

class EnsembleMember(models.Model):
    person = models.ForeignKey(Person, verbose_name=_('member'), on_delete=models.PROTECT)
    ensemble = models.ForeignKey(Ensemble, verbose_name=_('ensemble'), related_name='ensemble_member', on_delete=models.PROTECT)


ensemble_member = EnsemberMember.objects.first()
instrumentation = ensemble_member.ensemble.instrumentation

It sounds like you would like to use a database constraint to ensure that you don't accidentally add an invalid player's instrument to an ensemble.

I don't see a way to do this at the database level using Django. You can look at constraints in Django 2.2+ if you want to pursue that approach further: https://docs.djangoproject.com/en/2.2/ref/models/constraints/

Personally, I would customize the model manager for the EnsembleMember model to validate the constraint at the application-code level instead.

In your view code, instead of calling...

EnsembleMember.objects.create(...)

...call the custom model manager method (see the test case for a concrete example)

EnsembleMember.objects.add_member(...)

Your models.py file looks like this (I replaced your models.Model subclasses to get my tests to work):

class Person(models.Model):
    name = models.CharField(max_length=100, null=True, blank=True)


class Instrumentation(models.Model):
    name = models.CharField(max_length=100, null=True, blank=True)


class Instrument(models.Model):
    name = models.CharField(max_length=100, null=True, blank=True)
    category = models.CharField(max_length=100, null=True, blank=True)
    instrumentation = models.ManyToManyField(
        Instrumentation,
        verbose_name='instrumentation',
        related_name='instrument',
        blank=True
    )


class Ensemble(models.Model):
    name = models.CharField(max_length=100, null=True, blank=True)
    instrumentation = models.ForeignKey(
        Instrumentation,
        verbose_name='part',
        related_name='ensemble_member',
        null=True, blank=True,
        on_delete=models.PROTECT
    )


# Catch this error in the view
class InvalidInstrumentError(Exception):
    pass


class EnsembleMemberManager(models.Manager):
    # This custom manager enforces your business logic
    def add_member(
        self,
        ensemble: 'Ensemble',
        member: 'Person',
        instrument: 'Instrument'
    ):
        # This clause ensures that the new ensemble member plays a valid
        # instrument for the ensemble's instrumentation
        if not instrument in ensemble.instrumentation.instrument.all():
            raise InvalidInstrumentError
        # Just like EnsembleMember.objects.create
        # use **kwargs if you have more properties
        return self.create(
            person=member,
            instrument=instrument,
            ensemble=ensemble
        )


class EnsembleMember(models.Model):
    person = models.ForeignKey(
        Person,
        verbose_name='member',
        on_delete=models.PROTECT
    )
    # You want the ForeignKey to reference Instrument, not Instrumentation
    # because the person plays a specific instrument and the Ensemble
    # already references Instrumentation.
    # Also updated the related_name so there is no duplication
    instrument = models.ForeignKey(
        Instrument,
        verbose_name='part',
        related_name='ensemble_member_instrument',
        null=True,
        blank=True,
        on_delete=models.PROTECT
    )
    ensemble = models.ForeignKey(
        Ensemble,
        verbose_name='ensemble',
        related_name='ensemble_member',
        on_delete=models.PROTECT
    )

    # Add this line to use the custom manager.
    objects = EnsembleMemberManager()

Here is a test case that demonstrates your use case.

from django.test import TestCase

from model_mommy import mommy

from instruments.models import (
    Person,
    Instrumentation,
    Instrument,
    Ensemble,
    EnsembleMember,
    InvalidInstrumentError,
)

class TestMrPressler(TestCase):
    def test_mgr_constraint(self):
        # Valid instruments
        valid_instrumentation = mommy.make(Instrumentation)
        piano = mommy.make(Instrument)
        violin = mommy.make(Instrument)
        cello = mommy.make(Instrument)

        piano.instrumentation.add(valid_instrumentation)
        violin.instrumentation.add(valid_instrumentation)
        cello.instrumentation.add(valid_instrumentation)

        # Invalid instrument
        kazoo = mommy.make(Instrument)

        beaux_arts_trio = mommy.make(
            Ensemble,
            instrumentation=valid_instrumentation
        )

        menahem_pressler = mommy.make(Person)

        # Mr.Pressler does NOT play kazoo
        with self.assertRaises(InvalidInstrumentError):
            EnsembleMember.objects.add_member(
                ensemble=beaux_arts_trio,
                member=menahem_pressler,
                instrument=kazoo
            )

        # But he fiddles like a beast
        presslers_role = EnsembleMember.objects.add_member(
            ensemble=beaux_arts_trio,
            member=menahem_pressler,
            instrument=violin
        )

        self.assertEqual(
            presslers_role.instrument.name,
            violin.name
        )
Related