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
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.
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.