diff --git a/Cargo.lock b/Cargo.lock index 165fb01a8235f24bb474ca4b6bab1545f27e26bf..33c33d2241d5966243e085c507a73ca7ae5123d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1227,7 +1227,7 @@ dependencies = [ "bitflags 2.9.0", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools 0.11.0", "lazy_static", "lazycell", "log", @@ -4368,15 +4368,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.14.0" @@ -6071,9 +6062,9 @@ dependencies = [ [[package]] name = "opentalk-types-api-v1" -version = "0.33.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8565d68e6994ab6d0d6b5bdbc37d94c4edff34900215309c88d0fe3e248a5924" +checksum = "a7ffb9ca739d7b748de0e188c7a44f5fed08ecdd2d7063ac383e2c92eb2ba527" dependencies = [ "actix-web", "base64 0.22.1", @@ -6094,9 +6085,9 @@ dependencies = [ [[package]] name = "opentalk-types-common" -version = "0.32.0" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc7b6f5082ce4410a8a4b483a33ac523863a974344be8ffeeb339e37624a9fd" +checksum = "13523221c1cf75be9cf89b5149542013472d58eafdadb085f6bdd93def143921" dependencies = [ "actix-http", "actix-web-httpauth", @@ -6336,9 +6327,9 @@ dependencies = [ [[package]] name = "opentalk-types-signaling-training-participation-report" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8658e3a6b4303b0d3f637d152029f3ee206ff1bdfdd411bd680094c15f9b004" +checksum = "94dc459b07a8f2f60c2bd7c8c5713b38b053a9527e3995dee7c7ccd904040c4e" dependencies = [ "opentalk-types-common", "opentalk-types-signaling", @@ -7147,7 +7138,7 @@ checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes", "heck 0.5.0", - "itertools 0.12.1", + "itertools 0.11.0", "log", "multimap", "once_cell", @@ -7187,7 +7178,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.11.0", "proc-macro2", "quote", "syn", diff --git a/Cargo.toml b/Cargo.toml index 77408c9b7307df4e90f0bb22d4d50cdd1807a986..56b9a9d8bf29e49673c3eb31c739b46bb42a8d29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,8 +92,8 @@ opentalk-mail-worker-protocol = "0.32" opentalk-nextcloud-client = { version = "0.2.0", default-features = false, features = [ "rustls-tls-native-roots", ] } -opentalk-types-api-v1 = "0.33" -opentalk-types-common = "0.32" +opentalk-types-api-v1 = "0.34" +opentalk-types-common = "0.32.1" opentalk-types-signaling = "0.32" opentalk-types-signaling-breakout = "0.32" opentalk-types-signaling-chat = "0.32" @@ -108,7 +108,7 @@ opentalk-types-signaling-recording-service = "0.32" opentalk-types-signaling-shared-folder = "0.32" opentalk-types-signaling-subroom-audio = "0.32" opentalk-types-signaling-timer = "0.32" -opentalk-types-signaling-training-participation-report = "0.2" +opentalk-types-signaling-training-participation-report = "0.3" opentalk-types-signaling-whiteboard = "0.32" opentalk-version = "0.1.1" opentelemetry = { version = "0.27", default-features = false, features = [ diff --git a/api/controller/frontend_api.yaml b/api/controller/frontend_api.yaml index e72ceaa0fcee629fc004747353872957ef8c2476..be3db6e1f3e124db5a74427b0363fedfa8e54fbd 100644 --- a/api/controller/frontend_api.yaml +++ b/api/controller/frontend_api.yaml @@ -3095,6 +3095,13 @@ components: title: $ref: "#/components/schemas/EventTitle" description: Title of the event + training_participation_report: + $ref: "#/components/schemas/TrainingParticipationReportParameterSet" + description: |- + The training participation report parameter set for the event. + + When present, the training participation report will be started + automatically in the meeting. type: $ref: "#/components/schemas/EventType" description: "Must always be `instance`" @@ -3158,6 +3165,13 @@ components: timezone: Europe/Berlin status: ok title: Team Event + training_participation_report: + checkpoint_interval: + after: 300 + within: 400 + initial_checkpoint_delay: + after: 100 + within: 200 type: recurring updated_at: "2024-07-20T14:16:19Z" updated_by: @@ -3315,6 +3329,13 @@ components: streaming_endpoint: "https://ingress.streaming.example.com/" streaming_key: aabbccddeeff title: Team Event + training_participation_report: + checkpoint_interval: + after: 300 + within: 400 + initial_checkpoint_delay: + after: 100 + within: 200 type: single updated_at: "2024-07-20T14:16:19Z" updated_by: @@ -3453,6 +3474,13 @@ components: Title of the event For display purposes + training_participation_report: + $ref: "#/components/schemas/TrainingParticipationReportParameterSet" + description: |- + The training participation report parameter set for the event. + + When present, the training participation report will be started + automatically in the meeting. type: $ref: "#/components/schemas/EventType" description: |- @@ -3520,6 +3548,13 @@ components: streaming_endpoint: "https://ingress.streaming.example.com/" streaming_key: aabbccddeeff title: Team Event + training_participation_report: + checkpoint_interval: + after: 300 + within: 400 + initial_checkpoint_delay: + after: 100 + within: 200 type: single updated_at: "2024-07-20T14:16:19Z" updated_by: @@ -3666,6 +3701,13 @@ components: timezone: Europe/Berlin status: ok title: Team Event + training_participation_report: + checkpoint_interval: + after: 300 + within: 400 + initial_checkpoint_delay: + after: 100 + within: 200 type: recurring updated_at: "2024-07-20T14:16:19Z" updated_by: @@ -4037,6 +4079,13 @@ components: title: $ref: "#/components/schemas/EventTitle" description: Patch the title of th event + training_participation_report: + $ref: "#/components/schemas/TrainingParticipationReportParameterSet" + description: |- + The training participation report parameter set for the event. + + When present, the training participation report will be started + automatically in the meeting. waiting_room: type: boolean description: Patch the presence of a waiting room @@ -4243,6 +4292,13 @@ components: title: $ref: "#/components/schemas/EventTitle" description: Title of the event + training_participation_report: + $ref: "#/components/schemas/TrainingParticipationReportParameterSet" + description: |- + The training participation report parameter set for the event. + + When present, the training participation report will be started + automatically in the meeting. waiting_room: type: boolean description: Should the created event have a waiting room? @@ -4269,6 +4325,13 @@ components: streaming_endpoint: "https://ingress.streaming.example.com/" streaming_key: aabbccddeeff title: Teammeeting + training_participation_report: + checkpoint_interval: + after: 300 + within: 400 + initial_checkpoint_delay: + after: 100 + within: 200 waiting_room: false PostInviteRequestBody: type: object @@ -4983,6 +5046,26 @@ components: TicketToken: type: string description: A ticket token + TimeRange: + type: object + description: A time range within which checkpoints can be randomly created + required: + - after + - within + properties: + after: + type: integer + format: int64 + description: The earliest number of seconds after which the checkpoint can be created. + minimum: 0 + within: + type: integer + format: int64 + description: "The number of seconds within which the checkpoint can be created after the `after` value." + minimum: 0 + example: + after: 1200 + within: 600 TimeZone: type: string description: A time zone @@ -4995,6 +5078,26 @@ components: A UTC DateTime wrapper that implements ToRedisArgs and FromRedisValue. The values are stores as unix timestamps in redis. + TrainingParticipationReportParameterSet: + type: object + description: The parameters for a training participant report checkpoint procedure. + required: + - initial_checkpoint_delay + - checkpoint_interval + properties: + checkpoint_interval: + $ref: "#/components/schemas/TimeRange" + description: The time range definition for the subsequent checkpoints. + initial_checkpoint_delay: + $ref: "#/components/schemas/TimeRange" + description: The time range definition for the initial checkpoint delay. + example: + checkpoint_interval: + after: 300 + within: 400 + initial_checkpoint_delay: + after: 100 + within: 200 TurnServer: type: object description: TURN access credentials for users. diff --git a/crates/opentalk-controller-core/src/api/v1/events/instances.rs b/crates/opentalk-controller-core/src/api/v1/events/instances.rs index 56d50276b77fea0881c4171646a4f5c692c4d7c2..36dc1ec4efbb128db70552000767de71866d3227 100644 --- a/crates/opentalk-controller-core/src/api/v1/events/instances.rs +++ b/crates/opentalk-controller-core/src/api/v1/events/instances.rs @@ -36,6 +36,7 @@ use opentalk_types_api_v1::{ use opentalk_types_common::{ events::{invites::EventInviteStatus, EventId}, shared_folders::SharedFolder, + training_participation_report::TrainingParticipationReportParameterSet, }; use rrule::RRuleSet; @@ -151,8 +152,16 @@ async fn get_event_instances_inner( let mut conn = db.get_conn().await?; - let (event, invite, room, sip_config, is_favorite, shared_folder, tariff) = - Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; + let ( + event, + invite, + room, + sip_config, + is_favorite, + shared_folder, + tariff, + training_participation_report_parameter_set, + ) = Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; let (invitees, invitees_truncated) = super::get_invitees_for_event(settings, &mut conn, event.id, invitees_max).await?; @@ -198,6 +207,9 @@ async fn get_event_instances_inner( .fetch(settings, &mut conn) .await?; + let training_participation_report = training_participation_report_parameter_set + .map(TrainingParticipationReportParameterSet::from); + drop(conn); let room = EventRoomInfo::from_room(settings, room, sip_config, &tariff); @@ -225,6 +237,7 @@ async fn get_event_instances_inner( invitees_truncated, can_edit, shared_folder.clone(), + training_participation_report.clone(), )?; instances.push(instance); @@ -341,8 +354,16 @@ async fn get_event_instance_inner( ) -> DefaultApiResult<GetEventInstanceResponseBody, CaptureApiError> { let mut conn = db.get_conn().await?; - let (event, invite, room, sip_config, is_favorite, shared_folder, tariff) = - Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; + let ( + event, + invite, + room, + sip_config, + is_favorite, + shared_folder, + tariff, + training_participation_report_parameter_set, + ) = Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; verify_recurrence_date(&event, instance_id.into())?; let (invitees, invitees_truncated) = @@ -376,6 +397,7 @@ async fn get_event_instance_inner( invitees_truncated, can_edit, shared_folder, + training_participation_report_parameter_set.map(Into::into), )?; let event_instance = EventInstance { @@ -490,8 +512,16 @@ async fn patch_event_instance_inner( let mut conn = db.get_conn().await?; - let (event, invite, room, sip_config, is_favorite, shared_folder, tariff) = - Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; + let ( + event, + invite, + room, + sip_config, + is_favorite, + shared_folder, + tariff, + training_participation_report_parameter_set, + ) = Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; if !event.is_recurring.unwrap_or_default() { return Err(ApiError::not_found().into()); @@ -648,6 +678,7 @@ async fn patch_event_instance_inner( invitees_truncated, can_edit, shared_folder, + training_participation_report_parameter_set.map(Into::into), )?; let event_instance = EventInstance { @@ -677,6 +708,7 @@ fn create_event_instance( invitees_truncated: bool, can_edit: bool, shared_folder: Option<SharedFolder>, + training_participation_report: Option<TrainingParticipationReportParameterSet>, ) -> opentalk_database::Result<EventInstance> { let mut status = EventStatus::Ok; @@ -741,6 +773,7 @@ fn create_event_instance( is_favorite, can_edit, shared_folder, + training_participation_report, }) } @@ -849,6 +882,7 @@ mod tests { is_favorite: false, can_edit: false, shared_folder: None, + training_participation_report: None, }; assert_eq_json!( diff --git a/crates/opentalk-controller-core/src/api/v1/events/invites.rs b/crates/opentalk-controller-core/src/api/v1/events/invites.rs index 22612b8eac59be0f1968ffa8008bbdd995da80ba..624403f37050fd6e30658ebd56f463ee25679ef5 100644 --- a/crates/opentalk-controller-core/src/api/v1/events/invites.rs +++ b/crates/opentalk-controller-core/src/api/v1/events/invites.rs @@ -912,8 +912,16 @@ async fn delete_invite_to_event_inner( let mut conn = db.get_conn().await?; // TODO(w.rabl) Further DB access optimization (replacing call to get_with_invite_and_room)? - let (event, _invite, room, sip_config, _is_favorite, shared_folder, _tariff) = - Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; + let ( + event, + _invite, + room, + sip_config, + _is_favorite, + shared_folder, + _tariff, + _training_participation_report_parameter_set, + ) = Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; let streaming_targets = get_room_streaming_targets(&mut conn, room.id).await?; let created_by = if event.created_by == current_user.id { @@ -1069,8 +1077,16 @@ async fn delete_email_invite_to_event_inner( let mut conn = db.get_conn().await?; - let (event, _invite, room, sip_config, _is_favorite, shared_folder, _tariff) = - Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; + let ( + event, + _invite, + room, + sip_config, + _is_favorite, + shared_folder, + _tariff, + _training_participation_report_parameter_set, + ) = Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; let streaming_targets = get_room_streaming_targets(&mut conn, room.id).await?; let created_by = if event.created_by == current_user.id { diff --git a/crates/opentalk-controller-core/src/api/v1/events/mod.rs b/crates/opentalk-controller-core/src/api/v1/events/mod.rs index f8240a3f33ee6f98182570e6369fd0bb45c9a5bf..9ac8d14ade404254030a9cb43d9423514c024423 100644 --- a/crates/opentalk-controller-core/src/api/v1/events/mod.rs +++ b/crates/opentalk-controller-core/src/api/v1/events/mod.rs @@ -30,7 +30,8 @@ use opentalk_database::{Db, DbConnection}; use opentalk_db_storage::{ events::{ email_invites::EventEmailInvite, shared_folders::EventSharedFolder, Event, EventException, - EventExceptionKind, EventInvite, NewEvent, UpdateEvent, + EventExceptionKind, EventInvite, EventTrainingParticipationReportParameterSet, NewEvent, + UpdateEvent, UpdateEventTrainingParticipationReportParameterSet, }, invites::Invite, rooms::{NewRoom, Room, UpdateRoom}, @@ -63,6 +64,7 @@ use opentalk_types_common::{ shared_folders::{SharedFolder, SharedFolderAccess}, streaming::{RoomStreamingTarget, StreamingTarget}, time::{DateTimeTz, RecurrencePattern, TimeZone, Timestamp}, + training_participation_report::TrainingParticipationReportParameterSet, users::UserId, }; use rrule::{Frequency, RRuleSet}; @@ -334,6 +336,7 @@ async fn new_event_inner( streaming_targets, has_shared_folder: _, show_meeting_details, + training_participation_report } if recurrence_pattern.is_empty() => { create_time_independent_event( settings, @@ -348,6 +351,7 @@ async fn new_event_inner( streaming_targets, show_meeting_details, query, + training_participation_report ) .await? } @@ -366,6 +370,7 @@ async fn new_event_inner( streaming_targets, has_shared_folder: _, show_meeting_details, + training_participation_report } => { create_time_dependent_event( settings, @@ -384,6 +389,7 @@ async fn new_event_inner( streaming_targets, show_meeting_details, query, + training_participation_report ) .await? } @@ -458,6 +464,34 @@ async fn store_event_streaming_targets( Ok(room_streaming_targets) } +async fn store_training_participation_report( + conn: &mut DbConnection, + event_id: EventId, + TrainingParticipationReportParameterSet { + initial_checkpoint_delay, + checkpoint_interval, + }: TrainingParticipationReportParameterSet, +) -> Result<Option<TrainingParticipationReportParameterSet>, CaptureApiError> { + let initial_checkpoint_delay_after = + i64::try_from(initial_checkpoint_delay.after).unwrap_or(i64::MAX); + let initial_checkpoint_delay_within = + i64::try_from(initial_checkpoint_delay.within).unwrap_or(i64::MAX); + let checkpoint_interval_after = i64::try_from(checkpoint_interval.after).unwrap_or(i64::MAX); + let checkpoint_interval_within = i64::try_from(checkpoint_interval.within).unwrap_or(i64::MAX); + + let inserted = EventTrainingParticipationReportParameterSet { + event_id, + initial_checkpoint_delay_after, + initial_checkpoint_delay_within, + checkpoint_interval_after, + checkpoint_interval_within, + } + .try_insert(conn) + .await?; + + Ok(inserted.map(Into::into)) +} + struct MailResource { pub current_user: User, pub event: Event, @@ -480,6 +514,7 @@ async fn create_time_independent_event( streaming_targets: Vec<StreamingTarget>, show_meeting_details: bool, query: EventOptionsQuery, + training_participation_report: Option<TrainingParticipationReportParameterSet>, ) -> Result<(EventResource, Option<MailResource>), CaptureApiError> { let room = NewRoom { created_by: current_user.id, @@ -518,6 +553,12 @@ async fn create_time_independent_event( let streaming_targets = store_event_streaming_targets(conn, event.id, streaming_targets).await?; + let training_participation_report = if let Some(parameters) = training_participation_report { + store_training_participation_report(conn, event.id, parameters).await? + } else { + None + }; + let tariff = Tariff::get_by_user_id(conn, ¤t_user.id).await?; let suppress_email_notification = is_adhoc || query.suppress_email_notification; @@ -554,6 +595,7 @@ async fn create_time_independent_event( shared_folder: None, streaming_targets, show_meeting_details, + training_participation_report, }, mail_resource, )) @@ -578,6 +620,7 @@ async fn create_time_dependent_event( streaming_targets: Vec<StreamingTarget>, show_meeting_details: bool, query: EventOptionsQuery, + training_participation_report: Option<TrainingParticipationReportParameterSet>, ) -> Result<(EventResource, Option<MailResource>), CaptureApiError> { let recurrence_pattern = recurrence_pattern.to_multiline_string(); @@ -664,6 +707,7 @@ async fn create_time_dependent_event( shared_folder: None, streaming_targets, show_meeting_details, + training_participation_report, }, mail_resource, )) @@ -771,7 +815,7 @@ async fn get_events_inner( ) .await?; - for (event, _, _, _, exceptions, _, _, _) in &events { + for (event, _, _, _, exceptions, _, _, _, _) in &events { users.add(event); users.add(exceptions); } @@ -809,7 +853,17 @@ async fn get_events_inner( let mut ret_cursor_data = None; for ( - (event, invite, room, sip_config, exceptions, is_favorite, shared_folder, tariff), + ( + event, + invite, + room, + sip_config, + exceptions, + is_favorite, + shared_folder, + tariff, + training_participation_report, + ), (mut invites_with_user, mut email_invites), ) in events.into_iter().zip(invites_grouped_by_event) { @@ -884,6 +938,7 @@ async fn get_events_inner( shared_folder, streaming_targets: Vec::new(), show_meeting_details: event.show_meeting_details, + training_participation_report, })); for exception in exceptions { @@ -994,8 +1049,16 @@ async fn get_event_inner( ) -> DefaultApiResult<EventResource, CaptureApiError> { let mut conn = db.get_conn().await?; - let (event, invite, room, sip_config, is_favorite, shared_folder, tariff) = - Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; + let ( + event, + invite, + room, + sip_config, + is_favorite, + shared_folder, + tariff, + training_participation_report, + ) = Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; let room_streaming_targets = get_room_streaming_targets(&mut conn, room.id).await?; let (invitees, invitees_truncated) = get_invitees_for_event(settings, &mut conn, event_id, query.invitees_max).await?; @@ -1047,6 +1110,7 @@ async fn get_event_inner( shared_folder, streaming_targets: room_streaming_targets, show_meeting_details: event.show_meeting_details, + training_participation_report: training_participation_report.map(Into::into), }; let event_resource = EventResource { @@ -1165,8 +1229,16 @@ async fn patch_event_inner( let mut conn = db.get_conn().await?; - let (event, invite, room, sip_config, is_favorite, shared_folder, tariff) = - Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; + let ( + event, + invite, + room, + sip_config, + is_favorite, + shared_folder, + tariff, + training_participation_report, + ) = Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; let room = if patch.password.is_some() || patch.waiting_room.is_some() { // Update the event's room if at least one of the fields is set @@ -1205,6 +1277,19 @@ async fn patch_event_inner( None => {} } + let training_participation_report = match &patch.training_participation_report { + Some(Some(parameter_set)) => Some( + UpdateEventTrainingParticipationReportParameterSet::from(parameter_set.clone()) + .apply(&mut conn, event.id) + .await?, + ), + Some(None) => { + EventTrainingParticipationReportParameterSet::delete_by_id(&mut conn, event.id).await?; + None + } + None => training_participation_report, + }; + // Special case: if the patch only modifies the password do not update the event let event = if patch.only_modifies_room() { event @@ -1304,6 +1389,7 @@ async fn patch_event_inner( shared_folder: shared_folder.clone(), streaming_targets: streaming_targets.clone(), show_meeting_details: event.show_meeting_details, + training_participation_report: training_participation_report.map(Into::into), }; if send_email_notification { @@ -1344,8 +1430,16 @@ pub async fn notify_event_invitees_by_room_about_update( let event = Event::get_for_room(conn, room_id).await?; if let Some(event) = event { - let (event, _invite, room, sip_config, _is_favorite, shared_folder, _tariff) = - Event::get_with_related_items(conn, current_user.id, event.id).await?; + let ( + event, + _invite, + room, + sip_config, + _is_favorite, + shared_folder, + _tariff, + _training_participation_report_parameter_set, + ) = Event::get_with_related_items(conn, current_user.id, event.id).await?; let shared_folder_for_user = shared_folder_for_user(shared_folder, event.created_by, current_user.id); @@ -1734,8 +1828,16 @@ async fn delete_event_inner( let mut conn = db.get_conn().await?; // TODO(w.rabl) Further DB access optimization (replacing call to get_with_invite_and_room)? - let (event, _invite, room, sip_config, _is_favorite, shared_folder, _tariff) = - Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; + let ( + event, + _invite, + room, + sip_config, + _is_favorite, + shared_folder, + _tariff, + _training_participation_report, + ) = Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; let streaming_targets = get_room_streaming_targets(&mut conn, room.id).await?; @@ -2253,7 +2355,10 @@ mod tests { use std::time::SystemTime; use opentalk_test_util::assert_eq_json; - use opentalk_types_common::events::invites::InviteRole; + use opentalk_types_common::{ + events::invites::InviteRole, rooms::RoomId, training_participation_report::TimeRange, + users::UserId, + }; use super::*; @@ -2330,6 +2435,16 @@ mod tests { shared_folder: None, streaming_targets: Vec::new(), show_meeting_details: true, + training_participation_report: Some(TrainingParticipationReportParameterSet { + initial_checkpoint_delay: TimeRange { + after: 100, + within: 200, + }, + checkpoint_interval: TimeRange { + after: 300, + within: 400, + }, + }), }; assert_eq_json!( @@ -2396,6 +2511,16 @@ mod tests { "can_edit": true, "is_adhoc": false, "show_meeting_details": true, + "training_participation_report": { + "initial_checkpoint_delay": { + "after": 100, + "within": 200, + }, + "checkpoint_interval": { + "after": 300, + "within": 400, + }, + }, } ); } @@ -2457,6 +2582,16 @@ mod tests { shared_folder: None, streaming_targets: Vec::new(), show_meeting_details: false, + training_participation_report: Some(TrainingParticipationReportParameterSet { + initial_checkpoint_delay: TimeRange { + after: 100, + within: 200, + }, + checkpoint_interval: TimeRange { + after: 300, + within: 400, + }, + }), }; assert_eq_json!( @@ -2519,6 +2654,16 @@ mod tests { "can_edit": false, "is_adhoc": false, "show_meeting_details": false, + "training_participation_report": { + "initial_checkpoint_delay": { + "after": 100, + "within": 200, + }, + "checkpoint_interval": { + "after": 300, + "within": 400, + }, + }, } ); } diff --git a/crates/opentalk-controller-core/src/api/v1/events/shared_folder.rs b/crates/opentalk-controller-core/src/api/v1/events/shared_folder.rs index c81426b25563a5a94e241a40b306134484bd0ac2..f546032933293a96a5c05bbff35b784a200fa661 100644 --- a/crates/opentalk-controller-core/src/api/v1/events/shared_folder.rs +++ b/crates/opentalk-controller-core/src/api/v1/events/shared_folder.rs @@ -200,8 +200,16 @@ async fn put_shared_folder_for_event_inner( let (shared_folder, created) = put_shared_folder(settings, event_id, &mut conn).await?; - let (event, _invite, room, sip_config, _is_favorite, _shared_folder, _tariff) = - Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; + let ( + event, + _invite, + room, + sip_config, + _is_favorite, + _shared_folder, + _tariff, + _training_participation_report_parameter_set, + ) = Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; if send_email_notification { let shared_folder_for_user = shared_folder_for_user( @@ -552,8 +560,16 @@ async fn delete_shared_folder_for_event_inner( let mut conn = db.get_conn().await?; - let (event, _invite, room, sip_config, _is_favorite, shared_folder, _tariff) = - Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; + let ( + event, + _invite, + room, + sip_config, + _is_favorite, + shared_folder, + _tariff, + _training_participation_report_parameter_set, + ) = Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; if let Some(shared_folder) = shared_folder { let shared_folders = std::slice::from_ref(&shared_folder); diff --git a/crates/opentalk-controller-service/src/controller_backend/events/shared_folder.rs b/crates/opentalk-controller-service/src/controller_backend/events/shared_folder.rs new file mode 100644 index 0000000000000000000000000000000000000000..ce9cd25be8d16cd59f2cbacaca420a47c1b8118b --- /dev/null +++ b/crates/opentalk-controller-service/src/controller_backend/events/shared_folder.rs @@ -0,0 +1,455 @@ +// SPDX-FileCopyrightText: OpenTalk GmbH <mail@opentalk.eu> +// +// SPDX-License-Identifier: EUPL-1.2 + +use std::collections::HashSet; + +use chrono::{Days, NaiveDate, Utc}; +use log::warn; +use opentalk_controller_service_facade::RequestUser; +use opentalk_controller_settings::Settings; +use opentalk_controller_utils::CaptureApiError; +use opentalk_database::DbConnection; +use opentalk_db_storage::{ + events::{ + shared_folders::{EventSharedFolder, NewEventSharedFolder}, + Event, + }, + streaming_targets::get_room_streaming_targets, + tenants::Tenant, + users::User, +}; +use opentalk_nextcloud_client::{Client, ShareId, SharePermission, ShareType}; +use opentalk_types_api_v1::{ + error::ApiError, + events::{DeleteSharedFolderQuery, PutSharedFolderQuery}, +}; +use opentalk_types_common::{ + events::EventId, + shared_folders::{SharedFolder, SharedFolderAccess}, +}; +use snafu::Report; + +use crate::{ + events::{notifications::notify_event_invitees_about_update, shared_folder_for_user}, + ControllerBackend, +}; + +impl ControllerBackend { + pub(crate) async fn get_shared_folder_for_event( + &self, + current_user: RequestUser, + event_id: EventId, + ) -> Result<SharedFolder, CaptureApiError> { + let mut conn = self.db.get_conn().await?; + + let event = Event::get(&mut conn, event_id).await?; + + let shared_folder = SharedFolder::from( + EventSharedFolder::get_for_event(&mut conn, event_id) + .await? + .ok_or_else(ApiError::not_found)?, + ); + + let shared_folder = if event.created_by == current_user.id { + shared_folder + } else { + shared_folder.without_write_access() + }; + + Ok(shared_folder) + } + + pub(crate) async fn put_shared_folder_for_event( + &self, + current_user: RequestUser, + event_id: EventId, + query: PutSharedFolderQuery, + ) -> Result<(SharedFolder, bool), CaptureApiError> { + let settings = self.settings.load(); + let mut conn = self.db.get_conn().await?; + + let send_email_notification = !query.suppress_email_notification; + + let (shared_folder, created) = put_shared_folder(&settings, event_id, &mut conn).await?; + + let ( + event, + _invite, + room, + sip_config, + _is_favorite, + _shared_folder, + _tariff, + _training_participation_report, + ) = Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; + + if send_email_notification { + let shared_folder_for_user = shared_folder_for_user( + Some(shared_folder.clone()), + event.created_by, + current_user.id, + ); + + let current_tenant = Tenant::get(&mut conn, current_user.tenant_id).await?; + let current_user = User::get(&mut conn, current_user.id).await?; + let streaming_targets = get_room_streaming_targets(&mut conn, room.id).await?; + + notify_event_invitees_about_update( + &self.kc_admin_client, + &settings, + &self.mail_service, + current_tenant, + current_user, + &mut conn, + event, + room, + sip_config, + shared_folder_for_user, + streaming_targets, + ) + .await?; + } + + Ok((SharedFolder::from(shared_folder), created)) + } + + pub(crate) async fn delete_shared_folder_for_event( + &self, + current_user: RequestUser, + event_id: EventId, + query: DeleteSharedFolderQuery, + ) -> Result<(), CaptureApiError> { + let settings = self.settings.load(); + let mut conn = self.db.get_conn().await?; + + let send_email_notification = !query.suppress_email_notification; + + let ( + event, + _invite, + room, + sip_config, + _is_favorite, + shared_folder, + _tariff, + _training_participation_report, + ) = Event::get_with_related_items(&mut conn, current_user.id, event_id).await?; + + if let Some(shared_folder) = shared_folder { + let shared_folders = std::slice::from_ref(&shared_folder); + let deletion = delete_shared_folders(&settings, shared_folders).await; + + let streaming_targets = get_room_streaming_targets(&mut conn, room.id).await?; + + match deletion { + Ok(()) => { + shared_folder.delete(&mut conn).await?; + + if send_email_notification { + let current_tenant = Tenant::get(&mut conn, current_user.tenant_id).await?; + let current_user = User::get(&mut conn, current_user.id).await?; + + notify_event_invitees_about_update( + &self.kc_admin_client, + &settings, + &self.mail_service, + current_tenant, + current_user, + &mut conn, + event, + room, + sip_config, + None, + streaming_targets, + ) + .await?; + } + + Ok(()) + } + Err(e) => { + if query.force_delete_reference_if_shared_folder_deletion_fails { + warn!( + "Deleting local shared folder reference anyway, because \ + `force_delete_reference_if_shared_folder_deletion_fails` is set to true" + ); + shared_folder.delete(&mut conn).await?; + + if send_email_notification { + let current_tenant = + Tenant::get(&mut conn, current_user.tenant_id).await?; + let current_user = User::get(&mut conn, current_user.id).await?; + + notify_event_invitees_about_update( + &self.kc_admin_client, + &settings, + &self.mail_service, + current_tenant, + current_user, + &mut conn, + event, + room, + sip_config, + None, + streaming_targets, + ) + .await?; + } + + Ok(()) + } else { + Err(e.into()) + } + } + } + } else { + Ok(()) + } + } +} + +/// Adds a shared folder to the specified event +pub async fn put_shared_folder( + settings: &Settings, + event_id: EventId, + conn: &mut DbConnection, +) -> Result<(EventSharedFolder, bool), CaptureApiError> { + let shared_folder = EventSharedFolder::get_for_event(conn, event_id).await?; + + if let Some(shared_folder) = shared_folder { + return Ok((shared_folder, false)); + } + let shared_folder_settings = settings.shared_folder.as_ref().ok_or_else(|| { + ApiError::bad_request().with_message("No shared folder configured for this server") + })?; + + match shared_folder_settings { + opentalk_controller_settings::SharedFolder::Nextcloud { + url, + username, + password, + directory, + expiry, + } => { + let client = + Client::new(url.clone(), username.clone(), password.clone()).map_err(|e| { + warn!("Error creating NextCloud client: {}", Report::from_error(e)); + ApiError::internal().with_message("Error creating NextCloud client") + })?; + let path = format!( + "{}/opentalk-event-{}", + directory.trim_matches('/'), + event_id + ); + let user_path = format!("files/{username}/{path}"); + client.create_folder(&user_path).await.map_err(|e| { + warn!( + "Error creating folder on NextCloud: {}", + Report::from_error(e) + ); + ApiError::internal().with_message("Error creating folder on NextCloud") + })?; + + let expire_date = expiry + .as_ref() + .map(|days| Utc::now().date_naive() + Days::new(*days)); + + let write_permissions = HashSet::from([ + SharePermission::Read, + SharePermission::Create, + SharePermission::Update, + SharePermission::Delete, + ]); + let read_permissions = HashSet::from([SharePermission::Read]); + + async fn create_share( + client: &Client, + path: &str, + permissions: HashSet<SharePermission>, + label: &str, + password: String, + expire_date: Option<NaiveDate>, + ) -> Result<(ShareId, SharedFolderAccess), ApiError> { + let mut creator = client + .create_share(path, ShareType::PublicLink) + .password(&password) + .label(label); + for permission in &permissions { + creator = creator.permission(*permission); + } + if let Some(expire_date) = expire_date { + creator = creator.expire_date(expire_date); + } + let share = creator.send().await.map_err(|e| { + warn!( + "Error creating share on NextCloud: {}", + Report::from_error(e) + ); + ApiError::internal().with_message("Error creating share on NextCloud") + })?; + + // Workaround for NextCloud up to version 25 not processing the share permissions + // on folder creation. We just need to change them with a subsequent update request. + // + // See: https://github.com/nextcloud/server/issues/32611 + if share.data.permissions != permissions { + _ = client + .update_share(share.data.id.clone()) + .permissions(permissions) + .await + .map_err(|e| { + warn!( + "Error setting permissions for share on NextCloud: {}", + Report::from_error(e) + ); + ApiError::internal() + .with_message("Error setting permissions for share on NextCloud") + })?; + } + + Ok(( + share.data.id, + SharedFolderAccess { + url: share.data.url, + password, + }, + )) + } + + let write_password = generate_password(&client).await?; + let read_password = generate_password(&client).await?; + + let ( + write_share_id, + SharedFolderAccess { + url: write_url, + password: write_password, + }, + ) = create_share( + &client, + &path, + write_permissions, + "OpenTalk read-write", + write_password, + expire_date, + ) + .await?; + let ( + read_share_id, + SharedFolderAccess { + url: read_url, + password: read_password, + }, + ) = create_share( + &client, + &path, + read_permissions, + "OpenTalk read-only", + read_password, + expire_date, + ) + .await?; + + let new_shared_folder = NewEventSharedFolder { + event_id, + path, + write_share_id: write_share_id.to_string(), + write_url, + write_password, + read_share_id: read_share_id.to_string(), + read_url, + read_password, + }; + + let shared_folder = new_shared_folder + .try_insert(conn) + .await? + .ok_or_else(ApiError::internal)?; + + Ok((shared_folder, true)) + } + } +} + +/// Deletes the shared folders for the specified event +pub async fn delete_shared_folders( + settings: &Settings, + shared_folders: &[EventSharedFolder], +) -> Result<(), ApiError> { + if shared_folders.is_empty() { + return Ok(()); + } + + let shared_folder_settings = if let Some(settings) = settings.shared_folder.as_ref() { + settings + } else { + return Err( + ApiError::bad_request().with_message("No shared folder configured for this server") + ); + }; + + match shared_folder_settings { + opentalk_controller_settings::SharedFolder::Nextcloud { + url, + username, + password, + .. + } => { + let client = + Client::new(url.clone(), username.clone(), password.clone()).map_err(|e| { + warn!("Error creating NextCloud client: {}", Report::from_error(e)); + ApiError::internal().with_message("Error creating NextCloud client") + })?; + for shared_folder in shared_folders { + let path = &shared_folder.path; + if path.trim_matches('/').is_empty() { + warn!("Preventing recursive deletion of empty shared folder path, this is probably harmful and not intended"); + return Err(ApiError::internal()); + } + let user_path = format!("files/{username}/{path}"); + if let Err(e) = client + .delete_share(ShareId::from(shared_folder.read_share_id.clone())) + .await + { + warn!( + "Could not delete NextCloud read share: {}", + Report::from_error(e) + ); + } + if let Err(e) = client + .delete_share(ShareId::from(shared_folder.write_share_id.clone())) + .await + { + warn!( + "Could not delete NextCloud write share: {}", + Report::from_error(e) + ); + } + match client.delete(&user_path).await { + Ok(()) | Err(opentalk_nextcloud_client::Error::FileNotFound { .. }) => {} + Err(e) => { + warn!( + "Error deleting folder on NextCloud: {}", + Report::from_error(e) + ); + return Err( + ApiError::internal().with_message("Error deleting folder on NextCloud") + ); + } + }; + } + Ok(()) + } + } +} + +async fn generate_password(client: &Client) -> Result<String, ApiError> { + client.generate_password().await.map_err(|e| { + warn!( + "Error generating share password on NextCloud: {}", + Report::from_error(e) + ); + ApiError::internal().with_message("Error generating share password NextCloud") + }) +} diff --git a/crates/opentalk-controller-service/src/events/notifications.rs b/crates/opentalk-controller-service/src/events/notifications.rs new file mode 100644 index 0000000000000000000000000000000000000000..fc1ba56b6d58ad38737ecaab6ed7907d06c48308 --- /dev/null +++ b/crates/opentalk-controller-service/src/events/notifications.rs @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: OpenTalk GmbH <mail@opentalk.eu> +// +// SPDX-License-Identifier: EUPL-1.2 + +//! Handles event-related notifications. + +use opentalk_controller_settings::Settings; +use opentalk_controller_utils::CaptureApiError; +use opentalk_database::DbConnection; +use opentalk_db_storage::{ + events::{Event, EventException}, + invites::Invite, + rooms::Room, + sip_configs::SipConfig, + streaming_targets::get_room_streaming_targets, + tenants::Tenant, + users::User, +}; +use opentalk_keycloak_admin::KeycloakAdminClient; +use opentalk_types_common::{ + rooms::RoomId, shared_folders::SharedFolder, streaming::RoomStreamingTarget, +}; +use snafu::Report; + +use crate::{ + events::{enrich_from_keycloak, get_invited_mail_recipients_for_event, shared_folder_for_user}, + services::{MailRecipient, MailService}, +}; + +/// Provides information for event update notifications (e.g. via email) +#[derive(Debug)] +pub struct UpdateNotificationValues { + /// The tenant id + pub tenant: Tenant, + /// The user who has created the event + pub created_by: User, + /// The event that was updated + pub event: Event, + /// The event exception that was updated + pub event_exception: Option<EventException>, + /// The room of the updated event + pub room: Room, + /// The SIP configuration of the updated event + pub sip_config: Option<SipConfig>, + /// The users to notify about the update + pub users_to_notify: Vec<MailRecipient>, + /// The updated invite + pub invite_for_room: Invite, +} + +/// Notifies the invitees of an event belonging to the specified room +pub async fn notify_event_invitees_by_room_about_update( + kc_admin_client: &KeycloakAdminClient, + settings: &Settings, + mail_service: &MailService, + current_tenant: Tenant, + current_user: User, + conn: &mut DbConnection, + room_id: RoomId, +) -> Result<(), CaptureApiError> { + let event = Event::get_for_room(conn, room_id).await?; + + if let Some(event) = event { + let ( + event, + _invite, + room, + sip_config, + _is_favorite, + shared_folder, + _tariff, + _training_participation_report, + ) = Event::get_with_related_items(conn, current_user.id, event.id).await?; + + let shared_folder_for_user = + shared_folder_for_user(shared_folder, event.created_by, current_user.id); + + let streaming_targets = get_room_streaming_targets(conn, room.id).await?; + + notify_event_invitees_about_update( + kc_admin_client, + settings, + mail_service, + current_tenant, + current_user, + conn, + event, + room, + sip_config, + shared_folder_for_user, + streaming_targets, + ) + .await?; + } + Ok(()) +} + +/// Notifies the invitees of an event about updates +#[allow(clippy::too_many_arguments)] +pub async fn notify_event_invitees_about_update( + kc_admin_client: &KeycloakAdminClient, + settings: &Settings, + mail_service: &MailService, + current_tenant: Tenant, + current_user: User, + conn: &mut DbConnection, + event: Event, + room: Room, + sip_config: Option<SipConfig>, + shared_folder_for_user: Option<SharedFolder>, + streaming_targets: Vec<RoomStreamingTarget>, +) -> Result<(), CaptureApiError> { + let invited_users = get_invited_mail_recipients_for_event(conn, event.id).await?; + let current_user_mail_recipient = MailRecipient::Registered(current_user.clone().into()); + let users_to_notify = invited_users + .into_iter() + .chain(std::iter::once(current_user_mail_recipient)) + .collect::<Vec<_>>(); + let invite_for_room = + Invite::get_first_or_create_for_room(conn, room.id, current_user.id).await?; + let created_by = if event.created_by == current_user.id { + current_user + } else { + User::get(conn, event.created_by).await? + }; + + let notification_values = UpdateNotificationValues { + tenant: current_tenant, + created_by, + event, + event_exception: None, + room, + sip_config, + users_to_notify, + invite_for_room, + }; + + notify_invitees_about_update( + settings, + notification_values, + mail_service, + kc_admin_client, + shared_folder_for_user, + streaming_targets, + ) + .await; + Ok(()) +} + +/// Notifies the invitees of an event about updates +pub async fn notify_invitees_about_update( + settings: &Settings, + notification_values: UpdateNotificationValues, + mail_service: &MailService, + kc_admin_client: &KeycloakAdminClient, + shared_folder: Option<SharedFolder>, + streaming_targets: Vec<RoomStreamingTarget>, +) { + for user in notification_values.users_to_notify { + let invited_user = + enrich_from_keycloak(settings, user, ¬ification_values.tenant, kc_admin_client) + .await; + + if let Err(e) = mail_service + .send_event_update( + notification_values.created_by.clone(), + notification_values.event.clone(), + notification_values.event_exception.clone(), + notification_values.room.clone(), + notification_values.sip_config.clone(), + invited_user, + notification_values.invite_for_room.id.to_string(), + shared_folder.clone(), + streaming_targets.clone(), + ) + .await + { + log::error!( + "Failed to send event update with MailService, {}", + Report::from_error(e) + ); + } + } +} diff --git a/crates/opentalk-db-storage/src/events/mod.rs b/crates/opentalk-db-storage/src/events/mod.rs index f5d7d7d87c7271415499c1bd714f1b2a5000f578..b0d97c724a2f9f8d70d16fa93af272f73395d7ac 100644 --- a/crates/opentalk-db-storage/src/events/mod.rs +++ b/crates/opentalk-db-storage/src/events/mod.rs @@ -27,6 +27,7 @@ use opentalk_types_common::{ sql_enum, tenants::TenantId, time::TimeZone, + training_participation_report::{TimeRange, TrainingParticipationReportParameterSet}, users::UserId, }; use redis_args::{FromRedisValue, ToRedisArgs}; @@ -36,8 +37,9 @@ use self::shared_folders::EventSharedFolder; use crate::{ rooms::Room, schema::{ - event_exceptions, event_favorites, event_invites, event_shared_folders, events, rooms, - sip_configs, tariffs, users, + event_exceptions, event_favorites, event_invites, event_shared_folders, + event_training_participation_report_parameter_sets, events, rooms, sip_configs, tariffs, + users, }, sip_configs::SipConfig, tariffs::Tariff, @@ -352,36 +354,43 @@ impl Event { bool, Option<EventSharedFolder>, Tariff, + Option<EventTrainingParticipationReportParameterSet>, )> { - let query = events::table - .left_join( - event_invites::table.on(event_invites::event_id - .eq(events::id) - .and(event_invites::invitee.eq(user_id))), - ) - .left_join( - event_favorites::table.on(event_favorites::event_id - .eq(events::id) - .and(event_favorites::user_id.eq(user_id))), - ) - .left_join( - event_shared_folders::table.on(event_shared_folders::event_id.eq(events::id)), - ) - .inner_join(rooms::table.on(events::room.eq(rooms::id))) - .left_join(sip_configs::table.on(rooms::id.eq(sip_configs::room))) - .inner_join(users::table.on(users::id.eq(rooms::created_by))) - .inner_join(tariffs::table.on(tariffs::id.eq(users::tariff_id))) - .select(( - events::all_columns, - event_invites::all_columns.nullable(), - rooms::all_columns, - sip_configs::all_columns.nullable(), - event_favorites::user_id.nullable().is_not_null(), - event_shared_folders::all_columns.nullable(), - tariffs::all_columns, - )) - .filter(events::id.eq(event_id)); - + let query = + events::table + .left_join( + event_invites::table.on(event_invites::event_id + .eq(events::id) + .and(event_invites::invitee.eq(user_id))), + ) + .left_join( + event_favorites::table.on(event_favorites::event_id + .eq(events::id) + .and(event_favorites::user_id.eq(user_id))), + ) + .left_join( + event_shared_folders::table.on(event_shared_folders::event_id.eq(events::id)), + ) + .inner_join(rooms::table.on(events::room.eq(rooms::id))) + .left_join(sip_configs::table.on(rooms::id.eq(sip_configs::room))) + .inner_join(users::table.on(users::id.eq(rooms::created_by))) + .inner_join(tariffs::table.on(tariffs::id.eq(users::tariff_id))) + .inner_join( + event_training_participation_report_parameter_sets::table + .on(event_training_participation_report_parameter_sets::event_id + .eq(events::id)), + ) + .select(( + events::all_columns, + event_invites::all_columns.nullable(), + rooms::all_columns, + sip_configs::all_columns.nullable(), + event_favorites::user_id.nullable().is_not_null(), + event_shared_folders::all_columns.nullable(), + tariffs::all_columns, + event_training_participation_report_parameter_sets::all_columns.nullable(), + )) + .filter(events::id.eq(event_id)); Ok(query.first(conn).await?) } @@ -431,6 +440,7 @@ impl Event { bool, Option<EventSharedFolder>, Tariff, + Option<TrainingParticipationReportParameterSet>, )>, > { // Filter applied to all events which validates that the event is either created by @@ -585,6 +595,8 @@ impl Event { } else { vec![] }; + // TODO: remove this + let training_participation_report_parameter_set = None; events_with_invite_room_and_exceptions.push(( event, @@ -595,6 +607,7 @@ impl Event { is_favorite, shared_folders, tariff, + training_participation_report_parameter_set, )); } @@ -1132,3 +1145,139 @@ impl NewEventFavorite { } } } + +#[derive(Debug, Insertable, Queryable, Identifiable, Associations)] +#[diesel(table_name = event_training_participation_report_parameter_sets)] +#[diesel(primary_key(event_id))] +#[diesel(belongs_to(Event, foreign_key = event_id))] +pub struct EventTrainingParticipationReportParameterSet { + pub event_id: EventId, + pub initial_checkpoint_delay_after: i64, + pub initial_checkpoint_delay_within: i64, + pub checkpoint_interval_after: i64, + pub checkpoint_interval_within: i64, +} + +impl EventTrainingParticipationReportParameterSet { + #[tracing::instrument(err, skip_all)] + pub async fn get_for_event(conn: &mut DbConnection, event_id: EventId) -> Result<Option<Self>> { + let parameter_set = event_training_participation_report_parameter_sets::table + .filter(event_training_participation_report_parameter_sets::event_id.eq(event_id)) + .get_result(conn) + .await + .optional()?; + + Ok(parameter_set) + } + + /// Tries to insert the EventTrainingParticipationParameterSet into the database + /// + /// When yielding a unique key violation, None is returned. + #[tracing::instrument(err, skip_all)] + pub async fn try_insert(self, conn: &mut DbConnection) -> Result<Option<Self>> { + let query = self.insert_into(event_training_participation_report_parameter_sets::table); + + let result = query.get_result(conn).await; + + match result { + Ok(event_invite) => Ok(Some(event_invite)), + Err(diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::UniqueViolation, + .., + )) => Ok(None), + Err(e) => Err(e.into()), + } + } + + #[tracing::instrument(err, skip_all)] + pub async fn delete_by_id(conn: &mut DbConnection, event_id: EventId) -> Result<()> { + let query = diesel::delete( + event_training_participation_report_parameter_sets::table + .filter(event_training_participation_report_parameter_sets::event_id.eq(event_id)), + ); + + query.execute(conn).await?; + + Ok(()) + } +} + +impl From<EventTrainingParticipationReportParameterSet> + for TrainingParticipationReportParameterSet +{ + fn from( + EventTrainingParticipationReportParameterSet { + event_id: _, + initial_checkpoint_delay_after, + initial_checkpoint_delay_within, + checkpoint_interval_after, + checkpoint_interval_within, + }: EventTrainingParticipationReportParameterSet, + ) -> Self { + Self { + initial_checkpoint_delay: TimeRange { + after: u64::try_from(initial_checkpoint_delay_after).unwrap_or_default(), + within: u64::try_from(initial_checkpoint_delay_within).unwrap_or_default(), + }, + checkpoint_interval: TimeRange { + after: u64::try_from(checkpoint_interval_after).unwrap_or_default(), + within: u64::try_from(checkpoint_interval_within).unwrap_or_default(), + }, + } + } +} + +#[derive(AsChangeset)] +#[diesel(table_name = event_training_participation_report_parameter_sets)] +pub struct UpdateEventTrainingParticipationReportParameterSet { + pub initial_checkpoint_delay_after: Option<i64>, + pub initial_checkpoint_delay_within: Option<i64>, + pub checkpoint_interval_after: Option<i64>, + pub checkpoint_interval_within: Option<i64>, +} + +impl UpdateEventTrainingParticipationReportParameterSet { + /// Apply the update to the invite where `user_id` is the invitee + #[tracing::instrument(err, skip_all)] + pub async fn apply( + self, + conn: &mut DbConnection, + event_id: EventId, + ) -> Result<EventTrainingParticipationReportParameterSet> { + let query = diesel::update(event_training_participation_report_parameter_sets::table) + .filter(event_training_participation_report_parameter_sets::event_id.eq(event_id)) + .set(self) + // change it here + .returning(event_training_participation_report_parameter_sets::all_columns); + + let event_training_participation_report_parameter_sets = query.get_result(conn).await?; + + Ok(event_training_participation_report_parameter_sets) + } +} + +impl From<TrainingParticipationReportParameterSet> + for UpdateEventTrainingParticipationReportParameterSet +{ + fn from( + TrainingParticipationReportParameterSet { + initial_checkpoint_delay, + checkpoint_interval, + }: TrainingParticipationReportParameterSet, + ) -> Self { + Self { + initial_checkpoint_delay_after: Some( + i64::try_from(initial_checkpoint_delay.after).unwrap_or(i64::MAX), + ), + initial_checkpoint_delay_within: Some( + i64::try_from(initial_checkpoint_delay.within).unwrap_or(i64::MAX), + ), + checkpoint_interval_after: Some( + i64::try_from(checkpoint_interval.after).unwrap_or(i64::MAX), + ), + checkpoint_interval_within: Some( + i64::try_from(checkpoint_interval.within).unwrap_or(i64::MAX), + ), + } + } +} diff --git a/crates/opentalk-db-storage/src/migrations/V47__create_event_training_participation_report_parameter_sets.sql b/crates/opentalk-db-storage/src/migrations/V47__create_event_training_participation_report_parameter_sets.sql new file mode 100644 index 0000000000000000000000000000000000000000..c9b3ed6f9e30799a7c4dd46f3785fb57668dfd09 --- /dev/null +++ b/crates/opentalk-db-storage/src/migrations/V47__create_event_training_participation_report_parameter_sets.sql @@ -0,0 +1,7 @@ +CREATE TABLE event_training_participation_report_parameter_sets ( + event_id UUID PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE NOT NULL, + initial_checkpoint_delay_after BIGINT NOT NULL, + initial_checkpoint_delay_within BIGINT NOT NULL, + checkpoint_interval_after BIGINT NOT NULL, + checkpoint_interval_within BIGINT NOT NULL +); diff --git a/crates/opentalk-db-storage/src/schema.rs b/crates/opentalk-db-storage/src/schema.rs index f41661531b35bcd229d01cd8ef0f7ea61ecb7f9e..3e091cc2a6ea7ccbd14504820aa630cbf6c02b56 100644 --- a/crates/opentalk-db-storage/src/schema.rs +++ b/crates/opentalk-db-storage/src/schema.rs @@ -113,6 +113,18 @@ diesel::table! { } } +diesel::table! { + use crate::sql_types::*; + + event_training_participation_report_parameter_sets (event_id) { + event_id -> Uuid, + initial_checkpoint_delay_after -> Int8, + initial_checkpoint_delay_within -> Int8, + checkpoint_interval_after -> Int8, + checkpoint_interval_within -> Int8, + } +} + diesel::table! { use crate::sql_types::*; @@ -384,6 +396,7 @@ diesel::joinable!(event_favorites -> events (event_id)); diesel::joinable!(event_favorites -> users (user_id)); diesel::joinable!(event_invites -> events (event_id)); diesel::joinable!(event_shared_folders -> events (event_id)); +diesel::joinable!(event_training_participation_report_parameter_sets -> events (event_id)); diesel::joinable!(events -> rooms (room)); diesel::joinable!(events -> tenants (tenant_id)); diesel::joinable!(external_tariffs -> tariffs (tariff_id)); @@ -413,6 +426,7 @@ diesel::allow_tables_to_appear_in_same_query!( event_favorites, event_invites, event_shared_folders, + event_training_participation_report_parameter_sets, events, external_tariffs, groups, diff --git a/crates/opentalk-signaling-module-training-participation-report/src/lib.rs b/crates/opentalk-signaling-module-training-participation-report/src/lib.rs index 2361ae8d376217bf03a7fa0ec9518529f1737518..d9f58596c5c3948a9934426c61353503e392e14a 100644 --- a/crates/opentalk-signaling-module-training-participation-report/src/lib.rs +++ b/crates/opentalk-signaling-module-training-participation-report/src/lib.rs @@ -52,6 +52,7 @@ use opentalk_types_common::{ modules::ModuleId, rooms::RoomId, time::{TimeZone, Timestamp}, + training_participation_report::TimeRange, users::{DisplayName, UserId}, }; use opentalk_types_signaling::ParticipantId; @@ -63,7 +64,7 @@ use opentalk_types_signaling_training_participation_report::{ PresenceLoggingStartedReason, TrainingParticipationReportEvent, }, state::{ParticipationLoggingState, TrainingParticipationReportState}, - TimeRange, MODULE_ID, + MODULE_ID, }; use rand::Rng as _; use snafu::{Report, ResultExt as _}; @@ -209,6 +210,8 @@ impl TrainingParticipationReport { .get_recorded_presence_state(self.room, self.participant) .await?; + let parameter_set = None; + if control_data.is_room_owner { let (other_room_owners, trainees) = self .load_already_present_room_owners_and_trainees( @@ -225,8 +228,17 @@ impl TrainingParticipationReport { // Don't ask the room owner for confirmation state = ParticipationLoggingState::Enabled; } + + *frontend_data = Some(TrainingParticipationReportState { + state, + parameter_set, + }); + } else { + *frontend_data = Some(TrainingParticipationReportState { + state, + parameter_set: None, + }); } - *frontend_data = Some(TrainingParticipationReportState { state }); Ok(()) } diff --git a/crates/opentalk-signaling-module-training-participation-report/src/storage/mod.rs b/crates/opentalk-signaling-module-training-participation-report/src/storage/mod.rs index 3f0a19e1bcc163b53ffe27a377a44e77e265b5c6..655a0e75c191a614f29c19f54aff984e20d1f808 100644 --- a/crates/opentalk-signaling-module-training-participation-report/src/storage/mod.rs +++ b/crates/opentalk-signaling-module-training-participation-report/src/storage/mod.rs @@ -19,11 +19,9 @@ mod test_common { use std::collections::{BTreeMap, BTreeSet}; use opentalk_signaling_core::SignalingModuleError; - use opentalk_types_common::rooms::RoomId; + use opentalk_types_common::{rooms::RoomId, training_participation_report::TimeRange}; use opentalk_types_signaling::ParticipantId; - use opentalk_types_signaling_training_participation_report::{ - state::ParticipationLoggingState, TimeRange, - }; + use opentalk_types_signaling_training_participation_report::state::ParticipationLoggingState; use pretty_assertions::assert_eq; use super::TrainingParticipationReportStorage; diff --git a/crates/opentalk-signaling-module-training-participation-report/src/storage/redis.rs b/crates/opentalk-signaling-module-training-participation-report/src/storage/redis.rs index 2c7a0e1538ec473d7c5734ecf387eee8ee422946..9bf44eddd92dc036821f8fe2f60810b98fa39c01 100644 --- a/crates/opentalk-signaling-module-training-participation-report/src/storage/redis.rs +++ b/crates/opentalk-signaling-module-training-participation-report/src/storage/redis.rs @@ -6,11 +6,11 @@ use std::collections::{BTreeMap, BTreeSet}; use async_trait::async_trait; use opentalk_signaling_core::{NotFoundSnafu, RedisConnection, RedisSnafu, SignalingModuleError}; -use opentalk_types_common::{rooms::RoomId, time::Timestamp}; -use opentalk_types_signaling::ParticipantId; -use opentalk_types_signaling_training_participation_report::{ - state::ParticipationLoggingState, TimeRange, +use opentalk_types_common::{ + rooms::RoomId, time::Timestamp, training_participation_report::TimeRange, }; +use opentalk_types_signaling::ParticipantId; +use opentalk_types_signaling_training_participation_report::state::ParticipationLoggingState; use redis::{AsyncCommands, ExistenceCheck, SetOptions}; use redis_args::{FromRedisValue, ToRedisArgs}; use serde::{Deserialize, Serialize}; diff --git a/crates/opentalk-signaling-module-training-participation-report/src/storage/room_state.rs b/crates/opentalk-signaling-module-training-participation-report/src/storage/room_state.rs index 16e734e91412e659957c6a53d5f75aa20fad5dfb..ce960b89af7134d8fca90c028bad83b2fe8a739f 100644 --- a/crates/opentalk-signaling-module-training-participation-report/src/storage/room_state.rs +++ b/crates/opentalk-signaling-module-training-participation-report/src/storage/room_state.rs @@ -4,9 +4,8 @@ use std::collections::BTreeSet; -use opentalk_types_common::time::Timestamp; +use opentalk_types_common::{time::Timestamp, training_participation_report::TimeRange}; use opentalk_types_signaling::ParticipantId; -use opentalk_types_signaling_training_participation_report::TimeRange; use super::{Checkpoint, TrainingReportState}; diff --git a/crates/opentalk-signaling-module-training-participation-report/src/storage/training_participation_report_storage.rs b/crates/opentalk-signaling-module-training-participation-report/src/storage/training_participation_report_storage.rs index 83648365e2c67a588aaec2afc210da0546fb02f8..1d0f5d87cf1b5ee42ad0ecafac9f2138c5b5e433 100644 --- a/crates/opentalk-signaling-module-training-participation-report/src/storage/training_participation_report_storage.rs +++ b/crates/opentalk-signaling-module-training-participation-report/src/storage/training_participation_report_storage.rs @@ -6,11 +6,11 @@ use std::collections::BTreeSet; use async_trait::async_trait; use opentalk_signaling_core::SignalingModuleError; -use opentalk_types_common::{rooms::RoomId, time::Timestamp}; -use opentalk_types_signaling::ParticipantId; -use opentalk_types_signaling_training_participation_report::{ - state::ParticipationLoggingState, TimeRange, +use opentalk_types_common::{ + rooms::RoomId, time::Timestamp, training_participation_report::TimeRange, }; +use opentalk_types_signaling::ParticipantId; +use opentalk_types_signaling_training_participation_report::state::ParticipationLoggingState; use super::{RoomState, TrainingReportState}; diff --git a/crates/opentalk-signaling-module-training-participation-report/src/storage/volatile/memory.rs b/crates/opentalk-signaling-module-training-participation-report/src/storage/volatile/memory.rs index 998f2ff60f1fc0d97fb41173100f580d30ec0402..2b2b4d85c7315e743a005a478d27e59d013db102 100644 --- a/crates/opentalk-signaling-module-training-participation-report/src/storage/volatile/memory.rs +++ b/crates/opentalk-signaling-module-training-participation-report/src/storage/volatile/memory.rs @@ -5,11 +5,11 @@ use std::collections::{BTreeMap, BTreeSet}; use opentalk_signaling_core::{NotFoundSnafu, SignalingModuleError}; -use opentalk_types_common::{rooms::RoomId, time::Timestamp}; -use opentalk_types_signaling::ParticipantId; -use opentalk_types_signaling_training_participation_report::{ - state::ParticipationLoggingState, TimeRange, +use opentalk_types_common::{ + rooms::RoomId, time::Timestamp, training_participation_report::TimeRange, }; +use opentalk_types_signaling::ParticipantId; +use opentalk_types_signaling_training_participation_report::state::ParticipationLoggingState; use snafu::{ensure_whatever, OptionExt as _}; use crate::storage::{Checkpoint, RoomState, TrainingReportState}; diff --git a/crates/opentalk-signaling-module-training-participation-report/src/storage/volatile/storage.rs b/crates/opentalk-signaling-module-training-participation-report/src/storage/volatile/storage.rs index 40355229aabc37167ffb3e6b52dbbe6066900567..95ebf1e25c8613c550b361cccb7529d449ed8096 100644 --- a/crates/opentalk-signaling-module-training-participation-report/src/storage/volatile/storage.rs +++ b/crates/opentalk-signaling-module-training-participation-report/src/storage/volatile/storage.rs @@ -9,11 +9,11 @@ use std::{ use async_trait::async_trait; use opentalk_signaling_core::{SignalingModuleError, VolatileStaticMemoryStorage}; -use opentalk_types_common::{rooms::RoomId, time::Timestamp}; -use opentalk_types_signaling::ParticipantId; -use opentalk_types_signaling_training_participation_report::{ - state::ParticipationLoggingState, TimeRange, +use opentalk_types_common::{ + rooms::RoomId, time::Timestamp, training_participation_report::TimeRange, }; +use opentalk_types_signaling::ParticipantId; +use opentalk_types_signaling_training_participation_report::state::ParticipationLoggingState; use parking_lot::RwLock; use super::memory::TrainingParticipationReportState; diff --git a/deny.toml b/deny.toml index 8c842641410e3a6e980b8bec2ff7404bce81c183..9473a9cc33f8ce6cb2b91382633574e8c12a112b 100644 --- a/deny.toml +++ b/deny.toml @@ -319,7 +319,6 @@ skip = [ { name = "indexmap", version = "1" }, { name = "itertools", version = "0.10" }, { name = "itertools", version = "0.11" }, - { name = "itertools", version = "0.12" }, { name = "jiff", version = "0.1" }, { name = "linux-raw-sys", version = "0.4" }, { name = "p256", version = "0.11" }, diff --git a/docs/developer/database.md b/docs/developer/database.md index 8783ef729fd83c864611d7d9adff6392a7ced347..b885582a5ab678a062d8631bdf831b384d6bae7f 100644 --- a/docs/developer/database.md +++ b/docs/developer/database.md @@ -75,6 +75,13 @@ event_shared_folders { text write_share_id text write_url } +event_training_participation_report_parameter_sets { + uuid event_id PK,FK + bigint checkpoint_interval_after + bigint checkpoint_interval_within + bigint initial_checkpoint_delay_after + bigint initial_checkpoint_delay_within +} events { uuid id PK uuid created_by FK @@ -241,6 +248,7 @@ event_invites }o--|| events: "" event_invites }o--|| users: "" event_invites }o--|| users: "" event_shared_folders |o--|| events: "" +event_training_participation_report_parameter_sets |o--|| events: "" events }o--|| rooms: "" events }o--|| users: "" events }o--|| users: ""