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