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 23, 2024
1 parent 799f2cb commit f36617e
Show file tree
Hide file tree
Showing 23 changed files with 960 additions and 164 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 public.library_favorites (
id SERIAL NOT NULL PRIMARY KEY,
created_at timestamp with time zone,
updated_at timestamp with time zone,
deleted_at timestamp 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,
FOREIGN KEY (user_id) REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE CASCADE,
FOREIGN KEY (open_content_provider_id) REFERENCES public.open_content_providers(id) ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE INDEX idx_library_favorites_name ON public.library_favorites USING btree (name);
CREATE INDEX idx_library_favorites_visibility_status ON public.library_favorites USING btree (visibility_status);
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


142 changes: 116 additions & 26 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 Expand Up @@ -221,32 +222,6 @@ func (db *DB) SeedTestData() {
log.Fatalf("Failed to create platform: %v", err)
}
}
openContent, err := os.ReadFile("test_data/open_content.json")
if err != nil {
log.Fatalf("Failed to read test data: %v", err)
}
var openContentProviders []models.OpenContentProvider
if err := json.Unmarshal(openContent, &openContentProviders); err != nil {
log.Fatalf("Failed to unmarshal test data: %v", err)
}
for i := range openContentProviders {
if err := db.Create(&openContentProviders[i]).Error; err != nil {
log.Fatalf("Failed to create open content provider: %v", err)
}
}
libraries, err := os.ReadFile("test_data/libraries.json")
if err != nil {
log.Fatalf("Failed to read test data: %v", err)
}
var library []models.Library
if err := json.Unmarshal(libraries, &library); err != nil {
log.Fatalf("Failed to unmarshal test data: %v", err)
}
for i := range library {
if err := db.Create(&library[i]).Error; err != nil {
log.Fatalf("Failed to create library: %v", err)
}
}
oidcFile, err := os.ReadFile("test_data/oidc_client.json")
if err != nil {
log.Fatalf("Failed to read test data: %v", err)
Expand Down Expand Up @@ -331,6 +306,121 @@ func (db *DB) SeedTestData() {
log.Fatalf("Failed to get users from db")
return
}
openContent := []models.OpenContentProvider{{Name: models.Kiwix, BaseUrl: models.KiwixLibraryUrl, CurrentlyEnabled: true, Thumbnail: models.KiwixThumbnailURL, Description: models.Kiwix},
{Name: models.Youtube, BaseUrl: models.YoutubeApi, CurrentlyEnabled: true, Thumbnail: models.YoutubeThumbnail, Description: models.YoutubeDescription}}
for idx := range openContent {
if err := db.Create(&openContent[idx]).Error; err != nil {
log.Fatalf("Failed to create kiwix open content provider: %v", err)
}
}
libraries, err := os.ReadFile("test_data/libraries.json")
if err != nil {
log.Fatalf("Failed to read test data: %v", err)
}
var library []models.Library
if err := json.Unmarshal(libraries, &library); err != nil {
log.Fatalf("Failed to unmarshal test data: %v", err)
}
videosJson, err := os.ReadFile("test_data/videos.json")
if err != nil {
log.Fatalf("Failed to read test data: %v", err)
}
var videos []models.Video
if err := json.Unmarshal(videosJson, &videos); err != nil {
log.Fatalf("Failed to unmarshal test data: %v", err)
}
var kwixID uint //get id for kwix
if db.Model(&models.OpenContentProvider{}).Select("id").Where("name = ?", models.Kiwix).First(&kwixID).RowsAffected == 0 {
log.Fatalf("Failed to get %s open_content_provider: %v", models.Kiwix, err)
}
var youtubeID uint
if db.Model(&models.OpenContentProvider{}).Select("id").Where("name = ?", models.Youtube).First(&youtubeID).RowsAffected == 0 {
log.Fatalf("Failed to get %s open_content_provider: %v", models.Kiwix, err)
}
var url models.OpenContentUrl
var activity models.OpenContentActivity
for i := range videos {
user := dbUsers[uint(rand.Intn(len(dbUsers)))]
videos[i].OpenContentProviderID = youtubeID
if err := db.Create(&videos[i]).Error; err != nil {
log.Fatalf("Failed to create video: %v", err)
}
videoViewerUrl := fmt.Sprintf("/viewer/videos/%d", videos[i].ID)
url = models.OpenContentUrl{
ContentURL: videoViewerUrl,
}
if err := db.Create(&url).Error; err != nil {
log.Fatalf("Failed to create content url: %v", err)
}
for j, k := 0, rand.Intn(50); j < k; j++ {
activity = models.OpenContentActivity{
OpenContentProviderID: youtubeID,
FacilityID: user.FacilityID,
UserID: user.ID,
ContentID: videos[i].ID,
OpenContentUrlID: url.ID,
RequestTS: time.Now(),
}
if err := db.Create(&activity).Error; err != nil {
log.Fatalf("Failed to create open content activity: %v", err)
}
time.Sleep(time.Millisecond * 1)
}
if i%2 == 0 { //just going to favorite every other video
favoriteVideo := models.VideoFavorite{
UserID: user.ID,
VideoID: videos[i].ID,
}
if err := db.Create(&favoriteVideo).Error; err != nil {
log.Fatalf("Failed to create favorite video: %v", err)
}
}
}
openContentUrlPrefixes := []string{"alpha-bravo", "sunny-breeze", "stormy-night", "crimson-sky", "electric-wave", "golden-hour", "starry-dream", "lunar-echo", "cosmic-dust", "silent-whisper", "ocean-tide", "shadow-flame", "emerald-haze", "velvet-sun", "fire-bolt", "thunder-cloud", "frozen-peak", "radiant-gem", "mystic-vortex", "crystal-shard", "obsidian-moon", "solar-wind", "arctic-light", "nebula-glow", "desert-spark", "forest-blaze", "phantom-frost", "twilight-glimmer", "vivid-flare", "prism-halo", "aurora-wave", "blazing-star", "icy-horizon", "jagged-dream", "vivid-shadow", "iron-bloom", "canyon-sky", "frost-spark"}
for i := range library {
user := dbUsers[uint(rand.Intn(len(dbUsers)))]
library[i].OpenContentProviderID = kwixID
if err := db.Create(&library[i]).Error; err != nil {
log.Fatalf("Failed to create library: %v", err)
}
for j, k := 0, len(openContentUrlPrefixes); j < k; j++ {
url = models.OpenContentUrl{
ContentURL: fmt.Sprintf("/api/proxy/libraries/%d/content/%s", library[i].ID, openContentUrlPrefixes[j]),
}
if err := db.Create(&url).Error; err != nil {
log.Fatalf("Failed to create library: %v", err)
}
for j, k := 0, rand.Intn(50); j < k; j++ {

activity = models.OpenContentActivity{
OpenContentProviderID: kwixID,
FacilityID: user.FacilityID,
UserID: user.ID,
ContentID: library[i].ID,
OpenContentUrlID: url.ID,
RequestTS: time.Now(),
}
if err := db.Create(&activity).Error; err != nil {
log.Fatalf("Failed to create open content activity: %v", err)
}
if i%2 == 0 && j == 0 { //just the first one should be favorited
libraryFavorite := models.LibraryFavorite{
UserID: user.ID,
ContentID: library[i].ID,
OpenContentUrlID: url.ID,
Name: library[i].Name,
VisibilityStatus: true,
OpenContentProviderID: library[i].OpenContentProviderID,
}
if err := db.Create(&libraryFavorite).Error; err != nil {
log.Fatalf("Failed to create favorite library: %v", err)
}
}
time.Sleep(time.Millisecond * 1)
}
}
}

events := []models.ProgramSectionEvent{}
if err := db.Find(&events).Error; err != nil {
log.Fatalf("Failed to get events from db")
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
97 changes: 97 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,99 @@ 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, page, perPage int) (int64, []models.OpenContentFavorite, error) {
var openContentFavorites []models.OpenContentFavorite
if err := db.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
`).
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 0, nil, newGetRecordsDBError(err, "library_favorites")
}

var videoFavorites []models.OpenContentFavorite
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 0, 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)
})

total := int64(len(allFavorites))

offset := (page - 1) * perPage
if offset > len(allFavorites) {
return total, []models.OpenContentFavorite{}, nil
}
end := offset + perPage
if end > len(allFavorites) {
end = len(allFavorites)
}
paginatedFavorites := allFavorites[offset:end]
return total, paginatedFavorites, 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
Loading

0 comments on commit f36617e

Please sign in to comment.