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>
108 lines
2.3 KiB
Go
108 lines
2.3 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/joho/godotenv"
|
|
)
|
|
|
|
// Config holds all configuration for the school service
|
|
type Config struct {
|
|
// Server
|
|
Port string
|
|
Environment string
|
|
|
|
// Database
|
|
DatabaseURL string
|
|
|
|
// JWT
|
|
JWTSecret string
|
|
|
|
// CORS
|
|
AllowedOrigins []string
|
|
|
|
// Rate Limiting
|
|
RateLimitRequests int
|
|
RateLimitWindow int // in seconds
|
|
|
|
// LLM Gateway (for AI features)
|
|
LLMGatewayURL string
|
|
|
|
// Timetable solver service (Python/FastAPI, port 8095)
|
|
SolverServiceURL string
|
|
}
|
|
|
|
// Load loads configuration from environment variables
|
|
func Load() (*Config, error) {
|
|
// Load .env file if exists (for development)
|
|
_ = godotenv.Load()
|
|
|
|
cfg := &Config{
|
|
Port: getEnv("PORT", "8084"),
|
|
Environment: getEnv("ENVIRONMENT", "development"),
|
|
DatabaseURL: getEnv("DATABASE_URL", ""),
|
|
JWTSecret: getEnv("JWT_SECRET", ""),
|
|
RateLimitRequests: getEnvInt("RATE_LIMIT_REQUESTS", 100),
|
|
RateLimitWindow: getEnvInt("RATE_LIMIT_WINDOW", 60),
|
|
LLMGatewayURL: getEnv("LLM_GATEWAY_URL", "http://backend:8000/llm"),
|
|
SolverServiceURL: getEnv("SOLVER_SERVICE_URL", "http://timetable-solver-service:8095"),
|
|
}
|
|
|
|
// Parse allowed origins
|
|
originsStr := getEnv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:8000")
|
|
cfg.AllowedOrigins = parseCommaSeparated(originsStr)
|
|
|
|
// Validate required fields
|
|
if cfg.DatabaseURL == "" {
|
|
return nil, fmt.Errorf("DATABASE_URL is required")
|
|
}
|
|
|
|
if cfg.JWTSecret == "" {
|
|
return nil, fmt.Errorf("JWT_SECRET is required")
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
func getEnv(key, defaultValue string) string {
|
|
if value := os.Getenv(key); value != "" {
|
|
return value
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
func getEnvInt(key string, defaultValue int) int {
|
|
if value := os.Getenv(key); value != "" {
|
|
var result int
|
|
fmt.Sscanf(value, "%d", &result)
|
|
return result
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
func parseCommaSeparated(s string) []string {
|
|
if s == "" {
|
|
return []string{}
|
|
}
|
|
var result []string
|
|
start := 0
|
|
for i := 0; i <= len(s); i++ {
|
|
if i == len(s) || s[i] == ',' {
|
|
item := s[start:i]
|
|
// Trim whitespace
|
|
for len(item) > 0 && item[0] == ' ' {
|
|
item = item[1:]
|
|
}
|
|
for len(item) > 0 && item[len(item)-1] == ' ' {
|
|
item = item[:len(item)-1]
|
|
}
|
|
if item != "" {
|
|
result = append(result, item)
|
|
}
|
|
start = i + 1
|
|
}
|
|
}
|
|
return result
|
|
}
|