Skip to content

Commit

Permalink
feat: learning insights dashboard
Browse files Browse the repository at this point in the history
  • Loading branch information
corypride authored Jan 13, 2025
1 parent 423720b commit 0a8c319
Show file tree
Hide file tree
Showing 8 changed files with 344 additions and 117 deletions.
86 changes: 86 additions & 0 deletions backend/src/database/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,3 +356,89 @@ func (db *DB) GetAdminDashboardInfo(facilityID uint) (models.AdminDashboardJoin,

return dashboard, nil
}
func (db *DB) GetTotalCoursesOffered(facilityID *uint) (int, error) {
var totalCourses int
subQry := db.Table("courses c").
Select("COUNT(DISTINCT c.id) as total_courses_offered").
Joins("INNER JOIN user_enrollments ue on c.id = ue.course_id").
Joins("INNER JOIN users u on ue.user_id = u.id")

if facilityID != nil {
subQry = subQry.Where("u.facility_id = ?", facilityID)
}

err := subQry.Find(&totalCourses).Error
if err != nil {
return 0, NewDBError(err, "error getting total courses offered")
}
return totalCourses, nil
}

func (db *DB) GetTotalStudentsEnrolled(facilityID *uint) (int, error) {
var totalStudents int
query := db.Table("user_enrollments ue").
Select("COUNT(DISTINCT ue.user_id) AS students_enrolled").
Joins("INNER JOIN users u on ue.user_id = u.id")

if facilityID != nil {
query = query.Where("u.facility_id = ?", facilityID)
}

err := query.Scan(&totalStudents).Error
if err != nil {
return 0, NewDBError(err, "error getting total students enrolled")
}
return totalStudents, nil
}

func (db *DB) GetTotalHourlyActivity(facilityID *uint) (int, error) {
var totalActivity int
subQry := db.Table("users u").
Select("CASE WHEN SUM(a.total_time) IS NULL THEN 0 ELSE ROUND(SUM(a.total_time)/3600, 0) END AS total_time").
Joins("LEFT JOIN activities a ON u.id = a.user_id").
Where("u.role = ?", "student")

if facilityID != nil {
subQry = subQry.Where("u.facility_id = ?", facilityID)
}

err := subQry.Scan(&totalActivity).Error
if err != nil {
return 0, NewDBError(err, "error getting total hourly activity")
}
return totalActivity, nil
}

func (db *DB) GetLearningInsights(facilityID *uint) ([]models.LearningInsight, error) {
var insights []models.LearningInsight
subQry := db.Table("outcomes o").
Select("o.course_id, COUNT(o.id) AS outcome_count").
Group("o.course_id")

subQry2 := db.Table("courses c").
Select(`
c.name AS course_name,
COUNT(DISTINCT u.id) AS total_students_enrolled,
CASE
WHEN MAX(subqry.outcome_count) > 0 THEN
COUNT(DISTINCT u.id) / NULLIF(CAST(MAX(c.total_progress_milestones) AS float), 0) * 100.0
ELSE 0
END AS completion_rate,
COALESCE(ROUND(SUM(a.total_time) / 3600, 0), 0) AS activity_hours
`).
Joins("LEFT JOIN milestones m ON m.course_id = c.id").
Joins("LEFT JOIN users u ON m.user_id = u.id").
Joins("LEFT JOIN activities a ON u.id = a.user_id").
Joins("INNER JOIN (?) AS subqry ON m.course_id = subqry.course_id", subQry).
Where("u.role = ?", "student")

if facilityID != nil {
subQry2 = subQry2.Where("u.facility_id = ?", facilityID)
}

err := subQry2.Group("c.name, c.total_progress_milestones").Find(&insights).Error
if err != nil {
return nil, NewDBError(err, "error getting learning insights")
}
return insights, nil
}
2 changes: 1 addition & 1 deletion backend/src/database/helpful_links.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func (db *DB) GetHelpfulLinks(page, perPage int, search, orderBy string, onlyVis

func (db *DB) AddHelpfulLink(link *models.HelpfulLink) error {
if db.Where("url = ?", link.Url).First(&models.HelpfulLink{}).RowsAffected > 0 {
return NewDBError(fmt.Errorf("Link already exists"), "helpful_links")
return NewDBError(fmt.Errorf("link already exists"), "helpful_links")
}
if err := db.Create(link).Error; err != nil {
return newCreateDBError(err, "helpful_links")
Expand Down
4 changes: 2 additions & 2 deletions backend/src/database/seed_demo.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"time"

"github.com/google/uuid"
"github.com/sirupsen/logrus"
// "github.com/sirupsen/logrus"
log "github.com/sirupsen/logrus"
)

Expand Down Expand Up @@ -86,7 +86,7 @@ func (db *DB) RunDemoSeed(facilityId uint) error {
}) {
if err := db.Exec("INSERT INTO user_enrollments (user_id, course_id, external_id, created_at) VALUES (?, ?, ?, ?)",
user.ID, course.ID, uuid.NewString(), startDate).Error; err != nil {
logrus.Println(err)
log.Println(err)
}
}
daysSinceStart := int(time.Since(*startDate).Hours() / 24)
Expand Down
54 changes: 54 additions & 0 deletions backend/src/handlers/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func (srv *Server) registerDashboardRoutes() []routeDef {
{"GET /api/login-metrics", srv.handleLoginMetrics, true, models.Feature()},
{"GET /api/users/{id}/student-dashboard", srv.handleStudentDashboard, false, models.Feature()},
{"GET /api/users/{id}/admin-dashboard", srv.handleAdminDashboard, true, models.Feature()},
{"GET /api/users/{id}/admin-layer2", srv.handleAdminLayer2, true, models.Feature()},
{"GET /api/users/{id}/catalog", srv.handleUserCatalog, false, axx},
{"GET /api/users/{id}/courses", srv.handleUserCourses, false, axx},
}
Expand Down Expand Up @@ -48,6 +49,59 @@ func (srv *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request,
return writeJsonResponse(w, http.StatusOK, adminDashboard)
}

func (srv *Server) handleAdminLayer2(w http.ResponseWriter, r *http.Request, log sLog) error {
facility := r.URL.Query().Get("facility")
claims := r.Context().Value(ClaimsKey).(*Claims)
var facilityId *uint

switch facility {
case "all":
facilityId = nil
case "":
facilityId = &claims.FacilityID
default:
facilityIdInt, err := strconv.Atoi(facility)
if err != nil {
return newInvalidIdServiceError(err, "facility")
}
ref := uint(facilityIdInt)
facilityId = &ref
}

totalCourses, err := srv.Db.GetTotalCoursesOffered(facilityId)
if err != nil {
log.add("facilityId", claims.FacilityID)
return newDatabaseServiceError(err)
}

totalStudents, err := srv.Db.GetTotalStudentsEnrolled(facilityId)
if err != nil {
log.add("facilityId", claims.FacilityID)
return newDatabaseServiceError(err)
}

totalActivity, err := srv.Db.GetTotalHourlyActivity(facilityId)
if err != nil {
log.add("facilityId", claims.FacilityID)
return newDatabaseServiceError(err)
}

learningInsights, err := srv.Db.GetLearningInsights(facilityId)
if err != nil {
log.add("facilityId", claims.FacilityID)
return newDatabaseServiceError(err)
}

adminDashboard := models.AdminLayer2Join{
TotalCoursesOffered: int64(totalCourses),
TotalStudentsEnrolled: int64(totalStudents),
TotalHourlyActivity: int64(totalActivity),
LearningInsights: learningInsights,
}

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

func (srv *Server) handleLoginMetrics(w http.ResponseWriter, r *http.Request, log sLog) error {
facility := r.URL.Query().Get("facility")
claims := r.Context().Value(ClaimsKey).(*Claims)
Expand Down
54 changes: 54 additions & 0 deletions backend/src/handlers/dashboard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,60 @@ func TestHandleAdminDashboard(t *testing.T) {
}
}

func TestHandleAdminLayer2(t *testing.T) {
httpTests := []httpTest{
{"TestAdminDashboardAsAdmin", "admin", map[string]any{"id": "1"}, http.StatusOK, ""},
{"TestAdminDashboardAsUser", "student", map[string]any{"id": "4"}, http.StatusUnauthorized, ""},
}
for _, test := range httpTests {
t.Run(test.testName, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/api/users/{id}/admin-layer2", nil)
if err != nil {
t.Fatalf("unable to create new request, error is %v", err)
}
req.SetPathValue("id", test.mapKeyValues["id"].(string))
handler := getHandlerByRoleWithMiddleware(server.handleAdminLayer2, test.role)
rr := executeRequest(t, req, handler, test)
id, _ := strconv.Atoi(test.mapKeyValues["id"].(string))
if test.expectedStatusCode == http.StatusOK {

id := uint(id)
totalCourses, err := server.Db.GetTotalCoursesOffered(&id)
if err != nil {
t.Fatalf("unable to get total courses offered, error is %v", err)
}
totalStudents, err := server.Db.GetTotalStudentsEnrolled(&id)
if err != nil {
t.Fatalf("unable to get total students enrolled, error is %v", err)
}
totalActivity, err := server.Db.GetTotalHourlyActivity(&id)
if err != nil {
t.Fatalf("unable to get total hourly activity, error is %v", err)
}
learningInsights, err := server.Db.GetLearningInsights(&id)
if err != nil {
t.Fatalf("unable to get learning insights, error is %v", err)
}
adminDashboard := models.AdminLayer2Join{
TotalCoursesOffered: int64(totalCourses),
TotalStudentsEnrolled: int64(totalStudents),
TotalHourlyActivity: int64(totalActivity),
LearningInsights: learningInsights,
}

received := rr.Body.String()
resource := models.Resource[models.AdminLayer2Join]{}
if err := json.Unmarshal([]byte(received), &resource); err != nil {
t.Errorf("failed to unmarshal resource, error is %v", err)
}
if diff := cmp.Diff(&adminDashboard, &resource.Data); diff != "" {
t.Errorf("handler returned unexpected response body: %v", diff)
}
}
})
}
}

func TestHandleUserCatalog(t *testing.T) {
httpTests := []httpTest{
{"TestGetAllUserCatalogAsAdmin", "admin", getUserCatalogSearch(4, nil, "", ""), http.StatusOK, ""},
Expand Down
14 changes: 14 additions & 0 deletions backend/src/models/course.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,20 @@ type AdminDashboardJoin struct {
TopCourseActivity []CourseActivity `json:"top_course_activity"`
}

type LearningInsight struct {
CourseName string `json:"course_name"`
TotalStudentsEnrolled int64 `json:"total_students_enrolled"`
CompletionRate float32 `json:"completion_rate"`
ActivityHours int64 `json:"activity_hours"`
}

type AdminLayer2Join struct {
TotalCoursesOffered int64 `json:"total_courses_offered"`
TotalStudentsEnrolled int64 `json:"total_students_enrolled"`
TotalHourlyActivity int64 `json:"total_hourly_activity"`
LearningInsights []LearningInsight `json:"learning_insights"`
}

type CourseMilestones struct {
Name string `json:"name"`
Milestones int `json:"milestones"`
Expand Down
Loading

0 comments on commit 0a8c319

Please sign in to comment.