Skip to content

Commit

Permalink
Change mount_series endpoint to accept and move existing series (#1225
Browse files Browse the repository at this point in the history
)

This is necessary for an upcoming admin UI feature which will allow
admins to change the path of series pages with no other blocks (part of
opencast/opencast-admin-interface#311).

This shouldn't break any existing behaviour.
Related Opencast and admin UI PRs:
opencast/opencast#6091,
opencast/opencast-admin-interface#878.
  • Loading branch information
LukasKalbertodt authored Oct 8, 2024
2 parents b042daf + 1bf3720 commit fe23b4e
Show file tree
Hide file tree
Showing 7 changed files with 286 additions and 121 deletions.
2 changes: 1 addition & 1 deletion backend/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ use self::{
subscription::Subscription,
};

pub(crate) mod err;
pub(crate) mod util;
pub(crate) mod model;

mod common;
mod context;
mod err;
mod id;
mod jwt;
mod mutation;
Expand Down
39 changes: 24 additions & 15 deletions backend/src/api/model/realm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ mod mutations;
pub(crate) use mutations::{
ChildIndex, NewRealm, RemovedRealm, UpdateRealm, UpdatedPermissions,
UpdatedRealmName, RealmSpecifier, RealmLineageComponent, CreateRealmLineageOutcome,
RemoveMountedSeriesOutcome,
};


Expand Down Expand Up @@ -231,6 +232,28 @@ impl Realm {
self.is_user_realm() && self.parent_key.is_none()
}

/// Returns all immediate children of this realm. The children are always
/// ordered by the internal index. If `childOrder` returns an ordering
/// different from `BY_INDEX`, the frontend is supposed to sort the
/// children.
pub(crate) async fn children(&self, context: &Context) -> ApiResult<Vec<Self>> {
let selection = Self::select();
let query = format!(
"select {selection} \
from realms \
where realms.parent = $1 \
order by index",
);
context.db
.query_mapped(
&query,
&[&self.key],
|row| Self::from_row_start(&row),
)
.await?
.pipe(Ok)
}

/// Returns the username of the user owning this realm tree IF it is a user
/// realm. Otherwise returns `None`.
pub(crate) fn owning_user(&self) -> Option<&str> {
Expand Down Expand Up @@ -412,21 +435,7 @@ impl Realm {
/// different from `BY_INDEX`, the frontend is supposed to sort the
/// children.
async fn children(&self, context: &Context) -> ApiResult<Vec<Self>> {
let selection = Self::select();
let query = format!(
"select {selection} \
from realms \
where realms.parent = $1 \
order by index",
);
context.db
.query_mapped(
&query,
&[&self.key],
|row| Self::from_row_start(&row),
)
.await?
.pipe(Ok)
self.children(context).await
}

/// Returns the (content) blocks of this realm.
Expand Down
59 changes: 57 additions & 2 deletions backend/src/api/model/realm/mutations.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
use std::collections::{HashMap, HashSet};

use crate::{
api::{Context, Id, err::{ApiResult, invalid_input, map_db_err}},
api::{
Context,
err::{invalid_input, map_db_err, ApiResult},
Id,
model::block::RemovedBlock,
},
auth::AuthContext,
db::types::Key,
prelude::*, auth::AuthContext,
prelude::*,
};
use super::{Realm, RealmOrder};

Expand Down Expand Up @@ -339,6 +345,41 @@ impl Realm {
info!(%id, path = realm.full_path, "Removed realm");
Ok(RemovedRealm { parent })
}

pub(crate) async fn create_lineage(
realms: Vec<RealmLineageComponent>,
context: &Context,
) -> ApiResult<CreateRealmLineageOutcome> {
context.auth.required_trusted_external()?;

if realms.len() == 0 {
return Ok(CreateRealmLineageOutcome { num_created: 0 });
}

if context.config.general.reserved_paths().any(|r| realms[0].path_segment == r) {
return Err(invalid_input!(key = "realm.path-is-reserved", "path is reserved and cannot be used"));
}

let mut parent_path = String::new();
let mut num_created = 0;
for realm in realms {
let sql = "\
insert into realms (parent, name, path_segment) \
values ((select id from realms where full_path = $1), $2, $3) \
on conflict do nothing";
let res = context.db.execute(sql, &[&parent_path, &realm.name, &realm.path_segment])
.await;
let affected = map_db_err!(res, {
if constraint == "valid_path" => invalid_input!("path invalid"),
})?;
num_created += affected as i32;

parent_path.push('/');
parent_path.push_str(&realm.path_segment);
}

Ok(CreateRealmLineageOutcome { num_created })
}
}

/// Makes sure the ID refers to a realm and returns its key.
Expand Down Expand Up @@ -379,6 +420,13 @@ impl UpdatedRealmName {
block: Some(block),
}
}

pub(crate) fn plain(name: String) -> Self {
Self {
plain: Some(name),
block: None,
}
}
}

#[derive(juniper::GraphQLInputObject)]
Expand Down Expand Up @@ -410,3 +458,10 @@ pub(crate) struct RemovedRealm {
pub struct CreateRealmLineageOutcome {
pub num_created: i32,
}

#[derive(juniper::GraphQLUnion)]
#[graphql(Context = Context)]
pub(crate) enum RemoveMountedSeriesOutcome {
RemovedRealm(RemovedRealm),
RemovedBlock(RemovedBlock),
}
154 changes: 150 additions & 4 deletions backend/src/api/model/series.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use postgres_types::ToSql;
use crate::{
api::{
Context,
err::ApiResult,
err::{invalid_input, ApiResult},
Id,
model::{
realm::Realm,
Expand All @@ -17,12 +17,16 @@ use crate::{
prelude::*,
};

use super::playlist::VideoListEntry;
use super::{
block::{BlockValue, NewSeriesBlock, VideoListLayout, VideoListOrder},
playlist::VideoListEntry,
realm::{NewRealm, RealmSpecifier, RemoveMountedSeriesOutcome, UpdatedRealmName},
};


pub(crate) struct Series {
pub(crate) key: Key,
opencast_id: String,
pub(crate) opencast_id: String,
synced_data: Option<SyncedSeriesData>,
title: String,
created: Option<DateTime<Utc>>,
Expand Down Expand Up @@ -99,6 +103,148 @@ impl Series {
.pipe(|row| Self::from_row_start(&row))
.pipe(Ok)
}

pub(crate) async fn announce(series: NewSeries, context: &Context) -> ApiResult<Self> {
context.auth.required_trusted_external()?;
Self::create(series, context).await
}

pub(crate) async fn add_mount_point(
series_oc_id: String,
target_path: String,
context: &Context,
) -> ApiResult<Realm> {
context.auth.required_trusted_external()?;

let series = Self::load_by_opencast_id(series_oc_id, context)
.await?
.ok_or_else(|| invalid_input!("`seriesId` does not refer to a valid series"))?;

let target_realm = Realm::load_by_path(target_path, context)
.await?
.ok_or_else(|| invalid_input!("`targetPath` does not refer to a valid realm"))?;

let blocks = BlockValue::load_for_realm(target_realm.key, context).await?;
if !blocks.is_empty() {
return Err(invalid_input!("series can only be mounted in empty realms"));
}

BlockValue::add_series(
Id::realm(target_realm.key),
0,
NewSeriesBlock {
series: series.id(),
show_title: false,
show_metadata: true,
order: VideoListOrder::NewToOld,
layout: VideoListLayout::Gallery,
},
context,
).await?;

let block = &BlockValue::load_for_realm(target_realm.key, context).await?[0];

Realm::rename(
target_realm.id(),
UpdatedRealmName::from_block(block.id()),
context,
).await
}

pub(crate) async fn remove_mount_point(
series_oc_id: String,
path: String,
context: &Context,
) -> ApiResult<RemoveMountedSeriesOutcome> {
context.auth.required_trusted_external()?;

let series = Self::load_by_opencast_id(series_oc_id, context)
.await?
.ok_or_else(|| invalid_input!("`seriesId` does not refer to a valid series"))?;

let old_realm = Realm::load_by_path(path, context)
.await?
.ok_or_else(|| invalid_input!("`path` does not refer to a valid realm"))?;

let blocks = BlockValue::load_for_realm(old_realm.key, context).await?;

if blocks.len() != 1 {
return Err(invalid_input!("series can only be removed if it is the realm's only block"));
}

if !matches!(&blocks[0], BlockValue::SeriesBlock(b) if b.series == Some(series.id())) {
return Err(invalid_input!("the series is not mounted on the specified realm"));
}

if old_realm.children(context).await?.len() == 0 {
// The realm has no children, so it can be removed.
let removed_realm = Realm::remove(old_realm.id(), context).await?;

return Ok(RemoveMountedSeriesOutcome::RemovedRealm(removed_realm));
}

if old_realm.name_from_block.map(Id::block) == Some(blocks[0].id()) {
// The realm has its name derived from the series block that is being removed - so the name
// shouldn't be used anymore. Ideally this would restore the previous title,
// but that isn't stored anywhere. Instead the realm is given the name of its path segment.
Realm::rename(
old_realm.id(),
UpdatedRealmName::plain(old_realm.path_segment),
context,
).await?;
}

let removed_block = BlockValue::remove(blocks[0].id(), context).await?;

Ok(RemoveMountedSeriesOutcome::RemovedBlock(removed_block))
}

pub(crate) async fn mount(
series: NewSeries,
parent_realm_path: String,
new_realms: Vec<RealmSpecifier>,
context: &Context,
) -> ApiResult<Realm> {
context.auth.required_trusted_external()?;

// Check parameters
if new_realms.iter().rev().skip(1).any(|r| r.name.is_none()) {
return Err(invalid_input!("all new realms except the last need to have a name"));
}

let parent_realm = Realm::load_by_path(parent_realm_path, context)
.await?
.ok_or_else(|| invalid_input!("`parentRealmPath` does not refer to a valid realm"))?;

if new_realms.is_empty() {
let blocks = BlockValue::load_for_realm(parent_realm.key, context).await?;
if !blocks.is_empty() {
return Err(invalid_input!("series can only be mounted in empty realms"));
}
}

// Create series
let series = Series::create(series, context).await?;

// Create realms
let target_realm = {
let mut target_realm = parent_realm;
for RealmSpecifier { name, path_segment } in new_realms {
target_realm = Realm::add(NewRealm {
// The `unwrap_or` case is only potentially used for the
// last realm, which is renamed below anyway. See the check
// above.
name: name.unwrap_or_else(|| "temporary-dummy-name".into()),
path_segment,
parent: Id::realm(target_realm.key),
}, context).await?
}
target_realm
};

// Create mount point
Self::add_mount_point(series.opencast_id, target_realm.full_path, context).await
}
}

/// Represents an Opencast series.
Expand Down Expand Up @@ -176,7 +322,7 @@ impl Node for Series {

#[derive(GraphQLInputObject)]
pub(crate) struct NewSeries {
opencast_id: String,
pub(crate) opencast_id: String,
title: String,
// TODO In the future this `struct` can be extended with additional
// (potentially optional) fields. For now we only need these.
Expand Down
Loading

0 comments on commit fe23b4e

Please sign in to comment.