diff --git a/web/admin.py b/web/admin.py index a0eb4328f..1cfe59c24 100644 --- a/web/admin.py +++ b/web/admin.py @@ -11,6 +11,8 @@ from .models import ( Achievement, + Assignment, + AssignmentSubmission, Badge, BlogComment, BlogPost, @@ -610,6 +612,21 @@ class ChallengeSubmissionAdmin(admin.ModelAdmin): # Unregister the default User admin and register our custom one admin.site.unregister(User) +@admin.register(Assignment) +class AssignmentAdmin(admin.ModelAdmin): + list_display = ("title", "course", "status", "due_date", "max_score", "created_at") + list_filter = ("status", "course") + search_fields = ("title", "course__title") + + +@admin.register(AssignmentSubmission) +class AssignmentSubmissionAdmin(admin.ModelAdmin): + list_display = ("student", "assignment", "status", "score", "submitted_at") + list_filter = ("status",) + search_fields = ("student__username", "assignment__title") + raw_id_fields = ("student", "assignment", "graded_by") + + admin.site.register(User, CustomUserAdmin) diff --git a/web/migrations/0064_add_assignment_and_submission.py b/web/migrations/0064_add_assignment_and_submission.py new file mode 100644 index 000000000..785290c3a --- /dev/null +++ b/web/migrations/0064_add_assignment_and_submission.py @@ -0,0 +1,93 @@ +# Generated by Django 5.1.15 on 2026-03-21 19:19 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("web", "0063_virtualclassroom_virtualclassroomcustomization_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Assignment", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("title", models.CharField(max_length=200)), + ("description", models.TextField()), + ("due_date", models.DateTimeField(blank=True, null=True)), + ("max_score", models.PositiveIntegerField(default=100)), + ( + "status", + models.CharField( + choices=[("draft", "Draft"), ("published", "Published")], default="draft", max_length=10 + ), + ), + ("allow_late_submissions", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "course", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="assignments", to="web.course" + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="AssignmentSubmission", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("text_response", models.TextField(blank=True)), + ("file_submission", models.FileField(blank=True, null=True, upload_to="assignment_submissions/")), + ( + "status", + models.CharField( + choices=[("submitted", "Submitted"), ("graded", "Graded"), ("returned", "Returned")], + default="submitted", + max_length=10, + ), + ), + ("score", models.PositiveIntegerField(blank=True, null=True)), + ("feedback", models.TextField(blank=True)), + ("submitted_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("graded_at", models.DateTimeField(blank=True, null=True)), + ( + "assignment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="submissions", to="web.assignment" + ), + ), + ( + "graded_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="graded_submissions", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "student", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="assignment_submissions", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-submitted_at"], + "unique_together": {("assignment", "student")}, + }, + ), + ] diff --git a/web/models.py b/web/models.py index 6b8ea8ef4..920e5b5f2 100644 --- a/web/models.py +++ b/web/models.py @@ -3176,3 +3176,92 @@ class Meta: ordering = ["-last_updated"] verbose_name = "Virtual Classroom Whiteboard" verbose_name_plural = "Virtual Classroom Whiteboards" + + +class Assignment(models.Model): + """A teacher-created assignment for a course.""" + + STATUS_CHOICES = [ + ("draft", "Draft"), + ("published", "Published"), + ] + + course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="assignments") + title = models.CharField(max_length=200) + description = models.TextField() + due_date = models.DateTimeField(null=True, blank=True) + max_score = models.PositiveIntegerField(default=100, validators=[MinValueValidator(1)]) + status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="draft") + allow_late_submissions = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"{self.title} ({self.course.title})" + + @property + def is_past_due(self) -> bool: + if self.due_date: + return timezone.now() > self.due_date + return False + + @property + def submission_count(self) -> int: + return self.submissions.count() + + +class AssignmentSubmission(models.Model): + """A student submission for an assignment.""" + + STATUS_CHOICES = [ + ("submitted", "Submitted"), + ("graded", "Graded"), + ("returned", "Returned"), + ] + + assignment = models.ForeignKey(Assignment, on_delete=models.CASCADE, related_name="submissions") + student = models.ForeignKey(User, on_delete=models.CASCADE, related_name="assignment_submissions") + text_response = models.TextField(blank=True) + file_submission = models.FileField(upload_to="assignment_submissions/", blank=True, null=True) + status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="submitted") + score = models.PositiveIntegerField(null=True, blank=True) + feedback = models.TextField(blank=True) + submitted_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + graded_at = models.DateTimeField(null=True, blank=True) + graded_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="graded_submissions", + ) + + class Meta: + unique_together = ["assignment", "student"] + ordering = ["-submitted_at"] + + def __str__(self) -> str: + return f"{self.student.username} - {self.assignment.title}" + + @property + def percentage(self) -> "int | None": + if self.score is not None and self.assignment.max_score > 0: + return round((self.score / self.assignment.max_score) * 100) + return None + + + +from django.db.models.signals import post_delete as _post_delete + + +def _delete_submission_file(sender, instance, **kwargs): + """Delete file from storage when AssignmentSubmission is deleted.""" + if instance.file_submission: + instance.file_submission.delete(save=False) + + +_post_delete.connect(_delete_submission_file, sender=AssignmentSubmission) diff --git a/web/templates/courses/assignment_confirm_delete.html b/web/templates/courses/assignment_confirm_delete.html new file mode 100644 index 000000000..5ab2709f0 --- /dev/null +++ b/web/templates/courses/assignment_confirm_delete.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}Delete Assignment - {{ assignment.title }}{% endblock title %} +{% block content %} +
+ Are you sure you want to delete {{ assignment.title }}? + This will also delete all student submissions. This action cannot be undone. +
+Score: {{ submission.score }}/{{ assignment.max_score }} ({{ submission.percentage }}%)
+ {% if submission.feedback %} +{{ submission.feedback }}
+ {% endif %} +This assignment is past due and does not accept late submissions.
+ {% else %} + + {% endif %} + {% endif %} +{{ sub.student.get_full_name|default:sub.student.username }}
+{{ sub.submitted_at|date:"M d, Y H:i" }}
+No submissions yet.
+ {% endif %} +{{ assignment.description }}
+No assignments yet.
+ {% if is_teacher %} + + Create First Assignment + + {% endif %} ++ {{ submission.student.get_full_name|default:submission.student.username }} — submitted {{ submission.submitted_at|date:"M d, Y H:i" }} +
+No response provided.
+ {% endif %} +