headless_lms_models/library/agent_changesets/course_state/
records.rs1use 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#[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 pub content: Arc<Vec<GutenbergBlock>>,
27 pub joins_page_language_group_id: Option<Uuid>,
29}
30
31impl PageRecord {
32 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 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#[derive(Debug, Clone, PartialEq)]
108pub struct ExerciseRecord {
109 pub id: Uuid,
110 pub page_id: Uuid,
115 pub name: String,
116 pub deadline: Option<DateTime<Utc>>,
118 pub needs_peer_review: bool,
119 pub needs_self_review: bool,
120 pub tasks: Vec<ExerciseTaskRecord>,
122 pub review_config: ExerciseReviewConfig,
123}
124
125#[derive(Debug, Clone, PartialEq)]
127pub struct ExerciseTaskRecord {
128 pub id: Uuid,
129 pub exercise_type: String,
130 pub assignment: Arc<serde_json::Value>,
132}
133
134#[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#[derive(Debug, Clone, PartialEq)]
144pub enum ExerciseReviewConfig {
145 CourseDefault,
146 Own {
147 config_id: Uuid,
148 review_instructions: Option<Arc<serde_json::Value>>,
150 },
151 Unset,
153}
154
155impl ExerciseReviewConfig {
156 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 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 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 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 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 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 pub fn has_reviews(&self) -> bool {
247 self.needs_peer_review || self.needs_self_review
248 }
249
250 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
319pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub enum ExerciseEditError {
346 ChangedSinceRead,
348 NoCourseDefault,
350 UnsupportedReviewConfigChange,
352}
353
354pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372pub struct ExerciseDeadline {
373 pub own: Option<DateTime<Utc>>,
375 pub chapter_id: Option<Uuid>,
379}