Skip to content

Commit

Permalink
Add endpoints to move existing series
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.
  • Loading branch information
owi92 committed Oct 8, 2024
1 parent b042daf commit 274a1f0
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 274a1f0

Please sign in to comment.