headless_lms_server/controllers/study_registry/completions.rs
1//! Controllers for requests starting with `/api/v0/study-registry/completions`
2//!
3//! The study registry provides an access to student completion records. It is generally only available
4//! to authorized study registries, meaning that most endpoints will require a valid authorization token
5//! to access.
6//!
7//! When accessing study registry, the authorization token should be given as the following header:
8//! ```http
9//! Authorization: Basic documentationOnlyExampleSecretKey-12345
10//! ```
11//!
12//! For more details, please view the individual functions.
13
14use actix_web::http::header::ContentType;
15use bytes::Bytes;
16
17use futures::{StreamExt, future};
18use models::course_modules::CourseModule;
19use tokio_stream::wrappers::UnboundedReceiverStream;
20
21use crate::{
22 domain::csv_export::{
23 make_authorized_streamable, serializable_sqlx_result_stream_to_json_stream,
24 },
25 prelude::*,
26};
27
28#[derive(Debug, Deserialize)]
29struct GetCompletionsQueryParamers {
30 #[serde(default)]
31 pub exclude_already_registered: bool,
32}
33
34/**
35GET `/api/v0/study-registry/completions/[:course_id | :uh_course_code | :course_slug]` -- Get completions from all modules in a course.
36
37Gets all course completions for a given course. The course identifier can either be its University of
38Helsinki course code, or a system-local slug or hash id.
39
40This endpoint is only available to authorized study registries, and requires a valid authorization token
41to access. Results are also streamed rather than included in the response body. In case of an error
42during transmission, an error message will be appended to the end of the broken stream output.
43
44This endpoint returns an array of [StudyRegistryCompletion](models::course_module_completions::StudyRegistryCompletion) structs.
45A course with no completions returns an empty array `[]`.
46
47If no course matches the given identifier, the endpoint responds with `404 Not Found` and a JSON error body:
48
49```json
50{
51 "type": "not_found",
52 "message_key": "not_found",
53 "message": "No course found with the identifier 'BSCS1001'."
54}
55```
56
57## Excluding already registering completions.
58
59If the study registry has already registered some completions, it can exclude them from the results. This is achieved by adding a query parameter `?exclude_already_registered=true` to the request. The value of the parameter is a boolean, and it defaults to `false`.
60
61## Example requests
62
63Using University of Helsinki course code:
64```http
65GET /api/v0/study-registry/completions/BSCS1001 HTTP/1.1
66Authorization: Basic documentationOnlyExampleSecretKey-12345
67```
68
69Using course slug:
70```http
71GET /api/v0/study-registry/completions/introduction-to-programming HTTP/1.1
72Authorization: Basic documentationOnlyExampleSecretKey-12345
73```
74
75Using course id:
76```http
77GET /api/v0/study-registry/completions/b3e9575b-fa13-492c-bd14-10cb27df4eec HTTP/1.1
78Authorization: Basic documentationOnlyExampleSecretKey-12345
79```
80
81Exclude already registereed:
82```http
83GET /api/v0/study-registry/completions/BSCS1001?exlcude_already_registered=true HTTP/1.1
84Authorization: Basic documentationOnlyExampleSecretKey-12345
85```
86*/
87#[generated_doc(Vec<StudyRegistryCompletion>)]
88#[instrument(skip(req, pool))]
89async fn get_completions(
90 req: HttpRequest,
91 course_id_slug_or_code: web::Path<String>,
92 pool: web::Data<PgPool>,
93 query: web::Query<GetCompletionsQueryParamers>,
94) -> ControllerResult<HttpResponse> {
95 let mut conn = pool.acquire().await?;
96 let secret_key = parse_secret_key_from_header(&req)?;
97 let token = authorize(
98 &mut conn,
99 Act::View,
100 None,
101 Res::StudyRegistry(secret_key.to_string()),
102 )
103 .await?;
104
105 let dont_include_completions_from_this_registrar = if query.exclude_already_registered {
106 Some(models::study_registry_registrars::get_by_secret_key(&mut conn, secret_key).await?)
107 } else {
108 // In this case, we'll return all completions.
109 None
110 };
111
112 // Try to parse the param as UUID to know whether the completions should be from a distinct or
113 // multiple modules.
114 let course_modules = if let Ok(course_id) = Uuid::parse_str(&course_id_slug_or_code) {
115 let module = models::course_modules::get_default_by_course_id(&mut conn, course_id).await?;
116 vec![module.id]
117 } else {
118 // The param is either a course slug or non-unique UH course code.
119 let modules = models::course_modules::get_ids_by_course_slug_or_uh_course_code(
120 &mut conn,
121 course_id_slug_or_code.as_str(),
122 )
123 .await?;
124 if modules.is_empty() {
125 // Distinguishes an unknown course from a known course with no completions, which
126 // answers with an empty array.
127 return Err(ControllerError::new(
128 ControllerErrorType::NotFound,
129 format!(
130 "No course found with the identifier '{}'.",
131 *course_id_slug_or_code
132 ),
133 None,
134 ));
135 }
136 modules
137 };
138
139 // Duplicated below but `spawn` requires static lifetime.
140 // TODO: Create a macro instead.
141 let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<ControllerResult<Bytes>>();
142 let mut handle_conn = pool.acquire().await?;
143 let _handle = tokio::spawn(async move {
144 let stream = models::course_module_completions::stream_by_course_module_id(
145 &mut handle_conn,
146 &course_modules,
147 &dont_include_completions_from_this_registrar,
148 )
149 .map(|result| {
150 result.map(|mut completion| {
151 completion.normalize_language_code();
152 completion
153 })
154 });
155 let fut = serializable_sqlx_result_stream_to_json_stream(stream).for_each(|message| {
156 let token = skip_authorize();
157 let message = match message {
158 Ok(message) => message,
159 Err(err) => {
160 error!("Error received from sqlx result stream: {}", err);
161 Bytes::from(format!("Streaming error. Details: {:?}", err))
162 }
163 };
164 if let Err(err) = sender.send(token.authorized_ok(message)) {
165 error!("Failed to send data to UnboundedReceiver: {}", err);
166 }
167 future::ready(())
168 });
169 fut.await;
170 });
171 token.authorized_ok(
172 HttpResponse::Ok()
173 .content_type(ContentType::json())
174 .streaming(make_authorized_streamable(UnboundedReceiverStream::new(
175 receiver,
176 ))),
177 )
178}
179
180/**
181GET `/api/v0/study-registry/completions/[:course_id | :uh_course_code | :course_slug]/:course_module_id` -- Get completions from a single course module.
182
183
184
185Gets all course completions for a submodule of a given course. The course identifier can either be its
186University of Helsinki course code, or a system-local slug or hash id. For module identifier,
187only the hash id is supported.
188
189This endpoint is only available to authorized study registries, and requires a valid authorization token
190to access. Results are also streamed rather than included in the response body. In case of an error
191during transmission, an error message will be appended to the end of the broken stream output.
192
193This endpoint returns an array of [StudyRegistryCompletion](models::course_module_completions::StudyRegistryCompletion) structs.
194A module with no completions returns an empty array `[]`.
195
196If the course or the module does not exist, or the module does not belong to the course, the endpoint
197responds with `404 Not Found` and a JSON error body of the same shape as the course-wide endpoint.
198
199## Excluding already registering completions.
200
201If the study registry has already registered some completions, it can exclude them from the results. This is achieved by adding a query parameter `?exclude_already_registered=true` to the request. The value of the parameter is a boolean, and it defaults to `false`.
202
203## Example requests
204
205Using University of Helsinki course code:
206```http
207GET /api/v0/study-registry/completions/BSCS1001/caf3ccb2-abe9-4661-822c-20b117049dbf HTTP/1.1
208Authorization: Basic documentationOnlyExampleSecretKey-12345
209Content-Type: application/json
210```
211
212Using course slug:
213```http
214GET /api/v0/study-registry/completions/introduction-to-programming/caf3ccb2-abe9-4661-822c-20b117049dbf HTTP/1.1
215Authorization: Basic documentationOnlyExampleSecretKey-12345
216Content-Type: application/json
217```
218
219Using course id:
220```http
221GET /api/v0/study-registry/completions/b3e9575b-fa13-492c-bd14-10cb27df4eec/caf3ccb2-abe9-4661-822c-20b117049dbf HTTP/1.1
222Authorization: Basic documentationOnlyExampleSecretKey-12345
223Content-Type: application/json
224
225Exclude already registereed:
226```http
227GET /api/v0/study-registry/completions/BSCS1001/caf3ccb2-abe9-4661-822c-20b117049dbf?exlcude_already_registered=true HTTP/1.1
228Authorization: Basic documentationOnlyExampleSecretKey-12345
229```
230*/
231#[generated_doc(Vec<StudyRegistryCompletion>)]
232#[instrument(skip(req, pool))]
233async fn get_module_completions(
234 req: HttpRequest,
235 path: web::Path<(String, Uuid)>,
236 pool: web::Data<PgPool>,
237 query: web::Query<GetCompletionsQueryParamers>,
238) -> ControllerResult<HttpResponse> {
239 let (course_id_slug_or_code, module_id) = path.into_inner();
240 let mut conn = pool.acquire().await?;
241 let secret_key = parse_secret_key_from_header(&req)?;
242 let token = authorize(
243 &mut conn,
244 Act::View,
245 None,
246 Res::StudyRegistry(secret_key.to_string()),
247 )
248 .await?;
249
250 let module = models::course_modules::get_by_id(&mut conn, module_id).await?;
251 if !module_belongs_to_course(&mut conn, &module, &course_id_slug_or_code).await? {
252 return Err(ControllerError::new(
253 ControllerErrorType::NotFound,
254 "No such module in a given course.".to_string(),
255 None,
256 ));
257 }
258
259 let dont_include_completions_from_this_registrar = if query.exclude_already_registered {
260 Some(models::study_registry_registrars::get_by_secret_key(&mut conn, secret_key).await?)
261 } else {
262 None
263 };
264
265 let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<ControllerResult<Bytes>>();
266 let mut handle_conn = pool.acquire().await?;
267 let _handle = tokio::spawn(async move {
268 let modules = vec![module.id];
269 let stream = models::course_module_completions::stream_by_course_module_id(
270 &mut handle_conn,
271 &modules,
272 &dont_include_completions_from_this_registrar,
273 )
274 .map(|result| {
275 result.map(|mut completion| {
276 completion.normalize_language_code();
277 completion
278 })
279 });
280 let fut = serializable_sqlx_result_stream_to_json_stream(stream).for_each(|message| {
281 let token = skip_authorize();
282 let message = match message {
283 Ok(message) => message,
284 Err(err) => {
285 error!("Error received from sqlx result stream: {}", err);
286 Bytes::from(format!("Streaming error. Details: {:?}", err))
287 }
288 };
289 if let Err(err) = sender.send(token.authorized_ok(message)) {
290 error!("Failed to send data to UnboundedReceiver: {}", err);
291 }
292 future::ready(())
293 });
294 fut.await;
295 });
296 token.authorized_ok(
297 HttpResponse::Ok()
298 .content_type(ContentType::json())
299 .streaming(make_authorized_streamable(UnboundedReceiverStream::new(
300 receiver,
301 ))),
302 )
303}
304
305#[doc(hidden)]
306async fn module_belongs_to_course(
307 conn: &mut PgConnection,
308 module: &CourseModule,
309 course_id_slug_or_code: &str,
310) -> anyhow::Result<bool> {
311 if module.uh_course_code.as_deref() == Some(course_id_slug_or_code) {
312 Ok(true)
313 } else if let Ok(course_id) = Uuid::parse_str(course_id_slug_or_code) {
314 Ok(module.course_id == course_id)
315 } else {
316 let course = models::courses::get_course_by_slug(conn, course_id_slug_or_code).await?;
317 Ok(module.course_id == course.id)
318 }
319}
320
321/**
322Add a route for each controller in this module.
323
324The name starts with an underline in order to appear before other functions in the module documentation.
325
326We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
327*/
328#[doc(hidden)]
329pub fn _add_routes(cfg: &mut ServiceConfig) {
330 cfg.route("/{course_id_slug_or_code}", web::get().to(get_completions))
331 .route(
332 "/{course_id_slug_or_code}/{module_id}",
333 web::get().to(get_module_completions),
334 );
335}