Skip to main content

headless_lms_models/library/agent_changesets/course_state/
records.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use headless_lms_utils::document_schema_processor::GutenbergBlock;
5use utoipa::ToSchema;
6
7use crate::{
8    library::entity_snapshots::{ContentEntityType, EntityRef, ExerciseBundle, PageSnapshot},
9    pages::CmsPageExercise,
10    peer_or_self_review_configs::CmsPeerOrSelfReviewConfiguration,
11    peer_or_self_review_questions::normalize_cms_peer_or_self_review_questions,
12    prelude::*,
13};
14
15/// A page's structure fields and blocks; exercises stay in the full [`PageSnapshot`].
16#[derive(Debug, Clone, PartialEq)]
17pub struct PageRecord {
18    pub id: Uuid,
19    pub chapter_id: Option<Uuid>,
20    pub url_path: String,
21    pub title: String,
22    pub hidden: bool,
23    pub order_number: i32,
24    /// Shared between states and snapshots until one of them changes it, so that comparing
25    /// unchanged blocks is a pointer comparison.
26    pub content: Arc<Vec<GutenbergBlock>>,
27    /// See [`PageSnapshot::joins_page_language_group_id`].
28    pub joins_page_language_group_id: Option<Uuid>,
29}
30
31impl PageRecord {
32    /// The page, labelled with its title.
33    pub fn entity_ref(&self) -> EntityRef {
34        EntityRef {
35            entity_type: ContentEntityType::Page,
36            entity_id: self.id,
37            label: self.title.clone(),
38        }
39    }
40}
41
42impl PageSnapshot {
43    /// The page `record` describes, with exactly the exercises its exercise blocks name, taken
44    /// from `bundles`. Errors with the exercise ids `bundles` doesn't have.
45    pub fn from_record_and_bundles(
46        course_id: Uuid,
47        record: &PageRecord,
48        bundles: &HashMap<Uuid, ExerciseBundle>,
49    ) -> Result<Self, Vec<Uuid>> {
50        let mut snapshot = Self {
51            id: record.id,
52            course_id,
53            chapter_id: record.chapter_id,
54            url_path: record.url_path.clone(),
55            title: record.title.clone(),
56            hidden: record.hidden,
57            order_number: record.order_number,
58            content: Arc::clone(&record.content),
59            exercises: Vec::new(),
60            exercise_slides: Vec::new(),
61            exercise_tasks: Vec::new(),
62            joins_page_language_group_id: record.joins_page_language_group_id,
63        };
64        let mut missing = Vec::new();
65        for exercise_id in record
66            .content
67            .iter()
68            .filter_map(GutenbergBlock::exercise_id)
69        {
70            match bundles.get(&exercise_id) {
71                Some(bundle) => {
72                    snapshot.exercises.push(bundle.exercise.clone());
73                    snapshot
74                        .exercise_slides
75                        .extend(bundle.slides.iter().cloned());
76                    snapshot.exercise_tasks.extend(bundle.tasks.iter().cloned());
77                }
78                None => missing.push(exercise_id),
79            }
80        }
81        snapshot.sort();
82        if missing.is_empty() {
83            Ok(snapshot)
84        } else {
85            Err(missing)
86        }
87    }
88}
89
90impl From<&PageSnapshot> for PageRecord {
91    fn from(page: &PageSnapshot) -> Self {
92        Self {
93            id: page.id,
94            chapter_id: page.chapter_id,
95            url_path: page.url_path.clone(),
96            title: page.title.clone(),
97            hidden: page.hidden,
98            order_number: page.order_number,
99            content: Arc::clone(&page.content),
100            joins_page_language_group_id: page.joins_page_language_group_id,
101        }
102    }
103}
104
105/// What agents may edit of an exercise, and what identifies its tasks and review settings. The
106/// exercise itself is stored in the full [`PageSnapshot`] of its page.
107#[derive(Debug, Clone, PartialEq)]
108pub struct ExerciseRecord {
109    pub id: Uuid,
110    /// The page whose snapshot the record was made from. A call that moves the exercise's block
111    /// leaves it as it was;
112    /// [`CourseState::exercise_page_id`](super::CourseState::exercise_page_id) has the page that
113    /// holds the block.
114    pub page_id: Uuid,
115    pub name: String,
116    /// The exercise's own deadline; `None` lets its chapter's apply.
117    pub deadline: Option<DateTime<Utc>>,
118    pub needs_peer_review: bool,
119    pub needs_self_review: bool,
120    /// In slide order, then task order.
121    pub tasks: Vec<ExerciseTaskRecord>,
122    pub review_config: ExerciseReviewConfig,
123}
124
125/// One task of an [`ExerciseRecord`].
126#[derive(Debug, Clone, PartialEq)]
127pub struct ExerciseTaskRecord {
128    pub id: Uuid,
129    pub exercise_type: String,
130    /// The task's instructions: blocks, as the CMS stores them.
131    pub assignment: Arc<serde_json::Value>,
132}
133
134/// Which review settings an exercise uses.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
136#[serde(rename_all = "snake_case")]
137pub enum ReviewConfigKind {
138    CourseDefault,
139    Own,
140}
141
142/// Which peer and self review settings an exercise uses.
143#[derive(Debug, Clone, PartialEq)]
144pub enum ExerciseReviewConfig {
145    CourseDefault,
146    Own {
147        config_id: Uuid,
148        /// Blocks; `None` when there are none.
149        review_instructions: Option<Arc<serde_json::Value>>,
150    },
151    /// Neither the course default nor settings of its own.
152    Unset,
153}
154
155impl ExerciseReviewConfig {
156    /// The settings the exercise uses.
157    pub fn of_exercise(exercise: &CmsPageExercise) -> Self {
158        match &exercise.peer_or_self_review_config {
159            _ if exercise.use_course_default_peer_or_self_review_config => Self::CourseDefault,
160            Some(config) => Self::Own {
161                config_id: config.id,
162                review_instructions: config.review_instructions.clone().map(Arc::new),
163            },
164            None => Self::Unset,
165        }
166    }
167
168    /// Whether these are the course default's or the exercise's own; `None` for
169    /// [`ExerciseReviewConfig::Unset`].
170    pub fn kind(&self) -> Option<ReviewConfigKind> {
171        match self {
172            Self::CourseDefault => Some(ReviewConfigKind::CourseDefault),
173            Self::Own { .. } => Some(ReviewConfigKind::Own),
174            Self::Unset => None,
175        }
176    }
177
178    /// The review instructions of the exercise's own settings; `None` also when it uses the
179    /// course default's.
180    pub fn own_review_instructions(&self) -> Option<&serde_json::Value> {
181        match self {
182            Self::Own {
183                review_instructions,
184                ..
185            } => review_instructions.as_deref(),
186            _ => None,
187        }
188    }
189}
190
191impl ExerciseRecord {
192    /// The record of one exercise of the page `page_id`.
193    pub fn of_bundle(page_id: Uuid, bundle: &ExerciseBundle) -> Self {
194        let exercise = &bundle.exercise;
195        let review_config = ExerciseReviewConfig::of_exercise(exercise);
196        Self {
197            id: exercise.id,
198            page_id,
199            name: exercise.name.clone(),
200            deadline: exercise.deadline,
201            needs_peer_review: exercise.needs_peer_review,
202            needs_self_review: exercise.needs_self_review,
203            tasks: bundle
204                .tasks
205                .iter()
206                .map(|task| ExerciseTaskRecord {
207                    id: task.id,
208                    exercise_type: task.exercise_type.clone(),
209                    assignment: Arc::new(task.assignment.clone()),
210                })
211                .collect(),
212            review_config,
213        }
214    }
215
216    /// The records of the page's exercises.
217    pub fn of_page(page: &PageSnapshot) -> Vec<Self> {
218        page.exercise_bundles()
219            .iter()
220            .map(|bundle| Self::of_bundle(page.id, bundle))
221            .collect()
222    }
223
224    /// Whether the records differ only in the page they were made from.
225    pub fn has_same_content(&self, other: &ExerciseRecord) -> bool {
226        let ExerciseRecord {
227            id,
228            page_id: _,
229            name,
230            deadline,
231            needs_peer_review,
232            needs_self_review,
233            tasks,
234            review_config,
235        } = self;
236        *id == other.id
237            && *name == other.name
238            && *deadline == other.deadline
239            && *needs_peer_review == other.needs_peer_review
240            && *needs_self_review == other.needs_self_review
241            && *tasks == other.tasks
242            && *review_config == other.review_config
243    }
244
245    /// Whether the exercise has peer or self reviews, whose instructions it then shows.
246    pub fn has_reviews(&self) -> bool {
247        self.needs_peer_review || self.needs_self_review
248    }
249
250    /// Writes the change from `before` to this record into `bundle`. Switching to own review
251    /// settings copies `course_default`, with `new_id` ids for its questions. Errors if `bundle`
252    /// isn't the exercise `before` describes, or the copy needs a default the course lacks.
253    pub fn apply_to(
254        &self,
255        before: &ExerciseRecord,
256        bundle: &mut ExerciseBundle,
257        course_default: Option<&CmsPeerOrSelfReviewConfiguration>,
258        new_id: impl FnMut() -> Uuid,
259    ) -> Result<(), ExerciseEditError> {
260        let current = ExerciseRecord::of_bundle(before.page_id, bundle);
261        if current != *before {
262            return Err(ExerciseEditError::ChangedSinceRead);
263        }
264        let exercise = &mut bundle.exercise;
265        if self.name != before.name {
266            exercise.name = self.name.clone();
267        }
268        if self.deadline != before.deadline {
269            exercise.deadline = self.deadline;
270        }
271        for task in &self.tasks {
272            let before_task = before.tasks.iter().find(|t| t.id == task.id);
273            if before_task.is_some_and(|before_task| before_task.assignment != task.assignment)
274                && let Some(bundle_task) = bundle.tasks.iter_mut().find(|t| t.id == task.id)
275            {
276                bundle_task.assignment = (*task.assignment).clone();
277            }
278        }
279        match (&before.review_config, &self.review_config) {
280            (before_config, after_config) if before_config == after_config => {}
281            (
282                ExerciseReviewConfig::Own { config_id, .. },
283                ExerciseReviewConfig::Own {
284                    config_id: after_id,
285                    review_instructions,
286                },
287            ) if config_id == after_id => {
288                if let Some(config) = exercise.peer_or_self_review_config.as_mut() {
289                    config.review_instructions = review_instructions.as_deref().cloned();
290                }
291            }
292            (
293                ExerciseReviewConfig::CourseDefault,
294                ExerciseReviewConfig::Own {
295                    config_id,
296                    review_instructions,
297                },
298            ) => {
299                let course_default = course_default.ok_or(ExerciseEditError::NoCourseDefault)?;
300                copy_course_default_review_config(
301                    exercise,
302                    course_default,
303                    *config_id,
304                    review_instructions.as_deref().cloned(),
305                    new_id,
306                );
307            }
308            (_, ExerciseReviewConfig::CourseDefault) => {
309                exercise.peer_or_self_review_config = None;
310                exercise.peer_or_self_review_questions = None;
311                exercise.use_course_default_peer_or_self_review_config = true;
312            }
313            _ => return Err(ExerciseEditError::UnsupportedReviewConfigChange),
314        }
315        Ok(())
316    }
317}
318
319/// Gives the exercise review settings of its own, copied from `course_default` with its
320/// questions: config `config_id` with `review_instructions`, questions with ids from `new_id`.
321pub(crate) fn copy_course_default_review_config(
322    exercise: &mut CmsPageExercise,
323    course_default: &CmsPeerOrSelfReviewConfiguration,
324    config_id: Uuid,
325    review_instructions: Option<serde_json::Value>,
326    mut new_id: impl FnMut() -> Uuid,
327) {
328    let mut config = course_default.peer_or_self_review_config.clone();
329    config.id = config_id;
330    config.exercise_id = Some(exercise.id);
331    config.review_instructions = review_instructions;
332    let mut questions = course_default.peer_or_self_review_questions.clone();
333    for question in questions.iter_mut() {
334        question.id = new_id();
335        question.peer_or_self_review_config_id = config_id;
336    }
337    normalize_cms_peer_or_self_review_questions(&mut questions);
338    exercise.peer_or_self_review_config = Some(config);
339    exercise.peer_or_self_review_questions = Some(questions);
340    exercise.use_course_default_peer_or_self_review_config = false;
341}
342
343/// Why an [`ExerciseRecord`] couldn't be applied.
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub enum ExerciseEditError {
346    /// The exercise changed after the record's base was read.
347    ChangedSinceRead,
348    /// Own review settings are copied from a course default the course doesn't have.
349    NoCourseDefault,
350    /// Review settings change only by copying the course default or by switching back to it.
351    UnsupportedReviewConfigChange,
352}
353
354/// Review instructions as the CMS stores them: `None` for no blocks or only empty paragraphs.
355pub fn review_instructions_for_storage(blocks: serde_json::Value) -> Option<serde_json::Value> {
356    let is_empty = match &blocks {
357        serde_json::Value::Null => true,
358        serde_json::Value::Array(items) => items.iter().all(|block| {
359            block["name"] == "core/paragraph"
360                && block["attributes"]["content"]
361                    .as_str()
362                    .is_none_or(|content| content.trim().is_empty())
363        }),
364        _ => false,
365    };
366    (!is_empty).then_some(blocks)
367}
368
369/// What decides an exercise's deadline, held for every exercise of a course's pages so that risk
370/// sees deadlines without the [`ExerciseRecord`]s loaded.
371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372pub struct ExerciseDeadline {
373    /// The exercise's own deadline, which overrides its chapter's.
374    pub own: Option<DateTime<Utc>>,
375    /// The chapter whose deadline applies without an own one: `exercises.chapter_id` live, which
376    /// can differ from its page's chapter; in a staged page, that page's chapter, as publishing
377    /// sets it.
378    pub chapter_id: Option<Uuid>,
379}