Skip to content

Commit 44bc0fc

Browse files
jsb2092claude
andcommitted
Add quiz invitation system with individual student codes
- Add QuizInvitation model for per-student unique codes - Add roster import endpoint (CSV or JSON) - Add Resend email service for sending quiz invitations - Update quiz access/start views to handle invitation codes - 8-char invitation codes vs 6-char shared access codes - Invitation tracks: email sent, used, linked submission - Add RESEND_API_KEY, FROM_EMAIL, BASE_URL settings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 46825a6 commit 44bc0fc

8 files changed

Lines changed: 472 additions & 27 deletions

File tree

questionbank/config/settings.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,11 @@
148148
ANTHROPIC_API_KEY = os.getenv('ANTHROPIC_API_KEY', '')
149149
OPENAI_API_KEY = os.getenv('OPENAI_API_KEY', '')
150150

151+
# Email (Resend)
152+
RESEND_API_KEY = os.getenv('RESEND_API_KEY', '')
153+
FROM_EMAIL = os.getenv('FROM_EMAIL', 'quizzes@yourdomain.com')
154+
BASE_URL = os.getenv('BASE_URL', 'https://mkt-production.up.railway.app')
155+
151156
# Production security settings
152157
if not DEBUG:
153158
SECURE_SSL_REDIRECT = True
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Generated by Django 4.2.27 on 2025-12-12 00:15
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
import quizzes.models
6+
import uuid
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('quizzes', '0002_make_template_optional'),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name='QuizInvitation',
18+
fields=[
19+
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
20+
('student_name', models.CharField(max_length=200)),
21+
('student_email', models.EmailField(db_index=True, max_length=254)),
22+
('student_id', models.CharField(blank=True, max_length=50)),
23+
('code', models.CharField(db_index=True, default=quizzes.models.generate_invitation_code, max_length=12, unique=True)),
24+
('email_sent_at', models.DateTimeField(blank=True, null=True)),
25+
('email_error', models.TextField(blank=True)),
26+
('used_at', models.DateTimeField(blank=True, null=True)),
27+
('created_at', models.DateTimeField(auto_now_add=True)),
28+
('quiz_session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='quizzes.quizsession')),
29+
('submission', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invitation', to='quizzes.studentsubmission')),
30+
],
31+
options={
32+
'ordering': ['student_name'],
33+
'unique_together': {('quiz_session', 'student_email')},
34+
},
35+
),
36+
]

questionbank/quizzes/models.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,55 @@ def get_final_score(self):
229229
return self.points_earned
230230

231231

232+
def generate_invitation_code():
233+
"""Generate a unique 8-character invitation code."""
234+
return ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(8))
235+
236+
237+
class QuizInvitation(models.Model):
238+
"""Individual invitation for a student to take a quiz."""
239+
240+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
241+
quiz_session = models.ForeignKey(QuizSession, on_delete=models.CASCADE, related_name='invitations')
242+
243+
# Student info (from roster import)
244+
student_name = models.CharField(max_length=200)
245+
student_email = models.EmailField(db_index=True)
246+
student_id = models.CharField(max_length=50, blank=True)
247+
248+
# Unique code for this student
249+
code = models.CharField(max_length=12, unique=True, default=generate_invitation_code, db_index=True)
250+
251+
# Email tracking
252+
email_sent_at = models.DateTimeField(null=True, blank=True)
253+
email_error = models.TextField(blank=True)
254+
255+
# Usage tracking
256+
used_at = models.DateTimeField(null=True, blank=True)
257+
submission = models.OneToOneField(
258+
'StudentSubmission', on_delete=models.SET_NULL, null=True, blank=True,
259+
related_name='invitation'
260+
)
261+
262+
# Audit
263+
created_at = models.DateTimeField(auto_now_add=True)
264+
265+
class Meta:
266+
ordering = ['student_name']
267+
unique_together = ['quiz_session', 'student_email']
268+
269+
def __str__(self):
270+
return f"{self.student_name} - {self.quiz_session.name} ({self.code})"
271+
272+
@property
273+
def is_used(self):
274+
return self.used_at is not None
275+
276+
@property
277+
def quiz_url(self):
278+
return f"/quiz/{self.code}/"
279+
280+
232281
class ScannedExam(models.Model):
233282
"""A scanned paper exam uploaded for OCR processing."""
234283

questionbank/quizzes/serializers.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Serializers for quiz models.
33
"""
44
from rest_framework import serializers
5-
from .models import QuizSession, StudentSubmission, QuestionResponse, ScannedExam
5+
from .models import QuizSession, StudentSubmission, QuestionResponse, ScannedExam, QuizInvitation
66
from questions.serializers import QuestionListSerializer
77
from exams.serializers import ExamTemplateSerializer
88

@@ -303,3 +303,26 @@ class Meta:
303303
'ocr_text', 'ocr_confidence', 'extracted_answers',
304304
'submission', 'uploaded_at', 'processed_at'
305305
]
306+
307+
308+
class QuizInvitationSerializer(serializers.ModelSerializer):
309+
"""Serializer for quiz invitations."""
310+
quiz_name = serializers.CharField(source='quiz_session.name', read_only=True)
311+
quiz_url = serializers.CharField(read_only=True)
312+
is_used = serializers.BooleanField(read_only=True)
313+
314+
class Meta:
315+
model = QuizInvitation
316+
fields = [
317+
'id', 'quiz_session', 'quiz_name',
318+
'student_name', 'student_email', 'student_id',
319+
'code', 'quiz_url',
320+
'email_sent_at', 'email_error',
321+
'used_at', 'is_used', 'submission',
322+
'created_at'
323+
]
324+
read_only_fields = [
325+
'id', 'code', 'quiz_url', 'is_used',
326+
'email_sent_at', 'email_error',
327+
'used_at', 'submission', 'created_at'
328+
]
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""
2+
Email service for sending quiz invitations via Resend.
3+
"""
4+
import resend
5+
from django.conf import settings
6+
from django.utils import timezone
7+
8+
9+
def send_quiz_invitations(quiz, invitations, request=None):
10+
"""
11+
Send quiz invitation emails to students.
12+
13+
Args:
14+
quiz: QuizSession instance
15+
invitations: List of QuizInvitation instances
16+
request: Optional HTTP request for building URLs
17+
18+
Returns:
19+
dict with sent, failed counts and errors list
20+
"""
21+
resend.api_key = settings.RESEND_API_KEY
22+
23+
# Build base URL
24+
if request:
25+
base_url = f"{request.scheme}://{request.get_host()}"
26+
else:
27+
base_url = getattr(settings, 'BASE_URL', 'https://mkt-production.up.railway.app')
28+
29+
from_email = getattr(settings, 'FROM_EMAIL', 'quizzes@updates.yourdomain.com')
30+
31+
sent = 0
32+
failed = 0
33+
errors = []
34+
35+
for invitation in invitations:
36+
quiz_url = f"{base_url}/quiz/{invitation.code}/"
37+
38+
try:
39+
# Build email content
40+
subject = f"Quiz Invitation: {quiz.name}"
41+
42+
html_content = f"""
43+
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
44+
<h2 style="color: #0284c7;">Quiz Invitation</h2>
45+
46+
<p>Hello {invitation.student_name or 'Student'},</p>
47+
48+
<p>You have been invited to take the following quiz:</p>
49+
50+
<div style="background: #f0f9ff; padding: 20px; border-radius: 8px; margin: 20px 0;">
51+
<h3 style="margin: 0 0 10px 0; color: #0369a1;">{quiz.name}</h3>
52+
{f'<p style="margin: 0; color: #64748b;">{quiz.description}</p>' if quiz.description else ''}
53+
<p style="margin: 10px 0 0 0; color: #64748b;">
54+
Time limit: {quiz.time_limit_minutes} minutes
55+
</p>
56+
</div>
57+
58+
<p>Click the button below to start your quiz:</p>
59+
60+
<a href="{quiz_url}"
61+
style="display: inline-block; background: #0284c7; color: white;
62+
padding: 12px 24px; text-decoration: none; border-radius: 6px;
63+
font-weight: bold; margin: 10px 0;">
64+
Start Quiz
65+
</a>
66+
67+
<p style="color: #64748b; font-size: 14px; margin-top: 20px;">
68+
Or copy this link: <a href="{quiz_url}">{quiz_url}</a>
69+
</p>
70+
71+
<p style="color: #94a3b8; font-size: 12px; margin-top: 30px;">
72+
This is your personal quiz link. Do not share it with others.
73+
</p>
74+
</div>
75+
"""
76+
77+
text_content = f"""
78+
Quiz Invitation: {quiz.name}
79+
80+
Hello {invitation.student_name or 'Student'},
81+
82+
You have been invited to take the quiz: {quiz.name}
83+
84+
Time limit: {quiz.time_limit_minutes} minutes
85+
86+
Click here to start: {quiz_url}
87+
88+
This is your personal quiz link. Do not share it with others.
89+
"""
90+
91+
# Send via Resend
92+
response = resend.Emails.send({
93+
"from": from_email,
94+
"to": [invitation.student_email],
95+
"subject": subject,
96+
"html": html_content,
97+
"text": text_content
98+
})
99+
100+
# Update invitation
101+
invitation.email_sent_at = timezone.now()
102+
invitation.email_error = ''
103+
invitation.save(update_fields=['email_sent_at', 'email_error'])
104+
sent += 1
105+
106+
except Exception as e:
107+
invitation.email_error = str(e)
108+
invitation.save(update_fields=['email_error'])
109+
errors.append(f"{invitation.student_email}: {str(e)}")
110+
failed += 1
111+
112+
return {
113+
'sent': sent,
114+
'failed': failed,
115+
'errors': errors
116+
}

questionbank/quizzes/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
router.register(r'submissions', views.StudentSubmissionViewSet, basename='submissions')
1111
router.register(r'responses', views.QuestionResponseViewSet, basename='responses')
1212
router.register(r'scanned', views.ScannedExamViewSet, basename='scanned-exams')
13+
router.register(r'invitations', views.QuizInvitationViewSet, basename='invitations')
1314

1415
urlpatterns = [
1516
# Instructor API endpoints (auth required)
@@ -21,6 +22,10 @@
2122
# Scanned exam upload
2223
path('scan/upload/', views.ScannedExamUploadView.as_view(), name='scan-upload'),
2324

25+
# Roster and invitations
26+
path('sessions/<uuid:quiz_id>/roster/import/', views.RosterImportView.as_view(), name='roster-import'),
27+
path('sessions/<uuid:quiz_id>/invitations/send/', views.SendInvitationsView.as_view(), name='send-invitations'),
28+
2429
# Public student API endpoints (no auth required)
2530
path('take/<str:code>/', views.QuizAccessView.as_view(), name='quiz-access'),
2631
path('take/<str:code>/start/', views.QuizStartView.as_view(), name='quiz-start'),

0 commit comments

Comments
 (0)