Skip to main content

headless_lms_chatbot/chatbot_tools/custom_tools/
user_course_state.rs

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