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};
7
8use models::{
9 certificate_configurations::{
10 CertificateTextAnchor, DatabaseCertificateConfiguration, PaperSize,
11 },
12 generated_certificates::GeneratedCertificate,
13};
14
15#[derive(Debug, Deserialize)]
16#[cfg_attr(feature = "ts_rs", derive(TS))]
17pub struct CertificateConfigurationUpdate {
18 pub course_module_id: Uuid,
19 pub course_instance_id: Option<Uuid>,
20 pub certificate_owner_name_y_pos: Option<String>,
21 pub certificate_owner_name_x_pos: Option<String>,
22 pub certificate_owner_name_font_size: Option<String>,
23 pub certificate_owner_name_text_color: Option<String>,
24 pub certificate_owner_name_text_anchor: Option<CertificateTextAnchor>,
25 pub certificate_validate_url_y_pos: Option<String>,
26 pub certificate_validate_url_x_pos: Option<String>,
27 pub certificate_validate_url_font_size: Option<String>,
28 pub certificate_validate_url_text_color: Option<String>,
29 pub certificate_validate_url_text_anchor: Option<CertificateTextAnchor>,
30 pub certificate_date_y_pos: Option<String>,
31 pub certificate_date_x_pos: Option<String>,
32 pub certificate_date_font_size: Option<String>,
33 pub certificate_date_text_color: Option<String>,
34 pub certificate_date_text_anchor: Option<CertificateTextAnchor>,
35 pub certificate_locale: Option<String>,
36 pub paper_size: Option<PaperSize>,
37 pub background_svg_file_name: Option<String>,
38 pub overlay_svg_file_name: Option<String>,
39 pub clear_overlay_svg_file: bool,
40 pub render_certificate_grade: bool,
41 pub certificate_grade_y_pos: Option<String>,
42 pub certificate_grade_x_pos: Option<String>,
43 pub certificate_grade_font_size: Option<String>,
44 pub certificate_grade_text_color: Option<String>,
45 pub certificate_grade_text_anchor: Option<CertificateTextAnchor>,
46}
47
48#[derive(Debug, MultipartForm)]
49pub struct CertificateConfigurationUpdateForm {
50 metadata: actix_multipart::form::json::Json<CertificateConfigurationUpdate>,
51 #[multipart(rename = "file")]
52 files: Vec<TempFile>,
53}
54
55#[instrument(skip(pool, payload, file_store))]
62pub async fn update_certificate_configuration(
63 pool: web::Data<PgPool>,
64 payload: MultipartForm<CertificateConfigurationUpdateForm>,
65 file_store: web::Data<dyn FileStore>,
66 user: AuthUser,
67) -> ControllerResult<web::Json<bool>> {
68 let mut conn = pool.acquire().await?;
69 let mut tx = conn.begin().await?;
70
71 let payload = payload.into_inner();
72
73 let course_id = models::course_modules::get_by_id(&mut tx, payload.metadata.course_module_id)
74 .await?
75 .course_id;
76 let token = authorize(&mut tx, Act::Edit, Some(user.id), Res::Course(course_id)).await?;
77 let mut uploaded_files = vec![];
78 let result = update_certificate_configuration_inner(
79 &mut tx,
80 &mut uploaded_files,
81 course_id,
82 payload,
83 file_store.as_ref(),
84 user,
85 )
86 .await;
87 match result {
88 Ok(files_to_delete) => {
89 tx.commit().await?;
90 for file_to_delete in files_to_delete {
91 if let Err(err) = file_uploading::delete_file_from_storage(
92 &mut conn,
93 file_to_delete,
94 file_store.as_ref(),
95 )
96 .await
97 {
98 error!("Failed to delete file '{file_to_delete}': {err}");
100 }
101 }
102 }
103 Err(err) => {
104 drop(tx);
106 for uploaded_file in uploaded_files {
108 if let Err(err) = file_uploading::delete_file_from_storage(
109 &mut conn,
110 uploaded_file,
111 file_store.as_ref(),
112 )
113 .await
114 {
115 error!("Failed to delete file '{uploaded_file}' during cleanup: {err}");
117 }
118 }
119 return Err(err);
120 }
121 }
122 token.authorized_ok(web::Json(true))
123}
124
125async fn update_certificate_configuration_inner(
127 conn: &mut PgConnection,
128 uploaded_files: &mut Vec<Uuid>,
129 course_id: Uuid,
130 payload: CertificateConfigurationUpdateForm,
131 file_store: &dyn FileStore,
132 user: AuthUser,
133) -> Result<Vec<Uuid>, ControllerError> {
134 let mut tx = conn.begin().await?;
135 let mut files_to_delete = vec![];
136
137 let metadata = payload.metadata.into_inner();
138 let mut new_background_svg_file: Option<(Uuid, String)> = None;
140 let mut new_overlay_svg_file: Option<(Uuid, String)> = None;
141 for file in payload.files {
142 let Some(file_name) = file.file_name else {
143 return Err(ControllerError::new(
144 ControllerErrorType::BadRequest,
145 "Missing file name in multipart request".to_string(),
146 None,
147 ));
148 };
149 let (file, _temp_path) = file.file.into_parts();
150 let content = file_utils::file_to_payload(file);
151 match (
152 metadata.background_svg_file_name.as_ref(),
153 metadata.overlay_svg_file_name.as_ref(),
154 ) {
155 (Some(background_svg_file_name), _) if background_svg_file_name == &file_name => {
156 info!("Saving new background svg file");
157 let (id, path) = file_uploading::upload_certificate_svg(
159 &mut tx,
160 background_svg_file_name,
161 content,
162 file_store,
163 course_id,
164 user,
165 )
166 .await?;
167 uploaded_files.push(id);
168 new_background_svg_file =
169 Some((id, path.to_str().context("Invalid path")?.to_string()));
170 }
171 (_, Some(overlay_svg_file_name)) if overlay_svg_file_name == &file_name => {
172 info!("Saving new overlay svg file");
173 let (id, path) = file_uploading::upload_certificate_svg(
175 &mut tx,
176 overlay_svg_file_name,
177 content,
178 file_store,
179 course_id,
180 user,
181 )
182 .await?;
183 uploaded_files.push(id);
184 new_overlay_svg_file =
185 Some((id, path.to_str().context("Invalid path")?.to_string()));
186 }
187 _ => {
188 return Err(ControllerError::new(
189 ControllerErrorType::BadRequest,
190 "Invalid field in multipart request".to_string(),
191 None,
192 ));
193 }
194 }
195 }
196
197 let existing_configuration =
198 models::certificate_configurations::get_default_configuration_by_course_module(
199 &mut tx,
200 metadata.course_module_id,
201 )
202 .await
203 .optional()?;
204 let (background_svg_file_upload_id, background_svg_path) =
207 match (&existing_configuration, &new_background_svg_file) {
208 (Some(existing_configuration), None) => {
209 (
211 existing_configuration.background_svg_file_upload_id,
212 existing_configuration.background_svg_path.clone(),
213 )
214 }
215 (existing, Some(background_svg_file)) => {
216 if let Some(existing) = existing {
218 files_to_delete.push(existing.background_svg_file_upload_id);
219 }
220 background_svg_file.clone()
222 }
223 (None, None) => {
224 return Err(ControllerError::new(
226 ControllerErrorType::BadRequest,
227 "Missing background SVG file".to_string(),
228 None,
229 ));
230 }
231 };
232 let overlay_data = match (
235 &existing_configuration,
236 &new_overlay_svg_file,
237 metadata.clear_overlay_svg_file,
238 ) {
239 (_, Some(new_overlay), _) => {
240 Some(new_overlay.clone())
242 }
243 (Some(existing), None, false) => {
244 existing
246 .overlay_svg_file_upload_id
247 .zip(existing.overlay_svg_path.clone())
248 }
249 (Some(existing), None, true) => {
250 if let Some(existing_overlay) = existing.overlay_svg_file_upload_id {
252 files_to_delete.push(existing_overlay);
253 }
254 None
255 }
256 (None, None, _) => {
257 None
259 }
260 };
261 let (overlay_svg_file_id, overlay_svg_file_path) = overlay_data.unzip();
262 let conf = DatabaseCertificateConfiguration {
263 id: existing_configuration
264 .as_ref()
265 .map(|c| c.id)
266 .unwrap_or(Uuid::new_v4()),
267 certificate_owner_name_y_pos: metadata.certificate_owner_name_y_pos,
268 certificate_owner_name_x_pos: metadata.certificate_owner_name_x_pos,
269 certificate_owner_name_font_size: metadata.certificate_owner_name_font_size,
270 certificate_owner_name_text_color: metadata.certificate_owner_name_text_color,
271 certificate_owner_name_text_anchor: metadata.certificate_owner_name_text_anchor,
272 certificate_validate_url_y_pos: metadata.certificate_validate_url_y_pos,
273 certificate_validate_url_x_pos: metadata.certificate_validate_url_x_pos,
274 certificate_validate_url_font_size: metadata.certificate_validate_url_font_size,
275 certificate_validate_url_text_color: metadata.certificate_validate_url_text_color,
276 certificate_validate_url_text_anchor: metadata.certificate_validate_url_text_anchor,
277 certificate_date_y_pos: metadata.certificate_date_y_pos,
278 certificate_date_x_pos: metadata.certificate_date_x_pos,
279 certificate_date_font_size: metadata.certificate_date_font_size,
280 certificate_date_text_color: metadata.certificate_date_text_color,
281 certificate_date_text_anchor: metadata.certificate_date_text_anchor,
282 certificate_locale: metadata.certificate_locale,
283 paper_size: metadata.paper_size,
284 background_svg_path,
285 background_svg_file_upload_id,
286 overlay_svg_path: overlay_svg_file_path,
287 overlay_svg_file_upload_id: overlay_svg_file_id,
288 render_certificate_grade: metadata.render_certificate_grade,
289 certificate_grade_y_pos: metadata.certificate_grade_y_pos,
290 certificate_grade_x_pos: metadata.certificate_grade_x_pos,
291 certificate_grade_font_size: metadata.certificate_grade_font_size,
292 certificate_grade_text_color: metadata.certificate_grade_text_color,
293 certificate_grade_text_anchor: metadata.certificate_grade_text_anchor,
294 };
295 if let Some(existing_configuration) = existing_configuration {
296 models::certificate_configurations::update(&mut tx, existing_configuration.id, &conf)
298 .await?;
299 } else {
300 let inserted_configuration =
301 models::certificate_configurations::insert(&mut tx, &conf).await?;
302 models::certificate_configuration_to_requirements::insert(
303 &mut tx,
304 inserted_configuration.id,
305 Some(metadata.course_module_id),
306 )
307 .await?;
308 }
309 tx.commit().await?;
310 Ok(files_to_delete)
311}
312
313#[derive(Debug, Deserialize)]
314pub struct CertificateGenerationRequest {
315 pub certificate_configuration_id: Uuid,
316 pub name_on_certificate: String,
317 pub grade: Option<String>,
318}
319
320#[instrument(skip(pool))]
326pub async fn generate_generated_certificate(
327 request: web::Json<CertificateGenerationRequest>,
328 pool: web::Data<PgPool>,
329 user: AuthUser,
330) -> ControllerResult<web::Json<bool>> {
331 let mut conn = pool.acquire().await?;
332
333 let requirements = models::certificate_configuration_to_requirements::get_all_requirements_for_certificate_configuration(
334 &mut conn,
335 request.certificate_configuration_id,
336 ).await?;
337
338 if !requirements
339 .has_user_completed_all_requirements(&mut conn, user.id)
340 .await?
341 {
342 return Err(ControllerError::new(
343 ControllerErrorType::BadRequest,
344 "Cannot generate certificate; user has not completed all the requirements to be eligible for this certificate."
345 .to_string(),
346 None,
347 ));
348 }
349 let token = skip_authorize();
351 models::generated_certificates::generate_and_insert(
353 &mut conn,
354 user.id,
355 &request.name_on_certificate,
356 request.certificate_configuration_id,
357 )
358 .await?;
359
360 token.authorized_ok(web::Json(true))
361}
362
363#[instrument(skip(pool))]
369pub async fn get_generated_certificate(
370 certificate_configuration_id: web::Path<Uuid>,
371 pool: web::Data<PgPool>,
372 user: AuthUser,
373) -> ControllerResult<web::Json<Option<GeneratedCertificate>>> {
374 let mut conn = pool.acquire().await?;
375
376 let token = skip_authorize();
378 let certificate = models::generated_certificates::get_certificate_for_user(
379 &mut conn,
380 user.id,
381 certificate_configuration_id.into_inner(),
382 )
383 .await
384 .optional()?;
385
386 token.authorized_ok(web::Json(certificate))
387}
388
389#[derive(Debug, Deserialize)]
390pub struct CertificateQuery {
391 #[serde(default)]
392 debug: bool,
393 #[serde(default)]
394 test_certificate_configuration_id: Option<Uuid>,
398}
399
400#[instrument(skip(pool, file_store))]
408pub async fn get_cerficate_by_verification_id(
409 certificate_verification_id: web::Path<String>,
410 pool: web::Data<PgPool>,
411 file_store: web::Data<dyn FileStore>,
412 query: web::Query<CertificateQuery>,
413 icu4x_blob: web::Data<Icu4xBlob>,
414) -> ControllerResult<HttpResponse> {
415 let mut conn = pool.acquire().await?;
416
417 let token = skip_authorize();
419
420 let certificate =
421 if let Some(test_certificate_configuration_id) = query.test_certificate_configuration_id {
422 GeneratedCertificate {
424 id: Uuid::new_v4(),
425 created_at: Utc::now(),
426 updated_at: Utc::now(),
427 deleted_at: None,
428 user_id: Uuid::new_v4(),
429 certificate_configuration_id: test_certificate_configuration_id,
430 name_on_certificate: "Example user".to_string(),
431 verification_id: "test".to_string(),
432 }
433 } else {
434 models::generated_certificates::get_certificate_by_verification_id(
435 &mut conn,
436 &certificate_verification_id,
437 )
438 .await?
439 };
440
441 let data = certificates::generate_certificate(
442 &mut conn,
443 file_store.as_ref(),
444 &certificate,
445 query.debug,
446 **icu4x_blob,
447 )
448 .await?;
449 let max_age = if query.debug { 0 } else { 300 };
450
451 token.authorized_ok(
452 HttpResponse::Ok()
453 .content_type("image/png")
454 .insert_header(("Cache-Control", format!("max-age={max_age}")))
455 .body(data),
456 )
457}
458
459#[instrument(skip(pool))]
465pub async fn delete_certificate_configuration(
466 configuration_id: web::Path<Uuid>,
467 pool: web::Data<PgPool>,
468 user: AuthUser,
469) -> ControllerResult<web::Json<bool>> {
470 let mut conn = pool.acquire().await?;
471 let requirements = models::certificate_configuration_to_requirements::get_all_requirements_for_certificate_configuration(
472 &mut conn,
473 *configuration_id,
474 ).await?;
475
476 let course_module_ids = requirements.course_module_ids;
477 let mut token = None;
478 if course_module_ids.is_empty() {
479 token =
480 Some(authorize(&mut conn, Act::Teach, Some(user.id), Res::GlobalPermissions).await?);
481 }
482
483 for course_module_id in course_module_ids {
484 let course_module = models::course_modules::get_by_id(&mut conn, course_module_id).await?;
485 let course_id = course_module.course_id;
486 token =
487 Some(authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(course_id)).await?);
488 }
489
490 models::certificate_configurations::delete(&mut conn, *configuration_id).await?;
491 let token = token.ok_or_else(|| {
492 ControllerError::new(
493 ControllerErrorType::InternalServerError,
494 "Authorization token was not set".to_string(),
495 None,
496 )
497 })?;
498 token.authorized_ok(web::Json(true))
499}
500
501#[instrument(skip(pool))]
502pub async fn update_generated_certificate(
503 certificate_id: web::Path<Uuid>,
504 payload: web::Json<CertificateUpdateRequest>,
505 pool: web::Data<PgPool>,
506 user: AuthUser,
507) -> ControllerResult<web::Json<GeneratedCertificate>> {
508 let mut conn = pool.acquire().await?;
509
510 let cert = models::generated_certificates::get_by_id(&mut conn, *certificate_id).await?;
511
512 let req = models::certificate_configuration_to_requirements::get_all_requirements_for_certificate_configuration(
514 &mut conn,
515 cert.certificate_configuration_id,
516 ).await?;
517
518 let course_module_id = req.course_module_ids.first().ok_or_else(|| {
519 ControllerError::new(
520 ControllerErrorType::BadRequest,
521 "Certificate has no associated course module",
522 None,
523 )
524 })?;
525
526 let course_module = models::course_modules::get_by_id(&mut conn, *course_module_id).await?;
527 let course_id = course_module.course_id;
528
529 let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(course_id)).await?;
530
531 let updated = models::generated_certificates::update_certificate(
532 &mut conn,
533 *certificate_id,
534 payload.date_issued,
535 payload.name_on_certificate.clone(),
536 )
537 .await?;
538
539 token.authorized_ok(web::Json(updated))
540}
541
542pub fn _add_routes(cfg: &mut ServiceConfig) {
550 cfg.route("", web::post().to(update_certificate_configuration))
551 .route("/generate", web::post().to(generate_generated_certificate))
552 .route(
553 "/get-by-configuration-id/{certificate_configuration_id}",
554 web::get().to(get_generated_certificate),
555 )
556 .route(
557 "/generated/{certificate_id}",
558 web::put().to(update_generated_certificate),
559 )
560 .route(
561 "/{certificate_verification_id}",
562 web::get().to(get_cerficate_by_verification_id),
563 )
564 .route(
565 "/configuration/{certificate_configuration_id}",
566 web::delete().to(delete_certificate_configuration),
567 );
568}