Skip to main content

headless_lms_chatbot/chatbot_tools/custom_tools/
user_course_state.rs

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/// The facets `user_course_state` can be asked for. Internal to this tool: the wire form is the
67/// `facets` string array in the schema, never this enum.
68#[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
158/// Manual, not derived: ids and facets need validation `#[derive(Deserialize)]` can't express,
159/// and this is what [ChatbotTool::Arguments]'s `DeserializeOwned` bound is satisfied by
160/// (`parse_arguments` below is overridden and never calls it, but the bound still has to hold).
161impl<'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        // Fetched once and shared across facets instead of once per facet, since a single call
321        // commonly requests several facets that would otherwise repeat the same query.
322        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
716/// Maps exercise id to name, for annotating rows that only carry an exercise id.
717fn 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/// How a call's submissions are reported, which follows from whether it named an exercise.
781///
782/// A whole-course call is answered per exercise: the underlying query returns up to 5001 rows,
783/// and one row per submission repeats the exercise on every one of them for an answer that is
784/// almost always a count. The individual timestamps are what a call about one exercise is asking
785/// for, so that is where they are kept.
786#[derive(Serialize)]
787#[serde(rename_all = "snake_case")]
788enum SubmissionsView {
789    PerExercise(CappedList<ExerciseSubmissionSummary>),
790    Timestamps(CappedList<SubmissionRow>),
791}
792
793/// The most exercises a whole-course call reports on, and the most timestamps a call narrowed to
794/// one exercise lists. Both are far above what a real course or a real student reaches; they exist
795/// so a pathological row count cannot crowd the other facets out of the same output.
796const 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
821/// Collapses submission times into one row per exercise, in the order the exercises were first
822/// submitted to.
823fn 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(&times, &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        // A slide is out of tries once its own count reaches the per-slide cap, whichever slide
909        // gets there first - matching the per-slide limit the course-material endpoint enforces.
910        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
936/// The most rows any one review or reset list reports. These grow with the exercises of the
937/// course, and a teacher's justification on a grading decision is free text, so the product is
938/// only loosely bounded even though the row count is.
939const 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        // A completion can exist but still be withheld from certificate eligibility because it
1177        // needs_to_be_reviewed (e.g. a suspected-cheater flag) - surfaced here separately so
1178        // support can explain "still processing" without naming the real, internal reason.
1179        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, &registrar_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(&registrar_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    /// The whole-course view exists because a student's submissions run to thousands of rows that
1297    /// only ever get counted, so the collapse has to preserve the count and the span while
1298    /// emitting one row per exercise.
1299    #[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}