5460 lines
312 KiB
Python
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
|