Files
breakpilot-lehrer/timetable-solver-service/app/domain.py
T
Benjamin Admin bf5ea860cc
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 37s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 3m56s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 23s
Phase 7: pinning, plan versions, solver budget + UX polish
Backend (school-service):
  - tt_solution gains parent_solution_id (self-FK, ON DELETE SET NULL)
    and seconds_limit columns via ALTER TABLE IF NOT EXISTS.
  - CreateTimetableSolutionRequest accepts optional parent_solution_id
    and seconds_limit (5-600s) with binding validation.
  - CreateSolution checks parent ownership before INSERT so users can't
    fork another tenant's plan.
  - New PUT /timetable/lessons/:id/pin endpoint; ownership enforced via
    the lesson's solution.created_by_user_id JOIN.

Solver:
  - Lesson.pinned now carries @PlanningPin so Timefold leaves locked
    cells untouched during the search.
  - build_problem() takes optional parent_solution_id; if set, copies
    pinned (class_id, subject_id, day, period, room) tuples onto fresh
    Lesson objects via greedy first-fit matching. Surplus pinned rows
    from curriculum changes are silently dropped.
  - _build_factory(seconds) replaces the module-level factory so each
    job honours its tt_solution.seconds_limit override.
  - persist_solution writes lesson.pinned back so subsequent re-solves
    inherit it.

Frontend (studio-v2):
  - SolutionList grows three knobs in the create-form: Basieren auf
    (parent dropdown, only completed solutions, disabled when none),
    Sekunden-Limit (5-600), and the existing Name.
  - PlanView cells get a pin/unpin button with optimistic update and
    rollback on error. Pinned cells gain an amber ring.
  - types.ts + api.ts mirror the new fields; lessonsApi.pin(id, bool).
  - HelpPanel: collapsible 6-step Bedienungsanleitung explaining the
    setup-to-plan workflow. Anchored at the top of /stundenplan above
    the dev token banner.
  - page.tsx switches to the same gradient + animated-blob background
    used on /korrektur so /stundenplan stops looking like a slate-900
    test page.
  - JWT dev banner gets a step-by-step explanation of how to grab the
    token from DevTools and a non-blocking success indicator (no more
    alert()).

Tests:
  - school-service: 6 new validator cases for parent_solution_id +
    seconds_limit boundaries. 73 subtests total, all green.
  - studio-v2: mockSchoolApi adds PUT /lessons/:id/pin route. 5 new
    Playwright tests across two suites (parent-selector visibility +
    options, seconds-limit input, pin button render, pin-icon flip).
    Existing tests adjusted to the new help panel + JWT banner wording.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:19:39 +02:00

141 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Timefold planning domain for school timetables.
Lessons are the planning entities; their `timeslot` and `room` are the
variables the solver picks. Class/subject/teacher come from the assignment
(`tt_assignment`) and stay fixed for a given Lesson instance.
Note on equality: Timefold compares facts by identity by default, so we
use frozen dataclasses with id-based equality where needed.
"""
from dataclasses import dataclass, field
from typing import Annotated, Optional
from timefold.solver.domain import (
planning_entity,
planning_solution,
PlanningVariable,
PlanningPin,
PlanningId,
PlanningEntityCollectionProperty,
ProblemFactCollectionProperty,
ValueRangeProvider,
PlanningScore,
)
from timefold.solver.score import HardSoftScore
@dataclass(frozen=True)
class Timeslot:
"""A single weekday + lesson period (e.g. Monday 1st hour, 08:0008:45)."""
id: Annotated[str, PlanningId]
day_of_week: int # 1..7
period_index: int # 1..N
start_time: str # HH:MM
end_time: str # HH:MM
def __str__(self) -> str:
return f"D{self.day_of_week}P{self.period_index}"
@dataclass(frozen=True)
class Room:
id: Annotated[str, PlanningId]
name: str
room_type: str = ""
def __str__(self) -> str:
return self.name
@dataclass(frozen=True)
class Teacher:
id: Annotated[str, PlanningId]
last_name: str
first_name: str
short_code: str
def __str__(self) -> str:
return f"{self.last_name}, {self.first_name}"
@dataclass(frozen=True)
class SchoolClass:
id: Annotated[str, PlanningId]
name: str
grade_level: int
def __str__(self) -> str:
return self.name
@dataclass(frozen=True)
class Subject:
id: Annotated[str, PlanningId]
name: str
short_code: str
required_room_type: str = ""
def __str__(self) -> str:
return self.short_code
@planning_entity
@dataclass
class Lesson:
"""One scheduled class-subject pairing.
Curriculum says "5a needs 4 hours of Mathe per week" → 4 Lesson instances
with school_class=5a, subject=Mathe, teacher fixed (from tt_assignment).
The solver assigns timeslot + room.
pinned (Phase 7): when True, this Lesson's timeslot + room have been
pre-assigned and the solver must not move it. Used for plan versioning
so re-solves keep the parent solution's locked cells in place.
"""
id: Annotated[str, PlanningId]
school_class: SchoolClass
subject: Subject
teacher: Teacher
timeslot: Annotated[Optional[Timeslot], PlanningVariable] = field(default=None)
room: Annotated[Optional[Room], PlanningVariable] = field(default=None)
pinned: Annotated[bool, PlanningPin] = field(default=False)
def __str__(self) -> str:
return f"{self.school_class}-{self.subject}#{self.id[:8]}"
from .rules import (
TeacherUnavailableDayRule, TeacherUnavailableWindowRule, TeacherExcludedRoomRule,
RoomUnavailableRule, SubjectPreferredPeriodRule, RoomRequiresTypeRule,
)
@planning_solution
@dataclass
class Timetable:
"""The solver works on one Timetable instance: shuffles `lessons[*].timeslot`
and `lessons[*].room` to satisfy the constraints.
Constraint-rule facts are pulled from the DB at solve time and passed
here so the constraint provider can join against them.
"""
timeslots: Annotated[list[Timeslot], ProblemFactCollectionProperty, ValueRangeProvider]
rooms: Annotated[list[Room], ProblemFactCollectionProperty, ValueRangeProvider]
teachers: Annotated[list[Teacher], ProblemFactCollectionProperty]
classes: Annotated[list[SchoolClass], ProblemFactCollectionProperty]
subjects: Annotated[list[Subject], ProblemFactCollectionProperty]
teacher_unavailable_days: Annotated[list[TeacherUnavailableDayRule], ProblemFactCollectionProperty]
teacher_unavailable_windows: Annotated[list[TeacherUnavailableWindowRule], ProblemFactCollectionProperty]
teacher_excluded_rooms: Annotated[list[TeacherExcludedRoomRule], ProblemFactCollectionProperty]
room_unavailables: Annotated[list[RoomUnavailableRule], ProblemFactCollectionProperty]
subject_preferred_periods: Annotated[list[SubjectPreferredPeriodRule], ProblemFactCollectionProperty]
room_requires_types: Annotated[list[RoomRequiresTypeRule], ProblemFactCollectionProperty]
lessons: Annotated[list[Lesson], PlanningEntityCollectionProperty]
score: Annotated[Optional[HardSoftScore], PlanningScore] = field(default=None)