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/2] 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/2] 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