api/routes/me/
grades.rs

1use axum::{
2    extract::{Query, State},
3    http::StatusCode,
4    response::IntoResponse,
5    Extension, Json,
6};
7use common::format_validation_errors;
8use sea_orm::{
9    ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, RelationTrait, QuerySelect, FromQueryResult,
10};
11use serde::{Deserialize, Serialize};
12use validator::Validate;
13use crate::{auth::claims::AuthUser, response::ApiResponse};
14use db::models::{
15    assignment,
16    assignment_submission::{self, Column as GradeColumn, Entity as GradeEntity},
17    module,
18    user,
19    user_module_role::{self, Column as RoleColumn, Role},
20};
21use util::state::AppState;
22
23#[derive(Debug, Deserialize, Validate)]
24pub struct GetGradesQuery {
25    #[validate(range(min = 1))]
26    pub page: Option<u64>,
27    #[validate(range(min = 1, max = 100))]
28    pub per_page: Option<u64>,
29    pub query: Option<String>,
30    pub role: Option<Role>,
31    pub year: Option<i32>,
32    pub sort: Option<String>,
33}
34
35#[derive(Debug, Serialize)]
36pub struct GradeItem {
37    pub id: i64,
38    pub score: Score,
39    pub percentage: f64,
40    pub created_at: String,
41    pub updated_at: String,
42    pub module: ModuleInfo,
43    pub assignment: AssignmentInfo,
44    pub user: UserInfo,
45}
46
47#[derive(Debug, Serialize)]
48pub struct Score {
49    pub earned: i64,
50    pub total: i64,
51}
52
53#[derive(Debug, Serialize)]
54pub struct ModuleInfo {
55    pub id: i64,
56    pub code: String,
57}
58
59#[derive(Debug, Serialize)]
60pub struct AssignmentInfo {
61    pub id: i64,
62    pub title: String,
63}
64
65#[derive(Debug, Serialize)]
66pub struct UserInfo {
67    pub id: i64,
68    pub username: String,
69}
70
71#[derive(Debug, Serialize)]
72pub struct GetGradesResponse {
73    pub grades: Vec<GradeItem>,
74    pub page: u64,
75    pub per_page: u64,
76    pub total: u64,
77}
78
79#[derive(Debug, FromQueryResult)]
80pub struct GradeWithRelations {
81    pub id: i64,
82    pub earned: i64,
83    pub total: i64,
84    pub created_at: chrono::NaiveDateTime,
85    pub updated_at: chrono::NaiveDateTime,
86    pub user_id: i64,
87    pub assignment_id: i64,
88    pub assignment_name: String,
89    pub module_id: i64,
90    pub module_code: String,
91    pub username: String,
92}
93
94/// GET /api/me/grades
95///
96/// Retrieves a paginated list of grades for the authenticated user.
97/// The behavior of this endpoint changes based on the `role` query parameter,
98/// allowing users to view grades based on their permissions.
99///
100/// ### Authorization
101/// Requires a valid bearer token.
102///
103/// ### Query Parameters
104/// - `page` (optional, u64, min: 1): The page number for pagination. Defaults to 1.
105/// - `per_page` (optional, u64, min: 1, max: 100): The number of items per page. Defaults to 20.
106/// - `query` (optional, string): A search term to filter grades by assignment title, student username, or module code.
107/// - `role` (optional, string): The role to filter by. Can be `Student`, `Tutor`, `AssistantLecturer`, or `Lecturer`. Defaults to `Student`.
108///   - If `Student`, returns only the authenticated user's grades.
109///   - If `Lecturer`, `Tutor`, etc., returns grades for all students in modules where the user holds that role.
110/// - `year` (optional, i32): Filters grades by the module's academic year.
111/// - `sort` (optional, string): A comma-separated list of fields to sort by. Prefix with `-` for descending order.
112///   - Allowed fields: `score`, `created_at`.
113///
114/// ### Response: 200 OK
115/// Returns a paginated list of grades.
116/// ```json
117/// {
118///   "success": true,
119///   "message": "Grades retrieved successfully",
120///   "data": {
121///     "grades": [
122///       {
123///         "id": 1,
124///         "score": {
125///           "earned": 85,
126///           "total": 100
127///         },
128///         "percentage": 85.0,
129///         "created_at": "2025-08-17T10:00:00",
130///         "updated_at": "2025-08-17T11:30:00",
131///         "module": {
132///           "id": 101,
133///           "code": "CS101"
134///         },
135///         "assignment": {
136///           "id": 201,
137///           "title": "Introduction to Programming"
138///         },
139///         "user": {
140///           "id": 42,
141///           "username": "student_user"
142///         }
143///       }
144///     ],
145///     "page": 1,
146///     "per_page": 20,
147///     "total": 1
148///   }
149/// }
150/// ```
151///
152/// ### Error Responses
153/// - `400 Bad Request`: Invalid query parameters (e.g., `page` out of range).
154/// - `403 Forbidden`: Missing or invalid authentication token.
155/// - `500 Internal Server Error`: Database or other internal errors.
156pub async fn get_my_grades(
157    State(app_state): State<AppState>,
158    Extension(user): Extension<AuthUser>,
159    Query(query): Query<GetGradesQuery>,
160) -> impl IntoResponse {
161    if let Err(e) = query.validate() {
162        return (
163            StatusCode::BAD_REQUEST,
164            Json(ApiResponse::<()>::error(format_validation_errors(&e))),
165        )
166            .into_response();
167    }
168
169    let db = app_state.db();
170    let caller_id = user.0.sub;
171
172    let page = query.page.unwrap_or(1);
173    let per_page = query.per_page.unwrap_or(20);
174
175    let mut query_builder = GradeEntity::find()
176        .column_as(assignment_submission::Column::Id, "id")
177        .column_as(assignment_submission::Column::Earned, "earned")
178        .column_as(assignment_submission::Column::Total, "total")
179        .column_as(assignment_submission::Column::CreatedAt, "created_at")
180        .column_as(assignment_submission::Column::UpdatedAt, "updated_at")
181        .column_as(assignment_submission::Column::UserId, "user_id")
182        .column_as(assignment_submission::Column::AssignmentId, "assignment_id")
183        .column_as(assignment::Column::Name, "assignment_name")
184        .column_as(module::Column::Id, "module_id")
185        .column_as(module::Column::Code, "module_code")
186        .column_as(user::Column::Username, "username")
187        .join(
188            sea_orm::JoinType::InnerJoin,
189            assignment_submission::Relation::Assignment.def(),
190        )
191        .join(
192            sea_orm::JoinType::InnerJoin,
193            assignment::Relation::Module.def(),
194        )
195        .join(
196            sea_orm::JoinType::InnerJoin,
197            assignment_submission::Relation::User.def(),
198        )
199        .join(
200            sea_orm::JoinType::InnerJoin,
201            module::Relation::UserModuleRole.def(),
202        )
203        .filter(user_module_role::Column::UserId.eq(caller_id));
204
205    let role_to_check = query.role.unwrap_or(Role::Student);
206
207    match role_to_check {
208        Role::Student => {
209            query_builder = query_builder.filter(GradeColumn::UserId.eq(caller_id));
210        }
211        Role::Lecturer | Role::AssistantLecturer | Role::Tutor => {
212            query_builder = query_builder.filter(RoleColumn::Role.eq(role_to_check));
213        }
214    }
215
216    let mut condition = Condition::all();
217    
218    if let Some(q) = &query.query {
219        let pattern = format!("%{}%", q.to_lowercase());
220        condition = condition.add(
221            Condition::any()
222                .add(assignment::Column::Name.like(&pattern))
223                .add(user::Column::Username.like(&pattern))
224                .add(module::Column::Code.like(&pattern)),
225        );
226    }
227
228    if let Some(year) = query.year {
229        condition = condition.add(module::Column::Year.eq(year));
230    }
231
232    query_builder = query_builder.filter(condition);
233
234    if let Some(sort) = &query.sort {
235        for s in sort.split(',') {
236            let (field, order) = if s.starts_with('-') {
237                (&s[1..], sea_orm::Order::Desc)
238            } else {
239                (s, sea_orm::Order::Asc)
240            };
241
242            match field {
243                "score" => {
244                    query_builder = query_builder.order_by(
245                        sea_orm::prelude::Expr::cust(
246                            "COALESCE((earned * 1.0) / NULLIF(total, 0), 0)",
247                        ),
248                        order,
249                    );
250                }
251                "created_at" => {
252                    query_builder = query_builder.order_by(GradeColumn::CreatedAt, order);
253                }
254                _ => {}
255            }
256        }
257    } else {
258        query_builder = query_builder.order_by(GradeColumn::CreatedAt, sea_orm::Order::Desc);
259    }
260    
261    query_builder = query_builder.order_by(GradeColumn::Id, sea_orm::Order::Asc);
262
263    let paginator = query_builder
264        .into_model::<GradeWithRelations>()
265        .paginate(db, per_page);
266    
267    let total = paginator.num_items().await.unwrap_or(0);
268    let grades: Vec<GradeWithRelations> = paginator
269        .fetch_page(page - 1)
270        .await
271        .unwrap_or_default();
272
273    let grades: Vec<GradeItem> = grades
274        .into_iter()
275        .map(|grade| {
276            let percentage = if grade.total > 0 {
277                (grade.earned as f64 / grade.total as f64) * 100.0
278            } else {
279                0.0
280            };
281
282            GradeItem {
283                id: grade.id,
284                score: Score {
285                    earned: grade.earned,
286                    total: grade.total,
287                },
288                percentage,
289                created_at: grade.created_at.to_string(),
290                updated_at: grade.updated_at.to_string(),
291                module: ModuleInfo {
292                    id: grade.module_id,
293                    code: grade.module_code,
294                },
295                assignment: AssignmentInfo {
296                    id: grade.assignment_id,
297                    title: grade.assignment_name,
298                },
299                user: UserInfo {
300                    id: grade.user_id,
301                    username: grade.username,
302                },
303            }
304        })
305        .collect();
306
307    if grades.is_empty() {
308        return (
309            StatusCode::OK,
310            Json(ApiResponse::success(
311                GetGradesResponse {
312                    grades: vec![],
313                    page,
314                    per_page,
315                    total: 0,
316                },
317                "No grades found",
318            )),
319        ).into_response();
320    }
321
322    (
323        StatusCode::OK,
324        Json(ApiResponse::success(
325            GetGradesResponse {
326                grades,
327                page,
328                per_page,
329                total,
330            },
331            "Grades retrieved successfully",
332        )),
333    )
334        .into_response()
335}