The Active Unique Pattern: Better Than Soft Delete
Problem
Student takes an exam and needs to retry.
Requirements:
- Only one active attempt at a time
- Keep history of all previous attempts
- Related data (answers, submissions, grades) stays linked
Common Approaches
1. Hard delete
old_attempt.delete()
new_attempt = Attempt.objects.create(...)
Lose all history.
2. Soft delete
old_attempt.deleted_at = timezone.now()
Every query needs .filter(deleted_at__isnull=True).
3. Archive table
ArchivedAttempt.objects.create(...)
old_attempt.delete()
Two tables to maintain.
Solution: Active Unique Pattern
Only one active record. Keep all inactive records as history.
class Exam(Model):
owner = ForeignKey(User, CASCADE)
honor_code = ForeignKey(HonorCode, CASCADE)
question_pool = ForeignKey(QuestionPool, CASCADE)
duration = DurationField()
class Attempt(Model):
exam = ForeignKey(Exam, CASCADE)
learner = ForeignKey(User, CASCADE, related_name="+")
started = DateTimeField()
active = BooleanField(default=True)
retry = PositiveSmallIntegerField(default=0)
class Meta:
constraints = [
UniqueConstraint(
fields=["exam", "learner", "active"],
condition=Q(active=True),
name="one_active_attempt"
)
]
class TempAnswer(Model):
attempt = OneToOneField(Attempt, CASCADE)
answers = JSONField()
class Submission(Model):
attempt = OneToOneField(Attempt, CASCADE)
answers = JSONField()
class Grade(Model):
attempt = OneToOneField(Attempt, CASCADE)
grader = ForeignKey(User, CASCADE, null=True, blank=True, related_name="+")
score = DecimalField(max_digits=5, decimal_places=2)
class Appeal(Model):
....
The constraint:
UniqueConstraint(
fields=["exam", "learner", "active"],
condition=Q(active=True),
name="one_active_attempt"
)
Only ONE active attempt per (exam, learner).
Inactive attempts have no limit.
How It Works
Student starts exam:
attempt = Attempt.objects.create(
exam=exam,
learner=student,
active=True,
retry=0
)
Student needs retry:
# Deactivate old
old = Attempt.objects.get(exam=exam, learner=student, active=True)
old.active = False
old.save()
# Create new
new = Attempt.objects.create(
exam=exam,
learner=student,
active=True,
retry=old.retry + 1
)
Related data (TempAnswer, Submission, Grade) stays linked to old attempt.
No cascade deletes. No data loss.
Queries
Get active attempt:
attempt = Attempt.objects.get(exam=exam, learner=student, active=True)
Get all attempts:
attempts = Attempt.objects.filter(exam=exam, learner=student).order_by('-started')
No deleted_at__isnull=True everywhere.
Why This Works
-
active=True= current -
active=False= history - Database-enforced uniqueness
- Clean queries
- Related data stays linked
Comparison
Hard Delete: No history
Soft Delete: Messy queries
Archive Table: Two tables
Active Unique: History + clean queries
Use Cases
- Exam retries
- Draft vs published
- Session management
- Subscription renewals
- Order revisions
Anytime you need "one current + keep all previous."
Top comments (0)