1use crate::{controllers::helpers::file_uploading, prelude::*};
2use actix_multipart::form::{MultipartForm, tempfile::TempFile};
3use chrono::Utc;
4use headless_lms_certificates as certificates;
5use headless_lms_models::generated_certificates::CertificateUpdateRequest;
6use headless_lms_utils::{file_store::file_utils, icu4x::Icu4xBlob};
7use utoipa::{OpenApi, ToSchema};
8
9use models::{
10 certificate_configurations::{
11 CertificateTextAnchor, DatabaseCertificateConfiguration, PaperSize,
12 },
13 generated_certificates::GeneratedCertificate,
14};
15
16#[derive(OpenApi)]
17#[openapi(paths(
18 update_certificate_configuration,
19 generate_generated_certificate,
20 get_generated_certificate,
21 update_generated_certificate,
22 delete_certificate_configuration,
23 get_cerficate_by_verification_id
24))]
25pub(crate) struct MainFrontendCertificatesApiDoc;
26
27#[derive(Debug, Deserialize, ToSchema)]
28
29pub struct CertificateConfigurationUpdate {
30 pub course_module_id: Uuid,
31 pub course_instance_id: Option<Uuid>,
32 pub certificate_owner_name_y_pos: Option<String>,
33 pub certificate_owner_name_x_pos: Option<String>,
34 pub certificate_owner_name_font_size: Option<String>,
35 pub certificate_owner_name_text_color: Option<String>,
36 pub certificate_owner_name_text_anchor: Option<CertificateTextAnchor>,
37 pub certificate_validate_url_y_pos: Option<String>,
38 pub certificate_validate_url_x_pos: Option<String>,
39 pub certificate_validate_url_font_size: Option<String>,
40 pub certificate_validate_url_text_color: Option<String>,
41 pub certificate_validate_url_text_anchor: Option<CertificateTextAnchor>,
42 pub certificate_date_y_pos: Option<String>,
43 pub certificate_date_x_pos: Option<String>,
44 pub certificate_date_font_size: Option<String>,
45 pub certificate_date_text_color: Option<String>,
46 pub certificate_date_text_anchor: Option<CertificateTextAnchor>,
47 pub certificate_locale: Option<String>,
48 pub paper_size: Option<PaperSize>,
49 pub background_svg_file_name: Option<String>,
50 pub overlay_svg_file_name: Option<String>,
51 pub clear_overlay_svg_file: bool,
52 pub render_certificate_grade: bool,
53 pub certificate_grade_y_pos: Option<String>,
54 pub certificate_grade_x_pos: Option<String>,
55 pub certificate_grade_font_size: Option<String>,
56 pub certificate_grade_text_color: Option<String>,
57 pub certificate_grade_text_anchor: Option<CertificateTextAnchor>,
58}
59
60#[derive(Debug, MultipartForm)]
61pub struct CertificateConfigurationUpdateForm {
62 metadata: actix_multipart::form::json::Json<CertificateConfigurationUpdate>,
63 #[multipart(rename = "file")]
64 files: Vec<TempFile>,
65}
66
67#[allow(dead_code)]
68#[derive(Debug, ToSchema)]
69struct CertificateConfigurationUpdateMultipartPayload {
70 #[schema(content_media_type = "application/json")]
71 metadata: CertificateConfigurationUpdate,
72 #[schema(content_media_type = "application/octet-stream", value_type = Vec<String>, format = Binary)]
73 file: Vec<Vec<u8>>,
74}
75
76#[utoipa::path(
82 post,
83 path = "",
84 operation_id = "updateCertificateConfiguration",
85 tag = "certificates",
86 request_body(
87 content = inline(CertificateConfigurationUpdateMultipartPayload),
88 content_type = "multipart/form-data",
89 encoding(("metadata" = (content_type = "application/json")))
90 ),
91 responses(
92 (status = 200, description = "Certificate configuration updated", body = bool)
93 )
94)]
95#[instrument(skip(pool, payload, file_store))]
96pub async fn update_certificate_configuration(
97 pool: web::Data<PgPool>,
98 payload: MultipartForm<CertificateConfigurationUpdateForm>,
99 file_store: web::Data<dyn FileStore>,
100 user: AuthUser,
101) -> ControllerResult<web::Json<bool>> {
102 let mut conn = pool.acquire().await?;
103 let mut tx = conn.begin().await?;
104
105 let payload = payload.into_inner();
106
107 let course_id = models::course_modules::get_by_id(&mut tx, payload.metadata.course_module_id)
108 .await?
109 .course_id;
110 let token = authorize(&mut tx, Act::Edit, Some(user.id), Res::Course(course_id)).await?;
111 let mut uploaded_files = vec![];
112 let result = update_certificate_configuration_inner(
113 &mut tx,
114 &mut uploaded_files,
115 course_id,
116 payload,
117 file_store.as_ref(),
118 user,
119 )
120 .await;
121 match result {
122 Ok(files_to_delete) => {
123 tx.commit().await?;
124 for file_to_delete in files_to_delete {
125 match models::certificate_configurations::file_upload_is_referenced(
128 &mut conn,
129 file_to_delete,
130 )
131 .await
132 {
133 Ok(true) => continue,
134 Ok(false) => {}
135 Err(err) => {
136 error!("Failed to check if file '{file_to_delete}' is still in use: {err}");
137 continue;
138 }
139 }
140 if let Err(err) = file_uploading::delete_file_from_storage(
141 &mut conn,
142 file_to_delete,
143 file_store.as_ref(),
144 )
145 .await
146 {
147 error!("Failed to delete file '{file_to_delete}': {err}");
149 }
150 }
151 }
152 Err(err) => {
153 drop(tx);
155 for uploaded_file in uploaded_files {
157 if let Err(err) = file_uploading::delete_file_from_storage(
158 &mut conn,
159 uploaded_file,
160 file_store.as_ref(),
161 )
162 .await
163 {
164 error!("Failed to delete file '{uploaded_file}' during cleanup: {err}");
166 }
167 }
168 return Err(err);
169 }
170 }
171 token.authorized_ok(web::Json(true))
172}
173
174async fn update_certificate_configuration_inner(
176 conn: &mut PgConnection,
177 uploaded_files: &mut Vec<Uuid>,
178 course_id: Uuid,
179 payload: CertificateConfigurationUpdateForm,
180 file_store: &dyn FileStore,
181 user: AuthUser,
182) -> Result<Vec<Uuid>, ControllerError> {
183 let mut tx = conn.begin().await?;
184 let mut files_to_delete = vec![];
185
186 let metadata = payload.metadata.into_inner();
187 let mut new_background_svg_file: Option<(Uuid, String)> = None;
189 let mut new_overlay_svg_file: Option<(Uuid, String)> = None;
190 for file in payload.files {
191 let Some(file_name) = file.file_name else {
192 return Err(controller_err!(
193 BadRequest,
194 "Missing file name in multipart request".to_string()
195 ));
196 };
197 let (file, _temp_path) = file.file.into_parts();
198 let content = file_utils::file_to_payload(file);
199 match (
200 metadata.background_svg_file_name.as_ref(),
201 metadata.overlay_svg_file_name.as_ref(),
202 ) {
203 (Some(background_svg_file_name), _) if background_svg_file_name == &file_name => {
204 info!("Saving new background svg file");
205 let (id, path) = file_uploading::upload_certificate_svg(
207 &mut tx,
208 background_svg_file_name,
209 content,
210 file_store,
211 course_id,
212 user,
213 )
214 .await?;
215 uploaded_files.push(id);
216 new_background_svg_file =
217 Some((id, path.to_str().context("Invalid path")?.to_string()));
218 }
219 (_, Some(overlay_svg_file_name)) if overlay_svg_file_name == &file_name => {
220 info!("Saving new overlay svg file");
221 let (id, path) = file_uploading::upload_certificate_svg(
223 &mut tx,
224 overlay_svg_file_name,
225 content,
226 file_store,
227 course_id,
228 user,
229 )
230 .await?;
231 uploaded_files.push(id);
232 new_overlay_svg_file =
233 Some((id, path.to_str().context("Invalid path")?.to_string()));
234 }
235 _ => {
236 return Err(controller_err!(
237 BadRequest,
238 "Invalid field in multipart request".to_string()
239 ));
240 }
241 }
242 }
243
244 let existing_configuration =
245 models::certificate_configurations::get_default_configuration_by_course_module(
246 &mut tx,
247 metadata.course_module_id,
248 )
249 .await
250 .optional()?;
251 let (background_svg_file_upload_id, background_svg_path) =
254 match (&existing_configuration, &new_background_svg_file) {
255 (Some(existing_configuration), None) => {
256 (
258 existing_configuration.background_svg_file_upload_id,
259 existing_configuration.background_svg_path.clone(),
260 )
261 }
262 (existing, Some(background_svg_file)) => {
263 if let Some(existing) = existing {
265 files_to_delete.push(existing.background_svg_file_upload_id);
266 }
267 background_svg_file.clone()
269 }
270 (None, None) => {
271 return Err(controller_err!(
273 BadRequest,
274 "Missing background SVG file".to_string()
275 ));
276 }
277 };
278 let overlay_data = match (
281 &existing_configuration,
282 &new_overlay_svg_file,
283 metadata.clear_overlay_svg_file,
284 ) {
285 (_, Some(new_overlay), _) => {
286 Some(new_overlay.clone())
288 }
289 (Some(existing), None, false) => {
290 existing
292 .overlay_svg_file_upload_id
293 .zip(existing.overlay_svg_path.clone())
294 }
295 (Some(existing), None, true) => {
296 if let Some(existing_overlay) = existing.overlay_svg_file_upload_id {
298 files_to_delete.push(existing_overlay);
299 }
300 None
301 }
302 (None, None, _) => {
303 None
305 }
306 };
307 let (overlay_svg_file_id, overlay_svg_file_path) = overlay_data.unzip();
308 let conf = DatabaseCertificateConfiguration {
309 id: existing_configuration
310 .as_ref()
311 .map(|c| c.id)
312 .unwrap_or(Uuid::new_v4()),
313 certificate_owner_name_y_pos: metadata.certificate_owner_name_y_pos,
314 certificate_owner_name_x_pos: metadata.certificate_owner_name_x_pos,
315 certificate_owner_name_font_size: metadata.certificate_owner_name_font_size,
316 certificate_owner_name_text_color: metadata.certificate_owner_name_text_color,
317 certificate_owner_name_text_anchor: metadata.certificate_owner_name_text_anchor,
318 certificate_validate_url_y_pos: metadata.certificate_validate_url_y_pos,
319 certificate_validate_url_x_pos: metadata.certificate_validate_url_x_pos,
320 certificate_validate_url_font_size: metadata.certificate_validate_url_font_size,
321 certificate_validate_url_text_color: metadata.certificate_validate_url_text_color,
322 certificate_validate_url_text_anchor: metadata.certificate_validate_url_text_anchor,
323 certificate_date_y_pos: metadata.certificate_date_y_pos,
324 certificate_date_x_pos: metadata.certificate_date_x_pos,
325 certificate_date_font_size: metadata.certificate_date_font_size,
326 certificate_date_text_color: metadata.certificate_date_text_color,
327 certificate_date_text_anchor: metadata.certificate_date_text_anchor,
328 certificate_locale: metadata.certificate_locale,
329 paper_size: metadata.paper_size,
330 background_svg_path,
331 background_svg_file_upload_id,
332 overlay_svg_path: overlay_svg_file_path,
333 overlay_svg_file_upload_id: overlay_svg_file_id,
334 render_certificate_grade: metadata.render_certificate_grade,
335 certificate_grade_y_pos: metadata.certificate_grade_y_pos,
336 certificate_grade_x_pos: metadata.certificate_grade_x_pos,
337 certificate_grade_font_size: metadata.certificate_grade_font_size,
338 certificate_grade_text_color: metadata.certificate_grade_text_color,
339 certificate_grade_text_anchor: metadata.certificate_grade_text_anchor,
340 };
341 if let Some(existing_configuration) = existing_configuration {
342 models::certificate_configurations::update(&mut tx, existing_configuration.id, &conf)
344 .await?;
345 } else {
346 let inserted_configuration =
347 models::certificate_configurations::insert(&mut tx, &conf).await?;
348 models::certificate_configuration_to_requirements::insert(
349 &mut tx,
350 inserted_configuration.id,
351 Some(metadata.course_module_id),
352 )
353 .await?;
354 }
355 tx.commit().await?;
356 Ok(files_to_delete)
357}
358
359#[derive(Debug, Deserialize, ToSchema)]
360pub struct CertificateGenerationRequest {
361 pub certificate_configuration_id: Uuid,
362 pub name_on_certificate: String,
363 pub grade: Option<String>,
364}
365
366#[utoipa::path(
372 post,
373 path = "/generate",
374 operation_id = "generateCertificate",
375 tag = "certificates",
376 request_body = CertificateGenerationRequest,
377 responses(
378 (status = 200, description = "Certificate generated", body = bool)
379 )
380)]
381#[instrument(skip(pool))]
382pub async fn generate_generated_certificate(
383 request: web::Json<CertificateGenerationRequest>,
384 pool: web::Data<PgPool>,
385 user: AuthUser,
386) -> ControllerResult<web::Json<bool>> {
387 let mut conn = pool.acquire().await?;
388
389 let requirements = models::certificate_configuration_to_requirements::get_all_requirements_for_certificate_configuration(
390 &mut conn,
391 request.certificate_configuration_id,
392 ).await?;
393
394 if !requirements
395 .has_user_completed_all_requirements(&mut conn, user.id)
396 .await?
397 {
398 return Err(controller_err!(BadRequest, "Cannot generate certificate; user has not completed all the requirements to be eligible for this certificate."
399 .to_string()));
400 }
401 let token = skip_authorize();
403 models::generated_certificates::generate_and_insert(
405 &mut conn,
406 user.id,
407 &request.name_on_certificate,
408 request.certificate_configuration_id,
409 )
410 .await?;
411
412 token.authorized_ok(web::Json(true))
413}
414
415#[utoipa::path(
421 get,
422 path = "/get-by-configuration-id/{certificate_configuration_id}",
423 operation_id = "getCertificateByConfigurationId",
424 tag = "certificates",
425 params(
426 ("certificate_configuration_id" = Uuid, Path, description = "Certificate configuration id")
427 ),
428 responses(
429 (
430 status = 200,
431 description = "Generated certificate",
432 body = Option<GeneratedCertificate>
433 )
434 )
435)]
436#[instrument(skip(pool))]
437pub async fn get_generated_certificate(
438 certificate_configuration_id: web::Path<Uuid>,
439 pool: web::Data<PgPool>,
440 user: AuthUser,
441) -> ControllerResult<web::Json<Option<GeneratedCertificate>>> {
442 let mut conn = pool.acquire().await?;
443
444 let token = skip_authorize();
446 let certificate = models::generated_certificates::get_certificate_for_user(
447 &mut conn,
448 user.id,
449 certificate_configuration_id.into_inner(),
450 )
451 .await
452 .optional()?;
453
454 token.authorized_ok(web::Json(certificate))
455}
456
457#[derive(Debug, Deserialize)]
458pub struct CertificateQuery {
459 #[serde(default)]
460 debug: bool,
461 #[serde(default)]
462 test_certificate_configuration_id: Option<Uuid>,
466}
467
468#[utoipa::path(
476 get,
477 path = "/{certificate_verification_id}",
478 operation_id = "getCertificateByVerificationId",
479 tag = "certificates",
480 params(
481 ("certificate_verification_id" = String, Path, description = "Certificate verification id"),
482 ("debug" = bool, Query, description = "Whether to render a debug certificate"),
483 ("test_certificate_configuration_id" = Option<Uuid>, Query, description = "Certificate configuration id to use for preview rendering")
484 ),
485 responses(
486 (status = 200, description = "Certificate image", content_type = "image/png", body = serde_json::Value)
487 )
488)]
489#[instrument(skip(pool, file_store))]
490pub async fn get_cerficate_by_verification_id(
491 certificate_verification_id: web::Path<String>,
492 pool: web::Data<PgPool>,
493 file_store: web::Data<dyn FileStore>,
494 query: web::Query<CertificateQuery>,
495 icu4x_blob: web::Data<Icu4xBlob>,
496) -> ControllerResult<HttpResponse> {
497 let mut conn = pool.acquire().await?;
498
499 let token = skip_authorize();
501
502 let certificate =
503 if let Some(test_certificate_configuration_id) = query.test_certificate_configuration_id {
504 GeneratedCertificate {
506 id: Uuid::new_v4(),
507 created_at: Utc::now(),
508 updated_at: Utc::now(),
509 deleted_at: None,
510 user_id: Uuid::new_v4(),
511 certificate_configuration_id: test_certificate_configuration_id,
512 name_on_certificate: "Example user".to_string(),
513 verification_id: "test".to_string(),
514 }
515 } else {
516 models::generated_certificates::get_certificate_by_verification_id(
517 &mut conn,
518 &certificate_verification_id,
519 )
520 .await?
521 };
522
523 let data = certificates::generate_certificate(
524 &mut conn,
525 file_store.as_ref(),
526 &certificate,
527 query.debug,
528 **icu4x_blob,
529 )
530 .await?;
531 let max_age = if query.debug { 0 } else { 300 };
532
533 token.authorized_ok(
534 HttpResponse::Ok()
535 .content_type("image/png")
536 .insert_header(("Cache-Control", format!("max-age={max_age}")))
537 .body(data),
538 )
539}
540
541#[utoipa::path(
547 delete,
548 path = "/configuration/{certificate_configuration_id}",
549 operation_id = "deleteCertificateConfiguration",
550 tag = "certificates",
551 params(
552 ("certificate_configuration_id" = Uuid, Path, description = "Certificate configuration id")
553 ),
554 responses(
555 (status = 200, description = "Certificate configuration deleted", body = bool)
556 )
557)]
558#[instrument(skip(pool))]
559pub async fn delete_certificate_configuration(
560 configuration_id: web::Path<Uuid>,
561 pool: web::Data<PgPool>,
562 user: AuthUser,
563) -> ControllerResult<web::Json<bool>> {
564 let mut conn = pool.acquire().await?;
565 let requirements = models::certificate_configuration_to_requirements::get_all_requirements_for_certificate_configuration(
566 &mut conn,
567 *configuration_id,
568 ).await?;
569
570 let course_module_ids = requirements.course_module_ids;
571 let mut token = None;
572 if course_module_ids.is_empty() {
573 token =
574 Some(authorize(&mut conn, Act::Teach, Some(user.id), Res::GlobalPermissions).await?);
575 }
576
577 for course_module_id in course_module_ids {
578 let course_module = models::course_modules::get_by_id(&mut conn, course_module_id).await?;
579 let course_id = course_module.course_id;
580 token =
581 Some(authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(course_id)).await?);
582 }
583
584 let mut tx = conn.begin().await?;
585 models::certificate_configurations::delete(&mut tx, *configuration_id).await?;
586 tx.commit().await?;
587
588 let token = token.ok_or_else(|| {
589 controller_err!(
590 InternalServerError,
591 "Authorization token was not set".to_string()
592 )
593 })?;
594 token.authorized_ok(web::Json(true))
595}
596
597#[utoipa::path(
598 put,
599 path = "/generated/{certificate_id}",
600 operation_id = "updateGeneratedCertificate",
601 tag = "certificates",
602 params(
603 ("certificate_id" = Uuid, Path, description = "Generated certificate id")
604 ),
605 request_body = CertificateUpdateRequest,
606 responses(
607 (status = 200, description = "Generated certificate updated", body = GeneratedCertificate)
608 )
609)]
610#[instrument(skip(pool))]
611pub async fn update_generated_certificate(
612 certificate_id: web::Path<Uuid>,
613 payload: web::Json<CertificateUpdateRequest>,
614 pool: web::Data<PgPool>,
615 user: AuthUser,
616) -> ControllerResult<web::Json<GeneratedCertificate>> {
617 let mut conn = pool.acquire().await?;
618
619 let cert = models::generated_certificates::get_by_id(&mut conn, *certificate_id).await?;
620
621 let req = models::certificate_configuration_to_requirements::get_all_requirements_for_certificate_configuration(
623 &mut conn,
624 cert.certificate_configuration_id,
625 ).await?;
626
627 let mut token = None;
628 if req.course_module_ids.is_empty() {
629 token =
630 Some(authorize(&mut conn, Act::Teach, Some(user.id), Res::GlobalPermissions).await?);
631 } else {
632 let course_modules =
633 models::course_modules::get_by_ids(&mut conn, &req.course_module_ids).await?;
634 if course_modules.len() != req.course_module_ids.len() {
635 return Err(controller_err!(
636 BadRequest,
637 "Certificate has a missing course module requirement".to_string()
638 ));
639 }
640
641 for course_id in course_modules.iter().map(|module| module.course_id) {
642 token = Some(
643 authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(course_id)).await?,
644 );
645 }
646 }
647
648 let token = token.ok_or_else(|| {
649 controller_err!(
650 InternalServerError,
651 "Authorization token was not set".to_string()
652 )
653 })?;
654
655 let updated = models::generated_certificates::update_certificate(
656 &mut conn,
657 *certificate_id,
658 payload.date_issued,
659 payload.name_on_certificate.clone(),
660 None,
661 )
662 .await?
663 .ok_or_else(|| controller_err!(NotFound, "Certificate not found".to_string()))?;
664
665 token.authorized_ok(web::Json(updated))
666}
667
668pub fn _add_routes(cfg: &mut ServiceConfig) {
676 cfg.route("", web::post().to(update_certificate_configuration))
677 .route("/generate", web::post().to(generate_generated_certificate))
678 .route(
679 "/get-by-configuration-id/{certificate_configuration_id}",
680 web::get().to(get_generated_certificate),
681 )
682 .route(
683 "/generated/{certificate_id}",
684 web::put().to(update_generated_certificate),
685 )
686 .route(
687 "/{certificate_verification_id}",
688 web::get().to(get_cerficate_by_verification_id),
689 )
690 .route(
691 "/configuration/{certificate_configuration_id}",
692 web::delete().to(delete_certificate_configuration),
693 );
694}