db/models/
assignment_submission_output.rs

1use chrono::{DateTime, Utc};
2use sea_orm::entity::prelude::*;
3use sea_orm::{ActiveValue::Set, DatabaseConnection, EntityTrait};
4use std::fs;
5use std::io::ErrorKind;
6use std::path::PathBuf;
7use std::{env, io};
8
9use crate::models::assignment_submission;
10
11/// Represents the output generated by a student's submission for an assignment task.
12///
13/// Each output is linked to a specific assignment, task, and submitting user.
14/// Timestamps are included to track when the file was created and last updated.
15#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
16#[sea_orm(table_name = "assignment_submission_outputs")]
17pub struct Model {
18    #[sea_orm(primary_key)]
19    pub id: i64,
20    pub task_id: i64,
21    pub submission_id: i64,
22    pub path: String,
23    pub created_at: DateTime<Utc>,
24    pub updated_at: DateTime<Utc>,
25}
26
27#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
28pub enum Relation {
29    #[sea_orm(
30        belongs_to = "super::assignment_task::Entity",
31        from = "Column::TaskId",
32        to = "super::assignment_task::Column::Id"
33    )]
34    AssignmentTask,
35
36    #[sea_orm(
37        belongs_to = "super::user::Entity",
38        from = "Column::SubmissionId",
39        to = "super::user::Column::Id"
40    )]
41    AssignmentSubmission,
42}
43
44impl ActiveModelBehavior for ActiveModel {}
45
46impl Model {
47    pub fn storage_root() -> PathBuf {
48        let relative_root = env::var("ASSIGNMENT_STORAGE_ROOT")
49            .unwrap_or_else(|_| "data/assignment_files".to_string());
50
51        let mut dir = std::env::current_dir().expect("Failed to get current dir");
52
53        while let Some(parent) = dir.parent() {
54            if dir.ends_with("backend") {
55                return dir.join(relative_root);
56            }
57            dir = parent.to_path_buf();
58        }
59
60        PathBuf::from(relative_root)
61    }
62    pub fn full_directory_path(
63        module_id: i64,
64        assignment_id: i64,
65        user_id: i64,
66        attempt_number: i64,
67    ) -> PathBuf {
68        Self::storage_root()
69            .join(format!("module_{module_id}"))
70            .join(format!("assignment_{assignment_id}"))
71            .join("assignment_submissions")
72            .join(format!("user_{user_id}"))
73            .join(format!("attempt_{attempt_number}"))
74            .join("submission_output")
75    }
76
77    pub fn full_path(&self) -> PathBuf {
78        Self::storage_root().join(&self.path)
79    }
80
81    pub async fn delete_for_submission(
82        db: &DatabaseConnection,
83        submission_id: i64,
84    ) -> Result<(), DbErr> {
85        use sea_orm::QueryFilter;
86
87        // Find all outputs for the submission
88        let outputs = Entity::find()
89            .filter(Column::SubmissionId.eq(submission_id))
90            .all(db)
91            .await?;
92
93        for output in outputs {
94            // Delete file from disk
95            let path = output.full_path();
96            if path.exists() {
97                if let Err(e) = fs::remove_file(&path) {
98                    eprintln!("Failed to delete file {:?}: {}", path, e);
99                }
100            }
101
102            // Delete database entry
103            let am: ActiveModel = output.into();
104            am.delete(db).await?;
105        }
106
107        Ok(())
108    }
109
110    pub async fn save_file(
111        db: &DatabaseConnection,
112        task_id: i64,
113        submission_id: i64,
114        filename: &str,
115        bytes: &[u8],
116    ) -> Result<Self, DbErr> {
117        let now = Utc::now();
118
119        let partial = ActiveModel {
120            task_id: Set(task_id),
121            submission_id: Set(submission_id),
122            path: Set("".to_string()),
123            created_at: Set(now),
124            updated_at: Set(now),
125            ..Default::default()
126        };
127
128        let inserted: Model = partial.insert(db).await?;
129
130        let ext = PathBuf::from(filename)
131            .extension()
132            .map(|e| e.to_string_lossy().to_string());
133
134        let stored_filename = match ext {
135            Some(ext) => format!("{}.{}", inserted.id, ext),
136            None => inserted.id.to_string(),
137        };
138
139        // Get submission
140        let submission = super::assignment_submission::Entity::find_by_id(submission_id)
141            .one(db)
142            .await
143            .map_err(|e| DbErr::Custom(format!("DB error finding submission: {}", e)))?
144            .ok_or_else(|| DbErr::Custom("Submission not found".to_string()))?;
145
146        let assignment = super::assignment::Entity::find_by_id(submission.assignment_id)
147            .one(db)
148            .await
149            .map_err(|e| DbErr::Custom(format!("DB error finding assignment: {}", e)))?
150            .ok_or_else(|| DbErr::Custom("Assignment not found".to_string()))?;
151
152        let dir_path = Self::full_directory_path(
153            assignment.module_id,
154            assignment.id,
155            submission.user_id,
156            submission.attempt,
157        );
158        fs::create_dir_all(&dir_path)
159            .map_err(|e| DbErr::Custom(format!("Failed to create directory: {e}")))?;
160
161        let file_path = dir_path.join(&stored_filename);
162        let relative_path = file_path
163            .strip_prefix(Self::storage_root())
164            .unwrap()
165            .to_string_lossy()
166            .to_string();
167
168        fs::write(&file_path, bytes)
169            .map_err(|e| DbErr::Custom(format!("Failed to write file: {e}")))?;
170
171        let mut model: ActiveModel = inserted.into();
172        model.path = Set(relative_path);
173        model.updated_at = Set(Utc::now());
174
175        model.update(db).await
176    }
177
178    /// Reads the contents of a submission output file from disk,
179    /// given the module_id, assignment_id, user_id, submission_id
180    pub async fn get_output(
181        db: &DatabaseConnection,
182        module_id: i64,
183        assignment_id: i64,
184        submission_id: i64,
185    ) -> io::Result<Vec<(i64, String)>> {
186        let submission = assignment_submission::Entity::find_by_id(submission_id)
187            .one(db)
188            .await
189            .map_err(|e| io::Error::new(ErrorKind::Other, format!("DB error: {}", e)))?
190            .ok_or_else(|| io::Error::new(ErrorKind::NotFound, "Submission not found"))?;
191
192        let base_dir_path = Self::storage_root()
193            .join(format!("module_{module_id}"))
194            .join(format!("assignment_{assignment_id}"))
195            .join("assignment_submissions")
196            .join(format!("user_{}", submission.user_id))
197            .join(format!("attempt_{}", submission.attempt))
198            .join("submission_output");
199
200        if !base_dir_path.exists() {
201            return Err(io::Error::new(
202                ErrorKind::NotFound,
203                format!(
204                    "Submission output directory {:?} does not exist",
205                    base_dir_path
206                ),
207            ));
208        }
209
210        let mut results = Vec::new();
211        for entry in fs::read_dir(&base_dir_path)? {
212            let entry = entry?;
213            if entry.file_type()?.is_file() {
214                let file_path = entry.path();
215                if let Some(file_name) = file_path.file_stem().and_then(|n| n.to_str()) {
216                    if let Ok(output_id) = file_name.parse::<i64>() {
217                        // Find the output in the database to get the task_id
218                        let output = Entity::find_by_id(output_id)
219                            .one(db)
220                            .await
221                            .map_err(|e| {
222                                io::Error::new(ErrorKind::Other, format!("DB error: {}", e))
223                            })?
224                            .ok_or_else(|| {
225                                io::Error::new(ErrorKind::NotFound, "Output not found")
226                            })?;
227
228                        let content = fs::read_to_string(&file_path)?;
229                        results.push((output.task_id, content));
230                    }
231                }
232            }
233        }
234
235        Ok(results)
236    }
237}