1use std::collections::{HashMap, HashSet};
4use std::sync::Arc;
5
6use headless_lms_models::{
7 course_modules::{AutomaticCompletionRequirements, CompletionPolicy as ModelCompletionPolicy},
8 courses::{Course, CourseAiPolicy},
9 library::agent_changesets::course_state::{CourseState, PageRecord},
10 library::entity_snapshots::{
11 ChapterSnapshot, CourseAudienceSnapshot, GlossaryTermSnapshot, ModuleSnapshot,
12 ReferenceSnapshot,
13 },
14};
15use headless_lms_utils::document_schema_processor::GutenbergBlock;
16use uuid::Uuid;
17
18use crate::engine::blocks;
19use crate::engine::course_settings::{
20 AiPolicy, Audience, CompletionPolicy, CompletionPolicyKind, CourseSettings,
21 CourseSettingsCourse, ModuleSettings,
22};
23use crate::engine::course_structure::{
24 CourseStructure, StructureChapter, StructureModule, StructurePage,
25};
26use crate::engine::glossary_references::{GlossaryAndReferences, GlossaryTerm, Reference};
27use crate::engine::{PageDocument, PageRef, PageType};
28
29fn structure_page(page: &PageRecord, facts: &PageFacts) -> StructurePage {
30 let (block_count, exercise_count) = blocks::content_counts(&page.content);
31 StructurePage {
32 id: page.id,
33 title: page.title.clone(),
34 url_path: page.url_path.clone(),
35 hidden: page.hidden,
36 block_count,
37 exercise_count,
38 has_submissions: facts.pages_with_submissions.contains(&page.id),
39 page_language_group_id: facts
40 .page_language_groups
41 .get(&page.id)
42 .copied()
43 .or(page.joins_page_language_group_id),
44 }
45}
46
47pub struct PageFacts {
49 pub pages_with_submissions: HashSet<Uuid>,
50 pub page_language_groups: HashMap<Uuid, Uuid>,
52}
53
54pub fn structure(state: &CourseState, facts: &PageFacts) -> CourseStructure {
56 let modules = state
57 .modules_in_order()
58 .into_iter()
59 .map(|module| StructureModule {
60 id: module.id,
61 default: module.name.is_none(),
62 name: module.name.clone(),
63 chapters: state
64 .chapters_in_order(module.id)
65 .into_iter()
66 .map(|chapter| StructureChapter {
67 id: chapter.id,
68 chapter_number: chapter.chapter_number,
69 name: chapter.name.clone(),
70 color: chapter.color.clone(),
71 opens_at: chapter.opens_at,
72 deadline: chapter.deadline,
73 pages: state
74 .pages_in_order(Some(chapter.id))
75 .into_iter()
76 .map(|p| structure_page(p, facts))
77 .collect(),
78 })
79 .collect(),
80 })
81 .collect();
82 CourseStructure {
83 id: state.course_id(),
84 pages: state
85 .pages_in_order(None)
86 .into_iter()
87 .map(|p| structure_page(p, facts))
88 .collect(),
89 modules,
90 }
91}
92
93pub fn page_type(page: &PageRecord) -> PageType {
95 PageType::of(page.chapter_id.is_some(), &page.url_path)
96}
97
98pub fn page_document(state: &CourseState, page_id: Uuid) -> Option<PageDocument> {
100 state.page(page_id).map(|page| page_ref(page).to_document())
101}
102
103pub fn page_ref(page: &PageRecord) -> PageRef<'_> {
105 PageRef {
106 page_id: page.id,
107 page_type: page_type(page),
108 blocks: &page.content,
109 }
110}
111
112pub fn page_refs(state: &CourseState) -> Vec<PageRef<'_>> {
114 state.pages.iter().map(|page| page_ref(page)).collect()
115}
116
117pub fn apply_page_documents(
119 state: &mut CourseState,
120 documents: impl IntoIterator<Item = PageDocument>,
121) {
122 for document in documents {
123 if let Some(page) = state.page_mut(document.page_id) {
124 page.content = Arc::new(document.blocks);
125 }
126 }
127}
128
129fn page_lists(structure: &CourseStructure) -> Vec<(Option<Uuid>, &[StructurePage])> {
130 std::iter::once((None, structure.pages.as_slice()))
131 .chain(
132 structure
133 .chapters()
134 .map(|c| (Some(c.id), c.pages.as_slice())),
135 )
136 .collect()
137}
138
139pub fn apply_structure(
142 state: &CourseState,
143 before: &CourseStructure,
144 after: &CourseStructure,
145 contents: &HashMap<Uuid, Vec<GutenbergBlock>>,
146) -> CourseState {
147 let mut next = state.clone();
148
149 let modules_before: Vec<Uuid> = before.modules().map(|m| m.id).collect();
150 let mut modules: Vec<ModuleSnapshot> = state
151 .modules
152 .iter()
153 .filter(|m| !modules_before.contains(&m.id))
154 .cloned()
155 .collect();
156 let modules_by_id: HashMap<Uuid, &ModuleSnapshot> =
157 state.modules.iter().map(|m| (m.id, m)).collect();
158 let module_order_numbers = list_order_numbers(
159 Some(&modules_before),
160 &after
161 .modules()
162 .map(|m| {
163 (
164 m.id,
165 modules_by_id.get(&m.id).map(|module| module.order_number),
166 )
167 })
168 .collect::<Vec<_>>(),
169 );
170 for (structure_module, order_number) in after.modules().zip(module_order_numbers) {
171 let mut module = modules_by_id
172 .get(&structure_module.id)
173 .copied()
174 .cloned()
175 .unwrap_or_else(|| ModuleSnapshot::new(structure_module.id, state.course_id()));
176 module.name = structure_module.name.clone();
177 if let Some(order_number) = order_number {
178 module.order_number = order_number;
179 }
180 modules.push(module);
181 }
182 next.modules = modules;
183
184 let chapters_before: HashSet<Uuid> = before.chapters().map(|c| c.id).collect();
185 let mut chapters: Vec<ChapterSnapshot> = state
186 .chapters
187 .iter()
188 .filter(|c| !chapters_before.contains(&c.id))
189 .cloned()
190 .collect();
191 let chapters_by_id: HashMap<Uuid, &ChapterSnapshot> =
192 state.chapters.iter().map(|c| (c.id, c)).collect();
193 for structure_module in after.modules() {
194 for structure_chapter in &structure_module.chapters {
195 let mut chapter = chapters_by_id
196 .get(&structure_chapter.id)
197 .copied()
198 .cloned()
199 .unwrap_or_else(|| {
200 ChapterSnapshot::new(
201 structure_chapter.id,
202 state.course_id(),
203 structure_module.id,
204 )
205 });
206 chapter.course_module_id = structure_module.id;
207 chapter.name = structure_chapter.name.clone();
208 chapter.color = structure_chapter.color.clone();
209 chapter.opens_at = structure_chapter.opens_at;
210 chapter.deadline = structure_chapter.deadline;
211 chapter.chapter_number = structure_chapter.chapter_number;
212 chapter.front_page_id = structure_chapter.pages.first().map(|p| p.id);
213 chapters.push(chapter);
214 }
215 }
216 next.chapters = chapters;
217
218 let lists_before: HashMap<Option<Uuid>, Vec<Uuid>> = page_lists(before)
219 .into_iter()
220 .map(|(chapter, pages)| (chapter, pages.iter().map(|p| p.id).collect()))
221 .collect();
222 let pages_before: HashSet<Uuid> = lists_before.values().flatten().copied().collect();
223 let mut pages: Vec<PageRecord> = state
224 .pages
225 .iter()
226 .filter(|p| !pages_before.contains(&p.id))
227 .cloned()
228 .collect();
229 let pages_by_id: HashMap<Uuid, &PageRecord> = state.pages.iter().map(|p| (p.id, p)).collect();
230 for (chapter_id, list) in page_lists(after) {
231 let order_numbers = list_order_numbers(
232 lists_before.get(&chapter_id).map(Vec::as_slice),
233 &list
234 .iter()
235 .map(|p| (p.id, pages_by_id.get(&p.id).map(|page| page.order_number)))
236 .collect::<Vec<_>>(),
237 );
238 for (structure_page, new_order_number) in list.iter().zip(order_numbers) {
239 let mut page = pages_by_id
240 .get(&structure_page.id)
241 .copied()
242 .cloned()
243 .unwrap_or_else(|| PageRecord {
244 id: structure_page.id,
245 chapter_id,
246 url_path: String::new(),
247 title: String::new(),
248 hidden: false,
249 order_number: new_order_number.unwrap_or_default(),
250 content: Arc::new(
251 contents
252 .get(&structure_page.id)
253 .cloned()
254 .unwrap_or_default(),
255 ),
256 joins_page_language_group_id: None,
257 });
258 page.chapter_id = chapter_id;
259 page.title = structure_page.title.clone();
260 page.url_path = structure_page.url_path.clone();
261 page.hidden = structure_page.hidden;
262 page.order_number = new_order_number.unwrap_or(page.order_number);
263 pages.push(page);
264 }
265 }
266 next.pages = pages;
267 next
268}
269
270fn list_order_numbers(
274 before_ids: Option<&[Uuid]>,
275 after: &[(Uuid, Option<i32>)],
276) -> Vec<Option<i32>> {
277 let is_reordered =
278 before_ids.is_none_or(|before| !before.iter().copied().eq(after.iter().map(|(id, _)| *id)));
279 if is_reordered {
280 increasing_order_numbers(after.iter().map(|(_, own)| *own))
281 .into_iter()
282 .map(Some)
283 .collect()
284 } else {
285 vec![None; after.len()]
286 }
287}
288
289fn increasing_order_numbers(current: impl Iterator<Item = Option<i32>>) -> Vec<i32> {
293 let mut previous: Option<i32> = None;
294 current
295 .map(|own| {
296 let next = match (own, previous) {
297 (Some(own), Some(previous)) if own > previous => own,
298 (_, Some(previous)) => previous + 1,
299 (_, None) => 0,
300 };
301 previous = Some(next);
302 next
303 })
304 .collect()
305}
306
307impl From<CourseAiPolicy> for AiPolicy {
308 fn from(policy: CourseAiPolicy) -> Self {
309 match policy {
310 CourseAiPolicy::NotSet => AiPolicy::NotSet,
311 CourseAiPolicy::NoAi => AiPolicy::NoAi,
312 CourseAiPolicy::PlanningOnly => AiPolicy::PlanningOnly,
313 CourseAiPolicy::Limited => AiPolicy::Limited,
314 CourseAiPolicy::FullUse => AiPolicy::FullUse,
315 CourseAiPolicy::Required => AiPolicy::Required,
316 }
317 }
318}
319
320impl From<AiPolicy> for CourseAiPolicy {
321 fn from(policy: AiPolicy) -> Self {
322 match policy {
323 AiPolicy::NotSet => CourseAiPolicy::NotSet,
324 AiPolicy::NoAi => CourseAiPolicy::NoAi,
325 AiPolicy::PlanningOnly => CourseAiPolicy::PlanningOnly,
326 AiPolicy::Limited => CourseAiPolicy::Limited,
327 AiPolicy::FullUse => CourseAiPolicy::FullUse,
328 AiPolicy::Required => CourseAiPolicy::Required,
329 }
330 }
331}
332
333fn completion_policy(policy: &ModelCompletionPolicy) -> CompletionPolicy {
334 match policy {
335 ModelCompletionPolicy::Automatic(requirements) => CompletionPolicy {
336 policy: CompletionPolicyKind::Automatic,
337 number_of_points_treshold: requirements.number_of_points_treshold,
338 number_of_exercises_attempted_treshold: requirements
339 .number_of_exercises_attempted_treshold,
340 requires_exam: requirements.requires_exam,
341 },
342 ModelCompletionPolicy::Manual => CompletionPolicy::manual(),
343 }
344}
345
346fn model_completion_policy(module_id: Uuid, policy: &CompletionPolicy) -> ModelCompletionPolicy {
347 match policy.policy {
348 CompletionPolicyKind::Automatic => {
349 ModelCompletionPolicy::Automatic(AutomaticCompletionRequirements {
350 course_module_id: module_id,
351 number_of_exercises_attempted_treshold: policy
352 .number_of_exercises_attempted_treshold,
353 number_of_points_treshold: policy.number_of_points_treshold,
354 requires_exam: policy.requires_exam,
355 })
356 }
357 CompletionPolicyKind::Manual => ModelCompletionPolicy::Manual,
358 }
359}
360
361pub fn settings_document(state: &CourseState, course: &Course) -> CourseSettings {
363 let settings = &state.settings;
364 CourseSettings {
365 course: CourseSettingsCourse {
366 id: state.course_id(),
367 slug: course.slug.clone(),
368 language_code: course.language_code.clone(),
369 name: settings.name.clone(),
370 description: settings.description.clone(),
371 is_draft: settings.is_draft,
372 is_test_mode: settings.is_test_mode,
373 is_unlisted: settings.is_unlisted,
374 chapter_locking_enabled: settings.chapter_locking_enabled,
375 base_module_completion_requires_n_submodule_completions: settings
376 .base_module_completion_requires_n_submodule_completions,
377 ai_policy: settings.ai_policy.into(),
378 audiences: settings
379 .audiences
380 .iter()
381 .map(|a| Audience {
382 id: a.id,
383 name: a.audience.clone(),
384 })
385 .collect(),
386 closed_at: settings.closed_at,
387 },
388 modules: state
389 .modules_in_order()
390 .into_iter()
391 .map(|m| ModuleSettings {
392 id: m.id,
393 name: m.name.clone(),
394 completion_policy: completion_policy(&m.completion_policy),
395 certification_enabled: m.certification_enabled,
396 })
397 .collect(),
398 }
399}
400
401pub fn apply_settings(state: &mut CourseState, document: &CourseSettings) {
403 let course = &document.course;
404 let settings = &mut state.settings;
405 settings.name = course.name.clone();
406 settings.description = course.description.clone();
407 settings.is_unlisted = course.is_unlisted;
408 settings.chapter_locking_enabled = course.chapter_locking_enabled;
409 settings.base_module_completion_requires_n_submodule_completions =
410 course.base_module_completion_requires_n_submodule_completions;
411 settings.ai_policy = course.ai_policy.into();
412 settings.audiences = course
413 .audiences
414 .iter()
415 .map(|a| CourseAudienceSnapshot {
416 id: a.id,
417 audience: a.name.clone(),
418 })
419 .collect();
420 settings.audiences.sort_by_key(|a| a.id);
421 for module_settings in &document.modules {
422 if let Some(module) = state
423 .modules
424 .iter_mut()
425 .find(|m| m.id == module_settings.id)
426 {
427 module.completion_policy =
428 model_completion_policy(module.id, &module_settings.completion_policy);
429 module.certification_enabled = module_settings.certification_enabled;
430 }
431 }
432}
433
434pub fn glossary_document(state: &CourseState) -> GlossaryAndReferences {
436 let mut glossary: Vec<GlossaryTerm> = state
437 .glossary
438 .iter()
439 .map(|t| GlossaryTerm {
440 id: t.id,
441 term: t.term.clone(),
442 definition: t.definition.clone(),
443 })
444 .collect();
445 glossary.sort_by_key(|t| t.term.to_lowercase());
446 let mut references: Vec<Reference> = state
447 .references
448 .iter()
449 .map(|r| Reference {
450 id: r.id,
451 citation_key: r.citation_key.clone(),
452 bibtex: r.reference.clone(),
453 })
454 .collect();
455 references.sort_by(|a, b| a.citation_key.cmp(&b.citation_key));
456 GlossaryAndReferences {
457 glossary,
458 references,
459 }
460}
461
462pub fn apply_glossary(state: &mut CourseState, document: &GlossaryAndReferences) {
464 state.glossary = document
465 .glossary
466 .iter()
467 .map(|t| GlossaryTermSnapshot {
468 id: t.id,
469 course_id: state.course_id(),
470 term: t.term.clone(),
471 definition: t.definition.clone(),
472 })
473 .collect();
474 state.references = document
475 .references
476 .iter()
477 .map(|r| ReferenceSnapshot {
478 id: r.id,
479 course_id: state.course_id(),
480 citation_key: r.citation_key.clone(),
481 reference: r.bibtex.clone(),
482 })
483 .collect();
484}
485
486#[cfg(test)]
487#[path = "snapshots_tests.rs"]
488mod tests;