Skip to content

Commit 42152b1

Browse files
committed
feat: add course assignment system with submissions and grading
1 parent c94caf8 commit 42152b1

11 files changed

Lines changed: 833 additions & 0 deletions

web/admin.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
from .models import (
1313
Achievement,
14+
Assignment,
15+
AssignmentSubmission,
1416
Badge,
1517
BlogComment,
1618
BlogPost,
@@ -610,6 +612,20 @@ class ChallengeSubmissionAdmin(admin.ModelAdmin):
610612

611613
# Unregister the default User admin and register our custom one
612614
admin.site.unregister(User)
615+
@admin.register(Assignment)
616+
class AssignmentAdmin(admin.ModelAdmin):
617+
list_display = ("title", "course", "status", "due_date", "max_score", "created_at")
618+
list_filter = ("status", "course")
619+
search_fields = ("title", "course__title")
620+
621+
622+
@admin.register(AssignmentSubmission)
623+
class AssignmentSubmissionAdmin(admin.ModelAdmin):
624+
list_display = ("student", "assignment", "status", "score", "submitted_at")
625+
list_filter = ("status",)
626+
search_fields = ("student__username", "assignment__title")
627+
628+
613629
admin.site.register(User, CustomUserAdmin)
614630

615631

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Generated by Django 5.1.15 on 2026-03-21 19:19
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
("web", "0063_virtualclassroom_virtualclassroomcustomization_and_more"),
12+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="Assignment",
18+
fields=[
19+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
20+
("title", models.CharField(max_length=200)),
21+
("description", models.TextField()),
22+
("due_date", models.DateTimeField(blank=True, null=True)),
23+
("max_score", models.PositiveIntegerField(default=100)),
24+
(
25+
"status",
26+
models.CharField(
27+
choices=[("draft", "Draft"), ("published", "Published")], default="draft", max_length=10
28+
),
29+
),
30+
("allow_late_submissions", models.BooleanField(default=False)),
31+
("created_at", models.DateTimeField(auto_now_add=True)),
32+
("updated_at", models.DateTimeField(auto_now=True)),
33+
(
34+
"course",
35+
models.ForeignKey(
36+
on_delete=django.db.models.deletion.CASCADE, related_name="assignments", to="web.course"
37+
),
38+
),
39+
],
40+
options={
41+
"ordering": ["-created_at"],
42+
},
43+
),
44+
migrations.CreateModel(
45+
name="AssignmentSubmission",
46+
fields=[
47+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
48+
("text_response", models.TextField(blank=True)),
49+
("file_submission", models.FileField(blank=True, null=True, upload_to="assignment_submissions/")),
50+
(
51+
"status",
52+
models.CharField(
53+
choices=[("submitted", "Submitted"), ("graded", "Graded"), ("returned", "Returned")],
54+
default="submitted",
55+
max_length=10,
56+
),
57+
),
58+
("score", models.PositiveIntegerField(blank=True, null=True)),
59+
("feedback", models.TextField(blank=True)),
60+
("submitted_at", models.DateTimeField(auto_now_add=True)),
61+
("updated_at", models.DateTimeField(auto_now=True)),
62+
("graded_at", models.DateTimeField(blank=True, null=True)),
63+
(
64+
"assignment",
65+
models.ForeignKey(
66+
on_delete=django.db.models.deletion.CASCADE, related_name="submissions", to="web.assignment"
67+
),
68+
),
69+
(
70+
"graded_by",
71+
models.ForeignKey(
72+
blank=True,
73+
null=True,
74+
on_delete=django.db.models.deletion.SET_NULL,
75+
related_name="graded_submissions",
76+
to=settings.AUTH_USER_MODEL,
77+
),
78+
),
79+
(
80+
"student",
81+
models.ForeignKey(
82+
on_delete=django.db.models.deletion.CASCADE,
83+
related_name="assignment_submissions",
84+
to=settings.AUTH_USER_MODEL,
85+
),
86+
),
87+
],
88+
options={
89+
"ordering": ["-submitted_at"],
90+
"unique_together": {("assignment", "student")},
91+
},
92+
),
93+
]

web/models.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3176,3 +3176,79 @@ class Meta:
31763176
ordering = ["-last_updated"]
31773177
verbose_name = "Virtual Classroom Whiteboard"
31783178
verbose_name_plural = "Virtual Classroom Whiteboards"
3179+
3180+
3181+
class Assignment(models.Model):
3182+
"""A teacher-created assignment for a course."""
3183+
3184+
STATUS_CHOICES = [
3185+
("draft", "Draft"),
3186+
("published", "Published"),
3187+
]
3188+
3189+
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="assignments")
3190+
title = models.CharField(max_length=200)
3191+
description = models.TextField()
3192+
due_date = models.DateTimeField(null=True, blank=True)
3193+
max_score = models.PositiveIntegerField(default=100)
3194+
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="draft")
3195+
allow_late_submissions = models.BooleanField(default=False)
3196+
created_at = models.DateTimeField(auto_now_add=True)
3197+
updated_at = models.DateTimeField(auto_now=True)
3198+
3199+
class Meta:
3200+
ordering = ["-created_at"]
3201+
3202+
def __str__(self) -> str:
3203+
return f"{self.title} ({self.course.title})"
3204+
3205+
@property
3206+
def is_past_due(self) -> bool:
3207+
if self.due_date:
3208+
return timezone.now() > self.due_date
3209+
return False
3210+
3211+
@property
3212+
def submission_count(self) -> int:
3213+
return self.submissions.count()
3214+
3215+
3216+
class AssignmentSubmission(models.Model):
3217+
"""A student submission for an assignment."""
3218+
3219+
STATUS_CHOICES = [
3220+
("submitted", "Submitted"),
3221+
("graded", "Graded"),
3222+
("returned", "Returned"),
3223+
]
3224+
3225+
assignment = models.ForeignKey(Assignment, on_delete=models.CASCADE, related_name="submissions")
3226+
student = models.ForeignKey(User, on_delete=models.CASCADE, related_name="assignment_submissions")
3227+
text_response = models.TextField(blank=True)
3228+
file_submission = models.FileField(upload_to="assignment_submissions/", blank=True, null=True)
3229+
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="submitted")
3230+
score = models.PositiveIntegerField(null=True, blank=True)
3231+
feedback = models.TextField(blank=True)
3232+
submitted_at = models.DateTimeField(auto_now_add=True)
3233+
updated_at = models.DateTimeField(auto_now=True)
3234+
graded_at = models.DateTimeField(null=True, blank=True)
3235+
graded_by = models.ForeignKey(
3236+
User,
3237+
on_delete=models.SET_NULL,
3238+
null=True,
3239+
blank=True,
3240+
related_name="graded_submissions",
3241+
)
3242+
3243+
class Meta:
3244+
unique_together = ["assignment", "student"]
3245+
ordering = ["-submitted_at"]
3246+
3247+
def __str__(self) -> str:
3248+
return f"{self.student.username} - {self.assignment.title}"
3249+
3250+
@property
3251+
def percentage(self) -> "int | None":
3252+
if self.score is not None and self.assignment.max_score > 0:
3253+
return round((self.score / self.assignment.max_score) * 100)
3254+
return None
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{% extends "base.html" %}
2+
{% block title %}Delete Assignment - {{ assignment.title }}{% endblock title %}
3+
{% block content %}
4+
<div class="container mx-auto px-4 py-8 max-w-lg">
5+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
6+
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Delete Assignment</h1>
7+
<p class="text-gray-600 dark:text-gray-400 mb-6">
8+
Are you sure you want to delete <span class="font-semibold text-gray-900 dark:text-white">{{ assignment.title }}</span>?
9+
This will also delete all student submissions. This action cannot be undone.
10+
</p>
11+
<div class="flex items-center space-x-3">
12+
<form method="post">
13+
{% csrf_token %}
14+
<button type="submit"
15+
class="bg-red-500 hover:bg-red-600 dark:bg-red-700 dark:hover:bg-red-600 text-white font-semibold px-6 py-2 rounded-lg transition duration-200 focus:outline-none focus:ring-2 focus:ring-red-400">
16+
Yes, Delete
17+
</button>
18+
</form>
19+
<a href="{% url 'course_assignments' course.slug %}"
20+
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 text-sm">Cancel</a>
21+
</div>
22+
</div>
23+
</div>
24+
{% endblock content %}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
{% extends "base.html" %}
2+
{% block title %}{{ assignment.title }} - {{ course.title }}{% endblock title %}
3+
{% block content %}
4+
<div class="container mx-auto px-4 py-8">
5+
<nav class="text-sm mb-4">
6+
<a href="{% url 'course_detail' course.slug %}" class="text-blue-600 hover:text-blue-800 dark:text-blue-400">{{ course.title }}</a>
7+
<span class="mx-2 text-gray-400">/</span>
8+
<a href="{% url 'course_assignments' course.slug %}" class="text-blue-600 hover:text-blue-800 dark:text-blue-400">Assignments</a>
9+
<span class="mx-2 text-gray-400">/</span>
10+
<span class="text-gray-600 dark:text-gray-400">{{ assignment.title }}</span>
11+
</nav>
12+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
13+
<div class="lg:col-span-2 space-y-6">
14+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
15+
<div class="flex items-start justify-between mb-4">
16+
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ assignment.title }}</h1>
17+
{% if is_teacher %}
18+
<div class="flex space-x-2">
19+
<a href="{% url 'edit_assignment' course.slug assignment.id %}" class="text-teal-600 hover:text-teal-800 dark:text-teal-400 text-sm font-medium">Edit</a>
20+
<a href="{% url 'delete_assignment' course.slug assignment.id %}" class="text-red-500 hover:text-red-700 dark:text-red-400 text-sm font-medium">Delete</a>
21+
</div>
22+
{% endif %}
23+
</div>
24+
<div class="prose dark:prose-invert max-w-none text-gray-700 dark:text-gray-300 text-sm mb-4">
25+
{{ assignment.description|linebreaks }}
26+
</div>
27+
<div class="flex flex-wrap gap-4 text-sm text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 pt-4">
28+
<span><i class="fas fa-star mr-1" aria-hidden="true"></i> Max Score: {{ assignment.max_score }}</span>
29+
{% if assignment.due_date %}
30+
<span class="{% if assignment.is_past_due %}text-red-500 dark:text-red-400{% endif %}">
31+
<i class="fas fa-calendar mr-1" aria-hidden="true"></i> Due: {{ assignment.due_date|date:"M d, Y H:i" }}
32+
{% if assignment.is_past_due %} (Past Due){% endif %}
33+
</span>
34+
{% endif %}
35+
{% if assignment.allow_late_submissions %}
36+
<span class="text-green-600 dark:text-green-400"><i class="fas fa-check mr-1" aria-hidden="true"></i> Late submissions allowed</span>
37+
{% endif %}
38+
</div>
39+
</div>
40+
{% if not is_teacher %}
41+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
42+
<h2 class="text-xl font-semibold text-gray-800 dark:text-white mb-4">
43+
{% if submission %}Your Submission{% else %}Submit Your Work{% endif %}
44+
</h2>
45+
{% if submission %}
46+
<div class="space-y-3">
47+
<div class="flex items-center space-x-2">
48+
<span class="px-2 py-1 text-xs rounded-full {% if submission.status == 'graded' %}bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300{% else %}bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300{% endif %}">
49+
{{ submission.get_status_display }}
50+
</span>
51+
<span class="text-sm text-gray-500 dark:text-gray-400">Submitted {{ submission.submitted_at|date:"M d, Y H:i" }}</span>
52+
</div>
53+
{% if submission.text_response %}
54+
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 text-sm text-gray-800 dark:text-gray-200">
55+
{{ submission.text_response|linebreaks }}
56+
</div>
57+
{% endif %}
58+
{% if submission.file_submission %}
59+
<a href="{{ submission.file_submission.url }}" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm">
60+
<i class="fas fa-paperclip mr-1" aria-hidden="true"></i> Download submitted file
61+
</a>
62+
{% endif %}
63+
{% if submission.status == 'graded' %}
64+
<div class="bg-teal-50 dark:bg-teal-900 rounded-lg p-4 border-l-4 border-teal-400">
65+
<p class="font-semibold text-gray-900 dark:text-white">Score: {{ submission.score }}/{{ assignment.max_score }} ({{ submission.percentage }}%)</p>
66+
{% if submission.feedback %}
67+
<p class="text-sm text-gray-700 dark:text-gray-300 mt-2">{{ submission.feedback }}</p>
68+
{% endif %}
69+
</div>
70+
{% endif %}
71+
</div>
72+
{% else %}
73+
{% if assignment.is_past_due and not assignment.allow_late_submissions %}
74+
<p class="text-red-500 dark:text-red-400 text-sm">This assignment is past due and does not accept late submissions.</p>
75+
{% else %}
76+
<form method="post" enctype="multipart/form-data" class="space-y-4">
77+
{% csrf_token %}
78+
<div>
79+
<label for="text_response" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Text Response</label>
80+
<textarea id="text_response" name="text_response" rows="6"
81+
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
82+
placeholder="Write your response here..."></textarea>
83+
</div>
84+
<div>
85+
<label for="file_submission" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Upload File (optional)</label>
86+
<input type="file" id="file_submission" name="file_submission"
87+
class="w-full text-sm text-gray-500 dark:text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-teal-100 file:text-teal-700 hover:file:bg-teal-200">
88+
<p class="text-xs text-gray-400 mt-1">Allowed: PDF, JPEG, PNG, TXT, DOC, DOCX. Max 10MB.</p>
89+
</div>
90+
<button type="submit"
91+
class="bg-teal-300 hover:bg-teal-400 dark:bg-teal-600 dark:hover:bg-teal-500 text-white font-semibold px-6 py-2 rounded-lg transition duration-200 focus:outline-none focus:ring-2 focus:ring-teal-300">
92+
Submit Assignment
93+
</button>
94+
</form>
95+
{% endif %}
96+
{% endif %}
97+
</div>
98+
{% endif %}
99+
{% if is_teacher %}
100+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
101+
<h2 class="text-xl font-semibold text-gray-800 dark:text-white mb-4">
102+
<i class="fas fa-users mr-2 text-teal-500 dark:text-teal-300" aria-hidden="true"></i>
103+
Submissions ({{ submissions.count }})
104+
</h2>
105+
{% if submissions %}
106+
<div class="space-y-3">
107+
{% for sub in submissions %}
108+
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
109+
<div>
110+
<p class="font-medium text-gray-900 dark:text-white text-sm">{{ sub.student.get_full_name|default:sub.student.username }}</p>
111+
<p class="text-xs text-gray-500 dark:text-gray-400">{{ sub.submitted_at|date:"M d, Y H:i" }}</p>
112+
</div>
113+
<div class="flex items-center space-x-3">
114+
{% if sub.status == 'graded' %}
115+
<span class="text-sm font-semibold text-green-600 dark:text-green-400">{{ sub.score }}/{{ assignment.max_score }}</span>
116+
{% else %}
117+
<span class="px-2 py-0.5 text-xs rounded-full bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300">Pending</span>
118+
{% endif %}
119+
<a href="{% url 'grade_submission' course.slug sub.id %}" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm font-medium">
120+
{% if sub.status == 'graded' %}Re-grade{% else %}Grade{% endif %}
121+
</a>
122+
</div>
123+
</div>
124+
{% endfor %}
125+
</div>
126+
{% else %}
127+
<p class="text-gray-500 dark:text-gray-400 text-sm">No submissions yet.</p>
128+
{% endif %}
129+
</div>
130+
{% endif %}
131+
</div>
132+
<div class="space-y-4">
133+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
134+
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-3">Details</h3>
135+
<dl class="space-y-2 text-sm">
136+
<div class="flex justify-between">
137+
<dt class="text-gray-500 dark:text-gray-400">Status</dt>
138+
<dd class="font-medium text-gray-900 dark:text-white">{{ assignment.get_status_display }}</dd>
139+
</div>
140+
<div class="flex justify-between">
141+
<dt class="text-gray-500 dark:text-gray-400">Max Score</dt>
142+
<dd class="font-medium text-gray-900 dark:text-white">{{ assignment.max_score }}</dd>
143+
</div>
144+
{% if assignment.due_date %}
145+
<div class="flex justify-between">
146+
<dt class="text-gray-500 dark:text-gray-400">Due</dt>
147+
<dd class="font-medium {% if assignment.is_past_due %}text-red-500 dark:text-red-400{% else %}text-gray-900 dark:text-white{% endif %}">
148+
{{ assignment.due_date|date:"M d, Y" }}
149+
</dd>
150+
</div>
151+
{% endif %}
152+
{% if is_teacher %}
153+
<div class="flex justify-between">
154+
<dt class="text-gray-500 dark:text-gray-400">Submissions</dt>
155+
<dd class="font-medium text-gray-900 dark:text-white">{{ assignment.submission_count }}</dd>
156+
</div>
157+
{% endif %}
158+
</dl>
159+
</div>
160+
</div>
161+
</div>
162+
</div>
163+
{% endblock content %}

0 commit comments

Comments
 (0)