1use 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#[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#[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#[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 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 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 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); }
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); }
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}