Skip to content

Commit

Permalink
Add configurable deletion modes for series, events and playlists
Browse files Browse the repository at this point in the history
Automatic realm deletion can now be configured for series, events (and
playlists, though that won't do anything for now).
If configured, Tobira will delete the corresponding realm page(s) when they meet
the following conditions:
- Realm name is derived from the deleted item.
- Realm has no sub realms.
- Realm has no other blocks than the deleted item.

The last condition can be disabled by adding `:eager` to the deletion mode.
  • Loading branch information
owi92 committed Feb 24, 2025
1 parent a793a66 commit 2a78875
Show file tree
Hide file tree
Showing 5 changed files with 198 additions and 8 deletions.
11 changes: 10 additions & 1 deletion backend/src/api/model/block/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
//! Blocks that make up the content of realm pages.
use std::fmt;
use juniper::{graphql_interface, graphql_object, GraphQLEnum};
use postgres_types::{FromSql, ToSql};
use serde::Serialize;

use crate::{
api::{
Expand Down Expand Up @@ -69,8 +71,9 @@ macro_rules! impl_block {
}


#[derive(Debug, Clone, Copy, FromSql)]
#[derive(Debug, Clone, Copy, FromSql, Serialize)]
#[postgres(name = "block_type")]
#[serde(rename_all = "lowercase")]
pub(crate) enum BlockType {
#[postgres(name = "title")]
Title,
Expand All @@ -84,6 +87,12 @@ pub(crate) enum BlockType {
Playlist,
}

impl fmt::Display for BlockType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.serialize(f)
}
}

#[derive(Debug, Clone, Copy, FromSql, ToSql, GraphQLEnum)]
#[postgres(name = "video_list_order")]
pub(crate) enum VideoListOrder {
Expand Down
69 changes: 64 additions & 5 deletions backend/src/sync/harvest/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ use crate::{
auth::{is_special_eth_role, ROLE_ADMIN, ROLE_ANONYMOUS, ETH_ROLE_CREDENTIALS_RE},
config::Config,
db::{
self, types::{Credentials, EventCaption, EventSegment, EventState, EventTrack, SeriesState}, DbConnection
self,
types::{Credentials, EventCaption, EventSegment, EventState, EventTrack, SeriesState},
DbConnection,
},
prelude::*,
};
Expand Down Expand Up @@ -230,7 +232,9 @@ async fn store_in_db(
upserted_events += 1;
}

HarvestItem::EventDeleted { id: opencast_id, .. } => {
HarvestItem::EventDeleted { id: ref opencast_id, .. } => {
remove_realms(db, config, &item).await?;

let rows_affected = db
.execute("delete from all_events where opencast_id = $1", &[&opencast_id])
.await?;
Expand Down Expand Up @@ -278,7 +282,9 @@ async fn store_in_db(
upserted_series += 1;
},

HarvestItem::SeriesDeleted { id: opencast_id, .. } => {
HarvestItem::SeriesDeleted { id: ref opencast_id, .. } => {
remove_realms(db, config, &item).await?;

// We simply remove the series and do not care about any linked
// events. The foreign key has `on delete set null`. That's
// what we want: treat it as if the event has no series
Expand Down Expand Up @@ -323,7 +329,10 @@ async fn store_in_db(
upserted_playlists += 1;
}

HarvestItem::PlaylistDeleted { id: opencast_id, .. } => {
HarvestItem::PlaylistDeleted { id: ref opencast_id, .. } => {
// This doesn't have any effect since realms can't derive their name from playlists yet.
remove_realms(db, config, &item).await?;

let rows_affected = db
.execute("delete from playlists where opencast_id = $1", &[&opencast_id])
.await?;
Expand Down Expand Up @@ -416,7 +425,7 @@ fn hashed_eth_credentials(read_roles: &[String]) -> Option<Credentials> {
read_roles.iter().find_map(|role| {
ETH_ROLE_CREDENTIALS_RE.captures(role).map(|captures| Credentials {
name: format!("sha1:{}", &captures[1]),
password: format!("sha1:{}", &captures[2]),
password: format!("sha1:{}", &captures[2]),
})
})
}
Expand Down Expand Up @@ -469,3 +478,53 @@ async fn upsert(
let statement = db.prepare_cached(&*query).await?;
Ok(db.query_one(&statement, &values).await?.get::<_, i64>(0))
}


/// Removes realms that reference a deleted series, event, or playlist,
/// if the configuration allows it.
///
/// If realm deletion is enabled for the given type, the function:
/// 1. Finds all realms whose names are derived from the deleted series/event/playlist.
/// 2. Ensures that those realms have no child realms.
/// 3. If `eager = false`, ensures that the realm has **only one** block (the deleted one).
/// 4. Deletes the qualifying realms from the database.
async fn remove_realms(
db: &deadpool_postgres::Transaction<'_>,
config: &Config,
item: &HarvestItem,
) -> Result<(), tokio_postgres::Error> {
let Some(props) = item.deleted_props(config) else {
return Ok(());
};

let block_count_condition = if props.eager {
String::new()
} else {
"and (select count(*) from blocks b2 where b2.realm = r.id) = 1".to_owned()
};

let query = format!(" \
delete from realms r \
where r.name_from_block in ( \
select b.id from blocks b \
join {table_name} t on b.{block_type} = t.id \
where t.opencast_id = $1 and b.type = '{block_type}' \
) \
and not exists ( \
select 1 from realms child where child.parent = r.id \
) \
{block_count_condition}",
table_name = props.table_name,
block_type = props.block_type.to_string(),
);

let rows_affected = db.execute(&query, &[&props.id]).await?;
info!(
"Removed {rows_affected} realms referencing {} {} (eager={})",
props.block_type,
props.id,
props.eager
);

Ok(())
}
44 changes: 43 additions & 1 deletion backend/src/sync/harvest/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::{
db::types::{CustomActions, EventCaption, EventTrack, EventSegment},
api::model::block::BlockType,
db::types::{CustomActions, EventCaption, EventSegment, EventTrack},
model::ExtraMetadata,
sync::DeletionMode,
Config,
};


Expand Down Expand Up @@ -108,6 +111,13 @@ pub struct Playlist {
pub updated: DateTime<Utc>,
}

pub struct DeletedItemProps<'a> {
pub table_name: &'static str,
pub block_type: BlockType,
pub id: &'a str,
pub eager: bool,
}


impl HarvestItem {
pub(crate) fn updated(&self) -> Option<DateTime<Utc>> {
Expand All @@ -121,6 +131,38 @@ impl HarvestItem {
Self::Unknown(_) => None,
}
}

pub fn deleted_props<'a>(&'a self, config: &Config) -> Option<DeletedItemProps<'a>> {
config.sync.auto_delete_pages.iter().find_map(|mode| {
match (self, mode) {
(HarvestItem::SeriesDeleted { id, .. }, DeletionMode::Series { eager }) => Some(
DeletedItemProps {
table_name: "series",
block_type: BlockType::Series,
id,
eager: *eager,
}
),
(HarvestItem::EventDeleted { id, .. }, DeletionMode::Events { eager }) => Some(
DeletedItemProps {
table_name: "events",
block_type: BlockType::Video,
id,
eager: *eager,
}
),
(HarvestItem::PlaylistDeleted { id, .. }, DeletionMode::Playlists { eager }) => Some(
DeletedItemProps {
table_name: "playlists",
block_type: BlockType::Playlist,
id,
eager: *eager,
}
),
_ => None,
}
})
}
}

#[derive(Debug, Deserialize)]
Expand Down
61 changes: 60 additions & 1 deletion backend/src/sync/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use core::fmt;
use std::time::Duration;
use std::{str::FromStr, time::Duration};

use serde::{Deserialize, Deserializer, Serialize};

use crate::{config::Config, db::DbConnection, prelude::*};

Expand Down Expand Up @@ -72,6 +74,26 @@ pub(crate) struct SyncConfig {
/// load on Opencast, increase to speed up download a bit.
#[config(default = 8)]
concurrent_download_tasks: u8,

/// List of deletion modes that which, if any, realm pages are to be deleted
/// automatically when the corresponding Opencast item (series, event or playlist)
/// is deleted.
/// If configured, Tobira will delete the corresponding realm page(s) when they meet
/// the following conditions:
/// - Realm name is derived from the deleted item.
/// - Realm has no sub realms.
/// - Realm has no other blocks than the deleted item.
///
/// The last option can be disabled by adding `:eager` to the deletion mode.
///
/// Example:
/// ```
/// auto_delete_pages = ["series", "events:eager"]
/// ```
///
/// This would delete series pages in non-eager mode and event pages in eager mode.
#[config(default = [])]
pub auto_delete_pages: Vec<DeletionMode>,
}

/// Version of the Tobira-module API in Opencast.
Expand Down Expand Up @@ -122,3 +144,40 @@ impl VersionResponse {
self.version.parse().expect("invalid version string")
}
}


#[derive(Debug, Serialize)]
pub enum DeletionMode {
Series { eager: bool },
Events { eager: bool },
Playlists { eager: bool },
}

impl FromStr for DeletionMode {
type Err = String;

fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"series" => Ok(Self::Series { eager: false }),
"series:eager" => Ok(Self::Series { eager: true }),

"events" => Ok(Self::Events { eager: false }),
"events:eager" => Ok(Self::Events { eager: true }),

"playlists" => Ok(Self::Playlists { eager: false }),
"playlists:eager" => Ok(Self::Playlists { eager: true }),

other => Err(format!("Invalid auto_delete_pages value: {}", other)),
}
}
}

impl<'de> Deserialize<'de> for DeletionMode {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
DeletionMode::from_str(&s).map_err(serde::de::Error::custom)
}
}
21 changes: 21 additions & 0 deletions docs/docs/setup/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,27 @@
# Default value: 8
#concurrent_download_tasks = 8

# List of deletion modes that which, if any, realm pages are to be deleted
# automatically when the corresponding Opencast item (series, event or playlist)
# is deleted.
# If configured, Tobira will delete the corresponding realm page(s) when they meet
# the following conditions:
# - Realm name is derived from the deleted item.
# - Realm has no sub realms.
# - Realm has no other blocks than the deleted item.
#
# The last option can be disabled by adding `:eager` to the deletion mode.
#
# Example:
# ```
# auto_delete_pages = ["series", "events:eager"]
# ```
#
# This would delete series pages in non-eager mode and event pages in eager mode.
#
# Default value: []
#auto_delete_pages = []


[meili]
# The access key. This can be the master key, but ideally should be an API
Expand Down

0 comments on commit 2a78875

Please sign in to comment.