db/models/
assignment.rs

1//! Entity and business logic for managing assignments.
2//!
3//! This module defines the `Assignment` model, its relations, and
4//! methods for creating, editing, and filtering assignments.
5
6use sea_orm::entity::prelude::*;
7use sea_orm::{
8    ActiveModelTrait, ColumnTrait, Condition, DbErr, EntityTrait, IntoActiveModel,
9    JsonValue, PaginatorTrait, QueryFilter, QueryOrder, Set, QuerySelect
10};
11use crate::models::assignment_file::{Model as AssignmentFileModel, FileType};
12use crate::models::assignment_task::{Entity as TaskEntity, Column as TaskColumn};
13use chrono::{DateTime, Utc};
14use std::{env, fs, path::PathBuf};
15use serde::{Serialize, Deserialize};
16use strum::{Display, EnumIter, EnumString};
17
18/// Assignment model representing the `assignments` table in the database.
19#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
20#[sea_orm(table_name = "assignments")]
21pub struct Model {
22    #[sea_orm(primary_key)]
23    pub id: i64,
24    pub module_id: i64,
25    pub name: String,
26    pub description: Option<String>,
27    pub assignment_type: AssignmentType,
28    pub status: Status,
29    pub available_from: DateTime<Utc>,
30    pub due_date: DateTime<Utc>,
31    #[sea_orm(column_type = "Json", nullable)]
32    pub config: Option<JsonValue>,
33    pub created_at: DateTime<Utc>,
34    pub updated_at: DateTime<Utc>,
35}
36
37/// Defines the relationship between `Assignment` and `Module`.
38#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
39pub enum Relation {
40    #[sea_orm(
41        belongs_to = "super::module::Entity",
42        from = "Column::ModuleId",
43        to = "super::module::Column::Id"
44    )]
45    Module,
46}
47
48impl ActiveModelBehavior for ActiveModel {}
49
50#[derive(Debug, Clone, PartialEq, Display, EnumIter, EnumString, Serialize, Deserialize, DeriveActiveEnum)]
51#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "assignment_type_enum")]
52#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
53pub enum AssignmentType {
54    #[sea_orm(string_value = "assignment")]
55    Assignment,
56
57    #[sea_orm(string_value = "practical")]
58    Practical,
59}
60
61#[derive(Debug, Clone, PartialEq, Display, EnumIter, EnumString, Serialize, Deserialize, DeriveActiveEnum)]
62#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "assignment_status_enum")]
63#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
64pub enum Status {
65    #[sea_orm(string_value = "setup")]
66    Setup,
67    #[sea_orm(string_value = "ready")]
68    Ready,
69    #[sea_orm(string_value = "open")]
70    Open,
71    #[sea_orm(string_value = "closed")]
72    Closed,
73    #[sea_orm(string_value = "archived")]
74    Archived,
75}
76
77/// Detailed report of assignment readiness state.
78#[derive(Debug, Serialize, Deserialize)]
79pub struct ReadinessReport {
80    pub config_present: bool,
81    pub tasks_present: bool,
82    pub main_present: bool,
83    pub memo_present: bool,
84    pub makefile_present: bool,
85    pub memo_output_present: bool,
86    pub mark_allocator_present: bool,
87}
88
89impl ReadinessReport {
90    /// Convenience helper: true if all components are present.
91    pub fn is_ready(&self) -> bool {
92        self.config_present
93            && self.tasks_present
94            && self.main_present
95            && self.memo_present
96            && self.makefile_present
97            && self.memo_output_present
98            && self.mark_allocator_present
99    }
100}
101
102impl Model {
103    pub async fn create(
104        db: &DatabaseConnection,
105        module_id: i64,
106        name: &str,
107        description: Option<&str>,
108        assignment_type: AssignmentType,
109        available_from: DateTime<Utc>,
110        due_date: DateTime<Utc>,
111    ) -> Result<Self, DbErr> {
112        Self::validate_dates(available_from, due_date)?;
113
114        let active = ActiveModel {
115            module_id: Set(module_id),
116            name: Set(name.to_string()),
117            description: Set(description.map(|d| d.to_string())),
118            assignment_type: Set(assignment_type),
119            status: Set(Status::Setup),
120            available_from: Set(available_from),
121            due_date: Set(due_date),
122            created_at: Set(Utc::now()),
123            updated_at: Set(Utc::now()),
124            ..Default::default()
125        };
126
127        active.insert(db).await
128    }
129
130    pub async fn edit(
131        db: &DatabaseConnection,
132        id: i64,
133        module_id: i64,
134        name: &str,
135        description: Option<&str>,
136        assignment_type: AssignmentType,
137        available_from: DateTime<Utc>,
138        due_date: DateTime<Utc>,
139    ) -> Result<Self, DbErr> {
140        Self::validate_dates(available_from, due_date)?;
141
142        let mut assignment = Entity::find()
143            .filter(Column::Id.eq(id))
144            .filter(Column::ModuleId.eq(module_id))
145            .one(db)
146            .await?
147            .ok_or(DbErr::RecordNotFound("Assignment not found".to_string()))?
148            .into_active_model();
149
150        assignment.name = Set(name.to_string());
151        assignment.description = Set(description.map(|d| d.to_string()));
152        assignment.assignment_type = Set(assignment_type);
153        assignment.available_from = Set(available_from);
154        assignment.due_date = Set(due_date);
155        assignment.updated_at = Set(Utc::now());
156
157        assignment.update(db).await
158    }
159
160    pub async fn delete(db: &DatabaseConnection, id: i32, module_id: i32) -> Result<(), DbErr> {
161        let Some(model) = Entity::find()
162            .filter(Column::Id.eq(id))
163            .filter(Column::ModuleId.eq(module_id))
164            .one(db)
165            .await?
166        else {
167            return Err(DbErr::RecordNotFound(format!(
168                "Assignment {id} in module {module_id} not found"
169            )));
170        };
171
172        let active = model.into_active_model();
173        active.delete(db).await?;
174
175        let storage_root = env::var("ASSIGNMENT_STORAGE_ROOT")
176            .unwrap_or_else(|_| "data/assignment_files".to_string());
177
178        let assignment_dir = PathBuf::from(storage_root)
179            .join(format!("module_{module_id}"))
180            .join(format!("assignment_{id}"));
181
182        if assignment_dir.exists() {
183            if let Err(e) = fs::remove_dir_all(&assignment_dir) {
184                eprintln!("Warning: Failed to delete assignment directory {:?}: {}", assignment_dir, e);
185            }
186        }
187
188        Ok(())
189    }
190
191    pub async fn filter(
192        db: &DatabaseConnection,
193        page: u64,
194        per_page: u64,
195        sort_by: Option<String>,
196        query: Option<String>,
197    ) -> Result<Vec<Self>, DbErr> {
198        let mut query_builder = Entity::find();
199
200        if let Some(q) = query {
201            let pattern = format!("%{}%", q.to_lowercase());
202            query_builder = query_builder.filter(
203                Condition::any()
204                    .add(Expr::cust("LOWER(name)").like(&pattern))
205                    .add(Expr::cust("LOWER(description)").like(&pattern)),
206            );
207        }
208
209        if let Some(sort) = sort_by {
210            let (column, asc) = if sort.starts_with('-') {
211                (&sort[1..], false)
212            } else {
213                (sort.as_str(), true)
214            };
215
216            query_builder = match column {
217                "name" => {
218                    if asc {
219                        query_builder.order_by_asc(Column::Name)
220                    } else {
221                        query_builder.order_by_desc(Column::Name)
222                    }
223                }
224                "due_date" => {
225                    if asc {
226                        query_builder.order_by_asc(Column::DueDate)
227                    } else {
228                        query_builder.order_by_desc(Column::DueDate)
229                    }
230                }
231                "available_from" => {
232                    if asc {
233                        query_builder.order_by_asc(Column::AvailableFrom)
234                    } else {
235                        query_builder.order_by_desc(Column::AvailableFrom)
236                    }
237                }
238                _ => query_builder,
239            };
240        }
241
242        query_builder.paginate(db, per_page).fetch_page(page - 1).await
243    }
244
245    fn validate_dates(available_from: DateTime<Utc>, due_date: DateTime<Utc>) -> Result<(), DbErr> {
246        if due_date < available_from {
247            Err(DbErr::Custom(
248                "Due date cannot be before Available From date".into(),
249            ))
250        } else {
251            Ok(())
252        }
253    }
254
255    /// Computes a detailed readiness report for an assignment by checking if all required components are present.
256    ///
257    /// This function verifies the presence of all expected resources for an assignment:
258    /// - At least one task is defined in the database.
259    /// - A configuration file exists.
260    /// - A main file exists.
261    /// - A memo file exists.
262    /// - A makefile exists.
263    /// - A memo output file exists.
264    /// - A JSON mark allocator file exists.
265    ///
266    /// The returned [`ReadinessReport`] (or [`AssignmentReadiness`]) contains boolean flags for each component
267    /// and an `is_ready` field indicating whether the assignment is fully ready
268    /// (i.e., suitable to transition from `Setup` to `Ready` state).
269    ///
270    /// This function only checks readiness — it does **not** modify the assignment's status in the database.
271    ///
272    /// # Arguments
273    /// * `db` - A reference to the database connection.
274    /// * `module_id` - The ID of the module to which the assignment belongs.
275    /// * `assignment_id` - The ID of the assignment to check.
276    ///
277    /// # Returns
278    /// * `Ok(ReadinessReport)` with per-component readiness details.
279    /// * `Err(DbErr)` if a database error occurs while checking tasks.
280    ///
281    /// # Notes
282    /// File presence is checked on the file system under the expected directories for each file type.
283    /// Missing directories or I/O errors are treated as absence of the respective component.
284    pub async fn compute_readiness_report(
285        db: &DatabaseConnection,
286        module_id: i64,
287        assignment_id: i64,
288    ) -> Result<ReadinessReport, DbErr> {
289        let config_present = AssignmentFileModel::full_directory_path(
290            module_id,
291            assignment_id,
292            &FileType::Config,
293        )
294        .read_dir()
295        .map(|mut it| it.any(|f| f.is_ok()))
296        .unwrap_or(false);
297
298        let tasks_present = TaskEntity::find()
299            .filter(TaskColumn::AssignmentId.eq(assignment_id))
300            .limit(1)
301            .all(db)
302            .await
303            .map(|tasks| !tasks.is_empty())
304            .unwrap_or(false);
305
306        let main_present = AssignmentFileModel::full_directory_path(
307            module_id,
308            assignment_id,
309            &FileType::Main,
310        )
311        .read_dir()
312        .map(|mut it| it.any(|f| f.is_ok()))
313        .unwrap_or(false);
314
315        let memo_present = AssignmentFileModel::full_directory_path(
316            module_id,
317            assignment_id,
318            &FileType::Memo,
319        )
320        .read_dir()
321        .map(|mut it| it.any(|f| f.is_ok()))
322        .unwrap_or(false);
323
324        let makefile_present = AssignmentFileModel::full_directory_path(
325            module_id,
326            assignment_id,
327            &FileType::Makefile,
328        )
329        .read_dir()
330        .map(|mut it| it.any(|f| f.is_ok()))
331        .unwrap_or(false);
332
333        let memo_output_present = {
334            let base_path = AssignmentFileModel::storage_root()
335                .join(format!("module_{}", module_id))
336                .join(format!("assignment_{}", assignment_id))
337                .join("memo_output");
338
339            if let Ok(entries) = fs::read_dir(&base_path) {
340                entries.flatten().any(|entry| entry.path().is_file())
341            } else {
342                false
343            }
344        };
345
346        let mark_allocator_present = AssignmentFileModel::full_directory_path(
347            module_id,
348            assignment_id,
349            &FileType::MarkAllocator,
350        )
351        .read_dir()
352        .map(|it| {
353            it.flatten()
354                .any(|f| f.path().extension().map(|e| e == "json").unwrap_or(false))
355        })
356        .unwrap_or(false);
357
358        Ok(ReadinessReport {
359            config_present,
360            tasks_present,
361            main_present,
362            memo_present,
363            makefile_present,
364            memo_output_present,
365            mark_allocator_present,
366        })
367    }
368
369    /// Attempts to transition an assignment to `Ready` state if all readiness conditions are met.
370    ///
371    /// This function:
372    /// - Computes a full readiness report for the assignment.
373    /// - If all components are present (`is_ready` == true), it checks the current status.
374    /// - If the current status is `Setup`, it updates the status to `Ready` and updates `updated_at`.
375    /// - If already in `Ready`, `Open`, etc., it does not change the status.
376    ///
377    /// # Arguments
378    /// * `db` - A reference to the database connection.
379    /// * `module_id` - The ID of the module to which the assignment belongs.
380    /// * `assignment_id` - The ID of the assignment.
381    ///
382    /// # Returns
383    /// * `Ok(true)` if the assignment is fully ready (regardless of whether the status was updated).
384    /// * `Ok(false)` if the assignment is not ready.
385    /// * `Err(DbErr)` if a database error occurs.
386    pub async fn try_transition_to_ready(
387        db: &DatabaseConnection,
388        module_id: i64,
389        assignment_id: i64,
390    ) -> Result<bool, DbErr> {
391        let report = Self::compute_readiness_report(db, module_id, assignment_id).await?;
392
393        if report.is_ready() {
394            let mut active = Entity::find()
395                .filter(Column::Id.eq(assignment_id))
396                .filter(Column::ModuleId.eq(module_id))
397                .one(db)
398                .await?
399                .ok_or(DbErr::RecordNotFound("Assignment not found".into()))?
400                .into_active_model();
401
402            if active.status.as_ref() == &Status::Setup {
403                active.status = Set(Status::Ready);
404                active.updated_at = Set(Utc::now());
405                active.update(db).await?;
406            }
407        }
408
409        Ok(report.is_ready())
410    }
411
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use chrono::{TimeZone, Utc};
418    use crate::test_utils::setup_test_db;
419    use crate::models::module::ActiveModel as ModuleActiveModel;
420
421    fn sample_dates() -> (DateTime<Utc>, DateTime<Utc>) {
422        (
423            Utc.with_ymd_and_hms(2025, 6, 1, 9, 0, 0).unwrap(),
424            Utc.with_ymd_and_hms(2025, 6, 30, 17, 0, 0).unwrap(),
425        )
426    }
427
428    #[tokio::test]
429    async fn test_create_assignment() {
430        let db = setup_test_db().await;
431        let (from, due) = sample_dates();
432
433        let module = ModuleActiveModel {
434            code: Set("COS301".to_string()),
435            year: Set(2025),
436            description: Set(Some("Capstone Project".to_string())),
437            created_at: Set(Utc::now()),
438            updated_at: Set(Utc::now()),
439            ..Default::default()
440        }
441        .insert(&db)
442        .await
443        .expect("Failed to insert test module");
444
445        let assignment = Model::create(
446            &db,
447            module.id,
448            "Test Assignment",
449            Some("Intro to Testing"),
450            AssignmentType::Practical,
451            from,
452            due,
453        )
454        .await
455        .unwrap();
456
457        assert_eq!(assignment.module_id, module.id);
458        assert_eq!(assignment.name, "Test Assignment");
459        assert_eq!(assignment.status, Status::Setup); // status defaults to Setup
460    }
461
462    #[tokio::test]
463    async fn test_edit_assignment() {
464        let db = setup_test_db().await;
465        let (from, due) = sample_dates();
466
467        let module = ModuleActiveModel {
468            code: Set("COS301".to_string()),
469            year: Set(2025),
470            description: Set(Some("Capstone Project".to_string())),
471            created_at: Set(Utc::now()),
472            updated_at: Set(Utc::now()),
473            ..Default::default()
474        }
475        .insert(&db)
476        .await
477        .expect("Failed to insert test module");
478
479        let created = Model::create(
480            &db,
481            module.id,
482            "Initial",
483            Some("Initial Desc"),
484            AssignmentType::Assignment,
485            from,
486            due,
487        )
488        .await
489        .unwrap();
490
491        let updated = Model::edit(
492            &db,
493            created.id,
494            module.id,
495            "Updated Name",
496            Some("Updated Desc"),
497            AssignmentType::Practical,
498            from,
499            due,
500        )
501        .await
502        .unwrap();
503
504        assert_eq!(updated.name, "Updated Name");
505        assert_eq!(updated.status, created.status); // status remains unchanged
506    }
507
508    #[tokio::test]
509    async fn test_filter_assignments_by_query_and_sort() {
510        let db = setup_test_db().await;
511        let (from, due) = sample_dates();
512
513        let module = ModuleActiveModel {
514            code: Set("COS301".to_string()),
515            year: Set(2025),
516            description: Set(Some("Capstone Project".to_string())),
517            created_at: Set(Utc::now()),
518            updated_at: Set(Utc::now()),
519            ..Default::default()
520        }
521        .insert(&db)
522        .await
523        .expect("Failed to insert test module");
524
525        Model::create(
526            &db,
527            module.id,
528            "Rust Basics",
529            Some("Learn Rust"),
530            AssignmentType::Assignment,
531            from,
532            due,
533        )
534        .await
535        .unwrap();
536        Model::create(
537            &db,
538            module.id,
539            "Advanced Rust",
540            Some("Ownership and lifetimes"),
541            AssignmentType::Assignment,
542            from,
543            due,
544        )
545        .await
546        .unwrap();
547        Model::create(
548            &db,
549            module.id,
550            "Python Basics",
551            Some("Learn Python"),
552            AssignmentType::Assignment,
553            from,
554            due,
555        )
556        .await
557        .unwrap();
558
559        let rust_results = Model::filter(
560            &db,
561            module.id.try_into().unwrap(),
562            10,
563            Some("name".into()),
564            Some("rust".into()),
565        )
566        .await
567        .unwrap();
568
569        assert_eq!(rust_results.len(), 2);
570        assert!(rust_results
571            .iter()
572            .all(|a| a.name.to_lowercase().contains("rust")));
573    }
574
575}