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