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