diff --git a/src/homeworks/tests/test_draft_mode_forms.py b/src/homeworks/tests/test_draft_mode_forms.py
new file mode 100644
index 0000000..cf612e4
--- /dev/null
+++ b/src/homeworks/tests/test_draft_mode_forms.py
@@ -0,0 +1,252 @@
+"""
+Tests for draft mode form validation.
+
+Covers:
+- HomeworkCreateForm: publish_at validation (past rejected, future accepted, publish_now bypasses)
+- HomeworkCreateForm: due_date validation (today allowed, past rejected, draft bypasses)
+- HomeworkEditForm: publish_at not restricted on edit; requires when publishing scheduled
+- SectionFormSet: order uniqueness, sequential order, draft bypass
+"""
+
+from datetime import timedelta
+
+from django.forms import formset_factory
+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.forms import HomeworkCreateForm, HomeworkEditForm, SectionForm, SectionFormSet
+from homeworks.models import Homework, HomeworkType
+
+User = get_user_model()
+
+
+def _make_section_post(sections, prefix="sections"):
+ """Build a POST dict for a SectionFormSet from a list of dicts."""
+ data = {
+ f"{prefix}-TOTAL_FORMS": str(len(sections)),
+ f"{prefix}-INITIAL_FORMS": "0",
+ f"{prefix}-MIN_NUM_FORMS": "0",
+ f"{prefix}-MAX_NUM_FORMS": "1000",
+ }
+ for i, s in enumerate(sections):
+ data[f"{prefix}-{i}-id"] = s.get("id", "")
+ data[f"{prefix}-{i}-title"] = s.get("title", "")
+ data[f"{prefix}-{i}-content"] = s.get("content", "")
+ data[f"{prefix}-{i}-order"] = str(s.get("order", i + 1))
+ data[f"{prefix}-{i}-solution"] = s.get("solution", "")
+ if s.get("DELETE"):
+ data[f"{prefix}-{i}-DELETE"] = "on"
+ return data
+
+
+def _make_formset(sections, is_draft=False):
+ FS = formset_factory(SectionForm, extra=0, formset=SectionFormSet)
+ post = _make_section_post(sections)
+ fs = FS(post, prefix="sections")
+ fs.is_draft_save = is_draft
+ return fs
+
+
+class DraftModeFormSetUpMixin(TestCase):
+ 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.future = timezone.now() + timedelta(days=7)
+ self.base_post = {
+ "title": "HW",
+ "description": "desc",
+ "course": str(self.course.id),
+ "due_date": self.future.strftime("%Y-%m-%dT%H:%M"),
+ "expires_at": "",
+ "publish_at": "",
+ "llm_config": "",
+ }
+
+
+# ---------------------------------------------------------------------------
+# HomeworkCreateForm
+# ---------------------------------------------------------------------------
+
+class HomeworkCreateFormPublishAtTests(DraftModeFormSetUpMixin):
+ def test_valid_with_no_publish_at_and_no_submit_button(self):
+ """No publish button in POST — publish_at is optional."""
+ form = HomeworkCreateForm(self.base_post)
+ self.assertTrue(form.is_valid(), form.errors)
+
+ def test_valid_with_publish_now_checked_and_no_publish_at(self):
+ """publish_now in POST → schedule validation skipped even if publish_at empty."""
+ data = {**self.base_post, "publish": "1", "publish_now": "on"}
+ form = HomeworkCreateForm(data)
+ self.assertTrue(form.is_valid(), form.errors)
+
+ def test_invalid_when_scheduling_without_publish_at(self):
+ """publish button + no publish_now + no publish_at → required error."""
+ data = {**self.base_post, "publish": "1"}
+ form = HomeworkCreateForm(data)
+ self.assertFalse(form.is_valid())
+ self.assertIn("publish_at", form.errors)
+
+ def test_valid_with_future_publish_at_when_scheduling(self):
+ data = {
+ **self.base_post,
+ "publish": "1",
+ "publish_at": (timezone.now() + timedelta(hours=3)).strftime("%Y-%m-%dT%H:%M"),
+ }
+ form = HomeworkCreateForm(data)
+ self.assertTrue(form.is_valid(), form.errors)
+
+ def test_invalid_with_past_publish_at(self):
+ data = {
+ **self.base_post,
+ "publish_at": (timezone.now() - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M"),
+ }
+ form = HomeworkCreateForm(data)
+ self.assertFalse(form.is_valid())
+ self.assertIn("publish_at", form.errors)
+
+
+class HomeworkCreateFormDueDateTests(DraftModeFormSetUpMixin):
+ def test_due_date_today_is_valid(self):
+ """Today's date is allowed — only strictly past dates are rejected."""
+ today_end = timezone.now().replace(hour=23, minute=59, second=0, microsecond=0)
+ data = {**self.base_post, "due_date": today_end.strftime("%Y-%m-%dT%H:%M")}
+ form = HomeworkCreateForm(data)
+ self.assertTrue(form.is_valid(), form.errors)
+
+ def test_due_date_yesterday_is_invalid(self):
+ yesterday = timezone.now() - timedelta(days=1)
+ data = {**self.base_post, "due_date": yesterday.strftime("%Y-%m-%dT%H:%M")}
+ form = HomeworkCreateForm(data)
+ self.assertFalse(form.is_valid())
+ self.assertIn("due_date", form.errors)
+
+ def test_draft_save_skips_due_date_requirement(self):
+ data = {
+ "title": "My Draft",
+ "description": "",
+ "course": str(self.course.id),
+ "due_date": "",
+ "expires_at": "",
+ "publish_at": "",
+ "llm_config": "",
+ }
+ form = HomeworkCreateForm(data, is_draft_save=True)
+ self.assertTrue(form.is_valid(), form.errors)
+
+ def test_publish_requires_due_date(self):
+ data = {**self.base_post, "due_date": ""}
+ form = HomeworkCreateForm(data)
+ self.assertFalse(form.is_valid())
+ self.assertIn("due_date", form.errors)
+
+
+# ---------------------------------------------------------------------------
+# HomeworkEditForm
+# ---------------------------------------------------------------------------
+
+class HomeworkEditFormTests(DraftModeFormSetUpMixin):
+ def setUp(self):
+ super().setUp()
+ self.homework = Homework.objects.create(
+ title="HW",
+ description="desc",
+ created_by=self.teacher,
+ course=self.course,
+ due_date=self.future,
+ homework_type=HomeworkType.DRAFT,
+ is_hidden=True,
+ publish_at=timezone.now() + timedelta(days=2),
+ )
+
+ def _post(self, **overrides):
+ base = {
+ "title": "HW",
+ "description": "desc",
+ "due_date": self.future.strftime("%Y-%m-%dT%H:%M"),
+ "expires_at": "",
+ "publish_at": "",
+ "llm_config": "",
+ }
+ base.update(overrides)
+ return base
+
+ def test_valid_with_no_publish_at(self):
+ form = HomeworkEditForm(self._post(), instance=self.homework)
+ self.assertTrue(form.is_valid(), form.errors)
+
+ def test_valid_with_past_due_date(self):
+ """Edit form has no due_date restriction — teacher may backdate."""
+ past = (timezone.now() - timedelta(days=3)).strftime("%Y-%m-%dT%H:%M")
+ form = HomeworkEditForm(self._post(due_date=past), instance=self.homework)
+ self.assertTrue(form.is_valid(), form.errors)
+
+ def test_valid_with_past_publish_at(self):
+ """Edit form allows past publish_at — teacher may be correcting a mistake."""
+ past = (timezone.now() - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M")
+ form = HomeworkEditForm(self._post(publish_at=past), instance=self.homework)
+ self.assertTrue(form.is_valid(), form.errors)
+
+ def test_valid_with_future_publish_at(self):
+ future = (timezone.now() + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M")
+ form = HomeworkEditForm(self._post(publish_at=future), instance=self.homework)
+ self.assertTrue(form.is_valid(), form.errors)
+
+ def test_invalid_when_scheduling_without_publish_at(self):
+ """publish button + no publish_now + no publish_at → required error on edit too."""
+ data = {**self._post(), "publish": "1"}
+ form = HomeworkEditForm(data, instance=self.homework)
+ self.assertFalse(form.is_valid())
+ self.assertIn("publish_at", form.errors)
+
+ def test_valid_when_publish_now_checked_and_no_publish_at(self):
+ data = {**self._post(), "publish": "1", "publish_now": "on"}
+ form = HomeworkEditForm(data, instance=self.homework)
+ self.assertTrue(form.is_valid(), form.errors)
+
+
+# ---------------------------------------------------------------------------
+# SectionFormSet
+# ---------------------------------------------------------------------------
+
+class SectionFormSetOrderTests(TestCase):
+ def _section(self, order, title="T", content="C"):
+ return {"title": title, "content": content, "order": order}
+
+ def test_valid_sequential_sections(self):
+ fs = _make_formset([self._section(1), self._section(2), self._section(3)])
+ self.assertTrue(fs.is_valid(), fs.errors)
+
+ def test_duplicate_order_rejected(self):
+ fs = _make_formset([self._section(1), self._section(1)])
+ self.assertFalse(fs.is_valid())
+ self.assertTrue(any("multiple times" in str(e) for e in fs.non_form_errors()))
+
+ def test_gap_in_order_rejected(self):
+ fs = _make_formset([self._section(1), self._section(3)])
+ self.assertFalse(fs.is_valid())
+ self.assertTrue(any("sequential" in str(e) for e in fs.non_form_errors()))
+
+ def test_order_not_starting_at_1_rejected(self):
+ fs = _make_formset([self._section(2), self._section(3)])
+ self.assertFalse(fs.is_valid())
+ self.assertTrue(any("start with order 1" in str(e) for e in fs.non_form_errors()))
+
+ def test_empty_formset_rejected_for_publish(self):
+ FS = formset_factory(SectionForm, extra=0, formset=SectionFormSet)
+ post = _make_section_post([])
+ fs = FS(post, prefix="sections")
+ fs.is_draft_save = False
+ self.assertFalse(fs.is_valid())
+ self.assertTrue(any("required" in str(e) for e in fs.non_form_errors()))
+
+ def test_empty_formset_valid_for_draft_save(self):
+ fs = _make_formset([], is_draft=True)
+ self.assertTrue(fs.is_valid(), fs.errors)
+
+ def test_single_valid_section_passes(self):
+ fs = _make_formset([{"title": "T", "content": "C", "order": 1}])
+ self.assertTrue(fs.is_valid(), fs.errors)
diff --git a/src/homeworks/tests/test_draft_mode_model.py b/src/homeworks/tests/test_draft_mode_model.py
new file mode 100644
index 0000000..004c20a
--- /dev/null
+++ b/src/homeworks/tests/test_draft_mode_model.py
@@ -0,0 +1,164 @@
+"""
+Tests for Homework draft mode model properties.
+
+Covers:
+- is_draft property
+- is_overdue (including None due_date guard)
+- is_expired (including None expires_at guard)
+- is_accessible_to_students (hidden, expired, and draft combinations)
+- should_auto_publish (all states, including at-exactly-now boundary)
+- 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, HomeworkType
+
+User = get_user_model()
+
+
+class HomeworkDraftModelTests(TestCase):
+ 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):
+ return Homework.objects.create(**{**self.base_data, **kwargs})
+
+ # --- defaults ---
+
+ def test_default_homework_type_is_published(self):
+ hw = self._make()
+ self.assertEqual(hw.homework_type, HomeworkType.PUBLISHED)
+
+ def test_default_publish_at_is_null(self):
+ hw = self._make()
+ self.assertIsNone(hw.publish_at)
+
+ def test_default_is_hidden_is_false(self):
+ hw = self._make()
+ self.assertFalse(hw.is_hidden)
+
+ # --- is_draft ---
+
+ def test_is_draft_true_when_type_is_draft(self):
+ hw = self._make(homework_type=HomeworkType.DRAFT)
+ self.assertTrue(hw.is_draft)
+
+ def test_is_draft_false_when_type_is_published(self):
+ hw = self._make(homework_type=HomeworkType.PUBLISHED)
+ self.assertFalse(hw.is_draft)
+
+ def test_is_draft_false_when_type_is_hidden(self):
+ hw = self._make(homework_type=HomeworkType.HIDDEN)
+ self.assertFalse(hw.is_draft)
+
+ # --- is_overdue ---
+
+ def test_is_overdue_false_when_due_date_is_none(self):
+ """None due_date must not raise — was a real bug after making due_date nullable."""
+ hw = self._make(due_date=None)
+ self.assertFalse(hw.is_overdue)
+
+ def test_is_overdue_false_when_due_date_in_future(self):
+ hw = self._make(due_date=timezone.now() + timedelta(days=1))
+ self.assertFalse(hw.is_overdue)
+
+ def test_is_overdue_true_when_due_date_in_past(self):
+ hw = self._make(due_date=timezone.now() - timedelta(days=1))
+ self.assertTrue(hw.is_overdue)
+
+ # --- is_expired ---
+
+ def test_is_expired_false_when_expires_at_is_none(self):
+ hw = self._make()
+ self.assertIsNone(hw.expires_at)
+ self.assertFalse(hw.is_expired)
+
+ def test_is_expired_false_when_expires_at_in_future(self):
+ hw = self._make(expires_at=timezone.now() + timedelta(days=1))
+ self.assertFalse(hw.is_expired)
+
+ def test_is_expired_true_when_expires_at_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_draft_type_but_is_hidden_false(self):
+ """homework_type is display-only. A DRAFT with is_hidden=False is accessible."""
+ hw = self._make(homework_type=HomeworkType.DRAFT, is_hidden=False)
+ self.assertTrue(hw.is_accessible_to_students)
+
+ def test_not_accessible_when_draft_type_and_is_hidden_true(self):
+ hw = self._make(homework_type=HomeworkType.DRAFT, is_hidden=True)
+ self.assertFalse(hw.is_accessible_to_students)
+
+ # --- should_auto_publish ---
+
+ def test_should_auto_publish_false_when_no_publish_at(self):
+ hw = self._make(homework_type=HomeworkType.DRAFT)
+ self.assertFalse(hw.should_auto_publish)
+
+ def test_should_auto_publish_false_when_publish_at_in_future(self):
+ hw = self._make(
+ homework_type=HomeworkType.DRAFT,
+ publish_at=timezone.now() + timedelta(hours=1),
+ )
+ self.assertFalse(hw.should_auto_publish)
+
+ def test_should_auto_publish_true_when_publish_at_in_past(self):
+ hw = self._make(
+ homework_type=HomeworkType.DRAFT,
+ publish_at=timezone.now() - timedelta(seconds=1),
+ )
+ self.assertTrue(hw.should_auto_publish)
+
+ def test_should_auto_publish_false_when_already_published(self):
+ """publish_at in past but type=PUBLISHED → already done, don't re-publish."""
+ hw = self._make(
+ homework_type=HomeworkType.PUBLISHED,
+ publish_at=timezone.now() - timedelta(seconds=1),
+ )
+ self.assertFalse(hw.should_auto_publish)
+
+ def test_should_auto_publish_false_when_type_is_hidden(self):
+ """HIDDEN type should not be auto-published."""
+ hw = self._make(
+ homework_type=HomeworkType.HIDDEN,
+ publish_at=timezone.now() - timedelta(seconds=1),
+ )
+ self.assertFalse(hw.should_auto_publish)
diff --git a/src/homeworks/tests/test_draft_mode_views.py b/src/homeworks/tests/test_draft_mode_views.py
new file mode 100644
index 0000000..9be792a
--- /dev/null
+++ b/src/homeworks/tests/test_draft_mode_views.py
@@ -0,0 +1,461 @@
+"""
+Tests for draft mode enforcement in views.
+
+Covers:
+- Students cannot see draft homeworks in list or detail
+- Teachers always see draft homeworks
+- publish_now action publishes a draft
+- auto_publish_due_drafts fires on list/detail page load
+- Scheduled draft stays hidden before publish_at
+"""
+
+from datetime import timedelta
+
+from django.test import TestCase, RequestFactory, Client
+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, HomeworkType, Section
+
+User = get_user_model()
+
+
+class DraftModeViewSetUpMixin(TestCase):
+ """Shared setUp for draft mode view tests."""
+
+ def setUp(self):
+ self.factory = RequestFactory()
+
+ 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_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
+
+ 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
+ )
+
+ self.published_hw = Homework.objects.create(
+ title="Published",
+ description="desc",
+ created_by=self.teacher,
+ course=self.course,
+ due_date=timezone.now() + timedelta(days=7),
+ homework_type=HomeworkType.PUBLISHED,
+ is_hidden=False,
+ )
+
+ self.draft_hw = Homework.objects.create(
+ title="Draft",
+ description="desc",
+ created_by=self.teacher,
+ course=self.course,
+ due_date=timezone.now() + timedelta(days=7),
+ homework_type=HomeworkType.DRAFT,
+ is_hidden=True,
+ )
+ # Add a section so detail view can render
+ Section.objects.create(
+ homework=self.draft_hw, title="S1", content="content", order=1
+ )
+ Section.objects.create(
+ homework=self.published_hw, title="S1", content="content", order=1
+ )
+
+
+class HomeworkListDraftTests(DraftModeViewSetUpMixin):
+ """HomeworkListView — draft visibility."""
+
+ def test_student_cannot_see_draft_in_list(self):
+ client = Client()
+ client.login(username="student", password="pass")
+ response = client.get(reverse("homeworks:list"))
+ titles = [hw.title for hw in response.context["data"].homeworks]
+ self.assertNotIn("Draft", titles)
+ self.assertIn("Published", titles)
+
+ def test_teacher_sees_draft_in_list(self):
+ client = Client()
+ client.login(username="teacher", password="pass")
+ response = client.get(reverse("homeworks:list"))
+ titles = [hw.title for hw in response.context["data"].homeworks]
+ self.assertIn("Draft", titles)
+ self.assertIn("Published", titles)
+
+ def test_teacher_list_item_has_is_draft_true(self):
+ client = Client()
+ client.login(username="teacher", password="pass")
+ response = client.get(reverse("homeworks:list"))
+ draft_items = [
+ hw for hw in response.context["data"].homeworks if hw.title == "Draft"
+ ]
+ self.assertEqual(len(draft_items), 1)
+ self.assertTrue(draft_items[0].is_draft)
+
+
+class HomeworkDetailDraftTests(DraftModeViewSetUpMixin):
+ """HomeworkDetailView — draft visibility."""
+
+ def test_student_cannot_access_draft_detail(self):
+ client = Client()
+ client.login(username="student", password="pass")
+ url = reverse("homeworks:detail", kwargs={"homework_id": self.draft_hw.id})
+ response = client.get(url)
+ # Returns redirect (302) because _get_view_data returns None for inaccessible
+ self.assertEqual(response.status_code, 302)
+
+ def test_teacher_can_access_draft_detail(self):
+ client = Client()
+ client.login(username="teacher", password="pass")
+ url = reverse("homeworks:detail", kwargs={"homework_id": self.draft_hw.id})
+ response = client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+ def test_detail_data_has_is_draft_true_for_teacher(self):
+ client = Client()
+ client.login(username="teacher", password="pass")
+ url = reverse("homeworks:detail", kwargs={"homework_id": self.draft_hw.id})
+ response = client.get(url)
+ self.assertTrue(response.context["data"].is_draft)
+
+
+class PublishNowActionTests(DraftModeViewSetUpMixin):
+ """HomeworkDetailView publish_now POST action."""
+
+ def test_publish_now_publishes_draft(self):
+ client = Client()
+ client.login(username="teacher", password="pass")
+ url = reverse("homeworks:detail", kwargs={"homework_id": self.draft_hw.id})
+ response = client.post(url, {"action": "publish_now"})
+ self.assertEqual(response.status_code, 302)
+ self.draft_hw.refresh_from_db()
+ self.assertFalse(self.draft_hw.is_hidden)
+ self.assertEqual(self.draft_hw.homework_type, HomeworkType.PUBLISHED)
+ self.assertIsNone(self.draft_hw.publish_at)
+
+ def test_publish_now_forbidden_for_student(self):
+ client = Client()
+ client.login(username="student", password="pass")
+ url = reverse("homeworks:detail", kwargs={"homework_id": self.draft_hw.id})
+ response = client.post(url, {"action": "publish_now"})
+ self.assertEqual(response.status_code, 403)
+
+ def test_publish_now_on_another_teachers_homework_is_forbidden(self):
+ other_user = User.objects.create_user(username="other", password="pass")
+ other_teacher = Teacher.objects.create(user=other_user)
+ other_course = Course.objects.create(name="Other", code="OTH")
+ other_hw = Homework.objects.create(
+ title="Other Draft",
+ description="desc",
+ created_by=other_teacher,
+ course=other_course,
+ due_date=timezone.now() + timedelta(days=7),
+ homework_type=HomeworkType.DRAFT,
+ is_hidden=True,
+ )
+ client = Client()
+ client.login(username="teacher", password="pass")
+ url = reverse("homeworks:detail", kwargs={"homework_id": other_hw.id})
+ response = client.post(url, {"action": "publish_now"})
+ self.assertEqual(response.status_code, 403)
+
+
+class DraftSaveMinimalDataTests(DraftModeViewSetUpMixin):
+ """Draft saves with minimal data redirect instead of re-rendering."""
+
+ def test_create_draft_with_only_title_redirects(self):
+ """Saving a draft with just a title should succeed and redirect."""
+ client = Client()
+ client.login(username="teacher", password="pass")
+ url = reverse("courses:homework-create", kwargs={"course_id": self.course.id})
+ response = client.post(
+ url,
+ {
+ "save_draft": "1",
+ "title": "Untitled Draft",
+ "description": "",
+ "due_date": "",
+ "expires_at": "",
+ "is_hidden": "",
+ "publish_at": "",
+ "llm_config": "",
+ "sections-TOTAL_FORMS": "0",
+ "sections-INITIAL_FORMS": "0",
+ "sections-MIN_NUM_FORMS": "0",
+ "sections-MAX_NUM_FORMS": "1000",
+ },
+ )
+ self.assertEqual(response.status_code, 302)
+ from homeworks.models import HomeworkType
+
+ hw = Homework.objects.filter(title="Untitled Draft").first()
+ self.assertIsNotNone(hw)
+ self.assertEqual(hw.homework_type, HomeworkType.DRAFT)
+ self.assertTrue(hw.is_hidden)
+ self.assertIsNone(hw.due_date)
+
+ def test_edit_draft_save_redirects_to_detail(self):
+ """Saving draft on edit redirects to detail page, not the form."""
+ client = Client()
+ client.login(username="teacher", password="pass")
+ url = reverse("homeworks:edit", kwargs={"homework_id": self.draft_hw.id})
+ response = client.post(
+ url,
+ {
+ "save_draft": "1",
+ "title": "Updated Draft Title",
+ "description": "",
+ "due_date": "",
+ "expires_at": "",
+ "is_hidden": "",
+ "publish_at": "",
+ "llm_config": "",
+ "sections-TOTAL_FORMS": "0",
+ "sections-INITIAL_FORMS": "0",
+ "sections-MIN_NUM_FORMS": "0",
+ "sections-MAX_NUM_FORMS": "1000",
+ },
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertIn(str(self.draft_hw.id), response["Location"])
+
+
+class AutoPublishOnPageLoadTests(DraftModeViewSetUpMixin):
+ """Auto-publish fires when list/detail loads."""
+
+ def _make_scheduled_draft(self):
+ hw = Homework.objects.create(
+ title="Scheduled",
+ description="desc",
+ created_by=self.teacher,
+ course=self.course,
+ due_date=timezone.now() + timedelta(days=7),
+ homework_type=HomeworkType.DRAFT,
+ is_hidden=True,
+ publish_at=timezone.now() - timedelta(seconds=1),
+ )
+ Section.objects.create(homework=hw, title="S1", content="content", order=1)
+ return hw
+
+ def test_auto_publish_fires_on_list_view_load(self):
+ hw = self._make_scheduled_draft()
+ client = Client()
+ client.login(username="teacher", password="pass")
+ client.get(reverse("homeworks:list"))
+ hw.refresh_from_db()
+ self.assertEqual(hw.homework_type, HomeworkType.PUBLISHED)
+ self.assertFalse(hw.is_hidden)
+
+ def test_auto_publish_fires_on_detail_view_load(self):
+ hw = self._make_scheduled_draft()
+ client = Client()
+ client.login(username="teacher", password="pass")
+ url = reverse("homeworks:detail", kwargs={"homework_id": hw.id})
+ client.get(url)
+ hw.refresh_from_db()
+ self.assertEqual(hw.homework_type, HomeworkType.PUBLISHED)
+ self.assertFalse(hw.is_hidden)
+
+ def test_scheduled_draft_stays_hidden_before_publish_at(self):
+ hw = Homework.objects.create(
+ title="Future",
+ description="desc",
+ created_by=self.teacher,
+ course=self.course,
+ due_date=timezone.now() + timedelta(days=7),
+ homework_type=HomeworkType.DRAFT,
+ is_hidden=True,
+ publish_at=timezone.now() + timedelta(hours=2),
+ )
+ client = Client()
+ client.login(username="student", password="pass")
+ client.get(reverse("homeworks:list"))
+ hw.refresh_from_db()
+ self.assertEqual(hw.homework_type, HomeworkType.DRAFT)
+ self.assertTrue(hw.is_hidden)
+
+
+class HomeworkEditPublishTests(DraftModeViewSetUpMixin):
+ """HomeworkEditView publish paths — publish_now and scheduled."""
+
+ def _section_management(self):
+ return {
+ "sections-TOTAL_FORMS": "1",
+ "sections-INITIAL_FORMS": "1",
+ "sections-MIN_NUM_FORMS": "0",
+ "sections-MAX_NUM_FORMS": "1000",
+ f"sections-0-id": str(Section.objects.filter(homework=self.published_hw).first().id),
+ "sections-0-title": "Section 1",
+ "sections-0-content": "Content",
+ "sections-0-order": "1",
+ "sections-0-solution": "",
+ }
+
+ def _publish_post(self, **overrides):
+ base = {
+ "publish": "1",
+ "publish_now": "on",
+ "title": self.published_hw.title,
+ "description": self.published_hw.description,
+ "due_date": (timezone.now() + timedelta(days=7)).strftime("%Y-%m-%dT%H:%M"),
+ "expires_at": "",
+ "publish_at": "",
+ "llm_config": "",
+ **self._section_management(),
+ }
+ base.update(overrides)
+ return base
+
+ def test_publish_now_sets_published_and_not_hidden(self):
+ client = Client()
+ client.login(username="teacher", password="pass")
+ url = reverse("homeworks:edit", kwargs={"homework_id": self.published_hw.id})
+ response = client.post(url, self._publish_post())
+ self.assertEqual(response.status_code, 302)
+ self.published_hw.refresh_from_db()
+ self.assertFalse(self.published_hw.is_hidden)
+ self.assertEqual(self.published_hw.homework_type, HomeworkType.PUBLISHED)
+ self.assertIsNone(self.published_hw.publish_at)
+
+ def test_publish_now_clears_stale_expires_at(self):
+ """Re-publishing an expired homework should clear expires_at."""
+ self.published_hw.expires_at = timezone.now() - timedelta(days=1)
+ self.published_hw.save()
+ client = Client()
+ client.login(username="teacher", password="pass")
+ url = reverse("homeworks:edit", kwargs={"homework_id": self.published_hw.id})
+ response = client.post(url, self._publish_post())
+ self.assertEqual(response.status_code, 302)
+ self.published_hw.refresh_from_db()
+ self.assertIsNone(self.published_hw.expires_at)
+
+ def test_publish_now_does_not_clear_future_expires_at(self):
+ """A future expires_at submitted in the form is preserved when re-publishing."""
+ future_expires = timezone.now() + timedelta(days=30)
+ self.published_hw.expires_at = future_expires
+ self.published_hw.save()
+ client = Client()
+ client.login(username="teacher", password="pass")
+ url = reverse("homeworks:edit", kwargs={"homework_id": self.published_hw.id})
+ # Explicitly include expires_at in POST to preserve it
+ post = self._publish_post(expires_at=future_expires.strftime("%Y-%m-%dT%H:%M"))
+ response = client.post(url, post)
+ self.assertEqual(response.status_code, 302)
+ self.published_hw.refresh_from_db()
+ self.assertIsNotNone(self.published_hw.expires_at)
+
+ def test_scheduled_publish_keeps_homework_as_draft(self):
+ """Clicking publish without publish_now + future publish_at → stays DRAFT."""
+ future_publish = (timezone.now() + timedelta(days=2)).strftime("%Y-%m-%dT%H:%M")
+ post = self._publish_post(publish_at=future_publish)
+ del post["publish_now"] # uncheck the toggle
+ client = Client()
+ client.login(username="teacher", password="pass")
+ url = reverse("homeworks:edit", kwargs={"homework_id": self.draft_hw.id})
+ # Need draft hw's section
+ section = Section.objects.filter(homework=self.draft_hw).first()
+ post["sections-0-id"] = str(section.id)
+ post["sections-0-title"] = "S1"
+ post["sections-0-content"] = "content"
+ post["sections-0-order"] = "1"
+ response = client.post(url, post)
+ self.assertEqual(response.status_code, 302)
+ self.draft_hw.refresh_from_db()
+ self.assertEqual(self.draft_hw.homework_type, HomeworkType.DRAFT)
+ self.assertTrue(self.draft_hw.is_hidden)
+ self.assertIsNotNone(self.draft_hw.publish_at)
+
+ def test_scheduled_draft_not_visible_to_students(self):
+ """A scheduled draft (future publish_at) remains invisible to students."""
+ self.draft_hw.publish_at = timezone.now() + timedelta(hours=2)
+ self.draft_hw.save()
+ client = Client()
+ client.login(username="student", password="pass")
+ url = reverse("homeworks:detail", kwargs={"homework_id": self.draft_hw.id})
+ response = client.get(url)
+ self.assertEqual(response.status_code, 302) # inaccessible → redirect
+
+
+class AutoPublishServiceTests(DraftModeViewSetUpMixin):
+ """HomeworkService.auto_publish_due_drafts edge cases."""
+
+ def test_returns_count_of_published_homeworks(self):
+ from homeworks.services import HomeworkService
+ # Create 2 more overdue drafts
+ for i in range(2):
+ Homework.objects.create(
+ title=f"Scheduled {i}",
+ description="",
+ created_by=self.teacher,
+ course=self.course,
+ due_date=timezone.now() + timedelta(days=7),
+ homework_type=HomeworkType.DRAFT,
+ is_hidden=True,
+ publish_at=timezone.now() - timedelta(seconds=1),
+ )
+ count = HomeworkService.auto_publish_due_drafts()
+ self.assertEqual(count, 2)
+
+ def test_does_not_touch_published_homeworks(self):
+ from homeworks.services import HomeworkService
+ HomeworkService.auto_publish_due_drafts()
+ self.published_hw.refresh_from_db()
+ self.assertFalse(self.published_hw.is_hidden)
+ self.assertEqual(self.published_hw.homework_type, HomeworkType.PUBLISHED)
+
+ def test_does_not_touch_drafts_with_future_publish_at(self):
+ from homeworks.services import HomeworkService
+ future_draft = Homework.objects.create(
+ title="Future Draft",
+ description="",
+ created_by=self.teacher,
+ course=self.course,
+ due_date=timezone.now() + timedelta(days=7),
+ homework_type=HomeworkType.DRAFT,
+ is_hidden=True,
+ publish_at=timezone.now() + timedelta(hours=2),
+ )
+ HomeworkService.auto_publish_due_drafts()
+ future_draft.refresh_from_db()
+ self.assertEqual(future_draft.homework_type, HomeworkType.DRAFT)
+ self.assertTrue(future_draft.is_hidden)
+
+ def test_does_not_touch_hidden_type_homeworks(self):
+ """HIDDEN type (manually hidden, not draft) should not be auto-published."""
+ from homeworks.services import HomeworkService
+ hidden_hw = Homework.objects.create(
+ title="Hidden",
+ description="",
+ created_by=self.teacher,
+ course=self.course,
+ due_date=timezone.now() + timedelta(days=7),
+ homework_type=HomeworkType.HIDDEN,
+ is_hidden=True,
+ publish_at=timezone.now() - timedelta(seconds=1),
+ )
+ HomeworkService.auto_publish_due_drafts()
+ hidden_hw.refresh_from_db()
+ self.assertEqual(hidden_hw.homework_type, HomeworkType.HIDDEN)
+
+ def test_publish_homework_idempotent(self):
+ """Calling publish_homework on an already-published homework is safe."""
+ from homeworks.services import HomeworkService
+ result = HomeworkService.publish_homework(self.published_hw.id)
+ self.assertTrue(result.success)
+ self.published_hw.refresh_from_db()
+ self.assertFalse(self.published_hw.is_hidden)
+ self.assertEqual(self.published_hw.homework_type, HomeworkType.PUBLISHED)
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..cad724e
--- /dev/null
+++ b/src/homeworks/tests/test_homework_expiry_views.py
@@ -0,0 +1,370 @@
+"""
+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 9933d64..1984b4f 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
@@ -39,6 +39,16 @@
logger = logging.getLogger(__name__)
+def _mark_invalid_fields(form) -> None:
+ """Add Bootstrap is-invalid CSS class to any form widget that has errors."""
+ for field_name in form.errors:
+ if field_name in form.fields:
+ widget = form.fields[field_name].widget
+ css = widget.attrs.get("class", "")
+ if "is-invalid" not in css:
+ widget.attrs["class"] = f"{css} is-invalid".strip()
+
+
@dataclass
class HomeworkListItem:
"""Data structure for a single homework item in the list view."""
@@ -50,6 +60,11 @@ 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
+ is_draft: bool = False
+ publish_at: Any = None # datetime or None
sections: list[SectionData] | None = None
completed_percentage: int = 0
in_progress_percentage: int = 0
@@ -108,6 +123,12 @@ def _get_view_data(self, user) -> HomeworkListData:
# Teacher view - show homeworks from courses this teacher teaches
user_type = "teacher"
+ # Auto-publish any scheduled drafts before building the list
+ try:
+ HomeworkService.auto_publish_due_drafts()
+ except Exception:
+ pass # Never break the page load
+
# Get courses where this teacher is teaching
teacher_courses = teacher_profile.courses.all()
@@ -135,6 +156,11 @@ 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,
+ is_draft=homework.is_draft,
+ publish_at=homework.publish_at,
sections=None, # No section data needed for teacher view
)
)
@@ -149,9 +175,14 @@ 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")
)
@@ -254,19 +285,26 @@ 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
+ is_draft: bool = False
+ publish_at: Any = None # datetime or None
llm_config: Dict[str, Any] | None = None
@dataclass
class HomeworkFormData:
- """Data structure for the homework form view."""
+ """Data structure for the homework form view (create and edit)."""
- form: "HomeworkEditForm"
+ form: Any # HomeworkCreateForm or HomeworkEditForm
section_forms: "SectionFormSet"
user_type: str
action: str # 'create' or 'edit'
is_submitted: bool = False
errors: Dict[str, Any] | None = None
+ course_name: str = ""
+ course_id: Any = None # UUID or None
class HomeworkEditView(View):
@@ -343,6 +381,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})
@@ -370,8 +413,9 @@ 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
@@ -390,23 +434,69 @@ def _get_view_data(
def _process_form_submission(
self, request: TeacherRequest, homework: Homework
) -> HomeworkFormData:
- """Process the form submission for updating a homework."""
- # Create forms from POST data with homework instance
+ """Process the form submission for updating a homework.
+
+ The submit button name determines the publish action:
+ name="save_draft" → keep/set as draft (is_hidden=True, homework_type=draft)
+ name="publish" → publish immediately (is_hidden=False, homework_type=published)
+ anything else → preserve existing homework_type
+ """
+ is_draft_save = "save_draft" in request.POST
+
+ # Draft save: bypass all validation, update only the raw text fields
+ if is_draft_save:
+ from .models import HomeworkType
+
+ homework.title = request.POST.get("title") or homework.title
+ homework.description = request.POST.get("description") or homework.description
+ homework.homework_type = HomeworkType.DRAFT
+ homework.is_hidden = True
+ homework.save(update_fields=["title", "description", "homework_type", "is_hidden", "updated_at"])
+ return HomeworkFormData(
+ form=HomeworkEditForm(instance=homework),
+ section_forms=None, # type: ignore[arg-type]
+ user_type="teacher",
+ action="edit",
+ is_submitted=True,
+ )
+
+ # Publish path: run full validation
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)
- # Check form validity
+ # Check form validity (publish path only)
if form.is_valid() and section_formset.is_valid():
- # Save basic homework data
- homework = form.save()
+ from .models import HomeworkType
+
+ publish_now = "publish_now" in request.POST
+ homework_instance = form.save(commit=False)
+
+ if publish_now:
+ homework_instance.homework_type = HomeworkType.PUBLISHED
+ homework_instance.is_hidden = False
+ homework_instance.publish_at = None
+ # Clear expires_at if it has already passed (re-publish expired homework)
+ if (
+ homework_instance.expires_at
+ and homework_instance.expires_at <= timezone.now()
+ ):
+ homework_instance.expires_at = None
+ else:
+ # Scheduled publish: keep as draft until auto_publish_due_drafts runs
+ homework_instance.homework_type = HomeworkType.DRAFT
+ homework_instance.is_hidden = True
+ # publish_at already set from form.save()
+
+ homework = homework_instance
+ homework.save()
- # Process sections
sections_to_update = []
sections_to_create = []
sections_to_delete = []
@@ -416,24 +506,19 @@ def _process_form_submission(
continue
if section_form.cleaned_data.get("DELETE", False):
- # Section marked for deletion
if section_form.cleaned_data.get("id"):
sections_to_delete.append(section_form.cleaned_data["id"])
else:
- # Get section data
section_data = {
"title": section_form.cleaned_data["title"],
"content": section_form.cleaned_data["content"],
"order": section_form.cleaned_data["order"],
"solution": section_form.cleaned_data["solution"],
}
-
if section_form.cleaned_data.get("id"):
- # Existing section to update
section_data["id"] = section_form.cleaned_data["id"]
sections_to_update.append(section_data)
else:
- # New section to create
sections_to_create.append(
SectionCreateData(
title=section_data["title"],
@@ -443,7 +528,6 @@ def _process_form_submission(
)
)
- # Create update data
update_data = HomeworkUpdateData(
title=homework.title,
description=homework.description,
@@ -454,11 +538,9 @@ def _process_form_submission(
sections_to_delete=sections_to_delete,
)
- # Update homework using service
result = HomeworkService.update_homework(homework.id, update_data)
if result.success:
- # Return success data
return HomeworkFormData(
form=form,
section_forms=section_formset,
@@ -467,10 +549,11 @@ def _process_form_submission(
is_submitted=True,
)
else:
- # Service error
messages.error(request, f"Error updating homework: {result.error}")
- # Form validation error or service error
+ # Highlight fields with errors and re-render
+ _mark_invalid_fields(form)
+
errors: dict[str, ErrorDict | list[ErrorList]] = {}
if form.errors:
errors["homework"] = form.errors
@@ -479,7 +562,6 @@ def _process_form_submission(
if section_formset.non_form_errors():
errors["formset"] = [section_formset.non_form_errors()]
- # Return form data with errors
return HomeworkFormData(
form=form,
section_forms=section_formset,
@@ -516,16 +598,48 @@ def get(self, request: HttpRequest, homework_id: UUID) -> HttpResponse:
return render(request, "homeworks/detail.html", {"data": data})
def post(self, request: HttpRequest, homework_id: UUID) -> HttpResponse:
- """Handle POST requests for homework actions (like deletion)."""
- # Check if this is a delete action
+ """Handle POST requests for homework actions (like deletion or publishing)."""
action = request.POST.get("action")
if action == "delete":
return self._handle_delete(request, homework_id)
+ if action == "publish_now":
+ return self._handle_publish_now(request, homework_id)
+
# If no valid action, redirect to detail view
return redirect("homeworks:detail", homework_id=homework_id)
+ def _handle_publish_now(
+ self, request: HttpRequest, homework_id: UUID
+ ) -> HttpResponse:
+ """Immediately publish a draft homework."""
+ teacher_profile = getattr(request.user, "teacher_profile", None)
+ if not teacher_profile:
+ return HttpResponseForbidden("Only teachers can publish homeworks.")
+
+ try:
+ homework = Homework.objects.get(id=homework_id)
+ created_by_teacher = homework.created_by == teacher_profile
+ teaches_course = False
+ if homework.course:
+ teaches_course = homework.course in teacher_profile.courses.all()
+ if not (created_by_teacher or teaches_course):
+ return HttpResponseForbidden(
+ "You don't have permission to publish this homework."
+ )
+ except Homework.DoesNotExist:
+ messages.error(request, "Homework not found.")
+ return redirect("homeworks:list")
+
+ result = HomeworkService.publish_homework(homework_id)
+ if result.success:
+ messages.success(request, f"'{homework.title}' has been published.")
+ else:
+ messages.error(request, "Failed to publish homework. Please try again.")
+
+ return redirect("homeworks:detail", homework_id=homework_id)
+
def _handle_delete(self, request: HttpRequest, homework_id: UUID) -> HttpResponse:
"""Handle homework deletion."""
# Check if user is a teacher
@@ -577,11 +691,17 @@ def _get_view_data(self, user, homework_id: UUID) -> HomeworkDetailData | None:
Returns:
HomeworkDetailData with homework details, or None if not found or access denied
"""
+ # Auto-publish any scheduled drafts before access checks
+ try:
+ HomeworkService.auto_publish_due_drafts()
+ except Exception:
+ pass # Never break the page load
+
# Determine user type
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:
@@ -592,6 +712,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
@@ -673,6 +796,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,
@@ -683,9 +809,24 @@ def _get_view_data(self, user, homework_id: UUID) -> HomeworkDetailData | None:
created_by_name=created_by_name,
created_at=homework_detail.created_at,
sections=sections,
- is_overdue=homework_detail.due_date < timezone.now(),
+ is_overdue=homework_detail.due_date is not None and 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,
+ is_draft=homework_obj_for_expiry.is_draft
+ if homework_obj_for_expiry
+ else False,
+ publish_at=homework_obj_for_expiry.publish_at
+ if homework_obj_for_expiry
+ else None,
llm_config={"id": homework_detail.llm_config}
if homework_detail.llm_config
else None,
@@ -751,7 +892,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)
@@ -759,6 +900,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/llm/services.py b/src/llm/services.py
index 2dfc4d5..488f527 100644
--- a/src/llm/services.py
+++ b/src/llm/services.py
@@ -385,18 +385,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
@@ -878,8 +880,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/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,
)
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