Refactor code structure for improved readability and maintainability
This commit is contained in:
Generated
+768
-413
File diff suppressed because it is too large
Load Diff
+17
-14
@@ -1,35 +1,40 @@
|
||||
# Security Triage
|
||||
|
||||
Fecha de última actualización: 2026-04-28
|
||||
Fecha de última actualización: 2026-04-28 (P2+P3 completados)
|
||||
|
||||
---
|
||||
|
||||
## Estado actual (post-remediación)
|
||||
|
||||
### Rust (cargo audit)
|
||||
- Vulnerabilidades: 4 (todas en dependencias transitivas de terceros sin fix directo)
|
||||
- Warnings: 4
|
||||
- Vulnerabilidades: 4 (todas en dependencias transitivas via AWS SDK, sin fix directo)
|
||||
- Warnings: 1
|
||||
|
||||
#### Vulnerabilidades activas
|
||||
|
||||
1. RUSTSEC-2023-0071 (rsa 0.9.9)
|
||||
|
||||
#### Nota sobre lru (nuevo)
|
||||
- RUSTSEC-2026-0104 (lru 0.12.5): IterMut viola Stacked Borrows
|
||||
- Cadena: aws-sdk-s3 → lru
|
||||
- Bloqueado por AWS SDK. Monitorear con `cargo update && cargo audit`.
|
||||
- Severidad: media (Marvin Attack)
|
||||
- Cadena: sqlx-mysql → rsa
|
||||
- Fix upstream: no disponible
|
||||
- Estado: aceptación de riesgo temporal
|
||||
|
||||
2. RUSTSEC-2026-0098 (rustls-webpki 0.101.7)
|
||||
- Nombre: URI name constraints incorrectly accepted
|
||||
- Nombre: RUSTSEC-2026-0098 URI name constraints incorrectly accepted
|
||||
- Cadena: aws-sdk-s3/aws-config → aws-smithy-http-client → rustls 0.21 → rustls-webpki 0.101.7
|
||||
- Fix: requiere AWS SDK 1.x actualice su stack TLS a rustls >=0.22
|
||||
- Estado: bloqueado por tercero (AWS SDK)
|
||||
|
||||
3. RUSTSEC-2026-0099 (rustls-webpki 0.101.7)
|
||||
- Nombre: wildcard name constraints incorrectly accepted
|
||||
- Nombre: RUSTSEC-2026-0099 wildcard name constraints incorrectly accepted
|
||||
- Misma cadena que 0098
|
||||
|
||||
4. RUSTSEC-2026-0104 (rustls-webpki 0.101.7)
|
||||
- Nombre: panic alcanzable en CRL parsing
|
||||
- Nombre: RUSTSEC-2026-0104 panic alcanzable en CRL parsing
|
||||
- Misma cadena que 0098
|
||||
|
||||
#### Nota: openidconnect ya NO es un vector
|
||||
@@ -43,17 +48,15 @@ Fecha de última actualización: 2026-04-28
|
||||
|
||||
#### Studio
|
||||
- Pre-remediación: 1 critical, 12 high, 1 moderate (total 14)
|
||||
- Post-remediación: 0 critical, 2 high, 3 moderate (total 5)
|
||||
- Resuelto: dompurify critical + toda la cadena d3/mermaid/dagre-d3 (via upgrade mermaid 9 → 11.14.0)
|
||||
- Restante high: xlsx (2 advisories, sin fix disponible)
|
||||
- Restante moderate: next + postcss (requieren Next major upgrade)
|
||||
- Post-remediación: **0 high**, 4 moderate (total 4) ✅
|
||||
- Resuelto: mermaid upgrade (9→11.14.0), xlsx eliminado (parseo movido a backend Rust con calamine), Next.js 14→15.5.15
|
||||
- Restante moderate: postcss bundled en Next (sin fix sin downgrade), uuid via mermaid (sin fix sin downgrade mermaid)
|
||||
|
||||
#### Experience
|
||||
- Pre-remediación: 1 critical, 11 high, 1 moderate (total 13)
|
||||
- Post-remediación: 0 critical, 1 high, 3 moderate (total 4)
|
||||
- Resuelto: dompurify critical + toda la cadena d3/mermaid/dagre-d3 ✓
|
||||
- Restante high: next (requiere Next major upgrade)
|
||||
- Restante moderate: next postcss + uuid (moderate, via mermaid 11; revertir a mermaid 9 empeoraría)
|
||||
- Post-remediación: **0 high**, 4 moderate (total 4) ✅
|
||||
- Resuelto: mermaid upgrade, Next.js 14→15.5.15
|
||||
- Restante moderate: postcss bundled en Next, uuid via mermaid (mismas restricciones que studio)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,3 +36,4 @@ regex = "1.11"
|
||||
rand = "0.8"
|
||||
aws-config = "1"
|
||||
aws-sdk-s3 = "1"
|
||||
calamine = { version = "0.26", features = ["dates"] }
|
||||
|
||||
@@ -1618,6 +1618,181 @@ pub struct ImportResult {
|
||||
// unimplemented!()
|
||||
// }
|
||||
|
||||
/// POST /question-bank/import-excel - Importar preguntas desde archivo Excel (.xlsx/.xls)
|
||||
pub async fn import_from_excel(
|
||||
Org(org_ctx): Org,
|
||||
_claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
mut multipart: axum::extract::Multipart,
|
||||
) -> Result<Json<ImportResult>, (StatusCode, String)> {
|
||||
use calamine::{open_workbook_auto_from_rs, Reader};
|
||||
use std::io::Cursor;
|
||||
|
||||
const MAX_FILE_SIZE: usize = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
// Extraer el campo "file" del multipart
|
||||
let mut file_bytes: Option<Vec<u8>> = None;
|
||||
while let Some(field) = multipart.next_field().await.map_err(|_| {
|
||||
(StatusCode::BAD_REQUEST, "Error leyendo multipart".to_string())
|
||||
})? {
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
if name == "file" {
|
||||
let bytes = field.bytes().await.map_err(|_| {
|
||||
(StatusCode::BAD_REQUEST, "Error leyendo bytes del archivo".to_string())
|
||||
})?;
|
||||
if bytes.len() > MAX_FILE_SIZE {
|
||||
return Err((StatusCode::PAYLOAD_TOO_LARGE, "El archivo supera el límite de 10MB".to_string()));
|
||||
}
|
||||
file_bytes = Some(bytes.to_vec());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = file_bytes.ok_or((StatusCode::BAD_REQUEST, "No se recibió ningún archivo".to_string()))?;
|
||||
|
||||
// Parsear el workbook
|
||||
let cursor = Cursor::new(bytes);
|
||||
let mut workbook = open_workbook_auto_from_rs(cursor).map_err(|_| {
|
||||
(StatusCode::BAD_REQUEST, "Archivo Excel inválido o no soportado".to_string())
|
||||
})?;
|
||||
|
||||
let sheet_names = workbook.sheet_names().to_vec();
|
||||
let first_name = sheet_names.first().ok_or((StatusCode::BAD_REQUEST, "El archivo no contiene hojas".to_string()))?;
|
||||
let range = workbook.worksheet_range(first_name).map_err(|_| {
|
||||
(StatusCode::BAD_REQUEST, "No se pudo leer la hoja de cálculo".to_string())
|
||||
})?;
|
||||
|
||||
let mut rows = range.rows();
|
||||
let header_row = rows.next().ok_or((StatusCode::BAD_REQUEST, "El archivo está vacío".to_string()))?;
|
||||
|
||||
// Mapear índices de columnas por nombre (case-insensitive)
|
||||
let headers: Vec<String> = header_row.iter()
|
||||
.map(|c| c.to_string().trim().to_lowercase())
|
||||
.collect();
|
||||
|
||||
let col = |name: &str| -> Option<usize> {
|
||||
headers.iter().position(|h| h == name)
|
||||
};
|
||||
|
||||
let idx_question_text = col("question_text");
|
||||
let idx_question_type = col("question_type");
|
||||
let idx_options = col("options");
|
||||
let idx_correct_answer = col("correct_answer");
|
||||
let idx_explanation = col("explanation");
|
||||
let idx_difficulty = col("difficulty");
|
||||
let idx_tags = col("tags");
|
||||
let idx_points = col("points");
|
||||
|
||||
let get_cell = |row: &[calamine::Data], idx: Option<usize>| -> String {
|
||||
idx.and_then(|i| row.get(i))
|
||||
.map(|c: &calamine::Data| c.to_string().trim().to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let to_question_type = |s: &str| -> Option<&'static str> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"multiple-choice" | "multiple choice" | "mcq" => Some("multiple-choice"),
|
||||
"true-false" | "true false" | "boolean" => Some("true-false"),
|
||||
"short-answer" | "short answer" => Some("short-answer"),
|
||||
"essay" => Some("essay"),
|
||||
"matching" => Some("matching"),
|
||||
"ordering" => Some("ordering"),
|
||||
"fill-in-the-blanks" | "fill in the blanks" => Some("fill-in-the-blanks"),
|
||||
"audio-response" | "audio response" => Some("audio-response"),
|
||||
"hotspot" => Some("hotspot"),
|
||||
"code-lab" | "code lab" => Some("code-lab"),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
let mut imported = 0i32;
|
||||
let mut skipped = 0i32;
|
||||
|
||||
for row in rows {
|
||||
let question_text = get_cell(row, idx_question_text);
|
||||
let question_type_raw = get_cell(row, idx_question_type);
|
||||
let Some(question_type) = to_question_type(&question_type_raw) else {
|
||||
skipped += 1;
|
||||
continue;
|
||||
};
|
||||
if question_text.is_empty() {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let options_raw = get_cell(row, idx_options);
|
||||
let correct_raw = get_cell(row, idx_correct_answer);
|
||||
let explanation_raw = get_cell(row, idx_explanation);
|
||||
let difficulty_raw = get_cell(row, idx_difficulty);
|
||||
let tags_raw = get_cell(row, idx_tags);
|
||||
let points_raw = get_cell(row, idx_points);
|
||||
|
||||
let difficulty = match difficulty_raw.to_lowercase().as_str() {
|
||||
"easy" | "hard" => difficulty_raw.to_lowercase(),
|
||||
_ => "medium".to_string(),
|
||||
};
|
||||
|
||||
let options: serde_json::Value = if question_type == "true-false" {
|
||||
serde_json::json!(["Verdadero", "Falso"])
|
||||
} else if options_raw.starts_with('[') {
|
||||
serde_json::from_str(&options_raw).unwrap_or(serde_json::Value::Null)
|
||||
} else if !options_raw.is_empty() {
|
||||
let parts: Vec<&str> = options_raw.split(',').map(str::trim).collect();
|
||||
serde_json::json!(parts)
|
||||
} else {
|
||||
serde_json::Value::Null
|
||||
};
|
||||
|
||||
let correct_answer: serde_json::Value = if question_type == "true-false" {
|
||||
let lower = correct_raw.to_lowercase();
|
||||
if lower == "verdadero" || lower == "true" {
|
||||
serde_json::json!(0)
|
||||
} else {
|
||||
serde_json::json!(1)
|
||||
}
|
||||
} else if correct_raw.starts_with('[') || correct_raw.starts_with('{') {
|
||||
serde_json::from_str(&correct_raw).unwrap_or(serde_json::Value::Null)
|
||||
} else if let Ok(n) = correct_raw.parse::<i64>() {
|
||||
serde_json::json!(n)
|
||||
} else {
|
||||
serde_json::json!(correct_raw)
|
||||
};
|
||||
|
||||
let tags: Option<Vec<String>> = if tags_raw.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(tags_raw.split(',').map(|t: &str| t.trim().to_string()).filter(|t: &String| !t.is_empty()).collect())
|
||||
};
|
||||
|
||||
let points: i32 = points_raw.parse::<i32>().unwrap_or(1).max(1);
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"INSERT INTO question_bank
|
||||
(organization_id, question_text, question_type, options, correct_answer,
|
||||
explanation, difficulty, tags, points, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())"#
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.bind(&question_text)
|
||||
.bind(question_type)
|
||||
.bind(&options)
|
||||
.bind(&correct_answer)
|
||||
.bind(if explanation_raw.is_empty() { None } else { Some(explanation_raw) })
|
||||
.bind(&difficulty)
|
||||
.bind(tags.as_deref().map(|t| serde_json::json!(t)))
|
||||
.bind(points)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => imported += 1,
|
||||
Err(_) => skipped += 1,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(ImportResult { imported, skipped, updated: 0, error: None }))
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow, Serialize, Deserialize)]
|
||||
pub struct MySqlQuestionFull {
|
||||
pub id_pregunta: i32,
|
||||
|
||||
@@ -482,6 +482,10 @@ async fn main() {
|
||||
"/question-bank/ai-generate",
|
||||
post(handlers_question_bank::ai_generate_question),
|
||||
)
|
||||
.route(
|
||||
"/question-bank/import-excel",
|
||||
post(handlers_question_bank::import_from_excel),
|
||||
)
|
||||
// Rutas de embeddings para búsqueda semántica
|
||||
.route(
|
||||
"/question-bank/embeddings/generate",
|
||||
|
||||
Generated
+611
-539
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.395.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"next": "^14.2.35",
|
||||
"next": "^15.3.9",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -35,7 +35,7 @@
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "^16.2.4",
|
||||
"eslint-config-next": "^15.3.9",
|
||||
"postcss": "^8",
|
||||
"prettier": "^3.2.0",
|
||||
"prettier-plugin-tailwindcss": "^0.5.0",
|
||||
|
||||
Generated
+611
-745
File diff suppressed because it is too large
Load Diff
@@ -21,19 +21,18 @@
|
||||
"isomorphic-dompurify": "^3.10.0",
|
||||
"lucide-react": "^0.395.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"next": "^14.2.35",
|
||||
"next": "^15.3.9",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-markdown": "^10.1.0",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"xlsx": "^0.18.5"
|
||||
"tailwind-merge": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "^16.2.4",
|
||||
"eslint-config-next": "^15.3.9",
|
||||
"postcss": "^8",
|
||||
"prettier": "^3.2.0",
|
||||
"prettier-plugin-tailwindcss": "^0.5.0",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { CreateQuestionBankPayload, QuestionBankType, questionBankApi } from '@/lib/api';
|
||||
import { questionBankApi } from '@/lib/api';
|
||||
import { X, Upload, FileSpreadsheet, Check, AlertCircle, Download } from 'lucide-react';
|
||||
|
||||
interface ExcelImportModalProps {
|
||||
@@ -16,131 +15,6 @@ export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportMod
|
||||
const [result, setResult] = useState<{ imported: number; skipped: number; error?: string } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const toQuestionType = (value: string): QuestionBankType | null => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
const mapping: Record<string, QuestionBankType> = {
|
||||
'multiple-choice': 'multiple-choice',
|
||||
'multiple choice': 'multiple-choice',
|
||||
'mcq': 'multiple-choice',
|
||||
'true-false': 'true-false',
|
||||
'true false': 'true-false',
|
||||
'boolean': 'true-false',
|
||||
'short-answer': 'short-answer',
|
||||
'short answer': 'short-answer',
|
||||
'essay': 'essay',
|
||||
'matching': 'matching',
|
||||
'ordering': 'ordering',
|
||||
'fill-in-the-blanks': 'fill-in-the-blanks',
|
||||
'fill in the blanks': 'fill-in-the-blanks',
|
||||
'audio-response': 'audio-response',
|
||||
'audio response': 'audio-response',
|
||||
'hotspot': 'hotspot',
|
||||
'code-lab': 'code-lab',
|
||||
'code lab': 'code-lab',
|
||||
};
|
||||
return mapping[normalized] || null;
|
||||
};
|
||||
|
||||
const parseUnknownJson = (value: string): unknown => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (/^-?\d+$/.test(trimmed)) {
|
||||
return Number.parseInt(trimmed, 10);
|
||||
}
|
||||
if (/^-?\d+\.\d+$/.test(trimmed)) {
|
||||
return Number.parseFloat(trimmed);
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const parseOptions = (raw: string): unknown => {
|
||||
const parsed = parseUnknownJson(raw);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
if (typeof parsed === 'string') {
|
||||
const pieces = parsed
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
return pieces.length > 0 ? pieces : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const parseExcelRows = async (file: File): Promise<CreateQuestionBankPayload[]> => {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(buffer, { type: 'array' });
|
||||
const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
if (!firstSheet) return [];
|
||||
|
||||
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(firstSheet, {
|
||||
defval: '',
|
||||
raw: false,
|
||||
});
|
||||
|
||||
return rows
|
||||
.map((row) => {
|
||||
const getField = (name: string): string => {
|
||||
const direct = row[name];
|
||||
if (direct !== undefined) return String(direct).trim();
|
||||
const lowerName = name.toLowerCase();
|
||||
const foundKey = Object.keys(row).find((k) => k.trim().toLowerCase() === lowerName);
|
||||
return foundKey ? String(row[foundKey]).trim() : '';
|
||||
};
|
||||
|
||||
const questionText = getField('question_text');
|
||||
const questionTypeRaw = getField('question_type');
|
||||
const questionType = toQuestionType(questionTypeRaw);
|
||||
if (!questionText || !questionType) return null;
|
||||
|
||||
const optionsRaw = getField('options');
|
||||
const correctAnswerRaw = getField('correct_answer');
|
||||
const explanation = getField('explanation') || undefined;
|
||||
const difficultyRaw = getField('difficulty').toLowerCase();
|
||||
const difficulty = ['easy', 'medium', 'hard'].includes(difficultyRaw) ? difficultyRaw : 'medium';
|
||||
const tagsRaw = getField('tags');
|
||||
const pointsRaw = getField('points');
|
||||
|
||||
let options = parseOptions(optionsRaw);
|
||||
let correctAnswer = parseUnknownJson(correctAnswerRaw);
|
||||
|
||||
if (questionType === 'true-false') {
|
||||
options = ['Verdadero', 'Falso'];
|
||||
if (typeof correctAnswer === 'string') {
|
||||
const lower = correctAnswer.toLowerCase();
|
||||
correctAnswer = lower === 'verdadero' || lower === 'true' ? 0 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
const tags = tagsRaw
|
||||
? tagsRaw
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
const pointsNum = Number.parseInt(pointsRaw, 10);
|
||||
|
||||
return {
|
||||
question_text: questionText,
|
||||
question_type: questionType,
|
||||
options,
|
||||
correct_answer: correctAnswer,
|
||||
explanation,
|
||||
difficulty,
|
||||
tags,
|
||||
points: Number.isFinite(pointsNum) && pointsNum > 0 ? pointsNum : 1,
|
||||
} as CreateQuestionBankPayload;
|
||||
})
|
||||
.filter((item): item is CreateQuestionBankPayload => item !== null);
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
@@ -158,42 +32,12 @@ export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportMod
|
||||
alert('Selecciona un archivo primero');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
const payloads = await parseExcelRows(excelFile);
|
||||
if (payloads.length === 0) {
|
||||
throw new Error('No se encontraron filas válidas en el archivo. Verifica columnas y tipos.');
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const payload of payloads) {
|
||||
try {
|
||||
await questionBankApi.create(payload);
|
||||
imported += 1;
|
||||
} catch (e) {
|
||||
skipped += 1;
|
||||
if (errors.length < 5) {
|
||||
errors.push((e as Error).message || 'Error desconocido al crear pregunta');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setResult({
|
||||
imported,
|
||||
skipped,
|
||||
error: errors.length > 0 ? errors.join(' | ') : undefined,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
onSuccess?.();
|
||||
}, 2000);
|
||||
|
||||
const res = await questionBankApi.importFromExcel(excelFile);
|
||||
setResult(res);
|
||||
setTimeout(() => { onSuccess?.(); }, 2000);
|
||||
} catch (err: unknown) {
|
||||
console.error('Excel import failed:', err);
|
||||
setError((err as Error)?.message || 'Error al importar');
|
||||
@@ -208,7 +52,6 @@ export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportMod
|
||||
'What color is the sky?,multiple-choice,"[""Blue"",""Green"",""Red"",""Yellow""]",0,"The sky appears blue due to Rayleigh scattering",easy,"science,colors",1',
|
||||
'The sun rises in the east.,true-false,"[""Verdadero"",""Falso""]",0,"The sun always rises in the east",easy,"geography",1',
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
@@ -246,7 +89,6 @@ export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportMod
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-blue-900 dark:text-blue-100 mb-2 text-sm">
|
||||
¿Cómo funciona?
|
||||
@@ -256,11 +98,9 @@ export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportMod
|
||||
<li>• question_type: multiple-choice, true-false, short-answer, etc.</li>
|
||||
<li>• options: Formato JSON ["A","B","C","D"] o separado por comas</li>
|
||||
<li>• correct_answer: Índice de la opción correcta (0, 1, 2, 3)</li>
|
||||
<li>• Todas las preguntas se importarán al banco</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Download Template Button */}
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Download className="w-5 h-5 text-gray-500" />
|
||||
@@ -277,7 +117,6 @@ export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportMod
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File Upload */}
|
||||
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-8 text-center">
|
||||
<input
|
||||
type="file"
|
||||
@@ -286,24 +125,16 @@ export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportMod
|
||||
className="hidden"
|
||||
id="excel-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="excel-upload"
|
||||
className="cursor-pointer flex flex-col items-center gap-3"
|
||||
>
|
||||
<label htmlFor="excel-upload" className="cursor-pointer flex flex-col items-center gap-3">
|
||||
<FileSpreadsheet className="w-12 h-12 text-green-500" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-blue-600 hover:text-blue-700">
|
||||
Subir archivo Excel
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{' '}o arrastrar aquí
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400"> o arrastrar aquí</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
.xlsx, .xls - Máx 10MB
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">.xlsx, .xls - Máx 10MB</p>
|
||||
</label>
|
||||
|
||||
{excelFile && (
|
||||
<div className="mt-4 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm text-green-800 dark:text-green-200">
|
||||
@@ -315,34 +146,25 @@ export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportMod
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Result Messages */}
|
||||
{result && (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Check className="w-5 h-5 text-green-600" />
|
||||
<p className="font-semibold text-green-900 dark:text-green-100">
|
||||
¡Importación completada!
|
||||
</p>
|
||||
<p className="font-semibold text-green-900 dark:text-green-100">¡Importación completada!</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-green-700 dark:text-green-300">Importadas:</span>
|
||||
<span className="ml-2 font-bold text-green-900 dark:text-green-100">
|
||||
{result.imported}
|
||||
</span>
|
||||
<span className="ml-2 font-bold text-green-900 dark:text-green-100">{result.imported}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-green-700 dark:text-green-300">Saltadas:</span>
|
||||
<span className="ml-2 font-bold text-green-900 dark:text-green-100">
|
||||
{result.skipped}
|
||||
</span>
|
||||
<span className="ml-2 font-bold text-green-900 dark:text-green-100">{result.skipped}</span>
|
||||
</div>
|
||||
{result.error && (
|
||||
<div>
|
||||
<span className="text-green-700 dark:text-green-300">Errores:</span>
|
||||
<span className="ml-2 font-bold text-green-900 dark:text-green-100">
|
||||
{result.error}
|
||||
</span>
|
||||
<span className="ml-2 font-bold text-green-900 dark:text-green-100">{result.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -353,12 +175,8 @@ export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportMod
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-900 dark:text-red-100">
|
||||
Error
|
||||
</p>
|
||||
<p className="text-xs text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-red-900 dark:text-red-100">Error</p>
|
||||
<p className="text-xs text-red-700 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1622,6 +1622,12 @@ export const questionBankApi = {
|
||||
},
|
||||
delete: (id: string): Promise<void> =>
|
||||
apiFetch(`/question-bank/${id}`, { method: 'DELETE' }, false),
|
||||
importFromExcel: async (file: File): Promise<{ imported: number; skipped: number; error?: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const result = await apiFetch('/question-bank/import-excel', { method: 'POST', body: formData }, false);
|
||||
return result as { imported: number; skipped: number; error?: string };
|
||||
},
|
||||
importFromMySQL: async (courseId?: number, questionIds?: number[], importAll?: boolean): Promise<QuestionBank[]> => {
|
||||
const questions = await apiFetch('/question-bank/import-mysql', { method: 'POST', body: JSON.stringify({ mysql_course_id: courseId, question_ids: questionIds, import_all: importAll }) }, false);
|
||||
return (questions as QuestionBank[]).map(normalizeQuestionBank);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user