Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: remove course favorites, add program favs #513

Merged
merged 1 commit into from
Nov 19, 2024
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- +goose Up
-- +goose StatementBegin
DROP TABLE IF EXISTS favorites CASCADE;
CREATE TABLE public.program_favorites (
id SERIAL PRIMARY KEY,
program_id INT NOT NULL,
user_id INT NOT NULL,
FOREIGN KEY (program_id) REFERENCES programs(id),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX program_favorites_program_id_index ON program_favorites USING btree (program_id);
CREATE INDEX program_favorites_user_id_index ON program_favorites USING btree (user_id);
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
CREATE TABLE public.favorites (
id SERIAL NOT NULL PRIMARY KEY,
user_id integer,
course_id integer,
FOREIGN KEY (user_id) REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE CASCADE,
FOREIGN KEY (course_id) REFERENCES public.courses(id) ON UPDATE CASCADE ON DELETE CASCADE
);
DROP TABLE IF EXISTS program_favorites CASCADE;
-- +goose StatementEnd
2 changes: 1 addition & 1 deletion backend/src/database/DB.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ func MigrateTesting(db *gorm.DB) {
&models.Outcome{},
&models.Activity{},
&models.OidcClient{},
&models.UserFavorite{},
&models.ProgramFavorite{},
&models.Facility{},
&models.OpenContentProvider{},
&models.CronJob{},
Expand Down
26 changes: 21 additions & 5 deletions backend/src/database/programs.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ func (db *DB) GetProgramByID(id int) (*models.Program, error) {
return content, nil
}

func (db *DB) GetProgram(page, perPage int, tags []string, search string) (int64, []models.Program, error) {
func (db *DB) GetProgram(page, perPage int, tags []string, search string, userId uint) (int64, []models.Program, error) {
content := []models.Program{}
total := int64(0)
if len(tags) > 0 {
Expand All @@ -26,23 +26,39 @@ func (db *DB) GetProgram(page, perPage int, tags []string, search string) (int64
}
}

tx := db.Model(&models.Program{}).Preload("Tags").Preload("Facilities").Find(&content, "id IN (?)", query).Count(&total)
tx := db.Model(&models.Program{}).Preload("Tags").Preload("Facilities").Preload("Favorites", "user_id = ?", userId).Find(&content, "id IN (?)", query)
if search != "" {
tx = tx.Where("name LIKE ?", "%"+search+"%")
}
if err := tx.Count(&total).Error; err != nil {
return 0, nil, newGetRecordsDBError(err, "programs")
}
if err := tx.Limit(perPage).Offset((page - 1) * perPage).Error; err != nil {
return 0, nil, newGetRecordsDBError(err, "programs")
}
} else {
tx := db.Model(&models.Program{}).Preload("Tags").Preload("Facilities")
tx := db.Model(&models.Program{}).
Preload("Tags").
Preload("Facilities").
Preload("Favorites", "user_id = ?", userId)
if search != "" {
tx = tx.Where("name LIKE ?", "%"+search+"%").Count(&total)
}
if err := tx.Find(&content).Limit(perPage).Offset((page - 1) * perPage).Error; err != nil {
if err := tx.Count(&total).Error; err != nil {
return 0, nil, newGetRecordsDBError(err, "programs")
}
if err := tx.Limit(perPage).Offset((page - 1) * perPage).Find(&content).Error; err != nil {
return 0, nil, newGetRecordsDBError(err, "programs")
}
}
return total, content, nil
programs := iterMap(func(prog models.Program) models.Program {
if len(prog.Favorites) > 0 {
prog.IsFavorited = true
return prog
}
return prog
}, content)
return total, programs, nil
}

func (db *DB) CreateProgram(content *models.Program) error {
Expand Down
12 changes: 2 additions & 10 deletions backend/src/database/user_catalogue.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,14 @@ type UserCatalogueJoin struct {
ExternalURL string `json:"external_url"`
CourseType string `json:"course_type"`
Description string `json:"description"`
IsFavorited bool `json:"is_favorited"`
OutcomeTypes string `json:"outcome_types"`
}

func (db *DB) GetUserCatalogue(userId int, tags []string, search, order string) ([]UserCatalogueJoin, error) {
catalogue := []UserCatalogueJoin{}
tx := db.Table("courses c").
Select("c.id as course_id, c.thumbnail_url, c.name as course_name, pp.name as provider_name, c.external_url, c.type as course_type, c.description, c.outcome_types, f.user_id IS NOT NULL as is_favorited").
Select("c.id as course_id, c.thumbnail_url, c.name as course_name, pp.name as provider_name, c.external_url, c.type as course_type, c.description, c.outcome_types").
Joins("LEFT JOIN provider_platforms pp ON c.provider_platform_id = pp.id").
Joins("LEFT JOIN favorites f ON f.course_id = c.id AND f.user_id = ?", userId).
Where("c.deleted_at IS NULL").
Where("pp.deleted_at IS NULL")
for i, tag := range tags {
Expand Down Expand Up @@ -53,7 +51,6 @@ type UserCourses struct {
ProviderName string `json:"provider_platform_name"`
ExternalURL string `json:"external_url"`
CourseProgress float64 `json:"course_progress"`
IsFavorited bool `json:"is_favorited"`
TotalTime uint `json:"total_time"`
}

Expand All @@ -70,7 +67,6 @@ func (db *DB) GetUserCourses(userId uint, order string, orderBy string, search s
"course_name": "c.name",
"provider_name": "pp.name",
"course_progress": "course_progress",
"is_favorited": "is_favorited",
"total_time": "a.total_time",
}
dbField, ok := fieldMap[orderBy]
Expand All @@ -82,7 +78,6 @@ func (db *DB) GetUserCourses(userId uint, order string, orderBy string, search s
tx := db.Table("courses c").
Select(`c.id, c.thumbnail_url,
c.name as course_name, pp.name as provider_name, c.external_url,
f.user_id IS NOT NULL as is_favorited,
CASE
WHEN EXISTS (SELECT 1 FROM outcomes o WHERE o.course_id = c.id AND o.user_id = ?) THEN 100
WHEN c.total_progress_milestones = 0 THEN 0
Expand All @@ -100,7 +95,6 @@ func (db *DB) GetUserCourses(userId uint, order string, orderBy string, search s
) a on a.course_id = c.id
and a.user_id = m.user_id
and a.RN = 1`).
Joins("LEFT JOIN favorites f ON f.course_id = c.id AND f.user_id = m.user_id").
Joins("LEFT JOIN outcomes o ON o.course_id = c.id AND o.user_id = m.user_id").
Where("c.deleted_at IS NULL").
Where("pp.deleted_at IS NULL")
Expand All @@ -113,8 +107,6 @@ func (db *DB) GetUserCourses(userId uint, order string, orderBy string, search s
for i, tag := range tags {
var query string
switch tag {
case "is_favorited":
query = "f.user_id IS NOT NULL"
case "completed":
query = "o.type IS NOT NULL"
case "in_progress":
Expand All @@ -127,7 +119,7 @@ func (db *DB) GetUserCourses(userId uint, order string, orderBy string, search s
}
}

tx.Group("c.id, c.name, c.thumbnail_url, pp.name, c.external_url, f.user_id, a.total_time")
tx.Group("c.id, c.name, c.thumbnail_url, pp.name, c.external_url, a.total_time")
err := tx.Scan(&courses).Error
if err != nil {
return nil, 0, 0, NewDBError(err, "error getting user programs")
Expand Down
10 changes: 5 additions & 5 deletions backend/src/database/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,16 +187,16 @@ func (db *DB) UpdateUser(user *models.User) error {
return nil
}

func (db *DB) ToggleUserFavorite(user_id uint, id uint) (bool, error) {
func (db *DB) ToggleProgramFavorite(user_id uint, id uint) (bool, error) {
var favRemoved bool
var favorite models.UserFavorite
if db.First(&favorite, "user_id = ? AND course_id = ?", user_id, id).Error == nil {
if err := db.Delete(&favorite).Error; err != nil {
var favorite models.ProgramFavorite
if db.First(&favorite, "user_id = ? AND program_id = ?", user_id, id).Error == nil {
if err := db.Unscoped().Delete(&favorite).Error; err != nil {
return favRemoved, newDeleteDBError(err, "favorites")
}
favRemoved = true
} else {
favorite = models.UserFavorite{UserID: user_id, CourseID: id}
favorite = models.ProgramFavorite{UserID: user_id, ProgramID: id}
if err := db.Create(&favorite).Error; err != nil {
return favRemoved, newCreateDBError(err, "error creating favorites")
}
Expand Down
21 changes: 0 additions & 21 deletions backend/src/handlers/courses_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ func (srv *Server) registerCoursesRoutes() []routeDef {
return []routeDef{
{"GET /api/courses", srv.handleIndexCourses, true, axx},
{"GET /api/courses/{id}", srv.handleShowCourse, false, axx},
{"PUT /api/courses/{id}/save", srv.handleFavoriteCourse, true, axx},
}
}

Expand Down Expand Up @@ -51,23 +50,3 @@ func (srv *Server) handleShowCourse(w http.ResponseWriter, r *http.Request, log
}
return writeJsonResponse(w, http.StatusOK, course)
}

func (srv *Server) handleFavoriteCourse(w http.ResponseWriter, r *http.Request, log sLog) error {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
return newInvalidIdServiceError(err, "course ID")
}
user_id := srv.userIdFromRequest(r)
favoriteRemoved, err := srv.Db.ToggleUserFavorite(user_id, uint(id))
if err != nil {
log.add("course_id", id)
log.add("user_id", user_id)
return newDatabaseServiceError(err)
}
log.debugf("Favorite removed: %v", favoriteRemoved)
if favoriteRemoved {
w.WriteHeader(http.StatusNoContent)
return nil
}
return writeJsonResponse(w, http.StatusOK, "Favorite updated successfully")
}
36 changes: 0 additions & 36 deletions backend/src/handlers/courses_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"fmt"
"math/rand"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"testing"
Expand Down Expand Up @@ -81,41 +80,6 @@ func TestHandleShowCourse(t *testing.T) {
}
}

func TestHandleFavoriteCourse(t *testing.T) {
httpTests := []httpTest{
{"TestUserCanToggleFavoriteCourseOnOff", "student", map[string]any{"id": "4", "message": "Favorite updated successfully"}, http.StatusOK, ""},
{"TestAdminCanToggleFavoriteCourseOnOff", "admin", map[string]any{"id": "4", "message": "Favorite updated successfully"}, http.StatusOK, ""},
}
for _, test := range httpTests {
t.Run(test.testName, func(t *testing.T) {
req, err := http.NewRequest(http.MethodPut, "/api/courses/{id}/save", nil)
if err != nil {
t.Fatalf("unable to create new request, error is %v", err)
}
req.SetPathValue("id", test.mapKeyValues["id"].(string))
handler := getHandlerByRole(server.handleFavoriteCourse, test.role)
rr := executeRequest(t, req, handler, test)
if test.expectedStatusCode == http.StatusOK {
received := rr.Body.String()
data := models.Resource[struct{}]{}
if err := json.Unmarshal([]byte(received), &data); err != nil {
t.Errorf("failed to unmarshal response error is %v", err)
}
if data.Message != test.mapKeyValues["message"] {
t.Errorf("handler returned wrong body: got %v want %v", data.Message, test.mapKeyValues["message"])
}
//special case here, execute another request to toggle the user's favorite
req.SetPathValue("id", test.mapKeyValues["id"].(string))
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusNoContent {
t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusNoContent)
}
}
})
}
}

func getCourseSearch(search string) map[string]any {
form := make(map[string]any)
_, courses, err := server.Db.GetCourse(1, 10, search)
Expand Down
1 change: 0 additions & 1 deletion backend/src/handlers/dashboard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,6 @@ func TestHandleUserCourses(t *testing.T) {
{"TestGetUserCoursesWithTagsAndOrderByProvNmDescAsUser", "student", getUserCoursesSearch(4, "asc", "provider_name", "", []string{"certificate", "grade", "progress_completion"}), http.StatusOK, "?tags=certificate,grade,progress_completion,pathway_completion,college_credit&order=asc&order_by=provider_name"},
{"TestGetUserCoursesWithTagsAndOrderByCoursePgrDescAsUser", "student", getUserCoursesSearch(4, "desc", "course_progress", "", []string{"certificate", "grade", "progress_completion"}), http.StatusOK, "?tags=certificate,grade,progress_completion,pathway_completion,college_credit&order=desc&order_by=course_progress"},
{"TestGetUserCoursesWithTagsAndOrderByProvNmDescAsUser", "student", getUserCoursesSearch(4, "asc", "provider_name", "", []string{"certificate", "grade", "progress_completion"}), http.StatusOK, "?tags=certificate,grade,progress_completion,pathway_completion,college_credit&order=asc&order_by=provider_name"},
{"TestGetUserCoursesWithTagsAndOrderByIsFavdDescAsUser", "student", getUserCoursesSearch(4, "desc", "is_favorited", "", []string{"certificate", "grade", "progress_completion"}), http.StatusOK, "?tags=certificate,grade,progress_completion,pathway_completion,college_credit&order=desc&order_by=is_favorited"},
{"TestGetUserCoursesWithTagsAndOrderByTotalTmDescAsUser", "student", getUserCoursesSearch(4, "desc", "total_time", "", []string{"certificate", "grade", "progress_completion"}), http.StatusOK, "?tags=certificate,grade,progress_completion,pathway_completion,college_credit&order=desc&order_by=total_time"},
{"TestUserCoursesWithSearchAscAsUser", "student", getUserCoursesSearch(4, "asc", "", "of", []string{"certificate", "grade", "progress_completion", "pathway_completion", "college_credit"}), http.StatusOK, "?tags=certificate,grade,progress_completion,pathway_completion,college_credit&order=asc&search=of"},
{"TestUserCoursesWithSearchDescAsUser", "student", getUserCoursesSearch(4, "desc", "", "Intro", []string{"certificate", "grade", "progress_completion", "pathway_completion", "college_credit"}), http.StatusOK, "?tags=certificate,grade,progress_completion,pathway_completion,college_credit&order=desc&search=Intro"},
Expand Down
2 changes: 1 addition & 1 deletion backend/src/handlers/milestones_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ func getNewMilestoneModelAndForm() (models.Milestone, map[string]any, error) {

func getNewMilestoneForm() map[string]any {
form := make(map[string]any)
_, courses, err := server.Db.GetProgram(1, 10, nil, "")
_, courses, err := server.Db.GetCourse(1, 10, "")
if err != nil {
form["err"] = err
}
Expand Down
5 changes: 3 additions & 2 deletions backend/src/handlers/programs_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,14 @@ func (srv *Server) registerProgramsRoutes() []routeDef {
*/
func (srv *Server) handleIndexPrograms(w http.ResponseWriter, r *http.Request, log sLog) error {
page, perPage := srv.getPaginationInfo(r)
userId := srv.userIdFromRequest(r)
search := r.URL.Query().Get("search")
tags := r.URL.Query()["tags"]
var tagsSplit []string
if len(tags) > 0 {
tagsSplit = strings.Split(tags[0], ",")
}
total, programs, err := srv.Db.GetProgram(page, perPage, tagsSplit, search)
total, programs, err := srv.Db.GetProgram(page, perPage, tagsSplit, search, userId)
if err != nil {
log.add("search", search)
return newDatabaseServiceError(err)
Expand Down Expand Up @@ -150,7 +151,7 @@ func (srv *Server) handleFavoriteProgram(w http.ResponseWriter, r *http.Request,
return newInvalidIdServiceError(err, "program ID")
}
user_id := srv.userIdFromRequest(r)
favoriteRemoved, err := srv.Db.ToggleUserFavorite(user_id, uint(id))
favoriteRemoved, err := srv.Db.ToggleProgramFavorite(user_id, uint(id))
if err != nil {
log.add("programId", id)
log.add("userId", user_id)
Expand Down
4 changes: 2 additions & 2 deletions backend/src/handlers/programs_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ func TestHandleFavoriteProgram(t *testing.T) {
}

func getAllPrograms() map[string]any {
total, programs, err := server.Db.GetProgram(1, 10, nil, "")
total, programs, err := server.Db.GetProgram(1, 10, nil, "", 1)
form := make(map[string]any)
form["programs"] = programs
form["err"] = err
Expand All @@ -263,7 +263,7 @@ func getAllPrograms() map[string]any {
}

func getProgramsBySearch(tags string, search string) map[string]any {
total, programs, err := server.Db.GetProgram(1, 10, strings.Split(tags, ","), search)
total, programs, err := server.Db.GetProgram(1, 10, strings.Split(tags, ","), search, 1)
form := make(map[string]any)
form["programs"] = programs
form["err"] = err
Expand Down
6 changes: 3 additions & 3 deletions backend/src/handlers/sections_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ func TestHandleDeleteSection(t *testing.T) {

func getProgramSection(facilityId uint) map[string]any {
form := make(map[string]any)
_, programs, err := server.Db.GetProgram(1, 10, nil, "")
_, programs, err := server.Db.GetProgram(1, 10, nil, "", 1)
if err != nil {
form["err"] = err
}
Expand All @@ -286,7 +286,7 @@ func getProgramSection(facilityId uint) map[string]any {
}
func getProgramId() map[string]any {
form := make(map[string]any)
_, programs, err := server.Db.GetProgram(1, 10, nil, "")
_, programs, err := server.Db.GetProgram(1, 10, nil, "", 1)
if err != nil {
form["err"] = err
}
Expand All @@ -296,7 +296,7 @@ func getProgramId() map[string]any {

func getSectionId() map[string]any {
form := make(map[string]any)
_, programs, err := server.Db.GetProgram(1, 10, nil, "")
_, programs, err := server.Db.GetProgram(1, 10, nil, "", 1)
if err != nil {
form["err"] = err
}
Expand Down
15 changes: 9 additions & 6 deletions backend/src/models/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,15 @@ func (LeftMenuLink) TableName() string {
return "left_menu_links"
}

type UserFavorite struct {
ID uint `gorm:"primaryKey" json:"-"`
UserID uint `json:"user_id"`
CourseID uint `json:"course_id"`
type ProgramFavorite struct {
ID uint `gorm:"primaryKey" json:"-"`
UserID uint `json:"user_id"`
ProgramID uint `json:"program_id"`

User *User `json:"user,omitempty" gorm:"foreignKey:UserID;constraint:OnDelete CASCADE"`
Program *Program `json:"program,omitempty" gorm:"foreignKey:ProgramID;constraint:OnDelete CASCADE"`
}

func (UserFavorite) TableName() string {
return "favorites"
func (ProgramFavorite) TableName() string {
return "program_favorites"
}
6 changes: 4 additions & 2 deletions backend/src/models/program.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ type Program struct {
CreditType string `json:"credit_type" gorm:"not null" validate:"required,max=50"`
ProgramStatus string `json:"program_status" gorm:"not null" validate:"required,max=50"`
ProgramType string `json:"program_type" gorm:"not null" validate:"required,max=50"`
IsFavorited bool `json:"is_favorited" gorm:"-"`

Tags []ProgramTag `json:"tags" gorm:"foreignKey:ProgramID;references:ID"`
Facilities []Facility `json:"facilities" gorm:"many2many:facilities_programs;"`
Tags []ProgramTag `json:"tags" gorm:"foreignKey:ProgramID;references:ID"`
Facilities []Facility `json:"facilities" gorm:"many2many:facilities_programs;"`
Favorites []ProgramFavorite `json:"-" gorm:"foreignKey:ProgramID;references:ID"`
}

func (Program) TableName() string { return "programs" }
Expand Down
10 changes: 5 additions & 5 deletions backend/src/models/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@ type User struct {
FacilityID uint `json:"facility_id"`

/* foreign keys */
Mappings []ProviderUserMapping `json:"mappings,omitempty" gorm:"foreignKey:UserID;constraint:OnDelete CASCADE"`
FavoriteVideos []VideoFavorite `json:"favorite_videos,omitempty" goem:"foreignKey:UserID;constraint:OnDelete CASCADE"`
FavoriteCourses []UserFavorite `json:"favorite_courses,omitempty" gorm:"foreignKey:UserID;constraint:OnDelete CASCADE"`
Facility *Facility `json:"facility,omitempty" gorm:"foreignKey:FacilityID;constraint:OnDelete SET NULL"`
UserRole *Role `json:"-" gorm:"foreignKey:Role;constraint:OnDelete SET NULL"`
Mappings []ProviderUserMapping `json:"mappings,omitempty" gorm:"foreignKey:UserID;constraint:OnDelete CASCADE"`
FavoriteVideos []VideoFavorite `json:"favorite_videos,omitempty" goem:"foreignKey:UserID;constraint:OnDelete CASCADE"`
FavoritePrograms []ProgramFavorite `json:"favorite_programs,omitempty" gorm:"foreignKey:UserID;constraint:OnDelete CASCADE"`
Facility *Facility `json:"facility,omitempty" gorm:"foreignKey:FacilityID;constraint:OnDelete SET NULL"`
UserRole *Role `json:"-" gorm:"foreignKey:Role;constraint:OnDelete SET NULL"`
}

type ImportUser struct {
Expand Down
Loading
Loading