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        let fut = serializable_sqlx_result_stream_to_json_stream(stream).for_each(|message| {
126            let token = skip_authorize();
127            let message = match message {
128                Ok(message) => message,
129                Err(err) => {
130                    error!("Error received from sqlx result stream: {}", err);
131                    Bytes::from(format!("Streaming error. Details: {:?}", err))
132                }
133            };
134            if let Err(err) = sender.send(token.authorized_ok(message)) {
135                error!("Failed to send data to UnboundedReceiver: {}", err);
136            }
137            future::ready(())
138        });
139        fut.await;
140    });
141    token.authorized_ok(
142        HttpResponse::Ok()
143            .content_type(ContentType::json())
144            .streaming(make_authorized_streamable(UnboundedReceiverStream::new(
145                receiver,
146            ))),
147    )
148}
149
150/**
151GET `/api/v0/study-registry/completions/[:course_id | :uh_course_code | :course_slug]/:course_module_id` -- Get completions from a single course module.
152
153
154
155Gets all course completions for a submodule of a given course. The course identifier can either be its
156University of Helsinki course code, or a system-local slug or hash id. For module identifier,
157only the hash id is supported.
158
159This endpoint is only available to authorized study registries, and requires a valid authorization token
160to access. Results are also streamed rather than included in the response body. In case of an error
161during transmission, an error message will be appended to the end of the broken stream output.
162
163This endpoint returns an array of [StudyRegistryCompletion](models::course_module_completions::StudyRegistryCompletion) structs.
164
165## Excluding already registering completions.
166
167If 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`.
168
169## Example requests
170
171Using University of Helsinki course code:
172```http
173GET /api/v0/study-registry/completions/BSCS1001/caf3ccb2-abe9-4661-822c-20b117049dbf HTTP/1.1
174Authorization: Basic documentationOnlyExampleSecretKey-12345
175Content-Type: application/json
176```
177
178Using course slug:
179```http
180GET /api/v0/study-registry/completions/introduction-to-programming/caf3ccb2-abe9-4661-822c-20b117049dbf HTTP/1.1
181Authorization: Basic documentationOnlyExampleSecretKey-12345
182Content-Type: application/json
183```
184
185Using course id:
186```http
187GET /api/v0/study-registry/completions/b3e9575b-fa13-492c-bd14-10cb27df4eec/caf3ccb2-abe9-4661-822c-20b117049dbf HTTP/1.1
188Authorization: Basic documentationOnlyExampleSecretKey-12345
189Content-Type: application/json
190
191Exclude already registereed:
192```http
193GET /api/v0/study-registry/completions/BSCS1001/caf3ccb2-abe9-4661-822c-20b117049dbf?exlcude_already_registered=true HTTP/1.1
194Authorization: Basic documentationOnlyExampleSecretKey-12345
195```
196*/
197#[generated_doc(Vec<StudyRegistryCompletion>)]
198#[instrument(skip(req, pool))]
199async fn get_module_completions(
200    req: HttpRequest,
201    path: web::Path<(String, Uuid)>,
202    pool: web::Data<PgPool>,
203    query: web::Query<GetCompletionsQueryParamers>,
204) -> ControllerResult<HttpResponse> {
205    let (course_id_slug_or_code, module_id) = path.into_inner();
206    let mut conn = pool.acquire().await?;
207    let secret_key = parse_secret_key_from_header(&req)?;
208    let token = authorize(
209        &mut conn,
210        Act::View,
211        None,
212        Res::StudyRegistry(secret_key.to_string()),
213    )
214    .await?;
215
216    let module = models::course_modules::get_by_id(&mut conn, module_id).await?;
217    if !module_belongs_to_course(&mut conn, &module, &course_id_slug_or_code).await? {
218        return Err(ControllerError::new(
219            ControllerErrorType::NotFound,
220            "No such module in a given course.".to_string(),
221            None,
222        ));
223    }
224
225    let dont_include_completions_from_this_registrar = if query.exclude_already_registered {
226        Some(models::study_registry_registrars::get_by_secret_key(&mut conn, secret_key).await?)
227    } else {
228        None
229    };
230
231    let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<ControllerResult<Bytes>>();
232    let mut handle_conn = pool.acquire().await?;
233    let _handle = tokio::spawn(async move {
234        let modules = vec![module.id];
235        let stream = models::course_module_completions::stream_by_course_module_id(
236            &mut handle_conn,
237            &modules,
238            &dont_include_completions_from_this_registrar,
239        );
240        let fut = serializable_sqlx_result_stream_to_json_stream(stream).for_each(|message| {
241            let token = skip_authorize();
242            let message = match message {
243                Ok(message) => message,
244                Err(err) => {
245                    error!("Error received from sqlx result stream: {}", err);
246                    Bytes::from(format!("Streaming error. Details: {:?}", err))
247                }
248            };
249            if let Err(err) = sender.send(token.authorized_ok(message)) {
250                error!("Failed to send data to UnboundedReceiver: {}", err);
251            }
252            future::ready(())
253        });
254        fut.await;
255    });
256    token.authorized_ok(
257        HttpResponse::Ok()
258            .content_type(ContentType::json())
259            .streaming(make_authorized_streamable(UnboundedReceiverStream::new(
260                receiver,
261            ))),
262    )
263}
264
265#[doc(hidden)]
266async fn module_belongs_to_course(
267    conn: &mut PgConnection,
268    module: &CourseModule,
269    course_id_slug_or_code: &str,
270) -> anyhow::Result<bool> {
271    if module.uh_course_code.as_deref() == Some(course_id_slug_or_code) {
272        Ok(true)
273    } else if let Ok(course_id) = Uuid::parse_str(course_id_slug_or_code) {
274        Ok(module.course_id == course_id)
275    } else {
276        let course = models::courses::get_course_by_slug(conn, course_id_slug_or_code).await?;
277        Ok(module.course_id == course.id)
278    }
279}
280
281/**
282Add a route for each controller in this module.
283
284The name starts with an underline in order to appear before other functions in the module documentation.
285
286We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
287*/
288#[doc(hidden)]
289pub fn _add_routes(cfg: &mut ServiceConfig) {
290    cfg.route("/{course_id_slug_or_code}", web::get().to(get_completions))
291        .route(
292            "/{course_id_slug_or_code}/{module_id}",
293            web::get().to(get_module_completions),
294        );
295}