diff --git a/src/conversations/views.py b/src/conversations/views.py index caf22fc..b167dfc 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 @@ -108,9 +108,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 @@ -156,7 +158,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/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 %} -
-
{{ homework.title }}
- - Due: {{ homework.due_date }} +
+
+
{{ homework.title }}
+ {% if data.user_type == 'teacher' %} + {% if homework.is_draft %} + Draft + {% if homework.publish_at %} + Publishes {{ homework.publish_at|date:"M d, H:i" }} + {% endif %} + {% elif 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 %} + {% if homework.is_overdue and not homework.is_draft %} + Overdue + {% endif %} +
+ + {{ homework.due_date }} + {% if homework.section_count %} +  · {{ homework.section_count }} section{{ homework.section_count|pluralize }} + {% endif %}
-

{{ homework.description|truncatewords:20 }}

+ {% if homework.description %} +

{{ homework.description|truncatewords:20 }}

+ {% endif %}
{% endfor %}
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 671b01a..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 +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 @@ -291,7 +300,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 @@ -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.""" @@ -437,17 +445,21 @@ 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 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( @@ -462,37 +474,64 @@ def _process_form_submission( ) from django.forms import formset_factory - # Create a mutable copy of POST data and inject course - post_data = request.POST.copy() - post_data["course"] = course.id + is_draft_save = "save_draft" in request.POST - # Create forms from POST data + # 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) 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 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"], @@ -501,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 d722d41..ef39659 100644 --- a/src/homeworks/admin.py +++ b/src/homeworks/admin.py @@ -1,6 +1,38 @@ from django.contrib import admin +from django.http import HttpRequest from .models import Homework, Section, SectionSolution +from .services import HomeworkService + + +@admin.register(Homework) +class HomeworkAdmin(admin.ModelAdmin): + list_display = ( + "title", + "course", + "homework_type", + "due_date", + "publish_at", + "expires_at", + "is_hidden", + "accessible_to_students", + ) + 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(Homework) admin.site.register(Section) admin.site.register(SectionSolution) diff --git a/src/homeworks/forms.py b/src/homeworks/forms.py index fa7c841..92d6052 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.""" @@ -33,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, @@ -54,7 +69,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", + "publish_at", + "llm_config", + ] widgets = { "title": forms.TextInput( attrs={"class": "form-control", "placeholder": "Homework Title"} @@ -76,28 +99,84 @@ class Meta: "placeholder": "Due Date", } ), + "expires_at": forms.DateTimeInput( + attrs={ + "class": "form-control", + "type": "datetime-local", + } + ), + "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 - # 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) + if self.instance and self.instance.publish_at: + self.initial["publish_at"] = _to_local_str(self.instance.publish_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_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() + 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,8 +187,10 @@ class Meta: "title", "description", "due_date", + "expires_at", + "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"} @@ -128,43 +209,91 @@ class Meta: "placeholder": "Due Date", } ), + "expires_at": forms.DateTimeInput( + attrs={ + "class": "form-control", + "type": "datetime-local", + } + ), + "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 - # 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) + if self.instance and self.instance.publish_at: + self.initial["publish_at"] = _to_local_str(self.instance.publish_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_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() + 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): """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/0005_add_homework_expiry_and_visibility.py b/src/homeworks/migrations/0005_add_homework_expiry_and_visibility.py new file mode 100644 index 0000000..d14646d --- /dev/null +++ b/src/homeworks/migrations/0005_add_homework_expiry_and_visibility.py @@ -0,0 +1,28 @@ +# 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/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 06e0be0..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,33 @@ 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, + help_text="Automatically hide from students after this date. Leave blank to never auto-hide.", + ) + is_hidden = models.BooleanField( + default=False, + 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 ) @@ -36,7 +68,36 @@ 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. + 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): diff --git a/src/homeworks/services.py b/src/homeworks/services.py index 436cd59..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( @@ -890,7 +950,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/templates/homeworks/detail.html b/src/homeworks/templates/homeworks/detail.html index 60e119d..c8cfa8b 100644 --- a/src/homeworks/templates/homeworks/detail.html +++ b/src/homeworks/templates/homeworks/detail.html @@ -20,7 +20,26 @@

{{ data.title }}

Overdue
{% endif %} Due: {{ data.due_date|date:"F d, Y" }} - + + {% if data.user_type == 'teacher' %} +
+ {% 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 + {% 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..e74fced 100644 --- a/src/homeworks/templates/homeworks/form.html +++ b/src/homeworks/templates/homeworks/form.html @@ -23,37 +23,61 @@ cursor: pointer; color: #dc3545; } + .field-hint { + font-size: 0.85rem; + 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 %} @@ -68,39 +92,85 @@
    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 %}
- + + +
+
+
Scheduling
+
+
+
+
+ + +
+
When enabled, "Save & Publish" makes this homework visible to students immediately.
+
+
+ + {{ data.form.publish_at }} +
Set a future date/time to automatically publish this draft.
+ {% if data.form.publish_at.errors %} +
{{ data.form.publish_at.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:", " }} @@ -111,74 +181,100 @@
Homework Details
-

Sections

+

Sections (required to publish)

-

Add at least one section to your homework. Sections are ordered numerically starting from 1.

+

At least one section is required before publishing. You can save a draft without sections and add them later.

{{ data.section_forms.management_form }} + + +
{% for section_form in data.section_forms %}
Section {{ forloop.counter }}
- {% if forloop.counter > 1 %} - - Remove - - {% endif %} +
- + {{ section_form.id }} - + {{ section_form.order }} +
{{ section_form.title }} {% if section_form.title.errors %} -
- {{ section_form.title.errors|join:", " }} -
+
{{ section_form.title.errors|join:", " }}
{% endif %}
- +
{{ section_form.content }} {% if section_form.content.errors %} -
- {{ section_form.content.errors|join:", " }} -
+
{{ section_form.content.errors|join:", " }}
{% endif %}
- -
-
- - {{ section_form.order }} - {% if section_form.order.errors %} -
- {{ section_form.order.errors|join:", " }} -
- {% endif %} -
-
- +
{{ section_form.solution }}
Add an optional solution that can be viewed by teachers only
{% if section_form.solution.errors %} -
- {{ section_form.solution.errors|join:", " }} -
+
{{ section_form.solution.errors|join:", " }}
{% endif %}
- + {% if not forloop.first %} -
+ {% endif %}
@@ -191,10 +287,13 @@
Section {{ forloop.counter }}
- -
- +
@@ -203,6 +302,31 @@
Section {{ forloop.counter }}
{% block extra_js %} {% endblock %} \ No newline at end of file diff --git a/src/homeworks/templates/homeworks/list.html b/src/homeworks/templates/homeworks/list.html index 5771722..0fa7e4a 100644 --- a/src/homeworks/templates/homeworks/list.html +++ b/src/homeworks/templates/homeworks/list.html @@ -48,6 +48,20 @@
{% elif homework.is_overdue %} Overdue {% endif %} + {% if data.user_type == 'teacher' %} + {% if homework.is_draft %} + Draft + {% if homework.publish_at %} + Publishes {{ homework.publish_at|date:"M d, H:i" }} + {% endif %} + {% elif 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_draft_mode_forms.py b/src/homeworks/tests/test_draft_mode_forms.py new file mode 100644 index 0000000..cf612e4 --- /dev/null +++ b/src/homeworks/tests/test_draft_mode_forms.py @@ -0,0 +1,252 @@ +""" +Tests for draft mode form validation. + +Covers: +- HomeworkCreateForm: publish_at validation (past rejected, future accepted, publish_now bypasses) +- HomeworkCreateForm: due_date validation (today allowed, past rejected, draft bypasses) +- HomeworkEditForm: publish_at not restricted on edit; requires when publishing scheduled +- SectionFormSet: order uniqueness, sequential order, draft bypass +""" + +from datetime import timedelta + +from django.forms import formset_factory +from django.test import TestCase +from django.utils import timezone + +from accounts.models import Teacher +from courses.models import Course +from django.contrib.auth import get_user_model +from homeworks.forms import HomeworkCreateForm, HomeworkEditForm, SectionForm, SectionFormSet +from homeworks.models import Homework, HomeworkType + +User = get_user_model() + + +def _make_section_post(sections, prefix="sections"): + """Build a POST dict for a SectionFormSet from a list of dicts.""" + data = { + f"{prefix}-TOTAL_FORMS": str(len(sections)), + f"{prefix}-INITIAL_FORMS": "0", + f"{prefix}-MIN_NUM_FORMS": "0", + f"{prefix}-MAX_NUM_FORMS": "1000", + } + for i, s in enumerate(sections): + data[f"{prefix}-{i}-id"] = s.get("id", "") + data[f"{prefix}-{i}-title"] = s.get("title", "") + data[f"{prefix}-{i}-content"] = s.get("content", "") + data[f"{prefix}-{i}-order"] = str(s.get("order", i + 1)) + data[f"{prefix}-{i}-solution"] = s.get("solution", "") + if s.get("DELETE"): + data[f"{prefix}-{i}-DELETE"] = "on" + return data + + +def _make_formset(sections, is_draft=False): + FS = formset_factory(SectionForm, extra=0, formset=SectionFormSet) + post = _make_section_post(sections) + fs = FS(post, prefix="sections") + fs.is_draft_save = is_draft + return fs + + +class DraftModeFormSetUpMixin(TestCase): + def setUp(self): + user = User.objects.create_user(username="teacher", password="pass") + self.teacher = Teacher.objects.create(user=user) + self.course = Course.objects.create(name="Course", code="C1") + self.future = timezone.now() + timedelta(days=7) + self.base_post = { + "title": "HW", + "description": "desc", + "course": str(self.course.id), + "due_date": self.future.strftime("%Y-%m-%dT%H:%M"), + "expires_at": "", + "publish_at": "", + "llm_config": "", + } + + +# --------------------------------------------------------------------------- +# HomeworkCreateForm +# --------------------------------------------------------------------------- + +class HomeworkCreateFormPublishAtTests(DraftModeFormSetUpMixin): + def test_valid_with_no_publish_at_and_no_submit_button(self): + """No publish button in POST — publish_at is optional.""" + form = HomeworkCreateForm(self.base_post) + self.assertTrue(form.is_valid(), form.errors) + + def test_valid_with_publish_now_checked_and_no_publish_at(self): + """publish_now in POST → schedule validation skipped even if publish_at empty.""" + data = {**self.base_post, "publish": "1", "publish_now": "on"} + form = HomeworkCreateForm(data) + self.assertTrue(form.is_valid(), form.errors) + + def test_invalid_when_scheduling_without_publish_at(self): + """publish button + no publish_now + no publish_at → required error.""" + data = {**self.base_post, "publish": "1"} + form = HomeworkCreateForm(data) + self.assertFalse(form.is_valid()) + self.assertIn("publish_at", form.errors) + + def test_valid_with_future_publish_at_when_scheduling(self): + data = { + **self.base_post, + "publish": "1", + "publish_at": (timezone.now() + timedelta(hours=3)).strftime("%Y-%m-%dT%H:%M"), + } + form = HomeworkCreateForm(data) + self.assertTrue(form.is_valid(), form.errors) + + def test_invalid_with_past_publish_at(self): + data = { + **self.base_post, + "publish_at": (timezone.now() - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M"), + } + form = HomeworkCreateForm(data) + self.assertFalse(form.is_valid()) + self.assertIn("publish_at", form.errors) + + +class HomeworkCreateFormDueDateTests(DraftModeFormSetUpMixin): + def test_due_date_today_is_valid(self): + """Today's date is allowed — only strictly past dates are rejected.""" + today_end = timezone.now().replace(hour=23, minute=59, second=0, microsecond=0) + data = {**self.base_post, "due_date": today_end.strftime("%Y-%m-%dT%H:%M")} + form = HomeworkCreateForm(data) + self.assertTrue(form.is_valid(), form.errors) + + def test_due_date_yesterday_is_invalid(self): + yesterday = timezone.now() - timedelta(days=1) + data = {**self.base_post, "due_date": yesterday.strftime("%Y-%m-%dT%H:%M")} + form = HomeworkCreateForm(data) + self.assertFalse(form.is_valid()) + self.assertIn("due_date", form.errors) + + def test_draft_save_skips_due_date_requirement(self): + data = { + "title": "My Draft", + "description": "", + "course": str(self.course.id), + "due_date": "", + "expires_at": "", + "publish_at": "", + "llm_config": "", + } + form = HomeworkCreateForm(data, is_draft_save=True) + self.assertTrue(form.is_valid(), form.errors) + + def test_publish_requires_due_date(self): + data = {**self.base_post, "due_date": ""} + form = HomeworkCreateForm(data) + self.assertFalse(form.is_valid()) + self.assertIn("due_date", form.errors) + + +# --------------------------------------------------------------------------- +# HomeworkEditForm +# --------------------------------------------------------------------------- + +class HomeworkEditFormTests(DraftModeFormSetUpMixin): + def setUp(self): + super().setUp() + self.homework = Homework.objects.create( + title="HW", + description="desc", + created_by=self.teacher, + course=self.course, + due_date=self.future, + homework_type=HomeworkType.DRAFT, + is_hidden=True, + publish_at=timezone.now() + timedelta(days=2), + ) + + def _post(self, **overrides): + base = { + "title": "HW", + "description": "desc", + "due_date": self.future.strftime("%Y-%m-%dT%H:%M"), + "expires_at": "", + "publish_at": "", + "llm_config": "", + } + base.update(overrides) + return base + + def test_valid_with_no_publish_at(self): + form = HomeworkEditForm(self._post(), instance=self.homework) + self.assertTrue(form.is_valid(), form.errors) + + def test_valid_with_past_due_date(self): + """Edit form has no due_date restriction — teacher may backdate.""" + past = (timezone.now() - timedelta(days=3)).strftime("%Y-%m-%dT%H:%M") + form = HomeworkEditForm(self._post(due_date=past), instance=self.homework) + self.assertTrue(form.is_valid(), form.errors) + + def test_valid_with_past_publish_at(self): + """Edit form allows past publish_at — teacher may be correcting a mistake.""" + past = (timezone.now() - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M") + form = HomeworkEditForm(self._post(publish_at=past), instance=self.homework) + self.assertTrue(form.is_valid(), form.errors) + + def test_valid_with_future_publish_at(self): + future = (timezone.now() + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M") + form = HomeworkEditForm(self._post(publish_at=future), instance=self.homework) + self.assertTrue(form.is_valid(), form.errors) + + def test_invalid_when_scheduling_without_publish_at(self): + """publish button + no publish_now + no publish_at → required error on edit too.""" + data = {**self._post(), "publish": "1"} + form = HomeworkEditForm(data, instance=self.homework) + self.assertFalse(form.is_valid()) + self.assertIn("publish_at", form.errors) + + def test_valid_when_publish_now_checked_and_no_publish_at(self): + data = {**self._post(), "publish": "1", "publish_now": "on"} + form = HomeworkEditForm(data, instance=self.homework) + self.assertTrue(form.is_valid(), form.errors) + + +# --------------------------------------------------------------------------- +# SectionFormSet +# --------------------------------------------------------------------------- + +class SectionFormSetOrderTests(TestCase): + def _section(self, order, title="T", content="C"): + return {"title": title, "content": content, "order": order} + + def test_valid_sequential_sections(self): + fs = _make_formset([self._section(1), self._section(2), self._section(3)]) + self.assertTrue(fs.is_valid(), fs.errors) + + def test_duplicate_order_rejected(self): + fs = _make_formset([self._section(1), self._section(1)]) + self.assertFalse(fs.is_valid()) + self.assertTrue(any("multiple times" in str(e) for e in fs.non_form_errors())) + + def test_gap_in_order_rejected(self): + fs = _make_formset([self._section(1), self._section(3)]) + self.assertFalse(fs.is_valid()) + self.assertTrue(any("sequential" in str(e) for e in fs.non_form_errors())) + + def test_order_not_starting_at_1_rejected(self): + fs = _make_formset([self._section(2), self._section(3)]) + self.assertFalse(fs.is_valid()) + self.assertTrue(any("start with order 1" in str(e) for e in fs.non_form_errors())) + + def test_empty_formset_rejected_for_publish(self): + FS = formset_factory(SectionForm, extra=0, formset=SectionFormSet) + post = _make_section_post([]) + fs = FS(post, prefix="sections") + fs.is_draft_save = False + self.assertFalse(fs.is_valid()) + self.assertTrue(any("required" in str(e) for e in fs.non_form_errors())) + + def test_empty_formset_valid_for_draft_save(self): + fs = _make_formset([], is_draft=True) + self.assertTrue(fs.is_valid(), fs.errors) + + def test_single_valid_section_passes(self): + fs = _make_formset([{"title": "T", "content": "C", "order": 1}]) + self.assertTrue(fs.is_valid(), fs.errors) diff --git a/src/homeworks/tests/test_draft_mode_model.py b/src/homeworks/tests/test_draft_mode_model.py new file mode 100644 index 0000000..004c20a --- /dev/null +++ b/src/homeworks/tests/test_draft_mode_model.py @@ -0,0 +1,164 @@ +""" +Tests for Homework draft mode model properties. + +Covers: +- is_draft property +- is_overdue (including None due_date guard) +- is_expired (including None expires_at guard) +- is_accessible_to_students (hidden, expired, and draft combinations) +- should_auto_publish (all states, including at-exactly-now boundary) +- Default field values +""" + +from datetime import timedelta + +from django.test import TestCase +from django.utils import timezone + +from accounts.models import Teacher +from courses.models import Course +from django.contrib.auth import get_user_model +from homeworks.models import Homework, HomeworkType + +User = get_user_model() + + +class HomeworkDraftModelTests(TestCase): + def setUp(self): + user = User.objects.create_user(username="teacher", password="pass") + self.teacher = Teacher.objects.create(user=user) + self.course = Course.objects.create(name="Course", code="C1") + self.base_data = { + "title": "HW", + "description": "desc", + "created_by": self.teacher, + "course": self.course, + "due_date": timezone.now() + timedelta(days=7), + } + + def _make(self, **kwargs): + return Homework.objects.create(**{**self.base_data, **kwargs}) + + # --- defaults --- + + def test_default_homework_type_is_published(self): + hw = self._make() + self.assertEqual(hw.homework_type, HomeworkType.PUBLISHED) + + def test_default_publish_at_is_null(self): + hw = self._make() + self.assertIsNone(hw.publish_at) + + def test_default_is_hidden_is_false(self): + hw = self._make() + self.assertFalse(hw.is_hidden) + + # --- is_draft --- + + def test_is_draft_true_when_type_is_draft(self): + hw = self._make(homework_type=HomeworkType.DRAFT) + self.assertTrue(hw.is_draft) + + def test_is_draft_false_when_type_is_published(self): + hw = self._make(homework_type=HomeworkType.PUBLISHED) + self.assertFalse(hw.is_draft) + + def test_is_draft_false_when_type_is_hidden(self): + hw = self._make(homework_type=HomeworkType.HIDDEN) + self.assertFalse(hw.is_draft) + + # --- is_overdue --- + + def test_is_overdue_false_when_due_date_is_none(self): + """None due_date must not raise — was a real bug after making due_date nullable.""" + hw = self._make(due_date=None) + self.assertFalse(hw.is_overdue) + + def test_is_overdue_false_when_due_date_in_future(self): + hw = self._make(due_date=timezone.now() + timedelta(days=1)) + self.assertFalse(hw.is_overdue) + + def test_is_overdue_true_when_due_date_in_past(self): + hw = self._make(due_date=timezone.now() - timedelta(days=1)) + self.assertTrue(hw.is_overdue) + + # --- is_expired --- + + def test_is_expired_false_when_expires_at_is_none(self): + hw = self._make() + self.assertIsNone(hw.expires_at) + self.assertFalse(hw.is_expired) + + def test_is_expired_false_when_expires_at_in_future(self): + hw = self._make(expires_at=timezone.now() + timedelta(days=1)) + self.assertFalse(hw.is_expired) + + def test_is_expired_true_when_expires_at_in_past(self): + hw = self._make(expires_at=timezone.now() - timedelta(seconds=1)) + self.assertTrue(hw.is_expired) + + # --- is_accessible_to_students --- + + def test_accessible_by_default(self): + hw = self._make() + self.assertTrue(hw.is_accessible_to_students) + + def test_not_accessible_when_is_hidden_true(self): + hw = self._make(is_hidden=True) + self.assertFalse(hw.is_accessible_to_students) + + def test_not_accessible_when_expired(self): + hw = self._make(expires_at=timezone.now() - timedelta(seconds=1)) + self.assertFalse(hw.is_accessible_to_students) + + def test_not_accessible_when_hidden_and_expired(self): + hw = self._make( + is_hidden=True, + expires_at=timezone.now() - timedelta(seconds=1), + ) + self.assertFalse(hw.is_accessible_to_students) + + def test_accessible_when_draft_type_but_is_hidden_false(self): + """homework_type is display-only. A DRAFT with is_hidden=False is accessible.""" + hw = self._make(homework_type=HomeworkType.DRAFT, is_hidden=False) + self.assertTrue(hw.is_accessible_to_students) + + def test_not_accessible_when_draft_type_and_is_hidden_true(self): + hw = self._make(homework_type=HomeworkType.DRAFT, is_hidden=True) + self.assertFalse(hw.is_accessible_to_students) + + # --- should_auto_publish --- + + def test_should_auto_publish_false_when_no_publish_at(self): + hw = self._make(homework_type=HomeworkType.DRAFT) + self.assertFalse(hw.should_auto_publish) + + def test_should_auto_publish_false_when_publish_at_in_future(self): + hw = self._make( + homework_type=HomeworkType.DRAFT, + publish_at=timezone.now() + timedelta(hours=1), + ) + self.assertFalse(hw.should_auto_publish) + + def test_should_auto_publish_true_when_publish_at_in_past(self): + hw = self._make( + homework_type=HomeworkType.DRAFT, + publish_at=timezone.now() - timedelta(seconds=1), + ) + self.assertTrue(hw.should_auto_publish) + + def test_should_auto_publish_false_when_already_published(self): + """publish_at in past but type=PUBLISHED → already done, don't re-publish.""" + hw = self._make( + homework_type=HomeworkType.PUBLISHED, + publish_at=timezone.now() - timedelta(seconds=1), + ) + self.assertFalse(hw.should_auto_publish) + + def test_should_auto_publish_false_when_type_is_hidden(self): + """HIDDEN type should not be auto-published.""" + hw = self._make( + homework_type=HomeworkType.HIDDEN, + publish_at=timezone.now() - timedelta(seconds=1), + ) + self.assertFalse(hw.should_auto_publish) diff --git a/src/homeworks/tests/test_draft_mode_views.py b/src/homeworks/tests/test_draft_mode_views.py new file mode 100644 index 0000000..9be792a --- /dev/null +++ b/src/homeworks/tests/test_draft_mode_views.py @@ -0,0 +1,461 @@ +""" +Tests for draft mode enforcement in views. + +Covers: +- Students cannot see draft homeworks in list or detail +- Teachers always see draft homeworks +- publish_now action publishes a draft +- auto_publish_due_drafts fires on list/detail page load +- Scheduled draft stays hidden before publish_at +""" + +from datetime import timedelta + +from django.test import TestCase, RequestFactory, Client +from django.urls import reverse +from django.utils import timezone +from django.contrib.auth import get_user_model + +from accounts.models import Teacher, Student +from courses.models import Course, CourseEnrollment, CourseTeacher +from homeworks.models import Homework, HomeworkType, Section + +User = get_user_model() + + +class DraftModeViewSetUpMixin(TestCase): + """Shared setUp for draft mode view tests.""" + + def setUp(self): + self.factory = RequestFactory() + + teacher_user = User.objects.create_user( + username="teacher", email="t@test.com", password="pass" + ) + self.teacher = Teacher.objects.create(user=teacher_user) + self.teacher_user = teacher_user + + student_user = User.objects.create_user( + username="student", email="s@test.com", password="pass" + ) + self.student = Student.objects.create(user=student_user) + self.student_user = student_user + + self.course = Course.objects.create(name="Course", code="C1", is_active=True) + CourseTeacher.objects.create( + course=self.course, teacher=self.teacher, role="owner" + ) + CourseEnrollment.objects.create( + course=self.course, student=self.student, is_active=True + ) + + self.published_hw = Homework.objects.create( + title="Published", + description="desc", + created_by=self.teacher, + course=self.course, + due_date=timezone.now() + timedelta(days=7), + homework_type=HomeworkType.PUBLISHED, + is_hidden=False, + ) + + self.draft_hw = Homework.objects.create( + title="Draft", + description="desc", + created_by=self.teacher, + course=self.course, + due_date=timezone.now() + timedelta(days=7), + homework_type=HomeworkType.DRAFT, + is_hidden=True, + ) + # Add a section so detail view can render + Section.objects.create( + homework=self.draft_hw, title="S1", content="content", order=1 + ) + Section.objects.create( + homework=self.published_hw, title="S1", content="content", order=1 + ) + + +class HomeworkListDraftTests(DraftModeViewSetUpMixin): + """HomeworkListView — draft visibility.""" + + def test_student_cannot_see_draft_in_list(self): + client = Client() + client.login(username="student", password="pass") + response = client.get(reverse("homeworks:list")) + titles = [hw.title for hw in response.context["data"].homeworks] + self.assertNotIn("Draft", titles) + self.assertIn("Published", titles) + + def test_teacher_sees_draft_in_list(self): + client = Client() + client.login(username="teacher", password="pass") + response = client.get(reverse("homeworks:list")) + titles = [hw.title for hw in response.context["data"].homeworks] + self.assertIn("Draft", titles) + self.assertIn("Published", titles) + + def test_teacher_list_item_has_is_draft_true(self): + client = Client() + client.login(username="teacher", password="pass") + response = client.get(reverse("homeworks:list")) + draft_items = [ + hw for hw in response.context["data"].homeworks if hw.title == "Draft" + ] + self.assertEqual(len(draft_items), 1) + self.assertTrue(draft_items[0].is_draft) + + +class HomeworkDetailDraftTests(DraftModeViewSetUpMixin): + """HomeworkDetailView — draft visibility.""" + + def test_student_cannot_access_draft_detail(self): + client = Client() + client.login(username="student", password="pass") + url = reverse("homeworks:detail", kwargs={"homework_id": self.draft_hw.id}) + response = client.get(url) + # Returns redirect (302) because _get_view_data returns None for inaccessible + self.assertEqual(response.status_code, 302) + + def test_teacher_can_access_draft_detail(self): + client = Client() + client.login(username="teacher", password="pass") + url = reverse("homeworks:detail", kwargs={"homework_id": self.draft_hw.id}) + response = client.get(url) + self.assertEqual(response.status_code, 200) + + def test_detail_data_has_is_draft_true_for_teacher(self): + client = Client() + client.login(username="teacher", password="pass") + url = reverse("homeworks:detail", kwargs={"homework_id": self.draft_hw.id}) + response = client.get(url) + self.assertTrue(response.context["data"].is_draft) + + +class PublishNowActionTests(DraftModeViewSetUpMixin): + """HomeworkDetailView publish_now POST action.""" + + def test_publish_now_publishes_draft(self): + client = Client() + client.login(username="teacher", password="pass") + url = reverse("homeworks:detail", kwargs={"homework_id": self.draft_hw.id}) + response = client.post(url, {"action": "publish_now"}) + self.assertEqual(response.status_code, 302) + self.draft_hw.refresh_from_db() + self.assertFalse(self.draft_hw.is_hidden) + self.assertEqual(self.draft_hw.homework_type, HomeworkType.PUBLISHED) + self.assertIsNone(self.draft_hw.publish_at) + + def test_publish_now_forbidden_for_student(self): + client = Client() + client.login(username="student", password="pass") + url = reverse("homeworks:detail", kwargs={"homework_id": self.draft_hw.id}) + response = client.post(url, {"action": "publish_now"}) + self.assertEqual(response.status_code, 403) + + def test_publish_now_on_another_teachers_homework_is_forbidden(self): + other_user = User.objects.create_user(username="other", password="pass") + other_teacher = Teacher.objects.create(user=other_user) + other_course = Course.objects.create(name="Other", code="OTH") + other_hw = Homework.objects.create( + title="Other Draft", + description="desc", + created_by=other_teacher, + course=other_course, + due_date=timezone.now() + timedelta(days=7), + homework_type=HomeworkType.DRAFT, + is_hidden=True, + ) + client = Client() + client.login(username="teacher", password="pass") + url = reverse("homeworks:detail", kwargs={"homework_id": other_hw.id}) + response = client.post(url, {"action": "publish_now"}) + self.assertEqual(response.status_code, 403) + + +class DraftSaveMinimalDataTests(DraftModeViewSetUpMixin): + """Draft saves with minimal data redirect instead of re-rendering.""" + + def test_create_draft_with_only_title_redirects(self): + """Saving a draft with just a title should succeed and redirect.""" + client = Client() + client.login(username="teacher", password="pass") + url = reverse("courses:homework-create", kwargs={"course_id": self.course.id}) + response = client.post( + url, + { + "save_draft": "1", + "title": "Untitled Draft", + "description": "", + "due_date": "", + "expires_at": "", + "is_hidden": "", + "publish_at": "", + "llm_config": "", + "sections-TOTAL_FORMS": "0", + "sections-INITIAL_FORMS": "0", + "sections-MIN_NUM_FORMS": "0", + "sections-MAX_NUM_FORMS": "1000", + }, + ) + self.assertEqual(response.status_code, 302) + from homeworks.models import HomeworkType + + hw = Homework.objects.filter(title="Untitled Draft").first() + self.assertIsNotNone(hw) + self.assertEqual(hw.homework_type, HomeworkType.DRAFT) + self.assertTrue(hw.is_hidden) + self.assertIsNone(hw.due_date) + + def test_edit_draft_save_redirects_to_detail(self): + """Saving draft on edit redirects to detail page, not the form.""" + client = Client() + client.login(username="teacher", password="pass") + url = reverse("homeworks:edit", kwargs={"homework_id": self.draft_hw.id}) + response = client.post( + url, + { + "save_draft": "1", + "title": "Updated Draft Title", + "description": "", + "due_date": "", + "expires_at": "", + "is_hidden": "", + "publish_at": "", + "llm_config": "", + "sections-TOTAL_FORMS": "0", + "sections-INITIAL_FORMS": "0", + "sections-MIN_NUM_FORMS": "0", + "sections-MAX_NUM_FORMS": "1000", + }, + ) + self.assertEqual(response.status_code, 302) + self.assertIn(str(self.draft_hw.id), response["Location"]) + + +class AutoPublishOnPageLoadTests(DraftModeViewSetUpMixin): + """Auto-publish fires when list/detail loads.""" + + def _make_scheduled_draft(self): + hw = Homework.objects.create( + title="Scheduled", + description="desc", + created_by=self.teacher, + course=self.course, + due_date=timezone.now() + timedelta(days=7), + homework_type=HomeworkType.DRAFT, + is_hidden=True, + publish_at=timezone.now() - timedelta(seconds=1), + ) + Section.objects.create(homework=hw, title="S1", content="content", order=1) + return hw + + def test_auto_publish_fires_on_list_view_load(self): + hw = self._make_scheduled_draft() + client = Client() + client.login(username="teacher", password="pass") + client.get(reverse("homeworks:list")) + hw.refresh_from_db() + self.assertEqual(hw.homework_type, HomeworkType.PUBLISHED) + self.assertFalse(hw.is_hidden) + + def test_auto_publish_fires_on_detail_view_load(self): + hw = self._make_scheduled_draft() + client = Client() + client.login(username="teacher", password="pass") + url = reverse("homeworks:detail", kwargs={"homework_id": hw.id}) + client.get(url) + hw.refresh_from_db() + self.assertEqual(hw.homework_type, HomeworkType.PUBLISHED) + self.assertFalse(hw.is_hidden) + + def test_scheduled_draft_stays_hidden_before_publish_at(self): + hw = Homework.objects.create( + title="Future", + description="desc", + created_by=self.teacher, + course=self.course, + due_date=timezone.now() + timedelta(days=7), + homework_type=HomeworkType.DRAFT, + is_hidden=True, + publish_at=timezone.now() + timedelta(hours=2), + ) + client = Client() + client.login(username="student", password="pass") + client.get(reverse("homeworks:list")) + hw.refresh_from_db() + self.assertEqual(hw.homework_type, HomeworkType.DRAFT) + self.assertTrue(hw.is_hidden) + + +class HomeworkEditPublishTests(DraftModeViewSetUpMixin): + """HomeworkEditView publish paths — publish_now and scheduled.""" + + def _section_management(self): + return { + "sections-TOTAL_FORMS": "1", + "sections-INITIAL_FORMS": "1", + "sections-MIN_NUM_FORMS": "0", + "sections-MAX_NUM_FORMS": "1000", + f"sections-0-id": str(Section.objects.filter(homework=self.published_hw).first().id), + "sections-0-title": "Section 1", + "sections-0-content": "Content", + "sections-0-order": "1", + "sections-0-solution": "", + } + + def _publish_post(self, **overrides): + base = { + "publish": "1", + "publish_now": "on", + "title": self.published_hw.title, + "description": self.published_hw.description, + "due_date": (timezone.now() + timedelta(days=7)).strftime("%Y-%m-%dT%H:%M"), + "expires_at": "", + "publish_at": "", + "llm_config": "", + **self._section_management(), + } + base.update(overrides) + return base + + def test_publish_now_sets_published_and_not_hidden(self): + client = Client() + client.login(username="teacher", password="pass") + url = reverse("homeworks:edit", kwargs={"homework_id": self.published_hw.id}) + response = client.post(url, self._publish_post()) + self.assertEqual(response.status_code, 302) + self.published_hw.refresh_from_db() + self.assertFalse(self.published_hw.is_hidden) + self.assertEqual(self.published_hw.homework_type, HomeworkType.PUBLISHED) + self.assertIsNone(self.published_hw.publish_at) + + def test_publish_now_clears_stale_expires_at(self): + """Re-publishing an expired homework should clear expires_at.""" + self.published_hw.expires_at = timezone.now() - timedelta(days=1) + self.published_hw.save() + client = Client() + client.login(username="teacher", password="pass") + url = reverse("homeworks:edit", kwargs={"homework_id": self.published_hw.id}) + response = client.post(url, self._publish_post()) + self.assertEqual(response.status_code, 302) + self.published_hw.refresh_from_db() + self.assertIsNone(self.published_hw.expires_at) + + def test_publish_now_does_not_clear_future_expires_at(self): + """A future expires_at submitted in the form is preserved when re-publishing.""" + future_expires = timezone.now() + timedelta(days=30) + self.published_hw.expires_at = future_expires + self.published_hw.save() + client = Client() + client.login(username="teacher", password="pass") + url = reverse("homeworks:edit", kwargs={"homework_id": self.published_hw.id}) + # Explicitly include expires_at in POST to preserve it + post = self._publish_post(expires_at=future_expires.strftime("%Y-%m-%dT%H:%M")) + response = client.post(url, post) + self.assertEqual(response.status_code, 302) + self.published_hw.refresh_from_db() + self.assertIsNotNone(self.published_hw.expires_at) + + def test_scheduled_publish_keeps_homework_as_draft(self): + """Clicking publish without publish_now + future publish_at → stays DRAFT.""" + future_publish = (timezone.now() + timedelta(days=2)).strftime("%Y-%m-%dT%H:%M") + post = self._publish_post(publish_at=future_publish) + del post["publish_now"] # uncheck the toggle + client = Client() + client.login(username="teacher", password="pass") + url = reverse("homeworks:edit", kwargs={"homework_id": self.draft_hw.id}) + # Need draft hw's section + section = Section.objects.filter(homework=self.draft_hw).first() + post["sections-0-id"] = str(section.id) + post["sections-0-title"] = "S1" + post["sections-0-content"] = "content" + post["sections-0-order"] = "1" + response = client.post(url, post) + self.assertEqual(response.status_code, 302) + self.draft_hw.refresh_from_db() + self.assertEqual(self.draft_hw.homework_type, HomeworkType.DRAFT) + self.assertTrue(self.draft_hw.is_hidden) + self.assertIsNotNone(self.draft_hw.publish_at) + + def test_scheduled_draft_not_visible_to_students(self): + """A scheduled draft (future publish_at) remains invisible to students.""" + self.draft_hw.publish_at = timezone.now() + timedelta(hours=2) + self.draft_hw.save() + client = Client() + client.login(username="student", password="pass") + url = reverse("homeworks:detail", kwargs={"homework_id": self.draft_hw.id}) + response = client.get(url) + self.assertEqual(response.status_code, 302) # inaccessible → redirect + + +class AutoPublishServiceTests(DraftModeViewSetUpMixin): + """HomeworkService.auto_publish_due_drafts edge cases.""" + + def test_returns_count_of_published_homeworks(self): + from homeworks.services import HomeworkService + # Create 2 more overdue drafts + for i in range(2): + Homework.objects.create( + title=f"Scheduled {i}", + description="", + created_by=self.teacher, + course=self.course, + due_date=timezone.now() + timedelta(days=7), + homework_type=HomeworkType.DRAFT, + is_hidden=True, + publish_at=timezone.now() - timedelta(seconds=1), + ) + count = HomeworkService.auto_publish_due_drafts() + self.assertEqual(count, 2) + + def test_does_not_touch_published_homeworks(self): + from homeworks.services import HomeworkService + HomeworkService.auto_publish_due_drafts() + self.published_hw.refresh_from_db() + self.assertFalse(self.published_hw.is_hidden) + self.assertEqual(self.published_hw.homework_type, HomeworkType.PUBLISHED) + + def test_does_not_touch_drafts_with_future_publish_at(self): + from homeworks.services import HomeworkService + future_draft = Homework.objects.create( + title="Future Draft", + description="", + created_by=self.teacher, + course=self.course, + due_date=timezone.now() + timedelta(days=7), + homework_type=HomeworkType.DRAFT, + is_hidden=True, + publish_at=timezone.now() + timedelta(hours=2), + ) + HomeworkService.auto_publish_due_drafts() + future_draft.refresh_from_db() + self.assertEqual(future_draft.homework_type, HomeworkType.DRAFT) + self.assertTrue(future_draft.is_hidden) + + def test_does_not_touch_hidden_type_homeworks(self): + """HIDDEN type (manually hidden, not draft) should not be auto-published.""" + from homeworks.services import HomeworkService + hidden_hw = Homework.objects.create( + title="Hidden", + description="", + created_by=self.teacher, + course=self.course, + due_date=timezone.now() + timedelta(days=7), + homework_type=HomeworkType.HIDDEN, + is_hidden=True, + publish_at=timezone.now() - timedelta(seconds=1), + ) + HomeworkService.auto_publish_due_drafts() + hidden_hw.refresh_from_db() + self.assertEqual(hidden_hw.homework_type, HomeworkType.HIDDEN) + + def test_publish_homework_idempotent(self): + """Calling publish_homework on an already-published homework is safe.""" + from homeworks.services import HomeworkService + result = HomeworkService.publish_homework(self.published_hw.id) + self.assertTrue(result.success) + self.published_hw.refresh_from_db() + self.assertFalse(self.published_hw.is_hidden) + self.assertEqual(self.published_hw.homework_type, HomeworkType.PUBLISHED) diff --git a/src/homeworks/tests/test_homework_expiry_model.py b/src/homeworks/tests/test_homework_expiry_model.py new file mode 100644 index 0000000..145fecc --- /dev/null +++ b/src/homeworks/tests/test_homework_expiry_model.py @@ -0,0 +1,99 @@ +""" +Tests for the Homework expiry and visibility model properties. + +Covers: +- is_expired property +- is_accessible_to_students property +- Default field values +""" + +from datetime import timedelta + +from django.test import TestCase +from django.utils import timezone + +from accounts.models import Teacher +from courses.models import Course +from django.contrib.auth import get_user_model +from homeworks.models import Homework + +User = get_user_model() + + +class HomeworkExpiryModelTests(TestCase): + """Tests for Homework.is_expired and is_accessible_to_students.""" + + def setUp(self): + user = User.objects.create_user(username="teacher", password="pass") + self.teacher = Teacher.objects.create(user=user) + self.course = Course.objects.create(name="Course", code="C1") + self.base_data = { + "title": "HW", + "description": "desc", + "created_by": self.teacher, + "course": self.course, + "due_date": timezone.now() + timedelta(days=7), + } + + def _make(self, **kwargs): + data = {**self.base_data, **kwargs} + return Homework.objects.create(**data) + + # --- defaults --- + + def test_expires_at_defaults_to_null(self): + hw = self._make() + self.assertIsNone(hw.expires_at) + + def test_is_hidden_defaults_to_false(self): + hw = self._make() + self.assertFalse(hw.is_hidden) + + # --- is_expired --- + + def test_is_expired_false_when_expires_at_is_null(self): + hw = self._make() + self.assertFalse(hw.is_expired) + + def test_is_expired_false_when_expires_at_is_in_future(self): + hw = self._make(expires_at=timezone.now() + timedelta(days=3)) + self.assertFalse(hw.is_expired) + + def test_is_expired_true_when_expires_at_is_in_past(self): + hw = self._make(expires_at=timezone.now() - timedelta(seconds=1)) + self.assertTrue(hw.is_expired) + + # --- is_accessible_to_students --- + + def test_accessible_by_default(self): + hw = self._make() + self.assertTrue(hw.is_accessible_to_students) + + def test_not_accessible_when_is_hidden_true(self): + hw = self._make(is_hidden=True) + self.assertFalse(hw.is_accessible_to_students) + + def test_not_accessible_when_expired(self): + hw = self._make(expires_at=timezone.now() - timedelta(seconds=1)) + self.assertFalse(hw.is_accessible_to_students) + + def test_not_accessible_when_hidden_and_expired(self): + hw = self._make( + is_hidden=True, + expires_at=timezone.now() - timedelta(seconds=1), + ) + self.assertFalse(hw.is_accessible_to_students) + + def test_accessible_when_not_hidden_and_future_expiry(self): + hw = self._make( + is_hidden=False, + expires_at=timezone.now() + timedelta(days=1), + ) + self.assertTrue(hw.is_accessible_to_students) + + def test_not_accessible_when_hidden_despite_future_expiry(self): + hw = self._make( + is_hidden=True, + expires_at=timezone.now() + timedelta(days=1), + ) + self.assertFalse(hw.is_accessible_to_students) diff --git a/src/homeworks/tests/test_homework_expiry_views.py b/src/homeworks/tests/test_homework_expiry_views.py new file mode 100644 index 0000000..cad724e --- /dev/null +++ b/src/homeworks/tests/test_homework_expiry_views.py @@ -0,0 +1,370 @@ +""" +Tests for homework expiry/visibility enforcement in views. + +Covers: +- HomeworkListView: expired/hidden homeworks hidden from students, visible to teachers +- HomeworkDetailView: students blocked when inaccessible; teachers always see it +- SectionDetailView: students blocked when homework is inaccessible +- HomeworkForm validation: expires_at must be after due_date +""" + +from datetime import timedelta + +from django.test import TestCase, RequestFactory +from django.urls import reverse +from django.utils import timezone +from django.contrib.auth import get_user_model + +from accounts.models import Teacher, Student +from courses.models import Course, CourseEnrollment, CourseTeacher +from homeworks.models import Homework, Section +from homeworks.views import HomeworkListView +from homeworks.forms import HomeworkCreateForm, HomeworkEditForm + +User = get_user_model() + + +class HomeworkExpiryViewSetUpMixin(TestCase): + """Shared setUp for expiry view tests.""" + + def setUp(self): + self.factory = RequestFactory() + + # Teacher + teacher_user = User.objects.create_user( + username="teacher", email="t@test.com", password="pass" + ) + self.teacher = Teacher.objects.create(user=teacher_user) + self.teacher_user = teacher_user + + # Student + student_user = User.objects.create_user( + username="student", email="s@test.com", password="pass" + ) + self.student = Student.objects.create(user=student_user) + self.student_user = student_user + + # Course + self.course = Course.objects.create(name="Course", code="C1", is_active=True) + CourseTeacher.objects.create( + course=self.course, teacher=self.teacher, role="owner" + ) + CourseEnrollment.objects.create( + course=self.course, student=self.student, is_active=True + ) + + # Visible homework (control) + self.visible_hw = Homework.objects.create( + title="Visible", + description="desc", + created_by=self.teacher, + course=self.course, + due_date=timezone.now() + timedelta(days=7), + ) + + # Hidden homework (manual toggle) + self.hidden_hw = Homework.objects.create( + title="Hidden", + description="desc", + created_by=self.teacher, + course=self.course, + due_date=timezone.now() + timedelta(days=7), + is_hidden=True, + ) + + # Expired homework + self.expired_hw = Homework.objects.create( + title="Expired", + description="desc", + created_by=self.teacher, + course=self.course, + due_date=timezone.now() - timedelta(days=14), + expires_at=timezone.now() - timedelta(days=1), + ) + + # Section for visible homework + self.section = Section.objects.create( + homework=self.visible_hw, + title="Section 1", + content="content", + order=1, + ) + + # Section for expired homework + self.expired_section = Section.objects.create( + homework=self.expired_hw, + title="Section 1", + content="content", + order=1, + ) + + +# ─── HomeworkListView ───────────────────────────────────────────────────────── + + +class HomeworkListViewExpiryTests(HomeworkExpiryViewSetUpMixin): + def _get_student_list_data(self): + request = self.factory.get("/homeworks/") + request.user = self.student_user + view = HomeworkListView() + return view._get_view_data(self.student_user) + + def _get_teacher_list_data(self): + request = self.factory.get("/homeworks/") + request.user = self.teacher_user + view = HomeworkListView() + return view._get_view_data(self.teacher_user) + + def test_student_does_not_see_hidden_homework(self): + data = self._get_student_list_data() + titles = [hw.title for hw in data.homeworks] + self.assertNotIn("Hidden", titles) + + def test_student_does_not_see_expired_homework(self): + data = self._get_student_list_data() + titles = [hw.title for hw in data.homeworks] + self.assertNotIn("Expired", titles) + + def test_student_sees_visible_homework(self): + data = self._get_student_list_data() + titles = [hw.title for hw in data.homeworks] + self.assertIn("Visible", titles) + + def test_teacher_sees_all_homeworks_including_hidden(self): + data = self._get_teacher_list_data() + titles = [hw.title for hw in data.homeworks] + self.assertIn("Hidden", titles) + + def test_teacher_sees_all_homeworks_including_expired(self): + data = self._get_teacher_list_data() + titles = [hw.title for hw in data.homeworks] + self.assertIn("Expired", titles) + + def test_teacher_list_item_exposes_is_hidden_flag(self): + data = self._get_teacher_list_data() + hidden_item = next(hw for hw in data.homeworks if hw.title == "Hidden") + self.assertTrue(hidden_item.is_hidden) + + def test_teacher_list_item_exposes_is_accessible_to_students_false_for_hidden(self): + data = self._get_teacher_list_data() + hidden_item = next(hw for hw in data.homeworks if hw.title == "Hidden") + self.assertFalse(hidden_item.is_accessible_to_students) + + def test_teacher_list_item_exposes_expires_at(self): + data = self._get_teacher_list_data() + expired_item = next(hw for hw in data.homeworks if hw.title == "Expired") + self.assertIsNotNone(expired_item.expires_at) + + +# ─── HomeworkDetailView ─────────────────────────────────────────────────────── + + +class HomeworkDetailViewExpiryTests(HomeworkExpiryViewSetUpMixin): + def test_student_cannot_access_hidden_homework_detail(self): + self.client.login(username="student", password="pass") + url = reverse("homeworks:detail", kwargs={"homework_id": self.hidden_hw.id}) + response = self.client.get(url) + # Returns redirect (to list) when data is None + self.assertEqual(response.status_code, 302) + + def test_student_cannot_access_expired_homework_detail(self): + self.client.login(username="student", password="pass") + url = reverse("homeworks:detail", kwargs={"homework_id": self.expired_hw.id}) + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + + def test_student_can_access_visible_homework_detail(self): + self.client.login(username="student", password="pass") + url = reverse("homeworks:detail", kwargs={"homework_id": self.visible_hw.id}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_teacher_can_access_hidden_homework_detail(self): + self.client.login(username="teacher", password="pass") + url = reverse("homeworks:detail", kwargs={"homework_id": self.hidden_hw.id}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_teacher_can_access_expired_homework_detail(self): + self.client.login(username="teacher", password="pass") + url = reverse("homeworks:detail", kwargs={"homework_id": self.expired_hw.id}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_teacher_detail_data_includes_visibility_fields(self): + self.client.login(username="teacher", password="pass") + url = reverse("homeworks:detail", kwargs={"homework_id": self.hidden_hw.id}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + data = response.context["data"] + self.assertTrue(data.is_hidden) + self.assertFalse(data.is_accessible_to_students) + + +# ─── SectionDetailView ──────────────────────────────────────────────────────── + + +class SectionDetailViewExpiryTests(HomeworkExpiryViewSetUpMixin): + def test_student_blocked_from_section_of_expired_homework(self): + self.client.login(username="student", password="pass") + url = reverse( + "homeworks:section_detail", + kwargs={ + "homework_id": self.expired_hw.id, + "section_id": self.expired_section.id, + }, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + def test_student_blocked_from_section_of_hidden_homework(self): + # Add a section to hidden_hw + hidden_section = Section.objects.create( + homework=self.hidden_hw, + title="Section 1", + content="content", + order=1, + ) + self.client.login(username="student", password="pass") + url = reverse( + "homeworks:section_detail", + kwargs={"homework_id": self.hidden_hw.id, "section_id": hidden_section.id}, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + def test_student_can_access_section_of_visible_homework(self): + self.client.login(username="student", password="pass") + url = reverse( + "homeworks:section_detail", + kwargs={"homework_id": self.visible_hw.id, "section_id": self.section.id}, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_teacher_can_access_section_of_expired_homework(self): + self.client.login(username="teacher", password="pass") + url = reverse( + "homeworks:section_detail", + kwargs={ + "homework_id": self.expired_hw.id, + "section_id": self.expired_section.id, + }, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + +# ─── Form validation ────────────────────────────────────────────────────────── + + +class HomeworkFormExpiryValidationTests(TestCase): + def setUp(self): + user = User.objects.create_user(username="t", password="p") + self.teacher = Teacher.objects.create(user=user) + self.course = Course.objects.create(name="C", code="C1") + + def _base_data(self, **overrides): + data = { + "title": "HW", + "description": "desc", + "course": self.course.id, + "due_date": (timezone.now() + timedelta(days=3)).strftime("%Y-%m-%dT%H:%M"), + "expires_at": (timezone.now() + timedelta(days=10)).strftime( + "%Y-%m-%dT%H:%M" + ), + "is_hidden": False, + } + data.update(overrides) + return data + + def test_create_form_valid_when_expires_at_after_due_date(self): + form = HomeworkCreateForm(data=self._base_data()) + self.assertTrue(form.is_valid(), form.errors) + + def test_create_form_warns_when_expires_at_before_due_date(self): + """When expires_at < due_date, form is valid and expires_at_adjusted flag is set as warning.""" + due = timezone.now() + timedelta(days=3) + data = self._base_data( + due_date=due.strftime("%Y-%m-%dT%H:%M"), + expires_at=(timezone.now() + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M"), + ) + form = HomeworkCreateForm(data=data) + self.assertTrue(form.is_valid(), form.errors) + self.assertTrue(form.expires_at_adjusted) + + def test_create_form_no_warning_when_expires_at_equals_due_date(self): + """When expires_at == due_date, form is valid with no warning (equal is allowed).""" + due = timezone.now() + timedelta(days=3) + data = self._base_data( + due_date=due.strftime("%Y-%m-%dT%H:%M"), + expires_at=due.strftime("%Y-%m-%dT%H:%M"), + ) + form = HomeworkCreateForm(data=data) + self.assertTrue(form.is_valid(), form.errors) + self.assertFalse(form.expires_at_adjusted) + + def test_create_form_valid_with_no_expires_at(self): + data = self._base_data(expires_at="") + form = HomeworkCreateForm(data=data) + self.assertTrue(form.is_valid(), form.errors) + + def test_edit_form_valid_when_expires_at_after_due_date(self): + hw = Homework.objects.create( + title="HW", + description="desc", + created_by=self.teacher, + course=self.course, + due_date=timezone.now() + timedelta(days=7), + ) + form = HomeworkEditForm(data=self._base_data(), instance=hw) + self.assertTrue(form.is_valid(), form.errors) + + def test_edit_form_warns_when_expires_at_before_due_date(self): + """When expires_at < due_date, edit form is valid and expires_at_adjusted warning is set.""" + hw = Homework.objects.create( + title="HW", + description="desc", + created_by=self.teacher, + course=self.course, + due_date=timezone.now() + timedelta(days=7), + ) + data = self._base_data( + expires_at=(timezone.now() + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M") + ) + form = HomeworkEditForm(data=data, instance=hw) + self.assertTrue(form.is_valid(), form.errors) + self.assertTrue(form.expires_at_adjusted) + + def test_edit_form_allows_past_due_date(self): + """Edit form accepts any due_date — no restriction.""" + hw = Homework.objects.create( + title="HW", + description="desc", + created_by=self.teacher, + course=self.course, + due_date=timezone.now() + timedelta(days=7), + ) + data = self._base_data( + due_date=(timezone.now() - timedelta(days=3)).strftime("%Y-%m-%dT%H:%M"), + expires_at="", + ) + form = HomeworkEditForm(data=data, instance=hw) + self.assertTrue(form.is_valid(), form.errors) + + def test_create_form_allows_today_as_due_date(self): + """Create form allows due_date set to today.""" + data = self._base_data( + due_date=timezone.now().strftime("%Y-%m-%dT%H:%M"), + ) + form = HomeworkCreateForm(data=data) + self.assertTrue(form.is_valid(), form.errors) + + def test_create_form_rejects_past_due_date(self): + """Create form rejects a due_date strictly in the past (yesterday).""" + data = self._base_data( + due_date=(timezone.now() - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M"), + ) + form = HomeworkCreateForm(data=data) + self.assertFalse(form.is_valid()) + self.assertIn("due_date", form.errors) diff --git a/src/homeworks/views.py b/src/homeworks/views.py index 9933d64..1984b4f 100644 --- a/src/homeworks/views.py +++ b/src/homeworks/views.py @@ -6,7 +6,7 @@ """ from dataclasses import dataclass -from typing import TYPE_CHECKING, Dict, Any, assert_type +from typing import TYPE_CHECKING, Dict, Any, assert_type, cast from uuid import UUID from django.forms import formset_factory @@ -39,6 +39,16 @@ logger = logging.getLogger(__name__) +def _mark_invalid_fields(form) -> None: + """Add Bootstrap is-invalid CSS class to any form widget that has errors.""" + for field_name in form.errors: + if field_name in form.fields: + widget = form.fields[field_name].widget + css = widget.attrs.get("class", "") + if "is-invalid" not in css: + widget.attrs["class"] = f"{css} is-invalid".strip() + + @dataclass class HomeworkListItem: """Data structure for a single homework item in the list view.""" @@ -50,6 +60,11 @@ class HomeworkListItem: section_count: int created_at: Any # datetime is_overdue: bool + expires_at: Any = None # datetime or None + is_hidden: bool = False + is_accessible_to_students: bool = True + is_draft: bool = False + publish_at: Any = None # datetime or None sections: list[SectionData] | None = None completed_percentage: int = 0 in_progress_percentage: int = 0 @@ -108,6 +123,12 @@ def _get_view_data(self, user) -> HomeworkListData: # Teacher view - show homeworks from courses this teacher teaches user_type = "teacher" + # Auto-publish any scheduled drafts before building the list + try: + HomeworkService.auto_publish_due_drafts() + except Exception: + pass # Never break the page load + # Get courses where this teacher is teaching teacher_courses = teacher_profile.courses.all() @@ -135,6 +156,11 @@ def _get_view_data(self, user) -> HomeworkListData: section_count=homework.section_count, created_at=homework.created_at, is_overdue=homework.is_overdue, + expires_at=homework.expires_at, + is_hidden=homework.is_hidden, + is_accessible_to_students=homework.is_accessible_to_students, + is_draft=homework.is_draft, + publish_at=homework.publish_at, sections=None, # No section data needed for teacher view ) ) @@ -149,9 +175,14 @@ def _get_view_data(self, user) -> HomeworkListData: courseenrollment__is_active=True ) - # Get homeworks for courses student is enrolled in (direct FK now) + # Get homeworks for courses student is enrolled in (direct FK now), + # excluding homeworks that are hidden or have expired. + from django.db.models import Q + homework_objects = ( Homework.objects.filter(course__in=enrolled_courses) + .filter(is_hidden=False) + .filter(Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now())) .order_by("-created_at") .prefetch_related("sections") ) @@ -254,19 +285,26 @@ class HomeworkDetailData: is_overdue: bool user_type: str # 'teacher', 'student', or 'unknown' can_edit: bool + expires_at: Any = None # datetime or None + is_hidden: bool = False + is_accessible_to_students: bool = True + is_draft: bool = False + publish_at: Any = None # datetime or None llm_config: Dict[str, Any] | None = None @dataclass class HomeworkFormData: - """Data structure for the homework form view.""" + """Data structure for the homework form view (create and edit).""" - form: "HomeworkEditForm" + form: Any # HomeworkCreateForm or HomeworkEditForm section_forms: "SectionFormSet" user_type: str action: str # 'create' or 'edit' is_submitted: bool = False errors: Dict[str, Any] | None = None + course_name: str = "" + course_id: Any = None # UUID or None class HomeworkEditView(View): @@ -343,6 +381,11 @@ def post(self, request: TeacherRequest, homework_id: UUID) -> HttpResponse: if data.is_submitted: messages.success(request, "Homework updated successfully!") + if getattr(data.form, "expires_at_adjusted", False): + messages.warning( + request, + "Note: the expiry date is set before the due date. Students will lose access before the homework is officially due.", + ) return redirect("homeworks:detail", homework_id=homework_id) return render(request, "homeworks/form.html", {"data": data}) @@ -370,8 +413,9 @@ def _get_view_data( initial_section_data.append(section_data) # Create section formset with initial data - SectionFormset: type[SectionFormSet] = formset_factory( - SectionForm, extra=0, formset=SectionFormSet + SectionFormset = cast( + type[SectionFormSet], + formset_factory(SectionForm, extra=0, formset=SectionFormSet), ) section_formset = SectionFormset( prefix="sections", initial=initial_section_data @@ -390,23 +434,69 @@ def _get_view_data( def _process_form_submission( self, request: TeacherRequest, homework: Homework ) -> HomeworkFormData: - """Process the form submission for updating a homework.""" - # Create forms from POST data with homework instance + """Process the form submission for updating a homework. + + The submit button name determines the publish action: + name="save_draft" → keep/set as draft (is_hidden=True, homework_type=draft) + name="publish" → publish immediately (is_hidden=False, homework_type=published) + anything else → preserve existing homework_type + """ + is_draft_save = "save_draft" in request.POST + + # Draft save: bypass all validation, update only the raw text fields + if is_draft_save: + from .models import HomeworkType + + homework.title = request.POST.get("title") or homework.title + homework.description = request.POST.get("description") or homework.description + homework.homework_type = HomeworkType.DRAFT + homework.is_hidden = True + homework.save(update_fields=["title", "description", "homework_type", "is_hidden", "updated_at"]) + return HomeworkFormData( + form=HomeworkEditForm(instance=homework), + section_forms=None, # type: ignore[arg-type] + user_type="teacher", + action="edit", + is_submitted=True, + ) + + # Publish path: run full validation form = HomeworkEditForm(request.POST, instance=homework) # Create formset for sections - SectionFormset: type[SectionFormSet] = formset_factory( - SectionForm, extra=0, formset=SectionFormSet + SectionFormset = cast( + type[SectionFormSet], + formset_factory(SectionForm, extra=0, formset=SectionFormSet), ) section_formset = SectionFormset(request.POST, prefix="sections") assert_type(section_formset, SectionFormSet) - # Check form validity + # Check form validity (publish path only) if form.is_valid() and section_formset.is_valid(): - # Save basic homework data - homework = form.save() + from .models import HomeworkType + + publish_now = "publish_now" in request.POST + homework_instance = form.save(commit=False) + + if publish_now: + homework_instance.homework_type = HomeworkType.PUBLISHED + homework_instance.is_hidden = False + homework_instance.publish_at = None + # Clear expires_at if it has already passed (re-publish expired homework) + if ( + homework_instance.expires_at + and homework_instance.expires_at <= timezone.now() + ): + homework_instance.expires_at = None + else: + # Scheduled publish: keep as draft until auto_publish_due_drafts runs + homework_instance.homework_type = HomeworkType.DRAFT + homework_instance.is_hidden = True + # publish_at already set from form.save() + + homework = homework_instance + homework.save() - # Process sections sections_to_update = [] sections_to_create = [] sections_to_delete = [] @@ -416,24 +506,19 @@ def _process_form_submission( continue if section_form.cleaned_data.get("DELETE", False): - # Section marked for deletion if section_form.cleaned_data.get("id"): sections_to_delete.append(section_form.cleaned_data["id"]) else: - # Get section data section_data = { "title": section_form.cleaned_data["title"], "content": section_form.cleaned_data["content"], "order": section_form.cleaned_data["order"], "solution": section_form.cleaned_data["solution"], } - if section_form.cleaned_data.get("id"): - # Existing section to update section_data["id"] = section_form.cleaned_data["id"] sections_to_update.append(section_data) else: - # New section to create sections_to_create.append( SectionCreateData( title=section_data["title"], @@ -443,7 +528,6 @@ def _process_form_submission( ) ) - # Create update data update_data = HomeworkUpdateData( title=homework.title, description=homework.description, @@ -454,11 +538,9 @@ def _process_form_submission( sections_to_delete=sections_to_delete, ) - # Update homework using service result = HomeworkService.update_homework(homework.id, update_data) if result.success: - # Return success data return HomeworkFormData( form=form, section_forms=section_formset, @@ -467,10 +549,11 @@ def _process_form_submission( is_submitted=True, ) else: - # Service error messages.error(request, f"Error updating homework: {result.error}") - # Form validation error or service error + # Highlight fields with errors and re-render + _mark_invalid_fields(form) + errors: dict[str, ErrorDict | list[ErrorList]] = {} if form.errors: errors["homework"] = form.errors @@ -479,7 +562,6 @@ def _process_form_submission( if section_formset.non_form_errors(): errors["formset"] = [section_formset.non_form_errors()] - # Return form data with errors return HomeworkFormData( form=form, section_forms=section_formset, @@ -516,16 +598,48 @@ def get(self, request: HttpRequest, homework_id: UUID) -> HttpResponse: return render(request, "homeworks/detail.html", {"data": data}) def post(self, request: HttpRequest, homework_id: UUID) -> HttpResponse: - """Handle POST requests for homework actions (like deletion).""" - # Check if this is a delete action + """Handle POST requests for homework actions (like deletion or publishing).""" action = request.POST.get("action") if action == "delete": return self._handle_delete(request, homework_id) + if action == "publish_now": + return self._handle_publish_now(request, homework_id) + # If no valid action, redirect to detail view return redirect("homeworks:detail", homework_id=homework_id) + def _handle_publish_now( + self, request: HttpRequest, homework_id: UUID + ) -> HttpResponse: + """Immediately publish a draft homework.""" + teacher_profile = getattr(request.user, "teacher_profile", None) + if not teacher_profile: + return HttpResponseForbidden("Only teachers can publish homeworks.") + + try: + homework = Homework.objects.get(id=homework_id) + created_by_teacher = homework.created_by == teacher_profile + teaches_course = False + if homework.course: + teaches_course = homework.course in teacher_profile.courses.all() + if not (created_by_teacher or teaches_course): + return HttpResponseForbidden( + "You don't have permission to publish this homework." + ) + except Homework.DoesNotExist: + messages.error(request, "Homework not found.") + return redirect("homeworks:list") + + result = HomeworkService.publish_homework(homework_id) + if result.success: + messages.success(request, f"'{homework.title}' has been published.") + else: + messages.error(request, "Failed to publish homework. Please try again.") + + return redirect("homeworks:detail", homework_id=homework_id) + def _handle_delete(self, request: HttpRequest, homework_id: UUID) -> HttpResponse: """Handle homework deletion.""" # Check if user is a teacher @@ -577,11 +691,17 @@ def _get_view_data(self, user, homework_id: UUID) -> HomeworkDetailData | None: Returns: HomeworkDetailData with homework details, or None if not found or access denied """ + # Auto-publish any scheduled drafts before access checks + try: + HomeworkService.auto_publish_due_drafts() + except Exception: + pass # Never break the page load + # Determine user type teacher_profile = getattr(user, "teacher_profile", None) student_profile = getattr(user, "student_profile", None) - # For students, check if they're enrolled in the course that has this homework + # For students, check enrollment and visibility if student_profile: homework = Homework.objects.filter(id=homework_id).first() if homework and homework.course: @@ -592,6 +712,9 @@ def _get_view_data(self, user, homework_id: UUID) -> HomeworkDetailData | None: if not has_access: return None + + if not homework.is_accessible_to_students: + return None else: return None @@ -673,6 +796,9 @@ def _get_view_data(self, user, homework_id: UUID) -> HomeworkDetailData | None: else "Unknown Teacher" ) + # Fetch the raw homework object for expiry fields (may already be fetched above) + homework_obj_for_expiry = Homework.objects.filter(id=homework_id).first() + # Create and return the view data return HomeworkDetailData( id=homework_detail.id, @@ -683,9 +809,24 @@ def _get_view_data(self, user, homework_id: UUID) -> HomeworkDetailData | None: created_by_name=created_by_name, created_at=homework_detail.created_at, sections=sections, - is_overdue=homework_detail.due_date < timezone.now(), + is_overdue=homework_detail.due_date is not None and homework_detail.due_date < timezone.now(), user_type=user_type, can_edit=can_edit, + expires_at=homework_obj_for_expiry.expires_at + if homework_obj_for_expiry + else None, + is_hidden=homework_obj_for_expiry.is_hidden + if homework_obj_for_expiry + else False, + is_accessible_to_students=homework_obj_for_expiry.is_accessible_to_students + if homework_obj_for_expiry + else True, + is_draft=homework_obj_for_expiry.is_draft + if homework_obj_for_expiry + else False, + publish_at=homework_obj_for_expiry.publish_at + if homework_obj_for_expiry + else None, llm_config={"id": homework_detail.llm_config} if homework_detail.llm_config else None, @@ -751,7 +892,7 @@ def get( if not (created_by_teacher or teaches_course_with_homework): return HttpResponseForbidden("Access denied.") - # For students, check if they're enrolled in the course that has this homework + # For students, check enrollment and visibility if student_profile: if homework.course: is_enrolled = homework.course.is_student_enrolled(student_profile) @@ -759,6 +900,10 @@ def get( return HttpResponseForbidden( "Access denied. You are not enrolled in the course that has this homework." ) + if not homework.is_accessible_to_students: + return HttpResponseForbidden( + "This assignment is no longer available." + ) else: return HttpResponseForbidden( "Access denied. This homework is not assigned to a course." diff --git a/src/llm/services.py b/src/llm/services.py index 2dfc4d5..488f527 100644 --- a/src/llm/services.py +++ b/src/llm/services.py @@ -385,18 +385,20 @@ def _generate_openai_response( function_calls = [] if hasattr(choice.message, "tool_calls") and choice.message.tool_calls: for tool_call in choice.message.tool_calls: + if not hasattr(tool_call, "function"): + continue try: - arguments = json.loads(tool_call.function.arguments) + arguments = json.loads(tool_call.function.arguments) # type: ignore[union-attr] function_calls.append( FunctionCall( id=tool_call.id, - name=tool_call.function.name, + name=tool_call.function.name, # type: ignore[union-attr] arguments=arguments, ) ) except json.JSONDecodeError as e: logger.error( - f"Failed to parse function arguments: {tool_call.function.arguments}, error: {e}" + f"Failed to parse function arguments: {tool_call.function.arguments}, error: {e}" # type: ignore[union-attr] ) # Determine finish reason @@ -878,8 +880,8 @@ def _stream_with_finish_reason_detection( tool_calls_accumulator = {} # Accumulate tool call deltas by index for chunk in stream: - if chunk.choices and len(chunk.choices) > 0: - choice = chunk.choices[0] + if chunk.choices and len(chunk.choices) > 0: # type: ignore[union-attr] + choice = chunk.choices[0] # type: ignore[union-attr] # Extract token content if hasattr(choice.delta, "content") and choice.delta.content: diff --git a/src/llteacher/management/commands/populate_test_database.py b/src/llteacher/management/commands/populate_test_database.py index f82a64b..c4b69e4 100644 --- a/src/llteacher/management/commands/populate_test_database.py +++ b/src/llteacher/management/commands/populate_test_database.py @@ -8,6 +8,7 @@ from llm.models import LLMConfig from homeworks.models import Homework, Section, SectionSolution from conversations.models import Conversation, Message, Submission +from courses.models import Course, CourseTeacher, CourseEnrollment User = get_user_model() @@ -36,8 +37,11 @@ def handle(self, *args, **options): # Create LLM configuration llm_config = self.create_llm_config() + # Create courses + courses = self.create_courses(users["teachers"], users["students"]) + # Create homeworks with sections - homeworks = self.create_homeworks(users["teachers"], llm_config) + homeworks = self.create_homeworks(users["teachers"], llm_config, courses) # Create conversations and messages self.create_conversations_and_messages(users["students"], homeworks) @@ -53,6 +57,9 @@ def reset_database(self): SectionSolution.objects.all().delete() Section.objects.all().delete() Homework.objects.all().delete() + CourseEnrollment.objects.all().delete() + CourseTeacher.objects.all().delete() + Course.objects.all().delete() LLMConfig.objects.all().delete() Teacher.objects.all().delete() Student.objects.all().delete() @@ -161,7 +168,32 @@ def create_llm_config(self): self.stdout.write(" ✓ Created LLM configuration") return llm_config - def create_homeworks(self, teachers, llm_config): + def create_courses(self, teachers, students): + """Create sample courses and enroll users.""" + self.stdout.write("Creating courses...") + + course1 = Course.objects.create( + name="Introduction to Python", + description="A beginner course on Python programming.", + code="CS101", + ) + CourseTeacher.objects.create(course=course1, teacher=teachers[0], role="owner") + for student in students: + CourseEnrollment.objects.create(course=course1, student=student) + + course2 = Course.objects.create( + name="Data Analysis with Python", + description="Learn data analysis techniques using Python.", + code="CS201", + ) + CourseTeacher.objects.create(course=course2, teacher=teachers[1], role="owner") + for student in students: + CourseEnrollment.objects.create(course=course2, student=student) + + self.stdout.write(" ✓ Created 2 courses") + return [course1, course2] + + def create_homeworks(self, teachers, llm_config, courses): """Create sample homeworks with sections.""" self.stdout.write("Creating homeworks and sections...") @@ -172,6 +204,7 @@ def create_homeworks(self, teachers, llm_config): title="Python Basics", description="Introduction to Python programming fundamentals including variables, data types, and control structures.", created_by=teachers[0], + course=courses[0], due_date=timezone.now() + timedelta(days=7), llm_config=llm_config, ) @@ -317,6 +350,7 @@ def calculate_average(numbers): title="Data Analysis with Python", description="Learn to analyze data using Python lists and dictionaries. Practice with real-world data scenarios.", created_by=teachers[1], + course=courses[1], due_date=timezone.now() + timedelta(days=10), llm_config=llm_config, ) diff --git a/src/llteacher/test_settings.py b/src/llteacher/test_settings.py index fe6d53a..873502c 100644 --- a/src/llteacher/test_settings.py +++ b/src/llteacher/test_settings.py @@ -3,6 +3,8 @@ This file inherits from the main settings and overrides specific settings for testing. """ +from typing import Any, List, cast + from .settings import * # noqa: F403 # Use in-memory database for testing (faster and isolated) @@ -78,7 +80,7 @@ ] # Disable template debugging for faster tests -TEMPLATES[0]["OPTIONS"]["debug"] = False # noqa: F405 +cast(List[Any], TEMPLATES)[0]["OPTIONS"]["debug"] = False # noqa: F405 # Use faster timezone for tests USE_TZ = False