From 7938ee695e5e358bd153563c3890a0a9ce900ecf Mon Sep 17 00:00:00 2001 From: ayesha1145 <130880100+ayesha1145@users.noreply.github.com> Date: Sat, 21 Mar 2026 20:26:26 +0000 Subject: [PATCH] feat: add course assignment system with submissions and grading --- web/admin.py | 17 ++ .../0064_add_assignment_and_submission.py | 93 +++++++ web/models.py | 89 +++++++ .../courses/assignment_confirm_delete.html | 24 ++ web/templates/courses/assignment_detail.html | 163 ++++++++++++ web/templates/courses/assignment_form.html | 67 +++++ web/templates/courses/assignment_list.html | 84 +++++++ web/templates/courses/detail.html | 5 + web/templates/courses/grade_submission.html | 63 +++++ web/urls.py | 37 +++ web/views.py | 233 ++++++++++++++++++ 11 files changed, 875 insertions(+) create mode 100644 web/migrations/0064_add_assignment_and_submission.py create mode 100644 web/templates/courses/assignment_confirm_delete.html create mode 100644 web/templates/courses/assignment_detail.html create mode 100644 web/templates/courses/assignment_form.html create mode 100644 web/templates/courses/assignment_list.html create mode 100644 web/templates/courses/grade_submission.html 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 %} +
+
+

Delete Assignment

+

+ Are you sure you want to delete {{ assignment.title }}? + This will also delete all student submissions. This action cannot be undone. +

+
+
+ {% csrf_token %} + +
+ Cancel +
+
+
+{% endblock content %} diff --git a/web/templates/courses/assignment_detail.html b/web/templates/courses/assignment_detail.html new file mode 100644 index 000000000..72538b719 --- /dev/null +++ b/web/templates/courses/assignment_detail.html @@ -0,0 +1,163 @@ +{% extends "base.html" %} +{% block title %}{{ assignment.title }} - {{ course.title }}{% endblock title %} +{% block content %} +
+ +
+
+
+
+

{{ assignment.title }}

+ {% if is_teacher %} +
+ Edit + Delete +
+ {% endif %} +
+
+ {{ assignment.description|linebreaks }} +
+
+ Max Score: {{ assignment.max_score }} + {% if assignment.due_date %} + + Due: {{ assignment.due_date|date:"M d, Y H:i" }} + {% if assignment.is_past_due %} (Past Due){% endif %} + + {% endif %} + {% if assignment.allow_late_submissions %} + Late submissions allowed + {% endif %} +
+
+ {% if not is_teacher %} +
+

+ {% if submission %}Your Submission{% else %}Submit Your Work{% endif %} +

+ {% if submission %} +
+
+ + {{ submission.get_status_display }} + + Submitted {{ submission.submitted_at|date:"M d, Y H:i" }} +
+ {% if submission.text_response %} +
+ {{ submission.text_response|linebreaks }} +
+ {% endif %} + {% if submission.file_submission %} + + Download submitted file + + {% endif %} + {% if submission.status == 'graded' %} +
+

Score: {{ submission.score }}/{{ assignment.max_score }} ({{ submission.percentage }}%)

+ {% if submission.feedback %} +

{{ submission.feedback }}

+ {% endif %} +
+ {% endif %} +
+ {% else %} + {% if assignment.is_past_due and not assignment.allow_late_submissions %} +

This assignment is past due and does not accept late submissions.

+ {% else %} +
+ {% csrf_token %} +
+ + +
+
+ + +

Allowed: PDF, JPEG, PNG, TXT, DOC, DOCX. Max 10MB.

+
+ +
+ {% endif %} + {% endif %} +
+ {% endif %} + {% if is_teacher %} +
+

+ + Submissions ({{ submissions.count }}) +

+ {% if submissions %} +
+ {% for sub in submissions %} +
+
+

{{ sub.student.get_full_name|default:sub.student.username }}

+

{{ sub.submitted_at|date:"M d, Y H:i" }}

+
+
+ {% if sub.status == 'graded' %} + {{ sub.score }}/{{ assignment.max_score }} + {% else %} + Pending + {% endif %} + + {% if sub.status == 'graded' %}Re-grade{% else %}Grade{% endif %} + +
+
+ {% endfor %} +
+ {% else %} +

No submissions yet.

+ {% endif %} +
+ {% endif %} +
+
+
+

Details

+
+
+
Status
+
{{ assignment.get_status_display }}
+
+
+
Max Score
+
{{ assignment.max_score }}
+
+ {% if assignment.due_date %} +
+
Due
+
+ {{ assignment.due_date|date:"M d, Y" }} +
+
+ {% endif %} + {% if is_teacher %} +
+
Submissions
+
{{ assignment.submission_count }}
+
+ {% endif %} +
+
+
+
+
+{% endblock content %} \ No newline at end of file diff --git a/web/templates/courses/assignment_form.html b/web/templates/courses/assignment_form.html new file mode 100644 index 000000000..ef7aa261a --- /dev/null +++ b/web/templates/courses/assignment_form.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% block title %}{{ action }} Assignment - {{ course.title }}{% endblock title %} +{% block content %} +
+ +
+

{{ action }} Assignment

+
+ {% csrf_token %} +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + Cancel +
+
+
+
+
+{% endblock content %} diff --git a/web/templates/courses/assignment_list.html b/web/templates/courses/assignment_list.html new file mode 100644 index 000000000..d0c7a299a --- /dev/null +++ b/web/templates/courses/assignment_list.html @@ -0,0 +1,84 @@ +{% extends "base.html" %} +{% block title %}Assignments - {{ course.title }}{% endblock title %} +{% block content %} +
+
+
+ +

Assignments

+
+ {% if is_teacher %} + + New Assignment + + {% endif %} +
+ {% if assignments %} +
+ {% for assignment in assignments %} +
+
+
+
+

{{ assignment.title }}

+ {% if is_teacher %} + + {{ assignment.get_status_display }} + + {% endif %} + {% if assignment.is_past_due %} + Past Due + {% endif %} +
+

{{ assignment.description }}

+
+ Max Score: {{ assignment.max_score }} + {% if assignment.due_date %} + Due: {{ assignment.due_date|date:"M d, Y H:i" }} + {% endif %} + {% if is_teacher %} + {{ assignment.submission_count }} submission{{ assignment.submission_count|pluralize }} + {% else %} + {% with sub=submission_map|default:{} %} + {% if assignment.id|stringformat:"i" in submission_map %} + Submitted + {% endif %} + {% endwith %} + {% endif %} +
+
+
+ View + {% if is_teacher %} + Edit + Delete + {% endif %} +
+
+
+ {% endfor %} +
+ {% else %} +
+ +

No assignments yet.

+ {% if is_teacher %} + + Create First Assignment + + {% endif %} +
+ {% endif %} +
+{% endblock content %} \ No newline at end of file diff --git a/web/templates/courses/detail.html b/web/templates/courses/detail.html index 7869d110d..53c27e770 100644 --- a/web/templates/courses/detail.html +++ b/web/templates/courses/detail.html @@ -594,6 +594,11 @@

{{ course.title }}< Progress + + + Assignments + diff --git a/web/templates/courses/grade_submission.html b/web/templates/courses/grade_submission.html new file mode 100644 index 000000000..2401f3e64 --- /dev/null +++ b/web/templates/courses/grade_submission.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% block title %}Grade Submission - {{ submission.assignment.title }}{% endblock title %} +{% block content %} +
+ +
+

Grade Submission

+

+ {{ submission.student.get_full_name|default:submission.student.username }} — submitted {{ submission.submitted_at|date:"M d, Y H:i" }} +

+
+
+

Student Response

+ {% if submission.text_response %} +
+ {{ submission.text_response|linebreaks }} +
+ {% endif %} + {% if submission.file_submission %} + + Download submitted file + + {% endif %} + {% if not submission.text_response and not submission.file_submission %} +

No response provided.

+ {% endif %} +
+
+

Grade

+
+ {% csrf_token %} +
+ + +
+
+ + +
+
+ + Cancel +
+
+
+
+{% endblock content %} diff --git a/web/urls.py b/web/urls.py index 3fb4e298a..1240e8ed0 100644 --- a/web/urls.py +++ b/web/urls.py @@ -185,6 +185,43 @@ views.course_progress_overview, name="course_progress_overview", ), + + # Assignment URLs + path( + "courses//assignments/", + views.course_assignments, + name="course_assignments", + ), + path( + "courses//assignments/create/", + views.create_assignment, + name="create_assignment", + ), + path( + "courses//assignments//", + views.assignment_detail, + name="assignment_detail", + ), + path( + "courses//assignments//edit/", + views.edit_assignment, + name="edit_assignment", + ), + path( + "courses//assignments//delete/", + views.delete_assignment, + name="delete_assignment", + ), + path( + "courses//submissions//grade/", + views.grade_submission, + name="grade_submission", + ), + path( + "courses//submissions//download/", + views.download_submission, + name="download_submission", + ), path( "courses//materials/upload/", views.upload_material, diff --git a/web/views.py b/web/views.py index b4d485749..b90bffd9e 100644 --- a/web/views.py +++ b/web/views.py @@ -49,6 +49,7 @@ from django.template.loader import render_to_string from django.urls import NoReverseMatch, reverse, reverse_lazy from django.utils import timezone +from django.utils.dateparse import parse_datetime from django.utils.crypto import get_random_string from django.utils.html import strip_tags from django.utils.text import slugify @@ -113,6 +114,8 @@ ) from .models import ( Achievement, + Assignment, + AssignmentSubmission, Badge, BlogComment, BlogPost, @@ -1950,6 +1953,213 @@ def download_material(request, slug, material_id): return redirect("course_detail", slug=slug) +# ─── Assignment Views ──────────────────────────────────────────────────────── + + +@login_required +@teacher_required +def create_assignment(request: HttpRequest, slug: str) -> HttpResponse: + """Teacher creates a new assignment for a course.""" + course = get_object_or_404(Course, slug=slug, teacher=request.user) + if request.method == "POST": + title = request.POST.get("title", "").strip() + description = request.POST.get("description", "").strip() + due_date = request.POST.get("due_date", "").strip() or None + try: + max_score = int(request.POST.get("max_score", 100)) + if max_score < 1 or max_score > 1000: + raise ValueError + except (ValueError, TypeError): + messages.error(request, "Max score must be a number between 1 and 1000.") + return redirect("create_assignment", slug=slug) + status = request.POST.get("status", "draft") + if status not in ("draft", "published"): + status = "draft" + allow_late = request.POST.get("allow_late_submissions") == "on" + if not title or not description: + messages.error(request, "Title and description are required.") + return redirect("create_assignment", slug=slug) + parsed_due = parse_datetime(due_date) if due_date else None + Assignment.objects.create( + course=course, + title=title, + description=description, + due_date=parsed_due, + max_score=max_score, + status=status, + allow_late_submissions=allow_late, + ) + messages.success(request, "Assignment created successfully.") + return redirect("course_assignments", slug=slug) + return render(request, "courses/assignment_form.html", {"course": course, "action": "Create"}) + + +@login_required +def course_assignments(request: HttpRequest, slug: str) -> HttpResponse: + """List all assignments for a course.""" + course = get_object_or_404(Course, slug=slug) + is_teacher = request.user == course.teacher + is_enrolled = Enrollment.objects.filter( + student=request.user, course=course, status__in=["approved", "completed"] + ).exists() + if not is_teacher and not is_enrolled: + messages.error(request, "You must be enrolled to view assignments.") + return redirect("course_detail", slug=slug) + if is_teacher: + assignments = course.assignments.all() + else: + assignments = course.assignments.filter(status="published") + # Annotate submission status for student + submission_map = {} + if not is_teacher: + subs = AssignmentSubmission.objects.filter( + student=request.user, assignment__in=assignments + ).values("assignment_id", "status", "score") + submission_map = {s["assignment_id"]: s for s in subs} + context = { + "course": course, + "assignments": assignments, + "is_teacher": is_teacher, + "submission_map": submission_map, + } + return render(request, "courses/assignment_list.html", context) + + +@login_required +def assignment_detail(request: HttpRequest, slug: str, assignment_id: int) -> HttpResponse: + """View assignment details and submit work (students) or view submissions (teachers).""" + course = get_object_or_404(Course, slug=slug) + assignment = get_object_or_404(Assignment, id=assignment_id, course=course) + is_teacher = request.user == course.teacher + is_enrolled = Enrollment.objects.filter( + student=request.user, course=course, status__in=["approved", "completed"] + ).exists() + if not is_teacher and not is_enrolled: + messages.error(request, "You must be enrolled to view this assignment.") + return redirect("course_detail", slug=slug) + if not is_teacher and assignment.status == "draft": + messages.error(request, "This assignment is not yet published.") + return redirect("course_assignments", slug=slug) + submission = None + submissions = None + if is_teacher: + submissions = assignment.submissions.select_related("student").order_by("-submitted_at") + else: + submission = AssignmentSubmission.objects.filter( + assignment=assignment, student=request.user + ).first() + # Handle student submission + if request.method == "POST": + if submission: + messages.error(request, "You have already submitted this assignment.") + return redirect("assignment_detail", slug=slug, assignment_id=assignment_id) + if assignment.is_past_due and not assignment.allow_late_submissions: + messages.error(request, "This assignment is past due and does not accept late submissions.") + return redirect("assignment_detail", slug=slug, assignment_id=assignment_id) + text_response = request.POST.get("text_response", "").strip() + file_submission = request.FILES.get("file_submission") + if file_submission: + allowed_types = ["application/pdf", "image/jpeg", "image/png", "text/plain", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"] + if file_submission.content_type not in allowed_types: + messages.error(request, "Invalid file type. Allowed: PDF, JPEG, PNG, TXT, DOC, DOCX.") + return redirect("assignment_detail", slug=slug, assignment_id=assignment_id) + if file_submission.size > 10 * 1024 * 1024: # 10MB + messages.error(request, "File size must not exceed 10MB.") + return redirect("assignment_detail", slug=slug, assignment_id=assignment_id) + if not text_response and not file_submission: + messages.error(request, "Please provide a text response or upload a file.") + return redirect("assignment_detail", slug=slug, assignment_id=assignment_id) + _, created = AssignmentSubmission.objects.get_or_create( + assignment=assignment, + student=request.user, + defaults={"text_response": text_response, "file_submission": file_submission}, + ) + if not created: + messages.error(request, "You have already submitted this assignment.") + return redirect("assignment_detail", slug=slug, assignment_id=assignment_id) + messages.success(request, "Assignment submitted successfully.") + return redirect("assignment_detail", slug=slug, assignment_id=assignment_id) + context = { + "course": course, + "assignment": assignment, + "is_teacher": is_teacher, + "submission": submission, + "submissions": submissions, + } + return render(request, "courses/assignment_detail.html", context) + + +@login_required +@teacher_required +def grade_submission(request: HttpRequest, slug: str, submission_id: int) -> HttpResponse: + """Teacher grades a student submission.""" + course = get_object_or_404(Course, slug=slug, teacher=request.user) + submission = get_object_or_404(AssignmentSubmission, id=submission_id, assignment__course=course) + if request.method == "POST": + score_str = request.POST.get("score", "").strip() + feedback = request.POST.get("feedback", "").strip() + if not score_str.isdigit(): + messages.error(request, "Score must be a valid number.") + return redirect("grade_submission", slug=slug, submission_id=submission_id) + score = int(score_str) + if score > submission.assignment.max_score: + messages.error(request, f"Score cannot exceed {submission.assignment.max_score}.") + return redirect("grade_submission", slug=slug, submission_id=submission_id) + submission.score = score + submission.feedback = feedback + submission.status = "graded" + submission.graded_by = request.user + submission.graded_at = timezone.now() + submission.save(update_fields=["score", "feedback", "status", "graded_by", "graded_at"]) + messages.success(request, f"Submission graded: {score}/{submission.assignment.max_score}.") + return redirect("assignment_detail", slug=slug, assignment_id=submission.assignment.id) + context = {"course": course, "submission": submission} + return render(request, "courses/grade_submission.html", context) + + +@login_required +@teacher_required +def edit_assignment(request: HttpRequest, slug: str, assignment_id: int) -> HttpResponse: + """Teacher edits an existing assignment.""" + course = get_object_or_404(Course, slug=slug, teacher=request.user) + assignment = get_object_or_404(Assignment, id=assignment_id, course=course) + if request.method == "POST": + assignment.title = request.POST.get("title", "").strip() + assignment.description = request.POST.get("description", "").strip() + due_date = request.POST.get("due_date", "").strip() or None + assignment.max_score = int(request.POST.get("max_score", 100)) + assignment.status = request.POST.get("status", "draft") + assignment.allow_late_submissions = request.POST.get("allow_late_submissions") == "on" + assignment.due_date = parse_datetime(due_date) if due_date else None + assignment.save() + messages.success(request, "Assignment updated successfully.") + return redirect("course_assignments", slug=slug) + return render(request, "courses/assignment_form.html", { + "course": course, + "assignment": assignment, + "action": "Edit", + }) + + +@login_required +@teacher_required +def delete_assignment(request: HttpRequest, slug: str, assignment_id: int) -> HttpResponse: + """Teacher deletes an assignment.""" + course = get_object_or_404(Course, slug=slug, teacher=request.user) + assignment = get_object_or_404(Assignment, id=assignment_id, course=course) + if request.method == "POST": + assignment.delete() + messages.success(request, "Assignment deleted.") + return redirect("course_assignments", slug=slug) + return render(request, "courses/assignment_confirm_delete.html", { + "course": course, + "assignment": assignment, + }) + + + @login_required @teacher_required def course_marketing(request, slug): @@ -8839,3 +9049,26 @@ def leave_session_waiting_room(request, course_slug): messages.info(request, "You are not in the session waiting room for this course.") return redirect("course_detail", slug=course_slug) + + +@login_required +def download_submission(request, submission_id): + """Serve submission files only to the course teacher or the submitting student.""" + submission = get_object_or_404(AssignmentSubmission, id=submission_id) + course = submission.assignment.course + is_teacher = request.user == course.teacher + is_owner = request.user == submission.student + if not is_teacher and not is_owner: + messages.error(request, "You do not have permission to access this file.") + return redirect("course_detail", slug=course.slug) + if not submission.file_submission: + messages.error(request, "No file attached to this submission.") + return redirect("assignment_detail", slug=course.slug, assignment_id=submission.assignment.id) + from django.http import FileResponse + import os + file_path = submission.file_submission.path + if not os.path.exists(file_path): + messages.error(request, "File not found.") + return redirect("assignment_detail", slug=course.slug, assignment_id=submission.assignment.id) + return FileResponse(open(file_path, "rb"), as_attachment=True, + filename=os.path.basename(file_path))