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

Add playlists to sync code, DB & API and add playlist direct links #1159

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
1 change: 1 addition & 0 deletions backend/src/api/id.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ define_kinds![
block = b"bl",
series = b"sr",
event = b"ev",
playlist = b"pl",
search_realm = b"rs",
search_event = b"es",
search_series = b"ss",
Expand Down
39 changes: 13 additions & 26 deletions backend/src/api/model/event.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
use std::fmt;

use chrono::{DateTime, Utc};
use postgres_types::ToSql;
use serde::{Serialize, Deserialize};
Expand All @@ -18,27 +16,26 @@ use crate::{
util::{impl_from_db, select},
},
prelude::*,
util::lazy_format,
};


#[derive(Debug)]
pub(crate) struct AuthorizedEvent {
key: Key,
series: Option<Key>,
opencast_id: String,
is_live: bool,
pub(crate) key: Key,
pub(crate) series: Option<Key>,
pub(crate) opencast_id: String,
pub(crate) is_live: bool,

title: String,
description: Option<String>,
created: DateTime<Utc>,
creators: Vec<String>,
pub(crate) title: String,
pub(crate) description: Option<String>,
pub(crate) created: DateTime<Utc>,
pub(crate) creators: Vec<String>,

metadata: ExtraMetadata,
read_roles: Vec<String>,
write_roles: Vec<String>,
pub(crate) metadata: ExtraMetadata,
pub(crate) read_roles: Vec<String>,
pub(crate) write_roles: Vec<String>,

synced_data: Option<SyncedEventData>,
pub(crate) synced_data: Option<SyncedEventData>,
}

#[derive(Debug)]
Expand Down Expand Up @@ -300,14 +297,12 @@ impl AuthorizedEvent {

pub(crate) async fn load_for_series(
series_key: Key,
order: EventSortOrder,
context: &Context,
) -> ApiResult<Vec<Self>> {
let selection = Self::select();
let query = format!(
"select {selection} from events \
where series = $2 and (read_roles || 'ROLE_ADMIN'::text) && $1 {}",
order.to_sql(),
where series = $2 and (read_roles || 'ROLE_ADMIN'::text) && $1",
);
context.db
.query_mapped(
Expand Down Expand Up @@ -557,14 +552,6 @@ impl Default for EventSortOrder {
}
}

impl EventSortOrder {
/// Returns an SQL query fragment like `order by foo asc`.
fn to_sql(&self) -> impl fmt::Display {
let Self { column, direction } = *self;
lazy_format!("order by {} {}", column.to_sql(), direction.to_sql())
}
}

impl EventSortColumn {
fn to_sql(self) -> &'static str {
match self {
Expand Down
1 change: 1 addition & 0 deletions backend/src/api/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub(crate) mod acl;
pub(crate) mod block;
pub(crate) mod event;
pub(crate) mod known_roles;
pub(crate) mod playlist;
pub(crate) mod realm;
pub(crate) mod search;
pub(crate) mod series;
Expand Down
163 changes: 163 additions & 0 deletions backend/src/api/model/playlist/mod.rs
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)
}
}
7 changes: 3 additions & 4 deletions backend/src/api/model/series.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::{
Id,
model::{
realm::Realm,
event::{AuthorizedEvent, EventSortOrder}
event::AuthorizedEvent,
},
Node,
},
Expand Down Expand Up @@ -145,9 +145,8 @@ impl Series {
.pipe(Ok)
}

#[graphql(arguments(order(default = Default::default())))]
async fn events(&self, order: EventSortOrder, context: &Context) -> ApiResult<Vec<AuthorizedEvent>> {
AuthorizedEvent::load_for_series(self.key, order, context).await
async fn events(&self, context: &Context) -> ApiResult<Vec<AuthorizedEvent>> {
AuthorizedEvent::load_for_series(self.key, context).await
}
}

Expand Down
11 changes: 11 additions & 0 deletions backend/src/api/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use super::{
model::{
event::{AuthorizedEvent, Event},
known_roles::{self, KnownGroup, KnownUsersSearchOutcome},
playlist::Playlist,
realm::Realm,
search::{self, EventSearchOutcome, Filters, SearchOutcome, SeriesSearchOutcome},
series::Series,
Expand Down Expand Up @@ -66,6 +67,16 @@ impl Query {
Series::load_by_id(id, context).await
}

/// Returns a playlist by its Opencast ID.
async fn playlist_by_opencast_id(id: String, context: &Context) -> ApiResult<Option<Playlist>> {
Playlist::load_by_opencast_id(id, context).await
}

/// Returns a playlist by its ID.
async fn playlist_by_id(id: Id, context: &Context) -> ApiResult<Option<Playlist>> {
Playlist::load_by_id(id, context).await
}

/// Returns the current user.
fn current_user(context: &Context) -> Option<&User> {
match &context.auth {
Expand Down
2 changes: 1 addition & 1 deletion backend/src/db/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ async fn clear(db: &mut Db, config: &Config, yes: bool) -> Result<()> {

// Next we drop all types.
for ty in types {
tx.execute(&format!("drop type if exists {ty}"), &[]).await?;
tx.execute(&format!("drop type if exists {ty} cascade"), &[]).await?;
trace!("Dropped type {ty}");
}

Expand Down
1 change: 1 addition & 0 deletions backend/src/db/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,4 +366,5 @@ static MIGRATIONS: Lazy<BTreeMap<u64, Migration>> = include_migrations![
31: "series-metadata",
32: "custom-actions",
33: "event-slide-text-and-segments",
34: "playlists",
];
45 changes: 45 additions & 0 deletions backend/src/db/migrations/34-playlists.sql
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 events using gin (write_roles);


-- Search index ---------------------------------------------------------------

-- TODO
17 changes: 17 additions & 0 deletions backend/src/db/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,23 @@ pub enum SeriesState {
Waiting,
}

/// Represents the `playlist_entry_type` type defined in `31-playlists.sql`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromSql, ToSql)]
#[postgres(name = "playlist_entry_type")]
pub enum PlaylistEntryType {
#[postgres(name = "event")]
Event,
}

/// Represents the `playlist_entry` type defined in `31-playlists.sql`.
#[derive(Debug, FromSql, ToSql, Clone)]
#[postgres(name = "playlist_entry")]
pub struct PlaylistEntry {
pub opencast_id: i64,
#[postgres(name = "type")]
pub ty: PlaylistEntryType,
pub content_id: String,
}

/// Represents extra metadata in the DB. Is a map from "namespace" to a
/// `string -> string array` map.
Expand Down