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.
45
46## Excluding already registering completions.
47
48If 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`.
49
50## Example requests
51
52Using University of Helsinki course code:
53```http
54GET /api/v0/study-registry/completions/BSCS1001 HTTP/1.1
55Authorization: Basic documentationOnlyExampleSecretKey-12345
56```
57
58Using course slug:
59```http
60GET /api/v0/study-registry/completions/introduction-to-programming HTTP/1.1
61Authorization: Basic documentationOnlyExampleSecretKey-12345
62```
63
64Using course id:
65```http
66GET /api/v0/study-registry/completions/b3e9575b-fa13-492c-bd14-10cb27df4eec HTTP/1.1
67Authorization: Basic documentationOnlyExampleSecretKey-12345
68```
69
70Exclude already registereed:
71```http
72GET /api/v0/study-registry/completions/BSCS1001?exlcude_already_registered=true HTTP/1.1
73Authorization: Basic documentationOnlyExampleSecretKey-12345
74```
75*/
76#[generated_doc(Vec<StudyRegistryCompletion>)]
77#[instrument(skip(req, pool))]
78async fn get_completions(
79 req: HttpRequest,
80 course_id_slug_or_code: web::Path<String>,
81 pool: web::Data<PgPool>,
82 query: web::Query<GetCompletionsQueryParamers>,
83) -> ControllerResult<HttpResponse> {
84 let mut conn = pool.acquire().await?;
85 let secret_key = parse_secret_key_from_header(&req)?;
86 let token = authorize(
87 &mut conn,
88 Act::View,
89 None,
90 Res::StudyRegistry(secret_key.to_string()),
91 )
92 .await?;
93
94 let dont_include_completions_from_this_registrar = if query.exclude_already_registered {
95 Some(models::study_registry_registrars::get_by_secret_key(&mut conn, secret_key).await?)
96 } else {
97 // In this case, we'll return all completions.
98 None
99 };
100
101 // Try to parse the param as UUID to know whether the completions should be from a distinct or
102 // multiple modules.
103 let course_modules = if let Ok(course_id) = Uuid::parse_str(&course_id_slug_or_code) {
104 let module = models::course_modules::get_default_by_course_id(&mut conn, course_id).await?;
105 vec![module.id]
106 } else {
107 // The param is either a course slug or non-unique UH course code.
108 models::course_modules::get_ids_by_course_slug_or_uh_course_code(
109 &mut conn,
110 course_id_slug_or_code.as_str(),
111 )
112 .await?
113 };
114
115 // Duplicated below but `spawn` requires static lifetime.
116 // TODO: Create a macro instead.
117 let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<ControllerResult<Bytes>>();
118 let mut handle_conn = pool.acquire().await?;
119 let _handle = tokio::spawn(async move {
120 let stream = models::course_module_completions::stream_by_course_module_id(
121 &mut handle_conn,
122 &course_modules,
123 &dont_include_completions_from_this_registrar,
124 )
125 .map(|result| {
126 result.map(|mut completion| {
127 completion.normalize_language_code();
128 completion
129 })
130 });
131 let fut = serializable_sqlx_result_stream_to_json_stream(stream).for_each(|message| {
132 let token = skip_authorize();
133 let message = match message {
134 Ok(message) => message,
135 Err(err) => {
136 error!("Error received from sqlx result stream: {}", err);
137 Bytes::from(format!("Streaming error. Details: {:?}", err))
138 }
139 };
140 if let Err(err) = sender.send(token.authorized_ok(message)) {
141 error!("Failed to send data to UnboundedReceiver: {}", err);
142 }
143 future::ready(())
144 });
145 fut.await;
146 });
147 token.authorized_ok(
148 HttpResponse::Ok()
149 .content_type(ContentType::json())
150 .streaming(make_authorized_streamable(UnboundedReceiverStream::new(
151 receiver,
152 ))),
153 )
154}
155
156/**
157GET `/api/v0/study-registry/completions/[:course_id | :uh_course_code | :course_slug]/:course_module_id` -- Get completions from a single course module.
158
159
160
161Gets all course completions for a submodule of a given course. The course identifier can either be its
162University of Helsinki course code, or a system-local slug or hash id. For module identifier,
163only the hash id is supported.
164
165This endpoint is only available to authorized study registries, and requires a valid authorization token
166to access. Results are also streamed rather than included in the response body. In case of an error
167during transmission, an error message will be appended to the end of the broken stream output.
168
169This endpoint returns an array of [StudyRegistryCompletion](models::course_module_completions::StudyRegistryCompletion) structs.
170
171## Excluding already registering completions.
172
173If 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`.
174
175## Example requests
176
177Using University of Helsinki course code:
178```http
179GET /api/v0/study-registry/completions/BSCS1001/caf3ccb2-abe9-4661-822c-20b117049dbf HTTP/1.1
180Authorization: Basic documentationOnlyExampleSecretKey-12345
181Content-Type: application/json
182```
183
184Using course slug:
185```http
186GET /api/v0/study-registry/completions/introduction-to-programming/caf3ccb2-abe9-4661-822c-20b117049dbf HTTP/1.1
187Authorization: Basic documentationOnlyExampleSecretKey-12345
188Content-Type: application/json
189```
190
191Using course id:
192```http
193GET /api/v0/study-registry/completions/b3e9575b-fa13-492c-bd14-10cb27df4eec/caf3ccb2-abe9-4661-822c-20b117049dbf HTTP/1.1
194Authorization: Basic documentationOnlyExampleSecretKey-12345
195Content-Type: application/json
196
197Exclude already registereed:
198```http
199GET /api/v0/study-registry/completions/BSCS1001/caf3ccb2-abe9-4661-822c-20b117049dbf?exlcude_already_registered=true HTTP/1.1
200Authorization: Basic documentationOnlyExampleSecretKey-12345
201```
202*/
203#[generated_doc(Vec<StudyRegistryCompletion>)]
204#[instrument(skip(req, pool))]
205async fn get_module_completions(
206 req: HttpRequest,
207 path: web::Path<(String, Uuid)>,
208 pool: web::Data<PgPool>,
209 query: web::Query<GetCompletionsQueryParamers>,
210) -> ControllerResult<HttpResponse> {
211 let (course_id_slug_or_code, module_id) = path.into_inner();
212 let mut conn = pool.acquire().await?;
213 let secret_key = parse_secret_key_from_header(&req)?;
214 let token = authorize(
215 &mut conn,
216 Act::View,
217 None,
218 Res::StudyRegistry(secret_key.to_string()),
219 )
220 .await?;
221
222 let module = models::course_modules::get_by_id(&mut conn, module_id).await?;
223 if !module_belongs_to_course(&mut conn, &module, &course_id_slug_or_code).await? {
224 return Err(ControllerError::new(
225 ControllerErrorType::NotFound,
226 "No such module in a given course.".to_string(),
227 None,
228 ));
229 }
230
231 let dont_include_completions_from_this_registrar = if query.exclude_already_registered {
232 Some(models::study_registry_registrars::get_by_secret_key(&mut conn, secret_key).await?)
233 } else {
234 None
235 };
236
237 let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<ControllerResult<Bytes>>();
238 let mut handle_conn = pool.acquire().await?;
239 let _handle = tokio::spawn(async move {
240 let modules = vec![module.id];
241 let stream = models::course_module_completions::stream_by_course_module_id(
242 &mut handle_conn,
243 &modules,
244 &dont_include_completions_from_this_registrar,
245 )
246 .map(|result| {
247 result.map(|mut completion| {
248 completion.normalize_language_code();
249 completion
250 })
251 });
252 let fut = serializable_sqlx_result_stream_to_json_stream(stream).for_each(|message| {
253 let token = skip_authorize();
254 let message = match message {
255 Ok(message) => message,
256 Err(err) => {
257 error!("Error received from sqlx result stream: {}", err);
258 Bytes::from(format!("Streaming error. Details: {:?}", err))
259 }
260 };
261 if let Err(err) = sender.send(token.authorized_ok(message)) {
262 error!("Failed to send data to UnboundedReceiver: {}", err);
263 }
264 future::ready(())
265 });
266 fut.await;
267 });
268 token.authorized_ok(
269 HttpResponse::Ok()
270 .content_type(ContentType::json())
271 .streaming(make_authorized_streamable(UnboundedReceiverStream::new(
272 receiver,
273 ))),
274 )
275}
276
277#[doc(hidden)]
278async fn module_belongs_to_course(
279 conn: &mut PgConnection,
280 module: &CourseModule,
281 course_id_slug_or_code: &str,
282) -> anyhow::Result<bool> {
283 if module.uh_course_code.as_deref() == Some(course_id_slug_or_code) {
284 Ok(true)
285 } else if let Ok(course_id) = Uuid::parse_str(course_id_slug_or_code) {
286 Ok(module.course_id == course_id)
287 } else {
288 let course = models::courses::get_course_by_slug(conn, course_id_slug_or_code).await?;
289 Ok(module.course_id == course.id)
290 }
291}
292
293/**
294Add a route for each controller in this module.
295
296The name starts with an underline in order to appear before other functions in the module documentation.
297
298We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
299*/
300#[doc(hidden)]
301pub fn _add_routes(cfg: &mut ServiceConfig) {
302 cfg.route("/{course_id_slug_or_code}", web::get().to(get_completions))
303 .route(
304 "/{course_id_slug_or_code}/{module_id}",
305 web::get().to(get_module_completions),
306 );
307}