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}
43
44#[derive(Debug, Serialize, Deserialize)]
50#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
51pub struct ExerciseTask {
52 pub task_id: Uuid,
53 pub order_number: i32,
54 pub assignment: serde_json::Value,
55 pub public_spec: Option<serde_json::Value>,
56 pub model_solution_spec: Option<serde_json::Value>,
57 pub exercise_service_slug: String,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
64#[serde(rename_all = "snake_case")]
65pub enum AnswerKind {
66 Json,
67 File,
68}
69
70#[derive(Debug, Serialize, Deserialize)]
73#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
74pub struct AnswerFile {
75 pub id: Uuid,
77 pub name: String,
79 pub mime: String,
80 pub size_bytes: Option<i64>,
83 pub order_number: Option<i32>,
86 pub url: String,
88}
89
90#[derive(Debug, Serialize, Deserialize)]
92#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
93pub struct UploadedFiles {
94 pub data_files: Vec<AnswerFile>,
95}
96
97#[derive(Debug, Serialize, Deserialize)]
100#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
101pub struct ExerciseSlideSubmission {
102 pub exercise_slide_id: Uuid,
103 pub exercise_task_id: Uuid,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub answer_kind: Option<AnswerKind>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub data_json: Option<serde_json::Value>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub data_files: Option<Vec<Uuid>>,
116}
117
118#[derive(Debug, Serialize, Deserialize)]
121#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
122pub struct ExerciseTaskSubmissionResult {
123 pub task_submission_id: Uuid,
124 pub slide_submission_id: Uuid,
125}
126
127#[derive(Debug, Serialize, Deserialize)]
129#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
130pub enum ExerciseTaskSubmissionStatus {
131 NoGradingYet,
132 Grading {
133 grading_progress: GradingProgress,
134 score_given: Option<f32>,
137 grading_started_at: Option<DateTime<Utc>>,
138 grading_completed_at: Option<DateTime<Utc>>,
139 feedback_json: Option<serde_json::Value>,
142 feedback_text: Option<String>,
144 },
145}
146
147#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
149#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
150pub enum GradingProgress {
151 Failed,
153 NotReady,
155 PendingManual,
157 Pending,
159 FullyGraded,
161}
162
163#[derive(Debug, Serialize, Deserialize)]
166#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
167pub struct ExerciseSlideSubmissionListItem {
168 pub id: Uuid,
169 pub exercise_id: Uuid,
170 pub created_at: DateTime<Utc>,
171 pub score_given: Option<f32>,
172 pub grading_progress: Option<GradingProgress>,
173}
174
175#[derive(Debug, Serialize, Deserialize)]
184#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
185pub struct SubmissionFiles {
186 pub data_files: Vec<AnswerFile>,
187}
188
189#[derive(Debug, Serialize, Deserialize)]
194#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
195pub struct CourseProgress {
196 pub course_id: Uuid,
198 pub exercises: Vec<ExerciseProgress>,
199}
200
201#[derive(Debug, Serialize, Deserialize)]
208#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
209pub struct ExerciseProgress {
210 pub exercise_id: Uuid,
211 pub score_given: f32,
213 pub score_maximum: i32,
215 pub completed: bool,
218 pub attempted: bool,
221}
222
223#[derive(Debug, Serialize, Deserialize)]
225#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
226pub struct PasteResult {
227 pub paste_url: String,
228}
229
230#[cfg(test)]
231mod test {
232 #![allow(clippy::unwrap_used)]
233 use super::*;
234 use serde_json::json;
235
236 #[test]
240 fn submission_status_serializes_externally_tagged() {
241 assert_eq!(
242 serde_json::to_value(ExerciseTaskSubmissionStatus::NoGradingYet).unwrap(),
243 json!("NoGradingYet"),
244 );
245
246 let graded = ExerciseTaskSubmissionStatus::Grading {
247 grading_progress: GradingProgress::FullyGraded,
248 score_given: Some(1.0),
249 grading_started_at: None,
250 grading_completed_at: None,
251 feedback_json: None,
252 feedback_text: Some("ok".to_string()),
253 };
254 assert_eq!(
255 serde_json::to_value(&graded).unwrap(),
256 json!({
257 "Grading": {
258 "grading_progress": "FullyGraded",
259 "score_given": 1.0,
260 "grading_started_at": null,
261 "grading_completed_at": null,
262 "feedback_json": null,
263 "feedback_text": "ok",
264 }
265 }),
266 );
267 }
268
269 #[test]
270 fn grading_progress_serializes_as_plain_strings() {
271 assert_eq!(
272 serde_json::to_value(GradingProgress::FullyGraded).unwrap(),
273 json!("FullyGraded"),
274 );
275 assert_eq!(
276 serde_json::to_value(GradingProgress::PendingManual).unwrap(),
277 json!("PendingManual"),
278 );
279 }
280
281 #[test]
284 fn exercise_slide_submission_names_files() {
285 let file_id = Uuid::max();
286 let value = serde_json::to_value(ExerciseSlideSubmission {
287 exercise_slide_id: Uuid::nil(),
288 exercise_task_id: Uuid::nil(),
289 answer_kind: Some(AnswerKind::File),
290 data_json: None,
291 data_files: Some(vec![file_id]),
292 })
293 .unwrap();
294 let obj = value.as_object().unwrap();
295 assert!(obj.contains_key("exercise_slide_id"));
296 assert!(obj.contains_key("exercise_task_id"));
297 assert_eq!(obj["answer_kind"], json!("file"));
298 assert_eq!(obj["data_files"], json!([file_id]));
299 assert!(!obj.contains_key("data_json"));
300 assert_eq!(obj.len(), 4);
301 }
302
303 #[test]
305 fn exercise_slide_submission_answer_kind_is_optional() {
306 let submission: ExerciseSlideSubmission = serde_json::from_value(json!({
307 "exercise_slide_id": Uuid::nil(),
308 "exercise_task_id": Uuid::nil(),
309 "data_json": { "opaque": "service owned" },
310 }))
311 .unwrap();
312 assert_eq!(submission.answer_kind, None);
313 assert_eq!(submission.data_files, None);
314 }
315
316 #[test]
318 fn submit_result_carries_both_submission_ids() {
319 let task_submission_id = Uuid::nil();
320 let slide_submission_id = Uuid::max();
321 let value = serde_json::to_value(ExerciseTaskSubmissionResult {
322 task_submission_id,
323 slide_submission_id,
324 })
325 .unwrap();
326 assert_eq!(
327 value,
328 json!({
329 "task_submission_id": task_submission_id,
330 "slide_submission_id": slide_submission_id,
331 })
332 );
333 }
334
335 #[test]
336 fn uploaded_and_submission_files_share_the_file_shape() {
337 let id = Uuid::max();
338 let file = || AnswerFile {
339 id,
340 name: "src/main.rs".to_string(),
341 mime: "application/octet-stream".to_string(),
342 size_bytes: Some(11),
343 order_number: Some(0),
344 url: "http://project-331.local/api/v0/files/tmc/abc".to_string(),
345 };
346 let expected = json!({
347 "data_files": [{
348 "id": id,
349 "name": "src/main.rs",
350 "mime": "application/octet-stream",
351 "size_bytes": 11,
352 "order_number": 0,
353 "url": "http://project-331.local/api/v0/files/tmc/abc",
354 }]
355 });
356 assert_eq!(
357 serde_json::to_value(UploadedFiles {
358 data_files: vec![file()]
359 })
360 .unwrap(),
361 expected
362 );
363 assert_eq!(
364 serde_json::to_value(SubmissionFiles {
365 data_files: vec![file()]
366 })
367 .unwrap(),
368 expected
369 );
370 }
371
372 #[test]
375 fn submission_files_may_be_empty() {
376 assert_eq!(
377 serde_json::to_value(SubmissionFiles {
378 data_files: Vec::new()
379 })
380 .unwrap(),
381 json!({ "data_files": [] })
382 );
383 }
384
385 #[test]
386 fn course_progress_shape() {
387 let value = serde_json::to_value(CourseProgress {
388 course_id: Uuid::nil(),
389 exercises: vec![ExerciseProgress {
390 exercise_id: Uuid::nil(),
391 score_given: 1.5,
392 score_maximum: 3,
393 completed: false,
394 attempted: true,
395 }],
396 })
397 .unwrap();
398 let obj = value.as_object().unwrap();
399 assert!(obj.contains_key("course_id"));
400 let exercises = obj["exercises"].as_array().unwrap();
401 let ex = exercises[0].as_object().unwrap();
402 assert_eq!(ex["score_given"], json!(1.5));
403 assert_eq!(ex["score_maximum"], json!(3));
404 assert_eq!(ex["completed"], json!(false));
405 assert_eq!(ex["attempted"], json!(true));
406 }
407
408 #[test]
409 fn submission_list_item_shape() {
410 let value = json!({
411 "id": Uuid::nil(),
412 "exercise_id": Uuid::nil(),
413 "created_at": "2026-07-21T00:00:00Z",
414 "score_given": 1.0,
415 "grading_progress": "FullyGraded"
416 });
417 let item: ExerciseSlideSubmissionListItem = serde_json::from_value(value).unwrap();
418 assert_eq!(item.score_given, Some(1.0));
419 assert!(matches!(
420 item.grading_progress,
421 Some(GradingProgress::FullyGraded)
422 ));
423 }
424}