1use headless_lms_models::{
4 agent_changeset_entities::{AgentChangesetEntityOperation, ChangesetEntitySummary, RiskReason},
5 library::{agent_changesets::PublishBlocker, entity_snapshots::EntityRef},
6};
7use serde::Serialize;
8
9#[derive(Debug, Clone, PartialEq, Serialize)]
11pub struct ExplainedRiskReason {
12 #[serde(flatten)]
13 pub reason: RiskReason,
14 pub message: String,
15 pub consequence: &'static str,
16}
17
18impl From<RiskReason> for ExplainedRiskReason {
19 fn from(reason: RiskReason) -> Self {
20 let (message, consequence) = match &reason {
21 RiskReason::ExerciseWithSubmissionsRemoved { exercise_id } => (
22 format!("removes exercise {exercise_id}, which has submissions"),
23 "students' answers and points for this exercise stop counting",
24 ),
25 RiskReason::PageWithSubmissionsRemoved => (
26 "removes the page, which has exercises with submissions".to_string(),
27 "students' answers and points on this page stop counting",
28 ),
29 RiskReason::ChapterWithSubmissionsRemoved => (
30 "removes the chapter, which has exercises with submissions".to_string(),
31 "students' answers and points in this chapter stop counting",
32 ),
33 RiskReason::VisiblePageRemoved => (
34 "removes the page, which students can see".to_string(),
35 "the page disappears for students and its address gets no redirect",
36 ),
37 RiskReason::OpenChapterRemoved => (
38 "removes the chapter, which is open to students".to_string(),
39 "the chapter and its pages disappear for students, without redirects",
40 ),
41 RiskReason::DeadlineMovedEarlier { before, after } => (
42 format!("moves the deadline of the open chapter earlier, from {before} to {after}"),
43 "students get less time; answers after the new deadline no longer count",
44 ),
45 RiskReason::DeadlineAddedToOpenChapter { deadline } => (
46 format!("adds the deadline {deadline} to the open chapter"),
47 "answers after the deadline no longer count",
48 ),
49 RiskReason::ExerciseDeadlineMovedEarlier {
50 exercise_id,
51 before,
52 after,
53 } => (
54 format!(
55 "moves the deadline of open exercise {exercise_id} earlier, from {before} to {after}"
56 ),
57 "students get less time; answers after the new deadline no longer count",
58 ),
59 RiskReason::ExerciseDeadlineAdded {
60 exercise_id,
61 deadline,
62 } => (
63 format!("adds the deadline {deadline} to open exercise {exercise_id}"),
64 "answers after the deadline no longer count",
65 ),
66 RiskReason::OpeningMovedLater { opens_at } => (
67 format!("moves the opening time of the open chapter later, to {opens_at}"),
68 "students lose access to the chapter until it opens again",
69 ),
70 RiskReason::CompletionPolicyChangedAfterCompletions => (
71 "changes the completion policy of a module that students have already completed"
72 .to_string(),
73 "who completes the module changes from now on, while existing completions stay",
74 ),
75 RiskReason::CompletionRequirementChangedAfterCompletions => (
76 "changes how many module completions the base module requires after completions exist"
77 .to_string(),
78 "students who have completed modules may gain or lose the course completion",
79 ),
80 RiskReason::ChapterLockingToggled { is_enabled } => (
81 format!(
82 "turns chapter locking {}",
83 if *is_enabled { "on" } else { "off" }
84 ),
85 "students' access to later chapters changes immediately",
86 ),
87 };
88 Self {
89 reason,
90 message,
91 consequence,
92 }
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Serialize)]
99pub struct ExplainedEntity {
100 #[serde(flatten)]
101 pub entity: EntityRef,
102 #[serde(skip_serializing_if = "std::ops::Not::not")]
104 pub is_chapter_front_page: bool,
105 pub operation: AgentChangesetEntityOperation,
106 #[serde(skip_serializing_if = "Vec::is_empty")]
107 pub risk_reasons: Vec<ExplainedRiskReason>,
108}
109
110impl From<ChangesetEntitySummary> for ExplainedEntity {
111 fn from(summary: ChangesetEntitySummary) -> Self {
112 Self {
113 entity: summary.entity,
114 is_chapter_front_page: false,
115 operation: summary.operation,
116 risk_reasons: summary.risk_reasons.into_iter().map(Into::into).collect(),
117 }
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Serialize)]
123pub struct ExplainedBlocker {
124 #[serde(flatten)]
125 pub blocker: PublishBlocker,
126 pub message: String,
127}
128
129fn named(entity: &EntityRef) -> String {
130 format!("\"{}\" ({})", entity.label, entity.entity_id)
131}
132
133impl From<PublishBlocker> for ExplainedBlocker {
134 fn from(blocker: PublishBlocker) -> Self {
135 let message = match &blocker {
136 PublishBlocker::PageInMissingChapter { page, chapter_id } => format!(
137 "page {} would be in chapter {chapter_id}, which wouldn't exist",
138 named(page)
139 ),
140 PublishBlocker::DuplicatePath { url_path, pages } => {
141 format!("{} pages would have the url_path {url_path}", pages.len())
142 }
143 PublishBlocker::DuplicatePageOrderNumber {
144 chapter,
145 order_number,
146 pages,
147 } => format!(
148 "{} pages would share the order number {order_number} {}",
149 pages.len(),
150 match chapter {
151 Some(chapter) => format!("in chapter {}", named(chapter)),
152 None => "among the top-level pages".to_string(),
153 }
154 ),
155 PublishBlocker::ChapterInMissingModule { chapter, module_id } => format!(
156 "chapter {} would be in module {module_id}, which wouldn't exist",
157 named(chapter)
158 ),
159 PublishBlocker::MissingFrontPage {
160 chapter,
161 front_page_id,
162 } => format!(
163 "chapter {} would have front page {front_page_id}, which wouldn't exist",
164 named(chapter)
165 ),
166 PublishBlocker::FrontPageInOtherChapter {
167 chapter,
168 front_page,
169 } => format!(
170 "chapter {} would have front page {}, which would be in another chapter",
171 named(chapter),
172 named(front_page)
173 ),
174 PublishBlocker::FrontPageOffPath {
175 chapter,
176 front_page,
177 url_path,
178 expected_url_path,
179 } => format!(
180 "chapter {} would have front page {} at {url_path} instead of {expected_url_path}",
181 named(chapter),
182 named(front_page)
183 ),
184 PublishBlocker::DuplicateChapterNumber {
185 chapter_number,
186 chapters,
187 } => format!(
188 "{} chapters would have the number {chapter_number}",
189 chapters.len()
190 ),
191 PublishBlocker::DuplicateModuleOrderNumber {
192 order_number,
193 modules,
194 } => format!(
195 "{} modules would have the order number {order_number}",
196 modules.len()
197 ),
198 };
199 Self { blocker, message }
200 }
201}
202
203pub fn explain_blockers(blockers: Vec<PublishBlocker>) -> Vec<ExplainedBlocker> {
205 blockers.into_iter().map(Into::into).collect()
206}