1use crate::prelude::*;
2use std::convert::TryFrom;
3
4#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy)]
5#[cfg_attr(feature = "ts_rs", derive(TS))]
6#[serde(rename_all = "snake_case")]
7pub enum ChapterLockingStatus {
8 Unlocked,
10 CompletedAndLocked,
12 NotUnlockedYet,
14}
15
16impl ChapterLockingStatus {
17 pub fn from_db(s: &str) -> ModelResult<Self> {
18 match s {
19 "unlocked" => Ok(ChapterLockingStatus::Unlocked),
20 "completed_and_locked" => Ok(ChapterLockingStatus::CompletedAndLocked),
21 "not_unlocked_yet" => Ok(ChapterLockingStatus::NotUnlockedYet),
22 _ => Err(ModelError::new(
23 ModelErrorType::Database,
24 format!("Invalid chapter locking status from database: {}", s),
25 None,
26 )),
27 }
28 }
29}
30
31#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
32#[cfg_attr(feature = "ts_rs", derive(TS))]
33pub struct UserChapterLockingStatus {
34 pub id: Uuid,
35 pub created_at: DateTime<Utc>,
36 pub updated_at: DateTime<Utc>,
37 pub deleted_at: Option<DateTime<Utc>>,
38 pub user_id: Uuid,
39 pub chapter_id: Uuid,
40 pub course_id: Uuid,
41 pub status: ChapterLockingStatus,
42}
43
44struct DatabaseRow {
45 id: Uuid,
46 created_at: DateTime<Utc>,
47 updated_at: DateTime<Utc>,
48 deleted_at: Option<DateTime<Utc>>,
49 user_id: Uuid,
50 chapter_id: Uuid,
51 course_id: Uuid,
52 status: String,
53}
54
55impl TryFrom<DatabaseRow> for UserChapterLockingStatus {
56 type Error = ModelError;
57
58 fn try_from(row: DatabaseRow) -> Result<Self, Self::Error> {
59 let status = ChapterLockingStatus::from_db(&row.status)?;
60 Ok(UserChapterLockingStatus {
61 id: row.id,
62 created_at: row.created_at,
63 updated_at: row.updated_at,
64 deleted_at: row.deleted_at,
65 user_id: row.user_id,
66 chapter_id: row.chapter_id,
67 course_id: row.course_id,
68 status,
69 })
70 }
71}
72
73async fn get_status_row(
74 conn: &mut PgConnection,
75 user_id: Uuid,
76 chapter_id: Uuid,
77 course_id: Option<Uuid>,
78 course_locking_enabled: Option<bool>,
79) -> ModelResult<Option<UserChapterLockingStatus>> {
80 let res = sqlx::query_as!(
81 DatabaseRow,
82 r#"
83SELECT id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status::text as "status!"
84FROM user_chapter_locking_statuses
85WHERE user_id = $1
86 AND chapter_id = $2
87 AND deleted_at IS NULL
88 "#,
89 user_id,
90 chapter_id
91 )
92 .fetch_optional(&mut *conn)
93 .await?;
94
95 if let Some(row) = res {
96 return row.try_into().map(Some);
97 }
98
99 if let (Some(course_id), Some(true)) = (course_id, course_locking_enabled) {
100 return Ok(Some(
101 ensure_not_unlocked_yet_status(&mut *conn, user_id, chapter_id, course_id).await?,
102 ));
103 }
104
105 Ok(None)
106}
107
108pub async fn get_or_init_status(
109 conn: &mut PgConnection,
110 user_id: Uuid,
111 chapter_id: Uuid,
112 course_id: Option<Uuid>,
113 course_locking_enabled: Option<bool>,
114) -> ModelResult<Option<ChapterLockingStatus>> {
115 get_status_row(conn, user_id, chapter_id, course_id, course_locking_enabled)
116 .await?
117 .map(|s| Ok(s.status))
118 .transpose()
119}
120
121pub async fn is_chapter_accessible(
122 conn: &mut PgConnection,
123 user_id: Uuid,
124 chapter_id: Uuid,
125 course_id: Uuid,
126) -> ModelResult<bool> {
127 use crate::courses;
128
129 let course = courses::get_course(conn, course_id).await?;
130
131 if !course.chapter_locking_enabled {
132 return Ok(true);
133 }
134
135 let status = get_or_init_status(
136 conn,
137 user_id,
138 chapter_id,
139 Some(course_id),
140 Some(course.chapter_locking_enabled),
141 )
142 .await?;
143 match status {
144 None => Ok(false),
145 Some(ChapterLockingStatus::Unlocked) => Ok(true),
146 Some(ChapterLockingStatus::CompletedAndLocked) => Ok(true),
147 Some(ChapterLockingStatus::NotUnlockedYet) => Ok(false),
148 }
149}
150
151pub async fn is_chapter_exercises_locked(
152 conn: &mut PgConnection,
153 user_id: Uuid,
154 chapter_id: Uuid,
155 course_id: Uuid,
156) -> ModelResult<bool> {
157 use crate::courses;
158
159 let course = courses::get_course(conn, course_id).await?;
160
161 if !course.chapter_locking_enabled {
162 return Ok(false);
163 }
164
165 let status = get_or_init_status(
166 conn,
167 user_id,
168 chapter_id,
169 Some(course_id),
170 Some(course.chapter_locking_enabled),
171 )
172 .await?;
173
174 match status {
175 None => Ok(true),
176 Some(ChapterLockingStatus::Unlocked) => Ok(false),
177 Some(ChapterLockingStatus::CompletedAndLocked) => Ok(true),
178 Some(ChapterLockingStatus::NotUnlockedYet) => Ok(true),
179 }
180}
181
182pub async fn unlock_chapter(
183 conn: &mut PgConnection,
184 user_id: Uuid,
185 chapter_id: Uuid,
186 course_id: Uuid,
187) -> ModelResult<UserChapterLockingStatus> {
188 let res = sqlx::query_as!(
189 DatabaseRow,
190 r#"
191INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
192VALUES ($1, $2, $3, 'unlocked'::chapter_locking_status, NULL)
193ON CONFLICT ON CONSTRAINT idx_user_chapter_locking_statuses_user_chapter_active DO UPDATE
194SET status = 'unlocked'::chapter_locking_status, deleted_at = NULL
195RETURNING id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status::text as "status!"
196 "#,
197 user_id,
198 chapter_id,
199 course_id
200 )
201 .fetch_optional(&mut *conn)
202 .await?;
203
204 res.map(|s| s.try_into())
205 .transpose()?
206 .ok_or_else(|| ModelError::new(ModelErrorType::NotFound, "Failed to unlock chapter", None))
207}
208
209pub async fn complete_chapter(
210 conn: &mut PgConnection,
211 user_id: Uuid,
212 chapter_id: Uuid,
213 course_id: Uuid,
214) -> ModelResult<UserChapterLockingStatus> {
215 let res = sqlx::query_as!(
216 DatabaseRow,
217 r#"
218INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
219VALUES ($1, $2, $3, 'completed_and_locked'::chapter_locking_status, NULL)
220ON CONFLICT ON CONSTRAINT idx_user_chapter_locking_statuses_user_chapter_active DO UPDATE
221SET status = 'completed_and_locked'::chapter_locking_status, deleted_at = NULL
222RETURNING id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status::text as "status!"
223 "#,
224 user_id,
225 chapter_id,
226 course_id
227 )
228 .fetch_optional(&mut *conn)
229 .await?;
230
231 res.map(|s| s.try_into()).transpose()?.ok_or_else(|| {
232 ModelError::new(ModelErrorType::NotFound, "Failed to complete chapter", None)
233 })
234}
235
236pub async fn get_by_user_and_chapter(
237 conn: &mut PgConnection,
238 user_id: Uuid,
239 chapter_id: Uuid,
240 course_id: Option<Uuid>,
241 course_locking_enabled: Option<bool>,
242) -> ModelResult<Option<UserChapterLockingStatus>> {
243 get_status_row(conn, user_id, chapter_id, course_id, course_locking_enabled).await
244}
245
246pub async fn get_by_user_and_course(
247 conn: &mut PgConnection,
248 user_id: Uuid,
249 course_id: Uuid,
250) -> ModelResult<Vec<UserChapterLockingStatus>> {
251 let course_locking_enabled: bool = sqlx::query!(
252 r#"
253SELECT chapter_locking_enabled
254FROM courses
255WHERE id = $1
256 "#,
257 course_id
258 )
259 .fetch_optional(&mut *conn)
260 .await?
261 .map(|r| r.chapter_locking_enabled)
262 .unwrap_or(false);
263
264 if course_locking_enabled {
265 sqlx::query!(
266 r#"
267INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
268SELECT $1, chapters.id, $2, 'not_unlocked_yet'::chapter_locking_status, NULL
269FROM chapters
270WHERE chapters.course_id = $2
271 AND chapters.deleted_at IS NULL
272 AND NOT EXISTS (
273 SELECT 1
274 FROM user_chapter_locking_statuses
275 WHERE user_chapter_locking_statuses.user_id = $1
276 AND user_chapter_locking_statuses.chapter_id = chapters.id
277 AND user_chapter_locking_statuses.deleted_at IS NULL
278 )
279ON CONFLICT (user_id, chapter_id, deleted_at) DO NOTHING
280 "#,
281 user_id,
282 course_id
283 )
284 .execute(&mut *conn)
285 .await?;
286 }
287
288 let res = sqlx::query_as!(
289 DatabaseRow,
290 r#"
291SELECT id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status::text as "status!"
292FROM user_chapter_locking_statuses
293WHERE user_id = $1
294 AND course_id = $2
295 AND deleted_at IS NULL
296 "#,
297 user_id,
298 course_id
299 )
300 .fetch_all(&mut *conn)
301 .await?;
302
303 res.into_iter()
304 .map(|r| r.try_into())
305 .collect::<ModelResult<Vec<_>>>()
306}
307
308pub async fn ensure_not_unlocked_yet_status(
312 conn: &mut PgConnection,
313 user_id: Uuid,
314 chapter_id: Uuid,
315 course_id: Uuid,
316) -> ModelResult<UserChapterLockingStatus> {
317 let res: Option<DatabaseRow> = sqlx::query_as!(
318 DatabaseRow,
319 r#"
320INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
321VALUES ($1, $2, $3, 'not_unlocked_yet'::chapter_locking_status, NULL)
322ON CONFLICT (user_id, chapter_id, deleted_at) DO NOTHING
323RETURNING id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status::text as "status!"
324 "#,
325 user_id,
326 chapter_id,
327 course_id
328 )
329 .fetch_optional(&mut *conn)
330 .await?;
331
332 if let Some(status) = res {
333 return status.try_into();
334 }
335
336 let retrieved = sqlx::query_as!(
337 DatabaseRow,
338 r#"
339SELECT id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status::text as "status!"
340FROM user_chapter_locking_statuses
341WHERE user_id = $1
342 AND chapter_id = $2
343 AND deleted_at IS NULL
344 "#,
345 user_id,
346 chapter_id
347 )
348 .fetch_optional(&mut *conn)
349 .await?;
350
351 retrieved.map(|r| r.try_into()).transpose()?.ok_or_else(|| {
352 ModelError::new(
353 ModelErrorType::NotFound,
354 "Failed to ensure not_unlocked_yet status",
355 None,
356 )
357 })
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use crate::test_helper::*;
364
365 #[tokio::test]
366 async fn get_status_returns_none_when_no_status_exists() {
367 insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
368 let chapter = crate::chapters::insert(
369 tx.as_mut(),
370 PKeyPolicy::Generate,
371 &crate::chapters::NewChapter {
372 name: "Test Chapter".to_string(),
373 color: None,
374 course_id: course,
375 chapter_number: 1,
376 front_page_id: None,
377 opens_at: None,
378 deadline: None,
379 course_module_id: Some(course_module.id),
380 },
381 )
382 .await
383 .unwrap();
384
385 let status = get_or_init_status(tx.as_mut(), user, chapter, None, None)
386 .await
387 .unwrap();
388 assert_eq!(status, None);
389 }
390
391 #[tokio::test]
392 async fn unlock_chapter_creates_unlocked_status() {
393 insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
394 let chapter = crate::chapters::insert(
395 tx.as_mut(),
396 PKeyPolicy::Generate,
397 &crate::chapters::NewChapter {
398 name: "Test Chapter".to_string(),
399 color: None,
400 course_id: course,
401 chapter_number: 1,
402 front_page_id: None,
403 opens_at: None,
404 deadline: None,
405 course_module_id: Some(course_module.id),
406 },
407 )
408 .await
409 .unwrap();
410
411 let status = unlock_chapter(tx.as_mut(), user, chapter, course)
412 .await
413 .unwrap();
414 assert_eq!(status.status, ChapterLockingStatus::Unlocked);
415 assert_eq!(status.user_id, user);
416 assert_eq!(status.chapter_id, chapter);
417
418 let retrieved_status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
419 .await
420 .unwrap();
421 assert_eq!(retrieved_status, Some(ChapterLockingStatus::Unlocked));
422 }
423
424 #[tokio::test]
425 async fn complete_chapter_creates_completed_status() {
426 insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
427 let chapter = crate::chapters::insert(
428 tx.as_mut(),
429 PKeyPolicy::Generate,
430 &crate::chapters::NewChapter {
431 name: "Test Chapter".to_string(),
432 color: None,
433 course_id: course,
434 chapter_number: 1,
435 front_page_id: None,
436 opens_at: None,
437 deadline: None,
438 course_module_id: Some(course_module.id),
439 },
440 )
441 .await
442 .unwrap();
443
444 let status = complete_chapter(tx.as_mut(), user, chapter, course)
445 .await
446 .unwrap();
447 assert_eq!(status.status, ChapterLockingStatus::CompletedAndLocked);
448 assert_eq!(status.user_id, user);
449 assert_eq!(status.chapter_id, chapter);
450
451 let retrieved_status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
452 .await
453 .unwrap();
454 assert_eq!(
455 retrieved_status,
456 Some(ChapterLockingStatus::CompletedAndLocked)
457 );
458 }
459
460 #[tokio::test]
461 async fn unlock_then_complete_chapter_updates_status() {
462 insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
463 let chapter = crate::chapters::insert(
464 tx.as_mut(),
465 PKeyPolicy::Generate,
466 &crate::chapters::NewChapter {
467 name: "Test Chapter".to_string(),
468 color: None,
469 course_id: course,
470 chapter_number: 1,
471 front_page_id: None,
472 opens_at: None,
473 deadline: None,
474 course_module_id: Some(course_module.id),
475 },
476 )
477 .await
478 .unwrap();
479
480 unlock_chapter(tx.as_mut(), user, chapter, course)
481 .await
482 .unwrap();
483 let status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
484 .await
485 .unwrap();
486 assert_eq!(status, Some(ChapterLockingStatus::Unlocked));
487
488 complete_chapter(tx.as_mut(), user, chapter, course)
489 .await
490 .unwrap();
491 let status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
492 .await
493 .unwrap();
494 assert_eq!(status, Some(ChapterLockingStatus::CompletedAndLocked));
495 }
496
497 #[tokio::test]
498 async fn get_by_user_and_course_returns_all_statuses() {
499 insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
500 let chapter1 = crate::chapters::insert(
501 tx.as_mut(),
502 PKeyPolicy::Generate,
503 &crate::chapters::NewChapter {
504 name: "Chapter 1".to_string(),
505 color: None,
506 course_id: course,
507 chapter_number: 1,
508 front_page_id: None,
509 opens_at: None,
510 deadline: None,
511 course_module_id: Some(course_module.id),
512 },
513 )
514 .await
515 .unwrap();
516 let chapter2 = crate::chapters::insert(
517 tx.as_mut(),
518 PKeyPolicy::Generate,
519 &crate::chapters::NewChapter {
520 name: "Chapter 2".to_string(),
521 color: None,
522 course_id: course,
523 chapter_number: 2,
524 front_page_id: None,
525 opens_at: None,
526 deadline: None,
527 course_module_id: Some(course_module.id),
528 },
529 )
530 .await
531 .unwrap();
532
533 unlock_chapter(tx.as_mut(), user, chapter1, course)
534 .await
535 .unwrap();
536 complete_chapter(tx.as_mut(), user, chapter2, course)
537 .await
538 .unwrap();
539
540 let statuses = get_by_user_and_course(tx.as_mut(), user, course)
541 .await
542 .unwrap();
543 assert_eq!(statuses.len(), 2);
544 assert!(
545 statuses
546 .iter()
547 .any(|s| s.chapter_id == chapter1 && s.status == ChapterLockingStatus::Unlocked)
548 );
549 assert!(
550 statuses.iter().any(|s| s.chapter_id == chapter2
551 && s.status == ChapterLockingStatus::CompletedAndLocked)
552 );
553 }
554}