Skip to main content

headless_lms_models/course_module_suotar_configurations/
listing.rs

1//! Enrolment discovery's per-module listing: which modules a roster request covers, and what the
2//! last listing of each found.
3
4use crate::credit_registrations::CreditRegistrationErrorCode;
5use crate::prelude::*;
6
7/// Outcome counters for one enrolment-discovery run over one module. Written whole, so the
8/// dashboard never mixes two runs.
9#[derive(Debug, Clone, PartialEq, Default)]
10pub struct ModuleListingOutcome {
11    pub listed_person_count: i32,
12    pub already_linked_count: i32,
13    pub mailed_count: i32,
14    pub suppressed_by_dedup_count: i32,
15    pub suppressed_by_rate_cap_count: i32,
16    pub no_address_count: i32,
17}
18
19/// An active module and the facts a `list-by-course` request for it needs.
20#[derive(Debug, Clone, PartialEq)]
21pub struct ModuleToList {
22    pub course_module_id: Uuid,
23    pub course_id: Uuid,
24    pub uh_course_code: String,
25    /// The language the mails this listing sets off are written in.
26    pub course_language_code: String,
27}
28
29/// Every active, listable module of one course, unpaginated.
30pub async fn get_active_modules_for_course(
31    conn: &mut PgConnection,
32    course_id: Uuid,
33) -> ModelResult<Vec<ModuleToList>> {
34    let res = sqlx::query_as!(
35        ModuleToList,
36        r#"
37SELECT acm.course_module_id AS "course_module_id!",
38  acm.course_id AS "course_id!",
39  TRIM(cm.uh_course_code) AS "uh_course_code!",
40  co.language_code AS "course_language_code!"
41FROM credit_registration_active_course_modules acm
42  JOIN course_modules cm ON cm.id = acm.course_module_id AND cm.deleted_at IS NULL
43  JOIN courses co ON co.id = acm.course_id AND co.deleted_at IS NULL
44WHERE TRIM(COALESCE(cm.uh_course_code, '')) <> ''
45  AND acm.course_id = $1
46ORDER BY cm.order_number,
47  cm.id
48        "#,
49        course_id,
50    )
51    .fetch_all(conn)
52    .await?;
53    Ok(res)
54}
55
56/// One active module's counters from its last successful discovery run, and whether the attempts
57/// since then have been failing. Point-in-time, not a windowed sum: the phase overwrites the row
58/// whole, so every surface rendering them has to say so.
59#[derive(Debug, Clone, PartialEq)]
60pub struct ModuleDiscoveryReport {
61    pub course_id: Uuid,
62    pub course_name: String,
63    pub course_module_id: Uuid,
64    pub course_module_name: Option<String>,
65    pub uh_course_code: Option<String>,
66    pub last_listed_at: Option<DateTime<Utc>>,
67    pub last_listed_person_count: Option<i32>,
68    pub last_already_linked_count: Option<i32>,
69    pub last_mailed_count: Option<i32>,
70    pub last_suppressed_by_dedup_count: Option<i32>,
71    pub last_suppressed_by_rate_cap_count: Option<i32>,
72    pub last_no_address_count: Option<i32>,
73    pub last_listing_attempted_at: Option<DateTime<Utc>>,
74    pub last_listing_error: Option<CreditRegistrationErrorCode>,
75    pub consecutive_listing_failures: i32,
76}
77
78/// Every active module's last discovery counters, for the account-linking dashboard.
79pub async fn get_active_discovery_reports(
80    conn: &mut PgConnection,
81) -> ModelResult<Vec<ModuleDiscoveryReport>> {
82    let res = sqlx::query_as!(
83        ModuleDiscoveryReport,
84        r#"
85SELECT cm.course_id,
86  c.name AS course_name,
87  conf.course_module_id,
88  cm.name AS course_module_name,
89  cm.uh_course_code,
90  conf.last_listed_at,
91  conf.last_listed_person_count,
92  conf.last_already_linked_count,
93  conf.last_mailed_count,
94  conf.last_suppressed_by_dedup_count,
95  conf.last_suppressed_by_rate_cap_count,
96  conf.last_no_address_count,
97  conf.last_listing_attempted_at,
98  conf.last_listing_error AS "last_listing_error?",
99  conf.consecutive_listing_failures
100FROM course_module_suotar_configurations conf
101  JOIN credit_registration_active_course_modules acm ON acm.course_module_id = conf.course_module_id
102  JOIN course_modules cm ON cm.id = conf.course_module_id AND cm.deleted_at IS NULL
103  JOIN courses c ON c.id = cm.course_id AND c.deleted_at IS NULL
104WHERE conf.deleted_at IS NULL
105  AND c.deleted_at IS NULL
106ORDER BY c.name,
107  cm.order_number
108        "#,
109    )
110    .fetch_all(conn)
111    .await?;
112    Ok(res)
113}
114
115/// Records a listing attempt whose roster never arrived. `last_listed_at` and the counters keep
116/// describing the last roster that did, because zeroing them would make a failed listing read as an
117/// empty course.
118pub async fn mark_listing_failed(
119    conn: &mut PgConnection,
120    course_module_id: Uuid,
121    error: CreditRegistrationErrorCode,
122) -> ModelResult<()> {
123    sqlx::query!(
124        r#"
125UPDATE course_module_suotar_configurations
126SET last_listing_attempted_at = now(),
127  last_listing_error = $2,
128  consecutive_listing_failures = consecutive_listing_failures + 1
129WHERE course_module_id = $1
130  AND deleted_at IS NULL
131        "#,
132        course_module_id,
133        error as CreditRegistrationErrorCode,
134    )
135    .execute(conn)
136    .await?;
137    Ok(())
138}
139
140/// Records a listing whose roster arrived while account linking is switched off: clears the failure
141/// streak, but leaves `last_listed_at` and the counters describing the last run that fed linking.
142/// Sibling of [`record_listing_outcome`].
143pub async fn mark_listing_succeeded_without_linking(
144    conn: &mut PgConnection,
145    course_module_id: Uuid,
146) -> ModelResult<()> {
147    sqlx::query!(
148        r#"
149UPDATE course_module_suotar_configurations
150SET last_listing_attempted_at = now(),
151  last_listing_error = NULL,
152  consecutive_listing_failures = 0
153WHERE course_module_id = $1
154  AND deleted_at IS NULL
155        "#,
156        course_module_id,
157    )
158    .execute(conn)
159    .await?;
160    Ok(())
161}
162
163/// Records a listing whose roster arrived: overwrites every counter and clears the failure streak.
164/// Sibling of [`mark_listing_failed`].
165pub async fn record_listing_outcome(
166    conn: &mut PgConnection,
167    course_module_id: Uuid,
168    outcome: &ModuleListingOutcome,
169) -> ModelResult<()> {
170    sqlx::query!(
171        r#"
172UPDATE course_module_suotar_configurations
173SET last_listed_at = now(),
174  last_listing_attempted_at = now(),
175  last_listing_error = NULL,
176  consecutive_listing_failures = 0,
177  last_listed_person_count = $2,
178  last_already_linked_count = $3,
179  last_mailed_count = $4,
180  last_suppressed_by_dedup_count = $5,
181  last_suppressed_by_rate_cap_count = $6,
182  last_no_address_count = $7
183WHERE course_module_id = $1
184  AND deleted_at IS NULL
185        "#,
186        course_module_id,
187        outcome.listed_person_count,
188        outcome.already_linked_count,
189        outcome.mailed_count,
190        outcome.suppressed_by_dedup_count,
191        outcome.suppressed_by_rate_cap_count,
192        outcome.no_address_count,
193    )
194    .execute(conn)
195    .await?;
196    Ok(())
197}