Skip to main content

headless_lms_server/controllers/main_frontend/credit_registration_admin/
audit.rs

1//! The Audit tab: every hand action on the pipeline, whoever took it.
2//!
3//! Two actor kinds share this log. Teachers may retry and resend on their own course, so filtering
4//! by `actor_role` is what makes "which teachers are retrying, and on what" answerable at all — an
5//! admin acting while looking at a course is otherwise indistinguishable from the course's teacher.
6
7use headless_lms_models::credit_registration_admin_actions::{
8    self, CreditRegistrationAdminAction, CreditRegistrationAdminActionFilters,
9    CreditRegistrationAdminActionListRow, CreditRegistrationAdminActionTarget,
10};
11use headless_lms_models::credit_registrations::CreditRegistrationState;
12use utoipa::ToSchema;
13
14use crate::prelude::*;
15
16use super::authorize_credit_registration_admin;
17
18#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
19pub struct CreditRegistrationAdminActionRow {
20    pub id: Uuid,
21    pub created_at: DateTime<Utc>,
22    pub action: CreditRegistrationAdminAction,
23    pub target_kind: CreditRegistrationAdminActionTarget,
24    /// `None` for a phase target, which is named by `target_phase`, and for a bulk action over a
25    /// selection, whose ids are in `details`.
26    pub target_id: Option<Uuid>,
27    pub target_phase: Option<String>,
28    pub actor_user_id: Uuid,
29    pub actor_first_name: Option<String>,
30    pub actor_last_name: Option<String>,
31    pub actor_email: Option<String>,
32    /// `global_admin` or `course_teacher`.
33    pub actor_role: String,
34    /// The course whose edit permission authorised a teacher action.
35    pub actor_course_id: Option<Uuid>,
36    /// The course the action was about: a teacher's own, a course target, or the course of a
37    /// targeted registration.
38    pub course_name: Option<String>,
39    /// The student a registration- or link-targeted action was about, so a row names who it
40    /// concerned rather than only an id prefix. `None` for a phase, course or module target.
41    pub target_user_id: Option<Uuid>,
42    pub target_first_name: Option<String>,
43    pub target_last_name: Option<String>,
44    /// In full, like the actor's.
45    pub target_email: Option<String>,
46    pub reason: Option<String>,
47    pub before_state: Option<CreditRegistrationState>,
48    pub after_state: Option<CreditRegistrationState>,
49    pub details: Option<serde_json::Value>,
50    pub affected_row_count: Option<i32>,
51}
52
53#[derive(Debug, Deserialize)]
54pub struct ListAdminActionsQuery {
55    page: Option<u32>,
56    limit: Option<u32>,
57    action: Option<Vec<CreditRegistrationAdminAction>>,
58    actor_user_id: Option<Uuid>,
59    actor_role: Option<String>,
60    target_kind: Option<CreditRegistrationAdminActionTarget>,
61    target_id: Option<Uuid>,
62    target_phase: Option<String>,
63    user_id: Option<Uuid>,
64    course_id: Option<Uuid>,
65    from: Option<DateTime<Utc>>,
66    to: Option<DateTime<Utc>>,
67}
68
69/**
70GET `/api/v0/main-frontend/credit-registration-admin/audit` - A page of the global action log,
71newest first.
72
73Covers admin and course-teacher actors alike, and every target kind: a registration, a course
74module, a course, a phase, a student-number link or its token.
75*/
76#[instrument(skip(pool))]
77#[utoipa::path(
78    get,
79    path = "/audit",
80    operation_id = "listCreditRegistrationAdminActions",
81    tag = "credit-registration-admin",
82    params(
83        ("page" = Option<u32>, Query, description = "Page number, from 1"),
84        ("limit" = Option<u32>, Query, description = "Rows per page"),
85        ("action" = Option<Vec<CreditRegistrationAdminAction>>, Query, description = "Action kinds; repeat the parameter for several"),
86        ("actor_user_id" = Option<Uuid>, Query, description = "Who acted"),
87        ("actor_role" = Option<String>, Query, description = "global_admin or course_teacher"),
88        ("target_kind" = Option<CreditRegistrationAdminActionTarget>, Query, description = "What was acted on"),
89        ("target_id" = Option<Uuid>, Query, description = "One target row"),
90        ("target_phase" = Option<String>, Query, description = "One pipeline phase"),
91        ("user_id" = Option<Uuid>, Query, description = "Every action about this student: their registrations, student number links, tokens, and actions naming them or their student number"),
92        ("course_id" = Option<Uuid>, Query, description = "Actions on this course, and actions its teachers took"),
93        ("from" = Option<DateTime<Utc>>, Query, description = "Taken at or after"),
94        ("to" = Option<DateTime<Utc>>, Query, description = "Taken at or before")
95    ),
96    responses(
97        (status = 200, description = "A page of the action log", body = Page<CreditRegistrationAdminActionRow>)
98    )
99)]
100pub async fn list_credit_registration_admin_actions(
101    user: AuthUser,
102    pool: web::Data<PgPool>,
103    query: MultiQuery<ListAdminActionsQuery>,
104) -> ControllerResult<web::Json<Page<CreditRegistrationAdminActionRow>>> {
105    let mut conn = pool.acquire().await?;
106    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
107
108    let pagination = parse_pagination(query.page, query.limit, 50)?;
109    let actor_role = non_empty(query.actor_role.as_deref());
110    let target_phase = non_empty(query.target_phase.as_deref());
111    let filters = CreditRegistrationAdminActionFilters {
112        actions: query.action.as_deref(),
113        actor_user_id: query.actor_user_id,
114        actor_role,
115        target_kind: query.target_kind,
116        target_id: query.target_id,
117        target_phase,
118        user_id: query.user_id,
119        course_id: query.course_id,
120        from: query.from,
121        to: query.to,
122    };
123    let rows = credit_registration_admin_actions::get_page(
124        &mut conn,
125        &filters,
126        pagination.limit(),
127        pagination.offset(),
128    )
129    .await?;
130    let total_count = rows.first().map_or(0, |row| row.total_count);
131
132    token.authorized_ok(web::Json(Page::new(
133        pagination,
134        rows.into_iter().map(to_action_row).collect(),
135        total_count,
136    )))
137}
138
139fn to_action_row(row: CreditRegistrationAdminActionListRow) -> CreditRegistrationAdminActionRow {
140    CreditRegistrationAdminActionRow {
141        actor_first_name: row.actor_first_name,
142        actor_last_name: row.actor_last_name,
143        actor_email: row.actor_email,
144        course_name: row.course_name,
145        target_user_id: row.target_user_id,
146        target_first_name: row.target_first_name,
147        target_last_name: row.target_last_name,
148        target_email: row.target_email,
149        id: row.action.id,
150        created_at: row.action.created_at,
151        action: row.action.action,
152        target_kind: row.action.target_kind,
153        target_id: row.action.target_id,
154        target_phase: row.action.target_phase,
155        actor_user_id: row.action.actor_user_id,
156        actor_role: row.action.actor_role,
157        actor_course_id: row.action.actor_course_id,
158        reason: row.action.reason,
159        before_state: row.action.before_state,
160        after_state: row.action.after_state,
161        details: row.action.details,
162        affected_row_count: row.action.affected_row_count,
163    }
164}
165
166/// Distinct earlier reasons offered for one action.
167const EARLIER_REASON_LIMIT: i64 = 20;
168
169/// The query of `GET /audit/earlier-reasons`.
170#[derive(Debug, Deserialize)]
171pub struct EarlierReasonsQuery {
172    action: CreditRegistrationAdminAction,
173}
174
175/**
176GET `/api/v0/main-frontend/credit-registration-admin/audit/earlier-reasons` - The reasons given
177before for an action, to pick from instead of typing the same one again.
178
179Distinct after trimming, most recently used first, at most 20.
180*/
181#[instrument(skip(pool))]
182#[utoipa::path(
183    get,
184    path = "/audit/earlier-reasons",
185    operation_id = "getCreditRegistrationEarlierReasons",
186    tag = "credit-registration-admin",
187    params(("action" = CreditRegistrationAdminAction, Query, description = "The action the reasons were given for")),
188    responses(
189        (status = 200, description = "Earlier reasons, most recently used first", body = Vec<String>)
190    )
191)]
192pub async fn get_credit_registration_earlier_reasons(
193    user: AuthUser,
194    pool: web::Data<PgPool>,
195    query: web::Query<EarlierReasonsQuery>,
196) -> ControllerResult<web::Json<Vec<String>>> {
197    let mut conn = pool.acquire().await?;
198    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
199    let reasons = credit_registration_admin_actions::get_earlier_reasons(
200        &mut conn,
201        query.action,
202        EARLIER_REASON_LIMIT,
203    )
204    .await?;
205    token.authorized_ok(web::Json(reasons))
206}
207
208pub fn _add_routes(cfg: &mut ServiceConfig) {
209    cfg.route(
210        "/audit",
211        web::get().to(list_credit_registration_admin_actions),
212    )
213    .route(
214        "/audit/earlier-reasons",
215        web::get().to(get_credit_registration_earlier_reasons),
216    );
217}