Skip to content

Commit

Permalink
feat: add library favorites
Browse files Browse the repository at this point in the history
  • Loading branch information
CK-7vn committed Nov 21, 2024
1 parent 799f2cb commit eb370cd
Show file tree
Hide file tree
Showing 19 changed files with 607 additions and 101 deletions.
28 changes: 28 additions & 0 deletions backend/migrations/00022_add_library_favorites_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE library_favorites (
id SERIAL NOT NULL,
created_at TIMESTAMP(6) WITH TIME ZONE,
updated_at TIMESTAMP(6) WITH TIME ZONE,
deleted_at TIMESTAMP(6) WITH TIME ZONE,
user_id INTEGER NOT NULL,
name CHARACTER VARYING(255),
content_id INTEGER NOT NULL,
visibility_status BOOLEAN,
open_content_url_id INTEGER NOT NULL,
open_content_provider_id INTEGER NOT NULL,
PRIMARY KEY (id),
CONSTRAINT library_favorites_user_id_fkey FOREIGN KEY (user_id) REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT library_favorites_open_content_url_id_fkey FOREIGN KEY (open_content_url_id) REFERENCES "open_content_urls" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT library_favorites_open_content_provider_id_fkey FOREIGN KEY (open_content_provider_id) REFERENCES "open_content_providers" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX idx_library_favorites_user_id ON public.library_favorites USING btree (user_id);
CREATE INDEX idx_library_favorites_user_id_open_content_url_id ON public.library_favorites USING btree (user_id, open_content_url_id);

-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS library_favorites CASCADE;
-- +goose StatementEnd


1 change: 1 addition & 0 deletions backend/src/database/DB.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ func MigrateTesting(db *gorm.DB) {
&models.CronJob{},
&models.RunnableTask{},
&models.Library{},
&models.LibraryFavorite{},
&models.Video{},
&models.VideoDownloadAttempt{},
&models.VideoFavorite{},
Expand Down
31 changes: 24 additions & 7 deletions backend/src/database/libraries.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,41 @@ import (
log "github.com/sirupsen/logrus"
)

func (db *DB) GetAllLibraries(page, perPage int, visibility string, search string, providerId int) (int64, []models.Library, error) {
var libraries []models.Library
func (db *DB) GetAllLibraries(page, perPage, providerId int, userId uint, visibility, search string) (int64, []models.LibraryDto, error) {
var libraries []models.LibraryDto
var total int64
offset := (page - 1) * perPage
tx := db.Model(&models.Library{}).Preload("OpenContentProvider").Order("created_at DESC")
tx := db.Table("libraries lib").
Select(`lib.id, lib.open_content_provider_id, lib.external_id, lib.name, lib.language,
lib.description, lib.path, lib.image_url, lib.visibility_status, ocf.id IS NOT NULL as is_favorited,
ocp.base_url, ocp.thumbnail, ocp.currently_enabled, ocp.description as open_content_provider_desc,
ocp.name as open_content_provider_name`).
Joins(`join open_content_providers ocp on ocp.id = lib.open_content_provider_id
and ocp.currently_enabled = true
and ocp.deleted_at IS NULL`).
Joins(`left join (select ocf.user_id, ocf.content_id, ocf.id, ocf.open_content_provider_id
from library_favorites ocf
join open_content_urls urls on urls.id = ocf.open_content_url_id
and urls.content_url LIKE '/api/proxy/libraries/%/'
where ocf.deleted_at IS NULL
) ocf on ocf.content_id = lib.id
and ocf.open_content_provider_id = ocp.id
and ocf.user_id = ?`, userId).
Where("lib.deleted_at IS NULL").
Order("lib.created_at DESC")
visibility = strings.ToLower(visibility)
if visibility == "hidden" {
tx = tx.Where("visibility_status = false")
tx = tx.Where("lib.visibility_status = false")
}
if visibility == "visible" {
tx = tx.Where("visibility_status = true")
tx = tx.Where("lib.visibility_status = true")
}
if search != "" {
search = "%" + strings.ToLower(search) + "%"
tx = tx.Where("LOWER(name) LIKE ?", search)
tx = tx.Where("LOWER(lib.name) LIKE ?", search)
}
if providerId != 0 {
tx = tx.Where("open_content_provider_id = ?", providerId)
tx = tx.Where("lib.open_content_provider_id = ?", providerId)
}
if err := tx.Count(&total).Error; err != nil {
return 0, nil, newGetRecordsDBError(err, "libraries")
Expand Down
86 changes: 86 additions & 0 deletions backend/src/database/open_content.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package database

import (
"UnlockEdv2/src/models"
"sort"

log "github.com/sirupsen/logrus"
)
Expand Down Expand Up @@ -68,3 +69,88 @@ func (db *DB) CreateContentActivity(urlString string, activity *models.OpenConte
log.Warn("unable to create content activity for url, ", urlString)
}
}

func (db *DB) ToggleLibraryFavorite(contentParams *models.OpenContentParams) (bool, error) {
var fav models.LibraryFavorite
url := models.OpenContentUrl{}
if db.Where("content_url = ?", contentParams.ContentUrl).First(&url).RowsAffected == 0 {
url.ContentURL = contentParams.ContentUrl
if err := db.Create(&url).Error; err != nil {
log.Warn("unable to create content url for activity")
return false, newCreateDBError(err, "open_content_urls")
}
}
if db.Model(&models.LibraryFavorite{}).Where("user_id = ? AND content_id = ? AND open_content_url_id = ?", contentParams.UserID, contentParams.ContentID, url.ID).First(&fav).RowsAffected > 0 {
if err := db.Delete(&fav).Error; err != nil {
return false, newNotFoundDBError(err, "library_favorites")
}
} else {
newFav := models.LibraryFavorite{
UserID: contentParams.UserID,
ContentID: contentParams.ContentID,
OpenContentUrlID: url.ID,
Name: contentParams.Name,
OpenContentProviderID: contentParams.OpenContentProviderID,
}
if err := db.Create(&newFav).Error; err != nil {
return false, newCreateDBError(err, "library_favorites")
}
}
return true, nil
}

func (db *DB) GetUserFavorites(userID uint) ([]models.CombinedFavorite, error) {
var openContentFavorites []models.CombinedFavorite
if err := db.Debug().Table("library_favorites fav").
Select(`
fav.id,
fav.name,
'library' as type,
fav.content_id,
lib.image_url as thumbnail_url,
ocp.description,
NOT lib.visibility_status AS visibility_status,
fav.open_content_provider_id,
ocp.name AS provider_name,
fav.created_at AS created_at
`).
Joins(`JOIN open_content_providers ocp ON ocp.id = fav.open_content_provider_id
AND ocp.currently_enabled = true
AND ocp.deleted_at IS NULL`).
Joins(`JOIN libraries lib ON lib.id = fav.content_id
AND fav.open_content_provider_id = ocp.id`).
Where("fav.user_id = ? AND fav.deleted_at IS NULL", userID).
Order("fav.created_at desc").
Scan(&openContentFavorites).Error; err != nil {
return nil, newGetRecordsDBError(err, "library_favorites")
}

var videoFavorites []models.CombinedFavorite
if err := db.Table("video_favorites vf").
Select(`
vf.id,
videos.title as name,
'video' as type,
vf.video_id as content_id,
videos.thumbnail_url,
videos.description,
videos.open_content_provider_id,
videos.channel_title,
NOT videos.visibility_status as visibility_status
`).
Joins("JOIN videos on vf.video_id = videos.id").
Joins(`JOIN open_content_providers ocp ON ocp.id = videos.open_content_provider_id
AND ocp.currently_enabled = true
AND ocp.deleted_at IS NULL`).
Where("vf.user_id = ? AND vf.deleted_at IS NULL", userID).
Order("vf.created_at desc").
Scan(&videoFavorites).Error; err != nil {
return nil, newGetRecordsDBError(err, "video_favorites")
}
allFavorites := append(openContentFavorites, videoFavorites...)
sort.Slice(allFavorites, func(i, j int) bool {
return allFavorites[i].CreatedAt.After(allFavorites[j].CreatedAt)
})

return allFavorites, nil
}
3 changes: 2 additions & 1 deletion backend/src/handlers/libraries_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ func (srv *Server) handleIndexLibraries(w http.ResponseWriter, r *http.Request,
if srv.UserIsAdmin(r) {
showHidden = r.URL.Query().Get("visibility")
}
total, libraries, err := srv.Db.GetAllLibraries(page, perPage, showHidden, search, providerId)
userID := r.Context().Value(ClaimsKey).(*Claims).UserID
total, libraries, err := srv.Db.GetAllLibraries(page, perPage, providerId, userID, showHidden, search)
if err != nil {
return newDatabaseServiceError(err)
}
Expand Down
18 changes: 9 additions & 9 deletions backend/src/handlers/libraries_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ import (

func TestHandleIndexLibraries(t *testing.T) {
httpTests := []httpTest{
{"TestGetLibrariesAsUser", "student", map[string]any{"page": 1, "per_page": 10, "visibility": "visible", "search": "", "provider_id": 0}, http.StatusOK, "?page=1&per_page=10"},
{"TestGetLibrariesAsUserShowHidden", "student", map[string]any{"page": 1, "per_page": 10, "visibility": "hidden", "search": "", "provider_id": 0}, http.StatusUnauthorized, "?page=1&per_page=10&visibility=hidden"},
{"TestGetLibrariesAsAdmin", "admin", map[string]any{"page": 1, "per_page": 10, "visibility": "", "search": "", "provider_id": 0}, http.StatusOK, "?page=1&per_page=10"},
{"TestGetLibrariesAsAdmin", "admin", map[string]any{"page": 1, "per_page": 10, "visibility": "hidden", "search": "", "provider_id": 0}, http.StatusOK, "?page=1&per_page=10&visibility=hidden&provider_id=0"},
{"TestGetLibrariesAsAdmin", "admin", map[string]any{"page": 1, "per_page": 10, "visibility": "hidden", "search": "", "provider_id": 1}, http.StatusOK, "?page=1&per_page=10&visibility=hidden&provider_id=1"},
{"TestGetLibrariesAsAdminOnlyVisible", "admin", map[string]any{"page": 1, "per_page": 10, "visibility": "visible", "search": "", "provider_id": 0}, http.StatusOK, "?page=1&per_page=10&visibility=visible"},
{"TestGetLibrariesAsAdminOnlyHidden", "admin", map[string]any{"page": 1, "per_page": 10, "visibility": "hidden", "search": "", "provider_id": 0}, http.StatusOK, "?page=1&per_page=10&visibility=hidden"},
{"TestGetLibrariesAsAdminWithParams", "admin", map[string]any{"page": 1, "per_page": 10, "visibility": "", "search": "python", "provider_id": 0}, http.StatusOK, "?page=1&per_page=10&search=python"},
{"TestGetLibrariesAsUser", "student", map[string]any{"page": 1, "per_page": 10, "visibility": "visible", "search": "", "provider_id": 0, "user_id": uint(1)}, http.StatusOK, "?page=1&per_page=10"},
{"TestGetLibrariesAsUserShowHidden", "student", map[string]any{"page": 1, "per_page": 10, "visibility": "hidden", "search": "", "provider_id": 0, "user_id": uint(1)}, http.StatusUnauthorized, "?page=1&per_page=10&visibility=hidden"},
{"TestGetLibrariesAsAdmin", "admin", map[string]any{"page": 1, "per_page": 10, "visibility": "", "search": "", "provider_id": 0, "user_id": uint(1)}, http.StatusOK, "?page=1&per_page=10"},
{"TestGetLibrariesAsAdmin", "admin", map[string]any{"page": 1, "per_page": 10, "visibility": "hidden", "search": "", "provider_id": 0, "user_id": uint(1)}, http.StatusOK, "?page=1&per_page=10&visibility=hidden&provider_id=0"},
{"TestGetLibrariesAsAdmin", "admin", map[string]any{"page": 1, "per_page": 10, "visibility": "hidden", "search": "", "provider_id": 1, "user_id": uint(1)}, http.StatusOK, "?page=1&per_page=10&visibility=hidden&provider_id=1"},
{"TestGetLibrariesAsAdminOnlyVisible", "admin", map[string]any{"page": 1, "per_page": 10, "visibility": "visible", "search": "", "provider_id": 0, "user_id": uint(1)}, http.StatusOK, "?page=1&per_page=10&visibility=visible"},
{"TestGetLibrariesAsAdminOnlyHidden", "admin", map[string]any{"page": 1, "per_page": 10, "visibility": "hidden", "search": "", "provider_id": 0, "user_id": uint(1)}, http.StatusOK, "?page=1&per_page=10&visibility=hidden"},
{"TestGetLibrariesAsAdminWithParams", "admin", map[string]any{"page": 1, "per_page": 10, "visibility": "", "search": "python", "provider_id": 0, "user_id": uint(1)}, http.StatusOK, "?page=1&per_page=10&search=python"},
}
for _, test := range httpTests {
t.Run(test.testName, func(t *testing.T) {
Expand All @@ -30,7 +30,7 @@ func TestHandleIndexLibraries(t *testing.T) {
handler := getHandlerByRole(server.handleIndexLibraries, test.role)
rr := executeRequest(t, req, handler, test)
if test.expectedStatusCode == http.StatusOK {
_, expectedLibraries, err := server.Db.GetAllLibraries(test.mapKeyValues["page"].(int), test.mapKeyValues["per_page"].(int), test.mapKeyValues["visibility"].(string), test.mapKeyValues["search"].(string), test.mapKeyValues["provider_id"].(int))
_, expectedLibraries, err := server.Db.GetAllLibraries(test.mapKeyValues["page"].(int), test.mapKeyValues["per_page"].(int), test.mapKeyValues["provider_id"].(int), test.mapKeyValues["user_id"].(uint), test.mapKeyValues["visibility"].(string), test.mapKeyValues["search"].(string))
if err != nil {
t.Fatalf("unable to get libraries, error is %v", err)
}
Expand Down
30 changes: 30 additions & 0 deletions backend/src/handlers/open_content_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ func (srv *Server) registerOpenContentRoutes() []routeDef {
{"PUT /api/open-content/{id}", srv.handleToggleOpenContent, true, axx},
{"PATCH /api/open-content/{id}", srv.handleUpdateOpenContentProvider, true, axx},
{"POST /api/open-content", srv.handleCreateOpenContent, true, axx},
{"PUT /api/open-content/{id}/save", srv.handleToggleFavoriteOpenContent, false, axx},
{"GET /api/open-content/favorites", srv.handleGetUserFavoriteOpenContent, false, axx},
}
}

Expand Down Expand Up @@ -76,3 +78,31 @@ func (srv *Server) handleCreateOpenContent(w http.ResponseWriter, r *http.Reques
}
return writeJsonResponse(w, http.StatusCreated, "Content provider created successfully")
}

func (srv *Server) handleToggleFavoriteOpenContent(w http.ResponseWriter, r *http.Request, log sLog) error {
var reqBody models.OpenContentParams
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
return newJSONReqBodyServiceError(err)
}
defer r.Body.Close()
userID := r.Context().Value(ClaimsKey).(*Claims).UserID
contentID, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
return newInternalServerServiceError(err, "error converting content id to int")
}
reqBody.UserID = uint(userID)
reqBody.ContentID = uint(contentID)
if _, err := srv.Db.ToggleLibraryFavorite(&reqBody); err != nil {
return newDatabaseServiceError(err)
}
return writeJsonResponse(w, http.StatusOK, "Favorite toggled successfully")
}

func (srv *Server) handleGetUserFavoriteOpenContent(w http.ResponseWriter, r *http.Request, log sLog) error {
userID := r.Context().Value(ClaimsKey).(*Claims).UserID
favorites, err := srv.Db.GetUserFavorites(userID)
if err != nil {
return newDatabaseServiceError(err)
}
return writeJsonResponse(w, http.StatusOK, favorites)
}
21 changes: 21 additions & 0 deletions backend/src/handlers/video_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func (srv *Server) registerVideoRoutes() []routeDef {
{"GET /api/videos/{id}", srv.handleGetVideoById, false, axx},
{"POST /api/videos", srv.handlePostVideos, true, axx},
{"PUT /api/videos/{id}/{action}", srv.handleVideoAction, true, axx},
{"PUT /api/videos/{id}/favorite", srv.handleFavoriteVideo, false, axx},
{"DELETE /api/videos/{id}", srv.handleDeleteVideo, true, axx},
}
}
Expand Down Expand Up @@ -154,6 +155,26 @@ func (srv *Server) handleDeleteVideo(w http.ResponseWriter, r *http.Request, log
return writeJsonResponse(w, http.StatusOK, "video deleted")
}

func (srv *Server) handleFavoriteVideo(w http.ResponseWriter, r *http.Request, log sLog) error {
userID := r.Context().Value(ClaimsKey).(*Claims).UserID
vidId, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
return newInvalidIdServiceError(err, "video_id")
}

isFavorited, err := srv.Db.FavoriteVideo(vidId, userID)
if err != nil {
return newInternalServerServiceError(err, "error favoriting video")
}
msg := ""
if isFavorited {
msg = "video added to favorites"
} else {
msg = "video removed from favorites"
}
return writeJsonResponse(w, http.StatusOK, msg)
}

func getAddVideoNatsMsg(videoUrls []string, provider *models.OpenContentProvider) (*nats.Msg, error) {
msg := nats.NewMsg(models.AddVideosJob.PubName())
body := make(map[string]interface{})
Expand Down
35 changes: 35 additions & 0 deletions backend/src/models/library.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,38 @@ type LibraryProxyPO struct {
BaseUrl string
VisibilityStatus bool
}

type LibraryFavorite struct {
DatabaseFields
UserID uint `gorm:"not null" json:"user_id"`
ContentID uint `gorm:"not null" json:"content_id"`
OpenContentUrlID uint `gorm:"not null" json:"open_content_url_id"`
Name string `gorm:"size:255;not null" json:"name"`
VisibilityStatus bool `gorm:"default:false;not null" json:"visibility_status"`
OpenContentProviderID uint `gorm:"not null" json:"open_content_provider_id"`

User *User `gorm:"foreignKey:UserID" json:"-"`
OpenContentProvider *OpenContentProvider `gorm:"foreignKey:OpenContentProviderID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL" json:"open_content_provider"`
}

func (LibraryFavorite) TableName() string { return "library_favorites" }

type LibraryDto struct {
ID uint `json:"id"`
ExternalID *string `json:"external_id"`
Name string `json:"name"`
Language *string `json:"language"`
Description *string `json:"description"`
Path string `json:"url"`
ImageUrl *string `json:"image_url"`
VisibilityStatus bool `json:"visibility_status"`
IsFavorited bool `json:"is_favorited"`

//open_content_provider
OpenContentProviderID uint `json:"open_content_provider_id"`
OpenContentProviderName string `json:"open_content_provider_name"`
BaseUrl string `json:"base_url"`
Thumbnail string `json:"thumbnail_url"`
CurrentlyEnabled bool `json:"currently_enabled"`
OpenContentProviderDesc string `json:"open_content_provider_description"`
}
22 changes: 22 additions & 0 deletions backend/src/models/open_content.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,28 @@ type OpenContentUrl struct {

func (OpenContentUrl) TableName() string { return "open_content_urls" }

type CombinedFavorite struct {
ID uint `json:"id"`
ContentID uint `json:"content_id"`
Name string `json:"name"`
Type string `json:"type"`
ThumbnailUrl string `json:"thumbnail_url"`
Description string `json:"description"`
VisibilityStatus bool `json:"visibility_status"`
OpenContentProviderID uint `json:"open_content_provider_id"`
ProviderName string `json:"provider_name,omitempty"`
ChannelTitle string `json:"channel_title,omitempty"`
CreatedAt time.Time `json:"created_at"`
}

type OpenContentParams struct {
UserID uint `json:"user_id"`
ContentID uint `json:"content_id"`
OpenContentProviderID uint `json:"open_content_provider_id"`
Name string `json:"name"`
ContentUrl string `json:"content_url"`
}

const (
KolibriThumbnailUrl string = "https://learningequality.org/static/assets/kolibri-ecosystem-logos/blob-logo.svg"
Kiwix string = "Kiwix"
Expand Down
Loading

0 comments on commit eb370cd

Please sign in to comment.