Files
breakpilot-lehrer/timetable-solver-service/app/repository.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

321 lines
12 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.
"""Read stammdaten + constraints from PostgreSQL and turn them into Timefold
domain objects. Used by runner.py to build a Timetable problem instance.
Ownership is enforced via created_by_user_id everywhere — the solver only
sees data belonging to the Rektor who triggered the solve.
"""
from typing import Any
import asyncpg
from .domain import Lesson, Room, SchoolClass, Subject, Teacher, Timeslot, Timetable
from .rules import (
RoomRequiresTypeRule, RoomUnavailableRule,
SubjectPreferredPeriodRule, TeacherExcludedRoomRule,
TeacherUnavailableDayRule, TeacherUnavailableWindowRule,
)
async def build_problem(
pool: asyncpg.Pool,
user_id: str,
parent_solution_id: str | None = None,
) -> Timetable:
async with pool.acquire() as conn:
timeslots = await _load_timeslots(conn, user_id)
rooms = await _load_rooms(conn, user_id)
teachers = await _load_teachers(conn, user_id)
classes = await _load_classes(conn, user_id)
subjects = await _load_subjects(conn, user_id)
lessons = await _build_lessons(conn, user_id, classes, subjects, teachers)
rules = await _load_rules(conn, user_id)
if parent_solution_id:
await _inherit_pinned_from_parent(
conn, parent_solution_id, lessons, timeslots, rooms,
)
return Timetable(
timeslots=timeslots,
rooms=rooms,
teachers=teachers,
classes=classes,
subjects=subjects,
lessons=lessons,
score=None,
**rules,
)
async def _inherit_pinned_from_parent(
conn: asyncpg.Connection,
parent_solution_id: str,
lessons: list[Lesson],
timeslots: list[Timeslot],
rooms: list[Room],
) -> None:
"""Apply pinned lessons from a parent solution onto the new problem.
Matching rule: parent pinned lesson maps to a new Lesson with the same
(class_id, subject_id) that hasn't already received a pinned assignment.
Greedy first-fit so the count matches the parent's pinned count up to
the curriculum's weekly_hours per (class, subject). If curriculum
changed between solves, surplus pinned rows are silently dropped.
"""
rows = await conn.fetch("""
SELECT class_id::text, subject_id::text, room_id::text,
day_of_week, period_index
FROM tt_lesson
WHERE solution_id = $1 AND pinned = true
""", parent_solution_id)
if not rows:
return
ts_by_dp = {(t.day_of_week, t.period_index): t for t in timeslots}
room_by_id = {r.id: r for r in rooms}
used: set[str] = set()
for r in rows:
ts = ts_by_dp.get((r["day_of_week"], r["period_index"]))
if ts is None:
continue # period was deleted in the meantime
# Find a not-yet-pinned lesson with matching class+subject.
candidate: Lesson | None = None
for lesson in lessons:
if lesson.id in used:
continue
if (lesson.school_class.id == r["class_id"] and
lesson.subject.id == r["subject_id"]):
candidate = lesson
break
if candidate is None:
continue
candidate.timeslot = ts
room_id = r["room_id"]
if room_id:
candidate.room = room_by_id.get(room_id)
candidate.pinned = True
used.add(candidate.id)
async def _load_timeslots(conn: asyncpg.Connection, user_id: str) -> list[Timeslot]:
rows = await conn.fetch("""
SELECT id::text, day_of_week, period_index,
to_char(start_time, 'HH24:MI') AS st,
to_char(end_time, 'HH24:MI') AS et,
is_break
FROM tt_period
WHERE created_by_user_id = $1 AND is_break = false
ORDER BY day_of_week, period_index
""", user_id)
return [
Timeslot(id=r["id"], day_of_week=r["day_of_week"], period_index=r["period_index"],
start_time=r["st"], end_time=r["et"])
for r in rows
]
async def _load_rooms(conn: asyncpg.Connection, user_id: str) -> list[Room]:
rows = await conn.fetch("""
SELECT id::text, name, COALESCE(room_type, '') AS rt
FROM tt_room WHERE created_by_user_id = $1 ORDER BY name
""", user_id)
return [Room(id=r["id"], name=r["name"], room_type=r["rt"]) for r in rows]
async def _load_teachers(conn: asyncpg.Connection, user_id: str) -> list[Teacher]:
rows = await conn.fetch("""
SELECT id::text, first_name, last_name, short_code
FROM tt_teacher WHERE created_by_user_id = $1 ORDER BY last_name, first_name
""", user_id)
return [Teacher(id=r["id"], first_name=r["first_name"], last_name=r["last_name"], short_code=r["short_code"]) for r in rows]
async def _load_classes(conn: asyncpg.Connection, user_id: str) -> list[SchoolClass]:
rows = await conn.fetch("""
SELECT id::text, name, grade_level
FROM tt_class WHERE created_by_user_id = $1 ORDER BY grade_level, name
""", user_id)
return [SchoolClass(id=r["id"], name=r["name"], grade_level=r["grade_level"]) for r in rows]
async def _load_subjects(conn: asyncpg.Connection, user_id: str) -> list[Subject]:
rows = await conn.fetch("""
SELECT id::text, name, short_code, COALESCE(required_room_type, '') AS rt
FROM tt_subject WHERE created_by_user_id = $1 ORDER BY name
""", user_id)
return [Subject(id=r["id"], name=r["name"], short_code=r["short_code"], required_room_type=r["rt"]) for r in rows]
async def _build_lessons(
conn: asyncpg.Connection,
user_id: str,
classes: list[SchoolClass],
subjects: list[Subject],
teachers: list[Teacher],
) -> list[Lesson]:
"""Materialise curriculum × assignment into Lesson instances.
For each (class, subject) row in tt_curriculum with weekly_hours=N, we
create N Lesson rows. The teacher is the one assigned in tt_assignment
for the same (class, subject) — there must be exactly one, else the
lesson can't be scheduled and is skipped (the UI surfaces this gap).
"""
rows = await conn.fetch("""
SELECT cu.class_id::text, cu.subject_id::text, cu.weekly_hours,
a.teacher_id::text
FROM tt_curriculum cu
JOIN tt_class cl ON cu.class_id = cl.id
LEFT JOIN tt_assignment a
ON a.class_id = cu.class_id AND a.subject_id = cu.subject_id
WHERE cl.created_by_user_id = $1
""", user_id)
class_by_id = {c.id: c for c in classes}
subject_by_id = {s.id: s for s in subjects}
teacher_by_id = {t.id: t for t in teachers}
lessons: list[Lesson] = []
counter = 0
for r in rows:
cls = class_by_id.get(r["class_id"])
sub = subject_by_id.get(r["subject_id"])
tch = teacher_by_id.get(r["teacher_id"]) if r["teacher_id"] else None
if cls is None or sub is None or tch is None:
# Missing assignment — solver can't schedule without a teacher.
continue
for _ in range(int(r["weekly_hours"])):
lessons.append(Lesson(
id=f"L{counter}-{cls.id[:6]}-{sub.id[:6]}",
school_class=cls,
subject=sub,
teacher=tch,
))
counter += 1
return lessons
async def _load_rules(conn: asyncpg.Connection, user_id: str) -> dict[str, list[Any]]:
"""Pull the subset of constraint tables the constraint provider uses."""
rules: dict[str, list[Any]] = {}
rows = await conn.fetch("""
SELECT teacher_id::text, day_of_week, is_hard, weight
FROM tt_constraint_teacher_unavailable_day
WHERE created_by_user_id = $1 AND active = true
""", user_id)
rules["teacher_unavailable_days"] = [TeacherUnavailableDayRule(**dict(r)) for r in rows]
rows = await conn.fetch("""
SELECT teacher_id::text, day_of_week,
to_char(start_time, 'HH24:MI') AS start_time,
to_char(end_time, 'HH24:MI') AS end_time,
is_hard, weight
FROM tt_constraint_teacher_unavailable_window
WHERE created_by_user_id = $1 AND active = true
""", user_id)
rules["teacher_unavailable_windows"] = [TeacherUnavailableWindowRule(**dict(r)) for r in rows]
rows = await conn.fetch("""
SELECT teacher_id::text, room_id::text, is_hard, weight
FROM tt_constraint_teacher_excluded_room
WHERE created_by_user_id = $1 AND active = true
""", user_id)
rules["teacher_excluded_rooms"] = [TeacherExcludedRoomRule(**dict(r)) for r in rows]
rows = await conn.fetch("""
SELECT room_id::text, day_of_week, period_index, is_hard, weight
FROM tt_constraint_room_unavailable
WHERE created_by_user_id = $1 AND active = true
""", user_id)
rules["room_unavailables"] = [RoomUnavailableRule(**dict(r)) for r in rows]
rows = await conn.fetch("""
SELECT subject_id::text, period_from, period_to, is_hard, weight
FROM tt_constraint_subject_preferred_period
WHERE created_by_user_id = $1 AND active = true
""", user_id)
rules["subject_preferred_periods"] = [SubjectPreferredPeriodRule(**dict(r)) for r in rows]
rows = await conn.fetch("""
SELECT subject_id::text, room_type, is_hard, weight
FROM tt_constraint_room_requires_type
WHERE created_by_user_id = $1 AND active = true
""", user_id)
rules["room_requires_types"] = [RoomRequiresTypeRule(**dict(r)) for r in rows]
return rules
async def persist_solution(
pool: asyncpg.Pool,
solution_id: str,
timetable: Timetable,
hard_score: int,
soft_score: int,
) -> None:
"""Write the solver result back to tt_solution + tt_lesson."""
async with pool.acquire() as conn:
async with conn.transaction():
await conn.execute("""
UPDATE tt_solution
SET status = 'completed',
hard_score = $2,
soft_score = $3,
finished_at = NOW()
WHERE id = $1
""", solution_id, hard_score, soft_score)
# Clear any prior lesson rows (re-solves overwrite).
await conn.execute("DELETE FROM tt_lesson WHERE solution_id = $1", solution_id)
for lesson in timetable.lessons:
if lesson.timeslot is None:
continue
await conn.execute("""
INSERT INTO tt_lesson
(solution_id, class_id, subject_id, teacher_id, room_id,
day_of_week, period_index, pinned)
VALUES ($1, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8)
""",
solution_id,
lesson.school_class.id,
lesson.subject.id,
lesson.teacher.id,
lesson.room.id if lesson.room else None,
lesson.timeslot.day_of_week,
lesson.timeslot.period_index,
lesson.pinned,
)
async def mark_failed(pool: asyncpg.Pool, solution_id: str, error_message: str) -> None:
async with pool.acquire() as conn:
await conn.execute("""
UPDATE tt_solution
SET status = 'failed', error_message = $2, finished_at = NOW()
WHERE id = $1
""", solution_id, error_message)
async def mark_running(pool: asyncpg.Pool, solution_id: str) -> None:
async with pool.acquire() as conn:
await conn.execute("""
UPDATE tt_solution SET status = 'running', started_at = NOW()
WHERE id = $1
""", solution_id)
async def mark_infeasible(pool: asyncpg.Pool, solution_id: str, hard_score: int, soft_score: int) -> None:
async with pool.acquire() as conn:
await conn.execute("""
UPDATE tt_solution
SET status = 'infeasible',
hard_score = $2,
soft_score = $3,
finished_at = NOW()
WHERE id = $1
""", solution_id, hard_score, soft_score)