Skip to main content

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;
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
564/// Edit proposals on the given pages of the course, handled ones included.
565pub 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
596/// Sets each proposal's page; `proposal_ids` and `page_ids` are parallel.
597pub 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}