db/models/
assignment_submission.rs

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/// Represents a user's submission for a specific assignment.
12///
13/// Each submission is linked to one assignment and one user.
14/// Timestamps are included to track when the submission was created and last updated.
15#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
16#[sea_orm(table_name = "assignment_submissions")]
17pub struct Model {
18    /// Primary key of the submission.
19    #[sea_orm(primary_key)]
20    pub id: i64,
21    /// ID of the related assignment.
22    pub assignment_id: i64,
23    /// ID of the user who submitted the assignment.
24    pub user_id: i64,
25    /// Attempt number
26    pub attempt: i64,
27    /// The score earned by the user.
28    pub earned: i64,
29    /// The total possible score.
30    pub total: i64,
31    /// The original filename uploaded by the user.
32    pub filename: String,
33    /// The hash of the submitted files.
34    pub file_hash: String,
35    /// Relative file path from the storage root.
36    pub path: String,
37    /// Is this submission a practice submission?
38    pub is_practice: bool,
39    /// Timestamp when the submission was created.
40    pub created_at: DateTime<Utc>,
41    /// Timestamp when the submission was last updated.
42    pub updated_at: DateTime<Utc>,
43}
44
45/// Defines relationships between `assignment_submissions` and other tables.
46#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
47pub enum Relation {
48    /// Link to the related assignment.
49    #[sea_orm(
50        belongs_to = "super::assignment::Entity",
51        from = "Column::AssignmentId",
52        to = "super::assignment::Column::Id"
53    )]
54    Assignment,
55
56    /// Link to the user who submitted the assignment.
57    #[sea_orm(
58        belongs_to = "super::user::Entity",
59        from = "Column::UserId",
60        to = "super::user::Column::Id"
61    )]
62    User,
63}
64
65/// Custom behavior for the active model (currently using default behavior).
66impl ActiveModelBehavior for ActiveModel {}
67
68impl Related<user::Entity> for Entity {
69    fn to() -> RelationDef {
70        Relation::User.def()
71    }
72}
73
74impl Model {
75    /// Returns the root directory used for storing assignment submissions on disk.
76    ///
77    /// # Returns
78    /// - `PathBuf` pointing to the base directory.
79    ///
80    /// Uses the `ASSIGNMENT_STORAGE_ROOT` environment variable if set,
81    /// otherwise defaults to `data/assignment_files`.
82    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    /// Constructs the full directory path for a submission based on
89    /// its module and assignment identifiers.
90    ///
91    /// # Arguments
92    /// - `module_id`: ID of the module containing the assignment.
93    /// - `assignment_id`: ID of the specific assignment.
94    ///
95    /// # Returns
96    /// - `PathBuf` with the complete directory path.
97    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    /// Computes the absolute path to the stored file on disk.
112    ///
113    /// # Returns
114    /// - `PathBuf` pointing to the file location.
115    pub fn full_path(&self) -> PathBuf {
116        Self::storage_root().join(&self.path)
117    }
118
119    /// Saves a file to disk and creates or updates its metadata in the database.
120    ///
121    /// This method:
122    /// 1. Creates a temporary DB entry.
123    /// 2. Looks up the associated assignment and module.
124    /// 3. Saves the file with a generated name on disk.
125    /// 4. Updates the DB entry with the file path.
126    ///
127    /// # Arguments
128    /// - `db`: Reference to the active database connection.
129    /// - `assignment_id`: ID of the assignment this submission is for.
130    /// - `user_id`: ID of the user submitting.
131    /// - `attempt`: Attempt number,
132    /// - `filename`: The original filename as submitted.
133    /// - `bytes`: The file content as a byte slice.
134    ///
135    /// # Returns
136    /// - `Ok(Model)`: The complete, updated `Model` representing the saved file.
137    /// - `Err(DbErr)`: If any database or filesystem operation fails.
138    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        // Step 1: Insert placeholder model
157        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        // Step 2: Lookup module_id
175        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        // Step 3: Construct stored filename
183        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        // Step 4: Write file to disk
193        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        // Step 5: Update DB with path
208        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    /// Loads the contents of the stored file from disk.
216    ///
217    /// # Returns
218    /// - `Ok(Vec<u8>)`: The file contents as bytes.
219    /// - `Err(std::io::Error)`: If reading the file fails.
220    pub fn load_file(&self) -> Result<Vec<u8>, std::io::Error> {
221        fs::read(self.full_path())
222    }
223
224    /// Deletes the file from disk without removing the database record.
225    ///
226    /// # Returns
227    /// - `Ok(())`: If the file was successfully deleted.
228    /// - `Err(std::io::Error)`: If the file deletion failed.
229    pub fn delete_file_only(&self) -> Result<(), std::io::Error> {
230        fs::remove_file(self.full_path())
231    }
232
233    /// Find all submission IDs for a given assignment
234    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] // ZIP header (PK...)
281    }
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        // Create dummy user
296        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        // Create dummy module
307        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        // Create dummy assignment
320        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        // Create dummy assignment_submission
333        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        // Save file via submission
352        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        // Confirm file written
362        let full_path = Model::storage_root().join(&file.path);
363        assert!(full_path.exists());
364
365        // Load content and verify
366        let loaded = file.load_file().expect("Failed to load file");
367        assert_eq!(loaded, content);
368
369        // Delete file
370        file.delete_file_only().expect("Failed to delete file");
371        assert!(!full_path.exists());
372    }
373}