1use crate::models::assignment;
2use chrono::{DateTime, Utc};
3use sea_orm::entity::prelude::*;
4use sea_orm::{ActiveValue::Set, DatabaseConnection, EntityTrait, QueryOrder};
5use std::collections::HashSet;
6use std::env;
7use std::fs;
8use std::path::PathBuf;
9use crate::models::user;
10
11#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
16#[sea_orm(table_name = "assignment_submissions")]
17pub struct Model {
18 #[sea_orm(primary_key)]
20 pub id: i64,
21 pub assignment_id: i64,
23 pub user_id: i64,
25 pub attempt: i64,
27 pub earned: i64,
29 pub total: i64,
31 pub filename: String,
33 pub file_hash: String,
35 pub path: String,
37 pub is_practice: bool,
39 pub created_at: DateTime<Utc>,
41 pub updated_at: DateTime<Utc>,
43}
44
45#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
47pub enum Relation {
48 #[sea_orm(
50 belongs_to = "super::assignment::Entity",
51 from = "Column::AssignmentId",
52 to = "super::assignment::Column::Id"
53 )]
54 Assignment,
55
56 #[sea_orm(
58 belongs_to = "super::user::Entity",
59 from = "Column::UserId",
60 to = "super::user::Column::Id"
61 )]
62 User,
63}
64
65impl ActiveModelBehavior for ActiveModel {}
67
68impl Related<user::Entity> for Entity {
69 fn to() -> RelationDef {
70 Relation::User.def()
71 }
72}
73
74impl Model {
75 pub fn storage_root() -> PathBuf {
83 env::var("ASSIGNMENT_STORAGE_ROOT")
84 .map(PathBuf::from)
85 .unwrap_or_else(|_| PathBuf::from("data/assignment_files"))
86 }
87
88 pub fn full_directory_path(
98 module_id: i64,
99 assignment_id: i64,
100 user_id: i64,
101 attempt: i64,
102 ) -> PathBuf {
103 Self::storage_root()
104 .join(format!("module_{module_id}"))
105 .join(format!("assignment_{assignment_id}"))
106 .join("assignment_submissions")
107 .join(format!("user_{user_id}"))
108 .join(format!("attempt_{attempt}"))
109 }
110
111 pub fn full_path(&self) -> PathBuf {
116 Self::storage_root().join(&self.path)
117 }
118
119 pub async fn save_file(
139 db: &DatabaseConnection,
140 assignment_id: i64,
141 user_id: i64,
142 attempt: i64,
143 earned: i64,
144 total: i64,
145 is_practice: bool,
146 filename: &str,
147 file_hash: &str,
148 bytes: &[u8],
149 ) -> Result<Self, DbErr> {
150 if earned > total {
151 return Err(DbErr::Custom("Earned score cannot be greater than total score".into()));
152 }
153
154 let now = Utc::now();
155
156 let partial = ActiveModel {
158 assignment_id: Set(assignment_id),
159 user_id: Set(user_id),
160 attempt: Set(attempt),
161 is_practice: Set(is_practice),
162 earned: Set(earned),
163 total: Set(total),
164 filename: Set(filename.to_string()),
165 file_hash: Set(file_hash.to_string()),
166 path: Set("".to_string()),
167 created_at: Set(now),
168 updated_at: Set(now),
169 ..Default::default()
170 };
171
172 let inserted: Model = partial.insert(db).await?;
173
174 let assignment = assignment::Entity::find_by_id(assignment_id)
176 .one(db)
177 .await?
178 .ok_or_else(|| DbErr::Custom("Assignment not found".into()))?;
179
180 let module_id = assignment.module_id;
181
182 let ext = PathBuf::from(filename)
184 .extension()
185 .map(|e| e.to_string_lossy().to_string());
186
187 let stored_filename = match ext {
188 Some(ext) => format!("{}.{}", inserted.id, ext),
189 None => inserted.id.to_string(),
190 };
191
192 let dir_path = Self::full_directory_path(module_id, assignment_id, user_id, attempt);
194 fs::create_dir_all(&dir_path)
195 .map_err(|e| DbErr::Custom(format!("Failed to create directory: {e}")))?;
196
197 let file_path = dir_path.join(&stored_filename);
198 let relative_path = file_path
199 .strip_prefix(Self::storage_root())
200 .unwrap()
201 .to_string_lossy()
202 .to_string();
203
204 fs::write(&file_path, bytes)
205 .map_err(|e| DbErr::Custom(format!("Failed to write file: {e}")))?;
206
207 let mut model: ActiveModel = inserted.into();
209 model.path = Set(relative_path);
210 model.updated_at = Set(Utc::now());
211
212 model.update(db).await
213 }
214
215 pub fn load_file(&self) -> Result<Vec<u8>, std::io::Error> {
221 fs::read(self.full_path())
222 }
223
224 pub fn delete_file_only(&self) -> Result<(), std::io::Error> {
230 fs::remove_file(self.full_path())
231 }
232
233 pub async fn find_by_assignment(
235 assignment_id: i64,
236 db: &DatabaseConnection,
237 ) -> Result<Vec<i64>, DbErr> {
238 let submissions = Entity::find()
239 .filter(Column::AssignmentId.eq(assignment_id as i32))
240 .all(db)
241 .await?;
242
243 Ok(submissions.into_iter().map(|s| s.id as i64).collect())
244 }
245
246 pub async fn get_latest_submissions_for_assignment(
247 db: &DatabaseConnection,
248 assignment_id: i64,
249 ) -> Result<Vec<Self>, DbErr> {
250 let all = Entity::find()
251 .filter(Column::AssignmentId.eq(assignment_id))
252 .order_by_asc(Column::UserId)
253 .order_by_desc(Column::Attempt)
254 .all(db)
255 .await?;
256
257 let mut seen = HashSet::new();
258 let mut latest = Vec::new();
259
260 for s in all {
261 if seen.insert(s.user_id) {
262 latest.push(s);
263 }
264 }
265 Ok(latest)
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::Model;
272 use crate::models::{assignment::AssignmentType, user::Model as UserModel};
273 use crate::test_utils::setup_test_db;
274 use chrono::Utc;
275 use sea_orm::{ActiveModelTrait, Set};
276 use std::env;
277 use tempfile::TempDir;
278
279 fn fake_bytes() -> Vec<u8> {
280 vec![0x50, 0x4B, 0x03, 0x04] }
282
283 fn override_storage_dir(temp: &TempDir) {
284 unsafe {
285 env::set_var("ASSIGNMENT_STORAGE_ROOT", temp.path());
286 }
287 }
288
289 #[tokio::test]
290 async fn test_save_load_delete_submission_file() {
291 let temp_dir = TempDir::new().unwrap();
292 override_storage_dir(&temp_dir);
293 let db = setup_test_db().await;
294
295 let user = UserModel::create(
297 &db,
298 "u63963920",
299 "[email protected]",
300 "password123",
301 false,
302 )
303 .await
304 .expect("Failed to insert user");
305
306 let module = crate::models::module::ActiveModel {
308 code: Set("COS629".to_string()),
309 year: Set(9463),
310 description: Set(Some("aqw".to_string())),
311 created_at: Set(Utc::now()),
312 updated_at: Set(Utc::now()),
313 ..Default::default()
314 }
315 .insert(&db)
316 .await
317 .expect("Failed to insert module");
318
319 let assignment = crate::models::assignment::Model::create(
321 &db,
322 module.id,
323 "Test fsh",
324 Some("Description"),
325 AssignmentType::Practical,
326 Utc::now(),
327 Utc::now(),
328 )
329 .await
330 .expect("Failed to insert assignment");
331
332 let submission = crate::models::assignment_submission::ActiveModel {
334 assignment_id: Set(assignment.id),
335 user_id: Set(user.id),
336 attempt: Set(1),
337 earned: Set(10),
338 total: Set(10),
339 filename: Set("solution.zip".to_string()),
340 file_hash: Set("hash123#".to_string()),
341 path: Set("".to_string()),
342 is_practice: Set(false),
343 created_at: Set(Utc::now()),
344 updated_at: Set(Utc::now()),
345 ..Default::default()
346 }
347 .insert(&db)
348 .await
349 .expect("Failed to insert submission");
350
351 let content = fake_bytes();
353 let file = Model::save_file(&db, submission.id, user.id, 6, 10, 10, false, "solution.zip", "hash123#", &content)
354 .await
355 .expect("Failed to save file");
356
357 assert_eq!(file.assignment_id, assignment.id);
358 assert_eq!(file.user_id, user.id);
359 assert!(file.path.contains("assignment_submissions"));
360
361 let full_path = Model::storage_root().join(&file.path);
363 assert!(full_path.exists());
364
365 let loaded = file.load_file().expect("Failed to load file");
367 assert_eq!(loaded, content);
368
369 file.delete_file_only().expect("Failed to delete file");
371 assert!(!full_path.exists());
372 }
373}