Skip to main content

headless_lms_models/
page_history.rs

1use std::collections::HashMap;
2
3use serde_json::Value;
4use utoipa::ToSchema;
5
6use crate::{
7    pages::{CmsPageExercise, CmsPageExerciseSlide, CmsPageExerciseTask},
8    peer_or_self_review_configs::CmsPeerOrSelfReviewConfig,
9    peer_or_self_review_questions::CmsPeerOrSelfReviewQuestion,
10    prelude::*,
11};
12
13#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Type, ToSchema)]
14#[sqlx(type_name = "history_change_reason", rename_all = "kebab-case")]
15pub enum HistoryChangeReason {
16    PageSaved,
17    HistoryRestored,
18    PageDeleted,
19}
20
21#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
22
23pub struct PageHistory {
24    pub id: Uuid,
25    pub created_at: DateTime<Utc>,
26    pub updated_at: DateTime<Utc>,
27    pub deleted_at: Option<DateTime<Utc>>,
28    pub title: String,
29    pub content: Value,
30    pub history_change_reason: HistoryChangeReason,
31    pub restored_from_id: Option<Uuid>,
32    pub author_user_id: Uuid,
33    pub page_id: Uuid,
34}
35
36#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
37
38pub struct PageHistoryContent {
39    pub content: serde_json::Value,
40    pub exercises: Vec<CmsPageExercise>,
41    pub exercise_slides: Vec<CmsPageExerciseSlide>,
42    pub exercise_tasks: Vec<CmsPageExerciseTask>,
43    pub peer_or_self_review_configs: Vec<CmsPeerOrSelfReviewConfig>,
44    pub peer_or_self_review_questions: Vec<CmsPeerOrSelfReviewQuestion>,
45}
46
47// Batch refactor pushed past the limit
48#[allow(clippy::too_many_arguments)]
49pub async fn insert(
50    conn: &mut PgConnection,
51    pkey_policy: PKeyPolicy<Uuid>,
52    page_id: Uuid,
53    title: &str,
54    content: &PageHistoryContent,
55    history_change_reason: HistoryChangeReason,
56    author_user_id: Uuid,
57    restored_from_id: Option<Uuid>,
58) -> ModelResult<Uuid> {
59    let res = sqlx::query!(
60        "
61INSERT INTO page_history (
62    id,
63    page_id,
64    title,
65    content,
66    history_change_reason,
67    author_user_id,
68    restored_from_id
69  )
70VALUES ($1, $2, $3, $4, $5, $6, $7)
71RETURNING *
72        ",
73        pkey_policy.into_uuid(),
74        page_id,
75        title,
76        serde_json::to_value(content)?,
77        history_change_reason as HistoryChangeReason,
78        author_user_id,
79        restored_from_id
80    )
81    .fetch_one(&mut *conn)
82    .await?;
83    // A restore has to be able to bring back the private specs in this snapshot, so the files they
84    // name must stay out of the abandoned-upload reaper's reach for as long as the snapshot exists.
85    // Only the private ones: a restore re-derives the other two from them.
86    let spec_files: Vec<Uuid> = content
87        .exercise_tasks
88        .iter()
89        .flat_map(|task| task.private_spec_files.iter().copied())
90        .collect();
91    crate::page_history_spec_files::insert_many(conn, res.id, &spec_files).await?;
92    Ok(res.id)
93}
94
95pub struct PageHistoryData {
96    pub content: PageHistoryContent,
97    pub title: String,
98    pub exam_id: Option<Uuid>,
99}
100
101pub async fn get_history_data(conn: &mut PgConnection, id: Uuid) -> ModelResult<PageHistoryData> {
102    let record = sqlx::query!(
103        "
104SELECT page_history.content,
105  page_history.title,
106  pages.exam_id
107FROM page_history
108  JOIN pages ON pages.id = page_history.page_id
109WHERE page_history.id = $1
110  AND pages.deleted_at IS NULL
111  AND page_history.deleted_at IS NULL
112        ",
113        id,
114    )
115    .fetch_one(conn)
116    .await?;
117    Ok(PageHistoryData {
118        content: serde_json::from_value(record.content)?,
119        title: record.title,
120        exam_id: record.exam_id,
121    })
122}
123
124pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<PageHistory> {
125    let res = sqlx::query_as!(
126        PageHistory,
127        r#"
128SELECT *
129FROM page_history
130WHERE id = $1
131  AND deleted_at IS NULL
132        "#,
133        id
134    )
135    .fetch_one(conn)
136    .await?;
137    Ok(res)
138}
139
140pub async fn history(
141    conn: &mut PgConnection,
142    page_id: Uuid,
143    pagination: Pagination,
144) -> ModelResult<Vec<PageHistory>> {
145    let res = sqlx::query_as!(
146        PageHistory,
147        r#"
148SELECT *
149FROM page_history
150WHERE page_id = $1
151AND deleted_at IS NULL
152ORDER BY created_at DESC, id
153LIMIT $2
154OFFSET $3
155"#,
156        page_id,
157        pagination.limit(),
158        pagination.offset()
159    )
160    .fetch_all(conn)
161    .await?;
162    Ok(res)
163}
164
165pub async fn history_count(conn: &mut PgConnection, page_id: Uuid) -> ModelResult<i64> {
166    let res = sqlx::query!(
167        "
168SELECT COUNT(*) AS count
169FROM page_history
170WHERE page_id = $1
171AND deleted_at IS NULL
172",
173        page_id
174    )
175    .fetch_one(conn)
176    .await?;
177    Ok(res.count.unwrap_or_default())
178}
179
180/// Latest non-deleted `page_history` row id per page for pages in the given courses.
181pub async fn get_latest_page_history_ids_by_course_ids(
182    conn: &mut PgConnection,
183    course_ids: &[Uuid],
184) -> ModelResult<HashMap<Uuid, Uuid>> {
185    let rows = sqlx::query!(
186        r#"
187SELECT DISTINCT ON (ph.page_id)
188  ph.id,
189  ph.page_id
190FROM page_history ph
191  INNER JOIN pages p ON p.id = ph.page_id
192WHERE p.course_id = ANY($1)
193  AND ph.deleted_at IS NULL
194ORDER BY ph.page_id,
195  ph.created_at DESC,
196  ph.id DESC
197"#,
198        course_ids
199    )
200    .fetch_all(conn)
201    .await?;
202
203    Ok(rows.into_iter().map(|row| (row.page_id, row.id)).collect())
204}