1use std::collections::{HashMap, hash_map::Entry};
2
3use futures::future::BoxFuture;
4use headless_lms_utils::{document_schema_processor::GutenbergBlock, merge_edits};
5use serde_json::Value;
6use url::Url;
7
8use crate::{
9 SpecFetcher,
10 exercise_service_info::ExerciseServiceInfoApi,
11 page_history::HistoryChangeReason,
12 pages::{CmsPageUpdate, PageUpdateArgs},
13 prelude::*,
14 proposed_block_edits::{
15 BlockProposal, BlockProposalAction, BlockProposalInfo, EditedBlockNoLongerExistsData,
16 EditedBlockStillExistsData, NewProposedBlockEdit, ProposalStatus,
17 },
18};
19
20#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq)]
21#[cfg_attr(feature = "ts_rs", derive(TS))]
22pub struct NewProposedPageEdits {
23 pub page_id: Uuid,
24 pub block_edits: Vec<NewProposedBlockEdit>,
25}
26
27#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq)]
28#[cfg_attr(feature = "ts_rs", derive(TS))]
29pub struct PageProposal {
30 pub id: Uuid,
31 pub page_id: Uuid,
32 pub user_id: Option<Uuid>,
33 pub pending: bool,
34 pub created_at: DateTime<Utc>,
35 pub block_proposals: Vec<BlockProposal>,
36 pub page_title: String,
37 pub page_url_path: String,
38}
39
40#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq)]
41#[cfg_attr(feature = "ts_rs", derive(TS))]
42pub struct EditProposalInfo {
43 pub page_id: Uuid,
44 pub page_proposal_id: Uuid,
45 pub block_proposals: Vec<BlockProposalInfo>,
46}
47
48#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq)]
49#[cfg_attr(feature = "ts_rs", derive(TS))]
50pub struct ProposalCount {
51 pub pending: u32,
52 pub handled: u32,
53}
54
55pub async fn insert(
56 conn: &mut PgConnection,
57 pkey_policy: PKeyPolicy<Uuid>,
58 course_id: Uuid,
59 user_id: Option<Uuid>,
60 edits: &NewProposedPageEdits,
61) -> ModelResult<(Uuid, Vec<Uuid>)> {
62 if edits.block_edits.is_empty() {
63 return Err(ModelError::new(
64 ModelErrorType::Generic,
65 "No block edits".to_string(),
66 None,
67 ));
68 }
69
70 let mut tx = conn.begin().await?;
71 let page_res = sqlx::query!(
72 "
73INSERT INTO proposed_page_edits (id, course_id, page_id, user_id)
74VALUES ($1, $2, $3, $4)
75RETURNING id
76 ",
77 pkey_policy.into_uuid(),
78 course_id,
79 edits.page_id,
80 user_id,
81 )
82 .fetch_one(&mut *tx)
83 .await?;
84
85 let mut block_ids = vec![];
86 for block_edit in &edits.block_edits {
87 let res = sqlx::query!(
88 "
89INSERT INTO proposed_block_edits (
90 proposal_id,
91 block_id,
92 block_attribute,
93 original_text,
94 changed_text
95)
96VALUES ($1, $2, $3, $4, $5)
97RETURNING id
98",
99 page_res.id,
100 block_edit.block_id,
101 block_edit.block_attribute,
102 block_edit.original_text,
103 block_edit.changed_text
104 )
105 .fetch_one(&mut *tx)
106 .await?;
107 block_ids.push(res.id);
108 }
109 tx.commit().await?;
110 Ok((page_res.id, block_ids))
111}
112
113pub async fn get_proposals_for_course(
114 conn: &mut PgConnection,
115 course_id: Uuid,
116 pending: bool,
117 pagination: Pagination,
118) -> ModelResult<Vec<PageProposal>> {
119 let res = sqlx::query!(
120 r#"
121SELECT proposed_page_edits.id AS "page_proposal_id!",
122 proposed_block_edits.id AS "block_proposal_id!",
123 page_id as "page_id!",
124 user_id,
125 block_id,
126 original_text,
127 changed_text,
128 proposed_page_edits.pending as "pending!",
129 block_attribute,
130 proposed_block_edits.status as "block_proposal_status: ProposalStatus",
131 proposed_page_edits.created_at as "created_at!",
132 pages.title as "page_title!",
133 pages.url_path as "page_url_path!"
134FROM (
135 SELECT id,
136 page_id,
137 user_id,
138 pending,
139 created_at
140 FROM proposed_page_edits
141 WHERE course_id = $1
142 AND pending = $2
143 AND deleted_at IS NULL
144 ORDER BY created_at DESC,
145 id
146 LIMIT $3 OFFSET $4
147 ) proposed_page_edits
148 LEFT JOIN proposed_block_edits ON proposed_page_edits.id = proposed_block_edits.proposal_id
149 LEFT JOIN pages ON proposed_page_edits.page_id = pages.id
150WHERE proposed_block_edits.deleted_at IS NULL
151"#,
152 course_id,
153 pending,
154 pagination.limit(),
155 pagination.offset(),
156 )
157 .fetch_all(&mut *conn)
158 .await?;
159
160 let mut proposals = HashMap::new();
161 let mut pages = HashMap::new();
162
163 for r in res {
164 let page_proposal_id = r.page_proposal_id;
165 let page_id = r.page_id;
166 let user_id = r.user_id;
167 let page_proposal_pending = r.pending;
168 let created_at = r.created_at;
169 let original_text = r.original_text;
170 let changed_text = r.changed_text;
171 let page_proposal =
172 proposals
173 .entry(r.page_proposal_id)
174 .or_insert_with(move || PageProposal {
175 id: page_proposal_id,
176 page_id,
177 user_id,
178 pending: page_proposal_pending,
179 created_at,
180 block_proposals: Vec::new(),
181 page_title: r.page_title,
182 page_url_path: r.page_url_path,
183 });
184
185 let content = match pages.entry(r.page_id) {
186 Entry::Occupied(o) => o.into_mut(),
187 Entry::Vacant(v) => {
188 let page = crate::pages::get_page(&mut *conn, r.page_id).await?;
189 let content: Vec<GutenbergBlock> = serde_json::from_value(page.content)?;
190 v.insert(content)
191 }
192 };
193
194 let block = content.iter().find(|b| b.client_id == r.block_id);
195
196 let block_proposal_id = r.block_proposal_id;
197 let block_id = r.block_id;
198 let block_proposal_status = r.block_proposal_status;
199
200 if let Some(block) = block {
201 let content = block
202 .attributes
203 .get(&r.block_attribute)
204 .ok_or_else(|| {
205 ModelError::new(
206 ModelErrorType::Generic,
207 format!(
208 "Missing expected attribute '{}' in edited block",
209 r.block_attribute
210 ),
211 None,
212 )
213 })?
214 .as_str()
215 .ok_or_else(|| {
216 ModelError::new(
217 ModelErrorType::Generic,
218 format!("Attribute '{}' did not contain a string", r.block_attribute),
219 None,
220 )
221 })?
222 .to_string();
223 page_proposal
224 .block_proposals
225 .push(BlockProposal::EditedBlockStillExists(
226 EditedBlockStillExistsData {
227 accept_preview: merge_edits::merge(&original_text, &changed_text, &content),
228 id: block_proposal_id,
229 block_id,
230 current_text: content.to_string(),
231 changed_text: changed_text.to_string(),
232 status: block_proposal_status,
233 original_text: original_text.to_string(),
234 },
235 ));
236 } else {
237 page_proposal
238 .block_proposals
239 .push(BlockProposal::EditedBlockNoLongerExists(
240 EditedBlockNoLongerExistsData {
241 id: block_proposal_id,
242 block_id,
243 changed_text: changed_text.to_string(),
244 status: block_proposal_status,
245 original_text: original_text.to_string(),
246 },
247 ));
248 }
249 }
250
251 let mut proposals = proposals.into_values().collect::<Vec<_>>();
252 proposals.sort_by(|left, right| left.created_at.cmp(&right.created_at).reverse());
253 Ok(proposals)
254}
255
256pub async fn get_proposal_count_for_course(
257 conn: &mut PgConnection,
258 course_id: Uuid,
259) -> ModelResult<ProposalCount> {
260 let res = sqlx::query!(
261 "
262SELECT COUNT(*) filter (
263 where proposed_page_edits.pending = true
264) AS pending,
265COUNT(*) filter (
266 where proposed_page_edits.pending = false
267) AS handled
268FROM proposed_page_edits
269WHERE proposed_page_edits.course_id = $1
270AND proposed_page_edits.deleted_at IS NULL
271",
272 course_id,
273 )
274 .fetch_one(conn)
275 .await?;
276 let count = ProposalCount {
277 pending: res.pending.unwrap_or_default().try_into()?,
278 handled: res.handled.unwrap_or_default().try_into()?,
279 };
280 Ok(count)
281}
282
283pub async fn process_proposal(
284 conn: &mut PgConnection,
285 page_id: Uuid,
286 page_proposal_id: Uuid,
287 block_proposals: Vec<BlockProposalInfo>,
288 author: Uuid,
289 spec_fetcher: impl SpecFetcher,
290 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
291) -> ModelResult<()> {
292 if block_proposals.is_empty() {
293 return Err(ModelError::new(
294 ModelErrorType::Generic,
295 "No block proposals to process".to_string(),
296 None,
297 ));
298 }
299
300 let mut tx = conn.begin().await?;
301 let page_with_exercises = crate::pages::get_page_with_exercises(&mut tx, page_id).await?;
302 let mut blocks = page_with_exercises.page.blocks_cloned()?;
303 for BlockProposalInfo { id, action } in block_proposals {
304 match action {
305 BlockProposalAction::Accept(contents) => {
306 let res = sqlx::query!(
307 "
308UPDATE proposed_block_edits
309SET status = 'accepted'
310WHERE id = $1
311RETURNING block_id,
312 block_attribute,
313 original_text,
314 changed_text
315",
316 id
317 )
318 .fetch_one(&mut *tx)
319 .await?;
320 let block = blocks
321 .iter_mut()
322 .find(|b| b.client_id == res.block_id)
323 .ok_or_else(|| {
324 ModelError::new(
325 ModelErrorType::Generic,
326 "Failed to find the block which the proposal was for".to_string(),
327 None,
328 )
329 })?;
330 let current_content =
331 block
332 .attributes
333 .get_mut(&res.block_attribute)
334 .ok_or_else(|| {
335 ModelError::new(
336 ModelErrorType::Generic,
337 format!("Edited block has no attribute {}", &res.block_attribute),
338 None,
339 )
340 })?;
341 if let Value::String(s) = current_content {
342 *s = contents;
343 } else {
344 return Err(ModelError::new(
345 ModelErrorType::Generic,
346 format!(
347 "Block attribute {} did not contain a string",
348 res.block_attribute
349 ),
350 None,
351 ));
352 }
353 }
354 BlockProposalAction::Reject => {
355 sqlx::query!(
356 "
357UPDATE proposed_block_edits
358SET status = 'rejected'
359WHERE id = $1
360",
361 id
362 )
363 .execute(&mut *tx)
364 .await?;
365 }
366 }
367 }
368
369 let updated_content = serde_json::to_value(&blocks)?;
370 let cms_page_update = CmsPageUpdate {
371 content: updated_content,
372 exercises: page_with_exercises.exercises,
373 exercise_slides: page_with_exercises.exercise_slides,
374 exercise_tasks: page_with_exercises.exercise_tasks,
375 url_path: page_with_exercises.page.url_path,
376 title: page_with_exercises.page.title,
377 chapter_id: page_with_exercises.page.chapter_id,
378 };
379 crate::pages::update_page(
380 &mut tx,
381 PageUpdateArgs {
382 page_id: page_with_exercises.page.id,
383 author,
384 cms_page_update,
385 retain_ids: true,
386 history_change_reason: HistoryChangeReason::PageSaved,
387 is_exam_page: page_with_exercises.page.exam_id.is_some(),
388 },
389 spec_fetcher,
390 fetch_service_info,
391 )
392 .await?;
393
394 update_page_edit_status(&mut tx, page_proposal_id).await?;
395
396 tx.commit().await?;
397 Ok(())
398}
399
400pub async fn update_page_edit_status(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
401 let block_proposals = sqlx::query!(
402 r#"
403SELECT status AS "status: ProposalStatus"
404FROM proposed_block_edits
405WHERE proposal_id = $1
406AND deleted_at IS NULL
407"#,
408 id
409 )
410 .fetch_all(&mut *conn)
411 .await?;
412 let pending = block_proposals
413 .iter()
414 .any(|bp| bp.status == ProposalStatus::Pending);
415 sqlx::query!(
416 "
417UPDATE proposed_page_edits
418SET pending = $1
419WHERE id = $2
420",
421 pending,
422 id,
423 )
424 .execute(&mut *conn)
425 .await?;
426 Ok(())
427}
428
429#[cfg(test)]
430mod test {
431 use headless_lms_utils::document_schema_processor::{GutenbergBlock, attributes};
432
433 use super::*;
434 use crate::{pages::PageUpdateArgs, proposed_block_edits::*, test_helper::*};
435
436 async fn init_content(
437 conn: &mut PgConnection,
438 chapter: Uuid,
439 page: Uuid,
440 user: Uuid,
441 content: &str,
442 ) -> Uuid {
443 let client_id = Uuid::new_v4();
444 let new_content: Vec<GutenbergBlock> = vec![GutenbergBlock {
445 client_id,
446 name: "core/paragraph".to_string(),
447 is_valid: true,
448 attributes: attributes! {
449 "content": content
450 },
451 inner_blocks: vec![],
452 }];
453 let cms_page_update = CmsPageUpdate {
454 content: serde_json::to_value(&new_content).unwrap(),
455 url_path: "".to_string(),
456 title: "".to_string(),
457 chapter_id: Some(chapter),
458 exercises: vec![],
459 exercise_slides: vec![],
460 exercise_tasks: vec![],
461 };
462 crate::pages::update_page(
463 conn,
464 PageUpdateArgs {
465 page_id: page,
466 author: user,
467 cms_page_update,
468 retain_ids: true,
469 history_change_reason: HistoryChangeReason::PageSaved,
470 is_exam_page: false,
471 },
472 |_, _, _| unimplemented!(),
473 |_| unimplemented!(),
474 )
475 .await
476 .unwrap();
477 client_id
478 }
479
480 async fn assert_content(conn: &mut PgConnection, page_id: Uuid, expected: &str) {
481 let page = crate::pages::get_page(conn, page_id).await.unwrap();
482 let mut new_content: Vec<GutenbergBlock> = serde_json::from_value(page.content).unwrap();
483 let block = new_content.pop().unwrap();
484 let content = block.attributes.get("content").unwrap().as_str().unwrap();
485 assert_eq!(content, expected);
486 }
487
488 #[tokio::test]
489 async fn typo_fix() {
490 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, :chapter, :page);
491 let block_id = init_content(
492 tx.as_mut(),
493 chapter,
494 page,
495 user,
496 "Content with a tpo in it.",
497 )
498 .await;
499
500 let new = NewProposedPageEdits {
501 page_id: page,
502 block_edits: vec![NewProposedBlockEdit {
503 block_id,
504 block_attribute: "content".to_string(),
505 original_text: "Content with a tpo in it.".to_string(),
506 changed_text: "Content with a typo in it.".to_string(),
507 }],
508 };
509 insert(tx.as_mut(), PKeyPolicy::Generate, course, None, &new)
510 .await
511 .unwrap();
512 let mut ps = get_proposals_for_course(tx.as_mut(), course, true, Pagination::default())
513 .await
514 .unwrap();
515 let mut p = ps.pop().unwrap();
516 let b = p.block_proposals.pop().unwrap();
517 match b {
518 BlockProposal::EditedBlockStillExists(b) => {
519 assert_eq!(b.accept_preview.unwrap(), "Content with a typo in it.");
520 process_proposal(
521 tx.as_mut(),
522 page,
523 p.id,
524 vec![BlockProposalInfo {
525 id: b.id,
526 action: BlockProposalAction::Accept(
527 "Content with a typo in it.".to_string(),
528 ),
529 }],
530 user,
531 |_, _, _| unimplemented!(),
532 |_| unimplemented!(),
533 )
534 .await
535 .unwrap();
536
537 let mut ps =
538 get_proposals_for_course(tx.as_mut(), course, false, Pagination::default())
539 .await
540 .unwrap();
541 let _ = ps.pop().unwrap();
542
543 assert_content(tx.as_mut(), page, "Content with a typo in it.").await;
544 }
545 BlockProposal::EditedBlockNoLongerExists(_o) => panic!("Wrong block proposal"),
546 };
547 }
548
549 #[tokio::test]
550 async fn rejection() {
551 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, :chapter, :page);
552 let block_id = init_content(
553 tx.as_mut(),
554 chapter,
555 page,
556 user,
557 "Content with a tpo in it.",
558 )
559 .await;
560 let new = NewProposedPageEdits {
561 page_id: page,
562 block_edits: vec![NewProposedBlockEdit {
563 block_id,
564 block_attribute: "content".to_string(),
565 original_text: "Content with a tpo in it.".to_string(),
566 changed_text: "Content with a typo in it.".to_string(),
567 }],
568 };
569 insert(tx.as_mut(), PKeyPolicy::Generate, course, None, &new)
570 .await
571 .unwrap();
572
573 let mut ps = get_proposals_for_course(tx.as_mut(), course, true, Pagination::default())
574 .await
575 .unwrap();
576 let mut p = ps.pop().unwrap();
577 let b = p.block_proposals.pop().unwrap();
578 match b {
579 BlockProposal::EditedBlockStillExists(b) => {
580 assert_eq!(b.accept_preview.unwrap(), "Content with a typo in it.");
581 assert_eq!(b.status, ProposalStatus::Pending);
582
583 process_proposal(
584 tx.as_mut(),
585 page,
586 p.id,
587 vec![BlockProposalInfo {
588 id: b.id,
589 action: BlockProposalAction::Reject,
590 }],
591 user,
592 |_, _, _| unimplemented!(),
593 |_| unimplemented!(),
594 )
595 .await
596 .unwrap();
597
598 let mut ps =
599 get_proposals_for_course(tx.as_mut(), course, false, Pagination::default())
600 .await
601 .unwrap();
602 let _ = ps.pop().unwrap();
603
604 assert_content(tx.as_mut(), page, "Content with a tpo in it.").await;
605 }
606 BlockProposal::EditedBlockNoLongerExists(_o) => panic!("Wrong block proposal"),
607 };
608 }
609}