Skip to content

1 · Setting up the project

About 30 minutes

Apparatus for measuring the personal equation

Apparatus for measuring the "personal equation", each observer's own reaction-time offset.
Image: public domain, via Wikimedia Commons.

By the end of this chapter you'll have a running Django project with one model, a Study, that you can add to and edit through Django's built-in admin.

Create the project

Go into your terminal and find somewhere you want to save your project (e.g. ~/projects). Make a folder, start a uv project inside it, then let uv and Django make the skeleton of your project for you:

# somewhere to keep everything
mkdir behavioural-study && cd behavioural-study   

# start a uv project (writes pyproject.toml)
uv init --python 3.14               

# add Django as a dependency
uv add "django>=6.1,<6.2"

# the project is "config"; the dot keeps manage.py here            
uv run django-admin startproject config .  

# our app, where the study code lives
uv run python manage.py startapp study           

On Windows

mkdir and cd are the same on Windows, but chaining them with && only works in the newer PowerShell 7 (and in the old cmd.exe). In Windows PowerShell 5.1, the one that comes with Windows, run them as two lines instead:

mkdir behavioural-study
cd behavioural-study

Paths differ too: ~/projects becomes C:\Users\you\projects. Other commands in this tutorial start with uv run, which are identical on Windows, macOS and Linux.

Why django-admin for one command and manage.py for the next?

They run the same underlying Django machinery, so having two commands looks a bit inconsistent. The reason is that manage.py doesn't exist yet (startproject is the command that creates this file). So for commands you have to run before you have created your project, use django-admin; everything after (startapp, migrate, runserver), use manage.py.

Those commands leave you with this project structure:

behavioural-study/
├── config/                 project-wide settings and URL routing
│   ├── __init__.py
│   ├── asgi.py
│   ├── settings.py         INSTALLED_APPS lives here
│   ├── urls.py             the project's URL map
│   └── wsgi.py
├── study/                  the app you'll build in this tutorial
│   ├── migrations/
│   │   └── __init__.py
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── models.py
│   ├── tests.py
│   └── views.py
├── .gitignore
├── .python-version
├── main.py                 the sample file from `uv init`; delete it
├── manage.py               how you run Django's commands
├── pyproject.toml          your dependencies
├── README.md
└── uv.lock

uv init also made a sample main.py, which Django doesn't need, so delete it. It also started a git repository. Add the line db.sqlite3 to its .gitignore now (as in the finished app's .gitignore): the database will soon hold participant data, which should never be committed alongside your code.

What's a git repository, and why put it online?

Git keeps a history of your project. Each time you commit, any changes in your files you are tracking are noted, with a short message saying what changed. The repository is that history, stored in a hidden .git folder inside your project. If you break something at a later point, git lets you find out what you did, or change and go back to a version that worked earlier. The .gitignore file lists what git should leave out, which is why the database goes in it.

Right now that history lives only on your computer. A remote repository is a copy of it on a service such as GitHub, GitLab or Codeberg, and it's well worth having one:

  • If your laptop dies, your code and its whole history are safe
  • Collaborators can pull your changes and send you theirs
  • Most hosting services build your app straight from a remote repository, so publishing an update is just a git push
  • You can make the repository public and cite it in your paper, so others can see exactly how your study was run

Note that the config/ folder holds project-wide settings and how the URLs link up to the separate apps (this is called 'routing'). The study/ folder contains the Study app that we'll be developing. We need to tell Django that the study app exists by adding it to INSTALLED_APPS in config/settings.py:

INSTALLED_APPS = [
    # ...the built-in apps Django added...
    "study",
]

config/settings.py on GitHub

The first model

A model is a Python class that describes a database table. Our first table, Study, is for defining our studies. Open study/models.py. Django created this for you automatically, along with this line of code at the top of the file: from django.db import models. Below this, add:

class Study(models.Model):
    """A single experiment a researcher wants to host and collect data for.

    jsPsych *timeline* code will end up being stored in `code`; the
    platform takes this and wraps it with the jsPsych library and the data-capture plumbing when it serves the study. Media assets (images, sounds) are hosted externally by the
    researcher and referenced by absolute HTTPS URL from within `code`.
    """

    name = models.CharField(max_length=200, help_text="Human-readable study title, e.g. 'Flanker task'.")

    # A slug is a short label built only from letters, numbers, hyphens and
    # underscores. These characters are safe in URLs.
    slug = models.SlugField(
        unique=True,
        help_text="Short URL-safe identifier; appears in the study URL, e.g. /study/flanker/.",
    )
    # blank=True lets a study be saved before its code is written. default="" rather than
    # null=True, so "no code" has one spelling in the database (an empty string), not two.
    code = models.TextField(
        blank=True, default="", help_text="The researcher's jsPsych timeline code (JavaScript)."
    )
    created = models.DateTimeField(auto_now_add=True)

    class Meta:
        verbose_name_plural = "studies"
        ordering = ["-created"]

    def __str__(self):
        return self.name

study/models.py on GitHub

A few things worth noticing:

  • Each attribute of the class (name, slug, code, created) becomes a column in our table. A slug is the short, URL-safe name that will appear in the study's address, like /study/flanker/
  • code is where the researcher's pasted jsPsych timeline lives. It's a TextField because it needs to be able to hold a long string of characters (see Django's model field types). blank=True makes it optional, so you can save a study now and paste its code in later
  • __str__ (looks pretty crazy but) is a special method Django looks for automatically whenever it needs to show an object as text. It controls how a Study shows up in lists (like the admin). Without it, you'd see Study object (1) (e.g. in the admin section), which is as useful as a kick in the head

Wire up the admin

Django autogenerates for you a full admin interface if you spend a moment telling it which models you want to see. Replace the contents of study/admin.py with:

from django.contrib import admin
from .models import Study

@admin.register(Study)
class StudyAdmin(admin.ModelAdmin):
    list_display = ["name", "slug", "created"]
    prepopulated_fields = {"slug": ["name"]}  # fills in the slug from the name as you type

study/admin.py on GitHub

Those few lines get you what's shown below: a full web interface where you can add, edit and delete rows in your database to your heart's content! Super powerful!

The Studies list in the Django admin, with one study added

The admin is only for you!

Getting into the admin requires an account with staff status, and what you see there is the whole model, laid out Django's way, with the ability to add, delete, and edit. It is better to give collaborators (or participants) a bespoke page containing a form with more limited permissions. To see this in action, check out Registering studies (forms) in Going further.

Create the database and log in

The Study model that we made above describes your data relationships. You use makemigrations to generate code which is used (via migrate) to turn that description into the actual database table. Each time you change your models, you need to make new migration files (via makemigrations) and update the database (via migrate). This seems tedious, but it's one of Django's great features: makemigrations writes the migration from your model changes automatically, and because the migration files are committed alongside your code, your database schema travels with the project. Anyone who clones your project gets the same tables.

uv run python manage.py makemigrations   # writes the migration file from your model
uv run python manage.py migrate          # applies it to the database

Let's also create a login for you and start the server:

uv run python manage.py createsuperuser  # make yourself an admin login
uv run python manage.py runserver

Checkpoint

Open http://127.0.0.1:8000/admin/, log in, and you'll find Studies, the screen you saw above. Click Add study and type a name. Watch the slug fill itself in as you go. Put anything in Code for now (you'll add a JsPsych study timeline in the next chapter), then click save. You've now got a model, a database table, and have a way to add/delete/edit studies.

The Add study form in the Django admin: Name typed as "Stroop task", Slug filled in as "stroop-task"
As you type the name, the slug is filled in for you. You can still edit it before you save.

Next, we make a study actually do something by serving it to the browser.