api/routes/modules/assignments/
put.rs

1//! Assignment management routes.
2//!
3//! Provides endpoints for managing assignments within a module, including:
4//! - Editing assignments (`PUT /api/modules/{module_id}/assignments/{assignment_id}`)
5//! - Bulk updating assignments (`PUT /api/modules/{module_id}/assignments/bulk`)
6//! - Transitioning assignment status (`Open` / `Close`)
7//!
8//! Access control:
9//! - Only lecturers or admins assigned to a module can edit or bulk update assignments.
10//! - Status transitions are controlled and enforced by the system.
11//!
12//! Notes:
13//! - Direct modification of `status` is not allowed through edit/bulk endpoints; status updates are automatic.
14//! - All date fields must be in ISO 8601 format (RFC 3339).
15
16use axum::{extract::{State, Path}, http::StatusCode, response::IntoResponse, Json};
17use chrono::{DateTime, Utc};
18use util::state::AppState;
19use crate::response::ApiResponse;
20use db::models::assignment::{self, AssignmentType, Status};
21use sea_orm::{ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, QueryFilter, IntoActiveModel, DbErr};
22use super::common::{AssignmentRequest, AssignmentResponse, BulkUpdateRequest, BulkUpdateResult};
23
24/// PUT /api/modules/{module_id}/assignments/{assignment_id}
25///
26/// Edit an existing assignment in a module. Only accessible by lecturers or admins assigned to the module.
27///
28/// This endpoint allows updating general details of the assignment but **does not allow editing its status**.
29/// Status transitions (e.g., from `setup` to `ready`) are handled automatically based on readiness checks.
30///
31/// ### Path Parameters
32/// - `module_id` (`i64`): The ID of the module containing the assignment.
33/// - `assignment_id` (`i64`): The ID of the assignment to edit.
34///
35/// ### Request Body (JSON)
36/// - `name` (`string`, required): The new name of the assignment.
37/// - `description` (`string`, optional): The new description of the assignment.
38/// - `assignment_type` (`string`, required): The type of assignment. Must be either `"assignment"` or `"practical"`.
39/// - `available_from` (`string`, required): The new date/time from which the assignment is available (ISO 8601 format).
40/// - `due_date` (`string`, required): The new due date/time for the assignment (ISO 8601 format).
41///
42/// ### Responses
43///
44/// - `200 OK`
45/// ```json
46/// {
47///   "success": true,
48///   "message": "Assignment updated successfully",
49///   "data": { /* updated assignment details */ }
50/// }
51/// ```
52///
53/// - `400 Bad Request`
54/// ```json
55/// {
56///   "success": false,
57///   "message": "Invalid available_from datetime format"
58/// }
59/// ```
60///
61/// - `404 Not Found`
62/// ```json
63/// {
64///   "success": false,
65///   "message": "Assignment not found"
66/// }
67/// ```
68///
69/// - `500 Internal Server Error`
70/// ```json
71/// {
72///   "success": false,
73///   "message": "Failed to update assignment"
74/// }
75/// ```
76/// ### Notes
77/// - The `status` field of the assignment cannot be updated with this endpoint.
78/// - Status is managed automatically by the system when all readiness checks pass.
79pub async fn edit_assignment(
80    State(app_state): State<AppState>,
81    Path((module_id, assignment_id)): Path<(i64, i64)>,
82    Json(req): Json<AssignmentRequest>,
83) -> impl IntoResponse {
84    let db = app_state.db();
85
86    let available_from = match DateTime::parse_from_rfc3339(&req.available_from)
87        .map(|dt| dt.with_timezone(&Utc))
88    {
89        Ok(dt) => dt,
90        Err(_) => {
91            return (
92                StatusCode::BAD_REQUEST,
93                Json(ApiResponse::<AssignmentResponse>::error(
94                    "Invalid available_from datetime format",
95                )),
96            );
97        }
98    };
99
100    let due_date = match DateTime::parse_from_rfc3339(&req.due_date)
101        .map(|dt| dt.with_timezone(&Utc))
102    {
103        Ok(dt) => dt,
104        Err(_) => {
105            return (
106                StatusCode::BAD_REQUEST,
107                Json(ApiResponse::<AssignmentResponse>::error(
108                    "Invalid due_date datetime format",
109                )),
110            );
111        }
112    };
113
114    let assignment_type = match req.assignment_type.parse::<AssignmentType>() {
115        Ok(t) => t,
116        Err(_) => {
117            return (
118                StatusCode::BAD_REQUEST,
119                Json(ApiResponse::<AssignmentResponse>::error(
120                    "assignment_type must be 'assignment' or 'practical'",
121                )),
122            );
123        }
124    };
125
126    match assignment::Model::edit(
127        db,
128        assignment_id,
129        module_id,
130        &req.name,
131        req.description.as_deref(),
132        assignment_type,
133        available_from,
134        due_date,
135    )
136    .await
137    {
138        Ok(updated) => {
139            let response = AssignmentResponse::from(updated);
140            (
141                StatusCode::OK,
142                Json(ApiResponse::success(
143                    response,
144                    "Assignment updated successfully",
145                )),
146            )
147        }
148        Err(DbErr::RecordNotFound(_)) => (
149            StatusCode::NOT_FOUND,
150            Json(ApiResponse::<AssignmentResponse>::error("Assignment not found")),
151        ),
152        Err(DbErr::Custom(msg)) => (
153            StatusCode::BAD_REQUEST,
154            Json(ApiResponse::<AssignmentResponse>::error(&msg)),
155        ),
156        Err(_) => (
157            StatusCode::INTERNAL_SERVER_ERROR,
158            Json(ApiResponse::<AssignmentResponse>::error(
159                "Failed to update assignment",
160            )),
161        ),
162    }
163}
164
165/// PUT /api/modules/:module_id/assignments/bulk
166///
167/// Bulk update fields on multiple assignments.
168/// Only accessible by lecturers or admins assigned to the module.
169///
170/// ### Path Parameters
171/// - `module_id` (i64): The ID of the module.
172///
173/// ### Request Body (JSON)
174/// ```json
175/// {
176///   "assignment_ids": [123, 124, 125],
177///   "available_from": "2024-01-01T00:00:00Z",
178///   "due_date": "2024-02-01T00:00:00Z"
179/// }
180/// ```
181///
182/// ### Notes
183/// - The `status` field of assignments cannot be updated using this endpoint.
184/// - Status transitions are handled automatically by the system based on readiness checks.
185///
186/// ### Responses
187///
188/// - `200 OK` (all succeeded)
189/// ```json
190/// {
191///   "success": true,
192///   "message": "Updated 3/3 assignments",
193///   "data": {
194///     "updated": 3,
195///     "failed": []
196///   }
197/// }
198/// ```
199///
200/// - `200 OK` (partial failure)
201/// ```json
202/// {
203///   "success": true,
204///   "message": "Updated 2/3 assignments",
205///   "data": {
206///     "updated": 2,
207///     "failed": [
208///       {
209///         "id": 125,
210///         "error": "Assignment not found"
211///       }
212///     ]
213///   }
214/// }
215/// ```
216///
217/// - `400 Bad Request`
218/// ```json
219/// {
220///   "success": false,
221///   "message": "No assignment IDs provided"
222/// }
223/// ```
224pub async fn bulk_update_assignments(
225    State(app_state): State<AppState>,
226    Path(module_id): Path<i64>,
227    Json(req): Json<BulkUpdateRequest>,
228) -> impl IntoResponse {
229    let db = app_state.db();
230
231    if req.assignment_ids.is_empty() {
232        return (
233            StatusCode::BAD_REQUEST,
234            Json(ApiResponse::error("No assignment IDs provided")),
235        );
236    }
237
238    let mut updated = 0;
239    let mut failed = Vec::new();
240
241    for id in &req.assignment_ids {
242        let res = assignment::Entity::find()
243            .filter(assignment::Column::Id.eq(*id))
244            .filter(assignment::Column::ModuleId.eq(module_id))
245            .one(db)
246            .await;
247
248        match res {
249            Ok(Some(model)) => {
250                let mut active = model.into_active_model();
251
252                if let Some(available_from) = &req.available_from {
253                    if let Ok(dt) = DateTime::parse_from_rfc3339(available_from) {
254                        active.available_from = Set(dt.with_timezone(&Utc));
255                    }
256                }
257
258                if let Some(due_date) = &req.due_date {
259                    if let Ok(dt) = DateTime::parse_from_rfc3339(due_date) {
260                        active.due_date = Set(dt.with_timezone(&Utc));
261                    }
262                }
263
264                active.updated_at = Set(Utc::now());
265
266                if active.update(db).await.is_ok() {
267                    updated += 1;
268                } else {
269                    failed.push(crate::routes::modules::assignments::common::FailedUpdate {
270                        id: *id,
271                        error: "Failed to save updated assignment".into(),
272                    });
273                }
274            }
275            Ok(None) => failed.push(crate::routes::modules::assignments::common::FailedUpdate {
276                id: *id,
277                error: "Assignment not found".into(),
278            }),
279            Err(e) => failed.push(crate::routes::modules::assignments::common::FailedUpdate {
280                id: *id,
281                error: e.to_string(),
282            }),
283        }
284    }
285
286    let result = BulkUpdateResult { updated, failed };
287
288    let message = format!("Updated {}/{} assignments", updated, req.assignment_ids.len());
289
290    (
291        StatusCode::OK,
292        Json(ApiResponse::success(result, message)),
293    )
294}
295
296/// PUT /api/modules/:module_id/assignments/:assignment_id/open
297///
298/// Transition an assignment to `Open`
299///
300/// Only works if current status is `Ready`, `Closed`, or `Archived`.
301pub async fn open_assignment(
302    State(app_state): State<AppState>,
303    Path((module_id, assignment_id)): Path<(i64, i64)>,
304) -> impl IntoResponse {
305    let db = app_state.db();
306
307    let assignment = assignment::Entity::find()
308        .filter(assignment::Column::Id.eq(assignment_id))
309        .filter(assignment::Column::ModuleId.eq(module_id))
310        .one(db)
311        .await;
312
313    match assignment {
314        Ok(Some(model)) => {
315            if !matches!(
316                model.status,
317                Status::Ready | Status::Closed | Status::Archived
318            ) {
319                return (
320                    StatusCode::BAD_REQUEST,
321                    Json(ApiResponse::<()>::error(
322                        "Assignment can only be opened if it is in Ready, Closed, or Archived state",
323                    )),
324                );
325            }
326
327            let mut active = model.into_active_model();
328            active.status = Set(Status::Open);
329            active.updated_at = Set(Utc::now());
330
331            if active.update(db).await.is_ok() {
332                (
333                    StatusCode::OK,
334                    Json(ApiResponse::<()>::success(
335                        (),
336                        "Assignment successfully opened",
337                    )),
338                )
339            } else {
340                (
341                    StatusCode::INTERNAL_SERVER_ERROR,
342                    Json(ApiResponse::<()>::error("Failed to update assignment")),
343                )
344            }
345        }
346        Ok(None) => (
347            StatusCode::NOT_FOUND,
348            Json(ApiResponse::<()>::error("Assignment not found")),
349        ),
350        Err(e) => (
351            StatusCode::INTERNAL_SERVER_ERROR,
352            Json(ApiResponse::<()>::error(&format!(
353                "Database error: {}",
354                e
355            ))),
356        ),
357    }
358}
359
360/// PUT /api/modules/:module_id/assignments/:assignment_id/close
361///
362/// Transition an assignment from `Open` to `Closed`
363///
364/// Only works if current status is `Open`.
365pub async fn close_assignment(
366    State(app_state): State<AppState>,
367    Path((module_id, assignment_id)): Path<(i64, i64)>,
368) -> impl IntoResponse {
369    let db = app_state.db();
370    let assignment = assignment::Entity::find()
371        .filter(assignment::Column::Id.eq(assignment_id))
372        .filter(assignment::Column::ModuleId.eq(module_id))
373        .one(db)
374        .await;
375
376    match assignment {
377        Ok(Some(model)) => {
378            if model.status != Status::Open {
379                return (
380                    StatusCode::BAD_REQUEST,
381                    Json(ApiResponse::<()>::error(
382                        "Assignment can only be closed if it is in Open state",
383                    )),
384                );
385            }
386
387            let mut active = model.into_active_model();
388            active.status = Set(Status::Closed);
389            active.updated_at = Set(Utc::now());
390
391            if active.update(db).await.is_ok() {
392                (
393                    StatusCode::OK,
394                    Json(ApiResponse::<()>::success(
395                        (),
396                        "Assignment successfully closed",
397                    )),
398                )
399            } else {
400                (
401                    StatusCode::INTERNAL_SERVER_ERROR,
402                    Json(ApiResponse::<()>::error("Failed to update assignment")),
403                )
404            }
405        }
406        Ok(None) => (
407            StatusCode::NOT_FOUND,
408            Json(ApiResponse::<()>::error("Assignment not found")),
409        ),
410        Err(e) => (
411            StatusCode::INTERNAL_SERVER_ERROR,
412            Json(ApiResponse::<()>::error(&format!(
413                "Database error: {}",
414                e
415            ))),
416        ),
417    }
418}