From 73a9b03dd64f908b49d716d39222ee6d14dc39c3 Mon Sep 17 00:00:00 2001 From: PavanVemparala <109388662+PavanVemparala@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:35:11 -0700 Subject: [PATCH 1/3] hide homework after expiry date --- src/homeworks/admin.py | 13 +- src/homeworks/forms.py | 108 +++++- ...0005_add_homework_expiry_and_visibility.py | 29 ++ src/homeworks/models.py | 23 ++ src/homeworks/templates/homeworks/detail.html | 16 +- src/homeworks/templates/homeworks/form.html | 36 +- src/homeworks/templates/homeworks/list.html | 9 + .../tests/test_homework_expiry_model.py | 99 +++++ .../tests/test_homework_expiry_views.py | 358 ++++++++++++++++++ src/homeworks/views.py | 40 +- .../commands/populate_test_database.py | 38 +- 11 files changed, 746 insertions(+), 23 deletions(-) create mode 100644 src/homeworks/migrations/0005_add_homework_expiry_and_visibility.py create mode 100644 src/homeworks/tests/test_homework_expiry_model.py create mode 100644 src/homeworks/tests/test_homework_expiry_views.py diff --git a/src/homeworks/admin.py b/src/homeworks/admin.py index d722d41..ce81d26 100644 --- a/src/homeworks/admin.py +++ b/src/homeworks/admin.py @@ -1,6 +1,17 @@ from django.contrib import admin from .models import Homework, Section, SectionSolution -admin.site.register(Homework) + +@admin.register(Homework) +class HomeworkAdmin(admin.ModelAdmin): + list_display = ("title", "course", "due_date", "expires_at", "is_hidden", "accessible_to_students") + list_filter = ("is_hidden", "course") + readonly_fields = ("accessible_to_students",) + + @admin.display(boolean=True, description="Accessible to students") + def accessible_to_students(self, obj): + return obj.is_accessible_to_students + + admin.site.register(Section) admin.site.register(SectionSolution) diff --git a/src/homeworks/forms.py b/src/homeworks/forms.py index fa7c841..a7fe325 100644 --- a/src/homeworks/forms.py +++ b/src/homeworks/forms.py @@ -6,11 +6,28 @@ """ from django import forms +from django.conf import settings from django.utils import timezone from .models import Homework +def _to_local_str(dt) -> str: + """Convert a datetime to a local datetime-local input string.""" + if settings.USE_TZ and timezone.is_naive(dt): + dt = timezone.make_aware(dt) + if settings.USE_TZ: + return timezone.localtime(dt).strftime("%Y-%m-%dT%H:%M") + return dt.strftime("%Y-%m-%dT%H:%M") + + +def _make_aware_if_naive(dt): + """Make a naive datetime timezone-aware when USE_TZ is active.""" + if dt is not None and settings.USE_TZ and timezone.is_naive(dt): + return timezone.make_aware(dt) + return dt + + class SectionForm(forms.Form): """Form for creating or editing a section.""" @@ -54,7 +71,15 @@ class HomeworkCreateForm(forms.ModelForm): class Meta: model = Homework - fields = ["title", "description", "course", "due_date", "llm_config"] + fields = [ + "title", + "description", + "course", + "due_date", + "expires_at", + "is_hidden", + "llm_config", + ] widgets = { "title": forms.TextInput( attrs={"class": "form-control", "placeholder": "Homework Title"} @@ -76,6 +101,15 @@ class Meta: "placeholder": "Due Date", } ), + "expires_at": forms.DateTimeInput( + attrs={ + "class": "form-control", + "type": "datetime-local", + } + ), + "is_hidden": forms.CheckboxInput( + attrs={"class": "form-check-input"} + ), "llm_config": forms.Select( attrs={"class": "form-select", "placeholder": "LLM Configuration"} ), @@ -84,20 +118,41 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["llm_config"].required = False + self.fields["expires_at"].required = False + self.expires_at_adjusted = False # flag for view to flash a warning - # Convert datetime to format expected by datetime-local input + # Display stored datetimes in server local time, not raw UTC if self.instance and self.instance.due_date: - self.initial["due_date"] = self.instance.due_date.strftime("%Y-%m-%dT%H:%M") + self.initial["due_date"] = _to_local_str(self.instance.due_date) + if self.instance and self.instance.expires_at: + self.initial["expires_at"] = _to_local_str(self.instance.expires_at) def clean_due_date(self): - """Validate due date is in the future.""" - due_date = self.cleaned_data.get("due_date") + """Make aware. Reject only strictly past dates — today is allowed.""" + due_date = _make_aware_if_naive(self.cleaned_data.get("due_date")) - if due_date and due_date <= timezone.now(): - raise forms.ValidationError("Due date must be in the future.") + if due_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.") return due_date + def clean_expires_at(self): + """Make aware if naive.""" + return _make_aware_if_naive(self.cleaned_data.get("expires_at")) + + def clean(self): + """Warn if expires_at precedes due_date, but allow it.""" + cleaned_data = super().clean() + due_date = cleaned_data.get("due_date") + expires_at = cleaned_data.get("expires_at") + + if due_date and expires_at and expires_at < due_date: + self.expires_at_adjusted = True # view will warn the teacher + + return cleaned_data + class HomeworkEditForm(forms.ModelForm): """Form for editing an existing homework assignment.""" @@ -108,6 +163,8 @@ class Meta: "title", "description", "due_date", + "expires_at", + "is_hidden", "llm_config", ] # Note: course is excluded widgets = { @@ -128,6 +185,15 @@ class Meta: "placeholder": "Due Date", } ), + "expires_at": forms.DateTimeInput( + attrs={ + "class": "form-control", + "type": "datetime-local", + } + ), + "is_hidden": forms.CheckboxInput( + attrs={"class": "form-check-input"} + ), "llm_config": forms.Select( attrs={"class": "form-select", "placeholder": "LLM Configuration"} ), @@ -136,19 +202,33 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["llm_config"].required = False + self.fields["expires_at"].required = False + self.expires_at_adjusted = False # flag for view to flash a warning - # Convert datetime to format expected by datetime-local input + # Display stored datetimes in server local time, not raw UTC if self.instance and self.instance.due_date: - self.initial["due_date"] = self.instance.due_date.strftime("%Y-%m-%dT%H:%M") + self.initial["due_date"] = _to_local_str(self.instance.due_date) + if self.instance and self.instance.expires_at: + self.initial["expires_at"] = _to_local_str(self.instance.expires_at) def clean_due_date(self): - """Validate due date is in the future.""" - due_date = self.cleaned_data.get("due_date") + """Make aware. No date restrictions on edit — teacher has full control.""" + return _make_aware_if_naive(self.cleaned_data.get("due_date")) - if due_date and due_date <= timezone.now(): - raise forms.ValidationError("Due date must be in the future.") + def clean_expires_at(self): + """Make aware if naive.""" + return _make_aware_if_naive(self.cleaned_data.get("expires_at")) - return due_date + def clean(self): + """Warn if expires_at precedes due_date, but allow it.""" + cleaned_data = super().clean() + due_date = cleaned_data.get("due_date") + expires_at = cleaned_data.get("expires_at") + + if due_date and expires_at and expires_at < due_date: + self.expires_at_adjusted = True # view will warn the teacher + + return cleaned_data class SectionFormSet(forms.BaseFormSet): diff --git a/src/homeworks/migrations/0005_add_homework_expiry_and_visibility.py b/src/homeworks/migrations/0005_add_homework_expiry_and_visibility.py new file mode 100644 index 0000000..ab84c4f --- /dev/null +++ b/src/homeworks/migrations/0005_add_homework_expiry_and_visibility.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.7 on 2026-03-13 20:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("homeworks", "0004_alter_homework_course"), + ] + + operations = [ + migrations.AddField( + model_name="homework", + name="expires_at", + field=models.DateTimeField( + blank=True, + help_text="Automatically hide from students after this date. Leave blank to never auto-hide.", + null=True, + ), + ), + migrations.AddField( + model_name="homework", + name="is_hidden", + field=models.BooleanField( + default=False, help_text="Immediately hide this homework from students." + ), + ), + ] diff --git a/src/homeworks/models.py b/src/homeworks/models.py index 06e0be0..f94f8f9 100644 --- a/src/homeworks/models.py +++ b/src/homeworks/models.py @@ -17,6 +17,15 @@ class Homework(models.Model): "courses.Course", on_delete=models.CASCADE, related_name="homeworks" ) due_date = models.DateTimeField() + expires_at = models.DateTimeField( + null=True, + blank=True, + help_text="Automatically hide from students after this date. Leave blank to never auto-hide.", + ) + is_hidden = models.BooleanField( + default=False, + help_text="Immediately hide this homework from students.", + ) llm_config = models.ForeignKey( "llm.LLMConfig", on_delete=models.SET_NULL, null=True, blank=True ) @@ -38,6 +47,20 @@ def section_count(self): def is_overdue(self): return timezone.now() > self.due_date + @property + def is_expired(self) -> bool: + """True if the auto-expiry date has passed.""" + return self.expires_at is not None and timezone.now() > self.expires_at + + @property + def is_accessible_to_students(self) -> bool: + """False if the teacher has hidden it or the expiry date has passed.""" + if self.is_hidden: + return False + if self.is_expired: + return False + return True + class Section(models.Model): """Individual section within a homework assignment.""" diff --git a/src/homeworks/templates/homeworks/detail.html b/src/homeworks/templates/homeworks/detail.html index 60e119d..cacbd89 100644 --- a/src/homeworks/templates/homeworks/detail.html +++ b/src/homeworks/templates/homeworks/detail.html @@ -20,7 +20,21 @@

{{ data.title }}

Overdue
{% endif %} Due: {{ data.due_date|date:"F d, Y" }} - + + {% if data.user_type == 'teacher' %} +
+ {% if data.is_hidden %} + Hidden from students + {% elif not data.is_accessible_to_students %} + Expired — students cannot access + {% elif data.expires_at %} + Expires {{ data.expires_at|date:"F d, Y" }} + {% else %} + Visible to students + {% endif %} +
+ {% endif %} + {% if data.can_edit %}
diff --git a/src/homeworks/templates/homeworks/form.html b/src/homeworks/templates/homeworks/form.html index 6c52693..c4a2ad9 100644 --- a/src/homeworks/templates/homeworks/form.html +++ b/src/homeworks/templates/homeworks/form.html @@ -23,6 +23,11 @@ cursor: pointer; color: #dc3545; } + .field-hint { + font-size: 0.85rem; + color: #8a9ab0; + margin-top: 0.25rem; + } {% endblock %} @@ -96,11 +101,38 @@
Homework Details
{% endif %} - + + +
+
+
Student Visibility
+
+
+
+ {{ data.form.is_hidden }} + +
Students will not be able to see or access this homework regardless of dates.
+ {% if data.form.is_hidden.errors %} +
{{ data.form.is_hidden.errors|join:", " }}
+ {% endif %} +
+
+ + {{ data.form.expires_at }} +
Leave blank to never auto-hide. Must be after the due date.
+ {% if data.form.expires_at.errors %} +
{{ data.form.expires_at.errors|join:", " }}
+ {% endif %} +
+
+
+
{{ data.form.llm_config }} -
Select a configuration for the LLM assistant (optional)
+
Select a configuration for the LLM assistant (optional)
{% if data.form.llm_config.errors %}
{{ data.form.llm_config.errors|join:", " }} diff --git a/src/homeworks/templates/homeworks/list.html b/src/homeworks/templates/homeworks/list.html index 5771722..d43bbe9 100644 --- a/src/homeworks/templates/homeworks/list.html +++ b/src/homeworks/templates/homeworks/list.html @@ -48,6 +48,15 @@
{% elif homework.is_overdue %} Overdue {% endif %} + {% if data.user_type == 'teacher' %} + {% if homework.is_hidden %} + Hidden + {% elif not homework.is_accessible_to_students %} + Expired + {% elif homework.expires_at %} + Expires {{ homework.expires_at|date:"M d" }} + {% endif %} + {% endif %}
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

{% for homework in data.homeworks %} - diff --git a/src/courses/templates/courses/homework_form.html b/src/courses/templates/courses/homework_form.html deleted file mode 100644 index 8cd8f00..0000000 --- a/src/courses/templates/courses/homework_form.html +++ /dev/null @@ -1,251 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Create Homework for {{ data.course_name }}{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
-
- -

Create Homework

-

for {{ data.course_name }}

-
-
- -
- {% csrf_token %} - - -
-
-
Homework Details
-
-
-
- - {{ data.form.title }} - {% if data.form.title.errors %} -
- {{ data.form.title.errors|join:", " }} -
- {% endif %} -
- -
- - {{ data.form.description }} - {% if data.form.description.errors %} -
- {{ data.form.description.errors|join:", " }} -
- {% endif %} -
- -
- - {{ data.form.due_date }} - {% if data.form.due_date.errors %} -
- {{ data.form.due_date.errors|join:", " }} -
- {% endif %} -
- -
- - {{ data.form.llm_config }} - {% if data.form.llm_config.errors %} -
- {{ data.form.llm_config.errors|join:", " }} -
- {% endif %} -
-
-
- - -
-
-
Sections
-
-
-
- {{ data.section_forms.management_form }} - - {% for form in data.section_forms %} -
-
-
Section {{ forloop.counter }}
- {% if forloop.counter > 1 %} - - Remove - - {% endif %} -
- -
- - {{ form.title }} - {% if form.title.errors %} -
- {{ form.title.errors|join:", " }} -
- {% endif %} -
- -
- - {{ form.content }} - {% if form.content.errors %} -
- {{ form.content.errors|join:", " }} -
- {% endif %} -
- -
-
- - {{ form.order }} - {% if form.order.errors %} -
- {{ form.order.errors|join:", " }} -
- {% endif %} -
-
- -
- - {{ form.solution }} - {% if form.solution.errors %} -
- {{ form.solution.errors|join:", " }} -
- {% endif %} -
-
- {% endfor %} -
- - {% if data.section_forms.non_form_errors %} -
- {{ data.section_forms.non_form_errors }} -
- {% endif %} - - -
-
- -
- - Cancel - - -
-
-
- - -{% endblock %} diff --git a/src/courses/tests/test_views.py b/src/courses/tests/test_views.py index 0c761b9..344feab 100644 --- a/src/courses/tests/test_views.py +++ b/src/courses/tests/test_views.py @@ -727,7 +727,7 @@ def test_get_homework_create_form_renders(self): ) self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, "courses/homework_form.html") + self.assertTemplateUsed(response, "homeworks/form.html") def test_create_homework_for_course_success(self): """Test that teachers can create homeworks for their courses.""" diff --git a/src/courses/views.py b/src/courses/views.py index eb21fce..b3109a0 100644 --- a/src/courses/views.py +++ b/src/courses/views.py @@ -6,7 +6,7 @@ """ from dataclasses import dataclass -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast from uuid import UUID from django.views import View from django.http import HttpRequest, HttpResponse, HttpResponseForbidden @@ -15,8 +15,10 @@ from django.contrib.auth.decorators import login_required from django.contrib import messages +from homeworks.views import HomeworkFormData, _mark_invalid_fields + if TYPE_CHECKING: - from homeworks.forms import HomeworkCreateForm, SectionFormSet + from homeworks.forms import SectionFormSet from llteacher.permissions.decorators import ( student_required, @@ -229,6 +231,13 @@ class HomeworkItem: title: str description: str due_date: str # Formatted due date + is_draft: bool = False + is_overdue: bool = False + is_hidden: bool = False + is_accessible_to_students: bool = True + expires_at: Any = None + publish_at: Any = None + section_count: int = 0 @dataclass @@ -318,7 +327,11 @@ def _get_view_data( # Get homeworks for this course (direct FK relationship) from homeworks.models import Homework - course_homeworks = Homework.objects.filter(course=course).order_by("due_date") + hw_qs = Homework.objects.filter(course=course).order_by("due_date") + # Students must not see hidden homeworks (drafts included) + if user_type == "student": + hw_qs = hw_qs.filter(is_hidden=False) + course_homeworks = hw_qs homeworks = [] for hw in course_homeworks: @@ -327,7 +340,14 @@ def _get_view_data( id=hw.id, title=hw.title, description=hw.description, - due_date=hw.due_date.strftime("%B %d, %Y at %I:%M %p"), + due_date=hw.due_date.strftime("%B %d, %Y at %I:%M %p") if hw.due_date else "No due date", + is_draft=hw.is_draft, + is_overdue=hw.is_overdue, + is_hidden=hw.is_hidden, + is_accessible_to_students=hw.is_accessible_to_students, + expires_at=hw.expires_at, + publish_at=hw.publish_at, + section_count=hw.section_count, ) ) @@ -363,18 +383,6 @@ def _get_view_data( ) -@dataclass -class HomeworkFormData: - """Data structure for homework form view.""" - - form: "HomeworkCreateForm" - section_forms: "SectionFormSet" - course_name: str - course_id: UUID - action: str # 'create' - is_submitted: bool - - class CourseHomeworkCreateView(View): """ View for creating a new homework for a specific course. @@ -401,7 +409,7 @@ def get(self, request: TeacherRequest, course_id: UUID) -> HttpResponse: ) data = self._get_view_data(request, course) - return render(request, "courses/homework_form.html", {"data": data}) + return render(request, "homeworks/form.html", {"data": data}) def post(self, request: TeacherRequest, course_id: UUID) -> HttpResponse: """Handle POST requests to process the form submission.""" @@ -419,7 +427,7 @@ def post(self, request: TeacherRequest, course_id: UUID) -> HttpResponse: messages.success(request, "Homework created successfully!") return redirect("courses:detail", course_id=course.id) - return render(request, "courses/homework_form.html", {"data": data}) + return render(request, "homeworks/form.html", {"data": data}) def _can_teacher_create_homework(self, teacher_profile, course: Course) -> bool: """Check if teacher can create homework for this course.""" @@ -447,10 +455,11 @@ def _get_view_data( return HomeworkFormData( form=form, section_forms=section_formset, - course_name=course.name, - course_id=course.id, + user_type="teacher", action="create", is_submitted=False, + course_name=course.name, + course_id=course.id, ) def _process_form_submission( @@ -465,11 +474,33 @@ def _process_form_submission( ) from django.forms import formset_factory - # Create a mutable copy of POST data and inject course + is_draft_save = "save_draft" in request.POST + + # Draft save: bypass all validation, write directly to the ORM + if is_draft_save: + from homeworks.models import Homework, HomeworkType + + Homework.objects.create( + title=request.POST.get("title") or "Untitled Draft", + description=request.POST.get("description") or "", + course=course, + created_by=request.user.teacher_profile, + homework_type=HomeworkType.DRAFT, + is_hidden=True, + ) + return HomeworkFormData( + form=HomeworkCreateForm(is_draft_save=True), + section_forms=None, # type: ignore[arg-type] + user_type="teacher", + action="create", + is_submitted=True, + course_name=course.name, + course_id=course.id, + ) + + # Publish path: run full validation post_data = request.POST.copy() post_data["course"] = str(course.id) - - # Create forms from POST data form = HomeworkCreateForm(post_data) SectionFormset = cast( @@ -478,27 +509,29 @@ def _process_form_submission( ) section_formset = SectionFormset(request.POST, prefix="sections") - # Check form validity if form.is_valid() and section_formset.is_valid(): - # Extract homework data from form + publish_now = "publish_now" in request.POST + homework_type = "published" if publish_now else "draft" + publish_at = None if publish_now else form.cleaned_data.get("publish_at") + homework_data = HomeworkCreateData( title=form.cleaned_data["title"], - description=form.cleaned_data["description"], - due_date=form.cleaned_data["due_date"], + description=form.cleaned_data.get("description") or "", + due_date=form.cleaned_data.get("due_date"), course_id=course.id, sections=[], llm_config=form.cleaned_data["llm_config"].id if form.cleaned_data["llm_config"] else None, + homework_type=homework_type, + publish_at=publish_at, ) - # Extract sections data from formset section_data = [] for section_form in section_formset.forms: if section_form.cleaned_data and not section_form.cleaned_data.get( "DELETE", False ): - # Extract data from form section_data.append( SectionCreateData( title=section_form.cleaned_data["title"], @@ -507,35 +540,34 @@ def _process_form_submission( solution=section_form.cleaned_data["solution"], ) ) - - # Add sections to homework data homework_data.sections = section_data - # Use service to create homework with sections (course already included in data) result = HomeworkService.create_homework_with_sections( homework_data, request.user.teacher_profile ) if result.success: - # Return success data return HomeworkFormData( form=form, section_forms=section_formset, - course_name=course.name, - course_id=course.id, + user_type="teacher", action="create", is_submitted=True, + course_name=course.name, + course_id=course.id, ) else: - # Service returned error messages.error(request, "Failed to create homework. Please try again.") - # Form has errors, re-render with errors + # Highlight fields with errors + _mark_invalid_fields(form) + return HomeworkFormData( form=form, section_forms=section_formset, - course_name=course.name, - course_id=course.id, + user_type="teacher", action="create", is_submitted=False, + course_name=course.name, + course_id=course.id, ) diff --git a/src/homeworks/admin.py b/src/homeworks/admin.py index cdd2f62..ef39659 100644 --- a/src/homeworks/admin.py +++ b/src/homeworks/admin.py @@ -1,5 +1,7 @@ from django.contrib import admin +from django.http import HttpRequest from .models import Homework, Section, SectionSolution +from .services import HomeworkService @admin.register(Homework) @@ -7,18 +9,30 @@ class HomeworkAdmin(admin.ModelAdmin): list_display = ( "title", "course", + "homework_type", "due_date", + "publish_at", "expires_at", "is_hidden", "accessible_to_students", ) - list_filter = ("is_hidden", "course") + list_filter = ("homework_type", "is_hidden", "course") readonly_fields = ("accessible_to_students",) + actions = ["publish_selected"] @admin.display(boolean=True, description="Accessible to students") def accessible_to_students(self, obj): return obj.is_accessible_to_students + @admin.action(description="Publish selected homeworks") + def publish_selected(self, request: HttpRequest, queryset): + published = 0 + for homework in queryset: + result = HomeworkService.publish_homework(homework.id) + if result.success: + published += 1 + self.message_user(request, f"{published} homework(s) published.") + admin.site.register(Section) admin.site.register(SectionSolution) diff --git a/src/homeworks/forms.py b/src/homeworks/forms.py index 31af643..92d6052 100644 --- a/src/homeworks/forms.py +++ b/src/homeworks/forms.py @@ -50,9 +50,7 @@ class SectionForm(forms.Form): order = forms.IntegerField( min_value=1, max_value=20, - widget=forms.NumberInput( - attrs={"class": "form-control", "placeholder": "Order (1-20)"} - ), + widget=forms.HiddenInput(), ) solution = forms.CharField( required=False, @@ -77,7 +75,7 @@ class Meta: "course", "due_date", "expires_at", - "is_hidden", + "publish_at", "llm_config", ] widgets = { @@ -107,16 +105,26 @@ class Meta: "type": "datetime-local", } ), - "is_hidden": forms.CheckboxInput(attrs={"class": "form-check-input"}), + "publish_at": forms.DateTimeInput( + attrs={ + "class": "form-control", + "type": "datetime-local", + } + ), "llm_config": forms.Select( attrs={"class": "form-select", "placeholder": "LLM Configuration"} ), } - def __init__(self, *args, **kwargs): + def __init__(self, *args, is_draft_save=False, **kwargs): super().__init__(*args, **kwargs) self.fields["llm_config"].required = False self.fields["expires_at"].required = False + self.fields["publish_at"].required = False + # due_date is nullable on the model so ModelForm won't auto-require it + self.fields["due_date"].required = not is_draft_save + if is_draft_save: + self.fields["description"].required = False self.expires_at_adjusted = False # flag for view to flash a warning # Display stored datetimes in server local time, not raw UTC @@ -124,6 +132,8 @@ def __init__(self, *args, **kwargs): self.initial["due_date"] = _to_local_str(self.instance.due_date) if self.instance and self.instance.expires_at: self.initial["expires_at"] = _to_local_str(self.instance.expires_at) + if self.instance and self.instance.publish_at: + self.initial["publish_at"] = _to_local_str(self.instance.publish_at) def clean_due_date(self): """Make aware. Reject only strictly past dates — today is allowed.""" @@ -144,6 +154,18 @@ def clean_expires_at(self): """Make aware if naive.""" return _make_aware_if_naive(self.cleaned_data.get("expires_at")) + def clean_publish_at(self): + """Make aware if naive. Required (and future) when publishing without 'Publish now'.""" + publish_at = _make_aware_if_naive(self.cleaned_data.get("publish_at")) + publishing_scheduled = "publish" in self.data and "publish_now" not in self.data + if publishing_scheduled and not publish_at: + raise forms.ValidationError( + 'Set a future publish date, or enable "Publish now".' + ) + if publish_at and publish_at <= timezone.now(): + raise forms.ValidationError("Scheduled publish time must be in the future.") + return publish_at + def clean(self): """Warn if expires_at precedes due_date, but allow it.""" cleaned_data = super().clean() @@ -166,9 +188,9 @@ class Meta: "description", "due_date", "expires_at", - "is_hidden", + "publish_at", "llm_config", - ] # Note: course is excluded + ] # Note: course and is_hidden are excluded (managed automatically) widgets = { "title": forms.TextInput( attrs={"class": "form-control", "placeholder": "Homework Title"} @@ -193,16 +215,26 @@ class Meta: "type": "datetime-local", } ), - "is_hidden": forms.CheckboxInput(attrs={"class": "form-check-input"}), + "publish_at": forms.DateTimeInput( + attrs={ + "class": "form-control", + "type": "datetime-local", + } + ), "llm_config": forms.Select( attrs={"class": "form-select", "placeholder": "LLM Configuration"} ), } - def __init__(self, *args, **kwargs): + def __init__(self, *args, is_draft_save=False, **kwargs): super().__init__(*args, **kwargs) self.fields["llm_config"].required = False self.fields["expires_at"].required = False + self.fields["publish_at"].required = False + # due_date is nullable on the model so ModelForm won't auto-require it + self.fields["due_date"].required = not is_draft_save + if is_draft_save: + self.fields["description"].required = False self.expires_at_adjusted = False # flag for view to flash a warning # Display stored datetimes in server local time, not raw UTC @@ -210,6 +242,8 @@ def __init__(self, *args, **kwargs): self.initial["due_date"] = _to_local_str(self.instance.due_date) if self.instance and self.instance.expires_at: self.initial["expires_at"] = _to_local_str(self.instance.expires_at) + if self.instance and self.instance.publish_at: + self.initial["publish_at"] = _to_local_str(self.instance.publish_at) def clean_due_date(self): """Make aware. No date restrictions on edit — teacher has full control.""" @@ -219,6 +253,16 @@ def clean_expires_at(self): """Make aware if naive.""" return _make_aware_if_naive(self.cleaned_data.get("expires_at")) + def clean_publish_at(self): + """Make aware if naive. Required when publishing without 'Publish now'.""" + publish_at = _make_aware_if_naive(self.cleaned_data.get("publish_at")) + publishing_scheduled = "publish" in self.data and "publish_now" not in self.data + if publishing_scheduled and not publish_at: + raise forms.ValidationError( + 'Set a future publish date, or enable "Publish now".' + ) + return publish_at + def clean(self): """Warn if expires_at precedes due_date, but allow it.""" cleaned_data = super().clean() @@ -234,17 +278,22 @@ def clean(self): class SectionFormSet(forms.BaseFormSet): """Formset for managing multiple sections in a homework.""" + is_draft_save: bool = False + def clean(self): """Validate the formset as a whole. Checks that: - 1. At least one section exists + 1. At least one section exists (skipped for draft saves) 2. No duplicate orders 3. Orders are sequential """ if any(self.errors): return + if self.is_draft_save: + return # Sections are optional for drafts + if not any( form.cleaned_data for form in self.forms diff --git a/src/homeworks/migrations/0006_homework_homework_type_homework_publish_at_and_more.py b/src/homeworks/migrations/0006_homework_homework_type_homework_publish_at_and_more.py new file mode 100644 index 0000000..1298f21 --- /dev/null +++ b/src/homeworks/migrations/0006_homework_homework_type_homework_publish_at_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.7 on 2026-03-25 02:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("homeworks", "0005_add_homework_expiry_and_visibility"), + ] + + operations = [ + migrations.AddField( + model_name="homework", + name="homework_type", + field=models.CharField( + choices=[ + ("draft", "Draft"), + ("published", "Published"), + ("hidden", "Hidden"), + ], + default="published", + help_text="Display label only. is_hidden is the access control source of truth.", + max_length=20, + ), + ), + migrations.AddField( + model_name="homework", + name="publish_at", + field=models.DateTimeField( + blank=True, + help_text="Auto-publish this draft at the given datetime.", + null=True, + ), + ), + migrations.AlterField( + model_name="homework", + name="is_hidden", + field=models.BooleanField( + default=False, + help_text="Source of truth for student visibility. True means students cannot access.", + ), + ), + ] diff --git a/src/homeworks/migrations/0007_alter_homework_due_date.py b/src/homeworks/migrations/0007_alter_homework_due_date.py new file mode 100644 index 0000000..d5415aa --- /dev/null +++ b/src/homeworks/migrations/0007_alter_homework_due_date.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.7 on 2026-03-25 05:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("homeworks", "0006_homework_homework_type_homework_publish_at_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="homework", + name="due_date", + field=models.DateTimeField( + blank=True, + help_text="Required before publishing. Drafts may leave this blank.", + null=True, + ), + ), + ] diff --git a/src/homeworks/models.py b/src/homeworks/models.py index f94f8f9..9d90cc8 100644 --- a/src/homeworks/models.py +++ b/src/homeworks/models.py @@ -4,6 +4,12 @@ from django.utils import timezone +class HomeworkType(models.TextChoices): + DRAFT = "draft", "Draft" + PUBLISHED = "published", "Published" + HIDDEN = "hidden", "Hidden" + + class Homework(models.Model): """Homework assignment with multiple sections.""" @@ -16,7 +22,11 @@ class Homework(models.Model): course = models.ForeignKey( "courses.Course", on_delete=models.CASCADE, related_name="homeworks" ) - due_date = models.DateTimeField() + due_date = models.DateTimeField( + null=True, + blank=True, + help_text="Required before publishing. Drafts may leave this blank.", + ) expires_at = models.DateTimeField( null=True, blank=True, @@ -24,7 +34,20 @@ class Homework(models.Model): ) is_hidden = models.BooleanField( default=False, - help_text="Immediately hide this homework from students.", + help_text="Source of truth for student visibility. True means students cannot access.", + ) + # Display-only type — never use for access control, use is_hidden instead. + # draft: not yet published; published: visible to students; hidden: published but manually hidden. + homework_type = models.CharField( + max_length=20, + choices=HomeworkType.choices, + default=HomeworkType.PUBLISHED, + help_text="Display label only. is_hidden is the access control source of truth.", + ) + publish_at = models.DateTimeField( + null=True, + blank=True, + help_text="Auto-publish this draft at the given datetime.", ) llm_config = models.ForeignKey( "llm.LLMConfig", on_delete=models.SET_NULL, null=True, blank=True @@ -45,22 +68,37 @@ def section_count(self): @property def is_overdue(self): - return timezone.now() > self.due_date + return self.due_date is not None and timezone.now() > self.due_date @property def is_expired(self) -> bool: """True if the auto-expiry date has passed.""" return self.expires_at is not None and timezone.now() > self.expires_at + @property + def is_draft(self) -> bool: + """True when the homework has never been published (display label only).""" + return self.homework_type == HomeworkType.DRAFT + @property def is_accessible_to_students(self) -> bool: - """False if the teacher has hidden it or the expiry date has passed.""" + """False if the teacher has hidden it or the expiry date has passed. + is_hidden is the single source of truth — this never reads homework_type.""" if self.is_hidden: return False if self.is_expired: return False return True + @property + def should_auto_publish(self) -> bool: + """True when a draft has a past publish_at and should be auto-published.""" + return ( + self.homework_type == HomeworkType.DRAFT + and self.publish_at is not None + and timezone.now() >= self.publish_at + ) + class Section(models.Model): """Individual section within a homework assignment.""" diff --git a/src/homeworks/services.py b/src/homeworks/services.py index 7511d04..d8497e6 100644 --- a/src/homeworks/services.py +++ b/src/homeworks/services.py @@ -54,11 +54,13 @@ class SectionCreateData: @dataclass class HomeworkCreateData: title: str - description: str - due_date: Any # datetime sections: list[SectionCreateData] course_id: UUID # Required - every homework belongs to a course + description: str = "" + due_date: Any | None = None # datetime; None allowed for drafts llm_config: UUID | None = None + homework_type: str = "published" + publish_at: Any | None = None # datetime @dataclass @@ -104,7 +106,7 @@ class HomeworkDetailData: id: UUID title: str description: str - due_date: datetime + due_date: datetime | None created_by: UUID created_at: datetime updated_at: datetime @@ -123,6 +125,8 @@ class HomeworkUpdateData: sections_to_update: list[Any] | None = None # Will be defined with proper type sections_to_create: list[SectionCreateData] | None = None sections_to_delete: list[UUID] | None = None + homework_type: str | None = None + publish_at: Any | None = None # datetime @dataclass @@ -196,7 +200,7 @@ class HomeworkSubmissionsData: homework_id: UUID homework_title: str - homework_due_date: datetime + homework_due_date: datetime | None total_sections: int students: list[StudentSubmissionSummary] total_students: int @@ -294,7 +298,8 @@ def create_homework_with_sections( try: with transaction.atomic(): - # Create homework + # Create homework — draft type implies is_hidden=True + is_draft = data.homework_type == "draft" homework = Homework.objects.create( title=data.title, description=data.description, @@ -302,6 +307,9 @@ def create_homework_with_sections( created_by=teacher, course_id=data.course_id, llm_config_id=data.llm_config, + homework_type=data.homework_type, + publish_at=data.publish_at, + is_hidden=is_draft, ) # Create sections @@ -337,6 +345,58 @@ def create_homework_with_sections( error=str(e), ) + @staticmethod + def publish_homework(homework_id: UUID) -> HomeworkUpdateResult: + """Immediately publish a draft homework. + + Sets is_hidden=False, homework_type='published', publish_at=None. + is_hidden is the access-control source of truth. + """ + from .models import Homework, HomeworkType + + try: + homework = Homework.objects.get(id=homework_id) + homework.is_hidden = False + homework.homework_type = HomeworkType.PUBLISHED + homework.publish_at = None + homework.save( + update_fields=["is_hidden", "homework_type", "publish_at", "updated_at"] + ) + return HomeworkUpdateResult(success=True, homework_id=homework_id) + except Homework.DoesNotExist: + return HomeworkUpdateResult(success=False, error="Homework not found") + except Exception as e: + record_exception(e) + return HomeworkUpdateResult(success=False, error=str(e)) + + @staticmethod + def auto_publish_due_drafts() -> int: + """Bulk-publish all drafts whose publish_at has passed. + + Updates is_hidden=False, homework_type='published', publish_at=None. + Returns the count of homeworks published. + Called lazily on page load — no background worker required. + """ + from django.utils import timezone + from .models import Homework, HomeworkType + + try: + count = Homework.objects.filter( + homework_type=HomeworkType.DRAFT, + publish_at__lte=timezone.now(), + ).update( + is_hidden=False, + homework_type=HomeworkType.PUBLISHED, + publish_at=None, + ) + if count: + logger.info("Auto-published %d draft homework(s)", count) + return count + except Exception as e: + record_exception(e) + logger.error("auto_publish_due_drafts failed: %s", e) + return 0 + @staticmethod @traced def get_student_homework_progress( diff --git a/src/homeworks/templates/homeworks/detail.html b/src/homeworks/templates/homeworks/detail.html index cacbd89..c8cfa8b 100644 --- a/src/homeworks/templates/homeworks/detail.html +++ b/src/homeworks/templates/homeworks/detail.html @@ -23,7 +23,12 @@

{{ data.title }}

{% if data.user_type == 'teacher' %}
- {% if data.is_hidden %} + {% if data.is_draft %} + Draft — not visible to students + {% if data.publish_at %} +
Scheduled to publish: {{ data.publish_at|date:"F d, Y H:i" }} + {% endif %} + {% elif data.is_hidden %} Hidden from students {% elif not data.is_accessible_to_students %} Expired — students cannot access diff --git a/src/homeworks/templates/homeworks/form.html b/src/homeworks/templates/homeworks/form.html index c4a2ad9..e74fced 100644 --- a/src/homeworks/templates/homeworks/form.html +++ b/src/homeworks/templates/homeworks/form.html @@ -28,37 +28,56 @@ color: #8a9ab0; margin-top: 0.25rem; } + #publish-at-wrapper { + transition: opacity 0.2s ease; + } {% endblock %} {% block content %}
+ {% if data.action == 'create' and data.course_id %} +
+
+ +
+
+ {% endif %}

{% if data.action == 'create' %}Create New Homework{% else %}Edit Homework{% endif %}

+ {% if data.action == 'create' and data.course_name %} +

for {{ data.course_name }}

+ {% endif %}
+ {% if data.action == 'create' and data.course_id %} + + Cancel + + {% else %} Back to List + {% endif %}
- {% if data.errors %} -
- There were errors with your submission: -
    - {% for field, errors in data.errors.homework.items %} -
  • {{ field }}: {{ errors|join:", " }}
  • - {% endfor %} - - {% if data.errors.formset %} - {% for error in data.errors.formset %} -
  • {{ error }}
  • + {% if data.form.errors %} + {% endif %} @@ -73,49 +92,68 @@
    Homework Details
- + {{ data.form.title }} {% if data.form.title.errors %} -
- {{ data.form.title.errors|join:", " }} +
+ {{ data.form.title.errors|join:", " }}
{% endif %}
- +
- + {{ data.form.description }} {% if data.form.description.errors %} -
- {{ data.form.description.errors|join:", " }} +
+ {{ data.form.description.errors|join:", " }}
{% endif %}
- +
- + {{ data.form.due_date }} {% if data.form.due_date.errors %} -
- {{ data.form.due_date.errors|join:", " }} +
+ {{ data.form.due_date.errors|join:", " }}
{% endif %}
- -
-
-
Student Visibility
+ +
+
+
Scheduling
-
- {{ data.form.is_hidden }} -