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/views.py b/src/courses/views.py index 671b01a..eb21fce 100644 --- a/src/courses/views.py +++ b/src/courses/views.py @@ -6,7 +6,7 @@ """ from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from uuid import UUID from django.views import View from django.http import HttpRequest, HttpResponse, HttpResponseForbidden @@ -291,7 +291,7 @@ def get(self, request: HttpRequest, course_id: UUID) -> HttpResponse: has_access = course.is_student_enrolled(student_profile) user_type = "student" - if not has_access: + if not has_access or user_type is None: return HttpResponseForbidden("You do not have access to this course.") # Get the appropriate data based on user type @@ -437,7 +437,10 @@ def _get_view_data( form = HomeworkCreateForm(initial={"course": course}) # Create empty section form (we'll start with one) - SectionFormset = formset_factory(SectionForm, extra=1, formset=SectionFormSet) + SectionFormset = cast( + "type[SectionFormSet]", + formset_factory(SectionForm, extra=1, formset=SectionFormSet), + ) section_formset = SectionFormset(prefix="sections") # Return form data @@ -464,12 +467,15 @@ def _process_form_submission( # Create a mutable copy of POST data and inject course post_data = request.POST.copy() - post_data["course"] = course.id + post_data["course"] = str(course.id) # Create forms from POST data form = HomeworkCreateForm(post_data) - SectionFormset = formset_factory(SectionForm, extra=0, formset=SectionFormSet) + SectionFormset = cast( + "type[SectionFormSet]", + formset_factory(SectionForm, extra=0, formset=SectionFormSet), + ) section_formset = SectionFormset(request.POST, prefix="sections") # Check form validity diff --git a/src/homeworks/admin.py b/src/homeworks/admin.py index d722d41..cdd2f62 100644 --- a/src/homeworks/admin.py +++ b/src/homeworks/admin.py @@ -1,6 +1,24 @@ from django.contrib import admin from .models import Homework, Section, SectionSolution -admin.site.register(Homework) + +@admin.register(Homework) +class HomeworkAdmin(admin.ModelAdmin): + list_display = ( + "title", + "course", + "due_date", + "expires_at", + "is_hidden", + "accessible_to_students", + ) + list_filter = ("is_hidden", "course") + readonly_fields = ("accessible_to_students",) + + @admin.display(boolean=True, description="Accessible to students") + def accessible_to_students(self, obj): + return obj.is_accessible_to_students + + admin.site.register(Section) admin.site.register(SectionSolution) diff --git a/src/homeworks/forms.py b/src/homeworks/forms.py index fa7c841..31af643 100644 --- a/src/homeworks/forms.py +++ b/src/homeworks/forms.py @@ -6,11 +6,28 @@ """ from django import forms +from django.conf import settings from django.utils import timezone from .models import Homework +def _to_local_str(dt) -> str: + """Convert a datetime to a local datetime-local input string.""" + if settings.USE_TZ and timezone.is_naive(dt): + dt = timezone.make_aware(dt) + if settings.USE_TZ: + return timezone.localtime(dt).strftime("%Y-%m-%dT%H:%M") + return dt.strftime("%Y-%m-%dT%H:%M") + + +def _make_aware_if_naive(dt): + """Make a naive datetime timezone-aware when USE_TZ is active.""" + if dt is not None and settings.USE_TZ and timezone.is_naive(dt): + return timezone.make_aware(dt) + return dt + + class SectionForm(forms.Form): """Form for creating or editing a section.""" @@ -54,7 +71,15 @@ class HomeworkCreateForm(forms.ModelForm): class Meta: model = Homework - fields = ["title", "description", "course", "due_date", "llm_config"] + fields = [ + "title", + "description", + "course", + "due_date", + "expires_at", + "is_hidden", + "llm_config", + ] widgets = { "title": forms.TextInput( attrs={"class": "form-control", "placeholder": "Homework Title"} @@ -76,6 +101,13 @@ class Meta: "placeholder": "Due Date", } ), + "expires_at": forms.DateTimeInput( + attrs={ + "class": "form-control", + "type": "datetime-local", + } + ), + "is_hidden": forms.CheckboxInput(attrs={"class": "form-check-input"}), "llm_config": forms.Select( attrs={"class": "form-select", "placeholder": "LLM Configuration"} ), @@ -84,20 +116,45 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["llm_config"].required = False + self.fields["expires_at"].required = False + self.expires_at_adjusted = False # flag for view to flash a warning - # Convert datetime to format expected by datetime-local input + # Display stored datetimes in server local time, not raw UTC if self.instance and self.instance.due_date: - self.initial["due_date"] = self.instance.due_date.strftime("%Y-%m-%dT%H:%M") + self.initial["due_date"] = _to_local_str(self.instance.due_date) + if self.instance and self.instance.expires_at: + self.initial["expires_at"] = _to_local_str(self.instance.expires_at) def clean_due_date(self): - """Validate due date is in the future.""" - due_date = self.cleaned_data.get("due_date") + """Make aware. Reject only strictly past dates — today is allowed.""" + due_date = _make_aware_if_naive(self.cleaned_data.get("due_date")) - if due_date and due_date <= timezone.now(): - raise forms.ValidationError("Due date must be in the future.") + if due_date: + today = ( + timezone.now().date() + if not settings.USE_TZ + else timezone.localtime(timezone.now()).date() + ) + if due_date.date() < today: + raise forms.ValidationError("Due date cannot be in the past.") return due_date + def clean_expires_at(self): + """Make aware if naive.""" + return _make_aware_if_naive(self.cleaned_data.get("expires_at")) + + def clean(self): + """Warn if expires_at precedes due_date, but allow it.""" + cleaned_data = super().clean() + due_date = cleaned_data.get("due_date") + expires_at = cleaned_data.get("expires_at") + + if due_date and expires_at and expires_at < due_date: + self.expires_at_adjusted = True # view will warn the teacher + + return cleaned_data + class HomeworkEditForm(forms.ModelForm): """Form for editing an existing homework assignment.""" @@ -108,6 +165,8 @@ class Meta: "title", "description", "due_date", + "expires_at", + "is_hidden", "llm_config", ] # Note: course is excluded widgets = { @@ -128,6 +187,13 @@ class Meta: "placeholder": "Due Date", } ), + "expires_at": forms.DateTimeInput( + attrs={ + "class": "form-control", + "type": "datetime-local", + } + ), + "is_hidden": forms.CheckboxInput(attrs={"class": "form-check-input"}), "llm_config": forms.Select( attrs={"class": "form-select", "placeholder": "LLM Configuration"} ), @@ -136,19 +202,33 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["llm_config"].required = False + self.fields["expires_at"].required = False + self.expires_at_adjusted = False # flag for view to flash a warning - # Convert datetime to format expected by datetime-local input + # Display stored datetimes in server local time, not raw UTC if self.instance and self.instance.due_date: - self.initial["due_date"] = self.instance.due_date.strftime("%Y-%m-%dT%H:%M") + self.initial["due_date"] = _to_local_str(self.instance.due_date) + if self.instance and self.instance.expires_at: + self.initial["expires_at"] = _to_local_str(self.instance.expires_at) def clean_due_date(self): - """Validate due date is in the future.""" - due_date = self.cleaned_data.get("due_date") + """Make aware. No date restrictions on edit — teacher has full control.""" + return _make_aware_if_naive(self.cleaned_data.get("due_date")) - if due_date and due_date <= timezone.now(): - raise forms.ValidationError("Due date must be in the future.") + def clean_expires_at(self): + """Make aware if naive.""" + return _make_aware_if_naive(self.cleaned_data.get("expires_at")) - return due_date + def clean(self): + """Warn if expires_at precedes due_date, but allow it.""" + cleaned_data = super().clean() + due_date = cleaned_data.get("due_date") + expires_at = cleaned_data.get("expires_at") + + if due_date and expires_at and expires_at < due_date: + self.expires_at_adjusted = True # view will warn the teacher + + return cleaned_data class SectionFormSet(forms.BaseFormSet): diff --git a/src/homeworks/migrations/0005_add_homework_expiry_and_visibility.py b/src/homeworks/migrations/0005_add_homework_expiry_and_visibility.py new file mode 100644 index 0000000..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/models.py b/src/homeworks/models.py index 06e0be0..f94f8f9 100644 --- a/src/homeworks/models.py +++ b/src/homeworks/models.py @@ -17,6 +17,15 @@ class Homework(models.Model): "courses.Course", on_delete=models.CASCADE, related_name="homeworks" ) due_date = models.DateTimeField() + expires_at = models.DateTimeField( + null=True, + blank=True, + help_text="Automatically hide from students after this date. Leave blank to never auto-hide.", + ) + is_hidden = models.BooleanField( + default=False, + help_text="Immediately hide this homework from students.", + ) llm_config = models.ForeignKey( "llm.LLMConfig", on_delete=models.SET_NULL, null=True, blank=True ) @@ -38,6 +47,20 @@ def section_count(self): def is_overdue(self): return timezone.now() > self.due_date + @property + def is_expired(self) -> bool: + """True if the auto-expiry date has passed.""" + return self.expires_at is not None and timezone.now() > self.expires_at + + @property + def is_accessible_to_students(self) -> bool: + """False if the teacher has hidden it or the expiry date has passed.""" + if self.is_hidden: + return False + if self.is_expired: + return False + return True + class Section(models.Model): """Individual section within a homework assignment.""" diff --git a/src/homeworks/services.py b/src/homeworks/services.py index 436cd59..7511d04 100644 --- a/src/homeworks/services.py +++ b/src/homeworks/services.py @@ -890,7 +890,7 @@ def get_homework_submissions(homework_id: UUID) -> HomeworkSubmissionsData | Non # Create a map of conversation_id -> paste event count for quick lookup from collections import defaultdict - paste_event_count_map = defaultdict(int) + paste_event_count_map: defaultdict[UUID, int] = defaultdict(int) for paste_event in paste_events: if paste_event.last_message_before_paste: conv_id = paste_event.last_message_before_paste.conversation.id diff --git a/src/homeworks/templates/homeworks/detail.html b/src/homeworks/templates/homeworks/detail.html index 60e119d..cacbd89 100644 --- a/src/homeworks/templates/homeworks/detail.html +++ b/src/homeworks/templates/homeworks/detail.html @@ -20,7 +20,21 @@