Phase 7: pinning, plan versions, solver budget + UX polish
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

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>
This commit is contained in:
Benjamin Admin
2026-05-22 08:19:39 +02:00
parent 612ecec6d9
commit bf5ea860cc
17 changed files with 591 additions and 124 deletions
+3
View File
@@ -225,6 +225,9 @@ func main() {
api.GET("/timetable/solutions/:id", handler.GetTimetableSolution) api.GET("/timetable/solutions/:id", handler.GetTimetableSolution)
api.DELETE("/timetable/solutions/:id", handler.DeleteTimetableSolution) api.DELETE("/timetable/solutions/:id", handler.DeleteTimetableSolution)
api.GET("/timetable/solutions/:id/lessons", handler.ListTimetableLessons) api.GET("/timetable/solutions/:id/lessons", handler.ListTimetableLessons)
// Phase 7: pin/unpin individual lessons for the next re-solve.
api.PUT("/timetable/lessons/:id/pin", handler.UpdateTimetableLessonPin)
} }
// Start server // Start server
@@ -46,5 +46,9 @@ func TimetableSolutionMigrations() []string {
`CREATE INDEX IF NOT EXISTS idx_tt_lesson_solution ON tt_lesson(solution_id)`, `CREATE INDEX IF NOT EXISTS idx_tt_lesson_solution ON tt_lesson(solution_id)`,
`CREATE INDEX IF NOT EXISTS idx_tt_lesson_class ON tt_lesson(class_id)`, `CREATE INDEX IF NOT EXISTS idx_tt_lesson_class ON tt_lesson(class_id)`,
`CREATE INDEX IF NOT EXISTS idx_tt_lesson_teacher ON tt_lesson(teacher_id)`, `CREATE INDEX IF NOT EXISTS idx_tt_lesson_teacher ON tt_lesson(teacher_id)`,
// Phase 7: plan versioning + per-solve solver-timeout override.
`ALTER TABLE tt_solution ADD COLUMN IF NOT EXISTS parent_solution_id UUID REFERENCES tt_solution(id) ON DELETE SET NULL`,
`ALTER TABLE tt_solution ADD COLUMN IF NOT EXISTS seconds_limit INT`,
} }
} }
@@ -89,3 +89,23 @@ func (h *Handler) DeleteTimetableSolution(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{"message": "Solution deleted"}) c.JSON(http.StatusOK, gin.H{"message": "Solution deleted"})
} }
// UpdateTimetableLessonPin flips the pinned flag on a single lesson.
// The solver respects pinned cells via @PlanningPin when this user re-solves.
func (h *Handler) UpdateTimetableLessonPin(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.UpdateLessonPinRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if err := h.timetableService.UpdateLessonPin(c.Request.Context(), c.Param("id"), uid, req.Pinned); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to update lesson pin: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Lesson pin updated", "pinned": req.Pinned})
}
@@ -19,6 +19,8 @@ type TimetableSolution struct {
StartedAt *time.Time `json:"started_at,omitempty" db:"started_at"` StartedAt *time.Time `json:"started_at,omitempty" db:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty" db:"finished_at"` FinishedAt *time.Time `json:"finished_at,omitempty" db:"finished_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `json:"created_at" db:"created_at"`
ParentSolutionID *uuid.UUID `json:"parent_solution_id,omitempty" db:"parent_solution_id"`
SecondsLimit *int `json:"seconds_limit,omitempty" db:"seconds_limit"`
} }
// TimetableLesson is one scheduled class-period in a solution. // TimetableLesson is one scheduled class-period in a solution.
@@ -44,6 +46,18 @@ type TimetableLesson struct {
// CreateTimetableSolutionRequest kicks off a solve. The solver-service is // CreateTimetableSolutionRequest kicks off a solve. The solver-service is
// invoked async — this endpoint only registers the solution row and queues // invoked async — this endpoint only registers the solution row and queues
// the job. // the job.
//
// ParentSolutionID, if set, instructs the solver to seed the new problem
// with the parent solution's pinned lessons (Phase 7 plan versioning).
// SecondsLimit overrides the default 60s solver budget.
type CreateTimetableSolutionRequest struct { type CreateTimetableSolutionRequest struct {
Name string `json:"name"` Name string `json:"name"`
ParentSolutionID *string `json:"parent_solution_id,omitempty" binding:"omitempty,uuid"`
SecondsLimit *int `json:"seconds_limit,omitempty" binding:"omitempty,min=5,max=600"`
}
// UpdateLessonPinRequest toggles tt_lesson.pinned. Used by the Plan view's
// pin/unpin button.
type UpdateLessonPinRequest struct {
Pinned bool `json:"pinned"`
} }
@@ -16,24 +16,63 @@ import (
// same DB once it finishes, so listing solutions = simple SELECTs here. // same DB once it finishes, so listing solutions = simple SELECTs here.
func (s *TimetableService) CreateSolution(ctx context.Context, userID string, req *models.CreateTimetableSolutionRequest) (*models.TimetableSolution, error) { func (s *TimetableService) CreateSolution(ctx context.Context, userID string, req *models.CreateTimetableSolutionRequest) (*models.TimetableSolution, error) {
// Resolve optional parent — guard against cross-user references.
var parentID *string
if req.ParentSolutionID != nil && *req.ParentSolutionID != "" {
var owned bool
err := s.db.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM tt_solution
WHERE id = $1 AND created_by_user_id = $2)
`, *req.ParentSolutionID, userID).Scan(&owned)
if err != nil {
return nil, err
}
if !owned {
return nil, fmt.Errorf("parent_solution_id not found or not owned by user")
}
parentID = req.ParentSolutionID
}
var sol models.TimetableSolution var sol models.TimetableSolution
err := s.db.QueryRow(ctx, ` err := s.db.QueryRow(ctx, `
INSERT INTO tt_solution (created_by_user_id, name, status) INSERT INTO tt_solution (created_by_user_id, name, status, parent_solution_id, seconds_limit)
VALUES ($1, $2, 'pending') VALUES ($1, $2, 'pending', $3::uuid, $4)
RETURNING id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score, RETURNING id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score,
COALESCE(error_message, ''), started_at, finished_at, created_at COALESCE(error_message, ''), started_at, finished_at, created_at,
`, userID, req.Name).Scan( parent_solution_id, seconds_limit
`, userID, req.Name, parentID, req.SecondsLimit).Scan(
&sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status, &sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status,
&sol.HardScore, &sol.SoftScore, &sol.ErrorMessage, &sol.HardScore, &sol.SoftScore, &sol.ErrorMessage,
&sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt, &sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt,
&sol.ParentSolutionID, &sol.SecondsLimit,
) )
return &sol, err return &sol, err
} }
// UpdateLessonPin flips tt_lesson.pinned. Ownership is enforced via the
// lesson's solution.created_by_user_id — users can only pin their own
// solutions' lessons.
func (s *TimetableService) UpdateLessonPin(ctx context.Context, lessonID, userID string, pinned bool) error {
res, err := s.db.Exec(ctx, `
UPDATE tt_lesson l
SET pinned = $1
FROM tt_solution s
WHERE l.solution_id = s.id AND l.id = $2 AND s.created_by_user_id = $3
`, pinned, lessonID, userID)
if err != nil {
return err
}
if res.RowsAffected() == 0 {
return fmt.Errorf("lesson not found or not owned")
}
return nil
}
func (s *TimetableService) ListSolutions(ctx context.Context, userID string) ([]models.TimetableSolution, error) { func (s *TimetableService) ListSolutions(ctx context.Context, userID string) ([]models.TimetableSolution, error) {
rows, err := s.db.Query(ctx, ` rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score, SELECT id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score,
COALESCE(error_message, ''), started_at, finished_at, created_at COALESCE(error_message, ''), started_at, finished_at, created_at,
parent_solution_id, seconds_limit
FROM tt_solution WHERE created_by_user_id = $1 ORDER BY created_at DESC FROM tt_solution WHERE created_by_user_id = $1 ORDER BY created_at DESC
`, userID) `, userID)
if err != nil { if err != nil {
@@ -45,7 +84,8 @@ func (s *TimetableService) ListSolutions(ctx context.Context, userID string) ([]
var sol models.TimetableSolution var sol models.TimetableSolution
if err := rows.Scan(&sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status, if err := rows.Scan(&sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status,
&sol.HardScore, &sol.SoftScore, &sol.ErrorMessage, &sol.HardScore, &sol.SoftScore, &sol.ErrorMessage,
&sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt); err != nil { &sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt,
&sol.ParentSolutionID, &sol.SecondsLimit); err != nil {
return nil, err return nil, err
} }
out = append(out, sol) out = append(out, sol)
@@ -57,12 +97,14 @@ func (s *TimetableService) GetSolution(ctx context.Context, id, userID string) (
var sol models.TimetableSolution var sol models.TimetableSolution
err := s.db.QueryRow(ctx, ` err := s.db.QueryRow(ctx, `
SELECT id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score, SELECT id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score,
COALESCE(error_message, ''), started_at, finished_at, created_at COALESCE(error_message, ''), started_at, finished_at, created_at,
parent_solution_id, seconds_limit
FROM tt_solution WHERE id = $1 AND created_by_user_id = $2 FROM tt_solution WHERE id = $1 AND created_by_user_id = $2
`, id, userID).Scan( `, id, userID).Scan(
&sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status, &sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status,
&sol.HardScore, &sol.SoftScore, &sol.ErrorMessage, &sol.HardScore, &sol.SoftScore, &sol.ErrorMessage,
&sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt, &sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt,
&sol.ParentSolutionID, &sol.SecondsLimit,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -25,3 +25,29 @@ func TestCreateTimetableSolutionRequest_NoBindingTags(t *testing.T) {
}) })
} }
} }
func TestCreateTimetableSolutionRequest_ParentAndLimit_Validation(t *testing.T) {
uid := "00000000-0000-0000-0000-000000000001"
secs := func(n int) *int { return &n }
parent := func(s string) *string { return &s }
tests := []struct {
name string
req models.CreateTimetableSolutionRequest
wantErr bool
}{
{"valid parent + limit", models.CreateTimetableSolutionRequest{ParentSolutionID: parent(uid), SecondsLimit: secs(60)}, false},
{"bad parent uuid", models.CreateTimetableSolutionRequest{ParentSolutionID: parent("not-a-uuid")}, true},
{"limit too low", models.CreateTimetableSolutionRequest{SecondsLimit: secs(1)}, true},
{"limit too high", models.CreateTimetableSolutionRequest{SecondsLimit: secs(9999)}, true},
{"limit at boundary (5)", models.CreateTimetableSolutionRequest{SecondsLimit: secs(5)}, false},
{"limit at boundary (600)", models.CreateTimetableSolutionRequest{SecondsLimit: secs(600)}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if (validate.Struct(tt.req) != nil) != tt.wantErr {
t.Errorf("unexpected validation outcome for %s", tt.name)
}
})
}
}
@@ -0,0 +1,76 @@
'use client'
import { useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
const STEPS: { title: string; body: string }[] = [
{
title: '1. Klassen, Lehrer, Faecher, Raeume anlegen',
body: 'Stammdaten zuerst — der Solver kann nur scheduln was er kennt. Ohne mindestens 1 Klasse, 1 Fach, 1 Raum und 1 Lehrer wird der Plan leer.',
},
{
title: '2. Zeitraster definieren',
body: 'Wochentag + Stundennummer + Start/Ende fuer jeden Slot. Pausen anhaken; der Solver belegt sie nicht.',
},
{
title: '3. Stundentafel + Lehrauftraege',
body: 'Stundentafel: pro Klasse, wie viele Wochenstunden welches Fach. Lehrauftraege: welcher Lehrer unterrichtet welches Fach in welcher Klasse. Ohne Lehrauftrag wird die Lesson uebersprungen.',
},
{
title: '4. Regeln (Constraints) — optional',
body: 'Lehrer-Abwesenheiten, Fach-Bevorzugungen, Raum-Sperren. Hart-Regeln muss der Solver einhalten, Soft-Regeln werden gewichtet.',
},
{
title: '5. Plan generieren',
body: 'Zurueck auf den Plan-Tab → "Neuen Plan generieren". Der Solver laeuft im Hintergrund (bis zu 60 s) und schreibt das Ergebnis direkt in die Datenbank. Status erscheint live in der Liste.',
},
{
title: '6. Cells anpinnen + Re-Solve',
body: 'Im Wochengrid einzelne Stunden anpinnen (Schloss-Icon). Beim naechsten Solve mit dem Plan als "Basieren auf"-Quelle bleiben die gepinnten Cells stehen, alles andere wird neu gerechnet.',
},
]
export function HelpPanel() {
const { isDark } = useTheme()
const [open, setOpen] = useState(false)
return (
<div
className={`mb-4 rounded-2xl border backdrop-blur-xl ${
isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
}`}
data-testid="help-panel"
>
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-4 py-3 text-left"
>
<span className="flex items-center gap-2 font-medium">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Bedienungsanleitung
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
(6 Schritte vom Setup bis zum fertigen Stundenplan)
</span>
</span>
<span className={`text-sm transition-transform ${open ? 'rotate-180' : ''}`}></span>
</button>
{open && (
<div className={`px-4 pb-4 space-y-3 border-t ${isDark ? 'border-white/10' : 'border-black/10'}`}>
{STEPS.map((s, i) => (
<div key={i} className="pt-3">
<h4 className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>{s.title}</h4>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>{s.body}</p>
</div>
))}
<p className={`pt-3 text-xs italic ${isDark ? 'text-white/40' : 'text-slate-500'}`}>
Tipp: Solver-Probleme im Status-Feld der Plan-Liste &quot;Keine Lessons&quot; heisst meistens fehlende
Lehrauftraege; &quot;Nicht loesbar&quot; heisst harte Constraints widersprechen sich.
</p>
</div>
)}
</div>
)
}
@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import { useTheme } from '@/lib/ThemeContext' import { useTheme } from '@/lib/ThemeContext'
import { solutionsApi, subjectsApi } from '@/lib/stundenplan/api' import { solutionsApi, subjectsApi, lessonsApi } from '@/lib/stundenplan/api'
import type { TimetableLesson, TimetableSubject } from '@/app/stundenplan/types' import type { TimetableLesson, TimetableSubject } from '@/app/stundenplan/types'
interface PlanViewProps { interface PlanViewProps {
@@ -109,6 +109,19 @@ export function PlanView({ solutionId }: PlanViewProps) {
const cellLesson = (day: number, periodIdx: number): TimetableLesson | undefined => const cellLesson = (day: number, periodIdx: number): TimetableLesson | undefined =>
visibleLessons.find(l => l.day_of_week === day && l.period_index === periodIdx) visibleLessons.find(l => l.day_of_week === day && l.period_index === periodIdx)
const togglePin = useCallback(async (lesson: TimetableLesson) => {
// Optimistic update so the lock icon flips immediately even if the
// server is slow.
setLessons(prev => prev.map(l => l.id === lesson.id ? { ...l, pinned: !l.pinned } : l))
try {
await lessonsApi.pin(lesson.id, !lesson.pinned)
} catch (e) {
// Revert on failure and surface the error.
setLessons(prev => prev.map(l => l.id === lesson.id ? { ...l, pinned: lesson.pinned } : l))
setError(e instanceof Error ? e.message : 'Pin fehlgeschlagen')
}
}, [])
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900' const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
const selectClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900' const selectClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
@@ -177,11 +190,23 @@ export function PlanView({ solutionId }: PlanViewProps) {
return ( return (
<td key={d.v} className="px-2 py-1"> <td key={d.v} className="px-2 py-1">
<div <div
className="rounded-md p-2 text-xs space-y-0.5" className={`rounded-md p-2 text-xs space-y-0.5 relative ${lesson.pinned ? 'ring-2 ring-amber-400/70' : ''}`}
style={{ backgroundColor: color + (isDark ? '40' : '30'), borderLeft: `3px solid ${color}` }} style={{ backgroundColor: color + (isDark ? '40' : '30'), borderLeft: `3px solid ${color}` }}
data-testid={`cell-${d.v}-${idx}`} data-testid={`cell-${d.v}-${idx}`}
> >
<div className="font-semibold">{lesson.subject_name || '?'}</div> <button
onClick={() => togglePin(lesson)}
data-testid={`pin-${lesson.id}`}
title={lesson.pinned ? 'Lesson loesen' : 'Lesson anpinnen'}
className={`absolute top-1 right-1 text-xs leading-none px-1 py-0.5 rounded ${
lesson.pinned
? 'text-amber-300 hover:text-amber-200'
: 'opacity-30 hover:opacity-100'
}`}
>
{lesson.pinned ? '🔒' : '📌'}
</button>
<div className="font-semibold pr-5">{lesson.subject_name || '?'}</div>
{perspective !== 'class' && lesson.class_name && ( {perspective !== 'class' && lesson.class_name && (
<div className="opacity-80">{lesson.class_name}</div> <div className="opacity-80">{lesson.class_name}</div>
)} )}
@@ -33,6 +33,8 @@ export function SolutionList({ onView, selectedId }: SolutionListProps) {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [name, setName] = useState('') const [name, setName] = useState('')
const [parentId, setParentId] = useState<string>('')
const [secondsLimit, setSecondsLimit] = useState<number | ''>('')
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null) const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -70,8 +72,14 @@ export function SolutionList({ onView, selectedId }: SolutionListProps) {
setSubmitting(true) setSubmitting(true)
setError(null) setError(null)
try { try {
await solutionsApi.create({ name: name || `Plan ${new Date().toLocaleString('de-DE')}` }) await solutionsApi.create({
name: name || `Plan ${new Date().toLocaleString('de-DE')}`,
parent_solution_id: parentId || null,
seconds_limit: secondsLimit === '' ? null : Number(secondsLimit),
})
setName('') setName('')
setParentId('')
setSecondsLimit('')
await load() await load()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Solve fehlgeschlagen') setError(err instanceof Error ? err.message : 'Solve fehlgeschlagen')
@@ -93,14 +101,49 @@ export function SolutionList({ onView, selectedId }: SolutionListProps) {
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900' const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white placeholder-white/40' : 'bg-white border-slate-300 text-slate-900 placeholder-slate-400' const inputClass = isDark ? 'bg-white/10 border-white/20 text-white placeholder-white/40' : 'bg-white border-slate-300 text-slate-900 placeholder-slate-400'
const completedParents = items.filter(s => s.status === 'completed')
return ( return (
<div className="space-y-4" data-testid="solution-list"> <div className="space-y-4" data-testid="solution-list">
<div className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}> <div className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
<div className="flex items-end gap-3"> <div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div className="flex-1"> <div className="md:col-span-2">
<label className="block text-sm mb-1 opacity-70">Name (optional)</label> <label className="block text-sm mb-1 opacity-70">Name (optional)</label>
<input value={name} onChange={e => setName(e.target.value)} placeholder="z.B. Schuljahr 26/27 Variante 1" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> <input value={name} onChange={e => setName(e.target.value)} placeholder="z.B. Schuljahr 26/27 Variante 1" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div> </div>
<div>
<label className="block text-sm mb-1 opacity-70">Basieren auf (optional)</label>
<select
value={parentId}
onChange={e => setParentId(e.target.value)}
disabled={completedParents.length === 0}
data-testid="parent-selector"
className={`w-full px-3 py-2 rounded-lg border disabled:opacity-50 ${inputClass}`}
>
<option value=""> ohne Vorlage </option>
{completedParents.map(p => (
<option key={p.id} value={p.id}>{p.name || p.id.slice(0, 8)}</option>
))}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Sekunden-Limit</label>
<input
type="number"
min={5}
max={600}
value={secondsLimit}
onChange={e => setSecondsLimit(e.target.value === '' ? '' : parseInt(e.target.value))}
placeholder="60"
data-testid="seconds-limit"
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
/>
</div>
</div>
<div className="mt-3 flex items-center justify-between">
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-500'}`}>
Mit Vorlage uebernimmt der Solver alle gepinnten Cells aus dem Quellplan.
</p>
<button <button
onClick={handleSolve} onClick={handleSolve}
disabled={submitting} disabled={submitting}
@@ -110,9 +153,6 @@ export function SolutionList({ onView, selectedId }: SolutionListProps) {
{submitting ? 'Startet…' : 'Neuen Plan generieren'} {submitting ? 'Startet…' : 'Neuen Plan generieren'}
</button> </button>
</div> </div>
<p className={`mt-2 text-xs ${isDark ? 'text-white/40' : 'text-slate-500'}`}>
Solver laeuft im Hintergrund (bis zu 60 Sekunden). Status erscheint in der Liste.
</p>
</div> </div>
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>} {error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
+45 -11
View File
@@ -14,6 +14,7 @@ import { CurriculumManager } from './_components/CurriculumManager'
import { AssignmentsManager } from './_components/AssignmentsManager' import { AssignmentsManager } from './_components/AssignmentsManager'
import { RegelnHub } from './_components/regeln/RegelnHub' import { RegelnHub } from './_components/regeln/RegelnHub'
import { PlanHub } from './_components/plan/PlanHub' import { PlanHub } from './_components/plan/PlanHub'
import { HelpPanel } from './_components/HelpPanel'
import { setStundenplanToken, getStundenplanToken } from '@/lib/stundenplan/api' import { setStundenplanToken, getStundenplanToken } from '@/lib/stundenplan/api'
type Tab = 'plan' | 'klassen' | 'lehrer' | 'faecher' | 'raeume' | 'periods' | 'curriculum' | 'assignments' | 'regeln' type Tab = 'plan' | 'klassen' | 'lehrer' | 'faecher' | 'raeume' | 'periods' | 'curriculum' | 'assignments' | 'regeln'
@@ -34,24 +35,36 @@ export default function StundenplanPage() {
const { isDark } = useTheme() const { isDark } = useTheme()
const [tab, setTab] = useState<Tab>('plan') const [tab, setTab] = useState<Tab>('plan')
const [token, setToken] = useState(getStundenplanToken()) const [token, setToken] = useState(getStundenplanToken())
const [tokenSaved, setTokenSaved] = useState(false)
const handleSaveToken = () => { const handleSaveToken = () => {
setStundenplanToken(token) setStundenplanToken(token)
alert('Token gespeichert. Seite neu laden um die Aenderung zu uebernehmen.') setTokenSaved(true)
setTimeout(() => setTokenSaved(false), 2500)
} }
return ( return (
<div className={`flex min-h-screen p-4 gap-4 ${isDark ? 'bg-slate-900' : 'bg-slate-50'}`}> <div className={`min-h-screen flex relative overflow-hidden ${
<Sidebar selectedTab="stundenplan" /> isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
}`}>
{/* Background blobs — same effect as /korrektur to keep the visual
language consistent across studio-v2 pages. */}
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
<main className="flex-1 max-w-7xl mx-auto"> <div className="relative z-10 p-4"><Sidebar selectedTab="stundenplan" /></div>
<main className="flex-1 relative z-10 p-6 overflow-y-auto">
<div className="max-w-7xl mx-auto">
<header className="flex items-center justify-between mb-6"> <header className="flex items-center justify-between mb-6">
<div> <div>
<h1 className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}> <h1 className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Stundenplan Stundenplan
</h1> </h1>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}> <p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Stammdaten und Regeln fuer den Solver Stammdaten, Regeln und Plan-Generierung fuer den Schul-Stundenplan
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -60,6 +73,8 @@ export default function StundenplanPage() {
</div> </div>
</header> </header>
<HelpPanel />
<div <div
className={`mb-4 p-3 rounded-xl border backdrop-blur-xl ${ className={`mb-4 p-3 rounded-xl border backdrop-blur-xl ${
isDark ? 'bg-amber-500/10 border-amber-500/30 text-amber-200' : 'bg-amber-50 border-amber-200 text-amber-900' isDark ? 'bg-amber-500/10 border-amber-500/30 text-amber-200' : 'bg-amber-50 border-amber-200 text-amber-900'
@@ -67,14 +82,26 @@ export default function StundenplanPage() {
> >
<details> <details>
<summary className="cursor-pointer text-sm font-medium"> <summary className="cursor-pointer text-sm font-medium">
Dev: JWT-Token setzen (Anmeldung noch nicht integriert) Anmeldung noch nicht integriert Dev-Token setzen
</summary> </summary>
<div className="mt-3 space-y-2 text-sm">
<p>
Bis die volle BreakPilot-Anmeldung an dieses Modul angebunden ist, muss
ein gueltiger JWT-Token manuell hinterlegt werden. Ohne Token antwortet
die API mit <code className={`px-1 rounded ${isDark ? 'bg-white/10' : 'bg-amber-100'}`}>Authorization header required</code>.
</p>
<ol className="list-decimal list-inside space-y-1 opacity-90">
<li>An BreakPilot anmelden (z.B. ueber das Lehrer-Login)</li>
<li>Im Browser DevTools Application/Storage Cookies oder localStorage den
JWT-Token kopieren (Feldname kann je nach Login-Flow variieren)</li>
<li>Token unten einfuegen, Speichern, Seite neu laden</li>
</ol>
<div className="mt-2 flex gap-2"> <div className="mt-2 flex gap-2">
<input <input
type="password" type="password"
value={token} value={token}
onChange={e => setToken(e.target.value)} onChange={e => setToken(e.target.value)}
placeholder="Bearer-Token" placeholder="Bearer-Token (ohne 'Bearer '-Prefix)"
className={`flex-1 px-3 py-1.5 rounded-lg text-sm ${ className={`flex-1 px-3 py-1.5 rounded-lg text-sm ${
isDark ? 'bg-white/10 border border-white/20 text-white' : 'bg-white border border-slate-300 text-slate-900' isDark ? 'bg-white/10 border border-white/20 text-white' : 'bg-white border border-slate-300 text-slate-900'
}`} }`}
@@ -86,6 +113,12 @@ export default function StundenplanPage() {
Speichern Speichern
</button> </button>
</div> </div>
{tokenSaved && (
<p className={`text-xs ${isDark ? 'text-emerald-300' : 'text-emerald-700'}`}>
Token gespeichert. Seite neu laden, um die Aenderung zu uebernehmen.
</p>
)}
</div>
</details> </details>
</div> </div>
@@ -97,11 +130,11 @@ export default function StundenplanPage() {
className={`px-4 py-2 rounded-xl text-sm font-medium transition-colors ${ className={`px-4 py-2 rounded-xl text-sm font-medium transition-colors ${
tab === t.id tab === t.id
? isDark ? isDark
? 'bg-white/20 text-white' ? 'bg-white/20 text-white shadow-lg'
: 'bg-indigo-100 text-indigo-900' : 'bg-indigo-600 text-white shadow-lg'
: isDark : isDark
? 'bg-white/5 text-white/70 hover:bg-white/10' ? 'bg-white/5 text-white/70 hover:bg-white/15'
: 'bg-white text-slate-700 hover:bg-slate-100 border border-slate-200' : 'bg-white/70 text-slate-700 hover:bg-white border border-slate-200'
}`} }`}
> >
{t.label} {t.label}
@@ -120,6 +153,7 @@ export default function StundenplanPage() {
{tab === 'assignments' && <AssignmentsManager />} {tab === 'assignments' && <AssignmentsManager />}
{tab === 'regeln' && <RegelnHub />} {tab === 'regeln' && <RegelnHub />}
</section> </section>
</div>
</main> </main>
</div> </div>
) )
+4
View File
@@ -240,6 +240,8 @@ export interface TimetableSolution {
started_at?: string | null started_at?: string | null
finished_at?: string | null finished_at?: string | null
created_at: string created_at: string
parent_solution_id?: string | null
seconds_limit?: number | null
} }
export interface TimetableLesson { export interface TimetableLesson {
@@ -261,4 +263,6 @@ export interface TimetableLesson {
export interface CreateTimetableSolution { export interface CreateTimetableSolution {
name?: string name?: string
parent_solution_id?: string | null
seconds_limit?: number | null
} }
+10
View File
@@ -118,6 +118,16 @@ export async function mockSchoolApi(page: Page, opts: MockOpts = {}) {
await page.route(/\/api\/school\/timetable\/solutions\/[^/]+\/lessons$/, async (route) => { await page.route(/\/api\/school\/timetable\/solutions\/[^/]+\/lessons$/, async (route) => {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(lessons) }) return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(lessons) })
}) })
// Phase 7: lesson-level pin toggle.
await page.route(/\/api\/school\/timetable\/lessons\/[^/]+\/pin$/, async (route) => {
if (route.request().method() !== 'PUT') return route.fulfill({ status: 405 })
const body = JSON.parse(route.request().postData() || '{}')
return route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify({ message: 'ok', pinned: body.pinned ?? false }),
})
})
await page.route(/\/api\/school\/timetable\/solutions\/[^/]+$/, async (route) => { await page.route(/\/api\/school\/timetable\/solutions\/[^/]+$/, async (route) => {
if (route.request().method() === 'DELETE') { if (route.request().method() === 'DELETE') {
return route.fulfill({ status: 200, contentType: 'application/json', body: '{"message":"deleted"}' }) return route.fulfill({ status: 200, contentType: 'application/json', body: '{"message":"deleted"}' })
+81 -4
View File
@@ -18,7 +18,15 @@ test.describe('Stundenplan — Page Shell', () => {
test('page loads with title and subtitle', async ({ page }) => { test('page loads with title and subtitle', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Stundenplan' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Stundenplan' })).toBeVisible()
await expect(page.getByText('Stammdaten und Regeln fuer den Solver')).toBeVisible() await expect(page.getByText('Stammdaten, Regeln und Plan-Generierung fuer den Schul-Stundenplan')).toBeVisible()
})
test('help panel toggles open', async ({ page }) => {
const panel = page.getByTestId('help-panel')
await expect(panel).toBeVisible()
await expect(panel.getByText('1. Klassen, Lehrer, Faecher, Raeume anlegen')).toHaveCount(0)
await panel.getByRole('button', { name: /Bedienungsanleitung/ }).click()
await expect(panel.getByText('1. Klassen, Lehrer, Faecher, Raeume anlegen')).toBeVisible()
}) })
test('shows all 9 tabs', async ({ page }) => { test('shows all 9 tabs', async ({ page }) => {
@@ -34,9 +42,8 @@ test.describe('Stundenplan — Page Shell', () => {
}) })
test('JWT dev field exists and persists into localStorage', async ({ page }) => { test('JWT dev field exists and persists into localStorage', async ({ page }) => {
await page.getByText('Dev: JWT-Token setzen').click() await page.getByText('Anmeldung noch nicht integriert').click()
page.on('dialog', d => d.accept()) await page.getByPlaceholder(/Bearer-Token/).fill('test-jwt-abc')
await page.getByPlaceholder('Bearer-Token').fill('test-jwt-abc')
await page.getByRole('button', { name: 'Speichern' }).click() await page.getByRole('button', { name: 'Speichern' }).click()
const stored = await page.evaluate(() => localStorage.getItem('bp_stundenplan_jwt')) const stored = await page.evaluate(() => localStorage.getItem('bp_stundenplan_jwt'))
expect(stored).toBe('test-jwt-abc') expect(stored).toBe('test-jwt-abc')
@@ -401,3 +408,73 @@ test.describe('Stundenplan — PlanView grid', () => {
await expect(page.locator('select').last()).toHaveValue('r1') await expect(page.locator('select').last()).toHaveValue('r1')
}) })
}) })
// ==========================================================================
// Phase 7 — Pinning + Plan-Versionen
// ==========================================================================
test.describe('Stundenplan — Solve options (parent + seconds limit)', () => {
test('parent-selector disabled when no completed solutions exist', async ({ page }) => {
await mockSchoolApi(page, { solutions: [] })
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await expect(page.getByTestId('parent-selector')).toBeDisabled()
})
test('parent-selector lists only completed solutions', async ({ page }) => {
await mockSchoolApi(page, {
solutions: [
{ id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: 0, created_at: '2026-05-22T10:00:00Z' },
{ id: 's2', created_by_user_id: 'u', name: 'Plan B', status: 'failed', created_at: '2026-05-22T11:00:00Z' },
{ id: 's3', created_by_user_id: 'u', name: 'Plan C', status: 'completed', hard_score: 0, soft_score: -5, created_at: '2026-05-22T12:00:00Z' },
],
})
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
const sel = page.getByTestId('parent-selector')
await expect(sel).toBeEnabled()
// Only Plan A and Plan C are options (Plan B failed).
await expect(sel.locator('option', { hasText: 'Plan A' })).toHaveCount(1)
await expect(sel.locator('option', { hasText: 'Plan C' })).toHaveCount(1)
await expect(sel.locator('option', { hasText: 'Plan B' })).toHaveCount(0)
})
test('seconds-limit field accepts numeric input', async ({ page }) => {
await mockSchoolApi(page, { solutions: [] })
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
const field = page.getByTestId('seconds-limit')
await field.fill('120')
await expect(field).toHaveValue('120')
})
})
test.describe('Stundenplan — Pin lesson', () => {
const baseOpts = () => ({
solutions: [
{ id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: 0, created_at: '2026-05-22T10:00:00Z' },
],
lessons: [
{ id: 'l1', solution_id: 's1', class_id: 'c1', subject_id: 'sub1', teacher_id: 't1', room_id: 'r1', day_of_week: 1, period_index: 1, pinned: false, created_at: '', class_name: '5a', subject_name: 'Mathe', teacher_name: 'Schmidt, Anna', room_name: 'A101' },
],
})
test('pin button is rendered on each lesson cell', async ({ page }) => {
await mockSchoolApi(page, baseOpts())
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: 'Anzeigen' }).click()
await expect(page.getByTestId('pin-l1')).toBeVisible()
})
test('clicking pin flips the icon via optimistic update', async ({ page }) => {
await mockSchoolApi(page, baseOpts())
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: 'Anzeigen' }).click()
const pinBtn = page.getByTestId('pin-l1')
await expect(pinBtn).toContainText('📌')
await pinBtn.click()
await expect(pinBtn).toContainText('🔒')
})
})
+8
View File
@@ -152,3 +152,11 @@ export const solutionsApi = {
lessons: (id: string) => lessons: (id: string) =>
apiFetch<TimetableLesson[]>(`/timetable/solutions/${id}/lessons`), apiFetch<TimetableLesson[]>(`/timetable/solutions/${id}/lessons`),
} }
export const lessonsApi = {
pin: (id: string, pinned: boolean) =>
apiFetch<{ message: string; pinned: boolean }>(`/timetable/lessons/${id}/pin`, {
method: 'PUT',
body: JSON.stringify({ pinned }),
}),
}
+6
View File
@@ -15,6 +15,7 @@ from timefold.solver.domain import (
planning_entity, planning_entity,
planning_solution, planning_solution,
PlanningVariable, PlanningVariable,
PlanningPin,
PlanningId, PlanningId,
PlanningEntityCollectionProperty, PlanningEntityCollectionProperty,
ProblemFactCollectionProperty, ProblemFactCollectionProperty,
@@ -88,6 +89,10 @@ class Lesson:
Curriculum says "5a needs 4 hours of Mathe per week" → 4 Lesson instances Curriculum says "5a needs 4 hours of Mathe per week" → 4 Lesson instances
with school_class=5a, subject=Mathe, teacher fixed (from tt_assignment). with school_class=5a, subject=Mathe, teacher fixed (from tt_assignment).
The solver assigns timeslot + room. 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] id: Annotated[str, PlanningId]
@@ -96,6 +101,7 @@ class Lesson:
teacher: Teacher teacher: Teacher
timeslot: Annotated[Optional[Timeslot], PlanningVariable] = field(default=None) timeslot: Annotated[Optional[Timeslot], PlanningVariable] = field(default=None)
room: Annotated[Optional[Room], PlanningVariable] = field(default=None) room: Annotated[Optional[Room], PlanningVariable] = field(default=None)
pinned: Annotated[bool, PlanningPin] = field(default=False)
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.school_class}-{self.subject}#{self.id[:8]}" return f"{self.school_class}-{self.subject}#{self.id[:8]}"
+64 -2
View File
@@ -17,7 +17,11 @@ from .rules import (
) )
async def build_problem(pool: asyncpg.Pool, user_id: str) -> Timetable: async def build_problem(
pool: asyncpg.Pool,
user_id: str,
parent_solution_id: str | None = None,
) -> Timetable:
async with pool.acquire() as conn: async with pool.acquire() as conn:
timeslots = await _load_timeslots(conn, user_id) timeslots = await _load_timeslots(conn, user_id)
rooms = await _load_rooms(conn, user_id) rooms = await _load_rooms(conn, user_id)
@@ -27,6 +31,11 @@ async def build_problem(pool: asyncpg.Pool, user_id: str) -> Timetable:
lessons = await _build_lessons(conn, user_id, classes, subjects, teachers) lessons = await _build_lessons(conn, user_id, classes, subjects, teachers)
rules = await _load_rules(conn, user_id) 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( return Timetable(
timeslots=timeslots, timeslots=timeslots,
rooms=rooms, rooms=rooms,
@@ -39,6 +48,58 @@ async def build_problem(pool: asyncpg.Pool, user_id: str) -> Timetable:
) )
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]: async def _load_timeslots(conn: asyncpg.Connection, user_id: str) -> list[Timeslot]:
rows = await conn.fetch(""" rows = await conn.fetch("""
SELECT id::text, day_of_week, period_index, SELECT id::text, day_of_week, period_index,
@@ -217,7 +278,7 @@ async def persist_solution(
INSERT INTO tt_lesson INSERT INTO tt_lesson
(solution_id, class_id, subject_id, teacher_id, room_id, (solution_id, class_id, subject_id, teacher_id, room_id,
day_of_week, period_index, pinned) day_of_week, period_index, pinned)
VALUES ($1, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6, $7, false) VALUES ($1, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8)
""", """,
solution_id, solution_id,
lesson.school_class.id, lesson.school_class.id,
@@ -226,6 +287,7 @@ async def persist_solution(
lesson.room.id if lesson.room else None, lesson.room.id if lesson.room else None,
lesson.timeslot.day_of_week, lesson.timeslot.day_of_week,
lesson.timeslot.period_index, lesson.timeslot.period_index,
lesson.pinned,
) )
+22 -6
View File
@@ -35,7 +35,10 @@ logger = logging.getLogger(__name__)
_executor = ThreadPoolExecutor(max_workers=2) _executor = ThreadPoolExecutor(max_workers=2)
_solver_factory = SolverFactory.create( def _build_factory(seconds: int) -> SolverFactory:
"""One factory per solve so we can honour per-job timeout overrides.
Cheap to construct — the Java side caches what it can across builds."""
return SolverFactory.create(
SolverConfig( SolverConfig(
solution_class=Timetable, solution_class=Timetable,
entity_class_list=[Lesson], entity_class_list=[Lesson],
@@ -43,24 +46,37 @@ _solver_factory = SolverFactory.create(
constraint_provider_function=define_constraints, constraint_provider_function=define_constraints,
), ),
termination_config=TerminationConfig( termination_config=TerminationConfig(
spent_limit=Duration(seconds=settings.solver_seconds_limit), spent_limit=Duration(seconds=seconds),
), ),
) )
) )
def _solve_sync(problem: Timetable) -> Timetable: def _solve_sync(problem: Timetable, seconds: int) -> Timetable:
"""Blocking solver call; runs in a worker thread.""" """Blocking solver call; runs in a worker thread."""
solver = _solver_factory.build_solver() solver = _build_factory(seconds).build_solver()
return solver.solve(problem) return solver.solve(problem)
async def _fetch_solve_options(pool, solution_id: str) -> tuple[str | None, int]:
"""Read parent_solution_id + seconds_limit from tt_solution."""
async with pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT parent_solution_id::text, seconds_limit
FROM tt_solution WHERE id = $1
""", solution_id)
parent = row["parent_solution_id"] if row else None
limit = row["seconds_limit"] if row and row["seconds_limit"] else settings.solver_seconds_limit
return parent, int(limit)
async def run_solve(solution_id: str, user_id: str) -> None: async def run_solve(solution_id: str, user_id: str) -> None:
"""Top-level async entry. Caller fires-and-forgets via BackgroundTasks.""" """Top-level async entry. Caller fires-and-forgets via BackgroundTasks."""
pool = await get_pool() pool = await get_pool()
try: try:
parent_id, seconds = await _fetch_solve_options(pool, solution_id)
await mark_running(pool, solution_id) await mark_running(pool, solution_id)
problem = await build_problem(pool, user_id) problem = await build_problem(pool, user_id, parent_solution_id=parent_id)
if not problem.lessons: if not problem.lessons:
await mark_failed(pool, solution_id, await mark_failed(pool, solution_id,
@@ -76,7 +92,7 @@ async def run_solve(solution_id: str, user_id: str) -> None:
return return
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
solved: Timetable = await loop.run_in_executor(_executor, _solve_sync, problem) solved: Timetable = await loop.run_in_executor(_executor, _solve_sync, problem, seconds)
score = solved.score score = solved.score
hard = score.hard_score() if score else 0 hard = score.hard_score() if score else 0