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