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 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}