Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 0 additions & 28 deletions SESSION_STATE.md

This file was deleted.

186 changes: 155 additions & 31 deletions backend/src/database/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,68 +4,192 @@ import (
"UnlockEdv2/src/models"
)

func (db *DB) GetClassDashboardMetrics(ctx *models.QueryContext, facilityID *uint) (int64, int64, int64, int64, error) {
var activeClasses int64
var totalEnrollments int64
var totalSeats int64
var attendanceConcerns int64
func (db *DB) GetClassDashboardMetrics(ctx *models.QueryContext, facilityID *uint) (models.ClassDashboardMetrics, error) {
var metrics models.ClassDashboardMetrics

var err error
if metrics.ActiveClasses, err = db.GetActiveClassCount(ctx, facilityID); err != nil {
return metrics, err
}
if metrics.TotalSeats, err = db.GetTotalSeatCount(ctx, facilityID); err != nil {
return metrics, err
}
if metrics.ScheduledClasses, err = db.GetScheduledClassCount(ctx, facilityID); err != nil {
return metrics, err
}
if metrics.TotalEnrollments, err = db.GetTotalEnrollmentsCount(ctx, facilityID); err != nil {
return metrics, err
}
if metrics.AttendanceConcerns, err = db.GetAttendanceConcernsCount(ctx, facilityID); err != nil {
return metrics, err
}

classQuery := db.WithContext(ctx.Ctx).
return metrics, nil
}

func (db *DB) GetActiveClassCount(ctx *models.QueryContext, facilityID *uint) (int64, error) {
var count int64
query := db.WithContext(ctx.Ctx).
Model(&models.ProgramClass{}).
Where("status = ?", models.Active).
Where("archived_at IS NULL")
if facilityID != nil {
classQuery = classQuery.Where("facility_id = ?", *facilityID)
query = query.Where("facility_id = ?", *facilityID)
}
if err := query.Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}

if err := classQuery.Count(&activeClasses).Error; err != nil {
return 0, 0, 0, 0, err
func (db *DB) GetTotalSeatCount(ctx *models.QueryContext, facilityID *uint) (int64, error) {
var totalSeats int64
query := db.WithContext(ctx.Ctx).
Model(&models.ProgramClass{}).
Where("status = ?", models.Active).
Where("archived_at IS NULL")
if facilityID != nil {
query = query.Where("facility_id = ?", *facilityID)
}
if err := query.Select("COALESCE(SUM(capacity), 0)").Scan(&totalSeats).Error; err != nil {
return 0, err
}
return totalSeats, nil
}

if err := classQuery.Select("COALESCE(SUM(capacity), 0)").Scan(&totalSeats).Error; err != nil {
return 0, 0, 0, 0, err
func (db *DB) GetScheduledClassCount(ctx *models.QueryContext, facilityID *uint) (int64, error) {
var count int64
query := db.WithContext(ctx.Ctx).
Model(&models.ProgramClass{}).
Where("status = ?", models.Scheduled).
Where("archived_at IS NULL")
if facilityID != nil {
query = query.Where("facility_id = ?", *facilityID)
}
if err := query.Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}

enrollmentQuery := db.WithContext(ctx.Ctx).
func (db *DB) GetTotalEnrollmentsCount(ctx *models.QueryContext, facilityID *uint) (int64, error) {
var count int64
query := db.WithContext(ctx.Ctx).
Table("program_class_enrollments e").
Select("COUNT(*)").
Joins("JOIN program_classes c ON c.id = e.class_id").
Where("e.enrollment_status = ?", models.Enrolled).
Where("c.status = ?", models.Active).
Where("c.archived_at IS NULL")
if facilityID != nil {
enrollmentQuery = enrollmentQuery.Where("c.facility_id = ?", *facilityID)
query = query.Where("c.facility_id = ?", *facilityID)
}
if err := enrollmentQuery.Scan(&totalEnrollments).Error; err != nil {
return 0, 0, 0, 0, err
if err := query.Scan(&count).Error; err != nil {
return 0, err
}
return count, nil
}

attendanceSQL := `
SELECT COUNT(DISTINCT c.id)
FROM program_classes c
JOIN program_class_enrollments e ON e.class_id = c.id
WHERE c.status = ?
AND c.archived_at IS NULL
AND e.enrollment_status = ?
`
func (db *DB) GetAttendanceConcernsCount(ctx *models.QueryContext, facilityID *uint) (int64, error) {
attendanceSQL := `SELECT COUNT(DISTINCT c.id)
FROM program_classes c
JOIN program_class_enrollments e ON e.class_id = c.id
WHERE c.status = ?
AND c.archived_at IS NULL
AND e.enrollment_status = ? `
args := []any{models.Active, models.Enrolled}
if facilityID != nil {
attendanceSQL += "AND c.facility_id = ?\n"
attendanceSQL += "AND c.facility_id = ? "
args = append(args, *facilityID)
}
attendanceSQL += `
AND (
attendanceSQL += ` AND (
select count(*)
from program_class_events evt
inner join program_class_event_attendance att on att.event_id = evt.id
where evt.class_id = c.id
and att.user_id = e.user_id
and att.attendance_status = 'absent_unexcused'
) >= 3
`
if err := db.WithContext(ctx.Ctx).Raw(attendanceSQL, args...).Scan(&attendanceConcerns).Error; err != nil {
return 0, 0, 0, 0, err
) >= 3 `
var count int64
if err := db.WithContext(ctx.Ctx).Raw(attendanceSQL, args...).Scan(&count).Error; err != nil {
return 0, err
}
return count, nil
}

return activeClasses, totalEnrollments, totalSeats, attendanceConcerns, nil
type facilityCount struct {
FacilityID uint `gorm:"column:facility_id"`
Count int64 `gorm:"column:count"`
}

func (db *DB) GetFacilityHealthSummaries(ctx *models.QueryContext, facilityID *uint) ([]models.FacilityHealthSummary, error) {
var summaries []models.FacilityHealthSummary
query := `SELECT
f.id as facility_id,
f.name as facility_name,
COALESCE(COUNT(DISTINCT p.id), 0) as programs,
COALESCE(COUNT(DISTINCT pc.id), 0) as active_classes,
COALESCE(COUNT(DISTINCT pce.id), 0) as enrollment
FROM facilities f
LEFT JOIN facilities_programs fp
ON fp.facility_id = f.id
AND fp.deleted_at IS NULL
LEFT JOIN programs p
ON p.id = fp.program_id
AND p.is_active = true
AND p.deleted_at IS NULL
AND p.archived_at IS NULL
LEFT JOIN program_classes pc
ON pc.facility_id = f.id
AND pc.status = ?
AND pc.archived_at IS NULL
LEFT JOIN program_class_enrollments pce
ON pce.class_id = pc.id
AND pce.enrollment_status = ?
WHERE f.deleted_at IS NULL `
args := []any{models.Active, models.Enrolled}
if facilityID != nil {
query += " AND f.id = ? "
args = append(args, *facilityID)
}
query += "GROUP BY f.id, f.name ORDER BY f.name"

if err := db.WithContext(ctx.Ctx).Raw(query, args...).Scan(&summaries).Error; err != nil {
return nil, err
}
return summaries, nil
}

func (db *DB) GetFacilityAttendanceConcerns(ctx *models.QueryContext, facilityID *uint) (map[uint]int64, error) {
attendanceSQL := `SELECT c.facility_id as facility_id, COUNT(DISTINCT c.id) as count
FROM program_classes c
JOIN program_class_enrollments e ON e.class_id = c.id
WHERE c.status = ?
AND c.archived_at IS NULL
AND e.enrollment_status = ? `
args := []any{models.Active, models.Enrolled}
if facilityID != nil {
attendanceSQL += "AND c.facility_id = ? "
args = append(args, *facilityID)
}
attendanceSQL += ` AND (
select count(*)
from program_class_events evt
inner join program_class_event_attendance att on att.event_id = evt.id
where evt.class_id = c.id
and att.user_id = e.user_id
and att.attendance_status = 'absent_unexcused'
) >= 3
GROUP BY c.facility_id`

var counts []facilityCount
if err := db.WithContext(ctx.Ctx).Raw(attendanceSQL, args...).Scan(&counts).Error; err != nil {
return nil, err
}

results := make(map[uint]int64, len(counts))
for _, entry := range counts {
results[entry.FacilityID] = entry.Count
}
return results, nil
}
2 changes: 1 addition & 1 deletion backend/src/database/events_attendance.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ func (db *DB) GetActiveClassesForMissingAttendance(args *models.QueryContext, fa
var missClasses []models.MissingAttendanceClass
classQuery := db.WithContext(args.Ctx).
Table("program_classes c").
Select("c.id, c.name, f.name as facility_name").
Select("c.id, c.name, f.name as facility_name, c.facility_id").
Joins("JOIN facilities f ON f.id = c.facility_id").
Where("c.status = ?", models.Active).
Where("c.archived_at IS NULL")
Expand Down
9 changes: 8 additions & 1 deletion backend/src/database/program_classes.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,15 @@ func (db *DB) GetClassByID(id int) (*models.ProgramClass, error) {
}

func (db *DB) GetClassesForFacility(args *models.QueryContext) ([]models.ProgramClass, error) {
return db.GetClasses(args, &args.FacilityID)
}

func (db *DB) GetClasses(args *models.QueryContext, facilityID *uint) ([]models.ProgramClass, error) {
content := []models.ProgramClass{}
tx := db.WithContext(args.Ctx).Model(&models.ProgramClass{}).Where("facility_id = ?", args.FacilityID)
tx := db.WithContext(args.Ctx).Model(&models.ProgramClass{})
if facilityID != nil {
tx = tx.Where("facility_id = ?", *facilityID)
}
if args.Search != "" {
tx = tx.Where("LOWER(name) LIKE ?", args.SearchQuery())
}
Expand Down
10 changes: 9 additions & 1 deletion backend/src/handlers/classes_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,15 @@ func (srv *Server) handleGetClass(w http.ResponseWriter, r *http.Request, log sL

func (srv *Server) handleIndexClassesForFacility(w http.ResponseWriter, r *http.Request, log sLog) error {
args := srv.getQueryContext(r)
classes, err := srv.Db.GetClassesForFacility(&args)
claims := r.Context().Value(ClaimsKey).(*Claims)
facilityParam := r.URL.Query().Get("facility")
var facilityID *uint
if facilityParam == "all" && claims.canSwitchFacility() {
facilityID = nil
} else {
facilityID = &args.FacilityID
}
classes, err := srv.Db.GetClasses(&args, facilityID)
if err != nil {
return newDatabaseServiceError(err)
}
Expand Down
48 changes: 29 additions & 19 deletions backend/src/handlers/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package handlers

import (
"UnlockEdv2/src/models"
"UnlockEdv2/src/services"
"encoding/json"
"errors"
"fmt"
Expand All @@ -19,6 +20,7 @@ func (srv *Server) registerDashboardRoutes() []routeDef {
return []routeDef{
newAdminRoute("GET /api/department-metrics", srv.handleDepartmentMetrics),
newAdminRoute("GET /api/dashboard/class-metrics", srv.handleClassDashboardMetrics),
newAdminRoute("GET /api/dashboard/facility-health", srv.handleFacilityHealthSummary),
newAdminRoute("GET /api/users/{id}/admin-layer2", srv.handleAdminLayer2),
validatedFeatureRoute("GET /api/users/{id}/catalog", srv.handleUserCatalog, axx, resolver),
validatedFeatureRoute("GET /api/users/{id}/courses", srv.handleUserCourses, axx, resolver),
Expand Down Expand Up @@ -133,13 +135,6 @@ type DashboardMetrics struct {
PeakLoginTimes []models.LoginActivity `json:"peak_login_times"`
}

type ClassDashboardMetrics struct {
ActiveClasses int64 `json:"active_classes"`
TotalEnrollments int64 `json:"total_enrollments"`
TotalSeats int64 `json:"total_seats"`
AttendanceConcerns int64 `json:"attendance_concerns"`
}

func (srv *Server) handleDepartmentMetrics(w http.ResponseWriter, r *http.Request, log sLog) error {
args := srv.getQueryContext(r)
facility := r.URL.Query().Get("facility")
Expand Down Expand Up @@ -256,24 +251,39 @@ func (srv *Server) handleClassDashboardMetrics(w http.ResponseWriter, r *http.Re
facilityId = &args.FacilityID
}

activeClasses, totalEnrollments, totalSeats, attendanceConcerns, err := srv.Db.GetClassDashboardMetrics(&args, facilityId)
metrics, err := srv.Db.GetClassDashboardMetrics(&args, facilityId)
if err != nil {
return newDatabaseServiceError(err)
}

response := struct {
ActiveClasses int64 `json:"active_classes"`
TotalEnrollments int64 `json:"total_enrollments"`
TotalSeats int64 `json:"total_seats"`
AttendanceConcerns int64 `json:"attendance_concerns"`
}{
ActiveClasses: activeClasses,
TotalEnrollments: totalEnrollments,
TotalSeats: totalSeats,
AttendanceConcerns: attendanceConcerns,
return writeJsonResponse(w, http.StatusOK, metrics)
}

func (srv *Server) handleFacilityHealthSummary(w http.ResponseWriter, r *http.Request, log sLog) error {
args := srv.getQueryContext(r)
claims := r.Context().Value(ClaimsKey).(*Claims)
facility := r.URL.Query().Get("facility")
var facilityID *uint
if facility == "all" && claims.canSwitchFacility() {
facilityID = nil
} else {
facilityID = &args.FacilityID
}

return writeJsonResponse(w, http.StatusOK, response)
days := 3
if daysQuery := r.URL.Query().Get("days"); daysQuery != "" {
if parsedDays, err := strconv.Atoi(daysQuery); err == nil {
days = parsedDays
}
}

service := services.NewClassesService(srv.Db)
summaries, err := service.GetFacilityHealthSummaries(&args, facilityID, days)
if err != nil {
return newDatabaseServiceError(err)
}

return writeJsonResponse(w, http.StatusOK, summaries)
}

/**
Expand Down
1 change: 1 addition & 0 deletions backend/src/models/class_event.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ type MissingAttendanceClass struct {
ID uint `json:"id"`
Name string `json:"name"`
FacilityName string `json:"facility_name"`
FacilityID uint `json:"facility_id"`
}

type EventDates struct {
Expand Down
Loading
Loading