1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::fmt::Debug;
9use uuid::Uuid;
10
11pub type Token =
14 oauth2::StandardTokenResponse<oauth2::EmptyExtraTokenFields, oauth2::basic::BasicTokenType>;
15
16#[derive(Debug, Serialize, Deserialize)]
18#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
19pub struct Course {
20 pub id: Uuid,
21 pub slug: String,
22 pub name: String,
23 pub description: Option<String>,
24 pub organization_name: String,
26 #[serde(default)]
29 pub ai_policy: CourseAiPolicy,
30}
31
32#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
34#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
35#[serde(rename_all = "snake_case")]
36pub enum CourseAiPolicy {
37 #[default]
39 NotSet,
40 NoAi,
42 PlanningOnly,
44 Limited,
46 FullUse,
48 Required,
50 #[serde(other, skip_serializing)]
53 Unrecognized,
54}
55
56#[derive(Debug, Serialize, Deserialize)]
60#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
61pub struct ExerciseSlide {
62 pub slide_id: Uuid,
63 pub exercise_id: Uuid,
64 pub course_id: Uuid,
66 pub exercise_name: String,
67 pub exercise_order_number: i32,
68 pub deadline: Option<DateTime<Utc>>,
69 pub tasks: Vec<ExerciseTask>,
70 #[serde(default)]
73 pub page_url: Option<String>,
74 #[serde(default)]
76 pub chapter: Option<ExerciseChapter>,
77}
78
79#[derive(Debug, Serialize, Deserialize)]
82#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
83pub struct ExerciseChapter {
84 pub id: Uuid,
85 pub name: String,
86 pub chapter_number: i32,
88}
89
90#[derive(Debug, Serialize, Deserialize)]
96#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
97pub struct ExerciseTask {
98 pub task_id: Uuid,
99 pub order_number: i32,
100 pub assignment: serde_json::Value,
101 pub public_spec: Option<serde_json::Value>,
102 pub model_solution_spec: Option<serde_json::Value>,
103 pub exercise_service_slug: String,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
109#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
110#[serde(rename_all = "snake_case")]
111pub enum AnswerKind {
112 Json,
113 File,
114}
115
116#[derive(Debug, Serialize, Deserialize)]
119#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
120pub struct AnswerFile {
121 pub id: Uuid,
123 pub name: String,
125 pub mime: String,
126 pub size_bytes: Option<i64>,
129 pub order_number: Option<i32>,
132 pub url: String,
134}
135
136#[derive(Debug, Serialize, Deserialize)]
138#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
139pub struct UploadedFiles {
140 pub data_files: Vec<AnswerFile>,
141}
142
143#[derive(Debug, Serialize, Deserialize)]
146#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
147pub struct ExerciseSlideSubmission {
148 pub exercise_slide_id: Uuid,
149 pub exercise_task_id: Uuid,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub answer_kind: Option<AnswerKind>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub data_json: Option<serde_json::Value>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub data_files: Option<Vec<Uuid>>,
162}
163
164#[derive(Debug, Serialize, Deserialize)]
167#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
168pub struct ExerciseTaskSubmissionResult {
169 pub task_submission_id: Uuid,
170 pub slide_submission_id: Uuid,
171}
172
173#[derive(Debug, Serialize, Deserialize)]
175#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
176pub enum ExerciseTaskSubmissionStatus {
177 NoGradingYet,
178 Grading {
179 grading_progress: GradingProgress,
180 score_given: Option<f32>,
183 grading_started_at: Option<DateTime<Utc>>,
184 grading_completed_at: Option<DateTime<Utc>>,
185 feedback_json: Option<serde_json::Value>,
188 feedback_text: Option<String>,
190 #[serde(default)]
194 exercise_progress: Option<ExerciseProgress>,
195 },
196}
197
198#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
200#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
201pub enum GradingProgress {
202 Failed,
204 NotReady,
206 PendingManual,
208 Pending,
210 FullyGraded,
212}
213
214#[derive(Debug, Serialize, Deserialize)]
217#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
218pub struct ExerciseSlideSubmissionListItem {
219 pub id: Uuid,
220 pub exercise_id: Uuid,
221 pub created_at: DateTime<Utc>,
222 pub score_given: Option<f32>,
223 pub grading_progress: Option<GradingProgress>,
224}
225
226#[derive(Debug, Serialize, Deserialize)]
235#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
236pub struct SubmissionFiles {
237 pub data_files: Vec<AnswerFile>,
238}
239
240#[derive(Debug, Serialize, Deserialize)]
245#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
246pub struct CourseProgress {
247 pub course_id: Uuid,
249 pub exercises: Vec<ExerciseProgress>,
250}
251
252#[derive(Debug, Serialize, Deserialize)]
258#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
259pub struct ExerciseProgress {
260 pub exercise_id: Uuid,
261 pub score_given: f32,
263 pub score_maximum: i32,
265 pub completed: bool,
267 pub attempted: bool,
270 #[serde(default)]
272 pub standing: Option<ExerciseStanding>,
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
277#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
278pub enum ExerciseStanding {
279 NotAttempted,
281 Attempted,
283 Passed,
285 OutOfTries,
288}
289
290#[derive(Debug, Serialize, Deserialize)]
292#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
293pub struct PasteResult {
294 pub paste_url: String,
295}
296
297#[cfg(test)]
298mod test {
299 #![allow(clippy::unwrap_used)]
300 use super::*;
301 use serde_json::json;
302
303 #[test]
307 fn submission_status_serializes_externally_tagged() {
308 assert_eq!(
309 serde_json::to_value(ExerciseTaskSubmissionStatus::NoGradingYet).unwrap(),
310 json!("NoGradingYet"),
311 );
312
313 let graded = ExerciseTaskSubmissionStatus::Grading {
314 grading_progress: GradingProgress::FullyGraded,
315 score_given: Some(1.0),
316 grading_started_at: None,
317 grading_completed_at: None,
318 feedback_json: None,
319 feedback_text: Some("ok".to_string()),
320 exercise_progress: Some(ExerciseProgress {
321 exercise_id: Uuid::nil(),
322 score_given: 1.0,
323 score_maximum: 1,
324 completed: true,
325 attempted: true,
326 standing: Some(ExerciseStanding::Passed),
327 }),
328 };
329 assert_eq!(
330 serde_json::to_value(&graded).unwrap(),
331 json!({
332 "Grading": {
333 "grading_progress": "FullyGraded",
334 "score_given": 1.0,
335 "grading_started_at": null,
336 "grading_completed_at": null,
337 "feedback_json": null,
338 "feedback_text": "ok",
339 "exercise_progress": {
340 "exercise_id": Uuid::nil(),
341 "score_given": 1.0,
342 "score_maximum": 1,
343 "completed": true,
344 "attempted": true,
345 "standing": "Passed",
346 },
347 }
348 }),
349 );
350 }
351
352 #[test]
354 fn grading_without_exercise_progress_still_parses() {
355 let status: ExerciseTaskSubmissionStatus = serde_json::from_value(json!({
356 "Grading": {
357 "grading_progress": "FullyGraded",
358 "score_given": 1.0,
359 "grading_started_at": null,
360 "grading_completed_at": null,
361 "feedback_json": null,
362 "feedback_text": null,
363 }
364 }))
365 .unwrap();
366 assert!(matches!(
367 status,
368 ExerciseTaskSubmissionStatus::Grading {
369 exercise_progress: None,
370 ..
371 }
372 ));
373 }
374
375 #[test]
376 fn course_ai_policy_wire_values() {
377 for (policy, wire) in [
378 (CourseAiPolicy::NotSet, "not_set"),
379 (CourseAiPolicy::NoAi, "no_ai"),
380 (CourseAiPolicy::PlanningOnly, "planning_only"),
381 (CourseAiPolicy::Limited, "limited"),
382 (CourseAiPolicy::FullUse, "full_use"),
383 (CourseAiPolicy::Required, "required"),
384 ] {
385 assert_eq!(serde_json::to_value(policy).unwrap(), json!(wire));
386 assert_eq!(
387 serde_json::from_value::<CourseAiPolicy>(json!(wire)).unwrap(),
388 policy
389 );
390 }
391 }
392
393 #[test]
394 fn course_ai_policy_unknown_value_reads_as_unrecognized() {
395 assert_eq!(
396 serde_json::from_value::<CourseAiPolicy>(json!("some_future_policy")).unwrap(),
397 CourseAiPolicy::Unrecognized
398 );
399 assert!(serde_json::to_value(CourseAiPolicy::Unrecognized).is_err());
400 }
401
402 #[test]
404 fn course_without_ai_policy_reads_as_not_set() {
405 let course: Course = serde_json::from_value(json!({
406 "id": Uuid::nil(),
407 "slug": "slug",
408 "name": "name",
409 "description": null,
410 "organization_name": "org",
411 }))
412 .unwrap();
413 assert_eq!(course.ai_policy, CourseAiPolicy::NotSet);
414 }
415
416 #[test]
417 fn course_serializes_ai_policy() {
418 let value = serde_json::to_value(Course {
419 id: Uuid::nil(),
420 slug: "slug".to_string(),
421 name: "name".to_string(),
422 description: None,
423 organization_name: "org".to_string(),
424 ai_policy: CourseAiPolicy::FullUse,
425 })
426 .unwrap();
427 assert_eq!(value["ai_policy"], json!("full_use"));
428 }
429
430 #[test]
432 fn exercise_slide_without_page_url_or_chapter_still_parses() {
433 let slide: ExerciseSlide = serde_json::from_value(json!({
434 "slide_id": Uuid::nil(),
435 "exercise_id": Uuid::nil(),
436 "course_id": Uuid::nil(),
437 "exercise_name": "name",
438 "exercise_order_number": 0,
439 "deadline": null,
440 "tasks": [],
441 }))
442 .unwrap();
443 assert_eq!(slide.page_url, None);
444 assert!(slide.chapter.is_none());
445 }
446
447 #[test]
448 fn grading_progress_serializes_as_plain_strings() {
449 assert_eq!(
450 serde_json::to_value(GradingProgress::FullyGraded).unwrap(),
451 json!("FullyGraded"),
452 );
453 assert_eq!(
454 serde_json::to_value(GradingProgress::PendingManual).unwrap(),
455 json!("PendingManual"),
456 );
457 }
458
459 #[test]
462 fn exercise_slide_submission_names_files() {
463 let file_id = Uuid::max();
464 let value = serde_json::to_value(ExerciseSlideSubmission {
465 exercise_slide_id: Uuid::nil(),
466 exercise_task_id: Uuid::nil(),
467 answer_kind: Some(AnswerKind::File),
468 data_json: None,
469 data_files: Some(vec![file_id]),
470 })
471 .unwrap();
472 let obj = value.as_object().unwrap();
473 assert!(obj.contains_key("exercise_slide_id"));
474 assert!(obj.contains_key("exercise_task_id"));
475 assert_eq!(obj["answer_kind"], json!("file"));
476 assert_eq!(obj["data_files"], json!([file_id]));
477 assert!(!obj.contains_key("data_json"));
478 assert_eq!(obj.len(), 4);
479 }
480
481 #[test]
483 fn exercise_slide_submission_answer_kind_is_optional() {
484 let submission: ExerciseSlideSubmission = serde_json::from_value(json!({
485 "exercise_slide_id": Uuid::nil(),
486 "exercise_task_id": Uuid::nil(),
487 "data_json": { "opaque": "service owned" },
488 }))
489 .unwrap();
490 assert_eq!(submission.answer_kind, None);
491 assert_eq!(submission.data_files, None);
492 }
493
494 #[test]
496 fn submit_result_carries_both_submission_ids() {
497 let task_submission_id = Uuid::nil();
498 let slide_submission_id = Uuid::max();
499 let value = serde_json::to_value(ExerciseTaskSubmissionResult {
500 task_submission_id,
501 slide_submission_id,
502 })
503 .unwrap();
504 assert_eq!(
505 value,
506 json!({
507 "task_submission_id": task_submission_id,
508 "slide_submission_id": slide_submission_id,
509 })
510 );
511 }
512
513 #[test]
514 fn uploaded_and_submission_files_share_the_file_shape() {
515 let id = Uuid::max();
516 let file = || AnswerFile {
517 id,
518 name: "src/main.rs".to_string(),
519 mime: "application/octet-stream".to_string(),
520 size_bytes: Some(11),
521 order_number: Some(0),
522 url: "http://project-331.local/api/v0/files/tmc/abc".to_string(),
523 };
524 let expected = json!({
525 "data_files": [{
526 "id": id,
527 "name": "src/main.rs",
528 "mime": "application/octet-stream",
529 "size_bytes": 11,
530 "order_number": 0,
531 "url": "http://project-331.local/api/v0/files/tmc/abc",
532 }]
533 });
534 assert_eq!(
535 serde_json::to_value(UploadedFiles {
536 data_files: vec![file()]
537 })
538 .unwrap(),
539 expected
540 );
541 assert_eq!(
542 serde_json::to_value(SubmissionFiles {
543 data_files: vec![file()]
544 })
545 .unwrap(),
546 expected
547 );
548 }
549
550 #[test]
553 fn submission_files_may_be_empty() {
554 assert_eq!(
555 serde_json::to_value(SubmissionFiles {
556 data_files: Vec::new()
557 })
558 .unwrap(),
559 json!({ "data_files": [] })
560 );
561 }
562
563 #[test]
564 fn course_progress_shape() {
565 let value = serde_json::to_value(CourseProgress {
566 course_id: Uuid::nil(),
567 exercises: vec![ExerciseProgress {
568 exercise_id: Uuid::nil(),
569 score_given: 1.5,
570 score_maximum: 3,
571 completed: false,
572 attempted: true,
573 standing: Some(ExerciseStanding::OutOfTries),
574 }],
575 })
576 .unwrap();
577 let obj = value.as_object().unwrap();
578 assert!(obj.contains_key("course_id"));
579 let exercises = obj["exercises"].as_array().unwrap();
580 let ex = exercises[0].as_object().unwrap();
581 assert_eq!(ex["score_given"], json!(1.5));
582 assert_eq!(ex["score_maximum"], json!(3));
583 assert_eq!(ex["completed"], json!(false));
584 assert_eq!(ex["attempted"], json!(true));
585 assert_eq!(ex["standing"], json!("OutOfTries"));
586 }
587
588 #[test]
590 fn progress_without_standing_still_parses() {
591 let progress: ExerciseProgress = serde_json::from_value(json!({
592 "exercise_id": Uuid::nil(),
593 "score_given": 0.0,
594 "score_maximum": 1,
595 "completed": false,
596 "attempted": false,
597 }))
598 .unwrap();
599 assert_eq!(progress.standing, None);
600 }
601
602 #[test]
603 fn submission_list_item_shape() {
604 let value = json!({
605 "id": Uuid::nil(),
606 "exercise_id": Uuid::nil(),
607 "created_at": "2026-07-21T00:00:00Z",
608 "score_given": 1.0,
609 "grading_progress": "FullyGraded"
610 });
611 let item: ExerciseSlideSubmissionListItem = serde_json::from_value(value).unwrap();
612 assert_eq!(item.score_given, Some(1.0));
613 assert!(matches!(
614 item.grading_progress,
615 Some(GradingProgress::FullyGraded)
616 ));
617 }
618}