1use chrono::{Duration, Months, NaiveDate};
6
7use crate::prelude::*;
8
9use super::ids;
10use super::wire::{
11 self, AttainmentReference, AttainmentSummary, COURSE_NOT_CARRIED, CourseAllowedResult,
12 DuplicateAttainmentResult, Endpoint, EnrolmentResolutionResult, EnrolmentsListedResult,
13 ExistingAttainment, ExistingAttainmentsResult, ListedEnrolment, ListedPerson,
14 NotImprovedAttainmentResult, RecentlySentAttainment, RecentlySentDuplicateResult,
15 RegisteredResult, ResponseItem, SubmissionPendingResult, SubmittedAttainment, iso_millis,
16 sisu_midnight,
17};
18use super::world::{
19 AttainmentState, EnrolmentState, ImporterVisibility, MockAttainment, MockCourseUnit,
20 MockEnrolment, MockRealisation, MockStudyRight, MockSubmission, PENDING_WINDOW_HOURS,
21 REFUSED_COURSE_CODES, SendState, WorkingSet, WorldWrite, person_course_key,
22};
23
24const NO_ACCEPTORS_VIOLATION: &str = "Approver is required.";
26
27pub fn resolve_person_item(
28 item: &wire::ResolvePersonRequestItem,
29 working: &WorkingSet,
30) -> ResponseItem {
31 match working.persons.get(&item.student_number) {
32 Some(person) => ResponseItem::ok(
33 &item.request_item_id,
34 "personFound",
35 wire::PersonResult {
36 student_number: person.student_number.clone(),
37 person_id: person.person_id.clone(),
38 first_names: person.first_names.clone(),
39 last_name: person.last_name.clone(),
40 },
41 ),
42 None => ResponseItem::error(
43 Endpoint::ResolvePersons,
44 &item.request_item_id,
45 "personNotFound",
46 ),
47 }
48}
49
50pub fn resolve_enrolments_item(
51 item: &wire::ResolveEnrolmentRequestItem,
52 working: &WorkingSet,
53) -> ResponseItem {
54 let endpoint = Endpoint::ResolveEnrolments;
55 let id = &item.request_item_id;
56 if !working.persons.contains_key(&item.student_number) {
57 return ResponseItem::error(endpoint, id, "personNotFound");
58 }
59 let Some(course_unit) = working.course_units.get(&item.course_code) else {
60 return ResponseItem::error(endpoint, id, "courseCodeNotFound");
61 };
62
63 let mut existing: Vec<&MockAttainment> =
65 attainments_for(working, &item.student_number, &item.course_code)
66 .into_iter()
67 .filter(|attainment| attainment.state != AttainmentState::Misregistered)
68 .collect();
69 existing.sort_by(|a, b| {
70 a.attainment_date
71 .cmp(&b.attainment_date)
72 .then_with(|| a.id.cmp(&b.id))
73 });
74 let existing_attainments: Vec<ExistingAttainment> =
75 existing.into_iter().map(existing_attainment).collect();
76
77 let mut enrolled: Vec<&MockEnrolment> =
79 enrolments_for(working, &item.student_number, &item.course_code)
80 .into_iter()
81 .filter(|enrolment| enrolment.state == EnrolmentState::Enrolled)
82 .collect();
83 enrolled.sort_by_key(|enrolment| enrolment.enrolment_date_time);
84 let enrolments: Vec<wire::Enrolment> = enrolled
85 .into_iter()
86 .filter_map(|enrolment| {
87 course_unit
88 .realisation(&enrolment.realisation_id)
89 .map(|realisation| enrolment_dto(course_unit, enrolment, realisation))
90 })
91 .collect();
92 if enrolments.is_empty() {
93 return ResponseItem::error(endpoint, id, "enrolmentNotFound").with_result(
94 ExistingAttainmentsResult {
95 existing_attainments,
96 },
97 );
98 }
99
100 ResponseItem::ok(
101 id,
102 "enrolmentFound",
103 EnrolmentResolutionResult {
104 enrolments,
105 existing_attainments,
106 },
107 )
108}
109
110pub enum ImportResolution {
112 Answered(ResponseItem),
113 Write(Box<MockSubmission>),
115}
116
117pub fn resolve_import_item(
119 item: &wire::ImportAttainmentRequestItem,
120 working: &WorkingSet,
121 now: DateTime<Utc>,
122) -> ImportResolution {
123 let endpoint = Endpoint::ImportAttainments;
124 let id = &item.request_item_id;
125 let error = |code: &str| ImportResolution::Answered(ResponseItem::error(endpoint, id, code));
126 let error_with_message = |code: &str, message: String| {
127 ImportResolution::Answered(ResponseItem::error_with_message(id, code, message))
128 };
129
130 let course_unit = working.course_units.get(&item.course_code);
131 if let Some(reason) = course_not_allowed_reason(&item.course_code, course_unit) {
132 return error_with_message("courseNotAllowed", reason);
133 }
134 let Some(course_unit) = course_unit else {
135 return error_with_message("courseNotAllowed", COURSE_NOT_CARRIED.to_string());
136 };
137 let Some(person) = working.persons.get(&item.student_number) else {
138 return error("personNotFound");
139 };
140 let enrolment = working
141 .enrolments
142 .get(&item.enrolment_id)
143 .filter(|enrolment| {
144 enrolment.student_number == item.student_number
145 && enrolment.course_code == item.course_code
146 && enrolment.state == EnrolmentState::Enrolled
147 });
148 let Some((enrolment, realisation)) = enrolment.and_then(|enrolment| {
149 course_unit
150 .realisation(&enrolment.realisation_id)
151 .map(|realisation| (enrolment, realisation))
152 }) else {
153 return error("enrolmentNotFound");
154 };
155
156 let Some(credits) = &course_unit.credits else {
157 return error_with_message(
158 "invalidCredits",
159 format!(
160 "Sisu gives no credit range for course {}.",
161 item.course_code
162 ),
163 );
164 };
165 if item.credits < credits.min || credits.max.is_none_or(|max| item.credits > max) {
167 return error_with_message(
168 "invalidCredits",
169 format!(
170 "Credits must be between {} and {} for course {}.",
171 credits.min,
172 credits
173 .max
174 .map_or_else(|| "null".to_string(), |max| max.to_string()),
175 item.course_code
176 ),
177 );
178 }
179
180 let grade_scale_id = course_unit.grade_scale_for(realisation);
181 if let Some(expected) = &grade_scale_id
182 && &item.grade_scale_id != expected
183 {
184 return error_with_message(
185 "gradeScaleMismatch",
186 format!(
187 "Grade scale {} was sent, but the enrolment is graded on {expected}.",
188 item.grade_scale_id
189 ),
190 );
191 }
192 let is_known_grade = grade_scale_id
193 .as_deref()
194 .and_then(|scale_id| working.defaults.scale(scale_id))
195 .is_some_and(|scale| scale.grade(&item.grade_id).is_some());
196 if !is_known_grade {
197 return error("invalidGradeForGradeScale");
198 }
199
200 let incoming = IncomingGrade::of(&item.grade_scale_id, &item.grade_id);
201 let mut earlier: Vec<&MockAttainment> =
202 attainments_for(working, &item.student_number, &item.course_code)
203 .into_iter()
204 .filter(|attainment| attainment.state != AttainmentState::Misregistered)
205 .collect();
206 earlier.sort_by_key(|attainment| std::cmp::Reverse(attainment.attainment_date));
207 if let Some(newest) = earlier.first()
209 && earlier
210 .iter()
211 .any(|attainment| is_identical(attainment, incoming, item, working))
212 {
213 return ImportResolution::Answered(ResponseItem::ok(
214 id,
215 "duplicateAttainment",
216 DuplicateAttainmentResult {
217 attainment: attainment_summary(newest),
218 },
219 ));
220 }
221 if let Some(recent) = recently_accepted(working, item, now) {
222 return ImportResolution::Answered(ResponseItem::ok(
223 id,
224 "duplicateAttainment",
225 RecentlySentDuplicateResult {
226 attainment: RecentlySentAttainment {
227 id: recent.submitted_attainment_id.clone(),
228 attainment_type: wire::ASSESSMENT_ITEM_ATTAINMENT.to_string(),
229 attainment_date: recent.adjusted_attainment_date,
230 grade_scale_id: recent.grade_scale_id.clone(),
231 grade_id: recent.grade_id.clone(),
232 },
233 },
234 ));
235 }
236 if let Some(newest) = earlier.first()
237 && !earlier
238 .iter()
239 .all(|attainment| beats(incoming, attainment, item, working))
240 {
241 return ImportResolution::Answered(ResponseItem::ok(
242 id,
243 "notImprovedAttainment",
244 NotImprovedAttainmentResult {
245 previous_attainment: attainment_summary(newest),
246 },
247 ));
248 }
249
250 let adjusted_attainment_date = match &enrolment.study_right {
251 Some(study_right) => clamp_into_study_right(item.attainment_date(), study_right),
252 None if person.behaviour.study_right_unresolvable => return error("studyRightNotValid"),
253 None => item.attainment_date(),
254 };
255
256 ImportResolution::Write(Box::new(MockSubmission {
257 submitted_attainment_id: ids::submitted_attainment_id(),
258 request_item_id: id.clone(),
259 student_number: item.student_number.clone(),
260 course_code: item.course_code.clone(),
261 enrolment_id: enrolment.id.clone(),
262 realisation_id: realisation.id.clone(),
263 person_id: person.person_id.clone(),
264 course_unit_id: course_unit.course_unit_id.clone(),
265 assessment_item_id: realisation.assessment_item_id.clone(),
266 attainment_date: item.attainment_date(),
267 adjusted_attainment_date,
268 attainment_language: item.attainment_language.clone(),
269 grade_scale_id: item.grade_scale_id.clone(),
270 grade_id: item.grade_id.clone(),
271 credits: item.credits,
272 send_state: SendState::NotSent,
273 violations: Vec::new(),
274 importer: ImporterVisibility::None,
275 created_at: now,
276 }))
277}
278
279pub(super) const RECENTLY_ACCEPTED_HOURS: i64 = 24;
282
283fn recently_accepted<'a>(
286 working: &'a WorkingSet,
287 item: &wire::ImportAttainmentRequestItem,
288 now: DateTime<Utc>,
289) -> Option<&'a MockSubmission> {
290 working
291 .submissions_by_person_course
292 .get(&person_course_key(&item.student_number, &item.course_code))
293 .into_iter()
294 .flatten()
295 .filter_map(|id| working.submissions.get(id))
296 .find(|submission| {
297 submission.send_state == SendState::Accepted
298 && submission.created_at >= now - Duration::hours(RECENTLY_ACCEPTED_HOURS)
299 && submission.credits == item.credits
300 && submission.grade_scale_id == item.grade_scale_id
301 && submission.grade_id == item.grade_id
302 })
303}
304
305pub fn acceptor_lookup_fails(working: &WorkingSet, submissions: &[MockSubmission]) -> bool {
307 submissions.iter().any(|submission| {
308 working
309 .course_units
310 .get(&submission.course_code)
311 .is_some_and(|unit| unit.behaviour.acceptor_lookup_fails)
312 })
313}
314
315pub fn write_and_send(working: &mut WorkingSet, mut submission: MockSubmission) -> ResponseItem {
318 let mut violations = working
319 .sisu_violations
320 .get(&person_course_key(
321 &submission.student_number,
322 &submission.course_code,
323 ))
324 .cloned()
325 .unwrap_or_default();
326 if working
327 .course_units
328 .get(&submission.course_code)
329 .is_some_and(|unit| unit.behaviour.no_acceptors)
330 {
331 violations.push(NO_ACCEPTORS_VIOLATION.to_string());
332 }
333 submission.send_state = if violations.is_empty() {
334 SendState::Accepted
335 } else {
336 SendState::Rejected
337 };
338 submission.violations = violations;
339 let outcome = import_outcome(&submission);
340 record_submission(working, submission);
341 outcome
342}
343
344pub fn import_outcome(submission: &MockSubmission) -> ResponseItem {
346 let id = &submission.request_item_id;
347 match submission.send_state {
348 SendState::Rejected => {
349 let message = if submission.violations.is_empty() {
350 "Sisu rejected the attainment.".to_string()
351 } else {
352 format!(
353 "Sisu rejected the attainment: {}",
354 submission.violations.join("; ")
355 )
356 };
357 ResponseItem::error_with_message(id, "sisuValidationFailed", message)
358 }
359 SendState::Accepted => ResponseItem::ok(
360 id,
361 "sent",
362 SubmittedAttainment::new(&submission.submitted_attainment_id),
363 ),
364 SendState::Attempted | SendState::NotSent => {
365 ResponseItem::error(Endpoint::ImportAttainments, id, "sisuTimeout").with_result(
366 SubmittedAttainment::new(&submission.submitted_attainment_id),
367 )
368 }
369 }
370}
371
372pub fn verify_item(
373 item: &wire::VerifyAttainmentRequestItem,
374 working: &WorkingSet,
375 now: DateTime<Utc>,
376) -> ResponseItem {
377 let endpoint = Endpoint::VerifyAttainments;
378 let id = &item.request_item_id;
379 let registered = |attainment_id: &str, attainment_type: &str| {
380 ResponseItem::ok(
381 id,
382 "registered",
383 RegisteredResult {
384 attainment: AttainmentReference {
385 id: attainment_id.to_string(),
386 attainment_type: attainment_type.to_string(),
387 },
388 },
389 )
390 };
391
392 if let Some(submission) = working.submissions.get(&item.submitted_attainment_id) {
393 return match &submission.importer {
394 ImporterVisibility::Misregistered { .. } => {
395 ResponseItem::error(endpoint, id, "misregistered")
396 }
397 ImporterVisibility::Partial { attainment_id } => {
398 registered(attainment_id, wire::ASSESSMENT_ITEM_ATTAINMENT)
399 }
400 ImporterVisibility::Final { attainment_id } => {
401 registered(attainment_id, wire::COURSE_UNIT_ATTAINMENT)
402 }
403 ImporterVisibility::None if is_pending(submission, now) => {
404 ResponseItem::error(endpoint, id, "submissionPending").with_result(
405 SubmissionPendingResult {
406 submitted_attainment_id: submission.submitted_attainment_id.clone(),
407 submitted_attainment_type: wire::ASSESSMENT_ITEM_ATTAINMENT.to_string(),
408 retry_after: iso_millis(retry_after(submission, now)),
409 },
410 )
411 }
412 ImporterVisibility::None => ResponseItem::error(endpoint, id, "notRegistered"),
413 };
414 }
415 match working.attainments.get(&item.submitted_attainment_id) {
416 Some(attainment) if attainment.state == AttainmentState::Misregistered => {
417 ResponseItem::error(endpoint, id, "misregistered")
418 }
419 Some(attainment) => registered(&attainment.id, &attainment.attainment_type),
420 None => ResponseItem::error(endpoint, id, "notRegistered"),
421 }
422}
423
424fn is_pending(submission: &MockSubmission, now: DateTime<Utc>) -> bool {
426 match submission.send_state {
427 SendState::Accepted => true,
428 SendState::Attempted => submission.created_at > now - Duration::hours(PENDING_WINDOW_HOURS),
429 SendState::NotSent | SendState::Rejected => false,
430 }
431}
432
433fn retry_after(submission: &MockSubmission, now: DateTime<Utc>) -> DateTime<Utc> {
434 let scheduled = submission.created_at + Duration::hours(PENDING_WINDOW_HOURS);
435 if scheduled > now {
436 scheduled
437 } else {
438 now + Duration::hours(PENDING_WINDOW_HOURS)
439 }
440}
441
442pub fn list_by_course_item(
445 item: &wire::CourseCodeRequestItem,
446 working: &WorkingSet,
447 now: DateTime<Utc>,
448) -> ResponseItem {
449 let id = &item.request_item_id;
450 let not_found = || ResponseItem::error(Endpoint::ListByCourse, id, "courseCodeNotFound");
451 let Some(course_unit) = working.course_units.get(&item.course_code) else {
452 return not_found();
453 };
454 let cutoff = (now - Months::new(2)).date_naive();
455 let current: Vec<&MockRealisation> = course_unit
456 .realisations
457 .iter()
458 .filter(|realisation| {
459 realisation
460 .activity_period
461 .as_ref()
462 .is_some_and(|period| period.end_date.is_some_and(|end| end > cutoff))
463 })
464 .collect();
465 if current.is_empty() {
466 return not_found();
467 }
468
469 let people = current
470 .into_iter()
471 .flat_map(|realisation| {
472 let mut enrolments: Vec<&MockEnrolment> = working
473 .enrolments_by_realisation
474 .get(&realisation.id)
475 .into_iter()
476 .flatten()
477 .filter_map(|enrolment_id| working.enrolments.get(enrolment_id))
478 .filter(|enrolment| enrolment.state == EnrolmentState::Enrolled)
479 .collect();
480 enrolments.sort_by(|a, b| a.student_number.cmp(&b.student_number));
481 enrolments
482 })
483 .filter_map(|enrolment| {
484 let person = working.persons.get(&enrolment.student_number)?;
485 Some(ListedPerson {
486 student_number: person.student_number.clone(),
487 person_id: person.person_id.clone(),
488 first_names: person.first_names.clone(),
489 last_name: person.last_name.clone(),
490 primary_email: person.primary_email.clone(),
491 secondary_email: person.secondary_email.clone(),
492 enrolment: ListedEnrolment {
493 id: enrolment.id.clone(),
494 course_unit_realisation_id: enrolment.realisation_id.clone(),
495 state: "ENROLLED".to_string(),
496 enrolment_date_time: enrolment.enrolment_date_time.map(iso_millis),
497 },
498 })
499 })
500 .collect();
501
502 ResponseItem::ok(id, "enrolmentsListed", EnrolmentsListedResult { people })
503}
504
505pub fn validate_course_code_item(
506 item: &wire::CourseCodeRequestItem,
507 working: &WorkingSet,
508) -> ResponseItem {
509 let course_unit = working.course_units.get(&item.course_code);
510 match (
511 course_not_allowed_reason(&item.course_code, course_unit),
512 course_unit.and_then(|unit| unit.suotar_course.as_ref()),
513 ) {
514 (None, Some(course)) => ResponseItem::ok(
515 &item.request_item_id,
516 "courseAllowed",
517 CourseAllowedResult {
518 course_code: item.course_code.clone(),
519 name: course.name.clone(),
520 },
521 ),
522 (reason, _) => ResponseItem::error_with_message(
523 &item.request_item_id,
524 "courseNotAllowed",
525 reason.unwrap_or_else(|| COURSE_NOT_CARRIED.to_string()),
526 ),
527 }
528}
529
530fn course_not_allowed_reason(
531 course_code: &str,
532 course_unit: Option<&MockCourseUnit>,
533) -> Option<String> {
534 if REFUSED_COURSE_CODES.contains(&course_code) {
535 return Some(format!(
536 "{course_code} cannot be registered through this API yet."
537 ));
538 }
539 if course_unit
540 .and_then(|unit| unit.suotar_course.as_ref())
541 .is_none()
542 {
543 return Some(COURSE_NOT_CARRIED.to_string());
544 }
545 None
546}
547
548pub fn record_submission(working: &mut WorkingSet, submission: MockSubmission) {
549 let id = submission.submitted_attainment_id.clone();
550 let key = person_course_key(&submission.student_number, &submission.course_code);
551 let write = WorldWrite::IndexSubmission {
552 student_number: submission.student_number.clone(),
553 course_code: submission.course_code.clone(),
554 };
555 working.submissions.insert(id.clone(), submission);
556 let index = working.submissions_by_person_course.entry(key).or_default();
557 if !index.contains(&id) {
558 index.push(id.clone());
559 }
560 working.writes.push(WorldWrite::UpsertSubmission(id));
561 working.writes.push(write);
562}
563
564fn clamp_into_study_right(date: NaiveDate, study_right: &MockStudyRight) -> NaiveDate {
567 let validity = &study_right.validity;
568 let mut adjusted = match validity.end_date {
569 _ if date <= validity.start_date => validity.start_date,
570 Some(end_date) if date >= end_date => end_date - Duration::days(1),
571 _ => date,
572 };
573 if let Some(grant_date) = study_right.grant_date
574 && grant_date > validity.start_date
575 && validity
576 .end_date
577 .is_none_or(|end_date| grant_date < end_date)
578 && adjusted < grant_date
579 {
580 adjusted = grant_date;
581 }
582 adjusted
583}
584
585#[derive(Debug, Clone, Copy, PartialEq, Eq)]
587enum IncomingGrade {
588 Numeric(i32),
589 Pass,
590 Fail,
591}
592
593impl IncomingGrade {
594 fn of(grade_scale_id: &str, grade_id: &str) -> Self {
595 match (grade_scale_id, grade_id.parse::<i32>().ok()) {
596 ("sis-0-5", Some(value @ 1..=5)) => Self::Numeric(value),
597 ("sis-hyl-hyv", Some(1)) => Self::Pass,
598 _ => Self::Fail,
599 }
600 }
601}
602
603fn numeric_grade(attainment: &MockAttainment) -> i32 {
605 if attainment.grade_scale_id == "sis-0-5" {
606 attainment.grade_id.parse().unwrap_or(0)
607 } else {
608 0
609 }
610}
611
612fn is_passed(attainment: &MockAttainment, working: &WorkingSet) -> bool {
613 attainment.passed.unwrap_or_else(|| {
614 working
615 .defaults
616 .scale(&attainment.grade_scale_id)
617 .and_then(|scale| scale.grade(&attainment.grade_id))
618 .is_some_and(|grade| grade.passed)
619 })
620}
621
622fn credits_of(attainment: &MockAttainment, item: &wire::ImportAttainmentRequestItem) -> f64 {
623 attainment.credits.unwrap_or(item.credits)
624}
625
626fn is_identical(
627 attainment: &MockAttainment,
628 incoming: IncomingGrade,
629 item: &wire::ImportAttainmentRequestItem,
630 working: &WorkingSet,
631) -> bool {
632 let same_grade = match incoming {
633 IncomingGrade::Numeric(value) => {
634 attainment.grade_scale_id == "sis-0-5" && numeric_grade(attainment) == value
635 }
636 IncomingGrade::Pass => {
637 attainment.grade_scale_id == "sis-hyl-hyv" && attainment.grade_id == "1"
638 }
639 IncomingGrade::Fail => !is_passed(attainment, working),
640 };
641 same_grade
642 && attainment.attainment_date == item.attainment_date()
643 && credits_of(attainment, item) == item.credits
644}
645
646fn beats(
649 incoming: IncomingGrade,
650 attainment: &MockAttainment,
651 item: &wire::ImportAttainmentRequestItem,
652 working: &WorkingSet,
653) -> bool {
654 let credits = credits_of(attainment, item);
655 let later_than =
656 |days: i64| item.attainment_date() - Duration::days(days) > attainment.attainment_date;
657 let tie_break = |days: i64| {
658 if credits != item.credits {
659 credits < item.credits
660 } else {
661 later_than(days)
662 }
663 };
664 match incoming {
665 IncomingGrade::Numeric(value) => {
666 let existing = numeric_grade(attainment);
667 if existing != value {
668 existing < value
669 } else {
670 tie_break(1)
671 }
672 }
673 IncomingGrade::Pass => !is_passed(attainment, working) || tie_break(0),
674 IncomingGrade::Fail => !is_passed(attainment, working) && tie_break(0),
675 }
676}
677
678fn attainments_for<'a>(
679 working: &'a WorkingSet,
680 student_number: &str,
681 course_code: &str,
682) -> Vec<&'a MockAttainment> {
683 working
684 .attainments_by_person_course
685 .get(&person_course_key(student_number, course_code))
686 .into_iter()
687 .flatten()
688 .filter_map(|id| working.attainments.get(id))
689 .collect()
690}
691
692fn existing_attainment(attainment: &MockAttainment) -> ExistingAttainment {
693 ExistingAttainment {
694 id: attainment.id.clone(),
695 attainment_type: attainment.attainment_type.clone(),
696 state: attainment.state.wire_state().to_string(),
697 person_id: attainment.person_id.clone(),
698 course_unit_id: attainment.course_unit_id.clone(),
699 assessment_item_id: attainment.assessment_item_id.clone(),
700 course_unit_realisation_id: attainment.course_unit_realisation_id.clone(),
701 attainment_date: sisu_midnight(attainment.attainment_date),
702 registration_date: sisu_midnight(attainment.registration_date),
703 grade_scale_id: attainment.grade_scale_id.clone(),
704 grade_id: attainment.grade_id.clone(),
705 passed: attainment.passed,
706 }
707}
708
709fn attainment_summary(attainment: &MockAttainment) -> AttainmentSummary {
710 AttainmentSummary {
711 id: attainment.id.clone(),
712 attainment_type: attainment.attainment_type.clone(),
713 state: attainment.state.wire_state().to_string(),
714 attainment_date: sisu_midnight(attainment.attainment_date),
715 registration_date: sisu_midnight(attainment.registration_date),
716 grade_scale_id: attainment.grade_scale_id.clone(),
717 grade_id: attainment.grade_id.clone(),
718 }
719}
720
721fn enrolments_for<'a>(
722 working: &'a WorkingSet,
723 student_number: &str,
724 course_code: &str,
725) -> Vec<&'a MockEnrolment> {
726 working
727 .enrolments_by_person
728 .get(student_number)
729 .into_iter()
730 .flatten()
731 .filter_map(|id| working.enrolments.get(id))
732 .filter(|enrolment| enrolment.course_code == course_code)
733 .collect()
734}
735
736fn enrolment_dto(
737 course_unit: &MockCourseUnit,
738 enrolment: &MockEnrolment,
739 realisation: &MockRealisation,
740) -> wire::Enrolment {
741 wire::Enrolment {
742 id: enrolment.id.clone(),
743 state: "ENROLLED".to_string(),
744 kind: enrolment.kind().to_string(),
745 course_unit_id: course_unit.course_unit_id.clone(),
746 assessment_item_id: realisation.assessment_item_id.clone(),
747 course_unit_realisation_id: realisation.id.clone(),
748 course_unit_realisation_name: realisation.name.clone(),
749 activity_period: realisation.activity_period.clone(),
750 grade_scale_id: course_unit.grade_scale_for(realisation),
751 credits: course_unit.credits.clone(),
752 study_right_id: enrolment.study_right_id.clone(),
753 study_right_validity_period: enrolment
754 .study_right
755 .as_ref()
756 .map(|study_right| study_right.validity.clone()),
757 enrolment_date_time: enrolment.enrolment_date_time.map(iso_millis),
758 }
759}
760
761#[cfg(test)]
762mod tests {
763 use super::super::world::{
764 CourseBehaviour, CreditRange, DatePeriod, LocalizedName, MockPerson, PersonBehaviour,
765 RealisationKind, SuotarCourse,
766 };
767 use super::*;
768
769 const STUDENT_NUMBER: &str = "090001019";
770 const COURSE_CODE: &str = "CRS-101";
771
772 fn world() -> WorkingSet {
773 let now = Utc::now();
774 let period = DatePeriod {
775 start_date: (now - Duration::days(30)).date_naive(),
776 end_date: Some((now + Duration::days(30)).date_naive()),
777 };
778 let name = LocalizedName {
779 fi: COURSE_CODE.to_string(),
780 sv: COURSE_CODE.to_string(),
781 en: COURSE_CODE.to_string(),
782 };
783 let realisation = MockRealisation {
784 id: ids::realisation_id(COURSE_CODE, RealisationKind::Degree),
785 name: Some(name.clone()),
786 assessment_item_id: ids::assessment_item_id(COURSE_CODE, RealisationKind::Degree),
787 kind: RealisationKind::Degree,
788 activity_period: Some(period.clone()),
789 grade_scale_id: None,
790 };
791 let enrolment = MockEnrolment {
792 id: ids::enrolment_id(STUDENT_NUMBER, COURSE_CODE, RealisationKind::Degree),
793 student_number: STUDENT_NUMBER.to_string(),
794 course_code: COURSE_CODE.to_string(),
795 realisation_id: realisation.id.clone(),
796 state: EnrolmentState::Enrolled,
797 study_right_id: Some(ids::study_right_id(STUDENT_NUMBER, RealisationKind::Degree)),
798 study_right: Some(MockStudyRight {
799 validity: period,
800 grant_date: None,
801 }),
802 enrolment_date_time: Some(now),
803 };
804 WorkingSet {
805 persons: [(
806 STUDENT_NUMBER.to_string(),
807 MockPerson {
808 student_number: STUDENT_NUMBER.to_string(),
809 person_id: ids::person_id(STUDENT_NUMBER),
810 first_names: Some("Zzyzx".to_string()),
811 last_name: Some("Happypath".to_string()),
812 primary_email: Some("zzyzx.happypath@helsinki.example.com".to_string()),
813 secondary_email: None,
814 behaviour: PersonBehaviour::default(),
815 owner_user_email: None,
816 },
817 )]
818 .into(),
819 course_units: [(
820 COURSE_CODE.to_string(),
821 MockCourseUnit {
822 course_code: COURSE_CODE.to_string(),
823 course_unit_id: ids::course_unit_id(COURSE_CODE),
824 name,
825 credits: Some(CreditRange {
826 min: 5.0,
827 max: Some(5.0),
828 }),
829 grade_scale_id: Some("sis-hyl-hyv".to_string()),
830 realisations: vec![realisation],
831 suotar_course: Some(SuotarCourse {
832 name: COURSE_CODE.to_string(),
833 }),
834 behaviour: CourseBehaviour::default(),
835 owner_course_slug: None,
836 },
837 )]
838 .into(),
839 enrolments: [(enrolment.id.clone(), enrolment)].into(),
840 ..Default::default()
841 }
842 }
843
844 #[test]
847 fn an_import_queues_its_submission_before_any_response_shaping() {
848 let mut working = world();
849 let item = wire::ImportAttainmentRequestItem {
850 request_item_id: "item-1".to_string(),
851 student_number: STUDENT_NUMBER.to_string(),
852 course_code: COURSE_CODE.to_string(),
853 enrolment_id: ids::enrolment_id(STUDENT_NUMBER, COURSE_CODE, RealisationKind::Degree),
854 attained_at: Utc::now(),
855 attainment_language: "en".to_string(),
856 grade_scale_id: "sis-hyl-hyv".to_string(),
857 grade_id: "1".to_string(),
858 credits: 5.0,
859 };
860 let ImportResolution::Write(submission) = resolve_import_item(&item, &working, Utc::now())
861 else {
862 panic!("a well-formed import resolves to a write");
863 };
864 let id = submission.submitted_attainment_id.clone();
865 let response = write_and_send(&mut working, *submission);
866 assert_eq!(response.code, "sent");
867 assert!(working.submissions.contains_key(&id));
868 assert!(
869 working
870 .writes
871 .contains(&WorldWrite::UpsertSubmission(id.clone()))
872 );
873 }
874}