1use crate::credit_registrations::{
6 BatchMove, CreditRegistrationState, NewCreditRegistration, RegistrationScope, Transition,
7 mark_improvement_checked, mark_superseded, transition_batch,
8};
9use crate::prelude::*;
10
11use super::grade_mapping::{GradeComparison, GradeSource, compare_grades, map_grade};
12
13pub const MATERIALIZE_LIMIT: i64 = 500;
15
16pub const GRADE_IMPROVEMENT_LIMIT: i64 = 200;
19
20pub async fn ensure_registration_rows_for_eligible_completions(
23 conn: &mut PgConnection,
24 scope: &RegistrationScope,
25 limit: i64,
26) -> ModelResult<i64> {
27 let created = sqlx::query_scalar!(
30 r#"
31WITH registrable_completion AS (
32 SELECT uuid_generate_v4() AS id,
33 c.course_module_completion_id,
34 c.user_id,
35 c.course_id,
36 c.course_module_id,
37 enrolment.course_instance_id
38 FROM credit_registration_registrable_completions c
39 -- The completion does not name an instance, and the ledger row must. An inner join, so a
40 -- completion whose enrolment was removed is skipped rather than guessed at.
41 JOIN LATERAL (
42 SELECT cie.course_instance_id
43 FROM course_instance_enrollments cie
44 WHERE cie.user_id = c.user_id
45 AND cie.course_id = c.course_id
46 AND cie.deleted_at IS NULL
47 ORDER BY cie.created_at DESC
48 LIMIT 1
49 ) enrolment ON TRUE
50 WHERE ($2::uuid IS NULL OR c.course_id = $2)
51 AND ($3::uuid IS NULL OR c.user_id = $3)
52 ORDER BY c.created_at
53 LIMIT $1
54),
55inserted AS (
56 INSERT INTO credit_registrations (
57 id,
58 course_module_completion_id,
59 user_id,
60 course_id,
61 course_module_id,
62 course_instance_id,
63 request_item_id
64 )
65 SELECT id,
66 course_module_completion_id,
67 user_id,
68 course_id,
69 course_module_id,
70 course_instance_id,
71 'cr-' || id
72 FROM registrable_completion ON CONFLICT DO NOTHING
73 RETURNING id
74),
75events AS (
76 INSERT INTO credit_registration_events (credit_registration_id, kind, to_state, message)
77 SELECT id,
78 'created',
79 'pending',
80 'Created for an eligible completion.'
81 FROM inserted
82 RETURNING credit_registration_id
83)
84SELECT COUNT(*) AS "created!"
85FROM events
86 "#,
87 limit,
88 scope.course_id,
89 scope.user_id,
90 )
91 .fetch_one(conn)
92 .await?;
93 Ok(created)
94}
95
96#[derive(Debug, Clone, PartialEq)]
98pub struct UnmaterialisedCompletion {
99 pub course_module_completion_id: Uuid,
100 pub user_id: Uuid,
101 pub first_name: Option<String>,
102 pub last_name: Option<String>,
103 pub email: Option<String>,
104 pub course_id: Uuid,
105 pub course_name: String,
106 pub course_module_id: Uuid,
107 pub course_module_name: Option<String>,
108 pub completion_date: DateTime<Utc>,
109 pub created_at: DateTime<Utc>,
110 pub missing_enrolment: bool,
113}
114
115pub async fn get_unmaterialised_eligible_completions(
123 conn: &mut PgConnection,
124 min_age_secs: i64,
125 limit: i64,
126) -> ModelResult<Vec<UnmaterialisedCompletion>> {
127 let res = sqlx::query_as!(
128 UnmaterialisedCompletion,
129 r#"
130SELECT rc.course_module_completion_id AS "course_module_completion_id!",
131 rc.user_id AS "user_id!",
132 ud.first_name AS "first_name?",
133 ud.last_name AS "last_name?",
134 ud.email AS "email?",
135 rc.course_id AS "course_id!",
136 c.name AS course_name,
137 rc.course_module_id AS "course_module_id!",
138 cm.name AS course_module_name,
139 rc.completion_date AS "completion_date!",
140 rc.created_at AS "created_at!",
141 NOT EXISTS (
142 SELECT 1
143 FROM course_instance_enrollments cie
144 WHERE cie.user_id = rc.user_id
145 AND cie.course_id = rc.course_id
146 AND cie.deleted_at IS NULL
147 ) AS "missing_enrolment!"
148FROM credit_registration_registrable_completions rc
149 JOIN course_modules cm ON cm.id = rc.course_module_id
150 JOIN courses c ON c.id = rc.course_id
151 LEFT JOIN user_details ud ON ud.user_id = rc.user_id
152WHERE rc.created_at < now() - MAKE_INTERVAL(secs => $1::double precision)
153ORDER BY rc.created_at
154LIMIT $2
155 "#,
156 min_age_secs as f64,
157 limit,
158 )
159 .fetch_all(conn)
160 .await?;
161 Ok(res)
162}
163
164pub async fn start_re_attempts_for_improved_grades(
172 conn: &mut PgConnection,
173 scope: &RegistrationScope,
174 limit: i64,
175) -> ModelResult<i64> {
176 let mut tx = conn.begin().await?;
177 let candidates = sqlx::query!(
178 r#"
179SELECT cr.id,
180 cr.attempt_number,
181 cr.course_module_completion_id,
182 cr.user_id,
183 cr.course_id,
184 cr.course_module_id,
185 cr.course_instance_id,
186 cr.grade_scale_id AS "registered_grade_scale_id!",
187 cr.grade_id AS "registered_grade_id!",
188 cmc.passed,
189 cmc.grade,
190 cmc.updated_at AS completion_updated_at,
191 conf.grade_scale_id AS "configured_grade_scale_id?"
192FROM credit_registrations cr
193 JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id
194 -- Membership is the whole eligibility check: the view is the module opt-in and the completion
195 -- being live, passed and ECTS-eligible, and fully_eligible the prerequisites and the review.
196 JOIN credit_registration_eligible_completions e ON e.course_module_completion_id = cr.course_module_completion_id
197 AND e.fully_eligible
198 LEFT JOIN course_module_suotar_configurations conf ON conf.course_module_id = cr.course_module_id
199 AND conf.deleted_at IS NULL
200WHERE cr.deleted_at IS NULL
201 AND cr.superseded_by_id IS NULL
202 -- The success set only: a row whose outcome we do not know must not gain a successor.
203 AND cr.state = ANY($4::credit_registration_state [])
204 AND cr.grade_scale_id IS NOT NULL
205 AND cr.grade_id IS NOT NULL
206 -- Two halves of one cheap pre-filter. A completion untouched since the attempt was created cannot
207 -- have been regraded after that attempt froze its grade; but one touched for any other reason
208 -- passes that test forever, and only the grade comparison below can tell the two apart, which is
209 -- why the loop stamps the watermark on every candidate it declines. Without the second half the
210 -- limit keeps spending itself on the same rows while a real regrade waits behind them.
211 AND cmc.updated_at > cr.created_at
212 AND (
213 cr.improvement_checked_completion_updated_at IS NULL
214 OR cmc.updated_at > cr.improvement_checked_completion_updated_at
215 )
216 AND ($2::uuid IS NULL OR cr.course_id = $2)
217 AND ($3::uuid IS NULL OR cr.user_id = $3)
218ORDER BY cmc.updated_at FOR
219UPDATE OF cr SKIP LOCKED
220LIMIT $1
221 "#,
222 limit,
223 scope.course_id,
224 scope.user_id,
225 &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
226 )
227 .fetch_all(&mut *tx)
228 .await?;
229
230 let mut starts = Vec::new();
231 for candidate in candidates {
232 let looked_at = candidate.completion_updated_at;
236 let Ok(mapped) = map_grade(GradeSource {
237 passed: candidate.passed,
238 grade: candidate.grade,
239 configured_grade_scale_id: candidate.configured_grade_scale_id.as_deref(),
240 enrolment_grade_scale_id: None,
243 }) else {
244 mark_improvement_checked(&mut tx, candidate.id, looked_at).await?;
245 continue;
246 };
247 if compare_grades(
248 &candidate.registered_grade_scale_id,
249 &candidate.registered_grade_id,
250 &mapped,
251 ) != GradeComparison::Better
252 {
253 mark_improvement_checked(&mut tx, candidate.id, looked_at).await?;
254 continue;
255 }
256 let next = Uuid::new_v4();
261 mark_superseded(&mut tx, candidate.id, next).await?;
262 crate::credit_registrations::insert(
263 &mut tx,
264 PKeyPolicy::Fixed(next),
265 &NewCreditRegistration {
266 course_module_completion_id: candidate.course_module_completion_id,
267 user_id: candidate.user_id,
268 course_id: candidate.course_id,
269 course_module_id: candidate.course_module_id,
270 course_instance_id: candidate.course_instance_id,
271 attempt_number: candidate.attempt_number + 1,
272 },
273 Some(&format!(
274 "The completion's grade rose from {} to {}, so the registered attempt was \
275 superseded.",
276 candidate.registered_grade_id, mapped.grade_id
277 )),
278 )
279 .await?;
280 starts.push(BatchMove {
284 id: next,
285 transition: Transition::to(CreditRegistrationState::ReadyToSubmit),
286 });
287 }
288 let started = transition_batch(&mut tx, &starts).await?;
289 tx.commit().await?;
290 Ok(started)
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use crate::course_module_completion_registered_to_study_registries::NewCourseModuleCompletionRegisteredToStudyRegistry;
297 use crate::course_module_completions::{
298 CourseModuleCompletionGranter, NewCourseModuleCompletion,
299 };
300 use crate::credit_registrations::{CreditRegistrationState, import_request_item_id};
301 use crate::test_helper::*;
302
303 async fn enable_suotar(
304 conn: &mut PgConnection,
305 course_module: &crate::course_modules::CourseModule,
306 ) {
307 crate::course_modules::update(
308 conn,
309 course_module.id,
310 &crate::course_modules::NewCourseModule::new(
311 course_module.course_id,
312 course_module.name.clone(),
313 course_module.order_number,
314 )
315 .set_enable_credit_registration_via_suotar(true),
316 )
317 .await
318 .unwrap();
319 }
320
321 async fn add_completion(
322 conn: &mut PgConnection,
323 course: Uuid,
324 course_module: Uuid,
325 course_instance: Uuid,
326 user: Uuid,
327 passed: bool,
328 eligible_for_ects: bool,
329 ) -> Uuid {
330 crate::course_instance_enrollments::insert(conn, user, course, course_instance)
331 .await
332 .unwrap();
333 crate::course_module_completions::insert(
334 conn,
335 PKeyPolicy::Generate,
336 &NewCourseModuleCompletion {
337 course_id: course,
338 course_module_id: course_module,
339 user_id: user,
340 completion_date: Utc::now(),
341 completion_registration_attempt_date: None,
342 completion_language: "en".to_string(),
343 eligible_for_ects,
344 email: "student@example.com".to_string(),
345 grade: Some(4),
346 passed,
347 },
348 CourseModuleCompletionGranter::Automatic,
349 )
350 .await
351 .unwrap()
352 .id
353 }
354
355 #[tokio::test]
356 async fn a_completion_on_an_enabled_module_gets_a_row_addressed_by_its_own_id() {
357 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
358 enable_suotar(tx.as_mut(), &course_module).await;
359 add_completion(
360 tx.as_mut(),
361 course,
362 course_module.id,
363 instance.id,
364 user,
365 true,
366 true,
367 )
368 .await;
369
370 let created = ensure_registration_rows_for_eligible_completions(
371 tx.as_mut(),
372 &RegistrationScope::default(),
373 MATERIALIZE_LIMIT,
374 )
375 .await
376 .unwrap();
377 assert_eq!(created, 1);
378
379 let rows = crate::credit_registrations::get_by_course_id(tx.as_mut(), course)
380 .await
381 .unwrap();
382 assert_eq!(rows.len(), 1);
383 assert_eq!(rows[0].state, CreditRegistrationState::Pending);
384 assert_eq!(rows[0].request_item_id, import_request_item_id(rows[0].id));
385
386 let events =
387 crate::credit_registration_events::get_by_registration_id(tx.as_mut(), rows[0].id)
388 .await
389 .unwrap();
390 assert_eq!(events.len(), 1);
391 }
392
393 #[tokio::test]
394 async fn running_twice_creates_nothing_the_second_time() {
395 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
396 enable_suotar(tx.as_mut(), &course_module).await;
397 add_completion(
398 tx.as_mut(),
399 course,
400 course_module.id,
401 instance.id,
402 user,
403 true,
404 true,
405 )
406 .await;
407
408 let scope = RegistrationScope::default();
409 assert_eq!(
410 ensure_registration_rows_for_eligible_completions(
411 tx.as_mut(),
412 &scope,
413 MATERIALIZE_LIMIT
414 )
415 .await
416 .unwrap(),
417 1
418 );
419 assert_eq!(
420 ensure_registration_rows_for_eligible_completions(
421 tx.as_mut(),
422 &scope,
423 MATERIALIZE_LIMIT
424 )
425 .await
426 .unwrap(),
427 0
428 );
429 }
430
431 #[tokio::test]
432 async fn a_module_that_was_never_opted_in_materialises_nothing() {
433 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
434 add_completion(
435 tx.as_mut(),
436 course,
437 course_module.id,
438 instance.id,
439 user,
440 true,
441 true,
442 )
443 .await;
444 assert_eq!(
445 ensure_registration_rows_for_eligible_completions(
446 tx.as_mut(),
447 &RegistrationScope::default(),
448 MATERIALIZE_LIMIT
449 )
450 .await
451 .unwrap(),
452 0
453 );
454 }
455
456 #[tokio::test]
457 async fn a_failed_or_ects_ineligible_completion_never_gets_a_row() {
458 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
459 enable_suotar(tx.as_mut(), &course_module).await;
460 add_completion(
461 tx.as_mut(),
462 course,
463 course_module.id,
464 instance.id,
465 user,
466 false,
467 true,
468 )
469 .await;
470
471 insert_data!(tx: tx; user: other_user);
472 add_completion(
473 tx.as_mut(),
474 course,
475 course_module.id,
476 instance.id,
477 other_user,
478 true,
479 false,
480 )
481 .await;
482
483 assert_eq!(
484 ensure_registration_rows_for_eligible_completions(
485 tx.as_mut(),
486 &RegistrationScope::default(),
487 MATERIALIZE_LIMIT
488 )
489 .await
490 .unwrap(),
491 0
492 );
493 }
494
495 #[tokio::test]
496 async fn a_completion_the_pull_path_already_registered_is_skipped() {
497 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
498 enable_suotar(tx.as_mut(), &course_module).await;
499 let completion = add_completion(
500 tx.as_mut(),
501 course,
502 course_module.id,
503 instance.id,
504 user,
505 true,
506 true,
507 )
508 .await;
509 let registrar = crate::study_registry_registrars::insert(
510 tx.as_mut(),
511 PKeyPolicy::Generate,
512 "Test registrar",
513 "test-registrar-secret-key",
514 )
515 .await
516 .unwrap();
517 crate::course_module_completion_registered_to_study_registries::insert(
518 tx.as_mut(),
519 PKeyPolicy::Generate,
520 &NewCourseModuleCompletionRegisteredToStudyRegistry {
521 course_id: course,
522 course_module_completion_id: completion,
523 course_module_id: course_module.id,
524 study_registry_registrar_id: registrar,
525 user_id: user,
526 real_student_number: "012345678".to_string(),
527 },
528 )
529 .await
530 .unwrap();
531
532 assert_eq!(
533 ensure_registration_rows_for_eligible_completions(
534 tx.as_mut(),
535 &RegistrationScope::default(),
536 MATERIALIZE_LIMIT
537 )
538 .await
539 .unwrap(),
540 0
541 );
542 }
543
544 #[tokio::test]
545 async fn a_scoped_run_leaves_another_courses_completions_alone() {
546 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
547 enable_suotar(tx.as_mut(), &course_module).await;
548 add_completion(
549 tx.as_mut(),
550 course,
551 course_module.id,
552 instance.id,
553 user,
554 true,
555 true,
556 )
557 .await;
558
559 let elsewhere = Uuid::new_v4();
560 assert_eq!(
561 ensure_registration_rows_for_eligible_completions(
562 tx.as_mut(),
563 &RegistrationScope::for_course(elsewhere),
564 MATERIALIZE_LIMIT
565 )
566 .await
567 .unwrap(),
568 0
569 );
570 assert_eq!(
571 ensure_registration_rows_for_eligible_completions(
572 tx.as_mut(),
573 &RegistrationScope::for_course(course),
574 MATERIALIZE_LIMIT
575 )
576 .await
577 .unwrap(),
578 1
579 );
580 }
581
582 #[tokio::test]
583 async fn the_limit_bounds_one_iteration() {
584 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
585 enable_suotar(tx.as_mut(), &course_module).await;
586 add_completion(
587 tx.as_mut(),
588 course,
589 course_module.id,
590 instance.id,
591 user,
592 true,
593 true,
594 )
595 .await;
596 insert_data!(tx: tx; user: second_user);
597 add_completion(
598 tx.as_mut(),
599 course,
600 course_module.id,
601 instance.id,
602 second_user,
603 true,
604 true,
605 )
606 .await;
607
608 let scope = RegistrationScope::default();
609 assert_eq!(
610 ensure_registration_rows_for_eligible_completions(tx.as_mut(), &scope, 1)
611 .await
612 .unwrap(),
613 1
614 );
615 assert_eq!(
616 ensure_registration_rows_for_eligible_completions(tx.as_mut(), &scope, 1)
617 .await
618 .unwrap(),
619 1
620 );
621 }
622}