AI Tools

Designing Database Models for an AI Interview App in Django

Design clean Django models for an AI interview app: Interview, Question, and Answer relationships that keep your data structured as the AI flow grows.

Drake Nguyen

Founder · System Architect

3 min read
Database schema for an AI interview app: Interview, InterviewQuestion, and InterviewReport models and their relationships

What an Interview Actually Produces

Before writing a single field, it helps to design the Django models for an AI interview app around what an interview really is, not around what feels quick to type. In part 1 we chose the stack and planned the features. Now we give that plan a real data shape.

Think about what one interview session generates:

  • an interview tied to a job title and a CV, moving through a lifecycle from pending to completed;
  • a set of questions, each with a topic, difficulty level, and position in the interview;
  • a user answer and a score attached to each question;
  • a final report summarizing the whole session.

Model the domain first, and the fields almost write themselves. Jump straight to fields, and you end up reshaping your schema later — the single most expensive kind of change once real data has piled on top of it.

A Shared Timestamp Base

Almost every model needs the same housekeeping fields: when it was created and when it was last updated. Instead of repeating that on every model, define it once as an abstract base and inherit from it. This is a small discipline that keeps the whole schema consistent.

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


class TimeInfo(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    metadata = models.JSONField(default=dict, null=True, blank=True)

    class Meta:
        ordering = ("-created_at",)
        abstract = True

    @property
    def created_at_display(self):
        return localtime(self.created_at).strftime("%d-%m-%Y %H:%M:%S")

Because abstract = True, Django won't create a table for TimeInfo itself — its fields are simply added to any model that inherits it. The metadata JSON field is a deliberate escape hatch: a place to attach extra, evolving data without a migration every time a need appears.

The Core Models

The interview flow itself rests on three models: Interview, InterviewQuestion, and InterviewReport. The interview is the parent session; questions belong to it; the report summarizes it. One small supporting model, DeviceToken, lets the app recognize a returning device without requiring a login.

import uuid
from django.db import models


class DeviceToken(TimeInfo):
    token_hash = models.CharField(max_length=64, unique=True, db_index=True)

    class Meta:
        db_table = "device_tokens"


class Interview(TimeInfo):
    INTERVIEW_STATUS = [
        (1, "Pending"),
        (2, "Processing Resume"),
        (3, "Preparing Interview"),
        (4, "In Progress"),
        (5, "Started"),
        (6, "Generating Report"),
        (7, "Completed"),
        (8, "Failed"),
    ]

    job_title = models.CharField(max_length=255, null=True, blank=True)
    interview_uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
    cv_file = models.FileField(upload_to="storage/files/", null=True, blank=True)
    cv_text = models.TextField(null=True, blank=True)
    status = models.SmallIntegerField(default=1, choices=INTERVIEW_STATUS)
    candidate_name = models.CharField(max_length=100, null=True, blank=True)
    candidate_email = models.EmailField(null=True, blank=True)
    device = models.ForeignKey(
        DeviceToken, on_delete=models.SET_NULL, null=True, blank=True
    )
    client_ip = models.GenericIPAddressField(
        null=True, blank=True, db_index=True
    )  # for per-IP daily rate limit

    class Meta:
        db_table = "interviews"

    def __str__(self):
        return str(self.interview_uuid)


class InterviewQuestion(TimeInfo):
    QUESTION_LEVEL_CHOICES = (
        ("easy", "Easy"),
        ("medium", "Medium"),
        ("hard", "Hard"),
    )

    interview = models.ForeignKey(Interview, on_delete=models.CASCADE)
    topic = models.CharField(max_length=255, null=True, blank=True)
    position = models.IntegerField(default=1)
    question = models.TextField(null=True, blank=True)
    sample_answer = models.TextField(null=True, blank=True)  # suggested model answer
    user_answer = models.TextField(null=True, blank=True)
    user_score = models.SmallIntegerField(null=True, blank=True)
    reason_score = models.TextField(null=True, blank=True)
    level = models.CharField(
        max_length=10, null=True, blank=True, choices=QUESTION_LEVEL_CHOICES
    )
    record = models.FileField(
        upload_to="storage/audio/", null=True, blank=True
    )  # voice answer recording

    class Meta:
        db_table = "interview_questions"

    def __str__(self):
        return f"{self.question}"


class InterviewReport(TimeInfo):
    interview = models.OneToOneField(Interview, on_delete=models.CASCADE)
    content = models.TextField(null=True, blank=True)
    is_passed = models.BooleanField(default=False)

    class Meta:
        db_table = "interview_reports"

A few deliberate choices worth calling out. The status field uses an integer-choices lifecycle so the interview's exact stage — from processing the resume to generating the report — is always explicit, never a guess. Each question carries its own topic, level, and position, so the interview reconstructs in the right order with escalating difficulty, and keeps both a sample_answer (a suggested strong answer) and the candidate's own user_answer beside it. The record field stores the audio of a spoken answer, which is what makes real voice practice possible later. And interview_uuid gives every session a non-sequential public identifier, so URLs don't leak how many interviews exist or let anyone guess the next one.

Notice too that device and client_ip sit on the interview. The app is login-free by design, so it leans on a hashed device token and the client IP to apply fair per-device and per-IP daily limits — practicing seriously stays possible, while abuse of the AI endpoints does not.

Relationships and on_delete Choices

The relationships carry real meaning, so they deserve a deliberate decision rather than a copied default:

Relationship Field type Meaning on_delete
Interview → InterviewQuestion ForeignKey One interview has many questions CASCADE
Interview → InterviewReport OneToOneField Each interview has exactly one report CASCADE
Interview → DeviceToken ForeignKey An interview is linked to the device that started it SET_NULL

on_delete=CASCADE is the right call for the questions and the report: this is session data, so deleting an interview should take them with it. Leaving orphaned rows behind would be a bug, not a feature. Using OneToOneField for the report also enforces the rule at the database level — one interview can never accidentally end up with two reports. The device link uses SET_NULL instead, because a device and an interview have independent lifecycles: removing a device token should not delete the interviews someone already practiced.

Running Your First Migrations

Models are just Python until you turn them into database tables. Django does that in two steps — one to generate the migration file, one to apply it:

python manage.py makemigrations
python manage.py migrate

makemigrations reads your models and writes a migration describing the change; migrate runs it against the database. It's worth opening the generated migration file at least once to see the SQL Django is about to run — understanding what happens under the hood keeps migrations from feeling like magic when something goes wrong.

One practical warning: SQLite is convenient for local development, but it doesn't enforce every constraint the way PostgreSQL does — field length and some validations behave differently. If you plan to deploy on PostgreSQL, develop against it early so you don't discover the differences in production.

This is the core of the interview data model. The full application layers more on top — a background-task system to run processing off the request cycle, soft-deletion of old interviews, and the pieces that make voice answers and abuse protection production-grade. Those parts, and the rest of the build, are packaged in the starter kit. Otherwise, part 3 is next, where we handle the CV upload and pull clean text out of PDF resumes with pdfplumber.

Stay updated with Netalith

Get coding resources, product updates, and special offers directly in your inbox.