1use crate::prelude::*;
2
3#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, sqlx::FromRow, utoipa::ToSchema)]
7pub struct ExerciseSlideSubmissionShare {
8 pub id: Uuid,
9 pub exercise_slide_submission_id: Uuid,
10 pub created_by: Uuid,
11 pub created_at: DateTime<Utc>,
12 pub updated_at: DateTime<Utc>,
13 pub deleted_at: Option<DateTime<Utc>>,
14}
15
16pub async fn insert(
17 conn: &mut PgConnection,
18 exercise_slide_submission_id: Uuid,
19 created_by: Uuid,
20) -> ModelResult<ExerciseSlideSubmissionShare> {
21 let row = sqlx::query_as!(
22 ExerciseSlideSubmissionShare,
23 r#"
24INSERT INTO exercise_slide_submission_shares (exercise_slide_submission_id, created_by)
25VALUES ($1, $2)
26RETURNING *
27 "#,
28 exercise_slide_submission_id,
29 created_by,
30 )
31 .fetch_one(conn)
32 .await?;
33 Ok(row)
34}
35
36pub async fn get_by_id(
37 conn: &mut PgConnection,
38 id: Uuid,
39) -> ModelResult<ExerciseSlideSubmissionShare> {
40 let row = sqlx::query_as!(
41 ExerciseSlideSubmissionShare,
42 r#"
43SELECT *
44FROM exercise_slide_submission_shares
45WHERE id = $1
46 AND deleted_at IS NULL
47 "#,
48 id,
49 )
50 .fetch_one(conn)
51 .await?;
52 Ok(row)
53}
54
55pub async fn list_by_creator(
57 conn: &mut PgConnection,
58 created_by: Uuid,
59) -> ModelResult<Vec<ExerciseSlideSubmissionShare>> {
60 let rows = sqlx::query_as!(
61 ExerciseSlideSubmissionShare,
62 r#"
63SELECT *
64FROM exercise_slide_submission_shares
65WHERE created_by = $1
66 AND deleted_at IS NULL
67ORDER BY created_at DESC, id DESC
68 "#,
69 created_by,
70 )
71 .fetch_all(conn)
72 .await?;
73 Ok(rows)
74}
75
76pub async fn revoke(conn: &mut PgConnection, id: Uuid, created_by: Uuid) -> ModelResult<bool> {
81 let revoked = sqlx::query_scalar!(
82 r#"
83UPDATE exercise_slide_submission_shares
84SET deleted_at = now()
85WHERE id = $1
86 AND created_by = $2
87 AND deleted_at IS NULL
88RETURNING id
89 "#,
90 id,
91 created_by,
92 )
93 .fetch_optional(conn)
94 .await?;
95 Ok(revoked.is_some())
96}
97
98pub async fn revoke_all_for_submission(
101 conn: &mut PgConnection,
102 exercise_slide_submission_id: Uuid,
103 created_by: Uuid,
104) -> ModelResult<u64> {
105 let revoked = sqlx::query!(
106 r#"
107UPDATE exercise_slide_submission_shares
108SET deleted_at = now()
109WHERE exercise_slide_submission_id = $1
110 AND created_by = $2
111 AND deleted_at IS NULL
112 "#,
113 exercise_slide_submission_id,
114 created_by,
115 )
116 .execute(conn)
117 .await?
118 .rows_affected();
119 Ok(revoked)
120}
121
122#[cfg(test)]
123mod test {
124 use super::*;
125 use crate::exercise_slide_submissions::{
126 NewExerciseSlideSubmission, insert_exercise_slide_submission,
127 };
128 use crate::exercise_task_gradings::UserPointsUpdateStrategy;
129 use crate::test_helper::*;
130
131 async fn insert_submission(
132 tx: &mut PgConnection,
133 course_id: Uuid,
134 user_id: Uuid,
135 exercise_id: Uuid,
136 exercise_slide_id: Uuid,
137 ) -> Uuid {
138 insert_exercise_slide_submission(
139 tx,
140 NewExerciseSlideSubmission {
141 exercise_slide_id,
142 course_id: Some(course_id),
143 exam_id: None,
144 user_id,
145 exercise_id,
146 user_points_update_strategy:
147 UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
148 },
149 )
150 .await
151 .unwrap()
152 .id
153 }
154
155 #[tokio::test]
158 async fn a_creator_can_revoke_a_share_and_it_stops_resolving() {
159 insert_data!(:tx, user:user_id, :org, course:course_id, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:slide_id, task:_task);
160 let submission_id =
161 insert_submission(tx.as_mut(), course_id, user_id, exercise_id, slide_id).await;
162 let share = insert(tx.as_mut(), submission_id, user_id).await.unwrap();
163
164 assert_eq!(get_by_id(tx.as_mut(), share.id).await.unwrap().id, share.id);
165 assert_eq!(
166 list_by_creator(tx.as_mut(), user_id)
167 .await
168 .unwrap()
169 .iter()
170 .map(|s| s.id)
171 .collect::<Vec<_>>(),
172 vec![share.id]
173 );
174
175 assert!(revoke(tx.as_mut(), share.id, user_id).await.unwrap());
176 assert!(
177 get_by_id(tx.as_mut(), share.id).await.is_err(),
178 "a revoked share must no longer resolve"
179 );
180 assert!(
181 list_by_creator(tx.as_mut(), user_id)
182 .await
183 .unwrap()
184 .is_empty()
185 );
186 assert!(
187 !revoke(tx.as_mut(), share.id, user_id).await.unwrap(),
188 "revoking twice must be a no-op, not a second success"
189 );
190 tx.rollback().await;
191 }
192
193 #[tokio::test]
195 async fn another_user_cannot_revoke_someone_elses_share() {
196 insert_data!(:tx, user:user_id, :org, course:course_id, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:slide_id, task:_task);
197 let other_user = crate::users::insert(
198 tx.as_mut(),
199 PKeyPolicy::Generate,
200 "share-thief@example.com",
201 None,
202 None,
203 )
204 .await
205 .unwrap();
206 let submission_id =
207 insert_submission(tx.as_mut(), course_id, user_id, exercise_id, slide_id).await;
208 let share = insert(tx.as_mut(), submission_id, user_id).await.unwrap();
209
210 assert!(!revoke(tx.as_mut(), share.id, other_user).await.unwrap());
211 assert_eq!(get_by_id(tx.as_mut(), share.id).await.unwrap().id, share.id);
212 tx.rollback().await;
213 }
214
215 #[tokio::test]
217 async fn revoking_by_submission_withdraws_every_share_of_it() {
218 insert_data!(:tx, user:user_id, :org, course:course_id, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:slide_id, task:_task);
219 let submission_id =
220 insert_submission(tx.as_mut(), course_id, user_id, exercise_id, slide_id).await;
221 let other_submission =
222 insert_submission(tx.as_mut(), course_id, user_id, exercise_id, slide_id).await;
223 let first = insert(tx.as_mut(), submission_id, user_id).await.unwrap();
224 let second = insert(tx.as_mut(), submission_id, user_id).await.unwrap();
225 let unrelated = insert(tx.as_mut(), other_submission, user_id)
226 .await
227 .unwrap();
228
229 assert_eq!(
230 revoke_all_for_submission(tx.as_mut(), submission_id, user_id)
231 .await
232 .unwrap(),
233 2
234 );
235 assert!(get_by_id(tx.as_mut(), first.id).await.is_err());
236 assert!(get_by_id(tx.as_mut(), second.id).await.is_err());
237 assert_eq!(
238 get_by_id(tx.as_mut(), unrelated.id).await.unwrap().id,
239 unrelated.id,
240 "another submission's share must be untouched"
241 );
242 tx.rollback().await;
243 }
244}