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

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
}