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;
7use utoipa::ToSchema;
8
9use crate::{
10 SpecFetcher,
11 exercise_service_info::ExerciseServiceInfoApi,
12 page_history::HistoryChangeReason,
13 pages::{CmsPageUpdate, PageUpdateArgs},
14 prelude::*,
15 proposed_block_edits::{
16 BlockProposal, BlockProposalAction, BlockProposalInfo, EditedBlockNoLongerExistsData,
17 EditedBlockStillExistsData, NewProposedBlockEdit, ProposalStatus,
18 },
19};
20
21#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, ToSchema)]
22
23pub struct NewProposedPageEdits {
24 pub page_id: Uuid,
25 pub block_edits: Vec<NewProposedBlockEdit>,
26}
27
28#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, ToSchema)]
29pub struct ProposedPageEdit {
30 pub id: Uuid,
31 pub course_id: Uuid,
32 pub page_id: Uuid,
33 pub user_id: Option<Uuid>,
34 pub pending: bool,
35 pub created_at: DateTime<Utc>,
36 pub updated_at: DateTime<Utc>,
37 pub deleted_at: Option<DateTime<Utc>>,
38}
39
40#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, ToSchema)]
41
42pub struct PageProposal {
43 pub id: Uuid,
44 pub page_id: Uuid,
45 pub user_id: Option<Uuid>,
46 pub pending: bool,
47 pub created_at: DateTime<Utc>,
48 pub block_proposals: Vec<BlockProposal>,
49 pub page_title: String,
50 pub page_url_path: String,
51}
52
53#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, ToSchema)]
54
55pub struct EditProposalInfo {
56 pub page_id: Uuid,
57 pub page_proposal_id: Uuid,
58 pub block_proposals: Vec<BlockProposalInfo>,
59}
60
61#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, ToSchema)]
62
63pub struct ProposalCount {
64 pub pending: u32,
65 pub handled: u32,
66}
67
68pub async fn insert(
69 conn: &mut PgConnection,
70 pkey_policy: PKeyPolicy<Uuid>,
71 course_id: Uuid,
72 user_id: Option<Uuid>,
73 edits: &NewProposedPageEdits,
74) -> ModelResult<(Uuid, Vec<Uuid>)> {
75 if edits.block_edits.is_empty() {
76 return Err(model_err!(Generic, "No block edits".to_string()));
77 }
78
79 let mut tx = conn.begin().await?;
80 let page_res = sqlx::query!(
81 "
82INSERT INTO proposed_page_edits (id, course_id, page_id, user_id)
83VALUES ($1, $2, $3, $4)
84RETURNING *
85 ",
86 pkey_policy.into_uuid(),
87 course_id,
88 edits.page_id,
89 user_id,
90 )
91 .fetch_one(&mut *tx)
92 .await?;
93
94 let mut block_ids = vec![];
95 for block_edit in &edits.block_edits {
96 let res = sqlx::query!(
97 "
98INSERT INTO proposed_block_edits (
99 proposal_id,
100 block_id,
101 block_attribute,
102 original_text,
103 changed_text
104)
105VALUES ($1, $2, $3, $4, $5)
106RETURNING *
107",
108 page_res.id,
109 block_edit.block_id,
110 block_edit.block_attribute,
111 block_edit.original_text,
112 block_edit.changed_text
113 )
114 .fetch_one(&mut *tx)
115 .await?;
116 block_ids.push(res.id);
117 }
118 tx.commit().await?;
119 Ok((page_res.id, block_ids))
120}
121
122pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<ProposedPageEdit> {
123 let res = sqlx::query_as!(
124 ProposedPageEdit,
125 r#"
126SELECT *
127FROM proposed_page_edits
128WHERE id = $1
129 AND deleted_at IS NULL
130 "#,
131 id
132 )
133 .fetch_one(conn)
134 .await?;
135 Ok(res)
136}
137
138pub async fn create_for_page_id_and_course_id(
139 conn: &mut PgConnection,
140 pkey_policy: PKeyPolicy<Uuid>,
141 course_id: Uuid,
142 user_id: Option<Uuid>,
143 edits: &NewProposedPageEdits,
144) -> ModelResult<(Uuid, Vec<Uuid>)> {
145 if edits.block_edits.is_empty() {
146 return Err(model_err!(Generic, "No block edits".to_string()));
147 }
148
149 let mut tx = conn.begin().await?;
150 let page_res = sqlx::query!(
151 r#"
152INSERT INTO proposed_page_edits (id, course_id, page_id, user_id)
153SELECT $1, $2, pages.id, $4
154FROM pages
155WHERE pages.id = $3
156 AND pages.course_id = $2
157 AND pages.deleted_at IS NULL
158RETURNING *
159 "#,
160 pkey_policy.into_uuid(),
161 course_id,
162 edits.page_id,
163 user_id
164 )
165 .fetch_one(&mut *tx)
166 .await?;
167
168 let mut block_ids = vec![];
169 for block_edit in &edits.block_edits {
170 let res = sqlx::query!(
171 "
172INSERT INTO proposed_block_edits (
173 proposal_id,
174 block_id,
175 block_attribute,
176 original_text,
177 changed_text
178)
179VALUES ($1, $2, $3, $4, $5)
180RETURNING *
181",
182 page_res.id,
183 block_edit.block_id,
184 block_edit.block_attribute,
185 block_edit.original_text,
186 block_edit.changed_text
187 )
188 .fetch_one(&mut *tx)
189 .await?;
190 block_ids.push(res.id);
191 }
192 tx.commit().await?;
193 Ok((page_res.id, block_ids))
194}
195
196pub async fn get_proposals_for_course(
197 conn: &mut PgConnection,
198 course_id: Uuid,
199 pending: bool,
200 pagination: Pagination,
201) -> ModelResult<Vec<PageProposal>> {
202 let res = sqlx::query!(
203 r#"
204SELECT proposed_page_edits.id AS "page_proposal_id!",
205 proposed_block_edits.id AS "block_proposal_id!",
206 page_id as "page_id!",
207 user_id,
208 block_id AS "block_id!",
209 original_text AS "original_text!",
210 changed_text AS "changed_text!",
211 proposed_page_edits.pending as "pending!",
212 block_attribute AS "block_attribute!",
213 proposed_block_edits.status AS "block_proposal_status!",
214 proposed_page_edits.created_at as "created_at!",
215 pages.title as "page_title!",
216 pages.url_path as "page_url_path!"
217FROM (
218 SELECT id,
219 page_id,
220 user_id,
221 pending,
222 created_at
223 FROM proposed_page_edits
224 WHERE course_id = $1
225 AND pending = $2
226 AND deleted_at IS NULL
227 ORDER BY created_at DESC,
228 id
229 LIMIT $3 OFFSET $4
230 ) proposed_page_edits
231 LEFT JOIN proposed_block_edits ON proposed_page_edits.id = proposed_block_edits.proposal_id
232 LEFT JOIN pages ON proposed_page_edits.page_id = pages.id
233WHERE proposed_block_edits.deleted_at IS NULL
234"#,
235 course_id,
236 pending,
237 pagination.limit(),
238 pagination.offset(),
239 )
240 .fetch_all(&mut *conn)
241 .await?;
242
243 let mut proposals = HashMap::new();
244 let mut pages = HashMap::new();
245
246 for r in res {
247 let page_proposal_id = r.page_proposal_id;
248 let page_id = r.page_id;
249 let user_id = r.user_id;
250 let page_proposal_pending = r.pending;
251 let created_at = r.created_at;
252 let original_text = r.original_text;
253 let changed_text = r.changed_text;
254 let page_proposal =
255 proposals
256 .entry(r.page_proposal_id)
257 .or_insert_with(move || PageProposal {
258 id: page_proposal_id,
259 page_id,
260 user_id,
261 pending: page_proposal_pending,
262 created_at,
263 block_proposals: Vec::new(),
264 page_title: r.page_title,
265 page_url_path: r.page_url_path,
266 });
267
268 let content = match pages.entry(r.page_id) {
269 Entry::Occupied(o) => o.into_mut(),
270 Entry::Vacant(v) => {
271 let page = crate::pages::get_page(&mut *conn, r.page_id).await?;
272 let content: Vec<GutenbergBlock> = serde_json::from_value(page.content)?;
273 v.insert(content)
274 }
275 };
276
277 let block = content.iter().find(|b| b.client_id == r.block_id);
278
279 let block_proposal_id = r.block_proposal_id;
280 let block_id = r.block_id;
281 let block_proposal_status = r.block_proposal_status;
282
283 if let Some(block) = block {
284 let content = block
285 .attributes
286 .get(&r.block_attribute)
287 .ok_or_else(|| {
288 ModelError::new(
289 ModelErrorType::Generic,
290 format!(
291 "Missing expected attribute '{}' in edited block",
292 r.block_attribute
293 ),
294 None,
295 )
296 })?
297 .as_str()
298 .ok_or_else(|| {
299 ModelError::new(
300 ModelErrorType::Generic,
301 format!("Attribute '{}' did not contain a string", r.block_attribute),
302 None,
303 )
304 })?
305 .to_string();
306 page_proposal
307 .block_proposals
308 .push(BlockProposal::EditedBlockStillExists(
309 EditedBlockStillExistsData {
310 accept_preview: merge_edits::merge(&original_text, &changed_text, &content),
311 id: block_proposal_id,
312 block_id,
313 current_text: content.to_string(),
314 changed_text: changed_text.to_string(),
315 status: block_proposal_status,
316 original_text: original_text.to_string(),
317 },
318 ));
319 } else {
320 page_proposal
321 .block_proposals
322 .push(BlockProposal::EditedBlockNoLongerExists(
323 EditedBlockNoLongerExistsData {
324 id: block_proposal_id,
325 block_id,
326 changed_text: changed_text.to_string(),
327 status: block_proposal_status,
328 original_text: original_text.to_string(),
329 },
330 ));
331 }
332 }
333
334 let mut proposals = proposals.into_values().collect::<Vec<_>>();
335 proposals.sort_by(|left, right| left.created_at.cmp(&right.created_at).reverse());
336 Ok(proposals)
337}
338
339pub async fn get_proposal_count_for_course(
340 conn: &mut PgConnection,
341 course_id: Uuid,
342) -> ModelResult<ProposalCount> {
343 let res = sqlx::query!(
344 "
345SELECT COUNT(*) filter (
346 where proposed_page_edits.pending = true
347) AS pending,
348COUNT(*) filter (
349 where proposed_page_edits.pending = false
350) AS handled
351FROM proposed_page_edits
352WHERE proposed_page_edits.course_id = $1
353AND proposed_page_edits.deleted_at IS NULL
354",
355 course_id,
356 )
357 .fetch_one(conn)
358 .await?;
359 let count = ProposalCount {
360 pending: res.pending.unwrap_or_default().try_into()?,
361 handled: res.handled.unwrap_or_default().try_into()?,
362 };
363 Ok(count)
364}
365
366pub async fn process_proposal(
367 conn: &mut PgConnection,
368 page_id: Uuid,
369 page_proposal_id: Uuid,
370 block_proposals: Vec<BlockProposalInfo>,
371 author: Uuid,
372 spec_fetcher: impl SpecFetcher,
373 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
374) -> ModelResult<()> {
375 if block_proposals.is_empty() {
376 return Err(model_err!(
377 Generic,
378 "No block proposals to process".to_string()
379 ));
380 }
381
382 let mut tx = conn.begin().await?;
383 let page_with_exercises = crate::pages::get_page_with_exercises(&mut tx, page_id).await?;
384 let mut blocks = page_with_exercises.page.blocks_cloned()?;
385 for BlockProposalInfo { id, action } in block_proposals {
386 match action {
387 BlockProposalAction::Accept(contents) => {
388 let res = sqlx::query!(
389 "
390UPDATE proposed_block_edits
391SET status = 'accepted'
392WHERE id = $1
393RETURNING *
394",
395 id
396 )
397 .fetch_one(&mut *tx)
398 .await?;
399 let block = blocks
400 .iter_mut()
401 .find(|b| b.client_id == res.block_id)
402 .ok_or_else(|| {
403 ModelError::new(
404 ModelErrorType::Generic,
405 "Failed to find the block which the proposal was for".to_string(),
406 None,
407 )
408 })?;
409 let current_content =
410 block
411 .attributes
412 .get_mut(&res.block_attribute)
413 .ok_or_else(|| {
414 ModelError::new(
415 ModelErrorType::Generic,
416 format!("Edited block has no attribute {}", &res.block_attribute),
417 None,
418 )
419 })?;
420 if let Value::String(s) = current_content {
421 *s = contents;
422 } else {
423 return Err(ModelError::new(
424 ModelErrorType::Generic,
425 format!(
426 "Block attribute {} did not contain a string",
427 res.block_attribute
428 ),
429 None,
430 ));
431 }
432 }
433 BlockProposalAction::Reject => {
434 sqlx::query!(
435 "
436UPDATE proposed_block_edits
437SET status = 'rejected'
438WHERE id = $1
439",
440 id
441 )
442 .execute(&mut *tx)
443 .await?;
444 }
445 }
446 }
447
448 let cms_page_update = CmsPageUpdate {
449 content: blocks,
450 exercises: page_with_exercises.exercises,
451 exercise_slides: page_with_exercises.exercise_slides,
452 exercise_tasks: page_with_exercises.exercise_tasks,
453 url_path: page_with_exercises.page.url_path,
454 title: page_with_exercises.page.title,
455 chapter_id: page_with_exercises.page.chapter_id,
456 hidden: page_with_exercises.page.hidden,
457 };
458 crate::pages::update_page(
459 &mut tx,
460 PageUpdateArgs {
461 page_id: page_with_exercises.page.id,
462 author: crate::entity_history::HistoryAuthor::user(author),
463 cms_page_update,
464 retain_ids: true,
465 history_change_reason: HistoryChangeReason::PageSaved,
466 is_exam_page: page_with_exercises.page.exam_id.is_some(),
467 },
468 spec_fetcher,
469 fetch_service_info,
470 )
471 .await?;
472
473 update_page_edit_status(&mut tx, page_proposal_id).await?;
474
475 tx.commit().await?;
476 Ok(())
477}
478
479pub async fn process_by_id_and_page_id(
480 conn: &mut PgConnection,
481 page_id: Uuid,
482 page_proposal_id: Uuid,
483 block_proposals: Vec<BlockProposalInfo>,
484 author: Uuid,
485 spec_fetcher: impl SpecFetcher,
486 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
487) -> ModelResult<()> {
488 if block_proposals.is_empty() {
489 return Err(model_err!(
490 Generic,
491 "No block proposals to process".to_string()
492 ));
493 }
494
495 let block_proposal_ids = block_proposals.iter().map(|bp| bp.id).collect::<Vec<_>>();
496 let matching_block_count = sqlx::query!(
497 r#"
498SELECT COUNT(*) AS count
499FROM proposed_page_edits ppe
500 JOIN proposed_block_edits pbe ON pbe.proposal_id = ppe.id
501WHERE ppe.id = $1
502 AND ppe.page_id = $2
503 AND ppe.deleted_at IS NULL
504 AND pbe.id = ANY($3)
505 AND pbe.deleted_at IS NULL
506 "#,
507 page_proposal_id,
508 page_id,
509 &block_proposal_ids
510 )
511 .fetch_one(&mut *conn)
512 .await?
513 .count
514 .unwrap_or(0);
515
516 if matching_block_count != block_proposal_ids.len() as i64 {
517 return Err(model_err!(
518 PreconditionFailed,
519 "Block proposals do not all belong to the requested page proposal".to_string()
520 ));
521 }
522
523 process_proposal(
524 conn,
525 page_id,
526 page_proposal_id,
527 block_proposals,
528 author,
529 spec_fetcher,
530 fetch_service_info,
531 )
532 .await
533}
534
535pub async fn update_page_edit_status(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
536 let block_proposals = sqlx::query!(
537 r#"
538SELECT *
539FROM proposed_block_edits
540WHERE proposal_id = $1
541AND deleted_at IS NULL
542"#,
543 id
544 )
545 .fetch_all(&mut *conn)
546 .await?;
547 let pending = block_proposals
548 .iter()
549 .any(|bp| bp.status == ProposalStatus::Pending);
550 sqlx::query!(
551 "
552UPDATE proposed_page_edits
553SET pending = $1
554WHERE id = $2
555",
556 pending,
557 id,
558 )
559 .execute(&mut *conn)
560 .await?;
561 Ok(())
562}
563
564pub async fn get_block_ids_by_page_ids(
566 conn: &mut PgConnection,
567 course_id: Uuid,
568 page_ids: &[Uuid],
569) -> ModelResult<Vec<crate::pages::PageBlockReferences>> {
570 let res = sqlx::query_as!(
571 crate::pages::PageBlockReferences,
572 r#"
573SELECT proposed_page_edits.id,
574 proposed_page_edits.page_id,
575 array_agg(
576 proposed_block_edits.block_id
577 ORDER BY proposed_block_edits.created_at,
578 proposed_block_edits.id
579 ) AS "block_ids!"
580FROM proposed_page_edits
581 JOIN proposed_block_edits ON proposed_block_edits.proposal_id = proposed_page_edits.id
582 AND proposed_block_edits.deleted_at IS NULL
583WHERE proposed_page_edits.page_id = ANY($1)
584 AND proposed_page_edits.course_id = $2
585 AND proposed_page_edits.deleted_at IS NULL
586GROUP BY proposed_page_edits.id
587 "#,
588 page_ids,
589 course_id
590 )
591 .fetch_all(conn)
592 .await?;
593 Ok(res)
594}
595
596pub async fn set_page_ids(
598 conn: &mut PgConnection,
599 proposal_ids: &[Uuid],
600 page_ids: &[Uuid],
601) -> ModelResult<()> {
602 sqlx::query!(
603 "
604UPDATE proposed_page_edits
605SET page_id = moved.page_id
606FROM UNNEST($1::uuid [], $2::uuid []) AS moved(id, page_id)
607WHERE proposed_page_edits.id = moved.id
608 ",
609 proposal_ids,
610 page_ids
611 )
612 .execute(conn)
613 .await?;
614 Ok(())
615}
616
617#[cfg(test)]
618mod test {
619 use headless_lms_utils::document_schema_processor::{GutenbergBlock, attributes};
620
621 use super::*;
622 use crate::{pages::PageUpdateArgs, proposed_block_edits::*, test_helper::*};
623
624 async fn init_content(
625 conn: &mut PgConnection,
626 chapter: Uuid,
627 page: Uuid,
628 user: Uuid,
629 content: &str,
630 ) -> Uuid {
631 let client_id = Uuid::new_v4();
632 let new_content: Vec<GutenbergBlock> = vec![GutenbergBlock {
633 client_id,
634 name: "core/paragraph".to_string(),
635 is_valid: true,
636 attributes: attributes! {
637 "content": content
638 },
639 inner_blocks: vec![],
640 }];
641 let cms_page_update = CmsPageUpdate {
642 content: new_content,
643 url_path: "".to_string(),
644 title: "".to_string(),
645 chapter_id: Some(chapter),
646 exercises: vec![],
647 exercise_slides: vec![],
648 exercise_tasks: vec![],
649 hidden: false,
650 };
651 crate::pages::update_page(
652 conn,
653 PageUpdateArgs {
654 page_id: page,
655 author: crate::entity_history::HistoryAuthor::user(user),
656 cms_page_update,
657 retain_ids: true,
658 history_change_reason: HistoryChangeReason::PageSaved,
659 is_exam_page: false,
660 },
661 |_, _, _| unimplemented!(),
662 |_| unimplemented!(),
663 )
664 .await
665 .unwrap();
666 client_id
667 }
668
669 async fn assert_content(conn: &mut PgConnection, page_id: Uuid, expected: &str) {
670 let page = crate::pages::get_page(conn, page_id).await.unwrap();
671 let mut new_content: Vec<GutenbergBlock> = serde_json::from_value(page.content).unwrap();
672 let block = new_content.pop().unwrap();
673 let content = block.attributes.get("content").unwrap().as_str().unwrap();
674 assert_eq!(content, expected);
675 }
676
677 #[tokio::test]
678 async fn typo_fix() {
679 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, :chapter, :page);
680 let block_id = init_content(
681 tx.as_mut(),
682 chapter,
683 page,
684 user,
685 "Content with a tpo in it.",
686 )
687 .await;
688
689 let new = NewProposedPageEdits {
690 page_id: page,
691 block_edits: vec![NewProposedBlockEdit {
692 block_id,
693 block_attribute: "content".to_string(),
694 original_text: "Content with a tpo in it.".to_string(),
695 changed_text: "Content with a typo in it.".to_string(),
696 }],
697 };
698 insert(tx.as_mut(), PKeyPolicy::Generate, course, None, &new)
699 .await
700 .unwrap();
701 let mut ps = get_proposals_for_course(tx.as_mut(), course, true, Pagination::default())
702 .await
703 .unwrap();
704 let mut p = ps.pop().unwrap();
705 let b = p.block_proposals.pop().unwrap();
706 match b {
707 BlockProposal::EditedBlockStillExists(b) => {
708 assert_eq!(b.accept_preview.unwrap(), "Content with a typo in it.");
709 process_proposal(
710 tx.as_mut(),
711 page,
712 p.id,
713 vec![BlockProposalInfo {
714 id: b.id,
715 action: BlockProposalAction::Accept(
716 "Content with a typo in it.".to_string(),
717 ),
718 }],
719 user,
720 |_, _, _| unimplemented!(),
721 |_| unimplemented!(),
722 )
723 .await
724 .unwrap();
725
726 let mut ps =
727 get_proposals_for_course(tx.as_mut(), course, false, Pagination::default())
728 .await
729 .unwrap();
730 let _ = ps.pop().unwrap();
731
732 assert_content(tx.as_mut(), page, "Content with a typo in it.").await;
733 }
734 BlockProposal::EditedBlockNoLongerExists(_o) => panic!("Wrong block proposal"),
735 };
736 }
737
738 #[tokio::test]
739 async fn rejection() {
740 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, :chapter, :page);
741 let block_id = init_content(
742 tx.as_mut(),
743 chapter,
744 page,
745 user,
746 "Content with a tpo in it.",
747 )
748 .await;
749 let new = NewProposedPageEdits {
750 page_id: page,
751 block_edits: vec![NewProposedBlockEdit {
752 block_id,
753 block_attribute: "content".to_string(),
754 original_text: "Content with a tpo in it.".to_string(),
755 changed_text: "Content with a typo in it.".to_string(),
756 }],
757 };
758 insert(tx.as_mut(), PKeyPolicy::Generate, course, None, &new)
759 .await
760 .unwrap();
761
762 let mut ps = get_proposals_for_course(tx.as_mut(), course, true, Pagination::default())
763 .await
764 .unwrap();
765 let mut p = ps.pop().unwrap();
766 let b = p.block_proposals.pop().unwrap();
767 match b {
768 BlockProposal::EditedBlockStillExists(b) => {
769 assert_eq!(b.accept_preview.unwrap(), "Content with a typo in it.");
770 assert_eq!(b.status, ProposalStatus::Pending);
771
772 process_proposal(
773 tx.as_mut(),
774 page,
775 p.id,
776 vec![BlockProposalInfo {
777 id: b.id,
778 action: BlockProposalAction::Reject,
779 }],
780 user,
781 |_, _, _| unimplemented!(),
782 |_| unimplemented!(),
783 )
784 .await
785 .unwrap();
786
787 let mut ps =
788 get_proposals_for_course(tx.as_mut(), course, false, Pagination::default())
789 .await
790 .unwrap();
791 let _ = ps.pop().unwrap();
792
793 assert_content(tx.as_mut(), page, "Content with a tpo in it.").await;
794 }
795 BlockProposal::EditedBlockNoLongerExists(_o) => panic!("Wrong block proposal"),
796 };
797 }
798}