1use headless_lms_authorization::Action;
2use std::collections::HashMap;
3use std::str::FromStr;
4
5use indexmap::IndexMap;
6
7use headless_lms_models::chatbot_configurations::ToolCategory;
8use headless_lms_models::{
9 certificate_configurations, course_module_completion_registered_to_study_registries,
10 course_module_completions::{self, CourseModuleCompletion},
11 course_modules, exercise_reset_logs,
12 exercise_slide_submissions::{self, UserCourseSubmissionTime},
13 exercises::{self, Exercise},
14 generated_certificates,
15 library::progressing::{self, UserModuleCompletionStatus},
16 peer_review_queue_entries, study_registry_registrars,
17 teacher_grading_decisions::{self, TeacherDecisionType},
18 user_details, user_exercise_states,
19 user_exercise_states::{ReviewingStage, UserCourseProgress},
20};
21use headless_lms_utils::json_schema_types::{
22 JSONType, JsonItem, Schema, SchemaPropertyType, string_array_property,
23};
24
25use crate::{
26 azure_chatbot::azure::tools::{AzureLLMFunctionToolDefinition, LLMToolType},
27 chatbot_tools::{
28 ChatbotTool, ChatbotToolDeclaration, ToolProperties,
29 argument_parsing::deserialize_to_optional_uuid_and_errors_to_none,
30 certificate_validation_url, output_limits::CappedList, search_url,
31 tool_authorization::ToolRequirement,
32 },
33 prelude::*,
34 user_context::ChatbotTurnContext,
35};
36
37pub type UserCourseStateTool = ToolProperties<UserCourseStateState>;
38
39pub struct UserCourseStateState {
40 output: UserCourseStateOutput,
41 base_url: String,
42 user_id: Uuid,
43 course_id: Uuid,
44}
45
46#[derive(Serialize)]
47struct UserCourseStateOutput {
48 user_email: String,
49 course_name: String,
50 #[serde(flatten)]
51 facets: IndexMap<String, UserCourseStateFacetValue>,
52}
53
54#[derive(Serialize)]
55#[serde(untagged)]
56enum UserCourseStateFacetValue {
57 Progress(Vec<UserCourseProgress>),
58 Completions(CompletionsFacet),
59 Submissions(SubmissionsFacet),
60 Reviews(ReviewsFacet),
61 Resets(ResetsFacet),
62 Certificates(CertificatesFacet),
63 CreditRegistration(CreditRegistrationFacet),
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69enum UserCourseStateFacet {
70 Progress,
71 Completions,
72 Submissions,
73 Reviews,
74 Resets,
75 Certificates,
76 CreditRegistration,
77}
78
79impl UserCourseStateFacet {
80 const ALL_WIRE_NAMES: &'static [&'static str] = &[
81 "progress",
82 "completions",
83 "submissions",
84 "reviews",
85 "resets",
86 "certificates",
87 "credit_registration",
88 ];
89
90 fn from_wire(s: &str) -> Option<Self> {
91 match s {
92 "progress" => Some(Self::Progress),
93 "completions" => Some(Self::Completions),
94 "submissions" => Some(Self::Submissions),
95 "reviews" => Some(Self::Reviews),
96 "resets" => Some(Self::Resets),
97 "certificates" => Some(Self::Certificates),
98 "credit_registration" => Some(Self::CreditRegistration),
99 _ => None,
100 }
101 }
102
103 fn wire_name(self) -> &'static str {
104 match self {
105 Self::Progress => "progress",
106 Self::Completions => "completions",
107 Self::Submissions => "submissions",
108 Self::Reviews => "reviews",
109 Self::Resets => "resets",
110 Self::Certificates => "certificates",
111 Self::CreditRegistration => "credit_registration",
112 }
113 }
114}
115
116fn parse_facets(raw: &[String]) -> ChatbotResult<Vec<UserCourseStateFacet>> {
117 if raw.is_empty() {
118 return Err(chatbot_err!(
119 InvalidToolArguments,
120 "facets must not be empty. Valid facets: progress, completions, submissions, reviews, resets, certificates, credit_registration.".to_string()
121 ));
122 }
123 let mut seen = std::collections::HashSet::new();
124 let mut facets = Vec::new();
125 for wire in raw {
126 let facet = UserCourseStateFacet::from_wire(wire).ok_or_else(|| {
127 chatbot_err!(
128 InvalidToolArguments,
129 format!(
130 "Unknown facet '{wire}'. Valid facets: {}.",
131 UserCourseStateFacet::ALL_WIRE_NAMES.join(", ")
132 )
133 )
134 })?;
135 if seen.insert(facet) {
136 facets.push(facet);
137 }
138 }
139 Ok(facets)
140}
141
142#[derive(Deserialize)]
143struct RawArguments {
144 user_id: String,
145 course_id: String,
146 facets: Vec<String>,
147 #[serde(deserialize_with = "deserialize_to_optional_uuid_and_errors_to_none")]
148 exercise_id: Option<Uuid>,
149}
150
151pub struct UserCourseStateArguments {
152 user_id: Uuid,
153 course_id: Uuid,
154 exercise_id: Option<Uuid>,
155 facets: Vec<UserCourseStateFacet>,
156}
157
158impl<'de> Deserialize<'de> for UserCourseStateArguments {
162 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
163 where
164 D: serde::Deserializer<'de>,
165 {
166 let raw = RawArguments::deserialize(deserializer)?;
167 build_arguments(raw).map_err(serde::de::Error::custom)
168 }
169}
170
171fn build_arguments(raw: RawArguments) -> ChatbotResult<UserCourseStateArguments> {
172 let user_id = Uuid::from_str(&raw.user_id).map_err(|e| {
173 chatbot_err!(
174 InvalidToolArguments,
175 format!("'{}' is not a valid user_id.", raw.user_id),
176 e
177 )
178 })?;
179 let course_id = Uuid::from_str(&raw.course_id).map_err(|e| {
180 chatbot_err!(
181 InvalidToolArguments,
182 format!("'{}' is not a valid course_id.", raw.course_id),
183 e
184 )
185 })?;
186 let facets = parse_facets(&raw.facets)?;
187 Ok(UserCourseStateArguments {
188 user_id,
189 course_id,
190 exercise_id: raw.exercise_id,
191 facets,
192 })
193}
194
195impl ChatbotToolDeclaration for UserCourseStateTool {
196 const NAME: &'static str = "user_course_state";
197
198 fn offer_requirements(user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
199 vec![ToolRequirement::on_turn(
200 Action::ViewUserProgressOrDetails,
201 user_context,
202 )]
203 }
204
205 const CATEGORY: ToolCategory = ToolCategory::AdminSupportLearningProgress;
206
207 fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
208 AzureLLMFunctionToolDefinition {
209 tool_type: LLMToolType::Function,
210 name: Self::NAME.to_string(),
211 description: "Get a user's state on a course: progress, module completions, submission timeline, peer/self/teacher review status, exercise resets, certificate eligibility, or credit registration status. Pick one or more facets. Resolve user_id with find_user and course_id with find_course first.".to_string(),
212 parameters: Schema::strict_object(
213 IndexMap::from([
214 (
215 "user_id".to_string(),
216 SchemaPropertyType::Item(JsonItem {
217 type_field: JSONType::String,
218 description: Some("The target user's id (UUID). Resolve it with find_user first.".to_string()),
219 }),
220 ),
221 (
222 "course_id".to_string(),
223 SchemaPropertyType::Item(JsonItem {
224 type_field: JSONType::String,
225 description: Some("The course's id (UUID). Resolve it with find_course first.".to_string()),
226 }),
227 ),
228 (
229 "facets".to_string(),
230 string_array_property(Some(
231 "Which facets of the user's course state to fetch. One or more of: progress, completions, submissions, reviews, resets, certificates, credit_registration.",
232 )),
233 ),
234 (
235 "exercise_id".to_string(),
236 SchemaPropertyType::Item(JsonItem {
237 type_field: JSONType::String,
238 description: Some("Optional. Pass an empty string for the whole course; pass an exercise UUID to narrow the submissions and reviews facets to that exercise.".to_string()),
239 }),
240 ),
241 ]),
242 None,
243 ),
244 strict: true,
245 }
246 }
247}
248
249impl ChatbotTool for UserCourseStateTool {
250 type Arguments = UserCourseStateArguments;
251
252 fn call_requirements(
253 arguments: &Self::Arguments,
254 _user_context: &ChatbotTurnContext,
255 ) -> Vec<ToolRequirement> {
256 vec![ToolRequirement::on_course(
257 Action::ViewUserProgressOrDetails,
258 arguments.course_id,
259 )]
260 }
261
262 fn parse_arguments(args_string: String) -> ChatbotResult<Self::Arguments> {
263 let raw: RawArguments = serde_json::from_str(&args_string).map_err(|e| {
264 chatbot_err!(
265 InvalidToolArguments,
266 format!("Couldn't parse tool arguments. Arguments: {args_string}"),
267 e
268 )
269 })?;
270 build_arguments(raw)
271 }
272
273 async fn from_db_and_arguments(
274 conn: &mut PgConnection,
275 app_config: &ApplicationConfiguration,
276 arguments: Self::Arguments,
277 _user_context: &ChatbotTurnContext,
278 ) -> ChatbotResult<Self> {
279 let base_url = app_config.base_url.trim_end_matches('/').to_string();
280 let course = headless_lms_models::courses::get_course(conn, arguments.course_id)
281 .await
282 .map_err(|e| {
283 chatbot_err!(
284 InvalidToolArguments,
285 format!("No course found with id {}.", arguments.course_id),
286 e
287 )
288 })?;
289 let user_detail = user_details::get_user_details_by_user_id(conn, arguments.user_id)
290 .await
291 .map_err(|e| {
292 chatbot_err!(
293 InvalidToolArguments,
294 format!("No user found with id {}.", arguments.user_id),
295 e
296 )
297 })?;
298
299 if let Some(exercise_id) = arguments.exercise_id {
300 let exercise = exercises::get_by_id(conn, exercise_id).await.map_err(|e| {
301 chatbot_err!(
302 InvalidToolArguments,
303 format!("No exercise found with id {exercise_id}."),
304 e
305 )
306 })?;
307 if exercise.course_id != Some(arguments.course_id) {
308 return Err(chatbot_err!(
309 InvalidToolArguments,
310 format!(
311 "Exercise {exercise_id} does not belong to course {}.",
312 arguments.course_id
313 )
314 ));
315 }
316 }
317
318 let mut facets = IndexMap::new();
319
320 let course_exercises = if arguments.facets.iter().any(|f| {
323 matches!(
324 f,
325 UserCourseStateFacet::Submissions | UserCourseStateFacet::Reviews
326 )
327 }) {
328 Some(exercises::get_exercises_by_course_id(conn, arguments.course_id).await?)
329 } else {
330 None
331 };
332 let completions = if arguments.facets.iter().any(|f| {
333 matches!(
334 f,
335 UserCourseStateFacet::Completions
336 | UserCourseStateFacet::Certificates
337 | UserCourseStateFacet::CreditRegistration
338 )
339 }) {
340 Some(
341 course_module_completions::get_all_by_course_id_and_user_id(
342 conn,
343 arguments.course_id,
344 arguments.user_id,
345 )
346 .await?,
347 )
348 } else {
349 None
350 };
351
352 for facet in &arguments.facets {
353 let value = match facet {
354 UserCourseStateFacet::Progress => UserCourseStateFacetValue::Progress(
355 progress_facet(conn, arguments.user_id, arguments.course_id).await?,
356 ),
357 UserCourseStateFacet::Completions => {
358 let completions = completions.as_ref().ok_or_else(|| {
359 chatbot_err!(
360 ToolUseError,
361 "expected completions to have been prefetched".to_string()
362 )
363 })?;
364 UserCourseStateFacetValue::Completions(
365 completions_facet(
366 conn,
367 arguments.user_id,
368 arguments.course_id,
369 completions,
370 )
371 .await?,
372 )
373 }
374 UserCourseStateFacet::Submissions => {
375 let course_exercises = course_exercises.as_ref().ok_or_else(|| {
376 chatbot_err!(
377 ToolUseError,
378 "expected course_exercises to have been prefetched".to_string()
379 )
380 })?;
381 UserCourseStateFacetValue::Submissions(
382 submissions_facet(
383 conn,
384 arguments.user_id,
385 arguments.course_id,
386 arguments.exercise_id,
387 course_exercises,
388 )
389 .await?,
390 )
391 }
392 UserCourseStateFacet::Reviews => {
393 let course_exercises = course_exercises.as_ref().ok_or_else(|| {
394 chatbot_err!(
395 ToolUseError,
396 "expected course_exercises to have been prefetched".to_string()
397 )
398 })?;
399 UserCourseStateFacetValue::Reviews(
400 reviews_facet(
401 conn,
402 arguments.user_id,
403 arguments.course_id,
404 arguments.exercise_id,
405 course_exercises,
406 )
407 .await?,
408 )
409 }
410 UserCourseStateFacet::Resets => UserCourseStateFacetValue::Resets(
411 resets_facet(conn, arguments.user_id, arguments.course_id).await?,
412 ),
413 UserCourseStateFacet::Certificates => {
414 let completions = completions.as_ref().ok_or_else(|| {
415 chatbot_err!(
416 ToolUseError,
417 "expected completions to have been prefetched".to_string()
418 )
419 })?;
420 UserCourseStateFacetValue::Certificates(
421 certificates_facet(
422 conn,
423 arguments.user_id,
424 arguments.course_id,
425 completions,
426 &base_url,
427 )
428 .await?,
429 )
430 }
431 UserCourseStateFacet::CreditRegistration => {
432 let completions = completions.as_ref().ok_or_else(|| {
433 chatbot_err!(
434 ToolUseError,
435 "expected completions to have been prefetched".to_string()
436 )
437 })?;
438 UserCourseStateFacetValue::CreditRegistration(
439 credit_registration_facet(conn, completions).await?,
440 )
441 }
442 };
443 facets.insert(facet.wire_name().to_string(), value);
444 }
445
446 Ok(UserCourseStateTool {
447 state: UserCourseStateState {
448 output: UserCourseStateOutput {
449 user_email: user_detail.email.clone(),
450 course_name: course.name.clone(),
451 facets,
452 },
453 base_url,
454 user_id: arguments.user_id,
455 course_id: arguments.course_id,
456 },
457 })
458 }
459
460 fn output(&self) -> String {
461 serde_json::to_string_pretty(&self.state.output).unwrap_or_else(|_| "{}".to_string())
462 }
463
464 fn output_description_instructions(&self) -> Option<String> {
465 let facets = &self.state.output.facets;
466 let mut notes = Vec::new();
467 let base_url = &self.state.base_url;
468
469 if facets.contains_key(UserCourseStateFacet::Progress.wire_name())
470 || facets.contains_key(UserCourseStateFacet::Completions.wire_name())
471 || facets.contains_key(UserCourseStateFacet::Submissions.wire_name())
472 || facets.contains_key(UserCourseStateFacet::Reviews.wire_name())
473 {
474 notes.push(format!(
475 "{base_url}/manage/courses/{}/user-status-summary/{} shows progress, module completions, \
476 submissions and reviews for this student on one page - check the numbers there before telling \
477 the admin anything a student will act on.",
478 self.state.course_id, self.state.user_id,
479 ));
480 }
481
482 if facets.contains_key(UserCourseStateFacet::Progress.wire_name()) {
483 notes.push(
484 "progress: unlike the other facets this one serializes explicit nulls rather than omitting fields. \
485 score_required / attempted_exercises_required are null when the module's completion policy is Manual \
486 (staff grants completion), not \"nothing required\". Totals are computed across all chapters, including \
487 ones not yet opened, so a score can be measured against material the student cannot see yet. \
488 score_given: 0 does not distinguish \"scored zero\" from \"never attempted\"; attempted_exercises only \
489 counts submitted/completed activity, so a drafted-but-unsubmitted answer counts as zero. The unnamed \
490 default module is reported under the course's own name. Exam exercises have no module and are excluded \
491 from every number here.".to_string(),
492 );
493 }
494
495 if let Some(UserCourseStateFacetValue::Completions(completions)) =
496 facets.get(UserCourseStateFacet::Completions.wire_name())
497 {
498 notes.push(
499 "needs_to_be_reviewed and anything derived from it is internal: it silently blocks certificates and \
500 grade visibility, and the student must never learn it exists - explain effects (\"the completion is \
501 still being processed\") without the cause. The user-status-summary page above does show this \
502 needs-review state (with a banner) to staff, so that page is where an admin can confirm the \
503 blocked-certificate cause directly; the no-cause-to-student rule above only governs what you tell \
504 the student.".to_string(),
505 );
506 if completions
507 .raw_completions
508 .iter()
509 .any(|c| c.needs_to_be_reviewed)
510 {
511 notes.push(
512 "module_completion_statuses.completed: false can mean a completion exists in raw_completions but \
513 needs_to_be_reviewed - this is deliberate so a flagged student cannot infer suspicion, not a data \
514 contradiction. When that happens, passed is also null on the status entry as a consequence of \
515 completed: false, not a separate signal.".to_string(),
516 );
517 }
518 notes.push(
519 "raw_completions lists every completion row, including failed and superseded ones; \
520 module_completion_statuses reports only the best one per module (grade improvement wins), so the two \
521 can legitimately disagree in count - a regrade always writes a new row rather than editing one in \
522 place. passed: false is a recorded failure, not \"still in progress\". grade: null means the module \
523 is pass/fail, not that a grade is missing. completion_granter distinguishes staff-granted completions \
524 (exempt from cheating flags) from automatic ones. eligible_for_ects: false blocks credit registration \
525 regardless of the module's ECTS setting. completion_registration_attempt_date absent means the \
526 student never opened the open-university registration form - pair with the credit_registration facet.".to_string(),
527 );
528 }
529
530 if let Some(UserCourseStateFacetValue::Submissions(submissions)) =
531 facets.get(UserCourseStateFacet::Submissions.wire_name())
532 {
533 notes.push(
534 "Quote submission timestamps exactly when the question is whether an answer saved."
535 .to_string(),
536 );
537 notes.push(match &submissions.submissions {
538 SubmissionsView::PerExercise(_) => "submissions.per_exercise gives one row per exercise, with the \
539 count and the first and latest timestamp. Call this facet again with exercise_id set to that \
540 exercise to get its individual submission timestamps."
541 .to_string(),
542 SubmissionsView::Timestamps(_) => "submissions.timestamps lists this exercise's submissions \
543 individually because the call named an exercise_id."
544 .to_string(),
545 });
546 notes.push(format!(
547 "submissions are timestamps only, with no score or answer content. An absent row can mean nothing \
548 was submitted, or that a reset soft-deleted the submissions it would have come from - cross-check \
549 the resets facet before telling anyone an answer never saved. exercise_name absent means the exercise \
550 has since been deleted from the course. exercise_attempts is present only when exercise_id was \
551 passed; its absence means \"not requested\", never \"no attempts\". Each row's exercise_id links to \
552 {base_url}/manage/exercises/<exercise_id>/submissions, the exercise-wide submission view.",
553 ));
554 if let Some(attempts) = &submissions.exercise_attempts {
555 notes.push(
556 "attempt_count sums submissions across all slides, but out_of_tries is decided by the worst \
557 single slide reaching the cap, so attempt_count can exceed max_tries_per_slide with \
558 out_of_tries: false, or be below it with out_of_tries: true.".to_string(),
559 );
560 if attempts.limit_number_of_tries && attempts.max_tries_per_slide.is_none() {
561 notes.push(
562 "This exercise has limit_number_of_tries: true but no max_tries_per_slide, which means the \
563 limit does not actually exist - do not tell the student they are out of tries based on \
564 limit_number_of_tries alone.".to_string(),
565 );
566 }
567 }
568 }
569
570 if let Some(UserCourseStateFacetValue::Reviews(_)) =
571 facets.get(UserCourseStateFacet::Reviews.wire_name())
572 {
573 notes.push(
574 "in_review_stages only queries PeerReview, SelfReview, WaitingForPeerReviews and \
575 WaitingForManualGrading; it omits ReviewedAndLocked (reviewed, scored, and permanently unanswerable \
576 because the model solution was revealed) and the chapter-locking stages, so an empty list does not \
577 mean nothing is blocking the student - only a reset clears ReviewedAndLocked, and the \
578 user-status-summary page above shows ReviewedAndLocked as the exercise's own state, so check there \
579 when this list is empty. NotStarted never \
580 appears here and is the answerable state; any listed stage means the student cannot submit. Who is \
581 blocking differs by stage: PeerReview/SelfReview waits on the student's own reviews, \
582 WaitingForPeerReviews waits on other students, WaitingForManualGrading waits on the teacher. \
583 teacher_grading_decisions are the latest decision per still-live exercise state, so a reset that \
584 soft-deletes the state makes an earlier decision disappear from this list - an empty list is not \
585 proof the student was never graded down, and a listed decision may already be superseded by a later \
586 submission. hidden: true means the decision is hidden from the student - never quote its \
587 justification back to them; hidden absent means unrecorded, not safe to quote. A FullPoints decision \
588 can be system-generated by the peer-review timeout auto-pass. In peer_review_queue, no entry for an \
589 exercise usually means the student has not yet given their own required peer reviews, not that \
590 nothing is pending; peer_review_priority is higher-served-sooner, not a queue position.".to_string(),
591 );
592 }
593
594 if let Some(UserCourseStateFacetValue::Resets(resets)) =
595 facets.get(UserCourseStateFacet::Resets.wire_name())
596 {
597 notes.push(
598 "reset_by absent means either the system did it (e.g. the automatic peer-review reset) or the \
599 acting staff account has since been deleted - it does not mean the actor is unknown. reason is \
600 shown to the student verbatim in the course material as the reset notice, so treat it as \
601 user-facing text, not an internal field.".to_string(),
602 );
603 notes.push(format!(
604 "{base_url}/manage/users/{} lists this student's exercise resets across every course, not just this \
605 one - a superset of this facet worth checking when a reset elsewhere might explain something here.",
606 self.state.user_id,
607 ));
608 if !resets.resets.is_empty() {
609 notes.push(
610 "A reset soft-deletes the affected submissions, gradings, peer-review queue entries and \
611 exercise states, and unlocks the affected chapters, but it does not touch \
612 course_module_completions - a module can still show completed/graded while progress and \
613 submissions show nothing. A reset restores tries; it does not revoke completions.".to_string(),
614 );
615 }
616 }
617
618 if let Some(UserCourseStateFacetValue::Certificates(certificates)) =
619 facets.get(UserCourseStateFacet::Certificates.wire_name())
620 {
621 notes.push(
622 "certificates.configurations lists only single-module (\"default\") certificate configurations - an \
623 empty array does not mean the course has no certificate. modules_blocked_by_pending_review is the \
624 internal cause (a pending suspected-cheater review) and must never be told to the student; \
625 missing_module_completions is the safe, student-explainable list. generated_certificates empty does \
626 not mean generation failed - certificates are only created when the student explicitly clicks \
627 generate, nothing generates them automatically; empty plus eligible: true means they can download it \
628 now. verification_id both verifies and grants access to the certificate image - only share it with \
629 the certificate's owner or an admin acting for them. Whenever you mention a generated certificate, \
630 link it: render its validation_url as a markdown link on the certificate itself instead of pasting \
631 the URL as text. That page both proves the certificate is genuine and shows its image.".to_string(),
632 );
633 let certificates_search_url = search_url(
634 base_url,
635 &format!(
636 "/manage/courses/{}/students/certificates",
637 self.state.course_id
638 ),
639 &self.state.output.user_email,
640 );
641 notes.push(format!(
642 "{certificates_search_url} lists this student's generated certificates (issued date, \
643 verification URL, certificate image) for cross-checking generated_certificates.",
644 ));
645 if certificates
646 .configurations
647 .iter()
648 .any(|c| c.eligible && !c.missing_module_completions.is_empty())
649 {
650 notes.push(
651 "eligible checks completions across the whole platform and ignores passed, while \
652 missing_module_completions counts an unpassed completion as missing - so eligible: true \
653 alongside a non-empty missing_module_completions is an expected combination here, not a bug; \
654 trust missing_module_completions when explaining what is left to do.".to_string(),
655 );
656 }
657 }
658
659 if let Some(UserCourseStateFacetValue::CreditRegistration(credit_registration)) =
660 facets.get(UserCourseStateFacet::CreditRegistration.wire_name())
661 {
662 notes.push(
663 "credit_registration has one row per completion, keyed by course_module_id - a module with no \
664 completion produces no row at all, so an empty array can mean \"nothing completed yet\" rather than \
665 \"nothing registered\". study_registry: \"This platform\" means this platform recorded the \
666 attainment itself, not that an external registry accepted it. registered_at is the row's own \
667 created_at, not the registration date shown to the student in the registry.".to_string(),
668 );
669 let completions_search_url = search_url(
670 base_url,
671 &format!(
672 "/manage/courses/{}/students/completions",
673 self.state.course_id
674 ),
675 &self.state.output.user_email,
676 );
677 notes.push(format!(
678 "{completions_search_url} shows per-module grade and registration status for \
679 cross-checking, and \
680 {base_url}/manage/credit-registration/registrations?user_id={} is the richer per-user view \
681 (attempt chain, event timeline, API calls, attainment ids).",
682 self.state.user_id,
683 ));
684 if credit_registration
685 .registrations
686 .iter()
687 .any(|r| r.registered)
688 {
689 notes.push(
690 "registered: true only says this platform recorded the attainment as sent, so a student can \
691 still report not seeing it in Sisu. The usual cause is a Sisu-side state where the assessment \
692 item is sufficient but not attained, which nothing on this platform can fix and re-registering \
693 will not clear. A Sisu support person resolves it: in Sisu, open the Studies tab, search for \
694 courses by the course code, pick the right one, and open Assessment - when this is the cause \
695 the student is listed there and the attainment can be granted to them. Offer this as the next \
696 step, addressed to whoever can act in Sisu, instead of telling the student to wait.".to_string(),
697 );
698 }
699 }
700
701 if notes.is_empty() {
702 return None;
703 }
704 Some(notes.join(" "))
705 }
706}
707
708async fn progress_facet(
709 conn: &mut PgConnection,
710 user_id: Uuid,
711 course_id: Uuid,
712) -> ChatbotResult<Vec<UserCourseProgress>> {
713 Ok(user_exercise_states::get_user_course_progress(conn, course_id, user_id, false).await?)
714}
715
716fn exercise_name_index(exercises: &[Exercise]) -> HashMap<Uuid, &str> {
718 exercises.iter().map(|e| (e.id, e.name.as_str())).collect()
719}
720
721#[derive(Serialize)]
722struct CompletionsFacet {
723 module_completion_statuses: Vec<UserModuleCompletionStatus>,
724 raw_completions: Vec<RawCompletion>,
725}
726
727#[derive(Serialize)]
728struct RawCompletion {
729 course_module_id: Uuid,
730 completion_date: DateTime<Utc>,
731 #[serde(skip_serializing_if = "Option::is_none")]
732 grade: Option<i32>,
733 passed: bool,
734 needs_to_be_reviewed: bool,
735 completion_granter: &'static str,
736 eligible_for_ects: bool,
737 #[serde(skip_serializing_if = "Option::is_none")]
738 completion_registration_attempt_date: Option<DateTime<Utc>>,
739}
740
741async fn completions_facet(
742 conn: &mut PgConnection,
743 user_id: Uuid,
744 course_id: Uuid,
745 raw: &[CourseModuleCompletion],
746) -> ChatbotResult<CompletionsFacet> {
747 let module_completion_statuses =
748 progressing::get_user_module_completion_statuses_for_course(conn, user_id, course_id)
749 .await?;
750 let raw_completions = raw
751 .iter()
752 .map(|c| RawCompletion {
753 course_module_id: c.course_module_id,
754 completion_date: c.completion_date,
755 grade: c.grade,
756 passed: c.passed,
757 needs_to_be_reviewed: c.needs_to_be_reviewed,
758 completion_granter: if c.completion_granter_user_id.is_some() {
759 "granted by staff"
760 } else {
761 "automatic"
762 },
763 eligible_for_ects: c.eligible_for_ects,
764 completion_registration_attempt_date: c.completion_registration_attempt_date,
765 })
766 .collect();
767 Ok(CompletionsFacet {
768 module_completion_statuses,
769 raw_completions,
770 })
771}
772
773#[derive(Serialize)]
774struct SubmissionsFacet {
775 submissions: SubmissionsView,
776 #[serde(skip_serializing_if = "Option::is_none")]
777 exercise_attempts: Option<ExerciseAttempts>,
778}
779
780#[derive(Serialize)]
787#[serde(rename_all = "snake_case")]
788enum SubmissionsView {
789 PerExercise(CappedList<ExerciseSubmissionSummary>),
790 Timestamps(CappedList<SubmissionRow>),
791}
792
793const MAX_SUBMISSION_SUMMARIES: usize = 400;
797const MAX_SUBMISSION_TIMESTAMPS: usize = 300;
798
799#[derive(Serialize)]
800struct ExerciseSubmissionSummary {
801 exercise_id: Uuid,
802 #[serde(skip_serializing_if = "Option::is_none")]
803 exercise_name: Option<String>,
804 #[serde(skip_serializing_if = "Option::is_none")]
805 course_module_id: Option<Uuid>,
806 submission_count: usize,
807 first_submission_at: DateTime<Utc>,
808 latest_submission_at: DateTime<Utc>,
809}
810
811#[derive(Serialize)]
812struct SubmissionRow {
813 created_at: DateTime<Utc>,
814 exercise_id: Uuid,
815 #[serde(skip_serializing_if = "Option::is_none")]
816 exercise_name: Option<String>,
817 #[serde(skip_serializing_if = "Option::is_none")]
818 course_module_id: Option<Uuid>,
819}
820
821fn summarize_submissions_per_exercise(
824 times: &[UserCourseSubmissionTime],
825 exercise_names: &HashMap<Uuid, &str>,
826) -> Vec<ExerciseSubmissionSummary> {
827 let mut per_exercise: IndexMap<Uuid, ExerciseSubmissionSummary> = IndexMap::new();
828 for time in times {
829 match per_exercise.entry(time.exercise_id) {
830 indexmap::map::Entry::Occupied(mut entry) => {
831 let summary = entry.get_mut();
832 summary.submission_count += 1;
833 summary.first_submission_at = summary.first_submission_at.min(time.created_at);
834 summary.latest_submission_at = summary.latest_submission_at.max(time.created_at);
835 }
836 indexmap::map::Entry::Vacant(entry) => {
837 entry.insert(ExerciseSubmissionSummary {
838 exercise_id: time.exercise_id,
839 exercise_name: exercise_names
840 .get(&time.exercise_id)
841 .map(|name| name.to_string()),
842 course_module_id: time.course_module_id,
843 submission_count: 1,
844 first_submission_at: time.created_at,
845 latest_submission_at: time.created_at,
846 });
847 }
848 }
849 }
850 per_exercise.into_values().collect()
851}
852
853#[derive(Serialize)]
854struct ExerciseAttempts {
855 attempt_count: i64,
856 #[serde(skip_serializing_if = "Option::is_none")]
857 max_tries_per_slide: Option<i32>,
858 limit_number_of_tries: bool,
859 out_of_tries: bool,
860}
861
862async fn submissions_facet(
863 conn: &mut PgConnection,
864 user_id: Uuid,
865 course_id: Uuid,
866 exercise_id: Option<Uuid>,
867 course_exercises: &[Exercise],
868) -> ChatbotResult<SubmissionsFacet> {
869 let times =
870 exercise_slide_submissions::get_user_course_submission_times(conn, user_id, course_id)
871 .await?;
872 let exercise_names = exercise_name_index(course_exercises);
873
874 let submissions = match exercise_id {
875 Some(exercise_id) => SubmissionsView::Timestamps(CappedList::new(
876 times
877 .iter()
878 .filter(|t| t.exercise_id == exercise_id)
879 .map(|t| SubmissionRow {
880 created_at: t.created_at,
881 exercise_id: t.exercise_id,
882 exercise_name: exercise_names.get(&t.exercise_id).map(|s| s.to_string()),
883 course_module_id: t.course_module_id,
884 })
885 .collect(),
886 MAX_SUBMISSION_TIMESTAMPS,
887 )),
888 None => SubmissionsView::PerExercise(CappedList::new(
889 summarize_submissions_per_exercise(×, &exercise_names),
890 MAX_SUBMISSION_SUMMARIES,
891 )),
892 };
893
894 let mut exercise_attempts = None;
895
896 if let Some(exercise_id) = exercise_id
897 && let Some(exercise) = course_exercises.iter().find(|e| e.id == exercise_id)
898 {
899 let counts_per_slide =
900 exercise_slide_submissions::get_exercise_slide_submission_counts_for_exercise_user(
901 conn,
902 exercise_id,
903 CourseOrExamId::Course(course_id),
904 user_id,
905 )
906 .await?;
907 let attempt_count: i64 = counts_per_slide.values().sum();
908 let max_slide_attempt_count = counts_per_slide.values().copied().max().unwrap_or(0);
911 let out_of_tries = exercise.limit_number_of_tries
912 && exercise
913 .max_tries_per_slide
914 .is_some_and(|max| max_slide_attempt_count >= max as i64);
915 exercise_attempts = Some(ExerciseAttempts {
916 attempt_count,
917 max_tries_per_slide: exercise.max_tries_per_slide,
918 limit_number_of_tries: exercise.limit_number_of_tries,
919 out_of_tries,
920 });
921 }
922
923 Ok(SubmissionsFacet {
924 submissions,
925 exercise_attempts,
926 })
927}
928
929#[derive(Serialize)]
930struct ReviewsFacet {
931 in_review_stages: CappedList<InReviewStageRow>,
932 teacher_grading_decisions: CappedList<TeacherGradingDecisionRow>,
933 peer_review_queue: CappedList<PeerReviewQueueRow>,
934}
935
936const MAX_REVIEW_ROWS: usize = 300;
940
941#[derive(Serialize)]
942struct InReviewStageRow {
943 exercise_id: Uuid,
944 exercise_name: String,
945 reviewing_stage: ReviewingStage,
946 #[serde(skip_serializing_if = "Option::is_none")]
947 score_given: Option<f32>,
948}
949
950#[derive(Serialize)]
951struct TeacherGradingDecisionRow {
952 #[serde(skip_serializing_if = "Option::is_none")]
953 exercise_id: Option<Uuid>,
954 #[serde(skip_serializing_if = "Option::is_none")]
955 exercise_name: Option<String>,
956 teacher_decision: TeacherDecisionType,
957 score_given: f32,
958 #[serde(skip_serializing_if = "Option::is_none")]
959 justification: Option<String>,
960 #[serde(skip_serializing_if = "Option::is_none")]
961 hidden: Option<bool>,
962 created_at: DateTime<Utc>,
963}
964
965#[derive(Serialize)]
966struct PeerReviewQueueRow {
967 exercise_id: Uuid,
968 received_enough_peer_reviews: bool,
969 peer_review_priority: i32,
970 created_at: DateTime<Utc>,
971}
972
973async fn reviews_facet(
974 conn: &mut PgConnection,
975 user_id: Uuid,
976 course_id: Uuid,
977 exercise_id: Option<Uuid>,
978 course_exercises: &[Exercise],
979) -> ChatbotResult<ReviewsFacet> {
980 let states = user_exercise_states::get_states_in_reviewing_stages_for_user_and_course(
981 conn,
982 user_id,
983 course_id,
984 &[
985 ReviewingStage::PeerReview,
986 ReviewingStage::SelfReview,
987 ReviewingStage::WaitingForPeerReviews,
988 ReviewingStage::WaitingForManualGrading,
989 ],
990 )
991 .await?;
992 let in_review_stages = CappedList::new(
993 states
994 .iter()
995 .map(|s| InReviewStageRow {
996 exercise_id: s.exercise_id,
997 exercise_name: s.exercise_name.clone(),
998 reviewing_stage: s.reviewing_stage,
999 score_given: s.score_given,
1000 })
1001 .collect(),
1002 MAX_REVIEW_ROWS,
1003 );
1004
1005 let decisions =
1006 teacher_grading_decisions::get_all_latest_grading_decisions_by_user_id_and_course_id(
1007 conn, user_id, course_id,
1008 )
1009 .await?;
1010 let exercise_id_by_user_exercise_state_id: HashMap<Uuid, Uuid> =
1011 user_exercise_states::get_all_for_user_and_course_or_exam(
1012 conn,
1013 user_id,
1014 CourseOrExamId::Course(course_id),
1015 )
1016 .await?
1017 .into_iter()
1018 .map(|s| (s.id, s.exercise_id))
1019 .collect();
1020 let exercise_names = exercise_name_index(course_exercises);
1021 let teacher_grading_decisions = CappedList::new(
1022 decisions
1023 .iter()
1024 .map(|d| {
1025 let exercise_id = exercise_id_by_user_exercise_state_id
1026 .get(&d.user_exercise_state_id)
1027 .copied();
1028 TeacherGradingDecisionRow {
1029 exercise_id,
1030 exercise_name: exercise_id
1031 .and_then(|id| exercise_names.get(&id))
1032 .map(|s| s.to_string()),
1033 teacher_decision: d.teacher_decision,
1034 score_given: d.score_given,
1035 justification: d.justification.clone(),
1036 hidden: d.hidden,
1037 created_at: d.created_at,
1038 }
1039 })
1040 .collect(),
1041 MAX_REVIEW_ROWS,
1042 );
1043
1044 let peer_review_entries = if let Some(exercise_id) = exercise_id {
1045 peer_review_queue_entries::try_to_get_by_user_and_exercise_and_course_ids(
1046 conn,
1047 user_id,
1048 exercise_id,
1049 course_id,
1050 )
1051 .await?
1052 .into_iter()
1053 .collect()
1054 } else {
1055 peer_review_queue_entries::get_all_by_user_and_course_id(conn, user_id, course_id).await?
1056 };
1057 let peer_review_queue = CappedList::new(
1058 peer_review_entries
1059 .iter()
1060 .map(|e| PeerReviewQueueRow {
1061 exercise_id: e.exercise_id,
1062 received_enough_peer_reviews: e.received_enough_peer_reviews,
1063 peer_review_priority: e.peer_review_priority,
1064 created_at: e.created_at,
1065 })
1066 .collect(),
1067 MAX_REVIEW_ROWS,
1068 );
1069
1070 Ok(ReviewsFacet {
1071 in_review_stages,
1072 teacher_grading_decisions,
1073 peer_review_queue,
1074 })
1075}
1076
1077#[derive(Serialize)]
1078struct ResetsFacet {
1079 resets: CappedList<ResetRow>,
1080}
1081
1082#[derive(Serialize)]
1083struct ResetRow {
1084 exercise_name: String,
1085 #[serde(skip_serializing_if = "Option::is_none")]
1086 reset_by: Option<String>,
1087 #[serde(skip_serializing_if = "Option::is_none")]
1088 reason: Option<String>,
1089 reset_at: DateTime<Utc>,
1090}
1091
1092async fn resets_facet(
1093 conn: &mut PgConnection,
1094 user_id: Uuid,
1095 course_id: Uuid,
1096) -> ChatbotResult<ResetsFacet> {
1097 let logs = exercise_reset_logs::get_exercise_reset_logs_for_user(conn, user_id).await?;
1098 let resets = logs
1099 .into_iter()
1100 .filter(|l| l.course_id == course_id)
1101 .map(|l| {
1102 let reset_by = match (l.reset_by_first_name, l.reset_by_last_name) {
1103 (Some(first), Some(last)) => Some(format!("{first} {last}")),
1104 (Some(first), None) => Some(first),
1105 (None, Some(last)) => Some(last),
1106 (None, None) => None,
1107 };
1108 ResetRow {
1109 exercise_name: l.exercise_name,
1110 reset_by,
1111 reason: l.reason,
1112 reset_at: l.reset_at,
1113 }
1114 })
1115 .collect();
1116 Ok(ResetsFacet {
1117 resets: CappedList::new(resets, MAX_REVIEW_ROWS),
1118 })
1119}
1120
1121#[derive(Serialize)]
1122struct CertificatesFacet {
1123 configurations: Vec<CertificateConfigurationRow>,
1124 generated_certificates: Vec<GeneratedCertificateRow>,
1125}
1126
1127#[derive(Serialize)]
1128struct CertificateConfigurationRow {
1129 certificate_configuration_id: Uuid,
1130 eligible: bool,
1131 missing_module_completions: Vec<String>,
1132 modules_blocked_by_pending_review: Vec<String>,
1133}
1134
1135#[derive(Serialize)]
1136struct GeneratedCertificateRow {
1137 certificate_id: Uuid,
1138 verification_id: String,
1139 name_on_certificate: String,
1140 created_at: DateTime<Utc>,
1141 validation_url: String,
1142}
1143
1144async fn certificates_facet(
1145 conn: &mut PgConnection,
1146 user_id: Uuid,
1147 course_id: Uuid,
1148 raw_completions: &[CourseModuleCompletion],
1149 base_url: &str,
1150) -> ChatbotResult<CertificatesFacet> {
1151 let configurations =
1152 certificate_configurations::get_default_certificate_configurations_and_requirements_by_course(
1153 conn, course_id,
1154 )
1155 .await?;
1156 let modules = course_modules::get_by_course_id(conn, course_id).await?;
1157 let module_names: HashMap<Uuid, String> = modules
1158 .iter()
1159 .map(|m| {
1160 (
1161 m.id,
1162 m.name
1163 .clone()
1164 .unwrap_or_else(|| "Default module".to_string()),
1165 )
1166 })
1167 .collect();
1168
1169 let mut configurations_json = Vec::new();
1170 for configuration in &configurations {
1171 let eligible = configuration
1172 .requirements
1173 .has_user_completed_all_requirements(conn, user_id)
1174 .await?;
1175 let mut missing_module_completions = Vec::new();
1176 let mut modules_blocked_by_pending_review = Vec::new();
1180 for module_id in &configuration.requirements.course_module_ids {
1181 let module_name = module_names
1182 .get(module_id)
1183 .cloned()
1184 .unwrap_or_else(|| module_id.to_string());
1185 match raw_completions
1186 .iter()
1187 .find(|c| c.course_module_id == *module_id)
1188 {
1189 None => missing_module_completions.push(module_name),
1190 Some(c) if c.needs_to_be_reviewed => {
1191 modules_blocked_by_pending_review.push(module_name)
1192 }
1193 Some(c) if !c.passed => missing_module_completions.push(module_name),
1194 Some(_) => {}
1195 }
1196 }
1197 configurations_json.push(CertificateConfigurationRow {
1198 certificate_configuration_id: configuration.certificate_configuration.id,
1199 eligible,
1200 missing_module_completions,
1201 modules_blocked_by_pending_review,
1202 });
1203 }
1204
1205 let generated_certificates = generated_certificates::get_all_by_user_id(conn, user_id)
1206 .await?
1207 .into_iter()
1208 .filter(|c| c.course_id == course_id)
1209 .map(|c| GeneratedCertificateRow {
1210 certificate_id: c.id,
1211 validation_url: certificate_validation_url(base_url, &c.verification_id),
1212 verification_id: c.verification_id,
1213 name_on_certificate: c.name_on_certificate,
1214 created_at: c.created_at,
1215 })
1216 .collect();
1217
1218 Ok(CertificatesFacet {
1219 configurations: configurations_json,
1220 generated_certificates,
1221 })
1222}
1223
1224#[derive(Serialize)]
1225struct CreditRegistrationFacet {
1226 registrations: Vec<CreditRegistrationRow>,
1227}
1228
1229#[derive(Serialize)]
1230struct CreditRegistrationRow {
1231 course_module_id: Uuid,
1232 registered: bool,
1233 #[serde(skip_serializing_if = "Option::is_none")]
1234 registered_at: Option<DateTime<Utc>>,
1235 #[serde(skip_serializing_if = "Option::is_none")]
1236 study_registry: Option<String>,
1237}
1238
1239async fn credit_registration_facet(
1240 conn: &mut PgConnection,
1241 completions: &[CourseModuleCompletion],
1242) -> ChatbotResult<CreditRegistrationFacet> {
1243 let completion_ids: Vec<Uuid> = completions.iter().map(|c| c.id).collect();
1244 let registrations =
1245 course_module_completion_registered_to_study_registries::get_registrations_by_completion_ids(
1246 conn,
1247 &completion_ids,
1248 )
1249 .await?;
1250 let registrar_ids: Vec<Uuid> = registrations
1251 .iter()
1252 .filter_map(|r| r.study_registry_registrar_id)
1253 .collect();
1254 let registrar_names: HashMap<Uuid, String> =
1255 study_registry_registrars::get_by_ids(conn, ®istrar_ids)
1256 .await?
1257 .into_iter()
1258 .map(|registrar| (registrar.id, registrar.name))
1259 .collect();
1260
1261 let mut result = Vec::new();
1262 for completion in completions {
1263 let registration = registrations
1264 .iter()
1265 .find(|r| r.course_module_completion_id == completion.id);
1266 let study_registry = match registration.and_then(|r| r.study_registry_registrar_id) {
1267 Some(registrar_id) => registrar_names.get(®istrar_id).cloned(),
1268 None if registration.is_some() => Some("This platform".to_string()),
1269 None => None,
1270 };
1271 result.push(CreditRegistrationRow {
1272 course_module_id: completion.course_module_id,
1273 registered: registration.is_some(),
1274 registered_at: registration.map(|r| r.created_at),
1275 study_registry,
1276 });
1277 }
1278
1279 Ok(CreditRegistrationFacet {
1280 registrations: result,
1281 })
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286 use super::*;
1287
1288 fn submission_time(exercise: Uuid, minutes: i64) -> UserCourseSubmissionTime {
1289 UserCourseSubmissionTime {
1290 created_at: DateTime::from_timestamp(minutes * 60, 0).expect("a valid timestamp"),
1291 exercise_id: exercise,
1292 course_module_id: None,
1293 }
1294 }
1295
1296 #[test]
1300 fn submissions_collapse_to_one_row_per_exercise() {
1301 let first = Uuid::new_v4();
1302 let second = Uuid::new_v4();
1303 let names = HashMap::from([(first, "Muuttujat"), (second, "Silmukat")]);
1304
1305 let summaries = summarize_submissions_per_exercise(
1306 &[
1307 submission_time(first, 10),
1308 submission_time(second, 20),
1309 submission_time(first, 30),
1310 submission_time(first, 5),
1311 ],
1312 &names,
1313 );
1314
1315 assert_eq!(summaries.len(), 2);
1316 assert_eq!(summaries[0].exercise_id, first);
1317 assert_eq!(summaries[0].exercise_name.as_deref(), Some("Muuttujat"));
1318 assert_eq!(summaries[0].submission_count, 3);
1319 assert_eq!(
1320 summaries[0].first_submission_at,
1321 submission_time(first, 5).created_at
1322 );
1323 assert_eq!(
1324 summaries[0].latest_submission_at,
1325 submission_time(first, 30).created_at
1326 );
1327 assert_eq!(summaries[1].submission_count, 1);
1328 }
1329}