Initial commit: breakpilot-core - Shared Infrastructure
Docker Compose with 24+ services: - PostgreSQL (PostGIS), Valkey, MinIO, Qdrant - Vault (PKI/TLS), Nginx (Reverse Proxy) - Backend Core API, Consent Service, Billing Service - RAG Service, Embedding Service - Gitea, Woodpecker CI/CD - Night Scheduler, Health Aggregator - Jitsi (Web/XMPP/JVB/Jicofo), Mailpit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
543
consent-service/internal/services/grade_service.go
Normal file
543
consent-service/internal/services/grade_service.go
Normal file
@@ -0,0 +1,543 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/database"
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
"github.com/breakpilot/consent-service/internal/services/matrix"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GradeService handles grade management and notifications
|
||||
type GradeService struct {
|
||||
db *database.DB
|
||||
matrix *matrix.MatrixService
|
||||
}
|
||||
|
||||
// NewGradeService creates a new grade service
|
||||
func NewGradeService(db *database.DB, matrixService *matrix.MatrixService) *GradeService {
|
||||
return &GradeService{
|
||||
db: db,
|
||||
matrix: matrixService,
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Grade CRUD
|
||||
// ========================================
|
||||
|
||||
// CreateGrade creates a new grade for a student
|
||||
func (s *GradeService) CreateGrade(ctx context.Context, req models.CreateGradeRequest, teacherID uuid.UUID) (*models.Grade, error) {
|
||||
studentID, err := uuid.Parse(req.StudentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid student ID: %w", err)
|
||||
}
|
||||
|
||||
subjectID, err := uuid.Parse(req.SubjectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid subject ID: %w", err)
|
||||
}
|
||||
|
||||
schoolYearID, err := uuid.Parse(req.SchoolYearID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid school year ID: %w", err)
|
||||
}
|
||||
|
||||
date, err := time.Parse("2006-01-02", req.Date)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid date format: %w", err)
|
||||
}
|
||||
|
||||
// Get default grade scale for the school
|
||||
var gradeScaleID uuid.UUID
|
||||
var schoolID uuid.UUID
|
||||
err = s.db.Pool.QueryRow(ctx, `
|
||||
SELECT gs.id, gs.school_id
|
||||
FROM grade_scales gs
|
||||
JOIN students st ON st.school_id = gs.school_id
|
||||
WHERE st.id = $1 AND gs.is_default = true`, studentID).Scan(&gradeScaleID, &schoolID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get grade scale: %w", err)
|
||||
}
|
||||
|
||||
weight := req.Weight
|
||||
if weight == 0 {
|
||||
weight = 1.0
|
||||
}
|
||||
|
||||
grade := &models.Grade{
|
||||
ID: uuid.New(),
|
||||
StudentID: studentID,
|
||||
SubjectID: subjectID,
|
||||
TeacherID: teacherID,
|
||||
SchoolYearID: schoolYearID,
|
||||
GradeScaleID: gradeScaleID,
|
||||
Type: req.Type,
|
||||
Value: req.Value,
|
||||
Weight: weight,
|
||||
Date: date,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
IsVisible: true,
|
||||
Semester: req.Semester,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO grades (id, student_id, subject_id, teacher_id, school_year_id, grade_scale_id, type, value, weight, date, title, description, is_visible, semester, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
RETURNING id`
|
||||
|
||||
err = s.db.Pool.QueryRow(ctx, query,
|
||||
grade.ID, grade.StudentID, grade.SubjectID, grade.TeacherID,
|
||||
grade.SchoolYearID, grade.GradeScaleID, grade.Type, grade.Value,
|
||||
grade.Weight, grade.Date, grade.Title, grade.Description,
|
||||
grade.IsVisible, grade.Semester, grade.CreatedAt, grade.UpdatedAt,
|
||||
).Scan(&grade.ID)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create grade: %w", err)
|
||||
}
|
||||
|
||||
// Send notification to parents if grade is visible
|
||||
if grade.IsVisible {
|
||||
go s.notifyParentsOfNewGrade(context.Background(), grade)
|
||||
}
|
||||
|
||||
return grade, nil
|
||||
}
|
||||
|
||||
// GetGrade retrieves a grade by ID
|
||||
func (s *GradeService) GetGrade(ctx context.Context, gradeID uuid.UUID) (*models.Grade, error) {
|
||||
query := `
|
||||
SELECT id, student_id, subject_id, teacher_id, school_year_id, grade_scale_id, type, value, weight, date, title, description, is_visible, semester, created_at, updated_at
|
||||
FROM grades
|
||||
WHERE id = $1`
|
||||
|
||||
grade := &models.Grade{}
|
||||
err := s.db.Pool.QueryRow(ctx, query, gradeID).Scan(
|
||||
&grade.ID, &grade.StudentID, &grade.SubjectID, &grade.TeacherID,
|
||||
&grade.SchoolYearID, &grade.GradeScaleID, &grade.Type, &grade.Value,
|
||||
&grade.Weight, &grade.Date, &grade.Title, &grade.Description,
|
||||
&grade.IsVisible, &grade.Semester, &grade.CreatedAt, &grade.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get grade: %w", err)
|
||||
}
|
||||
|
||||
return grade, nil
|
||||
}
|
||||
|
||||
// UpdateGrade updates an existing grade
|
||||
func (s *GradeService) UpdateGrade(ctx context.Context, gradeID uuid.UUID, value float64, title, description *string) error {
|
||||
query := `
|
||||
UPDATE grades
|
||||
SET value = $1, title = COALESCE($2, title), description = COALESCE($3, description), updated_at = NOW()
|
||||
WHERE id = $4`
|
||||
|
||||
result, err := s.db.Pool.Exec(ctx, query, value, title, description, gradeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update grade: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return fmt.Errorf("grade not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteGrade deletes a grade
|
||||
func (s *GradeService) DeleteGrade(ctx context.Context, gradeID uuid.UUID) error {
|
||||
result, err := s.db.Pool.Exec(ctx, `DELETE FROM grades WHERE id = $1`, gradeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete grade: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return fmt.Errorf("grade not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Grade Queries
|
||||
// ========================================
|
||||
|
||||
// GetStudentGrades gets all grades for a student in a school year
|
||||
func (s *GradeService) GetStudentGrades(ctx context.Context, studentID, schoolYearID uuid.UUID) ([]models.Grade, error) {
|
||||
query := `
|
||||
SELECT id, student_id, subject_id, teacher_id, school_year_id, grade_scale_id, type, value, weight, date, title, description, is_visible, semester, created_at, updated_at
|
||||
FROM grades
|
||||
WHERE student_id = $1 AND school_year_id = $2 AND is_visible = true
|
||||
ORDER BY date DESC`
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, query, studentID, schoolYearID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get student grades: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var grades []models.Grade
|
||||
for rows.Next() {
|
||||
var grade models.Grade
|
||||
err := rows.Scan(
|
||||
&grade.ID, &grade.StudentID, &grade.SubjectID, &grade.TeacherID,
|
||||
&grade.SchoolYearID, &grade.GradeScaleID, &grade.Type, &grade.Value,
|
||||
&grade.Weight, &grade.Date, &grade.Title, &grade.Description,
|
||||
&grade.IsVisible, &grade.Semester, &grade.CreatedAt, &grade.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan grade: %w", err)
|
||||
}
|
||||
grades = append(grades, grade)
|
||||
}
|
||||
|
||||
return grades, nil
|
||||
}
|
||||
|
||||
// GetStudentGradesBySubject gets grades for a student in a specific subject
|
||||
func (s *GradeService) GetStudentGradesBySubject(ctx context.Context, studentID, subjectID, schoolYearID uuid.UUID, semester int) ([]models.Grade, error) {
|
||||
query := `
|
||||
SELECT id, student_id, subject_id, teacher_id, school_year_id, grade_scale_id, type, value, weight, date, title, description, is_visible, semester, created_at, updated_at
|
||||
FROM grades
|
||||
WHERE student_id = $1 AND subject_id = $2 AND school_year_id = $3 AND semester = $4 AND is_visible = true
|
||||
ORDER BY date DESC`
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, query, studentID, subjectID, schoolYearID, semester)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get grades by subject: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var grades []models.Grade
|
||||
for rows.Next() {
|
||||
var grade models.Grade
|
||||
err := rows.Scan(
|
||||
&grade.ID, &grade.StudentID, &grade.SubjectID, &grade.TeacherID,
|
||||
&grade.SchoolYearID, &grade.GradeScaleID, &grade.Type, &grade.Value,
|
||||
&grade.Weight, &grade.Date, &grade.Title, &grade.Description,
|
||||
&grade.IsVisible, &grade.Semester, &grade.CreatedAt, &grade.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan grade: %w", err)
|
||||
}
|
||||
grades = append(grades, grade)
|
||||
}
|
||||
|
||||
return grades, nil
|
||||
}
|
||||
|
||||
// GetClassGradesBySubject gets all grades for a class in a subject (Notenspiegel)
|
||||
func (s *GradeService) GetClassGradesBySubject(ctx context.Context, classID, subjectID, schoolYearID uuid.UUID, semester int) ([]models.StudentGradeOverview, error) {
|
||||
// Get all students in the class
|
||||
studentsQuery := `
|
||||
SELECT id, first_name, last_name
|
||||
FROM students
|
||||
WHERE class_id = $1 AND is_active = true
|
||||
ORDER BY last_name, first_name`
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, studentsQuery, classID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get students: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var students []struct {
|
||||
ID uuid.UUID
|
||||
FirstName string
|
||||
LastName string
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var student struct {
|
||||
ID uuid.UUID
|
||||
FirstName string
|
||||
LastName string
|
||||
}
|
||||
if err := rows.Scan(&student.ID, &student.FirstName, &student.LastName); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan student: %w", err)
|
||||
}
|
||||
students = append(students, student)
|
||||
}
|
||||
|
||||
// Get subject info
|
||||
var subject models.Subject
|
||||
err = s.db.Pool.QueryRow(ctx, `SELECT id, school_id, name, short_name, color, is_active, created_at FROM subjects WHERE id = $1`, subjectID).Scan(
|
||||
&subject.ID, &subject.SchoolID, &subject.Name, &subject.ShortName, &subject.Color, &subject.IsActive, &subject.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get subject: %w", err)
|
||||
}
|
||||
|
||||
var overviews []models.StudentGradeOverview
|
||||
|
||||
for _, student := range students {
|
||||
grades, err := s.GetStudentGradesBySubject(ctx, student.ID, subjectID, schoolYearID, semester)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate averages
|
||||
var totalWeight, weightedSum float64
|
||||
var oralWeight, oralSum float64
|
||||
var examWeight, examSum float64
|
||||
|
||||
for _, grade := range grades {
|
||||
totalWeight += grade.Weight
|
||||
weightedSum += grade.Value * grade.Weight
|
||||
|
||||
if grade.Type == models.GradeTypeOral || grade.Type == models.GradeTypeParticipation {
|
||||
oralWeight += grade.Weight
|
||||
oralSum += grade.Value * grade.Weight
|
||||
} else if grade.Type == models.GradeTypeExam || grade.Type == models.GradeTypeTest {
|
||||
examWeight += grade.Weight
|
||||
examSum += grade.Value * grade.Weight
|
||||
}
|
||||
}
|
||||
|
||||
var average, oralAverage, examAverage float64
|
||||
if totalWeight > 0 {
|
||||
average = weightedSum / totalWeight
|
||||
}
|
||||
if oralWeight > 0 {
|
||||
oralAverage = oralSum / oralWeight
|
||||
}
|
||||
if examWeight > 0 {
|
||||
examAverage = examSum / examWeight
|
||||
}
|
||||
|
||||
overview := models.StudentGradeOverview{
|
||||
Student: models.Student{
|
||||
ID: student.ID,
|
||||
FirstName: student.FirstName,
|
||||
LastName: student.LastName,
|
||||
},
|
||||
Subject: subject,
|
||||
Grades: grades,
|
||||
Average: average,
|
||||
OralAverage: oralAverage,
|
||||
ExamAverage: examAverage,
|
||||
Semester: semester,
|
||||
}
|
||||
|
||||
overviews = append(overviews, overview)
|
||||
}
|
||||
|
||||
return overviews, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Grade Statistics
|
||||
// ========================================
|
||||
|
||||
// GetStudentGradeAverage calculates the overall grade average for a student
|
||||
func (s *GradeService) GetStudentGradeAverage(ctx context.Context, studentID, schoolYearID uuid.UUID, semester int) (float64, error) {
|
||||
query := `
|
||||
SELECT COALESCE(SUM(value * weight) / NULLIF(SUM(weight), 0), 0)
|
||||
FROM grades
|
||||
WHERE student_id = $1 AND school_year_id = $2 AND semester = $3 AND is_visible = true`
|
||||
|
||||
var average float64
|
||||
err := s.db.Pool.QueryRow(ctx, query, studentID, schoolYearID, semester).Scan(&average)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to calculate average: %w", err)
|
||||
}
|
||||
|
||||
return average, nil
|
||||
}
|
||||
|
||||
// GetSubjectGradeStatistics gets grade statistics for a subject in a class
|
||||
func (s *GradeService) GetSubjectGradeStatistics(ctx context.Context, classID, subjectID, schoolYearID uuid.UUID, semester int) (map[string]interface{}, error) {
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(DISTINCT g.student_id) as student_count,
|
||||
AVG(g.value) as class_average,
|
||||
MIN(g.value) as best_grade,
|
||||
MAX(g.value) as worst_grade,
|
||||
COUNT(*) as total_grades
|
||||
FROM grades g
|
||||
JOIN students s ON g.student_id = s.id
|
||||
WHERE s.class_id = $1 AND g.subject_id = $2 AND g.school_year_id = $3 AND g.semester = $4 AND g.is_visible = true`
|
||||
|
||||
var studentCount, totalGrades int
|
||||
var classAverage, bestGrade, worstGrade float64
|
||||
|
||||
err := s.db.Pool.QueryRow(ctx, query, classID, subjectID, schoolYearID, semester).Scan(
|
||||
&studentCount, &classAverage, &bestGrade, &worstGrade, &totalGrades,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get statistics: %w", err)
|
||||
}
|
||||
|
||||
// Grade distribution (for German grades 1-6)
|
||||
distributionQuery := `
|
||||
SELECT
|
||||
COUNT(CASE WHEN value >= 1 AND value < 1.5 THEN 1 END) as grade_1,
|
||||
COUNT(CASE WHEN value >= 1.5 AND value < 2.5 THEN 1 END) as grade_2,
|
||||
COUNT(CASE WHEN value >= 2.5 AND value < 3.5 THEN 1 END) as grade_3,
|
||||
COUNT(CASE WHEN value >= 3.5 AND value < 4.5 THEN 1 END) as grade_4,
|
||||
COUNT(CASE WHEN value >= 4.5 AND value < 5.5 THEN 1 END) as grade_5,
|
||||
COUNT(CASE WHEN value >= 5.5 THEN 1 END) as grade_6
|
||||
FROM grades g
|
||||
JOIN students s ON g.student_id = s.id
|
||||
WHERE s.class_id = $1 AND g.subject_id = $2 AND g.school_year_id = $3 AND g.semester = $4 AND g.is_visible = true AND g.type IN ('exam', 'test')`
|
||||
|
||||
var g1, g2, g3, g4, g5, g6 int
|
||||
err = s.db.Pool.QueryRow(ctx, distributionQuery, classID, subjectID, schoolYearID, semester).Scan(
|
||||
&g1, &g2, &g3, &g4, &g5, &g6,
|
||||
)
|
||||
if err != nil {
|
||||
// Non-fatal, continue without distribution
|
||||
g1, g2, g3, g4, g5, g6 = 0, 0, 0, 0, 0, 0
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"student_count": studentCount,
|
||||
"class_average": classAverage,
|
||||
"best_grade": bestGrade,
|
||||
"worst_grade": worstGrade,
|
||||
"total_grades": totalGrades,
|
||||
"distribution": map[string]int{
|
||||
"1": g1,
|
||||
"2": g2,
|
||||
"3": g3,
|
||||
"4": g4,
|
||||
"5": g5,
|
||||
"6": g6,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Grade Comments
|
||||
// ========================================
|
||||
|
||||
// AddGradeComment adds a comment to a grade
|
||||
func (s *GradeService) AddGradeComment(ctx context.Context, gradeID, teacherID uuid.UUID, comment string, isPrivate bool) (*models.GradeComment, error) {
|
||||
gradeComment := &models.GradeComment{
|
||||
ID: uuid.New(),
|
||||
GradeID: gradeID,
|
||||
TeacherID: teacherID,
|
||||
Comment: comment,
|
||||
IsPrivate: isPrivate,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO grade_comments (id, grade_id, teacher_id, comment, is_private, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id`
|
||||
|
||||
err := s.db.Pool.QueryRow(ctx, query,
|
||||
gradeComment.ID, gradeComment.GradeID, gradeComment.TeacherID,
|
||||
gradeComment.Comment, gradeComment.IsPrivate, gradeComment.CreatedAt,
|
||||
).Scan(&gradeComment.ID)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to add grade comment: %w", err)
|
||||
}
|
||||
|
||||
return gradeComment, nil
|
||||
}
|
||||
|
||||
// GetGradeComments gets comments for a grade
|
||||
func (s *GradeService) GetGradeComments(ctx context.Context, gradeID uuid.UUID, includePrivate bool) ([]models.GradeComment, error) {
|
||||
query := `
|
||||
SELECT id, grade_id, teacher_id, comment, is_private, created_at
|
||||
FROM grade_comments
|
||||
WHERE grade_id = $1`
|
||||
|
||||
if !includePrivate {
|
||||
query += ` AND is_private = false`
|
||||
}
|
||||
|
||||
query += ` ORDER BY created_at DESC`
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, query, gradeID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get grade comments: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var comments []models.GradeComment
|
||||
for rows.Next() {
|
||||
var comment models.GradeComment
|
||||
err := rows.Scan(
|
||||
&comment.ID, &comment.GradeID, &comment.TeacherID,
|
||||
&comment.Comment, &comment.IsPrivate, &comment.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan grade comment: %w", err)
|
||||
}
|
||||
comments = append(comments, comment)
|
||||
}
|
||||
|
||||
return comments, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Parent Notifications
|
||||
// ========================================
|
||||
|
||||
func (s *GradeService) notifyParentsOfNewGrade(ctx context.Context, grade *models.Grade) {
|
||||
if s.matrix == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Get student info and Matrix room
|
||||
var studentFirstName, studentLastName, matrixDMRoom string
|
||||
err := s.db.Pool.QueryRow(ctx, `
|
||||
SELECT first_name, last_name, matrix_dm_room
|
||||
FROM students
|
||||
WHERE id = $1`, grade.StudentID).Scan(&studentFirstName, &studentLastName, &matrixDMRoom)
|
||||
if err != nil || matrixDMRoom == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Get subject name
|
||||
var subjectName string
|
||||
err = s.db.Pool.QueryRow(ctx, `SELECT name FROM subjects WHERE id = $1`, grade.SubjectID).Scan(&subjectName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
studentName := studentFirstName + " " + studentLastName
|
||||
gradeType := s.getGradeTypeDisplayName(grade.Type)
|
||||
|
||||
// Send Matrix notification
|
||||
err = s.matrix.SendGradeNotification(ctx, matrixDMRoom, studentName, subjectName, gradeType, grade.Value)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to send grade notification: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GradeService) getGradeTypeDisplayName(gradeType string) string {
|
||||
switch gradeType {
|
||||
case models.GradeTypeExam:
|
||||
return "Klassenarbeit"
|
||||
case models.GradeTypeTest:
|
||||
return "Test"
|
||||
case models.GradeTypeOral:
|
||||
return "Mündliche Note"
|
||||
case models.GradeTypeHomework:
|
||||
return "Hausaufgabe"
|
||||
case models.GradeTypeProject:
|
||||
return "Projekt"
|
||||
case models.GradeTypeParticipation:
|
||||
return "Mitarbeit"
|
||||
case models.GradeTypeSemester:
|
||||
return "Halbjahreszeugnis"
|
||||
case models.GradeTypeFinal:
|
||||
return "Zeugnisnote"
|
||||
default:
|
||||
return gradeType
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user