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}