headless_lms_models/
proposed_page_edits.rs

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}