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}