Skip to content

Commit 0a8c319

Browse files
authored
feat: learning insights dashboard
1 parent 423720b commit 0a8c319

File tree

8 files changed

+344
-117
lines changed

8 files changed

+344
-117
lines changed

backend/src/database/activity.go

+86
Original file line numberDiff line numberDiff line change
@@ -356,3 +356,89 @@ func (db *DB) GetAdminDashboardInfo(facilityID uint) (models.AdminDashboardJoin,
356356

357357
return dashboard, nil
358358
}
359+
func (db *DB) GetTotalCoursesOffered(facilityID *uint) (int, error) {
360+
var totalCourses int
361+
subQry := db.Table("courses c").
362+
Select("COUNT(DISTINCT c.id) as total_courses_offered").
363+
Joins("INNER JOIN user_enrollments ue on c.id = ue.course_id").
364+
Joins("INNER JOIN users u on ue.user_id = u.id")
365+
366+
if facilityID != nil {
367+
subQry = subQry.Where("u.facility_id = ?", facilityID)
368+
}
369+
370+
err := subQry.Find(&totalCourses).Error
371+
if err != nil {
372+
return 0, NewDBError(err, "error getting total courses offered")
373+
}
374+
return totalCourses, nil
375+
}
376+
377+
func (db *DB) GetTotalStudentsEnrolled(facilityID *uint) (int, error) {
378+
var totalStudents int
379+
query := db.Table("user_enrollments ue").
380+
Select("COUNT(DISTINCT ue.user_id) AS students_enrolled").
381+
Joins("INNER JOIN users u on ue.user_id = u.id")
382+
383+
if facilityID != nil {
384+
query = query.Where("u.facility_id = ?", facilityID)
385+
}
386+
387+
err := query.Scan(&totalStudents).Error
388+
if err != nil {
389+
return 0, NewDBError(err, "error getting total students enrolled")
390+
}
391+
return totalStudents, nil
392+
}
393+
394+
func (db *DB) GetTotalHourlyActivity(facilityID *uint) (int, error) {
395+
var totalActivity int
396+
subQry := db.Table("users u").
397+
Select("CASE WHEN SUM(a.total_time) IS NULL THEN 0 ELSE ROUND(SUM(a.total_time)/3600, 0) END AS total_time").
398+
Joins("LEFT JOIN activities a ON u.id = a.user_id").
399+
Where("u.role = ?", "student")
400+
401+
if facilityID != nil {
402+
subQry = subQry.Where("u.facility_id = ?", facilityID)
403+
}
404+
405+
err := subQry.Scan(&totalActivity).Error
406+
if err != nil {
407+
return 0, NewDBError(err, "error getting total hourly activity")
408+
}
409+
return totalActivity, nil
410+
}
411+
412+
func (db *DB) GetLearningInsights(facilityID *uint) ([]models.LearningInsight, error) {
413+
var insights []models.LearningInsight
414+
subQry := db.Table("outcomes o").
415+
Select("o.course_id, COUNT(o.id) AS outcome_count").
416+
Group("o.course_id")
417+
418+
subQry2 := db.Table("courses c").
419+
Select(`
420+
c.name AS course_name,
421+
COUNT(DISTINCT u.id) AS total_students_enrolled,
422+
CASE
423+
WHEN MAX(subqry.outcome_count) > 0 THEN
424+
COUNT(DISTINCT u.id) / NULLIF(CAST(MAX(c.total_progress_milestones) AS float), 0) * 100.0
425+
ELSE 0
426+
END AS completion_rate,
427+
COALESCE(ROUND(SUM(a.total_time) / 3600, 0), 0) AS activity_hours
428+
`).
429+
Joins("LEFT JOIN milestones m ON m.course_id = c.id").
430+
Joins("LEFT JOIN users u ON m.user_id = u.id").
431+
Joins("LEFT JOIN activities a ON u.id = a.user_id").
432+
Joins("INNER JOIN (?) AS subqry ON m.course_id = subqry.course_id", subQry).
433+
Where("u.role = ?", "student")
434+
435+
if facilityID != nil {
436+
subQry2 = subQry2.Where("u.facility_id = ?", facilityID)
437+
}
438+
439+
err := subQry2.Group("c.name, c.total_progress_milestones").Find(&insights).Error
440+
if err != nil {
441+
return nil, NewDBError(err, "error getting learning insights")
442+
}
443+
return insights, nil
444+
}

backend/src/database/helpful_links.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func (db *DB) GetHelpfulLinks(page, perPage int, search, orderBy string, onlyVis
5252

5353
func (db *DB) AddHelpfulLink(link *models.HelpfulLink) error {
5454
if db.Where("url = ?", link.Url).First(&models.HelpfulLink{}).RowsAffected > 0 {
55-
return NewDBError(fmt.Errorf("Link already exists"), "helpful_links")
55+
return NewDBError(fmt.Errorf("link already exists"), "helpful_links")
5656
}
5757
if err := db.Create(link).Error; err != nil {
5858
return newCreateDBError(err, "helpful_links")

backend/src/database/seed_demo.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77
"time"
88

99
"github.com/google/uuid"
10-
"github.com/sirupsen/logrus"
10+
// "github.com/sirupsen/logrus"
1111
log "github.com/sirupsen/logrus"
1212
)
1313

@@ -86,7 +86,7 @@ func (db *DB) RunDemoSeed(facilityId uint) error {
8686
}) {
8787
if err := db.Exec("INSERT INTO user_enrollments (user_id, course_id, external_id, created_at) VALUES (?, ?, ?, ?)",
8888
user.ID, course.ID, uuid.NewString(), startDate).Error; err != nil {
89-
logrus.Println(err)
89+
log.Println(err)
9090
}
9191
}
9292
daysSinceStart := int(time.Since(*startDate).Hours() / 24)

backend/src/handlers/dashboard.go

+54
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ func (srv *Server) registerDashboardRoutes() []routeDef {
1818
{"GET /api/login-metrics", srv.handleLoginMetrics, true, models.Feature()},
1919
{"GET /api/users/{id}/student-dashboard", srv.handleStudentDashboard, false, models.Feature()},
2020
{"GET /api/users/{id}/admin-dashboard", srv.handleAdminDashboard, true, models.Feature()},
21+
{"GET /api/users/{id}/admin-layer2", srv.handleAdminLayer2, true, models.Feature()},
2122
{"GET /api/users/{id}/catalog", srv.handleUserCatalog, false, axx},
2223
{"GET /api/users/{id}/courses", srv.handleUserCourses, false, axx},
2324
}
@@ -48,6 +49,59 @@ func (srv *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request,
4849
return writeJsonResponse(w, http.StatusOK, adminDashboard)
4950
}
5051

52+
func (srv *Server) handleAdminLayer2(w http.ResponseWriter, r *http.Request, log sLog) error {
53+
facility := r.URL.Query().Get("facility")
54+
claims := r.Context().Value(ClaimsKey).(*Claims)
55+
var facilityId *uint
56+
57+
switch facility {
58+
case "all":
59+
facilityId = nil
60+
case "":
61+
facilityId = &claims.FacilityID
62+
default:
63+
facilityIdInt, err := strconv.Atoi(facility)
64+
if err != nil {
65+
return newInvalidIdServiceError(err, "facility")
66+
}
67+
ref := uint(facilityIdInt)
68+
facilityId = &ref
69+
}
70+
71+
totalCourses, err := srv.Db.GetTotalCoursesOffered(facilityId)
72+
if err != nil {
73+
log.add("facilityId", claims.FacilityID)
74+
return newDatabaseServiceError(err)
75+
}
76+
77+
totalStudents, err := srv.Db.GetTotalStudentsEnrolled(facilityId)
78+
if err != nil {
79+
log.add("facilityId", claims.FacilityID)
80+
return newDatabaseServiceError(err)
81+
}
82+
83+
totalActivity, err := srv.Db.GetTotalHourlyActivity(facilityId)
84+
if err != nil {
85+
log.add("facilityId", claims.FacilityID)
86+
return newDatabaseServiceError(err)
87+
}
88+
89+
learningInsights, err := srv.Db.GetLearningInsights(facilityId)
90+
if err != nil {
91+
log.add("facilityId", claims.FacilityID)
92+
return newDatabaseServiceError(err)
93+
}
94+
95+
adminDashboard := models.AdminLayer2Join{
96+
TotalCoursesOffered: int64(totalCourses),
97+
TotalStudentsEnrolled: int64(totalStudents),
98+
TotalHourlyActivity: int64(totalActivity),
99+
LearningInsights: learningInsights,
100+
}
101+
102+
return writeJsonResponse(w, http.StatusOK, adminDashboard)
103+
}
104+
51105
func (srv *Server) handleLoginMetrics(w http.ResponseWriter, r *http.Request, log sLog) error {
52106
facility := r.URL.Query().Get("facility")
53107
claims := r.Context().Value(ClaimsKey).(*Claims)

backend/src/handlers/dashboard_test.go

+54
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,60 @@ func TestHandleAdminDashboard(t *testing.T) {
102102
}
103103
}
104104

105+
func TestHandleAdminLayer2(t *testing.T) {
106+
httpTests := []httpTest{
107+
{"TestAdminDashboardAsAdmin", "admin", map[string]any{"id": "1"}, http.StatusOK, ""},
108+
{"TestAdminDashboardAsUser", "student", map[string]any{"id": "4"}, http.StatusUnauthorized, ""},
109+
}
110+
for _, test := range httpTests {
111+
t.Run(test.testName, func(t *testing.T) {
112+
req, err := http.NewRequest(http.MethodGet, "/api/users/{id}/admin-layer2", nil)
113+
if err != nil {
114+
t.Fatalf("unable to create new request, error is %v", err)
115+
}
116+
req.SetPathValue("id", test.mapKeyValues["id"].(string))
117+
handler := getHandlerByRoleWithMiddleware(server.handleAdminLayer2, test.role)
118+
rr := executeRequest(t, req, handler, test)
119+
id, _ := strconv.Atoi(test.mapKeyValues["id"].(string))
120+
if test.expectedStatusCode == http.StatusOK {
121+
122+
id := uint(id)
123+
totalCourses, err := server.Db.GetTotalCoursesOffered(&id)
124+
if err != nil {
125+
t.Fatalf("unable to get total courses offered, error is %v", err)
126+
}
127+
totalStudents, err := server.Db.GetTotalStudentsEnrolled(&id)
128+
if err != nil {
129+
t.Fatalf("unable to get total students enrolled, error is %v", err)
130+
}
131+
totalActivity, err := server.Db.GetTotalHourlyActivity(&id)
132+
if err != nil {
133+
t.Fatalf("unable to get total hourly activity, error is %v", err)
134+
}
135+
learningInsights, err := server.Db.GetLearningInsights(&id)
136+
if err != nil {
137+
t.Fatalf("unable to get learning insights, error is %v", err)
138+
}
139+
adminDashboard := models.AdminLayer2Join{
140+
TotalCoursesOffered: int64(totalCourses),
141+
TotalStudentsEnrolled: int64(totalStudents),
142+
TotalHourlyActivity: int64(totalActivity),
143+
LearningInsights: learningInsights,
144+
}
145+
146+
received := rr.Body.String()
147+
resource := models.Resource[models.AdminLayer2Join]{}
148+
if err := json.Unmarshal([]byte(received), &resource); err != nil {
149+
t.Errorf("failed to unmarshal resource, error is %v", err)
150+
}
151+
if diff := cmp.Diff(&adminDashboard, &resource.Data); diff != "" {
152+
t.Errorf("handler returned unexpected response body: %v", diff)
153+
}
154+
}
155+
})
156+
}
157+
}
158+
105159
func TestHandleUserCatalog(t *testing.T) {
106160
httpTests := []httpTest{
107161
{"TestGetAllUserCatalogAsAdmin", "admin", getUserCatalogSearch(4, nil, "", ""), http.StatusOK, ""},

backend/src/models/course.go

+14
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,20 @@ type AdminDashboardJoin struct {
103103
TopCourseActivity []CourseActivity `json:"top_course_activity"`
104104
}
105105

106+
type LearningInsight struct {
107+
CourseName string `json:"course_name"`
108+
TotalStudentsEnrolled int64 `json:"total_students_enrolled"`
109+
CompletionRate float32 `json:"completion_rate"`
110+
ActivityHours int64 `json:"activity_hours"`
111+
}
112+
113+
type AdminLayer2Join struct {
114+
TotalCoursesOffered int64 `json:"total_courses_offered"`
115+
TotalStudentsEnrolled int64 `json:"total_students_enrolled"`
116+
TotalHourlyActivity int64 `json:"total_hourly_activity"`
117+
LearningInsights []LearningInsight `json:"learning_insights"`
118+
}
119+
106120
type CourseMilestones struct {
107121
Name string `json:"name"`
108122
Milestones int `json:"milestones"`

0 commit comments

Comments
 (0)