feat: implement course certificate generation system with organization-level toggles
This commit is contained in:
@@ -764,8 +764,8 @@ pub async fn ingest_course(
|
||||
// 1. Insertar o actualizar (Upsert) Organización
|
||||
let org_id = payload.course.organization_id;
|
||||
sqlx::query(
|
||||
"INSERT INTO organizations (id, name, domain, logo_url, primary_color, secondary_color, certificate_template, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
"INSERT INTO organizations (id, name, domain, logo_url, primary_color, secondary_color, certificate_template, certificates_enabled, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
domain = EXCLUDED.domain,
|
||||
@@ -773,6 +773,7 @@ pub async fn ingest_course(
|
||||
primary_color = EXCLUDED.primary_color,
|
||||
secondary_color = EXCLUDED.secondary_color,
|
||||
certificate_template = EXCLUDED.certificate_template,
|
||||
certificates_enabled = EXCLUDED.certificates_enabled,
|
||||
updated_at = EXCLUDED.updated_at"
|
||||
)
|
||||
.bind(payload.organization.id)
|
||||
@@ -782,6 +783,7 @@ pub async fn ingest_course(
|
||||
.bind(&payload.organization.primary_color)
|
||||
.bind(&payload.organization.secondary_color)
|
||||
.bind(&payload.organization.certificate_template)
|
||||
.bind(payload.organization.certificates_enabled)
|
||||
.bind(payload.organization.created_at)
|
||||
.bind(payload.organization.updated_at)
|
||||
.execute(&mut *tx)
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use common::auth::Claims;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Sha256, Digest};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Macro para usar json!() sin importar serde_json
|
||||
use serde_json::json;
|
||||
|
||||
// ============= Structs =============
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CertificateResponse {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub course_id: Uuid,
|
||||
pub course_title: String,
|
||||
pub student_name: String,
|
||||
pub certificate_html: String,
|
||||
pub issued_at: String,
|
||||
pub verification_code: String,
|
||||
pub metadata: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CertificateVerificationResponse {
|
||||
pub valid: bool,
|
||||
pub certificate: Option<CertificateResponse>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct IssueCertificateRequest {
|
||||
pub force_reissue: Option<bool>, // Para re-emitir si ya existe
|
||||
}
|
||||
|
||||
// ============= Handlers =============
|
||||
|
||||
/// GET /courses/{id}/certificate
|
||||
/// Obtiene el certificado emitido para el usuario actual en este curso.
|
||||
/// Si no existe, lo genera automáticamente si el usuario completó el curso.
|
||||
pub async fn get_certificate(
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(course_id): Path<Uuid>,
|
||||
) -> Result<Json<CertificateResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let user_id = claims.sub;
|
||||
|
||||
// 1. Verificar si la organización tiene certificados habilitados
|
||||
let org_certificates_enabled = sqlx::query!(
|
||||
"SELECT certificates_enabled FROM organizations WHERE id = (
|
||||
SELECT organization_id FROM courses WHERE id = $1
|
||||
)",
|
||||
course_id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error al verificar configuración de certificados: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "Error interno del servidor"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(org_config) = org_certificates_enabled {
|
||||
if !org_config.certificates_enabled {
|
||||
return Err((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
Json(json!({
|
||||
"error": "La generación de certificados está deshabilitada por el administrador",
|
||||
"hint": "Contacta al administrador de la plataforma para más información"
|
||||
})),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Verificar si ya existe un certificado emitido
|
||||
let existing_cert = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
ic.id,
|
||||
ic.user_id,
|
||||
ic.course_id,
|
||||
ic.certificate_html,
|
||||
ic.issued_at,
|
||||
ic.verification_code,
|
||||
ic.metadata
|
||||
FROM issued_certificates ic
|
||||
WHERE ic.user_id = $1 AND ic.course_id = $2
|
||||
"#,
|
||||
user_id,
|
||||
course_id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error al consultar certificado: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "Error interno del servidor"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(cert) = existing_cert {
|
||||
// Obtener título del curso y nombre del estudiante
|
||||
let course_info = sqlx::query!(
|
||||
"SELECT title FROM courses WHERE id = $1",
|
||||
course_id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error al obtener info del curso: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "Error interno del servidor"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let user_info = sqlx::query!(
|
||||
"SELECT full_name FROM users WHERE id = $1",
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error al obtener info del usuario: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "Error interno del servidor"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
return Ok(Json(CertificateResponse {
|
||||
id: cert.id,
|
||||
user_id: cert.user_id,
|
||||
course_id: cert.course_id,
|
||||
course_title: course_info.map(|c| c.title).unwrap_or_default(),
|
||||
student_name: user_info.map(|u| u.full_name).unwrap_or_default(),
|
||||
certificate_html: cert.certificate_html,
|
||||
issued_at: cert.issued_at.to_string(),
|
||||
verification_code: cert.verification_code,
|
||||
metadata: cert.metadata,
|
||||
}));
|
||||
}
|
||||
|
||||
// 2. Si no existe, verificar si el usuario completó el curso y generar certificado
|
||||
let course_completion = check_course_completion(user_id, course_id, &pool).await
|
||||
.map_err(|e| e)?;
|
||||
|
||||
if !course_completion.completed {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "No has completado este curso aún",
|
||||
"progress": course_completion.progress,
|
||||
"required": 100.0
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
// 3. Generar el certificado
|
||||
issue_certificate_internal(user_id, course_id, &pool, None).await
|
||||
.map_err(|e| e)
|
||||
}
|
||||
|
||||
/// POST /courses/{id}/certificate/issue
|
||||
/// Emite un certificado para el usuario actual si completó el curso.
|
||||
/// Permite re-emisión si force_reissue = true.
|
||||
pub async fn issue_certificate(
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(course_id): Path<Uuid>,
|
||||
Json(payload): Json<Option<IssueCertificateRequest>>,
|
||||
) -> Result<Json<CertificateResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let user_id = claims.sub;
|
||||
let force_reissue = payload.and_then(|p| p.force_reissue).unwrap_or(false);
|
||||
|
||||
// Verificar si la organización tiene certificados habilitados
|
||||
let org_certificates_enabled = sqlx::query!(
|
||||
"SELECT certificates_enabled FROM organizations WHERE id = (
|
||||
SELECT organization_id FROM courses WHERE id = $1
|
||||
)",
|
||||
course_id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error al verificar configuración de certificados: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "Error interno del servidor"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(org_config) = org_certificates_enabled {
|
||||
if !org_config.certificates_enabled {
|
||||
return Err((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
Json(json!({
|
||||
"error": "La generación de certificados está deshabilitada por el administrador",
|
||||
"hint": "Contacta al administrador de la plataforma para más información"
|
||||
})),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar si ya existe
|
||||
let existing = sqlx::query!(
|
||||
"SELECT id FROM issued_certificates WHERE user_id = $1 AND course_id = $2",
|
||||
user_id,
|
||||
course_id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error al verificar certificado existente: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "Error interno del servidor"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
if existing.is_some() && !force_reissue {
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({
|
||||
"error": "Ya tienes un certificado emitido para este curso",
|
||||
"hint": "Usa force_reissue=true para re-emitir"
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
// Verificar completitud del curso
|
||||
let course_completion = check_course_completion(user_id, course_id, &pool).await
|
||||
.map_err(|e| e)?;
|
||||
|
||||
if !course_completion.completed {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "No has completado este curso aún",
|
||||
"progress": course_completion.progress,
|
||||
"required": 100.0
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
// Emitir certificado
|
||||
issue_certificate_internal(user_id, course_id, &pool, None).await
|
||||
.map_err(|e| e)
|
||||
}
|
||||
|
||||
/// GET /certificates/verify/{code}
|
||||
/// Verifica la autenticidad de un certificado por su código público.
|
||||
pub async fn verify_certificate(
|
||||
Path(code): Path<String>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<CertificateVerificationResponse>, StatusCode> {
|
||||
let cert = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
ic.id,
|
||||
ic.user_id,
|
||||
ic.course_id,
|
||||
ic.certificate_html,
|
||||
ic.issued_at,
|
||||
ic.verification_code,
|
||||
ic.metadata,
|
||||
c.title as course_title,
|
||||
u.full_name as student_name
|
||||
FROM issued_certificates ic
|
||||
JOIN courses c ON c.id = ic.course_id
|
||||
JOIN users u ON u.id = ic.user_id
|
||||
WHERE ic.verification_code = $1
|
||||
"#,
|
||||
code
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error al verificar certificado: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
match cert {
|
||||
Some(cert) => Ok(Json(CertificateVerificationResponse {
|
||||
valid: true,
|
||||
certificate: Some(CertificateResponse {
|
||||
id: cert.id,
|
||||
user_id: cert.user_id,
|
||||
course_id: cert.course_id,
|
||||
course_title: cert.course_title,
|
||||
student_name: cert.student_name,
|
||||
certificate_html: cert.certificate_html,
|
||||
issued_at: cert.issued_at.to_string(),
|
||||
verification_code: cert.verification_code,
|
||||
metadata: cert.metadata,
|
||||
}),
|
||||
message: "Certificado válido".to_string(),
|
||||
})),
|
||||
None => Ok(Json(CertificateVerificationResponse {
|
||||
valid: false,
|
||||
certificate: None,
|
||||
message: "Certificado no encontrado o inválido".to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// ============= Funciones Internas =============
|
||||
|
||||
struct CourseCompletion {
|
||||
completed: bool,
|
||||
progress: f64,
|
||||
}
|
||||
|
||||
async fn check_course_completion(
|
||||
user_id: Uuid,
|
||||
course_id: Uuid,
|
||||
pool: &PgPool,
|
||||
) -> Result<CourseCompletion, (StatusCode, Json<serde_json::Value>)> {
|
||||
// Obtener todas las lecciones graduables del curso
|
||||
let gradable_lessons = sqlx::query!(
|
||||
r#"
|
||||
SELECT l.id, l.is_graded, l.passing_percentage
|
||||
FROM lessons l
|
||||
JOIN modules m ON m.id = l.module_id
|
||||
WHERE m.course_id = $1 AND l.is_graded = true
|
||||
"#,
|
||||
course_id
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error al obtener lecciones graduables: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "Error interno del servidor"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
if gradable_lessons.is_empty() {
|
||||
// Si no hay lecciones graduables, considerar completado si está inscrito
|
||||
let enrolled = sqlx::query!(
|
||||
"SELECT id FROM enrollments WHERE user_id = $1 AND course_id = $2",
|
||||
user_id,
|
||||
course_id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error al verificar inscripción: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "Error interno del servidor"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
return Ok(CourseCompletion {
|
||||
completed: enrolled.is_some(),
|
||||
progress: if enrolled.is_some() { 100.0 } else { 0.0 },
|
||||
});
|
||||
}
|
||||
|
||||
// Contar lecciones aprobadas
|
||||
let total_lessons = gradable_lessons.len() as f64;
|
||||
let mut passed_lessons = 0.0;
|
||||
|
||||
for lesson in gradable_lessons {
|
||||
let passing_pct = lesson.passing_percentage.unwrap_or(60.0);
|
||||
|
||||
let best_score = sqlx::query!(
|
||||
r#"
|
||||
SELECT MAX(score_percentage) as max_score
|
||||
FROM grades
|
||||
WHERE user_id = $1 AND lesson_id = $2
|
||||
"#,
|
||||
user_id,
|
||||
lesson.id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error al obtener calificaciones: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "Error interno del servidor"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(score) = best_score {
|
||||
if score.max_score.unwrap_or(0.0) >= passing_pct {
|
||||
passed_lessons += 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let progress = (passed_lessons / total_lessons) * 100.0;
|
||||
|
||||
Ok(CourseCompletion {
|
||||
completed: progress >= 100.0,
|
||||
progress,
|
||||
})
|
||||
}
|
||||
|
||||
async fn issue_certificate_internal(
|
||||
user_id: Uuid,
|
||||
course_id: Uuid,
|
||||
pool: &PgPool,
|
||||
_certificate_template_override: Option<&str>,
|
||||
) -> Result<Json<CertificateResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
// Obtener datos necesarios
|
||||
let course_data = sqlx::query!(
|
||||
r#"
|
||||
SELECT title, certificate_template, organization_id
|
||||
FROM courses
|
||||
WHERE id = $1
|
||||
"#,
|
||||
course_id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error al obtener datos del curso: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "Error interno del servidor"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let course_data = course_data.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "Curso no encontrado"})),
|
||||
))?;
|
||||
|
||||
let user_name = sqlx::query!(
|
||||
"SELECT full_name FROM users WHERE id = $1",
|
||||
user_id
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error al obtener nombre del usuario: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "Error interno del servidor"})),
|
||||
)
|
||||
})?
|
||||
.full_name;
|
||||
|
||||
let org_data = sqlx::query!(
|
||||
r#"
|
||||
SELECT name, certificate_template
|
||||
FROM organizations
|
||||
WHERE id = $1
|
||||
"#,
|
||||
course_data.organization_id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error al obtener datos de la organización: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "Error interno del servidor"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Determinar template a usar (curso > organización > default)
|
||||
let template = course_data
|
||||
.certificate_template
|
||||
.or_else(|| org_data.and_then(|o| o.certificate_template))
|
||||
.unwrap_or_else(|| get_default_certificate_template());
|
||||
|
||||
// Reemplazar variables en el template
|
||||
let now = chrono::Utc::now();
|
||||
let certificate_html = template
|
||||
.replace("{{student_name}}", &user_name)
|
||||
.replace("{{course_title}}", &course_data.title)
|
||||
.replace("{{date}}", &now.format("%d/%m/%Y").to_string())
|
||||
.replace("{{score}}", "Aprobado")
|
||||
.replace("{{verification_code}}", "VER-PLACEHOLDER");
|
||||
|
||||
// Generar código de verificación único
|
||||
let verification_code = format!(
|
||||
"VER-{}-{}",
|
||||
now.format("%Y%m%d"),
|
||||
&uuid::Uuid::new_v4().to_string()[..8].to_uppercase()
|
||||
);
|
||||
|
||||
// Reemplazar placeholder con código real
|
||||
let certificate_html = certificate_html.replace("VER-PLACEHOLDER", &verification_code);
|
||||
|
||||
// Generar hash para verificación
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(certificate_html.as_bytes());
|
||||
let certificate_hash = format!("{:x}", hasher.finalize());
|
||||
|
||||
// Obtener progreso final para metadata
|
||||
let course_completion = check_course_completion(user_id, course_id, pool).await
|
||||
.unwrap_or(CourseCompletion {
|
||||
completed: true,
|
||||
progress: 100.0,
|
||||
});
|
||||
|
||||
// Insertar certificado en BD
|
||||
let metadata = serde_json::json!({
|
||||
"completion_date": now.to_rfc3339(),
|
||||
"final_score": course_completion.progress,
|
||||
"organization_id": course_data.organization_id.to_string(),
|
||||
});
|
||||
|
||||
let issued_cert = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO issued_certificates
|
||||
(user_id, course_id, certificate_html, certificate_hash, verification_code, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, issued_at
|
||||
"#,
|
||||
user_id,
|
||||
course_id,
|
||||
certificate_html,
|
||||
certificate_hash,
|
||||
verification_code,
|
||||
metadata
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error al emitir certificado: {}", e);
|
||||
// Manejar unique constraint violation
|
||||
if e.to_string().contains("issued_certificates_user_course_unique") {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({"error": "Ya existe un certificado para este usuario y curso"})),
|
||||
);
|
||||
}
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "Error interno del servidor"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(CertificateResponse {
|
||||
id: issued_cert.id,
|
||||
user_id,
|
||||
course_id,
|
||||
course_title: course_data.title,
|
||||
student_name: user_name,
|
||||
certificate_html,
|
||||
issued_at: issued_cert.issued_at.to_string(),
|
||||
verification_code,
|
||||
metadata,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Template de certificado por defecto
|
||||
fn get_default_certificate_template() -> String {
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Georgia', serif;
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #333;
|
||||
}
|
||||
.certificate {
|
||||
background: white;
|
||||
padding: 50px;
|
||||
border: 20px solid #f0f0f0;
|
||||
border-radius: 10px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
}
|
||||
h1 {
|
||||
color: #667eea;
|
||||
font-size: 42px;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 3px;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.recipient {
|
||||
font-size: 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.student-name {
|
||||
font-size: 36px;
|
||||
color: #764ba2;
|
||||
font-weight: bold;
|
||||
margin: 20px 0;
|
||||
font-style: italic;
|
||||
}
|
||||
.course-name {
|
||||
font-size: 28px;
|
||||
color: #667eea;
|
||||
font-weight: bold;
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
border-top: 2px solid #667eea;
|
||||
border-bottom: 2px solid #667eea;
|
||||
display: inline-block;
|
||||
}
|
||||
.date {
|
||||
margin-top: 40px;
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
}
|
||||
.verification {
|
||||
margin-top: 30px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
.seal {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border: 4px solid #667eea;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 30px auto;
|
||||
font-size: 40px;
|
||||
color: #667eea;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="certificate">
|
||||
<div class="seal">🎓</div>
|
||||
<h1>Certificado</h1>
|
||||
<p class="subtitle">Se otorga este certificado a</p>
|
||||
<p class="student-name">{{student_name}}</p>
|
||||
<p class="recipient">Por haber completado exitosamente el curso</p>
|
||||
<p class="course-name">{{course_title}}</p>
|
||||
<p class="date">Fecha: {{date}}</p>
|
||||
<p class="date">Estado: {{score}}</p>
|
||||
<p class="verification">Código de verificación: {{verification_code}}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#.to_string()
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod handlers_payments;
|
||||
mod handlers_peer_review;
|
||||
mod handlers_embeddings;
|
||||
mod handlers_faq;
|
||||
mod handlers_certificates;
|
||||
mod lti;
|
||||
mod jwks;
|
||||
mod predictive;
|
||||
@@ -177,6 +178,10 @@ async fn main() {
|
||||
.route("/profile/{user_id}", get(portfolio::get_public_profile))
|
||||
.route("/my/badges", get(portfolio::get_my_badges))
|
||||
.route("/badges/award", post(portfolio::award_badge))
|
||||
// Certificados
|
||||
.route("/courses/{id}/certificate", get(handlers_certificates::get_certificate))
|
||||
.route("/courses/{id}/certificate/issue", post(handlers_certificates::issue_certificate))
|
||||
.route("/certificates/verify/{code}", get(handlers_certificates::verify_certificate))
|
||||
.route(
|
||||
"/users/{id}/gamification",
|
||||
get(handlers::get_user_gamification),
|
||||
|
||||
Reference in New Issue
Block a user