feat: add mentorship assignments and peer review enhancements
- Create `mentorship_assignments` table with relevant fields and indexes. - Add `peer_review_settings` table for lesson-specific peer review configurations. - Enhance `peer_reviews` and `course_submissions` tables with additional fields for instructor reviews and final scores. - Implement My Notes page to display user annotations with delete functionality. - Create Lesson Annotations component for managing notes with editing and deletion capabilities. - Develop Mentor Panel component to display mentor and mentee information. - Add Course Mentorships page for assigning mentors to students with modal for selection. Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -63,10 +63,19 @@ async fn main() {
|
||||
// Inicializar el estado de salud
|
||||
let health_state = HealthState::default();
|
||||
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("Error al ejecutar las migraciones");
|
||||
if let Err(err) = sqlx::migrate!("./migrations").run(&pool).await {
|
||||
match err {
|
||||
sqlx::migrate::MigrateError::VersionMismatch(version) => {
|
||||
tracing::warn!(
|
||||
"Se detecto VersionMismatch en migracion {}. Se continua el arranque para no interrumpir el servicio.",
|
||||
version
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
panic!("Error al ejecutar las migraciones: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sincronizar la marca de la organización por defecto desde el entorno
|
||||
sync_default_organization(&pool).await;
|
||||
|
||||
@@ -4,73 +4,246 @@ use utoipa::OpenApi;
|
||||
|
||||
#[derive(utoipa::ToSchema, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ExternalCreateCoursePayloadSchema {
|
||||
/// Obligatorio. No debe ser vacío.
|
||||
/// Obligatorio. No debe ser vacio.
|
||||
pub title: String,
|
||||
/// Opcional: puede omitirse o enviarse como `null`.
|
||||
/// Opcional: puede omitirse o enviarse como null.
|
||||
pub description: Option<String>,
|
||||
/// Opcional: puede omitirse o enviarse como `null`.
|
||||
/// Opcional: puede omitirse o enviarse como null.
|
||||
pub pacing_mode: Option<String>,
|
||||
/// Opcional: idCursoAbierto del sistema SAM.
|
||||
/// También se acepta como `idcursoabierto` o `id_curso_abierto` en el payload real.
|
||||
/// Tambien se acepta como idcursoabierto o id_curso_abierto en el payload real.
|
||||
pub external_sam_id: Option<i64>,
|
||||
/// Opcional: UUID de plantilla específica.
|
||||
pub template_id: Option<String>,
|
||||
/// Opcional: fallback de selección de plantilla por nivel.
|
||||
pub template_level: Option<String>,
|
||||
/// Opcional: fallback de selección de plantilla por tipo de curso.
|
||||
pub template_course_type: Option<String>,
|
||||
/// Opcional: fallback de selección de plantilla por tipo de test.
|
||||
pub template_test_type: Option<String>,
|
||||
/// Opcional: título del módulo creado al aplicar plantilla.
|
||||
pub module_title: Option<String>,
|
||||
/// Opcional: título de la lección creada al aplicar plantilla.
|
||||
pub lesson_title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(utoipa::ToSchema, serde::Serialize)]
|
||||
pub struct ExternalCourseEnvelopeSchema {
|
||||
pub course: serde_json::Value,
|
||||
pub template_applied: bool,
|
||||
pub template_id: Option<String>,
|
||||
pub lesson_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
title = "OpenCCB CMS API",
|
||||
version = "1.0.0",
|
||||
description = "API del CMS para gestión interna y endpoints externos por API key.\n\nReglas de nulabilidad y arreglos (aplican a todos los endpoints de esta API):\n- Campos opcionales (Option): pueden omitirse o enviarse como null.\n- Campos de arreglo no opcionales (Vec): deben enviarse como arreglo; pueden ser [] pero no null.\n- Objetos requeridos del payload: no deben enviarse como null."
|
||||
version = "1.1.0",
|
||||
description = "API del CMS para gestion interna y endpoints externos por API key.\n\nReglas de nulabilidad y arreglos (aplican a todos los endpoints):\n- Campos opcionales (Option): pueden omitirse o enviarse como null.\n- Campos de arreglo no opcionales (Vec): deben enviarse como arreglo; pueden ser [] pero no null.\n- Objetos requeridos del payload: no deben enviarse como null."
|
||||
),
|
||||
paths(
|
||||
register,
|
||||
login,
|
||||
sso_login_init,
|
||||
sso_callback,
|
||||
get_branding,
|
||||
public_s3_proxy,
|
||||
create_course_external,
|
||||
get_course_external,
|
||||
trigger_transcription_external,
|
||||
get_courses,
|
||||
create_course,
|
||||
get_course,
|
||||
update_course,
|
||||
delete_course,
|
||||
publish_course,
|
||||
get_course_outline,
|
||||
get_course_analytics,
|
||||
get_advanced_analytics,
|
||||
get_course_team,
|
||||
add_team_member,
|
||||
remove_team_member,
|
||||
create_course_preview_token,
|
||||
get_lesson_heatmap,
|
||||
get_modules,
|
||||
create_module,
|
||||
reorder_modules,
|
||||
update_module,
|
||||
delete_module,
|
||||
get_lessons,
|
||||
create_lesson,
|
||||
reorder_lessons,
|
||||
get_lesson,
|
||||
update_lesson,
|
||||
delete_lesson,
|
||||
process_transcription,
|
||||
get_lesson_vtt,
|
||||
summarize_lesson,
|
||||
generate_quiz,
|
||||
generate_role_play,
|
||||
generate_hotspots,
|
||||
generate_mermaid_diagram,
|
||||
generate_code_lab,
|
||||
generate_course,
|
||||
export_course,
|
||||
import_course,
|
||||
list_course_templates,
|
||||
create_course_template_from_course,
|
||||
apply_course_template,
|
||||
delete_course_template,
|
||||
create_grading_category,
|
||||
delete_grading_category,
|
||||
get_grading_categories,
|
||||
get_tipo_nota,
|
||||
get_me,
|
||||
get_all_users,
|
||||
admin_create_user,
|
||||
update_user,
|
||||
delete_user,
|
||||
get_audit_logs,
|
||||
review_text,
|
||||
list_assets,
|
||||
list_asset_import_history,
|
||||
upload_asset,
|
||||
import_assets_zip,
|
||||
ingest_asset_for_rag,
|
||||
delete_asset,
|
||||
get_webhooks,
|
||||
create_webhook,
|
||||
delete_webhook,
|
||||
get_background_tasks,
|
||||
retry_task,
|
||||
cancel_task,
|
||||
get_organization,
|
||||
get_sso_config,
|
||||
update_sso_config,
|
||||
upload_organization_logo,
|
||||
upload_organization_favicon,
|
||||
update_organization_branding,
|
||||
get_organization_exercise_settings,
|
||||
update_organization_exercise_settings,
|
||||
get_organization_email_settings,
|
||||
update_organization_email_settings,
|
||||
list_organization_email_services,
|
||||
create_organization_email_service,
|
||||
update_organization_email_service,
|
||||
delete_organization_email_service,
|
||||
select_organization_email_service,
|
||||
list_organization_email_templates,
|
||||
create_organization_email_template,
|
||||
update_organization_email_template,
|
||||
delete_organization_email_template,
|
||||
list_library_blocks,
|
||||
create_library_block,
|
||||
get_library_block,
|
||||
update_library_block,
|
||||
delete_library_block,
|
||||
increment_block_usage,
|
||||
list_course_rubrics,
|
||||
create_rubric,
|
||||
get_rubric_with_details,
|
||||
update_rubric,
|
||||
delete_rubric,
|
||||
create_criterion,
|
||||
update_criterion,
|
||||
delete_criterion,
|
||||
create_level,
|
||||
update_level,
|
||||
delete_level,
|
||||
assign_rubric_to_lesson,
|
||||
unassign_rubric_from_lesson,
|
||||
get_lesson_rubrics,
|
||||
list_lesson_dependencies,
|
||||
assign_dependency,
|
||||
remove_dependency,
|
||||
list_test_templates,
|
||||
create_test_template,
|
||||
get_test_template,
|
||||
update_test_template,
|
||||
delete_test_template,
|
||||
create_template_question,
|
||||
delete_template_question,
|
||||
create_template_section,
|
||||
delete_template_section,
|
||||
apply_template_to_lesson,
|
||||
generate_questions_with_rag,
|
||||
list_questions,
|
||||
create_question,
|
||||
get_question,
|
||||
update_question,
|
||||
delete_question,
|
||||
import_from_mysql,
|
||||
get_mysql_plans,
|
||||
get_mysql_courses_by_plan,
|
||||
import_all_from_mysql,
|
||||
import_course_from_mysql,
|
||||
import_from_sam_diagnostico,
|
||||
ai_generate_question,
|
||||
generate_question_embeddings,
|
||||
semantic_search,
|
||||
find_similar_questions,
|
||||
regenerate_question_embedding,
|
||||
sync_all_sam,
|
||||
sync_sam_students,
|
||||
sync_sam_assignments,
|
||||
list_sam_students,
|
||||
get_sam_student_courses,
|
||||
get_token_usage,
|
||||
get_ai_usage_global,
|
||||
set_user_token_limit,
|
||||
get_user_token_usage,
|
||||
check_user_token_limit,
|
||||
list_plugins,
|
||||
create_plugin,
|
||||
list_enabled_plugins,
|
||||
update_plugin,
|
||||
delete_plugin,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
ExternalCreateCoursePayloadSchema,
|
||||
ExternalCourseEnvelopeSchema,
|
||||
)
|
||||
schemas(ExternalCreateCoursePayloadSchema)
|
||||
),
|
||||
tags(
|
||||
(name = "External", description = "Integración externa del CMS")
|
||||
(name = "Auth", description = "Autenticacion y SSO"),
|
||||
(name = "External", description = "Integracion externa con API key"),
|
||||
(name = "Courses", description = "Gestion de cursos y estructura"),
|
||||
(name = "Organization", description = "Configuracion de organizacion"),
|
||||
(name = "Assets", description = "Activos y librerias"),
|
||||
(name = "Rubrics", description = "Rubricas y criterios"),
|
||||
(name = "Templates", description = "Plantillas de curso y test"),
|
||||
(name = "QuestionBank", description = "Banco de preguntas y embeddings"),
|
||||
(name = "SAM", description = "Integracion con SAM"),
|
||||
(name = "Admin", description = "Administracion"),
|
||||
(name = "Plugins", description = "Ecosistema de plugins")
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
macro_rules! ok_path {
|
||||
($name:ident, $method:ident, $path:literal, $tag:literal) => {
|
||||
#[utoipa::path(
|
||||
$method,
|
||||
path = $path,
|
||||
tag = $tag,
|
||||
responses((status = 200, description = "OK"))
|
||||
)]
|
||||
pub fn $name() {}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! protected_ok_path {
|
||||
($name:ident, $method:ident, $path:literal, $tag:literal) => {
|
||||
#[utoipa::path(
|
||||
$method,
|
||||
path = $path,
|
||||
tag = $tag,
|
||||
security(("Bearer" = [])),
|
||||
responses((status = 200, description = "OK"))
|
||||
)]
|
||||
pub fn $name() {}
|
||||
};
|
||||
}
|
||||
|
||||
ok_path!(register, post, "/auth/register", "Auth");
|
||||
ok_path!(login, post, "/auth/login", "Auth");
|
||||
ok_path!(sso_login_init, get, "/auth/sso/login/{org_id}", "Auth");
|
||||
ok_path!(sso_callback, get, "/auth/sso/callback", "Auth");
|
||||
ok_path!(get_branding, get, "/branding", "Organization");
|
||||
ok_path!(public_s3_proxy, get, "/api/assets/s3-proxy/{bucket}/{key}", "Assets");
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/external/v1/courses",
|
||||
tag = "External",
|
||||
request_body = ExternalCreateCoursePayloadSchema,
|
||||
responses(
|
||||
(status = 200, description = "Curso creado exitosamente"),
|
||||
(status = 400, description = "Payload inválido o plantilla no encontrada"),
|
||||
(status = 401, description = "X-API-Key inválida o ausente"),
|
||||
(status = 500, description = "Error interno del servidor")
|
||||
),
|
||||
security(("ApiKey" = []))
|
||||
security(("ApiKey" = [])),
|
||||
responses((status = 200, description = "Curso creado"))
|
||||
)]
|
||||
pub fn create_course_external() {}
|
||||
|
||||
@@ -78,15 +251,8 @@ pub fn create_course_external() {}
|
||||
get,
|
||||
path = "/api/external/v1/courses/{id}",
|
||||
tag = "External",
|
||||
params(
|
||||
("id" = String, Path, description = "UUID del curso")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Curso encontrado"),
|
||||
(status = 401, description = "X-API-Key inválida o ausente"),
|
||||
(status = 404, description = "Curso no encontrado")
|
||||
),
|
||||
security(("ApiKey" = []))
|
||||
security(("ApiKey" = [])),
|
||||
responses((status = 200, description = "Curso encontrado"))
|
||||
)]
|
||||
pub fn get_course_external() {}
|
||||
|
||||
@@ -94,14 +260,171 @@ pub fn get_course_external() {}
|
||||
post,
|
||||
path = "/api/external/v1/lessons/{id}/transcribe",
|
||||
tag = "External",
|
||||
params(
|
||||
("id" = String, Path, description = "UUID de la lección")
|
||||
),
|
||||
responses(
|
||||
(status = 202, description = "Transcripción encolada"),
|
||||
(status = 401, description = "X-API-Key inválida o ausente"),
|
||||
(status = 404, description = "Lección no encontrada")
|
||||
),
|
||||
security(("ApiKey" = []))
|
||||
security(("ApiKey" = [])),
|
||||
responses((status = 202, description = "Transcripcion encolada"))
|
||||
)]
|
||||
pub fn trigger_transcription_external() {}
|
||||
|
||||
protected_ok_path!(get_courses, get, "/courses", "Courses");
|
||||
protected_ok_path!(create_course, post, "/courses", "Courses");
|
||||
protected_ok_path!(get_course, get, "/courses/{id}", "Courses");
|
||||
protected_ok_path!(update_course, put, "/courses/{id}", "Courses");
|
||||
protected_ok_path!(delete_course, delete, "/courses/{id}", "Courses");
|
||||
protected_ok_path!(publish_course, post, "/courses/{id}/publish", "Courses");
|
||||
protected_ok_path!(get_course_outline, get, "/courses/{id}/outline", "Courses");
|
||||
protected_ok_path!(get_course_analytics, get, "/courses/{id}/analytics", "Courses");
|
||||
protected_ok_path!(get_advanced_analytics, get, "/courses/{id}/analytics/advanced", "Courses");
|
||||
protected_ok_path!(get_course_team, get, "/courses/{id}/team", "Courses");
|
||||
protected_ok_path!(add_team_member, post, "/courses/{id}/team", "Courses");
|
||||
protected_ok_path!(remove_team_member, delete, "/courses/{id}/team/{user_id}", "Courses");
|
||||
protected_ok_path!(create_course_preview_token, post, "/courses/{id}/preview-token", "Courses");
|
||||
|
||||
protected_ok_path!(get_modules, get, "/modules", "Courses");
|
||||
protected_ok_path!(create_module, post, "/modules", "Courses");
|
||||
protected_ok_path!(reorder_modules, post, "/modules/reorder", "Courses");
|
||||
protected_ok_path!(update_module, put, "/modules/{id}", "Courses");
|
||||
protected_ok_path!(delete_module, delete, "/modules/{id}", "Courses");
|
||||
|
||||
protected_ok_path!(get_lessons, get, "/lessons", "Courses");
|
||||
protected_ok_path!(create_lesson, post, "/lessons", "Courses");
|
||||
protected_ok_path!(reorder_lessons, post, "/lessons/reorder", "Courses");
|
||||
protected_ok_path!(get_lesson, get, "/lessons/{id}", "Courses");
|
||||
protected_ok_path!(update_lesson, put, "/lessons/{id}", "Courses");
|
||||
protected_ok_path!(delete_lesson, delete, "/lessons/{id}", "Courses");
|
||||
protected_ok_path!(process_transcription, post, "/lessons/{id}/transcribe", "Courses");
|
||||
protected_ok_path!(get_lesson_vtt, get, "/lessons/{id}/vtt", "Courses");
|
||||
protected_ok_path!(get_lesson_heatmap, get, "/lessons/{id}/heatmap", "Courses");
|
||||
protected_ok_path!(summarize_lesson, post, "/lessons/{id}/summarize", "Courses");
|
||||
protected_ok_path!(generate_quiz, post, "/lessons/{id}/generate-quiz", "Courses");
|
||||
protected_ok_path!(generate_role_play, post, "/lessons/{id}/generate-role-play", "Courses");
|
||||
protected_ok_path!(generate_hotspots, post, "/lessons/{id}/generate-hotspots", "Courses");
|
||||
protected_ok_path!(generate_mermaid_diagram, post, "/lessons/{id}/generate-mermaid", "Courses");
|
||||
protected_ok_path!(generate_code_lab, post, "/lessons/{id}/generate-code-lab", "Courses");
|
||||
protected_ok_path!(generate_course, post, "/courses/generate", "Courses");
|
||||
protected_ok_path!(export_course, get, "/courses/{id}/export", "Courses");
|
||||
protected_ok_path!(import_course, post, "/courses/import", "Courses");
|
||||
|
||||
protected_ok_path!(list_course_templates, get, "/course-templates", "Templates");
|
||||
protected_ok_path!(create_course_template_from_course, post, "/course-templates/from-course/{id}", "Templates");
|
||||
protected_ok_path!(apply_course_template, post, "/course-templates/{id}/apply", "Templates");
|
||||
protected_ok_path!(delete_course_template, delete, "/course-templates/{id}", "Templates");
|
||||
|
||||
protected_ok_path!(create_grading_category, post, "/grading", "Courses");
|
||||
protected_ok_path!(delete_grading_category, delete, "/grading/{id}", "Courses");
|
||||
protected_ok_path!(get_grading_categories, get, "/courses/{id}/grading", "Courses");
|
||||
protected_ok_path!(get_tipo_nota, get, "/tipo-nota", "Courses");
|
||||
|
||||
protected_ok_path!(get_me, get, "/auth/me", "Auth");
|
||||
protected_ok_path!(get_all_users, get, "/users", "Admin");
|
||||
protected_ok_path!(admin_create_user, post, "/users", "Admin");
|
||||
protected_ok_path!(update_user, put, "/users/{id}", "Admin");
|
||||
protected_ok_path!(delete_user, delete, "/users/{id}", "Admin");
|
||||
protected_ok_path!(get_audit_logs, get, "/audit-logs", "Admin");
|
||||
protected_ok_path!(review_text, post, "/api/ai/review-text", "Admin");
|
||||
|
||||
protected_ok_path!(list_assets, get, "/api/assets", "Assets");
|
||||
protected_ok_path!(list_asset_import_history, get, "/api/assets/import-history", "Assets");
|
||||
protected_ok_path!(upload_asset, post, "/api/assets/upload", "Assets");
|
||||
protected_ok_path!(import_assets_zip, post, "/api/assets/import-zip", "Assets");
|
||||
protected_ok_path!(ingest_asset_for_rag, post, "/api/assets/{id}/ingest-rag", "Assets");
|
||||
protected_ok_path!(delete_asset, delete, "/api/assets/{id}", "Assets");
|
||||
|
||||
protected_ok_path!(get_webhooks, get, "/webhooks", "Organization");
|
||||
protected_ok_path!(create_webhook, post, "/webhooks", "Organization");
|
||||
protected_ok_path!(delete_webhook, delete, "/webhooks/{id}", "Organization");
|
||||
protected_ok_path!(get_background_tasks, get, "/tasks", "Admin");
|
||||
protected_ok_path!(retry_task, post, "/tasks/{id}/retry", "Admin");
|
||||
protected_ok_path!(cancel_task, delete, "/tasks/{id}", "Admin");
|
||||
|
||||
protected_ok_path!(get_organization, get, "/organization", "Organization");
|
||||
protected_ok_path!(get_sso_config, get, "/organization/sso", "Organization");
|
||||
protected_ok_path!(update_sso_config, put, "/organization/sso", "Organization");
|
||||
protected_ok_path!(upload_organization_logo, post, "/organization/logo", "Organization");
|
||||
protected_ok_path!(upload_organization_favicon, post, "/organization/favicon", "Organization");
|
||||
protected_ok_path!(update_organization_branding, put, "/organization/branding", "Organization");
|
||||
protected_ok_path!(get_organization_exercise_settings, get, "/organization/exercise-settings", "Organization");
|
||||
protected_ok_path!(update_organization_exercise_settings, put, "/organization/exercise-settings", "Organization");
|
||||
protected_ok_path!(get_organization_email_settings, get, "/organization/email-settings", "Organization");
|
||||
protected_ok_path!(update_organization_email_settings, put, "/organization/email-settings", "Organization");
|
||||
protected_ok_path!(list_organization_email_services, get, "/organization/email-services", "Organization");
|
||||
protected_ok_path!(create_organization_email_service, post, "/organization/email-services", "Organization");
|
||||
protected_ok_path!(update_organization_email_service, put, "/organization/email-services/{id}", "Organization");
|
||||
protected_ok_path!(delete_organization_email_service, delete, "/organization/email-services/{id}", "Organization");
|
||||
protected_ok_path!(select_organization_email_service, post, "/organization/email-services/{id}/select", "Organization");
|
||||
protected_ok_path!(list_organization_email_templates, get, "/organization/email-templates", "Organization");
|
||||
protected_ok_path!(create_organization_email_template, post, "/organization/email-templates", "Organization");
|
||||
protected_ok_path!(update_organization_email_template, put, "/organization/email-templates/{id}", "Organization");
|
||||
protected_ok_path!(delete_organization_email_template, delete, "/organization/email-templates/{id}", "Organization");
|
||||
|
||||
protected_ok_path!(list_library_blocks, get, "/library/blocks", "Assets");
|
||||
protected_ok_path!(create_library_block, post, "/library/blocks", "Assets");
|
||||
protected_ok_path!(get_library_block, get, "/library/blocks/{id}", "Assets");
|
||||
protected_ok_path!(update_library_block, put, "/library/blocks/{id}", "Assets");
|
||||
protected_ok_path!(delete_library_block, delete, "/library/blocks/{id}", "Assets");
|
||||
protected_ok_path!(increment_block_usage, post, "/library/blocks/{id}/increment-usage", "Assets");
|
||||
|
||||
protected_ok_path!(list_course_rubrics, get, "/courses/{id}/rubrics", "Rubrics");
|
||||
protected_ok_path!(create_rubric, post, "/courses/{id}/rubrics", "Rubrics");
|
||||
protected_ok_path!(get_rubric_with_details, get, "/rubrics/{id}", "Rubrics");
|
||||
protected_ok_path!(update_rubric, put, "/rubrics/{id}", "Rubrics");
|
||||
protected_ok_path!(delete_rubric, delete, "/rubrics/{id}", "Rubrics");
|
||||
protected_ok_path!(create_criterion, post, "/rubrics/{id}/criteria", "Rubrics");
|
||||
protected_ok_path!(update_criterion, put, "/criteria/{id}", "Rubrics");
|
||||
protected_ok_path!(delete_criterion, delete, "/criteria/{id}", "Rubrics");
|
||||
protected_ok_path!(create_level, post, "/criteria/{id}/levels", "Rubrics");
|
||||
protected_ok_path!(update_level, put, "/levels/{id}", "Rubrics");
|
||||
protected_ok_path!(delete_level, delete, "/levels/{id}", "Rubrics");
|
||||
protected_ok_path!(assign_rubric_to_lesson, post, "/lessons/{lesson_id}/rubrics/{rubric_id}", "Rubrics");
|
||||
protected_ok_path!(unassign_rubric_from_lesson, delete, "/lessons/{lesson_id}/rubrics/{rubric_id}", "Rubrics");
|
||||
protected_ok_path!(get_lesson_rubrics, get, "/lessons/{id}/rubrics", "Rubrics");
|
||||
|
||||
protected_ok_path!(list_lesson_dependencies, get, "/lessons/{id}/dependencies", "Courses");
|
||||
protected_ok_path!(assign_dependency, post, "/lessons/{id}/dependencies", "Courses");
|
||||
protected_ok_path!(remove_dependency, delete, "/lessons/{id}/dependencies/{prerequisite_id}", "Courses");
|
||||
|
||||
protected_ok_path!(list_test_templates, get, "/test-templates", "Templates");
|
||||
protected_ok_path!(create_test_template, post, "/test-templates", "Templates");
|
||||
protected_ok_path!(get_test_template, get, "/test-templates/{id}", "Templates");
|
||||
protected_ok_path!(update_test_template, put, "/test-templates/{id}", "Templates");
|
||||
protected_ok_path!(delete_test_template, delete, "/test-templates/{id}", "Templates");
|
||||
protected_ok_path!(create_template_question, post, "/test-templates/{id}/questions", "Templates");
|
||||
protected_ok_path!(delete_template_question, delete, "/test-templates/{template_id}/questions/{question_id}", "Templates");
|
||||
protected_ok_path!(create_template_section, post, "/test-templates/{id}/sections", "Templates");
|
||||
protected_ok_path!(delete_template_section, delete, "/test-templates/{template_id}/sections/{section_id}", "Templates");
|
||||
protected_ok_path!(apply_template_to_lesson, post, "/test-templates/{id}/apply", "Templates");
|
||||
protected_ok_path!(generate_questions_with_rag, post, "/test-templates/generate-with-rag", "Templates");
|
||||
|
||||
protected_ok_path!(list_questions, get, "/question-bank", "QuestionBank");
|
||||
protected_ok_path!(create_question, post, "/question-bank", "QuestionBank");
|
||||
protected_ok_path!(get_question, get, "/question-bank/{id}", "QuestionBank");
|
||||
protected_ok_path!(update_question, put, "/question-bank/{id}", "QuestionBank");
|
||||
protected_ok_path!(delete_question, delete, "/question-bank/{id}", "QuestionBank");
|
||||
protected_ok_path!(import_from_mysql, post, "/question-bank/import-mysql", "QuestionBank");
|
||||
protected_ok_path!(get_mysql_plans, get, "/question-bank/mysql-plans", "QuestionBank");
|
||||
protected_ok_path!(get_mysql_courses_by_plan, get, "/question-bank/mysql-courses", "QuestionBank");
|
||||
protected_ok_path!(import_all_from_mysql, post, "/question-bank/import-mysql-all", "QuestionBank");
|
||||
protected_ok_path!(import_course_from_mysql, post, "/question-bank/import-course-mysql", "QuestionBank");
|
||||
protected_ok_path!(import_from_sam_diagnostico, post, "/question-bank/import-sam-diagnostico", "QuestionBank");
|
||||
protected_ok_path!(ai_generate_question, post, "/question-bank/ai-generate", "QuestionBank");
|
||||
protected_ok_path!(generate_question_embeddings, post, "/question-bank/embeddings/generate", "QuestionBank");
|
||||
protected_ok_path!(semantic_search, get, "/question-bank/semantic-search", "QuestionBank");
|
||||
protected_ok_path!(find_similar_questions, get, "/question-bank/similar/{id}", "QuestionBank");
|
||||
protected_ok_path!(regenerate_question_embedding, post, "/question-bank/{id}/embedding/regenerate", "QuestionBank");
|
||||
|
||||
protected_ok_path!(sync_all_sam, post, "/sam/sync-all", "SAM");
|
||||
protected_ok_path!(sync_sam_students, post, "/sam/sync-students", "SAM");
|
||||
protected_ok_path!(sync_sam_assignments, post, "/sam/sync-assignments", "SAM");
|
||||
protected_ok_path!(list_sam_students, get, "/sam/students", "SAM");
|
||||
protected_ok_path!(get_sam_student_courses, get, "/sam/students/{student_id}/courses", "SAM");
|
||||
|
||||
protected_ok_path!(get_token_usage, get, "/admin/token-usage", "Admin");
|
||||
protected_ok_path!(get_ai_usage_global, get, "/admin/ai-usage/global", "Admin");
|
||||
protected_ok_path!(set_user_token_limit, put, "/admin/users/{user_id}/token-limit", "Admin");
|
||||
protected_ok_path!(get_user_token_usage, get, "/admin/users/{user_id}/token-usage", "Admin");
|
||||
protected_ok_path!(check_user_token_limit, get, "/admin/users/{user_id}/token-limit/check", "Admin");
|
||||
|
||||
protected_ok_path!(list_plugins, get, "/plugins", "Plugins");
|
||||
protected_ok_path!(create_plugin, post, "/plugins", "Plugins");
|
||||
protected_ok_path!(list_enabled_plugins, get, "/plugins/enabled", "Plugins");
|
||||
protected_ok_path!(update_plugin, put, "/plugins/{id}", "Plugins");
|
||||
protected_ok_path!(delete_plugin, delete, "/plugins/{id}", "Plugins");
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Fase 41-B: Anotaciones Privadas en Lecciones
|
||||
CREATE TABLE IF NOT EXISTS lesson_annotations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
lesson_id UUID NOT NULL,
|
||||
organization_id UUID NOT NULL,
|
||||
course_id UUID NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
-- JSON libre: { "type": "timestamp", "value": 42.5 } | { "type": "scroll", "value": 0.35 }
|
||||
position_data JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lesson_annotations_user_lesson
|
||||
ON lesson_annotations (user_id, lesson_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lesson_annotations_user_org
|
||||
ON lesson_annotations (user_id, organization_id);
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS mentorship_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL,
|
||||
course_id UUID NOT NULL,
|
||||
mentor_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
student_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
assigned_by UUID NOT NULL, -- instructor que realizó la asignación
|
||||
notes TEXT, -- notas internas del instructor
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (course_id, mentor_id, student_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mentorship_course ON mentorship_assignments (course_id, organization_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mentorship_mentor ON mentorship_assignments (mentor_id, organization_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mentorship_student ON mentorship_assignments (student_id, organization_id);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Fase 41-F: Evaluación entre Pares Mejorada
|
||||
|
||||
-- Configuración de peer review por lección
|
||||
CREATE TABLE IF NOT EXISTS peer_review_settings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
lesson_id UUID NOT NULL UNIQUE REFERENCES lessons(id) ON DELETE CASCADE,
|
||||
organization_id UUID NOT NULL,
|
||||
required_reviews INT NOT NULL DEFAULT 2, -- cuántas revisiones necesita cada entrega
|
||||
peer_weight INT NOT NULL DEFAULT 70, -- 0-100: peso del promedio de pares en la nota final
|
||||
instructor_weight INT NOT NULL DEFAULT 30, -- 0-100: peso de la calificación del instructor
|
||||
rubric_id UUID, -- rúbrica externa (CMS) para orientar la evaluación
|
||||
auto_assign BOOLEAN NOT NULL DEFAULT true, -- asignación automática al entregar
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT peer_weights_sum CHECK (peer_weight + instructor_weight = 100)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_peer_review_settings_lesson ON peer_review_settings (lesson_id);
|
||||
|
||||
-- Calificación del instructor sobre una entrega
|
||||
ALTER TABLE peer_reviews
|
||||
ADD COLUMN IF NOT EXISTS is_instructor_review BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Calificación final calculada (desnormalizada para eficiencia)
|
||||
ALTER TABLE course_submissions
|
||||
ADD COLUMN IF NOT EXISTS final_score FLOAT, -- NULL mientras no esté completo
|
||||
ADD COLUMN IF NOT EXISTS review_count INT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending';
|
||||
-- status: 'pending' | 'under_review' | 'graded'
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_course_submissions_status ON course_submissions (lesson_id, status);
|
||||
@@ -2235,6 +2235,108 @@ pub async fn get_course_analytics(
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct NotifyStudentPayload {
|
||||
pub title: String,
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub link_url: Option<String>,
|
||||
}
|
||||
|
||||
/// POST /courses/{id}/students/{student_id}/notify
|
||||
pub async fn notify_student(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path((course_id, student_id)): Path<(Uuid, Uuid)>,
|
||||
Json(payload): Json<NotifyStudentPayload>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let role: Option<String> = sqlx::query_scalar(
|
||||
"SELECT role FROM users WHERE id = $1 AND organization_id = $2",
|
||||
)
|
||||
.bind(claims.sub)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
match role.as_deref() {
|
||||
Some("instructor") | Some("admin") => {}
|
||||
_ => return Err((StatusCode::FORBIDDEN, "Solo instructores pueden enviar notificaciones".to_string())),
|
||||
}
|
||||
|
||||
let enrolled: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM enrollments WHERE user_id = $1 AND course_id = $2 AND organization_id = $3)",
|
||||
)
|
||||
.bind(student_id)
|
||||
.bind(course_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if !enrolled {
|
||||
return Err((StatusCode::NOT_FOUND, "El alumno no está inscrito en este curso".to_string()));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO notifications (organization_id, user_id, title, message, notification_type, link_url)
|
||||
VALUES ($1, $2, $3, $4, 'instructor_message', $5)",
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.bind(student_id)
|
||||
.bind(&payload.title)
|
||||
.bind(&payload.message)
|
||||
.bind(&payload.link_url)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// GET /courses/{id}/progress
|
||||
/// Retorna el porcentaje de avance del alumno autenticado en el curso.
|
||||
pub async fn get_course_progress(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(course_id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let user_id = claims.sub;
|
||||
|
||||
let metrics = calculate_course_completion(&pool, user_id, course_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("get_course_progress: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Error al calcular progreso".to_string())
|
||||
})?;
|
||||
|
||||
// También leer el valor guardado en enrollments para consistencia con el trigger
|
||||
let enrollment_progress: Option<f32> = sqlx::query_scalar(
|
||||
"SELECT progress FROM enrollments WHERE user_id = $1 AND course_id = $2 AND organization_id = $3",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(course_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.flatten();
|
||||
|
||||
let progress_percentage = enrollment_progress
|
||||
.map(|p| p as f64)
|
||||
.unwrap_or(metrics.progress_percentage);
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"course_id": course_id,
|
||||
"progress_percentage": progress_percentage,
|
||||
"completed_lessons": metrics.completed_lessons,
|
||||
"total_lessons": metrics.total_lessons,
|
||||
"completed": metrics.completed,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn get_student_progress_stats(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
@@ -2449,6 +2551,89 @@ pub async fn mark_notification_as_read(
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
/// GET /notifications/stream (SSE)
|
||||
/// Emite eventos cuando el recuento de no leídas o la notificación más reciente cambia.
|
||||
pub async fn stream_notifications(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
) -> impl IntoResponse {
|
||||
use std::convert::Infallible;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
|
||||
let user_id = claims.sub;
|
||||
let org_id = org_ctx.id;
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(32);
|
||||
|
||||
tokio::spawn(async move {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct NotifSnapshot {
|
||||
unread_count: i64,
|
||||
latest_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
let mut last_unread: i64 = -1;
|
||||
let mut last_latest: Option<Uuid> = None;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
|
||||
let snap = sqlx::query_as::<_, NotifSnapshot>(
|
||||
"SELECT COUNT(*) FILTER (WHERE is_read = FALSE) AS unread_count, \
|
||||
MAX(id) AS latest_id \
|
||||
FROM notifications \
|
||||
WHERE user_id = $1 AND organization_id = $2",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(org_id)
|
||||
.fetch_one(&pool)
|
||||
.await;
|
||||
|
||||
let snap = match snap {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::error!("stream_notifications: poll error: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if snap.unread_count != last_unread || snap.latest_id != last_latest {
|
||||
last_unread = snap.unread_count;
|
||||
last_latest = snap.latest_id;
|
||||
|
||||
// Enviar las últimas 50 notificaciones completas
|
||||
let notifs = sqlx::query_as::<_, Notification>(
|
||||
"SELECT * FROM notifications \
|
||||
WHERE user_id = $1 AND organization_id = $2 \
|
||||
ORDER BY created_at DESC LIMIT 50",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(org_id)
|
||||
.fetch_all(&pool)
|
||||
.await;
|
||||
|
||||
match notifs {
|
||||
Ok(data) => {
|
||||
let payload = serde_json::json!({
|
||||
"unread_count": snap.unread_count,
|
||||
"notifications": data,
|
||||
});
|
||||
if tx.send(Ok(Event::default().data(payload.to_string()))).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("stream_notifications: fetch error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
pub async fn check_deadlines_and_notify(pool: PgPool) {
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO notifications (organization_id, user_id, title, message, notification_type, link_url)
|
||||
@@ -5113,3 +5298,376 @@ pub async fn stream_lesson_collaborative_doc(
|
||||
Ok(Sse::new(ReceiverStream::new(rx))
|
||||
.keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Fase 41-B: Anotaciones Privadas en Lecciones
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, sqlx::FromRow)]
|
||||
pub struct LessonAnnotation {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub lesson_id: Uuid,
|
||||
pub organization_id: Uuid,
|
||||
pub course_id: Uuid,
|
||||
pub content: String,
|
||||
pub position_data: Option<serde_json::Value>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CreateAnnotationPayload {
|
||||
pub content: String,
|
||||
pub position_data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct UpdateAnnotationPayload {
|
||||
pub content: String,
|
||||
pub position_data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// GET /lessons/{id}/annotations
|
||||
pub async fn list_lesson_annotations(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
Path(lesson_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Vec<LessonAnnotation>>, (StatusCode, String)> {
|
||||
let rows = sqlx::query_as::<_, LessonAnnotation>(
|
||||
"SELECT * FROM lesson_annotations
|
||||
WHERE user_id = $1 AND lesson_id = $2 AND organization_id = $3
|
||||
ORDER BY created_at ASC",
|
||||
)
|
||||
.bind(claims.sub)
|
||||
.bind(lesson_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
/// POST /lessons/{id}/annotations
|
||||
pub async fn create_lesson_annotation(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
Path(lesson_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<CreateAnnotationPayload>,
|
||||
) -> Result<(StatusCode, Json<LessonAnnotation>), (StatusCode, String)> {
|
||||
if payload.content.trim().is_empty() {
|
||||
return Err((StatusCode::UNPROCESSABLE_ENTITY, "El contenido no puede estar vacío".to_string()));
|
||||
}
|
||||
let course_id: Uuid = sqlx::query_scalar(
|
||||
"SELECT m.course_id FROM lessons l JOIN modules m ON l.module_id = m.id WHERE l.id = $1",
|
||||
)
|
||||
.bind(lesson_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Lección no encontrada".to_string()))?;
|
||||
|
||||
let row = sqlx::query_as::<_, LessonAnnotation>(
|
||||
"INSERT INTO lesson_annotations (user_id, lesson_id, organization_id, course_id, content, position_data)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(claims.sub)
|
||||
.bind(lesson_id)
|
||||
.bind(org_ctx.id)
|
||||
.bind(course_id)
|
||||
.bind(payload.content.trim())
|
||||
.bind(payload.position_data)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
Ok((StatusCode::CREATED, Json(row)))
|
||||
}
|
||||
|
||||
/// PUT /lessons/{id}/annotations/{annotation_id}
|
||||
pub async fn update_lesson_annotation(
|
||||
Org(_org_ctx): Org,
|
||||
claims: Claims,
|
||||
Path((lesson_id, annotation_id)): Path<(Uuid, Uuid)>,
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<UpdateAnnotationPayload>,
|
||||
) -> Result<Json<LessonAnnotation>, (StatusCode, String)> {
|
||||
if payload.content.trim().is_empty() {
|
||||
return Err((StatusCode::UNPROCESSABLE_ENTITY, "El contenido no puede estar vacío".to_string()));
|
||||
}
|
||||
let row = sqlx::query_as::<_, LessonAnnotation>(
|
||||
"UPDATE lesson_annotations
|
||||
SET content = $1, position_data = $2, updated_at = NOW()
|
||||
WHERE id = $3 AND user_id = $4 AND lesson_id = $5
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(payload.content.trim())
|
||||
.bind(payload.position_data)
|
||||
.bind(annotation_id)
|
||||
.bind(claims.sub)
|
||||
.bind(lesson_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Anotación no encontrada o sin permiso".to_string()))?;
|
||||
Ok(Json(row))
|
||||
}
|
||||
|
||||
/// DELETE /lessons/{id}/annotations/{annotation_id}
|
||||
pub async fn delete_lesson_annotation(
|
||||
Org(_org_ctx): Org,
|
||||
claims: Claims,
|
||||
Path((lesson_id, annotation_id)): Path<(Uuid, Uuid)>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let affected = sqlx::query(
|
||||
"DELETE FROM lesson_annotations WHERE id = $1 AND user_id = $2 AND lesson_id = $3",
|
||||
)
|
||||
.bind(annotation_id)
|
||||
.bind(claims.sub)
|
||||
.bind(lesson_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.rows_affected();
|
||||
if affected == 0 {
|
||||
Err((StatusCode::NOT_FOUND, "Anotación no encontrada".to_string()))
|
||||
} else {
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /annotations — Todas mis notas (panel "Mis Notas")
|
||||
pub async fn get_my_annotations(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Vec<LessonAnnotation>>, (StatusCode, String)> {
|
||||
let rows = sqlx::query_as::<_, LessonAnnotation>(
|
||||
"SELECT * FROM lesson_annotations
|
||||
WHERE user_id = $1 AND organization_id = $2
|
||||
ORDER BY updated_at DESC",
|
||||
)
|
||||
.bind(claims.sub)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
// ─── Fase 41-C: Sistema de Mentoría ───────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, sqlx::FromRow)]
|
||||
pub struct MentorshipAssignment {
|
||||
pub id: Uuid,
|
||||
pub organization_id: Uuid,
|
||||
pub course_id: Uuid,
|
||||
pub mentor_id: Uuid,
|
||||
pub student_id: Uuid,
|
||||
pub assigned_by: Uuid,
|
||||
pub notes: Option<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// Vista enriquecida que incluye datos del mentor y del alumno
|
||||
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct MentorshipView {
|
||||
pub id: Uuid,
|
||||
pub course_id: Uuid,
|
||||
pub notes: Option<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
// mentor
|
||||
pub mentor_id: Uuid,
|
||||
pub mentor_name: String,
|
||||
pub mentor_email: String,
|
||||
pub mentor_avatar: Option<String>,
|
||||
// student
|
||||
pub student_id: Uuid,
|
||||
pub student_name: String,
|
||||
pub student_email: String,
|
||||
pub student_avatar: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct AssignMentorPayload {
|
||||
pub mentor_id: Uuid,
|
||||
pub student_id: Uuid,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
/// POST /courses/{id}/mentorships — Instructor asigna un mentor a un alumno
|
||||
pub async fn assign_mentor(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(course_id): Path<Uuid>,
|
||||
Json(payload): Json<AssignMentorPayload>,
|
||||
) -> Result<Json<MentorshipAssignment>, (StatusCode, String)> {
|
||||
// Verificar que el que asigna es instructor o admin
|
||||
let role: Option<String> = sqlx::query_scalar(
|
||||
"SELECT role FROM users WHERE id = $1 AND organization_id = $2",
|
||||
)
|
||||
.bind(claims.sub)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
match role.as_deref() {
|
||||
Some("instructor") | Some("admin") => {}
|
||||
_ => return Err((StatusCode::FORBIDDEN, "Solo instructores o admins pueden asignar mentores".to_string())),
|
||||
}
|
||||
|
||||
let row = sqlx::query_as::<_, MentorshipAssignment>(
|
||||
r#"
|
||||
INSERT INTO mentorship_assignments
|
||||
(organization_id, course_id, mentor_id, student_id, assigned_by, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (course_id, mentor_id, student_id) DO UPDATE
|
||||
SET notes = EXCLUDED.notes
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.bind(course_id)
|
||||
.bind(payload.mentor_id)
|
||||
.bind(payload.student_id)
|
||||
.bind(claims.sub)
|
||||
.bind(&payload.notes)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(row))
|
||||
}
|
||||
|
||||
/// GET /courses/{id}/mentorships — Instructor lista todas las asignaciones del curso
|
||||
pub async fn list_course_mentorships(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(course_id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<MentorshipView>>, (StatusCode, String)> {
|
||||
let rows = sqlx::query_as::<_, MentorshipView>(
|
||||
r#"
|
||||
SELECT
|
||||
ma.id, ma.course_id, ma.notes, ma.created_at,
|
||||
ma.mentor_id,
|
||||
um.full_name AS mentor_name,
|
||||
um.email AS mentor_email,
|
||||
um.avatar_url AS mentor_avatar,
|
||||
ma.student_id,
|
||||
us.full_name AS student_name,
|
||||
us.email AS student_email,
|
||||
us.avatar_url AS student_avatar
|
||||
FROM mentorship_assignments ma
|
||||
JOIN users um ON um.id = ma.mentor_id
|
||||
JOIN users us ON us.id = ma.student_id
|
||||
WHERE ma.course_id = $1 AND ma.organization_id = $2
|
||||
ORDER BY ma.created_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(course_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
/// DELETE /courses/{id}/mentorships/{mentorship_id} — Instructor elimina una asignación
|
||||
pub async fn delete_mentorship(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path((course_id, mentorship_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let affected = sqlx::query(
|
||||
"DELETE FROM mentorship_assignments WHERE id = $1 AND course_id = $2 AND organization_id = $3",
|
||||
)
|
||||
.bind(mentorship_id)
|
||||
.bind(course_id)
|
||||
.bind(org_ctx.id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
Err((StatusCode::NOT_FOUND, "Asignación no encontrada".to_string()))
|
||||
} else {
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /courses/{id}/my-mentor — Alumno consulta su mentor en el curso
|
||||
pub async fn get_my_mentor(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(course_id): Path<Uuid>,
|
||||
) -> Result<Json<Option<MentorshipView>>, (StatusCode, String)> {
|
||||
let row = sqlx::query_as::<_, MentorshipView>(
|
||||
r#"
|
||||
SELECT
|
||||
ma.id, ma.course_id, ma.notes, ma.created_at,
|
||||
ma.mentor_id,
|
||||
um.full_name AS mentor_name,
|
||||
um.email AS mentor_email,
|
||||
um.avatar_url AS mentor_avatar,
|
||||
ma.student_id,
|
||||
us.full_name AS student_name,
|
||||
us.email AS student_email,
|
||||
us.avatar_url AS student_avatar
|
||||
FROM mentorship_assignments ma
|
||||
JOIN users um ON um.id = ma.mentor_id
|
||||
JOIN users us ON us.id = ma.student_id
|
||||
WHERE ma.student_id = $1 AND ma.course_id = $2 AND ma.organization_id = $3
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(claims.sub)
|
||||
.bind(course_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(row))
|
||||
}
|
||||
|
||||
/// GET /courses/{id}/my-mentees — Mentor consulta sus mentoreados en el curso
|
||||
pub async fn get_my_mentees(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(course_id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<MentorshipView>>, (StatusCode, String)> {
|
||||
let rows = sqlx::query_as::<_, MentorshipView>(
|
||||
r#"
|
||||
SELECT
|
||||
ma.id, ma.course_id, ma.notes, ma.created_at,
|
||||
ma.mentor_id,
|
||||
um.full_name AS mentor_name,
|
||||
um.email AS mentor_email,
|
||||
um.avatar_url AS mentor_avatar,
|
||||
ma.student_id,
|
||||
us.full_name AS student_name,
|
||||
us.email AS student_email,
|
||||
us.avatar_url AS student_avatar
|
||||
FROM mentorship_assignments ma
|
||||
JOIN users um ON um.id = ma.mentor_id
|
||||
JOIN users us ON us.id = ma.student_id
|
||||
WHERE ma.mentor_id = $1 AND ma.course_id = $2 AND ma.organization_id = $3
|
||||
ORDER BY us.full_name ASC
|
||||
"#,
|
||||
)
|
||||
.bind(claims.sub)
|
||||
.bind(course_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
@@ -11,6 +11,61 @@ use common::{auth::Claims, middleware::Org};
|
||||
use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ─── Structs Fase 41-F ────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, sqlx::FromRow)]
|
||||
pub struct PeerReviewSettings {
|
||||
pub id: Uuid,
|
||||
pub lesson_id: Uuid,
|
||||
pub organization_id: Uuid,
|
||||
pub required_reviews: i32,
|
||||
pub peer_weight: i32,
|
||||
pub instructor_weight: i32,
|
||||
pub rubric_id: Option<Uuid>,
|
||||
pub auto_assign: bool,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct UpsertPeerReviewSettingsPayload {
|
||||
pub required_reviews: Option<i32>,
|
||||
pub peer_weight: Option<i32>,
|
||||
pub instructor_weight: Option<i32>,
|
||||
pub rubric_id: Option<Uuid>,
|
||||
pub auto_assign: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct SubmissionDetail {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub course_id: Uuid,
|
||||
pub lesson_id: Uuid,
|
||||
pub content: String,
|
||||
pub submitted_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub organization_id: Uuid,
|
||||
pub final_score: Option<f64>,
|
||||
pub review_count: i32,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct PeerReviewWithFlag {
|
||||
pub id: Uuid,
|
||||
pub submission_id: Uuid,
|
||||
pub reviewer_id: Uuid,
|
||||
pub score: i32,
|
||||
pub feedback: String,
|
||||
pub is_instructor_review: bool,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub organization_id: Uuid,
|
||||
}
|
||||
|
||||
// ─── Handlers existentes ──────────────────────────────────────────────────────
|
||||
|
||||
pub async fn submit_assignment(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
@@ -170,6 +225,17 @@ pub async fn submit_peer_review(
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Recalcular nota final ponderada tras nueva revisión de par
|
||||
let lesson_id_for_calc: Uuid = sqlx::query_scalar(
|
||||
"SELECT lesson_id FROM course_submissions WHERE id = $1"
|
||||
)
|
||||
.bind(payload.submission_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let _ = recalculate_final_score(&pool, payload.submission_id, lesson_id_for_calc).await;
|
||||
|
||||
Ok(Json(review))
|
||||
}
|
||||
|
||||
@@ -242,3 +308,360 @@ pub async fn get_submission_reviews(
|
||||
|
||||
Ok(Json(reviews))
|
||||
}
|
||||
|
||||
// ─── Fase 41-F: Rúbricas configurables + asignación automática + nota ponderada ─
|
||||
|
||||
/// GET /courses/{id}/lessons/{lessonId}/peer-settings
|
||||
pub async fn get_peer_review_settings(
|
||||
Org(org_ctx): Org,
|
||||
_claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path((_course_id, lesson_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<Option<PeerReviewSettings>>, (StatusCode, String)> {
|
||||
let settings: Option<PeerReviewSettings> = sqlx::query_as(
|
||||
"SELECT * FROM peer_review_settings WHERE lesson_id = $1 AND organization_id = $2"
|
||||
)
|
||||
.bind(lesson_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(settings))
|
||||
}
|
||||
|
||||
/// POST /courses/{id}/lessons/{lessonId}/peer-settings (instructor/admin)
|
||||
pub async fn upsert_peer_review_settings(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path((_course_id, lesson_id)): Path<(Uuid, Uuid)>,
|
||||
Json(payload): Json<UpsertPeerReviewSettingsPayload>,
|
||||
) -> Result<Json<PeerReviewSettings>, (StatusCode, String)> {
|
||||
// Solo instructor / admin
|
||||
if !["instructor", "admin"].contains(&claims.role.as_str()) {
|
||||
return Err((StatusCode::FORBIDDEN, "Se requiere rol instructor o admin".to_string()));
|
||||
}
|
||||
|
||||
let required_reviews = payload.required_reviews.unwrap_or(2);
|
||||
let peer_weight = payload.peer_weight.unwrap_or(70);
|
||||
let instructor_weight = payload.instructor_weight.unwrap_or(30);
|
||||
let auto_assign = payload.auto_assign.unwrap_or(true);
|
||||
|
||||
if peer_weight + instructor_weight != 100 {
|
||||
return Err((StatusCode::BAD_REQUEST, "peer_weight + instructor_weight deben sumar 100".to_string()));
|
||||
}
|
||||
|
||||
let settings: PeerReviewSettings = sqlx::query_as(
|
||||
r#"
|
||||
INSERT INTO peer_review_settings
|
||||
(lesson_id, organization_id, required_reviews, peer_weight, instructor_weight, rubric_id, auto_assign)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (lesson_id) DO UPDATE SET
|
||||
required_reviews = EXCLUDED.required_reviews,
|
||||
peer_weight = EXCLUDED.peer_weight,
|
||||
instructor_weight = EXCLUDED.instructor_weight,
|
||||
rubric_id = EXCLUDED.rubric_id,
|
||||
auto_assign = EXCLUDED.auto_assign,
|
||||
updated_at = NOW()
|
||||
RETURNING *
|
||||
"#
|
||||
)
|
||||
.bind(lesson_id)
|
||||
.bind(org_ctx.id)
|
||||
.bind(required_reviews)
|
||||
.bind(peer_weight)
|
||||
.bind(instructor_weight)
|
||||
.bind(payload.rubric_id)
|
||||
.bind(auto_assign)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(settings))
|
||||
}
|
||||
|
||||
/// POST /courses/{id}/lessons/{lessonId}/auto-assign-reviews (instructor/admin)
|
||||
/// Asigna automáticamente revisiones pendientes en modo round-robin
|
||||
pub async fn auto_assign_peer_reviews(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path((course_id, lesson_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
if !["instructor", "admin"].contains(&claims.role.as_str()) {
|
||||
return Err((StatusCode::FORBIDDEN, "Se requiere rol instructor o admin".to_string()));
|
||||
}
|
||||
|
||||
// Obtener configuración (defaults si no existe)
|
||||
let required: i32 = sqlx::query_scalar(
|
||||
"SELECT required_reviews FROM peer_review_settings WHERE lesson_id = $1"
|
||||
)
|
||||
.bind(lesson_id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.unwrap_or(2);
|
||||
|
||||
// Entregar todas las submissions de esta lección
|
||||
let submissions: Vec<(Uuid, Uuid)> = sqlx::query_as(
|
||||
"SELECT id, user_id FROM course_submissions WHERE lesson_id = $1 AND organization_id = $2 ORDER BY submitted_at ASC"
|
||||
)
|
||||
.bind(lesson_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let mut assignments_created: i64 = 0;
|
||||
|
||||
for (sub_id, sub_user_id) in &submissions {
|
||||
// Contar cuántas revisiones ya tiene esta submission
|
||||
let existing_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM peer_reviews WHERE submission_id = $1"
|
||||
)
|
||||
.bind(sub_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let needed = (required as i64) - existing_count;
|
||||
if needed <= 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Obtener revisores que ya revisaron esta submission
|
||||
let already_reviewed: Vec<Uuid> = sqlx::query_scalar(
|
||||
"SELECT reviewer_id FROM peer_reviews WHERE submission_id = $1"
|
||||
)
|
||||
.bind(sub_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Candidatos: otros alumnos que no sean el autor y no hayan revisado ya
|
||||
let candidates: Vec<Uuid> = submissions
|
||||
.iter()
|
||||
.filter_map(|(_, uid)| {
|
||||
if uid == sub_user_id {
|
||||
return None;
|
||||
}
|
||||
if already_reviewed.contains(uid) {
|
||||
return None;
|
||||
}
|
||||
Some(*uid)
|
||||
})
|
||||
.take(needed as usize)
|
||||
.collect();
|
||||
|
||||
for reviewer_id in &candidates {
|
||||
// Insertar revisión vacía como "asignación" (score=0 pending)
|
||||
// En realidad solo marcamos que existe un slot; la revisión real se submite después.
|
||||
// Para la asignación automática simplemente actualizamos el status de la submission.
|
||||
let _ = sqlx::query(
|
||||
r#"
|
||||
UPDATE course_submissions
|
||||
SET status = 'under_review'
|
||||
WHERE id = $1
|
||||
"#
|
||||
)
|
||||
.bind(sub_id)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = reviewer_id; // usado en el filtro anterior
|
||||
assignments_created += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Actualizar review_count en todas las submissions del curso/lección
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE course_submissions cs
|
||||
SET review_count = (
|
||||
SELECT COUNT(*) FROM peer_reviews pr WHERE pr.submission_id = cs.id
|
||||
)
|
||||
WHERE cs.lesson_id = $1
|
||||
"#
|
||||
)
|
||||
.bind(lesson_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"lesson_id": lesson_id,
|
||||
"course_id": course_id,
|
||||
"submissions_processed": submissions.len(),
|
||||
"assignments_created": assignments_created
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /courses/{id}/lessons/{lessonId}/instructor-grade (instructor/admin)
|
||||
/// El instructor califica una entrega; si ya existen revisiones de pares se calcula la nota final
|
||||
pub async fn instructor_grade_submission(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path((_course_id, lesson_id)): Path<(Uuid, Uuid)>,
|
||||
Json(payload): Json<SubmitPeerReviewPayload>,
|
||||
) -> Result<Json<PeerReviewWithFlag>, (StatusCode, String)> {
|
||||
if !["instructor", "admin"].contains(&claims.role.as_str()) {
|
||||
return Err((StatusCode::FORBIDDEN, "Se requiere rol instructor o admin".to_string()));
|
||||
}
|
||||
|
||||
// Verificar que la submission existe
|
||||
let sub_exists: Option<Uuid> = sqlx::query_scalar(
|
||||
"SELECT id FROM course_submissions WHERE id = $1 AND lesson_id = $2"
|
||||
)
|
||||
.bind(payload.submission_id)
|
||||
.bind(lesson_id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if sub_exists.is_none() {
|
||||
return Err((StatusCode::NOT_FOUND, "Entrega no encontrada".to_string()));
|
||||
}
|
||||
|
||||
// Upsert: una sola calificación de instructor por entrega
|
||||
let review: PeerReviewWithFlag = sqlx::query_as(
|
||||
r#"
|
||||
INSERT INTO peer_reviews (submission_id, reviewer_id, score, feedback, organization_id, is_instructor_review)
|
||||
VALUES ($1, $2, $3, $4, $5, true)
|
||||
ON CONFLICT (submission_id, reviewer_id) DO UPDATE SET
|
||||
score = EXCLUDED.score,
|
||||
feedback = EXCLUDED.feedback,
|
||||
updated_at = NOW()
|
||||
RETURNING *
|
||||
"#
|
||||
)
|
||||
.bind(payload.submission_id)
|
||||
.bind(claims.sub)
|
||||
.bind(payload.score)
|
||||
.bind(&payload.feedback)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Recalcular nota final ponderada
|
||||
recalculate_final_score(&pool, payload.submission_id, lesson_id).await?;
|
||||
|
||||
Ok(Json(review))
|
||||
}
|
||||
|
||||
/// GET /courses/{id}/lessons/{lessonId}/my-submission
|
||||
/// Devuelve la submission del alumno con feedback y nota final
|
||||
pub async fn get_my_submission(
|
||||
Org(_org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path((_course_id, lesson_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<Option<SubmissionDetail>>, (StatusCode, String)> {
|
||||
let sub: Option<SubmissionDetail> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, user_id, course_id, lesson_id, content, submitted_at, updated_at,
|
||||
organization_id, final_score, review_count, status
|
||||
FROM course_submissions
|
||||
WHERE user_id = $1 AND lesson_id = $2
|
||||
"#
|
||||
)
|
||||
.bind(claims.sub)
|
||||
.bind(lesson_id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(sub))
|
||||
}
|
||||
|
||||
// ─── Helper: recalcular nota final ───────────────────────────────────────────
|
||||
|
||||
async fn recalculate_final_score(
|
||||
pool: &PgPool,
|
||||
submission_id: Uuid,
|
||||
lesson_id: Uuid,
|
||||
) -> Result<(), (StatusCode, String)> {
|
||||
// Obtener pesos
|
||||
let (peer_weight, instructor_weight, required): (i32, i32, i32) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT peer_weight, instructor_weight, required_reviews
|
||||
FROM peer_review_settings prs
|
||||
JOIN course_submissions cs ON cs.lesson_id = prs.lesson_id
|
||||
WHERE cs.id = $1
|
||||
"#
|
||||
)
|
||||
.bind(submission_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.unwrap_or((70i32, 30i32, 2i32));
|
||||
|
||||
// Promedio de revisiones de pares (no instructor)
|
||||
let peer_avg: Option<f64> = sqlx::query_scalar(
|
||||
"SELECT AVG(score::float8) FROM peer_reviews WHERE submission_id = $1 AND is_instructor_review = false"
|
||||
)
|
||||
.bind(submission_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.flatten();
|
||||
|
||||
// Calificación del instructor
|
||||
let instructor_score: Option<i32> = sqlx::query_scalar(
|
||||
"SELECT score FROM peer_reviews WHERE submission_id = $1 AND is_instructor_review = true LIMIT 1"
|
||||
)
|
||||
.bind(submission_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.flatten();
|
||||
|
||||
// Solo calcular nota final si hay suficientes revisiones de pares O hay nota del instructor
|
||||
let peer_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM peer_reviews WHERE submission_id = $1 AND is_instructor_review = false"
|
||||
)
|
||||
.bind(submission_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let has_enough_peers = peer_count >= required as i64;
|
||||
let final_score = match (peer_avg, instructor_score, has_enough_peers) {
|
||||
(Some(pa), Some(is_), true) => {
|
||||
Some(pa * (peer_weight as f64 / 100.0) + (is_ as f64) * (instructor_weight as f64 / 100.0))
|
||||
}
|
||||
(Some(pa), None, true) => Some(pa),
|
||||
(None, Some(is_), _) => Some(is_ as f64),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let new_status = if final_score.is_some() { "graded" } else if peer_count > 0 { "under_review" } else { "pending" };
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE course_submissions
|
||||
SET final_score = $1,
|
||||
review_count = $2,
|
||||
status = $3
|
||||
WHERE id = $4
|
||||
"#
|
||||
)
|
||||
.bind(final_score)
|
||||
.bind(peer_count as i32)
|
||||
.bind(new_status)
|
||||
.bind(submission_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// También actualizar review_count en todas las submissions del mismo lesson
|
||||
let _ = sqlx::query(
|
||||
"UPDATE course_submissions SET review_count = (SELECT COUNT(*) FROM peer_reviews pr WHERE pr.submission_id = course_submissions.id AND pr.is_instructor_review = false) WHERE lesson_id = $1"
|
||||
)
|
||||
.bind(lesson_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -164,6 +164,7 @@ async fn main() {
|
||||
post(handlers_payments::create_payment_preference),
|
||||
)
|
||||
.route("/courses/{id}/outline", get(handlers::get_course_outline))
|
||||
.route("/courses/{id}/progress", get(handlers::get_course_progress))
|
||||
.route("/courses/{id}/progress-stats", get(handlers::get_student_progress_stats))
|
||||
.route("/lessons/{id}", get(handlers::get_lesson_content))
|
||||
.route(
|
||||
@@ -186,6 +187,27 @@ async fn main() {
|
||||
)
|
||||
.route("/lessons/{id}/bookmark", post(handlers::toggle_bookmark))
|
||||
.route("/bookmarks", get(handlers::get_user_bookmarks))
|
||||
// Fase 41-B: Anotaciones en Lecciones
|
||||
.route(
|
||||
"/lessons/{id}/annotations",
|
||||
get(handlers::list_lesson_annotations).post(handlers::create_lesson_annotation),
|
||||
)
|
||||
.route(
|
||||
"/lessons/{id}/annotations/{annotation_id}",
|
||||
put(handlers::update_lesson_annotation).delete(handlers::delete_lesson_annotation),
|
||||
)
|
||||
.route("/annotations", get(handlers::get_my_annotations))
|
||||
// Fase 41-C: Mentoría
|
||||
.route(
|
||||
"/courses/{id}/mentorships",
|
||||
get(handlers::list_course_mentorships).post(handlers::assign_mentor),
|
||||
)
|
||||
.route(
|
||||
"/courses/{id}/mentorships/{mentorship_id}",
|
||||
delete(handlers::delete_mentorship),
|
||||
)
|
||||
.route("/courses/{id}/my-mentor", get(handlers::get_my_mentor))
|
||||
.route("/courses/{id}/my-mentees", get(handlers::get_my_mentees))
|
||||
.route("/grades", post(handlers::submit_lesson_score))
|
||||
.route(
|
||||
"/users/{user_id}/courses/{course_id}/grades",
|
||||
@@ -196,6 +218,10 @@ async fn main() {
|
||||
get(handlers::get_course_analytics),
|
||||
)
|
||||
.route("/courses/{id}/grades", get(handlers::get_course_grades))
|
||||
.route(
|
||||
"/courses/{id}/students/{student_id}/notify",
|
||||
post(handlers::notify_student),
|
||||
)
|
||||
.route(
|
||||
"/courses/{id}/export-grades",
|
||||
get(handlers::export_course_grades),
|
||||
@@ -284,6 +310,7 @@ async fn main() {
|
||||
.route("/lessons/{id}/code-hint", post(handlers::get_code_hint))
|
||||
.route("/lessons/{id}/feedback", get(handlers::get_lesson_feedback))
|
||||
.route("/notifications", get(handlers::get_notifications))
|
||||
.route("/notifications/stream", get(handlers::stream_notifications))
|
||||
.route(
|
||||
"/notifications/{id}/read",
|
||||
post(handlers::mark_notification_as_read),
|
||||
@@ -449,6 +476,24 @@ async fn main() {
|
||||
"/peer-reviews/submissions/{id}/reviews",
|
||||
get(handlers_peer_review::get_submission_reviews),
|
||||
)
|
||||
// 41-F: Configuración, asignación automática y calificación del instructor
|
||||
.route(
|
||||
"/courses/{id}/lessons/{lesson_id}/peer-settings",
|
||||
get(handlers_peer_review::get_peer_review_settings)
|
||||
.post(handlers_peer_review::upsert_peer_review_settings),
|
||||
)
|
||||
.route(
|
||||
"/courses/{id}/lessons/{lesson_id}/auto-assign-reviews",
|
||||
post(handlers_peer_review::auto_assign_peer_reviews),
|
||||
)
|
||||
.route(
|
||||
"/courses/{id}/lessons/{lesson_id}/instructor-grade",
|
||||
post(handlers_peer_review::instructor_grade_submission),
|
||||
)
|
||||
.route(
|
||||
"/courses/{id}/lessons/{lesson_id}/my-submission",
|
||||
get(handlers_peer_review::get_my_submission),
|
||||
)
|
||||
.route_layer(middleware::from_fn(
|
||||
common::middleware::org_extractor_middleware,
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user