diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 848aa562..507f3e7c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,6 +12,7 @@ jobs: runs-on: ubuntu-20.04 strategy: + fail-fast: false matrix: database: ['mysql', 'postgres', 'sqlite'] diff --git a/api/graphql/models/actions/album_actions.go b/api/graphql/models/actions/album_actions.go index 6787249e..2c429c8a 100644 --- a/api/graphql/models/actions/album_actions.go +++ b/api/graphql/models/actions/album_actions.go @@ -72,6 +72,41 @@ func Album(db *gorm.DB, user *models.User, id int) (*models.Album, error) { return &album, nil } +func AlbumPath(db *gorm.DB, user *models.User, album *models.Album) ([]*models.Album, error) { + var album_path []*models.Album + + err := db.Raw(` + WITH recursive path_albums AS ( + SELECT * FROM albums anchor WHERE anchor.id = ? + UNION + SELECT parent.* FROM path_albums child JOIN albums parent ON parent.id = child.parent_album_id + ) + SELECT * FROM path_albums WHERE id != ? + `, album.ID, album.ID).Scan(&album_path).Error + + // Make sure to only return albums this user owns + for i := len(album_path) - 1; i >= 0; i-- { + album := album_path[i] + + owns, err := user.OwnsAlbum(db, album) + if err != nil { + return nil, err + } + + if !owns { + album_path = album_path[i+1:] + break + } + + } + + if err != nil { + return nil, err + } + + return album_path, nil +} + func SetAlbumCover(db *gorm.DB, user *models.User, mediaID int) (*models.Album, error) { var media models.Media diff --git a/api/graphql/models/actions/album_actions_test.go b/api/graphql/models/actions/album_actions_test.go index 3cdc4e8d..99a76bfb 100644 --- a/api/graphql/models/actions/album_actions_test.go +++ b/api/graphql/models/actions/album_actions_test.go @@ -9,6 +9,36 @@ import ( "github.com/stretchr/testify/assert" ) +func TestAlbumPath(t *testing.T) { + db := test_utils.DatabaseTest(t) + + album := models.Album{ + Title: "Three", + Path: "/one/two/three", + ParentAlbum: &models.Album{ + Title: "Two", + Path: "/one/two", + ParentAlbum: &models.Album{ + Title: "One", + Path: "/one", + }, + }, + } + + assert.NoError(t, db.Save(&album).Error) + + user, err := models.RegisterUser(db, "user", nil, false) + assert.NoError(t, err) + + db.Model(&user).Association("Albums").Append(album.ParentAlbum.ParentAlbum) + + albumPath, err := actions.AlbumPath(db, user, &album) + assert.NoError(t, err) + assert.Len(t, albumPath, 2) + assert.Equal(t, "Two", albumPath[0].Title) + assert.Equal(t, "One", albumPath[1].Title) +} + func TestAlbumCover(t *testing.T) { db := test_utils.DatabaseTest(t) diff --git a/api/graphql/models/actions/photoview_test.db b/api/graphql/models/actions/photoview_test.db new file mode 100644 index 00000000..51babdaa Binary files /dev/null and b/api/graphql/models/actions/photoview_test.db differ diff --git a/api/graphql/models/actions/timeline_actions.go b/api/graphql/models/actions/timeline_actions.go new file mode 100644 index 00000000..9ca7ca33 --- /dev/null +++ b/api/graphql/models/actions/timeline_actions.go @@ -0,0 +1,54 @@ +package actions + +import ( + "time" + + "github.com/photoview/photoview/api/graphql/models" + "gorm.io/gorm" +) + +func MyTimeline(db *gorm.DB, user *models.User, paginate *models.Pagination, onlyFavorites *bool, fromDate *time.Time) ([]*models.Media, error) { + + query := db. + Joins("JOIN albums ON media.album_id = albums.id"). + Where("albums.id IN (?)", db.Table("user_albums").Select("user_albums.album_id").Where("user_id = ?", user.ID)) + + switch db.Dialector.Name() { + case "postgres": + query = query. + Order("DATE_TRUNC('year', date_shot) DESC"). + Order("DATE_TRUNC('month', date_shot) DESC"). + Order("DATE_TRUNC('day', date_shot) DESC"). + Order("albums.title ASC"). + Order("media.date_shot DESC") + case "sqlite": + query = query. + Order("strftime('%j', media.date_shot) DESC"). // convert to day of year 001-366 + Order("albums.title ASC"). + Order("TIME(media.date_shot) DESC") + default: + query = query. + Order("YEAR(media.date_shot) DESC"). + Order("MONTH(media.date_shot) DESC"). + Order("DAY(media.date_shot) DESC"). + Order("albums.title ASC"). + Order("TIME(media.date_shot) DESC") + } + + if fromDate != nil { + query = query.Where("media.date_shot < ?", fromDate) + } + + if onlyFavorites != nil && *onlyFavorites { + query = query.Where("media.id IN (?)", db.Table("user_media_data").Select("user_media_data.media_id").Where("user_media_data.user_id = ?", user.ID).Where("user_media_data.favorite")) + } + + query = models.FormatSQL(query, nil, paginate) + + var media []*models.Media + if err := query.Find(&media).Error; err != nil { + return nil, err + } + + return media, nil +} diff --git a/api/graphql/models/actions/timeline_actions_test.go b/api/graphql/models/actions/timeline_actions_test.go new file mode 100644 index 00000000..9e738b52 --- /dev/null +++ b/api/graphql/models/actions/timeline_actions_test.go @@ -0,0 +1,117 @@ +package actions_test + +import ( + "testing" + "time" + + "github.com/photoview/photoview/api/graphql/models" + "github.com/photoview/photoview/api/graphql/models/actions" + "github.com/photoview/photoview/api/test_utils" + "github.com/stretchr/testify/assert" +) + +func TestMyTimeline(t *testing.T) { + db := test_utils.DatabaseTest(t) + + password := "1234" + user, err := models.RegisterUser(db, "user", &password, false) + assert.NoError(t, err) + + rootAlbum := models.Album{ + Title: "root", + Path: "/photos", + } + + assert.NoError(t, db.Save(&rootAlbum).Error) + + childAlbum := models.Album{ + Title: "subalbum", + Path: "/photos/subalbum", + ParentAlbumID: &rootAlbum.ID, + } + + assert.NoError(t, db.Save(&childAlbum).Error) + + assert.NoError(t, db.Model(&user).Association("Albums").Append(&rootAlbum)) + assert.NoError(t, db.Model(&user).Association("Albums").Append(&childAlbum)) + + media := []models.Media{ + { + Title: "pic1", + Path: "/photos/pic1", + AlbumID: rootAlbum.ID, + DateShot: time.Unix(1632758400, 0), // Sep 27 2021 16:00:00 + }, + { + Title: "pic2", + Path: "/photos/pic2", + AlbumID: rootAlbum.ID, + DateShot: time.Unix(1628762400, 0), // Aug 12 2021 10:00:00 + }, + { + Title: "pic3", + Path: "/photos/subalbum/pic3", + AlbumID: childAlbum.ID, + DateShot: time.Unix(1632763800, 0), // Sep 27 2021 17:30:00 + }, + { + Title: "pic4", + Path: "/photos/subalbum/pic4", + AlbumID: childAlbum.ID, + DateShot: time.Unix(1628775900, 0), // Aug 12 2021 13:45:00 + }, + } + + assert.NoError(t, db.Save(&media).Error) + + _, err = user.FavoriteMedia(db, media[0].ID, true) + assert.NoError(t, err) + + // Add media not owned by first user + anotherUser, err := models.RegisterUser(db, "user2", &password, false) + assert.NoError(t, err) + + anotherAlbum := models.Album{ + Title: "AnotherAlbum", + Path: "/another", + } + + assert.NoError(t, db.Save(&anotherAlbum).Error) + + anotherMedia := models.Media{ + Title: "anotherPic", + Path: "/another/anotherPic", + AlbumID: anotherAlbum.ID, + } + + assert.NoError(t, db.Save(&anotherMedia).Error) + assert.NoError(t, db.Model(&anotherUser).Association("Albums").Append(&anotherAlbum)) + + t.Run("MyTimeline with no filters", func(t *testing.T) { + timelineMedia, err := actions.MyTimeline(db, user, nil, nil, nil) + + assert.NoError(t, err) + assert.Len(t, timelineMedia, 4) + + for i, title := range []string{"pic1", "pic3", "pic2", "pic4"} { + assert.Equalf(t, timelineMedia[i].Title, title, "Element %d didn't match: got %s expected %s", i, timelineMedia[i].Title, title) + } + + }) + + t.Run("MyTimeline with only favorites", func(t *testing.T) { + favorites := true + timelineMedia, err := actions.MyTimeline(db, user, nil, &favorites, nil) + + assert.NoError(t, err) + assert.Len(t, timelineMedia, 1) + }) + + t.Run("MyTimeline before date", func(t *testing.T) { + beforeDate := time.Unix(1629792000, 0) // Aug 24 2021 08:00:00 + timelineMedia, err := actions.MyTimeline(db, user, nil, nil, &beforeDate) + + assert.NoError(t, err) + assert.Len(t, timelineMedia, 2) + }) +} diff --git a/api/graphql/models/user.go b/api/graphql/models/user.go index 49d021a5..854885e0 100644 --- a/api/graphql/models/user.go +++ b/api/graphql/models/user.go @@ -8,6 +8,7 @@ import ( "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type User struct { @@ -185,3 +186,23 @@ func (user *User) OwnsAlbum(db *gorm.DB, album *Album) (bool, error) { return len(ownedParents) > 0, nil } + +// FavoriteMedia sets/clears a media as favorite for the user +func (user *User) FavoriteMedia(db *gorm.DB, mediaID int, favorite bool) (*Media, error) { + userMediaData := UserMediaData{ + UserID: user.ID, + MediaID: mediaID, + Favorite: favorite, + } + + if err := db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&userMediaData).Error; err != nil { + return nil, errors.Wrapf(err, "update user favorite media in database") + } + + var media Media + if err := db.First(&media, mediaID).Error; err != nil { + return nil, errors.Wrap(err, "get media from database after favorite update") + } + + return &media, nil +} diff --git a/api/graphql/models/user_test.go b/api/graphql/models/user_test.go index d0a1cf62..1daf38a0 100644 --- a/api/graphql/models/user_test.go +++ b/api/graphql/models/user_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/photoview/photoview/api/dataloader" "github.com/photoview/photoview/api/graphql/models" "github.com/photoview/photoview/api/test_utils" "github.com/stretchr/testify/assert" @@ -183,3 +184,48 @@ func TestUserOwnsAlbum(t *testing.T) { assert.NoError(t, err) assert.False(t, owns) } + +func TestUserFavoriteMedia(t *testing.T) { + db := test_utils.DatabaseTest(t) + + user, err := models.RegisterUser(db, "user1", nil, false) + assert.NoError(t, err) + + rootAlbum := models.Album{ + Title: "root", + Path: "/photos", + } + + assert.NoError(t, db.Save(&rootAlbum).Error) + assert.NoError(t, db.Model(&user).Association("Albums").Append(&rootAlbum)) + + media := models.Media{ + Title: "pic1", + Path: "/photos/pic1", + AlbumID: rootAlbum.ID, + } + + assert.NoError(t, db.Save(&media).Error) + + // test that it starts out being false + favourite, err := dataloader.NewUserFavoriteLoader(db).Load(&models.UserMediaData{ + UserID: user.ID, + MediaID: media.ID, + }) + + assert.NoError(t, err) + assert.False(t, favourite) + + favMedia, err := user.FavoriteMedia(db, media.ID, true) + assert.NoError(t, err) + assert.NotNil(t, favMedia) + + // test that it is now true + favourite, err = dataloader.NewUserFavoriteLoader(db).Load(&models.UserMediaData{ + UserID: user.ID, + MediaID: media.ID, + }) + + assert.NoError(t, err) + assert.True(t, favourite) +} diff --git a/api/graphql/resolvers/album.go b/api/graphql/resolvers/album.go index b89388e9..d57df3b6 100644 --- a/api/graphql/resolvers/album.go +++ b/api/graphql/resolvers/album.go @@ -131,38 +131,7 @@ func (r *albumResolver) Path(ctx context.Context, obj *models.Album) ([]*models. return empty, nil } - var album_path []*models.Album - - err := r.Database.Raw(` - WITH recursive path_albums AS ( - SELECT * FROM albums anchor WHERE anchor.id = ? - UNION - SELECT parent.* FROM path_albums child JOIN albums parent ON parent.id = child.parent_album_id - ) - SELECT * FROM path_albums WHERE id != ? - `, obj.ID, obj.ID).Scan(&album_path).Error - - // Make sure to only return albums this user owns - for i := len(album_path) - 1; i >= 0; i-- { - album := album_path[i] - - owns, err := user.OwnsAlbum(r.Database, album) - if err != nil { - return nil, err - } - - if !owns { - album_path = album_path[i+1:] - break - } - - } - - if err != nil { - return nil, err - } - - return album_path, nil + return actions.AlbumPath(r.Database, user, obj) } // Takes album_id, resets album.cover_id to 0 (null) diff --git a/api/graphql/resolvers/media.go b/api/graphql/resolvers/media.go index a6601756..66c5a711 100644 --- a/api/graphql/resolvers/media.go +++ b/api/graphql/resolvers/media.go @@ -11,7 +11,6 @@ import ( "github.com/photoview/photoview/api/graphql/models/actions" "github.com/photoview/photoview/api/scanner/face_detection" "github.com/pkg/errors" - "gorm.io/gorm/clause" ) func (r *queryResolver) MyMedia(ctx context.Context, order *models.Ordering, paginate *models.Pagination) ([]*models.Media, error) { @@ -198,22 +197,7 @@ func (r *mutationResolver) FavoriteMedia(ctx context.Context, mediaID int, favor return nil, auth.ErrUnauthorized } - userMediaData := models.UserMediaData{ - UserID: user.ID, - MediaID: mediaID, - Favorite: favorite, - } - - if err := r.Database.Clauses(clause.OnConflict{UpdateAll: true}).Create(&userMediaData).Error; err != nil { - return nil, errors.Wrapf(err, "update user favorite media in database") - } - - var media models.Media - if err := r.Database.First(&media, mediaID).Error; err != nil { - return nil, errors.Wrap(err, "get media from database after favorite update") - } - - return &media, nil + return user.FavoriteMedia(r.Database, mediaID, favorite) } func (r *mediaResolver) Faces(ctx context.Context, media *models.Media) ([]*models.ImageFace, error) { diff --git a/api/graphql/resolvers/timeline.go b/api/graphql/resolvers/timeline.go index a26f7dd0..aef3f6ea 100644 --- a/api/graphql/resolvers/timeline.go +++ b/api/graphql/resolvers/timeline.go @@ -6,6 +6,7 @@ import ( "github.com/photoview/photoview/api/graphql/auth" "github.com/photoview/photoview/api/graphql/models" + "github.com/photoview/photoview/api/graphql/models/actions" ) func (r *queryResolver) MyTimeline(ctx context.Context, paginate *models.Pagination, onlyFavorites *bool, fromDate *time.Time) ([]*models.Media, error) { @@ -14,29 +15,5 @@ func (r *queryResolver) MyTimeline(ctx context.Context, paginate *models.Paginat return nil, auth.ErrUnauthorized } - query := r.Database. - Joins("JOIN albums ON media.album_id = albums.id"). - Where("albums.id IN (?)", r.Database.Table("user_albums").Select("user_albums.album_id").Where("user_id = ?", user.ID)). - Order("YEAR(media.date_shot) DESC"). - Order("MONTH(media.date_shot) DESC"). - Order("DAY(media.date_shot) DESC"). - Order("albums.title ASC"). - Order("TIME(media.date_shot) DESC") - - if fromDate != nil { - query = query.Where("media.date_shot < ?", fromDate) - } - - if onlyFavorites != nil && *onlyFavorites == true { - query = query.Where("media.id IN (?)", r.Database.Table("user_media_data").Select("user_media_data.media_id").Where("user_media_data.user_id = ?", user.ID).Where("user_media_data.favorite = 1")) - } - - query = models.FormatSQL(query, nil, paginate) - - var media []*models.Media - if err := query.Find(&media).Error; err != nil { - return nil, err - } - - return media, nil + return actions.MyTimeline(r.Database, user, paginate, onlyFavorites, fromDate) }