Modulo de Reportes v1

This commit is contained in:
2025-11-19 23:49:37 -03:00
parent 0917d24029
commit cfe49ee0c8
2 changed files with 534 additions and 4 deletions

View File

@@ -1554,6 +1554,240 @@ def get_inspections_report(
]
@app.get("/api/inspections/{inspection_id}/pdf")
def export_inspection_to_pdf(
inspection_id: int,
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Exportar inspección a PDF con imágenes"""
from fastapi.responses import StreamingResponse
from reportlab.lib.pagesizes import letter, A4
from reportlab.lib import colors
from reportlab.lib.units import inch
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image as RLImage, PageBreak
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from io import BytesIO
import base64
# Obtener inspección
inspection = db.query(models.Inspection).filter(
models.Inspection.id == inspection_id
).first()
if not inspection:
raise HTTPException(status_code=404, detail="Inspección no encontrada")
# Verificar permisos (admin o mecánico dueño)
if current_user.role != "admin" and inspection.mechanic_id != current_user.id:
raise HTTPException(status_code=403, detail="No tienes permisos para ver esta inspección")
# Obtener datos relacionados
checklist = db.query(models.Checklist).filter(models.Checklist.id == inspection.checklist_id).first()
mechanic = db.query(models.User).filter(models.User.id == inspection.mechanic_id).first()
answers = db.query(models.Answer).options(
joinedload(models.Answer.media_files)
).join(models.Question).filter(
models.Answer.inspection_id == inspection_id
).order_by(models.Question.section, models.Question.order).all()
# Crear PDF en memoria
buffer = BytesIO()
doc = SimpleDocTemplate(buffer, pagesize=A4, rightMargin=30, leftMargin=30, topMargin=30, bottomMargin=30)
# Contenedor para elementos del PDF
elements = []
styles = getSampleStyleSheet()
# Estilos personalizados
title_style = ParagraphStyle(
'CustomTitle',
parent=styles['Heading1'],
fontSize=24,
textColor=colors.HexColor('#4338ca'),
spaceAfter=30,
alignment=TA_CENTER
)
heading_style = ParagraphStyle(
'CustomHeading',
parent=styles['Heading2'],
fontSize=16,
textColor=colors.HexColor('#4338ca'),
spaceAfter=12,
spaceBefore=12
)
# Título
elements.append(Paragraph("REPORTE DE INSPECCIÓN", title_style))
elements.append(Spacer(1, 20))
# Información general
info_data = [
['Checklist:', checklist.name if checklist else 'N/A'],
['Mecánico:', mechanic.full_name if mechanic else 'N/A'],
['Fecha:', inspection.started_at.strftime('%d/%m/%Y %H:%M') if inspection.started_at else 'N/A'],
['Estado:', inspection.status.upper()],
['', ''],
['Vehículo', ''],
['Patente:', inspection.vehicle_plate or 'N/A'],
['Marca:', inspection.vehicle_brand or 'N/A'],
['Modelo:', inspection.vehicle_model or 'N/A'],
['Kilometraje:', f"{inspection.vehicle_km:,} km" if inspection.vehicle_km else 'N/A'],
['Cliente:', inspection.client_name or 'N/A'],
['OR Número:', inspection.or_number or 'N/A'],
]
if inspection.status == 'completed' and inspection.score is not None:
info_data.insert(4, ['Score:', f"{inspection.score}/{inspection.max_score} ({inspection.percentage:.1f}%)"])
info_data.insert(5, ['Items Señalados:', str(inspection.flagged_items_count)])
info_table = Table(info_data, colWidths=[2*inch, 4*inch])
info_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (0, -1), colors.HexColor('#e0e7ff')),
('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#4338ca')),
('ALIGN', (0, 0), (0, -1), 'RIGHT'),
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
('TOPPADDING', (0, 0), (-1, -1), 8),
('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
]))
elements.append(info_table)
elements.append(Spacer(1, 30))
# Respuestas por sección
if answers:
elements.append(Paragraph("RESULTADOS DE INSPECCIÓN", heading_style))
elements.append(Spacer(1, 10))
current_section = None
for answer in answers:
question = answer.question
# Nueva sección
if question.section != current_section:
if current_section is not None:
elements.append(Spacer(1, 20))
current_section = question.section
section_style = ParagraphStyle(
'Section',
parent=styles['Heading3'],
fontSize=14,
textColor=colors.HexColor('#6366f1'),
spaceAfter=10
)
elements.append(Paragraph(f"{current_section}", section_style))
# Datos de la pregunta
answer_color = colors.white
if answer.is_flagged:
answer_color = colors.HexColor('#fee2e2')
elif answer.answer_value in ['pass', 'good']:
answer_color = colors.HexColor('#dcfce7')
elif answer.answer_value in ['fail', 'bad']:
answer_color = colors.HexColor('#fee2e2')
answer_text = answer.answer_value or answer.answer_text or 'N/A'
if answer.points_earned is not None:
answer_text += f" ({answer.points_earned} pts)"
question_data = [
[Paragraph(f"<b>{question.text}</b>", styles['Normal']), answer_text.upper()]
]
if answer.comment:
question_data.append([Paragraph(f"<i>Comentarios: {answer.comment}</i>", styles['Normal']), ''])
question_table = Table(question_data, colWidths=[4*inch, 2*inch])
question_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), answer_color),
('TEXTCOLOR', (0, 0), (-1, -1), colors.black),
('ALIGN', (1, 0), (1, 0), 'CENTER'),
('FONTNAME', (1, 0), (1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 9),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 6),
('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
]))
elements.append(question_table)
elements.append(Spacer(1, 5))
# Fotos adjuntas
if answer.media_files and len(answer.media_files) > 0:
elements.append(Spacer(1, 5))
photos_per_row = 2
photo_width = 2.5 * inch
photo_height = 2 * inch
for i in range(0, len(answer.media_files), photos_per_row):
photo_row = []
for media_file in answer.media_files[i:i+photos_per_row]:
try:
photo_path = media_file.file_path
# Si la foto es base64
if photo_path.startswith('data:image'):
img_data = base64.b64decode(photo_path.split(',')[1])
img_buffer = BytesIO(img_data)
img = RLImage(img_buffer, width=photo_width, height=photo_height)
else:
# Si es una ruta de archivo
full_path = os.path.join(os.getcwd(), photo_path)
if os.path.exists(full_path):
img = RLImage(full_path, width=photo_width, height=photo_height)
else:
continue
photo_row.append(img)
except Exception as e:
print(f"Error loading image: {e}")
continue
if photo_row:
photo_table = Table([photo_row])
photo_table.setStyle(TableStyle([
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
]))
elements.append(photo_table)
elements.append(Spacer(1, 10))
else:
elements.append(Paragraph("No hay respuestas registradas", styles['Normal']))
# Pie de página
elements.append(Spacer(1, 30))
footer_style = ParagraphStyle(
'Footer',
parent=styles['Normal'],
fontSize=8,
textColor=colors.grey,
alignment=TA_CENTER
)
elements.append(Paragraph(f"Generado por Syntria - {datetime.now().strftime('%d/%m/%Y %H:%M')}", footer_style))
# Construir PDF
doc.build(elements)
# Preparar respuesta
buffer.seek(0)
filename = f"inspeccion_{inspection_id}_{inspection.vehicle_plate or 'sin-patente'}.pdf"
return StreamingResponse(
buffer,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}"
}
)
# ============= HEALTH CHECK =============
@app.get("/")
def root():