Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions src/conversations/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down
4 changes: 2 additions & 2 deletions src/courses/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down
16 changes: 11 additions & 5 deletions src/courses/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
20 changes: 19 additions & 1 deletion src/homeworks/admin.py
Original file line number Diff line number Diff line change
@@ -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)
108 changes: 94 additions & 14 deletions src/homeworks/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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"}
Expand All @@ -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"}
),
Expand All @@ -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."""
Expand All @@ -108,6 +165,8 @@ class Meta:
"title",
"description",
"due_date",
"expires_at",
"is_hidden",
"llm_config",
] # Note: course is excluded
widgets = {
Expand All @@ -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"}
),
Expand All @@ -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):
Expand Down
Original file line number Diff line number Diff line change
@@ -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."
),
),
]
23 changes: 23 additions & 0 deletions src/homeworks/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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."""
Expand Down
2 changes: 1 addition & 1 deletion src/homeworks/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion src/homeworks/templates/homeworks/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,21 @@ <h1>{{ data.title }}</h1>
<span class="badge bg-danger mb-2">Overdue</span><br>
{% endif %}
<span class="text-muted">Due: {{ data.due_date|date:"F d, Y" }}</span>


{% if data.user_type == 'teacher' %}
<div class="mt-2">
{% if data.is_hidden %}
<span class="badge bg-secondary fs-6"><i class="bi bi-eye-slash"></i> Hidden from students</span>
{% elif not data.is_accessible_to_students %}
<span class="badge bg-danger fs-6"><i class="bi bi-clock-history"></i> Expired — students cannot access</span>
{% elif data.expires_at %}
<span class="badge bg-primary fs-6"><i class="bi bi-calendar-x"></i> Expires {{ data.expires_at|date:"F d, Y" }}</span>
{% else %}
<span class="badge bg-success fs-6"><i class="bi bi-eye"></i> Visible to students</span>
{% endif %}
</div>
{% endif %}

{% if data.can_edit %}
<div class="mt-2">
<a href="{% url 'homeworks:edit' data.id %}" class="btn btn-outline-primary">
Expand Down
Loading
Loading