Skip to content

Study ownership: many-to-many

About 20 minutes

The relationships we built in chapter 4 are one-to-many: a study has many datasets, and each dataset belongs to exactly one study. What happens though when a study might be run by two collaborators, and they each might have several other studies with others? Meet the many-to-many relationship.

A model for researchers

Add a Researcher model to study/models.py. Put it above the Study class, just below the from django.db import models line. Study is about to refer to Researcher by name, and Python only knows a name once it has read it.

class Researcher(models.Model):
    """A researcher who owns and registers studies.

    This gives us the other half of a ManyToMany relationship with Study: one study can
    have several researchers, and one researcher can own several studies. Django creates
    the link table (``study_study_researchers``) for that relationship automatically.
    """

    name = models.CharField(max_length=200)
    email = models.EmailField(blank=True)

    def __str__(self):
        return self.name

study/models.py on GitHub

Then we link it to Study with a ManyToManyField. Add this inside the Study class, indented like the other fields, just below completion_url:

    researchers = models.ManyToManyField(
        Researcher,
        related_name="studies",
        blank=True,
        help_text="The researcher(s) who own this study.",
    )

study/models.py on GitHub

That's a new model and a new field, so both need migrating:

Remind me: how do I run a migration?

Changing a model changes the shape your database needs to be. makemigrations works out what changed and writes it down as a migration file; migrate applies that file to the database. Two commands, every time, in that order:

uv run python manage.py makemigrations   # write the migration from your model changes
uv run python manage.py migrate          # apply it to the database

The migration file is code, so commit it alongside the model change. That's what makes your database schema travel with the project.

You don't create a table for this yourself. Django makes a hidden link table, study_study_researchers (the app name, then the model, then the field), with two foreign keys linking to the appropriate rows on other tables.

     Study               study_study_researchers         Researcher
 ┌────┬─────────┐       ┌────┬──────────┬───────────────┐   ┌────┬───────────────────┐
 │ id │ name    │       │ id │ study_id │ researcher_id │   │ id │ name              │
 ├────┼─────────┤       ├────┼──────────┼───────────────┤   ├────┼───────────────────┤
 │  1 │ Flanker │       │  1 │    1     │       1       │   │  1 │ Andy Woods        │
 │  2 │ Stroop  │       │  2 │    1     │       2       │   │  2 │ Laryssa Whittaker │
 └────┴─────────┘       │  3 │    2     │       2       │   │  3 │ Maruša Levstek    │
      ▲                 └────┴────┬─────┴──────┬────────┘   └────┴───────────────────┘
      │                          │            │                  ▲
      └──────────────────────────┘            └──────────────────┘
        study_id → Study.id            researcher_id → Researcher.id

Read the middle table a row at a time:

  • Row 1 ties Flanker (study 1) to Andy Woods (researcher 1)
  • Row 2 ties Flanker again, this time to Laryssa Whittaker (2), so one study has two owners
  • Row 3 ties Stroop (2) back to Laryssa Whittaker, so one researcher owns two studies

Maruša Levstek (3) has no study yet: a researcher with no studies simply has no rows in the middle table. This is the same arrangement used in the paper's model diagram.

Reading a model relationship 'from either end'

Because we set related_name="studies", the relationship reads naturally in both directions:

study.researchers.all()    # who owns this study
researcher.studies.all()   # what this researcher owns

In the admin

Register Researcher and you get the usual admin screens for it, so you can add people and see who is on the platform. In study/admin.py, add Researcher to the models import, so it reads from .models import Participant, Researcher, Study, StudyData, then add:

@admin.register(Researcher)
class ResearcherAdmin(admin.ModelAdmin):
    list_display = ["name", "email"]

study/admin.py on GitHub

The Researcher list in the Django admin, showing three researchers with their email addresses
The three researchers from the table above, in the admin.

Assigning them to a study is the other half. A raw multi-select box for that is miserable to use once you have more than a handful of researchers, so filter_horizontal gives you the friendlier two-pane picker instead. Add the filter_horizontal line to the StudyAdmin you already have (don't register Study a second time):

@admin.register(Study)
class StudyAdmin(admin.ModelAdmin):
    list_display = ["name", "slug", "created"]
    prepopulated_fields = {"slug": ["name"]}
    filter_horizontal = ["researchers"]

study/admin.py on GitHub

The researchers picker on the Study admin form

Open a study in the admin and you'll find the picker at the bottom of the form. Move a researcher across, save, and study.researchers.all() now includes them.