-
Notifications
You must be signed in to change notification settings - Fork 18
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add playlists to sync code, DB & API and add playlist direct links (#…
…1159) This is the first PR about playlists for Tobira. It requires opencast/opencast#5734 to be useful. This does mainly two things: - Tobira now understands the playlist data that Opencast is sending via Harvest API and stores it in Tobira's DB. - Adds Playlists to Tobira's GraphQL API (without any mutations yet) - Add the two direct link routes so that playlists can be viewed given their ID. This required making the core "series block" code more generic to also work with playlists. Two main features are still missing: - Playlist blocks (that can be placed on pages) - "My Playlists": management area to create, delete and edit playlists For these two, I will create separate issues. Therefore: Closes #937 ## Screenshots Unfortunately, there are no playlists on the test deployment yet. So here are some screenshots: ![image](https://github.com/elan-ev/tobira/assets/7419664/b64db947-c384-498c-b71f-bb216cbfe5cf) ![image](https://github.com/elan-ev/tobira/assets/7419664/39d0fe04-f077-4938-b9e5-b19c36223ca9) ![image](https://github.com/elan-ev/tobira/assets/7419664/fd50b965-3d93-44c7-b410-86c42813f134) As you can see: - Currently this looks very very similar to how series are displayed. - For playlists, there is a new order mode "Playlist order" which shows the videos in the order that the playlist specifies. This is the default of course. But users can still sort by date and title, if they so wish. - In the "Playlist order" mode, video that are missing or cannot be accessed are shown with a placeholder. - In any other ordering mode, only a small note is shown at the bottom. (Since we have no information about the missing items, we cannot properly sort them. They would be sorted all at the very end anyway.) ## Open questions - Should the "missing items" note also be shown for series? So far we don't: we simply do not show the videos that the user has no read access to. - What about the specific formulations for the note and the missing video placeholders? Specifically: - German: ```yaml missing-video: Video nicht gefunden unauthorized: Fehlende Berechtigung hidden-items_one: 'Ein Video wurde nicht gefunden oder Sie haben keinen Zugriff darauf.' hidden-items_other: '{{count}} Videos wurden nicht gefunden oder Sie haben keinen Zugriff darauf.' ``` - English: ```yaml missing-video: Video not found unauthorized: Missing permissions hidden-items_one: 'One video is missing or requires additional permissions to view.' hidden-items_other: '{{count}} videos are missing or require additional permissions to view.' ``` - How should we call the "order as specified by the playlist" ordering mode? Currently I called it "Playlist order" (en) / "Wie Playlist" (de). Better ideas appreciated. --- Can be reviewed commit by commit.
- Loading branch information
Showing
24 changed files
with
1,618 additions
and
962 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,163 @@ | ||
use juniper::graphql_object; | ||
use postgres_types::ToSql; | ||
|
||
use crate::{ | ||
api::{ | ||
common::NotAllowed, err::ApiResult, Context, Id, Node | ||
}, | ||
db::{types::Key, util::{impl_from_db, select}}, | ||
prelude::*, | ||
}; | ||
|
||
use super::event::AuthorizedEvent; | ||
|
||
|
||
#[derive(juniper::GraphQLUnion)] | ||
#[graphql(Context = Context)] | ||
pub(crate) enum Playlist { | ||
Playlist(AuthorizedPlaylist), | ||
NotAllowed(NotAllowed), | ||
} | ||
|
||
pub(crate) struct AuthorizedPlaylist { | ||
pub(crate) key: Key, | ||
opencast_id: String, | ||
title: String, | ||
description: Option<String>, | ||
|
||
read_roles: Vec<String>, | ||
#[allow(dead_code)] // TODO | ||
write_roles: Vec<String>, | ||
} | ||
|
||
|
||
#[derive(juniper::GraphQLUnion)] | ||
#[graphql(Context = Context)] | ||
pub(crate) enum PlaylistEntry { | ||
Event(AuthorizedEvent), | ||
NotAllowed(NotAllowed), | ||
Missing(Missing), | ||
} | ||
|
||
/// The data referred to by a playlist entry was not found. | ||
pub(crate) struct Missing; | ||
crate::api::util::impl_object_with_dummy_field!(Missing); | ||
|
||
|
||
impl_from_db!( | ||
AuthorizedPlaylist, | ||
select: { | ||
playlists.{ id, opencast_id, title, description, read_roles, write_roles }, | ||
}, | ||
|row| { | ||
Self { | ||
key: row.id(), | ||
opencast_id: row.opencast_id(), | ||
title: row.title(), | ||
description: row.description(), | ||
read_roles: row.read_roles(), | ||
write_roles: row.write_roles(), | ||
} | ||
}, | ||
); | ||
|
||
impl Playlist { | ||
pub(crate) async fn load_by_id(id: Id, context: &Context) -> ApiResult<Option<Self>> { | ||
if let Some(key) = id.key_for(Id::SERIES_KIND) { | ||
Self::load_by_key(key, context).await | ||
} else { | ||
Ok(None) | ||
} | ||
} | ||
|
||
pub(crate) async fn load_by_key(key: Key, context: &Context) -> ApiResult<Option<Self>> { | ||
Self::load_by_any_id("id", &key, context).await | ||
} | ||
|
||
pub(crate) async fn load_by_opencast_id(id: String, context: &Context) -> ApiResult<Option<Self>> { | ||
Self::load_by_any_id("opencast_id", &id, context).await | ||
} | ||
|
||
async fn load_by_any_id( | ||
col: &str, | ||
id: &(dyn ToSql + Sync), | ||
context: &Context, | ||
) -> ApiResult<Option<Self>> { | ||
let selection = AuthorizedPlaylist::select(); | ||
let query = format!("select {selection} from playlists where {col} = $1"); | ||
context.db | ||
.query_opt(&query, &[id]) | ||
.await? | ||
.map(|row| { | ||
let playlist = AuthorizedPlaylist::from_row_start(&row); | ||
if context.auth.overlaps_roles(&playlist.read_roles) { | ||
Playlist::Playlist(playlist) | ||
} else { | ||
Playlist::NotAllowed(NotAllowed) | ||
} | ||
}) | ||
.pipe(Ok) | ||
} | ||
} | ||
|
||
/// Represents an Opencast series. | ||
#[graphql_object(Context = Context)] | ||
impl AuthorizedPlaylist { | ||
fn id(&self) -> Id { | ||
Node::id(self) | ||
} | ||
|
||
fn opencast_id(&self) -> &str { | ||
&self.opencast_id | ||
} | ||
|
||
fn title(&self) -> &str { | ||
&self.title | ||
} | ||
|
||
fn description(&self) -> Option<&str> { | ||
self.description.as_deref() | ||
} | ||
|
||
async fn entries(&self, context: &Context) -> ApiResult<Vec<PlaylistEntry>> { | ||
let (selection, mapping) = select!( | ||
found: "events.id is not null", | ||
event: AuthorizedEvent, | ||
); | ||
let query = format!("\ | ||
with entries as (\ | ||
select unnest(entries) as entry \ | ||
from playlists \ | ||
where id = $1\ | ||
), | ||
event_ids as (\ | ||
select (entry).content_id as id \ | ||
from entries \ | ||
where (entry).type = 'event'\ | ||
) | ||
select {selection} from event_ids \ | ||
left join events on events.opencast_id = event_ids.id\ | ||
"); | ||
context.db | ||
.query_mapped(&query, dbargs![&self.key], |row| { | ||
if !mapping.found.of::<bool>(&row) { | ||
return PlaylistEntry::Missing(Missing); | ||
} | ||
|
||
let event = AuthorizedEvent::from_row(&row, mapping.event); | ||
if !context.auth.overlaps_roles(&event.read_roles) { | ||
return PlaylistEntry::NotAllowed(NotAllowed); | ||
} | ||
|
||
PlaylistEntry::Event(event) | ||
}) | ||
.await? | ||
.pipe(Ok) | ||
} | ||
} | ||
|
||
impl Node for AuthorizedPlaylist { | ||
fn id(&self) -> Id { | ||
Id::playlist(self.key) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
select prepare_randomized_ids('playlist'); | ||
|
||
|
||
create type playlist_entry_type as enum ('event'); | ||
|
||
-- All fields should never be null. | ||
create type playlist_entry as ( | ||
-- The Opencast ID of this entry. Not a UUID. | ||
opencast_id bigint, | ||
|
||
type playlist_entry_type, | ||
|
||
-- The Opencast ID of the referenced content. | ||
content_id text | ||
); | ||
|
||
create table playlists ( | ||
id bigint primary key default randomized_id('playlist'), | ||
opencast_id text not null unique, | ||
|
||
title text not null, | ||
description text, | ||
creator text, | ||
|
||
entries playlist_entry[] not null, | ||
|
||
read_roles text[] not null, | ||
write_roles text[] not null, | ||
|
||
updated timestamp with time zone not null, | ||
|
||
constraint read_roles_no_null_value check (array_position(read_roles, null) is null), | ||
constraint write_roles_no_null_value check (array_position(write_roles, null) is null), | ||
constraint entries_no_null_value check (array_position(entries, null) is null) | ||
); | ||
|
||
|
||
-- To perform queries like `write_roles && $1` on the whole table. Probably just | ||
-- to list all playlists that a user has write access to. | ||
create index idx_playlists_write_roles on playlists using gin (write_roles); | ||
|
||
|
||
-- Search index --------------------------------------------------------------- | ||
|
||
-- TODO |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.