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:
218
school-service/internal/services/ai_service.go
Normal file
218
school-service/internal/services/ai_service.go
Normal 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
|
||||
}
|
||||
540
school-service/internal/services/ai_service_test.go
Normal file
540
school-service/internal/services/ai_service_test.go
Normal 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
|
||||
}
|
||||
251
school-service/internal/services/certificate_service.go
Normal file
251
school-service/internal/services/certificate_service.go
Normal 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
|
||||
}
|
||||
563
school-service/internal/services/certificate_service_test.go
Normal file
563
school-service/internal/services/certificate_service_test.go
Normal 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
|
||||
}
|
||||
236
school-service/internal/services/class_service.go
Normal file
236
school-service/internal/services/class_service.go
Normal 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
|
||||
}
|
||||
439
school-service/internal/services/class_service_test.go
Normal file
439
school-service/internal/services/class_service_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
248
school-service/internal/services/exam_service.go
Normal file
248
school-service/internal/services/exam_service.go
Normal 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
|
||||
}
|
||||
451
school-service/internal/services/exam_service_test.go
Normal file
451
school-service/internal/services/exam_service_test.go
Normal 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
|
||||
}
|
||||
646
school-service/internal/services/grade_service.go
Normal file
646
school-service/internal/services/grade_service.go
Normal 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
|
||||
}
|
||||
487
school-service/internal/services/grade_service_test.go
Normal file
487
school-service/internal/services/grade_service_test.go
Normal 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
|
||||
}
|
||||
261
school-service/internal/services/gradebook_service.go
Normal file
261
school-service/internal/services/gradebook_service.go
Normal 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
|
||||
}
|
||||
465
school-service/internal/services/gradebook_service_test.go
Normal file
465
school-service/internal/services/gradebook_service_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user