mi-proyecto/app/routes.py

5460 lines
312 KiB
Python

import io
import os
import json
import csv
import zipfile
import shutil
import re
import requests
from bs4 import BeautifulSoup
from pathlib import Path
from datetime import datetime, timedelta, date
from flask import render_template, request, redirect, url_for, flash, current_app, jsonify, abort, session, send_file, Response
from flask_login import login_user, logout_user, login_required, current_user
from sqlalchemy import func, or_
from . import db
from . import socketio # Importa la instancia
from flask_socketio import emit, join_room
from .models import (
Institution,
InstitutionBranch,
User,
ProfessionalProfile,
Service,
WorkingHour,
Leave,
Appointment,
AuditLog,
Specialty,
Patient,
ObraSocialCatalog,
ObraSocialPageSnapshot,
ErrorLog,
Prescription,
BackupRecord,
FrontendBlock,
ContactInquiry,
ClinicalRecord,
ClinicalEpisode,
ClinicalEpisodeTemplate,
ClinicalEpisodeMember,
ClinicalEntry,
ClinicalEntryTemplate,
ClinicalAttachment,
AccountingEntry,
SaasPlan,
InstitutionSubscription,
SaasInvoice,
SaasPayment,
ChatConversation,
ChatConversationMember,
ChatMessage,
ChatAttachment,
WhatsappBotRule,
WhatsappBotKnowledge,
WhatsappMessageLog,
AiKnowledgeItem,
AiChatLog,
)
from .utils import (
role_required,
log_action,
system_log_action,
generate_public_token,
get_available_slots,
appointment_overlaps_existing,
choose_first_available_professional,
choose_round_robin_professional,
professional_scope_filter,
parse_date,
parse_time,
today_iso,
WEEKDAY_LABELS,
build_week_dates,
get_due_reminders,
get_setting,
get_site_settings,
set_setting,
is_sisa_enabled,
store_error,
get_site_settings,
JURISDICTION_OPTIONS,
PRESCRIPTION_TYPE_OPTIONS,
PRESCRIPTION_SUBTYPE_OPTIONS,
ORDER_KIND_OPTIONS,
ORDER_KIND_META,
resolve_jurisdiction_code,
resolve_jurisdiction_name,
generate_prescription_group,
generate_legal_number,
build_cuir,
mask_last4_dni,
save_uploaded_asset,
get_smtp_settings,
render_message_template,
send_smtp_email,
sha256_file,
ensure_clinical_record,
get_frontend_payload,
hash_payload,
sanitize_text,
sanitize_multiline_text,
sanitize_digits,
sanitize_choice,
sanitize_code,
sanitize_json_text_map,
sanitize_identifier,
ALLOWED_CONFIDENTIALITY,
)
from .integrations import (
sync_obras_sociales,
sisa_search_professionals,
sisa_test_connection,
get_provinces,
get_municipios,
get_localidades,
)
from .catalogs import CIE10_CATALOG, SNOMED_CT_CATALOG, ENCOUNTER_TYPE_OPTIONS, ENTRY_STATUS_OPTIONS, VISIBILITY_SCOPE_OPTIONS, SPECIALTY_TEMPLATE_CATALOG
@socketio.on('join')
def handle_join(data):
room = data.get('room')
if room:
join_room(room)
print(f"DEBUG: Usuario unido a la sala {room}")
APPOINTMENT_STATUSES = ['pending', 'confirmed', 'completed', 'cancelled', 'no_show']
def user_institution_id():
if current_user.is_authenticated and getattr(current_user, 'role', None) != 'admin':
return getattr(current_user, 'institution_id', None)
return None
def scoped_query(model):
q = model.query
inst_id = user_institution_id()
if inst_id and hasattr(model, 'institution_id'):
q = q.filter(model.institution_id == inst_id)
return q
def assign_institution(obj):
if not hasattr(obj, 'institution_id'):
return obj
if current_user.is_authenticated and current_user.role == 'admin':
obj.institution_id = request.form.get('institution_id', type=int) or getattr(obj, 'institution_id', None)
else:
obj.institution_id = getattr(current_user, 'institution_id', None)
return obj
def require_same_institution(obj):
inst_id = user_institution_id()
if inst_id and hasattr(obj, 'institution_id') and obj.institution_id != inst_id:
abort(403)
return obj
def register_routes(app):
@app.context_processor
def inject_globals():
return {
'app_name': current_app.config.get('APP_NAME', 'Book Appointments Pro'),
'today_iso': today_iso,
'weekday_labels': WEEKDAY_LABELS,
'sisa_enabled_global': is_sisa_enabled(),
'site_settings': get_site_settings(),
'jurisdiction_options': JURISDICTION_OPTIONS,
'frontend_payload': get_frontend_payload(),
'current_institution': current_user.institution if current_user.is_authenticated and getattr(current_user, 'institution', None) else None,
'chat_unread_count': get_chat_unread_count(current_user.id) if current_user.is_authenticated and current_user.role in ['admin', 'receptionist', 'professional'] else 0,
'client_patient': current_client_patient() if current_user.is_authenticated and current_user.role == 'client' else None,
}
def current_professional_id():
if current_user.is_authenticated and current_user.role == 'professional' and current_user.professional_profile:
return current_user.professional_profile.id
return None
def current_professional_profile():
if current_user.is_authenticated and current_user.role == 'professional' and current_user.professional_profile:
return current_user.professional_profile
return None
def current_client_patient():
if not current_user.is_authenticated or current_user.role != 'client' or not current_user.email:
return None
q = Patient.query.filter(func.lower(Patient.email) == current_user.email.strip().lower())
if getattr(current_user, 'institution_id', None):
q = q.filter(Patient.institution_id == current_user.institution_id)
return q.order_by(Patient.created_at.desc()).first()
def sync_patient_portal_user(patient: Patient, old_email: str = ''):
patient_email = (patient.email or '').strip().lower()
if not patient_email:
return None, 'No se generó usuario cliente porque el paciente no tiene email.'
existing = User.query.filter(func.lower(User.email) == patient_email).first()
matched = existing
if not matched and old_email:
matched = User.query.filter(func.lower(User.email) == old_email.strip().lower()).filter(User.role == 'client').first()
if not matched and patient.documento:
matched = User.query.filter(User.role == 'client', User.full_name == patient.nombre_completo).first()
if matched and matched.role != 'client':
return None, 'Ya existe un usuario con ese email y no es de tipo cliente.'
created = False
if not matched:
matched = User(full_name=patient.nombre_completo, email=patient_email, role='client', is_active_user=True, institution_id=patient.institution_id)
matched.set_password((patient.documento or 'Cambio1234').strip())
db.session.add(matched)
created = True
else:
matched.full_name = patient.nombre_completo
matched.email = patient_email
matched.role = 'client'
matched.is_active_user = True
matched.institution_id = patient.institution_id or matched.institution_id
return matched, ('Usuario cliente creado con contraseña provisoria DNI.' if created else 'Usuario cliente vinculado / actualizado.')
def can_client_cancel_appointment(appointment: Appointment) -> bool:
if not appointment or appointment.status not in ['pending', 'confirmed']:
return False
service = appointment.service
if not service or not service.allow_online_cancel:
return False
cutoff = appointment.starts_at - timedelta(hours=service.cancel_notice_hours or 0)
return datetime.now() <= cutoff
def chat_effective_institution_id(*users):
"""Resuelve la institución operativa de una conversación sin mezclar clínicas."""
inst_ids = {getattr(u, 'institution_id', None) for u in users if u and getattr(u, 'institution_id', None)}
if len(inst_ids) > 1:
abort(403, description='No se permite crear conversaciones entre instituciones diferentes.')
return next(iter(inst_ids), None)
def chat_user_can_access_conversation(conversation: ChatConversation, user: User = None) -> bool:
user = user or current_user
if not conversation or not user or not getattr(user, 'is_authenticated', False):
return False
membership = ChatConversationMember.query.filter_by(conversation_id=conversation.id, user_id=user.id).first()
if not membership:
return False
if user.role == 'admin':
return True
return bool(user.institution_id and conversation.institution_id == user.institution_id)
def require_chat_access(conversation: ChatConversation):
if not chat_user_can_access_conversation(conversation, current_user):
abort(403)
return conversation
def allowed_chat_user_query():
q = User.query.filter(User.role.in_(['admin', 'receptionist', 'professional']), User.is_active_user == True)
inst_id = user_institution_id()
if inst_id:
q = q.filter(or_(User.institution_id == inst_id, User.role == 'admin'))
return q.order_by(User.full_name.asc())
def validate_chat_members_same_scope(users):
if not users:
raise ValueError('No hay integrantes válidos para crear la conversación.')
if current_user.role != 'admin':
for user in users:
if user.role != 'admin' and user.institution_id != current_user.institution_id:
abort(403, description='No podés agregar integrantes de otra institución.')
return chat_effective_institution_id(*users)
def get_or_create_private_chat(user_a: User, user_b: User):
if not user_a or not user_b or user_a.id == user_b.id:
return None
conversation_institution_id = validate_chat_members_same_scope([user_a, user_b])
memberships = ChatConversationMember.query.filter(ChatConversationMember.user_id.in_([user_a.id, user_b.id])).all()
convo_ids = {m.conversation_id for m in memberships}
for convo_id in convo_ids:
convo = ChatConversation.query.get(convo_id)
if not convo or convo.is_group or not convo.is_active:
continue
member_ids = sorted([m.user_id for m in convo.members])
if member_ids == sorted([user_a.id, user_b.id]) and (conversation_institution_id is None or convo.institution_id == conversation_institution_id):
return convo
convo = ChatConversation(title='', is_group=False, created_by_user_id=user_a.id, is_active=True, institution_id=conversation_institution_id)
db.session.add(convo)
db.session.flush()
db.session.add(ChatConversationMember(conversation_id=convo.id, user_id=user_a.id, is_admin=True, last_read_at=datetime.utcnow()))
db.session.add(ChatConversationMember(conversation_id=convo.id, user_id=user_b.id, is_admin=True))
db.session.flush()
return convo
def get_chat_unread_count(user_id: int) -> int:
if not user_id:
return 0
viewer = User.query.get(user_id)
memberships = ChatConversationMember.query.filter_by(user_id=user_id).all()
total = 0
for membership in memberships:
conversation = membership.conversation
if not conversation or not conversation.is_active:
continue
if viewer and viewer.role != 'admin' and conversation.institution_id != viewer.institution_id:
continue
q = ChatMessage.query.filter(ChatMessage.conversation_id == membership.conversation_id, ChatMessage.sender_id != user_id)
if membership.last_read_at:
q = q.filter(ChatMessage.created_at > membership.last_read_at)
total += q.count()
return total
def conversation_display_name(conversation: ChatConversation, viewer_id: int) -> str:
if conversation.is_group and conversation.title:
return conversation.title
others = [m.user.full_name for m in conversation.members if m.user_id != viewer_id and m.user]
return others[0] if others else (conversation.title or 'Chat')
def chat_attachment_payload(att: ChatAttachment):
return {
'id': att.id,
'filename': att.filename,
'url': url_for('static', filename=att.file_path),
'mime_type': att.mime_type or '',
}
def chat_message_payload(message: ChatMessage, viewer_id=None):
conversation = message.conversation
return {
'conversation_id': message.conversation_id,
'message_id': message.id,
'body': message.body or '',
'sender_id': message.sender_id,
'sender_name': message.sender.full_name if message.sender else 'Sistema',
'is_group': bool(conversation.is_group) if conversation else False,
'conversation_name': conversation_display_name(conversation, viewer_id or message.sender_id) if conversation else 'Chat',
'created_at': message.created_at.strftime('%d/%m/%Y %H:%M'),
'created_time': message.created_at.strftime('%H:%M'),
'attachments': [chat_attachment_payload(att) for att in message.attachments],
'system_flag': bool(message.system_flag),
}
def chat_unread_for_membership(membership: ChatConversationMember) -> int:
q = ChatMessage.query.filter(ChatMessage.conversation_id == membership.conversation_id, ChatMessage.sender_id != membership.user_id)
if membership.last_read_at:
q = q.filter(ChatMessage.created_at > membership.last_read_at)
return q.count()
def chat_conversation_payload(conversation: ChatConversation, viewer_id: int):
membership = ChatConversationMember.query.filter_by(conversation_id=conversation.id, user_id=viewer_id).first()
last_message = ChatMessage.query.filter_by(conversation_id=conversation.id).order_by(ChatMessage.created_at.desc()).first()
unread = chat_unread_for_membership(membership) if membership else 0
return {
'id': conversation.id,
'display_name': conversation_display_name(conversation, viewer_id),
'is_group': bool(conversation.is_group),
'title': conversation.title or '',
'members_count': len(conversation.members),
'unread': unread,
'last_message': last_message.body if last_message and last_message.body else ('Archivo adjunto' if last_message and last_message.attachments else 'Sin mensajes'),
'last_message_time': (last_message.created_at if last_message else conversation.created_at).strftime('%d/%m/%Y %H:%M'),
'last_message_short_time': (last_message.created_at if last_message else conversation.created_at).strftime('%H:%M'),
}
def emit_chat_updates(conversation: ChatConversation, message: ChatMessage, sender: User):
socketio.emit('new_message', chat_message_payload(message, sender.id), to=f"chat_{conversation.id}")
for membership in conversation.members:
member = membership.user
if not member:
continue
payload = chat_message_payload(message, member.id)
socketio.emit('chat_notify', {
'type': 'chat_message',
'conversation': chat_conversation_payload(conversation, member.id),
'message': payload,
'unread_total': get_chat_unread_count(member.id),
'show_toast': member.id != sender.id,
'toast_title': f'Tienes un mensaje de: {sender.full_name}',
'toast_body': payload['body'] or (payload['attachments'][0]['filename'] if payload['attachments'] else 'Nuevo mensaje'),
'sender_id': sender.id,
'sender_name': sender.full_name,
}, to=f"user_{member.id}")
def admin_professional_choices(include_hidden=False):
query = ProfessionalProfile.query
if not include_hidden:
query = query.filter_by(is_bookable=True)
prof_id = current_professional_id()
if prof_id:
query = query.filter_by(id=prof_id)
return query.order_by(ProfessionalProfile.display_name.asc()).all()
def get_sisa_settings():
return {
'enabled': is_sisa_enabled(),
'wsdl': get_setting('sisa_wsdl'),
'user': get_setting('sisa_user'),
'password': get_setting('sisa_password'),
'operation': get_setting('sisa_operation', 'profesionalNominal'),
}
def get_recipe_auth_context():
verified_until = session.get('recipe_issue_verified_until')
actor_user_id = session.get('recipe_issue_actor_user_id')
actor = User.query.get(actor_user_id) if actor_user_id else None
is_valid = False
if verified_until:
try:
is_valid = datetime.fromisoformat(verified_until) > datetime.utcnow()
except Exception:
is_valid = False
if not is_valid:
session.pop('recipe_issue_verified_until', None)
session.pop('recipe_issue_actor_user_id', None)
actor = None
return {
'is_valid': is_valid,
'actor': actor,
}
def patient_active_query():
return Patient.query.filter(Patient.estado != 'Eliminado')
def computed_recipe_status(item: Prescription) -> str:
return item.computed_status
def normalize_order_kind(raw_value: str) -> str:
value = sanitize_choice((raw_value or 'recipe').strip().lower(), [opt['code'] for opt in ORDER_KIND_OPTIONS], 'recipe')
return value or 'recipe'
def order_kind_meta(kind: str):
return ORDER_KIND_META.get(normalize_order_kind(kind), ORDER_KIND_META['recipe'])
def order_primary_title(item: Prescription) -> str:
kind = normalize_order_kind(getattr(item, 'document_kind', 'recipe'))
if kind == 'recipe':
return item.medication_generic_name or item.document_title or 'Receta médica'
return item.document_title or item.medication_generic_name or order_kind_meta(kind)['label']
def order_body_text(item: Prescription) -> str:
kind = normalize_order_kind(getattr(item, 'document_kind', 'recipe'))
if kind == 'recipe':
parts = []
if item.medication_presentation:
parts.append(item.medication_presentation)
if item.pharmaceutical_form:
parts.append(item.pharmaceutical_form)
if item.dosage_instructions:
parts.append(item.dosage_instructions)
return '\n'.join([p for p in parts if p])
return item.document_body or item.dosage_instructions or ''
def build_recipe_public_payload(item: Prescription):
kind = normalize_order_kind(getattr(item, 'document_kind', 'recipe'))
return {
'document_kind': kind,
'status': item.computed_status,
'patient_name': item.patient_full_name,
'patient_dni_last4': mask_last4_dni(item.patient_document),
'professional_name': item.professional_display_name,
'professional_specialty': item.professional_specialty,
'professional_matricula': item.professional_matricula,
'issued_at': item.prescription_date.strftime('%d/%m/%Y') if item.prescription_date else '',
'expires_at': item.expires_at.strftime('%d/%m/%Y') if item.expires_at else '',
'legal_number': item.legal_number,
'cuir': item.cuir,
'medication_generic_name': item.medication_generic_name,
'medication_presentation': item.medication_presentation,
'pharmaceutical_form': item.pharmaceutical_form,
'quantity_units': item.quantity_units,
'dosage_instructions': item.dosage_instructions,
'diagnosis': item.diagnosis,
}
def validate_recipe_credentials(auth_email: str, auth_password: str):
actor = User.query.filter(func.lower(User.email) == (auth_email or '').strip().lower()).first()
if not actor or not actor.check_password(auth_password or '') or not actor.is_active_user:
raise ValueError('Credenciales inválidas para emitir receta.')
if actor.role not in ['admin', 'professional']:
raise ValueError('Ese usuario no está habilitado para emitir recetas.')
if actor.role == 'professional' and not actor.professional_profile:
raise ValueError('El profesional autenticado no tiene perfil vinculado.')
return actor
def build_recipe_mail_context(recipe: Prescription):
verify_url = url_for('public_verify_recipe', q=recipe.cuir, _external=True)
return {
'patient_name': recipe.patient_full_name,
'professional_name': recipe.professional_display_name,
'legal_number': recipe.legal_number,
'cuir': recipe.cuir,
'verify_url': verify_url,
'issued_at': recipe.prescription_date.strftime('%d/%m/%Y') if recipe.prescription_date else '',
'expires_at': recipe.expires_at.strftime('%d/%m/%Y') if recipe.expires_at else '',
}
""" def create_prescription_pdf_bytes(recipe: Prescription):
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas
from reportlab.lib import colors
import qrcode
import io
import os
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
width, height = A4
site = get_site_settings()
gris_suave = colors.Color(0.8, 0.8, 0.8)
# --- 1. HEADER Y LÍNEA DIVISORIA ---
logo_path = site.get('logo_path')
if logo_path:
try:
full_logo = os.path.join(current_app.root_path, 'static', logo_path)
if os.path.exists(full_logo):
c.drawImage(ImageReader(full_logo), 20 * mm, height - 28 * mm, 24 * mm, 24 * mm, preserveAspectRatio=True, mask='auto')
except Exception:
pass
c.setFont('Helvetica-Bold', 16)
c.drawString(48 * mm, height - 18 * mm, site.get('title') or 'Receta electrónica')
c.setFont('Helvetica', 10)
c.drawString(48 * mm, height - 24 * mm, 'Receta oficial digital')
# Línea divisoria Header (Gris suave)
c.setStrokeColor(gris_suave)
c.setLineWidth(0.5)
c.line(20 * mm, height - 32 * mm, width - 20 * mm, height - 32 * mm)
# --- 2. TABLA CON BORDE GRIS SUAVE ---
# Coordenadas de la tabla
tabla_top = height - 35 * mm
tabla_bottom = 75 * mm # Límite inferior para dar espacio a los QR
c.setStrokeColor(gris_suave)
# Dibujamos el rectángulo que envuelve todos los datos
c.rect(20 * mm, tabla_bottom, width - 40 * mm, tabla_top - tabla_bottom, stroke=1, fill=0)
# Datos de cabecera de receta (dentro de la tabla)
c.setFont('Helvetica-Bold', 9)
c.setFillColor(colors.black)
c.drawString(25 * mm, tabla_top - 6 * mm, f'N° legal: {recipe.legal_number}')
c.drawString(100 * mm, tabla_top - 6 * mm, f'CUIR: {recipe.cuir}')
# Sub-cabecera con fechas
c.setFont('Helvetica', 8)
fecha_presc = recipe.prescription_date.strftime("%d/%m/%Y") if recipe.prescription_date else ""
fecha_vence = recipe.expires_at.strftime("%d/%m/%Y") if recipe.expires_at else ""
c.drawString(25 * mm, tabla_top - 11 * mm, f'Fecha: {fecha_presc} | Vence: {fecha_vence}')
# Función para filas de datos
y_current = tabla_top - 18 * mm
def draw_row(label, value, extra_spacing=0):
nonlocal y_current
if y_current < tabla_bottom + 5 * mm: return # Evita salirse del borde gris
c.setFont('Helvetica-Bold', 8)
c.drawString(25 * mm, y_current, f"{label}:")
c.setFont('Helvetica', 8)
c.drawString(60 * mm, y_current, str(value or '')[:110])
y_current -= (6 * mm + extra_spacing)
# Bloque Profesional
draw_row('Profesional', recipe.professional_display_name)
draw_row('Matrícula', f"{recipe.professional_matricula} ({recipe.professional_jurisdiction_name or ''})")
y_current -= 2 * mm # Separador visual interno
# Bloque Paciente
draw_row('Paciente', recipe.patient_full_name)
draw_row('DNI / OS', recipe.patient_document)
draw_row('O. Social', f"{recipe.patient_obra_social or 'Particular'}")
y_current -= 2 * mm
# Bloque Medicación
draw_row('Medicamento', recipe.medication_generic_name)
draw_row('Presentación', f"{recipe.medication_presentation or ''} ({recipe.pharmaceutical_form or ''})")
draw_row('Cantidad', recipe.quantity_units)
draw_row('Diagnóstico', recipe.diagnosis)
draw_row('Posología', recipe.dosage_instructions)
# --- 4. QR CENTRADOS Y BIEN ESPACIADOS ---
qr_size = 35 * mm
espaciado_qr = 30 * mm # Espacio amplio para evitar que la cámara enfoque ambos
total_ancho_qrs = (qr_size * 2) + espaciado_qr
# Cálculo para centrar el bloque completo de los 2 QRs
start_x_qr = (width - total_ancho_qrs) / 2
pos_y_qr = 26 * mm
# Generación de QRs
def get_qr_img(data):
qr = qrcode.QRCode(box_size=10, border=1)
qr.add_data(data)
qr.make(fit=True)
img_buf = io.BytesIO()
qr.make_image(fill_color="black", back_color="white").save(img_buf, format='PNG')
img_buf.seek(0)
return ImageReader(img_buf)
# Dibujar QR CUIR (Izquierda del bloque central)
c.drawImage(get_qr_img(recipe.cuir), start_x_qr, pos_y_qr, qr_size, qr_size)
c.setFont('Helvetica-Bold', 7)
c.drawCentredString(start_x_qr + (qr_size/2), pos_y_qr - 4 * mm, 'VALIDACIÓN CUIR')
# Dibujar QR N° Legal (Derecha del bloque central)
c.drawImage(get_qr_img(recipe.legal_number), start_x_qr + qr_size + espaciado_qr, pos_y_qr, qr_size, qr_size)
c.drawCentredString(start_x_qr + qr_size + espaciado_qr + (qr_size/2), pos_y_qr - 4 * mm, 'N° REGISTRO LEGAL')
# --- 3. SEGUNDA LÍNEA DIVISORIA (PRE-FOOTER) ---
c.setStrokeColor(gris_suave)
c.line(20 * mm, 70 * mm, width - 20 * mm, 70 * mm)
# Leyenda de auditoría justo debajo de la línea
c.setFont('Helvetica-Oblique', 7)
c.drawString(20 * mm, 66 * mm, recipe.platform_registry_legend or 'Receta electrónica firmada digitalmente.')
# --- 5. PIE DE PÁGINA (DATOS WEB Y CONTACTO) ---
c.setFont('Helvetica', 8)
c.setFillColor(colors.grey)
# Línea 1: Web, Mail y Teléfono
contacto_str = f"{site.get('site_url', '')} | {site.get('site_email', '')} | {site.get('site_phone', '')}"
c.drawCentredString(width / 2, 12 * mm, contacto_str)
# Línea 2: Dirección completa
localidad_info = f"{site.get('site_city', '')}, {site.get('site_province', '')}"
direccion_str = f"{site.get('site_contact_address', '')} - {localidad_info}"
c.drawCentredString(width / 2, 8 * mm, direccion_str)
c.showPage()
c.save()
return buffer.getvalue()
def create_backup_file(scope: str):
timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S')
backup_root = current_app.config['BACKUP_FOLDER']
os.makedirs(backup_root, exist_ok=True)
db_uri = current_app.config['SQLALCHEMY_DATABASE_URI']
if db_uri.startswith('sqlite:///'):
db_path = os.path.join(current_app.instance_path, db_uri.replace('sqlite:///', '').replace('instance/', ''))
else:
raise ValueError('Solo SQLite está soportado para backup automático en esta versión.')
if scope == 'recipes':
filename = f'recetas_{timestamp}.zip'
full_path = os.path.join(backup_root, filename)
data = []
for item in Prescription.query.order_by(Prescription.id.desc()).all():
data.append({
'id': item.id, 'legal_number': item.legal_number, 'cuir': item.cuir, 'status': item.status,
'patient_full_name': item.patient_full_name, 'patient_document': item.patient_document,
'professional_display_name': item.professional_display_name, 'prescription_date': item.prescription_date.isoformat() if item.prescription_date else '',
'expires_at': item.expires_at.isoformat() if item.expires_at else '',
})
with zipfile.ZipFile(full_path, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr('recetas.json', json.dumps(data, ensure_ascii=False, indent=2))
out = io.StringIO()
writer = csv.DictWriter(out, fieldnames=list(data[0].keys()) if data else ['id'])
writer.writeheader()
for row in data:
writer.writerow(row)
zf.writestr('recetas.csv', out.getvalue())
else:
filename = f'sistema_completo_{timestamp}.zip'
full_path = os.path.join(backup_root, filename)
with zipfile.ZipFile(full_path, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.write(db_path, arcname='instance/book_appointments.db')
upload_root = current_app.config['UPLOAD_FOLDER']
for root, dirs, files in os.walk(upload_root):
for f in files:
fp = os.path.join(root, f)
zf.write(fp, arcname=os.path.relpath(fp, os.path.dirname(upload_root)))
provider = get_setting('backup_provider', 'local')
target_path = get_setting('backup_target_path', '').strip()
notes = 'Backup creado localmente.'
status = 'created'
if provider in ['local', 'nas'] and target_path:
try:
os.makedirs(target_path, exist_ok=True)
shutil.copy2(full_path, os.path.join(target_path, os.path.basename(full_path)))
notes = f'Copiado a {target_path}'
except Exception as exc:
status = 'warning'
notes = f'Creado localmente pero no se pudo copiar al destino: {exc}'
elif provider not in ['local', 'nas']:
status = 'configured'
notes = f'Proveedor {provider} configurado. La copia automática remota requiere integración específica.'
record = BackupRecord(
scope=scope, provider=provider, filename=os.path.basename(full_path), relative_path=os.path.relpath(full_path, current_app.root_path),
target_path=target_path, status=status, size_bytes=os.path.getsize(full_path), sha256=sha256_file(full_path), notes=notes
)
db.session.add(record)
db.session.commit()
return record, full_path """
def create_prescription_pdf_bytes(recipe):
import io
import os
import qrcode
from urllib.parse import quote
from flask import current_app
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import Paragraph
from reportlab.pdfgen import canvas
from reportlab.graphics.barcode import createBarcodeDrawing
from reportlab.graphics import renderPDF
from reportlab.lib.utils import ImageReader
from xml.sax.saxutils import escape
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
page_w, page_h = A4
# =========================================================
# LAYOUT / PALETA
# =========================================================
margin_x = 14 * mm
top_margin = 10 * mm
bottom_margin = 9 * mm
content_w = page_w - (2 * margin_x)
border = colors.HexColor("#d7dde5")
soft_fill = colors.HexColor("#f8fafc")
section_fill = colors.HexColor("#f3f4f6")
header_fill = colors.HexColor("#eef2f7")
accent = colors.HexColor("#334155")
dark = colors.HexColor("#111827")
muted = colors.HexColor("#6b7280")
# =========================================================
# ESTILOS
# =========================================================
styles = getSampleStyleSheet()
st_title = ParagraphStyle(
"st_title",
parent=styles["Normal"],
fontName="Helvetica-Bold",
fontSize=14,
leading=16,
alignment=TA_CENTER,
textColor=dark,
)
st_subtitle = ParagraphStyle(
"st_subtitle",
parent=styles["Normal"],
fontName="Helvetica",
fontSize=8.4,
leading=10,
alignment=TA_CENTER,
textColor=muted,
)
st_label = ParagraphStyle(
"st_label",
parent=styles["Normal"],
fontName="Helvetica",
fontSize=9,
leading=11,
alignment=TA_LEFT,
textColor=dark,
)
st_label_center = ParagraphStyle(
"st_label_center",
parent=st_label,
alignment=TA_CENTER,
)
st_small = ParagraphStyle(
"st_small",
parent=styles["Normal"],
fontName="Helvetica",
fontSize=11,
leading=10.6,
alignment=TA_LEFT,
textColor=muted,
)
st_small_center = ParagraphStyle(
"st_small_center",
parent=st_small,
alignment=TA_CENTER,
)
st_verify_note = ParagraphStyle(
"st_verify_note",
parent=st_small_center,
fontName="Helvetica",
fontSize=7.4,
leading=8.4,
textColor=muted,
)
st_body_title = ParagraphStyle(
"st_body_title",
parent=styles["Normal"],
fontName="Helvetica-Bold",
fontSize=10.8,
leading=12,
alignment=TA_LEFT,
textColor=dark,
)
st_body = ParagraphStyle(
"st_body",
parent=styles["Normal"],
fontName="Helvetica",
fontSize=11,
leading=12.4,
alignment=TA_LEFT,
textColor=dark,
)
st_footer = ParagraphStyle(
"st_footer",
parent=styles["Normal"],
fontName="Helvetica",
fontSize=8,
leading=9.8,
alignment=TA_CENTER,
textColor=muted,
)
# =========================================================
# HELPERS
# =========================================================
def safe(v, default=""):
if v is None:
return default
v = str(v).strip()
return v if v else default
def esc(v):
return escape(str(v or ""))
def date_str(v):
try:
if hasattr(v, "strftime"):
return v.strftime("%d/%m/%Y")
txt = str(v).strip()
if not txt:
return ""
if len(txt) >= 10 and txt[4] == "-" and txt[7] == "-":
yyyy, mm_, dd = txt[:10].split("-")
return f"{dd}/{mm_}/{yyyy}"
return txt
except Exception:
return ""
def datetime_str(v):
try:
if hasattr(v, "strftime"):
return v.strftime("%d/%m/%Y %H:%M")
return safe(v)
except Exception:
return ""
def multiline_to_html(txt):
txt = str(txt or "").strip()
if not txt:
return ""
txt = escape(txt)
txt = txt.replace("\r\n", "\n").replace("\r", "\n")
lines = [line.strip() for line in txt.split("\n") if line.strip()]
return "<br/>".join(lines) if lines else ""
def draw_rect(x, top_y, w, h, fill_color=None, stroke=1):
c.setLineWidth(0.6)
c.setStrokeColor(border)
if fill_color:
c.setFillColor(fill_color)
c.rect(x, top_y - h, w, h, stroke=stroke, fill=1)
else:
c.rect(x, top_y - h, w, h, stroke=stroke, fill=0)
def draw_vline(x, top_y, h):
c.setStrokeColor(border)
c.setLineWidth(0.5)
c.line(x, top_y, x, top_y - h)
def draw_paragraph(text, style, x, top_y, w, h, padding=3 * mm):
para = Paragraph(text, style)
aw = max(10, w - 2 * padding)
ah = max(10, h - 2 * padding)
_, ph = para.wrap(aw, ah)
para.drawOn(c, x + padding, top_y - padding - ph)
return ph
def draw_para_obj(para, x, top_y, w, h, padding=3 * mm):
aw = max(10, w - 2 * padding)
ah = max(10, h - 2 * padding)
_, ph = para.wrap(aw, ah)
para.drawOn(c, x + padding, top_y - padding - ph)
return ph
def make_barcode(value, max_width):
last = None
for bw in [0.46, 0.42, 0.38, 0.34, 0.30, 0.26]:
last = createBarcodeDrawing(
"Code128",
value=str(value),
barHeight=13 * mm,
barWidth=bw * mm,
humanReadable=False,
)
if last.width <= max_width:
return last
return last
def infer_document_type(obj):
candidates = [
getattr(obj, "document_type", None),
getattr(obj, "document_kind", None),
getattr(obj, "order_type", None),
getattr(obj, "prescription_kind", None),
getattr(obj, "subtype", None),
getattr(obj, "document_title", None),
getattr(obj, "title", None),
getattr(obj, "document_body", None),
getattr(obj, "body_text", None),
getattr(obj, "content", None),
]
joined = " ".join([str(x).strip().lower() for x in candidates if x])
if any(k in joined for k in ["resultado", "resultados", "result "]):
return "result"
if any(k in joined for k in ["informe", "constancia", "apto"]):
return "report"
if any(k in joined for k in ["práctica", "practica", "laboratorio", "imagen", "interconsulta", "derivación", "derivacion"]):
return "practice"
if any(k in joined for k in ["receta", "rp./", "rp /", "medic", "farmac"]):
return "recipe"
if any(getattr(obj, a, None) for a in ["medication_generic_name", "dosage_instructions", "pharmaceutical_form"]):
return "recipe"
return "recipe"
# =========================================================
# SETTINGS
# =========================================================
site = {}
try:
site = get_site_settings() or {}
except Exception:
site = {}
site_title = safe(site.get("title") or site.get("site_title") or "Institución de Salud", "Institución de Salud")
site_subtitle = safe(site.get("subtitle") or site.get("site_subtitle") or "Documento clínico digital", "")
site_url = safe(site.get("url") or site.get("site_url") or current_app.config.get("BASE_URL") or "", "")
site_email = safe(site.get("email") or site.get("site_email") or "", "")
site_phone = safe(site.get("phone") or site.get("site_phone") or "", "")
site_address = safe(site.get("contact_address") or site.get("site_contact_address") or "", "")
site_city = safe(site.get("city") or site.get("site_city") or "", "")
site_province = safe(site.get("province") or site.get("site_province") or "", "")
logo_path = site.get("logo_path")
logo_full = None
if logo_path:
try:
candidate = os.path.join(current_app.root_path, "static", logo_path)
if os.path.exists(candidate):
logo_full = candidate
except Exception:
logo_full = None
institution = getattr(recipe, "institution", None) or (getattr(recipe, "patient", None).institution if getattr(recipe, "patient", None) else None)
institution_logo_full = None
if institution and getattr(institution, "logo_path", None):
try:
candidate = os.path.join(current_app.root_path, "static", institution.logo_path)
if os.path.exists(candidate):
institution_logo_full = candidate
except Exception:
institution_logo_full = None
institution_title = safe(getattr(institution, "name", "") if institution else "", "")
# =========================================================
# TIPO DOCUMENTAL
# =========================================================
doc_type = infer_document_type(recipe)
if doc_type == "recipe":
doc_label = "Receta"
body_title = "RP./"
number_label = "Nro. de Receta"
is_recipe = True
elif doc_type == "practice":
doc_label = "Práctica"
body_title = "Práctica /"
number_label = "Nro. de Práctica"
is_recipe = False
elif doc_type == "report":
doc_label = "Informe"
body_title = "Informe /"
number_label = "Nro. de Informe"
is_recipe = False
elif doc_type == "result":
doc_label = "Resultado"
body_title = "Resultado /"
number_label = "Nro. de Resultado"
is_recipe = False
else:
doc_label = "Documento clínico"
body_title = "Documento /"
number_label = "Nro. de Documento"
is_recipe = False
# =========================================================
# DATOS DOCUMENTO
# =========================================================
issue_date = date_str(getattr(recipe, "prescription_date", None))
issue_datetime = datetime_str(
getattr(recipe, "issued_at", None)
or getattr(recipe, "prescription_date", None)
or getattr(recipe, "created_at", None)
)
doc_number = safe(
getattr(recipe, "legal_number", None)
or getattr(recipe, "document_number", None)
or getattr(recipe, "cuir", None),
"S/N",
)
obra_social = safe(getattr(recipe, "patient_obra_social", None), "Particular")
afiliado = safe(getattr(recipe, "patient_plan", None) or getattr(recipe, "patient_affiliate_number", None))
patient_name = safe(getattr(recipe, "patient_full_name", None))
patient_dni = safe(getattr(recipe, "patient_document", None))
patient_gender = safe(getattr(recipe, "patient_gender", None))
patient_birth = date_str(getattr(recipe, "patient_birth_date", None))
diagnosis = safe(getattr(recipe, "diagnosis", None))
professional_name = safe(getattr(recipe, "professional_display_name", None))
professional_matricula = safe(getattr(recipe, "professional_matricula", None))
professional_jurisdiction = safe(getattr(recipe, "professional_jurisdiction_name", None))
professional_profession = safe(getattr(recipe, "professional_profession_name", None))
professional_specialty = safe(getattr(recipe, "professional_specialty", None))
professional_address = safe(getattr(recipe, "professional_address", None))
cuir = safe(getattr(recipe, "cuir", None), "S/D")
explicit_body = (
getattr(recipe, "document_body", None)
or getattr(recipe, "body_text", None)
or getattr(recipe, "content", None)
)
if explicit_body:
body_html = multiline_to_html(explicit_body)
elif is_recipe:
lines = []
generic_name = getattr(recipe, "medication_generic_name", None)
presentation = getattr(recipe, "medication_presentation", None)
pharmaceutical_form = getattr(recipe, "pharmaceutical_form", None)
quantity_units = getattr(recipe, "quantity_units", None)
dosage = getattr(recipe, "dosage_instructions", None)
if generic_name:
lines.append(f"{safe(generic_name)}")
if presentation or pharmaceutical_form:
lines.append(
f"{safe(presentation)}"
+ (f" · {safe(pharmaceutical_form)}" if pharmaceutical_form else "")
)
if quantity_units:
lines.append(f"Cantidad: {safe(quantity_units)}")
if dosage:
lines.append(safe(dosage))
body_html = "<br/>".join(escape(x) for x in lines) if lines else ""
else:
body_html = ""
# =========================================================
# QR FIRMA / CUIR
# =========================================================
qr_obj = None
try:
qr_value = cuir if is_recipe else doc_number
qr = qrcode.QRCode(box_size=8, border=1)
qr.add_data(qr_value)
qr.make(fit=True)
qr_io = io.BytesIO()
qr.make_image(fill_color="black", back_color="white").save(qr_io, format="PNG")
qr_io.seek(0)
qr_obj = ImageReader(qr_io)
except Exception:
qr_obj = None
# =========================================================
# QR VERIFICACIÓN WEB (SOLO RECETA)
# =========================================================
verify_qr_obj = None
if is_recipe:
try:
base = (site_url or current_app.config.get("BASE_URL") or "http://127.0.0.1:5000").strip().rstrip("/")
verify_url = f"{base}/recetas/verificar?q={quote(str(doc_number))}"
vqr = qrcode.QRCode(box_size=8, border=1)
vqr.add_data(verify_url)
vqr.make(fit=True)
vqr_io = io.BytesIO()
vqr.make_image(fill_color="black", back_color="white").save(vqr_io, format="PNG")
vqr_io.seek(0)
verify_qr_obj = ImageReader(vqr_io)
except Exception:
verify_qr_obj = None
# =========================================================
# ALTURAS
# =========================================================
h1 = 32 * mm
h2 = 40 * mm
h3 = 15 * mm
h4 = 19 * mm
h7 = 35 * mm
h8 = 16 * mm
h9 = 13 * mm
diag_para = Paragraph(f"<b>Diagnóstico:</b> {esc(diagnosis)}", st_label)
_, diag_h = diag_para.wrap(content_w - 6 * mm, 100 * mm)
h5 = max(15 * mm, diag_h + 7 * mm)
verify_block_h = 36 * mm if is_recipe else 0
# =========================================================
# PAGE 1 TOP
# =========================================================
def draw_page1_top():
y = page_h - top_margin
# FILA 1
draw_rect(margin_x, y, content_w, h1, fill_color=soft_fill)
c.setStrokeColor(border)
c.setLineWidth(1)
c.line(margin_x, y, margin_x + content_w, y)
if logo_full:
try:
img_w = 34 * mm
img_h = 18 * mm
c.drawImage(
logo_full,
margin_x + 4 * mm,
y - 8 * mm - img_h,
img_w,
img_h,
preserveAspectRatio=True,
mask="auto",
)
except Exception:
pass
if institution_logo_full:
try:
img_w = 34 * mm
img_h = 18 * mm
c.drawImage(
institution_logo_full,
margin_x + content_w - img_w - 4 * mm,
y - 8 * mm - img_h,
img_w,
img_h,
preserveAspectRatio=True,
mask="auto",
)
except Exception:
pass
draw_paragraph(esc(site_title + (" · " + institution_title if institution_title else "")), st_title, margin_x + 42 * mm, y - 1 * mm, content_w - 84 * mm, h1)
if site_subtitle:
draw_paragraph(esc(site_subtitle), st_subtitle, margin_x, y - 12 * mm, content_w, 10 * mm)
y -= h1
# FILA 2
draw_rect(margin_x, y, content_w, h2)
left_w = 50 * mm
right_w = content_w - left_w
draw_vline(margin_x + left_w, y, h2)
# Bloque fecha
draw_rect(margin_x + 0.8 * mm, y - 0.8 * mm, left_w - 1.6 * mm, 7 * mm, fill_color=section_fill, stroke=0)
draw_paragraph("<b>Fecha</b><br/><br/>" + esc(issue_date), st_label_center, margin_x, y, left_w, h2)
# Bloque número
rx = margin_x + left_w
draw_rect(rx + 0.8 * mm, y - 0.8 * mm, right_w - 1.6 * mm, 7 * mm, fill_color=section_fill, stroke=0)
draw_paragraph(f"<b>{esc(number_label)}</b>", st_label_center, rx, y, right_w, 8 * mm)
barcode_max_w = right_w - 18 * mm
bc = make_barcode(str(doc_number), barcode_max_w)
if bc:
bx = rx + (right_w - bc.width) / 2
by = (y - 9 * mm) - bc.height
renderPDF.draw(bc, c, bx, by)
draw_paragraph(esc(doc_number), st_small_center, rx, y - 29 * mm, right_w, 8 * mm)
y -= h2
# FILA 3
draw_rect(margin_x, y, content_w, h3)
draw_rect(margin_x + 0.8 * mm, y - 0.8 * mm, content_w - 1.6 * mm, 6.5 * mm, fill_color=section_fill, stroke=0)
draw_paragraph(
f"<b>OS:</b> {esc(obra_social)}<br/><b>Nro. Afiliado:</b> {esc(afiliado)}",
st_label,
margin_x,
y,
content_w,
h3,
)
y -= h3
# FILA 4
draw_rect(margin_x, y, content_w, h4)
c1 = 96 * mm
c2 = 42 * mm
c3 = content_w - c1 - c2
draw_vline(margin_x + c1, y, h4)
draw_vline(margin_x + c1 + c2, y, h4)
draw_rect(margin_x + 0.8 * mm, y - 0.8 * mm, c1 - 1.6 * mm, 6.5 * mm, fill_color=section_fill, stroke=0)
draw_rect(margin_x + c1 + 0.8 * mm, y - 0.8 * mm, c2 - 1.6 * mm, 6.5 * mm, fill_color=section_fill, stroke=0)
draw_rect(margin_x + c1 + c2 + 0.8 * mm, y - 0.8 * mm, c3 - 1.6 * mm, 6.5 * mm, fill_color=section_fill, stroke=0)
draw_paragraph(
f"<b>Apellido y Nombre:</b> {esc(patient_name)}<br/><b>Fecha Nac.:</b> {esc(patient_birth)}",
st_label,
margin_x,
y,
c1,
h4,
)
draw_paragraph(f"<b>DNI:</b> {esc(patient_dni)}", st_label, margin_x + c1, y, c2, h4)
draw_paragraph(f"<b>Sexo:</b> {esc(patient_gender)}", st_label, margin_x + c1 + c2, y, c3, h4)
y -= h4
# FILA 5
draw_rect(margin_x, y, content_w, h5)
draw_rect(margin_x + 0.8 * mm, y - 0.8 * mm, content_w - 1.6 * mm, 6.5 * mm, fill_color=section_fill, stroke=0)
draw_paragraph(f"<b>Diagnóstico:</b> {esc(diagnosis)}", st_label, margin_x, y, content_w, h5)
y -= h5
return y
# =========================================================
# CONTINUACIÓN
# =========================================================
def draw_continuation_top(page_index):
y = page_h - top_margin
h = 18 * mm
draw_rect(margin_x, y, content_w, h, fill_color=soft_fill)
draw_paragraph(esc(f"{doc_label} - Continuación"), st_title, margin_x, y - 1 * mm, content_w, 8 * mm)
draw_paragraph(esc(f"{number_label}: {doc_number}"), st_subtitle, margin_x, y - 9 * mm, content_w, 7 * mm)
return y - h
# =========================================================
# BLOQUE INFERIOR
# =========================================================
def draw_bottom_block(y_top):
# FILA 7
draw_rect(margin_x, y_top, content_w, h7)
left_w = 132 * mm
right_w = content_w - left_w
draw_vline(margin_x + left_w, y_top, h7)
draw_rect(margin_x + 0.8 * mm, y_top - 0.8 * mm, left_w - 1.6 * mm, 6.5 * mm, fill_color=section_fill, stroke=0)
draw_rect(margin_x + left_w + 0.8 * mm, y_top - 0.8 * mm, right_w - 1.6 * mm, 6.5 * mm, fill_color=section_fill, stroke=0)
sign_html = (
f"<b>Firmado electrónicamente por:</b><br/>"
f"Dr/a: {esc(professional_name)}<br/>"
f"Matrícula: {esc(professional_matricula)}"
+ (f" ({esc(professional_jurisdiction)})" if professional_jurisdiction != "" else "")
+ "<br/>"
f"Profesión: {esc(professional_profession)}<br/>"
f"Especialidad: {esc(professional_specialty)}<br/>"
f"Dirección: {esc(professional_address)}<br/>"
f"Emitida: {esc(issue_datetime)}"
)
draw_paragraph(sign_html, st_small, margin_x, y_top, left_w, h7)
qr_title = "QR CUIR" if is_recipe else "QR Documento"
draw_paragraph(f"<b>{esc(qr_title)}</b>", st_small_center, margin_x + left_w, y_top, right_w, 7 * mm)
if qr_obj:
qr_size = 26 * mm
qx = margin_x + left_w + (right_w - qr_size) / 2
qy = y_top - 8 * mm - qr_size
c.drawImage(qr_obj, qx, qy, qr_size, qr_size, preserveAspectRatio=True, mask="auto")
y_top -= h7
# FILA 8
draw_rect(margin_x, y_top, content_w, h8, fill_color=soft_fill)
if is_recipe:
legal_text = (
"Esta receta electrónica cumple con la normativa vigente aplicable a la "
"prescripción electrónica, conforme la Ley N° 27.553 y su reglamentación vigente."
)
else:
legal_text = (
f"Este {doc_label.lower()} electrónico cumple con la normativa vigente aplicable "
"a la documentación clínica digital."
)
legal_text += " Documento emitido por profesional autenticado en la plataforma institucional."
draw_paragraph(legal_text, st_small_center, margin_x, y_top, content_w, h8)
y_top -= h8
# FILA 9
draw_rect(margin_x, y_top, content_w, h9)
footer_top = " · ".join([x for x in [site_url, site_email, site_phone] if x])
footer_bottom = " · ".join([x for x in [site_address, site_city, site_province] if x])
if footer_top:
draw_paragraph(esc(footer_top), st_footer, margin_x, y_top - 1 * mm, content_w, 5.6 * mm, padding=1 * mm)
if footer_bottom:
draw_paragraph(esc(footer_bottom), st_footer, margin_x, y_top - 6.2 * mm, content_w, 5.6 * mm, padding=1 * mm)
# =========================================================
# BODY + PAGINACIÓN
# =========================================================
remaining_para = Paragraph(body_html, st_body)
first_page = True
page_index = 1
while True:
if first_page:
y_cursor = draw_page1_top()
else:
c.showPage()
y_cursor = draw_continuation_top(page_index)
reserved_bottom = h7 + h8 + h9
body_h = y_cursor - (bottom_margin + reserved_bottom)
draw_rect(margin_x, y_cursor, content_w, body_h)
inner_x = margin_x
inner_w = content_w
# encabezado del body
draw_rect(inner_x + 0.8 * mm, y_cursor - 0.8 * mm, inner_w - 1.6 * mm, 7 * mm, fill_color=header_fill, stroke=0)
draw_paragraph(esc(body_title), st_body_title, inner_x, y_cursor, inner_w, 8 * mm)
offset_top = 9 * mm
text_box_top = y_cursor - offset_top
text_box_h = body_h - offset_top - 2 * mm - verify_block_h
avail_w = inner_w - 7 * mm
avail_h = text_box_h - 4 * mm
_, ph = remaining_para.wrap(avail_w, avail_h)
if ph <= avail_h:
used_body_h = draw_para_obj(remaining_para, inner_x, text_box_top, inner_w, text_box_h)
if is_recipe:
note_h = 6 * mm
qr_size = 24 * mm
gap_after_body = 3 * mm
gap_note_qr = 2 * mm
verify_top = text_box_top - used_body_h - gap_after_body
# caja suave para verificación
box_h = 32 * mm
box_y = verify_top - 1 * mm
draw_rect(inner_x + 7 * mm, box_y, inner_w - 14 * mm, box_h, fill_color=soft_fill)
draw_paragraph(
"Esta receta electrónica puede ser verificada mediante el siguiente QR.",
st_verify_note,
inner_x + 7 * mm,
verify_top,
inner_w - 14 * mm,
note_h,
padding=1 * mm,
)
if verify_qr_obj:
qx = inner_x + (inner_w - qr_size) / 2
qy = verify_top - note_h - qr_size - gap_note_qr
c.drawImage(
verify_qr_obj,
qx,
qy,
qr_size,
qr_size,
preserveAspectRatio=True,
mask="auto",
)
draw_bottom_block(bottom_margin + reserved_bottom)
break
parts = remaining_para.split(avail_w, avail_h)
if len(parts) >= 2:
first_part = parts[0]
remaining_para = parts[1]
draw_para_obj(first_part, inner_x, text_box_top, inner_w, text_box_h)
draw_paragraph(
"Continúa en la página siguiente.",
st_small_center,
margin_x,
bottom_margin + reserved_bottom - 8 * mm,
content_w,
6 * mm,
padding=1 * mm,
)
else:
draw_para_obj(remaining_para, inner_x, text_box_top, inner_w, text_box_h)
draw_bottom_block(bottom_margin + reserved_bottom)
break
first_page = False
page_index += 1
c.save()
return buffer.getvalue()
def apply_site_settings_from_form():
def set_if_present(key, form_name=None, default=None):
form_name = form_name or key
if form_name in request.form:
value = request.form.get(form_name, '').strip()
if default is not None and not value:
value = default
set_setting(key, value)
simple_keys = [
'site_title','site_phone','site_url','site_email','site_country','site_platform_number',
'site_repository_number','site_tagline','site_contact_address','site_contact_map_embed',
'site_seo_title','site_meta_description','site_meta_keywords','site_canonical_url','site_robots_meta',
'site_lang','site_locale','site_og_title','site_og_description','site_og_image_alt','site_twitter_card',
'site_twitter_site','site_twitter_title','site_twitter_description','site_schema_type','site_legal_name',
'site_price_range','site_google_site_verification','site_bing_site_verification',
'site_copyright','site_nav_cta_label','site_body_font','site_heading_font','site_popup_title',
'site_popup_html','site_popup_link_text','site_popup_link_url','site_cookie_text',
'site_terms_html','site_privacy_html',
'site_topbar_bg','site_topbar_text','site_header_bg','site_header_text','site_nav_text','site_nav_hover',
'site_hero_bg','site_hero_surface','site_section_bg','site_section_muted_bg','site_card_bg','site_card_text',
'site_title_color','site_footer_bg','site_footer_text','site_button_text','site_border_radius'
]
for key in simple_keys:
set_if_present(key)
set_if_present('site_primary_color', default='#1977cc')
set_if_present('site_secondary_color', default='#0d6efd')
set_if_present('site_accent_color', default='#0dcaf0')
set_if_present('site_base_font_size', default='16')
if 'province_name' in request.form or 'site_province' in request.form:
set_setting('site_province', request.form.get('province_name', '').strip() or request.form.get('site_province', '').strip())
if 'municipality_name' in request.form or 'site_municipality' in request.form:
set_setting('site_municipality', request.form.get('municipality_name', '').strip() or request.form.get('site_municipality', '').strip())
if 'city_name' in request.form or 'site_city' in request.form:
set_setting('site_city', request.form.get('city_name', '').strip() or request.form.get('site_city', '').strip())
admin_style_keys = [
'admin_sidebar_bg','admin_sidebar_text','admin_sidebar_active_bg','admin_sidebar_active_text',
'admin_body_bg','admin_surface_bg','admin_text_color','admin_muted_text_color','admin_primary_color',
'admin_border_radius','admin_font_family','admin_font_size','admin_title_color','admin_footer_bg',
'admin_modal_bg','admin_section_bg','admin_input_bg','admin_input_border'
]
for key in admin_style_keys:
set_if_present(key)
if request.form.get('return_tab') == 'popup' or 'site_popup_enabled' in request.form:
set_setting('site_popup_enabled', '1' if request.form.get('site_popup_enabled') else '0')
if request.form.get('return_tab') == 'legal' or 'site_cookie_banner_enabled' in request.form:
set_setting('site_cookie_banner_enabled', '1' if request.form.get('site_cookie_banner_enabled') else '0')
if request.form.get('return_tab') == 'seo':
set_setting('site_seo_indexing_enabled', '1' if request.form.get('site_seo_indexing_enabled') else '0')
if request.form.get('return_tab') == 'general':
for network in ['facebook', 'instagram', 'x', 'youtube', 'linkedin']:
set_setting(f'site_social_{network}_enabled', '1' if request.form.get(f'site_social_{network}_enabled') else '0')
set_setting(f'site_social_{network}_url', request.form.get(f'site_social_{network}_url', '').strip())
logo_file = request.files.get('site_logo')
favicon_file = request.files.get('site_favicon')
popup_image_file = request.files.get('site_popup_image')
if logo_file and logo_file.filename:
rel = save_uploaded_asset(logo_file, current_app.config['UPLOAD_FOLDER'], 'logo')
set_setting('site_logo_path', rel)
if favicon_file and favicon_file.filename:
rel = save_uploaded_asset(favicon_file, current_app.config['UPLOAD_FOLDER'], 'favicon')
set_setting('site_favicon_path', rel)
if popup_image_file and popup_image_file.filename:
rel = save_uploaded_asset(popup_image_file, current_app.config['UPLOAD_FOLDER'], 'popup')
set_setting('site_popup_image_path', rel)
def apply_patient_to_appointment(appointment: Appointment, patient: Patient | None):
appointment.patient_id = patient.id if patient else None
if patient:
appointment.client_name = patient.nombre_completo
appointment.client_email = (patient.email or '').strip().lower()
appointment.client_phone = (patient.telefono or '').strip()
else:
appointment.client_name = request.form.get('client_name', '').strip()
appointment.client_email = request.form.get('client_email', '').strip().lower()
appointment.client_phone = request.form.get('client_phone', '').strip()
def patient_lookup_items(limit=1000):
return Patient.query.filter(Patient.estado != 'Eliminado').order_by(Patient.apellido.asc(), Patient.nombre.asc()).limit(limit).all()
def _absolute_public_url(path_or_url: str = '') -> str:
raw = (path_or_url or '').strip()
if raw.startswith(('http://', 'https://')):
return raw
if raw.startswith('/'):
return request.url_root.rstrip('/') + raw
if raw:
return url_for('static', filename=raw, _external=True)
return request.url_root.rstrip('/')
def _plain_text(value: str, limit: int = 500) -> str:
text = re.sub(r'<[^>]+>', ' ', value or '')
text = re.sub(r'\s+', ' ', text).strip()
return text[:limit]
def build_public_seo_context(services=None, professionals=None):
site = get_site_settings()
services = services or []
professionals = professionals or []
base_url = (site.get('url') or request.url_root or '').strip().rstrip('/') or request.url_root.rstrip('/')
canonical = (site.get('canonical_url') or '').strip() or f"{base_url}{url_for('index')}"
seo_title = (site.get('seo_title') or '').strip() or f"{site.get('title') or current_app.config.get('APP_NAME', 'Centro médico')} | Turnos médicos online"
meta_description = _plain_text(site.get('meta_description') or site.get('tagline') or 'Reserva online de turnos médicos, atención profesional y gestión clínica digital.', 300)
og_title = site.get('og_title') or seo_title
og_description = _plain_text(site.get('og_description') or meta_description, 300)
twitter_title = site.get('twitter_title') or og_title
twitter_description = _plain_text(site.get('twitter_description') or og_description, 300)
og_image_path = site.get('og_image_path') or site.get('logo_path') or 'medilab/assets/img/hero-bg.jpg'
og_image_url = _absolute_public_url(og_image_path)
logo_url = _absolute_public_url(site.get('logo_path') or 'medilab/assets/img/logo.png')
address = {
'@type': 'PostalAddress',
'addressCountry': site.get('country') or 'Argentina',
'addressRegion': site.get('province') or '',
'addressLocality': site.get('city') or site.get('municipality') or '',
'streetAddress': _plain_text(site.get('contact_address'), 180),
}
same_as = [s.get('url') for s in site.get('socials', []) if s.get('url')]
specialties = sorted({(getattr(p, 'specialty', '') or '').strip() for p in professionals if (getattr(p, 'specialty', '') or '').strip()})
offers = []
for service in services[:12]:
offer = {
'@type': 'Offer',
'itemOffered': {
'@type': 'MedicalProcedure' if (site.get('schema_type') or '') == 'MedicalClinic' else 'Service',
'name': getattr(service, 'name', '') or 'Servicio médico',
'description': _plain_text(getattr(service, 'description', ''), 220),
},
'availability': 'https://schema.org/InStock',
}
if getattr(service, 'price', None):
offer['price'] = str(getattr(service, 'price'))
offer['priceCurrency'] = 'ARS'
offers.append(offer)
schema = {
'@context': 'https://schema.org',
'@type': site.get('schema_type') or 'MedicalClinic',
'name': site.get('title') or current_app.config.get('APP_NAME', 'Centro médico'),
'legalName': site.get('legal_name') or site.get('title') or '',
'url': canonical,
'description': meta_description,
'image': og_image_url,
'logo': logo_url,
'telephone': site.get('phone') or '',
'email': site.get('email') or '',
'address': address,
'areaServed': site.get('city') or site.get('province') or site.get('country') or 'Argentina',
'priceRange': site.get('price_range') or '$$',
'sameAs': same_as,
}
if specialties:
schema['medicalSpecialty'] = specialties[:12]
if offers:
schema['makesOffer'] = offers
seo = {
'title': seo_title[:70],
'description': meta_description[:160],
'keywords': site.get('meta_keywords') or '',
'canonical_url': canonical,
'robots': site.get('robots_meta') or 'index,follow',
'og_title': og_title,
'og_description': og_description,
'og_image_url': og_image_url,
'og_image_alt': site.get('og_image_alt') or 'Imagen institucional del sitio',
'twitter_card': site.get('twitter_card') or 'summary_large_image',
'twitter_site': site.get('twitter_site') or '',
'twitter_title': twitter_title,
'twitter_description': twitter_description,
'lang': site.get('lang') or 'es-AR',
'locale': site.get('locale') or 'es_AR',
'google_site_verification': site.get('google_site_verification') or '',
'bing_site_verification': site.get('bing_site_verification') or '',
}
return seo, json.dumps(schema, ensure_ascii=False, default=str)
@app.route('/robots.txt')
def robots_txt():
site = get_site_settings()
base_url = (site.get('url') or request.url_root).rstrip('/')
if not site.get('seo_indexing_enabled', True):
body = 'User-agent: *\nDisallow: /\n'
else:
body = '\n'.join([
'User-agent: *',
'Allow: /',
'Disallow: /admin/',
'Disallow: /dashboard',
'Disallow: /login',
'Disallow: /logout',
f'Sitemap: {base_url}/sitemap.xml',
''
])
return Response(body, mimetype='text/plain; charset=utf-8')
@app.route('/sitemap.xml')
def sitemap_xml():
pages = [
(url_for('index', _external=True), '1.0'),
(url_for('booking', _external=True), '0.8'),
(url_for('public_verify_recipe', _external=True), '0.5'),
(url_for('public_legal_terms', _external=True), '0.3'),
(url_for('public_legal_privacy', _external=True), '0.3'),
]
now = datetime.utcnow().date().isoformat()
items = ''.join([f'<url><loc>{loc}</loc><lastmod>{now}</lastmod><changefreq>weekly</changefreq><priority>{priority}</priority></url>' for loc, priority in pages])
xml = f'<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">{items}</urlset>'
return Response(xml, mimetype='application/xml; charset=utf-8')
@app.route('/')
def index():
institutions = Institution.query.filter_by(active=True).order_by(Institution.name.asc()).all()
institution_id = request.values.get('institution_id', type=int)
booking_scope = request.values.get('booking_scope', 'all')
services_q = Service.query.filter_by(active=True)
professionals_q = ProfessionalProfile.query.filter_by(is_bookable=True)
if booking_scope == 'institution' and institution_id:
services_q = services_q.filter(Service.institution_id == institution_id)
professionals_q = professionals_q.filter(ProfessionalProfile.institution_id == institution_id)
services = services_q.order_by(Service.name.asc()).all()
professionals = professionals_q.order_by(ProfessionalProfile.display_name.asc()).all()
seo, schema_json = build_public_seo_context(services, professionals)
return render_template('index.html', services=services, professionals=professionals, seo=seo, schema_json=schema_json)
@app.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('dashboard'))
if request.method == 'POST':
email = request.form.get('email', '').strip().lower()
password = request.form.get('password', '')
user = User.query.filter(func.lower(User.email) == email).first()
if user and user.check_password(password) and user.is_active_user:
login_user(user)
flash('Sesión iniciada correctamente.', 'success')
return redirect(url_for('dashboard'))
flash('Credenciales inválidas.', 'danger')
return render_template('login.html')
@app.route('/logout')
@login_required
def logout():
logout_user()
flash('Sesión finalizada.', 'info')
return redirect(url_for('index'))
@app.route('/dashboard')
@login_required
def dashboard():
if current_user.role == 'client':
return redirect(url_for('client_portal'))
today = date.today()
prof_id = current_professional_id()
query = professional_scope_filter(Appointment.query, prof_id)
stats = {
'upcoming': query.filter(Appointment.appointment_date >= today, Appointment.status.in_(['pending', 'confirmed'])).count(),
'today': query.filter(Appointment.appointment_date == today, Appointment.status.in_(['pending', 'confirmed'])).count(),
'cancelled': query.filter(Appointment.status == 'cancelled').count(),
'completed': query.filter(Appointment.status == 'completed').count(),
'due_reminders': len(get_due_reminders(current_app.config['REMINDER_WINDOW_HOURS'], prof_id)),
'patients': Patient.query.filter(Patient.estado == 'Activo').count() if current_user.role != 'professional' else 0,
}
upcoming = query.filter(Appointment.appointment_date >= today).order_by(Appointment.appointment_date.asc(), Appointment.start_time.asc()).limit(8).all()
week_dates = build_week_dates(today)
week_items = query.filter(
Appointment.appointment_date >= week_dates[0],
Appointment.appointment_date <= week_dates[-1],
).order_by(Appointment.appointment_date.asc(), Appointment.start_time.asc()).all()
week_map = {day.isoformat(): [] for day in week_dates}
for item in week_items:
week_map[item.appointment_date.isoformat()].append(item)
return render_template('dashboard.html', stats=stats, upcoming=upcoming, week_dates=week_dates, week_map=week_map)
def _booking_norm(value):
return (value or '').strip().lower()
def _branch_matches_location(branch, province='', city=''):
province = _booking_norm(province)
city = _booking_norm(city)
if province and _booking_norm(branch.province) != province:
return False
if city and _booking_norm(branch.city) != city:
return False
return True
def _institution_matches_location(inst, province='', city=''):
province = _booking_norm(province)
city = _booking_norm(city)
if not province and not city:
return True
if province and _booking_norm(inst.provincia) == province and (not city or _booking_norm(inst.ciudad) == city):
return True
return any(_branch_matches_location(b, province, city) for b in getattr(inst, 'branches', []) if b.active)
def _branch_to_dict(branch):
return {
'id': branch.id,
'institution_id': branch.institution_id,
'institution_name': branch.institution.name if branch.institution else '',
'name': branch.display_name,
'address': branch.address or '',
'city': branch.city or '',
'province': branch.province or '',
'phone': branch.phone or '',
'label': ' · '.join([x for x in [branch.display_name, branch.city, branch.province] if x]),
}
def _institution_to_dict(inst):
active_branches = [b for b in inst.branches if b.active]
return {
'id': inst.id,
'name': inst.name,
'city': inst.ciudad or '',
'province': inst.provincia or '',
'phone': inst.telefono or '',
'branches_count': len(active_branches),
'label': ' · '.join([x for x in [inst.name, inst.ciudad, inst.provincia] if x]),
}
def _service_to_dict(service):
return {
'id': service.id,
'name': service.name,
'description': service.description or '',
'duration': service.duration_minutes,
'mode': service.mode or 'Presencial',
'price': ai_format_money(service.price),
'institution_id': service.institution_id,
'institution_name': service.institution.name if service.institution else '',
}
def _professional_to_dict(prof):
return {
'id': prof.id,
'name': prof.display_name,
'specialty': prof.specialty or '',
'institution_id': prof.institution_id,
'institution_name': prof.institution.name if prof.institution else '',
'location': prof.location or prof.full_address or '',
'city': prof.city or (prof.institution.ciudad if prof.institution else ''),
'province': prof.province or (prof.institution.provincia if prof.institution else ''),
}
def _active_branch_for_request(branch_id=None, institution_id=None):
query = InstitutionBranch.query.filter_by(active=True)
if branch_id:
query = query.filter(InstitutionBranch.id == branch_id)
if institution_id:
query = query.filter(InstitutionBranch.institution_id == institution_id)
return query.first()
def _booking_service_query(institution_id=None, branch=None, province='', city=''):
query = Service.query.filter_by(active=True)
if institution_id:
query = query.filter(Service.institution_id == institution_id)
elif branch:
query = query.filter(Service.institution_id == branch.institution_id)
services = query.order_by(Service.name.asc()).all()
if province or city:
services = [s for s in services if (not s.institution) or _institution_matches_location(s.institution, province, city)]
return services
def _booking_professionals(service=None, institution_id=None, branch=None, province='', city=''):
query = ProfessionalProfile.query.filter_by(is_bookable=True)
if institution_id:
query = query.filter(ProfessionalProfile.institution_id == institution_id)
elif branch:
query = query.filter(ProfessionalProfile.institution_id == branch.institution_id)
if service:
query = query.filter(ProfessionalProfile.services.any(Service.id == service.id))
professionals = query.order_by(ProfessionalProfile.display_name.asc()).all()
if province or city:
filtered = []
for p in professionals:
inst_ok = p.institution and _institution_matches_location(p.institution, province, city)
prof_ok = (not province or _booking_norm(p.province) == _booking_norm(province)) and (not city or _booking_norm(p.city) == _booking_norm(city))
if inst_ok or prof_ok:
filtered.append(p)
professionals = filtered
return professionals
def _booking_unique_locations():
provinces = set()
cities_by_province = {}
for inst in Institution.query.filter_by(active=True).all():
if inst.provincia:
provinces.add(inst.provincia)
cities_by_province.setdefault(inst.provincia, set())
if inst.ciudad:
cities_by_province[inst.provincia].add(inst.ciudad)
for b in inst.branches:
if not b.active:
continue
if b.province:
provinces.add(b.province)
cities_by_province.setdefault(b.province, set())
if b.city:
cities_by_province[b.province].add(b.city)
return sorted(provinces), {k: sorted(v) for k, v in cities_by_province.items()}
def _booking_options_payload(args):
mode = args.get('mode') or args.get('institution_mode') or 'unknown'
province = args.get('province', '').strip()
city = args.get('city', '').strip()
institution_id = args.get('institution_id', type=int)
branch_id = args.get('branch_id', type=int)
service_id = args.get('service_id', type=int)
branch = _active_branch_for_request(branch_id=branch_id) if branch_id else None
institutions = [i for i in Institution.query.filter_by(active=True).order_by(Institution.name.asc()).all() if _institution_matches_location(i, province, city)]
if institution_id:
institutions = [i for i in institutions if i.id == institution_id]
branches_q = InstitutionBranch.query.filter_by(active=True)
if institution_id:
branches_q = branches_q.filter(InstitutionBranch.institution_id == institution_id)
branches = [b for b in branches_q.order_by(InstitutionBranch.name.asc()).all() if _branch_matches_location(b, province, city)]
services = _booking_service_query(institution_id=institution_id if mode == 'known' else None, branch=branch, province=province, city=city)
service = Service.query.filter_by(id=service_id, active=True).first() if service_id else None
professionals = _booking_professionals(service=service, institution_id=institution_id if mode == 'known' else None, branch=branch, province=province, city=city)
provinces, cities_by_province = _booking_unique_locations()
return {
'ok': True,
'mode': mode,
'provinces': provinces,
'cities_by_province': cities_by_province,
'institutions': [_institution_to_dict(i) for i in institutions],
'branches': [_branch_to_dict(b) for b in branches],
'services': [_service_to_dict(s) for s in services],
'professionals': [_professional_to_dict(p) for p in professionals],
}
def _booking_find_slots(service, target_date, *, institution_id=None, branch=None, professional_id=None, province='', city='', booking_type='specific'):
candidates = _booking_professionals(service=service, institution_id=institution_id, branch=branch, province=province, city=city)
if professional_id:
candidates = [p for p in candidates if p.id == professional_id]
items = []
for p in candidates:
slots = get_available_slots(p, service, target_date)[:16]
if not slots:
continue
future_count = Appointment.query.filter(
Appointment.professional_id == p.id,
Appointment.appointment_date >= date.today(),
Appointment.status.in_(['pending', 'confirmed']),
).count()
items.append({
'professional': p,
'professional_id': p.id,
'professional_name': p.display_name,
'specialty': p.specialty or '',
'institution_id': p.institution_id,
'institution_name': p.institution.name if p.institution else '',
'rank': future_count,
'slots': [{'time': slot['label'], 'end': slot['end'].strftime('%H:%M')} for slot in slots],
'_raw_slots': slots,
})
if booking_type == 'round_robin':
items.sort(key=lambda x: (x['rank'], x['slots'][0]['time'], x['professional_name'].lower()))
else:
items.sort(key=lambda x: (x['slots'][0]['time'], x['professional_name'].lower()))
return items
@app.route('/api/booking-wizard/options')
def api_booking_wizard_options():
return jsonify(_booking_options_payload(request.args))
@app.route('/api/booking-wizard/slots')
def api_booking_wizard_slots():
service = Service.query.filter_by(id=request.args.get('service_id', type=int), active=True).first_or_404()
target_date = parse_date(request.args.get('date'), date.today() + timedelta(days=1))
institution_id = request.args.get('institution_id', type=int)
branch_id = request.args.get('branch_id', type=int)
branch = _active_branch_for_request(branch_id=branch_id, institution_id=institution_id) if branch_id else None
items = _booking_find_slots(
service,
target_date,
institution_id=institution_id,
branch=branch,
professional_id=request.args.get('professional_id', type=int),
province=request.args.get('province', '').strip(),
city=request.args.get('city', '').strip(),
booking_type=request.args.get('booking_type', 'specific'),
)
public_items = [{k: v for k, v in item.items() if not k.startswith('_') and k != 'professional'} for item in items]
return jsonify({'ok': True, 'date': target_date.isoformat(), 'items': public_items})
@app.route('/api/booking-wizard/create', methods=['POST'])
def api_booking_wizard_create():
try:
data = request.get_json(silent=True) or request.form.to_dict()
service = Service.query.filter_by(id=int(data.get('service_id') or 0), active=True).first_or_404()
selected_date = parse_date(data.get('date') or data.get('selected_date'), None)
selected_time = (data.get('time') or data.get('selected_slot') or '').strip()
if not selected_date or not selected_time:
raise ValueError('Seleccioná fecha y horario.')
linked_patient = current_client_patient() if current_user.is_authenticated and current_user.role == 'client' else None
client_name = (data.get('client_name') or '').strip()
client_email = (data.get('client_email') or '').strip().lower()
client_phone = (data.get('client_phone') or '').strip()
if linked_patient:
client_name = linked_patient.nombre_completo
client_email = (linked_patient.email or '').strip().lower()
client_phone = (linked_patient.telefono or '').strip()
if not client_name or not client_email:
raise ValueError('Completá nombre y email.')
institution_id = int(data.get('institution_id') or 0) or None
branch_id = int(data.get('branch_id') or 0) or None
branch = _active_branch_for_request(branch_id=branch_id, institution_id=institution_id) if branch_id else None
professional_id = int(data.get('professional_id') or 0) or None
booking_type = (data.get('booking_type') or 'specific').strip()
items = _booking_find_slots(
service,
selected_date,
institution_id=institution_id,
branch=branch,
professional_id=professional_id,
province=(data.get('province') or '').strip(),
city=(data.get('city') or '').strip(),
booking_type=booking_type,
)
if not items:
raise ValueError('No se encontró disponibilidad para la selección realizada.')
selected_item = items[0]
if professional_id:
selected_item = next((x for x in items if x['professional_id'] == professional_id), items[0])
chosen_slot = next((slot for slot in selected_item['_raw_slots'] if slot['label'] == selected_time), None)
if not chosen_slot:
raise ValueError('Ese horario ya no está disponible. Elegí otro.')
professional = selected_item['professional']
existing_patient = linked_patient or Patient.query.filter(func.lower(Patient.email) == client_email).first()
source = (data.get('source') or 'website').strip()[:50]
notes = (data.get('notes') or '').strip()
if source == 'ai_chatbot':
notes = (notes + '\n\nSolicitado desde Chatbot IA').strip()
appointment = Appointment(
service_id=service.id,
professional_id=professional.id,
patient_id=existing_patient.id if existing_patient else None,
client_name=client_name,
client_email=client_email,
client_phone=client_phone,
notes=notes,
appointment_date=selected_date,
start_time=chosen_slot['start'].time(),
end_time=chosen_slot['end'].time(),
status='confirmed',
booking_source='client_portal' if linked_patient else source,
public_token=generate_public_token(),
institution_id=professional.institution_id or service.institution_id or institution_id,
branch_id=branch.id if branch else None,
)
db.session.add(appointment)
db.session.commit()
log_action('create', 'Appointment', appointment.id, f'Reserva {source} para {client_email}')
success_url = url_for('booking_success', token=appointment.public_token)
return jsonify({'ok': True, 'message': 'Turno reservado correctamente.', 'appointment_id': appointment.id, 'success_url': success_url, 'redirect_url': url_for('client_portal', tab='appointments') if linked_patient else success_url, 'professional': professional.display_name, 'institution': appointment.institution.name if appointment.institution else '', 'branch': appointment.branch.display_name if appointment.branch else '', 'date': selected_date.strftime('%d/%m/%Y'), 'time': selected_time})
except Exception as exc:
db.session.rollback(); store_error('api_booking_wizard_create', exc); return jsonify({'ok': False, 'error': str(exc)}), 400
@app.route('/booking', methods=['GET', 'POST'])
def booking():
"""Solicitud pública de turnos en modo asistente visual.
Fase 11: el mismo motor se usa desde el botón Turno del frontend/template
y desde el Chatbot IA. Conserva la reserva multi-institución, suma sedes,
provincia/ciudad y flujo didáctico con progreso.
"""
linked_patient = current_client_patient() if current_user.is_authenticated and current_user.role == 'client' else None
return render_template(
'booking.html',
linked_patient=linked_patient,
initial_institution_id=request.args.get('institution_id', type=int) or '',
initial_service_id=request.args.get('service_id', type=int) or '',
)
@app.route('/booking/success/<token>')
def booking_success(token):
appointment = Appointment.query.filter_by(public_token=token).first_or_404()
manage_url = url_for('manage_booking', token=appointment.public_token, _external=True)
return render_template('booking_success.html', appointment=appointment, manage_url=manage_url)
@app.route('/client', methods=['GET', 'POST'])
@login_required
@role_required('client')
def client_portal():
patient = current_client_patient()
if not patient:
flash('Tu usuario cliente no está vinculado a un paciente por email. Revisá tus datos en administración.', 'danger')
return redirect(url_for('change_password'))
if request.method == 'POST':
action = request.form.get('action', 'update_profile')
try:
if action == 'update_profile':
old_email = (patient.email or '').strip().lower()
patient.telefono = sanitize_text(request.form.get('telefono', ''), max_length=60)
patient.email = sanitize_text(request.form.get('email', ''), max_length=150).lower()
if not patient.email:
raise ValueError('El email es obligatorio para mantener el acceso del portal de clientes.')
patient.calle = sanitize_text(request.form.get('calle', ''), max_length=150)
patient.numero = sanitize_text(request.form.get('numero', ''), max_length=30)
patient.piso = sanitize_text(request.form.get('piso', ''), max_length=30)
patient.provincia = sanitize_text(request.form.get('province_name', '') or request.form.get('provincia', ''), max_length=120)
patient.municipio = sanitize_text(request.form.get('municipality_name', '') or request.form.get('municipio', ''), max_length=120)
patient.localidad = sanitize_text(request.form.get('city_name', '') or request.form.get('localidad', ''), max_length=120)
patient.cp = sanitize_text(request.form.get('cp', ''), max_length=20)
patient.observaciones = sanitize_multiline_text(request.form.get('observaciones', ''), max_length=2000)
patient.nombre_contacto = sanitize_text(request.form.get('nombre_contacto', ''), max_length=150)
patient.telefono_contacto = sanitize_text(request.form.get('telefono_contacto', ''), max_length=60)
portal_user, portal_msg = sync_patient_portal_user(patient, old_email=old_email)
db.session.commit()
log_action('update', 'Patient', patient.id, 'Actualización por cliente')
flash('Datos personales actualizados.', 'success')
if portal_msg:
flash(portal_msg, 'info')
return redirect(url_for('client_portal', tab=request.form.get('return_tab', 'profile')))
except Exception as exc:
db.session.rollback()
store_error('client_portal_save', exc, extra=str(patient.id))
flash(f'No se pudieron guardar tus datos: {exc}', 'danger')
tab = request.args.get('tab', 'appointments')
appointments = Appointment.query.filter(Appointment.patient_id == patient.id).order_by(Appointment.appointment_date.desc(), Appointment.start_time.desc()).all()
clinical_entries = ClinicalEntry.query.filter(ClinicalEntry.patient_id == patient.id, ClinicalEntry.visibility_scope == 'Paciente', ClinicalEntry.entry_status == 'Firmado').order_by(ClinicalEntry.entry_datetime.desc()).all()
for item in clinical_entries:
item.patient_visible_attachments = [att for att in item.attachments if att.is_patient_visible]
prescriptions = Prescription.query.filter(Prescription.patient_id == patient.id, Prescription.document_kind == 'recipe').order_by(Prescription.issued_at.desc()).all()
services = Service.query.filter_by(active=True).order_by(Service.name.asc()).all()
professionals = ProfessionalProfile.query.filter_by(is_bookable=True).order_by(ProfessionalProfile.display_name.asc()).all()
return render_template('client_portal.html', patient=patient, appointments=appointments, clinical_entries=clinical_entries, prescriptions=prescriptions, services=services, professionals=professionals, active_tab=tab)
@app.route('/client/appointments/<int:appointment_id>/cancel', methods=['POST'])
@login_required
@role_required('client')
def client_cancel_appointment(appointment_id):
patient = current_client_patient()
appointment = Appointment.query.get_or_404(appointment_id)
if not patient or appointment.patient_id != patient.id:
abort(403)
if not can_client_cancel_appointment(appointment):
flash('Ese turno ya no puede cancelarse desde el portal.', 'warning')
return redirect(url_for('client_portal', tab='appointments'))
appointment.status = 'cancelled'
appointment.internal_notes = ((appointment.internal_notes or '').strip() + '\nCancelado por cliente desde portal.').strip()
db.session.commit()
log_action('cancel', 'Appointment', appointment.id, 'Cancelación por cliente')
flash('Turno cancelado correctamente.', 'success')
return redirect(url_for('client_portal', tab='appointments'))
@app.route('/client/recipes/<int:recipe_id>/pdf')
@login_required
@role_required('client')
def client_recipe_pdf(recipe_id):
patient = current_client_patient()
recipe = Prescription.query.get_or_404(recipe_id)
if not patient or recipe.patient_id != patient.id or normalize_order_kind(recipe.document_kind) != 'recipe':
abort(403)
pdf_bytes = create_prescription_pdf_bytes(recipe)
log_action('recipe_pdf_client', 'Prescription', recipe.id, recipe.legal_number)
kind = normalize_order_kind(recipe.document_kind)
filename_prefix = {'recipe':'receta','practice':'practica','report':'informe','result':'resultado'}.get(kind, 'documento')
return send_file(io.BytesIO(pdf_bytes), mimetype='application/pdf', as_attachment=False, download_name=f'{filename_prefix}_{recipe.legal_number}.pdf')
@app.route('/booking/manage/<token>', methods=['GET', 'POST'])
def manage_booking(token):
appointment = Appointment.query.filter_by(public_token=token).first_or_404()
service = appointment.service
professional = appointment.professional
if request.method == 'POST':
action = request.form.get('action')
if action == 'cancel':
if not service.allow_online_cancel:
flash('Este servicio no permite cancelación online.', 'danger')
else:
cutoff = appointment.starts_at - timedelta(hours=service.cancel_notice_hours or 0)
if datetime.now() > cutoff:
flash('La cancelación online ya no está disponible por política de aviso.', 'danger')
else:
appointment.status = 'cancelled'
db.session.commit()
log_action('cancel', 'Appointment', appointment.id, 'Cancelación pública')
flash('Turno cancelado.', 'success')
return redirect(url_for('manage_booking', token=token))
if action == 'reschedule':
selected_date = parse_date(request.form.get('selected_date'), appointment.appointment_date)
selected_slot = request.form.get('selected_slot')
slots = get_available_slots(professional, service, selected_date)
chosen_slot = next((slot for slot in slots if slot['label'] == selected_slot), None)
if not chosen_slot:
flash('El horario ya no está disponible.', 'danger')
else:
appointment.appointment_date = selected_date
appointment.start_time = chosen_slot['start'].time()
appointment.end_time = chosen_slot['end'].time()
appointment.reminder_sent = False
db.session.commit()
log_action('reschedule', 'Appointment', appointment.id, 'Reprogramación pública')
flash('Turno reprogramado.', 'success')
return redirect(url_for('manage_booking', token=token))
available_dates = [date.today() + timedelta(days=i) for i in range(1, 31)]
selected_date = parse_date(request.args.get('selected_date'), appointment.appointment_date)
slots = get_available_slots(professional, service, selected_date)
return render_template('manage_booking.html', appointment=appointment, available_dates=available_dates, selected_date=selected_date, slots=slots)
@app.route('/my-profile', methods=['GET', 'POST'])
@login_required
def my_profile():
if current_user.role != 'professional' or not current_user.professional_profile:
abort(403)
profile = current_user.professional_profile
if request.method == 'POST':
action = request.form.get('action', 'profile')
if action == 'delete_template':
template = ClinicalEntryTemplate.query.get_or_404(request.form.get('template_id', type=int) or 0)
if template.professional_id != profile.id:
abort(403)
template.is_active = False
db.session.commit()
log_action('delete', 'ClinicalEntryTemplate', template.id, f'my_profile|{template.title}')
flash('Template de evolución eliminado de tu perfil.', 'success')
return redirect(url_for('my_profile'))
if action == 'delete_episode_template':
template = ClinicalEpisodeTemplate.query.get_or_404(request.form.get('template_id', type=int) or 0)
if template.professional_id != profile.id:
abort(403)
template.is_active = False
db.session.commit()
log_action('delete', 'ClinicalEpisodeTemplate', template.id, f'my_profile|{template.title}')
flash('Template de episodio eliminado de tu perfil.', 'success')
return redirect(url_for('my_profile'))
profile.display_name = request.form.get('display_name', profile.display_name)
profile.specialty = request.form.get('specialty', profile.specialty)
profile.location = request.form.get('location', profile.location)
profile.phone = request.form.get('phone', profile.phone)
profile.bio = request.form.get('bio', profile.bio)
profile.contact_email = request.form.get('contact_email', profile.contact_email)
db.session.commit()
log_action('update', 'ProfessionalProfile', profile.id, 'Edición del profesional sobre su perfil')
flash('Perfil actualizado.', 'success')
return redirect(url_for('my_profile'))
entry_templates = ClinicalEntryTemplate.query.filter_by(professional_id=profile.id, is_active=True).order_by(ClinicalEntryTemplate.specialty_name.asc(), ClinicalEntryTemplate.category.asc(), ClinicalEntryTemplate.title.asc()).all()
episode_templates = ClinicalEpisodeTemplate.query.filter_by(professional_id=profile.id, is_active=True).order_by(ClinicalEpisodeTemplate.specialty_name.asc(), ClinicalEpisodeTemplate.category.asc(), ClinicalEpisodeTemplate.title.asc()).all()
return render_template('my_profile.html', profile=profile, templates=entry_templates, entry_templates=entry_templates, episode_templates=episode_templates)
@app.route('/change-password', methods=['GET', 'POST'])
@login_required
def change_password():
if request.method == 'POST':
current_password = request.form.get('current_password', '')
new_password = request.form.get('new_password', '')
if not current_user.check_password(current_password):
flash('La contraseña actual es incorrecta.', 'danger')
elif len(new_password) < 8:
flash('La nueva contraseña debe tener al menos 8 caracteres.', 'danger')
else:
current_user.set_password(new_password)
db.session.commit()
log_action('update', 'User', current_user.id, 'Cambio de contraseña')
flash('Contraseña actualizada.', 'success')
return redirect(url_for('dashboard'))
return render_template('change_password.html')
@app.route('/admin/institutions', methods=['GET', 'POST'])
@login_required
@role_required('admin')
def admin_institutions():
if request.method == 'POST':
institution_id = request.form.get('institution_id', type=int)
item = Institution.query.get(institution_id) if institution_id else Institution()
item.name = request.form.get('name', '').strip()
item.cuit = request.form.get('cuit', '').strip()
item.domicilio = request.form.get('domicilio', '').strip()
item.ciudad = request.form.get('ciudad', '').strip()
item.provincia = request.form.get('provincia', '').strip()
item.telefono = request.form.get('telefono', '').strip()
item.responsable = request.form.get('responsable', '').strip()
item.situacion_juridica = request.form.get('situacion_juridica', '').strip()
item.nro_habilitacion = request.form.get('nro_habilitacion', '').strip()
item.expediente_autorizacion = request.form.get('expediente_autorizacion', '').strip()
item.email = request.form.get('email', '').strip().lower()
item.telefono_responsable = request.form.get('telefono_responsable', '').strip()
item.active = bool(request.form.get('active'))
logo = request.files.get('logo')
if logo and logo.filename:
item.logo_path = save_uploaded_asset(logo, current_app.config['UPLOAD_FOLDER'], prefix='institution')
if not item.name:
flash('El nombre de la institución es obligatorio.', 'danger')
return redirect(url_for('admin_institutions'))
if not institution_id:
db.session.add(item)
db.session.flush()
sec_name = request.form.get('secretary_name', '').strip()
sec_email = request.form.get('secretary_email', '').strip().lower()
sec_password = request.form.get('secretary_password', '').strip() or 'Cambio1234'
if sec_name and sec_email:
existing = User.query.filter(func.lower(User.email) == sec_email).first()
if not existing:
existing = User(full_name=sec_name, email=sec_email, role='receptionist', is_active_user=True, institution_id=item.id)
existing.set_password(sec_password)
db.session.add(existing)
else:
existing.full_name = sec_name
existing.role = 'receptionist'
existing.institution_id = item.id
existing.is_active_user = True
if request.form.get('secretary_password'):
existing.set_password(sec_password)
# Fase 11: sedes por institución. Se cargan desde el mismo modal
# para que el wizard público y el chatbot puedan filtrar por provincia/ciudad/sede.
branch_ids = request.form.getlist('branch_id[]')
branch_names = request.form.getlist('branch_name[]')
branch_addresses = request.form.getlist('branch_address[]')
branch_cities = request.form.getlist('branch_city[]')
branch_provinces = request.form.getlist('branch_province[]')
branch_phones = request.form.getlist('branch_phone[]')
branch_emails = request.form.getlist('branch_email[]')
branch_active_values = set(request.form.getlist('branch_active[]'))
seen_branch_ids = set()
for idx, branch_name in enumerate(branch_names):
branch_name = (branch_name or '').strip()
if not branch_name:
continue
raw_id = branch_ids[idx] if idx < len(branch_ids) else ''
branch = InstitutionBranch.query.filter_by(id=int(raw_id), institution_id=item.id).first() if raw_id and raw_id.isdigit() else InstitutionBranch(institution_id=item.id)
branch.name = branch_name
branch.address = (branch_addresses[idx] if idx < len(branch_addresses) else '').strip()
branch.city = (branch_cities[idx] if idx < len(branch_cities) else '').strip()
branch.province = (branch_provinces[idx] if idx < len(branch_provinces) else '').strip()
branch.phone = (branch_phones[idx] if idx < len(branch_phones) else '').strip()
branch.email = (branch_emails[idx] if idx < len(branch_emails) else '').strip().lower()
branch.active = str(idx) in branch_active_values or (not branch.id and not branch_active_values)
db.session.add(branch)
db.session.flush()
seen_branch_ids.add(branch.id)
if InstitutionBranch.query.filter_by(institution_id=item.id).count() == 0:
db.session.add(InstitutionBranch(institution_id=item.id, name='Sede principal', address=item.domicilio, city=item.ciudad, province=item.provincia, phone=item.telefono, email=item.email, active=True))
db.session.commit()
log_action('upsert', 'Institution', item.id, item.name)
flash('Institución guardada.', 'success')
return redirect(url_for('admin_institutions'))
institutions = Institution.query.order_by(Institution.name.asc()).all()
edit_institution = Institution.query.get(request.args.get('edit', type=int)) if request.args.get('edit') else None
return render_template('admin_institutions.html', institutions=institutions, edit_institution=edit_institution)
@app.route('/admin/institutions/<int:institution_id>/toggle', methods=['POST'])
@login_required
@role_required('admin')
def admin_institution_toggle(institution_id):
item = Institution.query.get_or_404(institution_id)
item.active = not item.active
db.session.commit()
flash('Estado de institución actualizado.', 'success')
return redirect(url_for('admin_institutions'))
@app.route('/admin/users', methods=['GET', 'POST'])
@login_required
@role_required('admin')
def admin_users():
if request.method == 'POST':
user_id = request.form.get('user_id', type=int)
full_name = request.form.get('full_name', '').strip()
email = request.form.get('email', '').strip().lower()
password = request.form.get('password', '').strip()
role = request.form.get('role', 'professional').strip() or 'professional'
try:
user = User.query.get(user_id) if user_id else User()
existing_email = User.query.filter(func.lower(User.email) == email).first()
if existing_email and existing_email.id != (user.id or 0):
raise ValueError('Ese email ya existe.')
user.full_name = full_name
user.email = email
user.role = role
user.institution_id = request.form.get('institution_id', type=int) if role != 'admin' else None
user.is_active_user = True if request.form.get('is_active_user') or not user_id else bool(request.form.get('is_active_user'))
if not user_id:
user.set_password(password or 'Cambio1234')
db.session.add(user)
elif password:
user.set_password(password)
db.session.commit()
log_action('upsert', 'User', user.id, f'Usuario {email}')
flash('Usuario guardado.', 'success')
return redirect(url_for('admin_users'))
except Exception as exc:
db.session.rollback()
store_error('admin_users_save', exc, extra=email)
flash(f'No se pudo guardar el usuario: {exc}', 'danger')
users = User.query.order_by(User.created_at.desc()).all()
institutions = Institution.query.filter_by(active=True).order_by(Institution.name.asc()).all()
edit_user = User.query.get(request.args.get('edit', type=int)) if request.args.get('edit') else None
return render_template('admin_users.html', users=users, edit_user=edit_user, institutions=institutions)
@app.route('/admin/users/<int:user_id>/toggle', methods=['POST'])
@login_required
@role_required('admin')
def admin_user_toggle(user_id):
user = User.query.get_or_404(user_id)
if user.id == current_user.id:
flash('No podés desactivarte a vos mismo.', 'danger')
else:
user.is_active_user = not user.is_active_user
db.session.commit()
log_action('toggle', 'User', user.id, f'Activo={user.is_active_user}')
flash('Estado del usuario actualizado.', 'success')
return redirect(url_for('admin_users'))
@app.route('/admin/services', methods=['GET', 'POST'])
@login_required
@role_required('admin', 'receptionist')
def admin_services():
if request.method == 'POST':
service_id = request.form.get('service_id', type=int)
data = {
'name': request.form.get('name', '').strip(),
'description': request.form.get('description', '').strip(),
'duration_minutes': request.form.get('duration_minutes', type=int) or 30,
'buffer_before_minutes': request.form.get('buffer_before_minutes', type=int) or 0,
'buffer_after_minutes': request.form.get('buffer_after_minutes', type=int) or 0,
'price': request.form.get('price', type=float) or 0,
'mode': request.form.get('mode', 'Presencial'),
'min_notice_hours': request.form.get('min_notice_hours', type=int) or 0,
'cancel_notice_hours': request.form.get('cancel_notice_hours', type=int) or 0,
'allow_online_cancel': bool(request.form.get('allow_online_cancel')),
'active': bool(request.form.get('active')),
}
professional_ids = request.form.getlist('professional_ids')
service = scoped_query(Service).filter_by(id=service_id).first() if service_id else Service()
for key, value in data.items():
setattr(service, key, value)
assign_institution(service)
if not service_id:
db.session.add(service)
prof_q = scoped_query(ProfessionalProfile).filter(ProfessionalProfile.id.in_(professional_ids)) if professional_ids else None
service.professionals = prof_q.all() if prof_q is not None else []
db.session.commit()
log_action('upsert', 'Service', service.id, service.name)
flash('Servicio guardado.', 'success')
return redirect(url_for('admin_services'))
services = scoped_query(Service).order_by(Service.name.asc()).all()
professionals = scoped_query(ProfessionalProfile).order_by(ProfessionalProfile.display_name.asc()).all()
edit_service = Service.query.get(request.args.get('edit', type=int)) if request.args.get('edit') else None
return render_template('admin_services.html', services=services, professionals=professionals, edit_service=edit_service)
@app.route('/admin/services/<int:service_id>/delete', methods=['POST'])
@login_required
@role_required('admin')
def admin_service_delete(service_id):
service = Service.query.get_or_404(service_id)
if service.appointments:
flash('No se puede eliminar un servicio con turnos asociados. Desactivalo.', 'danger')
else:
db.session.delete(service)
db.session.commit()
log_action('delete', 'Service', service_id, service.name)
flash('Servicio eliminado.', 'success')
return redirect(url_for('admin_services'))
@app.route('/admin/specialties', methods=['GET', 'POST'])
@login_required
@role_required('admin')
def admin_specialties():
sisa_enabled = is_sisa_enabled()
if request.method == 'POST':
if sisa_enabled:
flash('No podés administrar especialidades manuales mientras Config. SISA esté activo.', 'warning')
return redirect(url_for('admin_specialties'))
specialty_id = request.form.get('specialty_id', type=int)
name = request.form.get('name', '').strip()
if not name:
flash('Ingresá un nombre.', 'danger')
else:
specialty = Specialty.query.get(specialty_id) if specialty_id else Specialty()
specialty.name = name
specialty.active = bool(request.form.get('active'))
if not specialty_id:
db.session.add(specialty)
db.session.commit()
log_action('upsert', 'Specialty', specialty.id, specialty.name)
flash('Especialidad guardada.', 'success')
return redirect(url_for('admin_specialties'))
specialties = Specialty.query.order_by(Specialty.name.asc()).all()
edit_specialty = Specialty.query.get(request.args.get('edit', type=int)) if request.args.get('edit') else None
return render_template('admin_specialties.html', specialties=specialties, edit_specialty=edit_specialty, sisa_enabled=sisa_enabled)
@app.route('/admin/specialties/<int:specialty_id>/toggle', methods=['POST'])
@login_required
@role_required('admin')
def admin_specialty_toggle(specialty_id):
if is_sisa_enabled():
flash('La herramienta está bloqueada mientras Config. SISA esté activo.', 'warning')
return redirect(url_for('admin_specialties'))
specialty = Specialty.query.get_or_404(specialty_id)
specialty.active = not specialty.active
db.session.commit()
log_action('toggle', 'Specialty', specialty.id, specialty.name)
flash('Estado actualizado.', 'success')
return redirect(url_for('admin_specialties'))
@app.route('/admin/specialties/<int:specialty_id>/delete', methods=['POST'])
@login_required
@role_required('admin')
def admin_specialty_delete(specialty_id):
if is_sisa_enabled():
flash('La herramienta está bloqueada mientras Config. SISA esté activo.', 'warning')
return redirect(url_for('admin_specialties'))
specialty = Specialty.query.get_or_404(specialty_id)
db.session.delete(specialty)
db.session.commit()
log_action('delete', 'Specialty', specialty_id, specialty.name)
flash('Especialidad eliminada.', 'success')
return redirect(url_for('admin_specialties'))
@app.route('/admin/professionals', methods=['GET', 'POST'])
@login_required
@role_required('admin', 'receptionist')
def admin_professionals():
sisa_enabled = is_sisa_enabled()
if request.method == 'POST':
professional_id = request.form.get('professional_id', type=int)
linked_user_id = request.form.get('user_id', type=int)
profile = scoped_query(ProfessionalProfile).filter_by(id=professional_id).first() if professional_id else ProfessionalProfile()
if linked_user_id:
existing_link = ProfessionalProfile.query.filter_by(user_id=linked_user_id).first()
if existing_link and existing_link.id != (profile.id or 0):
flash('Ese usuario profesional ya está vinculado a otro perfil.', 'danger')
return redirect(url_for('admin_professionals'))
profile.user_id = linked_user_id or None
assign_institution(profile)
profile.display_name = request.form.get('display_name', '').strip()
profile.bio = request.form.get('bio', '').strip()
profile.location = request.form.get('location', '').strip()
profile.phone = request.form.get('phone', '').strip()
profile.contact_email = request.form.get('contact_email', '').strip().lower()
profile.color = request.form.get('color', '#0d6efd')
profile.is_bookable = bool(request.form.get('is_bookable'))
profile.matricula = request.form.get('matricula', '').strip()
profile.profession_name = request.form.get('profession_name', '').strip()
profile.jurisdiction_name = resolve_jurisdiction_name(request.form.get('jurisdiction_name', '').strip())
profile.state_name = request.form.get('state_name', '').strip()
profile.province = request.form.get('province_name', '').strip() or request.form.get('province', '').strip()
profile.municipality = request.form.get('municipality_name', '').strip() or request.form.get('municipality', '').strip()
profile.city = request.form.get('city_name', '').strip() or request.form.get('city', '').strip()
profile.address = request.form.get('address', '').strip()
profile.address_number = request.form.get('address_number', '').strip()
if sisa_enabled:
profile.source_mode = 'sisa'
profile.specialty = request.form.get('specialty', '').strip()
profile.specialty_id = None
else:
profile.source_mode = 'manual'
specialty_id = request.form.get('specialty_id', type=int)
specialty = Specialty.query.get(specialty_id) if specialty_id else None
profile.specialty_id = specialty.id if specialty else None
profile.specialty = specialty.name if specialty else request.form.get('specialty', '').strip()
if not professional_id:
db.session.add(profile)
if profile.user_id and profile.user:
profile.user.institution_id = profile.institution_id
db.session.commit()
log_action('upsert', 'ProfessionalProfile', profile.id, profile.display_name)
flash('Profesional guardado.', 'success')
return redirect(url_for('admin_professionals'))
profiles = scoped_query(ProfessionalProfile).order_by(ProfessionalProfile.display_name.asc()).all()
users = scoped_query(User).filter(User.role == 'professional').order_by(User.full_name.asc()).all()
edit_profile = scoped_query(ProfessionalProfile).filter_by(id=request.args.get('edit', type=int)).first() if request.args.get('edit') else None
specialties = Specialty.query.filter_by(active=True).order_by(Specialty.name.asc()).all()
sisa_settings = get_sisa_settings()
return render_template('admin_professionals_v3.html', profiles=profiles, users=users, edit_profile=edit_profile, specialties=specialties, sisa_enabled=sisa_enabled, sisa_settings=sisa_settings, institutions=Institution.query.filter_by(active=True).order_by(Institution.name.asc()).all())
@app.route('/admin/professionals/<int:professional_id>/delete', methods=['POST'])
@login_required
@role_required('admin')
def admin_professional_delete(professional_id):
profile = ProfessionalProfile.query.get_or_404(professional_id)
if profile.appointments:
flash('No se puede eliminar un profesional con turnos. Podés deshabilitarlo.', 'danger')
else:
db.session.delete(profile)
db.session.commit()
log_action('delete', 'ProfessionalProfile', professional_id, profile.display_name)
flash('Profesional eliminado.', 'success')
return redirect(url_for('admin_professionals'))
@app.route('/admin/professionals/<int:professional_id>/schedule', methods=['GET', 'POST'])
@login_required
@role_required('admin', 'receptionist', 'professional')
def admin_professional_schedule(professional_id):
profile = ProfessionalProfile.query.get_or_404(professional_id)
if current_user.role == 'professional' and current_user.professional_profile and current_user.professional_profile.id != profile.id:
abort(403)
if request.method == 'POST':
item = WorkingHour(
professional_id=profile.id,
weekday=request.form.get('weekday', type=int),
start_time=parse_time(request.form.get('start_time')),
end_time=parse_time(request.form.get('end_time')),
is_active=True,
)
db.session.add(item)
db.session.commit()
log_action('create', 'WorkingHour', item.id, profile.display_name)
flash('Bloque horario agregado.', 'success')
return redirect(url_for('admin_professional_schedule', professional_id=profile.id))
blocks = WorkingHour.query.filter_by(professional_id=profile.id).order_by(WorkingHour.weekday.asc(), WorkingHour.start_time.asc()).all()
week_dates = build_week_dates()
preview_slots = {}
services = profile.services[:1]
preview_service = services[0] if services else None
if preview_service:
for week_day in week_dates:
preview_slots[week_day.isoformat()] = get_available_slots(profile, preview_service, week_day)[:6]
return render_template('admin_schedule.html', profile=profile, blocks=blocks, week_dates=week_dates, preview_slots=preview_slots, preview_service=preview_service)
@app.route('/admin/schedule/<int:block_id>/delete', methods=['POST'])
@login_required
@role_required('admin', 'receptionist', 'professional')
def admin_schedule_delete(block_id):
block = WorkingHour.query.get_or_404(block_id)
if current_user.role == 'professional' and current_user.professional_profile and current_user.professional_profile.id != block.professional_id:
abort(403)
professional_id = block.professional_id
db.session.delete(block)
db.session.commit()
log_action('delete', 'WorkingHour', block_id, f'professional={professional_id}')
flash('Bloque eliminado.', 'success')
return redirect(url_for('admin_professional_schedule', professional_id=professional_id))
@app.route('/admin/professionals/<int:professional_id>/leaves', methods=['GET', 'POST'])
@login_required
@role_required('admin', 'receptionist', 'professional')
def admin_professional_leaves(professional_id):
profile = ProfessionalProfile.query.get_or_404(professional_id)
if current_user.role == 'professional' and current_user.professional_profile and current_user.professional_profile.id != profile.id:
abort(403)
if request.method == 'POST':
item = Leave(
professional_id=profile.id,
start_date=parse_date(request.form.get('start_date'), date.today()),
end_date=parse_date(request.form.get('end_date'), date.today()),
reason=request.form.get('reason', '').strip(),
)
db.session.add(item)
db.session.commit()
log_action('create', 'Leave', item.id, profile.display_name)
flash('Licencia o bloqueo agregado.', 'success')
return redirect(url_for('admin_professional_leaves', professional_id=profile.id))
leaves = Leave.query.filter_by(professional_id=profile.id).order_by(Leave.start_date.desc()).all()
return render_template('admin_leaves.html', profile=profile, leaves=leaves)
@app.route('/admin/leaves/<int:leave_id>/delete', methods=['POST'])
@login_required
@role_required('admin', 'receptionist', 'professional')
def admin_leave_delete(leave_id):
item = Leave.query.get_or_404(leave_id)
if current_user.role == 'professional' and current_user.professional_profile and current_user.professional_profile.id != item.professional_id:
abort(403)
professional_id = item.professional_id
db.session.delete(item)
db.session.commit()
log_action('delete', 'Leave', leave_id, f'professional={professional_id}')
flash('Bloqueo eliminado.', 'success')
return redirect(url_for('admin_professional_leaves', professional_id=professional_id))
@app.route('/admin/patients', methods=['GET', 'POST'])
@login_required
@role_required('admin', 'receptionist')
def admin_patients():
if request.method == 'POST':
patient_id = request.form.get('patient_id', type=int)
patient = scoped_query(Patient).filter_by(id=patient_id).first() if patient_id else Patient()
old_email = (patient.email or '').strip().lower() if patient_id else ''
patient.nombre = request.form.get('nombre', '').strip()
patient.apellido = request.form.get('apellido', '').strip()
patient.documento = request.form.get('documento', '').strip()
patient.tipo_documento = request.form.get('tipo_documento', 'DNI').strip()
patient.fecha_nacimiento = request.form.get('fecha_nacimiento', '').strip()
patient.genero = request.form.get('genero', '').strip()
patient.telefono = request.form.get('telefono', '').strip()
patient.email = request.form.get('email', '').strip().lower()
patient.calle = request.form.get('calle', '').strip()
patient.numero = request.form.get('numero', '').strip()
patient.piso = request.form.get('piso', '').strip()
patient.provincia = request.form.get('province_name', '').strip() or request.form.get('provincia', '').strip()
patient.municipio = request.form.get('municipality_name', '').strip() or request.form.get('municipio', '').strip()
patient.localidad = request.form.get('city_name', '').strip() or request.form.get('localidad', '').strip()
patient.cp = request.form.get('cp', '').strip()
patient.obra_social_id = request.form.get('obra_social_id', type=int)
patient.afiliado_nro = request.form.get('afiliado_nro', '').strip()
patient.observaciones = request.form.get('observaciones', '').strip()
patient.nombre_contacto = request.form.get('nombre_contacto', '').strip()
patient.telefono_contacto = request.form.get('telefono_contacto', '').strip()
patient.estado = request.form.get('estado', 'Activo').strip() or 'Activo'
assign_institution(patient)
if not patient_id:
db.session.add(patient)
try:
db.session.flush()
ensure_clinical_record(patient, retention_years=10)
portal_user, portal_msg = sync_patient_portal_user(patient, old_email=old_email)
db.session.commit()
log_action('upsert', 'Patient', patient.id, patient.nombre_completo)
log_action('ensure_chart', 'ClinicalRecord', patient.clinical_record.id if patient.clinical_record else None, patient.documento)
flash('Paciente guardado.', 'success')
if portal_msg:
flash(portal_msg, 'info')
elif patient.email and patient.documento:
flash('No se pudo vincular el usuario cliente automáticamente.', 'warning')
return redirect(url_for('admin_patients'))
except Exception as exc:
db.session.rollback()
store_error('admin_patients_save', exc, extra=patient.documento)
flash(f'No se pudo guardar el paciente: {exc}', 'danger')
query = scoped_query(Patient).outerjoin(ObraSocialCatalog)
q = request.args.get('q', '').strip()
estado = request.args.get('estado', '').strip()
sort = request.args.get('sort', 'apellido')
direction = request.args.get('dir', 'asc')
page = request.args.get('page', type=int, default=1)
if q:
like = f'%{q}%'
query = query.filter(or_(Patient.nombre.ilike(like), Patient.apellido.ilike(like), Patient.documento.ilike(like), Patient.email.ilike(like), ObraSocialCatalog.denominacion.ilike(like)))
if estado:
query = query.filter(Patient.estado == estado)
sort_map = {
'apellido': Patient.apellido,
'documento': Patient.documento,
'estado': Patient.estado,
'fecha': Patient.fecha_creacion,
}
sort_col = sort_map.get(sort, Patient.apellido)
query = query.order_by(sort_col.desc() if direction == 'desc' else sort_col.asc())
pagination = query.paginate(page=page, per_page=12, error_out=False)
patients = pagination.items
edit_patient = scoped_query(Patient).filter_by(id=request.args.get('edit', type=int)).first() if request.args.get('edit') else None
obras_sociales = ObraSocialCatalog.query.filter_by(vigente=True).order_by(ObraSocialCatalog.denominacion.asc()).all()
return render_template('admin_patients.html', patients=patients, pagination=pagination, edit_patient=edit_patient, obras_sociales=obras_sociales, institutions=Institution.query.filter_by(active=True).order_by(Institution.name.asc()).all())
@app.route('/admin/patients/<int:patient_id>/toggle', methods=['POST'])
@login_required
@role_required('admin', 'receptionist')
def admin_patient_toggle(patient_id):
patient = Patient.query.get_or_404(patient_id)
require_same_institution(patient)
patient.estado = 'Suspendido' if patient.estado == 'Activo' else 'Activo'
db.session.commit()
log_action('toggle', 'Patient', patient.id, patient.estado)
flash('Estado del paciente actualizado.', 'success')
return redirect(url_for('admin_patients'))
@app.route('/admin/patients/<int:patient_id>/delete', methods=['POST'])
@login_required
@role_required('admin', 'receptionist')
def admin_patient_delete(patient_id):
patient = Patient.query.get_or_404(patient_id)
require_same_institution(patient)
db.session.delete(patient)
db.session.commit()
log_action('delete', 'Patient', patient_id, patient.nombre_completo)
flash('Paciente eliminado.', 'success')
return redirect(url_for('admin_patients'))
@app.route('/api/patients/<int:patient_id>')
@login_required
@role_required('admin', 'receptionist', 'professional')
def api_patient_detail(patient_id):
patient = Patient.query.get_or_404(patient_id)
return jsonify({
'id': patient.id,
'nombre_completo': patient.nombre_completo,
'documento': patient.documento,
'email': patient.email,
'telefono': patient.telefono,
'obra_social': patient.obra_social.denominacion if patient.obra_social else '',
})
@app.route('/api/appointments/slots')
@login_required
@role_required('admin', 'receptionist', 'professional')
def api_appointment_slots():
service_id = request.args.get('service_id', type=int)
professional_id = request.args.get('professional_id', type=int)
target_date = parse_date(request.args.get('date'), None)
if not service_id or not professional_id or not target_date:
return jsonify({'ok': True, 'items': []})
service = Service.query.get_or_404(service_id)
professional = ProfessionalProfile.query.get_or_404(professional_id)
if current_user.role == 'professional' and current_user.professional_profile and current_user.professional_profile.id != professional.id:
abort(403)
slots = get_available_slots(professional, service, target_date)
items = [
{
'label': slot['label'],
'start': slot['start'].strftime('%H:%M'),
'end': slot['end'].strftime('%H:%M'),
}
for slot in slots
]
return jsonify({'ok': True, 'items': items})
@app.route('/admin/appointments', methods=['GET', 'POST'])
@login_required
@role_required('admin', 'receptionist', 'professional')
def admin_appointments():
if request.method == 'POST':
appointment_id = request.form.get('appointment_id', type=int)
professional_id = request.form.get('professional_id', type=int)
service_id = request.form.get('service_id', type=int)
selected_date = parse_date(request.form.get('appointment_date'), date.today())
start_time = parse_time(request.form.get('start_time'))
status = request.form.get('status', 'confirmed')
patient_id = request.form.get('patient_id', type=int)
service = Service.query.get_or_404(service_id)
professional = ProfessionalProfile.query.get_or_404(professional_id)
patient = Patient.query.get(patient_id) if patient_id else None
if professional not in service.professionals:
flash('Ese profesional no está habilitado para el servicio seleccionado.', 'danger')
return redirect(url_for('admin_appointments'))
start_dt = datetime.combine(selected_date, start_time)
end_dt = start_dt + timedelta(minutes=service.duration_minutes)
appointment = Appointment.query.get(appointment_id) if appointment_id else Appointment(public_token=generate_public_token())
if current_user.role == 'professional' and current_user.professional_profile and current_user.professional_profile.id != professional.id:
abort(403)
if status in ['pending', 'confirmed'] and appointment_overlaps_existing(professional.id, selected_date, start_dt, end_dt, ignore_appointment_id=appointment.id if appointment.id else None):
flash('Ese horario se superpone con otro turno activo del profesional.', 'danger')
return redirect(url_for('admin_appointments'))
appointment.service_id = service.id
appointment.professional_id = professional.id
apply_patient_to_appointment(appointment, patient)
appointment.notes = request.form.get('notes', '').strip()
appointment.appointment_date = selected_date
appointment.start_time = start_time
appointment.end_time = end_dt.time()
appointment.status = status
appointment.booking_source = request.form.get('booking_source', 'admin')
appointment.internal_notes = request.form.get('internal_notes', '').strip()
appointment.reminder_sent = False if status in ['pending', 'confirmed'] else appointment.reminder_sent
if not appointment_id:
db.session.add(appointment)
db.session.commit()
log_action('upsert', 'Appointment', appointment.id, appointment.client_email)
flash('Turno guardado.', 'success')
return redirect(url_for('admin_appointments'))
query = Appointment.query.join(Service).join(ProfessionalProfile).outerjoin(Patient)
status = request.args.get('status', '').strip()
date_from = parse_date(request.args.get('date_from'), None)
date_to = parse_date(request.args.get('date_to'), None)
professional_id = request.args.get('professional_id', type=int)
q = request.args.get('q', '').strip()
page = request.args.get('page', type=int, default=1)
if current_user.role == 'professional' and current_user.professional_profile:
query = query.filter(Appointment.professional_id == current_user.professional_profile.id)
elif professional_id:
query = query.filter(Appointment.professional_id == professional_id)
if status:
query = query.filter(Appointment.status == status)
if date_from:
query = query.filter(Appointment.appointment_date >= date_from)
if date_to:
query = query.filter(Appointment.appointment_date <= date_to)
if q:
like = f'%{q}%'
query = query.filter(or_(Appointment.client_name.ilike(like), Appointment.client_email.ilike(like), Service.name.ilike(like), ProfessionalProfile.display_name.ilike(like), Patient.documento.ilike(like)))
pagination = query.order_by(Appointment.appointment_date.desc(), Appointment.start_time.desc()).paginate(page=page, per_page=12, error_out=False)
appointments = pagination.items
services = Service.query.filter_by(active=True).order_by(Service.name.asc()).all()
professionals = admin_professional_choices(include_hidden=True)
patients = patient_lookup_items()
edit_appointment = Appointment.query.get(request.args.get('edit', type=int)) if request.args.get('edit') else None
return render_template('admin_appointments_v3.html', appointments=appointments, services=services, professionals=professionals, edit_appointment=edit_appointment, pagination=pagination, patients=patients, statuses=APPOINTMENT_STATUSES)
@app.route('/admin/appointments/<int:appointment_id>/delete', methods=['POST'])
@login_required
@role_required('admin', 'receptionist')
def admin_appointment_delete(appointment_id):
appointment = Appointment.query.get_or_404(appointment_id)
db.session.delete(appointment)
db.session.commit()
log_action('delete', 'Appointment', appointment_id, appointment.client_email)
flash('Turno eliminado.', 'success')
return redirect(url_for('admin_appointments'))
@app.route('/admin/calendar')
@login_required
@role_required('admin', 'receptionist', 'professional')
def admin_calendar():
anchor = parse_date(request.args.get('week'), date.today())
week_dates = build_week_dates(anchor)
professional_id = request.args.get('professional_id', type=int)
query = Appointment.query.filter(
Appointment.appointment_date >= week_dates[0],
Appointment.appointment_date <= week_dates[-1],
)
if current_user.role == 'professional' and current_user.professional_profile:
professional_id = current_user.professional_profile.id
if professional_id:
query = query.filter(Appointment.professional_id == professional_id)
appointments = query.order_by(Appointment.appointment_date.asc(), Appointment.start_time.asc()).all()
calendar_map = {day.isoformat(): [] for day in week_dates}
for item in appointments:
calendar_map[item.appointment_date.isoformat()].append(item)
professionals = admin_professional_choices()
prev_week = (week_dates[0] - timedelta(days=7)).isoformat()
next_week = (week_dates[0] + timedelta(days=7)).isoformat()
return render_template('admin_calendar.html', week_dates=week_dates, calendar_map=calendar_map, professionals=professionals, selected_professional_id=professional_id, prev_week=prev_week, next_week=next_week)
@app.route('/admin/reminders', methods=['GET', 'POST'])
@login_required
@role_required('admin', 'receptionist', 'professional')
def admin_reminders():
prof_id = current_professional_id()
if request.method == 'POST':
due_now = get_due_reminders(current_app.config['REMINDER_WINDOW_HOURS'], prof_id)
for appt in due_now:
appt.reminder_sent = True
system_log_action('reminder_sent', 'Appointment', appt.id, f'Recordatorio manual simulado a {appt.client_email}')
db.session.commit()
flash(f'Se procesaron {len(due_now)} recordatorios pendientes.', 'success')
return redirect(url_for('admin_reminders'))
due = get_due_reminders(current_app.config['REMINDER_WINDOW_HOURS'], prof_id)
recent_logs_query = AuditLog.query.filter(AuditLog.action == 'reminder_sent')
recent_logs = recent_logs_query.order_by(AuditLog.created_at.desc()).limit(20).all()
return render_template('admin_reminders.html', due=due, recent_logs=recent_logs, reminder_window=current_app.config['REMINDER_WINDOW_HOURS'])
@app.route('/admin/integration')
@login_required
@role_required('admin', 'receptionist')
def admin_integration():
base_url = current_app.config['BASE_URL'].rstrip('/')
snippets = {
'admin_button': f'<a href="{base_url}/login" class="btn-admin-bookings">Administrar turnos</a>',
'booking_button': f'<a href="{base_url}/booking" class="btn-book">Reservar turno</a>',
'iframe': f'<iframe src="{base_url}/booking" width="100%" height="900" style="border:0;border-radius:16px;overflow:hidden;"></iframe>',
}
return render_template('admin_integration.html', base_url=base_url, snippets=snippets)
@app.route('/admin/site-settings', methods=['GET', 'POST'])
@login_required
@role_required('admin')
def admin_site_settings():
section_labels = {
'navbar_menu': 'Navbar / Menú',
'about_items': 'Nosotros',
'stats_items': 'Items / Números',
'service_cards': 'Servicios',
'department_cards': 'Departamentos',
'faq_items': 'Preguntas frecuentes',
'gallery_items': 'Galería',
'contact_items': 'Contacto',
'footer_columns': 'Footer columnas',
}
if request.method == 'POST':
try:
apply_site_settings_from_form()
log_action('update', 'SiteSettings', None, 'Configuración del sitio web actualizada')
flash('Configuración del sitio actualizada.', 'success')
return redirect(url_for('admin_site_settings', tab=request.form.get('return_tab', request.args.get('tab', 'general'))))
except Exception as exc:
store_error('admin_site_settings', exc)
flash(f'No se pudo guardar la configuración del sitio: {exc}', 'danger')
blocks = FrontendBlock.query.order_by(FrontendBlock.section.asc(), FrontendBlock.sort_order.asc(), FrontendBlock.id.asc()).all()
grouped = {key: [] for key in section_labels.keys()}
for block in blocks:
grouped.setdefault(block.section, []).append(block)
return render_template('admin_web_cms.html', settings=get_site_settings(), grouped=grouped, section_labels=section_labels, active_tab=request.args.get('tab', 'general'))
@app.route('/contact', methods=['POST'])
def public_contact_submit():
try:
inquiry = ContactInquiry(
name=request.form.get('name', '').strip(),
email=request.form.get('email', '').strip().lower(),
phone=request.form.get('phone', '').strip(),
detail=request.form.get('message', '').strip(),
status='No Leido',
source='frontend',
)
if not inquiry.name or not inquiry.email or not inquiry.detail:
raise ValueError('Completá nombre, email y detalle.')
db.session.add(inquiry)
db.session.commit()
log_action('create', 'ContactInquiry', inquiry.id, inquiry.email)
flash('Tu consulta fue enviada correctamente. Te responderemos a la brevedad.', 'success')
except Exception as exc:
db.session.rollback()
store_error('public_contact_submit', exc)
flash(f'No se pudo enviar la consulta: {exc}', 'danger')
return redirect(url_for('index') + '#contact')
@app.route('/legal/terms')
def public_legal_terms():
return render_template('legal_page.html', page_title='Términos y condiciones', html_content=get_site_settings().get('terms_html', ''))
@app.route('/legal/privacy')
def public_legal_privacy():
return render_template('legal_page.html', page_title='Privacidad y protección de datos', html_content=get_site_settings().get('privacy_html', ''))
def _safe_json_dict(raw_value, default=None):
if isinstance(raw_value, dict):
return raw_value
if not raw_value:
return default.copy() if isinstance(default, dict) else (default or {})
try:
payload = json.loads(raw_value)
return payload if isinstance(payload, dict) else (default.copy() if isinstance(default, dict) else (default or {}))
except Exception:
return default.copy() if isinstance(default, dict) else (default or {})
def _infer_specialty_template(specialty_name: str) -> str:
source = (specialty_name or '').strip().lower()
if not source:
return 'general_medicine'
for item in SPECIALTY_TEMPLATE_CATALOG:
if item['label'].strip().lower() == source:
return item['key']
for area in item.get('areas', []):
if area in source or source in area:
return item['key']
return 'general_medicine'
def _episode_membership(episode: ClinicalEpisode | None, user_id: int):
if not episode or not user_id:
return None
return next((m for m in episode.members if m.user_id == user_id and m.is_active), None)
def _episode_visible_for_user(episode: ClinicalEpisode | None) -> bool:
if not episode:
return True
if current_user.role == 'admin':
return True
membership = _episode_membership(episode, current_user.id)
if membership and membership.can_view:
return True
prof_id = current_professional_id()
if prof_id and any(item.professional_id == prof_id for item in episode.entries):
return True
return episode.created_by_user_id == current_user.id
def _episode_permissions(episode: ClinicalEpisode | None):
if current_user.role == 'admin':
return {'can_view': True, 'can_write': True, 'can_sign': True, 'can_export': True}
membership = _episode_membership(episode, current_user.id)
if membership:
return {'can_view': bool(membership.can_view), 'can_write': bool(membership.can_write), 'can_sign': bool(membership.can_sign), 'can_export': bool(membership.can_export)}
prof_id = current_professional_id()
if episode and prof_id and any(item.professional_id == prof_id for item in episode.entries):
return {'can_view': True, 'can_write': True, 'can_sign': True, 'can_export': False}
return {'can_view': False, 'can_write': False, 'can_sign': False, 'can_export': False}
def _entry_visible_for_user(entry: ClinicalEntry) -> bool:
if current_user.role == 'admin':
return True
if entry.episode and not _episode_visible_for_user(entry.episode):
return False
prof_id = current_professional_id()
if entry.visibility_scope == 'Restringido':
return entry.professional_id == prof_id or entry.created_by_user_id == current_user.id
return True
def _can_edit_entry(entry: ClinicalEntry) -> bool:
if entry.entry_status != 'Borrador':
return False
if current_user.role == 'admin':
return True
if entry.created_by_user_id == current_user.id:
return True
if entry.episode:
return _episode_permissions(entry.episode).get('can_write', False)
return entry.professional_id == current_professional_id()
def _summary_defaults(record: ClinicalRecord | None):
return _safe_json_dict(record.summary_payload if record else '', {
'allergies':'', 'current_medications':'', 'active_problems':'', 'clinical_alerts':'',
'personal_history':'', 'family_history':'', 'surgical_history':'', 'habits':'',
'immunizations':'', 'blood_type':'', 'disability_flags':'', 'pregnancy_status':'',
'emergency_notes':'', 'consent_overview':'', 'privacy_contacts':'',
})
CLINICAL_VITAL_KEYS = {'bp', 'hr', 'rr', 'temp', 'spo2', 'glucose', 'weight', 'height', 'bmi'}
CLINICAL_ALLOWED_TEMPLATES = {item.get('key') for item in SPECIALTY_TEMPLATE_CATALOG}
CLINICAL_TEMPLATE_FIELDS = {item.get('key'): {field.get('name') for field in item.get('fields', []) if field.get('name')} for item in SPECIALTY_TEMPLATE_CATALOG}
CLINICAL_ALLOWED_EPISODE_STATUS = {'Abierto', 'Cerrado', 'Seguimiento'}
CLINICAL_ALLOWED_CARE_LEVELS = {'Ambulatorio', 'Observación', 'Internación', 'Terapia intensiva'}
CLINICAL_ALLOWED_VISIBILITY = {item.get('value') for item in VISIBILITY_SCOPE_OPTIONS}
CLINICAL_ALLOWED_ENTRY_STATUS = set(ENTRY_STATUS_OPTIONS)
CLINICAL_ALLOWED_ENCOUNTER_TYPES = set(ENCOUNTER_TYPE_OPTIONS)
def _clean_clinical_name(value: str, max_length: int = 150):
return sanitize_text(value, max_length=max_length)
def _clean_clinical_note(value: str, max_length: int = 4000):
return sanitize_multiline_text(value, max_length=max_length)
CLINICAL_RICH_TAGS = {'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'blockquote', 'pre', 'h2', 'h3', 'span'}
CLINICAL_RICH_ATTRS = {'td': {'colspan', 'rowspan'}, 'th': {'colspan', 'rowspan'}, 'table': {'class'}, 'span': {'class'}}
def _clean_clinical_rich_note(value: str, max_length: int = 6000):
raw = (value or '').strip()
if not raw:
return ''
soup = BeautifulSoup(raw[:max_length * 2], 'html.parser')
for tag in list(soup.find_all(True)):
if tag.name in {'script', 'style', 'iframe', 'object', 'embed'}:
tag.decompose()
continue
if tag.name not in CLINICAL_RICH_TAGS:
tag.unwrap()
continue
allowed = CLINICAL_RICH_ATTRS.get(tag.name, set())
tag.attrs = {k: v for k, v in tag.attrs.items() if k in allowed}
if tag.name == 'table':
tag['class'] = 'table table-bordered table-sm'
cleaned = str(soup)
return cleaned[:max_length].strip()
def _clean_episode_payload(form_data):
return {
'title': _clean_clinical_name(form_data.get('title', ''), 180) or 'Episodio clínico',
'specialty_name': _clean_clinical_name(form_data.get('specialty_name', ''), 150),
'reason': _clean_clinical_name(form_data.get('reason', ''), 255),
'diagnosis_summary': _clean_clinical_name(form_data.get('diagnosis_summary', ''), 255),
'care_level': sanitize_choice(form_data.get('care_level', ''), CLINICAL_ALLOWED_CARE_LEVELS, 'Ambulatorio'),
'status': sanitize_choice(form_data.get('status', ''), CLINICAL_ALLOWED_EPISODE_STATUS, 'Abierto'),
'visibility_scope': sanitize_choice(form_data.get('visibility_scope', ''), CLINICAL_ALLOWED_VISIBILITY, 'Institucional'),
'notes': _clean_clinical_note(form_data.get('notes', ''), 3000),
}
def _clean_summary_payload(form_data):
return {
'allergies': _clean_clinical_note(form_data.get('allergies', ''), 1200),
'current_medications': _clean_clinical_note(form_data.get('current_medications', ''), 1200),
'active_problems': _clean_clinical_note(form_data.get('active_problems', ''), 1200),
'clinical_alerts': _clean_clinical_note(form_data.get('clinical_alerts', ''), 1200),
'personal_history': _clean_clinical_note(form_data.get('personal_history', ''), 1500),
'family_history': _clean_clinical_note(form_data.get('family_history', ''), 1200),
'surgical_history': _clean_clinical_note(form_data.get('surgical_history', ''), 1200),
'habits': _clean_clinical_note(form_data.get('habits', ''), 1200),
'immunizations': _clean_clinical_note(form_data.get('immunizations', ''), 1200),
'blood_type': _clean_clinical_name(form_data.get('blood_type', ''), 20),
'disability_flags': _clean_clinical_note(form_data.get('disability_flags', ''), 500),
'pregnancy_status': _clean_clinical_note(form_data.get('pregnancy_status', ''), 500),
'emergency_notes': _clean_clinical_note(form_data.get('emergency_notes', ''), 1200),
'consent_overview': _clean_clinical_note(form_data.get('consent_overview', ''), 1200),
'privacy_contacts': _clean_clinical_note(form_data.get('privacy_contacts', ''), 1000),
}
def _clean_member_role(value: str, fallback: str):
return _clean_clinical_name(value, 120) or fallback
def _care_team_user_choices():
return User.query.filter(User.role.in_(['admin', 'professional']), User.is_active_user == True).order_by(User.full_name.asc()).all()
def _entry_signature_payload(entry: ClinicalEntry):
payload = _safe_json_dict(entry.signature_payload or '', {})
if payload:
return payload
if not entry.signed_hash:
return {}
return {
'mode': 'Firma institucional avanzada',
'certificate_status': 'Pendiente integración certificada externa',
'algorithm': 'SHA-256',
'issuer': current_app.config.get('APP_NAME', 'Book Appointments Pro'),
'serial': (entry.signed_hash or '')[:16].upper(),
'signed_at': entry.locked_at.isoformat() if entry.locked_at else '',
'signer': entry.signed_name or '',
}
def _build_entry_signature(professional, signed_name: str, signed_at: datetime, folio_number: int):
base = f'{signed_name}|{signed_at.isoformat()}|folio={folio_number}|prof={professional.id if professional else 0}'
signed_hash = hash_payload(base)
return signed_hash, {
'mode': 'Firma institucional avanzada',
'certificate_status': 'Pendiente integración certificada externa',
'algorithm': 'SHA-256',
'issuer': current_app.config.get('APP_NAME', 'Book Appointments Pro'),
'serial': signed_hash[:16].upper(),
'signed_at': signed_at.isoformat(),
'signer': signed_name,
'professional_id': professional.id if professional else None,
'professional_specialty': professional.specialty if professional else '',
}
def _normalize_entry_payload(form_data, professional, entry_status='Firmado'):
specialty_name = _clean_clinical_name(form_data.get('specialty_name_display', ''), 150) or _clean_clinical_name(professional.specialty if professional else '', 150)
specialty_template = sanitize_choice(form_data.get('specialty_template', ''), CLINICAL_ALLOWED_TEMPLATES, _infer_specialty_template(specialty_name))
vitals_payload = sanitize_json_text_map(form_data.get('vitals_payload', '{}'), CLINICAL_VITAL_KEYS, max_value_length=30)
structured_payload = sanitize_json_text_map(form_data.get('structured_payload', '{}'), CLINICAL_TEMPLATE_FIELDS.get(specialty_template, set()), max_value_length=1200, multiline=True)
encounter_type = sanitize_choice(form_data.get('encounter_type', ''), CLINICAL_ALLOWED_ENCOUNTER_TYPES, 'Evolución médica')
visibility_scope = sanitize_choice(form_data.get('visibility_scope', ''), CLINICAL_ALLOWED_VISIBILITY, 'Paciente')
entry_status = sanitize_choice(entry_status, CLINICAL_ALLOWED_ENTRY_STATUS, 'Firmado')
return {
'episode_id': form_data.get('episode_id', type=int),
'specialty_name': specialty_name,
'encounter_type': encounter_type,
'diagnosis_text': _clean_clinical_name(form_data.get('diagnosis_text', ''), 255),
'chief_complaint': _clean_clinical_name(form_data.get('chief_complaint', ''), 255),
'provisional_diagnosis': _clean_clinical_name(form_data.get('provisional_diagnosis', ''), 255),
'specialty_template': specialty_template,
'cie10_code': sanitize_code(form_data.get('cie10_code', ''), 20),
'snomed_term': _clean_clinical_name(form_data.get('snomed_term', ''), 255),
'snomed_code': sanitize_code(form_data.get('snomed_code', ''), 50),
'subjective': _clean_clinical_rich_note(form_data.get('subjective', ''), 6000),
'objective': _clean_clinical_rich_note(form_data.get('objective', ''), 6000),
'assessment': _clean_clinical_rich_note(form_data.get('assessment', ''), 6000),
'plan': _clean_clinical_rich_note(form_data.get('plan', ''), 6000),
'treatment': _clean_clinical_rich_note(form_data.get('treatment', ''), 4500),
'study_results': _clean_clinical_rich_note(form_data.get('study_results', ''), 4500),
'vitals_payload': json.dumps(vitals_payload, ensure_ascii=False),
'structured_payload': json.dumps(structured_payload, ensure_ascii=False),
'consent_reference': _clean_clinical_name(form_data.get('consent_reference', ''), 120),
'entry_status': entry_status,
'visibility_scope': visibility_scope,
}
def _entry_form_payload(entry: ClinicalEntry | None):
if not entry:
return {}
return {
'id': entry.id,
'episode_id': entry.episode_id or '',
'encounter_type': entry.encounter_type or '',
'specialty_name': entry.specialty_name or '',
'specialty_template': entry.specialty_template or 'general_medicine',
'chief_complaint': entry.chief_complaint or '',
'provisional_diagnosis': entry.provisional_diagnosis or '',
'diagnosis_text': entry.diagnosis_text or '',
'cie10_code': entry.cie10_code or '',
'snomed_term': entry.snomed_term or '',
'snomed_code': entry.snomed_code or '',
'subjective': entry.subjective or '',
'objective': entry.objective or '',
'assessment': entry.assessment or '',
'plan': entry.plan or '',
'treatment': entry.treatment or '',
'study_results': entry.study_results or '',
'consent_reference': entry.consent_reference or '',
'visibility_scope': entry.visibility_scope or 'Paciente',
'entry_status': entry.entry_status or 'Borrador',
'vitals': _safe_json_dict(entry.vitals_payload, {}),
'structured': _safe_json_dict(entry.structured_payload, {}),
}
def _care_team_payload(episode: ClinicalEpisode):
rows = []
for member in episode.members:
if not member.is_active or not member.user:
continue
rows.append({
'id': member.id,
'full_name': member.user.full_name,
'role': member.role_label or member.user.role,
'flags': ' · '.join([flag for flag, ok in [('leer', member.can_view), ('escribir', member.can_write), ('firmar', member.can_sign), ('exportar', member.can_export)] if ok]) or 'sin permisos',
'can_revoke': member.user_id != current_user.id,
})
return rows
def _professional_templates_for_clinical(professional=None):
query = ClinicalEntryTemplate.query.filter_by(is_active=True)
if current_user.role == 'professional':
prof_id = current_professional_id()
query = query.filter(ClinicalEntryTemplate.professional_id == prof_id)
elif professional:
query = query.filter(ClinicalEntryTemplate.professional_id == professional.id)
inst_id = user_institution_id()
if inst_id:
query = query.filter(ClinicalEntryTemplate.institution_id == inst_id)
return query.order_by(ClinicalEntryTemplate.specialty_name.asc(), ClinicalEntryTemplate.category.asc(), ClinicalEntryTemplate.title.asc()).all()
def _professional_episode_templates_for_clinical(professional=None):
query = ClinicalEpisodeTemplate.query.filter_by(is_active=True)
if current_user.role == 'professional':
prof_id = current_professional_id()
query = query.filter(ClinicalEpisodeTemplate.professional_id == prof_id)
elif professional:
query = query.filter(ClinicalEpisodeTemplate.professional_id == professional.id)
inst_id = user_institution_id()
if inst_id:
query = query.filter(ClinicalEpisodeTemplate.institution_id == inst_id)
return query.order_by(ClinicalEpisodeTemplate.specialty_name.asc(), ClinicalEpisodeTemplate.category.asc(), ClinicalEpisodeTemplate.title.asc()).all()
def _clinical_episode_template_payload_from_episode_payload(payload, professional, source_episode=None):
title = _clean_clinical_name(request.form.get('episode_template_title', ''), 180)
if not title:
title = _clean_clinical_name(payload.get('title') or payload.get('reason') or 'Template de episodio', 180)
category = _clean_clinical_name(request.form.get('episode_template_category', ''), 120) or payload.get('specialty_name') or professional.specialty or 'General'
return ClinicalEpisodeTemplate(
professional_id=professional.id,
created_by_user_id=current_user.id,
title=title,
category=category,
specialty_name=payload.get('specialty_name') or professional.specialty or '',
reason=payload.get('reason') or '',
diagnosis_summary=payload.get('diagnosis_summary') or '',
care_level=payload.get('care_level') or 'Ambulatorio',
visibility_scope=payload.get('visibility_scope') or 'Institucional',
notes=payload.get('notes') or '',
source_episode_id=source_episode.id if source_episode else None,
institution_id=getattr(professional, 'institution_id', None) or getattr(current_user, 'institution_id', None),
is_active=True,
)
def _clinical_episode_templates_json_for(professional):
items = []
for tpl in _professional_episode_templates_for_clinical(professional):
payload = tpl.to_payload()
payload['professional_id'] = tpl.professional_id
payload['professional_name'] = tpl.professional.display_name if tpl.professional else ''
items.append(payload)
return items
def _clinical_template_payload_from_entry_payload(payload, professional, source_entry=None):
title = _clean_clinical_name(request.form.get('template_title', ''), 180)
if not title:
title = _clean_clinical_name(payload.get('chief_complaint') or payload.get('diagnosis_text') or 'Template de evolución', 180)
category = _clean_clinical_name(request.form.get('template_category', ''), 120) or payload.get('specialty_name') or professional.specialty or 'General'
template = ClinicalEntryTemplate(
professional_id=professional.id,
created_by_user_id=current_user.id,
title=title,
category=category,
specialty_name=payload.get('specialty_name') or professional.specialty or '',
specialty_template=payload.get('specialty_template') or _infer_specialty_template(professional.specialty if professional else ''),
encounter_type=payload.get('encounter_type') or 'Evolución médica',
chief_complaint=payload.get('chief_complaint') or '',
provisional_diagnosis=payload.get('provisional_diagnosis') or '',
diagnosis_text=payload.get('diagnosis_text') or '',
cie10_code=payload.get('cie10_code') or '',
snomed_term=payload.get('snomed_term') or '',
snomed_code=payload.get('snomed_code') or '',
subjective=payload.get('subjective') or '',
objective=payload.get('objective') or '',
assessment=payload.get('assessment') or '',
plan=payload.get('plan') or '',
treatment=payload.get('treatment') or '',
study_results=payload.get('study_results') or '',
vitals_payload=payload.get('vitals_payload') or '{}',
structured_payload=payload.get('structured_payload') or '{}',
consent_reference=payload.get('consent_reference') or '',
visibility_scope=payload.get('visibility_scope') or 'Institucional',
source_entry_id=source_entry.id if source_entry else None,
institution_id=getattr(professional, 'institution_id', None) or getattr(current_user, 'institution_id', None),
is_active=True,
)
return template
def _clinical_templates_json_for(professional):
items = []
for tpl in _professional_templates_for_clinical(professional):
payload = tpl.to_payload()
# Robustez ante payloads históricos inválidos.
payload['vitals'] = _safe_json_dict(tpl.vitals_payload, {})
payload['structured'] = _safe_json_dict(tpl.structured_payload, {})
payload['professional_id'] = tpl.professional_id
payload['professional_name'] = tpl.professional.display_name if tpl.professional else ''
items.append(payload)
return items
def _render_entry_for_view(item: ClinicalEntry):
item.vitals_data = _safe_json_dict(item.vitals_payload, {})
item.structured_data = _safe_json_dict(item.structured_payload, {})
item.signature_data = _entry_signature_payload(item)
item.has_signature = bool(item.signed_hash)
item.timeline_badge = 'success' if item.entry_status == 'Firmado' else 'warning'
item.can_edit_draft = _can_edit_entry(item)
item.episode_permissions = _episode_permissions(item.episode) if item.episode else {'can_view': True, 'can_write': current_user.role == 'admin', 'can_sign': current_user.role == 'admin', 'can_export': current_user.role == 'admin'}
for att in item.attachments:
att.is_previewable = (att.mime_type or '').startswith('image/') or 'pdf' in (att.mime_type or '').lower() or att.filename.lower().endswith('.pdf')
att.preview_url = url_for('static', filename=att.file_path)
return item
def _build_fhir_bundle(record: ClinicalRecord, entries, episodes):
patient = record.patient
base_url = (current_app.config.get('BASE_URL') or 'http://127.0.0.1:5000').rstrip('/')
bundle = {'resourceType': 'Bundle', 'type': 'collection', 'timestamp': datetime.utcnow().isoformat(), 'entry': []}
bundle['entry'].append({'fullUrl': f'{base_url}/Patient/{patient.id}', 'resource': {
'resourceType': 'Patient', 'id': str(patient.id), 'identifier': [{'system': 'urn:documento', 'value': patient.documento}],
'name': [{'family': patient.apellido, 'given': [patient.nombre]}],
'birthDate': patient.fecha_nacimiento or '',
}})
for episode in episodes:
participants = []
for m in episode.members:
if not m.is_active or not m.user:
continue
practitioner_id = f'pract-{m.user.id}'
bundle['entry'].append({'fullUrl': f'{base_url}/Practitioner/{practitioner_id}', 'resource': {'resourceType': 'Practitioner', 'id': practitioner_id, 'name': [{'text': m.user.full_name}], 'qualification': [{'code': {'text': m.role_label or m.user.role}}]}})
participants.append({'member': {'reference': f'Practitioner/{practitioner_id}', 'display': m.user.full_name}, 'role': [{'text': m.role_label or m.user.role}]})
bundle['entry'].append({'fullUrl': f'{base_url}/EpisodeOfCare/{episode.id}', 'resource': {'resourceType': 'EpisodeOfCare', 'id': str(episode.id), 'status': 'active' if episode.status == 'Abierto' else 'finished', 'patient': {'reference': f'Patient/{patient.id}', 'display': patient.nombre_completo}, 'diagnosis': [{'condition': {'display': episode.diagnosis_summary or episode.reason or episode.title}}], 'type': [{'text': episode.specialty_name or episode.title}], 'period': {'start': episode.started_at.isoformat() if episode.started_at else '', 'end': episode.closed_at.isoformat() if episode.closed_at else ''}}})
if participants:
bundle['entry'].append({'fullUrl': f'{base_url}/CareTeam/{episode.id}', 'resource': {'resourceType': 'CareTeam', 'id': str(episode.id), 'status': 'active', 'subject': {'reference': f'Patient/{patient.id}'}, 'participant': participants}})
for item in entries:
practitioner_id = f'prof-{item.professional_id}'
bundle['entry'].append({'fullUrl': f'{base_url}/Practitioner/{practitioner_id}', 'resource': {'resourceType': 'Practitioner', 'id': practitioner_id, 'name': [{'text': item.professional.display_name if item.professional else item.signed_name or 'Profesional'}]}})
encounter = {'resourceType': 'Encounter', 'id': str(item.id), 'status': 'finished' if item.entry_status == 'Firmado' else 'in-progress', 'class': {'code': 'AMB'}, 'subject': {'reference': f'Patient/{patient.id}'}, 'participant': [{'individual': {'reference': f'Practitioner/{practitioner_id}'}}], 'period': {'start': item.entry_datetime.isoformat() if item.entry_datetime else ''}, 'type': [{'text': item.encounter_type or 'Evolución médica'}]}
if item.episode_id:
encounter['episodeOfCare'] = [{'reference': f'EpisodeOfCare/{item.episode_id}'}]
bundle['entry'].append({'fullUrl': f'{base_url}/Encounter/{item.id}', 'resource': encounter})
if item.diagnosis_text or item.cie10_code or item.snomed_code:
codings = []
if item.cie10_code:
codings.append({'system': 'http://hl7.org/fhir/sid/icd-10', 'code': item.cie10_code, 'display': item.diagnosis_text or ''})
if item.snomed_code:
codings.append({'system': 'http://snomed.info/sct', 'code': item.snomed_code, 'display': item.snomed_term or ''})
bundle['entry'].append({'fullUrl': f'{base_url}/Condition/{item.id}', 'resource': {'resourceType': 'Condition', 'id': str(item.id), 'subject': {'reference': f'Patient/{patient.id}'}, 'encounter': {'reference': f'Encounter/{item.id}'}, 'code': {'text': item.diagnosis_text or item.provisional_diagnosis or '', 'coding': codings}}})
vd = _safe_json_dict(item.vitals_payload, {})
for key, label in [('bp','Presión arterial'),('hr','Frecuencia cardíaca'),('rr','Frecuencia respiratoria'),('temp','Temperatura'),('spo2','Saturación O2'),('glucose','Glucemia'),('weight','Peso'),('height','Talla'),('bmi','IMC')]:
if vd.get(key):
bundle['entry'].append({'fullUrl': f'{base_url}/Observation/{item.id}-{key}', 'resource': {'resourceType': 'Observation', 'id': f'{item.id}-{key}', 'status': 'final', 'subject': {'reference': f'Patient/{patient.id}'}, 'encounter': {'reference': f'Encounter/{item.id}'}, 'code': {'text': label}, 'valueString': str(vd.get(key))}})
for att in item.attachments:
bundle['entry'].append({'fullUrl': f'{base_url}/DocumentReference/{att.id}', 'resource': {'resourceType': 'DocumentReference', 'id': str(att.id), 'status': 'current', 'subject': {'reference': f'Patient/{patient.id}'}, 'context': {'encounter': [{'reference': f'Encounter/{item.id}'}]}, 'description': att.filename, 'content': [{'attachment': {'contentType': att.mime_type or 'application/octet-stream', 'url': f'{base_url}/static/{att.file_path}'}}]}})
if item.signed_hash:
sig = _entry_signature_payload(item)
bundle['entry'].append({'fullUrl': f'{base_url}/Provenance/{item.id}', 'resource': {'resourceType': 'Provenance', 'id': str(item.id), 'target': [{'reference': f'Encounter/{item.id}'}], 'recorded': item.locked_at.isoformat() if item.locked_at else datetime.utcnow().isoformat(), 'agent': [{'who': {'display': item.signed_name or 'Firma institucional'}}], 'signature': [{'type': [{'text': sig.get('mode', 'Firma institucional avanzada')}], 'when': sig.get('signed_at') or '', 'who': {'display': sig.get('signer') or ''}, 'data': item.signed_hash}]}})
return bundle
def _entry_view_guard(entry: ClinicalEntry):
if not _entry_visible_for_user(entry):
abort(403)
return entry
@app.route('/admin/clinical-records', methods=['GET', 'POST'])
@login_required
@role_required('admin', 'professional')
def admin_clinical_records():
q = sanitize_digits(request.values.get('dni', ''), max_length=16)
patient = None
record = None
entries = []
episodes = []
summary_data = _summary_defaults(None)
care_team_users = _care_team_user_choices()
editing_entry = None
editing_entry_form = {}
selected_professional_id = request.args.get('professional_id', type=int)
selected_type = request.args.get('encounter_type', '').strip()
selected_status = request.args.get('entry_status', '').strip()
selected_dx = request.args.get('diagnosis', '').strip()
selected_date_from = request.args.get('date_from', '').strip()
selected_date_to = request.args.get('date_to', '').strip()
if request.method == 'POST':
action = request.form.get('action', 'add_entry')
try:
patient_id = request.form.get('patient_id', type=int)
if patient_id:
patient = Patient.query.get_or_404(patient_id)
record = ensure_clinical_record(patient, retention_years=10)
if action == 'update_summary':
if not patient or not record:
raise ValueError('No se encontró el paciente para actualizar el resumen clínico.')
summary_payload = _clean_summary_payload(request.form)
record.summary_payload = json.dumps(summary_payload, ensure_ascii=False)
record.confidentiality_level = sanitize_choice(request.form.get('confidentiality_level', ''), ALLOWED_CONFIDENTIALITY, 'Restringido')
record.notes = _clean_clinical_note(request.form.get('record_notes', ''), 2500)
db.session.commit()
log_action('update', 'ClinicalRecord', record.id, f'resumen_clinico|dni={patient.documento}')
flash('Resumen clínico actualizado.', 'success')
return redirect(url_for('admin_clinical_records', dni=patient.documento))
if action == 'create_episode':
if not patient or not record:
raise ValueError('No se encontró el paciente para crear el episodio clínico.')
episode_data = _clean_episode_payload(request.form)
episode = ClinicalEpisode(record_id=record.id, patient_id=patient.id, created_by_user_id=current_user.id, **episode_data)
db.session.add(episode)
db.session.flush()
db.session.add(ClinicalEpisodeMember(episode_id=episode.id, user_id=current_user.id, added_by_user_id=current_user.id, role_label='Responsable', can_view=True, can_write=True, can_sign=True, can_export=True, is_active=True))
applied_episode_template_id = request.form.get('applied_episode_template_id', type=int)
if applied_episode_template_id:
applied_episode_template = ClinicalEpisodeTemplate.query.get(applied_episode_template_id)
if applied_episode_template and (current_user.role == 'admin' or applied_episode_template.professional_id == current_professional_id()):
applied_episode_template.usage_count = (applied_episode_template.usage_count or 0) + 1
if request.form.get('save_episode_as_template'):
template_professional = current_professional_profile()
if template_professional:
db.session.add(_clinical_episode_template_payload_from_episode_payload(episode_data, template_professional, source_episode=episode))
flash('El episodio también quedó guardado como template de tu perfil.', 'success')
else:
flash('El episodio se creó, pero solo un profesional puede guardar templates personales de episodio.', 'warning')
db.session.commit()
log_action('create', 'ClinicalEpisode', episode.id, f'legajo={record.legajo_number}|titulo={episode.title}')
flash('Episodio clínico creado.', 'success')
return redirect(url_for('admin_clinical_records', dni=patient.documento))
if action == 'add_episode_member':
episode = ClinicalEpisode.query.get_or_404(request.form.get('episode_id', type=int) or 0)
if not _episode_visible_for_user(episode):
raise ValueError('No tenés acceso al episodio seleccionado.')
perms = _episode_permissions(episode)
if current_user.role != 'admin' and not perms.get('can_write'):
raise ValueError('No tenés permisos para gestionar el equipo tratante de este episodio.')
target_user = User.query.get_or_404(request.form.get('user_id', type=int) or 0)
membership = ClinicalEpisodeMember.query.filter_by(episode_id=episode.id, user_id=target_user.id).first()
if not membership:
membership = ClinicalEpisodeMember(episode_id=episode.id, user_id=target_user.id, added_by_user_id=current_user.id)
db.session.add(membership)
membership.role_label = _clean_member_role(request.form.get('role_label', ''), target_user.role)
membership.can_view = bool(request.form.get('can_view'))
membership.can_write = bool(request.form.get('can_write'))
membership.can_sign = bool(request.form.get('can_sign'))
membership.can_export = bool(request.form.get('can_export'))
membership.is_active = True
membership.left_at = None
db.session.commit()
log_action('update', 'ClinicalEpisodeMember', membership.id, f'episode={episode.id}|user={target_user.id}')
flash('Integrante agregado o actualizado en el equipo tratante.', 'success')
return redirect(url_for('admin_clinical_records', dni=episode.patient.documento))
if action == 'close_episode':
episode = ClinicalEpisode.query.get_or_404(request.form.get('episode_id', type=int) or 0)
if not _episode_visible_for_user(episode):
raise ValueError('No tenés acceso al episodio seleccionado.')
perms = _episode_permissions(episode)
if current_user.role != 'admin' and not perms.get('can_sign'):
raise ValueError('No tenés permisos para cerrar el episodio.')
episode.status = 'Cerrado'
episode.closed_at = datetime.utcnow()
closure_note = _clean_clinical_note(request.form.get('closure_note', ''), 1000)
if closure_note:
episode.notes = (episode.notes or '') + ('\n' if episode.notes else '') + closure_note
db.session.commit()
log_action('close', 'ClinicalEpisode', episode.id, f'episode={episode.id}')
flash('Episodio clínico cerrado formalmente.', 'success')
return redirect(url_for('admin_clinical_records', dni=episode.patient.documento))
if action == 'revoke_episode_member':
membership = ClinicalEpisodeMember.query.get_or_404(request.form.get('membership_id', type=int) or 0)
episode = membership.episode
if not _episode_visible_for_user(episode):
raise ValueError('No tenés acceso al episodio seleccionado.')
perms = _episode_permissions(episode)
if current_user.role != 'admin' and not perms.get('can_write'):
raise ValueError('No tenés permisos para revocar accesos en este episodio.')
membership.is_active = False
membership.left_at = datetime.utcnow()
db.session.commit()
log_action('revoke', 'ClinicalEpisodeMember', membership.id, f'episode={episode.id}|user={membership.user_id}')
flash('Acceso del integrante revocado.', 'success')
return redirect(url_for('admin_clinical_records', dni=episode.patient.documento))
if action == 'deactivate_template':
template = ClinicalEntryTemplate.query.get_or_404(request.form.get('template_id', type=int) or 0)
if current_user.role != 'admin' and template.professional_id != current_professional_id():
raise ValueError('No tenés permisos para eliminar este template.')
template.is_active = False
db.session.commit()
log_action('delete', 'ClinicalEntryTemplate', template.id, f'template={template.title}')
flash('Template de evolución desactivado.', 'success')
redirect_dni = patient.documento if patient else request.form.get('dni', '')
return redirect(url_for('admin_clinical_records', dni=redirect_dni))
if action == 'deactivate_episode_template':
template = ClinicalEpisodeTemplate.query.get_or_404(request.form.get('template_id', type=int) or 0)
if current_user.role != 'admin' and template.professional_id != current_professional_id():
raise ValueError('No tenés permisos para eliminar este template de episodio.')
template.is_active = False
db.session.commit()
log_action('delete', 'ClinicalEpisodeTemplate', template.id, f'template={template.title}')
flash('Template de episodio desactivado.', 'success')
redirect_dni = patient.documento if patient else request.form.get('dni', '')
return redirect(url_for('admin_clinical_records', dni=redirect_dni))
if action == 'update_attachment_visibility':
attachment = ClinicalAttachment.query.get_or_404(request.form.get('attachment_id', type=int) or 0)
entry = attachment.entry
_entry_view_guard(entry)
if current_user.role != 'admin' and not _can_edit_entry(entry) and not _episode_permissions(entry.episode).get('can_write', False):
raise ValueError('No tenés permisos para modificar adjuntos de este registro.')
attachment.is_patient_visible = bool(request.form.get('is_patient_visible'))
db.session.commit()
log_action('update', 'ClinicalAttachment', attachment.id, f'entry={entry.id}|patient_visible={attachment.is_patient_visible}')
flash('Visibilidad del adjunto actualizada.', 'success')
return redirect(url_for('admin_clinical_records', dni=entry.patient.documento))
if action in ['add_entry', 'update_entry']:
if not patient or not record:
raise ValueError('No se encontró el paciente para registrar la evolución clínica.')
if current_user.role == 'professional':
professional = current_user.professional_profile
if not professional:
raise ValueError('Tu usuario profesional no tiene perfil vinculado.')
else:
professional = ProfessionalProfile.query.get_or_404(request.form.get('professional_id', type=int) or 0)
entry_status = request.form.get('entry_status', 'Firmado').strip() or 'Firmado'
payload = _normalize_entry_payload(request.form, professional, entry_status)
selected_episode = ClinicalEpisode.query.get(payload['episode_id']) if payload.get('episode_id') else None
if selected_episode:
if selected_episode.patient_id != patient.id:
raise ValueError('El episodio seleccionado no corresponde al paciente.')
perms = _episode_permissions(selected_episode)
if current_user.role != 'admin' and not perms.get('can_write'):
raise ValueError('No tenés permisos de escritura en ese episodio.')
if entry_status == 'Firmado' and current_user.role != 'admin' and not perms.get('can_sign'):
raise ValueError('No tenés permisos de firma en ese episodio.')
if action == 'add_entry':
next_folio = (db.session.query(func.max(ClinicalEntry.folio_number)).filter_by(record_id=record.id).scalar() or 0) + 1
entry_dt = datetime.utcnow()
entry = ClinicalEntry(record_id=record.id, patient_id=patient.id, professional_id=professional.id, created_by_user_id=current_user.id, last_edited_by_user_id=current_user.id, entry_datetime=entry_dt, folio_number=next_folio, **payload)
if entry_status == 'Firmado':
signed_hash, signature_payload = _build_entry_signature(professional, professional.display_name if professional else current_user.full_name, entry_dt, next_folio)
entry.locked_at = entry_dt
entry.signed_name = professional.display_name if professional else current_user.full_name
entry.signed_hash = signed_hash
entry.signature_payload = json.dumps(signature_payload, ensure_ascii=False)
db.session.add(entry)
db.session.flush()
msg = 'Evolución clínica agregada al legajo del paciente.'
else:
entry = ClinicalEntry.query.get_or_404(request.form.get('entry_id', type=int) or 0)
_entry_view_guard(entry)
if not _can_edit_entry(entry):
raise ValueError('Solo se pueden editar borradores con permisos de escritura.')
for key, value in payload.items():
setattr(entry, key, value)
entry.last_edited_at = datetime.utcnow()
entry.last_edited_by_user_id = current_user.id
entry.edit_revision = (entry.edit_revision or 1) + 1
if entry_status == 'Firmado':
signed_at = datetime.utcnow()
signed_hash, signature_payload = _build_entry_signature(professional, professional.display_name if professional else current_user.full_name, signed_at, entry.folio_number)
entry.locked_at = signed_at
entry.signed_name = professional.display_name if professional else current_user.full_name
entry.signed_hash = signed_hash
entry.signature_payload = json.dumps(signature_payload, ensure_ascii=False)
else:
entry.locked_at = None
entry.signed_name = ''
entry.signed_hash = ''
entry.signature_payload = ''
msg = 'Borrador clínico actualizado.'
applied_template_id = request.form.get('applied_template_id', type=int)
if applied_template_id:
applied_template = ClinicalEntryTemplate.query.get(applied_template_id)
if applied_template and (current_user.role == 'admin' or applied_template.professional_id == professional.id):
applied_template.usage_count = (applied_template.usage_count or 0) + 1
if request.form.get('save_as_template'):
template = _clinical_template_payload_from_entry_payload(payload, professional, source_entry=entry)
db.session.add(template)
flash(f'Template de evolución "{template.title}" guardado en el perfil del profesional.', 'success')
visible_attachment_ids = {str(val) for val in request.form.getlist('patient_visible_attachment_tokens')}
for upload in request.files.getlist('clinical_attachments'):
if not getattr(upload, 'filename', ''):
continue
rel = save_uploaded_asset(upload, current_app.config['UPLOAD_FOLDER'], prefix='clinical')
upload_token = sanitize_identifier(getattr(upload, 'filename', ''), max_length=120)
db.session.add(ClinicalAttachment(entry_id=entry.id, filename=os.path.basename(upload.filename), file_path=rel, mime_type=getattr(upload, 'mimetype', '') or '', is_patient_visible=(upload_token in visible_attachment_ids or entry.visibility_scope == 'Paciente')))
db.session.commit()
log_action('update' if action == 'update_entry' else 'create', 'ClinicalEntry', entry.id, f'legajo={record.legajo_number}|folio={entry.folio_number}|estado={entry.entry_status}')
flash(msg, 'success')
return redirect(url_for('admin_clinical_records', dni=patient.documento))
except Exception as exc:
db.session.rollback()
store_error('admin_clinical_records_save', exc)
flash(f'No se pudo guardar la operación clínica: {exc}', 'danger')
if q:
patient = Patient.query.filter(Patient.documento == q).first()
if patient:
record = ensure_clinical_record(patient, retention_years=10)
db.session.commit()
log_action('view', 'ClinicalRecord', record.id if record else None, f'dni={patient.documento}')
summary_data = _summary_defaults(record)
episodes = [ep for ep in ClinicalEpisode.query.filter_by(record_id=record.id).order_by(ClinicalEpisode.started_at.desc()).all() if _episode_visible_for_user(ep)]
for ep in episodes:
ep.permissions = _episode_permissions(ep)
ep.team_payload = _care_team_payload(ep)
ep.entries_count = ClinicalEntry.query.filter_by(episode_id=ep.id).count()
query = ClinicalEntry.query.filter_by(record_id=record.id)
if selected_professional_id:
query = query.filter(ClinicalEntry.professional_id == selected_professional_id)
if selected_type:
query = query.filter(ClinicalEntry.encounter_type == selected_type)
if selected_status:
query = query.filter(ClinicalEntry.entry_status == selected_status)
if selected_dx:
term = f"%{selected_dx}%"
query = query.filter(or_(ClinicalEntry.diagnosis_text.ilike(term), ClinicalEntry.provisional_diagnosis.ilike(term), ClinicalEntry.chief_complaint.ilike(term), ClinicalEntry.cie10_code.ilike(term), ClinicalEntry.snomed_term.ilike(term)))
if selected_date_from:
dt_from = parse_date(selected_date_from)
if dt_from:
query = query.filter(ClinicalEntry.entry_datetime >= datetime.combine(dt_from, datetime.min.time()))
if selected_date_to:
dt_to = parse_date(selected_date_to)
if dt_to:
query = query.filter(ClinicalEntry.entry_datetime <= datetime.combine(dt_to, datetime.max.time()))
entries = [_render_entry_for_view(item) for item in query.order_by(ClinicalEntry.entry_datetime.desc(), ClinicalEntry.folio_number.desc()).all() if _entry_visible_for_user(item)]
edit_entry_id = request.args.get('edit_entry', type=int)
if edit_entry_id:
editing_entry = next((item for item in entries if item.id == edit_entry_id and item.entry_status == 'Borrador' and item.can_edit_draft), None)
if editing_entry:
editing_entry_form = _entry_form_payload(editing_entry)
professionals = admin_professional_choices(include_hidden=True)
selected_professional = current_user.professional_profile if current_user.role == 'professional' else (professionals[0] if professionals else None)
default_specialty_name = selected_professional.specialty if selected_professional else ''
default_specialty_template = _infer_specialty_template(default_specialty_name)
clinical_templates = _professional_templates_for_clinical(None if current_user.role == 'admin' else selected_professional)
clinical_templates_json = _clinical_templates_json_for(None if current_user.role == 'admin' else selected_professional)
episode_templates = _professional_episode_templates_for_clinical(None if current_user.role == 'admin' else selected_professional)
episode_templates_json = _clinical_episode_templates_json_for(None if current_user.role == 'admin' else selected_professional)
return render_template('admin_clinical_records.html', patient=patient, record=record, summary_data=summary_data, entries=entries, episodes=episodes, professionals=professionals, care_team_users=care_team_users, query_dni=q, cie10_catalog=CIE10_CATALOG, snomed_catalog=SNOMED_CT_CATALOG, encounter_type_options=ENCOUNTER_TYPE_OPTIONS, entry_status_options=ENTRY_STATUS_OPTIONS, visibility_scope_options=VISIBILITY_SCOPE_OPTIONS, specialty_templates=SPECIALTY_TEMPLATE_CATALOG, specialty_templates_json=json.dumps(SPECIALTY_TEMPLATE_CATALOG, ensure_ascii=False), clinical_templates=clinical_templates, clinical_templates_json=json.dumps(clinical_templates_json, ensure_ascii=False), episode_templates=episode_templates, episode_templates_json=json.dumps(episode_templates_json, ensure_ascii=False), default_specialty_name=default_specialty_name, default_specialty_template=default_specialty_template, selected_professional_id=selected_professional_id, selected_type=selected_type, selected_status=selected_status, selected_dx=selected_dx, selected_date_from=selected_date_from, selected_date_to=selected_date_to, editing_entry=editing_entry, editing_entry_form=editing_entry_form)
@app.route('/admin/clinical-records/entry/<int:entry_id>/print')
@login_required
@role_required('admin', 'professional')
def clinical_entry_print(entry_id):
entry = ClinicalEntry.query.get_or_404(entry_id)
_entry_view_guard(entry)
entry = _render_entry_for_view(entry)
return render_template('clinical_print_entry.html', entry=entry, patient=entry.patient, record=entry.record, now=datetime.utcnow())
@app.route('/admin/clinical-records/episode/<int:episode_id>/epicrisis')
@login_required
@role_required('admin', 'professional')
def clinical_episode_epicrisis(episode_id):
episode = ClinicalEpisode.query.get_or_404(episode_id)
if not _episode_visible_for_user(episode):
abort(403)
entries = [_render_entry_for_view(item) for item in episode.entries if _entry_visible_for_user(item)]
entries = sorted(entries, key=lambda x: (x.entry_datetime or datetime.min, x.folio_number or 0))
return render_template('clinical_print_epicrisis.html', episode=episode, patient=episode.patient, record=episode.record, entries=entries, now=datetime.utcnow())
@app.route('/admin/clinical-records/record/<int:record_id>/fhir')
@login_required
@role_required('admin', 'professional')
def clinical_record_fhir(record_id):
record = ClinicalRecord.query.get_or_404(record_id)
entries = [item for item in record.entries if _entry_visible_for_user(item)]
if current_user.role != 'admin' and not entries:
abort(403)
episodes = [ep for ep in record.episodes if _episode_visible_for_user(ep)]
bundle = _build_fhir_bundle(record, entries, episodes)
payload = json.dumps(bundle, ensure_ascii=False, indent=2)
return current_app.response_class(payload, mimetype='application/fhir+json', headers={'Content-Disposition': f'attachment; filename=historia_clinica_{record.legajo_number}.json'})
@app.route('/admin/consultas', methods=['GET', 'POST'])
@login_required
@role_required('admin', 'receptionist')
def admin_consultas():
if request.method == 'POST':
inquiry = ContactInquiry.query.get_or_404(request.form.get('inquiry_id', type=int))
inquiry.status = request.form.get('status', 'Leido')
inquiry.read_at = datetime.utcnow() if inquiry.status == 'Leido' else None
db.session.commit()
log_action('update_status', 'ContactInquiry', inquiry.id, inquiry.status)
flash('Estado de la consulta actualizado.', 'success')
return redirect(url_for('admin_consultas'))
q = request.args.get('q', '').strip()
status = request.args.get('status', '').strip()
page = request.args.get('page', type=int, default=1)
query = ContactInquiry.query
if q:
like = f'%{q}%'
query = query.filter(or_(ContactInquiry.name.ilike(like), ContactInquiry.email.ilike(like), ContactInquiry.phone.ilike(like), ContactInquiry.detail.ilike(like)))
if status:
query = query.filter(ContactInquiry.status == status)
pagination = query.order_by(ContactInquiry.created_at.desc()).paginate(page=page, per_page=15, error_out=False)
return render_template('admin_consultas.html', inquiries=pagination.items, pagination=pagination)
@app.route('/chat', methods=['GET', 'POST'])
@login_required
@role_required('admin', 'receptionist', 'professional')
def admin_chat():
if request.method == 'POST':
action = request.form.get('action', 'send_message')
try:
if action == 'start_private':
target = allowed_chat_user_query().filter(User.id == (request.form.get('target_user_id', type=int) or 0)).first_or_404()
conversation = get_or_create_private_chat(current_user, target)
db.session.commit()
flash('Chat privado listo.', 'success')
return redirect(url_for('admin_chat', conversation_id=conversation.id))
if action == 'create_group':
title = request.form.get('title', '').strip() or 'Nuevo grupo'
requested_ids = sorted({current_user.id, *[int(x) for x in request.form.getlist('member_ids') if str(x).isdigit()]})
if len(requested_ids) < 2:
raise ValueError('Seleccioná al menos un usuario adicional para crear el grupo.')
allowed_ids = {u.id for u in allowed_chat_user_query().all()} | {current_user.id}
if not set(requested_ids).issubset(allowed_ids):
abort(403)
members = User.query.filter(User.id.in_(requested_ids), User.is_active_user == True).all()
if len(members) != len(requested_ids):
raise ValueError('Uno o más integrantes no son válidos.')
conversation_institution_id = validate_chat_members_same_scope(members)
conversation = ChatConversation(title=title, is_group=True, created_by_user_id=current_user.id, is_active=True, institution_id=conversation_institution_id)
db.session.add(conversation)
db.session.flush()
for user in members:
db.session.add(ChatConversationMember(conversation_id=conversation.id, user_id=user.id, is_admin=(user.id == current_user.id), last_read_at=datetime.utcnow() if user.id == current_user.id else None))
db.session.add(ChatMessage(conversation_id=conversation.id, sender_id=current_user.id, body=f'Se creó el grupo “{title}”.', system_flag=True))
db.session.commit()
flash('Grupo creado.', 'success')
return redirect(url_for('admin_chat', conversation_id=conversation.id))
if action == 'send_message':
conversation = ChatConversation.query.get_or_404(request.form.get('conversation_id', type=int))
require_chat_access(conversation)
body = request.form.get('body', '').strip()
upload = request.files.get('chat_file')
if not body and (not upload or not upload.filename):
raise ValueError('Escribí un mensaje o adjuntá un archivo.')
message = ChatMessage(conversation_id=conversation.id, sender_id=current_user.id, body=body)
db.session.add(message)
db.session.flush()
if upload and upload.filename:
rel = save_uploaded_asset(upload, current_app.config['UPLOAD_FOLDER'], prefix='chat')
att = ChatAttachment(message_id=message.id, filename=upload.filename, file_path=rel, mime_type=upload.mimetype, file_size=0)
db.session.add(att)
membership = ChatConversationMember.query.filter_by(conversation_id=conversation.id, user_id=current_user.id).first()
if membership:
membership.last_read_at = datetime.utcnow()
db.session.commit()
db.session.refresh(message)
log_action('create', 'ChatMessage', message.id, f'conversation={conversation.id}')
emit_chat_updates(conversation, message, current_user)
return redirect(url_for('admin_chat', conversation_id=conversation.id))
except Exception as exc:
db.session.rollback()
store_error('admin_chat', exc)
flash(f'No se pudo operar el chat: {exc}', 'danger')
return redirect(url_for('admin_chat', conversation_id=request.form.get('conversation_id', type=int)))
memberships = ChatConversationMember.query.filter_by(user_id=current_user.id).all()
conversations = []
for membership in memberships:
convo = membership.conversation
if not convo or not convo.is_active:
continue
if current_user.role != 'admin' and convo.institution_id != current_user.institution_id:
continue
last_message = ChatMessage.query.filter_by(conversation_id=convo.id).order_by(ChatMessage.created_at.desc()).first()
unread = chat_unread_for_membership(membership)
conversations.append({'conversation': convo, 'membership': membership, 'last_message': last_message, 'unread': unread, 'display_name': conversation_display_name(convo, current_user.id)})
conversations.sort(key=lambda item: item['last_message'].created_at if item['last_message'] else item['conversation'].created_at, reverse=True)
conversation_id = request.args.get('conversation_id', type=int) or (conversations[0]['conversation'].id if conversations else None)
active_conversation = ChatConversation.query.get(conversation_id) if conversation_id else None
active_chat_title = "Chat"
active_messages = []
if active_conversation:
require_chat_access(active_conversation)
membership = ChatConversationMember.query.filter_by(conversation_id=active_conversation.id, user_id=current_user.id).first()
membership.last_read_at = datetime.utcnow()
db.session.commit()
active_chat_title = conversation_display_name(active_conversation, current_user.id)
active_messages = ChatMessage.query.filter_by(conversation_id=active_conversation.id).order_by(ChatMessage.created_at.asc()).all()
users = allowed_chat_user_query().filter(User.id != current_user.id).all()
return render_template('admin_chat.html',
conversations=conversations,
active_conversation=active_conversation,
active_chat_title=active_chat_title,
active_messages=active_messages,
available_users=users)
@app.route('/api/chat/send', methods=['POST'])
@login_required
@role_required('admin', 'receptionist', 'professional')
def api_chat_send():
try:
conversation = ChatConversation.query.get_or_404(request.form.get('conversation_id', type=int))
require_chat_access(conversation)
membership = ChatConversationMember.query.filter_by(conversation_id=conversation.id, user_id=current_user.id).first()
body = request.form.get('body', '').strip()
upload = request.files.get('chat_file')
if not body and (not upload or not upload.filename):
return jsonify({'ok': False, 'error': 'Escribí un mensaje o adjuntá un archivo.'}), 400
message = ChatMessage(conversation_id=conversation.id, sender_id=current_user.id, body=body)
db.session.add(message)
db.session.flush()
if upload and upload.filename:
rel = save_uploaded_asset(upload, current_app.config['UPLOAD_FOLDER'], prefix='chat')
db.session.add(ChatAttachment(message_id=message.id, filename=upload.filename, file_path=rel, mime_type=upload.mimetype, file_size=0))
membership.last_read_at = datetime.utcnow()
db.session.commit()
db.session.refresh(message)
log_action('create', 'ChatMessage', message.id, f'conversation={conversation.id}')
emit_chat_updates(conversation, message, current_user)
return jsonify({'ok': True, 'message': chat_message_payload(message, current_user.id), 'conversation': chat_conversation_payload(conversation, current_user.id), 'unread_total': get_chat_unread_count(current_user.id)})
except Exception as exc:
db.session.rollback()
store_error('api_chat_send', exc)
return jsonify({'ok': False, 'error': str(exc)}), 500
@app.route('/api/chat/mark-read', methods=['POST'])
@login_required
@role_required('admin', 'receptionist', 'professional')
def api_chat_mark_read():
conversation = ChatConversation.query.get_or_404(request.form.get('conversation_id', type=int))
require_chat_access(conversation)
membership = ChatConversationMember.query.filter_by(conversation_id=conversation.id, user_id=current_user.id).first()
membership.last_read_at = datetime.utcnow()
db.session.commit()
unread_total = get_chat_unread_count(current_user.id)
socketio.emit('chat_notify', {
'type': 'mark_read',
'conversation': chat_conversation_payload(conversation, current_user.id),
'unread_total': unread_total,
'show_toast': False,
}, to=f"user_{current_user.id}")
return jsonify({'ok': True, 'conversation': chat_conversation_payload(conversation, current_user.id), 'unread_total': unread_total})
@app.route('/admin/frontend', methods=['GET', 'POST'])
@login_required
@role_required('admin')
def admin_frontend():
section_labels = {
'navbar_menu': 'Navbar / Menú',
'about_items': 'Nosotros',
'stats_items': 'Items / Números',
'service_cards': 'Servicios',
'department_cards': 'Departamentos',
'faq_items': 'Preguntas frecuentes',
'gallery_items': 'Galería',
'contact_items': 'Contacto',
'footer_columns': 'Footer columnas',
}
if request.method == 'POST':
action = request.form.get('action', 'save_block')
try:
if action == 'delete_block':
block = FrontendBlock.query.get_or_404(request.form.get('block_id', type=int))
db.session.delete(block)
db.session.commit()
log_action('delete', 'FrontendBlock', block.id, block.section)
flash('Bloque eliminado.', 'success')
else:
block_id = request.form.get('block_id', type=int)
block = FrontendBlock.query.get(block_id) if block_id else FrontendBlock()
block.section = request.form.get('section', '').strip()
block.key = request.form.get('key', '').strip() or 'default'
block.title = request.form.get('title', '').strip()
block.subtitle = request.form.get('subtitle', '').strip()
block.body = request.form.get('body', '').strip()
block.icon = request.form.get('icon', '').strip()
block.link_url = request.form.get('link_url', '').strip()
block.link_text = request.form.get('link_text', '').strip()
block.sort_order = request.form.get('sort_order', type=int) or 0
block.is_active = True if request.form.get('is_active') else False
block.extra_json = request.form.get('extra_json', '').strip()
image_file = request.files.get('image_file')
if image_file and image_file.filename:
block.image_path = save_uploaded_asset(image_file, current_app.config['UPLOAD_FOLDER'], prefix=f'frontend_{block.section}')
if not block_id:
db.session.add(block)
db.session.commit()
log_action('upsert', 'FrontendBlock', block.id, block.section)
flash('Bloque del frontend guardado.', 'success')
return redirect(url_for('admin_site_settings', tab='blocks'))
except Exception as exc:
db.session.rollback()
store_error('admin_frontend', exc)
flash(f'No se pudo guardar el frontend: {exc}', 'danger')
return redirect(url_for('admin_site_settings', tab='blocks'))
def _mp_access_token():
return (get_setting('mercadopago_access_token', '') or os.getenv('MERCADOPAGO_ACCESS_TOKEN', '') or '').strip()
def _mp_public_base_url():
configured = (get_setting('mercadopago_public_base_url', '') or os.getenv('PUBLIC_BASE_URL', '') or '').strip().rstrip('/')
if configured:
return configured
return request.host_url.rstrip('/')
def _mp_gateway_enabled():
return get_setting('mercadopago_gateway_enabled', '0') == '1'
def _mp_enabled():
return _mp_gateway_enabled() and bool(_mp_access_token())
def _ensure_mp_ready():
if not _mp_gateway_enabled():
raise ValueError('Mercado Pago está configurado como inactivo. Activá la pasarela desde Contabilidad > Mercado Pago.')
if not _mp_access_token():
raise ValueError('Falta configurar el Access Token de Mercado Pago en Contabilidad > Mercado Pago.')
def _create_mp_preference(invoice):
_ensure_mp_ready()
token = _mp_access_token()
base_url = _mp_public_base_url()
amount = round(float(invoice.debt_amount or invoice.total_amount or 0), 2)
if amount <= 0:
raise ValueError('La liquidación no tiene deuda pendiente para cobrar por Mercado Pago.')
payload = {
'items': [{
'title': f'Liquidación SaaS {invoice.period} - {invoice.institution.name}'[:250],
'description': f'Mensualidad/plataforma sanitaria - {invoice.period}',
'quantity': 1,
'currency_id': get_setting('mercadopago_currency', 'ARS') or 'ARS',
'unit_price': amount,
}],
'external_reference': f'saas_invoice:{invoice.id}',
'notification_url': f'{base_url}/webhooks/mercadopago',
'back_urls': {
'success': f'{base_url}/pagos/mercadopago/retorno?invoice_id={invoice.id}&result=success',
'pending': f'{base_url}/pagos/mercadopago/retorno?invoice_id={invoice.id}&result=pending',
'failure': f'{base_url}/pagos/mercadopago/retorno?invoice_id={invoice.id}&result=failure',
},
'auto_return': 'approved',
'metadata': {'invoice_id': invoice.id, 'institution_id': invoice.institution_id, 'period': invoice.period},
'payer': {'name': invoice.institution.responsable or invoice.institution.name, 'email': invoice.institution.email or get_setting('mercadopago_default_payer_email', '')},
}
r = requests.post('https://api.mercadopago.com/checkout/preferences', headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}, json=payload, timeout=20)
if r.status_code >= 400:
raise ValueError(f'Mercado Pago rechazó la preferencia ({r.status_code}): {r.text[:500]}')
data = r.json()
invoice.payment_method = 'mercadopago'
invoice.mercadopago_preference_id = data.get('id')
invoice.mercadopago_init_point = data.get('init_point')
invoice.mercadopago_sandbox_init_point = data.get('sandbox_init_point')
invoice.mercadopago_status = 'preference_created'
invoice.mercadopago_payload = json.dumps(data, ensure_ascii=False)
return data
def _create_mp_test_preference(amount, description='', payer_email=''):
_ensure_mp_ready()
token = _mp_access_token()
base_url = _mp_public_base_url()
amount = round(float(amount or 0), 2)
if amount <= 0:
raise ValueError('El importe de prueba debe ser mayor a cero.')
payload = {
'items': [{
'title': (description or 'Prueba de pasarela Mercado Pago')[:250],
'description': 'Preferencia de prueba generada desde el panel del sistema.',
'quantity': 1,
'currency_id': get_setting('mercadopago_currency', 'ARS') or 'ARS',
'unit_price': amount,
}],
'external_reference': f'gateway_test:{datetime.utcnow().strftime("%Y%m%d%H%M%S")}',
'notification_url': f'{base_url}/webhooks/mercadopago',
'back_urls': {
'success': f'{base_url}/pagos/mercadopago/retorno?result=success',
'pending': f'{base_url}/pagos/mercadopago/retorno?result=pending',
'failure': f'{base_url}/pagos/mercadopago/retorno?result=failure',
},
'auto_return': 'approved',
'metadata': {'source': 'gateway_test'},
}
if payer_email:
payload['payer'] = {'email': payer_email}
r = requests.post('https://api.mercadopago.com/checkout/preferences', headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}, json=payload, timeout=20)
if r.status_code >= 400:
raise ValueError(f'Mercado Pago rechazó la prueba ({r.status_code}): {r.text[:500]}')
data = r.json()
set_setting('mercadopago_last_test_url', data.get('init_point') or data.get('sandbox_init_point') or '')
set_setting('mercadopago_last_test_preference_id', data.get('id') or '')
return data
def _apply_mp_payment(payment_data):
payment_id = str(payment_data.get('id') or '')
external_reference = payment_data.get('external_reference') or ''
metadata = payment_data.get('metadata') or {}
invoice_id = None
if str(external_reference).startswith('saas_invoice:'):
invoice_id = int(str(external_reference).split(':', 1)[1])
elif metadata.get('invoice_id'):
invoice_id = int(metadata.get('invoice_id'))
if not invoice_id:
return None
invoice = SaasInvoice.query.get(invoice_id)
if not invoice:
return None
status = payment_data.get('status') or ''
status_detail = payment_data.get('status_detail') or ''
amount = float(payment_data.get('transaction_amount') or payment_data.get('total_paid_amount') or 0)
invoice.payment_method = 'mercadopago'
invoice.mercadopago_payment_id = payment_id or invoice.mercadopago_payment_id
invoice.mercadopago_status = status
invoice.mercadopago_status_detail = status_detail
invoice.mercadopago_payload = json.dumps(payment_data, ensure_ascii=False, default=str)
if status == 'approved' and payment_id:
existing = SaasPayment.query.filter_by(mercadopago_payment_id=payment_id).first()
if not existing:
payment = SaasPayment(invoice_id=invoice.id, payment_date=date.today(), amount=amount or invoice.debt_amount, method='mercadopago', reference=payment_id, status='aprobado', mercadopago_payment_id=payment_id, mercadopago_status=status, mercadopago_status_detail=status_detail, mercadopago_payload=json.dumps(payment_data, ensure_ascii=False, default=str))
invoice.paid_amount = min((invoice.paid_amount or 0) + payment.amount, invoice.total_amount or payment.amount)
db.session.add(payment)
invoice.status = 'pagada' if (invoice.paid_amount or 0) >= (invoice.total_amount or 0) else 'parcial'
elif status in ['pending', 'in_process', 'authorized']:
invoice.status = 'pendiente' if not invoice.paid_amount else 'parcial'
elif status in ['rejected', 'cancelled', 'refunded', 'charged_back']:
invoice.notes = ((invoice.notes or '') + f'\nMercado Pago: {status} / {status_detail}').strip()
db.session.commit()
return invoice
@app.route('/admin/payment-gateway')
@login_required
@role_required('admin', 'accounting')
def admin_payment_gateway():
return redirect(url_for('admin_accounting', tab='mercadopago'))
@app.route('/admin/accounting', methods=['GET', 'POST'])
@login_required
@role_required('admin', 'accounting')
def admin_accounting():
"""Módulo SaaS: planes, suscripciones, facturación y cobro de instituciones."""
def money(raw):
return float(str(raw or '0').replace('.', '').replace(',', '.'))
action = request.form.get('action', '')
if request.method == 'POST':
try:
if action == 'save_plan':
plan_id = request.form.get('plan_id', type=int)
plan = SaasPlan.query.get(plan_id) if plan_id else SaasPlan()
plan.name = request.form.get('name', '').strip()
plan.description = request.form.get('description', '').strip()
plan.monthly_price = money(request.form.get('monthly_price'))
plan.price_per_appointment = money(request.form.get('price_per_appointment'))
plan.included_professionals = request.form.get('included_professionals', type=int) or 0
plan.extra_professional_price = money(request.form.get('extra_professional_price'))
plan.included_appointments = request.form.get('included_appointments', type=int) or 0
plan.active = bool(request.form.get('active'))
if not plan.name:
raise ValueError('El nombre del plan es obligatorio.')
if not plan_id:
db.session.add(plan)
db.session.commit()
flash('Plan SaaS guardado.', 'success')
return redirect(url_for('admin_accounting', tab='plans'))
if action == 'save_subscription':
sub_id = request.form.get('subscription_id', type=int)
sub = InstitutionSubscription.query.get(sub_id) if sub_id else InstitutionSubscription()
sub.institution_id = request.form.get('institution_id', type=int)
sub.plan_id = request.form.get('plan_id', type=int)
sub.start_date = parse_date(request.form.get('start_date'), date.today())
sub.billing_day = max(1, min(28, request.form.get('billing_day', type=int) or 1))
sub.status = request.form.get('status', 'activa').strip() or 'activa'
sub.notes = request.form.get('notes', '').strip()
if not sub.institution_id or not sub.plan_id:
raise ValueError('Seleccioná institución y plan.')
if not sub_id:
db.session.add(sub)
db.session.commit()
flash('Suscripción guardada.', 'success')
return redirect(url_for('admin_accounting', tab='subscriptions'))
if action == 'generate_invoice':
sub = InstitutionSubscription.query.get_or_404(request.form.get('subscription_id', type=int))
period = request.form.get('period') or date.today().strftime('%Y-%m')
existing = SaasInvoice.query.filter_by(subscription_id=sub.id, period=period).filter(SaasInvoice.status != 'anulada').first()
if existing:
raise ValueError('Ya existe una liquidación activa para esa institución y período. Si la anterior fue anulada, podés generar una nueva.')
year, mon = [int(x) for x in period.split('-')]
start = date(year, mon, 1)
end = date(year + (mon == 12), 1 if mon == 12 else mon + 1, 1)
appt_count = Appointment.query.filter(Appointment.institution_id == sub.institution_id, Appointment.appointment_date >= start, Appointment.appointment_date < end).count()
prof_count = ProfessionalProfile.query.filter_by(institution_id=sub.institution_id).count()
plan = sub.plan
billable_appts = max(appt_count - (plan.included_appointments or 0), 0)
extra_profs = max(prof_count - (plan.included_professionals or 0), 0)
invoice = SaasInvoice(
institution_id=sub.institution_id,
subscription_id=sub.id,
period=period,
issue_date=date.today(),
due_date=date.today() + timedelta(days=10),
monthly_amount=plan.monthly_price or 0,
appointment_count=appt_count,
appointment_amount=billable_appts * (plan.price_per_appointment or 0),
professional_count=prof_count,
extra_professional_amount=extra_profs * (plan.extra_professional_price or 0),
status='pendiente',
)
invoice.total_amount = (invoice.monthly_amount or 0) + (invoice.appointment_amount or 0) + (invoice.extra_professional_amount or 0)
db.session.add(invoice)
db.session.commit()
flash('Liquidación SaaS generada.', 'success')
return redirect(url_for('admin_accounting', tab='invoices'))
if action == 'register_payment':
invoice = SaasInvoice.query.get_or_404(request.form.get('invoice_id', type=int))
if invoice.status == 'anulada':
raise ValueError('No se puede registrar un pago sobre una liquidación anulada.')
payment_amount = money(request.form.get('amount'))
if payment_amount <= 0:
raise ValueError('El importe del pago debe ser mayor a cero.')
payment = SaasPayment(
invoice_id=invoice.id,
payment_date=parse_date(request.form.get('payment_date'), date.today()),
amount=payment_amount,
method=request.form.get('method', 'efectivo').strip() or 'efectivo',
reference=request.form.get('reference', '').strip(),
notes=request.form.get('notes', '').strip(),
status='registrado',
created_by_user_id=current_user.id,
)
invoice.paid_amount = min((invoice.paid_amount or 0) + payment.amount, invoice.total_amount or payment.amount)
invoice.payment_method = payment.method
invoice.status = 'pagada' if invoice.paid_amount >= (invoice.total_amount or 0) else 'parcial'
db.session.add(payment)
db.session.commit()
flash('Pago registrado. El estado de deuda fue actualizado.', 'success')
return redirect(url_for('admin_accounting', tab='invoices'))
if action == 'void_invoice':
invoice = SaasInvoice.query.get_or_404(request.form.get('invoice_id', type=int))
if (invoice.paid_amount or 0) > 0:
raise ValueError('No se puede anular una liquidación con pagos registrados. Primero verificá o reversá los pagos manualmente.')
reason = request.form.get('void_reason', '').strip()
invoice.status = 'anulada'
invoice.notes = ((invoice.notes or '') + f"\nANULADA {date.today().isoformat()}: {reason or 'sin motivo informado'}").strip()
db.session.commit()
log_action('void', 'SaasInvoice', invoice.id, reason)
flash('Liquidación anulada correctamente.', 'success')
return redirect(url_for('admin_accounting', tab='invoices'))
if action == 'save_mp_config':
incoming_token = request.form.get('access_token', '').strip()
if incoming_token:
set_setting('mercadopago_access_token', incoming_token)
set_setting('mercadopago_gateway_enabled', '1' if request.form.get('gateway_enabled') else '0')
set_setting('mercadopago_public_base_url', request.form.get('public_base_url', '').strip().rstrip('/'))
set_setting('mercadopago_currency', request.form.get('currency', 'ARS').strip() or 'ARS')
set_setting('mercadopago_default_payer_email', request.form.get('default_payer_email', '').strip())
flash('Configuración de Mercado Pago guardada.', 'success')
return redirect(url_for('admin_accounting', tab='mercadopago'))
if action == 'create_mp_test':
data = _create_mp_test_preference(
money(request.form.get('test_amount') or '100'),
request.form.get('test_description', '').strip(),
request.form.get('test_payer_email', '').strip() or get_setting('mercadopago_default_payer_email', ''),
)
flash(f'Preferencia de prueba creada: {data.get("id", "sin id")}. Abrí el link desde la pestaña Mercado Pago.', 'success')
return redirect(url_for('admin_accounting', tab='mercadopago'))
if action == 'prepare_mp':
invoice = SaasInvoice.query.get_or_404(request.form.get('invoice_id', type=int))
_create_mp_preference(invoice)
db.session.commit()
flash('Preferencia de Mercado Pago generada. Ya podés abrir el link de pago.', 'success')
return redirect(url_for('admin_accounting', tab='invoices'))
except Exception as exc:
db.session.rollback()
store_error('admin_accounting_saas', exc)
flash(f'No se pudo procesar Contabilidad SaaS: {exc}', 'danger')
tab = request.args.get('tab', 'dashboard')
period = request.args.get('period', date.today().strftime('%Y-%m'))
selected_institution_id = request.args.get('institution_id', type=int)
selected_status = (request.args.get('status') or '').strip()
selected_period = (request.args.get('period_filter') or '').strip()
selected_search = (request.args.get('search') or '').strip()
history_institution_id = request.args.get('history_institution_id', type=int) or selected_institution_id
institutions = Institution.query.order_by(Institution.name.asc()).all()
plans = SaasPlan.query.order_by(SaasPlan.active.desc(), SaasPlan.name.asc()).all()
subscriptions = InstitutionSubscription.query.order_by(InstitutionSubscription.created_at.desc()).all()
invoice_query = SaasInvoice.query.join(Institution)
if selected_institution_id:
invoice_query = invoice_query.filter(SaasInvoice.institution_id == selected_institution_id)
if selected_status:
invoice_query = invoice_query.filter(SaasInvoice.status == selected_status)
if selected_period:
invoice_query = invoice_query.filter(SaasInvoice.period == selected_period)
if selected_search:
like = f"%{selected_search}%"
invoice_query = invoice_query.filter(or_(Institution.name.ilike(like), Institution.cuit.ilike(like), SaasInvoice.period.ilike(like), SaasInvoice.notes.ilike(like)))
invoices = invoice_query.order_by(SaasInvoice.issue_date.desc(), SaasInvoice.id.desc()).limit(500).all()
payments_query = SaasPayment.query.join(SaasInvoice).join(Institution)
if selected_institution_id:
payments_query = payments_query.filter(SaasInvoice.institution_id == selected_institution_id)
payments = payments_query.order_by(SaasPayment.payment_date.desc(), SaasPayment.id.desc()).limit(200).all()
total_billed = db.session.query(func.coalesce(func.sum(SaasInvoice.total_amount), 0)).filter(SaasInvoice.status != 'anulada').scalar() or 0
total_paid = db.session.query(func.coalesce(func.sum(SaasInvoice.paid_amount), 0)).filter(SaasInvoice.status != 'anulada').scalar() or 0
total_debt = max(total_billed - total_paid, 0)
overdue_count = SaasInvoice.query.filter(SaasInvoice.status.in_(['pendiente','parcial']), SaasInvoice.due_date < date.today()).count()
history_institution = Institution.query.get(history_institution_id) if history_institution_id else None
history_invoices = []
history_payments = []
history_totals = {'billed': 0, 'paid': 0, 'debt': 0}
if history_institution:
history_invoices = SaasInvoice.query.filter_by(institution_id=history_institution.id).order_by(SaasInvoice.period.desc(), SaasInvoice.id.desc()).all()
history_payments = SaasPayment.query.join(SaasInvoice).filter(SaasInvoice.institution_id == history_institution.id).order_by(SaasPayment.payment_date.desc(), SaasPayment.id.desc()).all()
history_totals['billed'] = sum((i.total_amount or 0) for i in history_invoices if i.status != 'anulada')
history_totals['paid'] = sum((i.paid_amount or 0) for i in history_invoices if i.status != 'anulada')
history_totals['debt'] = max(history_totals['billed'] - history_totals['paid'], 0)
mp_base_url = get_setting('mercadopago_public_base_url', '') or os.getenv('PUBLIC_BASE_URL', '') or request.host_url.rstrip('/')
mp_config = {
'enabled': _mp_enabled(),
'gateway_enabled': _mp_gateway_enabled(),
'token_configured': bool(_mp_access_token()),
'access_token_masked': ('••••' + _mp_access_token()[-6:]) if _mp_access_token() else '',
'public_base_url': mp_base_url,
'currency': get_setting('mercadopago_currency', 'ARS') or 'ARS',
'default_payer_email': get_setting('mercadopago_default_payer_email', ''),
'webhook_url': f'{mp_base_url.rstrip("/")}/webhooks/mercadopago',
'return_url': f'{mp_base_url.rstrip("/")}/pagos/mercadopago/retorno',
'last_test_url': get_setting('mercadopago_last_test_url', ''),
'last_test_preference_id': get_setting('mercadopago_last_test_preference_id', ''),
}
return render_template('admin_accounting.html', tab=tab, period=period, institutions=institutions, plans=plans, subscriptions=subscriptions, invoices=invoices, payments=payments, total_billed=total_billed, total_paid=total_paid, total_debt=total_debt, overdue_count=overdue_count, mp_config=mp_config, selected_institution_id=selected_institution_id, selected_status=selected_status, selected_period=selected_period, selected_search=selected_search, history_institution=history_institution, history_invoices=history_invoices, history_payments=history_payments, history_totals=history_totals, today_value=date.today().isoformat())
@app.route('/admin/accounting/invoice/<int:invoice_id>/mp/pay')
@login_required
@role_required('admin', 'accounting')
def accounting_mp_pay(invoice_id):
invoice = SaasInvoice.query.get_or_404(invoice_id)
try:
if not invoice.mercadopago_init_point and not invoice.mercadopago_sandbox_init_point:
_create_mp_preference(invoice)
db.session.commit()
except Exception as exc:
db.session.rollback()
store_error('accounting_mp_pay', exc)
flash(f'No se pudo generar el link de Mercado Pago: {exc}', 'danger')
return redirect(url_for('admin_accounting', tab='mercadopago'))
target = invoice.mercadopago_init_point or invoice.mercadopago_sandbox_init_point
if not target:
flash('No se pudo obtener el link de pago de Mercado Pago.', 'danger')
return redirect(url_for('admin_accounting', tab='invoices'))
return redirect(target)
def _invoice_pdf_response(invoice, download_name=None):
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
width, height = A4
y = height - 25 * mm
c.setFont('Helvetica-Bold', 16)
c.drawString(20 * mm, y, 'Liquidación SaaS / Comprobante')
y -= 10 * mm
c.setFont('Helvetica', 10)
c.drawString(20 * mm, y, f'Institución: {invoice.institution.name}')
y -= 6 * mm
c.drawString(20 * mm, y, f'CUIT: {invoice.institution.cuit or ""}')
y -= 6 * mm
c.drawString(20 * mm, y, f'Período: {invoice.period} Estado: {invoice.status}')
y -= 6 * mm
c.drawString(20 * mm, y, f'Emisión: {invoice.issue_date.strftime("%d/%m/%Y") if invoice.issue_date else ""} Vencimiento: {invoice.due_date.strftime("%d/%m/%Y") if invoice.due_date else ""}')
y -= 12 * mm
c.setFont('Helvetica-Bold', 11)
c.drawString(20 * mm, y, 'Detalle')
y -= 7 * mm
c.setFont('Helvetica', 10)
rows = [
('Mensualidad del plan', invoice.monthly_amount or 0),
(f'Turnos facturados ({invoice.appointment_count or 0})', invoice.appointment_amount or 0),
(f'Profesionales extra ({invoice.professional_count or 0})', invoice.extra_professional_amount or 0),
]
for label, amount in rows:
c.drawString(24 * mm, y, label)
c.drawRightString(185 * mm, y, f'$ {amount:,.2f}')
y -= 6 * mm
y -= 3 * mm
c.line(20 * mm, y, 190 * mm, y)
y -= 8 * mm
c.setFont('Helvetica-Bold', 11)
c.drawString(24 * mm, y, 'Total')
c.drawRightString(185 * mm, y, f'$ {(invoice.total_amount or 0):,.2f}')
y -= 7 * mm
c.drawString(24 * mm, y, 'Pagado')
c.drawRightString(185 * mm, y, f'$ {(invoice.paid_amount or 0):,.2f}')
y -= 7 * mm
c.drawString(24 * mm, y, 'Deuda')
c.drawRightString(185 * mm, y, f'$ {(invoice.debt_amount or 0):,.2f}')
y -= 14 * mm
c.setFont('Helvetica-Bold', 11)
c.drawString(20 * mm, y, 'Pagos registrados')
y -= 7 * mm
c.setFont('Helvetica', 9)
payments = invoice.payments.order_by(SaasPayment.payment_date.asc()).all()
if payments:
for pay in payments:
c.drawString(24 * mm, y, f'{pay.payment_date.strftime("%d/%m/%Y") if pay.payment_date else ""} - {pay.method} - {pay.reference or pay.mercadopago_payment_id or ""}')
c.drawRightString(185 * mm, y, f'$ {(pay.amount or 0):,.2f}')
y -= 6 * mm
if y < 30 * mm:
c.showPage()
y = height - 25 * mm
c.setFont('Helvetica', 9)
else:
c.drawString(24 * mm, y, 'Sin pagos registrados.')
y -= 6 * mm
if invoice.notes:
y -= 8 * mm
c.setFont('Helvetica-Bold', 10)
c.drawString(20 * mm, y, 'Notas')
y -= 6 * mm
c.setFont('Helvetica', 9)
for line in str(invoice.notes).splitlines()[:8]:
c.drawString(24 * mm, y, line[:115])
y -= 5 * mm
c.setFont('Helvetica', 8)
c.drawString(20 * mm, 15 * mm, f'Emitido desde la plataforma el {datetime.now().strftime("%d/%m/%Y %H:%M")}')
c.showPage()
c.save()
buffer.seek(0)
return send_file(buffer, mimetype='application/pdf', as_attachment=True, download_name=download_name or f'liquidacion_saas_{invoice.id}.pdf')
@app.route('/admin/accounting/invoice/<int:invoice_id>/receipt.pdf')
@login_required
@role_required('admin', 'accounting')
def accounting_invoice_receipt_pdf(invoice_id):
invoice = SaasInvoice.query.get_or_404(invoice_id)
return _invoice_pdf_response(invoice, f'comprobante_saas_{invoice.institution.name}_{invoice.period}.pdf')
@app.route('/admin/accounting/invoices/export.xlsx')
@login_required
@role_required('admin', 'accounting')
def admin_accounting_export_xlsx():
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = 'Liquidaciones SaaS'
headers = ['ID', 'Periodo', 'Institucion', 'CUIT', 'Emision', 'Vencimiento', 'Turnos', 'Profesionales', 'Total', 'Pagado', 'Deuda', 'Estado', 'Medio de pago']
ws.append(headers)
q = SaasInvoice.query.join(Institution)
institution_id = request.args.get('institution_id', type=int)
status = (request.args.get('status') or '').strip()
period_filter = (request.args.get('period_filter') or '').strip()
search = (request.args.get('search') or '').strip()
if institution_id:
q = q.filter(SaasInvoice.institution_id == institution_id)
if status:
q = q.filter(SaasInvoice.status == status)
if period_filter:
q = q.filter(SaasInvoice.period == period_filter)
if search:
like = f'%{search}%'
q = q.filter(or_(Institution.name.ilike(like), Institution.cuit.ilike(like), SaasInvoice.period.ilike(like)))
for inv in q.order_by(SaasInvoice.period.desc(), SaasInvoice.id.desc()).all():
ws.append([inv.id, inv.period, inv.institution.name, inv.institution.cuit, inv.issue_date, inv.due_date, inv.appointment_count, inv.professional_count, inv.total_amount, inv.paid_amount, inv.debt_amount, inv.status, inv.payment_method])
for col in ws.columns:
ws.column_dimensions[col[0].column_letter].width = max(12, min(34, max(len(str(c.value or '')) for c in col) + 2))
buffer = io.BytesIO()
wb.save(buffer)
buffer.seek(0)
return send_file(buffer, mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', as_attachment=True, download_name='liquidaciones_saas.xlsx')
@app.route('/admin/accounting/invoices/export.pdf')
@login_required
@role_required('admin', 'accounting')
def admin_accounting_export_pdf():
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas
q = SaasInvoice.query.join(Institution)
institution_id = request.args.get('institution_id', type=int)
status = (request.args.get('status') or '').strip()
period_filter = (request.args.get('period_filter') or '').strip()
if institution_id:
q = q.filter(SaasInvoice.institution_id == institution_id)
if status:
q = q.filter(SaasInvoice.status == status)
if period_filter:
q = q.filter(SaasInvoice.period == period_filter)
invoices = q.order_by(SaasInvoice.period.desc(), SaasInvoice.id.desc()).all()
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=landscape(A4))
width, height = landscape(A4)
y = height - 18 * mm
c.setFont('Helvetica-Bold', 14)
c.drawString(15 * mm, y, 'Reporte de liquidaciones SaaS')
y -= 9 * mm
c.setFont('Helvetica-Bold', 8)
x_positions = [15, 38, 62, 125, 150, 175, 200, 225, 250]
headers = ['ID', 'Periodo', 'Institución', 'Total', 'Pagado', 'Deuda', 'Estado', 'Vence', 'MP']
for x, h in zip(x_positions, headers):
c.drawString(x * mm, y, h)
y -= 5 * mm
c.setFont('Helvetica', 8)
for inv in invoices:
if y < 18 * mm:
c.showPage()
y = height - 18 * mm
c.setFont('Helvetica', 8)
values = [inv.id, inv.period, inv.institution.name[:34], f'$ {(inv.total_amount or 0):,.2f}', f'$ {(inv.paid_amount or 0):,.2f}', f'$ {(inv.debt_amount or 0):,.2f}', inv.status, inv.due_date.strftime('%d/%m/%Y') if inv.due_date else '', inv.mercadopago_status or '']
for x, v in zip(x_positions, values):
c.drawString(x * mm, y, str(v))
y -= 5 * mm
c.showPage()
c.save()
buffer.seek(0)
return send_file(buffer, mimetype='application/pdf', as_attachment=True, download_name='reporte_liquidaciones_saas.pdf')
@app.route('/institution/billing')
@login_required
@role_required('admin', 'accounting', 'receptionist')
def institution_billing():
institution_id = request.args.get('institution_id', type=int) if current_user.role in ['admin', 'accounting'] else None
if not institution_id:
institution_id = current_user.institution_id
if not institution_id:
flash('Tu usuario no tiene una institución vinculada para consultar facturación.', 'warning')
return redirect(url_for('dashboard'))
institution = Institution.query.get_or_404(institution_id)
invoices = SaasInvoice.query.filter_by(institution_id=institution.id).order_by(SaasInvoice.period.desc(), SaasInvoice.id.desc()).all()
payments = SaasPayment.query.join(SaasInvoice).filter(SaasInvoice.institution_id == institution.id).order_by(SaasPayment.payment_date.desc(), SaasPayment.id.desc()).all()
total_billed = sum((i.total_amount or 0) for i in invoices if i.status != 'anulada')
total_paid = sum((i.paid_amount or 0) for i in invoices if i.status != 'anulada')
total_debt = max(total_billed - total_paid, 0)
institutions = Institution.query.order_by(Institution.name.asc()).all() if current_user.role in ['admin', 'accounting'] else []
return render_template('institution_billing.html', institution=institution, invoices=invoices, payments=payments, total_billed=total_billed, total_paid=total_paid, total_debt=total_debt, institutions=institutions)
@app.route('/institution/billing/invoice/<int:invoice_id>/mp/pay')
@login_required
@role_required('admin', 'accounting', 'receptionist')
def institution_billing_mp_pay(invoice_id):
invoice = SaasInvoice.query.get_or_404(invoice_id)
if current_user.role not in ['admin', 'accounting'] and invoice.institution_id != current_user.institution_id:
abort(403)
if invoice.status == 'anulada':
flash('La liquidación está anulada y no puede pagarse.', 'warning')
return redirect(url_for('institution_billing'))
try:
if not invoice.mercadopago_init_point and not invoice.mercadopago_sandbox_init_point:
_create_mp_preference(invoice)
db.session.commit()
except Exception as exc:
db.session.rollback()
store_error('institution_billing_mp_pay', exc)
flash(f'No se pudo generar el link de Mercado Pago: {exc}', 'danger')
return redirect(url_for('institution_billing', institution_id=invoice.institution_id))
target = invoice.mercadopago_init_point or invoice.mercadopago_sandbox_init_point
return redirect(target or url_for('institution_billing', institution_id=invoice.institution_id))
@app.route('/institution/billing/invoice/<int:invoice_id>/receipt.pdf')
@login_required
@role_required('admin', 'accounting', 'receptionist')
def institution_invoice_receipt_pdf(invoice_id):
invoice = SaasInvoice.query.get_or_404(invoice_id)
if current_user.role not in ['admin', 'accounting'] and invoice.institution_id != current_user.institution_id:
abort(403)
return _invoice_pdf_response(invoice, f'comprobante_saas_{invoice.period}.pdf')
@app.route('/pagos/mercadopago/retorno')
def mercadopago_return():
invoice_id = request.args.get('invoice_id', type=int)
result = request.args.get('result', '')
collection_status = request.args.get('collection_status') or request.args.get('status') or result
payment_id = request.args.get('payment_id') or request.args.get('collection_id')
invoice = SaasInvoice.query.get(invoice_id) if invoice_id else None
if invoice and payment_id:
token = _mp_access_token()
if token:
try:
r = requests.get(f'https://api.mercadopago.com/v1/payments/{payment_id}', headers={'Authorization': f'Bearer {token}'}, timeout=20)
if r.status_code < 400:
_apply_mp_payment(r.json())
except Exception as exc:
store_error('mercadopago_return', exc)
return render_template('error.html', title='Pago recibido', message=f'Estado informado por Mercado Pago: {collection_status}. Si el pago fue aprobado, la liquidación se actualizará por webhook automáticamente.')
@app.route('/webhooks/mercadopago', methods=['POST', 'GET'])
def mercadopago_webhook():
payload = request.get_json(silent=True) or request.args.to_dict() or {}
topic = payload.get('type') or payload.get('topic') or request.args.get('topic') or request.args.get('type')
data = payload.get('data') or {}
payment_id = data.get('id') if isinstance(data, dict) else None
payment_id = payment_id or payload.get('id') or request.args.get('id') or request.args.get('data.id')
if topic in ['payment', 'merchant_order'] and payment_id:
token = _mp_access_token()
if token:
try:
r = requests.get(f'https://api.mercadopago.com/v1/payments/{payment_id}', headers={'Authorization': f'Bearer {token}'}, timeout=20)
if r.status_code < 400:
_apply_mp_payment(r.json())
except Exception as exc:
store_error('mercadopago_webhook', exc)
return jsonify({'ok': True})
@app.route('/admin/orders', methods=['GET', 'POST'])
@app.route('/admin/recipes', methods=['GET', 'POST'])
@login_required
@role_required('admin', 'professional')
def admin_recipes():
active_kind = normalize_order_kind(request.values.get('kind', 'recipe'))
meta = order_kind_meta(active_kind)
if request.method == 'POST':
action = request.form.get('action', 'create')
if action == 'create':
try:
auth_email = request.form.get('auth_email', '').strip().lower()
auth_password = request.form.get('auth_password', '')
actor = validate_recipe_credentials(auth_email, auth_password)
patient_id = request.form.get('patient_id', type=int)
if not patient_id:
raise ValueError('Seleccioná un paciente para emitir la orden.')
patient = Patient.query.get_or_404(patient_id)
if current_user.role == 'professional' and current_user.professional_profile:
professional = current_user.professional_profile
if actor.role == 'professional' and actor.professional_profile and actor.professional_profile.id != professional.id:
raise ValueError('Las credenciales no coinciden con el profesional autenticado en sesión.')
elif actor.role == 'professional':
professional = actor.professional_profile
else:
professional_id = request.form.get('professional_id', type=int)
professional = ProfessionalProfile.query.get_or_404(professional_id)
if not professional:
raise ValueError('No se encontró el profesional emisor.')
document_kind = normalize_order_kind(request.form.get('document_kind', active_kind))
platform_number = get_setting('site_platform_number', '0000')
repository_number = get_setting('site_repository_number', '0000')
jurisdiction_name = professional.jurisdiction_name or professional.province or get_setting('site_province')
jurisdiction_code = resolve_jurisdiction_code(jurisdiction_name)
prescription_type = request.form.get('prescription_type', '01')
prescription_subtype = request.form.get('prescription_subtype', '02')
if document_kind != 'recipe':
prescription_type = '03'
prescription_subtype = '00'
elif prescription_type in ['02', '03']:
prescription_subtype = '00'
prescription_group = request.form.get('prescription_group', '').strip() or generate_prescription_group()
item_number = request.form.get('item_number', '01').strip() or '01'
legal_number = generate_legal_number()
cuir = build_cuir(platform_number, repository_number, jurisdiction_code, prescription_type, prescription_subtype, prescription_group, item_number)
issue_date = parse_date(request.form.get('prescription_date'), date.today())
expires_at = issue_date + timedelta(days=30)
document_title = sanitize_text(request.form.get('document_title', '').strip(), 255)
document_body = sanitize_multiline_text(request.form.get('document_body', '').strip(), 4000)
medication_generic_name = sanitize_text(request.form.get('medication_generic_name', '').strip(), 255)
if document_kind == 'recipe':
if not medication_generic_name:
raise ValueError('Ingresá el medicamento / DCI.')
else:
if not document_title:
raise ValueError(f"Ingresá el título principal de la {order_kind_meta(document_kind)['label'].lower()}.")
if not medication_generic_name:
medication_generic_name = document_title
recipe = Prescription(
institution_id=patient.institution_id or getattr(professional, 'institution_id', None) or getattr(actor, 'institution_id', None),
patient_id=patient.id, professional_id=professional.id, issued_by_user_id=actor.id,
prescription_date=issue_date, expires_at=expires_at, legal_number=legal_number, cuir=cuir,
platform_number=platform_number, repository_number=repository_number, jurisdiction_code=jurisdiction_code,
prescription_type=prescription_type, prescription_subtype=prescription_subtype, prescription_group=prescription_group, item_number=item_number,
document_kind=document_kind, document_title=document_title, document_body=document_body, portal_visible=(document_kind == 'recipe'),
patient_full_name=patient.nombre_completo, patient_document=patient.documento, patient_birth_date=patient.fecha_nacimiento, patient_gender=patient.genero,
patient_obra_social=patient.obra_social.denominacion if patient.obra_social else 'Particular', patient_plan=patient.afiliado_nro,
professional_display_name=professional.display_name, professional_profession_name=professional.profession_name, professional_specialty=professional.specialty,
professional_matricula=professional.matricula, professional_jurisdiction_name=resolve_jurisdiction_name(jurisdiction_name),
professional_address=professional.full_address or professional.location,
medication_generic_name=medication_generic_name,
medication_presentation=sanitize_text(request.form.get('medication_presentation', '').strip(), 255),
pharmaceutical_form=sanitize_text(request.form.get('pharmaceutical_form', '').strip(), 255),
quantity_units=sanitize_text(request.form.get('quantity_units', '').strip(), 120),
diagnosis=sanitize_text(request.form.get('diagnosis', '').strip(), 255), dosage_instructions=sanitize_multiline_text(request.form.get('dosage_instructions', '').strip(), 4000),
barcode_value=cuir, platform_registry_legend=f"Documento clínico registrado por plataforma {platform_number} / repositorio {repository_number}",
internal_notes=sanitize_multiline_text(request.form.get('internal_notes', '').strip(), 4000),
)
db.session.add(recipe)
db.session.commit()
log_action('order_issue', 'Prescription', recipe.id, f"{document_kind} emitida por {actor.email} | {recipe.legal_number} / {recipe.cuir}")
flash(f"{order_kind_meta(document_kind)['label']} generada correctamente.", 'success')
return redirect(url_for('admin_recipes', patient_id=patient.id, kind=document_kind))
except Exception as exc:
db.session.rollback()
store_error('admin_recipes_create', exc)
log_action('order_issue_failed', 'Prescription', None, str(exc))
flash(f'No se pudo generar el documento: {exc}', 'danger')
return redirect(url_for('admin_recipes', patient_id=request.form.get('patient_id', ''), kind=request.form.get('document_kind', active_kind)))
patient_id = request.args.get('patient_id', type=int)
q = request.args.get('q', '').strip()
status = request.args.get('status', '').strip()
page = request.args.get('page', type=int, default=1)
selected_patient = Patient.query.get(patient_id) if patient_id else None
query = scoped_query(Prescription).filter(Prescription.document_kind == active_kind)
if current_user.role == 'professional' and current_user.professional_profile:
query = query.filter(Prescription.professional_id == current_user.professional_profile.id)
if patient_id:
query = query.filter(Prescription.patient_id == patient_id)
elif not q:
query = query.filter(Prescription.id == -1)
if q:
like = f'%{q}%'
query = query.filter(or_(Prescription.patient_full_name.ilike(like), Prescription.patient_document.ilike(like), Prescription.cuir.ilike(like), Prescription.legal_number.ilike(like), Prescription.professional_display_name.ilike(like), Prescription.document_title.ilike(like), Prescription.medication_generic_name.ilike(like)))
if status:
if status == 'Vencida':
query = query.filter(Prescription.status == 'Activa', Prescription.expires_at < date.today())
else:
query = query.filter(Prescription.status == status)
pagination = query.order_by(Prescription.issued_at.desc()).paginate(page=page, per_page=12, error_out=False)
recipes = pagination.items
patients = patient_active_query().order_by(Patient.apellido.asc(), Patient.nombre.asc()).all()
professionals = admin_professional_choices(include_hidden=True)
return render_template('admin_recipes.html', recipes=recipes, pagination=pagination, patients=patients, professionals=professionals, selected_patient=selected_patient, selected_patient_id=patient_id, statuses=['Activa','Vencida','Suspendida','Anulada'], type_options=PRESCRIPTION_TYPE_OPTIONS, subtype_options=PRESCRIPTION_SUBTYPE_OPTIONS, active_kind=active_kind, active_kind_meta=meta, order_kind_options=ORDER_KIND_OPTIONS, order_kind_meta=ORDER_KIND_META)
@app.route('/admin/recipes/<int:recipe_id>/status', methods=['POST'])
@login_required
@role_required('admin', 'professional')
def admin_recipe_status(recipe_id):
recipe = Prescription.query.get_or_404(recipe_id)
if current_user.role == 'professional' and current_user.professional_profile and recipe.professional_id != current_user.professional_profile.id:
abort(403)
new_status = request.form.get('status', 'Suspendida').strip()
recipe.status = new_status
recipe.suspended_reason = request.form.get('reason', '').strip() or f'Cambio manual a {new_status}'
recipe.suspended_at = datetime.utcnow()
db.session.commit()
log_action('update_status', 'Prescription', recipe.id, f'{new_status} | {recipe.suspended_reason}')
flash(f"Estado de la {order_kind_meta(recipe.document_kind)['label'].lower()} actualizado.", 'success')
return redirect(url_for('admin_recipes', kind=normalize_order_kind(recipe.document_kind)))
@app.route('/admin/recipes/<int:recipe_id>/pdf')
@login_required
@role_required('admin', 'professional')
def admin_recipe_pdf(recipe_id):
recipe = Prescription.query.get_or_404(recipe_id)
if current_user.role == 'professional' and current_user.professional_profile and recipe.professional_id != current_user.professional_profile.id:
abort(403)
pdf_bytes = create_prescription_pdf_bytes(recipe)
log_action('recipe_pdf', 'Prescription', recipe.id, recipe.legal_number)
kind = normalize_order_kind(recipe.document_kind)
filename_prefix = {'recipe':'receta','practice':'practica','report':'informe','result':'resultado'}.get(kind, 'documento')
return send_file(io.BytesIO(pdf_bytes), mimetype='application/pdf', as_attachment=False, download_name=f'{filename_prefix}_{recipe.legal_number}.pdf')
@app.route('/admin/recipes/<int:recipe_id>/send-email', methods=['POST'])
@login_required
@role_required('admin', 'professional')
def admin_recipe_send_email(recipe_id):
recipe = Prescription.query.get_or_404(recipe_id)
if current_user.role == 'professional' and current_user.professional_profile and recipe.professional_id != current_user.professional_profile.id:
abort(403)
patient = recipe.patient
if not patient or not patient.email:
flash('El paciente no tiene email cargado.', 'danger')
return redirect(url_for('admin_recipes', patient_id=recipe.patient_id, kind=normalize_order_kind(recipe.document_kind)))
try:
ctx = build_recipe_mail_context(recipe)
smtp = get_smtp_settings()
kind = normalize_order_kind(recipe.document_kind)
label = order_kind_meta(kind)['label']
subject = render_message_template(smtp.get(f'{kind}_subject') or smtp['recipe_subject'], ctx)
html_body = render_message_template(smtp.get(f'{kind}_html') or smtp['recipe_html'], ctx)
pdf_bytes = create_prescription_pdf_bytes(recipe)
filename_prefix = {'recipe':'receta','practice':'practica','report':'informe','result':'resultado'}.get(kind, 'documento')
send_smtp_email(patient.email, subject, html_body, attachments=[{'content': pdf_bytes, 'maintype': 'application', 'subtype': 'pdf', 'filename': f'{filename_prefix}_{recipe.legal_number}.pdf'}])
log_action('order_email_sent', 'Prescription', recipe.id, f'{kind} | {patient.email}')
flash(f'{label} enviada por email al paciente.', 'success')
except Exception as exc:
store_error('admin_recipe_send_email', exc, extra=recipe.legal_number)
flash(f'No se pudo enviar el email: {exc}', 'danger')
return redirect(url_for('admin_recipes', patient_id=recipe.patient_id, kind=normalize_order_kind(recipe.document_kind)))
@app.route('/recetas/verificar', methods=['GET'])
def public_verify_recipe():
q = (request.args.get('q') or request.args.get('nro') or request.args.get('cuir') or '').strip()
result = None
if q:
result = Prescription.query.filter(Prescription.document_kind == 'recipe').filter(or_(Prescription.cuir == q, Prescription.legal_number == q)).order_by(Prescription.id.desc()).first()
log_action('public_verify', 'Prescription', result.id if result else None, q)
return render_template('receta_verify.html', query=q, result=build_recipe_public_payload(result) if result else None, not_found=bool(q and not result))
@app.route('/admin/messaging', methods=['GET', 'POST'])
@login_required
@role_required('admin')
def admin_messaging():
if request.method == 'POST':
for key in ['smtp_host','smtp_port','smtp_username','smtp_password','smtp_from_email','smtp_from_name','message_recipe_subject','message_recipe_html','message_practice_subject','message_practice_html','message_report_subject','message_report_html','message_result_subject','message_result_html']:
if key in request.form:
set_setting(key, request.form.get(key, '').strip())
if 'smtp_use_tls' in request.form or 'smtp_host' in request.form or 'smtp_port' in request.form:
set_setting('smtp_use_tls', '1' if request.form.get('smtp_use_tls') else '0')
log_action('update', 'MessagingConfig', None, 'SMTP y plantillas actualizadas')
flash('Configuración de mensajería actualizada.', 'success')
return redirect(url_for('admin_messaging'))
settings = get_smtp_settings()
return render_template('admin_messaging.html', settings=settings)
@app.route('/admin/backups', methods=['GET', 'POST'])
@login_required
@role_required('admin')
def admin_backups():
if request.method == 'POST':
action = request.form.get('action', 'save_config')
if action == 'save_config':
for key in ['backup_provider','backup_target_path','backup_retention_years','backup_remote_host','backup_remote_user','backup_remote_password']:
set_setting(key, request.form.get(key, '').strip())
log_action('update', 'BackupConfig', None, 'Configuración de backups actualizada')
flash('Configuración de backup actualizada.', 'success')
elif action in ['create_full','create_recipes']:
scope = 'full' if action == 'create_full' else 'recipes'
try:
record, _ = create_backup_file(scope)
log_action('backup_create', 'BackupRecord', record.id, f'{record.scope} | {record.filename}')
flash(f'Backup generado: {record.filename}', 'success')
except Exception as exc:
store_error('admin_backups_create', exc, extra=action)
flash(f'No se pudo generar el backup: {exc}', 'danger')
return redirect(url_for('admin_backups'))
records = BackupRecord.query.order_by(BackupRecord.created_at.desc()).limit(100).all()
settings = {
'provider': get_setting('backup_provider', 'local'),
'target_path': get_setting('backup_target_path', ''),
'retention_years': get_setting('backup_retention_years', '3'),
'remote_host': get_setting('backup_remote_host', ''),
'remote_user': get_setting('backup_remote_user', ''),
'remote_password': get_setting('backup_remote_password', ''),
}
return render_template('admin_backups.html', records=records, settings=settings)
@app.route('/admin/backups/<int:record_id>/download')
@login_required
@role_required('admin')
def admin_backup_download(record_id):
record = BackupRecord.query.get_or_404(record_id)
full_path = os.path.normpath(os.path.join(current_app.root_path, record.relative_path))
if not os.path.exists(full_path):
flash('El archivo de backup no existe en disco.', 'danger')
return redirect(url_for('admin_backups'))
log_action('backup_download', 'BackupRecord', record.id, record.filename)
return send_file(full_path, as_attachment=True, download_name=record.filename)
@app.route('/admin/reports')
@login_required
@role_required('admin', 'receptionist', 'professional')
def admin_reports():
today = date.today()
start = parse_date(request.args.get('start'), today.replace(day=1))
end = parse_date(request.args.get('end'), today)
query = Appointment.query.filter(Appointment.appointment_date >= start, Appointment.appointment_date <= end)
if current_user.role == 'professional' and current_user.professional_profile:
query = query.filter(Appointment.professional_id == current_user.professional_profile.id)
appointments = query.all()
total = len(appointments)
confirmed = len([a for a in appointments if a.status == 'confirmed'])
completed = len([a for a in appointments if a.status == 'completed'])
cancelled = len([a for a in appointments if a.status == 'cancelled'])
revenue = sum(a.service.price for a in appointments if a.status in {'confirmed', 'completed'})
by_professional = db.session.query(
ProfessionalProfile.display_name,
func.count(Appointment.id)
).join(Appointment, Appointment.professional_id == ProfessionalProfile.id).filter(
Appointment.appointment_date >= start,
Appointment.appointment_date <= end,
)
if current_user.role == 'professional' and current_user.professional_profile:
by_professional = by_professional.filter(Appointment.professional_id == current_user.professional_profile.id)
by_professional = by_professional.group_by(ProfessionalProfile.display_name).all()
by_service = db.session.query(
Service.name,
func.count(Appointment.id)
).join(Appointment, Appointment.service_id == Service.id).filter(
Appointment.appointment_date >= start,
Appointment.appointment_date <= end,
)
if current_user.role == 'professional' and current_user.professional_profile:
by_service = by_service.filter(Appointment.professional_id == current_user.professional_profile.id)
by_service = by_service.group_by(Service.name).all()
return render_template('admin_reports.html', start=start, end=end, total=total, confirmed=confirmed, completed=completed, cancelled=cancelled, revenue=revenue, by_professional=by_professional, by_service=by_service)
@app.route('/admin/audit')
@login_required
@role_required('admin')
def admin_audit():
logs = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(300).all()
return render_template('admin_audit.html', logs=logs)
@app.route('/admin/logs')
@login_required
@role_required('admin')
def admin_logs():
page = request.args.get('page', type=int, default=1)
pagination = ErrorLog.query.order_by(ErrorLog.created_at.desc()).paginate(page=page, per_page=20, error_out=False)
return render_template('admin_logs.html', logs=pagination.items, pagination=pagination)
@app.route('/admin/obras-sociales')
@login_required
@role_required('admin')
def admin_obras_sociales():
q = request.args.get('q', '').strip()
tipo = request.args.get('tipo', type=int)
sort = request.args.get('sort', 'denominacion')
direction = request.args.get('dir', 'asc')
page = request.args.get('page', type=int, default=1)
query = ObraSocialCatalog.query
if q:
like = f'%{q}%'
query = query.filter(or_(ObraSocialCatalog.denominacion.ilike(like), ObraSocialCatalog.rnas.ilike(like), ObraSocialCatalog.localidad.ilike(like), ObraSocialCatalog.categoria_oficial.ilike(like)))
if tipo:
query = query.filter(ObraSocialCatalog.tipo == tipo)
sort_map = {
'denominacion': ObraSocialCatalog.denominacion,
'rnas': ObraSocialCatalog.rnas,
'tipo': ObraSocialCatalog.tipo,
'localidad': ObraSocialCatalog.localidad,
}
sort_col = sort_map.get(sort, ObraSocialCatalog.denominacion)
query = query.order_by(sort_col.desc() if direction == 'desc' else sort_col.asc())
pagination = query.paginate(page=page, per_page=20, error_out=False)
pages_meta = ObraSocialPageSnapshot.query.order_by(ObraSocialPageSnapshot.tipo.asc()).all()
return render_template('admin_obras_sociales.html', items=pagination.items, pagination=pagination, pages_meta=pages_meta)
@app.route('/admin/obras-sociales/sync', methods=['POST'])
@login_required
@role_required('admin')
def admin_obras_sociales_sync():
try:
result = sync_obras_sociales()
log_action('sync', 'ObraSocialCatalog', None, str(result))
flash(f"Sincronización finalizada. Páginas procesadas: {result['pages_processed']}, cambios detectados: {result['pages_changed']}, altas: {result['rows_new']}, actualizaciones: {result['rows_updated']}, con error: {result['pages_with_error']}", 'success')
except Exception as exc:
store_error('admin_obras_sociales_sync', exc)
flash(f'La sincronización falló: {exc}', 'danger')
return redirect(url_for('admin_obras_sociales'))
@app.route('/admin/config-sisa', methods=['GET', 'POST'])
@login_required
@role_required('admin')
def admin_config_sisa():
if request.method == 'POST':
set_setting('sisa_wsdl', request.form.get('sisa_wsdl', '').strip())
set_setting('sisa_user', request.form.get('sisa_user', '').strip())
set_setting('sisa_password', request.form.get('sisa_password', '').strip())
set_setting('sisa_operation', request.form.get('sisa_operation', '').strip())
set_setting('sisa_enabled', '1' if request.form.get('sisa_enabled') else '0')
log_action('update', 'ConfigSisa', None, f"enabled={request.form.get('sisa_enabled')}")
flash('Configuración SISA actualizada.', 'success')
return redirect(url_for('admin_config_sisa'))
settings = get_sisa_settings()
return render_template('admin_config_sisa.html', settings=settings)
@app.route('/admin/config-sisa/test', methods=['POST'])
@login_required
@role_required('admin')
def admin_config_sisa_test():
settings = get_sisa_settings()
try:
result = sisa_test_connection(settings['wsdl'], settings['user'], settings['password'], settings['operation'])
flash(result['message'], 'success')
log_action('test_connection', 'ConfigSisa', None, result['message'])
except Exception as exc:
store_error('admin_config_sisa_test', exc)
flash(f'Error de comunicación con SISA: {exc}', 'danger')
return redirect(url_for('admin_config_sisa'))
@app.route('/admin/config-sisa/search')
@login_required
@role_required('admin', 'receptionist')
def admin_config_sisa_search():
settings = get_sisa_settings()
if not settings['enabled']:
return jsonify({'ok': False, 'error': 'Config. SISA está desactivado.'}), 400
dni = request.args.get('dni', '').strip()
query_value = request.args.get('query', '').strip()
matricula = request.args.get('matricula', '').strip()
try:
items = sisa_search_professionals(settings['wsdl'], settings['user'], settings['password'], settings['operation'], dni=dni, query=query_value, matricula=matricula)
return jsonify({'ok': True, 'items': items})
except Exception as exc:
store_error('admin_config_sisa_search', exc, extra=f'dni={dni}|q={query_value}|matricula={matricula}')
return jsonify({'ok': False, 'error': f'Error de comunicación con SISA: {exc}'}), 500
@app.route('/api/georef/provinces')
@login_required
@role_required('admin', 'receptionist')
def api_georef_provinces():
try:
return jsonify({'ok': True, 'items': get_provinces()})
except Exception as exc:
store_error('api_georef_provinces', exc)
return jsonify({'ok': False, 'error': str(exc)}), 500
@app.route('/api/georef/municipios')
@login_required
@role_required('admin', 'receptionist')
def api_georef_municipios():
provincia_id = request.args.get('provincia_id', '').strip()
try:
return jsonify({'ok': True, 'items': get_municipios(provincia_id)})
except Exception as exc:
store_error('api_georef_municipios', exc, extra=provincia_id)
return jsonify({'ok': False, 'error': str(exc)}), 500
@app.route('/api/georef/localidades')
@login_required
@role_required('admin', 'receptionist')
def api_georef_localidades():
provincia_id = request.args.get('provincia_id', '').strip()
municipio_id = request.args.get('municipio_id', '').strip()
try:
return jsonify({'ok': True, 'items': get_localidades(provincia_id, municipio_id)})
except Exception as exc:
store_error('api_georef_localidades', exc, extra=f'{provincia_id}|{municipio_id}')
return jsonify({'ok': False, 'error': str(exc)}), 500
def whatsapp_plain_text(value):
text = re.sub(r'<br\s*/?>', '\n', value or '', flags=re.I)
text = re.sub(r'</p\s*>', '\n', text, flags=re.I)
text = re.sub(r'<[^>]+>', '', text)
return re.sub(r'\n{3,}', '\n\n', text).strip()
def whatsapp_settings():
return {
'enabled': get_setting('wa_bot_enabled', '0') == '1',
'verify_token': get_setting('wa_verify_token', 'change-me-token'),
'access_token': get_setting('wa_access_token', ''),
'phone_number_id': get_setting('wa_phone_number_id', ''),
'api_version': get_setting('wa_api_version', 'v23.0'),
'default_reply': get_setting('wa_default_reply', 'Gracias por escribirnos. Para ver opciones escribí MENU.'),
'menu_title': get_setting('wa_menu_title', 'Menú de atención'),
'floating_enabled': get_setting('wa_floating_button_enabled', '0') == '1',
'public_number': get_setting('wa_public_number', ''),
'public_message': get_setting('wa_public_message', 'Hola, quiero realizar una consulta.'),
'public_label': get_setting('wa_public_label', 'Escribinos por WhatsApp'),
}
def whatsapp_send_text(to_number, body):
cfg = whatsapp_settings()
if not cfg['access_token'] or not cfg['phone_number_id']:
return {'ok': False, 'error': 'WhatsApp Cloud API no está configurada. Respuesta registrada en log.'}
url = f"https://graph.facebook.com/{cfg['api_version']}/{cfg['phone_number_id']}/messages"
payload = {
'messaging_product': 'whatsapp',
'to': to_number,
'type': 'text',
'text': {'preview_url': False, 'body': body[:3900]},
}
headers = {'Authorization': f"Bearer {cfg['access_token']}", 'Content-Type': 'application/json'}
resp = requests.post(url, headers=headers, json=payload, timeout=20)
if resp.status_code >= 400:
return {'ok': False, 'error': resp.text}
data = resp.json()
return {'ok': True, 'message_id': (data.get('messages') or [{}])[0].get('id')}
def whatsapp_build_menu():
cfg = whatsapp_settings()
rules = WhatsappBotRule.query.filter_by(is_active=True).order_by(WhatsappBotRule.sort_order.asc(), WhatsappBotRule.id.asc()).all()
lines = [cfg['menu_title']]
for idx, rule in enumerate(rules, 1):
label = rule.title or rule.trigger
lines.append(f"{idx}. {label} — escribí: {rule.trigger}")
return '\n'.join(lines) if rules else cfg['default_reply']
def whatsapp_find_answer(message_text):
text = (message_text or '').strip().lower()
if not text:
return whatsapp_settings()['default_reply']
if text in {'menu', 'menú', 'hola', 'buenas', 'opciones', 'inicio'}:
return whatsapp_build_menu()
rules = WhatsappBotRule.query.filter_by(is_active=True).order_by(WhatsappBotRule.sort_order.asc(), WhatsappBotRule.id.asc()).all()
for rule in rules:
trig = (rule.trigger or '').strip().lower()
if not trig:
continue
if (rule.match_mode == 'exact' and text == trig) or (rule.match_mode == 'startswith' and text.startswith(trig)) or (rule.match_mode == 'contains' and trig in text):
return whatsapp_plain_text(rule.response_html)
tokens = [t for t in re.findall(r'[a-záéíóúñ0-9]{4,}', text) if t not in {'para','como','quiero','necesito','consulta'}]
if tokens:
knowledge = WhatsappBotKnowledge.query.filter_by(is_active=True).order_by(WhatsappBotKnowledge.updated_at.desc()).limit(80).all()
best = None
best_score = 0
for item in knowledge:
hay = f"{item.title or ''} {item.keywords or ''} {item.content or ''}".lower()
score = sum(1 for t in tokens if t in hay)
if score > best_score:
best, best_score = item, score
if best and best_score:
return whatsapp_plain_text(best.content[:1200])
return whatsapp_settings()['default_reply']
@app.route('/admin/whatsapp-bot', methods=['GET', 'POST'])
@login_required
@role_required('admin')
def admin_whatsapp_bot():
if request.method == 'POST':
action = request.form.get('action', 'settings')
try:
if action == 'settings':
for key in ['wa_access_token','wa_phone_number_id','wa_verify_token','wa_api_version','wa_default_reply','wa_menu_title','wa_public_number','wa_public_message','wa_public_label']:
set_setting(key, request.form.get(key, '').strip())
set_setting('wa_bot_enabled', '1' if request.form.get('wa_bot_enabled') else '0')
set_setting('wa_floating_button_enabled', '1' if request.form.get('wa_floating_button_enabled') else '0')
flash('Configuración de WhatsApp guardada.', 'success')
elif action == 'save_rule':
rule_id = request.form.get('rule_id')
rule = WhatsappBotRule.query.get(int(rule_id)) if rule_id else WhatsappBotRule()
rule.trigger = request.form.get('trigger', '').strip().lower()
rule.title = request.form.get('title', '').strip()
rule.response_html = request.form.get('response_html', '').strip()
rule.match_mode = request.form.get('match_mode', 'contains')
rule.sort_order = int(request.form.get('sort_order') or 0)
rule.is_active = bool(request.form.get('is_active'))
if not rule.trigger or not rule.response_html:
raise ValueError('Completá disparador y respuesta.')
db.session.add(rule); db.session.commit()
flash('Regla/autorespuesta guardada.', 'success')
elif action == 'delete_rule':
rule = WhatsappBotRule.query.get_or_404(int(request.form.get('rule_id')))
db.session.delete(rule); db.session.commit()
flash('Regla eliminada.', 'success')
elif action == 'train_url':
url = request.form.get('source_url', '').strip()
if not url.startswith(('http://', 'https://')):
raise ValueError('Ingresá una URL válida con http:// o https://')
resp = requests.get(url, timeout=20, headers={'User-Agent': 'BookAppointmentsBot/1.0'})
resp.raise_for_status()
soup = BeautifulSoup(resp.text, 'html.parser')
for tag in soup(['script','style','nav','footer','header','form']):
tag.decompose()
title = (soup.title.string.strip() if soup.title and soup.title.string else url)[:255]
text = re.sub(r'\s+', ' ', soup.get_text(' ', strip=True))[:5000]
if len(text) < 80:
raise ValueError('La página no tiene suficiente texto útil para entrenar.')
item = WhatsappBotKnowledge(source_url=url, title=title, content=text, keywords=request.form.get('keywords','').strip())
db.session.add(item); db.session.commit()
flash('Página leída y agregada a la base de conocimiento del bot.', 'success')
elif action == 'delete_knowledge':
item = WhatsappBotKnowledge.query.get_or_404(int(request.form.get('knowledge_id')))
db.session.delete(item); db.session.commit()
flash('Fuente eliminada.', 'success')
log_action('update', 'WhatsappBot', None, action)
except Exception as exc:
db.session.rollback(); store_error('admin_whatsapp_bot', exc); flash(f'No se pudo guardar: {exc}', 'danger')
return redirect(url_for('admin_whatsapp_bot'))
return render_template('admin_whatsapp_bot.html', cfg=whatsapp_settings(), rules=WhatsappBotRule.query.order_by(WhatsappBotRule.sort_order.asc(), WhatsappBotRule.id.asc()).all(), knowledge=WhatsappBotKnowledge.query.order_by(WhatsappBotKnowledge.updated_at.desc()).all(), logs=WhatsappMessageLog.query.order_by(WhatsappMessageLog.created_at.desc()).limit(50).all())
@app.route('/webhooks/whatsapp', methods=['GET', 'POST'])
def whatsapp_webhook():
cfg = whatsapp_settings()
if request.method == 'GET':
if request.args.get('hub.mode') == 'subscribe' and request.args.get('hub.verify_token') == cfg['verify_token']:
return request.args.get('hub.challenge', ''), 200
return 'Token inválido', 403
payload = request.get_json(silent=True) or {}
if not cfg['enabled']:
return jsonify({'ok': True, 'disabled': True})
try:
for entry in payload.get('entry', []):
for change in entry.get('changes', []):
value = change.get('value', {})
contacts = value.get('contacts') or [{}]
profile_name = ((contacts[0].get('profile') or {}).get('name') or '')
for msg in value.get('messages', []):
if msg.get('type') != 'text':
continue
wa_id = msg.get('from')
incoming = ((msg.get('text') or {}).get('body') or '').strip()
answer = whatsapp_find_answer(incoming)
send_result = whatsapp_send_text(wa_id, answer)
log = WhatsappMessageLog(wa_id=wa_id, customer_name=profile_name, incoming_text=incoming, outgoing_text=answer, provider_message_id=send_result.get('message_id'), status='answered' if send_result.get('ok') else 'pending_config', raw_payload=json.dumps(payload, ensure_ascii=False), error=send_result.get('error'))
db.session.add(log)
db.session.commit()
return jsonify({'ok': True})
except Exception as exc:
db.session.rollback(); store_error('whatsapp_webhook', exc); return jsonify({'ok': False, 'error': str(exc)}), 500
# ================================================================
# CHATBOT IA PROPIO DEL FRONTEND (sin WhatsApp / Meta / Telegram)
# ================================================================
def ai_chatbot_settings():
return {
'enabled': get_setting('ai_chatbot_enabled', '0') == '1',
'floating_enabled': get_setting('ai_chatbot_floating_enabled', '0') == '1',
'assistant_name': get_setting('ai_chatbot_assistant_name', 'Asistente Virtual'),
'welcome_message': get_setting('ai_chatbot_welcome_message', 'Hola, soy el asistente virtual.'),
'openai_enabled': get_setting('ai_chatbot_openai_enabled', '0') == '1',
'openai_model': get_setting('ai_chatbot_openai_model', 'gpt-5.5'),
'api_key_configured': bool((os.getenv('OPENAI_API_KEY') or get_setting('ai_chatbot_api_key', '')).strip()),
'booking_enabled': get_setting('ai_chatbot_booking_enabled', '1') == '1',
'sync_public_site': get_setting('ai_chatbot_sync_public_site', '1') == '1',
'system_instructions': get_setting('ai_chatbot_system_instructions', ''),
'fallback_message': get_setting('ai_chatbot_fallback_message', 'Puedo orientarte con especialidades, profesionales, precios y turnos.'),
}
def ai_normalize_text(value: str) -> str:
value = re.sub(r'<br\s*/?>', '\n', value or '', flags=re.I)
value = re.sub(r'</p\s*>', '\n', value, flags=re.I)
value = re.sub(r'<[^>]+>', ' ', value)
value = re.sub(r'\s+', ' ', value).strip()
return value
def ai_format_money(value):
try:
amount = float(value or 0)
except Exception:
amount = 0
if amount <= 0:
return 'Consultar'
return f"$ {amount:,.2f}".replace(',', 'X').replace('.', ',').replace('X', '.')
def ai_public_context(institution_id=None, max_knowledge=18):
inst_query = Institution.query.filter_by(active=True)
if institution_id:
inst_query = inst_query.filter(Institution.id == institution_id)
institutions = inst_query.order_by(Institution.name.asc()).all()
service_query = Service.query.filter_by(active=True)
professional_query = ProfessionalProfile.query.filter_by(is_bookable=True)
specialty_query = Specialty.query.filter_by(active=True)
knowledge_query = AiKnowledgeItem.query.filter_by(is_active=True)
if institution_id:
service_query = service_query.filter(or_(Service.institution_id == institution_id, Service.institution_id.is_(None)))
professional_query = professional_query.filter(or_(ProfessionalProfile.institution_id == institution_id, ProfessionalProfile.institution_id.is_(None)))
knowledge_query = knowledge_query.filter(or_(AiKnowledgeItem.institution_id == institution_id, AiKnowledgeItem.institution_id.is_(None)))
services = service_query.order_by(Service.name.asc()).all()
professionals = professional_query.order_by(ProfessionalProfile.display_name.asc()).all()
specialties = specialty_query.order_by(Specialty.name.asc()).all()
knowledge = knowledge_query.order_by(AiKnowledgeItem.updated_at.desc()).limit(max_knowledge).all()
frontend_blocks = FrontendBlock.query.filter_by(is_active=True).order_by(FrontendBlock.section.asc(), FrontendBlock.sort_order.asc()).limit(60).all()
lines = []
site = get_site_settings()
lines.append(f"Sitio: {site.get('title') or current_app.config.get('APP_NAME')}.")
if site.get('tagline'):
lines.append(f"Descripción: {site.get('tagline')}.")
if site.get('contact_address'):
lines.append(f"Dirección: {site.get('contact_address')}.")
if site.get('phone'):
lines.append(f"Teléfono: {site.get('phone')}.")
if site.get('email'):
lines.append(f"Email: {site.get('email')}.")
if institutions:
lines.append('Instituciones activas: ' + '; '.join([i.name for i in institutions[:12]]))
if specialties:
lines.append('Especialidades habilitadas: ' + '; '.join([s.name for s in specialties[:30]]))
if professionals:
prof_bits = []
for p in professionals[:40]:
prof_bits.append(f"{p.display_name} ({p.specialty or 'Especialidad no informada'}, {p.location or 'sin sede informada'})")
lines.append('Profesionales disponibles: ' + '; '.join(prof_bits))
if services:
service_bits = []
for s in services[:40]:
service_bits.append(f"{s.name}: {ai_format_money(s.price)}; duración {s.duration_minutes} min; modalidad {s.mode or 'no informada'}")
lines.append('Servicios/precios: ' + '; '.join(service_bits))
for block in frontend_blocks:
clean_block = ai_normalize_text(' '.join([block.title or '', block.subtitle or '', block.body or '']))[:700]
if clean_block:
lines.append(f"Web pública ({block.section}): {clean_block}")
for item in knowledge:
clean = ai_normalize_text(item.content)[:1100]
if clean:
lines.append(f"Conocimiento ({item.title}): {clean}")
return '\n'.join(lines)[:14000]
def ai_detect_intent(message: str):
text = (message or '').lower()
if any(w in text for w in ['turno', 'reserva', 'reservar', 'agenda', 'agendar', 'cita', 'horario']):
return 'booking'
if any(w in text for w in ['precio', 'valor', 'arancel', 'costo', 'cuanto sale', 'cuánto sale']):
return 'prices'
if any(w in text for w in ['especialidad', 'especialidades', 'atienden', 'servicio', 'servicios']):
return 'specialties'
if any(w in text for w in ['profesional', 'doctor', 'médico', 'medico', 'psicólogo', 'psicologo', 'psiquiatra']):
return 'professionals'
if any(w in text for w in ['ubicacion', 'ubicación', 'direccion', 'dirección', 'telefono', 'teléfono', 'mail', 'email', 'contacto']):
return 'contact'
return 'general'
def ai_keyword_fallback(message: str, institution_id=None):
intent = ai_detect_intent(message)
cfg = ai_chatbot_settings()
parts = []
actions = []
if intent == 'booking':
parts.append('Puedo ayudarte a iniciar la solicitud de turno desde este chat.')
parts.append('Completá servicio, fecha y datos de contacto; el sistema verificará disponibilidad real antes de confirmar.')
if cfg['booking_enabled']:
actions.append({'type': 'open_booking_form', 'label': 'Reservar turno en el chat'})
actions.append({'type': 'link', 'label': 'Abrir pantalla completa de turnos', 'url': url_for('booking')})
elif intent == 'prices':
services = Service.query.filter_by(active=True).order_by(Service.name.asc()).limit(30).all()
if services:
parts.append('Estos son los servicios y valores cargados actualmente:')
for s in services:
parts.append(f"{s.name}: {ai_format_money(s.price)} ({s.duration_minutes} min, {s.mode or 'modalidad no informada'}).")
else:
parts.append('Todavía no hay servicios/precios cargados en el sistema.')
actions.append({'type': 'open_booking_form', 'label': 'Solicitar turno'})
elif intent == 'specialties':
names = sorted(set([s.name for s in Specialty.query.filter_by(active=True).all()] + [p.specialty for p in ProfessionalProfile.query.filter_by(is_bookable=True).all() if p.specialty]))
if names:
parts.append('Las especialidades/áreas cargadas actualmente son: ' + ', '.join(names[:50]) + '.')
else:
parts.append('Aún no hay especialidades cargadas en el sistema.')
actions.append({'type': 'open_booking_form', 'label': 'Ver disponibilidad'})
elif intent == 'professionals':
pros = ProfessionalProfile.query.filter_by(is_bookable=True).order_by(ProfessionalProfile.display_name.asc()).limit(40).all()
if pros:
parts.append('Profesionales con reserva habilitada:')
for p in pros:
parts.append(f"{p.display_name}: {p.specialty or 'especialidad no informada'} · {p.location or 'sede no informada'}.")
else:
parts.append('No hay profesionales habilitados para reserva online en este momento.')
actions.append({'type': 'open_booking_form', 'label': 'Agendar con un profesional'})
elif intent == 'contact':
site = get_site_settings()
parts.append('Datos de contacto cargados en el sitio:')
if site.get('contact_address'): parts.append(f"• Dirección: {site.get('contact_address')}")
if site.get('phone'): parts.append(f"• Teléfono: {site.get('phone')}")
if site.get('email'): parts.append(f"• Email: {site.get('email')}")
if not any([site.get('contact_address'), site.get('phone'), site.get('email')]):
parts.append('Todavía no se cargaron datos de contacto completos en el panel del sitio.')
else:
tokens = [t for t in re.findall(r'[a-záéíóúñ0-9]{4,}', (message or '').lower()) if t not in {'para','como','quiero','necesito','consulta','puede','tengo'}]
best, best_score = None, 0
if tokens:
for item in AiKnowledgeItem.query.filter_by(is_active=True).order_by(AiKnowledgeItem.updated_at.desc()).limit(80).all():
hay = f"{item.title or ''} {item.keywords or ''} {item.content or ''}".lower()
score = sum(1 for t in tokens if t in hay)
if score > best_score:
best, best_score = item, score
if best and best_score:
parts.append(ai_normalize_text(best.content)[:1200])
else:
parts.append(cfg['fallback_message'])
actions.extend([
{'type': 'open_booking_form', 'label': 'Solicitar turno'},
{'type': 'link', 'label': 'Ver servicios', 'url': '#services'},
{'type': 'link', 'label': 'Contacto', 'url': '#contact'},
])
parts.append('Aviso: esta orientación no reemplaza una consulta médica. Ante urgencias, comunicate con emergencias o concurrí a una guardia.')
return {'reply': '\n'.join(parts), 'intent': intent, 'actions': actions}
def ai_openai_answer(message: str, context: str, session_id: str = ''):
cfg = ai_chatbot_settings()
api_key = (os.getenv('OPENAI_API_KEY') or get_setting('ai_chatbot_api_key', '')).strip()
if not cfg['openai_enabled'] or not api_key:
return None
try:
from openai import OpenAI
client = OpenAI(api_key=api_key)
instructions = (cfg['system_instructions'] or '') + "\n\nInformación autorizada del sistema:\n" + context
response = client.responses.create(
model=cfg['openai_model'] or 'gpt-5.5',
instructions=instructions,
input=message[:2000],
)
return (getattr(response, 'output_text', '') or '').strip()
except Exception as exc:
store_error('ai_openai_answer', exc)
return None
def ai_sync_system_knowledge():
"""Recrea conocimiento automático desde CMS, servicios, profesionales e instituciones."""
now = datetime.utcnow()
AiKnowledgeItem.query.filter_by(source_type='system_auto').delete()
created = 0
site = get_site_settings()
site_text = '\n'.join([str(site.get(k) or '') for k in ['title','tagline','contact_address','phone','email','city','province']])
if ai_normalize_text(site_text):
db.session.add(AiKnowledgeItem(source_type='system_auto', source_ref='site_settings', title='Datos generales del sitio', content=ai_normalize_text(site_text), keywords='sitio contacto dirección teléfono email', last_synced_at=now))
created += 1
for block in FrontendBlock.query.filter_by(is_active=True).all():
content = ai_normalize_text(' '.join([block.title or '', block.subtitle or '', block.body or '', block.link_text or '']))
if len(content) >= 30:
db.session.add(AiKnowledgeItem(source_type='system_auto', source_ref=f'frontend_block:{block.id}', title=block.title or f'Bloque {block.section}', content=content, keywords=f'{block.section} {block.key or ""}', institution_id=None, last_synced_at=now))
created += 1
for service in Service.query.filter_by(active=True).all():
content = f"Servicio {service.name}. Descripción: {ai_normalize_text(service.description)}. Precio: {ai_format_money(service.price)}. Duración: {service.duration_minutes} minutos. Modalidad: {service.mode}."
db.session.add(AiKnowledgeItem(source_type='system_auto', source_ref=f'service:{service.id}', title=f'Servicio: {service.name}', content=content, keywords=f'servicio precio arancel {service.name}', institution_id=service.institution_id, last_synced_at=now))
created += 1
for prof in ProfessionalProfile.query.filter_by(is_bookable=True).all():
content = f"Profesional {prof.display_name}. Especialidad: {prof.specialty}. Bio: {ai_normalize_text(prof.bio)}. Sede: {prof.location}. Teléfono: {prof.phone or ''}. Email: {prof.contact_email or ''}."
db.session.add(AiKnowledgeItem(source_type='system_auto', source_ref=f'professional:{prof.id}', title=f'Profesional: {prof.display_name}', content=content, keywords=f'profesional médico doctor {prof.specialty} {prof.display_name}', institution_id=prof.institution_id, last_synced_at=now))
created += 1
db.session.commit()
set_setting('ai_chatbot_last_sync_at', now.strftime('%Y-%m-%d %H:%M:%S'))
return created
@app.route('/admin/ai-chatbot', methods=['GET', 'POST'])
@login_required
@role_required('admin')
def admin_ai_chatbot():
if request.method == 'POST':
action = request.form.get('action', 'settings')
try:
if action == 'settings':
for key in ['ai_chatbot_assistant_name','ai_chatbot_welcome_message','ai_chatbot_openai_model','ai_chatbot_system_instructions','ai_chatbot_fallback_message']:
set_setting(key, request.form.get(key, '').strip())
incoming_key = request.form.get('ai_chatbot_api_key', '').strip()
if incoming_key:
set_setting('ai_chatbot_api_key', incoming_key)
set_setting('ai_chatbot_enabled', '1' if request.form.get('ai_chatbot_enabled') else '0')
set_setting('ai_chatbot_floating_enabled', '1' if request.form.get('ai_chatbot_floating_enabled') else '0')
set_setting('ai_chatbot_openai_enabled', '1' if request.form.get('ai_chatbot_openai_enabled') else '0')
set_setting('ai_chatbot_booking_enabled', '1' if request.form.get('ai_chatbot_booking_enabled') else '0')
set_setting('ai_chatbot_sync_public_site', '1' if request.form.get('ai_chatbot_sync_public_site') else '0')
flash('Configuración del Chatbot IA guardada.', 'success')
elif action == 'sync_system':
count = ai_sync_system_knowledge()
flash(f'Base de conocimiento sincronizada con el sistema. Ítems generados: {count}.', 'success')
elif action == 'train_url':
source_url = request.form.get('source_url', '').strip()
if not source_url.startswith(('http://', 'https://')):
raise ValueError('Ingresá una URL válida con http:// o https://')
resp = requests.get(source_url, timeout=20, headers={'User-Agent': 'BookAppointmentsAIChatbot/1.0'})
resp.raise_for_status()
soup = BeautifulSoup(resp.text, 'html.parser')
for tag in soup(['script','style','nav','footer','header','form']):
tag.decompose()
title = (soup.title.string.strip() if soup.title and soup.title.string else source_url)[:255]
text = ai_normalize_text(soup.get_text(' ', strip=True))[:9000]
if len(text) < 80:
raise ValueError('La página no tiene suficiente texto útil para entrenar.')
db.session.add(AiKnowledgeItem(source_type='url', source_ref=source_url, title=title, content=text, keywords=request.form.get('keywords','').strip(), last_synced_at=datetime.utcnow()))
db.session.commit()
flash('URL leída y agregada a la base de conocimiento IA.', 'success')
elif action == 'save_manual':
item_id = request.form.get('knowledge_id', type=int)
item = AiKnowledgeItem.query.get(item_id) if item_id else AiKnowledgeItem(source_type='manual')
item.title = request.form.get('title', '').strip()
item.content = request.form.get('content', '').strip()
item.keywords = request.form.get('keywords', '').strip()
item.is_active = bool(request.form.get('is_active'))
item.last_synced_at = datetime.utcnow()
if not item.title or not item.content:
raise ValueError('Completá título y contenido.')
db.session.add(item); db.session.commit()
flash('Conocimiento manual guardado.', 'success')
elif action == 'delete_knowledge':
item = AiKnowledgeItem.query.get_or_404(int(request.form.get('knowledge_id')))
db.session.delete(item); db.session.commit()
flash('Ítem eliminado.', 'success')
log_action('update', 'AiChatbot', None, action)
except Exception as exc:
db.session.rollback(); store_error('admin_ai_chatbot', exc); flash(f'No se pudo guardar: {exc}', 'danger')
return redirect(url_for('admin_ai_chatbot'))
cfg = ai_chatbot_settings()
cfg['last_sync_at'] = get_setting('ai_chatbot_last_sync_at', '')
knowledge = AiKnowledgeItem.query.order_by(AiKnowledgeItem.updated_at.desc()).limit(200).all()
logs = AiChatLog.query.order_by(AiChatLog.created_at.desc()).limit(80).all()
services = Service.query.filter_by(active=True).order_by(Service.name.asc()).all()
return render_template('admin_ai_chatbot.html', cfg=cfg, knowledge=knowledge, logs=logs, services=services)
@app.route('/api/ai-chatbot/message', methods=['POST'])
def api_ai_chatbot_message():
cfg = ai_chatbot_settings()
if not cfg['enabled']:
return jsonify({'ok': False, 'error': 'Chatbot IA desactivado.'}), 403
try:
data = request.get_json(silent=True) or {}
message = (data.get('message') or '').strip()
session_id = (data.get('session_id') or generate_public_token())[:120]
institution_id = data.get('institution_id')
if not message:
return jsonify({'ok': False, 'error': 'Mensaje vacío.'}), 400
context = ai_public_context(institution_id=institution_id)
fallback = ai_keyword_fallback(message, institution_id=institution_id)
ai_reply = ai_openai_answer(message, context, session_id=session_id)
reply = ai_reply or fallback['reply']
actions = fallback.get('actions', [])
log = AiChatLog(session_id=session_id, incoming_text=message, outgoing_text=reply, intent=fallback.get('intent'), context_snapshot=context[:4000], ip_address=request.remote_addr, institution_id=institution_id)
db.session.add(log); db.session.commit()
return jsonify({'ok': True, 'session_id': session_id, 'reply': reply, 'actions': actions, 'assistant_name': cfg['assistant_name']})
except Exception as exc:
db.session.rollback(); store_error('api_ai_chatbot_message', exc); return jsonify({'ok': False, 'error': 'No se pudo procesar el mensaje.'}), 500
@app.route('/api/ai-chatbot/booking/options')
def api_ai_chatbot_booking_options():
if get_setting('ai_chatbot_enabled', '0') != '1' or get_setting('ai_chatbot_booking_enabled', '1') != '1':
return jsonify({'ok': False, 'error': 'Reserva por chatbot desactivada.'}), 403
services = Service.query.filter_by(active=True).order_by(Service.name.asc()).all()
institutions = Institution.query.filter_by(active=True).order_by(Institution.name.asc()).all()
return jsonify({'ok': True, 'services': [{'id': s.id, 'name': s.name, 'price': ai_format_money(s.price), 'duration': s.duration_minutes, 'mode': s.mode} for s in services], 'institutions': [{'id': i.id, 'name': i.name} for i in institutions]})
@app.route('/api/ai-chatbot/booking/slots')
def api_ai_chatbot_booking_slots():
if get_setting('ai_chatbot_enabled', '0') != '1' or get_setting('ai_chatbot_booking_enabled', '1') != '1':
return jsonify({'ok': False, 'error': 'Reserva por chatbot desactivada.'}), 403
service = Service.query.filter_by(id=request.args.get('service_id', type=int), active=True).first_or_404()
target_date = parse_date(request.args.get('date'), date.today() + timedelta(days=1))
institution_id = request.args.get('institution_id', type=int)
professionals = [p for p in service.professionals if p.is_bookable and (not institution_id or p.institution_id == institution_id)]
result = []
for p in professionals:
slots = get_available_slots(p, service, target_date)[:12]
if slots:
result.append({'professional_id': p.id, 'professional_name': p.display_name, 'specialty': p.specialty, 'slots': [{'time': slot['label'], 'end': slot['end'].strftime('%H:%M')} for slot in slots]})
return jsonify({'ok': True, 'date': target_date.isoformat(), 'items': result})
@app.route('/api/ai-chatbot/booking/create', methods=['POST'])
def api_ai_chatbot_booking_create():
if get_setting('ai_chatbot_enabled', '0') != '1' or get_setting('ai_chatbot_booking_enabled', '1') != '1':
return jsonify({'ok': False, 'error': 'Reserva por chatbot desactivada.'}), 403
try:
data = request.get_json(silent=True) or {}
service = Service.query.filter_by(id=int(data.get('service_id') or 0), active=True).first_or_404()
selected_date = parse_date(data.get('date'), None)
selected_time = (data.get('time') or '').strip()
client_name = (data.get('client_name') or '').strip()
client_email = (data.get('client_email') or '').strip().lower()
client_phone = (data.get('client_phone') or '').strip()
notes = (data.get('notes') or '').strip()
professional_id = int(data.get('professional_id') or 0)
if not selected_date or not selected_time:
raise ValueError('Seleccioná fecha y horario.')
if not client_name or not client_email:
raise ValueError('Completá nombre y email.')
professional = ProfessionalProfile.query.filter_by(id=professional_id, is_bookable=True).first() if professional_id else None
if not professional or service not in professional.services:
professional, slots = choose_first_available_professional(service, selected_date)
else:
slots = get_available_slots(professional, service, selected_date)
if not professional:
raise ValueError('No se encontró profesional disponible.')
chosen_slot = next((slot for slot in slots if slot['label'] == selected_time), None)
if not chosen_slot:
raise ValueError('Ese horario ya no está disponible.')
existing_patient = Patient.query.filter(func.lower(Patient.email) == client_email).first()
appointment = Appointment(
service_id=service.id,
professional_id=professional.id,
patient_id=existing_patient.id if existing_patient else None,
client_name=client_name,
client_email=client_email,
client_phone=client_phone,
notes=(notes + '\n\nSolicitado desde Chatbot IA').strip(),
appointment_date=selected_date,
start_time=chosen_slot['start'].time(),
end_time=chosen_slot['end'].time(),
status='confirmed',
booking_source='ai_chatbot',
public_token=generate_public_token(),
institution_id=professional.institution_id or service.institution_id,
)
db.session.add(appointment); db.session.commit()
success_url = url_for('booking_success', token=appointment.public_token)
return jsonify({'ok': True, 'message': 'Turno reservado correctamente.', 'appointment_id': appointment.id, 'success_url': success_url, 'professional': professional.display_name, 'date': selected_date.strftime('%d/%m/%Y'), 'time': selected_time})
except Exception as exc:
db.session.rollback(); store_error('api_ai_chatbot_booking_create', exc); return jsonify({'ok': False, 'error': str(exc)}), 400