v1.0.63 Backend / v1.0.57 Frontend - Edición y auditoría de preguntas
This commit is contained in:
@@ -204,7 +204,7 @@ def send_completed_inspection_to_n8n(inspection, db):
|
|||||||
# No lanzamos excepción para no interrumpir el flujo normal
|
# No lanzamos excepción para no interrumpir el flujo normal
|
||||||
|
|
||||||
|
|
||||||
BACKEND_VERSION = "1.0.28"
|
BACKEND_VERSION = "1.0.63"
|
||||||
app = FastAPI(title="Checklist Inteligente API", version=BACKEND_VERSION)
|
app = FastAPI(title="Checklist Inteligente API", version=BACKEND_VERSION)
|
||||||
|
|
||||||
# S3/MinIO configuration
|
# S3/MinIO configuration
|
||||||
@@ -966,6 +966,19 @@ def create_question(
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_question)
|
db.refresh(db_question)
|
||||||
|
|
||||||
|
# Registrar auditoría
|
||||||
|
audit_log = models.QuestionAuditLog(
|
||||||
|
question_id=db_question.id,
|
||||||
|
checklist_id=question.checklist_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="created",
|
||||||
|
new_value=f"Pregunta creada: {question.text}",
|
||||||
|
comment=f"Sección: {question.section}, Tipo: {question.type}, Puntos: {question.points}"
|
||||||
|
)
|
||||||
|
db.add(audit_log)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
return db_question
|
return db_question
|
||||||
|
|
||||||
|
|
||||||
@@ -983,11 +996,44 @@ def update_question(
|
|||||||
if not db_question:
|
if not db_question:
|
||||||
raise HTTPException(status_code=404, detail="Pregunta no encontrada")
|
raise HTTPException(status_code=404, detail="Pregunta no encontrada")
|
||||||
|
|
||||||
|
# Guardar valores anteriores para auditoría
|
||||||
|
import json
|
||||||
|
changes = []
|
||||||
|
|
||||||
for key, value in question.dict(exclude_unset=True).items():
|
for key, value in question.dict(exclude_unset=True).items():
|
||||||
setattr(db_question, key, value)
|
old_value = getattr(db_question, key)
|
||||||
|
if old_value != value:
|
||||||
|
# Convertir a string para comparación y almacenamiento
|
||||||
|
old_str = json.dumps(old_value, ensure_ascii=False) if isinstance(old_value, (dict, list)) else str(old_value)
|
||||||
|
new_str = json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else str(value)
|
||||||
|
|
||||||
|
changes.append({
|
||||||
|
'field': key,
|
||||||
|
'old': old_str,
|
||||||
|
'new': new_str
|
||||||
|
})
|
||||||
|
setattr(db_question, key, value)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_question)
|
db.refresh(db_question)
|
||||||
|
|
||||||
|
# Registrar auditoría para cada campo cambiado
|
||||||
|
for change in changes:
|
||||||
|
audit_log = models.QuestionAuditLog(
|
||||||
|
question_id=question_id,
|
||||||
|
checklist_id=db_question.checklist_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="updated",
|
||||||
|
field_name=change['field'],
|
||||||
|
old_value=change['old'],
|
||||||
|
new_value=change['new'],
|
||||||
|
comment=f"Campo '{change['field']}' modificado"
|
||||||
|
)
|
||||||
|
db.add(audit_log)
|
||||||
|
|
||||||
|
if changes:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
return db_question
|
return db_question
|
||||||
|
|
||||||
|
|
||||||
@@ -1004,11 +1050,56 @@ def delete_question(
|
|||||||
if not db_question:
|
if not db_question:
|
||||||
raise HTTPException(status_code=404, detail="Pregunta no encontrada")
|
raise HTTPException(status_code=404, detail="Pregunta no encontrada")
|
||||||
|
|
||||||
|
# Registrar auditoría antes de eliminar
|
||||||
|
audit_log = models.QuestionAuditLog(
|
||||||
|
question_id=question_id,
|
||||||
|
checklist_id=db_question.checklist_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="deleted",
|
||||||
|
old_value=f"Pregunta eliminada: {db_question.text}",
|
||||||
|
comment=f"Sección: {db_question.section}, Tipo: {db_question.type}, Puntos: {db_question.points}"
|
||||||
|
)
|
||||||
|
db.add(audit_log)
|
||||||
|
|
||||||
db.delete(db_question)
|
db.delete(db_question)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "Pregunta eliminada"}
|
return {"message": "Pregunta eliminada"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/questions/{question_id}/audit", response_model=List[schemas.QuestionAuditLog])
|
||||||
|
def get_question_audit_history(
|
||||||
|
question_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Obtener historial de cambios de una pregunta"""
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Solo administradores pueden ver el historial")
|
||||||
|
|
||||||
|
audit_logs = db.query(models.QuestionAuditLog).filter(
|
||||||
|
models.QuestionAuditLog.question_id == question_id
|
||||||
|
).order_by(models.QuestionAuditLog.created_at.desc()).all()
|
||||||
|
|
||||||
|
return audit_logs
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/checklists/{checklist_id}/questions/audit", response_model=List[schemas.QuestionAuditLog])
|
||||||
|
def get_checklist_questions_audit_history(
|
||||||
|
checklist_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Obtener historial de cambios de todas las preguntas de un checklist"""
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Solo administradores pueden ver el historial")
|
||||||
|
|
||||||
|
audit_logs = db.query(models.QuestionAuditLog).filter(
|
||||||
|
models.QuestionAuditLog.checklist_id == checklist_id
|
||||||
|
).order_by(models.QuestionAuditLog.created_at.desc()).all()
|
||||||
|
|
||||||
|
return audit_logs
|
||||||
|
|
||||||
|
|
||||||
# ============= INSPECTION ENDPOINTS =============
|
# ============= INSPECTION ENDPOINTS =============
|
||||||
@app.get("/api/inspections", response_model=List[schemas.Inspection])
|
@app.get("/api/inspections", response_model=List[schemas.Inspection])
|
||||||
def get_inspections(
|
def get_inspections(
|
||||||
|
|||||||
@@ -208,6 +208,27 @@ class ChecklistPermission(Base):
|
|||||||
mechanic = relationship("User")
|
mechanic = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionAuditLog(Base):
|
||||||
|
"""Registro de auditoría para cambios en preguntas de checklists"""
|
||||||
|
__tablename__ = "question_audit_log"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
checklist_id = Column(Integer, ForeignKey("checklists.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
action = Column(String(50), nullable=False) # created, updated, deleted
|
||||||
|
field_name = Column(String(100), nullable=True) # Campo modificado
|
||||||
|
old_value = Column(Text, nullable=True) # Valor anterior
|
||||||
|
new_value = Column(Text, nullable=True) # Valor nuevo
|
||||||
|
comment = Column(Text, nullable=True) # Comentario del cambio
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
question = relationship("Question")
|
||||||
|
checklist = relationship("Checklist")
|
||||||
|
user = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
class InspectionAuditLog(Base):
|
class InspectionAuditLog(Base):
|
||||||
"""Registro de auditoría para cambios en inspecciones y respuestas"""
|
"""Registro de auditoría para cambios en inspecciones y respuestas"""
|
||||||
__tablename__ = "inspection_audit_log"
|
__tablename__ = "inspection_audit_log"
|
||||||
|
|||||||
@@ -142,6 +142,23 @@ class Question(QuestionBase):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# Question Audit Schemas
|
||||||
|
class QuestionAuditLog(BaseModel):
|
||||||
|
id: int
|
||||||
|
question_id: int
|
||||||
|
checklist_id: int
|
||||||
|
user_id: int
|
||||||
|
action: str
|
||||||
|
field_name: Optional[str] = None
|
||||||
|
old_value: Optional[str] = None
|
||||||
|
new_value: Optional[str] = None
|
||||||
|
comment: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
user: Optional['User'] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# Inspection Schemas
|
# Inspection Schemas
|
||||||
class InspectionBase(BaseModel):
|
class InspectionBase(BaseModel):
|
||||||
|
|||||||
44
backend/migrations/add_question_audit_log.sql
Normal file
44
backend/migrations/add_question_audit_log.sql
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
-- Migration: Add question_audit_log table
|
||||||
|
-- Date: 2025-11-27
|
||||||
|
-- Description: Add audit logging for question changes
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS question_audit_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
question_id INTEGER NOT NULL,
|
||||||
|
checklist_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
action VARCHAR(50) NOT NULL,
|
||||||
|
field_name VARCHAR(100),
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
comment TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
-- Foreign keys
|
||||||
|
CONSTRAINT fk_question_audit_question
|
||||||
|
FOREIGN KEY (question_id)
|
||||||
|
REFERENCES questions(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
|
CONSTRAINT fk_question_audit_checklist
|
||||||
|
FOREIGN KEY (checklist_id)
|
||||||
|
REFERENCES checklists(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
|
CONSTRAINT fk_question_audit_user
|
||||||
|
FOREIGN KEY (user_id)
|
||||||
|
REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for better query performance
|
||||||
|
CREATE INDEX idx_question_audit_question_id ON question_audit_log(question_id);
|
||||||
|
CREATE INDEX idx_question_audit_checklist_id ON question_audit_log(checklist_id);
|
||||||
|
CREATE INDEX idx_question_audit_created_at ON question_audit_log(created_at);
|
||||||
|
CREATE INDEX idx_question_audit_action ON question_audit_log(action);
|
||||||
|
|
||||||
|
-- Add comment to table
|
||||||
|
COMMENT ON TABLE question_audit_log IS 'Registro de auditoría para cambios en preguntas de checklists';
|
||||||
|
COMMENT ON COLUMN question_audit_log.action IS 'Tipo de acción: created, updated, deleted';
|
||||||
|
COMMENT ON COLUMN question_audit_log.field_name IS 'Nombre del campo modificado (solo para updates)';
|
||||||
|
COMMENT ON COLUMN question_audit_log.old_value IS 'Valor anterior del campo';
|
||||||
|
COMMENT ON COLUMN question_audit_log.new_value IS 'Valor nuevo del campo';
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "checklist-frontend",
|
"name": "checklist-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.57",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -917,6 +917,9 @@ function QuestionsManagerModal({ checklist, onClose }) {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false)
|
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||||
const [editingQuestion, setEditingQuestion] = useState(null)
|
const [editingQuestion, setEditingQuestion] = useState(null)
|
||||||
|
const [viewingAudit, setViewingAudit] = useState(null)
|
||||||
|
const [auditHistory, setAuditHistory] = useState([])
|
||||||
|
const [loadingAudit, setLoadingAudit] = useState(false)
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
section: '',
|
section: '',
|
||||||
text: '',
|
text: '',
|
||||||
@@ -962,6 +965,31 @@ function QuestionsManagerModal({ checklist, onClose }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadAuditHistory = async (questionId) => {
|
||||||
|
setLoadingAudit(true)
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||||
|
|
||||||
|
const response = await fetch(`${API_URL}/api/questions/${questionId}/audit`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setAuditHistory(data)
|
||||||
|
setViewingAudit(questionId)
|
||||||
|
} else {
|
||||||
|
alert('Error al cargar historial')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading audit history:', error)
|
||||||
|
alert('Error al cargar historial')
|
||||||
|
} finally {
|
||||||
|
setLoadingAudit(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleCreateQuestion = async (e) => {
|
const handleCreateQuestion = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
@@ -1539,6 +1567,13 @@ function QuestionsManagerModal({ checklist, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4 flex gap-2">
|
<div className="ml-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => loadAuditHistory(question.id)}
|
||||||
|
className="text-gray-600 hover:text-gray-700 text-sm"
|
||||||
|
title="Ver historial de cambios"
|
||||||
|
>
|
||||||
|
📜 Historial
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditQuestion(question)}
|
onClick={() => handleEditQuestion(question)}
|
||||||
className="text-blue-600 hover:text-blue-700 text-sm"
|
className="text-blue-600 hover:text-blue-700 text-sm"
|
||||||
@@ -1572,6 +1607,118 @@ function QuestionsManagerModal({ checklist, onClose }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Audit History Modal */}
|
||||||
|
{viewingAudit && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[80vh] flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gray-700 text-white p-4 rounded-t-lg flex justify-between items-center">
|
||||||
|
<h3 className="text-lg font-bold">📜 Historial de Cambios - Pregunta #{viewingAudit}</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setViewingAudit(null)
|
||||||
|
setAuditHistory([])
|
||||||
|
}}
|
||||||
|
className="text-white hover:bg-gray-600 rounded-lg p-2 transition"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{loadingAudit ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">Cargando historial...</div>
|
||||||
|
) : auditHistory.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">No hay cambios registrados</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{auditHistory.map((log) => (
|
||||||
|
<div key={log.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
||||||
|
log.action === 'created' ? 'bg-green-100 text-green-800' :
|
||||||
|
log.action === 'updated' ? 'bg-blue-100 text-blue-800' :
|
||||||
|
'bg-red-100 text-red-800'
|
||||||
|
}`}>
|
||||||
|
{log.action === 'created' ? '➕ Creado' :
|
||||||
|
log.action === 'updated' ? '✏️ Modificado' :
|
||||||
|
'🗑️ Eliminado'}
|
||||||
|
</span>
|
||||||
|
{log.field_name && (
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
Campo: <strong>{log.field_name}</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{new Date(log.created_at).toLocaleString('es-PY', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{log.field_name && (
|
||||||
|
<div className="grid grid-cols-2 gap-4 mt-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 mb-1">Valor anterior:</div>
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded p-2 text-sm">
|
||||||
|
{log.old_value || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 mb-1">Valor nuevo:</div>
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded p-2 text-sm">
|
||||||
|
{log.new_value || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!log.field_name && (log.old_value || log.new_value) && (
|
||||||
|
<div className="mt-2 text-sm text-gray-700">
|
||||||
|
{log.old_value || log.new_value}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{log.comment && (
|
||||||
|
<div className="mt-2 text-sm text-gray-600 italic">
|
||||||
|
💬 {log.comment}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{log.user && (
|
||||||
|
<div className="mt-2 text-xs text-gray-500">
|
||||||
|
Por: {log.user.full_name || log.user.username}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t p-4 bg-gray-50">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setViewingAudit(null)
|
||||||
|
setAuditHistory([])
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
|
||||||
|
>
|
||||||
|
Cerrar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user