1use std::collections::HashMap;
4
5use utoipa::ToSchema;
6
7use crate::{
8 course_modules::CompletionPolicy,
9 courses::CourseAiPolicy,
10 library::{
11 agent_changesets::course_state::{
12 CourseState, ExerciseRecord, PageRecord, ReviewConfigKind,
13 },
14 entity_snapshots::{
15 ChapterSnapshot, ContentEntity, ContentEntityType, CourseDefaultReviewConfigSnapshot,
16 CourseSettingsSnapshot, EntityKey, EntityRef, EntitySnapshot, GlossaryTermSnapshot,
17 ModuleSnapshot, PageSnapshot, ReferenceSnapshot,
18 },
19 },
20 prelude::*,
21};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
26#[serde(rename_all = "snake_case")]
27pub enum EntityField {
28 Title,
29 UrlPath,
30 Hidden,
31 ChapterId,
32 OrderNumber,
33 Name,
34 Color,
35 ChapterNumber,
36 CourseModuleId,
37 FrontPageId,
38 OpensAt,
39 Deadline,
40 ChapterImagePath,
41 CompletionPolicy,
42 CertificationEnabled,
43 UhCourseCode,
44 EctsCredits,
45 CompletionRegistrationLinkOverride,
46 CompletionsCanBeRegisteredToSisu,
47 Description,
48 IsDraft,
49 IsTestMode,
50 IsUnlisted,
51 CanAddChatbot,
52 IsJoinableByCodeOnly,
53 AskMarketingConsent,
54 FlaggedAnswersThreshold,
55 FlaggedAnswersSkipManualReviewAndAllowRetry,
56 ClosedAt,
57 ClosedAdditionalMessage,
58 ClosedCourseSuccessorId,
59 ChapterLockingEnabled,
60 AiPolicy,
61 CourseMaterialAiInstructions,
62 BaseModuleCompletionRequiresNSubmoduleCompletions,
63 Audiences,
64 Term,
65 Definition,
66 CitationKey,
67 Reference,
68 ReviewInstructions,
69 ExerciseName,
71 ExerciseDeadline,
73 TaskInstructions,
75 ReviewConfig,
77}
78
79impl EntityField {
80 pub fn is_placement(self) -> bool {
82 matches!(
83 self,
84 Self::ChapterId
85 | Self::OrderNumber
86 | Self::ChapterNumber
87 | Self::CourseModuleId
88 | Self::FrontPageId
89 )
90 }
91}
92
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
95#[serde(tag = "type", content = "value", rename_all = "snake_case")]
96pub enum FieldValue {
97 Text(String),
98 Number(f64),
99 Bool(bool),
100 DateTime(DateTime<Utc>),
101 Reference(EntityRef),
103 TextList(Vec<String>),
104 CompletionPolicy(CompletionPolicy),
105 AiPolicy(CourseAiPolicy),
106 Blocks(serde_json::Value),
108 ReviewConfig(ReviewConfigKind),
109}
110
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
113pub struct ExerciseFieldOwner {
114 pub exercise_id: Uuid,
115 pub exercise_name: String,
117 pub task_id: Option<Uuid>,
119}
120
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
123pub struct FieldChange {
124 pub field: EntityField,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub exercise: Option<ExerciseFieldOwner>,
128 pub before: Option<FieldValue>,
130 pub after: Option<FieldValue>,
132}
133
134impl FieldChange {
135 pub fn references_mut(&mut self) -> impl Iterator<Item = &mut EntityRef> {
137 [&mut self.before, &mut self.after]
138 .into_iter()
139 .filter_map(|value| match value {
140 Some(FieldValue::Reference(entity)) => Some(entity),
141 _ => None,
142 })
143 }
144}
145
146type Fields = Vec<(EntityField, Option<FieldValue>)>;
147
148fn text(value: &str) -> Option<FieldValue> {
149 Some(FieldValue::Text(value.to_string()))
150}
151
152fn optional_text(value: &Option<String>) -> Option<FieldValue> {
153 value.as_deref().and_then(text)
154}
155
156fn flag(value: bool) -> Option<FieldValue> {
157 Some(FieldValue::Bool(value))
158}
159
160fn number(value: impl Into<f64>) -> Option<FieldValue> {
161 Some(FieldValue::Number(value.into()))
162}
163
164fn date_time(value: Option<DateTime<Utc>>) -> Option<FieldValue> {
165 value.map(FieldValue::DateTime)
166}
167
168fn reference(entity_type: ContentEntityType, id: Option<Uuid>) -> Option<FieldValue> {
170 id.map(|entity_id| {
171 FieldValue::Reference(EntityRef {
172 entity_type,
173 entity_id,
174 label: String::new(),
175 })
176 })
177}
178
179trait HasFields {
182 fn fields(&self) -> Fields;
183}
184
185impl HasFields for PageRecord {
186 fn fields(&self) -> Fields {
187 let PageRecord {
188 id: _,
189 chapter_id,
190 url_path,
191 title,
192 hidden,
193 order_number,
194 content: _,
195 joins_page_language_group_id: _,
196 } = self;
197 vec![
198 (EntityField::Title, text(title)),
199 (EntityField::UrlPath, text(url_path)),
200 (EntityField::Hidden, flag(*hidden)),
201 (
202 EntityField::ChapterId,
203 reference(ContentEntityType::Chapter, *chapter_id),
204 ),
205 (EntityField::OrderNumber, number(*order_number)),
206 ]
207 }
208}
209
210impl HasFields for ChapterSnapshot {
211 fn fields(&self) -> Fields {
212 let ChapterSnapshot {
213 id: _,
214 course_id: _,
215 course_module_id,
216 name,
217 color,
218 chapter_number,
219 front_page_id,
220 opens_at,
221 deadline,
222 chapter_image_path,
223 } = self;
224 vec![
225 (EntityField::Name, text(name)),
226 (EntityField::Color, optional_text(color)),
227 (EntityField::ChapterNumber, number(*chapter_number)),
228 (
229 EntityField::CourseModuleId,
230 reference(ContentEntityType::Module, Some(*course_module_id)),
231 ),
232 (
233 EntityField::FrontPageId,
234 reference(ContentEntityType::Page, *front_page_id),
235 ),
236 (EntityField::OpensAt, date_time(*opens_at)),
237 (EntityField::Deadline, date_time(*deadline)),
238 (
239 EntityField::ChapterImagePath,
240 optional_text(chapter_image_path),
241 ),
242 ]
243 }
244}
245
246impl HasFields for ModuleSnapshot {
247 fn fields(&self) -> Fields {
248 let ModuleSnapshot {
249 id: _,
250 course_id: _,
251 name,
252 order_number,
253 completion_policy,
254 certification_enabled,
255 uh_course_code,
256 ects_credits,
257 completion_registration_link_override,
258 completions_can_be_registered_to_sisu,
259 } = self;
260 vec![
261 (EntityField::Name, optional_text(name)),
262 (EntityField::OrderNumber, number(*order_number)),
263 (
264 EntityField::CompletionPolicy,
265 Some(FieldValue::CompletionPolicy(completion_policy.clone())),
266 ),
267 (
268 EntityField::CertificationEnabled,
269 flag(*certification_enabled),
270 ),
271 (EntityField::UhCourseCode, optional_text(uh_course_code)),
272 (EntityField::EctsCredits, ects_credits.and_then(number)),
273 (
274 EntityField::CompletionRegistrationLinkOverride,
275 optional_text(completion_registration_link_override),
276 ),
277 (
278 EntityField::CompletionsCanBeRegisteredToSisu,
279 flag(*completions_can_be_registered_to_sisu),
280 ),
281 ]
282 }
283}
284
285impl HasFields for CourseSettingsSnapshot {
286 fn fields(&self) -> Fields {
287 let CourseSettingsSnapshot {
288 course_id: _,
289 name,
290 description,
291 is_draft,
292 is_test_mode,
293 is_unlisted,
294 can_add_chatbot,
295 is_joinable_by_code_only,
296 ask_marketing_consent,
297 flagged_answers_threshold,
298 flagged_answers_skip_manual_review_and_allow_retry,
299 closed_at,
300 closed_additional_message,
301 closed_course_successor_id,
302 chapter_locking_enabled,
303 ai_policy,
304 course_material_ai_instructions,
305 base_module_completion_requires_n_submodule_completions,
306 audiences,
307 } = self;
308 vec![
309 (EntityField::Name, text(name)),
310 (EntityField::Description, optional_text(description)),
311 (EntityField::IsDraft, flag(*is_draft)),
312 (EntityField::IsTestMode, flag(*is_test_mode)),
313 (EntityField::IsUnlisted, flag(*is_unlisted)),
314 (EntityField::CanAddChatbot, flag(*can_add_chatbot)),
315 (
316 EntityField::IsJoinableByCodeOnly,
317 flag(*is_joinable_by_code_only),
318 ),
319 (
320 EntityField::AskMarketingConsent,
321 flag(*ask_marketing_consent),
322 ),
323 (
324 EntityField::FlaggedAnswersThreshold,
325 flagged_answers_threshold.and_then(number),
326 ),
327 (
328 EntityField::FlaggedAnswersSkipManualReviewAndAllowRetry,
329 flag(*flagged_answers_skip_manual_review_and_allow_retry),
330 ),
331 (EntityField::ClosedAt, date_time(*closed_at)),
332 (
333 EntityField::ClosedAdditionalMessage,
334 optional_text(closed_additional_message),
335 ),
336 (
337 EntityField::ClosedCourseSuccessorId,
338 reference(
339 ContentEntityType::CourseSettings,
340 *closed_course_successor_id,
341 ),
342 ),
343 (
344 EntityField::ChapterLockingEnabled,
345 flag(*chapter_locking_enabled),
346 ),
347 (
348 EntityField::AiPolicy,
349 Some(FieldValue::AiPolicy(*ai_policy)),
350 ),
351 (
352 EntityField::CourseMaterialAiInstructions,
353 course_material_ai_instructions.and_then(flag),
354 ),
355 (
356 EntityField::BaseModuleCompletionRequiresNSubmoduleCompletions,
357 number(*base_module_completion_requires_n_submodule_completions),
358 ),
359 (
360 EntityField::Audiences,
361 Some(FieldValue::TextList(
362 audiences.iter().map(|a| a.audience.clone()).collect(),
363 )),
364 ),
365 ]
366 }
367}
368
369impl HasFields for GlossaryTermSnapshot {
370 fn fields(&self) -> Fields {
371 let GlossaryTermSnapshot {
372 id: _,
373 course_id: _,
374 term,
375 definition,
376 } = self;
377 vec![
378 (EntityField::Term, text(term)),
379 (EntityField::Definition, text(definition)),
380 ]
381 }
382}
383
384impl HasFields for ReferenceSnapshot {
385 fn fields(&self) -> Fields {
386 let ReferenceSnapshot {
387 id: _,
388 course_id: _,
389 citation_key,
390 reference,
391 } = self;
392 vec![
393 (EntityField::CitationKey, text(citation_key)),
394 (EntityField::Reference, text(reference)),
395 ]
396 }
397}
398
399impl HasFields for CourseDefaultReviewConfigSnapshot {
400 fn fields(&self) -> Fields {
401 let CourseDefaultReviewConfigSnapshot {
402 id: _,
403 course_id: _,
404 review_instructions,
405 } = self;
406 vec![(
407 EntityField::ReviewInstructions,
408 review_instructions.clone().map(FieldValue::Blocks),
409 )]
410 }
411}
412
413impl HasFields for EntitySnapshot {
414 fn fields(&self) -> Fields {
415 match self {
416 EntitySnapshot::Page(page) => PageRecord::from(page).fields(),
417 EntitySnapshot::Chapter(chapter) => chapter.fields(),
418 EntitySnapshot::Module(module) => module.fields(),
419 EntitySnapshot::CourseSettings(settings) => settings.fields(),
420 EntitySnapshot::GlossaryTerm(term) => term.fields(),
421 EntitySnapshot::Reference(reference) => reference.fields(),
422 EntitySnapshot::CourseDefaultReviewConfig(config) => config.fields(),
423 }
424 }
425}
426
427fn diff<T: HasFields>(before: Option<&T>, after: Option<&T>) -> Vec<FieldChange> {
430 let before = before.map(T::fields);
431 let after = after.map(T::fields);
432 let fields: Vec<EntityField> = before
433 .iter()
434 .chain(after.iter())
435 .flatten()
436 .map(|(field, _)| *field)
437 .collect();
438 let value = |state: &Option<Fields>, field: EntityField| {
439 state
440 .iter()
441 .flatten()
442 .find(|(f, _)| *f == field)
443 .and_then(|(_, value)| value.clone())
444 };
445 let mut changes: Vec<FieldChange> = Vec::new();
446 for field in fields {
447 let (before, after) = (value(&before, field), value(&after, field));
448 if before != after && !changes.iter().any(|change| change.field == field) {
449 changes.push(FieldChange {
450 field,
451 exercise: None,
452 before,
453 after,
454 });
455 }
456 }
457 changes
458}
459
460pub fn field_changes(
464 before: Option<&EntitySnapshot>,
465 after: Option<&EntitySnapshot>,
466 exercises_before: &HashMap<Uuid, ExerciseRecord>,
467) -> Vec<FieldChange> {
468 let mut changes = diff(before, after);
469 if let Some(page) = after.and_then(PageSnapshot::narrow) {
470 let exercises = ExerciseRecord::of_page(page);
471 changes.extend(exercises_field_changes(&exercises, |id| {
472 exercises_before.get(&id)
473 }));
474 }
475 changes
476}
477
478fn exercises_field_changes<'a>(
481 exercises: impl IntoIterator<Item = &'a ExerciseRecord>,
482 before: impl Fn(Uuid) -> Option<&'a ExerciseRecord>,
483) -> Vec<FieldChange> {
484 exercises
485 .into_iter()
486 .filter_map(|after| Some(exercise_field_changes(before(after.id)?, after)))
487 .flatten()
488 .collect()
489}
490
491pub fn exercise_field_changes(before: &ExerciseRecord, after: &ExerciseRecord) -> Vec<FieldChange> {
494 let owner = |task_id: Option<Uuid>| {
495 Some(ExerciseFieldOwner {
496 exercise_id: after.id,
497 exercise_name: after.name.clone(),
498 task_id,
499 })
500 };
501 let mut changes = Vec::new();
502 if before.name != after.name {
503 changes.push(FieldChange {
504 field: EntityField::ExerciseName,
505 exercise: owner(None),
506 before: text(&before.name),
507 after: text(&after.name),
508 });
509 }
510 if before.deadline != after.deadline {
511 changes.push(FieldChange {
512 field: EntityField::ExerciseDeadline,
513 exercise: owner(None),
514 before: date_time(before.deadline),
515 after: date_time(after.deadline),
516 });
517 }
518 for task in &after.tasks {
519 if let Some(before_task) = before.tasks.iter().find(|t| t.id == task.id)
520 && before_task.assignment != task.assignment
521 {
522 changes.push(FieldChange {
523 field: EntityField::TaskInstructions,
524 exercise: owner(Some(task.id)),
525 before: Some(FieldValue::Blocks((*before_task.assignment).clone())),
526 after: Some(FieldValue::Blocks((*task.assignment).clone())),
527 });
528 }
529 }
530 let (before_kind, after_kind) = (
531 before.review_config.kind().map(FieldValue::ReviewConfig),
532 after.review_config.kind().map(FieldValue::ReviewConfig),
533 );
534 if before_kind != after_kind {
535 changes.push(FieldChange {
536 field: EntityField::ReviewConfig,
537 exercise: owner(None),
538 before: before_kind,
539 after: after_kind,
540 });
541 }
542 let (before_instructions, after_instructions) = (
543 before
544 .review_config
545 .own_review_instructions()
546 .cloned()
547 .map(FieldValue::Blocks),
548 after
549 .review_config
550 .own_review_instructions()
551 .cloned()
552 .map(FieldValue::Blocks),
553 );
554 if before_instructions != after_instructions {
555 changes.push(FieldChange {
556 field: EntityField::ReviewInstructions,
557 exercise: owner(None),
558 before: before_instructions,
559 after: after_instructions,
560 });
561 }
562 changes
563}
564
565impl CourseState {
566 pub fn field_changes_since(&self, before: &CourseState, key: EntityKey) -> Vec<FieldChange> {
569 let mut changes = match key.entity_type {
570 ContentEntityType::Page => {
571 let mut changes = diff(before.page(key.entity_id), self.page(key.entity_id));
572 if let Some(page) = self.page(key.entity_id) {
573 let exercises = page
574 .content
575 .iter()
576 .filter_map(|block| self.exercise(block.exercise_id()?));
577 changes.extend(exercises_field_changes(exercises, |id| before.exercise(id)));
578 }
579 changes
580 }
581 _ => diff(
582 before.entity_snapshot(key).as_ref(),
583 self.entity_snapshot(key).as_ref(),
584 ),
585 };
586 for entity in changes.iter_mut().flat_map(FieldChange::references_mut) {
587 entity.label = self
588 .entity_label(entity.key())
589 .or_else(|| before.entity_label(entity.key()))
590 .unwrap_or_default();
591 }
592 changes
593 }
594}