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