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