Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • opentalk/controller
1 result
Show changes
Commits on Source (17)
Showing
with 1928 additions and 636 deletions
......@@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.29.1] - 2025-03-27
[0.29.1]: https://git.opentalk.dev/opentalk/backend/services/controller/-/compare/v0.29.0...v0.29.1
### 🚀 New features
- (training_participation_report) Add configuration to api ([#972](https://git.opentalk.dev/opentalk/backend/services/controller/-/issues/972))
- (training_participation_report) Communicate parameters to frontend on join ([#972](https://git.opentalk.dev/opentalk/backend/services/controller/-/issues/972))
- (training_participation_report) Start training participation report procedure automatically ([#972](https://git.opentalk.dev/opentalk/backend/services/controller/-/issues/972))
### 🐛 Bug fixes
- Address cargo-deny remarks ([!1480](https://git.opentalk.dev/opentalk/backend/services/controller/-/merge_requests/1480))
- (timer) Cleanup on room destroy ([#975](https://git.opentalk.dev/opentalk/backend/services/controller/-/issues/975))
- Exclude moderators from microphone restrictions ([#978](https://git.opentalk.dev/opentalk/backend/services/controller/-/issues/978))
- (event) Handle missing participation report parameter set correctly ([#979](https://git.opentalk.dev/opentalk/backend/services/controller/-/issues/979))
- (meeting_report) Include users who already left the meeting ([#981](https://git.opentalk.dev/opentalk/backend/services/controller/-/issues/981))
- (training-participation-report) Don't fail when updating the database entry ([#982](https://git.opentalk.dev/opentalk/backend/services/controller/-/issues/982))
- (training-participation-report) Parameter set not stored for unscheduled events ([!1507](https://git.opentalk.dev/opentalk/backend/services/controller/-/merge_requests/1507), [#987](https://git.opentalk.dev/opentalk/backend/services/controller/-/issues/987))
### 📦 Dependencies
- (deps) Update ring to 0.17.13 ([!1478](https://git.opentalk.dev/opentalk/backend/services/controller/-/merge_requests/1478))
- (deps) Update rust crate rand to 0.9 ([!1418](https://git.opentalk.dev/opentalk/backend/services/controller/-/merge_requests/1418))
- (deps) Update rust crate zip to 2.4.2 ([!1490](https://git.opentalk.dev/opentalk/backend/services/controller/-/merge_requests/1490))
### ⚙ Miscellaneous
- (justfile) Add commit release script ([!1322](https://git.opentalk.dev/opentalk/backend/services/controller/-/merge_requests/1322))
### Test
- Use ChaCha12Rng instead of StdRng for reproducibility ([!1480](https://git.opentalk.dev/opentalk/backend/services/controller/-/merge_requests/1480))
## [0.29.0] - 2025-03-05
[0.29.0]: https://git.opentalk.dev/opentalk/backend/services/controller/-/compare/v0.28.4...v0.29.0
......
This diff is collapsed.
......@@ -10,37 +10,37 @@ resolver = "2"
authors = ["OpenTalk Team <mail@opentalk.eu>"]
homepage = "https://opentalk.eu/"
repository = "https://gitlab.opencode.de/opentalk/controller"
version = "0.29.0"
version = "0.29.1"
[workspace.dependencies]
kustos = { path = "crates/kustos", version = "0.29.0" }
opentalk-cache = { path = "crates/opentalk-cache", version = "0.29.0" }
opentalk-community-signaling-modules = { path = "crates/opentalk-community-signaling-modules", version = "0.29.0" }
opentalk-controller-core = { path = "crates/opentalk-controller-core", version = "0.29.0" }
opentalk-controller-service = { path = "crates/opentalk-controller-service", version = "0.29.0" }
opentalk-controller-service-facade = { path = "crates/opentalk-controller-service-facade", version = "0.29.0" }
opentalk-controller-settings = { path = "crates/opentalk-controller-settings", version = "0.29.0" }
opentalk-controller-utils = { path = "crates/opentalk-controller-utils", version = "0.29.0" }
opentalk-database = { path = "crates/opentalk-database", version = "0.29.0" }
opentalk-db-storage = { path = "crates/opentalk-db-storage", version = "0.29.0" }
opentalk-jobs = { path = "crates/opentalk-jobs", version = "0.29.0" }
opentalk-log = { path = "crates/opentalk-log", version = "0.29.0" }
opentalk-r3dlock = { path = "crates/opentalk-r3dlock", version = "0.29.0" }
opentalk-report-generation = { path = "crates/opentalk-report-generation", version = "0.29.0" }
opentalk-signaling-core = { path = "crates/opentalk-signaling-core", version = "0.29.0" }
opentalk-signaling-module-chat = { path = "crates/opentalk-signaling-module-chat", version = "0.29.0" }
opentalk-signaling-module-core = { path = "crates/opentalk-signaling-module-core", version = "0.29.0" }
opentalk-signaling-module-integration = { path = "crates/opentalk-signaling-module-integration", version = "0.29.0" }
opentalk-signaling-module-livekit = { path = "crates/opentalk-signaling-module-livekit", version = "0.29.0" }
opentalk-signaling-module-meeting-notes = { path = "crates/opentalk-signaling-module-meeting-notes", version = "0.29.0" }
opentalk-signaling-module-meeting-report = { path = "crates/opentalk-signaling-module-meeting-report", version = "0.29.0" }
opentalk-signaling-module-polls = { path = "crates/opentalk-signaling-module-polls", version = "0.29.0" }
opentalk-signaling-module-shared-folder = { path = "crates/opentalk-signaling-module-shared-folder", version = "0.29.0" }
opentalk-signaling-module-subroom-audio = { path = "crates/opentalk-signaling-module-subroom-audio", version = "0.29.0" }
opentalk-signaling-module-timer = { path = "crates/opentalk-signaling-module-timer", version = "0.29.0" }
opentalk-signaling-module-training-participation-report = { path = "crates/opentalk-signaling-module-training-participation-report", version = "0.29.0" }
opentalk-signaling-module-whiteboard = { path = "crates/opentalk-signaling-module-whiteboard", version = "0.29.0" }
opentalk-test-util = { path = "crates/opentalk-test-util", version = "0.29.0" }
kustos = { path = "crates/kustos", version = "0.29.1" }
opentalk-cache = { path = "crates/opentalk-cache", version = "0.29.1" }
opentalk-community-signaling-modules = { path = "crates/opentalk-community-signaling-modules", version = "0.29.1" }
opentalk-controller-core = { path = "crates/opentalk-controller-core", version = "0.29.1" }
opentalk-controller-service = { path = "crates/opentalk-controller-service", version = "0.29.1" }
opentalk-controller-service-facade = { path = "crates/opentalk-controller-service-facade", version = "0.29.1" }
opentalk-controller-settings = { path = "crates/opentalk-controller-settings", version = "0.29.1" }
opentalk-controller-utils = { path = "crates/opentalk-controller-utils", version = "0.29.1" }
opentalk-database = { path = "crates/opentalk-database", version = "0.29.1" }
opentalk-db-storage = { path = "crates/opentalk-db-storage", version = "0.29.1" }
opentalk-jobs = { path = "crates/opentalk-jobs", version = "0.29.1" }
opentalk-log = { path = "crates/opentalk-log", version = "0.29.1" }
opentalk-r3dlock = { path = "crates/opentalk-r3dlock", version = "0.29.1" }
opentalk-report-generation = { path = "crates/opentalk-report-generation", version = "0.29.1" }
opentalk-signaling-core = { path = "crates/opentalk-signaling-core", version = "0.29.1" }
opentalk-signaling-module-chat = { path = "crates/opentalk-signaling-module-chat", version = "0.29.1" }
opentalk-signaling-module-core = { path = "crates/opentalk-signaling-module-core", version = "0.29.1" }
opentalk-signaling-module-integration = { path = "crates/opentalk-signaling-module-integration", version = "0.29.1" }
opentalk-signaling-module-livekit = { path = "crates/opentalk-signaling-module-livekit", version = "0.29.1" }
opentalk-signaling-module-meeting-notes = { path = "crates/opentalk-signaling-module-meeting-notes", version = "0.29.1" }
opentalk-signaling-module-meeting-report = { path = "crates/opentalk-signaling-module-meeting-report", version = "0.29.1" }
opentalk-signaling-module-polls = { path = "crates/opentalk-signaling-module-polls", version = "0.29.1" }
opentalk-signaling-module-shared-folder = { path = "crates/opentalk-signaling-module-shared-folder", version = "0.29.1" }
opentalk-signaling-module-subroom-audio = { path = "crates/opentalk-signaling-module-subroom-audio", version = "0.29.1" }
opentalk-signaling-module-timer = { path = "crates/opentalk-signaling-module-timer", version = "0.29.1" }
opentalk-signaling-module-training-participation-report = { path = "crates/opentalk-signaling-module-training-participation-report", version = "0.29.1" }
opentalk-signaling-module-whiteboard = { path = "crates/opentalk-signaling-module-whiteboard", version = "0.29.1" }
opentalk-test-util = { path = "crates/opentalk-test-util", version = "0.29.1" }
actix-http = "3"
actix-rt = "2"
......@@ -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 = [
......@@ -120,7 +120,8 @@ parking_lot = "0.12"
pdf-extract = "0.8"
phonenumber = "0.3"
pretty_assertions = "1"
rand = "0.8"
rand = "0.9"
rand_chacha = "0.9"
redis = "0.29"
redis-args = "0.19"
reqwest = { version = "0.12", default-features = false }
......
......@@ -9,7 +9,7 @@ info:
license:
name: EUPL-1.2
identifier: EUPL-1.2
version: 0.29.0
version: 0.29.1
servers:
- url: /v1
paths:
......@@ -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.
......
......@@ -121,6 +121,7 @@ yaml-rust2.workspace = true
[dev-dependencies]
opentalk-test-util = { workspace = true, features = ["database"] }
pretty_assertions.workspace = true
rand_chacha.workspace = true
serial_test.workspace = true
[build-dependencies]
......
......@@ -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!(
......
......@@ -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 {
......
......@@ -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, &current_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();
......@@ -625,6 +668,12 @@ async fn create_time_dependent_event(
let suppress_email_notification = is_adhoc || query.suppress_email_notification;
let training_participation_report = if let Some(parameters) = training_participation_report {
store_training_participation_report(conn, event.id, parameters).await?
} else {
None
};
let mail_resource = (!suppress_email_notification).then(|| MailResource {
current_user: current_user.clone(),
event: event.clone(),
......@@ -664,6 +713,7 @@ async fn create_time_dependent_event(
shared_folder: None,
streaming_targets,
show_meeting_details,
training_participation_report,
},
mail_resource,
))
......@@ -771,7 +821,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 +859,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 +944,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 +1055,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 +1116,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 +1235,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 +1283,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 +1395,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 +1436,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 +1834,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 +2361,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 +2441,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 +2517,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 +2588,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 +2660,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,
},
},
}
);
}
......
......@@ -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);
......
......@@ -29,8 +29,8 @@ use opentalk_types_api_v1::{
};
use opentalk_types_common::rooms::invite_codes::InviteCode;
use rand::{
distributions::{Distribution, Uniform},
prelude::SliceRandom,
distr::{Distribution, Uniform},
seq::IndexedRandom,
CryptoRng, Rng,
};
use ring::hmac;
......@@ -114,7 +114,7 @@ pub async fn get(
.map_err(|_| ApiError::internal())?)
.timestamp();
let mut rand_rng = ::rand::thread_rng();
rr_servers(&mut rand_rng, &turn_config.servers, expires)
rr_servers(&mut rand_rng, &turn_config.servers, expires)?
}
None => vec![],
};
......@@ -160,16 +160,16 @@ fn rr_servers<T: Rng + CryptoRng>(
rng: &mut T,
servers: &[TurnServer],
expires: i64,
) -> Vec<IceServer> {
) -> Result<Vec<IceServer>, CaptureApiError> {
// Create a list of TURN responses for each configured TURN server.
match servers.len() {
0 => vec![],
0 => Ok(vec![]),
// When we only have one configured TURN server, return the credentials for this single one.
1 => vec![create_credentials(rng, &servers[0].pre_shared_key, expires, &servers[0].uris)]
1 => Ok(vec![create_credentials(rng, &servers[0].pre_shared_key, expires, &servers[0].uris)])
,
// When we have two configured TURN servers, draw a random one and return the credentials for this drawn one.
2 => {
let between: Uniform<u32> = Uniform::from(0..1);
let between: Uniform<u32> = Uniform::try_from(0..1)?;
let selected_server = between.sample(rng) as usize;
let turn = create_credentials(
rng,
......@@ -178,15 +178,18 @@ fn rr_servers<T: Rng + CryptoRng>(
&servers[selected_server].uris,
);
vec![turn]
Ok(vec![turn])
}
// When we have more than two configured TURN servers, draw two and return the credentials for the drawn ones.
_ => servers
_ => {
let ice_servers = servers
.choose_multiple(rng, 2)
.map(|server| {
create_credentials(rng, &server.pre_shared_key, expires, &server.uris)
})
.collect::<Vec<_>>(),
.collect::<Vec<_>>();
Ok(ice_servers)
},
}
}
......@@ -249,13 +252,16 @@ async fn check_access_token_or_invite(
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use rand::SeedableRng;
use rand_chacha::ChaCha12Rng;
use super::*;
#[test]
fn test_create_credentials() {
use rand::{prelude::*, SeedableRng};
let mut rng = StdRng::seed_from_u64(1234567890);
// Using ChaCha12Rng instead of StdRng because of the reproducibility for randomness
// generation as recommeded by the documentation of the rand crate.
let mut rng = ChaCha12Rng::seed_from_u64(1234567890);
let credentials =
create_credentials(&mut rng, "PSK", 3400, &["turn:turn.turn.turn".to_owned()]);
assert_eq!(
......@@ -271,30 +277,28 @@ mod tests {
#[test]
fn test_round_robin() {
use rand::{prelude::*, SeedableRng};
// No configured servers
let mut rng = StdRng::seed_from_u64(1234567890);
assert_eq!(rr_servers(&mut rng, &[], 1200), vec![]);
let mut rng = ChaCha12Rng::from_seed(Default::default());
assert_eq!(rr_servers(&mut rng, &[], 1200).unwrap(), vec![]);
// One configured server
let mut rng = StdRng::seed_from_u64(1234567890);
let mut rng = ChaCha12Rng::from_seed(Default::default());
let one_server = vec![TurnServer {
uris: vec!["turn:turn1.turn.turn".to_owned()],
pre_shared_key: "PSK1".to_owned(),
}];
assert_eq!(
rr_servers(&mut rng, &one_server, 1200),
rr_servers(&mut rng, &one_server, 1200).unwrap(),
vec![IceServer::Turn(opentalk_types_api_v1::turn::TurnServer {
username: "1200:turn_random_for_privacy_8VbonSpZc9GXSw9gMxaV0A==".to_owned(),
password: "G7hjqVZX/dVAOgzzb+GeS8vEpcU=".to_owned(),
username: "1200:turn_random_for_privacy_mweBXwRJfi4F0iysOqBhQQ==".to_owned(),
password: "iSudkrDCRQdeTWGDRva9p1L7H/w=".to_owned(),
ttl: 1200.to_string(),
uris: vec!["turn:turn1.turn.turn".to_owned()]
})]
);
// Two configured servers
let mut rng = StdRng::seed_from_u64(1234567890);
let mut rng = ChaCha12Rng::from_seed(Default::default());
let two_servers = vec![
TurnServer {
uris: vec!["turn:turn1.turn.turn".to_owned()],
......@@ -306,17 +310,17 @@ mod tests {
},
];
assert_eq!(
rr_servers(&mut rng, &two_servers, 1200),
rr_servers(&mut rng, &two_servers, 1200).unwrap(),
vec![IceServer::Turn(opentalk_types_api_v1::turn::TurnServer {
username: "1200:turn_random_for_privacy_VuidKllz0ZdLD2AzFpXQYA==".to_owned(),
password: "Aybo95/GPrJhWN2qqbz6yP2qEvg=".to_owned(),
username: "1200:turn_random_for_privacy_B4FfBEl+LgXSLKw6oGFBCw==".to_owned(),
password: "GqPGDzE/kk+2IR/tBpdWiAHR90E=".to_owned(),
ttl: 1200.to_string(),
uris: vec!["turn:turn1.turn.turn".to_owned()]
})]
);
// Three configured servers
let mut rng = StdRng::seed_from_u64(1234567890);
let mut rng = ChaCha12Rng::from_seed(Default::default());
let three_servers = vec![
TurnServer {
uris: vec!["turn:turn1.turn.turn".to_owned()],
......@@ -332,19 +336,19 @@ mod tests {
},
];
assert_eq!(
rr_servers(&mut rng, &three_servers, 1200),
rr_servers(&mut rng, &three_servers, 1200).unwrap(),
vec![
IceServer::Turn(opentalk_types_api_v1::turn::TurnServer {
username: "1200:turn_random_for_privacy_nSpZc9GXSw9gMxaV0GDahQ==".to_owned(),
password: "6zfptEfCPlF3oWFtPKtlAPwjAWs=".to_owned(),
username: "1200:turn_random_for_privacy_gV8ESX4uBdIsrDqgYUELIA==".to_owned(),
password: "pg151PsT+0F78fxQhIcm7IMQgcU=".to_owned(),
ttl: 1200.to_string(),
uris: vec!["turn:turn1.turn.turn".to_owned()]
uris: vec!["turn:turn3.turn.turn".to_owned()]
}),
IceServer::Turn(opentalk_types_api_v1::turn::TurnServer {
username: "1200:turn_random_for_privacy_AYzBIYeOCHhhiwR7CQ3X1A==".to_owned(),
password: "fiWX+emwV1thN/dBcZ9melA061g=".to_owned(),
username: "1200:turn_random_for_privacy_hozGGRVMQqHGG+mQJxeySw==".to_owned(),
password: "f01GWWpM8lJpfb6pceKS+tdF35A=".to_owned(),
ttl: 1200.to_string(),
uris: vec!["turn:turn3.turn.turn".to_owned()]
uris: vec!["turn:turn1.turn.turn".to_owned()]
})
]
);
......@@ -355,6 +359,7 @@ mod tests {
let mut third = 0;
for _ in 1..5000 {
rr_servers(&mut rng, &three_servers, 1200)
.unwrap()
.iter()
.filter_map(|e| match e {
IceServer::Turn(turn) => Some(turn),
......
// 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")
})
}
// 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, &notification_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)
);
}
}
}
......@@ -33,6 +33,7 @@ opentalk-signaling-core.workspace = true
opentalk-types-api-v1 = { workspace = true, features = ["actix"] }
opentalk-types-common = { workspace = true, features = ["kustos"] }
opentalk-types-signaling.workspace = true
rand.workspace = true
rrule.workspace = true
serde_json.workspace = true
snafu.workspace = true
......@@ -7,6 +7,7 @@ use std::fmt::Display;
use opentalk_database::DatabaseError;
use opentalk_signaling_core::{assets::AssetError, ObjectStorageError};
use opentalk_types_api_v1::error::ApiError;
use rand::distr::uniform;
use snafu::Whatever;
use crate::event::EventRRuleSetError;
......@@ -93,6 +94,13 @@ impl From<AssetError> for CaptureApiError {
}
}
impl From<uniform::Error> for CaptureApiError {
fn from(value: uniform::Error) -> Self {
log::error!("REST API threw internal error: {value:?}");
CaptureApiError(ApiError::internal())
}
}
impl Display for CaptureApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
......
......@@ -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)))
.left_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,164 @@ 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(),
},
}
}
}
impl From<(EventId, TrainingParticipationReportParameterSet)>
for EventTrainingParticipationReportParameterSet
{
fn from(
(
event_id,
TrainingParticipationReportParameterSet {
initial_checkpoint_delay,
checkpoint_interval,
},
): (EventId, TrainingParticipationReportParameterSet),
) -> Self {
Self {
event_id,
initial_checkpoint_delay_after: i64::try_from(initial_checkpoint_delay.after)
.unwrap_or(i64::MAX),
initial_checkpoint_delay_within: i64::try_from(initial_checkpoint_delay.within)
.unwrap_or(i64::MAX),
checkpoint_interval_after: i64::try_from(checkpoint_interval.after).unwrap_or(i64::MAX),
checkpoint_interval_within: i64::try_from(checkpoint_interval.within)
.unwrap_or(i64::MAX),
}
}
}
#[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),
),
}
}
}
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
);
......@@ -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,
......
This diff is collapsed.

Consent

On this website, we use the web analytics service Matomo to analyze and review the use of our website. Through the collected statistics, we can improve our offerings and make them more appealing for you. Here, you can decide whether to allow us to process your data and set corresponding cookies for these purposes, in addition to technically necessary cookies. Further information on data protection—especially regarding "cookies" and "Matomo"—can be found in our privacy policy. You can withdraw your consent at any time.