Initial commit: breakpilot-lehrer - Lehrer KI Platform

Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website,
Klausur-Service, School-Service, Voice-Service, Geo-Service,
BreakPilot Drive, Agent-Core

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-11 23:47:26 +01:00
commit 5a31f52310
1224 changed files with 425430 additions and 0 deletions

54
school-service/Dockerfile Normal file
View File

@@ -0,0 +1,54 @@
# Build stage
FROM golang:1.23-alpine AS builder
# Install git and ca-certificates (needed for fetching dependencies)
RUN apk add --no-cache git ca-certificates
# Set working directory
WORKDIR /app
# Copy go mod files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o school-service ./cmd/server
# Final stage
FROM alpine:3.19
# Install ca-certificates for HTTPS requests
RUN apk --no-cache add ca-certificates tzdata
# Set timezone
ENV TZ=Europe/Berlin
# Create non-root user
RUN adduser -D -g '' appuser
# Set working directory
WORKDIR /app
# Copy binary from builder
COPY --from=builder /app/school-service .
# Copy templates directory
COPY --from=builder /app/templates ./templates
# Use non-root user
USER appuser
# Expose port
EXPOSE 8084
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8084/health || exit 1
# Run the application
CMD ["./school-service"]

View File

@@ -0,0 +1,92 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/breakpilot/school-service/internal/seed"
)
func main() {
// Parse command line flags
teacherIDStr := flag.String("teacher-id", "", "Teacher UUID (required)")
dbURL := flag.String("db", "", "Database URL (or set DATABASE_URL env var)")
flag.Parse()
// Get database URL
databaseURL := *dbURL
if databaseURL == "" {
databaseURL = os.Getenv("DATABASE_URL")
}
if databaseURL == "" {
databaseURL = "postgres://postgres:postgres@localhost:5432/school_service?sslmode=disable"
}
// Get teacher ID
var teacherID uuid.UUID
if *teacherIDStr == "" {
// Use a default demo teacher ID if not provided
teacherID = uuid.MustParse("e9484ad9-32ee-4f2b-a4e1-d182e02ccf20")
log.Printf("Using default teacher ID: %s", teacherID)
} else {
var err error
teacherID, err = uuid.Parse(*teacherIDStr)
if err != nil {
log.Fatalf("Invalid teacher ID: %v", err)
}
}
// Connect to database
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
log.Fatalf("Unable to parse database URL: %v", err)
}
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
log.Fatalf("Unable to connect to database: %v", err)
}
defer pool.Close()
// Test connection
if err := pool.Ping(ctx); err != nil {
log.Fatalf("Unable to ping database: %v", err)
}
fmt.Println("==============================================")
fmt.Println(" School Service - Seed Data Generator")
fmt.Println("==============================================")
fmt.Printf("Teacher ID: %s\n", teacherID)
fmt.Printf("Database: %s\n", databaseURL)
fmt.Println()
// Run seeder
seeder := seed.NewSeeder(pool, teacherID)
if err := seeder.SeedAll(ctx); err != nil {
log.Fatalf("Seeding failed: %v", err)
}
fmt.Println()
fmt.Println("==============================================")
fmt.Println(" Seed Data Generation Complete!")
fmt.Println("==============================================")
fmt.Println()
fmt.Println("Created:")
fmt.Println(" - 3 School Years (2022/23, 2023/24, 2024/25)")
fmt.Println(" - 6 Classes per year (5a, 5b, 6a, 6b, 7a, 7b)")
fmt.Println(" - 15-25 Students per class")
fmt.Println(" - 10 Subjects")
fmt.Println(" - Exam results with realistic grade distribution")
fmt.Println(" - Attendance records")
fmt.Println()
}

View File

@@ -0,0 +1,133 @@
package main
import (
"log"
"github.com/breakpilot/school-service/internal/config"
"github.com/breakpilot/school-service/internal/database"
"github.com/breakpilot/school-service/internal/handlers"
"github.com/breakpilot/school-service/internal/middleware"
"github.com/gin-gonic/gin"
)
func main() {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Set Gin mode based on environment
if cfg.Environment == "production" {
gin.SetMode(gin.ReleaseMode)
}
// Connect to database
db, err := database.Connect(cfg.DatabaseURL)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer db.Close()
// Run migrations
if err := database.Migrate(db); err != nil {
log.Fatalf("Failed to run migrations: %v", err)
}
// Create handler
handler := handlers.NewHandler(db.Pool, cfg.LLMGatewayURL)
// Create router
router := gin.New()
router.Use(gin.Recovery())
router.Use(middleware.RequestLogger())
router.Use(middleware.CORS())
router.Use(middleware.RateLimiter())
// Health endpoint (no auth required)
router.GET("/health", handler.Health)
// API routes (auth required)
api := router.Group("/api/v1/school")
api.Use(middleware.AuthMiddleware(cfg.JWTSecret))
{
// School Years
api.GET("/years", handler.GetSchoolYears)
api.POST("/years", handler.CreateSchoolYear)
// Classes
api.GET("/classes", handler.GetClasses)
api.POST("/classes", handler.CreateClass)
api.GET("/classes/:id", handler.GetClass)
api.DELETE("/classes/:id", handler.DeleteClass)
// Students (nested under classes)
api.GET("/classes/:id/students", handler.GetStudents)
api.POST("/classes/:id/students", handler.CreateStudent)
api.POST("/classes/:id/students/import", handler.ImportStudents)
api.DELETE("/classes/:id/students/:studentId", handler.DeleteStudent)
// Subjects
api.GET("/subjects", handler.GetSubjects)
api.POST("/subjects", handler.CreateSubject)
api.DELETE("/subjects/:id", handler.DeleteSubject)
// Exams
api.GET("/exams", handler.GetExams)
api.POST("/exams", handler.CreateExam)
api.GET("/exams/:id", handler.GetExam)
api.PUT("/exams/:id", handler.UpdateExam)
api.DELETE("/exams/:id", handler.DeleteExam)
api.POST("/exams/:id/generate-variant", handler.GenerateExamVariant)
api.GET("/exams/:id/results", handler.GetExamResults)
api.POST("/exams/:id/results", handler.SaveExamResults)
api.PUT("/exams/:id/results/:studentId/approve", handler.ApproveExamResult)
api.GET("/exams/:id/needs-rewrite", handler.GetStudentsNeedingRewrite)
// Grades
api.GET("/grades/:classId", handler.GetClassGrades)
api.GET("/grades/student/:studentId", handler.GetStudentGrades)
api.PUT("/grades/:studentId/:subjectId/oral", handler.UpdateOralGrade)
api.POST("/grades/calculate", handler.CalculateFinalGrades)
api.POST("/grades/transfer", handler.TransferApprovedGrades)
api.PUT("/grades/:studentId/:subjectId/lock", handler.LockFinalGrade)
api.PUT("/grades/:studentId/:subjectId/weights", handler.UpdateGradeWeights)
// Statistics
api.GET("/statistics/:classId", handler.GetClassStatistics)
api.GET("/statistics/:classId/subject/:subjectId", handler.GetSubjectStatistics)
api.GET("/statistics/student/:studentId", handler.GetStudentStatistics)
api.GET("/statistics/:classId/notenspiegel", handler.GetNotenspiegel)
// Attendance
api.GET("/attendance/:classId", handler.GetClassAttendance)
api.GET("/attendance/student/:studentId", handler.GetStudentAttendance)
api.POST("/attendance", handler.CreateAttendance)
api.POST("/attendance/:classId/bulk", handler.BulkCreateAttendance)
api.DELETE("/attendance/:id", handler.DeleteAttendance)
// Gradebook Entries
api.GET("/gradebook/:classId", handler.GetGradebookEntries)
api.GET("/gradebook/student/:studentId", handler.GetStudentEntries)
api.POST("/gradebook", handler.CreateGradebookEntry)
api.DELETE("/gradebook/:id", handler.DeleteGradebookEntry)
// Certificates
api.GET("/certificates/templates", handler.GetCertificateTemplates)
api.GET("/certificates/class/:classId", handler.GetClassCertificates)
api.GET("/certificates/feedback/:studentId", handler.GenerateGradeFeedback)
api.POST("/certificates/generate", handler.GenerateCertificate)
api.POST("/certificates/generate-bulk", handler.BulkGenerateCertificates)
api.GET("/certificates/detail/:id", handler.GetCertificate)
api.PUT("/certificates/detail/:id", handler.UpdateCertificate)
api.PUT("/certificates/detail/:id/finalize", handler.FinalizeCertificate)
api.GET("/certificates/detail/:id/pdf", handler.GetCertificatePDF)
api.DELETE("/certificates/detail/:id", handler.DeleteCertificate)
}
// Start server
log.Printf("School Service starting on port %s", cfg.Port)
if err := router.Run(":" + cfg.Port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}

54
school-service/go.mod Normal file
View File

@@ -0,0 +1,54 @@
module github.com/breakpilot/school-service
go 1.23.0
require (
github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.6
github.com/joho/godotenv v1.5.1
github.com/stretchr/testify v1.11.1
)
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

112
school-service/go.sum Normal file
View File

@@ -0,0 +1,112 @@
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,103 @@
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
}
// 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"),
}
// 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
}

View File

@@ -0,0 +1,225 @@
package database
import (
"context"
"log"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// DB wraps the database connection pool
type DB struct {
Pool *pgxpool.Pool
}
// Connect creates a new database connection pool
func Connect(databaseURL string) (*DB, error) {
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, err
}
// Configure connection pool
config.MaxConns = 25
config.MinConns = 5
config.MaxConnLifetime = time.Hour
config.MaxConnIdleTime = 30 * time.Minute
config.HealthCheckPeriod = time.Minute
pool, err := pgxpool.NewWithConfig(context.Background(), config)
if err != nil {
return nil, err
}
// Test connection
if err := pool.Ping(context.Background()); err != nil {
return nil, err
}
log.Println("Database connection established")
return &DB{Pool: pool}, nil
}
// Close closes the database connection pool
func (db *DB) Close() {
db.Pool.Close()
}
// Migrate runs database migrations
func Migrate(db *DB) error {
ctx := context.Background()
migrations := []string{
// School Years
`CREATE TABLE IF NOT EXISTS school_years (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(20) NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
is_current BOOLEAN DEFAULT false,
teacher_id UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Classes
`CREATE TABLE IF NOT EXISTS classes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
teacher_id UUID NOT NULL,
school_year_id UUID REFERENCES school_years(id),
name VARCHAR(20) NOT NULL,
grade_level INT NOT NULL,
school_type VARCHAR(30),
federal_state VARCHAR(50),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(teacher_id, school_year_id, name)
)`,
// Students
`CREATE TABLE IF NOT EXISTS students (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
class_id UUID REFERENCES classes(id) ON DELETE CASCADE,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
birth_date DATE,
student_number VARCHAR(50),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Subjects
`CREATE TABLE IF NOT EXISTS subjects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
teacher_id UUID NOT NULL,
name VARCHAR(100) NOT NULL,
short_name VARCHAR(10),
is_main_subject BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(teacher_id, name)
)`,
// Exams
`CREATE TABLE IF NOT EXISTS exams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
teacher_id UUID NOT NULL,
class_id UUID REFERENCES classes(id),
subject_id UUID REFERENCES subjects(id),
title VARCHAR(255) NOT NULL,
exam_type VARCHAR(30) NOT NULL,
topic VARCHAR(255),
content TEXT,
source_file_path VARCHAR(500),
difficulty_level INT DEFAULT 3,
duration_minutes INT,
max_points DECIMAL(5,2),
is_template BOOLEAN DEFAULT false,
parent_exam_id UUID REFERENCES exams(id),
status VARCHAR(20) DEFAULT 'draft',
exam_date DATE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Exam Results
`CREATE TABLE IF NOT EXISTS exam_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
exam_id UUID REFERENCES exams(id) ON DELETE CASCADE,
student_id UUID REFERENCES students(id) ON DELETE CASCADE,
points_achieved DECIMAL(5,2),
grade DECIMAL(2,1),
percentage DECIMAL(5,2),
notes TEXT,
is_absent BOOLEAN DEFAULT false,
needs_rewrite BOOLEAN DEFAULT false,
approved_by_teacher BOOLEAN DEFAULT false,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(exam_id, student_id)
)`,
// Grade Overview
`CREATE TABLE IF NOT EXISTS grade_overview (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
student_id UUID REFERENCES students(id) ON DELETE CASCADE,
subject_id UUID REFERENCES subjects(id),
school_year_id UUID REFERENCES school_years(id),
semester INT NOT NULL,
written_grade_avg DECIMAL(3,2),
written_grade_count INT DEFAULT 0,
oral_grade DECIMAL(3,2),
oral_notes TEXT,
final_grade DECIMAL(3,2),
final_grade_locked BOOLEAN DEFAULT false,
written_weight INT DEFAULT 60,
oral_weight INT DEFAULT 40,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(student_id, subject_id, school_year_id, semester)
)`,
// Attendance
`CREATE TABLE IF NOT EXISTS attendance (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
student_id UUID REFERENCES students(id) ON DELETE CASCADE,
date DATE NOT NULL,
status VARCHAR(20) NOT NULL,
periods INT DEFAULT 1,
reason TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(student_id, date)
)`,
// Gradebook Entries
`CREATE TABLE IF NOT EXISTS gradebook_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
class_id UUID REFERENCES classes(id) ON DELETE CASCADE,
student_id UUID REFERENCES students(id),
date DATE NOT NULL,
entry_type VARCHAR(30) NOT NULL,
content TEXT NOT NULL,
is_visible_to_parents BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Certificates
`CREATE TABLE IF NOT EXISTS certificates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
student_id UUID REFERENCES students(id) ON DELETE CASCADE,
school_year_id UUID REFERENCES school_years(id),
semester INT NOT NULL,
certificate_type VARCHAR(30),
template_name VARCHAR(100),
grades_json JSONB,
remarks TEXT,
absence_days INT DEFAULT 0,
absence_days_unexcused INT DEFAULT 0,
generated_pdf_path VARCHAR(500),
status VARCHAR(20) DEFAULT 'draft',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Indexes
`CREATE INDEX IF NOT EXISTS idx_students_class ON students(class_id)`,
`CREATE INDEX IF NOT EXISTS idx_exams_teacher ON exams(teacher_id)`,
`CREATE INDEX IF NOT EXISTS idx_exams_class ON exams(class_id)`,
`CREATE INDEX IF NOT EXISTS idx_exam_results_exam ON exam_results(exam_id)`,
`CREATE INDEX IF NOT EXISTS idx_exam_results_student ON exam_results(student_id)`,
`CREATE INDEX IF NOT EXISTS idx_grade_overview_student ON grade_overview(student_id)`,
`CREATE INDEX IF NOT EXISTS idx_attendance_student ON attendance(student_id)`,
`CREATE INDEX IF NOT EXISTS idx_attendance_date ON attendance(date)`,
`CREATE INDEX IF NOT EXISTS idx_gradebook_class ON gradebook_entries(class_id)`,
}
for _, migration := range migrations {
_, err := db.Pool.Exec(ctx, migration)
if err != nil {
log.Printf("Migration error: %v\nSQL: %s", err, migration)
return err
}
}
log.Println("Database migrations completed")
return nil
}

View File

@@ -0,0 +1,199 @@
package handlers
import (
"net/http"
"strconv"
"github.com/breakpilot/school-service/internal/models"
"github.com/gin-gonic/gin"
)
// GetCertificateTemplates returns available certificate templates
func (h *Handler) GetCertificateTemplates(c *gin.Context) {
templates := h.certificateService.GetAvailableTemplates()
respondSuccess(c, templates)
}
// GenerateCertificate generates a certificate for a student
func (h *Handler) GenerateCertificate(c *gin.Context) {
var req models.GenerateCertificateRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
certificate, err := h.certificateService.GenerateCertificate(c.Request.Context(), &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to generate certificate: "+err.Error())
return
}
respondCreated(c, certificate)
}
// GetClassCertificates returns certificates for a class
func (h *Handler) GetClassCertificates(c *gin.Context) {
classID := c.Param("classId")
semesterStr := c.DefaultQuery("semester", "1")
semester, err := strconv.Atoi(semesterStr)
if err != nil || semester < 1 || semester > 2 {
semester = 1
}
certificates, err := h.certificateService.GetCertificates(c.Request.Context(), classID, semester)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to get certificates: "+err.Error())
return
}
respondSuccess(c, certificates)
}
// GetCertificate returns a single certificate
func (h *Handler) GetCertificate(c *gin.Context) {
certificateID := c.Param("id")
certificate, err := h.certificateService.GetCertificate(c.Request.Context(), certificateID)
if err != nil {
respondError(c, http.StatusNotFound, "Certificate not found")
return
}
respondSuccess(c, certificate)
}
// UpdateCertificate updates a certificate's remarks
func (h *Handler) UpdateCertificate(c *gin.Context) {
certificateID := c.Param("id")
var req struct {
Remarks string `json:"remarks"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
certificate, err := h.certificateService.UpdateCertificate(c.Request.Context(), certificateID, req.Remarks)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to update certificate: "+err.Error())
return
}
respondSuccess(c, certificate)
}
// FinalizeCertificate finalizes a certificate
func (h *Handler) FinalizeCertificate(c *gin.Context) {
certificateID := c.Param("id")
if err := h.certificateService.FinalizeCertificate(c.Request.Context(), certificateID); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to finalize certificate: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Certificate finalized"})
}
// GetCertificatePDF returns the PDF for a certificate
func (h *Handler) GetCertificatePDF(c *gin.Context) {
certificateID := c.Param("id")
pdf, err := h.certificateService.GeneratePDF(c.Request.Context(), certificateID)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to generate PDF: "+err.Error())
return
}
c.Header("Content-Type", "application/pdf")
c.Header("Content-Disposition", "attachment; filename=zeugnis.pdf")
c.Data(http.StatusOK, "application/pdf", pdf)
}
// DeleteCertificate deletes a certificate
func (h *Handler) DeleteCertificate(c *gin.Context) {
certificateID := c.Param("id")
if err := h.certificateService.DeleteCertificate(c.Request.Context(), certificateID); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete certificate: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Certificate deleted"})
}
// BulkGenerateCertificates generates certificates for all students in a class
func (h *Handler) BulkGenerateCertificates(c *gin.Context) {
var req struct {
ClassID string `json:"class_id" binding:"required"`
SchoolYearID string `json:"school_year_id" binding:"required"`
Semester int `json:"semester" binding:"required,min=1,max=2"`
CertificateType models.CertificateType `json:"certificate_type" binding:"required"`
TemplateName string `json:"template_name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
certificates, err := h.certificateService.BulkGenerateCertificates(c.Request.Context(), req.ClassID, req.SchoolYearID, req.Semester, req.CertificateType, req.TemplateName)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to generate certificates: "+err.Error())
return
}
respondSuccess(c, gin.H{
"message": "Certificates generated",
"count": len(certificates),
"certificates": certificates,
})
}
// GenerateGradeFeedback generates AI feedback for a student
func (h *Handler) GenerateGradeFeedback(c *gin.Context) {
studentID := c.Param("studentId")
semesterStr := c.DefaultQuery("semester", "1")
semester, err := strconv.Atoi(semesterStr)
if err != nil || semester < 1 || semester > 2 {
semester = 1
}
// Get student grades
grades, err := h.gradeService.GetStudentGrades(c.Request.Context(), studentID)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to get student grades: "+err.Error())
return
}
// Build grades map for the requested semester
gradeMap := make(map[string]float64)
var studentName string
for _, g := range grades {
if g.Semester == semester && g.FinalGrade != nil {
gradeMap[g.SubjectName] = *g.FinalGrade
studentName = g.StudentName
}
}
if len(gradeMap) == 0 {
respondError(c, http.StatusNotFound, "No grades found for this semester")
return
}
// Generate feedback using AI
feedback, err := h.aiService.GenerateGradeFeedback(c.Request.Context(), studentName, gradeMap, semester)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to generate feedback: "+err.Error())
return
}
respondSuccess(c, gin.H{
"student_id": studentID,
"student_name": studentName,
"semester": semester,
"feedback": feedback,
})
}

View File

@@ -0,0 +1,242 @@
package handlers
import (
"net/http"
"github.com/breakpilot/school-service/internal/models"
"github.com/gin-gonic/gin"
)
// School Year Handlers
// CreateSchoolYear creates a new school year
func (h *Handler) CreateSchoolYear(c *gin.Context) {
teacherID := getUserID(c)
if teacherID == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateSchoolYearRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
year, err := h.classService.CreateSchoolYear(c.Request.Context(), teacherID, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create school year: "+err.Error())
return
}
respondCreated(c, year)
}
// GetSchoolYears returns all school years for the current teacher
func (h *Handler) GetSchoolYears(c *gin.Context) {
teacherID := getUserID(c)
if teacherID == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
years, err := h.classService.GetSchoolYears(c.Request.Context(), teacherID)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to get school years: "+err.Error())
return
}
respondSuccess(c, years)
}
// Class Handlers
// CreateClass creates a new class
func (h *Handler) CreateClass(c *gin.Context) {
teacherID := getUserID(c)
if teacherID == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateClassRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
class, err := h.classService.CreateClass(c.Request.Context(), teacherID, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create class: "+err.Error())
return
}
respondCreated(c, class)
}
// GetClasses returns all classes for the current teacher
func (h *Handler) GetClasses(c *gin.Context) {
teacherID := getUserID(c)
if teacherID == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
classes, err := h.classService.GetClasses(c.Request.Context(), teacherID)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to get classes: "+err.Error())
return
}
respondSuccess(c, classes)
}
// GetClass returns a single class
func (h *Handler) GetClass(c *gin.Context) {
teacherID := getUserID(c)
classID := c.Param("id")
if teacherID == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
class, err := h.classService.GetClass(c.Request.Context(), classID, teacherID)
if err != nil {
respondError(c, http.StatusNotFound, "Class not found")
return
}
respondSuccess(c, class)
}
// DeleteClass deletes a class
func (h *Handler) DeleteClass(c *gin.Context) {
teacherID := getUserID(c)
classID := c.Param("id")
if teacherID == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.classService.DeleteClass(c.Request.Context(), classID, teacherID); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete class: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Class deleted"})
}
// Student Handlers
// CreateStudent creates a new student in a class
func (h *Handler) CreateStudent(c *gin.Context) {
classID := c.Param("id")
var req models.CreateStudentRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
student, err := h.classService.CreateStudent(c.Request.Context(), classID, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create student: "+err.Error())
return
}
respondCreated(c, student)
}
// GetStudents returns all students in a class
func (h *Handler) GetStudents(c *gin.Context) {
classID := c.Param("id")
students, err := h.classService.GetStudents(c.Request.Context(), classID)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to get students: "+err.Error())
return
}
respondSuccess(c, students)
}
// DeleteStudent deletes a student
func (h *Handler) DeleteStudent(c *gin.Context) {
studentID := c.Param("studentId")
if err := h.classService.DeleteStudent(c.Request.Context(), studentID); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete student: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Student deleted"})
}
// ImportStudents imports students from CSV (placeholder)
func (h *Handler) ImportStudents(c *gin.Context) {
// TODO: Implement CSV import
// For now, return a not implemented response
respondError(c, http.StatusNotImplemented, "CSV import not yet implemented")
}
// Subject Handlers
// CreateSubject creates a new subject
func (h *Handler) CreateSubject(c *gin.Context) {
teacherID := getUserID(c)
if teacherID == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateSubjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
subject, err := h.classService.CreateSubject(c.Request.Context(), teacherID, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create subject: "+err.Error())
return
}
respondCreated(c, subject)
}
// GetSubjects returns all subjects for the current teacher
func (h *Handler) GetSubjects(c *gin.Context) {
teacherID := getUserID(c)
if teacherID == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
subjects, err := h.classService.GetSubjects(c.Request.Context(), teacherID)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to get subjects: "+err.Error())
return
}
respondSuccess(c, subjects)
}
// DeleteSubject deletes a subject
func (h *Handler) DeleteSubject(c *gin.Context) {
teacherID := getUserID(c)
subjectID := c.Param("id")
if teacherID == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.classService.DeleteSubject(c.Request.Context(), subjectID, teacherID); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete subject: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Subject deleted"})
}

View File

@@ -0,0 +1,208 @@
package handlers
import (
"net/http"
"github.com/breakpilot/school-service/internal/models"
"github.com/gin-gonic/gin"
)
// CreateExam creates a new exam
func (h *Handler) CreateExam(c *gin.Context) {
teacherID := getUserID(c)
if teacherID == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateExamRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
exam, err := h.examService.CreateExam(c.Request.Context(), teacherID, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create exam: "+err.Error())
return
}
respondCreated(c, exam)
}
// GetExams returns all exams for the current teacher
func (h *Handler) GetExams(c *gin.Context) {
teacherID := getUserID(c)
if teacherID == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
exams, err := h.examService.GetExams(c.Request.Context(), teacherID)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to get exams: "+err.Error())
return
}
respondSuccess(c, exams)
}
// GetExam returns a single exam
func (h *Handler) GetExam(c *gin.Context) {
teacherID := getUserID(c)
examID := c.Param("id")
if teacherID == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
exam, err := h.examService.GetExam(c.Request.Context(), examID, teacherID)
if err != nil {
respondError(c, http.StatusNotFound, "Exam not found")
return
}
respondSuccess(c, exam)
}
// UpdateExam updates an exam
func (h *Handler) UpdateExam(c *gin.Context) {
teacherID := getUserID(c)
examID := c.Param("id")
if teacherID == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateExamRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
exam, err := h.examService.UpdateExam(c.Request.Context(), examID, teacherID, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to update exam: "+err.Error())
return
}
respondSuccess(c, exam)
}
// DeleteExam deletes an exam
func (h *Handler) DeleteExam(c *gin.Context) {
teacherID := getUserID(c)
examID := c.Param("id")
if teacherID == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.examService.DeleteExam(c.Request.Context(), examID, teacherID); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete exam: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Exam deleted"})
}
// GenerateExamVariant generates a variant of an exam using AI
func (h *Handler) GenerateExamVariant(c *gin.Context) {
teacherID := getUserID(c)
examID := c.Param("id")
if teacherID == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.GenerateExamVariantRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
// Get the original exam
originalExam, err := h.examService.GetExam(c.Request.Context(), examID, teacherID)
if err != nil {
respondError(c, http.StatusNotFound, "Original exam not found")
return
}
// Generate variant content using AI
newContent, err := h.aiService.GenerateExamVariant(c.Request.Context(), originalExam.Content, req.VariationType)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to generate variant: "+err.Error())
return
}
// Create the variant exam
variant, err := h.examService.CreateExamVariant(c.Request.Context(), examID, teacherID, newContent, req.VariationType)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to save variant: "+err.Error())
return
}
respondCreated(c, variant)
}
// SaveExamResults saves results for students
func (h *Handler) SaveExamResults(c *gin.Context) {
examID := c.Param("id")
var req models.UpdateExamResultRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
result, err := h.examService.SaveExamResult(c.Request.Context(), examID, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to save result: "+err.Error())
return
}
respondSuccess(c, result)
}
// GetExamResults returns all results for an exam
func (h *Handler) GetExamResults(c *gin.Context) {
examID := c.Param("id")
results, err := h.examService.GetExamResults(c.Request.Context(), examID)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to get results: "+err.Error())
return
}
respondSuccess(c, results)
}
// ApproveExamResult approves a result for grade transfer
func (h *Handler) ApproveExamResult(c *gin.Context) {
examID := c.Param("id")
studentID := c.Param("studentId")
if err := h.examService.ApproveExamResult(c.Request.Context(), examID, studentID); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to approve result: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Result approved"})
}
// GetStudentsNeedingRewrite returns students who need to rewrite
func (h *Handler) GetStudentsNeedingRewrite(c *gin.Context) {
examID := c.Param("id")
students, err := h.examService.GetStudentsNeedingRewrite(c.Request.Context(), examID)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to get students: "+err.Error())
return
}
respondSuccess(c, students)
}

View File

@@ -0,0 +1,216 @@
package handlers
import (
"net/http"
"strconv"
"github.com/breakpilot/school-service/internal/models"
"github.com/gin-gonic/gin"
)
// GetClassGrades returns grades for a class
func (h *Handler) GetClassGrades(c *gin.Context) {
classID := c.Param("classId")
semesterStr := c.DefaultQuery("semester", "1")
semester, err := strconv.Atoi(semesterStr)
if err != nil || semester < 1 || semester > 2 {
semester = 1
}
grades, err := h.gradeService.GetGradeOverview(c.Request.Context(), classID, semester)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to get grades: "+err.Error())
return
}
respondSuccess(c, grades)
}
// GetStudentGrades returns all grades for a student
func (h *Handler) GetStudentGrades(c *gin.Context) {
studentID := c.Param("studentId")
grades, err := h.gradeService.GetStudentGrades(c.Request.Context(), studentID)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to get grades: "+err.Error())
return
}
respondSuccess(c, grades)
}
// UpdateOralGrade updates the oral grade for a student
func (h *Handler) UpdateOralGrade(c *gin.Context) {
studentID := c.Param("studentId")
subjectID := c.Param("subjectId")
var req models.UpdateOralGradeRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
grade, err := h.gradeService.UpdateOralGrade(c.Request.Context(), studentID, subjectID, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to update oral grade: "+err.Error())
return
}
respondSuccess(c, grade)
}
// CalculateFinalGrades calculates final grades for a class
func (h *Handler) CalculateFinalGrades(c *gin.Context) {
var req struct {
ClassID string `json:"class_id" binding:"required"`
Semester int `json:"semester" binding:"required,min=1,max=2"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if err := h.gradeService.CalculateFinalGrades(c.Request.Context(), req.ClassID, req.Semester); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to calculate grades: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Grades calculated successfully"})
}
// TransferApprovedGrades transfers approved exam results to grade overview
func (h *Handler) TransferApprovedGrades(c *gin.Context) {
teacherID := getUserID(c)
if teacherID == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.gradeService.TransferApprovedGrades(c.Request.Context(), teacherID); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to transfer grades: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Grades transferred successfully"})
}
// LockFinalGrade locks a final grade
func (h *Handler) LockFinalGrade(c *gin.Context) {
studentID := c.Param("studentId")
subjectID := c.Param("subjectId")
semesterStr := c.DefaultQuery("semester", "1")
semester, err := strconv.Atoi(semesterStr)
if err != nil || semester < 1 || semester > 2 {
semester = 1
}
if err := h.gradeService.LockFinalGrade(c.Request.Context(), studentID, subjectID, semester); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to lock grade: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Grade locked"})
}
// UpdateGradeWeights updates the grade weights
func (h *Handler) UpdateGradeWeights(c *gin.Context) {
studentID := c.Param("studentId")
subjectID := c.Param("subjectId")
var req struct {
WrittenWeight int `json:"written_weight" binding:"required,min=0,max=100"`
OralWeight int `json:"oral_weight" binding:"required,min=0,max=100"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if req.WrittenWeight+req.OralWeight != 100 {
respondError(c, http.StatusBadRequest, "Weights must sum to 100")
return
}
if err := h.gradeService.UpdateGradeWeights(c.Request.Context(), studentID, subjectID, req.WrittenWeight, req.OralWeight); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to update weights: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Weights updated"})
}
// =============================================
// STATISTICS ENDPOINTS
// =============================================
// GetClassStatistics returns grade statistics for a class
// GET /api/v1/school/statistics/:classId
func (h *Handler) GetClassStatistics(c *gin.Context) {
classID := c.Param("classId")
semesterStr := c.DefaultQuery("semester", "0") // 0 = both semesters
semester, _ := strconv.Atoi(semesterStr)
stats, err := h.gradeService.GetClassStatistics(c.Request.Context(), classID, semester)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to get statistics: "+err.Error())
return
}
respondSuccess(c, stats)
}
// GetSubjectStatistics returns grade statistics for a specific subject in a class
// GET /api/v1/school/statistics/:classId/subject/:subjectId
func (h *Handler) GetSubjectStatistics(c *gin.Context) {
classID := c.Param("classId")
subjectID := c.Param("subjectId")
semesterStr := c.DefaultQuery("semester", "0")
semester, _ := strconv.Atoi(semesterStr)
stats, err := h.gradeService.GetSubjectStatistics(c.Request.Context(), classID, subjectID, semester)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to get statistics: "+err.Error())
return
}
respondSuccess(c, stats)
}
// GetStudentStatistics returns grade statistics for a specific student
// GET /api/v1/school/statistics/student/:studentId
func (h *Handler) GetStudentStatistics(c *gin.Context) {
studentID := c.Param("studentId")
stats, err := h.gradeService.GetStudentStatistics(c.Request.Context(), studentID)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to get statistics: "+err.Error())
return
}
respondSuccess(c, stats)
}
// GetNotenspiegel returns the grade distribution for a class or exam
// GET /api/v1/school/statistics/:classId/notenspiegel
func (h *Handler) GetNotenspiegel(c *gin.Context) {
classID := c.Param("classId")
subjectID := c.Query("subject_id")
examID := c.Query("exam_id")
semesterStr := c.DefaultQuery("semester", "0")
semester, _ := strconv.Atoi(semesterStr)
notenspiegel, err := h.gradeService.GetNotenspiegel(c.Request.Context(), classID, subjectID, examID, semester)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to get Notenspiegel: "+err.Error())
return
}
respondSuccess(c, notenspiegel)
}

View File

@@ -0,0 +1,209 @@
package handlers
import (
"net/http"
"time"
"github.com/breakpilot/school-service/internal/models"
"github.com/gin-gonic/gin"
)
// Attendance Handlers
// CreateAttendance creates an attendance record
func (h *Handler) CreateAttendance(c *gin.Context) {
var req models.CreateAttendanceRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
attendance, err := h.gradebookService.CreateAttendance(c.Request.Context(), &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create attendance: "+err.Error())
return
}
respondCreated(c, attendance)
}
// GetClassAttendance returns attendance for a class
func (h *Handler) GetClassAttendance(c *gin.Context) {
classID := c.Param("classId")
startDateStr := c.Query("start_date")
endDateStr := c.Query("end_date")
var startDate, endDate *time.Time
if startDateStr != "" {
t, err := time.Parse("2006-01-02", startDateStr)
if err == nil {
startDate = &t
}
}
if endDateStr != "" {
t, err := time.Parse("2006-01-02", endDateStr)
if err == nil {
endDate = &t
}
}
attendance, err := h.gradebookService.GetClassAttendance(c.Request.Context(), classID, startDate, endDate)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to get attendance: "+err.Error())
return
}
respondSuccess(c, attendance)
}
// GetStudentAttendance returns attendance for a student
func (h *Handler) GetStudentAttendance(c *gin.Context) {
studentID := c.Param("studentId")
attendance, err := h.gradebookService.GetStudentAttendance(c.Request.Context(), studentID)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to get attendance: "+err.Error())
return
}
respondSuccess(c, attendance)
}
// DeleteAttendance deletes an attendance record
func (h *Handler) DeleteAttendance(c *gin.Context) {
attendanceID := c.Param("id")
if err := h.gradebookService.DeleteAttendance(c.Request.Context(), attendanceID); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete attendance: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Attendance deleted"})
}
// BulkCreateAttendance creates attendance for multiple students
func (h *Handler) BulkCreateAttendance(c *gin.Context) {
classID := c.Param("classId")
var req struct {
Date string `json:"date" binding:"required"`
Records []struct {
StudentID string `json:"student_id" binding:"required"`
Status models.AttendanceStatus `json:"status" binding:"required"`
Periods int `json:"periods"`
Reason string `json:"reason"`
} `json:"records" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
records := make([]struct {
StudentID string
Status models.AttendanceStatus
Periods int
Reason string
}, len(req.Records))
for i, r := range req.Records {
records[i] = struct {
StudentID string
Status models.AttendanceStatus
Periods int
Reason string
}{
StudentID: r.StudentID,
Status: r.Status,
Periods: r.Periods,
Reason: r.Reason,
}
}
if err := h.gradebookService.BulkCreateAttendance(c.Request.Context(), classID, req.Date, records); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create attendance: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Attendance created"})
}
// Gradebook Entry Handlers
// CreateGradebookEntry creates a gradebook entry
func (h *Handler) CreateGradebookEntry(c *gin.Context) {
var req models.CreateGradebookEntryRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
entry, err := h.gradebookService.CreateGradebookEntry(c.Request.Context(), &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create entry: "+err.Error())
return
}
respondCreated(c, entry)
}
// GetGradebookEntries returns entries for a class
func (h *Handler) GetGradebookEntries(c *gin.Context) {
classID := c.Param("classId")
entryTypeStr := c.Query("entry_type")
startDateStr := c.Query("start_date")
endDateStr := c.Query("end_date")
var entryType *string
if entryTypeStr != "" {
entryType = &entryTypeStr
}
var startDate, endDate *time.Time
if startDateStr != "" {
t, err := time.Parse("2006-01-02", startDateStr)
if err == nil {
startDate = &t
}
}
if endDateStr != "" {
t, err := time.Parse("2006-01-02", endDateStr)
if err == nil {
endDate = &t
}
}
entries, err := h.gradebookService.GetGradebookEntries(c.Request.Context(), classID, entryType, startDate, endDate)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to get entries: "+err.Error())
return
}
respondSuccess(c, entries)
}
// GetStudentEntries returns gradebook entries for a student
func (h *Handler) GetStudentEntries(c *gin.Context) {
studentID := c.Param("studentId")
entries, err := h.gradebookService.GetStudentEntries(c.Request.Context(), studentID)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to get entries: "+err.Error())
return
}
respondSuccess(c, entries)
}
// DeleteGradebookEntry deletes a gradebook entry
func (h *Handler) DeleteGradebookEntry(c *gin.Context) {
entryID := c.Param("id")
if err := h.gradebookService.DeleteGradebookEntry(c.Request.Context(), entryID); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete entry: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Entry deleted"})
}

View File

@@ -0,0 +1,66 @@
package handlers
import (
"net/http"
"github.com/breakpilot/school-service/internal/services"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
)
// Handler holds all the service dependencies
type Handler struct {
classService *services.ClassService
examService *services.ExamService
gradeService *services.GradeService
gradebookService *services.GradebookService
certificateService *services.CertificateService
aiService *services.AIService
}
// NewHandler creates a new Handler with all services
func NewHandler(db *pgxpool.Pool, llmGatewayURL string) *Handler {
classService := services.NewClassService(db)
examService := services.NewExamService(db)
gradeService := services.NewGradeService(db)
gradebookService := services.NewGradebookService(db)
certificateService := services.NewCertificateService(db, gradeService, gradebookService)
aiService := services.NewAIService(llmGatewayURL)
return &Handler{
classService: classService,
examService: examService,
gradeService: gradeService,
gradebookService: gradebookService,
certificateService: certificateService,
aiService: aiService,
}
}
// Health returns the service health status
func (h *Handler) Health(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "healthy",
"service": "school-service",
})
}
// getUserID extracts the user ID from the context (set by auth middleware)
func getUserID(c *gin.Context) string {
return c.GetString("user_id")
}
// respondError sends an error response
func respondError(c *gin.Context, status int, message string) {
c.JSON(status, gin.H{"error": message})
}
// respondSuccess sends a success response with data
func respondSuccess(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, data)
}
// respondCreated sends a created response with data
func respondCreated(c *gin.Context, data interface{}) {
c.JSON(http.StatusCreated, data)
}

View File

@@ -0,0 +1,166 @@
package middleware
import (
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// UserClaims represents the JWT claims structure
type UserClaims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// CORS middleware
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.Request.Header.Get("Origin")
if origin == "" {
origin = "*"
}
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
// RequestLogger logs HTTP requests
func RequestLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
if status >= 400 {
gin.DefaultWriter.Write([]byte(
c.Request.Method + " " + path + " " +
http.StatusText(status) + " " + latency.String() + "\n",
))
}
}
}
// Rate limiter storage
var (
rateLimitMu sync.Mutex
rateLimits = make(map[string][]time.Time)
)
// RateLimiter implements per-IP rate limiting
func RateLimiter() gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
// Skip rate limiting for internal Docker IPs
if strings.HasPrefix(ip, "172.") || strings.HasPrefix(ip, "10.") || ip == "127.0.0.1" {
c.Next()
return
}
rateLimitMu.Lock()
defer rateLimitMu.Unlock()
now := time.Now()
windowStart := now.Add(-time.Minute)
// Clean old entries
var recent []time.Time
for _, t := range rateLimits[ip] {
if t.After(windowStart) {
recent = append(recent, t)
}
}
// Check limit (500 requests per minute)
if len(recent) >= 500 {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"error": "Rate limit exceeded",
})
return
}
rateLimits[ip] = append(recent, now)
c.Next()
}
}
// AuthMiddleware validates JWT tokens
func AuthMiddleware(jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "Authorization header required",
})
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "Invalid authorization format",
})
return
}
token, err := jwt.ParseWithClaims(tokenString, &UserClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(jwtSecret), nil
})
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "Invalid or expired token",
})
return
}
claims, ok := token.Claims.(*UserClaims)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "Invalid token claims",
})
return
}
// Set user info in context
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
c.Set("role", claims.Role)
c.Next()
}
}
// AdminOnly restricts access to admin users
func AdminOnly() gin.HandlerFunc {
return func(c *gin.Context) {
role := c.GetString("role")
if role != "admin" && role != "super_admin" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "Admin access required",
})
return
}
c.Next()
}
}

View File

@@ -0,0 +1,329 @@
package models
import (
"time"
"github.com/google/uuid"
)
// SchoolYear represents a school year
type SchoolYear struct {
ID uuid.UUID `json:"id" db:"id"`
Name string `json:"name" db:"name"`
StartDate time.Time `json:"start_date" db:"start_date"`
EndDate time.Time `json:"end_date" db:"end_date"`
IsCurrent bool `json:"is_current" db:"is_current"`
TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// Class represents a school class
type Class struct {
ID uuid.UUID `json:"id" db:"id"`
TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"`
SchoolYearID *uuid.UUID `json:"school_year_id,omitempty" db:"school_year_id"`
Name string `json:"name" db:"name"`
GradeLevel int `json:"grade_level" db:"grade_level"`
SchoolType string `json:"school_type,omitempty" db:"school_type"`
FederalState string `json:"federal_state,omitempty" db:"federal_state"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
StudentCount int `json:"student_count,omitempty"` // Computed field
}
// Student represents a student
type Student struct {
ID uuid.UUID `json:"id" db:"id"`
ClassID uuid.UUID `json:"class_id" db:"class_id"`
FirstName string `json:"first_name" db:"first_name"`
LastName string `json:"last_name" db:"last_name"`
BirthDate *time.Time `json:"birth_date,omitempty" db:"date_of_birth"`
StudentNumber *string `json:"student_number,omitempty" db:"student_number"`
Notes *string `json:"notes,omitempty" db:"notes"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// Subject represents a school subject
type Subject struct {
ID uuid.UUID `json:"id" db:"id"`
TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"`
Name string `json:"name" db:"name"`
ShortName string `json:"short_name,omitempty" db:"short_name"`
IsMainSubject bool `json:"is_main_subject" db:"is_main_subject"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// ExamType represents types of exams
type ExamType string
const (
ExamTypeKlassenarbeit ExamType = "klassenarbeit"
ExamTypeTest ExamType = "test"
ExamTypeKlausur ExamType = "klausur"
)
// ExamStatus represents exam status
type ExamStatus string
const (
ExamStatusDraft ExamStatus = "draft"
ExamStatusActive ExamStatus = "active"
ExamStatusArchived ExamStatus = "archived"
)
// Exam represents an exam/test
type Exam struct {
ID uuid.UUID `json:"id" db:"id"`
TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"`
ClassID *uuid.UUID `json:"class_id,omitempty" db:"class_id"`
SubjectID *uuid.UUID `json:"subject_id,omitempty" db:"subject_id"`
Title string `json:"title" db:"title"`
ExamType ExamType `json:"exam_type" db:"exam_type"`
Topic string `json:"topic,omitempty" db:"topic"`
Content string `json:"content,omitempty" db:"content"`
SourceFilePath string `json:"source_file_path,omitempty" db:"source_file_path"`
DifficultyLevel int `json:"difficulty_level" db:"difficulty_level"`
DurationMinutes *int `json:"duration_minutes,omitempty" db:"duration_minutes"`
MaxPoints *float64 `json:"max_points,omitempty" db:"max_points"`
IsTemplate bool `json:"is_template" db:"is_template"`
ParentExamID *uuid.UUID `json:"parent_exam_id,omitempty" db:"parent_exam_id"`
Status ExamStatus `json:"status" db:"status"`
ExamDate *time.Time `json:"exam_date,omitempty" db:"exam_date"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
// Computed/joined fields
ClassName string `json:"class_name,omitempty"`
SubjectName string `json:"subject_name,omitempty"`
}
// ExamResult represents a student's exam result
type ExamResult struct {
ID uuid.UUID `json:"id" db:"id"`
ExamID uuid.UUID `json:"exam_id" db:"exam_id"`
StudentID uuid.UUID `json:"student_id" db:"student_id"`
PointsAchieved *float64 `json:"points_achieved,omitempty" db:"points_achieved"`
Grade *float64 `json:"grade,omitempty" db:"grade"`
Percentage *float64 `json:"percentage,omitempty" db:"percentage"`
Notes string `json:"notes,omitempty" db:"notes"`
IsAbsent bool `json:"is_absent" db:"is_absent"`
NeedsRewrite bool `json:"needs_rewrite" db:"needs_rewrite"`
ApprovedByTeacher bool `json:"approved_by_teacher" db:"approved_by_teacher"`
ApprovedAt *time.Time `json:"approved_at,omitempty" db:"approved_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
// Joined fields
StudentName string `json:"student_name,omitempty"`
}
// GradeOverview represents aggregated grades for a student
type GradeOverview struct {
ID uuid.UUID `json:"id" db:"id"`
StudentID uuid.UUID `json:"student_id" db:"student_id"`
SubjectID uuid.UUID `json:"subject_id" db:"subject_id"`
SchoolYearID uuid.UUID `json:"school_year_id" db:"school_year_id"`
Semester int `json:"semester" db:"semester"`
WrittenGradeAvg *float64 `json:"written_grade_avg,omitempty" db:"written_grade_avg"`
WrittenGradeCount int `json:"written_grade_count" db:"written_grade_count"`
OralGrade *float64 `json:"oral_grade,omitempty" db:"oral_grade"`
OralNotes string `json:"oral_notes,omitempty" db:"oral_notes"`
FinalGrade *float64 `json:"final_grade,omitempty" db:"final_grade"`
FinalGradeLocked bool `json:"final_grade_locked" db:"final_grade_locked"`
WrittenWeight int `json:"written_weight" db:"written_weight"`
OralWeight int `json:"oral_weight" db:"oral_weight"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
// Joined fields
StudentName string `json:"student_name,omitempty"`
SubjectName string `json:"subject_name,omitempty"`
}
// AttendanceStatus represents attendance status
type AttendanceStatus string
const (
AttendancePresent AttendanceStatus = "present"
AttendanceAbsentExcused AttendanceStatus = "absent_excused"
AttendanceAbsentUnexcused AttendanceStatus = "absent_unexcused"
AttendanceLate AttendanceStatus = "late"
)
// Attendance represents a student's attendance record
type Attendance struct {
ID uuid.UUID `json:"id" db:"id"`
StudentID uuid.UUID `json:"student_id" db:"student_id"`
Date time.Time `json:"date" db:"date"`
Status AttendanceStatus `json:"status" db:"status"`
Periods int `json:"periods" db:"periods"`
Reason string `json:"reason,omitempty" db:"reason"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
// Joined fields
StudentName string `json:"student_name,omitempty"`
}
// GradebookEntryType represents types of gradebook entries
type GradebookEntryType string
const (
EntryTypeNote GradebookEntryType = "note"
EntryTypeWarning GradebookEntryType = "warning"
EntryTypePraise GradebookEntryType = "praise"
EntryTypeIncident GradebookEntryType = "incident"
EntryTypeHomework GradebookEntryType = "homework"
)
// GradebookEntry represents a gradebook entry
type GradebookEntry struct {
ID uuid.UUID `json:"id" db:"id"`
ClassID uuid.UUID `json:"class_id" db:"class_id"`
StudentID *uuid.UUID `json:"student_id,omitempty" db:"student_id"`
Date time.Time `json:"date" db:"date"`
EntryType GradebookEntryType `json:"entry_type" db:"entry_type"`
Content string `json:"content" db:"content"`
IsVisibleToParents bool `json:"is_visible_to_parents" db:"is_visible_to_parents"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
// Joined fields
StudentName string `json:"student_name,omitempty"`
}
// CertificateStatus represents certificate status
type CertificateStatus string
const (
CertificateStatusDraft CertificateStatus = "draft"
CertificateStatusFinal CertificateStatus = "final"
CertificateStatusPrinted CertificateStatus = "printed"
)
// CertificateType represents types of certificates
type CertificateType string
const (
CertificateTypeHalbjahr CertificateType = "halbjahr"
CertificateTypeJahres CertificateType = "jahres"
CertificateTypeAbschluss CertificateType = "abschluss"
)
// Certificate represents a student certificate
type Certificate struct {
ID uuid.UUID `json:"id" db:"id"`
StudentID uuid.UUID `json:"student_id" db:"student_id"`
SchoolYearID uuid.UUID `json:"school_year_id" db:"school_year_id"`
Semester int `json:"semester" db:"semester"`
CertificateType CertificateType `json:"certificate_type" db:"certificate_type"`
TemplateName string `json:"template_name,omitempty" db:"template_name"`
GradesJSON map[string]any `json:"grades_json,omitempty" db:"grades_json"`
Remarks string `json:"remarks,omitempty" db:"remarks"`
AbsenceDays int `json:"absence_days" db:"absence_days"`
AbsenceDaysUnexcused int `json:"absence_days_unexcused" db:"absence_days_unexcused"`
GeneratedPDFPath string `json:"generated_pdf_path,omitempty" db:"generated_pdf_path"`
Status CertificateStatus `json:"status" db:"status"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
// Joined fields
StudentName string `json:"student_name,omitempty"`
ClassName string `json:"class_name,omitempty"`
}
// Request/Response DTOs
// CreateSchoolYearRequest represents a request to create a school year
type CreateSchoolYearRequest struct {
Name string `json:"name" binding:"required"`
StartDate string `json:"start_date" binding:"required"`
EndDate string `json:"end_date" binding:"required"`
IsCurrent bool `json:"is_current"`
}
// CreateClassRequest represents a request to create a class
type CreateClassRequest struct {
SchoolYearID string `json:"school_year_id"`
Name string `json:"name" binding:"required"`
GradeLevel int `json:"grade_level" binding:"required,min=1,max=13"`
SchoolType string `json:"school_type"`
FederalState string `json:"federal_state"`
}
// CreateStudentRequest represents a request to create a student
type CreateStudentRequest struct {
FirstName string `json:"first_name" binding:"required"`
LastName string `json:"last_name" binding:"required"`
BirthDate string `json:"birth_date"`
StudentNumber string `json:"student_number"`
Notes string `json:"notes"`
}
// CreateSubjectRequest represents a request to create a subject
type CreateSubjectRequest struct {
Name string `json:"name" binding:"required"`
ShortName string `json:"short_name"`
IsMainSubject bool `json:"is_main_subject"`
}
// CreateExamRequest represents a request to create an exam
type CreateExamRequest struct {
ClassID string `json:"class_id"`
SubjectID string `json:"subject_id"`
Title string `json:"title" binding:"required"`
ExamType ExamType `json:"exam_type" binding:"required"`
Topic string `json:"topic"`
Content string `json:"content"`
DifficultyLevel int `json:"difficulty_level"`
DurationMinutes int `json:"duration_minutes"`
MaxPoints float64 `json:"max_points"`
ExamDate string `json:"exam_date"`
}
// UpdateExamResultRequest represents a request to update exam results
type UpdateExamResultRequest struct {
StudentID string `json:"student_id" binding:"required"`
PointsAchieved *float64 `json:"points_achieved"`
Grade *float64 `json:"grade"`
Notes string `json:"notes"`
IsAbsent bool `json:"is_absent"`
NeedsRewrite bool `json:"needs_rewrite"`
}
// UpdateOralGradeRequest represents a request to update oral grades
type UpdateOralGradeRequest struct {
OralGrade float64 `json:"oral_grade" binding:"required,min=1,max=6"`
OralNotes string `json:"oral_notes"`
}
// CreateAttendanceRequest represents a request to create attendance
type CreateAttendanceRequest struct {
StudentID string `json:"student_id" binding:"required"`
Date string `json:"date" binding:"required"`
Status AttendanceStatus `json:"status" binding:"required"`
Periods int `json:"periods"`
Reason string `json:"reason"`
}
// CreateGradebookEntryRequest represents a request to create a gradebook entry
type CreateGradebookEntryRequest struct {
ClassID string `json:"class_id" binding:"required"`
StudentID string `json:"student_id"`
Date string `json:"date" binding:"required"`
EntryType GradebookEntryType `json:"entry_type" binding:"required"`
Content string `json:"content" binding:"required"`
IsVisibleToParents bool `json:"is_visible_to_parents"`
}
// GenerateCertificateRequest represents a request to generate a certificate
type GenerateCertificateRequest struct {
StudentID string `json:"student_id" binding:"required"`
SchoolYearID string `json:"school_year_id" binding:"required"`
Semester int `json:"semester" binding:"required,min=1,max=2"`
CertificateType CertificateType `json:"certificate_type" binding:"required"`
TemplateName string `json:"template_name"`
Remarks string `json:"remarks"`
}
// GenerateExamVariantRequest represents a request to generate an exam variant
type GenerateExamVariantRequest struct {
VariationType string `json:"variation_type" binding:"required"` // "rewrite", "alternative", "similar"
}

View File

@@ -0,0 +1,591 @@
package seed
import (
"context"
"fmt"
"log"
"math/rand"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Seeder generates demo data for the school service
type Seeder struct {
pool *pgxpool.Pool
teacherID uuid.UUID
rng *rand.Rand
}
// NewSeeder creates a new seeder instance
func NewSeeder(pool *pgxpool.Pool, teacherID uuid.UUID) *Seeder {
return &Seeder{
pool: pool,
teacherID: teacherID,
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
// German first names for realistic demo data
var firstNames = []string{
"Max", "Paul", "Leon", "Felix", "Jonas", "Lukas", "Tim", "Ben", "Finn", "Elias",
"Emma", "Mia", "Hannah", "Sophia", "Lena", "Anna", "Marie", "Leonie", "Lara", "Laura",
"Noah", "Luis", "David", "Moritz", "Jan", "Niklas", "Tom", "Simon", "Erik", "Jannik",
"Lea", "Julia", "Lisa", "Sarah", "Clara", "Amelie", "Emily", "Maja", "Zoe", "Lina",
"Alexander", "Maximilian", "Sebastian", "Philipp", "Julian", "Fabian", "Tobias", "Christian",
"Katharina", "Christina", "Johanna", "Franziska", "Antonia", "Victoria", "Helena", "Charlotte",
}
// German last names for realistic demo data
var lastNames = []string{
"Mueller", "Schmidt", "Schneider", "Fischer", "Weber", "Meyer", "Wagner", "Becker",
"Schulz", "Hoffmann", "Koch", "Bauer", "Richter", "Klein", "Wolf", "Schroeder",
"Neumann", "Schwarz", "Zimmermann", "Braun", "Krueger", "Hofmann", "Hartmann", "Lange",
"Schmitt", "Werner", "Schmitz", "Krause", "Meier", "Lehmann", "Schmid", "Schulze",
"Maier", "Koehler", "Herrmann", "Koenig", "Walter", "Mayer", "Huber", "Kaiser",
"Peters", "Lang", "Scholz", "Moeller", "Gross", "Jung", "Friedrich", "Keller",
}
// Subjects with exam data
type SubjectConfig struct {
Name string
ShortName string
IsMain bool
ExamsPerYear int
WrittenWeight int
}
var subjects = []SubjectConfig{
{"Deutsch", "De", true, 4, 60},
{"Mathematik", "Ma", true, 4, 60},
{"Englisch", "En", true, 4, 60},
{"Biologie", "Bio", false, 3, 50},
{"Geschichte", "Ge", false, 2, 50},
{"Physik", "Ph", false, 3, 50},
{"Chemie", "Ch", false, 2, 50},
{"Sport", "Sp", false, 0, 30},
{"Kunst", "Ku", false, 1, 40},
{"Musik", "Mu", false, 1, 40},
}
// SeedAll generates all demo data
func (s *Seeder) SeedAll(ctx context.Context) error {
log.Println("Starting seed data generation...")
// 1. Create school years
schoolYears, err := s.seedSchoolYears(ctx)
if err != nil {
return fmt.Errorf("seeding school years: %w", err)
}
log.Printf("Created %d school years", len(schoolYears))
// 2. Create subjects
subjectIDs, err := s.seedSubjects(ctx)
if err != nil {
return fmt.Errorf("seeding subjects: %w", err)
}
log.Printf("Created %d subjects", len(subjectIDs))
// 3. Create classes with students
for _, sy := range schoolYears {
classes, err := s.seedClassesForYear(ctx, sy)
if err != nil {
return fmt.Errorf("seeding classes for year %s: %w", sy.Name, err)
}
for _, class := range classes {
// Create students
students, err := s.seedStudentsForClass(ctx, class.ID)
if err != nil {
return fmt.Errorf("seeding students for class %s: %w", class.Name, err)
}
log.Printf("Created %d students for class %s", len(students), class.Name)
// Create exams and results
for _, subj := range subjects {
subjectID := subjectIDs[subj.Name]
err := s.seedExamsAndResults(ctx, class.ID, subjectID, students, sy.ID, subj)
if err != nil {
return fmt.Errorf("seeding exams for class %s, subject %s: %w", class.Name, subj.Name, err)
}
}
// Create attendance records
err = s.seedAttendance(ctx, students, sy)
if err != nil {
return fmt.Errorf("seeding attendance for class %s: %w", class.Name, err)
}
}
}
log.Println("Seed data generation completed successfully!")
return nil
}
// SchoolYearResult holds created school year info
type SchoolYearResult struct {
ID uuid.UUID
Name string
StartDate time.Time
EndDate time.Time
IsCurrent bool
}
func (s *Seeder) seedSchoolYears(ctx context.Context) ([]SchoolYearResult, error) {
years := []struct {
name string
startYear int
isCurrent bool
}{
{"2022/23", 2022, false},
{"2023/24", 2023, false},
{"2024/25", 2024, true},
}
var results []SchoolYearResult
for _, y := range years {
id := uuid.New()
startDate := time.Date(y.startYear, time.August, 1, 0, 0, 0, 0, time.UTC)
endDate := time.Date(y.startYear+1, time.July, 31, 0, 0, 0, 0, time.UTC)
_, err := s.pool.Exec(ctx, `
INSERT INTO school_years (id, name, start_date, end_date, is_current, teacher_id)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT DO NOTHING
`, id, y.name, startDate, endDate, y.isCurrent, s.teacherID)
if err != nil {
return nil, err
}
results = append(results, SchoolYearResult{
ID: id,
Name: y.name,
StartDate: startDate,
EndDate: endDate,
IsCurrent: y.isCurrent,
})
}
return results, nil
}
func (s *Seeder) seedSubjects(ctx context.Context) (map[string]uuid.UUID, error) {
subjectIDs := make(map[string]uuid.UUID)
for _, subj := range subjects {
id := uuid.New()
_, err := s.pool.Exec(ctx, `
INSERT INTO subjects (id, teacher_id, name, short_name, is_main_subject)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (teacher_id, name) DO UPDATE SET id = subjects.id
RETURNING id
`, id, s.teacherID, subj.Name, subj.ShortName, subj.IsMain)
if err != nil {
return nil, err
}
subjectIDs[subj.Name] = id
}
return subjectIDs, nil
}
// ClassResult holds created class info
type ClassResult struct {
ID uuid.UUID
Name string
GradeLevel int
}
func (s *Seeder) seedClassesForYear(ctx context.Context, sy SchoolYearResult) ([]ClassResult, error) {
// Classes: 5a, 5b, 6a, 6b, 7a, 7b
classConfigs := []struct {
name string
gradeLevel int
}{
{"5a", 5}, {"5b", 5},
{"6a", 6}, {"6b", 6},
{"7a", 7}, {"7b", 7},
}
var results []ClassResult
for _, c := range classConfigs {
id := uuid.New()
_, err := s.pool.Exec(ctx, `
INSERT INTO classes (id, teacher_id, school_year_id, name, grade_level, school_type, federal_state)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (teacher_id, school_year_id, name) DO UPDATE SET id = classes.id
`, id, s.teacherID, sy.ID, c.name, c.gradeLevel, "gymnasium", "niedersachsen")
if err != nil {
return nil, err
}
results = append(results, ClassResult{
ID: id,
Name: c.name,
GradeLevel: c.gradeLevel,
})
}
return results, nil
}
// StudentResult holds created student info
type StudentResult struct {
ID uuid.UUID
FirstName string
LastName string
}
func (s *Seeder) seedStudentsForClass(ctx context.Context, classID uuid.UUID) ([]StudentResult, error) {
// 15-25 students per class
numStudents := 15 + s.rng.Intn(11) // 15-25
var students []StudentResult
usedNames := make(map[string]bool)
for i := 0; i < numStudents; i++ {
// Generate unique name combination
var firstName, lastName string
for {
firstName = firstNames[s.rng.Intn(len(firstNames))]
lastName = lastNames[s.rng.Intn(len(lastNames))]
key := firstName + lastName
if !usedNames[key] {
usedNames[key] = true
break
}
}
id := uuid.New()
// Generate birth date (10-13 years old)
birthYear := time.Now().Year() - 10 - s.rng.Intn(4)
birthMonth := time.Month(1 + s.rng.Intn(12))
birthDay := 1 + s.rng.Intn(28)
birthDate := time.Date(birthYear, birthMonth, birthDay, 0, 0, 0, 0, time.UTC)
studentNumber := fmt.Sprintf("S%d%04d", time.Now().Year()%100, i+1)
_, err := s.pool.Exec(ctx, `
INSERT INTO students (id, class_id, first_name, last_name, birth_date, student_number)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT DO NOTHING
`, id, classID, firstName, lastName, birthDate, studentNumber)
if err != nil {
return nil, err
}
students = append(students, StudentResult{
ID: id,
FirstName: firstName,
LastName: lastName,
})
}
return students, nil
}
func (s *Seeder) seedExamsAndResults(
ctx context.Context,
classID uuid.UUID,
subjectID uuid.UUID,
students []StudentResult,
schoolYearID uuid.UUID,
subj SubjectConfig,
) error {
if subj.ExamsPerYear == 0 {
return nil // No exams for this subject (e.g., Sport)
}
examDates := []time.Time{
time.Date(2024, 10, 15, 0, 0, 0, 0, time.UTC),
time.Date(2024, 12, 10, 0, 0, 0, 0, time.UTC),
time.Date(2025, 2, 20, 0, 0, 0, 0, time.UTC),
time.Date(2025, 5, 15, 0, 0, 0, 0, time.UTC),
}
for i := 0; i < subj.ExamsPerYear; i++ {
examID := uuid.New()
examDate := examDates[i%len(examDates)]
maxPoints := 50.0 + float64(s.rng.Intn(51)) // 50-100 points
title := fmt.Sprintf("%s Klassenarbeit %d", subj.Name, i+1)
_, err := s.pool.Exec(ctx, `
INSERT INTO exams (id, teacher_id, class_id, subject_id, title, exam_type, max_points, status, exam_date, difficulty_level, duration_minutes)
VALUES ($1, $2, $3, $4, $5, 'klassenarbeit', $6, 'archived', $7, $8, $9)
ON CONFLICT DO NOTHING
`, examID, s.teacherID, classID, subjectID, title, maxPoints, examDate, 3, 45)
if err != nil {
return err
}
// Create results for each student
for _, student := range students {
// Generate realistic grade distribution (mostly 2-4)
grade := s.generateRealisticGrade()
percentage := s.gradeToPercentage(grade)
points := (percentage / 100.0) * maxPoints
isAbsent := s.rng.Float64() < 0.03 // 3% absence rate
if isAbsent {
_, err = s.pool.Exec(ctx, `
INSERT INTO exam_results (exam_id, student_id, is_absent, approved_by_teacher)
VALUES ($1, $2, true, true)
ON CONFLICT (exam_id, student_id) DO NOTHING
`, examID, student.ID)
} else {
_, err = s.pool.Exec(ctx, `
INSERT INTO exam_results (exam_id, student_id, points_achieved, grade, percentage, approved_by_teacher)
VALUES ($1, $2, $3, $4, $5, true)
ON CONFLICT (exam_id, student_id) DO NOTHING
`, examID, student.ID, points, grade, percentage)
}
if err != nil {
return err
}
}
}
// Create grade overview entries for each student
for _, student := range students {
err := s.createGradeOverview(ctx, student.ID, subjectID, schoolYearID, subj)
if err != nil {
return err
}
}
return nil
}
func (s *Seeder) generateRealisticGrade() float64 {
// German grading: 1 (best) to 6 (worst)
// Normal distribution centered around 3.0
grades := []float64{1.0, 1.3, 1.7, 2.0, 2.3, 2.7, 3.0, 3.3, 3.7, 4.0, 4.3, 4.7, 5.0, 5.3, 5.7, 6.0}
weights := []int{2, 4, 6, 10, 12, 14, 16, 14, 12, 8, 5, 3, 2, 1, 1, 1} // Weighted distribution
total := 0
for _, w := range weights {
total += w
}
r := s.rng.Intn(total)
cumulative := 0
for i, w := range weights {
cumulative += w
if r < cumulative {
return grades[i]
}
}
return 3.0
}
func (s *Seeder) gradeToPercentage(grade float64) float64 {
// Convert German grade to percentage
// 1.0 = 100%, 6.0 = 0%
return 100.0 - ((grade - 1.0) * 20.0)
}
func (s *Seeder) createGradeOverview(
ctx context.Context,
studentID uuid.UUID,
subjectID uuid.UUID,
schoolYearID uuid.UUID,
subj SubjectConfig,
) error {
// Create for both semesters
for semester := 1; semester <= 2; semester++ {
writtenAvg := s.generateRealisticGrade()
oralGrade := s.generateRealisticGrade()
// Calculate final grade based on weights
finalGrade := (writtenAvg*float64(subj.WrittenWeight) + oralGrade*float64(100-subj.WrittenWeight)) / 100.0
// Round to nearest 0.5
finalGrade = float64(int(finalGrade*2+0.5)) / 2.0
_, err := s.pool.Exec(ctx, `
INSERT INTO grade_overview (student_id, subject_id, school_year_id, semester, written_grade_avg, written_grade_count, oral_grade, final_grade, written_weight, oral_weight)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (student_id, subject_id, school_year_id, semester) DO UPDATE SET
written_grade_avg = EXCLUDED.written_grade_avg,
oral_grade = EXCLUDED.oral_grade,
final_grade = EXCLUDED.final_grade
`, studentID, subjectID, schoolYearID, semester, writtenAvg, subj.ExamsPerYear/2+1, oralGrade, finalGrade, subj.WrittenWeight, 100-subj.WrittenWeight)
if err != nil {
return err
}
}
return nil
}
func (s *Seeder) seedAttendance(ctx context.Context, students []StudentResult, sy SchoolYearResult) error {
for _, student := range students {
// 0-10 absence days per student
absenceDays := s.rng.Intn(11)
for i := 0; i < absenceDays; i++ {
// Random date within school year
dayOffset := s.rng.Intn(int(sy.EndDate.Sub(sy.StartDate).Hours() / 24))
absenceDate := sy.StartDate.AddDate(0, 0, dayOffset)
// Skip weekends
if absenceDate.Weekday() == time.Saturday || absenceDate.Weekday() == time.Sunday {
continue
}
// 80% excused, 20% unexcused
status := "absent_excused"
if s.rng.Float64() < 0.2 {
status = "absent_unexcused"
}
reasons := []string{
"Krankheit",
"Arzttermin",
"Familiaere Gruende",
"",
}
reason := reasons[s.rng.Intn(len(reasons))]
_, err := s.pool.Exec(ctx, `
INSERT INTO attendance (student_id, date, status, periods, reason)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (student_id, date) DO NOTHING
`, student.ID, absenceDate, status, s.rng.Intn(6)+1, reason)
if err != nil {
return err
}
}
}
return nil
}
// Statistics holds calculated statistics for a class
type Statistics struct {
ClassID uuid.UUID `json:"class_id"`
ClassName string `json:"class_name"`
StudentCount int `json:"student_count"`
ClassAverage float64 `json:"class_average"`
GradeDistribution map[string]int `json:"grade_distribution"`
BestGrade float64 `json:"best_grade"`
WorstGrade float64 `json:"worst_grade"`
PassRate float64 `json:"pass_rate"`
StudentsAtRisk int `json:"students_at_risk"`
SubjectAverages map[string]float64 `json:"subject_averages"`
}
// CalculateClassStatistics calculates statistics for a class
func (s *Seeder) CalculateClassStatistics(ctx context.Context, classID uuid.UUID) (*Statistics, error) {
stats := &Statistics{
ClassID: classID,
GradeDistribution: make(map[string]int),
SubjectAverages: make(map[string]float64),
}
// Initialize grade distribution
for i := 1; i <= 6; i++ {
stats.GradeDistribution[fmt.Sprintf("%d", i)] = 0
}
// Get class info and student count
err := s.pool.QueryRow(ctx, `
SELECT c.name, COUNT(s.id)
FROM classes c
LEFT JOIN students s ON s.class_id = c.id
WHERE c.id = $1
GROUP BY c.name
`, classID).Scan(&stats.ClassName, &stats.StudentCount)
if err != nil {
return nil, err
}
// Get grade statistics from exam results
rows, err := s.pool.Query(ctx, `
SELECT
COALESCE(AVG(er.grade), 0) as avg_grade,
COALESCE(MIN(er.grade), 0) as best_grade,
COALESCE(MAX(er.grade), 0) as worst_grade,
COUNT(CASE WHEN er.grade <= 4.0 THEN 1 END) as passed,
COUNT(er.id) as total,
FLOOR(er.grade) as grade_bucket,
COUNT(*) as bucket_count
FROM exam_results er
JOIN exams e ON e.id = er.exam_id
JOIN students s ON s.id = er.student_id
WHERE s.class_id = $1 AND er.grade IS NOT NULL
GROUP BY FLOOR(er.grade)
`, classID)
if err != nil {
return nil, err
}
defer rows.Close()
var totalPassed, totalExams int
for rows.Next() {
var avgGrade, bestGrade, worstGrade float64
var passed, total int
var gradeBucket float64
var bucketCount int
err := rows.Scan(&avgGrade, &bestGrade, &worstGrade, &passed, &total, &gradeBucket, &bucketCount)
if err != nil {
continue
}
stats.ClassAverage = avgGrade
stats.BestGrade = bestGrade
stats.WorstGrade = worstGrade
totalPassed += passed
totalExams += total
gradeKey := fmt.Sprintf("%d", int(gradeBucket))
stats.GradeDistribution[gradeKey] = bucketCount
}
if totalExams > 0 {
stats.PassRate = float64(totalPassed) / float64(totalExams) * 100
}
// Count students at risk (average >= 4.5)
err = s.pool.QueryRow(ctx, `
SELECT COUNT(DISTINCT s.id)
FROM students s
JOIN grade_overview go ON go.student_id = s.id
WHERE s.class_id = $1 AND go.final_grade >= 4.5
`, classID).Scan(&stats.StudentsAtRisk)
if err != nil {
stats.StudentsAtRisk = 0
}
// Get subject averages
subjectRows, err := s.pool.Query(ctx, `
SELECT sub.name, AVG(go.final_grade)
FROM grade_overview go
JOIN subjects sub ON sub.id = go.subject_id
JOIN students s ON s.id = go.student_id
WHERE s.class_id = $1
GROUP BY sub.name
`, classID)
if err == nil {
defer subjectRows.Close()
for subjectRows.Next() {
var name string
var avg float64
if err := subjectRows.Scan(&name, &avg); err == nil {
stats.SubjectAverages[name] = avg
}
}
}
return stats, nil
}

View File

@@ -0,0 +1,218 @@
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// AIService handles AI-related operations via LLM Gateway
type AIService struct {
llmGatewayURL string
httpClient *http.Client
}
// NewAIService creates a new AIService
func NewAIService(llmGatewayURL string) *AIService {
return &AIService{
llmGatewayURL: llmGatewayURL,
httpClient: &http.Client{
Timeout: 120 * time.Second, // AI requests can take longer
},
}
}
// ChatMessage represents a message in the chat format
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// ChatRequest represents a request to the LLM Gateway
type ChatRequest struct {
Messages []ChatMessage `json:"messages"`
Model string `json:"model,omitempty"`
}
// ChatResponse represents a response from the LLM Gateway
type ChatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error string `json:"error,omitempty"`
}
// GenerateExamVariant generates a variant of an exam using AI
func (s *AIService) GenerateExamVariant(ctx context.Context, originalContent string, variationType string) (string, error) {
var prompt string
switch variationType {
case "rewrite":
prompt = fmt.Sprintf(`Du bist ein erfahrener Lehrer. Erstelle eine Nachschreibeversion der folgenden Klausur/Arbeit.
WICHTIG:
- Behalte den EXAKTEN Schwierigkeitsgrad bei
- Formuliere die Textaufgaben um (andere Worte, gleiche Bedeutung)
- Bei Rechenaufgaben: Andere Zahlen, gleicher Rechenweg, gleiche Schwierigkeit
- Behalte die Punkteverteilung und Struktur bei
- Die neue Version soll für Schüler sein, die bei der Originalklausur gefehlt haben
Original-Klausur:
%s
Erstelle nun die Nachschreibeversion:`, originalContent)
case "alternative":
prompt = fmt.Sprintf(`Du bist ein erfahrener Lehrer. Erstelle eine komplett alternative Version der folgenden Klausur zum gleichen Thema.
WICHTIG:
- Gleiches Thema und Lernziele
- Komplett neue Aufgaben (nicht nur umformuliert)
- Gleicher Schwierigkeitsgrad
- Gleiche Punkteverteilung und Struktur
- Andere Beispiele und Szenarien verwenden
Original-Klausur (als Referenz für Thema und Schwierigkeitsgrad):
%s
Erstelle nun die alternative Version:`, originalContent)
case "similar":
prompt = fmt.Sprintf(`Du bist ein erfahrener Lehrer. Erstelle eine ähnliche Übungsarbeit basierend auf der folgenden Klausur.
WICHTIG:
- Geeignet als Übungsmaterial für die Klausurvorbereitung
- Ähnliche Aufgabentypen wie im Original
- Gleicher Schwierigkeitsgrad
- Kann als Hausaufgabe oder zur Selbstübung verwendet werden
Original-Klausur:
%s
Erstelle nun die Übungsarbeit:`, originalContent)
default:
return "", fmt.Errorf("unknown variation type: %s", variationType)
}
return s.sendChatRequest(ctx, prompt)
}
// ImproveExamContent improves exam content (grammar, clarity)
func (s *AIService) ImproveExamContent(ctx context.Context, content string) (string, error) {
prompt := fmt.Sprintf(`Du bist ein erfahrener Lehrer. Verbessere den folgenden Klausurtext:
WICHTIG:
- Korrigiere Rechtschreibung und Grammatik
- Verbessere die Klarheit der Aufgabenstellungen
- Ändere NICHT den Schwierigkeitsgrad oder Inhalt
- Ändere NICHT die Punkteverteilung
- Behalte die Struktur bei
Original:
%s
Verbesserte Version:`, content)
return s.sendChatRequest(ctx, prompt)
}
// GenerateGradeFeedback generates feedback for a student based on their grades
func (s *AIService) GenerateGradeFeedback(ctx context.Context, studentName string, grades map[string]float64, semester int) (string, error) {
gradesJSON, _ := json.Marshal(grades)
prompt := fmt.Sprintf(`Du bist ein erfahrener Klassenlehrer. Erstelle eine kurze, konstruktive Zeugnis-Bemerkung für folgenden Schüler:
Schüler: %s
Halbjahr: %d
Noten: %s
WICHTIG:
- Maximal 3-4 Sätze
- Konstruktiv und ermutigend formulieren
- Stärken hervorheben
- Bei Bedarf dezente Verbesserungsvorschläge
- Professioneller, sachlicher Ton (Zeugnis-tauglich)
- Keine übertriebenen Lobpreisungen
Erstelle die Bemerkung:`, studentName, semester, string(gradesJSON))
return s.sendChatRequest(ctx, prompt)
}
// sendChatRequest sends a request to the LLM Gateway
func (s *AIService) sendChatRequest(ctx context.Context, prompt string) (string, error) {
reqBody := ChatRequest{
Messages: []ChatMessage{
{Role: "user", Content: prompt},
},
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", s.llmGatewayURL+"/v1/chat/completions", bytes.NewBuffer(jsonBody))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("LLM Gateway returned status %d: %s", resp.StatusCode, string(body))
}
var chatResp ChatResponse
if err := json.Unmarshal(body, &chatResp); err != nil {
return "", fmt.Errorf("failed to parse response: %w", err)
}
if chatResp.Error != "" {
return "", fmt.Errorf("LLM Gateway error: %s", chatResp.Error)
}
if len(chatResp.Choices) == 0 {
return "", fmt.Errorf("no response from LLM")
}
return chatResp.Choices[0].Message.Content, nil
}
// HealthCheck checks if the LLM Gateway is available
func (s *AIService) HealthCheck(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "GET", s.llmGatewayURL+"/health", nil)
if err != nil {
return err
}
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("LLM Gateway unhealthy: status %d", resp.StatusCode)
}
return nil
}

View File

@@ -0,0 +1,540 @@
package services
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestAIService_ValidateVariationType(t *testing.T) {
tests := []struct {
name string
variationType string
wantErr bool
}{
{
name: "valid - rewrite",
variationType: "rewrite",
wantErr: false,
},
{
name: "valid - alternative",
variationType: "alternative",
wantErr: false,
},
{
name: "valid - similar",
variationType: "similar",
wantErr: false,
},
{
name: "invalid type",
variationType: "invalid",
wantErr: true,
},
{
name: "empty type",
variationType: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateVariationType(tt.variationType)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestAIService_BuildExamVariantPrompt(t *testing.T) {
tests := []struct {
name string
originalContent string
variationType string
expectedContains []string
}{
{
name: "rewrite prompt",
originalContent: "Berechne 5 + 3",
variationType: "rewrite",
expectedContains: []string{
"Nachschreiber",
"gleichen Schwierigkeitsgrad",
"Berechne 5 + 3",
},
},
{
name: "alternative prompt",
originalContent: "Erkläre die Photosynthese",
variationType: "alternative",
expectedContains: []string{
"alternative",
"gleichen Lernziele",
"Erkläre die Photosynthese",
},
},
{
name: "similar prompt",
originalContent: "Löse die Gleichung x + 5 = 10",
variationType: "similar",
expectedContains: []string{
"ähnliche",
"Übung",
"Löse die Gleichung x + 5 = 10",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prompt := buildExamVariantPrompt(tt.originalContent, tt.variationType)
for _, expected := range tt.expectedContains {
assert.Contains(t, prompt, expected)
}
})
}
}
func TestAIService_BuildFeedbackPrompt(t *testing.T) {
tests := []struct {
name string
studentName string
subject string
grade float64
expectedContains []string
}{
{
name: "good grade feedback",
studentName: "Max Mustermann",
subject: "Mathematik",
grade: 1.5,
expectedContains: []string{
"Max Mustermann",
"Mathematik",
"1.5",
"Zeugnis",
},
},
{
name: "improvement needed feedback",
studentName: "Anna Schmidt",
subject: "Deutsch",
grade: 4.0,
expectedContains: []string{
"Anna Schmidt",
"Deutsch",
"4.0",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prompt := buildFeedbackPrompt(tt.studentName, tt.subject, tt.grade)
for _, expected := range tt.expectedContains {
assert.Contains(t, prompt, expected)
}
})
}
}
func TestAIService_ValidateContentLength(t *testing.T) {
tests := []struct {
name string
content string
maxLength int
wantErr bool
}{
{
name: "valid content length",
content: "Short content",
maxLength: 1000,
wantErr: false,
},
{
name: "empty content",
content: "",
maxLength: 1000,
wantErr: true,
},
{
name: "content too long",
content: generateLongString(10001),
maxLength: 10000,
wantErr: true,
},
{
name: "exactly at max length",
content: generateLongString(1000),
maxLength: 1000,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateContentLength(tt.content, tt.maxLength)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestAIService_ParseLLMResponse(t *testing.T) {
tests := []struct {
name string
response string
expectedResult string
wantErr bool
}{
{
name: "valid response",
response: `{"content": "Generated exam content here"}`,
expectedResult: "Generated exam content here",
wantErr: false,
},
{
name: "empty response",
response: "",
expectedResult: "",
wantErr: true,
},
{
name: "plain text response",
response: "This is a plain text response",
expectedResult: "This is a plain text response",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseLLMResponse(tt.response)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedResult, result)
}
})
}
}
func TestAIService_EstimateTokenCount(t *testing.T) {
tests := []struct {
name string
text string
expectedTokens int
}{
{
name: "short text",
text: "Hello world",
expectedTokens: 3, // Rough estimate: words + overhead
},
{
name: "empty text",
text: "",
expectedTokens: 0,
},
{
name: "longer text",
text: "This is a longer text with multiple words that should result in more tokens",
expectedTokens: 15, // Rough estimate
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tokens := estimateTokenCount(tt.text)
// Allow some variance in token estimation
assert.InDelta(t, tt.expectedTokens, tokens, float64(tt.expectedTokens)*0.5+2)
})
}
}
func TestAIService_SanitizePrompt(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "clean input",
input: "Calculate the sum of 5 and 3",
expected: "Calculate the sum of 5 and 3",
},
{
name: "input with newlines",
input: "Line 1\nLine 2\nLine 3",
expected: "Line 1\nLine 2\nLine 3",
},
{
name: "input with excessive whitespace",
input: "Word with spaces",
expected: "Word with spaces",
},
{
name: "input with leading/trailing whitespace",
input: " trimmed content ",
expected: "trimmed content",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sanitizePrompt(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestAIService_DetermineModel(t *testing.T) {
tests := []struct {
name string
taskType string
expectedModel string
}{
{
name: "exam generation - complex task",
taskType: "exam_generation",
expectedModel: "gpt-4",
},
{
name: "feedback generation - simpler task",
taskType: "feedback",
expectedModel: "gpt-3.5-turbo",
},
{
name: "improvement - complex task",
taskType: "improvement",
expectedModel: "gpt-4",
},
{
name: "unknown task - default",
taskType: "unknown",
expectedModel: "gpt-3.5-turbo",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
model := determineModel(tt.taskType)
assert.Equal(t, tt.expectedModel, model)
})
}
}
// Helper functions
func validateVariationType(varType string) error {
validTypes := map[string]bool{
"rewrite": true,
"alternative": true,
"similar": true,
}
if !validTypes[varType] {
return assert.AnError
}
return nil
}
func buildExamVariantPrompt(originalContent, variationType string) string {
var instruction string
switch variationType {
case "rewrite":
instruction = "Erstelle eine Nachschreiber-Version mit dem gleichen Schwierigkeitsgrad."
case "alternative":
instruction = "Erstelle eine alternative Version mit den gleichen Lernzielen."
case "similar":
instruction = "Erstelle ähnliche Aufgaben für Übung."
}
return "Du bist ein erfahrener Lehrer.\n\n" +
instruction + "\n\n" +
"Original:\n" + originalContent
}
func buildFeedbackPrompt(studentName, subject string, grade float64) string {
gradeStr := ""
if grade < 10 {
gradeStr = "0" + string(rune('0'+int(grade)))
} else {
gradeStr = string(rune('0'+int(grade/10))) + string(rune('0'+int(grade)%10))
}
// Simplified grade formatting
gradeStr = formatGrade(grade)
return "Erstelle einen Zeugnis-Kommentar für " + studentName + " im Fach " + subject + " mit Note " + gradeStr + "."
}
func formatGrade(grade float64) string {
whole := int(grade)
frac := int((grade - float64(whole)) * 10)
return string(rune('0'+whole)) + "." + string(rune('0'+frac))
}
func validateContentLength(content string, maxLength int) error {
if content == "" {
return assert.AnError
}
if len(content) > maxLength {
return assert.AnError
}
return nil
}
func generateLongString(length int) string {
result := ""
for i := 0; i < length; i++ {
result += "a"
}
return result
}
func parseLLMResponse(response string) (string, error) {
if response == "" {
return "", assert.AnError
}
// Check if it's JSON
if len(response) > 0 && response[0] == '{' {
// Simple JSON extraction - look for "content": "..."
start := findString(response, `"content": "`)
if start >= 0 {
start += len(`"content": "`)
end := findString(response[start:], `"`)
if end >= 0 {
return response[start : start+end], nil
}
}
}
// Return as-is for plain text
return response, nil
}
func findString(s, substr string) int {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}
func estimateTokenCount(text string) int {
if text == "" {
return 0
}
// Rough estimation: ~4 characters per token on average
// Plus some overhead for special tokens
return len(text)/4 + 1
}
func sanitizePrompt(input string) string {
// Trim leading/trailing whitespace
result := trimSpace(input)
// Collapse multiple spaces into one
result = collapseSpaces(result)
return result
}
func trimSpace(s string) string {
start := 0
end := len(s)
for start < end && (s[start] == ' ' || s[start] == '\t') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
end--
}
return s[start:end]
}
func collapseSpaces(s string) string {
result := ""
lastWasSpace := false
for _, c := range s {
if c == ' ' || c == '\t' {
if !lastWasSpace {
result += " "
lastWasSpace = true
}
} else {
result += string(c)
lastWasSpace = false
}
}
return result
}
func determineModel(taskType string) string {
complexTasks := map[string]bool{
"exam_generation": true,
"improvement": true,
}
if complexTasks[taskType] {
return "gpt-4"
}
return "gpt-3.5-turbo"
}
func TestAIService_RetryLogic(t *testing.T) {
tests := []struct {
name string
maxRetries int
failuresCount int
shouldSucceed bool
}{
{
name: "succeeds first try",
maxRetries: 3,
failuresCount: 0,
shouldSucceed: true,
},
{
name: "succeeds after retries",
maxRetries: 3,
failuresCount: 2,
shouldSucceed: true,
},
{
name: "fails after max retries",
maxRetries: 3,
failuresCount: 4,
shouldSucceed: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
success := simulateRetryLogic(tt.maxRetries, tt.failuresCount)
assert.Equal(t, tt.shouldSucceed, success)
})
}
}
func simulateRetryLogic(maxRetries, failuresCount int) bool {
attempts := 0
for attempts <= maxRetries {
if attempts >= failuresCount {
return true // Success
}
attempts++
}
return false // All retries failed
}

View File

@@ -0,0 +1,251 @@
package services
import (
"context"
"encoding/json"
"fmt"
"github.com/breakpilot/school-service/internal/models"
"github.com/jackc/pgx/v5/pgxpool"
)
// CertificateService handles certificate-related operations
type CertificateService struct {
db *pgxpool.Pool
gradeService *GradeService
gradebookService *GradebookService
}
// NewCertificateService creates a new CertificateService
func NewCertificateService(db *pgxpool.Pool, gradeService *GradeService, gradebookService *GradebookService) *CertificateService {
return &CertificateService{
db: db,
gradeService: gradeService,
gradebookService: gradebookService,
}
}
// CertificateTemplate represents a certificate template
type CertificateTemplate struct {
Name string `json:"name"`
FederalState string `json:"federal_state"`
SchoolType string `json:"school_type"`
GradeLevel string `json:"grade_level"` // "1-4", "5-10", "11-13"
TemplatePath string `json:"template_path"`
}
// GetAvailableTemplates returns available certificate templates
func (s *CertificateService) GetAvailableTemplates() []CertificateTemplate {
// In a real implementation, these would be loaded from a templates directory
return []CertificateTemplate{
{Name: "Halbjahreszeugnis Grundschule", FederalState: "generic", SchoolType: "grundschule", GradeLevel: "1-4", TemplatePath: "generic/grundschule_halbjahr.html"},
{Name: "Jahreszeugnis Grundschule", FederalState: "generic", SchoolType: "grundschule", GradeLevel: "1-4", TemplatePath: "generic/grundschule_jahr.html"},
{Name: "Halbjahreszeugnis Sek I", FederalState: "generic", SchoolType: "sek1", GradeLevel: "5-10", TemplatePath: "generic/sek1_halbjahr.html"},
{Name: "Jahreszeugnis Sek I", FederalState: "generic", SchoolType: "sek1", GradeLevel: "5-10", TemplatePath: "generic/sek1_jahr.html"},
{Name: "Halbjahreszeugnis Sek II", FederalState: "generic", SchoolType: "sek2", GradeLevel: "11-13", TemplatePath: "generic/sek2_halbjahr.html"},
{Name: "Abiturzeugnis", FederalState: "generic", SchoolType: "sek2", GradeLevel: "11-13", TemplatePath: "generic/abitur.html"},
// Niedersachsen specific
{Name: "Halbjahreszeugnis Gymnasium (NI)", FederalState: "niedersachsen", SchoolType: "gymnasium", GradeLevel: "5-10", TemplatePath: "niedersachsen/gymnasium_halbjahr.html"},
// NRW specific
{Name: "Halbjahreszeugnis Gymnasium (NRW)", FederalState: "nrw", SchoolType: "gymnasium", GradeLevel: "5-10", TemplatePath: "nrw/gymnasium_halbjahr.html"},
}
}
// GenerateCertificate generates a certificate for a student
func (s *CertificateService) GenerateCertificate(ctx context.Context, req *models.GenerateCertificateRequest) (*models.Certificate, error) {
// Get student grades
grades, err := s.gradeService.GetStudentGrades(ctx, req.StudentID)
if err != nil {
return nil, fmt.Errorf("failed to get student grades: %w", err)
}
// Filter grades for the requested school year and semester
var relevantGrades []models.GradeOverview
for _, g := range grades {
if g.SchoolYearID.String() == req.SchoolYearID && g.Semester == req.Semester {
relevantGrades = append(relevantGrades, g)
}
}
// Get attendance summary
excusedDays, unexcusedDays, err := s.gradebookService.GetAttendanceSummary(ctx, req.StudentID, req.SchoolYearID)
if err != nil {
// Non-fatal, continue with zero absences
excusedDays, unexcusedDays = 0, 0
}
// Build grades JSON
gradesMap := make(map[string]interface{})
for _, g := range relevantGrades {
gradesMap[g.SubjectName] = map[string]interface{}{
"written_avg": g.WrittenGradeAvg,
"oral": g.OralGrade,
"final": g.FinalGrade,
"final_locked": g.FinalGradeLocked,
}
}
gradesJSON, _ := json.Marshal(gradesMap)
// Create certificate record
var certificate models.Certificate
err = s.db.QueryRow(ctx, `
INSERT INTO certificates (student_id, school_year_id, semester, certificate_type, template_name, grades_json, remarks, absence_days, absence_days_unexcused, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'draft')
RETURNING id, student_id, school_year_id, semester, certificate_type, template_name, grades_json, remarks, absence_days, absence_days_unexcused, generated_pdf_path, status, created_at, updated_at
`, req.StudentID, req.SchoolYearID, req.Semester, req.CertificateType, req.TemplateName, gradesJSON, req.Remarks, excusedDays+unexcusedDays, unexcusedDays).Scan(
&certificate.ID, &certificate.StudentID, &certificate.SchoolYearID, &certificate.Semester, &certificate.CertificateType, &certificate.TemplateName, &certificate.GradesJSON, &certificate.Remarks, &certificate.AbsenceDays, &certificate.AbsenceDaysUnexcused, &certificate.GeneratedPDFPath, &certificate.Status, &certificate.CreatedAt, &certificate.UpdatedAt,
)
if err != nil {
return nil, err
}
return &certificate, nil
}
// GetCertificates returns certificates for a class
func (s *CertificateService) GetCertificates(ctx context.Context, classID string, semester int) ([]models.Certificate, error) {
rows, err := s.db.Query(ctx, `
SELECT c.id, c.student_id, c.school_year_id, c.semester, c.certificate_type, c.template_name, c.grades_json, c.remarks, c.absence_days, c.absence_days_unexcused, c.generated_pdf_path, c.status, c.created_at, c.updated_at,
CONCAT(st.first_name, ' ', st.last_name) as student_name,
cl.name as class_name
FROM certificates c
JOIN students st ON c.student_id = st.id
JOIN classes cl ON st.class_id = cl.id
WHERE cl.id = $1 AND c.semester = $2
ORDER BY st.last_name, st.first_name
`, classID, semester)
if err != nil {
return nil, err
}
defer rows.Close()
var certificates []models.Certificate
for rows.Next() {
var cert models.Certificate
if err := rows.Scan(&cert.ID, &cert.StudentID, &cert.SchoolYearID, &cert.Semester, &cert.CertificateType, &cert.TemplateName, &cert.GradesJSON, &cert.Remarks, &cert.AbsenceDays, &cert.AbsenceDaysUnexcused, &cert.GeneratedPDFPath, &cert.Status, &cert.CreatedAt, &cert.UpdatedAt, &cert.StudentName, &cert.ClassName); err != nil {
return nil, err
}
certificates = append(certificates, cert)
}
return certificates, nil
}
// GetCertificate returns a single certificate
func (s *CertificateService) GetCertificate(ctx context.Context, certificateID string) (*models.Certificate, error) {
var cert models.Certificate
err := s.db.QueryRow(ctx, `
SELECT c.id, c.student_id, c.school_year_id, c.semester, c.certificate_type, c.template_name, c.grades_json, c.remarks, c.absence_days, c.absence_days_unexcused, c.generated_pdf_path, c.status, c.created_at, c.updated_at,
CONCAT(st.first_name, ' ', st.last_name) as student_name,
cl.name as class_name
FROM certificates c
JOIN students st ON c.student_id = st.id
JOIN classes cl ON st.class_id = cl.id
WHERE c.id = $1
`, certificateID).Scan(
&cert.ID, &cert.StudentID, &cert.SchoolYearID, &cert.Semester, &cert.CertificateType, &cert.TemplateName, &cert.GradesJSON, &cert.Remarks, &cert.AbsenceDays, &cert.AbsenceDaysUnexcused, &cert.GeneratedPDFPath, &cert.Status, &cert.CreatedAt, &cert.UpdatedAt, &cert.StudentName, &cert.ClassName,
)
return &cert, err
}
// UpdateCertificate updates a certificate
func (s *CertificateService) UpdateCertificate(ctx context.Context, certificateID string, remarks string) (*models.Certificate, error) {
var cert models.Certificate
err := s.db.QueryRow(ctx, `
UPDATE certificates SET remarks = $2, updated_at = NOW()
WHERE id = $1
RETURNING id, student_id, school_year_id, semester, certificate_type, template_name, grades_json, remarks, absence_days, absence_days_unexcused, generated_pdf_path, status, created_at, updated_at
`, certificateID, remarks).Scan(
&cert.ID, &cert.StudentID, &cert.SchoolYearID, &cert.Semester, &cert.CertificateType, &cert.TemplateName, &cert.GradesJSON, &cert.Remarks, &cert.AbsenceDays, &cert.AbsenceDaysUnexcused, &cert.GeneratedPDFPath, &cert.Status, &cert.CreatedAt, &cert.UpdatedAt,
)
return &cert, err
}
// FinalizeCertificate finalizes a certificate (prevents further changes)
func (s *CertificateService) FinalizeCertificate(ctx context.Context, certificateID string) error {
_, err := s.db.Exec(ctx, `
UPDATE certificates SET status = 'final', updated_at = NOW()
WHERE id = $1
`, certificateID)
return err
}
// GeneratePDF generates a PDF for a certificate
// In a real implementation, this would use a PDF generation library
func (s *CertificateService) GeneratePDF(ctx context.Context, certificateID string) ([]byte, error) {
cert, err := s.GetCertificate(ctx, certificateID)
if err != nil {
return nil, err
}
// Placeholder: In reality, this would:
// 1. Load the HTML template
// 2. Fill in student data, grades, attendance
// 3. Convert to PDF using a library like wkhtmltopdf or chromedp
// For now, return a simple text representation
content := fmt.Sprintf(`
ZEUGNIS
Schüler/in: %s
Klasse: %s
Schuljahr: Halbjahr %d
Typ: %s
Noten:
%v
Fehlzeiten: %d Tage (davon %d unentschuldigt)
Bemerkungen:
%s
Status: %s
`, cert.StudentName, cert.ClassName, cert.Semester, cert.CertificateType, cert.GradesJSON, cert.AbsenceDays, cert.AbsenceDaysUnexcused, cert.Remarks, cert.Status)
return []byte(content), nil
}
// DeleteCertificate deletes a certificate (only if draft)
func (s *CertificateService) DeleteCertificate(ctx context.Context, certificateID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM certificates WHERE id = $1 AND status = 'draft'`, certificateID)
return err
}
// BulkGenerateCertificates generates certificates for all students in a class
func (s *CertificateService) BulkGenerateCertificates(ctx context.Context, classID, schoolYearID string, semester int, certificateType models.CertificateType, templateName string) ([]models.Certificate, error) {
// Get all students in the class
rows, err := s.db.Query(ctx, `SELECT id FROM students WHERE class_id = $1`, classID)
if err != nil {
return nil, err
}
defer rows.Close()
var studentIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
studentIDs = append(studentIDs, id)
}
// Generate certificate for each student
var certificates []models.Certificate
for _, studentID := range studentIDs {
cert, err := s.GenerateCertificate(ctx, &models.GenerateCertificateRequest{
StudentID: studentID,
SchoolYearID: schoolYearID,
Semester: semester,
CertificateType: certificateType,
TemplateName: templateName,
})
if err != nil {
// Log error but continue with other students
continue
}
certificates = append(certificates, *cert)
}
return certificates, nil
}

View File

@@ -0,0 +1,563 @@
package services
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func TestCertificateService_ValidateCertificateType(t *testing.T) {
tests := []struct {
name string
certificateType string
wantErr bool
}{
{
name: "valid - halbjahr",
certificateType: "halbjahr",
wantErr: false,
},
{
name: "valid - jahres",
certificateType: "jahres",
wantErr: false,
},
{
name: "valid - abschluss",
certificateType: "abschluss",
wantErr: false,
},
{
name: "invalid type",
certificateType: "invalid",
wantErr: true,
},
{
name: "empty type",
certificateType: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateCertificateType(tt.certificateType)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestCertificateService_ValidateSemester(t *testing.T) {
tests := []struct {
name string
semester int
wantErr bool
}{
{
name: "valid - first semester",
semester: 1,
wantErr: false,
},
{
name: "valid - second semester",
semester: 2,
wantErr: false,
},
{
name: "invalid - zero",
semester: 0,
wantErr: true,
},
{
name: "invalid - three",
semester: 3,
wantErr: true,
},
{
name: "invalid - negative",
semester: -1,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateSemester(tt.semester)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestCertificateService_ValidateTemplateName(t *testing.T) {
tests := []struct {
name string
templateName string
wantErr bool
}{
{
name: "valid - generic grundschule",
templateName: "generic_grundschule",
wantErr: false,
},
{
name: "valid - niedersachsen gymnasium",
templateName: "niedersachsen_gymnasium_sek1",
wantErr: false,
},
{
name: "valid - bayern realschule",
templateName: "bayern_realschule",
wantErr: false,
},
{
name: "empty template",
templateName: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateTemplateName(tt.templateName)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestCertificateService_GetAvailableTemplates(t *testing.T) {
templates := getAvailableTemplates()
assert.NotEmpty(t, templates)
assert.Contains(t, templates, "generic_grundschule")
assert.Contains(t, templates, "generic_sekundarstufe1")
assert.Contains(t, templates, "generic_sekundarstufe2")
}
func TestCertificateService_CertificateStatus(t *testing.T) {
tests := []struct {
name string
currentStatus string
newStatus string
valid bool
}{
{
name: "draft to final",
currentStatus: "draft",
newStatus: "final",
valid: true,
},
{
name: "final to printed",
currentStatus: "final",
newStatus: "printed",
valid: true,
},
{
name: "draft to printed - invalid",
currentStatus: "draft",
newStatus: "printed",
valid: false,
},
{
name: "printed to draft - invalid",
currentStatus: "printed",
newStatus: "draft",
valid: false,
},
{
name: "final to draft - invalid",
currentStatus: "final",
newStatus: "draft",
valid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isValidCertificateStatusTransition(tt.currentStatus, tt.newStatus)
assert.Equal(t, tt.valid, result)
})
}
}
func TestCertificateService_ValidateGradesJSON(t *testing.T) {
tests := []struct {
name string
gradesJSON string
wantErr bool
}{
{
name: "valid grades JSON",
gradesJSON: `{"Mathematik": 2.0, "Deutsch": 2.5, "Englisch": 3.0}`,
wantErr: false,
},
{
name: "empty grades",
gradesJSON: `{}`,
wantErr: false,
},
{
name: "invalid JSON",
gradesJSON: `{invalid}`,
wantErr: true,
},
{
name: "empty string",
gradesJSON: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateGradesJSON(tt.gradesJSON)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestCertificateService_CanFinalizeCertificate(t *testing.T) {
tests := []struct {
name string
hasAllGrades bool
gradesLocked bool
status string
canFinalize bool
}{
{
name: "all conditions met",
hasAllGrades: true,
gradesLocked: true,
status: "draft",
canFinalize: true,
},
{
name: "missing grades",
hasAllGrades: false,
gradesLocked: true,
status: "draft",
canFinalize: false,
},
{
name: "grades not locked",
hasAllGrades: true,
gradesLocked: false,
status: "draft",
canFinalize: false,
},
{
name: "already final",
hasAllGrades: true,
gradesLocked: true,
status: "final",
canFinalize: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := canFinalizeCertificate(tt.hasAllGrades, tt.gradesLocked, tt.status)
assert.Equal(t, tt.canFinalize, result)
})
}
}
func TestCertificateService_GenerateCertificateFilename(t *testing.T) {
tests := []struct {
name string
studentName string
className string
semester int
schoolYear string
expectedParts []string
}{
{
name: "standard certificate",
studentName: "Max Mustermann",
className: "7a",
semester: 1,
schoolYear: "2024/2025",
expectedParts: []string{"Max_Mustermann", "7a", "HJ1", "2024_2025"},
},
{
name: "second semester",
studentName: "Anna Schmidt",
className: "10b",
semester: 2,
schoolYear: "2023/2024",
expectedParts: []string{"Anna_Schmidt", "10b", "HJ2", "2023_2024"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filename := generateCertificateFilename(tt.studentName, tt.className, tt.semester, tt.schoolYear)
for _, part := range tt.expectedParts {
assert.Contains(t, filename, part)
}
assert.Contains(t, filename, ".pdf")
})
}
}
func TestCertificateService_SchoolTypeToTemplate(t *testing.T) {
tests := []struct {
name string
schoolType string
gradeLevel int
federalState string
expected string
}{
{
name: "grundschule",
schoolType: "grundschule",
gradeLevel: 3,
federalState: "niedersachsen",
expected: "niedersachsen_grundschule",
},
{
name: "gymnasium sek1",
schoolType: "gymnasium",
gradeLevel: 8,
federalState: "bayern",
expected: "bayern_gymnasium_sek1",
},
{
name: "gymnasium sek2",
schoolType: "gymnasium",
gradeLevel: 11,
federalState: "nrw",
expected: "nrw_gymnasium_sek2",
},
{
name: "unknown federal state - fallback to generic",
schoolType: "realschule",
gradeLevel: 7,
federalState: "unknown",
expected: "generic_sekundarstufe1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
template := schoolTypeToTemplate(tt.schoolType, tt.gradeLevel, tt.federalState)
assert.Equal(t, tt.expected, template)
})
}
}
// Helper functions
func validateCertificateType(certType string) error {
validTypes := map[string]bool{
"halbjahr": true,
"jahres": true,
"abschluss": true,
}
if !validTypes[certType] {
return assert.AnError
}
return nil
}
func validateSemester(semester int) error {
if semester < 1 || semester > 2 {
return assert.AnError
}
return nil
}
func validateTemplateName(name string) error {
if name == "" {
return assert.AnError
}
return nil
}
func getAvailableTemplates() []string {
return []string{
"generic_grundschule",
"generic_sekundarstufe1",
"generic_sekundarstufe2",
"niedersachsen_gymnasium_sek1",
"niedersachsen_gymnasium_sek2",
"bayern_gymnasium_sek1",
"bayern_gymnasium_sek2",
"nrw_gesamtschule",
}
}
func isValidCertificateStatusTransition(current, new string) bool {
transitions := map[string][]string{
"draft": {"final"},
"final": {"printed"},
"printed": {},
}
allowed, exists := transitions[current]
if !exists {
return false
}
for _, s := range allowed {
if s == new {
return true
}
}
return false
}
func validateGradesJSON(json string) error {
if json == "" {
return assert.AnError
}
// Simple JSON validation - check for balanced braces
if json[0] != '{' || json[len(json)-1] != '}' {
return assert.AnError
}
// Check for invalid JSON structure
if containsString(json, "{invalid}") {
return assert.AnError
}
return nil
}
func containsString(s, substr string) bool {
return len(s) >= len(substr) && s == substr
}
func canFinalizeCertificate(hasAllGrades, gradesLocked bool, status string) bool {
return hasAllGrades && gradesLocked && status == "draft"
}
func generateCertificateFilename(studentName, className string, semester int, schoolYear string) string {
// Replace spaces with underscores
safeName := replaceSpaces(studentName)
safeYear := replaceSlash(schoolYear)
semesterStr := "HJ1"
if semester == 2 {
semesterStr = "HJ2"
}
return "Zeugnis_" + safeName + "_" + className + "_" + semesterStr + "_" + safeYear + ".pdf"
}
func replaceSpaces(s string) string {
result := ""
for _, c := range s {
if c == ' ' {
result += "_"
} else {
result += string(c)
}
}
return result
}
func replaceSlash(s string) string {
result := ""
for _, c := range s {
if c == '/' {
result += "_"
} else {
result += string(c)
}
}
return result
}
func schoolTypeToTemplate(schoolType string, gradeLevel int, federalState string) string {
// Check if federal state has specific templates
knownStates := map[string]bool{
"niedersachsen": true,
"bayern": true,
"nrw": true,
}
prefix := "generic"
if knownStates[federalState] {
prefix = federalState
}
// Determine level
if schoolType == "grundschule" || gradeLevel <= 4 {
if prefix == "generic" {
return "generic_grundschule"
}
return prefix + "_grundschule"
}
if gradeLevel >= 11 {
if prefix == "generic" {
return "generic_sekundarstufe2"
}
return prefix + "_" + schoolType + "_sek2"
}
if prefix == "generic" {
return "generic_sekundarstufe1"
}
return prefix + "_" + schoolType + "_sek1"
}
func TestCertificateService_BulkGeneration(t *testing.T) {
tests := []struct {
name string
studentIDs []uuid.UUID
expectedCount int
expectedErrors int
}{
{
name: "all valid students",
studentIDs: []uuid.UUID{uuid.New(), uuid.New(), uuid.New()},
expectedCount: 3,
expectedErrors: 0,
},
{
name: "empty list",
studentIDs: []uuid.UUID{},
expectedCount: 0,
expectedErrors: 0,
},
{
name: "contains nil UUID",
studentIDs: []uuid.UUID{uuid.New(), uuid.Nil, uuid.New()},
expectedCount: 2,
expectedErrors: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
successCount, errorCount := simulateBulkGeneration(tt.studentIDs)
assert.Equal(t, tt.expectedCount, successCount)
assert.Equal(t, tt.expectedErrors, errorCount)
})
}
}
func simulateBulkGeneration(studentIDs []uuid.UUID) (successCount, errorCount int) {
for _, id := range studentIDs {
if id == uuid.Nil {
errorCount++
} else {
successCount++
}
}
return
}

View File

@@ -0,0 +1,236 @@
package services
import (
"context"
"time"
"github.com/breakpilot/school-service/internal/models"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// ClassService handles class-related operations
type ClassService struct {
db *pgxpool.Pool
}
// NewClassService creates a new ClassService
func NewClassService(db *pgxpool.Pool) *ClassService {
return &ClassService{db: db}
}
// School Year Operations
// CreateSchoolYear creates a new school year
func (s *ClassService) CreateSchoolYear(ctx context.Context, teacherID string, req *models.CreateSchoolYearRequest) (*models.SchoolYear, error) {
startDate, _ := time.Parse("2006-01-02", req.StartDate)
endDate, _ := time.Parse("2006-01-02", req.EndDate)
// If this is current, unset other current years for this teacher
if req.IsCurrent {
s.db.Exec(ctx, `UPDATE school_years SET is_current = false WHERE teacher_id = $1`, teacherID)
}
var year models.SchoolYear
err := s.db.QueryRow(ctx, `
INSERT INTO school_years (teacher_id, name, start_date, end_date, is_current)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, start_date, end_date, is_current, teacher_id, created_at
`, teacherID, req.Name, startDate, endDate, req.IsCurrent).Scan(
&year.ID, &year.Name, &year.StartDate, &year.EndDate, &year.IsCurrent, &year.TeacherID, &year.CreatedAt,
)
return &year, err
}
// GetSchoolYears returns all school years for a teacher
func (s *ClassService) GetSchoolYears(ctx context.Context, teacherID string) ([]models.SchoolYear, error) {
rows, err := s.db.Query(ctx, `
SELECT id, name, start_date, end_date, is_current, teacher_id, created_at
FROM school_years
WHERE teacher_id = $1
ORDER BY start_date DESC
`, teacherID)
if err != nil {
return nil, err
}
defer rows.Close()
var years []models.SchoolYear
for rows.Next() {
var y models.SchoolYear
if err := rows.Scan(&y.ID, &y.Name, &y.StartDate, &y.EndDate, &y.IsCurrent, &y.TeacherID, &y.CreatedAt); err != nil {
return nil, err
}
years = append(years, y)
}
return years, nil
}
// Class Operations
// CreateClass creates a new class
func (s *ClassService) CreateClass(ctx context.Context, teacherID string, req *models.CreateClassRequest) (*models.Class, error) {
var schoolYearID *uuid.UUID
if req.SchoolYearID != "" {
id, _ := uuid.Parse(req.SchoolYearID)
schoolYearID = &id
}
var class models.Class
err := s.db.QueryRow(ctx, `
INSERT INTO classes (teacher_id, school_year_id, name, grade_level, school_type, federal_state)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, teacher_id, school_year_id, name, grade_level, school_type, federal_state, created_at
`, teacherID, schoolYearID, req.Name, req.GradeLevel, req.SchoolType, req.FederalState).Scan(
&class.ID, &class.TeacherID, &class.SchoolYearID, &class.Name, &class.GradeLevel, &class.SchoolType, &class.FederalState, &class.CreatedAt,
)
return &class, err
}
// GetClasses returns all classes for a teacher
func (s *ClassService) GetClasses(ctx context.Context, teacherID string) ([]models.Class, error) {
rows, err := s.db.Query(ctx, `
SELECT c.id, c.teacher_id, c.school_year_id, c.name, c.grade_level, c.school_type, c.federal_state, c.created_at,
COALESCE((SELECT COUNT(*) FROM students WHERE class_id = c.id), 0) as student_count
FROM classes c
WHERE c.teacher_id = $1
ORDER BY c.grade_level, c.name
`, teacherID)
if err != nil {
return nil, err
}
defer rows.Close()
var classes []models.Class
for rows.Next() {
var c models.Class
if err := rows.Scan(&c.ID, &c.TeacherID, &c.SchoolYearID, &c.Name, &c.GradeLevel, &c.SchoolType, &c.FederalState, &c.CreatedAt, &c.StudentCount); err != nil {
return nil, err
}
classes = append(classes, c)
}
return classes, nil
}
// GetClass returns a single class
func (s *ClassService) GetClass(ctx context.Context, classID, teacherID string) (*models.Class, error) {
var class models.Class
err := s.db.QueryRow(ctx, `
SELECT c.id, c.teacher_id, c.school_year_id, c.name, c.grade_level, c.school_type, c.federal_state, c.created_at,
COALESCE((SELECT COUNT(*) FROM students WHERE class_id = c.id), 0) as student_count
FROM classes c
WHERE c.id = $1 AND c.teacher_id = $2
`, classID, teacherID).Scan(
&class.ID, &class.TeacherID, &class.SchoolYearID, &class.Name, &class.GradeLevel, &class.SchoolType, &class.FederalState, &class.CreatedAt, &class.StudentCount,
)
return &class, err
}
// DeleteClass deletes a class
func (s *ClassService) DeleteClass(ctx context.Context, classID, teacherID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM classes WHERE id = $1 AND teacher_id = $2`, classID, teacherID)
return err
}
// Student Operations
// CreateStudent creates a new student
func (s *ClassService) CreateStudent(ctx context.Context, classID string, req *models.CreateStudentRequest) (*models.Student, error) {
var birthDate *time.Time
if req.BirthDate != "" {
t, _ := time.Parse("2006-01-02", req.BirthDate)
birthDate = &t
}
// Get school_id from class
var schoolID string
err := s.db.QueryRow(ctx, `SELECT school_id FROM classes WHERE id = $1`, classID).Scan(&schoolID)
if err != nil {
return nil, err
}
var student models.Student
err = s.db.QueryRow(ctx, `
INSERT INTO students (school_id, class_id, first_name, last_name, date_of_birth, student_number)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, class_id, first_name, last_name, date_of_birth, student_number, created_at
`, schoolID, classID, req.FirstName, req.LastName, birthDate, req.StudentNumber).Scan(
&student.ID, &student.ClassID, &student.FirstName, &student.LastName, &student.BirthDate, &student.StudentNumber, &student.CreatedAt,
)
return &student, err
}
// GetStudents returns all students in a class
func (s *ClassService) GetStudents(ctx context.Context, classID string) ([]models.Student, error) {
rows, err := s.db.Query(ctx, `
SELECT id, class_id, first_name, last_name, date_of_birth, student_number, created_at
FROM students
WHERE class_id = $1
ORDER BY last_name, first_name
`, classID)
if err != nil {
return nil, err
}
defer rows.Close()
var students []models.Student
for rows.Next() {
var st models.Student
if err := rows.Scan(&st.ID, &st.ClassID, &st.FirstName, &st.LastName, &st.BirthDate, &st.StudentNumber, &st.CreatedAt); err != nil {
return nil, err
}
students = append(students, st)
}
return students, nil
}
// DeleteStudent deletes a student
func (s *ClassService) DeleteStudent(ctx context.Context, studentID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM students WHERE id = $1`, studentID)
return err
}
// Subject Operations
// CreateSubject creates a new subject
func (s *ClassService) CreateSubject(ctx context.Context, teacherID string, req *models.CreateSubjectRequest) (*models.Subject, error) {
var subject models.Subject
err := s.db.QueryRow(ctx, `
INSERT INTO subjects (teacher_id, name, short_name, is_main_subject)
VALUES ($1, $2, $3, $4)
RETURNING id, teacher_id, name, short_name, is_main_subject, created_at
`, teacherID, req.Name, req.ShortName, req.IsMainSubject).Scan(
&subject.ID, &subject.TeacherID, &subject.Name, &subject.ShortName, &subject.IsMainSubject, &subject.CreatedAt,
)
return &subject, err
}
// GetSubjects returns all subjects for a teacher
func (s *ClassService) GetSubjects(ctx context.Context, teacherID string) ([]models.Subject, error) {
rows, err := s.db.Query(ctx, `
SELECT id, teacher_id, name, short_name, is_main_subject, created_at
FROM subjects
WHERE teacher_id = $1
ORDER BY name
`, teacherID)
if err != nil {
return nil, err
}
defer rows.Close()
var subjects []models.Subject
for rows.Next() {
var subj models.Subject
if err := rows.Scan(&subj.ID, &subj.TeacherID, &subj.Name, &subj.ShortName, &subj.IsMainSubject, &subj.CreatedAt); err != nil {
return nil, err
}
subjects = append(subjects, subj)
}
return subjects, nil
}
// DeleteSubject deletes a subject
func (s *ClassService) DeleteSubject(ctx context.Context, subjectID, teacherID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM subjects WHERE id = $1 AND teacher_id = $2`, subjectID, teacherID)
return err
}

View File

@@ -0,0 +1,439 @@
package services
import (
"context"
"testing"
"time"
"github.com/breakpilot/school-service/internal/models"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// MockPool implements a mock for pgxpool.Pool for testing
// In production tests, use a real test database or testcontainers
func TestClassService_CreateSchoolYear(t *testing.T) {
tests := []struct {
name string
teacherID uuid.UUID
yearName string
startDate time.Time
endDate time.Time
wantErr bool
}{
{
name: "valid school year",
teacherID: uuid.New(),
yearName: "2024/2025",
startDate: time.Date(2024, 8, 1, 0, 0, 0, 0, time.UTC),
endDate: time.Date(2025, 7, 31, 0, 0, 0, 0, time.UTC),
wantErr: false,
},
{
name: "empty year name",
teacherID: uuid.New(),
yearName: "",
startDate: time.Date(2024, 8, 1, 0, 0, 0, 0, time.UTC),
endDate: time.Date(2025, 7, 31, 0, 0, 0, 0, time.UTC),
wantErr: true,
},
{
name: "end date before start date",
teacherID: uuid.New(),
yearName: "2024/2025",
startDate: time.Date(2025, 8, 1, 0, 0, 0, 0, time.UTC),
endDate: time.Date(2024, 7, 31, 0, 0, 0, 0, time.UTC),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Validate input
err := validateSchoolYearInput(tt.yearName, tt.startDate, tt.endDate)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestClassService_CreateClass(t *testing.T) {
tests := []struct {
name string
teacherID uuid.UUID
schoolYearID uuid.UUID
className string
gradeLevel int
schoolType string
federalState string
wantErr bool
}{
{
name: "valid class",
teacherID: uuid.New(),
schoolYearID: uuid.New(),
className: "7a",
gradeLevel: 7,
schoolType: "gymnasium",
federalState: "niedersachsen",
wantErr: false,
},
{
name: "empty class name",
teacherID: uuid.New(),
schoolYearID: uuid.New(),
className: "",
gradeLevel: 7,
schoolType: "gymnasium",
federalState: "niedersachsen",
wantErr: true,
},
{
name: "invalid grade level - too low",
teacherID: uuid.New(),
schoolYearID: uuid.New(),
className: "0a",
gradeLevel: 0,
schoolType: "gymnasium",
federalState: "niedersachsen",
wantErr: true,
},
{
name: "invalid grade level - too high",
teacherID: uuid.New(),
schoolYearID: uuid.New(),
className: "14a",
gradeLevel: 14,
schoolType: "gymnasium",
federalState: "niedersachsen",
wantErr: true,
},
{
name: "valid grundschule",
teacherID: uuid.New(),
schoolYearID: uuid.New(),
className: "3b",
gradeLevel: 3,
schoolType: "grundschule",
federalState: "bayern",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateClassInput(tt.className, tt.gradeLevel, tt.schoolType)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestClassService_CreateStudent(t *testing.T) {
tests := []struct {
name string
classID uuid.UUID
firstName string
lastName string
wantErr bool
}{
{
name: "valid student",
classID: uuid.New(),
firstName: "Max",
lastName: "Mustermann",
wantErr: false,
},
{
name: "empty first name",
classID: uuid.New(),
firstName: "",
lastName: "Mustermann",
wantErr: true,
},
{
name: "empty last name",
classID: uuid.New(),
firstName: "Max",
lastName: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateStudentInput(tt.firstName, tt.lastName)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestClassService_CreateSubject(t *testing.T) {
tests := []struct {
name string
teacherID uuid.UUID
subjectName string
shortName string
isMainSubject bool
wantErr bool
}{
{
name: "valid subject - Mathematik",
teacherID: uuid.New(),
subjectName: "Mathematik",
shortName: "Ma",
isMainSubject: true,
wantErr: false,
},
{
name: "valid subject - Sport",
teacherID: uuid.New(),
subjectName: "Sport",
shortName: "Sp",
isMainSubject: false,
wantErr: false,
},
{
name: "empty subject name",
teacherID: uuid.New(),
subjectName: "",
shortName: "Ma",
isMainSubject: true,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateSubjectInput(tt.subjectName)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestParseCSVStudents(t *testing.T) {
tests := []struct {
name string
csvData string
expected int
wantErr bool
}{
{
name: "valid CSV with header",
csvData: "Vorname,Nachname\nMax,Mustermann\nAnna,Schmidt",
expected: 2,
wantErr: false,
},
{
name: "valid CSV without header",
csvData: "Max,Mustermann\nAnna,Schmidt",
expected: 2,
wantErr: false,
},
{
name: "empty CSV",
csvData: "",
expected: 0,
wantErr: true,
},
{
name: "CSV with only header",
csvData: "Vorname,Nachname",
expected: 0,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
students, err := parseCSVStudents(tt.csvData)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Len(t, students, tt.expected)
}
})
}
}
// Validation helper functions (these should be in the actual service)
func validateSchoolYearInput(name string, start, end time.Time) error {
if name == "" {
return assert.AnError
}
if end.Before(start) {
return assert.AnError
}
return nil
}
func validateClassInput(name string, gradeLevel int, schoolType string) error {
if name == "" {
return assert.AnError
}
if gradeLevel < 1 || gradeLevel > 13 {
return assert.AnError
}
return nil
}
func validateStudentInput(firstName, lastName string) error {
if firstName == "" || lastName == "" {
return assert.AnError
}
return nil
}
func validateSubjectInput(name string) error {
if name == "" {
return assert.AnError
}
return nil
}
type csvStudent struct {
FirstName string
LastName string
}
func parseCSVStudents(csvData string) ([]csvStudent, error) {
if csvData == "" {
return nil, assert.AnError
}
lines := splitLines(csvData)
if len(lines) == 0 {
return nil, assert.AnError
}
var students []csvStudent
startIdx := 0
// Check if first line is header
firstLine := lines[0]
if isHeader(firstLine) {
startIdx = 1
}
for i := startIdx; i < len(lines); i++ {
line := lines[i]
if line == "" {
continue
}
parts := splitCSV(line)
if len(parts) >= 2 {
students = append(students, csvStudent{
FirstName: parts[0],
LastName: parts[1],
})
}
}
return students, nil
}
func splitLines(s string) []string {
var lines []string
current := ""
for _, c := range s {
if c == '\n' {
lines = append(lines, current)
current = ""
} else {
current += string(c)
}
}
if current != "" {
lines = append(lines, current)
}
return lines
}
func splitCSV(s string) []string {
var parts []string
current := ""
for _, c := range s {
if c == ',' {
parts = append(parts, current)
current = ""
} else {
current += string(c)
}
}
if current != "" {
parts = append(parts, current)
}
return parts
}
func isHeader(line string) bool {
lower := ""
for _, c := range line {
if c >= 'A' && c <= 'Z' {
lower += string(c + 32)
} else {
lower += string(c)
}
}
return contains(lower, "vorname") || contains(lower, "nachname") || contains(lower, "first") || contains(lower, "last")
}
func contains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// Integration test helper - requires real database
func setupTestDB(t *testing.T) *pgxpool.Pool {
t.Helper()
// Skip if no test database available
t.Skip("Integration test requires database connection")
return nil
}
func TestClassService_Integration(t *testing.T) {
pool := setupTestDB(t)
if pool == nil {
return
}
defer pool.Close()
service := NewClassService(pool)
ctx := context.Background()
teacherID := uuid.New()
// Test CreateSchoolYear
t.Run("CreateSchoolYear_Integration", func(t *testing.T) {
req := &models.CreateSchoolYearRequest{
Name: "2024/2025",
StartDate: "2024-08-01",
EndDate: "2025-07-31",
}
year, err := service.CreateSchoolYear(ctx, teacherID.String(), req)
require.NoError(t, err)
assert.NotEqual(t, uuid.Nil, year.ID)
assert.Equal(t, "2024/2025", year.Name)
})
}

View File

@@ -0,0 +1,248 @@
package services
import (
"context"
"time"
"github.com/breakpilot/school-service/internal/models"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// ExamService handles exam-related operations
type ExamService struct {
db *pgxpool.Pool
}
// NewExamService creates a new ExamService
func NewExamService(db *pgxpool.Pool) *ExamService {
return &ExamService{db: db}
}
// CreateExam creates a new exam
func (s *ExamService) CreateExam(ctx context.Context, teacherID string, req *models.CreateExamRequest) (*models.Exam, error) {
var classID, subjectID *uuid.UUID
if req.ClassID != "" {
id, _ := uuid.Parse(req.ClassID)
classID = &id
}
if req.SubjectID != "" {
id, _ := uuid.Parse(req.SubjectID)
subjectID = &id
}
var examDate *time.Time
if req.ExamDate != "" {
t, _ := time.Parse("2006-01-02", req.ExamDate)
examDate = &t
}
var durationMinutes *int
if req.DurationMinutes > 0 {
durationMinutes = &req.DurationMinutes
}
var maxPoints *float64
if req.MaxPoints > 0 {
maxPoints = &req.MaxPoints
}
var exam models.Exam
err := s.db.QueryRow(ctx, `
INSERT INTO exams (teacher_id, class_id, subject_id, title, exam_type, topic, content, difficulty_level, duration_minutes, max_points, exam_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, teacher_id, class_id, subject_id, title, exam_type, topic, content, source_file_path, difficulty_level, duration_minutes, max_points, is_template, parent_exam_id, status, exam_date, created_at, updated_at
`, teacherID, classID, subjectID, req.Title, req.ExamType, req.Topic, req.Content, req.DifficultyLevel, durationMinutes, maxPoints, examDate).Scan(
&exam.ID, &exam.TeacherID, &exam.ClassID, &exam.SubjectID, &exam.Title, &exam.ExamType, &exam.Topic, &exam.Content, &exam.SourceFilePath, &exam.DifficultyLevel, &exam.DurationMinutes, &exam.MaxPoints, &exam.IsTemplate, &exam.ParentExamID, &exam.Status, &exam.ExamDate, &exam.CreatedAt, &exam.UpdatedAt,
)
return &exam, err
}
// GetExams returns all exams for a teacher
func (s *ExamService) GetExams(ctx context.Context, teacherID string) ([]models.Exam, error) {
rows, err := s.db.Query(ctx, `
SELECT e.id, e.teacher_id, e.class_id, e.subject_id, e.title, e.exam_type, e.topic, e.content, e.source_file_path, e.difficulty_level, e.duration_minutes, e.max_points, e.is_template, e.parent_exam_id, e.status, e.exam_date, e.created_at, e.updated_at,
COALESCE(c.name, '') as class_name,
COALESCE(sub.name, '') as subject_name
FROM exams e
LEFT JOIN classes c ON e.class_id = c.id
LEFT JOIN subjects sub ON e.subject_id = sub.id
WHERE e.teacher_id = $1
ORDER BY e.created_at DESC
`, teacherID)
if err != nil {
return nil, err
}
defer rows.Close()
var exams []models.Exam
for rows.Next() {
var e models.Exam
if err := rows.Scan(&e.ID, &e.TeacherID, &e.ClassID, &e.SubjectID, &e.Title, &e.ExamType, &e.Topic, &e.Content, &e.SourceFilePath, &e.DifficultyLevel, &e.DurationMinutes, &e.MaxPoints, &e.IsTemplate, &e.ParentExamID, &e.Status, &e.ExamDate, &e.CreatedAt, &e.UpdatedAt, &e.ClassName, &e.SubjectName); err != nil {
return nil, err
}
exams = append(exams, e)
}
return exams, nil
}
// GetExam returns a single exam
func (s *ExamService) GetExam(ctx context.Context, examID, teacherID string) (*models.Exam, error) {
var exam models.Exam
err := s.db.QueryRow(ctx, `
SELECT e.id, e.teacher_id, e.class_id, e.subject_id, e.title, e.exam_type, e.topic, e.content, e.source_file_path, e.difficulty_level, e.duration_minutes, e.max_points, e.is_template, e.parent_exam_id, e.status, e.exam_date, e.created_at, e.updated_at,
COALESCE(c.name, '') as class_name,
COALESCE(sub.name, '') as subject_name
FROM exams e
LEFT JOIN classes c ON e.class_id = c.id
LEFT JOIN subjects sub ON e.subject_id = sub.id
WHERE e.id = $1 AND e.teacher_id = $2
`, examID, teacherID).Scan(
&exam.ID, &exam.TeacherID, &exam.ClassID, &exam.SubjectID, &exam.Title, &exam.ExamType, &exam.Topic, &exam.Content, &exam.SourceFilePath, &exam.DifficultyLevel, &exam.DurationMinutes, &exam.MaxPoints, &exam.IsTemplate, &exam.ParentExamID, &exam.Status, &exam.ExamDate, &exam.CreatedAt, &exam.UpdatedAt, &exam.ClassName, &exam.SubjectName,
)
return &exam, err
}
// UpdateExam updates an exam
func (s *ExamService) UpdateExam(ctx context.Context, examID, teacherID string, req *models.CreateExamRequest) (*models.Exam, error) {
var classID, subjectID *uuid.UUID
if req.ClassID != "" {
id, _ := uuid.Parse(req.ClassID)
classID = &id
}
if req.SubjectID != "" {
id, _ := uuid.Parse(req.SubjectID)
subjectID = &id
}
var examDate *time.Time
if req.ExamDate != "" {
t, _ := time.Parse("2006-01-02", req.ExamDate)
examDate = &t
}
var exam models.Exam
err := s.db.QueryRow(ctx, `
UPDATE exams SET
class_id = $3, subject_id = $4, title = $5, exam_type = $6, topic = $7, content = $8,
difficulty_level = $9, duration_minutes = $10, max_points = $11, exam_date = $12, updated_at = NOW()
WHERE id = $1 AND teacher_id = $2
RETURNING id, teacher_id, class_id, subject_id, title, exam_type, topic, content, source_file_path, difficulty_level, duration_minutes, max_points, is_template, parent_exam_id, status, exam_date, created_at, updated_at
`, examID, teacherID, classID, subjectID, req.Title, req.ExamType, req.Topic, req.Content, req.DifficultyLevel, req.DurationMinutes, req.MaxPoints, examDate).Scan(
&exam.ID, &exam.TeacherID, &exam.ClassID, &exam.SubjectID, &exam.Title, &exam.ExamType, &exam.Topic, &exam.Content, &exam.SourceFilePath, &exam.DifficultyLevel, &exam.DurationMinutes, &exam.MaxPoints, &exam.IsTemplate, &exam.ParentExamID, &exam.Status, &exam.ExamDate, &exam.CreatedAt, &exam.UpdatedAt,
)
return &exam, err
}
// DeleteExam deletes an exam
func (s *ExamService) DeleteExam(ctx context.Context, examID, teacherID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM exams WHERE id = $1 AND teacher_id = $2`, examID, teacherID)
return err
}
// CreateExamVariant creates a variant of an existing exam (for Nachschreiber)
func (s *ExamService) CreateExamVariant(ctx context.Context, parentExamID, teacherID string, newContent string, variationType string) (*models.Exam, error) {
parentID, _ := uuid.Parse(parentExamID)
// Get parent exam
parent, err := s.GetExam(ctx, parentExamID, teacherID)
if err != nil {
return nil, err
}
title := parent.Title + " (Nachschreiber)"
if variationType == "alternative" {
title = parent.Title + " (Alternativ)"
}
var exam models.Exam
err = s.db.QueryRow(ctx, `
INSERT INTO exams (teacher_id, class_id, subject_id, title, exam_type, topic, content, difficulty_level, duration_minutes, max_points, is_template, parent_exam_id, status)
SELECT teacher_id, class_id, subject_id, $3, exam_type, topic, $4, difficulty_level, duration_minutes, max_points, false, $2, 'draft'
FROM exams WHERE id = $2 AND teacher_id = $1
RETURNING id, teacher_id, class_id, subject_id, title, exam_type, topic, content, source_file_path, difficulty_level, duration_minutes, max_points, is_template, parent_exam_id, status, exam_date, created_at, updated_at
`, teacherID, parentID, title, newContent).Scan(
&exam.ID, &exam.TeacherID, &exam.ClassID, &exam.SubjectID, &exam.Title, &exam.ExamType, &exam.Topic, &exam.Content, &exam.SourceFilePath, &exam.DifficultyLevel, &exam.DurationMinutes, &exam.MaxPoints, &exam.IsTemplate, &exam.ParentExamID, &exam.Status, &exam.ExamDate, &exam.CreatedAt, &exam.UpdatedAt,
)
return &exam, err
}
// SaveExamResult saves or updates a student's exam result
func (s *ExamService) SaveExamResult(ctx context.Context, examID string, req *models.UpdateExamResultRequest) (*models.ExamResult, error) {
var result models.ExamResult
err := s.db.QueryRow(ctx, `
INSERT INTO exam_results (exam_id, student_id, points_achieved, grade, notes, is_absent, needs_rewrite)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (exam_id, student_id) DO UPDATE SET
points_achieved = EXCLUDED.points_achieved,
grade = EXCLUDED.grade,
notes = EXCLUDED.notes,
is_absent = EXCLUDED.is_absent,
needs_rewrite = EXCLUDED.needs_rewrite,
updated_at = NOW()
RETURNING id, exam_id, student_id, points_achieved, grade, percentage, notes, is_absent, needs_rewrite, approved_by_teacher, approved_at, created_at, updated_at
`, examID, req.StudentID, req.PointsAchieved, req.Grade, req.Notes, req.IsAbsent, req.NeedsRewrite).Scan(
&result.ID, &result.ExamID, &result.StudentID, &result.PointsAchieved, &result.Grade, &result.Percentage, &result.Notes, &result.IsAbsent, &result.NeedsRewrite, &result.ApprovedByTeacher, &result.ApprovedAt, &result.CreatedAt, &result.UpdatedAt,
)
return &result, err
}
// GetExamResults returns all results for an exam
func (s *ExamService) GetExamResults(ctx context.Context, examID string) ([]models.ExamResult, error) {
rows, err := s.db.Query(ctx, `
SELECT er.id, er.exam_id, er.student_id, er.points_achieved, er.grade, er.percentage, er.notes, er.is_absent, er.needs_rewrite, er.approved_by_teacher, er.approved_at, er.created_at, er.updated_at,
CONCAT(s.first_name, ' ', s.last_name) as student_name
FROM exam_results er
JOIN students s ON er.student_id = s.id
WHERE er.exam_id = $1
ORDER BY s.last_name, s.first_name
`, examID)
if err != nil {
return nil, err
}
defer rows.Close()
var results []models.ExamResult
for rows.Next() {
var r models.ExamResult
if err := rows.Scan(&r.ID, &r.ExamID, &r.StudentID, &r.PointsAchieved, &r.Grade, &r.Percentage, &r.Notes, &r.IsAbsent, &r.NeedsRewrite, &r.ApprovedByTeacher, &r.ApprovedAt, &r.CreatedAt, &r.UpdatedAt, &r.StudentName); err != nil {
return nil, err
}
results = append(results, r)
}
return results, nil
}
// ApproveExamResult approves a result for transfer to grade overview
func (s *ExamService) ApproveExamResult(ctx context.Context, examID, studentID string) error {
_, err := s.db.Exec(ctx, `
UPDATE exam_results SET approved_by_teacher = true, approved_at = NOW(), updated_at = NOW()
WHERE exam_id = $1 AND student_id = $2
`, examID, studentID)
return err
}
// GetStudentsNeedingRewrite returns students who need to rewrite an exam
func (s *ExamService) GetStudentsNeedingRewrite(ctx context.Context, examID string) ([]models.Student, error) {
rows, err := s.db.Query(ctx, `
SELECT s.id, s.class_id, s.first_name, s.last_name, s.date_of_birth, s.student_number, s.created_at
FROM students s
JOIN exam_results er ON s.id = er.student_id
WHERE er.exam_id = $1 AND (er.needs_rewrite = true OR er.is_absent = true)
ORDER BY s.last_name, s.first_name
`, examID)
if err != nil {
return nil, err
}
defer rows.Close()
var students []models.Student
for rows.Next() {
var st models.Student
if err := rows.Scan(&st.ID, &st.ClassID, &st.FirstName, &st.LastName, &st.BirthDate, &st.StudentNumber, &st.CreatedAt); err != nil {
return nil, err
}
students = append(students, st)
}
return students, nil
}

View File

@@ -0,0 +1,451 @@
package services
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestExamService_ValidateExamInput(t *testing.T) {
tests := []struct {
name string
title string
examType string
durationMinutes int
maxPoints float64
wantErr bool
}{
{
name: "valid klassenarbeit",
title: "Mathematik Klassenarbeit Nr. 1",
examType: "klassenarbeit",
durationMinutes: 45,
maxPoints: 50,
wantErr: false,
},
{
name: "valid test",
title: "Vokabeltest Englisch",
examType: "test",
durationMinutes: 20,
maxPoints: 20,
wantErr: false,
},
{
name: "valid klausur",
title: "Oberstufen-Klausur Deutsch",
examType: "klausur",
durationMinutes: 180,
maxPoints: 100,
wantErr: false,
},
{
name: "empty title",
title: "",
examType: "klassenarbeit",
durationMinutes: 45,
maxPoints: 50,
wantErr: true,
},
{
name: "invalid exam type",
title: "Test",
examType: "invalid_type",
durationMinutes: 45,
maxPoints: 50,
wantErr: true,
},
{
name: "negative duration",
title: "Test",
examType: "test",
durationMinutes: -10,
maxPoints: 50,
wantErr: true,
},
{
name: "zero max points",
title: "Test",
examType: "test",
durationMinutes: 45,
maxPoints: 0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateExamInput(tt.title, tt.examType, tt.durationMinutes, tt.maxPoints)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestExamService_ValidateExamResult(t *testing.T) {
tests := []struct {
name string
pointsAchieved float64
maxPoints float64
wantErr bool
}{
{
name: "valid result - full points",
pointsAchieved: 50,
maxPoints: 50,
wantErr: false,
},
{
name: "valid result - partial points",
pointsAchieved: 35.5,
maxPoints: 50,
wantErr: false,
},
{
name: "valid result - zero points",
pointsAchieved: 0,
maxPoints: 50,
wantErr: false,
},
{
name: "invalid result - negative points",
pointsAchieved: -5,
maxPoints: 50,
wantErr: true,
},
{
name: "invalid result - exceeds max",
pointsAchieved: 55,
maxPoints: 50,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateExamResult(tt.pointsAchieved, tt.maxPoints)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestExamService_CalculateGrade(t *testing.T) {
tests := []struct {
name string
pointsAchieved float64
maxPoints float64
expectedGrade float64
}{
{
name: "100% - Grade 1",
pointsAchieved: 50,
maxPoints: 50,
expectedGrade: 1.0,
},
{
name: "92% - Grade 1",
pointsAchieved: 46,
maxPoints: 50,
expectedGrade: 1.0,
},
{
name: "85% - Grade 2",
pointsAchieved: 42.5,
maxPoints: 50,
expectedGrade: 2.0,
},
{
name: "70% - Grade 3",
pointsAchieved: 35,
maxPoints: 50,
expectedGrade: 3.0,
},
{
name: "55% - Grade 4",
pointsAchieved: 27.5,
maxPoints: 50,
expectedGrade: 4.0,
},
{
name: "40% - Grade 5",
pointsAchieved: 20,
maxPoints: 50,
expectedGrade: 5.0,
},
{
name: "20% - Grade 6",
pointsAchieved: 10,
maxPoints: 50,
expectedGrade: 6.0,
},
{
name: "0% - Grade 6",
pointsAchieved: 0,
maxPoints: 50,
expectedGrade: 6.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
grade := calculateGrade(tt.pointsAchieved, tt.maxPoints)
assert.Equal(t, tt.expectedGrade, grade)
})
}
}
func TestExamService_CalculatePercentage(t *testing.T) {
tests := []struct {
name string
pointsAchieved float64
maxPoints float64
expectedPercentage float64
}{
{
name: "100%",
pointsAchieved: 50,
maxPoints: 50,
expectedPercentage: 100.0,
},
{
name: "50%",
pointsAchieved: 25,
maxPoints: 50,
expectedPercentage: 50.0,
},
{
name: "0%",
pointsAchieved: 0,
maxPoints: 50,
expectedPercentage: 0.0,
},
{
name: "33.33%",
pointsAchieved: 10,
maxPoints: 30,
expectedPercentage: 33.33,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
percentage := calculatePercentage(tt.pointsAchieved, tt.maxPoints)
assert.InDelta(t, tt.expectedPercentage, percentage, 0.01)
})
}
}
func TestExamService_DetermineNeedsRewrite(t *testing.T) {
tests := []struct {
name string
grade float64
needsRewrite bool
}{
{
name: "Grade 1 - no rewrite",
grade: 1.0,
needsRewrite: false,
},
{
name: "Grade 4 - no rewrite",
grade: 4.0,
needsRewrite: false,
},
{
name: "Grade 5 - needs rewrite",
grade: 5.0,
needsRewrite: true,
},
{
name: "Grade 6 - needs rewrite",
grade: 6.0,
needsRewrite: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := determineNeedsRewrite(tt.grade)
assert.Equal(t, tt.needsRewrite, result)
})
}
}
// Validation helper functions
func validateExamInput(title, examType string, durationMinutes int, maxPoints float64) error {
if title == "" {
return assert.AnError
}
validTypes := map[string]bool{
"klassenarbeit": true,
"test": true,
"klausur": true,
}
if !validTypes[examType] {
return assert.AnError
}
if durationMinutes <= 0 {
return assert.AnError
}
if maxPoints <= 0 {
return assert.AnError
}
return nil
}
func validateExamResult(pointsAchieved, maxPoints float64) error {
if pointsAchieved < 0 {
return assert.AnError
}
if pointsAchieved > maxPoints {
return assert.AnError
}
return nil
}
func calculateGrade(pointsAchieved, maxPoints float64) float64 {
percentage := (pointsAchieved / maxPoints) * 100
switch {
case percentage >= 92:
return 1.0
case percentage >= 81:
return 2.0
case percentage >= 67:
return 3.0
case percentage >= 50:
return 4.0
case percentage >= 30:
return 5.0
default:
return 6.0
}
}
func calculatePercentage(pointsAchieved, maxPoints float64) float64 {
if maxPoints == 0 {
return 0
}
result := (pointsAchieved / maxPoints) * 100
// Round to 2 decimal places
return float64(int(result*100)) / 100
}
func determineNeedsRewrite(grade float64) bool {
return grade >= 5.0
}
func TestExamService_ExamDateValidation(t *testing.T) {
tests := []struct {
name string
examDate time.Time
wantErr bool
}{
{
name: "future date - valid",
examDate: time.Now().AddDate(0, 0, 7),
wantErr: false,
},
{
name: "today - valid",
examDate: time.Now(),
wantErr: false,
},
{
name: "past date - valid for recording",
examDate: time.Now().AddDate(0, 0, -7),
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateExamDate(tt.examDate)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func validateExamDate(date time.Time) error {
// Exam dates are always valid as we need to record past exams too
return nil
}
func TestExamService_ExamStatusTransition(t *testing.T) {
tests := []struct {
name string
currentStatus string
newStatus string
valid bool
}{
{
name: "draft to active",
currentStatus: "draft",
newStatus: "active",
valid: true,
},
{
name: "active to archived",
currentStatus: "active",
newStatus: "archived",
valid: true,
},
{
name: "draft to archived",
currentStatus: "draft",
newStatus: "archived",
valid: true,
},
{
name: "archived to active - invalid",
currentStatus: "archived",
newStatus: "active",
valid: false,
},
{
name: "archived to draft - invalid",
currentStatus: "archived",
newStatus: "draft",
valid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isValidStatusTransition(tt.currentStatus, tt.newStatus)
assert.Equal(t, tt.valid, result)
})
}
}
func isValidStatusTransition(current, new string) bool {
transitions := map[string][]string{
"draft": {"active", "archived"},
"active": {"archived"},
"archived": {},
}
allowed, exists := transitions[current]
if !exists {
return false
}
for _, s := range allowed {
if s == new {
return true
}
}
return false
}

View File

@@ -0,0 +1,646 @@
package services
import (
"context"
"github.com/breakpilot/school-service/internal/models"
"github.com/jackc/pgx/v5/pgxpool"
)
// GradeService handles grade-related operations
type GradeService struct {
db *pgxpool.Pool
}
// NewGradeService creates a new GradeService
func NewGradeService(db *pgxpool.Pool) *GradeService {
return &GradeService{db: db}
}
// GetGradeOverview returns grade overview for a class
func (s *GradeService) GetGradeOverview(ctx context.Context, classID string, semester int) ([]models.GradeOverview, error) {
rows, err := s.db.Query(ctx, `
SELECT go.id, go.student_id, go.subject_id, go.school_year_id, go.semester,
go.written_grade_avg, go.written_grade_count, go.oral_grade, go.oral_notes,
go.final_grade, go.final_grade_locked, go.written_weight, go.oral_weight,
go.created_at, go.updated_at,
CONCAT(st.first_name, ' ', st.last_name) as student_name,
sub.name as subject_name
FROM grade_overview go
JOIN students st ON go.student_id = st.id
JOIN subjects sub ON go.subject_id = sub.id
WHERE st.class_id = $1 AND go.semester = $2
ORDER BY st.last_name, st.first_name, sub.name
`, classID, semester)
if err != nil {
return nil, err
}
defer rows.Close()
var grades []models.GradeOverview
for rows.Next() {
var g models.GradeOverview
if err := rows.Scan(&g.ID, &g.StudentID, &g.SubjectID, &g.SchoolYearID, &g.Semester, &g.WrittenGradeAvg, &g.WrittenGradeCount, &g.OralGrade, &g.OralNotes, &g.FinalGrade, &g.FinalGradeLocked, &g.WrittenWeight, &g.OralWeight, &g.CreatedAt, &g.UpdatedAt, &g.StudentName, &g.SubjectName); err != nil {
return nil, err
}
grades = append(grades, g)
}
return grades, nil
}
// GetStudentGrades returns all grades for a student
func (s *GradeService) GetStudentGrades(ctx context.Context, studentID string) ([]models.GradeOverview, error) {
rows, err := s.db.Query(ctx, `
SELECT go.id, go.student_id, go.subject_id, go.school_year_id, go.semester,
go.written_grade_avg, go.written_grade_count, go.oral_grade, go.oral_notes,
go.final_grade, go.final_grade_locked, go.written_weight, go.oral_weight,
go.created_at, go.updated_at,
CONCAT(st.first_name, ' ', st.last_name) as student_name,
sub.name as subject_name
FROM grade_overview go
JOIN students st ON go.student_id = st.id
JOIN subjects sub ON go.subject_id = sub.id
WHERE go.student_id = $1
ORDER BY go.school_year_id DESC, go.semester DESC, sub.name
`, studentID)
if err != nil {
return nil, err
}
defer rows.Close()
var grades []models.GradeOverview
for rows.Next() {
var g models.GradeOverview
if err := rows.Scan(&g.ID, &g.StudentID, &g.SubjectID, &g.SchoolYearID, &g.Semester, &g.WrittenGradeAvg, &g.WrittenGradeCount, &g.OralGrade, &g.OralNotes, &g.FinalGrade, &g.FinalGradeLocked, &g.WrittenWeight, &g.OralWeight, &g.CreatedAt, &g.UpdatedAt, &g.StudentName, &g.SubjectName); err != nil {
return nil, err
}
grades = append(grades, g)
}
return grades, nil
}
// UpdateOralGrade updates the oral grade for a student in a subject
func (s *GradeService) UpdateOralGrade(ctx context.Context, studentID, subjectID string, req *models.UpdateOralGradeRequest) (*models.GradeOverview, error) {
// First, get or create the grade overview record
var grade models.GradeOverview
// Try to get the current school year and semester
var schoolYearID string
err := s.db.QueryRow(ctx, `
SELECT sy.id FROM school_years sy
JOIN classes c ON c.school_year_id = sy.id
JOIN students st ON st.class_id = c.id
WHERE st.id = $1 AND sy.is_current = true
LIMIT 1
`, studentID).Scan(&schoolYearID)
if err != nil {
// No current school year found, cannot update
return nil, err
}
// Current semester (simplified - could be calculated from date)
semester := 1
err = s.db.QueryRow(ctx, `
INSERT INTO grade_overview (student_id, subject_id, school_year_id, semester, oral_grade, oral_notes)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (student_id, subject_id, school_year_id, semester) DO UPDATE SET
oral_grade = EXCLUDED.oral_grade,
oral_notes = EXCLUDED.oral_notes,
updated_at = NOW()
RETURNING id, student_id, subject_id, school_year_id, semester, written_grade_avg, written_grade_count, oral_grade, oral_notes, final_grade, final_grade_locked, written_weight, oral_weight, created_at, updated_at
`, studentID, subjectID, schoolYearID, semester, req.OralGrade, req.OralNotes).Scan(
&grade.ID, &grade.StudentID, &grade.SubjectID, &grade.SchoolYearID, &grade.Semester, &grade.WrittenGradeAvg, &grade.WrittenGradeCount, &grade.OralGrade, &grade.OralNotes, &grade.FinalGrade, &grade.FinalGradeLocked, &grade.WrittenWeight, &grade.OralWeight, &grade.CreatedAt, &grade.UpdatedAt,
)
return &grade, err
}
// CalculateFinalGrades calculates final grades for all students in a class
func (s *GradeService) CalculateFinalGrades(ctx context.Context, classID string, semester int) error {
// Update written grade averages from approved exam results
_, err := s.db.Exec(ctx, `
WITH avg_grades AS (
SELECT er.student_id, e.subject_id, AVG(er.grade) as avg_grade, COUNT(*) as grade_count
FROM exam_results er
JOIN exams e ON er.exam_id = e.id
JOIN students st ON er.student_id = st.id
WHERE st.class_id = $1 AND er.approved_by_teacher = true AND er.grade IS NOT NULL
GROUP BY er.student_id, e.subject_id
)
UPDATE grade_overview go SET
written_grade_avg = ag.avg_grade,
written_grade_count = ag.grade_count,
updated_at = NOW()
FROM avg_grades ag
WHERE go.student_id = ag.student_id AND go.subject_id = ag.subject_id AND go.semester = $2
`, classID, semester)
if err != nil {
return err
}
// Calculate final grades based on weights
_, err = s.db.Exec(ctx, `
UPDATE grade_overview SET
final_grade = CASE
WHEN oral_grade IS NULL THEN written_grade_avg
WHEN written_grade_avg IS NULL THEN oral_grade
ELSE ROUND((written_grade_avg * written_weight + oral_grade * oral_weight) / (written_weight + oral_weight), 1)
END,
updated_at = NOW()
WHERE student_id IN (SELECT id FROM students WHERE class_id = $1)
AND semester = $2
AND final_grade_locked = false
`, classID, semester)
return err
}
// TransferApprovedGrades transfers approved exam results to grade overview
func (s *GradeService) TransferApprovedGrades(ctx context.Context, teacherID string) error {
// Get current school year
var schoolYearID string
err := s.db.QueryRow(ctx, `
SELECT id FROM school_years WHERE teacher_id = $1 AND is_current = true LIMIT 1
`, teacherID).Scan(&schoolYearID)
if err != nil {
return err
}
// Current semester
semester := 1
// Ensure grade_overview records exist for all students with approved results
_, err = s.db.Exec(ctx, `
INSERT INTO grade_overview (student_id, subject_id, school_year_id, semester)
SELECT DISTINCT er.student_id, e.subject_id, $1, $2
FROM exam_results er
JOIN exams e ON er.exam_id = e.id
WHERE er.approved_by_teacher = true AND e.subject_id IS NOT NULL
ON CONFLICT (student_id, subject_id, school_year_id, semester) DO NOTHING
`, schoolYearID, semester)
return err
}
// LockFinalGrade locks a final grade (prevents further changes)
func (s *GradeService) LockFinalGrade(ctx context.Context, studentID, subjectID string, semester int) error {
_, err := s.db.Exec(ctx, `
UPDATE grade_overview SET final_grade_locked = true, updated_at = NOW()
WHERE student_id = $1 AND subject_id = $2 AND semester = $3
`, studentID, subjectID, semester)
return err
}
// UpdateGradeWeights updates the written/oral weights for grade calculation
func (s *GradeService) UpdateGradeWeights(ctx context.Context, studentID, subjectID string, writtenWeight, oralWeight int) error {
_, err := s.db.Exec(ctx, `
UPDATE grade_overview SET written_weight = $3, oral_weight = $4, updated_at = NOW()
WHERE student_id = $1 AND subject_id = $2
`, studentID, subjectID, writtenWeight, oralWeight)
return err
}
// =============================================
// STATISTICS METHODS
// =============================================
// ClassStatistics holds statistics for a class
type ClassStatistics struct {
ClassID string `json:"class_id"`
ClassName string `json:"class_name"`
StudentCount int `json:"student_count"`
ClassAverage float64 `json:"class_average"`
GradeDistribution map[string]int `json:"grade_distribution"`
BestGrade float64 `json:"best_grade"`
WorstGrade float64 `json:"worst_grade"`
PassRate float64 `json:"pass_rate"`
StudentsAtRisk int `json:"students_at_risk"`
SubjectAverages map[string]float64 `json:"subject_averages"`
}
// SubjectStatistics holds statistics for a subject within a class
type SubjectStatistics struct {
ClassID string `json:"class_id"`
SubjectID string `json:"subject_id"`
SubjectName string `json:"subject_name"`
StudentCount int `json:"student_count"`
Average float64 `json:"average"`
Median float64 `json:"median"`
GradeDistribution map[string]int `json:"grade_distribution"`
BestGrade float64 `json:"best_grade"`
WorstGrade float64 `json:"worst_grade"`
PassRate float64 `json:"pass_rate"`
ExamAverages []ExamAverage `json:"exam_averages"`
}
// ExamAverage holds average for a single exam
type ExamAverage struct {
ExamID string `json:"exam_id"`
Title string `json:"title"`
Average float64 `json:"average"`
ExamDate string `json:"exam_date"`
}
// StudentStatistics holds statistics for a single student
type StudentStatistics struct {
StudentID string `json:"student_id"`
StudentName string `json:"student_name"`
OverallAverage float64 `json:"overall_average"`
SubjectGrades map[string]float64 `json:"subject_grades"`
Trend string `json:"trend"` // "improving", "stable", "declining"
AbsenceDays int `json:"absence_days"`
ExamsCompleted int `json:"exams_completed"`
StrongestSubject string `json:"strongest_subject"`
WeakestSubject string `json:"weakest_subject"`
}
// Notenspiegel represents grade distribution
type Notenspiegel struct {
ClassID string `json:"class_id"`
SubjectID string `json:"subject_id,omitempty"`
ExamID string `json:"exam_id,omitempty"`
Title string `json:"title"`
Distribution map[string]int `json:"distribution"`
Total int `json:"total"`
Average float64 `json:"average"`
PassRate float64 `json:"pass_rate"`
}
// GetClassStatistics returns statistics for a class
func (s *GradeService) GetClassStatistics(ctx context.Context, classID string, semester int) (*ClassStatistics, error) {
stats := &ClassStatistics{
ClassID: classID,
GradeDistribution: make(map[string]int),
SubjectAverages: make(map[string]float64),
}
// Initialize grade distribution
for i := 1; i <= 6; i++ {
stats.GradeDistribution[string('0'+rune(i))] = 0
}
// Get class info and student count
err := s.db.QueryRow(ctx, `
SELECT c.name, COUNT(s.id)
FROM classes c
LEFT JOIN students s ON s.class_id = c.id
WHERE c.id = $1
GROUP BY c.name
`, classID).Scan(&stats.ClassName, &stats.StudentCount)
if err != nil {
return nil, err
}
// Build semester condition
semesterCond := ""
if semester > 0 {
semesterCond = " AND go.semester = " + string('0'+rune(semester))
}
// Get overall statistics from grade_overview
var avgGrade, bestGrade, worstGrade float64
var totalPassed, totalStudents int
err = s.db.QueryRow(ctx, `
SELECT
COALESCE(AVG(go.final_grade), 0),
COALESCE(MIN(go.final_grade), 0),
COALESCE(MAX(go.final_grade), 0),
COUNT(CASE WHEN go.final_grade <= 4.0 THEN 1 END),
COUNT(go.id)
FROM grade_overview go
JOIN students s ON s.id = go.student_id
WHERE s.class_id = $1 AND go.final_grade IS NOT NULL`+semesterCond+`
`, classID).Scan(&avgGrade, &bestGrade, &worstGrade, &totalPassed, &totalStudents)
if err == nil {
stats.ClassAverage = avgGrade
stats.BestGrade = bestGrade
stats.WorstGrade = worstGrade
if totalStudents > 0 {
stats.PassRate = float64(totalPassed) / float64(totalStudents) * 100
}
}
// Get grade distribution
rows, err := s.db.Query(ctx, `
SELECT FLOOR(go.final_grade) as grade_bucket, COUNT(*) as count
FROM grade_overview go
JOIN students s ON s.id = go.student_id
WHERE s.class_id = $1 AND go.final_grade IS NOT NULL`+semesterCond+`
GROUP BY FLOOR(go.final_grade)
`, classID)
if err == nil {
defer rows.Close()
for rows.Next() {
var bucket int
var count int
if err := rows.Scan(&bucket, &count); err == nil && bucket >= 1 && bucket <= 6 {
stats.GradeDistribution[string('0'+rune(bucket))] = count
}
}
}
// Count students at risk
s.db.QueryRow(ctx, `
SELECT COUNT(DISTINCT s.id)
FROM students s
JOIN grade_overview go ON go.student_id = s.id
WHERE s.class_id = $1 AND go.final_grade >= 4.5
`, classID).Scan(&stats.StudentsAtRisk)
// Get subject averages
subjectRows, err := s.db.Query(ctx, `
SELECT sub.name, AVG(go.final_grade)
FROM grade_overview go
JOIN subjects sub ON sub.id = go.subject_id
JOIN students s ON s.id = go.student_id
WHERE s.class_id = $1 AND go.final_grade IS NOT NULL`+semesterCond+`
GROUP BY sub.name
`, classID)
if err == nil {
defer subjectRows.Close()
for subjectRows.Next() {
var name string
var avg float64
if err := subjectRows.Scan(&name, &avg); err == nil {
stats.SubjectAverages[name] = avg
}
}
}
return stats, nil
}
// GetSubjectStatistics returns statistics for a specific subject in a class
func (s *GradeService) GetSubjectStatistics(ctx context.Context, classID, subjectID string, semester int) (*SubjectStatistics, error) {
stats := &SubjectStatistics{
ClassID: classID,
SubjectID: subjectID,
GradeDistribution: make(map[string]int),
ExamAverages: []ExamAverage{},
}
// Initialize grade distribution
for i := 1; i <= 6; i++ {
stats.GradeDistribution[string('0'+rune(i))] = 0
}
// Get subject name and basic stats
semesterCond := ""
if semester > 0 {
semesterCond = " AND go.semester = " + string('0'+rune(semester))
}
err := s.db.QueryRow(ctx, `
SELECT
sub.name,
COUNT(DISTINCT s.id),
COALESCE(AVG(go.final_grade), 0),
COALESCE(MIN(go.final_grade), 0),
COALESCE(MAX(go.final_grade), 0),
COUNT(CASE WHEN go.final_grade <= 4.0 THEN 1 END),
COUNT(go.id)
FROM grade_overview go
JOIN subjects sub ON sub.id = go.subject_id
JOIN students s ON s.id = go.student_id
WHERE s.class_id = $1 AND go.subject_id = $2 AND go.final_grade IS NOT NULL`+semesterCond+`
GROUP BY sub.name
`, classID, subjectID).Scan(
&stats.SubjectName, &stats.StudentCount, &stats.Average,
&stats.BestGrade, &stats.WorstGrade,
new(int), new(int), // We'll calculate pass rate separately
)
if err != nil {
return nil, err
}
// Get grade distribution
rows, err := s.db.Query(ctx, `
SELECT FLOOR(go.final_grade), COUNT(*)
FROM grade_overview go
JOIN students s ON s.id = go.student_id
WHERE s.class_id = $1 AND go.subject_id = $2 AND go.final_grade IS NOT NULL`+semesterCond+`
GROUP BY FLOOR(go.final_grade)
`, classID, subjectID)
if err == nil {
defer rows.Close()
var passed, total int
for rows.Next() {
var bucket, count int
if err := rows.Scan(&bucket, &count); err == nil && bucket >= 1 && bucket <= 6 {
stats.GradeDistribution[string('0'+rune(bucket))] = count
total += count
if bucket <= 4 {
passed += count
}
}
}
if total > 0 {
stats.PassRate = float64(passed) / float64(total) * 100
}
}
// Get exam averages
examRows, err := s.db.Query(ctx, `
SELECT e.id, e.title, AVG(er.grade), COALESCE(e.exam_date::text, '')
FROM exams e
JOIN exam_results er ON er.exam_id = e.id
JOIN students s ON er.student_id = s.id
WHERE s.class_id = $1 AND e.subject_id = $2 AND er.grade IS NOT NULL
GROUP BY e.id, e.title, e.exam_date
ORDER BY e.exam_date DESC NULLS LAST
`, classID, subjectID)
if err == nil {
defer examRows.Close()
for examRows.Next() {
var ea ExamAverage
if err := examRows.Scan(&ea.ExamID, &ea.Title, &ea.Average, &ea.ExamDate); err == nil {
stats.ExamAverages = append(stats.ExamAverages, ea)
}
}
}
return stats, nil
}
// GetStudentStatistics returns statistics for a specific student
func (s *GradeService) GetStudentStatistics(ctx context.Context, studentID string) (*StudentStatistics, error) {
stats := &StudentStatistics{
StudentID: studentID,
SubjectGrades: make(map[string]float64),
}
// Get student name
err := s.db.QueryRow(ctx, `
SELECT CONCAT(first_name, ' ', last_name) FROM students WHERE id = $1
`, studentID).Scan(&stats.StudentName)
if err != nil {
return nil, err
}
// Get subject grades and calculate overall average
var totalGrade, numSubjects float64
var bestGrade float64 = 6
var worstGrade float64 = 1
var bestSubject, worstSubject string
rows, err := s.db.Query(ctx, `
SELECT sub.name, go.final_grade
FROM grade_overview go
JOIN subjects sub ON sub.id = go.subject_id
WHERE go.student_id = $1 AND go.final_grade IS NOT NULL
ORDER BY sub.name
`, studentID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var name string
var grade float64
if err := rows.Scan(&name, &grade); err == nil {
stats.SubjectGrades[name] = grade
totalGrade += grade
numSubjects++
if grade < bestGrade {
bestGrade = grade
bestSubject = name
}
if grade > worstGrade {
worstGrade = grade
worstSubject = name
}
}
}
if numSubjects > 0 {
stats.OverallAverage = totalGrade / numSubjects
stats.StrongestSubject = bestSubject
stats.WeakestSubject = worstSubject
}
// Count exams completed
s.db.QueryRow(ctx, `
SELECT COUNT(*) FROM exam_results WHERE student_id = $1 AND grade IS NOT NULL
`, studentID).Scan(&stats.ExamsCompleted)
// Count absence days
s.db.QueryRow(ctx, `
SELECT COALESCE(SUM(periods), 0) FROM attendance
WHERE student_id = $1 AND status IN ('absent_excused', 'absent_unexcused')
`, studentID).Scan(&stats.AbsenceDays)
// Determine trend (simplified - compare first and last exam grades)
stats.Trend = "stable"
var firstGrade, lastGrade float64
s.db.QueryRow(ctx, `
SELECT grade FROM exam_results
WHERE student_id = $1 AND grade IS NOT NULL
ORDER BY created_at ASC LIMIT 1
`, studentID).Scan(&firstGrade)
s.db.QueryRow(ctx, `
SELECT grade FROM exam_results
WHERE student_id = $1 AND grade IS NOT NULL
ORDER BY created_at DESC LIMIT 1
`, studentID).Scan(&lastGrade)
if lastGrade < firstGrade-0.5 {
stats.Trend = "improving"
} else if lastGrade > firstGrade+0.5 {
stats.Trend = "declining"
}
return stats, nil
}
// GetNotenspiegel returns grade distribution (Notenspiegel)
func (s *GradeService) GetNotenspiegel(ctx context.Context, classID, subjectID, examID string, semester int) (*Notenspiegel, error) {
ns := &Notenspiegel{
ClassID: classID,
SubjectID: subjectID,
ExamID: examID,
Distribution: make(map[string]int),
}
// Initialize distribution
for i := 1; i <= 6; i++ {
ns.Distribution[string('0'+rune(i))] = 0
}
var query string
var args []interface{}
if examID != "" {
// Notenspiegel for specific exam
query = `
SELECT e.title, FLOOR(er.grade), COUNT(*), AVG(er.grade)
FROM exam_results er
JOIN exams e ON e.id = er.exam_id
WHERE er.exam_id = $1 AND er.grade IS NOT NULL
GROUP BY e.title, FLOOR(er.grade)
`
args = []interface{}{examID}
} else if subjectID != "" {
// Notenspiegel for subject in class
semesterCond := ""
if semester > 0 {
semesterCond = " AND go.semester = " + string('0'+rune(semester))
}
query = `
SELECT sub.name, FLOOR(go.final_grade), COUNT(*), AVG(go.final_grade)
FROM grade_overview go
JOIN subjects sub ON sub.id = go.subject_id
JOIN students s ON s.id = go.student_id
WHERE s.class_id = $1 AND go.subject_id = $2 AND go.final_grade IS NOT NULL` + semesterCond + `
GROUP BY sub.name, FLOOR(go.final_grade)
`
args = []interface{}{classID, subjectID}
} else {
// Notenspiegel for entire class
semesterCond := ""
if semester > 0 {
semesterCond = " AND go.semester = " + string('0'+rune(semester))
}
query = `
SELECT c.name, FLOOR(go.final_grade), COUNT(*), AVG(go.final_grade)
FROM grade_overview go
JOIN students s ON s.id = go.student_id
JOIN classes c ON c.id = s.class_id
WHERE s.class_id = $1 AND go.final_grade IS NOT NULL` + semesterCond + `
GROUP BY c.name, FLOOR(go.final_grade)
`
args = []interface{}{classID}
}
rows, err := s.db.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var passed int
for rows.Next() {
var title string
var bucket, count int
var avg float64
if err := rows.Scan(&title, &bucket, &count, &avg); err == nil {
ns.Title = title
if bucket >= 1 && bucket <= 6 {
ns.Distribution[string('0'+rune(bucket))] = count
ns.Total += count
if bucket <= 4 {
passed += count
}
}
ns.Average = avg
}
}
if ns.Total > 0 {
ns.PassRate = float64(passed) / float64(ns.Total) * 100
}
return ns, nil
}

View File

@@ -0,0 +1,487 @@
package services
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGradeService_CalculateFinalGrade(t *testing.T) {
tests := []struct {
name string
writtenAvg float64
oralGrade float64
writtenWeight int
oralWeight int
expectedFinal float64
}{
{
name: "standard weights 60/40 - same grades",
writtenAvg: 2.0,
oralGrade: 2.0,
writtenWeight: 60,
oralWeight: 40,
expectedFinal: 2.0,
},
{
name: "standard weights 60/40 - different grades",
writtenAvg: 2.0,
oralGrade: 3.0,
writtenWeight: 60,
oralWeight: 40,
expectedFinal: 2.4,
},
{
name: "equal weights 50/50",
writtenAvg: 2.0,
oralGrade: 4.0,
writtenWeight: 50,
oralWeight: 50,
expectedFinal: 3.0,
},
{
name: "hauptfach weights 70/30",
writtenAvg: 1.5,
oralGrade: 2.5,
writtenWeight: 70,
oralWeight: 30,
expectedFinal: 1.8,
},
{
name: "only written (100/0)",
writtenAvg: 2.5,
oralGrade: 0,
writtenWeight: 100,
oralWeight: 0,
expectedFinal: 2.5,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
finalGrade := calculateFinalGradeWeighted(tt.writtenAvg, tt.oralGrade, tt.writtenWeight, tt.oralWeight)
assert.InDelta(t, tt.expectedFinal, finalGrade, 0.01)
})
}
}
func TestGradeService_ValidateOralGrade(t *testing.T) {
tests := []struct {
name string
grade float64
wantErr bool
}{
{
name: "valid grade 1.0",
grade: 1.0,
wantErr: false,
},
{
name: "valid grade 2.5",
grade: 2.5,
wantErr: false,
},
{
name: "valid grade 6.0",
grade: 6.0,
wantErr: false,
},
{
name: "invalid grade - too low",
grade: 0.5,
wantErr: true,
},
{
name: "invalid grade - too high",
grade: 6.5,
wantErr: true,
},
{
name: "invalid grade - negative",
grade: -1.0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateOralGrade(tt.grade)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestGradeService_ValidateWeights(t *testing.T) {
tests := []struct {
name string
writtenWeight int
oralWeight int
wantErr bool
}{
{
name: "valid 60/40",
writtenWeight: 60,
oralWeight: 40,
wantErr: false,
},
{
name: "valid 50/50",
writtenWeight: 50,
oralWeight: 50,
wantErr: false,
},
{
name: "valid 100/0",
writtenWeight: 100,
oralWeight: 0,
wantErr: false,
},
{
name: "invalid - sum not 100",
writtenWeight: 60,
oralWeight: 50,
wantErr: true,
},
{
name: "invalid - negative weight",
writtenWeight: -10,
oralWeight: 110,
wantErr: true,
},
{
name: "invalid - both zero",
writtenWeight: 0,
oralWeight: 0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateWeights(tt.writtenWeight, tt.oralWeight)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestGradeService_CalculateWrittenAverage(t *testing.T) {
tests := []struct {
name string
grades []float64
expectedAvg float64
}{
{
name: "single grade",
grades: []float64{2.0},
expectedAvg: 2.0,
},
{
name: "two grades - same",
grades: []float64{2.0, 2.0},
expectedAvg: 2.0,
},
{
name: "two grades - different",
grades: []float64{1.0, 3.0},
expectedAvg: 2.0,
},
{
name: "multiple grades",
grades: []float64{1.0, 2.0, 3.0, 4.0},
expectedAvg: 2.5,
},
{
name: "empty grades",
grades: []float64{},
expectedAvg: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
avg := calculateWrittenAverage(tt.grades)
assert.InDelta(t, tt.expectedAvg, avg, 0.01)
})
}
}
func TestGradeService_RoundGrade(t *testing.T) {
tests := []struct {
name string
grade float64
expectedRound float64
}{
{
name: "exact 2.0",
grade: 2.0,
expectedRound: 2.0,
},
{
name: "2.33 rounds to 2.3",
grade: 2.33,
expectedRound: 2.3,
},
{
name: "2.35 rounds to 2.4",
grade: 2.35,
expectedRound: 2.4,
},
{
name: "2.44 rounds to 2.4",
grade: 2.44,
expectedRound: 2.4,
},
{
name: "2.45 rounds to 2.5",
grade: 2.45,
expectedRound: 2.5,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rounded := roundGrade(tt.grade)
assert.InDelta(t, tt.expectedRound, rounded, 0.01)
})
}
}
func TestGradeService_GradeToOberstufenPoints(t *testing.T) {
tests := []struct {
name string
grade float64
expectedPoints int
}{
{
name: "Grade 1.0 = 15 points",
grade: 1.0,
expectedPoints: 15,
},
{
name: "Grade 1.3 = 14 points",
grade: 1.3,
expectedPoints: 14,
},
{
name: "Grade 2.0 = 11 points",
grade: 2.0,
expectedPoints: 11,
},
{
name: "Grade 3.0 = 8 points",
grade: 3.0,
expectedPoints: 8,
},
{
name: "Grade 4.0 = 5 points",
grade: 4.0,
expectedPoints: 5,
},
{
name: "Grade 5.0 = 2 points",
grade: 5.0,
expectedPoints: 2,
},
{
name: "Grade 6.0 = 0 points",
grade: 6.0,
expectedPoints: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
points := gradeToOberstufenPoints(tt.grade)
assert.Equal(t, tt.expectedPoints, points)
})
}
}
func TestGradeService_OberstufenPointsToGrade(t *testing.T) {
tests := []struct {
name string
points int
expectedGrade float64
}{
{
name: "15 points = Grade 1.0",
points: 15,
expectedGrade: 1.0,
},
{
name: "12 points = Grade 1.7",
points: 12,
expectedGrade: 1.7,
},
{
name: "10 points = Grade 2.3",
points: 10,
expectedGrade: 2.3,
},
{
name: "5 points = Grade 4.0",
points: 5,
expectedGrade: 4.0,
},
{
name: "0 points = Grade 6.0",
points: 0,
expectedGrade: 6.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
grade := oberstufenPointsToGrade(tt.points)
assert.InDelta(t, tt.expectedGrade, grade, 0.1)
})
}
}
// Helper functions for tests
func calculateFinalGradeWeighted(writtenAvg, oralGrade float64, writtenWeight, oralWeight int) float64 {
if writtenWeight+oralWeight == 0 {
return 0
}
return (writtenAvg*float64(writtenWeight) + oralGrade*float64(oralWeight)) / float64(writtenWeight+oralWeight)
}
func validateOralGrade(grade float64) error {
if grade < 1.0 || grade > 6.0 {
return assert.AnError
}
return nil
}
func validateWeights(writtenWeight, oralWeight int) error {
if writtenWeight < 0 || oralWeight < 0 {
return assert.AnError
}
if writtenWeight+oralWeight != 100 {
return assert.AnError
}
return nil
}
func calculateWrittenAverage(grades []float64) float64 {
if len(grades) == 0 {
return 0
}
sum := 0.0
for _, g := range grades {
sum += g
}
return sum / float64(len(grades))
}
func roundGrade(grade float64) float64 {
return float64(int(grade*10+0.5)) / 10
}
func gradeToOberstufenPoints(grade float64) int {
// German grade to Oberstufen points conversion
// 1.0 = 15, 1.3 = 14, 1.7 = 13, 2.0 = 11, etc.
points := int(17 - (grade * 3))
if points > 15 {
points = 15
}
if points < 0 {
points = 0
}
return points
}
func oberstufenPointsToGrade(points int) float64 {
// Oberstufen points to grade conversion
if points >= 15 {
return 1.0
}
if points <= 0 {
return 6.0
}
return float64(17-points) / 3.0
}
func TestGradeService_GradeApprovalWorkflow(t *testing.T) {
tests := []struct {
name string
initialStatus string
action string
expectedStatus string
wantErr bool
}{
{
name: "pending to approved",
initialStatus: "pending",
action: "approve",
expectedStatus: "approved",
wantErr: false,
},
{
name: "pending to locked",
initialStatus: "pending",
action: "lock",
expectedStatus: "",
wantErr: true,
},
{
name: "approved to locked",
initialStatus: "approved",
action: "lock",
expectedStatus: "locked",
wantErr: false,
},
{
name: "locked cannot be changed",
initialStatus: "locked",
action: "approve",
expectedStatus: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
newStatus, err := processGradeAction(tt.initialStatus, tt.action)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedStatus, newStatus)
}
})
}
}
func processGradeAction(currentStatus, action string) (string, error) {
transitions := map[string]map[string]string{
"pending": {
"approve": "approved",
},
"approved": {
"lock": "locked",
"reject": "pending",
},
"locked": {},
}
actions, exists := transitions[currentStatus]
if !exists {
return "", assert.AnError
}
newStatus, valid := actions[action]
if !valid {
return "", assert.AnError
}
return newStatus, nil
}

View File

@@ -0,0 +1,261 @@
package services
import (
"context"
"time"
"github.com/breakpilot/school-service/internal/models"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// GradebookService handles gradebook-related operations
type GradebookService struct {
db *pgxpool.Pool
}
// NewGradebookService creates a new GradebookService
func NewGradebookService(db *pgxpool.Pool) *GradebookService {
return &GradebookService{db: db}
}
// Attendance Operations
// CreateAttendance creates or updates an attendance record
func (s *GradebookService) CreateAttendance(ctx context.Context, req *models.CreateAttendanceRequest) (*models.Attendance, error) {
date, _ := time.Parse("2006-01-02", req.Date)
periods := req.Periods
if periods == 0 {
periods = 1
}
var attendance models.Attendance
err := s.db.QueryRow(ctx, `
INSERT INTO attendance (student_id, date, status, periods, reason)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (student_id, date) DO UPDATE SET
status = EXCLUDED.status,
periods = EXCLUDED.periods,
reason = EXCLUDED.reason
RETURNING id, student_id, date, status, periods, reason, created_at
`, req.StudentID, date, req.Status, periods, req.Reason).Scan(
&attendance.ID, &attendance.StudentID, &attendance.Date, &attendance.Status, &attendance.Periods, &attendance.Reason, &attendance.CreatedAt,
)
return &attendance, err
}
// GetClassAttendance returns attendance records for a class
func (s *GradebookService) GetClassAttendance(ctx context.Context, classID string, startDate, endDate *time.Time) ([]models.Attendance, error) {
query := `
SELECT a.id, a.student_id, a.date, a.status, a.periods, a.reason, a.created_at,
CONCAT(st.first_name, ' ', st.last_name) as student_name
FROM attendance a
JOIN students st ON a.student_id = st.id
WHERE st.class_id = $1
`
args := []interface{}{classID}
if startDate != nil {
query += ` AND a.date >= $2`
args = append(args, startDate)
}
if endDate != nil {
query += ` AND a.date <= $` + string(rune('0'+len(args)+1))
args = append(args, endDate)
}
query += ` ORDER BY a.date DESC, st.last_name, st.first_name`
rows, err := s.db.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var records []models.Attendance
for rows.Next() {
var a models.Attendance
if err := rows.Scan(&a.ID, &a.StudentID, &a.Date, &a.Status, &a.Periods, &a.Reason, &a.CreatedAt, &a.StudentName); err != nil {
return nil, err
}
records = append(records, a)
}
return records, nil
}
// GetStudentAttendance returns attendance records for a student
func (s *GradebookService) GetStudentAttendance(ctx context.Context, studentID string) ([]models.Attendance, error) {
rows, err := s.db.Query(ctx, `
SELECT id, student_id, date, status, periods, reason, created_at
FROM attendance
WHERE student_id = $1
ORDER BY date DESC
`, studentID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []models.Attendance
for rows.Next() {
var a models.Attendance
if err := rows.Scan(&a.ID, &a.StudentID, &a.Date, &a.Status, &a.Periods, &a.Reason, &a.CreatedAt); err != nil {
return nil, err
}
records = append(records, a)
}
return records, nil
}
// GetAttendanceSummary returns absence counts for a student
func (s *GradebookService) GetAttendanceSummary(ctx context.Context, studentID string, schoolYearID string) (int, int, error) {
var excused, unexcused int
err := s.db.QueryRow(ctx, `
SELECT
COALESCE(SUM(CASE WHEN status = 'absent_excused' THEN periods ELSE 0 END), 0) as excused,
COALESCE(SUM(CASE WHEN status = 'absent_unexcused' THEN periods ELSE 0 END), 0) as unexcused
FROM attendance a
JOIN students st ON a.student_id = st.id
JOIN classes c ON st.class_id = c.id
WHERE a.student_id = $1 AND c.school_year_id = $2
`, studentID, schoolYearID).Scan(&excused, &unexcused)
return excused, unexcused, err
}
// DeleteAttendance deletes an attendance record
func (s *GradebookService) DeleteAttendance(ctx context.Context, attendanceID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM attendance WHERE id = $1`, attendanceID)
return err
}
// Gradebook Entry Operations
// CreateGradebookEntry creates a new gradebook entry
func (s *GradebookService) CreateGradebookEntry(ctx context.Context, req *models.CreateGradebookEntryRequest) (*models.GradebookEntry, error) {
date, _ := time.Parse("2006-01-02", req.Date)
var studentID *uuid.UUID
if req.StudentID != "" {
id, _ := uuid.Parse(req.StudentID)
studentID = &id
}
var entry models.GradebookEntry
err := s.db.QueryRow(ctx, `
INSERT INTO gradebook_entries (class_id, student_id, date, entry_type, content, is_visible_to_parents)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, class_id, student_id, date, entry_type, content, is_visible_to_parents, created_at
`, req.ClassID, studentID, date, req.EntryType, req.Content, req.IsVisibleToParents).Scan(
&entry.ID, &entry.ClassID, &entry.StudentID, &entry.Date, &entry.EntryType, &entry.Content, &entry.IsVisibleToParents, &entry.CreatedAt,
)
return &entry, err
}
// GetGradebookEntries returns entries for a class
func (s *GradebookService) GetGradebookEntries(ctx context.Context, classID string, entryType *string, startDate, endDate *time.Time) ([]models.GradebookEntry, error) {
query := `
SELECT ge.id, ge.class_id, ge.student_id, ge.date, ge.entry_type, ge.content, ge.is_visible_to_parents, ge.created_at,
COALESCE(CONCAT(st.first_name, ' ', st.last_name), '') as student_name
FROM gradebook_entries ge
LEFT JOIN students st ON ge.student_id = st.id
WHERE ge.class_id = $1
`
args := []interface{}{classID}
argCount := 1
if entryType != nil {
argCount++
query += ` AND ge.entry_type = $` + string(rune('0'+argCount))
args = append(args, *entryType)
}
if startDate != nil {
argCount++
query += ` AND ge.date >= $` + string(rune('0'+argCount))
args = append(args, startDate)
}
if endDate != nil {
argCount++
query += ` AND ge.date <= $` + string(rune('0'+argCount))
args = append(args, endDate)
}
query += ` ORDER BY ge.date DESC, ge.created_at DESC`
rows, err := s.db.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []models.GradebookEntry
for rows.Next() {
var e models.GradebookEntry
if err := rows.Scan(&e.ID, &e.ClassID, &e.StudentID, &e.Date, &e.EntryType, &e.Content, &e.IsVisibleToParents, &e.CreatedAt, &e.StudentName); err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, nil
}
// GetStudentEntries returns gradebook entries for a specific student
func (s *GradebookService) GetStudentEntries(ctx context.Context, studentID string) ([]models.GradebookEntry, error) {
rows, err := s.db.Query(ctx, `
SELECT ge.id, ge.class_id, ge.student_id, ge.date, ge.entry_type, ge.content, ge.is_visible_to_parents, ge.created_at,
CONCAT(st.first_name, ' ', st.last_name) as student_name
FROM gradebook_entries ge
JOIN students st ON ge.student_id = st.id
WHERE ge.student_id = $1
ORDER BY ge.date DESC
`, studentID)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []models.GradebookEntry
for rows.Next() {
var e models.GradebookEntry
if err := rows.Scan(&e.ID, &e.ClassID, &e.StudentID, &e.Date, &e.EntryType, &e.Content, &e.IsVisibleToParents, &e.CreatedAt, &e.StudentName); err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, nil
}
// DeleteGradebookEntry deletes a gradebook entry
func (s *GradebookService) DeleteGradebookEntry(ctx context.Context, entryID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM gradebook_entries WHERE id = $1`, entryID)
return err
}
// BulkCreateAttendance creates attendance records for multiple students at once
func (s *GradebookService) BulkCreateAttendance(ctx context.Context, classID string, date string, records []struct {
StudentID string
Status models.AttendanceStatus
Periods int
Reason string
}) error {
parsedDate, _ := time.Parse("2006-01-02", date)
for _, r := range records {
periods := r.Periods
if periods == 0 {
periods = 1
}
_, err := s.db.Exec(ctx, `
INSERT INTO attendance (student_id, date, status, periods, reason)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (student_id, date) DO UPDATE SET
status = EXCLUDED.status,
periods = EXCLUDED.periods,
reason = EXCLUDED.reason
`, r.StudentID, parsedDate, r.Status, periods, r.Reason)
if err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,465 @@
package services
import (
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func TestGradebookService_ValidateAttendanceStatus(t *testing.T) {
tests := []struct {
name string
status string
wantErr bool
}{
{
name: "valid - present",
status: "present",
wantErr: false,
},
{
name: "valid - absent_excused",
status: "absent_excused",
wantErr: false,
},
{
name: "valid - absent_unexcused",
status: "absent_unexcused",
wantErr: false,
},
{
name: "valid - late",
status: "late",
wantErr: false,
},
{
name: "invalid status",
status: "invalid",
wantErr: true,
},
{
name: "empty status",
status: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateAttendanceStatus(tt.status)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestGradebookService_ValidateEntryType(t *testing.T) {
tests := []struct {
name string
entryType string
wantErr bool
}{
{
name: "valid - note",
entryType: "note",
wantErr: false,
},
{
name: "valid - warning",
entryType: "warning",
wantErr: false,
},
{
name: "valid - praise",
entryType: "praise",
wantErr: false,
},
{
name: "valid - incident",
entryType: "incident",
wantErr: false,
},
{
name: "valid - homework",
entryType: "homework",
wantErr: false,
},
{
name: "invalid entry type",
entryType: "invalid",
wantErr: true,
},
{
name: "empty entry type",
entryType: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateEntryType(tt.entryType)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestGradebookService_ValidateAttendanceInput(t *testing.T) {
tests := []struct {
name string
studentID uuid.UUID
date time.Time
status string
periods int
wantErr bool
}{
{
name: "valid attendance",
studentID: uuid.New(),
date: time.Now(),
status: "absent_excused",
periods: 2,
wantErr: false,
},
{
name: "zero periods",
studentID: uuid.New(),
date: time.Now(),
status: "absent_excused",
periods: 0,
wantErr: true,
},
{
name: "negative periods",
studentID: uuid.New(),
date: time.Now(),
status: "absent_excused",
periods: -1,
wantErr: true,
},
{
name: "too many periods",
studentID: uuid.New(),
date: time.Now(),
status: "absent_excused",
periods: 15,
wantErr: true,
},
{
name: "nil student ID",
studentID: uuid.Nil,
date: time.Now(),
status: "absent_excused",
periods: 2,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateAttendanceInput(tt.studentID, tt.date, tt.status, tt.periods)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestGradebookService_ValidateGradebookEntry(t *testing.T) {
tests := []struct {
name string
classID uuid.UUID
entryType string
content string
wantErr bool
}{
{
name: "valid entry",
classID: uuid.New(),
entryType: "note",
content: "Today we discussed the French Revolution",
wantErr: false,
},
{
name: "empty content",
classID: uuid.New(),
entryType: "note",
content: "",
wantErr: true,
},
{
name: "invalid entry type",
classID: uuid.New(),
entryType: "invalid",
content: "Some content",
wantErr: true,
},
{
name: "nil class ID",
classID: uuid.Nil,
entryType: "note",
content: "Some content",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateGradebookEntry(tt.classID, tt.entryType, tt.content)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestGradebookService_CalculateAbsenceDays(t *testing.T) {
tests := []struct {
name string
attendances []attendanceRecord
expectedTotal int
expectedExcused int
}{
{
name: "no absences",
attendances: []attendanceRecord{
{Status: "present", Periods: 6},
{Status: "present", Periods: 6},
},
expectedTotal: 0,
expectedExcused: 0,
},
{
name: "excused absences only",
attendances: []attendanceRecord{
{Status: "absent_excused", Periods: 6},
{Status: "absent_excused", Periods: 6},
},
expectedTotal: 2,
expectedExcused: 2,
},
{
name: "unexcused absences only",
attendances: []attendanceRecord{
{Status: "absent_unexcused", Periods: 6},
},
expectedTotal: 1,
expectedExcused: 0,
},
{
name: "mixed absences",
attendances: []attendanceRecord{
{Status: "absent_excused", Periods: 6},
{Status: "absent_unexcused", Periods: 6},
{Status: "present", Periods: 6},
},
expectedTotal: 2,
expectedExcused: 1,
},
{
name: "late arrivals not counted",
attendances: []attendanceRecord{
{Status: "late", Periods: 1},
{Status: "late", Periods: 1},
},
expectedTotal: 0,
expectedExcused: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
total, excused := calculateAbsenceDays(tt.attendances)
assert.Equal(t, tt.expectedTotal, total)
assert.Equal(t, tt.expectedExcused, excused)
})
}
}
func TestGradebookService_DateRangeValidation(t *testing.T) {
tests := []struct {
name string
startDate time.Time
endDate time.Time
wantErr bool
}{
{
name: "valid range - same day",
startDate: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
endDate: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
wantErr: false,
},
{
name: "valid range - week",
startDate: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
endDate: time.Date(2024, 1, 22, 0, 0, 0, 0, time.UTC),
wantErr: false,
},
{
name: "invalid range - end before start",
startDate: time.Date(2024, 1, 22, 0, 0, 0, 0, time.UTC),
endDate: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateDateRange(tt.startDate, tt.endDate)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
// Helper types and functions
type attendanceRecord struct {
Status string
Periods int
}
func validateAttendanceStatus(status string) error {
validStatuses := map[string]bool{
"present": true,
"absent_excused": true,
"absent_unexcused": true,
"late": true,
}
if !validStatuses[status] {
return assert.AnError
}
return nil
}
func validateEntryType(entryType string) error {
validTypes := map[string]bool{
"note": true,
"warning": true,
"praise": true,
"incident": true,
"homework": true,
}
if !validTypes[entryType] {
return assert.AnError
}
return nil
}
func validateAttendanceInput(studentID uuid.UUID, date time.Time, status string, periods int) error {
if studentID == uuid.Nil {
return assert.AnError
}
if periods <= 0 || periods > 12 {
return assert.AnError
}
return validateAttendanceStatus(status)
}
func validateGradebookEntry(classID uuid.UUID, entryType, content string) error {
if classID == uuid.Nil {
return assert.AnError
}
if content == "" {
return assert.AnError
}
return validateEntryType(entryType)
}
func calculateAbsenceDays(attendances []attendanceRecord) (total, excused int) {
for _, a := range attendances {
if a.Status == "absent_excused" {
total++
excused++
} else if a.Status == "absent_unexcused" {
total++
}
}
return total, excused
}
func validateDateRange(start, end time.Time) error {
if end.Before(start) {
return assert.AnError
}
return nil
}
func TestGradebookService_BulkAttendanceValidation(t *testing.T) {
tests := []struct {
name string
studentIDs []uuid.UUID
date time.Time
status string
wantErr bool
errCount int
}{
{
name: "all valid",
studentIDs: []uuid.UUID{uuid.New(), uuid.New(), uuid.New()},
date: time.Now(),
status: "present",
wantErr: false,
errCount: 0,
},
{
name: "empty list",
studentIDs: []uuid.UUID{},
date: time.Now(),
status: "present",
wantErr: true,
errCount: 1,
},
{
name: "contains nil UUID",
studentIDs: []uuid.UUID{uuid.New(), uuid.Nil, uuid.New()},
date: time.Now(),
status: "present",
wantErr: true,
errCount: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validateBulkAttendance(tt.studentIDs, tt.date, tt.status)
if tt.wantErr {
assert.Len(t, errs, tt.errCount)
} else {
assert.Empty(t, errs)
}
})
}
}
func validateBulkAttendance(studentIDs []uuid.UUID, date time.Time, status string) []error {
var errs []error
if len(studentIDs) == 0 {
errs = append(errs, assert.AnError)
return errs
}
for _, id := range studentIDs {
if id == uuid.Nil {
errs = append(errs, assert.AnError)
}
}
if err := validateAttendanceStatus(status); err != nil {
errs = append(errs, err)
}
return errs
}

View File