Fix: Remove broken getKlausurApiUrl and clean up empty lines
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 42s
CI / test-go-edu-search (push) Successful in 34s
CI / test-python-klausur (push) Failing after 2m51s
CI / test-python-agent-core (push) Successful in 21s
CI / test-nodejs-website (push) Successful in 29s

sed replacement left orphaned hostname references in story page
and empty lines in getApiBase functions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-24 16:02:04 +02:00
parent b07f802c24
commit 9ba420fa91
150 changed files with 30231 additions and 32053 deletions

View File

@@ -232,360 +232,3 @@ func (s *Seeder) seedClassesForYear(ctx context.Context, sy SchoolYearResult) ([
return results, nil
}
// StudentResult holds created student info
type StudentResult struct {
ID uuid.UUID
FirstName string
LastName string
}
func (s *Seeder) seedStudentsForClass(ctx context.Context, classID uuid.UUID) ([]StudentResult, error) {
// 15-25 students per class
numStudents := 15 + s.rng.Intn(11) // 15-25
var students []StudentResult
usedNames := make(map[string]bool)
for i := 0; i < numStudents; i++ {
// Generate unique name combination
var firstName, lastName string
for {
firstName = firstNames[s.rng.Intn(len(firstNames))]
lastName = lastNames[s.rng.Intn(len(lastNames))]
key := firstName + lastName
if !usedNames[key] {
usedNames[key] = true
break
}
}
id := uuid.New()
// Generate birth date (10-13 years old)
birthYear := time.Now().Year() - 10 - s.rng.Intn(4)
birthMonth := time.Month(1 + s.rng.Intn(12))
birthDay := 1 + s.rng.Intn(28)
birthDate := time.Date(birthYear, birthMonth, birthDay, 0, 0, 0, 0, time.UTC)
studentNumber := fmt.Sprintf("S%d%04d", time.Now().Year()%100, i+1)
_, err := s.pool.Exec(ctx, `
INSERT INTO students (id, class_id, first_name, last_name, birth_date, student_number)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT DO NOTHING
`, id, classID, firstName, lastName, birthDate, studentNumber)
if err != nil {
return nil, err
}
students = append(students, StudentResult{
ID: id,
FirstName: firstName,
LastName: lastName,
})
}
return students, nil
}
func (s *Seeder) seedExamsAndResults(
ctx context.Context,
classID uuid.UUID,
subjectID uuid.UUID,
students []StudentResult,
schoolYearID uuid.UUID,
subj SubjectConfig,
) error {
if subj.ExamsPerYear == 0 {
return nil // No exams for this subject (e.g., Sport)
}
examDates := []time.Time{
time.Date(2024, 10, 15, 0, 0, 0, 0, time.UTC),
time.Date(2024, 12, 10, 0, 0, 0, 0, time.UTC),
time.Date(2025, 2, 20, 0, 0, 0, 0, time.UTC),
time.Date(2025, 5, 15, 0, 0, 0, 0, time.UTC),
}
for i := 0; i < subj.ExamsPerYear; i++ {
examID := uuid.New()
examDate := examDates[i%len(examDates)]
maxPoints := 50.0 + float64(s.rng.Intn(51)) // 50-100 points
title := fmt.Sprintf("%s Klassenarbeit %d", subj.Name, i+1)
_, err := s.pool.Exec(ctx, `
INSERT INTO exams (id, teacher_id, class_id, subject_id, title, exam_type, max_points, status, exam_date, difficulty_level, duration_minutes)
VALUES ($1, $2, $3, $4, $5, 'klassenarbeit', $6, 'archived', $7, $8, $9)
ON CONFLICT DO NOTHING
`, examID, s.teacherID, classID, subjectID, title, maxPoints, examDate, 3, 45)
if err != nil {
return err
}
// Create results for each student
for _, student := range students {
// Generate realistic grade distribution (mostly 2-4)
grade := s.generateRealisticGrade()
percentage := s.gradeToPercentage(grade)
points := (percentage / 100.0) * maxPoints
isAbsent := s.rng.Float64() < 0.03 // 3% absence rate
if isAbsent {
_, err = s.pool.Exec(ctx, `
INSERT INTO exam_results (exam_id, student_id, is_absent, approved_by_teacher)
VALUES ($1, $2, true, true)
ON CONFLICT (exam_id, student_id) DO NOTHING
`, examID, student.ID)
} else {
_, err = s.pool.Exec(ctx, `
INSERT INTO exam_results (exam_id, student_id, points_achieved, grade, percentage, approved_by_teacher)
VALUES ($1, $2, $3, $4, $5, true)
ON CONFLICT (exam_id, student_id) DO NOTHING
`, examID, student.ID, points, grade, percentage)
}
if err != nil {
return err
}
}
}
// Create grade overview entries for each student
for _, student := range students {
err := s.createGradeOverview(ctx, student.ID, subjectID, schoolYearID, subj)
if err != nil {
return err
}
}
return nil
}
func (s *Seeder) generateRealisticGrade() float64 {
// German grading: 1 (best) to 6 (worst)
// Normal distribution centered around 3.0
grades := []float64{1.0, 1.3, 1.7, 2.0, 2.3, 2.7, 3.0, 3.3, 3.7, 4.0, 4.3, 4.7, 5.0, 5.3, 5.7, 6.0}
weights := []int{2, 4, 6, 10, 12, 14, 16, 14, 12, 8, 5, 3, 2, 1, 1, 1} // Weighted distribution
total := 0
for _, w := range weights {
total += w
}
r := s.rng.Intn(total)
cumulative := 0
for i, w := range weights {
cumulative += w
if r < cumulative {
return grades[i]
}
}
return 3.0
}
func (s *Seeder) gradeToPercentage(grade float64) float64 {
// Convert German grade to percentage
// 1.0 = 100%, 6.0 = 0%
return 100.0 - ((grade - 1.0) * 20.0)
}
func (s *Seeder) createGradeOverview(
ctx context.Context,
studentID uuid.UUID,
subjectID uuid.UUID,
schoolYearID uuid.UUID,
subj SubjectConfig,
) error {
// Create for both semesters
for semester := 1; semester <= 2; semester++ {
writtenAvg := s.generateRealisticGrade()
oralGrade := s.generateRealisticGrade()
// Calculate final grade based on weights
finalGrade := (writtenAvg*float64(subj.WrittenWeight) + oralGrade*float64(100-subj.WrittenWeight)) / 100.0
// Round to nearest 0.5
finalGrade = float64(int(finalGrade*2+0.5)) / 2.0
_, err := s.pool.Exec(ctx, `
INSERT INTO grade_overview (student_id, subject_id, school_year_id, semester, written_grade_avg, written_grade_count, oral_grade, final_grade, written_weight, oral_weight)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (student_id, subject_id, school_year_id, semester) DO UPDATE SET
written_grade_avg = EXCLUDED.written_grade_avg,
oral_grade = EXCLUDED.oral_grade,
final_grade = EXCLUDED.final_grade
`, studentID, subjectID, schoolYearID, semester, writtenAvg, subj.ExamsPerYear/2+1, oralGrade, finalGrade, subj.WrittenWeight, 100-subj.WrittenWeight)
if err != nil {
return err
}
}
return nil
}
func (s *Seeder) seedAttendance(ctx context.Context, students []StudentResult, sy SchoolYearResult) error {
for _, student := range students {
// 0-10 absence days per student
absenceDays := s.rng.Intn(11)
for i := 0; i < absenceDays; i++ {
// Random date within school year
dayOffset := s.rng.Intn(int(sy.EndDate.Sub(sy.StartDate).Hours() / 24))
absenceDate := sy.StartDate.AddDate(0, 0, dayOffset)
// Skip weekends
if absenceDate.Weekday() == time.Saturday || absenceDate.Weekday() == time.Sunday {
continue
}
// 80% excused, 20% unexcused
status := "absent_excused"
if s.rng.Float64() < 0.2 {
status = "absent_unexcused"
}
reasons := []string{
"Krankheit",
"Arzttermin",
"Familiaere Gruende",
"",
}
reason := reasons[s.rng.Intn(len(reasons))]
_, err := s.pool.Exec(ctx, `
INSERT INTO attendance (student_id, date, status, periods, reason)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (student_id, date) DO NOTHING
`, student.ID, absenceDate, status, s.rng.Intn(6)+1, reason)
if err != nil {
return err
}
}
}
return nil
}
// Statistics holds calculated statistics for a class
type Statistics struct {
ClassID uuid.UUID `json:"class_id"`
ClassName string `json:"class_name"`
StudentCount int `json:"student_count"`
ClassAverage float64 `json:"class_average"`
GradeDistribution map[string]int `json:"grade_distribution"`
BestGrade float64 `json:"best_grade"`
WorstGrade float64 `json:"worst_grade"`
PassRate float64 `json:"pass_rate"`
StudentsAtRisk int `json:"students_at_risk"`
SubjectAverages map[string]float64 `json:"subject_averages"`
}
// CalculateClassStatistics calculates statistics for a class
func (s *Seeder) CalculateClassStatistics(ctx context.Context, classID uuid.UUID) (*Statistics, error) {
stats := &Statistics{
ClassID: classID,
GradeDistribution: make(map[string]int),
SubjectAverages: make(map[string]float64),
}
// Initialize grade distribution
for i := 1; i <= 6; i++ {
stats.GradeDistribution[fmt.Sprintf("%d", i)] = 0
}
// Get class info and student count
err := s.pool.QueryRow(ctx, `
SELECT c.name, COUNT(s.id)
FROM classes c
LEFT JOIN students s ON s.class_id = c.id
WHERE c.id = $1
GROUP BY c.name
`, classID).Scan(&stats.ClassName, &stats.StudentCount)
if err != nil {
return nil, err
}
// Get grade statistics from exam results
rows, err := s.pool.Query(ctx, `
SELECT
COALESCE(AVG(er.grade), 0) as avg_grade,
COALESCE(MIN(er.grade), 0) as best_grade,
COALESCE(MAX(er.grade), 0) as worst_grade,
COUNT(CASE WHEN er.grade <= 4.0 THEN 1 END) as passed,
COUNT(er.id) as total,
FLOOR(er.grade) as grade_bucket,
COUNT(*) as bucket_count
FROM exam_results er
JOIN exams e ON e.id = er.exam_id
JOIN students s ON s.id = er.student_id
WHERE s.class_id = $1 AND er.grade IS NOT NULL
GROUP BY FLOOR(er.grade)
`, classID)
if err != nil {
return nil, err
}
defer rows.Close()
var totalPassed, totalExams int
for rows.Next() {
var avgGrade, bestGrade, worstGrade float64
var passed, total int
var gradeBucket float64
var bucketCount int
err := rows.Scan(&avgGrade, &bestGrade, &worstGrade, &passed, &total, &gradeBucket, &bucketCount)
if err != nil {
continue
}
stats.ClassAverage = avgGrade
stats.BestGrade = bestGrade
stats.WorstGrade = worstGrade
totalPassed += passed
totalExams += total
gradeKey := fmt.Sprintf("%d", int(gradeBucket))
stats.GradeDistribution[gradeKey] = bucketCount
}
if totalExams > 0 {
stats.PassRate = float64(totalPassed) / float64(totalExams) * 100
}
// Count students at risk (average >= 4.5)
err = s.pool.QueryRow(ctx, `
SELECT COUNT(DISTINCT s.id)
FROM students s
JOIN grade_overview go ON go.student_id = s.id
WHERE s.class_id = $1 AND go.final_grade >= 4.5
`, classID).Scan(&stats.StudentsAtRisk)
if err != nil {
stats.StudentsAtRisk = 0
}
// Get subject averages
subjectRows, err := s.pool.Query(ctx, `
SELECT sub.name, AVG(go.final_grade)
FROM grade_overview go
JOIN subjects sub ON sub.id = go.subject_id
JOIN students s ON s.id = go.student_id
WHERE s.class_id = $1
GROUP BY sub.name
`, classID)
if err == nil {
defer subjectRows.Close()
for subjectRows.Next() {
var name string
var avg float64
if err := subjectRows.Scan(&name, &avg); err == nil {
stats.SubjectAverages[name] = avg
}
}
}
return stats, nil
}

View File

@@ -0,0 +1,366 @@
package seed
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
)
// StudentResult holds created student info
type StudentResult struct {
ID uuid.UUID
FirstName string
LastName string
}
func (s *Seeder) seedStudentsForClass(ctx context.Context, classID uuid.UUID) ([]StudentResult, error) {
// 15-25 students per class
numStudents := 15 + s.rng.Intn(11) // 15-25
var students []StudentResult
usedNames := make(map[string]bool)
for i := 0; i < numStudents; i++ {
// Generate unique name combination
var firstName, lastName string
for {
firstName = firstNames[s.rng.Intn(len(firstNames))]
lastName = lastNames[s.rng.Intn(len(lastNames))]
key := firstName + lastName
if !usedNames[key] {
usedNames[key] = true
break
}
}
id := uuid.New()
// Generate birth date (10-13 years old)
birthYear := time.Now().Year() - 10 - s.rng.Intn(4)
birthMonth := time.Month(1 + s.rng.Intn(12))
birthDay := 1 + s.rng.Intn(28)
birthDate := time.Date(birthYear, birthMonth, birthDay, 0, 0, 0, 0, time.UTC)
studentNumber := fmt.Sprintf("S%d%04d", time.Now().Year()%100, i+1)
_, err := s.pool.Exec(ctx, `
INSERT INTO students (id, class_id, first_name, last_name, birth_date, student_number)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT DO NOTHING
`, id, classID, firstName, lastName, birthDate, studentNumber)
if err != nil {
return nil, err
}
students = append(students, StudentResult{
ID: id,
FirstName: firstName,
LastName: lastName,
})
}
return students, nil
}
func (s *Seeder) seedExamsAndResults(
ctx context.Context,
classID uuid.UUID,
subjectID uuid.UUID,
students []StudentResult,
schoolYearID uuid.UUID,
subj SubjectConfig,
) error {
if subj.ExamsPerYear == 0 {
return nil // No exams for this subject (e.g., Sport)
}
examDates := []time.Time{
time.Date(2024, 10, 15, 0, 0, 0, 0, time.UTC),
time.Date(2024, 12, 10, 0, 0, 0, 0, time.UTC),
time.Date(2025, 2, 20, 0, 0, 0, 0, time.UTC),
time.Date(2025, 5, 15, 0, 0, 0, 0, time.UTC),
}
for i := 0; i < subj.ExamsPerYear; i++ {
examID := uuid.New()
examDate := examDates[i%len(examDates)]
maxPoints := 50.0 + float64(s.rng.Intn(51)) // 50-100 points
title := fmt.Sprintf("%s Klassenarbeit %d", subj.Name, i+1)
_, err := s.pool.Exec(ctx, `
INSERT INTO exams (id, teacher_id, class_id, subject_id, title, exam_type, max_points, status, exam_date, difficulty_level, duration_minutes)
VALUES ($1, $2, $3, $4, $5, 'klassenarbeit', $6, 'archived', $7, $8, $9)
ON CONFLICT DO NOTHING
`, examID, s.teacherID, classID, subjectID, title, maxPoints, examDate, 3, 45)
if err != nil {
return err
}
// Create results for each student
for _, student := range students {
// Generate realistic grade distribution (mostly 2-4)
grade := s.generateRealisticGrade()
percentage := s.gradeToPercentage(grade)
points := (percentage / 100.0) * maxPoints
isAbsent := s.rng.Float64() < 0.03 // 3% absence rate
if isAbsent {
_, err = s.pool.Exec(ctx, `
INSERT INTO exam_results (exam_id, student_id, is_absent, approved_by_teacher)
VALUES ($1, $2, true, true)
ON CONFLICT (exam_id, student_id) DO NOTHING
`, examID, student.ID)
} else {
_, err = s.pool.Exec(ctx, `
INSERT INTO exam_results (exam_id, student_id, points_achieved, grade, percentage, approved_by_teacher)
VALUES ($1, $2, $3, $4, $5, true)
ON CONFLICT (exam_id, student_id) DO NOTHING
`, examID, student.ID, points, grade, percentage)
}
if err != nil {
return err
}
}
}
// Create grade overview entries for each student
for _, student := range students {
err := s.createGradeOverview(ctx, student.ID, subjectID, schoolYearID, subj)
if err != nil {
return err
}
}
return nil
}
func (s *Seeder) generateRealisticGrade() float64 {
// German grading: 1 (best) to 6 (worst)
// Normal distribution centered around 3.0
grades := []float64{1.0, 1.3, 1.7, 2.0, 2.3, 2.7, 3.0, 3.3, 3.7, 4.0, 4.3, 4.7, 5.0, 5.3, 5.7, 6.0}
weights := []int{2, 4, 6, 10, 12, 14, 16, 14, 12, 8, 5, 3, 2, 1, 1, 1} // Weighted distribution
total := 0
for _, w := range weights {
total += w
}
r := s.rng.Intn(total)
cumulative := 0
for i, w := range weights {
cumulative += w
if r < cumulative {
return grades[i]
}
}
return 3.0
}
func (s *Seeder) gradeToPercentage(grade float64) float64 {
// Convert German grade to percentage
// 1.0 = 100%, 6.0 = 0%
return 100.0 - ((grade - 1.0) * 20.0)
}
func (s *Seeder) createGradeOverview(
ctx context.Context,
studentID uuid.UUID,
subjectID uuid.UUID,
schoolYearID uuid.UUID,
subj SubjectConfig,
) error {
// Create for both semesters
for semester := 1; semester <= 2; semester++ {
writtenAvg := s.generateRealisticGrade()
oralGrade := s.generateRealisticGrade()
// Calculate final grade based on weights
finalGrade := (writtenAvg*float64(subj.WrittenWeight) + oralGrade*float64(100-subj.WrittenWeight)) / 100.0
// Round to nearest 0.5
finalGrade = float64(int(finalGrade*2+0.5)) / 2.0
_, err := s.pool.Exec(ctx, `
INSERT INTO grade_overview (student_id, subject_id, school_year_id, semester, written_grade_avg, written_grade_count, oral_grade, final_grade, written_weight, oral_weight)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (student_id, subject_id, school_year_id, semester) DO UPDATE SET
written_grade_avg = EXCLUDED.written_grade_avg,
oral_grade = EXCLUDED.oral_grade,
final_grade = EXCLUDED.final_grade
`, studentID, subjectID, schoolYearID, semester, writtenAvg, subj.ExamsPerYear/2+1, oralGrade, finalGrade, subj.WrittenWeight, 100-subj.WrittenWeight)
if err != nil {
return err
}
}
return nil
}
func (s *Seeder) seedAttendance(ctx context.Context, students []StudentResult, sy SchoolYearResult) error {
for _, student := range students {
// 0-10 absence days per student
absenceDays := s.rng.Intn(11)
for i := 0; i < absenceDays; i++ {
// Random date within school year
dayOffset := s.rng.Intn(int(sy.EndDate.Sub(sy.StartDate).Hours() / 24))
absenceDate := sy.StartDate.AddDate(0, 0, dayOffset)
// Skip weekends
if absenceDate.Weekday() == time.Saturday || absenceDate.Weekday() == time.Sunday {
continue
}
// 80% excused, 20% unexcused
status := "absent_excused"
if s.rng.Float64() < 0.2 {
status = "absent_unexcused"
}
reasons := []string{
"Krankheit",
"Arzttermin",
"Familiaere Gruende",
"",
}
reason := reasons[s.rng.Intn(len(reasons))]
_, err := s.pool.Exec(ctx, `
INSERT INTO attendance (student_id, date, status, periods, reason)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (student_id, date) DO NOTHING
`, student.ID, absenceDate, status, s.rng.Intn(6)+1, reason)
if err != nil {
return err
}
}
}
return nil
}
// Statistics holds calculated statistics for a class
type Statistics struct {
ClassID uuid.UUID `json:"class_id"`
ClassName string `json:"class_name"`
StudentCount int `json:"student_count"`
ClassAverage float64 `json:"class_average"`
GradeDistribution map[string]int `json:"grade_distribution"`
BestGrade float64 `json:"best_grade"`
WorstGrade float64 `json:"worst_grade"`
PassRate float64 `json:"pass_rate"`
StudentsAtRisk int `json:"students_at_risk"`
SubjectAverages map[string]float64 `json:"subject_averages"`
}
// CalculateClassStatistics calculates statistics for a class
func (s *Seeder) CalculateClassStatistics(ctx context.Context, classID uuid.UUID) (*Statistics, error) {
stats := &Statistics{
ClassID: classID,
GradeDistribution: make(map[string]int),
SubjectAverages: make(map[string]float64),
}
// Initialize grade distribution
for i := 1; i <= 6; i++ {
stats.GradeDistribution[fmt.Sprintf("%d", i)] = 0
}
// Get class info and student count
err := s.pool.QueryRow(ctx, `
SELECT c.name, COUNT(s.id)
FROM classes c
LEFT JOIN students s ON s.class_id = c.id
WHERE c.id = $1
GROUP BY c.name
`, classID).Scan(&stats.ClassName, &stats.StudentCount)
if err != nil {
return nil, err
}
// Get grade statistics from exam results
rows, err := s.pool.Query(ctx, `
SELECT
COALESCE(AVG(er.grade), 0) as avg_grade,
COALESCE(MIN(er.grade), 0) as best_grade,
COALESCE(MAX(er.grade), 0) as worst_grade,
COUNT(CASE WHEN er.grade <= 4.0 THEN 1 END) as passed,
COUNT(er.id) as total,
FLOOR(er.grade) as grade_bucket,
COUNT(*) as bucket_count
FROM exam_results er
JOIN exams e ON e.id = er.exam_id
JOIN students s ON s.id = er.student_id
WHERE s.class_id = $1 AND er.grade IS NOT NULL
GROUP BY FLOOR(er.grade)
`, classID)
if err != nil {
return nil, err
}
defer rows.Close()
var totalPassed, totalExams int
for rows.Next() {
var avgGrade, bestGrade, worstGrade float64
var passed, total int
var gradeBucket float64
var bucketCount int
err := rows.Scan(&avgGrade, &bestGrade, &worstGrade, &passed, &total, &gradeBucket, &bucketCount)
if err != nil {
continue
}
stats.ClassAverage = avgGrade
stats.BestGrade = bestGrade
stats.WorstGrade = worstGrade
totalPassed += passed
totalExams += total
gradeKey := fmt.Sprintf("%d", int(gradeBucket))
stats.GradeDistribution[gradeKey] = bucketCount
}
if totalExams > 0 {
stats.PassRate = float64(totalPassed) / float64(totalExams) * 100
}
// Count students at risk (average >= 4.5)
err = s.pool.QueryRow(ctx, `
SELECT COUNT(DISTINCT s.id)
FROM students s
JOIN grade_overview go ON go.student_id = s.id
WHERE s.class_id = $1 AND go.final_grade >= 4.5
`, classID).Scan(&stats.StudentsAtRisk)
if err != nil {
stats.StudentsAtRisk = 0
}
// Get subject averages
subjectRows, err := s.pool.Query(ctx, `
SELECT sub.name, AVG(go.final_grade)
FROM grade_overview go
JOIN subjects sub ON sub.id = go.subject_id
JOIN students s ON s.id = go.student_id
WHERE s.class_id = $1
GROUP BY sub.name
`, classID)
if err == nil {
defer subjectRows.Close()
for subjectRows.Next() {
var name string
var avg float64
if err := subjectRows.Scan(&name, &avg); err == nil {
stats.SubjectAverages[name] = avg
}
}
}
return stats, nil
}

View File

@@ -200,447 +200,3 @@ func (s *GradeService) UpdateGradeWeights(ctx context.Context, studentID, subjec
`, studentID, subjectID, writtenWeight, oralWeight)
return err
}
// =============================================
// STATISTICS METHODS
// =============================================
// ClassStatistics holds statistics for a class
type ClassStatistics struct {
ClassID string `json:"class_id"`
ClassName string `json:"class_name"`
StudentCount int `json:"student_count"`
ClassAverage float64 `json:"class_average"`
GradeDistribution map[string]int `json:"grade_distribution"`
BestGrade float64 `json:"best_grade"`
WorstGrade float64 `json:"worst_grade"`
PassRate float64 `json:"pass_rate"`
StudentsAtRisk int `json:"students_at_risk"`
SubjectAverages map[string]float64 `json:"subject_averages"`
}
// SubjectStatistics holds statistics for a subject within a class
type SubjectStatistics struct {
ClassID string `json:"class_id"`
SubjectID string `json:"subject_id"`
SubjectName string `json:"subject_name"`
StudentCount int `json:"student_count"`
Average float64 `json:"average"`
Median float64 `json:"median"`
GradeDistribution map[string]int `json:"grade_distribution"`
BestGrade float64 `json:"best_grade"`
WorstGrade float64 `json:"worst_grade"`
PassRate float64 `json:"pass_rate"`
ExamAverages []ExamAverage `json:"exam_averages"`
}
// ExamAverage holds average for a single exam
type ExamAverage struct {
ExamID string `json:"exam_id"`
Title string `json:"title"`
Average float64 `json:"average"`
ExamDate string `json:"exam_date"`
}
// StudentStatistics holds statistics for a single student
type StudentStatistics struct {
StudentID string `json:"student_id"`
StudentName string `json:"student_name"`
OverallAverage float64 `json:"overall_average"`
SubjectGrades map[string]float64 `json:"subject_grades"`
Trend string `json:"trend"` // "improving", "stable", "declining"
AbsenceDays int `json:"absence_days"`
ExamsCompleted int `json:"exams_completed"`
StrongestSubject string `json:"strongest_subject"`
WeakestSubject string `json:"weakest_subject"`
}
// Notenspiegel represents grade distribution
type Notenspiegel struct {
ClassID string `json:"class_id"`
SubjectID string `json:"subject_id,omitempty"`
ExamID string `json:"exam_id,omitempty"`
Title string `json:"title"`
Distribution map[string]int `json:"distribution"`
Total int `json:"total"`
Average float64 `json:"average"`
PassRate float64 `json:"pass_rate"`
}
// GetClassStatistics returns statistics for a class
func (s *GradeService) GetClassStatistics(ctx context.Context, classID string, semester int) (*ClassStatistics, error) {
stats := &ClassStatistics{
ClassID: classID,
GradeDistribution: make(map[string]int),
SubjectAverages: make(map[string]float64),
}
// Initialize grade distribution
for i := 1; i <= 6; i++ {
stats.GradeDistribution[string('0'+rune(i))] = 0
}
// Get class info and student count
err := s.db.QueryRow(ctx, `
SELECT c.name, COUNT(s.id)
FROM classes c
LEFT JOIN students s ON s.class_id = c.id
WHERE c.id = $1
GROUP BY c.name
`, classID).Scan(&stats.ClassName, &stats.StudentCount)
if err != nil {
return nil, err
}
// Build semester condition
semesterCond := ""
if semester > 0 {
semesterCond = " AND go.semester = " + string('0'+rune(semester))
}
// Get overall statistics from grade_overview
var avgGrade, bestGrade, worstGrade float64
var totalPassed, totalStudents int
err = s.db.QueryRow(ctx, `
SELECT
COALESCE(AVG(go.final_grade), 0),
COALESCE(MIN(go.final_grade), 0),
COALESCE(MAX(go.final_grade), 0),
COUNT(CASE WHEN go.final_grade <= 4.0 THEN 1 END),
COUNT(go.id)
FROM grade_overview go
JOIN students s ON s.id = go.student_id
WHERE s.class_id = $1 AND go.final_grade IS NOT NULL`+semesterCond+`
`, classID).Scan(&avgGrade, &bestGrade, &worstGrade, &totalPassed, &totalStudents)
if err == nil {
stats.ClassAverage = avgGrade
stats.BestGrade = bestGrade
stats.WorstGrade = worstGrade
if totalStudents > 0 {
stats.PassRate = float64(totalPassed) / float64(totalStudents) * 100
}
}
// Get grade distribution
rows, err := s.db.Query(ctx, `
SELECT FLOOR(go.final_grade) as grade_bucket, COUNT(*) as count
FROM grade_overview go
JOIN students s ON s.id = go.student_id
WHERE s.class_id = $1 AND go.final_grade IS NOT NULL`+semesterCond+`
GROUP BY FLOOR(go.final_grade)
`, classID)
if err == nil {
defer rows.Close()
for rows.Next() {
var bucket int
var count int
if err := rows.Scan(&bucket, &count); err == nil && bucket >= 1 && bucket <= 6 {
stats.GradeDistribution[string('0'+rune(bucket))] = count
}
}
}
// Count students at risk
s.db.QueryRow(ctx, `
SELECT COUNT(DISTINCT s.id)
FROM students s
JOIN grade_overview go ON go.student_id = s.id
WHERE s.class_id = $1 AND go.final_grade >= 4.5
`, classID).Scan(&stats.StudentsAtRisk)
// Get subject averages
subjectRows, err := s.db.Query(ctx, `
SELECT sub.name, AVG(go.final_grade)
FROM grade_overview go
JOIN subjects sub ON sub.id = go.subject_id
JOIN students s ON s.id = go.student_id
WHERE s.class_id = $1 AND go.final_grade IS NOT NULL`+semesterCond+`
GROUP BY sub.name
`, classID)
if err == nil {
defer subjectRows.Close()
for subjectRows.Next() {
var name string
var avg float64
if err := subjectRows.Scan(&name, &avg); err == nil {
stats.SubjectAverages[name] = avg
}
}
}
return stats, nil
}
// GetSubjectStatistics returns statistics for a specific subject in a class
func (s *GradeService) GetSubjectStatistics(ctx context.Context, classID, subjectID string, semester int) (*SubjectStatistics, error) {
stats := &SubjectStatistics{
ClassID: classID,
SubjectID: subjectID,
GradeDistribution: make(map[string]int),
ExamAverages: []ExamAverage{},
}
// Initialize grade distribution
for i := 1; i <= 6; i++ {
stats.GradeDistribution[string('0'+rune(i))] = 0
}
// Get subject name and basic stats
semesterCond := ""
if semester > 0 {
semesterCond = " AND go.semester = " + string('0'+rune(semester))
}
err := s.db.QueryRow(ctx, `
SELECT
sub.name,
COUNT(DISTINCT s.id),
COALESCE(AVG(go.final_grade), 0),
COALESCE(MIN(go.final_grade), 0),
COALESCE(MAX(go.final_grade), 0),
COUNT(CASE WHEN go.final_grade <= 4.0 THEN 1 END),
COUNT(go.id)
FROM grade_overview go
JOIN subjects sub ON sub.id = go.subject_id
JOIN students s ON s.id = go.student_id
WHERE s.class_id = $1 AND go.subject_id = $2 AND go.final_grade IS NOT NULL`+semesterCond+`
GROUP BY sub.name
`, classID, subjectID).Scan(
&stats.SubjectName, &stats.StudentCount, &stats.Average,
&stats.BestGrade, &stats.WorstGrade,
new(int), new(int), // We'll calculate pass rate separately
)
if err != nil {
return nil, err
}
// Get grade distribution
rows, err := s.db.Query(ctx, `
SELECT FLOOR(go.final_grade), COUNT(*)
FROM grade_overview go
JOIN students s ON s.id = go.student_id
WHERE s.class_id = $1 AND go.subject_id = $2 AND go.final_grade IS NOT NULL`+semesterCond+`
GROUP BY FLOOR(go.final_grade)
`, classID, subjectID)
if err == nil {
defer rows.Close()
var passed, total int
for rows.Next() {
var bucket, count int
if err := rows.Scan(&bucket, &count); err == nil && bucket >= 1 && bucket <= 6 {
stats.GradeDistribution[string('0'+rune(bucket))] = count
total += count
if bucket <= 4 {
passed += count
}
}
}
if total > 0 {
stats.PassRate = float64(passed) / float64(total) * 100
}
}
// Get exam averages
examRows, err := s.db.Query(ctx, `
SELECT e.id, e.title, AVG(er.grade), COALESCE(e.exam_date::text, '')
FROM exams e
JOIN exam_results er ON er.exam_id = e.id
JOIN students s ON er.student_id = s.id
WHERE s.class_id = $1 AND e.subject_id = $2 AND er.grade IS NOT NULL
GROUP BY e.id, e.title, e.exam_date
ORDER BY e.exam_date DESC NULLS LAST
`, classID, subjectID)
if err == nil {
defer examRows.Close()
for examRows.Next() {
var ea ExamAverage
if err := examRows.Scan(&ea.ExamID, &ea.Title, &ea.Average, &ea.ExamDate); err == nil {
stats.ExamAverages = append(stats.ExamAverages, ea)
}
}
}
return stats, nil
}
// GetStudentStatistics returns statistics for a specific student
func (s *GradeService) GetStudentStatistics(ctx context.Context, studentID string) (*StudentStatistics, error) {
stats := &StudentStatistics{
StudentID: studentID,
SubjectGrades: make(map[string]float64),
}
// Get student name
err := s.db.QueryRow(ctx, `
SELECT CONCAT(first_name, ' ', last_name) FROM students WHERE id = $1
`, studentID).Scan(&stats.StudentName)
if err != nil {
return nil, err
}
// Get subject grades and calculate overall average
var totalGrade, numSubjects float64
var bestGrade float64 = 6
var worstGrade float64 = 1
var bestSubject, worstSubject string
rows, err := s.db.Query(ctx, `
SELECT sub.name, go.final_grade
FROM grade_overview go
JOIN subjects sub ON sub.id = go.subject_id
WHERE go.student_id = $1 AND go.final_grade IS NOT NULL
ORDER BY sub.name
`, studentID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var name string
var grade float64
if err := rows.Scan(&name, &grade); err == nil {
stats.SubjectGrades[name] = grade
totalGrade += grade
numSubjects++
if grade < bestGrade {
bestGrade = grade
bestSubject = name
}
if grade > worstGrade {
worstGrade = grade
worstSubject = name
}
}
}
if numSubjects > 0 {
stats.OverallAverage = totalGrade / numSubjects
stats.StrongestSubject = bestSubject
stats.WeakestSubject = worstSubject
}
// Count exams completed
s.db.QueryRow(ctx, `
SELECT COUNT(*) FROM exam_results WHERE student_id = $1 AND grade IS NOT NULL
`, studentID).Scan(&stats.ExamsCompleted)
// Count absence days
s.db.QueryRow(ctx, `
SELECT COALESCE(SUM(periods), 0) FROM attendance
WHERE student_id = $1 AND status IN ('absent_excused', 'absent_unexcused')
`, studentID).Scan(&stats.AbsenceDays)
// Determine trend (simplified - compare first and last exam grades)
stats.Trend = "stable"
var firstGrade, lastGrade float64
s.db.QueryRow(ctx, `
SELECT grade FROM exam_results
WHERE student_id = $1 AND grade IS NOT NULL
ORDER BY created_at ASC LIMIT 1
`, studentID).Scan(&firstGrade)
s.db.QueryRow(ctx, `
SELECT grade FROM exam_results
WHERE student_id = $1 AND grade IS NOT NULL
ORDER BY created_at DESC LIMIT 1
`, studentID).Scan(&lastGrade)
if lastGrade < firstGrade-0.5 {
stats.Trend = "improving"
} else if lastGrade > firstGrade+0.5 {
stats.Trend = "declining"
}
return stats, nil
}
// GetNotenspiegel returns grade distribution (Notenspiegel)
func (s *GradeService) GetNotenspiegel(ctx context.Context, classID, subjectID, examID string, semester int) (*Notenspiegel, error) {
ns := &Notenspiegel{
ClassID: classID,
SubjectID: subjectID,
ExamID: examID,
Distribution: make(map[string]int),
}
// Initialize distribution
for i := 1; i <= 6; i++ {
ns.Distribution[string('0'+rune(i))] = 0
}
var query string
var args []interface{}
if examID != "" {
// Notenspiegel for specific exam
query = `
SELECT e.title, FLOOR(er.grade), COUNT(*), AVG(er.grade)
FROM exam_results er
JOIN exams e ON e.id = er.exam_id
WHERE er.exam_id = $1 AND er.grade IS NOT NULL
GROUP BY e.title, FLOOR(er.grade)
`
args = []interface{}{examID}
} else if subjectID != "" {
// Notenspiegel for subject in class
semesterCond := ""
if semester > 0 {
semesterCond = " AND go.semester = " + string('0'+rune(semester))
}
query = `
SELECT sub.name, FLOOR(go.final_grade), COUNT(*), AVG(go.final_grade)
FROM grade_overview go
JOIN subjects sub ON sub.id = go.subject_id
JOIN students s ON s.id = go.student_id
WHERE s.class_id = $1 AND go.subject_id = $2 AND go.final_grade IS NOT NULL` + semesterCond + `
GROUP BY sub.name, FLOOR(go.final_grade)
`
args = []interface{}{classID, subjectID}
} else {
// Notenspiegel for entire class
semesterCond := ""
if semester > 0 {
semesterCond = " AND go.semester = " + string('0'+rune(semester))
}
query = `
SELECT c.name, FLOOR(go.final_grade), COUNT(*), AVG(go.final_grade)
FROM grade_overview go
JOIN students s ON s.id = go.student_id
JOIN classes c ON c.id = s.class_id
WHERE s.class_id = $1 AND go.final_grade IS NOT NULL` + semesterCond + `
GROUP BY c.name, FLOOR(go.final_grade)
`
args = []interface{}{classID}
}
rows, err := s.db.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var passed int
for rows.Next() {
var title string
var bucket, count int
var avg float64
if err := rows.Scan(&title, &bucket, &count, &avg); err == nil {
ns.Title = title
if bucket >= 1 && bucket <= 6 {
ns.Distribution[string('0'+rune(bucket))] = count
ns.Total += count
if bucket <= 4 {
passed += count
}
}
ns.Average = avg
}
}
if ns.Total > 0 {
ns.PassRate = float64(passed) / float64(ns.Total) * 100
}
return ns, nil
}

View File

@@ -0,0 +1,449 @@
package services
import (
"context"
)
// =============================================
// 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
}