Skip to main content

headless_lms_server/controllers/main_frontend/
course_instances.rs

1//! Controllers for requests starting with `/api/v0/main-frontend/course-instances`.
2
3use chrono::Utc;
4use models::{
5    certificate_configurations::CertificateConfigurationAndRequirements,
6    course_instances::{self, CourseInstance, CourseInstanceForm, Points},
7    course_module_completions::CourseModuleCompletion,
8    courses,
9    email_templates::{EmailTemplate, EmailTemplateNew},
10    exercises::ExerciseStatusSummaryForUser,
11    library::{
12        self,
13        progressing::{
14            CourseInstanceCompletionSummary, ManualCompletionPreview,
15            TeacherManualCompletionRequest,
16        },
17    },
18    user_exercise_states::UserCourseProgress,
19};
20use utoipa::OpenApi;
21
22use crate::{
23    domain::csv_export::{
24        course_instance_export::CompletionsExportOperation, general_export,
25        points::PointExportOperation,
26    },
27    prelude::*,
28};
29
30#[derive(OpenApi)]
31#[openapi(paths(
32    get_course_instance,
33    post_new_email_template,
34    get_email_templates_by_course_instance_id,
35    point_export,
36    points,
37    completions,
38    post_completions,
39    preview_post_completions,
40    edit,
41    delete,
42    completions_export,
43    certificate_configurations,
44    get_all_exercise_statuses_by_course_instance_id,
45    get_all_get_all_course_module_completions_for_user_by_course_instance_id,
46    get_user_progress_for_course_instance
47))]
48pub(crate) struct MainFrontendCourseInstancesApiDoc;
49
50/**
51GET /course-instances/:id
52*/
53#[instrument(skip(pool))]
54#[utoipa::path(
55    get,
56    path = "/{course_instance_id}",
57    operation_id = "getCourseInstance",
58    tag = "course-instances",
59    params(
60        ("course_instance_id" = Uuid, Path, description = "Course instance id")
61    ),
62    responses(
63        (status = 200, description = "Course instance", body = CourseInstance)
64    )
65)]
66async fn get_course_instance(
67    course_instance_id: web::Path<Uuid>,
68    user: AuthUser,
69    pool: web::Data<PgPool>,
70) -> ControllerResult<web::Json<CourseInstance>> {
71    let mut conn = pool.acquire().await?;
72    let token = authorize(
73        &mut conn,
74        Act::ViewUserProgressOrDetails,
75        Some(user.id),
76        Res::CourseInstance(*course_instance_id),
77    )
78    .await?;
79    let course_instance =
80        models::course_instances::get_course_instance(&mut conn, *course_instance_id).await?;
81    token.authorized_ok(web::Json(course_instance))
82}
83
84#[instrument(skip(payload, pool))]
85#[utoipa::path(
86    post,
87    path = "/{course_instance_id}/email-templates",
88    operation_id = "createCourseInstanceEmailTemplate",
89    tag = "course-instances",
90    params(
91        ("course_instance_id" = Uuid, Path, description = "Course instance id")
92    ),
93    request_body = EmailTemplateNew,
94    responses(
95        (status = 200, description = "Created email template", body = EmailTemplate)
96    )
97)]
98async fn post_new_email_template(
99    course_instance_id: web::Path<Uuid>,
100    payload: web::Json<EmailTemplateNew>,
101    pool: web::Data<PgPool>,
102    user: AuthUser,
103) -> ControllerResult<web::Json<EmailTemplate>> {
104    let mut conn = pool.acquire().await?;
105    let token = authorize(
106        &mut conn,
107        Act::Edit,
108        Some(user.id),
109        Res::CourseInstance(*course_instance_id),
110    )
111    .await?;
112    let course_instance =
113        models::course_instances::get_course_instance(&mut conn, *course_instance_id).await?;
114    let new_email_template = payload.0;
115    let email_template = models::email_templates::insert_email_template(
116        &mut conn,
117        Some(course_instance.course_id),
118        new_email_template,
119        None,
120    )
121    .await?;
122    token.authorized_ok(web::Json(email_template))
123}
124
125#[instrument(skip(pool))]
126#[utoipa::path(
127    get,
128    path = "/{course_instance_id}/email-templates",
129    operation_id = "getCourseInstanceEmailTemplates",
130    tag = "course-instances",
131    params(
132        ("course_instance_id" = Uuid, Path, description = "Course instance id")
133    ),
134    responses(
135        (status = 200, description = "Course instance email templates", body = [EmailTemplate])
136    )
137)]
138async fn get_email_templates_by_course_instance_id(
139    course_instance_id: web::Path<Uuid>,
140    pool: web::Data<PgPool>,
141    user: AuthUser,
142) -> ControllerResult<web::Json<Vec<EmailTemplate>>> {
143    let mut conn = pool.acquire().await?;
144    let token = authorize(
145        &mut conn,
146        Act::Edit,
147        Some(user.id),
148        Res::CourseInstance(*course_instance_id),
149    )
150    .await?;
151
152    let course_instance =
153        models::course_instances::get_course_instance(&mut conn, *course_instance_id).await?;
154    let email_templates =
155        models::email_templates::get_email_templates(&mut conn, course_instance.course_id).await?;
156    token.authorized_ok(web::Json(email_templates))
157}
158
159/**
160GET `/api/v0/main-frontend/course-instances/${courseInstanceId}/export-points` - gets CSV of course instance points based on course_instance ID.
161*/
162#[instrument(skip(pool))]
163#[utoipa::path(
164    get,
165    path = "/{course_instance_id}/export-points",
166    operation_id = "exportCourseInstancePointsCsv",
167    tag = "course-instances",
168    params(
169        ("course_instance_id" = Uuid, Path, description = "Course instance id")
170    ),
171    responses(
172        (status = 200, description = "Course instance points CSV", body = String, content_type = "text/csv")
173    )
174)]
175pub async fn point_export(
176    course_instance_id: web::Path<Uuid>,
177    pool: web::Data<PgPool>,
178    user: AuthUser,
179) -> ControllerResult<HttpResponse> {
180    let mut conn = pool.acquire().await?;
181    let token = authorize(
182        &mut conn,
183        Act::Edit,
184        Some(user.id),
185        Res::CourseInstance(*course_instance_id),
186    )
187    .await?;
188
189    let course_instance =
190        course_instances::get_course_instance(&mut conn, *course_instance_id).await?;
191    let course = courses::get_course(&mut conn, course_instance.course_id).await?;
192
193    general_export(
194        pool,
195        &format!(
196            "attachment; filename=\"{} - {} - Point export {}.csv\"",
197            course.name,
198            course_instance.name.as_deref().unwrap_or("unnamed"),
199            Utc::now().format("%Y-%m-%d")
200        ),
201        PointExportOperation {
202            course_instance_id: *course_instance_id,
203        },
204        token,
205    )
206    .await
207}
208
209#[instrument(skip(pool))]
210#[utoipa::path(
211    get,
212    path = "/{course_instance_id}/points",
213    operation_id = "getCourseInstancePoints",
214    tag = "course-instances",
215    params(
216        ("course_instance_id" = Uuid, Path, description = "Course instance id"),
217        ("page" = Option<i64>, Query, description = "Page number"),
218        ("limit" = Option<i64>, Query, description = "Page size")
219    ),
220    responses(
221        (status = 200, description = "Course instance points", body = Points)
222    )
223)]
224async fn points(
225    course_instance_id: web::Path<Uuid>,
226    pagination: web::Query<Pagination>,
227    pool: web::Data<PgPool>,
228    user: AuthUser,
229) -> ControllerResult<web::Json<Points>> {
230    let mut conn = pool.acquire().await?;
231    let token = authorize(
232        &mut conn,
233        Act::ViewUserProgressOrDetails,
234        Some(user.id),
235        Res::CourseInstance(*course_instance_id),
236    )
237    .await?;
238    let points = course_instances::get_points(&mut conn, *course_instance_id, *pagination).await?;
239    token.authorized_ok(web::Json(points))
240}
241
242/**
243GET `/api/v0/main-frontend/course-instances/{course_instance_id}/completions`
244*/
245#[instrument(skip(pool))]
246#[utoipa::path(
247    get,
248    path = "/{course_instance_id}/completions",
249    operation_id = "getCourseInstanceCompletions",
250    tag = "course-instances",
251    params(
252        ("course_instance_id" = Uuid, Path, description = "Course instance id")
253    ),
254    responses(
255        (status = 200, description = "Course instance completion summary", body = CourseInstanceCompletionSummary)
256    )
257)]
258async fn completions(
259    course_instance_id: web::Path<Uuid>,
260    pool: web::Data<PgPool>,
261    user: AuthUser,
262) -> ControllerResult<web::Json<CourseInstanceCompletionSummary>> {
263    let mut conn = pool.acquire().await?;
264    let token = authorize(
265        &mut conn,
266        Act::ViewUserProgressOrDetails,
267        Some(user.id),
268        Res::CourseInstance(*course_instance_id),
269    )
270    .await?;
271    let course_instance =
272        course_instances::get_course_instance(&mut conn, *course_instance_id).await?;
273    let completions =
274        library::progressing::get_course_instance_completion_summary(&mut conn, &course_instance)
275            .await?;
276    token.authorized_ok(web::Json(completions))
277}
278
279/**
280POST `/api/v0/main-frontend/course-instances/{course_instance_id}/completions`
281*/
282#[instrument(skip(pool, payload))]
283#[utoipa::path(
284    post,
285    path = "/{course_instance_id}/completions",
286    operation_id = "createCourseInstanceCompletions",
287    tag = "course-instances",
288    params(
289        ("course_instance_id" = Uuid, Path, description = "Course instance id")
290    ),
291    request_body = TeacherManualCompletionRequest,
292    responses(
293        (status = 200, description = "Manual completions added")
294    )
295)]
296async fn post_completions(
297    course_instance_id: web::Path<Uuid>,
298    pool: web::Data<PgPool>,
299    user: AuthUser,
300    payload: web::Json<TeacherManualCompletionRequest>,
301) -> ControllerResult<web::Json<()>> {
302    let mut conn = pool.acquire().await?;
303    let token = authorize(
304        &mut conn,
305        Act::Edit,
306        Some(user.id),
307        Res::CourseInstance(*course_instance_id),
308    )
309    .await?;
310    let data = payload.0;
311    let course_instance =
312        course_instances::get_course_instance(&mut conn, *course_instance_id).await?;
313    library::progressing::add_manual_completions(&mut conn, user.id, &course_instance, &data)
314        .await?;
315    token.authorized_ok(web::Json(()))
316}
317
318#[instrument(skip(pool, payload))]
319#[utoipa::path(
320    post,
321    path = "/{course_instance_id}/completions/preview",
322    operation_id = "previewCourseInstanceCompletions",
323    tag = "course-instances",
324    params(
325        ("course_instance_id" = Uuid, Path, description = "Course instance id")
326    ),
327    request_body = TeacherManualCompletionRequest,
328    responses(
329        (status = 200, description = "Manual completion preview", body = ManualCompletionPreview)
330    )
331)]
332async fn preview_post_completions(
333    course_instance_id: web::Path<Uuid>,
334    pool: web::Data<PgPool>,
335    user: AuthUser,
336    payload: web::Json<TeacherManualCompletionRequest>,
337) -> ControllerResult<web::Json<ManualCompletionPreview>> {
338    let mut conn = pool.acquire().await?;
339    let token = authorize(
340        &mut conn,
341        Act::Edit,
342        Some(user.id),
343        Res::CourseInstance(*course_instance_id),
344    )
345    .await?;
346    let data = payload.0;
347    let instance = course_instances::get_course_instance(&mut conn, *course_instance_id).await?;
348    let preview =
349        library::progressing::get_manual_completion_result_preview(&mut conn, &instance, &data)
350            .await?;
351    token.authorized_ok(web::Json(preview))
352}
353
354/**
355POST /course-instances/:id/edit
356*/
357#[instrument(skip(pool))]
358#[utoipa::path(
359    post,
360    path = "/{course_instance_id}/edit",
361    operation_id = "editCourseInstance",
362    tag = "course-instances",
363    params(
364        ("course_instance_id" = Uuid, Path, description = "Course instance id")
365    ),
366    request_body = CourseInstanceForm,
367    responses(
368        (status = 200, description = "Course instance updated")
369    )
370)]
371pub async fn edit(
372    update: web::Json<CourseInstanceForm>,
373    course_instance_id: web::Path<Uuid>,
374    pool: web::Data<PgPool>,
375    user: AuthUser,
376) -> ControllerResult<HttpResponse> {
377    let mut conn = pool.acquire().await?;
378    let token = authorize(
379        &mut conn,
380        Act::Edit,
381        Some(user.id),
382        Res::CourseInstance(*course_instance_id),
383    )
384    .await?;
385    course_instances::edit(&mut conn, *course_instance_id, update.into_inner()).await?;
386    token.authorized_ok(HttpResponse::Ok().finish())
387}
388
389/**
390POST /course-instances/:id/delete
391*/
392#[instrument(skip(pool))]
393#[utoipa::path(
394    post,
395    path = "/{course_instance_id}/delete",
396    operation_id = "deleteCourseInstance",
397    tag = "course-instances",
398    params(
399        ("course_instance_id" = Uuid, Path, description = "Course instance id")
400    ),
401    responses(
402        (status = 200, description = "Course instance deleted")
403    )
404)]
405async fn delete(
406    id: web::Path<Uuid>,
407    pool: web::Data<PgPool>,
408    user: AuthUser,
409) -> ControllerResult<HttpResponse> {
410    let mut conn = pool.acquire().await?;
411    let token = authorize(
412        &mut conn,
413        Act::Edit,
414        Some(user.id),
415        Res::CourseInstance(*id),
416    )
417    .await?;
418    models::course_instances::delete(&mut conn, *id).await?;
419    token.authorized_ok(HttpResponse::Ok().finish())
420}
421
422/**
423GET /course-instances/:id/export-completions - gets CSV of course completion based on course_instance ID.
424*/
425#[instrument(skip(pool))]
426#[utoipa::path(
427    get,
428    path = "/{course_instance_id}/export-completions",
429    operation_id = "exportCourseInstanceCompletionsCsv",
430    tag = "course-instances",
431    params(
432        ("course_instance_id" = Uuid, Path, description = "Course instance id")
433    ),
434    responses(
435        (status = 200, description = "Course instance completions CSV", body = String, content_type = "text/csv")
436    )
437)]
438pub async fn completions_export(
439    course_instance_id: web::Path<Uuid>,
440    pool: web::Data<PgPool>,
441    user: AuthUser,
442) -> ControllerResult<HttpResponse> {
443    let mut conn = pool.acquire().await?;
444    let token = authorize(
445        &mut conn,
446        Act::Edit,
447        Some(user.id),
448        Res::CourseInstance(*course_instance_id),
449    )
450    .await?;
451
452    let course_instance =
453        course_instances::get_course_instance(&mut conn, *course_instance_id).await?;
454    let course = courses::get_course(&mut conn, course_instance.course_id).await?;
455
456    general_export(
457        pool,
458        &format!(
459            "attachment; filename=\"{} - {} - Completions export {}.csv\"",
460            course.name,
461            course_instance.name.as_deref().unwrap_or("unnamed"),
462            Utc::now().format("%Y-%m-%d")
463        ),
464        CompletionsExportOperation {
465            course_instance_id: *course_instance_id,
466        },
467        token,
468    )
469    .await
470}
471/**
472GET /course-instances/:id/default-certificate-configurations - gets default certificate configurations of the given course instance. A default certificate configuration requires only one course module to be completed.
473*/
474#[instrument(skip(pool))]
475#[utoipa::path(
476    get,
477    path = "/{course_instance_id}/default-certificate-configurations",
478    operation_id = "getCourseInstanceDefaultCertificateConfigurations",
479    tag = "course-instances",
480    params(
481        ("course_instance_id" = Uuid, Path, description = "Course instance id")
482    ),
483    responses(
484        (status = 200, description = "Default certificate configurations", body = [CertificateConfigurationAndRequirements])
485    )
486)]
487pub async fn certificate_configurations(
488    course_instance_id: web::Path<Uuid>,
489    pool: web::Data<PgPool>,
490    user: AuthUser,
491) -> ControllerResult<web::Json<Vec<CertificateConfigurationAndRequirements>>> {
492    let mut conn = pool.acquire().await?;
493    let token = authorize(
494        &mut conn,
495        Act::Teach,
496        Some(user.id),
497        Res::CourseInstance(*course_instance_id),
498    )
499    .await?;
500
501    let course_instance =
502        models::course_instances::get_course_instance(&mut conn, *course_instance_id).await?;
503
504    let certificate_configurations =
505        models::certificate_configurations::get_default_certificate_configurations_and_requirements_by_course(
506            &mut conn,
507            course_instance.course_id,
508        )
509        .await?;
510    token.authorized_ok(web::Json(certificate_configurations))
511}
512
513/**
514GET /course-instances/:id/status-for-all-exercises/:user_id - Returns a status for all exercises in a course instance for a given user.
515*/
516#[instrument(skip(pool))]
517#[utoipa::path(
518    get,
519    path = "/{course_instance_id}/status-for-all-exercises/{user_id}",
520    operation_id = "getCourseInstanceExerciseStatusesForUser",
521    tag = "course-instances",
522    params(
523        ("course_instance_id" = Uuid, Path, description = "Course instance id"),
524        ("user_id" = Uuid, Path, description = "User id")
525    ),
526    responses(
527        (status = 200, description = "Exercise statuses for user", body = serde_json::Value)
528    )
529)]
530async fn get_all_exercise_statuses_by_course_instance_id(
531    params: web::Path<(Uuid, Uuid)>,
532    pool: web::Data<PgPool>,
533    user: AuthUser,
534) -> ControllerResult<web::Json<Vec<ExerciseStatusSummaryForUser>>> {
535    let (course_instance_id, user_id) = params.into_inner();
536    let mut conn = pool.acquire().await?;
537    let token = authorize(
538        &mut conn,
539        Act::ViewUserProgressOrDetails,
540        Some(user.id),
541        Res::CourseInstance(course_instance_id),
542    )
543    .await?;
544
545    let course_instance =
546        models::course_instances::get_course_instance(&mut conn, course_instance_id).await?;
547
548    let _enrollment = models::course_instance_enrollments::get_by_user_and_course_instance_id(
549        &mut conn,
550        user_id,
551        course_instance_id,
552    )
553    .await
554    .map_err(|err| match err.error_type() {
555        ModelErrorType::RecordNotFound | ModelErrorType::NotFound => controller_err!(
556            Forbidden,
557            "User is not enrolled in the requested course instance".to_string()
558        ),
559        _ => err.into(),
560    })?;
561
562    let res = models::exercises::get_all_exercise_statuses_by_user_id_and_course_id(
563        &mut conn,
564        course_instance.course_id,
565        user_id,
566    )
567    .await?;
568
569    token.authorized_ok(web::Json(res))
570}
571
572/**
573GET /course-instances/:id/course-module-completions/:user_id - Returns a list of all course module completions for a given user for this course instance.
574*/
575#[instrument(skip(pool))]
576#[utoipa::path(
577    get,
578    path = "/{course_instance_id}/course-module-completions/{user_id}",
579    operation_id = "getCourseInstanceCourseModuleCompletionsForUser",
580    tag = "course-instances",
581    params(
582        ("course_instance_id" = Uuid, Path, description = "Course instance id"),
583        ("user_id" = Uuid, Path, description = "User id")
584    ),
585    responses(
586        (status = 200, description = "Course module completions for user", body = serde_json::Value)
587    )
588)]
589async fn get_all_get_all_course_module_completions_for_user_by_course_instance_id(
590    params: web::Path<(Uuid, Uuid)>,
591    pool: web::Data<PgPool>,
592    user: AuthUser,
593) -> ControllerResult<web::Json<Vec<CourseModuleCompletion>>> {
594    let (course_instance_id, user_id) = params.into_inner();
595    let mut conn = pool.acquire().await?;
596    let token = authorize(
597        &mut conn,
598        Act::ViewUserProgressOrDetails,
599        Some(user.id),
600        Res::CourseInstance(course_instance_id),
601    )
602    .await?;
603
604    let course_instance =
605        models::course_instances::get_course_instance(&mut conn, course_instance_id).await?;
606
607    let _enrollment = models::course_instance_enrollments::get_by_user_and_course_instance_id(
608        &mut conn,
609        user_id,
610        course_instance_id,
611    )
612    .await
613    .map_err(|err| match err.error_type() {
614        ModelErrorType::RecordNotFound | ModelErrorType::NotFound => controller_err!(
615            Forbidden,
616            "User is not enrolled in the requested course instance".to_string()
617        ),
618        _ => err.into(),
619    })?;
620
621    let res = models::course_module_completions::get_all_by_course_id_and_user_id(
622        &mut conn,
623        course_instance.course_id,
624        user_id,
625    )
626    .await?;
627
628    token.authorized_ok(web::Json(res))
629}
630
631/**
632 GET /api/v0/main-frontend/course-instance/:course_instance_id/progress/:user_id - returns user progress information.
633*/
634#[instrument(skip(pool))]
635#[utoipa::path(
636    get,
637    path = "/{course_instance_id}/progress/{user_id}",
638    operation_id = "getCourseInstanceUserProgress",
639    tag = "course-instances",
640    params(
641        ("course_instance_id" = Uuid, Path, description = "Course instance id"),
642        ("user_id" = Uuid, Path, description = "User id")
643    ),
644    responses(
645        (status = 200, description = "User progress for course instance", body = serde_json::Value)
646    )
647)]
648async fn get_user_progress_for_course_instance(
649    user: AuthUser,
650    params: web::Path<(Uuid, Uuid)>,
651    pool: web::Data<PgPool>,
652) -> ControllerResult<web::Json<Vec<UserCourseProgress>>> {
653    let (course_instance_id, user_id) = params.into_inner();
654    let mut conn = pool.acquire().await?;
655    let token = authorize(
656        &mut conn,
657        Act::ViewUserProgressOrDetails,
658        Some(user.id),
659        Res::CourseInstance(course_instance_id),
660    )
661    .await?;
662
663    let course_instance =
664        models::course_instances::get_course_instance(&mut conn, course_instance_id).await?;
665
666    let _enrollment = models::course_instance_enrollments::get_by_user_and_course_instance_id(
667        &mut conn,
668        user_id,
669        course_instance_id,
670    )
671    .await
672    .map_err(|err| match err.error_type() {
673        ModelErrorType::RecordNotFound | ModelErrorType::NotFound => controller_err!(
674            Forbidden,
675            "User is not enrolled in the requested course instance".to_string()
676        ),
677        _ => err.into(),
678    })?;
679
680    let user_course_progress = models::user_exercise_states::get_user_course_progress(
681        &mut conn,
682        course_instance.course_id,
683        user_id,
684        false,
685    )
686    .await?;
687    token.authorized_ok(web::Json(user_course_progress))
688}
689
690/**
691Add a route for each controller in this module.
692
693The name starts with an underline in order to appear before other functions in the module documentation.
694
695We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
696*/
697pub fn _add_routes(cfg: &mut ServiceConfig) {
698    cfg.route("/{course_instance_id}", web::get().to(get_course_instance))
699        .route(
700            "/{course_instance_id}/email-templates",
701            web::post().to(post_new_email_template),
702        )
703        .route(
704            "/{course_instance_id}/email-templates",
705            web::get().to(get_email_templates_by_course_instance_id),
706        )
707        .route(
708            "/{course_instance_id}/export-points",
709            web::get().to(point_export),
710        )
711        .route("/{course_instance_id}/edit", web::post().to(edit))
712        .route("/{course_instance_id}/delete", web::post().to(delete))
713        .route(
714            "/{course_instance_id}/completions",
715            web::get().to(completions),
716        )
717        .route(
718            "/{course_instance_id}/export-completions",
719            web::get().to(completions_export),
720        )
721        .route(
722            "/{course_instance_id}/completions",
723            web::post().to(post_completions),
724        )
725        .route(
726            "/{course_instance_id}/completions/preview",
727            web::post().to(preview_post_completions),
728        )
729        .route("/{course_instance_id}/points", web::get().to(points))
730        .route(
731            "/{course_instance_id}/status-for-all-exercises/{user_id}",
732            web::get().to(get_all_exercise_statuses_by_course_instance_id),
733        )
734        .route(
735            "/{course_instance_id}/course-module-completions/{user_id}",
736            web::get().to(get_all_get_all_course_module_completions_for_user_by_course_instance_id),
737        )
738        .route(
739            "/{course_instance_id}/progress/{user_id}",
740            web::get().to(get_user_progress_for_course_instance),
741        )
742        .route(
743            "/{course_instance_id}/default-certificate-configurations",
744            web::get().to(certificate_configurations),
745        );
746}