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 cms_page_update = CmsPageUpdate {
370 content: blocks,
371 exercises: page_with_exercises.exercises,
372 exercise_slides: page_with_exercises.exercise_slides,
373 exercise_tasks: page_with_exercises.exercise_tasks,
374 url_path: page_with_exercises.page.url_path,
375 title: page_with_exercises.page.title,
376 chapter_id: page_with_exercises.page.chapter_id,
377 };
378 crate::pages::update_page(
379 &mut tx,
380 PageUpdateArgs {
381 page_id: page_with_exercises.page.id,
382 author,
383 cms_page_update,
384 retain_ids: true,
385 history_change_reason: HistoryChangeReason::PageSaved,
386 is_exam_page: page_with_exercises.page.exam_id.is_some(),
387 },
388 spec_fetcher,
389 fetch_service_info,
390 )
391 .await?;
392
393 update_page_edit_status(&mut tx, page_proposal_id).await?;
394
395 tx.commit().await?;
396 Ok(())
397}
398
399pub async fn update_page_edit_status(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
400 let block_proposals = sqlx::query!(
401 r#"
402SELECT status AS "status: ProposalStatus"
403FROM proposed_block_edits
404WHERE proposal_id = $1
405AND deleted_at IS NULL
406"#,
407 id
408 )
409 .fetch_all(&mut *conn)
410 .await?;
411 let pending = block_proposals
412 .iter()
413 .any(|bp| bp.status == ProposalStatus::Pending);
414 sqlx::query!(
415 "
416UPDATE proposed_page_edits
417SET pending = $1
418WHERE id = $2
419",
420 pending,
421 id,
422 )
423 .execute(&mut *conn)
424 .await?;
425 Ok(())
426}
427
428#[cfg(test)]
429mod test {
430 use headless_lms_utils::document_schema_processor::{GutenbergBlock, attributes};
431
432 use super::*;
433 use crate::{pages::PageUpdateArgs, proposed_block_edits::*, test_helper::*};
434
435 async fn init_content(
436 conn: &mut PgConnection,
437 chapter: Uuid,
438 page: Uuid,
439 user: Uuid,
440 content: &str,
441 ) -> Uuid {
442 let client_id = Uuid::new_v4();
443 let new_content: Vec<GutenbergBlock> = vec![GutenbergBlock {
444 client_id,
445 name: "core/paragraph".to_string(),
446 is_valid: true,
447 attributes: attributes! {
448 "content": content
449 },
450 inner_blocks: vec![],
451 }];
452 let cms_page_update = CmsPageUpdate {
453 content: new_content,
454 url_path: "".to_string(),
455 title: "".to_string(),
456 chapter_id: Some(chapter),
457 exercises: vec![],
458 exercise_slides: vec![],
459 exercise_tasks: vec![],
460 };
461 crate::pages::update_page(
462 conn,
463 PageUpdateArgs {
464 page_id: page,
465 author: user,
466 cms_page_update,
467 retain_ids: true,
468 history_change_reason: HistoryChangeReason::PageSaved,
469 is_exam_page: false,
470 },
471 |_, _, _| unimplemented!(),
472 |_| unimplemented!(),
473 )
474 .await
475 .unwrap();
476 client_id
477 }
478
479 async fn assert_content(conn: &mut PgConnection, page_id: Uuid, expected: &str) {
480 let page = crate::pages::get_page(conn, page_id).await.unwrap();
481 let mut new_content: Vec<GutenbergBlock> = serde_json::from_value(page.content).unwrap();
482 let block = new_content.pop().unwrap();
483 let content = block.attributes.get("content").unwrap().as_str().unwrap();
484 assert_eq!(content, expected);
485 }
486
487 #[tokio::test]
488 async fn typo_fix() {
489 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, :chapter, :page);
490 let block_id = init_content(
491 tx.as_mut(),
492 chapter,
493 page,
494 user,
495 "Content with a tpo in it.",
496 )
497 .await;
498
499 let new = NewProposedPageEdits {
500 page_id: page,
501 block_edits: vec![NewProposedBlockEdit {
502 block_id,
503 block_attribute: "content".to_string(),
504 original_text: "Content with a tpo in it.".to_string(),
505 changed_text: "Content with a typo in it.".to_string(),
506 }],
507 };
508 insert(tx.as_mut(), PKeyPolicy::Generate, course, None, &new)
509 .await
510 .unwrap();
511 let mut ps = get_proposals_for_course(tx.as_mut(), course, true, Pagination::default())
512 .await
513 .unwrap();
514 let mut p = ps.pop().unwrap();
515 let b = p.block_proposals.pop().unwrap();
516 match b {
517 BlockProposal::EditedBlockStillExists(b) => {
518 assert_eq!(b.accept_preview.unwrap(), "Content with a typo in it.");
519 process_proposal(
520 tx.as_mut(),
521 page,
522 p.id,
523 vec![BlockProposalInfo {
524 id: b.id,
525 action: BlockProposalAction::Accept(
526 "Content with a typo in it.".to_string(),
527 ),
528 }],
529 user,
530 |_, _, _| unimplemented!(),
531 |_| unimplemented!(),
532 )
533 .await
534 .unwrap();
535
536 let mut ps =
537 get_proposals_for_course(tx.as_mut(), course, false, Pagination::default())
538 .await
539 .unwrap();
540 let _ = ps.pop().unwrap();
541
542 assert_content(tx.as_mut(), page, "Content with a typo in it.").await;
543 }
544 BlockProposal::EditedBlockNoLongerExists(_o) => panic!("Wrong block proposal"),
545 };
546 }
547
548 #[tokio::test]
549 async fn rejection() {
550 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, :chapter, :page);
551 let block_id = init_content(
552 tx.as_mut(),
553 chapter,
554 page,
555 user,
556 "Content with a tpo in it.",
557 )
558 .await;
559 let new = NewProposedPageEdits {
560 page_id: page,
561 block_edits: vec![NewProposedBlockEdit {
562 block_id,
563 block_attribute: "content".to_string(),
564 original_text: "Content with a tpo in it.".to_string(),
565 changed_text: "Content with a typo in it.".to_string(),
566 }],
567 };
568 insert(tx.as_mut(), PKeyPolicy::Generate, course, None, &new)
569 .await
570 .unwrap();
571
572 let mut ps = get_proposals_for_course(tx.as_mut(), course, true, Pagination::default())
573 .await
574 .unwrap();
575 let mut p = ps.pop().unwrap();
576 let b = p.block_proposals.pop().unwrap();
577 match b {
578 BlockProposal::EditedBlockStillExists(b) => {
579 assert_eq!(b.accept_preview.unwrap(), "Content with a typo in it.");
580 assert_eq!(b.status, ProposalStatus::Pending);
581
582 process_proposal(
583 tx.as_mut(),
584 page,
585 p.id,
586 vec![BlockProposalInfo {
587 id: b.id,
588 action: BlockProposalAction::Reject,
589 }],
590 user,
591 |_, _, _| unimplemented!(),
592 |_| unimplemented!(),
593 )
594 .await
595 .unwrap();
596
597 let mut ps =
598 get_proposals_for_course(tx.as_mut(), course, false, Pagination::default())
599 .await
600 .unwrap();
601 let _ = ps.pop().unwrap();
602
603 assert_content(tx.as_mut(), page, "Content with a tpo in it.").await;
604 }
605 BlockProposal::EditedBlockNoLongerExists(_o) => panic!("Wrong block proposal"),
606 };
607 }
608}