Skip to main content

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}