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 if let Err(err) = file_uploading::delete_file_from_storage(
126 &mut conn,
127 file_to_delete,
128 file_store.as_ref(),
129 )
130 .await
131 {
132 error!("Failed to delete file '{file_to_delete}': {err}");
134 }
135 }
136 }
137 Err(err) => {
138 drop(tx);
140 for uploaded_file in uploaded_files {
142 if let Err(err) = file_uploading::delete_file_from_storage(
143 &mut conn,
144 uploaded_file,
145 file_store.as_ref(),
146 )
147 .await
148 {
149 error!("Failed to delete file '{uploaded_file}' during cleanup: {err}");
151 }
152 }
153 return Err(err);
154 }
155 }
156 token.authorized_ok(web::Json(true))
157}
158
159async fn update_certificate_configuration_inner(
161 conn: &mut PgConnection,
162 uploaded_files: &mut Vec<Uuid>,
163 course_id: Uuid,
164 payload: CertificateConfigurationUpdateForm,
165 file_store: &dyn FileStore,
166 user: AuthUser,
167) -> Result<Vec<Uuid>, ControllerError> {
168 let mut tx = conn.begin().await?;
169 let mut files_to_delete = vec![];
170
171 let metadata = payload.metadata.into_inner();
172 let mut new_background_svg_file: Option<(Uuid, String)> = None;
174 let mut new_overlay_svg_file: Option<(Uuid, String)> = None;
175 for file in payload.files {
176 let Some(file_name) = file.file_name else {
177 return Err(controller_err!(
178 BadRequest,
179 "Missing file name in multipart request".to_string()
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(controller_err!(
222 BadRequest,
223 "Invalid field in multipart request".to_string()
224 ));
225 }
226 }
227 }
228
229 let existing_configuration =
230 models::certificate_configurations::get_default_configuration_by_course_module(
231 &mut tx,
232 metadata.course_module_id,
233 )
234 .await
235 .optional()?;
236 let (background_svg_file_upload_id, background_svg_path) =
239 match (&existing_configuration, &new_background_svg_file) {
240 (Some(existing_configuration), None) => {
241 (
243 existing_configuration.background_svg_file_upload_id,
244 existing_configuration.background_svg_path.clone(),
245 )
246 }
247 (existing, Some(background_svg_file)) => {
248 if let Some(existing) = existing {
250 files_to_delete.push(existing.background_svg_file_upload_id);
251 }
252 background_svg_file.clone()
254 }
255 (None, None) => {
256 return Err(controller_err!(
258 BadRequest,
259 "Missing background SVG file".to_string()
260 ));
261 }
262 };
263 let overlay_data = match (
266 &existing_configuration,
267 &new_overlay_svg_file,
268 metadata.clear_overlay_svg_file,
269 ) {
270 (_, Some(new_overlay), _) => {
271 Some(new_overlay.clone())
273 }
274 (Some(existing), None, false) => {
275 existing
277 .overlay_svg_file_upload_id
278 .zip(existing.overlay_svg_path.clone())
279 }
280 (Some(existing), None, true) => {
281 if let Some(existing_overlay) = existing.overlay_svg_file_upload_id {
283 files_to_delete.push(existing_overlay);
284 }
285 None
286 }
287 (None, None, _) => {
288 None
290 }
291 };
292 let (overlay_svg_file_id, overlay_svg_file_path) = overlay_data.unzip();
293 let conf = DatabaseCertificateConfiguration {
294 id: existing_configuration
295 .as_ref()
296 .map(|c| c.id)
297 .unwrap_or(Uuid::new_v4()),
298 certificate_owner_name_y_pos: metadata.certificate_owner_name_y_pos,
299 certificate_owner_name_x_pos: metadata.certificate_owner_name_x_pos,
300 certificate_owner_name_font_size: metadata.certificate_owner_name_font_size,
301 certificate_owner_name_text_color: metadata.certificate_owner_name_text_color,
302 certificate_owner_name_text_anchor: metadata.certificate_owner_name_text_anchor,
303 certificate_validate_url_y_pos: metadata.certificate_validate_url_y_pos,
304 certificate_validate_url_x_pos: metadata.certificate_validate_url_x_pos,
305 certificate_validate_url_font_size: metadata.certificate_validate_url_font_size,
306 certificate_validate_url_text_color: metadata.certificate_validate_url_text_color,
307 certificate_validate_url_text_anchor: metadata.certificate_validate_url_text_anchor,
308 certificate_date_y_pos: metadata.certificate_date_y_pos,
309 certificate_date_x_pos: metadata.certificate_date_x_pos,
310 certificate_date_font_size: metadata.certificate_date_font_size,
311 certificate_date_text_color: metadata.certificate_date_text_color,
312 certificate_date_text_anchor: metadata.certificate_date_text_anchor,
313 certificate_locale: metadata.certificate_locale,
314 paper_size: metadata.paper_size,
315 background_svg_path,
316 background_svg_file_upload_id,
317 overlay_svg_path: overlay_svg_file_path,
318 overlay_svg_file_upload_id: overlay_svg_file_id,
319 render_certificate_grade: metadata.render_certificate_grade,
320 certificate_grade_y_pos: metadata.certificate_grade_y_pos,
321 certificate_grade_x_pos: metadata.certificate_grade_x_pos,
322 certificate_grade_font_size: metadata.certificate_grade_font_size,
323 certificate_grade_text_color: metadata.certificate_grade_text_color,
324 certificate_grade_text_anchor: metadata.certificate_grade_text_anchor,
325 };
326 if let Some(existing_configuration) = existing_configuration {
327 models::certificate_configurations::update(&mut tx, existing_configuration.id, &conf)
329 .await?;
330 } else {
331 let inserted_configuration =
332 models::certificate_configurations::insert(&mut tx, &conf).await?;
333 models::certificate_configuration_to_requirements::insert(
334 &mut tx,
335 inserted_configuration.id,
336 Some(metadata.course_module_id),
337 )
338 .await?;
339 }
340 tx.commit().await?;
341 Ok(files_to_delete)
342}
343
344#[derive(Debug, Deserialize, ToSchema)]
345pub struct CertificateGenerationRequest {
346 pub certificate_configuration_id: Uuid,
347 pub name_on_certificate: String,
348 pub grade: Option<String>,
349}
350
351#[utoipa::path(
357 post,
358 path = "/generate",
359 operation_id = "generateCertificate",
360 tag = "certificates",
361 request_body = CertificateGenerationRequest,
362 responses(
363 (status = 200, description = "Certificate generated", body = bool)
364 )
365)]
366#[instrument(skip(pool))]
367pub async fn generate_generated_certificate(
368 request: web::Json<CertificateGenerationRequest>,
369 pool: web::Data<PgPool>,
370 user: AuthUser,
371) -> ControllerResult<web::Json<bool>> {
372 let mut conn = pool.acquire().await?;
373
374 let requirements = models::certificate_configuration_to_requirements::get_all_requirements_for_certificate_configuration(
375 &mut conn,
376 request.certificate_configuration_id,
377 ).await?;
378
379 if !requirements
380 .has_user_completed_all_requirements(&mut conn, user.id)
381 .await?
382 {
383 return Err(controller_err!(BadRequest, "Cannot generate certificate; user has not completed all the requirements to be eligible for this certificate."
384 .to_string()));
385 }
386 let token = skip_authorize();
388 models::generated_certificates::generate_and_insert(
390 &mut conn,
391 user.id,
392 &request.name_on_certificate,
393 request.certificate_configuration_id,
394 )
395 .await?;
396
397 token.authorized_ok(web::Json(true))
398}
399
400#[utoipa::path(
406 get,
407 path = "/get-by-configuration-id/{certificate_configuration_id}",
408 operation_id = "getCertificateByConfigurationId",
409 tag = "certificates",
410 params(
411 ("certificate_configuration_id" = Uuid, Path, description = "Certificate configuration id")
412 ),
413 responses(
414 (
415 status = 200,
416 description = "Generated certificate",
417 body = Option<GeneratedCertificate>
418 )
419 )
420)]
421#[instrument(skip(pool))]
422pub async fn get_generated_certificate(
423 certificate_configuration_id: web::Path<Uuid>,
424 pool: web::Data<PgPool>,
425 user: AuthUser,
426) -> ControllerResult<web::Json<Option<GeneratedCertificate>>> {
427 let mut conn = pool.acquire().await?;
428
429 let token = skip_authorize();
431 let certificate = models::generated_certificates::get_certificate_for_user(
432 &mut conn,
433 user.id,
434 certificate_configuration_id.into_inner(),
435 )
436 .await
437 .optional()?;
438
439 token.authorized_ok(web::Json(certificate))
440}
441
442#[derive(Debug, Deserialize)]
443pub struct CertificateQuery {
444 #[serde(default)]
445 debug: bool,
446 #[serde(default)]
447 test_certificate_configuration_id: Option<Uuid>,
451}
452
453#[utoipa::path(
461 get,
462 path = "/{certificate_verification_id}",
463 operation_id = "getCertificateByVerificationId",
464 tag = "certificates",
465 params(
466 ("certificate_verification_id" = String, Path, description = "Certificate verification id"),
467 ("debug" = bool, Query, description = "Whether to render a debug certificate"),
468 ("test_certificate_configuration_id" = Option<Uuid>, Query, description = "Certificate configuration id to use for preview rendering")
469 ),
470 responses(
471 (status = 200, description = "Certificate image", content_type = "image/png", body = serde_json::Value)
472 )
473)]
474#[instrument(skip(pool, file_store))]
475pub async fn get_cerficate_by_verification_id(
476 certificate_verification_id: web::Path<String>,
477 pool: web::Data<PgPool>,
478 file_store: web::Data<dyn FileStore>,
479 query: web::Query<CertificateQuery>,
480 icu4x_blob: web::Data<Icu4xBlob>,
481) -> ControllerResult<HttpResponse> {
482 let mut conn = pool.acquire().await?;
483
484 let token = skip_authorize();
486
487 let certificate =
488 if let Some(test_certificate_configuration_id) = query.test_certificate_configuration_id {
489 GeneratedCertificate {
491 id: Uuid::new_v4(),
492 created_at: Utc::now(),
493 updated_at: Utc::now(),
494 deleted_at: None,
495 user_id: Uuid::new_v4(),
496 certificate_configuration_id: test_certificate_configuration_id,
497 name_on_certificate: "Example user".to_string(),
498 verification_id: "test".to_string(),
499 }
500 } else {
501 models::generated_certificates::get_certificate_by_verification_id(
502 &mut conn,
503 &certificate_verification_id,
504 )
505 .await?
506 };
507
508 let data = certificates::generate_certificate(
509 &mut conn,
510 file_store.as_ref(),
511 &certificate,
512 query.debug,
513 **icu4x_blob,
514 )
515 .await?;
516 let max_age = if query.debug { 0 } else { 300 };
517
518 token.authorized_ok(
519 HttpResponse::Ok()
520 .content_type("image/png")
521 .insert_header(("Cache-Control", format!("max-age={max_age}")))
522 .body(data),
523 )
524}
525
526#[utoipa::path(
532 delete,
533 path = "/configuration/{certificate_configuration_id}",
534 operation_id = "deleteCertificateConfiguration",
535 tag = "certificates",
536 params(
537 ("certificate_configuration_id" = Uuid, Path, description = "Certificate configuration id")
538 ),
539 responses(
540 (status = 200, description = "Certificate configuration deleted", body = bool)
541 )
542)]
543#[instrument(skip(pool))]
544pub async fn delete_certificate_configuration(
545 configuration_id: web::Path<Uuid>,
546 pool: web::Data<PgPool>,
547 user: AuthUser,
548) -> ControllerResult<web::Json<bool>> {
549 let mut conn = pool.acquire().await?;
550 let requirements = models::certificate_configuration_to_requirements::get_all_requirements_for_certificate_configuration(
551 &mut conn,
552 *configuration_id,
553 ).await?;
554
555 let course_module_ids = requirements.course_module_ids;
556 let mut token = None;
557 if course_module_ids.is_empty() {
558 token =
559 Some(authorize(&mut conn, Act::Teach, Some(user.id), Res::GlobalPermissions).await?);
560 }
561
562 for course_module_id in course_module_ids {
563 let course_module = models::course_modules::get_by_id(&mut conn, course_module_id).await?;
564 let course_id = course_module.course_id;
565 token =
566 Some(authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(course_id)).await?);
567 }
568
569 models::certificate_configurations::delete(&mut conn, *configuration_id).await?;
570 let token = token.ok_or_else(|| {
571 controller_err!(
572 InternalServerError,
573 "Authorization token was not set".to_string()
574 )
575 })?;
576 token.authorized_ok(web::Json(true))
577}
578
579#[utoipa::path(
580 put,
581 path = "/generated/{certificate_id}",
582 operation_id = "updateGeneratedCertificate",
583 tag = "certificates",
584 params(
585 ("certificate_id" = Uuid, Path, description = "Generated certificate id")
586 ),
587 request_body = CertificateUpdateRequest,
588 responses(
589 (status = 200, description = "Generated certificate updated", body = GeneratedCertificate)
590 )
591)]
592#[instrument(skip(pool))]
593pub async fn update_generated_certificate(
594 certificate_id: web::Path<Uuid>,
595 payload: web::Json<CertificateUpdateRequest>,
596 pool: web::Data<PgPool>,
597 user: AuthUser,
598) -> ControllerResult<web::Json<GeneratedCertificate>> {
599 let mut conn = pool.acquire().await?;
600
601 let cert = models::generated_certificates::get_by_id(&mut conn, *certificate_id).await?;
602
603 let req = models::certificate_configuration_to_requirements::get_all_requirements_for_certificate_configuration(
605 &mut conn,
606 cert.certificate_configuration_id,
607 ).await?;
608
609 let mut token = None;
610 if req.course_module_ids.is_empty() {
611 token =
612 Some(authorize(&mut conn, Act::Teach, Some(user.id), Res::GlobalPermissions).await?);
613 } else {
614 let course_modules =
615 models::course_modules::get_by_ids(&mut conn, &req.course_module_ids).await?;
616 if course_modules.len() != req.course_module_ids.len() {
617 return Err(controller_err!(
618 BadRequest,
619 "Certificate has a missing course module requirement".to_string()
620 ));
621 }
622
623 for course_id in course_modules.iter().map(|module| module.course_id) {
624 token = Some(
625 authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(course_id)).await?,
626 );
627 }
628 }
629
630 let token = token.ok_or_else(|| {
631 controller_err!(
632 InternalServerError,
633 "Authorization token was not set".to_string()
634 )
635 })?;
636
637 let updated = models::generated_certificates::update_certificate(
638 &mut conn,
639 *certificate_id,
640 payload.date_issued,
641 payload.name_on_certificate.clone(),
642 )
643 .await?;
644
645 token.authorized_ok(web::Json(updated))
646}
647
648pub fn _add_routes(cfg: &mut ServiceConfig) {
656 cfg.route("", web::post().to(update_certificate_configuration))
657 .route("/generate", web::post().to(generate_generated_certificate))
658 .route(
659 "/get-by-configuration-id/{certificate_configuration_id}",
660 web::get().to(get_generated_certificate),
661 )
662 .route(
663 "/generated/{certificate_id}",
664 web::put().to(update_generated_certificate),
665 )
666 .route(
667 "/{certificate_verification_id}",
668 web::get().to(get_cerficate_by_verification_id),
669 )
670 .route(
671 "/configuration/{certificate_configuration_id}",
672 web::delete().to(delete_certificate_configuration),
673 );
674}