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