Skip to main content

headless_lms_models/library/agent_changesets/
field_changes.rs

1//! What a change does to an entity, field by field, in the shape the review page and agents read.
2
3use 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/// A field of an entity snapshot that a change can set, named as the snapshot names it. Ids and
24/// page content are no fields: content is shown rendered.
25#[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    /// An exercise's name.
70    ExerciseName,
71    /// An exercise's own deadline.
72    ExerciseDeadline,
73    /// One task's instructions.
74    TaskInstructions,
75    /// Whether an exercise uses the course default review config or its own.
76    ReviewConfig,
77}
78
79impl EntityField {
80    /// Whether the field only places the entity among its siblings or in a parent.
81    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/// A field's value, typed for display.
94#[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    /// Another entity, labelled as it is or will be called.
102    Reference(EntityRef),
103    TextList(Vec<String>),
104    CompletionPolicy(CompletionPolicy),
105    AiPolicy(CourseAiPolicy),
106    /// Blocks, as stored.
107    Blocks(serde_json::Value),
108    ReviewConfig(ReviewConfigKind),
109}
110
111/// The exercise of a page a field change is in.
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
113pub struct ExerciseFieldOwner {
114    pub exercise_id: Uuid,
115    /// The exercise's name after the change.
116    pub exercise_name: String,
117    /// The task, for [`EntityField::TaskInstructions`].
118    pub task_id: Option<Uuid>,
119}
120
121/// One field a change sets to another value.
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
123pub struct FieldChange {
124    pub field: EntityField,
125    /// The exercise whose field this is; `None` for the entity's own fields.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub exercise: Option<ExerciseFieldOwner>,
128    /// `None` when the entity or the value didn't exist.
129    pub before: Option<FieldValue>,
130    /// `None` when the entity or the value doesn't exist after the change.
131    pub after: Option<FieldValue>,
132}
133
134impl FieldChange {
135    /// The entities the values point at, whose labels are left to the caller.
136    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
168/// A reference to be labelled; see [`FieldChange::references_mut`].
169fn 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
179/// The fields of each kind of entity. Each destructures its snapshot, so that a new snapshot field
180/// can't go unlisted.
181trait 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
427/// The fields that differ between two states of one entity, `None` for a state where it doesn't
428/// exist; both must be of the same kind.
429fn 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
460/// What a change does to an entity, field by field, with references left unlabelled; see
461/// [`FieldChange::references_mut`]. A page's exercises are compared with `exercises_before`,
462/// the exercises before the change by id, wherever they were.
463pub 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
478/// The field changes of `exercises` from their records in `before`; exercises without one there
479/// are new, so their fields are left out.
480fn 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
491/// The fields agents edit that differ between two states of one exercise. Review instructions
492/// count only where the exercise has its own; the course default's are its own entity's.
493pub 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    /// What this state changes of one entity of `before`, field by field, with references
567    /// labelled as this state calls them, or `before` does.
568    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}