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_and_course_instance(
197 &mut tx,
198 metadata.course_module_id,
199 metadata.course_instance_id,
200 )
201 .await
202 .optional()?;
203 let (background_svg_file_upload_id, background_svg_path) =
206 match (&existing_configuration, &new_background_svg_file) {
207 (Some(existing_configuration), None) => {
208 (
210 existing_configuration.background_svg_file_upload_id,
211 existing_configuration.background_svg_path.clone(),
212 )
213 }
214 (existing, Some(background_svg_file)) => {
215 if let Some(existing) = existing {
217 files_to_delete.push(existing.background_svg_file_upload_id);
218 }
219 background_svg_file.clone()
221 }
222 (None, None) => {
223 return Err(ControllerError::new(
225 ControllerErrorType::BadRequest,
226 "Missing background SVG file".to_string(),
227 None,
228 ));
229 }
230 };
231 let overlay_data = match (
234 &existing_configuration,
235 &new_overlay_svg_file,
236 metadata.clear_overlay_svg_file,
237 ) {
238 (_, Some(new_overlay), _) => {
239 Some(new_overlay.clone())
241 }
242 (Some(existing), None, false) => {
243 existing
245 .overlay_svg_file_upload_id
246 .zip(existing.overlay_svg_path.clone())
247 }
248 (Some(existing), None, true) => {
249 if let Some(existing_overlay) = existing.overlay_svg_file_upload_id {
251 files_to_delete.push(existing_overlay);
252 }
253 None
254 }
255 (None, None, _) => {
256 None
258 }
259 };
260 let (overlay_svg_file_id, overlay_svg_file_path) = overlay_data.unzip();
261 let conf = DatabaseCertificateConfiguration {
262 id: existing_configuration
263 .as_ref()
264 .map(|c| c.id)
265 .unwrap_or(Uuid::new_v4()),
266 certificate_owner_name_y_pos: metadata.certificate_owner_name_y_pos,
267 certificate_owner_name_x_pos: metadata.certificate_owner_name_x_pos,
268 certificate_owner_name_font_size: metadata.certificate_owner_name_font_size,
269 certificate_owner_name_text_color: metadata.certificate_owner_name_text_color,
270 certificate_owner_name_text_anchor: metadata.certificate_owner_name_text_anchor,
271 certificate_validate_url_y_pos: metadata.certificate_validate_url_y_pos,
272 certificate_validate_url_x_pos: metadata.certificate_validate_url_x_pos,
273 certificate_validate_url_font_size: metadata.certificate_validate_url_font_size,
274 certificate_validate_url_text_color: metadata.certificate_validate_url_text_color,
275 certificate_validate_url_text_anchor: metadata.certificate_validate_url_text_anchor,
276 certificate_date_y_pos: metadata.certificate_date_y_pos,
277 certificate_date_x_pos: metadata.certificate_date_x_pos,
278 certificate_date_font_size: metadata.certificate_date_font_size,
279 certificate_date_text_color: metadata.certificate_date_text_color,
280 certificate_date_text_anchor: metadata.certificate_date_text_anchor,
281 certificate_locale: metadata.certificate_locale,
282 paper_size: metadata.paper_size,
283 background_svg_path,
284 background_svg_file_upload_id,
285 overlay_svg_path: overlay_svg_file_path,
286 overlay_svg_file_upload_id: overlay_svg_file_id,
287 render_certificate_grade: metadata.render_certificate_grade,
288 certificate_grade_y_pos: metadata.certificate_grade_y_pos,
289 certificate_grade_x_pos: metadata.certificate_grade_x_pos,
290 certificate_grade_font_size: metadata.certificate_grade_font_size,
291 certificate_grade_text_color: metadata.certificate_grade_text_color,
292 certificate_grade_text_anchor: metadata.certificate_grade_text_anchor,
293 };
294 if let Some(existing_configuration) = existing_configuration {
295 models::certificate_configurations::update(&mut tx, existing_configuration.id, &conf)
297 .await?;
298 } else {
299 let inserted_configuration =
300 models::certificate_configurations::insert(&mut tx, &conf).await?;
301 models::certificate_configuration_to_requirements::insert(
302 &mut tx,
303 inserted_configuration.id,
304 Some(metadata.course_module_id),
305 metadata.course_instance_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 related_course_instance_ids =
472 models::certificate_configurations::get_required_course_instance_ids(
473 &mut conn,
474 *configuration_id,
475 )
476 .await?;
477 let mut token = None;
478 if related_course_instance_ids.is_empty() {
479 token =
480 Some(authorize(&mut conn, Act::Teach, Some(user.id), Res::GlobalPermissions).await?);
481 }
482 for course_instance_id in related_course_instance_ids {
483 token = Some(
484 authorize(
485 &mut conn,
486 Act::Teach,
487 Some(user.id),
488 Res::CourseInstance(course_instance_id),
489 )
490 .await?,
491 );
492 }
493 models::certificate_configurations::delete(&mut conn, *configuration_id).await?;
494 token
495 .expect("Never None at this point")
496 .authorized_ok(web::Json(true))
497}
498
499pub fn _add_routes(cfg: &mut ServiceConfig) {
507 cfg.route("", web::post().to(update_certificate_configuration))
508 .route("/generate", web::post().to(generate_generated_certificate))
509 .route(
510 "/get-by-configuration-id/{certificate_configuration_id}",
511 web::get().to(get_generated_certificate),
512 )
513 .route(
514 "/{certificate_verification_id}",
515 web::get().to(get_cerficate_by_verification_id),
516 )
517 .route(
518 "/configuration/{certificate_configuration_id}",
519 web::delete().to(delete_certificate_configuration),
520 );
521}