Skip to main content

headless_lms_models/
course_module_completion_registered_to_study_registries.rs

1use crate::{course_module_completions, error::missing_model_error, prelude::*};
2
3#[derive(Clone, PartialEq, Deserialize, Serialize)]
4pub struct CourseModuleCompletionRegisteredToStudyRegistry {
5    pub id: Uuid,
6    pub created_at: DateTime<Utc>,
7    pub updated_at: DateTime<Utc>,
8    pub deleted_at: Option<DateTime<Utc>>,
9    pub course_id: Uuid,
10    pub course_module_completion_id: Uuid,
11    pub course_module_id: Uuid,
12    /// Null when this platform registered the attainment itself instead of a third-party registrar.
13    pub study_registry_registrar_id: Option<Uuid>,
14    pub user_id: Uuid,
15    pub real_student_number: String,
16}
17
18#[derive(Clone, PartialEq, Deserialize, Serialize)]
19pub struct NewCourseModuleCompletionRegisteredToStudyRegistry {
20    pub course_id: Uuid,
21    pub course_module_completion_id: Uuid,
22    pub course_module_id: Uuid,
23    pub study_registry_registrar_id: Uuid,
24    pub user_id: Uuid,
25    pub real_student_number: String,
26}
27
28pub async fn insert(
29    conn: &mut PgConnection,
30    pkey_policy: PKeyPolicy<Uuid>,
31    new_completion_registration: &NewCourseModuleCompletionRegisteredToStudyRegistry,
32) -> ModelResult<Uuid> {
33    let res = sqlx::query!(
34        "
35INSERT INTO course_module_completion_registered_to_study_registries (
36    id,
37    course_id,
38    course_module_completion_id,
39    course_module_id,
40    study_registry_registrar_id,
41    user_id,
42    real_student_number
43  )
44VALUES (
45    $1,
46    $2,
47    $3,
48    $4,
49    $5,
50    $6,
51    $7
52)
53RETURNING *
54        ",
55        pkey_policy.into_uuid(),
56        new_completion_registration.course_id,
57        new_completion_registration.course_module_completion_id,
58        new_completion_registration.course_module_id,
59        new_completion_registration.study_registry_registrar_id,
60        new_completion_registration.user_id,
61        new_completion_registration.real_student_number,
62    )
63    .fetch_one(conn)
64    .await?;
65    Ok(res.id)
66}
67
68/// Like [insert], but a pre-existing registration for the same completion is left untouched
69/// instead of erroring, for seeding scripts that may rerun against already-seeded data.
70pub async fn insert_or_ignore(
71    conn: &mut PgConnection,
72    new_completion_registration: &NewCourseModuleCompletionRegisteredToStudyRegistry,
73) -> ModelResult<()> {
74    sqlx::query!(
75        "
76INSERT INTO course_module_completion_registered_to_study_registries (
77    course_id,
78    course_module_completion_id,
79    course_module_id,
80    study_registry_registrar_id,
81    user_id,
82    real_student_number
83  )
84VALUES ($1, $2, $3, $4, $5, $6)
85ON CONFLICT DO NOTHING
86        ",
87        new_completion_registration.course_id,
88        new_completion_registration.course_module_completion_id,
89        new_completion_registration.course_module_id,
90        new_completion_registration.study_registry_registrar_id,
91        new_completion_registration.user_id,
92        new_completion_registration.real_student_number,
93    )
94    .execute(conn)
95    .await?;
96    Ok(())
97}
98
99pub async fn insert_bulk(
100    conn: &mut PgConnection,
101    new_completion_registrations: Vec<NewCourseModuleCompletionRegisteredToStudyRegistry>,
102) -> ModelResult<Vec<Uuid>> {
103    if new_completion_registrations.is_empty() {
104        return Ok(vec![]);
105    }
106
107    let ids: Vec<Uuid> = (0..new_completion_registrations.len())
108        .map(|_| Uuid::new_v4())
109        .collect();
110    let course_ids: Vec<Uuid> = new_completion_registrations
111        .iter()
112        .map(|r| r.course_id)
113        .collect();
114    let completion_ids: Vec<Uuid> = new_completion_registrations
115        .iter()
116        .map(|r| r.course_module_completion_id)
117        .collect();
118    let module_ids: Vec<Uuid> = new_completion_registrations
119        .iter()
120        .map(|r| r.course_module_id)
121        .collect();
122    let registrar_ids: Vec<Uuid> = new_completion_registrations
123        .iter()
124        .map(|r| r.study_registry_registrar_id)
125        .collect();
126    let user_ids: Vec<Uuid> = new_completion_registrations
127        .iter()
128        .map(|r| r.user_id)
129        .collect();
130    let student_numbers: Vec<String> = new_completion_registrations
131        .iter()
132        .map(|r| r.real_student_number.clone())
133        .collect();
134
135    let res = sqlx::query!(
136        r#"
137INSERT INTO course_module_completion_registered_to_study_registries (
138    id,
139    course_id,
140    course_module_completion_id,
141    course_module_id,
142    study_registry_registrar_id,
143    user_id,
144    real_student_number
145)
146SELECT * FROM UNNEST(
147    $1::uuid[],
148    $2::uuid[],
149    $3::uuid[],
150    $4::uuid[],
151    $5::uuid[],
152    $6::uuid[],
153    $7::text[]
154)
155-- Matches cmc_registered_to_study_registries_completion_registrar_idx, so a registrar's repeated
156-- POST for a completion it already registered stays idempotent instead of erroring.
157ON CONFLICT (course_module_completion_id, study_registry_registrar_id) WHERE deleted_at IS NULL DO NOTHING
158RETURNING *
159        "#,
160        &ids[..],
161        &course_ids[..],
162        &completion_ids[..],
163        &module_ids[..],
164        &registrar_ids[..],
165        &user_ids[..],
166        &student_numbers[..],
167    )
168    .fetch_all(conn)
169    .await?;
170
171    Ok(res.into_iter().map(|r| r.id).collect())
172}
173
174#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, Debug)]
175/// An object representing that a completion has been registered to a study registry.
176pub struct RegisteredCompletion {
177    /// Id of the completion that was registered to the study registry.
178    pub completion_id: Uuid,
179    /// The student number the completion was registed to.
180    pub student_number: String,
181    /// The registration date that is visible in the study registry for the user.
182    pub registration_date: DateTime<Utc>,
183}
184
185pub async fn mark_completions_as_registered_to_study_registry(
186    conn: &mut PgConnection,
187    completions: Vec<RegisteredCompletion>,
188    study_registry_registrar_id: Uuid,
189) -> ModelResult<()> {
190    if completions.is_empty() {
191        return Ok(());
192    }
193
194    let ids: Vec<Uuid> = completions.iter().map(|x| x.completion_id).collect();
195    let completions_by_id = course_module_completions::get_by_ids_as_map(conn, &ids).await?;
196
197    let mut new_registrations = Vec::with_capacity(completions.len());
198    for completion in completions {
199        let module_completion = completions_by_id
200            .get(&completion.completion_id)
201            .ok_or_else(missing_model_error(
202                ModelErrorType::PreconditionFailed,
203                format!(
204                    "Cannot find completion with id: {}. This completion does not exist in the database.",
205                    completion.completion_id
206                ),
207            ))?;
208        new_registrations.push(NewCourseModuleCompletionRegisteredToStudyRegistry {
209            course_id: module_completion.course_id,
210            course_module_completion_id: completion.completion_id,
211            course_module_id: module_completion.course_module_id,
212            study_registry_registrar_id,
213            user_id: module_completion.user_id,
214            real_student_number: completion.student_number,
215        });
216    }
217
218    insert_bulk(conn, new_registrations).await?;
219
220    Ok(())
221}
222
223pub async fn get_by_id(
224    conn: &mut PgConnection,
225    id: Uuid,
226) -> ModelResult<CourseModuleCompletionRegisteredToStudyRegistry> {
227    let res = sqlx::query_as!(
228        CourseModuleCompletionRegisteredToStudyRegistry,
229        "
230SELECT *
231FROM course_module_completion_registered_to_study_registries
232WHERE id = $1
233  AND deleted_at IS NULL
234        ",
235        id,
236    )
237    .fetch_one(conn)
238    .await?;
239    Ok(res)
240}
241
242/// The row this platform registered itself for a completion, as opposed to one filed by a
243/// third-party registrar: a null `study_registry_registrar_id` is what marks a row as ours.
244pub async fn get_platform_registered_row_for_completion(
245    conn: &mut PgConnection,
246    completion_id: Uuid,
247) -> ModelResult<Option<CourseModuleCompletionRegisteredToStudyRegistry>> {
248    let res = sqlx::query_as!(
249        CourseModuleCompletionRegisteredToStudyRegistry,
250        "
251SELECT *
252FROM course_module_completion_registered_to_study_registries
253WHERE course_module_completion_id = $1
254  AND study_registry_registrar_id IS NULL
255  AND deleted_at IS NULL
256        ",
257        completion_id,
258    )
259    .fetch_optional(conn)
260    .await?;
261    Ok(res)
262}
263
264pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
265    sqlx::query!(
266        "
267UPDATE course_module_completion_registered_to_study_registries
268SET deleted_at = now()
269WHERE id = $1
270AND deleted_at IS NULL
271        ",
272        id
273    )
274    .execute(conn)
275    .await?;
276    Ok(())
277}
278
279/// Get the number of students that have completed the course
280pub async fn get_count_of_distinct_users_with_registrations_by_course_id(
281    conn: &mut PgConnection,
282    course_id: Uuid,
283) -> ModelResult<i64> {
284    let res = sqlx::query!(
285        "
286SELECT COUNT(DISTINCT user_id) as count
287FROM course_module_completion_registered_to_study_registries
288WHERE course_id = $1
289  AND deleted_at IS NULL
290",
291        course_id,
292    )
293    .fetch_one(conn)
294    .await?;
295    Ok(res.count.unwrap_or(0))
296}
297
298pub async fn get_by_completion_id_and_registrar_id(
299    conn: &mut PgConnection,
300    completion_id: Uuid,
301    study_registry_registrar_id: Uuid,
302) -> ModelResult<Vec<CourseModuleCompletionRegisteredToStudyRegistry>> {
303    let registrations = sqlx::query_as!(
304        CourseModuleCompletionRegisteredToStudyRegistry,
305        r#"
306        SELECT *
307        FROM course_module_completion_registered_to_study_registries
308        WHERE course_module_completion_id = $1 AND study_registry_registrar_id = $2
309        AND deleted_at IS NULL
310        "#,
311        completion_id,
312        study_registry_registrar_id
313    )
314    .fetch_all(conn)
315    .await?;
316
317    Ok(registrations)
318}
319
320/// Returns non-deleted registrations for a registrar scoped to the given completion ids.
321pub async fn get_by_registrar_id_and_completion_ids(
322    conn: &mut PgConnection,
323    study_registry_registrar_id: Uuid,
324    completion_ids: &[Uuid],
325) -> ModelResult<Vec<CourseModuleCompletionRegisteredToStudyRegistry>> {
326    let registrations = sqlx::query_as!(
327        CourseModuleCompletionRegisteredToStudyRegistry,
328        r#"
329SELECT *
330FROM course_module_completion_registered_to_study_registries
331WHERE study_registry_registrar_id = $1
332  AND course_module_completion_id = ANY($2)
333  AND deleted_at IS NULL
334        "#,
335        study_registry_registrar_id,
336        completion_ids
337    )
338    .fetch_all(conn)
339    .await?;
340
341    Ok(registrations)
342}
343
344#[derive(Clone, PartialEq, Deserialize, Serialize)]
345/// A registration's state without the registrar-supplied student number, for surfaces that must
346/// never expose it (see `real_student_number` on [CourseModuleCompletionRegisteredToStudyRegistry]).
347pub struct CourseModuleCompletionRegistrationState {
348    pub course_module_completion_id: Uuid,
349    pub course_module_id: Uuid,
350    pub study_registry_registrar_id: Option<Uuid>,
351    pub created_at: DateTime<Utc>,
352}
353
354/// Registration state for the given completions, across all registrars. Selects no student
355/// number, unlike [get_by_registrar_id_and_completion_ids].
356pub async fn get_registrations_by_completion_ids(
357    conn: &mut PgConnection,
358    completion_ids: &[Uuid],
359) -> ModelResult<Vec<CourseModuleCompletionRegistrationState>> {
360    let registrations = sqlx::query_as!(
361        CourseModuleCompletionRegistrationState,
362        r#"
363SELECT course_module_completion_id,
364  course_module_id,
365  study_registry_registrar_id,
366  created_at
367FROM course_module_completion_registered_to_study_registries
368WHERE course_module_completion_id = ANY($1)
369  AND deleted_at IS NULL
370        "#,
371        completion_ids
372    )
373    .fetch_all(conn)
374    .await?;
375
376    Ok(registrations)
377}
378
379/// Of the given completions, the ones some registrar has already registered.
380///
381/// Rows this platform registered itself carry no registrar and are not counted, so a grade
382/// improvement's second submission stays allowed.
383pub async fn completion_ids_registered_by_a_registrar(
384    conn: &mut PgConnection,
385    completion_ids: &[Uuid],
386) -> ModelResult<Vec<Uuid>> {
387    let ids = sqlx::query_scalar!(
388        r#"
389SELECT DISTINCT course_module_completion_id
390FROM course_module_completion_registered_to_study_registries
391WHERE course_module_completion_id = ANY($1::uuid [])
392  AND study_registry_registrar_id IS NOT NULL
393  AND deleted_at IS NULL
394        "#,
395        completion_ids,
396    )
397    .fetch_all(conn)
398    .await?;
399    Ok(ids)
400}
401
402#[cfg(test)]
403mod test {
404    use super::*;
405    use crate::{course_module_completions::CourseModuleCompletionGranter, test_helper::*};
406
407    #[tokio::test]
408    async fn bulk_insert_works() {
409        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
410
411        let registrar_id = crate::study_registry_registrars::insert(
412            tx.as_mut(),
413            PKeyPolicy::Generate,
414            "Test Registrar",
415            "test_123131231231231231231231231231238971283718927389172893718923712893129837189273891278317892378193971289",
416        )
417        .await
418        .unwrap();
419
420        let completion_1 = crate::course_module_completions::insert(
421            tx.as_mut(),
422            PKeyPolicy::Generate,
423            &crate::course_module_completions::NewCourseModuleCompletion {
424                course_id: course,
425                course_module_id: course_module.id,
426                user_id: user,
427                completion_date: Utc::now(),
428                completion_registration_attempt_date: None,
429                completion_language: "en-US".to_string(),
430                eligible_for_ects: true,
431                email: "test@example.com".to_string(),
432                grade: Some(10),
433                passed: true,
434            },
435            CourseModuleCompletionGranter::User(user),
436        )
437        .await
438        .unwrap();
439        let completion_2 = crate::course_module_completions::insert(
440            tx.as_mut(),
441            PKeyPolicy::Generate,
442            &crate::course_module_completions::NewCourseModuleCompletion {
443                course_id: course,
444                course_module_id: course_module.id,
445                user_id: user,
446                completion_date: Utc::now(),
447                completion_registration_attempt_date: None,
448                completion_language: "en-US".to_string(),
449                eligible_for_ects: true,
450                email: "test2@example.com".to_string(),
451                grade: Some(9),
452                passed: true,
453            },
454            CourseModuleCompletionGranter::User(user),
455        )
456        .await
457        .unwrap();
458
459        let registrations = vec![
460            NewCourseModuleCompletionRegisteredToStudyRegistry {
461                course_id: course,
462                course_module_completion_id: completion_1.id,
463                course_module_id: course_module.id,
464                study_registry_registrar_id: registrar_id,
465                user_id: user,
466                real_student_number: "12345".to_string(),
467            },
468            NewCourseModuleCompletionRegisteredToStudyRegistry {
469                course_id: course,
470                course_module_completion_id: completion_2.id,
471                course_module_id: course_module.id,
472                study_registry_registrar_id: registrar_id,
473                user_id: user,
474                real_student_number: "67890".to_string(),
475            },
476        ];
477
478        let inserted_ids = insert_bulk(tx.as_mut(), registrations).await.unwrap();
479        assert_eq!(inserted_ids.len(), 2);
480
481        let mut registered_completion_ids = Vec::new();
482        for id in inserted_ids {
483            let registration = get_by_id(tx.as_mut(), id).await.unwrap();
484            assert_eq!(registration.course_id, course);
485            assert_eq!(registration.course_module_id, course_module.id);
486            assert_eq!(registration.study_registry_registrar_id, Some(registrar_id));
487            assert_eq!(registration.user_id, user);
488            registered_completion_ids.push(registration.course_module_completion_id);
489        }
490        registered_completion_ids.sort();
491        let mut expected = vec![completion_1.id, completion_2.id];
492        expected.sort();
493        assert_eq!(registered_completion_ids, expected);
494    }
495
496    #[tokio::test]
497    async fn bulk_insert_empty_vec_works() {
498        insert_data!(:tx);
499
500        let empty_vec = vec![];
501        let result = insert_bulk(tx.as_mut(), empty_vec).await.unwrap();
502        assert!(result.is_empty());
503    }
504
505    #[tokio::test]
506    async fn insert_completions_works() {
507        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
508
509        let registrar_id = crate::study_registry_registrars::insert(
510            tx.as_mut(),
511            PKeyPolicy::Generate,
512            "Test Registrar",
513            "test_123131231231231231231231231231238971283718927389172893718923712893129837189273891278317892378193971289",
514        )
515        .await
516        .unwrap();
517
518        let completion = crate::course_module_completions::insert(
519            tx.as_mut(),
520            PKeyPolicy::Generate,
521            &crate::course_module_completions::NewCourseModuleCompletion {
522                course_id: course,
523                course_module_id: course_module.id,
524                user_id: user,
525                completion_date: Utc::now(),
526                completion_registration_attempt_date: None,
527                completion_language: "en-US".to_string(),
528                eligible_for_ects: true,
529                email: "test@example.com".to_string(),
530                grade: Some(5),
531                passed: true,
532            },
533            CourseModuleCompletionGranter::User(user),
534        )
535        .await
536        .unwrap();
537
538        let registered_completions = vec![RegisteredCompletion {
539            completion_id: completion.id,
540            student_number: "12345".to_string(),
541            registration_date: Utc::now(),
542        }];
543
544        mark_completions_as_registered_to_study_registry(
545            tx.as_mut(),
546            registered_completions,
547            registrar_id,
548        )
549        .await
550        .unwrap();
551
552        let registrations =
553            get_by_completion_id_and_registrar_id(tx.as_mut(), completion.id, registrar_id)
554                .await
555                .unwrap();
556
557        assert_eq!(registrations.len(), 1);
558        assert_eq!(registrations[0].course_id, course);
559        assert_eq!(registrations[0].course_module_id, course_module.id);
560        assert_eq!(registrations[0].user_id, user);
561        assert_eq!(registrations[0].real_student_number, "12345");
562    }
563
564    #[tokio::test]
565    async fn insert_completions_with_invalid_completion_id_fails() {
566        insert_data!(:tx);
567
568        let registrar_id = crate::study_registry_registrars::insert(
569            tx.as_mut(),
570            PKeyPolicy::Generate,
571            "Test Registrar",
572            "test_123131231231231231231231231231238971283718927389172893718923712893129837189273891278317892378193971289",
573        )
574        .await
575        .unwrap();
576
577        let invalid_uuid = Uuid::new_v4(); // This UUID doesn't correspond to any completion
578        let registered_completions = vec![RegisteredCompletion {
579            completion_id: invalid_uuid,
580            student_number: "12345".to_string(),
581            registration_date: Utc::now(),
582        }];
583
584        // Attempt to insert the completions should fail
585        let result = mark_completions_as_registered_to_study_registry(
586            tx.as_mut(),
587            registered_completions,
588            registrar_id,
589        )
590        .await;
591
592        assert!(result.is_err());
593        let error = result.unwrap_err();
594        assert_eq!(*error.error_type(), ModelErrorType::PreconditionFailed);
595        assert!(error.message().contains("Cannot find completion with id"));
596        assert!(error.message().contains(&invalid_uuid.to_string()));
597    }
598}