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}
27
28#[derive(Debug, Serialize, Deserialize)]
32#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
33pub struct ExerciseSlide {
34 pub slide_id: Uuid,
35 pub exercise_id: Uuid,
36 pub course_id: Uuid,
38 pub exercise_name: String,
39 pub exercise_order_number: i32,
40 pub deadline: Option<DateTime<Utc>>,
41 pub tasks: Vec<ExerciseTask>,
42 #[serde(default)]
45 pub page_url: Option<String>,
46 #[serde(default)]
48 pub chapter: Option<ExerciseChapter>,
49}
50
51#[derive(Debug, Serialize, Deserialize)]
54#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
55pub struct ExerciseChapter {
56 pub id: Uuid,
57 pub name: String,
58 pub chapter_number: i32,
60}
61
62#[derive(Debug, Serialize, Deserialize)]
68#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
69pub struct ExerciseTask {
70 pub task_id: Uuid,
71 pub order_number: i32,
72 pub assignment: serde_json::Value,
73 pub public_spec: Option<serde_json::Value>,
74 pub model_solution_spec: Option<serde_json::Value>,
75 pub exercise_service_slug: String,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
82#[serde(rename_all = "snake_case")]
83pub enum AnswerKind {
84 Json,
85 File,
86}
87
88#[derive(Debug, Serialize, Deserialize)]
91#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
92pub struct AnswerFile {
93 pub id: Uuid,
95 pub name: String,
97 pub mime: String,
98 pub size_bytes: Option<i64>,
101 pub order_number: Option<i32>,
104 pub url: String,
106}
107
108#[derive(Debug, Serialize, Deserialize)]
110#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
111pub struct UploadedFiles {
112 pub data_files: Vec<AnswerFile>,
113}
114
115#[derive(Debug, Serialize, Deserialize)]
118#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
119pub struct ExerciseSlideSubmission {
120 pub exercise_slide_id: Uuid,
121 pub exercise_task_id: Uuid,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub answer_kind: Option<AnswerKind>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub data_json: Option<serde_json::Value>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub data_files: Option<Vec<Uuid>>,
134}
135
136#[derive(Debug, Serialize, Deserialize)]
139#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
140pub struct ExerciseTaskSubmissionResult {
141 pub task_submission_id: Uuid,
142 pub slide_submission_id: Uuid,
143}
144
145#[derive(Debug, Serialize, Deserialize)]
147#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
148pub enum ExerciseTaskSubmissionStatus {
149 NoGradingYet,
150 Grading {
151 grading_progress: GradingProgress,
152 score_given: Option<f32>,
155 grading_started_at: Option<DateTime<Utc>>,
156 grading_completed_at: Option<DateTime<Utc>>,
157 feedback_json: Option<serde_json::Value>,
160 feedback_text: Option<String>,
162 #[serde(default)]
166 exercise_progress: Option<ExerciseProgress>,
167 },
168}
169
170#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
172#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
173pub enum GradingProgress {
174 Failed,
176 NotReady,
178 PendingManual,
180 Pending,
182 FullyGraded,
184}
185
186#[derive(Debug, Serialize, Deserialize)]
189#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
190pub struct ExerciseSlideSubmissionListItem {
191 pub id: Uuid,
192 pub exercise_id: Uuid,
193 pub created_at: DateTime<Utc>,
194 pub score_given: Option<f32>,
195 pub grading_progress: Option<GradingProgress>,
196}
197
198#[derive(Debug, Serialize, Deserialize)]
207#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
208pub struct SubmissionFiles {
209 pub data_files: Vec<AnswerFile>,
210}
211
212#[derive(Debug, Serialize, Deserialize)]
217#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
218pub struct CourseProgress {
219 pub course_id: Uuid,
221 pub exercises: Vec<ExerciseProgress>,
222}
223
224#[derive(Debug, Serialize, Deserialize)]
230#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
231pub struct ExerciseProgress {
232 pub exercise_id: Uuid,
233 pub score_given: f32,
235 pub score_maximum: i32,
237 pub completed: bool,
239 pub attempted: bool,
242 #[serde(default)]
244 pub standing: Option<ExerciseStanding>,
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
249#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
250pub enum ExerciseStanding {
251 NotAttempted,
253 Attempted,
255 Passed,
257 OutOfTries,
260}
261
262#[derive(Debug, Serialize, Deserialize)]
264#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
265pub struct PasteResult {
266 pub paste_url: String,
267}
268
269#[cfg(test)]
270mod test {
271 #![allow(clippy::unwrap_used)]
272 use super::*;
273 use serde_json::json;
274
275 #[test]
279 fn submission_status_serializes_externally_tagged() {
280 assert_eq!(
281 serde_json::to_value(ExerciseTaskSubmissionStatus::NoGradingYet).unwrap(),
282 json!("NoGradingYet"),
283 );
284
285 let graded = ExerciseTaskSubmissionStatus::Grading {
286 grading_progress: GradingProgress::FullyGraded,
287 score_given: Some(1.0),
288 grading_started_at: None,
289 grading_completed_at: None,
290 feedback_json: None,
291 feedback_text: Some("ok".to_string()),
292 exercise_progress: Some(ExerciseProgress {
293 exercise_id: Uuid::nil(),
294 score_given: 1.0,
295 score_maximum: 1,
296 completed: true,
297 attempted: true,
298 standing: Some(ExerciseStanding::Passed),
299 }),
300 };
301 assert_eq!(
302 serde_json::to_value(&graded).unwrap(),
303 json!({
304 "Grading": {
305 "grading_progress": "FullyGraded",
306 "score_given": 1.0,
307 "grading_started_at": null,
308 "grading_completed_at": null,
309 "feedback_json": null,
310 "feedback_text": "ok",
311 "exercise_progress": {
312 "exercise_id": Uuid::nil(),
313 "score_given": 1.0,
314 "score_maximum": 1,
315 "completed": true,
316 "attempted": true,
317 "standing": "Passed",
318 },
319 }
320 }),
321 );
322 }
323
324 #[test]
326 fn grading_without_exercise_progress_still_parses() {
327 let status: ExerciseTaskSubmissionStatus = serde_json::from_value(json!({
328 "Grading": {
329 "grading_progress": "FullyGraded",
330 "score_given": 1.0,
331 "grading_started_at": null,
332 "grading_completed_at": null,
333 "feedback_json": null,
334 "feedback_text": null,
335 }
336 }))
337 .unwrap();
338 assert!(matches!(
339 status,
340 ExerciseTaskSubmissionStatus::Grading {
341 exercise_progress: None,
342 ..
343 }
344 ));
345 }
346
347 #[test]
349 fn exercise_slide_without_page_url_or_chapter_still_parses() {
350 let slide: ExerciseSlide = serde_json::from_value(json!({
351 "slide_id": Uuid::nil(),
352 "exercise_id": Uuid::nil(),
353 "course_id": Uuid::nil(),
354 "exercise_name": "name",
355 "exercise_order_number": 0,
356 "deadline": null,
357 "tasks": [],
358 }))
359 .unwrap();
360 assert_eq!(slide.page_url, None);
361 assert!(slide.chapter.is_none());
362 }
363
364 #[test]
365 fn grading_progress_serializes_as_plain_strings() {
366 assert_eq!(
367 serde_json::to_value(GradingProgress::FullyGraded).unwrap(),
368 json!("FullyGraded"),
369 );
370 assert_eq!(
371 serde_json::to_value(GradingProgress::PendingManual).unwrap(),
372 json!("PendingManual"),
373 );
374 }
375
376 #[test]
379 fn exercise_slide_submission_names_files() {
380 let file_id = Uuid::max();
381 let value = serde_json::to_value(ExerciseSlideSubmission {
382 exercise_slide_id: Uuid::nil(),
383 exercise_task_id: Uuid::nil(),
384 answer_kind: Some(AnswerKind::File),
385 data_json: None,
386 data_files: Some(vec![file_id]),
387 })
388 .unwrap();
389 let obj = value.as_object().unwrap();
390 assert!(obj.contains_key("exercise_slide_id"));
391 assert!(obj.contains_key("exercise_task_id"));
392 assert_eq!(obj["answer_kind"], json!("file"));
393 assert_eq!(obj["data_files"], json!([file_id]));
394 assert!(!obj.contains_key("data_json"));
395 assert_eq!(obj.len(), 4);
396 }
397
398 #[test]
400 fn exercise_slide_submission_answer_kind_is_optional() {
401 let submission: ExerciseSlideSubmission = serde_json::from_value(json!({
402 "exercise_slide_id": Uuid::nil(),
403 "exercise_task_id": Uuid::nil(),
404 "data_json": { "opaque": "service owned" },
405 }))
406 .unwrap();
407 assert_eq!(submission.answer_kind, None);
408 assert_eq!(submission.data_files, None);
409 }
410
411 #[test]
413 fn submit_result_carries_both_submission_ids() {
414 let task_submission_id = Uuid::nil();
415 let slide_submission_id = Uuid::max();
416 let value = serde_json::to_value(ExerciseTaskSubmissionResult {
417 task_submission_id,
418 slide_submission_id,
419 })
420 .unwrap();
421 assert_eq!(
422 value,
423 json!({
424 "task_submission_id": task_submission_id,
425 "slide_submission_id": slide_submission_id,
426 })
427 );
428 }
429
430 #[test]
431 fn uploaded_and_submission_files_share_the_file_shape() {
432 let id = Uuid::max();
433 let file = || AnswerFile {
434 id,
435 name: "src/main.rs".to_string(),
436 mime: "application/octet-stream".to_string(),
437 size_bytes: Some(11),
438 order_number: Some(0),
439 url: "http://project-331.local/api/v0/files/tmc/abc".to_string(),
440 };
441 let expected = json!({
442 "data_files": [{
443 "id": id,
444 "name": "src/main.rs",
445 "mime": "application/octet-stream",
446 "size_bytes": 11,
447 "order_number": 0,
448 "url": "http://project-331.local/api/v0/files/tmc/abc",
449 }]
450 });
451 assert_eq!(
452 serde_json::to_value(UploadedFiles {
453 data_files: vec![file()]
454 })
455 .unwrap(),
456 expected
457 );
458 assert_eq!(
459 serde_json::to_value(SubmissionFiles {
460 data_files: vec![file()]
461 })
462 .unwrap(),
463 expected
464 );
465 }
466
467 #[test]
470 fn submission_files_may_be_empty() {
471 assert_eq!(
472 serde_json::to_value(SubmissionFiles {
473 data_files: Vec::new()
474 })
475 .unwrap(),
476 json!({ "data_files": [] })
477 );
478 }
479
480 #[test]
481 fn course_progress_shape() {
482 let value = serde_json::to_value(CourseProgress {
483 course_id: Uuid::nil(),
484 exercises: vec![ExerciseProgress {
485 exercise_id: Uuid::nil(),
486 score_given: 1.5,
487 score_maximum: 3,
488 completed: false,
489 attempted: true,
490 standing: Some(ExerciseStanding::OutOfTries),
491 }],
492 })
493 .unwrap();
494 let obj = value.as_object().unwrap();
495 assert!(obj.contains_key("course_id"));
496 let exercises = obj["exercises"].as_array().unwrap();
497 let ex = exercises[0].as_object().unwrap();
498 assert_eq!(ex["score_given"], json!(1.5));
499 assert_eq!(ex["score_maximum"], json!(3));
500 assert_eq!(ex["completed"], json!(false));
501 assert_eq!(ex["attempted"], json!(true));
502 assert_eq!(ex["standing"], json!("OutOfTries"));
503 }
504
505 #[test]
507 fn progress_without_standing_still_parses() {
508 let progress: ExerciseProgress = serde_json::from_value(json!({
509 "exercise_id": Uuid::nil(),
510 "score_given": 0.0,
511 "score_maximum": 1,
512 "completed": false,
513 "attempted": false,
514 }))
515 .unwrap();
516 assert_eq!(progress.standing, None);
517 }
518
519 #[test]
520 fn submission_list_item_shape() {
521 let value = json!({
522 "id": Uuid::nil(),
523 "exercise_id": Uuid::nil(),
524 "created_at": "2026-07-21T00:00:00Z",
525 "score_given": 1.0,
526 "grading_progress": "FullyGraded"
527 });
528 let item: ExerciseSlideSubmissionListItem = serde_json::from_value(value).unwrap();
529 assert_eq!(item.score_given, Some(1.0));
530 assert!(matches!(
531 item.grading_progress,
532 Some(GradingProgress::FullyGraded)
533 ));
534 }
535}