1use headless_lms_models::credit_registration_events::CreditRegistrationEventKind;
9use headless_lms_models::credit_registrations::{
10 self, AdminCreditRegistrationFilters, AdminCreditRegistrationSort, CreditRegistrationErrorCode,
11 CreditRegistrationState,
12};
13use headless_lms_models::suotar_api_calls::{
14 self, SuotarApiCall, SuotarApiCallFilters, SuotarApiCallPageRow, SuotarEndpoint,
15};
16use utoipa::ToSchema;
17
18use crate::prelude::*;
19
20use super::authorize_credit_registration_admin;
21
22const MAX_REFERENCED_ROWS: i64 = 500;
25
26#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
27pub struct SuotarApiCallRow {
28 pub id: Uuid,
29 pub endpoint: SuotarEndpoint,
30 pub started_at: DateTime<Utc>,
31 pub duration_ms: Option<i32>,
32 pub http_status: Option<i32>,
35 pub succeeded: bool,
36 pub request_item_count: i32,
37 pub ok_item_count: i32,
38 pub error_item_count: i32,
39 pub request_level_error_code: Option<String>,
41 pub worker_name: String,
42 pub credit_registration_ids: Vec<Uuid>,
43}
44
45#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
46pub struct SuotarApiCallsPage {
47 #[serde(flatten)]
48 pub page: Page<SuotarApiCallRow>,
49 pub worker_names: Vec<String>,
51}
52
53#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
56pub struct SuotarApiCallLedgerReference {
57 pub credit_registration_id: Uuid,
58 pub request_item_id: String,
60 pub user_id: Uuid,
61 pub first_name: Option<String>,
62 pub last_name: Option<String>,
63 pub email: Option<String>,
64 pub student_number: Option<String>,
65 pub course_id: Uuid,
66 pub course_name: String,
67 pub state: CreditRegistrationState,
68 pub error_code: Option<CreditRegistrationErrorCode>,
69}
70
71#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
73pub struct SuotarApiCallEvent {
74 pub id: Uuid,
75 pub credit_registration_id: Uuid,
76 pub created_at: DateTime<Utc>,
77 pub kind: CreditRegistrationEventKind,
78 pub from_state: Option<CreditRegistrationState>,
79 pub to_state: Option<CreditRegistrationState>,
80 pub error_code: Option<CreditRegistrationErrorCode>,
81 pub details: Option<serde_json::Value>,
83}
84
85#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
86pub struct SuotarApiCallDetails {
87 pub call: SuotarApiCallRow,
88 pub request_body_sample: Option<serde_json::Value>,
90 pub response_body_sample: Option<serde_json::Value>,
91 pub error_message: Option<String>,
93 pub ledger_references: Vec<SuotarApiCallLedgerReference>,
94 pub events: Vec<SuotarApiCallEvent>,
95}
96
97#[derive(Debug, Deserialize)]
98pub struct ListSuotarApiCallsQuery {
99 page: Option<u32>,
100 limit: Option<u32>,
101 endpoint: Option<SuotarEndpoint>,
102 succeeded: Option<bool>,
103 worker_name: Option<String>,
104 started_after: Option<DateTime<Utc>>,
105 started_before: Option<DateTime<Utc>>,
106 credit_registration_id: Option<Uuid>,
107}
108
109#[instrument(skip(pool))]
117#[utoipa::path(
118 get,
119 path = "/suotar-api-calls",
120 operation_id = "listSuotarApiCalls",
121 tag = "credit-registration-admin",
122 params(
123 ("page" = Option<u32>, Query, description = "Page number, from 1"),
124 ("limit" = Option<u32>, Query, description = "Rows per page"),
125 ("endpoint" = Option<SuotarEndpoint>, Query, description = "One study registry endpoint"),
126 ("succeeded" = Option<bool>, Query, description = "Only calls that did, or did not, succeed"),
127 ("worker_name" = Option<String>, Query, description = "The phase or manual action that made the call"),
128 ("started_after" = Option<DateTime<Utc>>, Query, description = "Started at or after"),
129 ("started_before" = Option<DateTime<Utc>>, Query, description = "Started at or before"),
130 ("credit_registration_id" = Option<Uuid>, Query, description = "Only calls that carried this ledger row")
131 ),
132 responses(
133 (status = 200, description = "A page of the call log", body = SuotarApiCallsPage)
134 )
135)]
136pub async fn list_suotar_api_calls(
137 user: AuthUser,
138 pool: web::Data<PgPool>,
139 query: web::Query<ListSuotarApiCallsQuery>,
140) -> ControllerResult<web::Json<SuotarApiCallsPage>> {
141 let mut conn = pool.acquire().await?;
142 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
143
144 let pagination = parse_pagination(query.page, query.limit, 50)?;
145 let filters = SuotarApiCallFilters {
146 endpoint: query.endpoint,
147 succeeded: query.succeeded,
148 worker_name: non_empty(query.worker_name.as_deref()).map(str::to_string),
149 started_after: query.started_after,
150 started_before: query.started_before,
151 credit_registration_id: query.credit_registration_id,
152 };
153 let rows =
154 suotar_api_calls::get_page(&mut conn, &filters, pagination.limit(), pagination.offset())
155 .await?;
156 let total_count = rows.first().map_or(0, |row| row.total_count);
157 let worker_names = suotar_api_calls::get_worker_names(&mut conn).await?;
158
159 token.authorized_ok(web::Json(SuotarApiCallsPage {
160 page: Page::new(
161 pagination,
162 rows.into_iter().map(to_call_row).collect(),
163 total_count,
164 ),
165 worker_names,
166 }))
167}
168
169#[instrument(skip(pool))]
174#[utoipa::path(
175 get,
176 path = "/suotar-api-calls/{suotar_api_call_id}",
177 operation_id = "getSuotarApiCall",
178 tag = "credit-registration-admin",
179 params(("suotar_api_call_id" = Uuid, Path, description = "Study registry call id")),
180 responses(
181 (status = 200, description = "The call and everything it touched", body = SuotarApiCallDetails),
182 (status = 404, description = "No such call")
183 )
184)]
185pub async fn get_suotar_api_call(
186 user: AuthUser,
187 pool: web::Data<PgPool>,
188 suotar_api_call_id: web::Path<Uuid>,
189) -> ControllerResult<web::Json<SuotarApiCallDetails>> {
190 let mut conn = pool.acquire().await?;
191 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
192
193 let call = suotar_api_calls::get_by_id(&mut conn, *suotar_api_call_id).await?;
194 let ledger_references =
195 resolve_ledger_references(&mut conn, &call.credit_registration_ids).await?;
196 let events = models::credit_registration_events::get_by_suotar_api_call_id(
197 &mut conn,
198 *suotar_api_call_id,
199 )
200 .await?
201 .into_iter()
202 .map(|event| SuotarApiCallEvent {
203 id: event.id,
204 credit_registration_id: event.credit_registration_id,
205 created_at: event.created_at,
206 kind: event.kind,
207 from_state: event.from_state,
208 to_state: event.to_state,
209 error_code: event.error_code,
210 details: event.details,
211 })
212 .collect();
213
214 token.authorized_ok(web::Json(SuotarApiCallDetails {
215 request_body_sample: call.request_body_sample.clone(),
216 response_body_sample: call.response_body_sample.clone(),
217 error_message: call.error_message.clone(),
218 call: to_call_row_from_full(&call),
219 ledger_references,
220 events,
221 }))
222}
223
224async fn resolve_ledger_references(
227 conn: &mut PgConnection,
228 credit_registration_ids: &[Uuid],
229) -> Result<Vec<SuotarApiCallLedgerReference>, ControllerError> {
230 if credit_registration_ids.is_empty() {
231 return Ok(Vec::new());
232 }
233 let rows = credit_registrations::get_admin_facing(
234 conn,
235 &AdminCreditRegistrationFilters {
236 credit_registration_ids: Some(credit_registration_ids),
237 include_superseded: true,
238 ..AdminCreditRegistrationFilters::default()
239 },
240 AdminCreditRegistrationSort::default(),
241 MAX_REFERENCED_ROWS,
242 0,
243 )
244 .await?;
245 let mut by_id: std::collections::HashMap<Uuid, _> =
246 rows.into_iter().map(|row| (row.id, row)).collect();
247 Ok(credit_registration_ids
248 .iter()
249 .filter_map(|id| by_id.remove(id))
250 .map(|row| SuotarApiCallLedgerReference {
251 credit_registration_id: row.id,
252 request_item_id: row.request_item_id,
253 user_id: row.user_id,
254 first_name: row.first_name,
255 last_name: row.last_name,
256 email: row.email,
257 student_number: row.student_number,
258 course_id: row.course_id,
259 course_name: row.course_name,
260 state: row.state,
261 error_code: row.error_code,
262 })
263 .collect())
264}
265
266fn to_call_row(call: SuotarApiCallPageRow) -> SuotarApiCallRow {
267 SuotarApiCallRow {
268 id: call.id,
269 endpoint: call.endpoint,
270 started_at: call.started_at,
271 duration_ms: call.duration_ms,
272 http_status: call.http_status,
273 succeeded: call.succeeded,
274 request_item_count: call.request_item_count,
275 ok_item_count: call.ok_item_count,
276 error_item_count: call.error_item_count,
277 request_level_error_code: call.request_level_error_code,
278 worker_name: call.worker_name,
279 credit_registration_ids: call.credit_registration_ids,
280 }
281}
282
283fn to_call_row_from_full(call: &SuotarApiCall) -> SuotarApiCallRow {
285 SuotarApiCallRow {
286 id: call.id,
287 endpoint: call.endpoint,
288 started_at: call.started_at,
289 duration_ms: call.duration_ms,
290 http_status: call.http_status,
291 succeeded: call.succeeded,
292 request_item_count: call.request_item_count,
293 ok_item_count: call.ok_item_count,
294 error_item_count: call.error_item_count,
295 request_level_error_code: call.request_level_error_code.clone(),
296 worker_name: call.worker_name.clone(),
297 credit_registration_ids: call.credit_registration_ids.clone(),
298 }
299}
300
301pub fn _add_routes(cfg: &mut ServiceConfig) {
302 cfg.route("/suotar-api-calls", web::get().to(list_suotar_api_calls))
303 .route(
304 "/suotar-api-calls/{suotar_api_call_id}",
305 web::get().to(get_suotar_api_call),
306 );
307}