Some checks failed
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
ci/woodpecker/push/integration Pipeline failed
ci/woodpecker/push/main Pipeline failed
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Add complete Academy backend (Go) and frontend (Next.js) for DSGVO/IT-Security/AI-Literacy compliance training: - Go backend: Course CRUD, enrollments, quiz evaluation, PDF certificates (gofpdf), video generation pipeline (ElevenLabs + HeyGen) - In-memory data store with PostgreSQL migration for future DB support - Frontend: Course creation (AI + manual), lesson viewer, interactive quiz, certificate viewer with PDF download - Fix existing compile errors in generate.go (SearchResult type mismatch), llm/service.go (unused var), rag/service.go (Unicode chars) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
682 lines
16 KiB
Go
682 lines
16 KiB
Go
package db
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// AcademyMemStore provides in-memory storage for academy data
|
|
type AcademyMemStore struct {
|
|
mu sync.RWMutex
|
|
courses map[string]*AcademyCourseRow
|
|
lessons map[string]*AcademyLessonRow
|
|
quizQuestions map[string]*AcademyQuizQuestionRow
|
|
enrollments map[string]*AcademyEnrollmentRow
|
|
certificates map[string]*AcademyCertificateRow
|
|
lessonProgress map[string]*AcademyLessonProgressRow
|
|
}
|
|
|
|
// Row types matching the DB schema
|
|
type AcademyCourseRow struct {
|
|
ID string
|
|
TenantID string
|
|
Title string
|
|
Description string
|
|
Category string
|
|
PassingScore int
|
|
DurationMinutes int
|
|
RequiredForRoles []string
|
|
Status string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type AcademyLessonRow struct {
|
|
ID string
|
|
CourseID string
|
|
Title string
|
|
Type string
|
|
ContentMarkdown string
|
|
VideoURL string
|
|
AudioURL string
|
|
SortOrder int
|
|
DurationMinutes int
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type AcademyQuizQuestionRow struct {
|
|
ID string
|
|
LessonID string
|
|
Question string
|
|
Options []string
|
|
CorrectOptionIndex int
|
|
Explanation string
|
|
SortOrder int
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
type AcademyEnrollmentRow struct {
|
|
ID string
|
|
TenantID string
|
|
CourseID string
|
|
UserID string
|
|
UserName string
|
|
UserEmail string
|
|
Status string
|
|
Progress int
|
|
StartedAt time.Time
|
|
CompletedAt *time.Time
|
|
CertificateID string
|
|
Deadline time.Time
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type AcademyCertificateRow struct {
|
|
ID string
|
|
TenantID string
|
|
EnrollmentID string
|
|
CourseID string
|
|
UserID string
|
|
UserName string
|
|
CourseName string
|
|
Score int
|
|
IssuedAt time.Time
|
|
ValidUntil time.Time
|
|
PdfURL string
|
|
}
|
|
|
|
type AcademyLessonProgressRow struct {
|
|
ID string
|
|
EnrollmentID string
|
|
LessonID string
|
|
Completed bool
|
|
QuizScore *int
|
|
CompletedAt *time.Time
|
|
}
|
|
|
|
type AcademyStatisticsRow struct {
|
|
TotalCourses int
|
|
TotalEnrollments int
|
|
CompletionRate float64
|
|
OverdueCount int
|
|
ByCategory map[string]int
|
|
ByStatus map[string]int
|
|
}
|
|
|
|
func NewAcademyMemStore() *AcademyMemStore {
|
|
return &AcademyMemStore{
|
|
courses: make(map[string]*AcademyCourseRow),
|
|
lessons: make(map[string]*AcademyLessonRow),
|
|
quizQuestions: make(map[string]*AcademyQuizQuestionRow),
|
|
enrollments: make(map[string]*AcademyEnrollmentRow),
|
|
certificates: make(map[string]*AcademyCertificateRow),
|
|
lessonProgress: make(map[string]*AcademyLessonProgressRow),
|
|
}
|
|
}
|
|
|
|
// generateID creates a simple unique ID
|
|
func generateID() string {
|
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Course CRUD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ListCourses returns all courses for a tenant, sorted by UpdatedAt DESC.
|
|
func (s *AcademyMemStore) ListCourses(tenantID string) []*AcademyCourseRow {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
var result []*AcademyCourseRow
|
|
for _, c := range s.courses {
|
|
if c.TenantID == tenantID {
|
|
result = append(result, c)
|
|
}
|
|
}
|
|
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return result[i].UpdatedAt.After(result[j].UpdatedAt)
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
// GetCourse retrieves a single course by ID.
|
|
func (s *AcademyMemStore) GetCourse(id string) (*AcademyCourseRow, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
c, ok := s.courses[id]
|
|
if !ok {
|
|
return nil, fmt.Errorf("course not found: %s", id)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// CreateCourse inserts a new course with auto-generated ID and timestamps.
|
|
func (s *AcademyMemStore) CreateCourse(row *AcademyCourseRow) *AcademyCourseRow {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
row.ID = generateID()
|
|
row.CreatedAt = now
|
|
row.UpdatedAt = now
|
|
s.courses[row.ID] = row
|
|
return row
|
|
}
|
|
|
|
// UpdateCourse partially updates a course. Supported keys: Title, Description,
|
|
// Category, PassingScore, DurationMinutes, RequiredForRoles, Status.
|
|
func (s *AcademyMemStore) UpdateCourse(id string, updates map[string]interface{}) (*AcademyCourseRow, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
c, ok := s.courses[id]
|
|
if !ok {
|
|
return nil, fmt.Errorf("course not found: %s", id)
|
|
}
|
|
|
|
for k, v := range updates {
|
|
switch strings.ToLower(k) {
|
|
case "title":
|
|
if val, ok := v.(string); ok {
|
|
c.Title = val
|
|
}
|
|
case "description":
|
|
if val, ok := v.(string); ok {
|
|
c.Description = val
|
|
}
|
|
case "category":
|
|
if val, ok := v.(string); ok {
|
|
c.Category = val
|
|
}
|
|
case "passingscore", "passing_score":
|
|
switch val := v.(type) {
|
|
case int:
|
|
c.PassingScore = val
|
|
case float64:
|
|
c.PassingScore = int(val)
|
|
}
|
|
case "durationminutes", "duration_minutes":
|
|
switch val := v.(type) {
|
|
case int:
|
|
c.DurationMinutes = val
|
|
case float64:
|
|
c.DurationMinutes = int(val)
|
|
}
|
|
case "requiredforroles", "required_for_roles":
|
|
if val, ok := v.([]string); ok {
|
|
c.RequiredForRoles = val
|
|
}
|
|
case "status":
|
|
if val, ok := v.(string); ok {
|
|
c.Status = val
|
|
}
|
|
}
|
|
}
|
|
|
|
c.UpdatedAt = time.Now()
|
|
return c, nil
|
|
}
|
|
|
|
// DeleteCourse removes a course and all related lessons, quiz questions,
|
|
// enrollments, certificates, and lesson progress.
|
|
func (s *AcademyMemStore) DeleteCourse(id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if _, ok := s.courses[id]; !ok {
|
|
return fmt.Errorf("course not found: %s", id)
|
|
}
|
|
|
|
// Collect lesson IDs for this course
|
|
lessonIDs := make(map[string]bool)
|
|
for lid, l := range s.lessons {
|
|
if l.CourseID == id {
|
|
lessonIDs[lid] = true
|
|
}
|
|
}
|
|
|
|
// Delete quiz questions belonging to those lessons
|
|
for qid, q := range s.quizQuestions {
|
|
if lessonIDs[q.LessonID] {
|
|
delete(s.quizQuestions, qid)
|
|
}
|
|
}
|
|
|
|
// Delete lessons
|
|
for lid := range lessonIDs {
|
|
delete(s.lessons, lid)
|
|
}
|
|
|
|
// Collect enrollment IDs for this course
|
|
enrollmentIDs := make(map[string]bool)
|
|
for eid, e := range s.enrollments {
|
|
if e.CourseID == id {
|
|
enrollmentIDs[eid] = true
|
|
}
|
|
}
|
|
|
|
// Delete lesson progress belonging to those enrollments
|
|
for pid, p := range s.lessonProgress {
|
|
if enrollmentIDs[p.EnrollmentID] {
|
|
delete(s.lessonProgress, pid)
|
|
}
|
|
}
|
|
|
|
// Delete certificates belonging to those enrollments
|
|
for cid, cert := range s.certificates {
|
|
if cert.CourseID == id {
|
|
delete(s.certificates, cid)
|
|
}
|
|
}
|
|
|
|
// Delete enrollments
|
|
for eid := range enrollmentIDs {
|
|
delete(s.enrollments, eid)
|
|
}
|
|
|
|
// Delete the course itself
|
|
delete(s.courses, id)
|
|
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lesson CRUD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ListLessons returns all lessons for a course, sorted by SortOrder ASC.
|
|
func (s *AcademyMemStore) ListLessons(courseID string) []*AcademyLessonRow {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
var result []*AcademyLessonRow
|
|
for _, l := range s.lessons {
|
|
if l.CourseID == courseID {
|
|
result = append(result, l)
|
|
}
|
|
}
|
|
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return result[i].SortOrder < result[j].SortOrder
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
// GetLesson retrieves a single lesson by ID.
|
|
func (s *AcademyMemStore) GetLesson(id string) (*AcademyLessonRow, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
l, ok := s.lessons[id]
|
|
if !ok {
|
|
return nil, fmt.Errorf("lesson not found: %s", id)
|
|
}
|
|
return l, nil
|
|
}
|
|
|
|
// CreateLesson inserts a new lesson with auto-generated ID and timestamps.
|
|
func (s *AcademyMemStore) CreateLesson(row *AcademyLessonRow) *AcademyLessonRow {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
row.ID = generateID()
|
|
row.CreatedAt = now
|
|
row.UpdatedAt = now
|
|
s.lessons[row.ID] = row
|
|
return row
|
|
}
|
|
|
|
// UpdateLesson partially updates a lesson. Supported keys: Title, Type,
|
|
// ContentMarkdown, VideoURL, AudioURL, SortOrder, DurationMinutes.
|
|
func (s *AcademyMemStore) UpdateLesson(id string, updates map[string]interface{}) (*AcademyLessonRow, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
l, ok := s.lessons[id]
|
|
if !ok {
|
|
return nil, fmt.Errorf("lesson not found: %s", id)
|
|
}
|
|
|
|
for k, v := range updates {
|
|
switch strings.ToLower(k) {
|
|
case "title":
|
|
if val, ok := v.(string); ok {
|
|
l.Title = val
|
|
}
|
|
case "type":
|
|
if val, ok := v.(string); ok {
|
|
l.Type = val
|
|
}
|
|
case "contentmarkdown", "content_markdown":
|
|
if val, ok := v.(string); ok {
|
|
l.ContentMarkdown = val
|
|
}
|
|
case "videourl", "video_url":
|
|
if val, ok := v.(string); ok {
|
|
l.VideoURL = val
|
|
}
|
|
case "audiourl", "audio_url":
|
|
if val, ok := v.(string); ok {
|
|
l.AudioURL = val
|
|
}
|
|
case "sortorder", "sort_order":
|
|
switch val := v.(type) {
|
|
case int:
|
|
l.SortOrder = val
|
|
case float64:
|
|
l.SortOrder = int(val)
|
|
}
|
|
case "durationminutes", "duration_minutes":
|
|
switch val := v.(type) {
|
|
case int:
|
|
l.DurationMinutes = val
|
|
case float64:
|
|
l.DurationMinutes = int(val)
|
|
}
|
|
}
|
|
}
|
|
|
|
l.UpdatedAt = time.Now()
|
|
return l, nil
|
|
}
|
|
|
|
// DeleteLesson removes a lesson and its quiz questions.
|
|
func (s *AcademyMemStore) DeleteLesson(id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if _, ok := s.lessons[id]; !ok {
|
|
return fmt.Errorf("lesson not found: %s", id)
|
|
}
|
|
|
|
// Delete quiz questions belonging to this lesson
|
|
for qid, q := range s.quizQuestions {
|
|
if q.LessonID == id {
|
|
delete(s.quizQuestions, qid)
|
|
}
|
|
}
|
|
|
|
delete(s.lessons, id)
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Quiz Questions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ListQuizQuestions returns all quiz questions for a lesson, sorted by SortOrder ASC.
|
|
func (s *AcademyMemStore) ListQuizQuestions(lessonID string) []*AcademyQuizQuestionRow {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
var result []*AcademyQuizQuestionRow
|
|
for _, q := range s.quizQuestions {
|
|
if q.LessonID == lessonID {
|
|
result = append(result, q)
|
|
}
|
|
}
|
|
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return result[i].SortOrder < result[j].SortOrder
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
// CreateQuizQuestion inserts a new quiz question with auto-generated ID and timestamp.
|
|
func (s *AcademyMemStore) CreateQuizQuestion(row *AcademyQuizQuestionRow) *AcademyQuizQuestionRow {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
row.ID = generateID()
|
|
row.CreatedAt = time.Now()
|
|
s.quizQuestions[row.ID] = row
|
|
return row
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Enrollments
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ListEnrollments returns enrollments filtered by tenantID and optionally by courseID.
|
|
// If courseID is empty, all enrollments for the tenant are returned.
|
|
func (s *AcademyMemStore) ListEnrollments(tenantID string, courseID string) []*AcademyEnrollmentRow {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
var result []*AcademyEnrollmentRow
|
|
for _, e := range s.enrollments {
|
|
if e.TenantID != tenantID {
|
|
continue
|
|
}
|
|
if courseID != "" && e.CourseID != courseID {
|
|
continue
|
|
}
|
|
result = append(result, e)
|
|
}
|
|
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return result[i].UpdatedAt.After(result[j].UpdatedAt)
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
// GetEnrollment retrieves a single enrollment by ID.
|
|
func (s *AcademyMemStore) GetEnrollment(id string) (*AcademyEnrollmentRow, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
e, ok := s.enrollments[id]
|
|
if !ok {
|
|
return nil, fmt.Errorf("enrollment not found: %s", id)
|
|
}
|
|
return e, nil
|
|
}
|
|
|
|
// CreateEnrollment inserts a new enrollment with auto-generated ID and timestamps.
|
|
func (s *AcademyMemStore) CreateEnrollment(row *AcademyEnrollmentRow) *AcademyEnrollmentRow {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
row.ID = generateID()
|
|
row.CreatedAt = now
|
|
row.UpdatedAt = now
|
|
if row.StartedAt.IsZero() {
|
|
row.StartedAt = now
|
|
}
|
|
s.enrollments[row.ID] = row
|
|
return row
|
|
}
|
|
|
|
// UpdateEnrollment partially updates an enrollment. Supported keys: Status,
|
|
// Progress, CompletedAt, CertificateID, Deadline.
|
|
func (s *AcademyMemStore) UpdateEnrollment(id string, updates map[string]interface{}) (*AcademyEnrollmentRow, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
e, ok := s.enrollments[id]
|
|
if !ok {
|
|
return nil, fmt.Errorf("enrollment not found: %s", id)
|
|
}
|
|
|
|
for k, v := range updates {
|
|
switch strings.ToLower(k) {
|
|
case "status":
|
|
if val, ok := v.(string); ok {
|
|
e.Status = val
|
|
}
|
|
case "progress":
|
|
switch val := v.(type) {
|
|
case int:
|
|
e.Progress = val
|
|
case float64:
|
|
e.Progress = int(val)
|
|
}
|
|
case "completedat", "completed_at":
|
|
if val, ok := v.(*time.Time); ok {
|
|
e.CompletedAt = val
|
|
} else if val, ok := v.(time.Time); ok {
|
|
e.CompletedAt = &val
|
|
}
|
|
case "certificateid", "certificate_id":
|
|
if val, ok := v.(string); ok {
|
|
e.CertificateID = val
|
|
}
|
|
case "deadline":
|
|
if val, ok := v.(time.Time); ok {
|
|
e.Deadline = val
|
|
}
|
|
}
|
|
}
|
|
|
|
e.UpdatedAt = time.Now()
|
|
return e, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Certificates
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// GetCertificate retrieves a certificate by ID.
|
|
func (s *AcademyMemStore) GetCertificate(id string) (*AcademyCertificateRow, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
cert, ok := s.certificates[id]
|
|
if !ok {
|
|
return nil, fmt.Errorf("certificate not found: %s", id)
|
|
}
|
|
return cert, nil
|
|
}
|
|
|
|
// GetCertificateByEnrollment retrieves a certificate by enrollment ID.
|
|
func (s *AcademyMemStore) GetCertificateByEnrollment(enrollmentID string) (*AcademyCertificateRow, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
for _, cert := range s.certificates {
|
|
if cert.EnrollmentID == enrollmentID {
|
|
return cert, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("certificate not found for enrollment: %s", enrollmentID)
|
|
}
|
|
|
|
// CreateCertificate inserts a new certificate with auto-generated ID.
|
|
func (s *AcademyMemStore) CreateCertificate(row *AcademyCertificateRow) *AcademyCertificateRow {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
row.ID = generateID()
|
|
if row.IssuedAt.IsZero() {
|
|
row.IssuedAt = time.Now()
|
|
}
|
|
s.certificates[row.ID] = row
|
|
return row
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lesson Progress
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ListLessonProgress returns all progress entries for an enrollment.
|
|
func (s *AcademyMemStore) ListLessonProgress(enrollmentID string) []*AcademyLessonProgressRow {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
var result []*AcademyLessonProgressRow
|
|
for _, p := range s.lessonProgress {
|
|
if p.EnrollmentID == enrollmentID {
|
|
result = append(result, p)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// UpsertLessonProgress inserts or updates a lesson progress entry.
|
|
// Matching is done by EnrollmentID + LessonID composite key.
|
|
func (s *AcademyMemStore) UpsertLessonProgress(row *AcademyLessonProgressRow) *AcademyLessonProgressRow {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Look for existing entry with same enrollment_id + lesson_id
|
|
for _, p := range s.lessonProgress {
|
|
if p.EnrollmentID == row.EnrollmentID && p.LessonID == row.LessonID {
|
|
p.Completed = row.Completed
|
|
p.QuizScore = row.QuizScore
|
|
p.CompletedAt = row.CompletedAt
|
|
return p
|
|
}
|
|
}
|
|
|
|
// Insert new entry
|
|
row.ID = generateID()
|
|
s.lessonProgress[row.ID] = row
|
|
return row
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Statistics
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// GetStatistics computes aggregate statistics for a tenant.
|
|
func (s *AcademyMemStore) GetStatistics(tenantID string) *AcademyStatisticsRow {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
stats := &AcademyStatisticsRow{
|
|
ByCategory: make(map[string]int),
|
|
ByStatus: make(map[string]int),
|
|
}
|
|
|
|
// Count courses by category
|
|
for _, c := range s.courses {
|
|
if c.TenantID != tenantID {
|
|
continue
|
|
}
|
|
stats.TotalCourses++
|
|
if c.Category != "" {
|
|
stats.ByCategory[c.Category]++
|
|
}
|
|
}
|
|
|
|
// Count enrollments and compute completion rate
|
|
var completedCount int
|
|
now := time.Now()
|
|
for _, e := range s.enrollments {
|
|
if e.TenantID != tenantID {
|
|
continue
|
|
}
|
|
stats.TotalEnrollments++
|
|
stats.ByStatus[e.Status]++
|
|
|
|
if e.Status == "completed" {
|
|
completedCount++
|
|
}
|
|
|
|
// Overdue: not completed and past deadline
|
|
if e.Status != "completed" && !e.Deadline.IsZero() && now.After(e.Deadline) {
|
|
stats.OverdueCount++
|
|
}
|
|
}
|
|
|
|
if stats.TotalEnrollments > 0 {
|
|
stats.CompletionRate = float64(completedCount) / float64(stats.TotalEnrollments) * 100.0
|
|
}
|
|
|
|
return stats
|
|
}
|