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
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>
192 lines
6.7 KiB
Go
192 lines
6.7 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/breakpilot/school-service/internal/models"
|
|
)
|
|
|
|
// TimetableSolutionService persists solver runs and forwards solve requests
|
|
// to the timetable-solver-service. The solver writes lesson rows back to the
|
|
// 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) {
|
|
// 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
|
|
err := s.db.QueryRow(ctx, `
|
|
INSERT INTO tt_solution (created_by_user_id, name, status, parent_solution_id, seconds_limit)
|
|
VALUES ($1, $2, 'pending', $3::uuid, $4)
|
|
RETURNING id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score,
|
|
COALESCE(error_message, ''), started_at, finished_at, created_at,
|
|
parent_solution_id, seconds_limit
|
|
`, userID, req.Name, parentID, req.SecondsLimit).Scan(
|
|
&sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status,
|
|
&sol.HardScore, &sol.SoftScore, &sol.ErrorMessage,
|
|
&sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt,
|
|
&sol.ParentSolutionID, &sol.SecondsLimit,
|
|
)
|
|
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) {
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score,
|
|
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
|
|
`, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []models.TimetableSolution
|
|
for rows.Next() {
|
|
var sol models.TimetableSolution
|
|
if err := rows.Scan(&sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status,
|
|
&sol.HardScore, &sol.SoftScore, &sol.ErrorMessage,
|
|
&sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt,
|
|
&sol.ParentSolutionID, &sol.SecondsLimit); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, sol)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *TimetableService) GetSolution(ctx context.Context, id, userID string) (*models.TimetableSolution, error) {
|
|
var sol models.TimetableSolution
|
|
err := s.db.QueryRow(ctx, `
|
|
SELECT id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score,
|
|
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
|
|
`, id, userID).Scan(
|
|
&sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status,
|
|
&sol.HardScore, &sol.SoftScore, &sol.ErrorMessage,
|
|
&sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt,
|
|
&sol.ParentSolutionID, &sol.SecondsLimit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &sol, nil
|
|
}
|
|
|
|
func (s *TimetableService) ListLessons(ctx context.Context, solutionID, userID string) ([]models.TimetableLesson, error) {
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT l.id, l.solution_id, l.class_id, l.subject_id, l.teacher_id, l.room_id,
|
|
l.day_of_week, l.period_index, l.pinned, l.created_at,
|
|
cl.name, sub.name, t.last_name || ', ' || t.first_name,
|
|
COALESCE(r.name, '')
|
|
FROM tt_lesson l
|
|
JOIN tt_solution s ON l.solution_id = s.id
|
|
JOIN tt_class cl ON l.class_id = cl.id
|
|
JOIN tt_subject sub ON l.subject_id = sub.id
|
|
JOIN tt_teacher t ON l.teacher_id = t.id
|
|
LEFT JOIN tt_room r ON l.room_id = r.id
|
|
WHERE s.id = $1 AND s.created_by_user_id = $2
|
|
ORDER BY l.day_of_week, l.period_index
|
|
`, solutionID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []models.TimetableLesson
|
|
for rows.Next() {
|
|
var l models.TimetableLesson
|
|
if err := rows.Scan(&l.ID, &l.SolutionID, &l.ClassID, &l.SubjectID, &l.TeacherID, &l.RoomID,
|
|
&l.DayOfWeek, &l.PeriodIndex, &l.Pinned, &l.CreatedAt,
|
|
&l.ClassName, &l.SubjectName, &l.TeacherName, &l.RoomName); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, l)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *TimetableService) DeleteSolution(ctx context.Context, id, userID string) error {
|
|
_, err := s.db.Exec(ctx, `DELETE FROM tt_solution WHERE id = $1 AND created_by_user_id = $2`, id, userID)
|
|
return err
|
|
}
|
|
|
|
// TriggerSolve hands the freshly-created solution off to the solver-service.
|
|
// The solver writes back to tt_solution/tt_lesson directly once finished, so
|
|
// from this side we just need to fire-and-forget and let the client poll.
|
|
func (s *TimetableService) TriggerSolve(ctx context.Context, solverURL, solutionID, userID string) error {
|
|
payload := map[string]string{
|
|
"solution_id": solutionID,
|
|
"created_by_user_id": userID,
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
|
|
// 5s timeout — solver should accept the job in milliseconds and run async.
|
|
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(reqCtx, "POST", solverURL+"/api/v1/solve", bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
// Mark solution as failed so the user sees something went wrong.
|
|
_, _ = s.db.Exec(ctx, `
|
|
UPDATE tt_solution SET status = 'failed', error_message = $1, finished_at = NOW()
|
|
WHERE id = $2
|
|
`, "solver-service unreachable: "+err.Error(), solutionID)
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
_, _ = s.db.Exec(ctx, `
|
|
UPDATE tt_solution SET status = 'failed', error_message = $1, finished_at = NOW()
|
|
WHERE id = $2
|
|
`, fmt.Sprintf("solver returned HTTP %d", resp.StatusCode), solutionID)
|
|
return fmt.Errorf("solver returned HTTP %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|