f042f2896b
school-service additions:
- tt_solution + tt_lesson migration. tt_lesson carries three UNIQUEs
(solution+class, solution+teacher, solution+room per slot) so the
DB itself rejects any double-booking the solver might emit by
mistake.
- Solution CRUD + GET solutions/:id/lessons endpoint with joined
class/subject/teacher/room names for display.
- POST /timetable/solutions creates the row then fires off the
solver-service via HTTP (5s timeout, mark failed if unreachable).
- SOLVER_SERVICE_URL config wired through main.go/handlers.
New service timetable-solver-service:
- Python 3.11 + FastAPI + Timefold Solver 1.21 (Apache-2.0). Dockerfile
bundles OpenJDK 17 since Timefold for Python is a JPype bridge.
- app/domain.py — Timefold @planning_entity Lesson with timeslot+room
as PlanningVariables; @planning_solution Timetable holds problem
facts (rooms/teachers/etc.) AND rule-fact collections.
- app/rules.py — frozen dataclasses mirroring 6 of the 15 tt_
constraint_* tables initially.
- app/constraints.py — ConstraintProvider with 3 universal hard
constraints (no double-booking) + 5 DB-driven constraints
(teacher_unavailable_day/window, teacher_excluded_room,
room_unavailable, room_requires_type) + 1 quality soft constraint
(subject_preferred_period). Remaining 9 constraint types ready to
plug in via the same join pattern.
- app/repository.py — async loaders for stammdaten + rules; builds
one Lesson per (curriculum row × weekly_hours), skipping rows
without a tt_assignment teacher.
- app/runner.py — runs solver in ThreadPoolExecutor so the FastAPI
event loop stays responsive. Updates tt_solution status
pending→running→completed|infeasible|failed.
- app/main.py — POST /api/v1/solve (202 Accepted, background task),
GET /api/v1/jobs/{id}, /health. School-service polls tt_solution
directly instead of GET /jobs for the typical case.
- docker-compose.yml adds the service on port 8095, depending on
core-health-check.
Tests:
- school-service: validator test for CreateTimetableSolutionRequest
(allows empty name).
- solver-service: tests/test_domain.py + tests/test_rules.py cover
construction + hashability of the planning facts. Full solve flow
deferred to Phase 8 integration with seed data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
259 lines
9.8 KiB
Python
259 lines
9.8 KiB
Python
"""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) -> 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)
|
||
|
||
return Timetable(
|
||
timeslots=timeslots,
|
||
rooms=rooms,
|
||
teachers=teachers,
|
||
classes=classes,
|
||
subjects=subjects,
|
||
lessons=lessons,
|
||
score=None,
|
||
**rules,
|
||
)
|
||
|
||
|
||
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, false)
|
||
""",
|
||
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,
|
||
)
|
||
|
||
|
||
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)
|