diff --git a/backend/src/api/model/realm/mod.rs b/backend/src/api/model/realm/mod.rs index 1d3a3307d..561b85d9f 100644 --- a/backend/src/api/model/realm/mod.rs +++ b/backend/src/api/model/realm/mod.rs @@ -230,6 +230,18 @@ impl Realm { self.is_user_realm() && self.parent_key.is_none() } + pub(crate) async fn number_of_descendants(&self, context: &Context) -> ApiResult { + let count = context.db + .query_one( + "select count(*) from realms where full_path like $1 || '/%'", + &[&self.full_path], + ) + .await? + .get::<_, i64>(0); + + Ok(count.try_into().expect("number of descendants overflows i32")) + } + /// 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> { @@ -440,15 +452,7 @@ impl Realm { /// Returns the number of realms that are descendants of this one /// (excluding this one). Returns a number ≥ 0. async fn number_of_descendants(&self, context: &Context) -> ApiResult { - let count = context.db - .query_one( - "select count(*) from realms where full_path like $1 || '/%'", - &[&self.full_path], - ) - .await? - .get::<_, i64>(0); - - Ok(count.try_into().expect("number of descendants overflows i32")) + self.number_of_descendants(context).await } /// Returns whether the current user has the rights to add sub-pages, edit realm content, diff --git a/backend/src/api/model/realm/mutations.rs b/backend/src/api/model/realm/mutations.rs index 39508106e..463b3f4da 100644 --- a/backend/src/api/model/realm/mutations.rs +++ b/backend/src/api/model/realm/mutations.rs @@ -379,6 +379,13 @@ impl UpdatedRealmName { block: Some(block), } } + + pub(crate) fn plain(name: String) -> Self { + Self { + plain: Some(name), + block: None, + } + } } #[derive(juniper::GraphQLInputObject)] diff --git a/backend/src/api/model/series.rs b/backend/src/api/model/series.rs index 645a4290d..35b5199e0 100644 --- a/backend/src/api/model/series.rs +++ b/backend/src/api/model/series.rs @@ -176,7 +176,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. diff --git a/backend/src/api/mutation.rs b/backend/src/api/mutation.rs index 33bf2abac..ed22aec6d 100644 --- a/backend/src/api/mutation.rs +++ b/backend/src/api/mutation.rs @@ -232,12 +232,20 @@ impl Mutation { BlockValue::remove(id, context).await } - /// Atomically mount a series into an (empty) realm. + /// Atomically mount or move a series into an (empty) realm. /// Creates all the necessary realms on the path to the target /// and adds a block with the given series at the leaf. + /// + /// If the series is already mounted in `current_realm_path`, we need to check + /// if that realm has any other blocks. + /// - If it does, moving the series is not allowed. + /// - Otherwise, the series is moved, but there is some cleanup necessary: + /// - If the realm has any sub-realms: only the series block is removed. + /// - Otherwise the whole realm is removed. async fn mount_series( series: NewSeries, parent_realm_path: String, + current_realm_path: Option, #[graphql(default = vec![])] new_realms: Vec, context: &Context, @@ -268,8 +276,6 @@ impl Mutation { } } - let series = Series::create(series, context).await?; - let target_realm = { let mut target_realm = parent_realm; for RealmSpecifier { name, path_segment } in new_realms { @@ -285,6 +291,39 @@ impl Mutation { target_realm }; + let series = if let Some(current_realm_path) = current_realm_path { + let old_realm = Realm::load_by_path(current_realm_path, context) + .await? + .ok_or_else(|| invalid_input!("`currentRealmPath` does not refer to a valid realm"))?; + let blocks = BlockValue::load_for_realm(old_realm.key, context).await?; + if blocks.len() > 1 { + // This is already checked and prohibited on the front end side (admin UI), + // but I suppose checking here doesn't hurt . + return Err(invalid_input!("series can only be moved if it's current realm has no other blocks")); + } + + if old_realm.number_of_descendants(context).await? < 1 { + // The realm has no children, so it can be removed. + Realm::remove(old_realm.id(), context).await?; + } else { + // At this point we can be certain that there is only one block, which is the series block. + // Ideally we would restore the previous title, but that's not stored anywhere. So the realm + // gets the name of its path segment. + Realm::rename( + old_realm.id(), + UpdatedRealmName::plain(old_realm.path_segment), + context, + ).await?; + BlockValue::remove(blocks[0].id(), context).await?; + } + + Series::load_by_opencast_id(series.opencast_id, context) + .await? + .ok_or_else(|| invalid_input!("`seriesId` does not refer to a valid series"))? + } else { + Series::create(series, context).await? + }; + BlockValue::add_series( Id::realm(target_realm.key), 0, diff --git a/frontend/src/schema.graphql b/frontend/src/schema.graphql index 0ea3690e5..062be74d6 100644 --- a/frontend/src/schema.graphql +++ b/frontend/src/schema.graphql @@ -559,11 +559,18 @@ type Mutation { "Remove a block from a realm." removeBlock(id: ID!): RemovedBlock! """ - Atomically mount a series into an (empty) realm. + Atomically mount or move a series into an (empty) realm. Creates all the necessary realms on the path to the target and adds a block with the given series at the leaf. + + If the series is already mounted in `current_realm_path`, we need to check + if that realm has any other blocks. + - If it does, moving the series is not allowed. + - Otherwise, the series is moved, but there is some cleanup necessary: + - If the realm has any sub-realms: only the series block is removed. + - Otherwise the whole realm is removed. """ - mountSeries(series: NewSeries!, parentRealmPath: String!, newRealms: [RealmSpecifier!]!): Realm! + mountSeries(series: NewSeries!, parentRealmPath: String!, currentRealmPath: String, newRealms: [RealmSpecifier!]!): Realm! } "Exactly one of `plain` or `block` has to be non-null."