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