A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
249 lines
11 KiB
Go
249 lines
11 KiB
Go
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
|
|
}
|