diff --git a/src/homeworks/tests/test_homework_expiry_model.py b/src/homeworks/tests/test_homework_expiry_model.py
new file mode 100644
index 0000000..145fecc
--- /dev/null
+++ b/src/homeworks/tests/test_homework_expiry_model.py
@@ -0,0 +1,99 @@
+"""
+Tests for the Homework expiry and visibility model properties.
+
+Covers:
+- is_expired property
+- is_accessible_to_students property
+- Default field values
+"""
+
+from datetime import timedelta
+
+from django.test import TestCase
+from django.utils import timezone
+
+from accounts.models import Teacher
+from courses.models import Course
+from django.contrib.auth import get_user_model
+from homeworks.models import Homework
+
+User = get_user_model()
+
+
+class HomeworkExpiryModelTests(TestCase):
+ """Tests for Homework.is_expired and is_accessible_to_students."""
+
+ def setUp(self):
+ user = User.objects.create_user(username="teacher", password="pass")
+ self.teacher = Teacher.objects.create(user=user)
+ self.course = Course.objects.create(name="Course", code="C1")
+ self.base_data = {
+ "title": "HW",
+ "description": "desc",
+ "created_by": self.teacher,
+ "course": self.course,
+ "due_date": timezone.now() + timedelta(days=7),
+ }
+
+ def _make(self, **kwargs):
+ data = {**self.base_data, **kwargs}
+ return Homework.objects.create(**data)
+
+ # --- defaults ---
+
+ def test_expires_at_defaults_to_null(self):
+ hw = self._make()
+ self.assertIsNone(hw.expires_at)
+
+ def test_is_hidden_defaults_to_false(self):
+ hw = self._make()
+ self.assertFalse(hw.is_hidden)
+
+ # --- is_expired ---
+
+ def test_is_expired_false_when_expires_at_is_null(self):
+ hw = self._make()
+ self.assertFalse(hw.is_expired)
+
+ def test_is_expired_false_when_expires_at_is_in_future(self):
+ hw = self._make(expires_at=timezone.now() + timedelta(days=3))
+ self.assertFalse(hw.is_expired)
+
+ def test_is_expired_true_when_expires_at_is_in_past(self):
+ hw = self._make(expires_at=timezone.now() - timedelta(seconds=1))
+ self.assertTrue(hw.is_expired)
+
+ # --- is_accessible_to_students ---
+
+ def test_accessible_by_default(self):
+ hw = self._make()
+ self.assertTrue(hw.is_accessible_to_students)
+
+ def test_not_accessible_when_is_hidden_true(self):
+ hw = self._make(is_hidden=True)
+ self.assertFalse(hw.is_accessible_to_students)
+
+ def test_not_accessible_when_expired(self):
+ hw = self._make(expires_at=timezone.now() - timedelta(seconds=1))
+ self.assertFalse(hw.is_accessible_to_students)
+
+ def test_not_accessible_when_hidden_and_expired(self):
+ hw = self._make(
+ is_hidden=True,
+ expires_at=timezone.now() - timedelta(seconds=1),
+ )
+ self.assertFalse(hw.is_accessible_to_students)
+
+ def test_accessible_when_not_hidden_and_future_expiry(self):
+ hw = self._make(
+ is_hidden=False,
+ expires_at=timezone.now() + timedelta(days=1),
+ )
+ self.assertTrue(hw.is_accessible_to_students)
+
+ def test_not_accessible_when_hidden_despite_future_expiry(self):
+ hw = self._make(
+ is_hidden=True,
+ expires_at=timezone.now() + timedelta(days=1),
+ )
+ self.assertFalse(hw.is_accessible_to_students)
diff --git a/src/homeworks/tests/test_homework_expiry_views.py b/src/homeworks/tests/test_homework_expiry_views.py
new file mode 100644
index 0000000..16aa6d8
--- /dev/null
+++ b/src/homeworks/tests/test_homework_expiry_views.py
@@ -0,0 +1,358 @@
+"""
+Tests for homework expiry/visibility enforcement in views.
+
+Covers:
+- HomeworkListView: expired/hidden homeworks hidden from students, visible to teachers
+- HomeworkDetailView: students blocked when inaccessible; teachers always see it
+- SectionDetailView: students blocked when homework is inaccessible
+- HomeworkForm validation: expires_at must be after due_date
+"""
+
+from datetime import timedelta
+
+from django.test import TestCase, RequestFactory
+from django.urls import reverse
+from django.utils import timezone
+from django.contrib.auth import get_user_model
+
+from accounts.models import Teacher, Student
+from courses.models import Course, CourseEnrollment, CourseTeacher
+from homeworks.models import Homework, Section
+from homeworks.views import HomeworkListView
+from homeworks.forms import HomeworkCreateForm, HomeworkEditForm
+
+User = get_user_model()
+
+
+class HomeworkExpiryViewSetUpMixin(TestCase):
+ """Shared setUp for expiry view tests."""
+
+ def setUp(self):
+ self.factory = RequestFactory()
+
+ # Teacher
+ teacher_user = User.objects.create_user(
+ username="teacher", email="t@test.com", password="pass"
+ )
+ self.teacher = Teacher.objects.create(user=teacher_user)
+ self.teacher_user = teacher_user
+
+ # Student
+ student_user = User.objects.create_user(
+ username="student", email="s@test.com", password="pass"
+ )
+ self.student = Student.objects.create(user=student_user)
+ self.student_user = student_user
+
+ # Course
+ self.course = Course.objects.create(name="Course", code="C1", is_active=True)
+ CourseTeacher.objects.create(course=self.course, teacher=self.teacher, role="owner")
+ CourseEnrollment.objects.create(course=self.course, student=self.student, is_active=True)
+
+ # Visible homework (control)
+ self.visible_hw = Homework.objects.create(
+ title="Visible",
+ description="desc",
+ created_by=self.teacher,
+ course=self.course,
+ due_date=timezone.now() + timedelta(days=7),
+ )
+
+ # Hidden homework (manual toggle)
+ self.hidden_hw = Homework.objects.create(
+ title="Hidden",
+ description="desc",
+ created_by=self.teacher,
+ course=self.course,
+ due_date=timezone.now() + timedelta(days=7),
+ is_hidden=True,
+ )
+
+ # Expired homework
+ self.expired_hw = Homework.objects.create(
+ title="Expired",
+ description="desc",
+ created_by=self.teacher,
+ course=self.course,
+ due_date=timezone.now() - timedelta(days=14),
+ expires_at=timezone.now() - timedelta(days=1),
+ )
+
+ # Section for visible homework
+ self.section = Section.objects.create(
+ homework=self.visible_hw,
+ title="Section 1",
+ content="content",
+ order=1,
+ )
+
+ # Section for expired homework
+ self.expired_section = Section.objects.create(
+ homework=self.expired_hw,
+ title="Section 1",
+ content="content",
+ order=1,
+ )
+
+
+# ─── HomeworkListView ─────────────────────────────────────────────────────────
+
+class HomeworkListViewExpiryTests(HomeworkExpiryViewSetUpMixin):
+
+ def _get_student_list_data(self):
+ request = self.factory.get("/homeworks/")
+ request.user = self.student_user
+ view = HomeworkListView()
+ return view._get_view_data(self.student_user)
+
+ def _get_teacher_list_data(self):
+ request = self.factory.get("/homeworks/")
+ request.user = self.teacher_user
+ view = HomeworkListView()
+ return view._get_view_data(self.teacher_user)
+
+ def test_student_does_not_see_hidden_homework(self):
+ data = self._get_student_list_data()
+ titles = [hw.title for hw in data.homeworks]
+ self.assertNotIn("Hidden", titles)
+
+ def test_student_does_not_see_expired_homework(self):
+ data = self._get_student_list_data()
+ titles = [hw.title for hw in data.homeworks]
+ self.assertNotIn("Expired", titles)
+
+ def test_student_sees_visible_homework(self):
+ data = self._get_student_list_data()
+ titles = [hw.title for hw in data.homeworks]
+ self.assertIn("Visible", titles)
+
+ def test_teacher_sees_all_homeworks_including_hidden(self):
+ data = self._get_teacher_list_data()
+ titles = [hw.title for hw in data.homeworks]
+ self.assertIn("Hidden", titles)
+
+ def test_teacher_sees_all_homeworks_including_expired(self):
+ data = self._get_teacher_list_data()
+ titles = [hw.title for hw in data.homeworks]
+ self.assertIn("Expired", titles)
+
+ def test_teacher_list_item_exposes_is_hidden_flag(self):
+ data = self._get_teacher_list_data()
+ hidden_item = next(hw for hw in data.homeworks if hw.title == "Hidden")
+ self.assertTrue(hidden_item.is_hidden)
+
+ def test_teacher_list_item_exposes_is_accessible_to_students_false_for_hidden(self):
+ data = self._get_teacher_list_data()
+ hidden_item = next(hw for hw in data.homeworks if hw.title == "Hidden")
+ self.assertFalse(hidden_item.is_accessible_to_students)
+
+ def test_teacher_list_item_exposes_expires_at(self):
+ data = self._get_teacher_list_data()
+ expired_item = next(hw for hw in data.homeworks if hw.title == "Expired")
+ self.assertIsNotNone(expired_item.expires_at)
+
+
+# ─── HomeworkDetailView ───────────────────────────────────────────────────────
+
+class HomeworkDetailViewExpiryTests(HomeworkExpiryViewSetUpMixin):
+
+ def test_student_cannot_access_hidden_homework_detail(self):
+ self.client.login(username="student", password="pass")
+ url = reverse("homeworks:detail", kwargs={"homework_id": self.hidden_hw.id})
+ response = self.client.get(url)
+ # Returns redirect (to list) when data is None
+ self.assertEqual(response.status_code, 302)
+
+ def test_student_cannot_access_expired_homework_detail(self):
+ self.client.login(username="student", password="pass")
+ url = reverse("homeworks:detail", kwargs={"homework_id": self.expired_hw.id})
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 302)
+
+ def test_student_can_access_visible_homework_detail(self):
+ self.client.login(username="student", password="pass")
+ url = reverse("homeworks:detail", kwargs={"homework_id": self.visible_hw.id})
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+ def test_teacher_can_access_hidden_homework_detail(self):
+ self.client.login(username="teacher", password="pass")
+ url = reverse("homeworks:detail", kwargs={"homework_id": self.hidden_hw.id})
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+ def test_teacher_can_access_expired_homework_detail(self):
+ self.client.login(username="teacher", password="pass")
+ url = reverse("homeworks:detail", kwargs={"homework_id": self.expired_hw.id})
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+ def test_teacher_detail_data_includes_visibility_fields(self):
+ self.client.login(username="teacher", password="pass")
+ url = reverse("homeworks:detail", kwargs={"homework_id": self.hidden_hw.id})
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ data = response.context["data"]
+ self.assertTrue(data.is_hidden)
+ self.assertFalse(data.is_accessible_to_students)
+
+
+# ─── SectionDetailView ────────────────────────────────────────────────────────
+
+class SectionDetailViewExpiryTests(HomeworkExpiryViewSetUpMixin):
+
+ def test_student_blocked_from_section_of_expired_homework(self):
+ self.client.login(username="student", password="pass")
+ url = reverse(
+ "homeworks:section_detail",
+ kwargs={"homework_id": self.expired_hw.id, "section_id": self.expired_section.id},
+ )
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 403)
+
+ def test_student_blocked_from_section_of_hidden_homework(self):
+ # Add a section to hidden_hw
+ hidden_section = Section.objects.create(
+ homework=self.hidden_hw,
+ title="Section 1",
+ content="content",
+ order=1,
+ )
+ self.client.login(username="student", password="pass")
+ url = reverse(
+ "homeworks:section_detail",
+ kwargs={"homework_id": self.hidden_hw.id, "section_id": hidden_section.id},
+ )
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 403)
+
+ def test_student_can_access_section_of_visible_homework(self):
+ self.client.login(username="student", password="pass")
+ url = reverse(
+ "homeworks:section_detail",
+ kwargs={"homework_id": self.visible_hw.id, "section_id": self.section.id},
+ )
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+ def test_teacher_can_access_section_of_expired_homework(self):
+ self.client.login(username="teacher", password="pass")
+ url = reverse(
+ "homeworks:section_detail",
+ kwargs={"homework_id": self.expired_hw.id, "section_id": self.expired_section.id},
+ )
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+
+# ─── Form validation ──────────────────────────────────────────────────────────
+
+class HomeworkFormExpiryValidationTests(TestCase):
+
+ def setUp(self):
+ user = User.objects.create_user(username="t", password="p")
+ self.teacher = Teacher.objects.create(user=user)
+ self.course = Course.objects.create(name="C", code="C1")
+
+ def _base_data(self, **overrides):
+ data = {
+ "title": "HW",
+ "description": "desc",
+ "course": self.course.id,
+ "due_date": (timezone.now() + timedelta(days=3)).strftime("%Y-%m-%dT%H:%M"),
+ "expires_at": (timezone.now() + timedelta(days=10)).strftime("%Y-%m-%dT%H:%M"),
+ "is_hidden": False,
+ }
+ data.update(overrides)
+ return data
+
+ def test_create_form_valid_when_expires_at_after_due_date(self):
+ form = HomeworkCreateForm(data=self._base_data())
+ self.assertTrue(form.is_valid(), form.errors)
+
+ def test_create_form_warns_when_expires_at_before_due_date(self):
+ """When expires_at < due_date, form is valid and expires_at_adjusted flag is set as warning."""
+ due = timezone.now() + timedelta(days=3)
+ data = self._base_data(
+ due_date=due.strftime("%Y-%m-%dT%H:%M"),
+ expires_at=(timezone.now() + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M"),
+ )
+ form = HomeworkCreateForm(data=data)
+ self.assertTrue(form.is_valid(), form.errors)
+ self.assertTrue(form.expires_at_adjusted)
+
+ def test_create_form_no_warning_when_expires_at_equals_due_date(self):
+ """When expires_at == due_date, form is valid with no warning (equal is allowed)."""
+ due = timezone.now() + timedelta(days=3)
+ data = self._base_data(
+ due_date=due.strftime("%Y-%m-%dT%H:%M"),
+ expires_at=due.strftime("%Y-%m-%dT%H:%M"),
+ )
+ form = HomeworkCreateForm(data=data)
+ self.assertTrue(form.is_valid(), form.errors)
+ self.assertFalse(form.expires_at_adjusted)
+
+ def test_create_form_valid_with_no_expires_at(self):
+ data = self._base_data(expires_at="")
+ form = HomeworkCreateForm(data=data)
+ self.assertTrue(form.is_valid(), form.errors)
+
+ def test_edit_form_valid_when_expires_at_after_due_date(self):
+ hw = Homework.objects.create(
+ title="HW",
+ description="desc",
+ created_by=self.teacher,
+ course=self.course,
+ due_date=timezone.now() + timedelta(days=7),
+ )
+ form = HomeworkEditForm(data=self._base_data(), instance=hw)
+ self.assertTrue(form.is_valid(), form.errors)
+
+ def test_edit_form_warns_when_expires_at_before_due_date(self):
+ """When expires_at < due_date, edit form is valid and expires_at_adjusted warning is set."""
+ hw = Homework.objects.create(
+ title="HW",
+ description="desc",
+ created_by=self.teacher,
+ course=self.course,
+ due_date=timezone.now() + timedelta(days=7),
+ )
+ data = self._base_data(
+ expires_at=(timezone.now() + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M")
+ )
+ form = HomeworkEditForm(data=data, instance=hw)
+ self.assertTrue(form.is_valid(), form.errors)
+ self.assertTrue(form.expires_at_adjusted)
+
+ def test_edit_form_allows_past_due_date(self):
+ """Edit form accepts any due_date — no restriction."""
+ hw = Homework.objects.create(
+ title="HW",
+ description="desc",
+ created_by=self.teacher,
+ course=self.course,
+ due_date=timezone.now() + timedelta(days=7),
+ )
+ data = self._base_data(
+ due_date=(timezone.now() - timedelta(days=3)).strftime("%Y-%m-%dT%H:%M"),
+ expires_at="",
+ )
+ form = HomeworkEditForm(data=data, instance=hw)
+ self.assertTrue(form.is_valid(), form.errors)
+
+ def test_create_form_allows_today_as_due_date(self):
+ """Create form allows due_date set to today."""
+ data = self._base_data(
+ due_date=timezone.now().strftime("%Y-%m-%dT%H:%M"),
+ )
+ form = HomeworkCreateForm(data=data)
+ self.assertTrue(form.is_valid(), form.errors)
+
+ def test_create_form_rejects_past_due_date(self):
+ """Create form rejects a due_date strictly in the past (yesterday)."""
+ data = self._base_data(
+ due_date=(timezone.now() - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M"),
+ )
+ form = HomeworkCreateForm(data=data)
+ self.assertFalse(form.is_valid())
+ self.assertIn("due_date", form.errors)
diff --git a/src/homeworks/views.py b/src/homeworks/views.py
index 0ef0179..af75aca 100644
--- a/src/homeworks/views.py
+++ b/src/homeworks/views.py
@@ -49,6 +49,9 @@ class HomeworkListItem:
section_count: int
created_at: Any # datetime
is_overdue: bool
+ expires_at: Any = None # datetime or None
+ is_hidden: bool = False
+ is_accessible_to_students: bool = True
sections: list[SectionData] | None = None
completed_percentage: int = 0
in_progress_percentage: int = 0
@@ -134,6 +137,9 @@ def _get_view_data(self, user) -> HomeworkListData:
section_count=homework.section_count,
created_at=homework.created_at,
is_overdue=homework.is_overdue,
+ expires_at=homework.expires_at,
+ is_hidden=homework.is_hidden,
+ is_accessible_to_students=homework.is_accessible_to_students,
sections=None, # No section data needed for teacher view
)
)
@@ -148,9 +154,16 @@ def _get_view_data(self, user) -> HomeworkListData:
courseenrollment__is_active=True
)
- # Get homeworks for courses student is enrolled in (direct FK now)
+ # Get homeworks for courses student is enrolled in (direct FK now),
+ # excluding homeworks that are hidden or have expired.
+ from django.db.models import Q
+
homework_objects = (
Homework.objects.filter(course__in=enrolled_courses)
+ .filter(is_hidden=False)
+ .filter(
+ Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now())
+ )
.order_by("-created_at")
.prefetch_related("sections")
)
@@ -253,6 +266,9 @@ class HomeworkDetailData:
is_overdue: bool
user_type: str # 'teacher', 'student', or 'unknown'
can_edit: bool
+ expires_at: Any = None # datetime or None
+ is_hidden: bool = False
+ is_accessible_to_students: bool = True
llm_config: Dict[str, Any] | None = None
@@ -342,6 +358,11 @@ def post(self, request: TeacherRequest, homework_id: UUID) -> HttpResponse:
if data.is_submitted:
messages.success(request, "Homework updated successfully!")
+ if getattr(data.form, "expires_at_adjusted", False):
+ messages.warning(
+ request,
+ "Note: the expiry date is set before the due date. Students will lose access before the homework is officially due.",
+ )
return redirect("homeworks:detail", homework_id=homework_id)
return render(request, "homeworks/form.html", {"data": data})
@@ -576,7 +597,7 @@ def _get_view_data(self, user, homework_id: UUID) -> HomeworkDetailData | None:
teacher_profile = getattr(user, "teacher_profile", None)
student_profile = getattr(user, "student_profile", None)
- # For students, check if they're enrolled in the course that has this homework
+ # For students, check enrollment and visibility
if student_profile:
homework = Homework.objects.filter(id=homework_id).first()
if homework and homework.course:
@@ -587,6 +608,9 @@ def _get_view_data(self, user, homework_id: UUID) -> HomeworkDetailData | None:
if not has_access:
return None
+
+ if not homework.is_accessible_to_students:
+ return None
else:
return None
@@ -668,6 +692,9 @@ def _get_view_data(self, user, homework_id: UUID) -> HomeworkDetailData | None:
else "Unknown Teacher"
)
+ # Fetch the raw homework object for expiry fields (may already be fetched above)
+ homework_obj_for_expiry = Homework.objects.filter(id=homework_id).first()
+
# Create and return the view data
return HomeworkDetailData(
id=homework_detail.id,
@@ -681,6 +708,9 @@ def _get_view_data(self, user, homework_id: UUID) -> HomeworkDetailData | None:
is_overdue=homework_detail.due_date < timezone.now(),
user_type=user_type,
can_edit=can_edit,
+ expires_at=homework_obj_for_expiry.expires_at if homework_obj_for_expiry else None,
+ is_hidden=homework_obj_for_expiry.is_hidden if homework_obj_for_expiry else False,
+ is_accessible_to_students=homework_obj_for_expiry.is_accessible_to_students if homework_obj_for_expiry else True,
llm_config={"id": homework_detail.llm_config}
if homework_detail.llm_config
else None,
@@ -746,7 +776,7 @@ def get(
if not (created_by_teacher or teaches_course_with_homework):
return HttpResponseForbidden("Access denied.")
- # For students, check if they're enrolled in the course that has this homework
+ # For students, check enrollment and visibility
if student_profile:
if homework.course:
is_enrolled = homework.course.is_student_enrolled(student_profile)
@@ -754,6 +784,10 @@ def get(
return HttpResponseForbidden(
"Access denied. You are not enrolled in the course that has this homework."
)
+ if not homework.is_accessible_to_students:
+ return HttpResponseForbidden(
+ "This assignment is no longer available."
+ )
else:
return HttpResponseForbidden(
"Access denied. This homework is not assigned to a course."
diff --git a/src/llteacher/management/commands/populate_test_database.py b/src/llteacher/management/commands/populate_test_database.py
index f82a64b..c4b69e4 100644
--- a/src/llteacher/management/commands/populate_test_database.py
+++ b/src/llteacher/management/commands/populate_test_database.py
@@ -8,6 +8,7 @@
from llm.models import LLMConfig
from homeworks.models import Homework, Section, SectionSolution
from conversations.models import Conversation, Message, Submission
+from courses.models import Course, CourseTeacher, CourseEnrollment
User = get_user_model()
@@ -36,8 +37,11 @@ def handle(self, *args, **options):
# Create LLM configuration
llm_config = self.create_llm_config()
+ # Create courses
+ courses = self.create_courses(users["teachers"], users["students"])
+
# Create homeworks with sections
- homeworks = self.create_homeworks(users["teachers"], llm_config)
+ homeworks = self.create_homeworks(users["teachers"], llm_config, courses)
# Create conversations and messages
self.create_conversations_and_messages(users["students"], homeworks)
@@ -53,6 +57,9 @@ def reset_database(self):
SectionSolution.objects.all().delete()
Section.objects.all().delete()
Homework.objects.all().delete()
+ CourseEnrollment.objects.all().delete()
+ CourseTeacher.objects.all().delete()
+ Course.objects.all().delete()
LLMConfig.objects.all().delete()
Teacher.objects.all().delete()
Student.objects.all().delete()
@@ -161,7 +168,32 @@ def create_llm_config(self):
self.stdout.write(" ✓ Created LLM configuration")
return llm_config
- def create_homeworks(self, teachers, llm_config):
+ def create_courses(self, teachers, students):
+ """Create sample courses and enroll users."""
+ self.stdout.write("Creating courses...")
+
+ course1 = Course.objects.create(
+ name="Introduction to Python",
+ description="A beginner course on Python programming.",
+ code="CS101",
+ )
+ CourseTeacher.objects.create(course=course1, teacher=teachers[0], role="owner")
+ for student in students:
+ CourseEnrollment.objects.create(course=course1, student=student)
+
+ course2 = Course.objects.create(
+ name="Data Analysis with Python",
+ description="Learn data analysis techniques using Python.",
+ code="CS201",
+ )
+ CourseTeacher.objects.create(course=course2, teacher=teachers[1], role="owner")
+ for student in students:
+ CourseEnrollment.objects.create(course=course2, student=student)
+
+ self.stdout.write(" ✓ Created 2 courses")
+ return [course1, course2]
+
+ def create_homeworks(self, teachers, llm_config, courses):
"""Create sample homeworks with sections."""
self.stdout.write("Creating homeworks and sections...")
@@ -172,6 +204,7 @@ def create_homeworks(self, teachers, llm_config):
title="Python Basics",
description="Introduction to Python programming fundamentals including variables, data types, and control structures.",
created_by=teachers[0],
+ course=courses[0],
due_date=timezone.now() + timedelta(days=7),
llm_config=llm_config,
)
@@ -317,6 +350,7 @@ def calculate_average(numbers):
title="Data Analysis with Python",
description="Learn to analyze data using Python lists and dictionaries. Practice with real-world data scenarios.",
created_by=teachers[1],
+ course=courses[1],
due_date=timezone.now() + timedelta(days=10),
llm_config=llm_config,
)
From f1d953817ee3ec5564edacb35afef790df8f2543 Mon Sep 17 00:00:00 2001
From: PavanVemparala <109388662+PavanVemparala@users.noreply.github.com>
Date: Mon, 16 Mar 2026 16:50:24 -0700
Subject: [PATCH 2/3] fixed errors
---
src/conversations/views.py | 12 ++++----
src/courses/models.py | 4 +--
src/courses/views.py | 16 ++++++----
src/homeworks/admin.py | 9 +++++-
src/homeworks/forms.py | 14 ++++-----
...0005_add_homework_expiry_and_visibility.py | 1 -
src/homeworks/services.py | 2 +-
.../tests/test_homework_expiry_views.py | 30 +++++++++++++------
src/homeworks/views.py | 30 ++++++++++++-------
src/llm/services.py | 12 ++++----
src/llteacher/permissions/decorators.py | 1 -
src/llteacher/test_settings.py | 4 ++-
12 files changed, 87 insertions(+), 48 deletions(-)
diff --git a/src/conversations/views.py b/src/conversations/views.py
index 6f0d864..c3a45ca 100644
--- a/src/conversations/views.py
+++ b/src/conversations/views.py
@@ -5,7 +5,7 @@
following the testable-first architecture with typed data contracts.
"""
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from typing import Dict, Optional, List
from uuid import UUID
from datetime import datetime
@@ -107,9 +107,11 @@ class ConversationDetailData:
messages: List[MessageViewData]
can_submit: bool
is_teacher_test: bool
- paste_events: List[PasteEventViewData] = None
- rapid_text_growth_events: List[RapidTextGrowthEventViewData] = None
- user_id: UUID = None
+ paste_events: Optional[List[PasteEventViewData]] = field(default=None)
+ rapid_text_growth_events: Optional[List[RapidTextGrowthEventViewData]] = field(
+ default=None
+ )
+ user_id: Optional[UUID] = field(default=None)
@dataclass
@@ -155,7 +157,7 @@ def validate_and_authorize_request(
# Create processing request
processing_request = MessageProcessingRequest(
conversation_id=conversation_id,
- user=request.user,
+ user=request.user, # type: ignore[arg-type]
content=content,
message_type=message_type,
)
diff --git a/src/courses/models.py b/src/courses/models.py
index 62920d4..68af28e 100644
--- a/src/courses/models.py
+++ b/src/courses/models.py
@@ -11,10 +11,10 @@ class Course(models.Model):
code = models.CharField(max_length=256, unique=True)
# Many-to-many relationships
- teachers = models.ManyToManyField(
+ teachers: models.ManyToManyField = models.ManyToManyField(
"accounts.Teacher", through="CourseTeacher", related_name="courses"
)
- students = models.ManyToManyField(
+ students: models.ManyToManyField = models.ManyToManyField(
"accounts.Student", through="CourseEnrollment", related_name="enrolled_courses"
)
diff --git a/src/courses/views.py b/src/courses/views.py
index 671b01a..eb21fce 100644
--- a/src/courses/views.py
+++ b/src/courses/views.py
@@ -6,7 +6,7 @@
"""
from dataclasses import dataclass
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, cast
from uuid import UUID
from django.views import View
from django.http import HttpRequest, HttpResponse, HttpResponseForbidden
@@ -291,7 +291,7 @@ def get(self, request: HttpRequest, course_id: UUID) -> HttpResponse:
has_access = course.is_student_enrolled(student_profile)
user_type = "student"
- if not has_access:
+ if not has_access or user_type is None:
return HttpResponseForbidden("You do not have access to this course.")
# Get the appropriate data based on user type
@@ -437,7 +437,10 @@ def _get_view_data(
form = HomeworkCreateForm(initial={"course": course})
# Create empty section form (we'll start with one)
- SectionFormset = formset_factory(SectionForm, extra=1, formset=SectionFormSet)
+ SectionFormset = cast(
+ "type[SectionFormSet]",
+ formset_factory(SectionForm, extra=1, formset=SectionFormSet),
+ )
section_formset = SectionFormset(prefix="sections")
# Return form data
@@ -464,12 +467,15 @@ def _process_form_submission(
# Create a mutable copy of POST data and inject course
post_data = request.POST.copy()
- post_data["course"] = course.id
+ post_data["course"] = str(course.id)
# Create forms from POST data
form = HomeworkCreateForm(post_data)
- SectionFormset = formset_factory(SectionForm, extra=0, formset=SectionFormSet)
+ SectionFormset = cast(
+ "type[SectionFormSet]",
+ formset_factory(SectionForm, extra=0, formset=SectionFormSet),
+ )
section_formset = SectionFormset(request.POST, prefix="sections")
# Check form validity
diff --git a/src/homeworks/admin.py b/src/homeworks/admin.py
index ce81d26..cdd2f62 100644
--- a/src/homeworks/admin.py
+++ b/src/homeworks/admin.py
@@ -4,7 +4,14 @@
@admin.register(Homework)
class HomeworkAdmin(admin.ModelAdmin):
- list_display = ("title", "course", "due_date", "expires_at", "is_hidden", "accessible_to_students")
+ list_display = (
+ "title",
+ "course",
+ "due_date",
+ "expires_at",
+ "is_hidden",
+ "accessible_to_students",
+ )
list_filter = ("is_hidden", "course")
readonly_fields = ("accessible_to_students",)
diff --git a/src/homeworks/forms.py b/src/homeworks/forms.py
index a7fe325..31af643 100644
--- a/src/homeworks/forms.py
+++ b/src/homeworks/forms.py
@@ -107,9 +107,7 @@ class Meta:
"type": "datetime-local",
}
),
- "is_hidden": forms.CheckboxInput(
- attrs={"class": "form-check-input"}
- ),
+ "is_hidden": forms.CheckboxInput(attrs={"class": "form-check-input"}),
"llm_config": forms.Select(
attrs={"class": "form-select", "placeholder": "LLM Configuration"}
),
@@ -132,7 +130,11 @@ def clean_due_date(self):
due_date = _make_aware_if_naive(self.cleaned_data.get("due_date"))
if due_date:
- today = timezone.now().date() if not settings.USE_TZ else timezone.localtime(timezone.now()).date()
+ today = (
+ timezone.now().date()
+ if not settings.USE_TZ
+ else timezone.localtime(timezone.now()).date()
+ )
if due_date.date() < today:
raise forms.ValidationError("Due date cannot be in the past.")
@@ -191,9 +193,7 @@ class Meta:
"type": "datetime-local",
}
),
- "is_hidden": forms.CheckboxInput(
- attrs={"class": "form-check-input"}
- ),
+ "is_hidden": forms.CheckboxInput(attrs={"class": "form-check-input"}),
"llm_config": forms.Select(
attrs={"class": "form-select", "placeholder": "LLM Configuration"}
),
diff --git a/src/homeworks/migrations/0005_add_homework_expiry_and_visibility.py b/src/homeworks/migrations/0005_add_homework_expiry_and_visibility.py
index ab84c4f..d14646d 100644
--- a/src/homeworks/migrations/0005_add_homework_expiry_and_visibility.py
+++ b/src/homeworks/migrations/0005_add_homework_expiry_and_visibility.py
@@ -4,7 +4,6 @@
class Migration(migrations.Migration):
-
dependencies = [
("homeworks", "0004_alter_homework_course"),
]
diff --git a/src/homeworks/services.py b/src/homeworks/services.py
index 436cd59..7511d04 100644
--- a/src/homeworks/services.py
+++ b/src/homeworks/services.py
@@ -890,7 +890,7 @@ def get_homework_submissions(homework_id: UUID) -> HomeworkSubmissionsData | Non
# Create a map of conversation_id -> paste event count for quick lookup
from collections import defaultdict
- paste_event_count_map = defaultdict(int)
+ paste_event_count_map: defaultdict[UUID, int] = defaultdict(int)
for paste_event in paste_events:
if paste_event.last_message_before_paste:
conv_id = paste_event.last_message_before_paste.conversation.id
diff --git a/src/homeworks/tests/test_homework_expiry_views.py b/src/homeworks/tests/test_homework_expiry_views.py
index 16aa6d8..cad724e 100644
--- a/src/homeworks/tests/test_homework_expiry_views.py
+++ b/src/homeworks/tests/test_homework_expiry_views.py
@@ -46,8 +46,12 @@ def setUp(self):
# Course
self.course = Course.objects.create(name="Course", code="C1", is_active=True)
- CourseTeacher.objects.create(course=self.course, teacher=self.teacher, role="owner")
- CourseEnrollment.objects.create(course=self.course, student=self.student, is_active=True)
+ CourseTeacher.objects.create(
+ course=self.course, teacher=self.teacher, role="owner"
+ )
+ CourseEnrollment.objects.create(
+ course=self.course, student=self.student, is_active=True
+ )
# Visible homework (control)
self.visible_hw = Homework.objects.create(
@@ -97,8 +101,8 @@ def setUp(self):
# ─── HomeworkListView ─────────────────────────────────────────────────────────
-class HomeworkListViewExpiryTests(HomeworkExpiryViewSetUpMixin):
+class HomeworkListViewExpiryTests(HomeworkExpiryViewSetUpMixin):
def _get_student_list_data(self):
request = self.factory.get("/homeworks/")
request.user = self.student_user
@@ -154,8 +158,8 @@ def test_teacher_list_item_exposes_expires_at(self):
# ─── HomeworkDetailView ───────────────────────────────────────────────────────
-class HomeworkDetailViewExpiryTests(HomeworkExpiryViewSetUpMixin):
+class HomeworkDetailViewExpiryTests(HomeworkExpiryViewSetUpMixin):
def test_student_cannot_access_hidden_homework_detail(self):
self.client.login(username="student", password="pass")
url = reverse("homeworks:detail", kwargs={"homework_id": self.hidden_hw.id})
@@ -199,13 +203,16 @@ def test_teacher_detail_data_includes_visibility_fields(self):
# ─── SectionDetailView ────────────────────────────────────────────────────────
-class SectionDetailViewExpiryTests(HomeworkExpiryViewSetUpMixin):
+class SectionDetailViewExpiryTests(HomeworkExpiryViewSetUpMixin):
def test_student_blocked_from_section_of_expired_homework(self):
self.client.login(username="student", password="pass")
url = reverse(
"homeworks:section_detail",
- kwargs={"homework_id": self.expired_hw.id, "section_id": self.expired_section.id},
+ kwargs={
+ "homework_id": self.expired_hw.id,
+ "section_id": self.expired_section.id,
+ },
)
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
@@ -239,7 +246,10 @@ def test_teacher_can_access_section_of_expired_homework(self):
self.client.login(username="teacher", password="pass")
url = reverse(
"homeworks:section_detail",
- kwargs={"homework_id": self.expired_hw.id, "section_id": self.expired_section.id},
+ kwargs={
+ "homework_id": self.expired_hw.id,
+ "section_id": self.expired_section.id,
+ },
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
@@ -247,8 +257,8 @@ def test_teacher_can_access_section_of_expired_homework(self):
# ─── Form validation ──────────────────────────────────────────────────────────
-class HomeworkFormExpiryValidationTests(TestCase):
+class HomeworkFormExpiryValidationTests(TestCase):
def setUp(self):
user = User.objects.create_user(username="t", password="p")
self.teacher = Teacher.objects.create(user=user)
@@ -260,7 +270,9 @@ def _base_data(self, **overrides):
"description": "desc",
"course": self.course.id,
"due_date": (timezone.now() + timedelta(days=3)).strftime("%Y-%m-%dT%H:%M"),
- "expires_at": (timezone.now() + timedelta(days=10)).strftime("%Y-%m-%dT%H:%M"),
+ "expires_at": (timezone.now() + timedelta(days=10)).strftime(
+ "%Y-%m-%dT%H:%M"
+ ),
"is_hidden": False,
}
data.update(overrides)
diff --git a/src/homeworks/views.py b/src/homeworks/views.py
index af75aca..0e547f3 100644
--- a/src/homeworks/views.py
+++ b/src/homeworks/views.py
@@ -6,7 +6,7 @@
"""
from dataclasses import dataclass
-from typing import TYPE_CHECKING, Dict, Any, assert_type
+from typing import TYPE_CHECKING, Dict, Any, assert_type, cast
from uuid import UUID
from django.forms import formset_factory
@@ -35,7 +35,7 @@
SectionStatus,
SectionData,
)
-from .forms import HomeworkCreateForm, HomeworkEditForm, SectionForm, SectionFormSet
+from .forms import HomeworkEditForm, SectionForm, SectionFormSet
@dataclass
@@ -161,9 +161,7 @@ def _get_view_data(self, user) -> HomeworkListData:
homework_objects = (
Homework.objects.filter(course__in=enrolled_courses)
.filter(is_hidden=False)
- .filter(
- Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now())
- )
+ .filter(Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now()))
.order_by("-created_at")
.prefetch_related("sections")
)
@@ -390,7 +388,10 @@ def _get_view_data(
initial_section_data.append(section_data)
# Create section formset with initial data
- SectionFormset: type[SectionFormSet] = formset_factory(SectionForm, extra=0, formset=SectionFormSet)
+ SectionFormset = cast(
+ type[SectionFormSet],
+ formset_factory(SectionForm, extra=0, formset=SectionFormSet),
+ )
section_formset = SectionFormset(
prefix="sections", initial=initial_section_data
)
@@ -413,7 +414,10 @@ def _process_form_submission(
form = HomeworkEditForm(request.POST, instance=homework)
# Create formset for sections
- SectionFormset: type[SectionFormSet] = formset_factory(SectionForm, extra=0, formset=SectionFormSet)
+ SectionFormset = cast(
+ type[SectionFormSet],
+ formset_factory(SectionForm, extra=0, formset=SectionFormSet),
+ )
section_formset = SectionFormset(request.POST, prefix="sections")
assert_type(section_formset, SectionFormSet)
@@ -708,9 +712,15 @@ def _get_view_data(self, user, homework_id: UUID) -> HomeworkDetailData | None:
is_overdue=homework_detail.due_date < timezone.now(),
user_type=user_type,
can_edit=can_edit,
- expires_at=homework_obj_for_expiry.expires_at if homework_obj_for_expiry else None,
- is_hidden=homework_obj_for_expiry.is_hidden if homework_obj_for_expiry else False,
- is_accessible_to_students=homework_obj_for_expiry.is_accessible_to_students if homework_obj_for_expiry else True,
+ expires_at=homework_obj_for_expiry.expires_at
+ if homework_obj_for_expiry
+ else None,
+ is_hidden=homework_obj_for_expiry.is_hidden
+ if homework_obj_for_expiry
+ else False,
+ is_accessible_to_students=homework_obj_for_expiry.is_accessible_to_students
+ if homework_obj_for_expiry
+ else True,
llm_config={"id": homework_detail.llm_config}
if homework_detail.llm_config
else None,
diff --git a/src/llm/services.py b/src/llm/services.py
index a4c0794..61fc412 100644
--- a/src/llm/services.py
+++ b/src/llm/services.py
@@ -376,18 +376,20 @@ def _generate_openai_response(
function_calls = []
if hasattr(choice.message, "tool_calls") and choice.message.tool_calls:
for tool_call in choice.message.tool_calls:
+ if not hasattr(tool_call, "function"):
+ continue
try:
- arguments = json.loads(tool_call.function.arguments)
+ arguments = json.loads(tool_call.function.arguments) # type: ignore[union-attr]
function_calls.append(
FunctionCall(
id=tool_call.id,
- name=tool_call.function.name,
+ name=tool_call.function.name, # type: ignore[union-attr]
arguments=arguments,
)
)
except json.JSONDecodeError as e:
logger.error(
- f"Failed to parse function arguments: {tool_call.function.arguments}, error: {e}"
+ f"Failed to parse function arguments: {tool_call.function.arguments}, error: {e}" # type: ignore[union-attr]
)
# Determine finish reason
@@ -763,8 +765,8 @@ def _stream_with_finish_reason_detection(
tool_calls_accumulator = {} # Accumulate tool call deltas by index
for chunk in stream:
- if chunk.choices and len(chunk.choices) > 0:
- choice = chunk.choices[0]
+ if chunk.choices and len(chunk.choices) > 0: # type: ignore[union-attr]
+ choice = chunk.choices[0] # type: ignore[union-attr]
# Extract token content
if hasattr(choice.delta, "content") and choice.delta.content:
diff --git a/src/llteacher/permissions/decorators.py b/src/llteacher/permissions/decorators.py
index 7c9dba8..6aa0efa 100644
--- a/src/llteacher/permissions/decorators.py
+++ b/src/llteacher/permissions/decorators.py
@@ -14,7 +14,6 @@
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
from accounts.models import Teacher, Student
-from llteacher.tracing import set_span_attributes
# Type alias for request.user which can be either authenticated or anonymous
RequestUser = AbstractBaseUser | AnonymousUser
diff --git a/src/llteacher/test_settings.py b/src/llteacher/test_settings.py
index fe6d53a..873502c 100644
--- a/src/llteacher/test_settings.py
+++ b/src/llteacher/test_settings.py
@@ -3,6 +3,8 @@
This file inherits from the main settings and overrides specific settings for testing.
"""
+from typing import Any, List, cast
+
from .settings import * # noqa: F403
# Use in-memory database for testing (faster and isolated)
@@ -78,7 +80,7 @@
]
# Disable template debugging for faster tests
-TEMPLATES[0]["OPTIONS"]["debug"] = False # noqa: F405
+cast(List[Any], TEMPLATES)[0]["OPTIONS"]["debug"] = False # noqa: F405
# Use faster timezone for tests
USE_TZ = False
From 8aed61696d32af8dfa36684e5da8a2f2e58a8621 Mon Sep 17 00:00:00 2001
From: PavanVemparala <109388662+PavanVemparala@users.noreply.github.com>
Date: Wed, 25 Mar 2026 00:35:00 -0700
Subject: [PATCH 3/3] draft mode + homework form consolidation
---
src/courses/templates/courses/detail.html | 34 +-
.../templates/courses/homework_form.html | 251 ----------
src/courses/tests/test_views.py | 2 +-
src/courses/views.py | 110 +++--
src/homeworks/admin.py | 16 +-
src/homeworks/forms.py | 71 ++-
...ework_type_homework_publish_at_and_more.py | 43 ++
.../0007_alter_homework_due_date.py | 22 +
src/homeworks/models.py | 46 +-
src/homeworks/services.py | 70 ++-
src/homeworks/templates/homeworks/detail.html | 7 +-
src/homeworks/templates/homeworks/form.html | 354 +++++++++-----
src/homeworks/templates/homeworks/list.html | 7 +-
src/homeworks/tests/test_draft_mode_forms.py | 252 ++++++++++
src/homeworks/tests/test_draft_mode_model.py | 164 +++++++
src/homeworks/tests/test_draft_mode_views.py | 461 ++++++++++++++++++
src/homeworks/views.py | 149 +++++-
17 files changed, 1589 insertions(+), 470 deletions(-)
delete mode 100644 src/courses/templates/courses/homework_form.html
create mode 100644 src/homeworks/migrations/0006_homework_homework_type_homework_publish_at_and_more.py
create mode 100644 src/homeworks/migrations/0007_alter_homework_due_date.py
create mode 100644 src/homeworks/tests/test_draft_mode_forms.py
create mode 100644 src/homeworks/tests/test_draft_mode_model.py
create mode 100644 src/homeworks/tests/test_draft_mode_views.py
diff --git a/src/courses/templates/courses/detail.html b/src/courses/templates/courses/detail.html
index 917065a..272880c 100644
--- a/src/courses/templates/courses/detail.html
+++ b/src/courses/templates/courses/detail.html
@@ -60,13 +60,37 @@
Homeworks