mi-proyecto/app/utils.py

905 lines
39 KiB
Python

from __future__ import annotations
import json
import hashlib
import os
import re
import traceback
import smtplib
from urllib.parse import quote_plus
from email.message import EmailMessage
from jinja2 import Template
from datetime import datetime, date, time, timedelta
from functools import wraps
from secrets import token_urlsafe
from flask import abort, current_app, request
from flask_login import current_user
from werkzeug.utils import secure_filename
from .models import (
Appointment,
Leave,
WorkingHour,
AuditLog,
ProfessionalProfile,
AppSetting,
ErrorLog,
FrontendBlock,
ClinicalRecord,
Patient,
)
from . import db
WEEKDAY_LABELS = {
0: 'Lunes',
1: 'Martes',
2: 'Miércoles',
3: 'Jueves',
4: 'Viernes',
5: 'Sábado',
6: 'Domingo',
}
ACTIVE_APPOINTMENT_STATUSES = {'pending', 'confirmed'}
SAFE_TEXT_RE = re.compile(r"[^\w\sÁÉÍÓÚÜÑáéíóúüñ.,;:()/%+\-@#°º\n\r\t]")
SAFE_SINGLE_LINE_RE = re.compile(r"[^\w\sÁÉÍÓÚÜÑáéíóúüñ.,;:()/%+\-@#°º]")
SAFE_IDENTIFIER_RE = re.compile(r"[^A-Za-z0-9._\-]")
UPLOAD_RULES = {
'site': {'extensions': {'.png', '.jpg', '.jpeg', '.webp', '.svg', '.pdf'}, 'max_bytes': 8 * 1024 * 1024},
'logo': {'extensions': {'.png', '.jpg', '.jpeg', '.webp', '.svg'}, 'max_bytes': 5 * 1024 * 1024},
'favicon': {'extensions': {'.png', '.jpg', '.jpeg', '.webp', '.svg', '.ico'}, 'max_bytes': 2 * 1024 * 1024},
'popup': {'extensions': {'.png', '.jpg', '.jpeg', '.webp', '.svg'}, 'max_bytes': 8 * 1024 * 1024},
'frontend': {'extensions': {'.png', '.jpg', '.jpeg', '.webp', '.svg'}, 'max_bytes': 8 * 1024 * 1024},
'chat': {'extensions': {'.png', '.jpg', '.jpeg', '.webp', '.pdf', '.txt', '.doc', '.docx', '.xls', '.xlsx', '.csv'}, 'max_bytes': 10 * 1024 * 1024},
'clinical': {'extensions': {'.png', '.jpg', '.jpeg', '.webp', '.pdf', '.txt', '.doc', '.docx', '.xls', '.xlsx', '.csv'}, 'max_bytes': 12 * 1024 * 1024},
}
ALLOWED_CONFIDENTIALITY = {'Paciente', 'Institucional', 'Restringido'}
def sanitize_text(value: str, max_length: int = 255, multiline: bool = False) -> str:
value = (value or '').strip()
if not value:
return ''
value = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '', value)
cleaner = SAFE_TEXT_RE if multiline else SAFE_SINGLE_LINE_RE
value = cleaner.sub('', value)
value = re.sub(r'\s+', ' ', value) if not multiline else re.sub(r'\n{3,}', '\n\n', value)
return value[:max_length].strip()
def sanitize_multiline_text(value: str, max_length: int = 4000) -> str:
return sanitize_text(value, max_length=max_length, multiline=True)
def sanitize_identifier(value: str, max_length: int = 120) -> str:
value = (value or '').strip()
value = SAFE_IDENTIFIER_RE.sub('', value)
return value[:max_length]
def sanitize_digits(value: str, max_length: int = 32) -> str:
return re.sub(r'\D+', '', value or '')[:max_length]
def sanitize_code(value: str, max_length: int = 50) -> str:
value = (value or '').strip().upper()
value = re.sub(r'[^A-Z0-9.\-]', '', value)
return value[:max_length]
def sanitize_choice(value: str, allowed, default: str = '') -> str:
value = sanitize_text(value, max_length=80)
return value if value in allowed else default
def sanitize_json_text_map(payload, allowed_keys, *, max_value_length: int = 500, multiline: bool = False) -> dict:
if isinstance(payload, str):
try:
payload = json.loads(payload or '{}')
except Exception:
payload = {}
if not isinstance(payload, dict):
return {}
cleaned = {}
for key, value in payload.items():
if key not in allowed_keys:
continue
if value is None:
continue
cleaned_value = sanitize_multiline_text(str(value), max_length=max_value_length) if multiline else sanitize_text(str(value), max_length=max_value_length)
if cleaned_value:
cleaned[key] = cleaned_value
return cleaned
def _uploaded_file_size(file_storage) -> int:
try:
pos = file_storage.stream.tell()
file_storage.stream.seek(0, os.SEEK_END)
size = file_storage.stream.tell()
file_storage.stream.seek(pos)
return size
except Exception:
return 0
def validate_uploaded_asset(file_storage, prefix: str = 'site'):
if not file_storage or not getattr(file_storage, 'filename', ''):
raise ValueError('No se recibió ningún archivo.')
rules = UPLOAD_RULES.get(prefix, UPLOAD_RULES['site'])
original_name = secure_filename(file_storage.filename or '')
ext = os.path.splitext(original_name)[1].lower()
if ext not in rules['extensions']:
raise ValueError('El tipo de archivo no está permitido para este módulo.')
size = _uploaded_file_size(file_storage)
if size and size > rules['max_bytes']:
raise ValueError('El archivo excede el tamaño máximo permitido.')
return original_name, ext
JURISDICTION_OPTIONS = [
{'code': '02', 'name': 'Ciudad Autónoma de Buenos Aires'},
{'code': '06', 'name': 'Provincia de Buenos Aires'},
{'code': '10', 'name': 'Provincia de Catamarca'},
{'code': '14', 'name': 'Provincia de Córdoba'},
{'code': '18', 'name': 'Provincia de Corrientes'},
{'code': '22', 'name': 'Provincia del Chaco'},
{'code': '26', 'name': 'Provincia del Chubut'},
{'code': '30', 'name': 'Provincia de Entre Ríos'},
{'code': '34', 'name': 'Provincia de Formosa'},
{'code': '38', 'name': 'Provincia de Jujuy'},
{'code': '42', 'name': 'Provincia de La Pampa'},
{'code': '46', 'name': 'Provincia de La Rioja'},
{'code': '50', 'name': 'Provincia de Mendoza'},
{'code': '54', 'name': 'Provincia de Misiones'},
{'code': '58', 'name': 'Provincia de Neuquén'},
{'code': '62', 'name': 'Provincia de Río Negro'},
{'code': '66', 'name': 'Provincia de Salta'},
{'code': '70', 'name': 'Provincia de San Juan'},
{'code': '74', 'name': 'Provincia de San Luis'},
{'code': '78', 'name': 'Provincia de Santa Cruz'},
{'code': '82', 'name': 'Provincia de Santa Fe'},
{'code': '86', 'name': 'Provincia de Santiago del Estero'},
{'code': '90', 'name': 'Provincia de Tucumán'},
{'code': '94', 'name': 'Provincia de Tierra del Fuego, Antártida e Islas del Atlántico Sur'},
]
PRESCRIPTION_TYPE_OPTIONS = [
{'code': '01', 'name': 'Prescripción de medicamento'},
{'code': '02', 'name': 'Prescripción de dispositivo'},
{'code': '03', 'name': 'Prescripción de estudios / prácticas / procedimientos'},
]
PRESCRIPTION_SUBTYPE_OPTIONS = [
{'code': '01', 'name': 'Expendio libre'},
{'code': '02', 'name': 'Expendio bajo receta'},
{'code': '03', 'name': 'Expendio bajo receta archivada'},
{'code': '04', 'name': 'Expendio legalmente restringido'},
{'code': '00', 'name': 'No aplica'},
]
ORDER_KIND_OPTIONS = [
{'code': 'recipe', 'name': 'Receta', 'description': 'Prescripción farmacológica y dispositivos'},
{'code': 'practice', 'name': 'Prácticas', 'description': 'Órdenes de estudios, prácticas y procedimientos'},
{'code': 'report', 'name': 'Informes', 'description': 'Informes clínicos, aptos y constancias médicas'},
{'code': 'result', 'name': 'Resultados', 'description': 'Resultados e interpretación médica interna'},
]
ORDER_KIND_META = {
'recipe': {'label': 'Receta', 'plural': 'Recetas', 'icon': 'bi-file-earmark-medical', 'public_verify': True, 'portal_visible': True, 'number_label': 'Recetario', 'date_label': 'Fecha Receta'},
'practice': {'label': 'Práctica', 'plural': 'Prácticas', 'icon': 'bi-clipboard2-pulse', 'public_verify': False, 'portal_visible': False, 'number_label': 'Orden Nro', 'date_label': 'Fecha Orden'},
'report': {'label': 'Informe', 'plural': 'Informes', 'icon': 'bi-file-earmark-text', 'public_verify': False, 'portal_visible': False, 'number_label': 'Informe Nro', 'date_label': 'Fecha Informe'},
'result': {'label': 'Resultado', 'plural': 'Resultados', 'icon': 'bi-activity', 'public_verify': False, 'portal_visible': False, 'number_label': 'Resultado Nro', 'date_label': 'Fecha Resultado'},
}
DEFAULT_SETTINGS = {
'sisa_enabled': '0',
'sisa_wsdl': 'https://sisa.msal.gov.ar/sisa/services/profesionalService?wsdl',
'sisa_user': '',
'sisa_password': '',
'sisa_operation': 'profesionalNominal',
'site_title': 'Book Appointments Pro',
'site_phone': '',
'site_url': 'http://127.0.0.1:5000',
'site_email': '',
'site_logo_path': '',
'site_favicon_path': '',
'site_country': 'Argentina',
'site_province': '',
'site_municipality': '',
'site_city': '',
'site_platform_number': '0000',
'site_repository_number': '0000',
'site_tagline': 'Psiquiatría clínica · receta electrónica · verificación pública',
'site_seo_title': '',
'site_meta_description': 'Centro médico con reserva online de turnos, atención profesional, historia clínica digital y receta electrónica verificable.',
'site_meta_keywords': 'turnos médicos online, salud, clínica, profesionales, receta electrónica, historia clínica digital',
'site_canonical_url': '',
'site_seo_indexing_enabled': '1',
'site_robots_meta': 'index,follow',
'site_lang': 'es-AR',
'site_locale': 'es_AR',
'site_og_title': '',
'site_og_description': '',
'site_og_image_path': '',
'site_og_image_alt': 'Imagen institucional del sitio',
'site_twitter_card': 'summary_large_image',
'site_twitter_site': '',
'site_twitter_title': '',
'site_twitter_description': '',
'site_schema_type': 'MedicalClinic',
'site_legal_name': '',
'site_price_range': '$$',
'site_google_site_verification': '',
'site_bing_site_verification': '',
'site_contact_address': '',
'site_contact_map_embed': '',
'site_copyright': '© Book Appointments Pro Salud. Todos los derechos reservados.',
'site_nav_cta_label': 'Reservar turno',
'site_social_facebook_enabled': '0',
'site_social_facebook_url': '',
'site_social_instagram_enabled': '0',
'site_social_instagram_url': '',
'site_social_x_enabled': '0',
'site_social_x_url': '',
'site_social_youtube_enabled': '0',
'site_social_youtube_url': '',
'site_social_linkedin_enabled': '0',
'site_social_linkedin_url': '',
'wa_floating_button_enabled': '0',
'wa_public_number': '',
'wa_public_message': 'Hola, quiero realizar una consulta.',
'wa_public_label': 'Escribinos por WhatsApp',
'ai_chatbot_enabled': '0',
'ai_chatbot_floating_enabled': '0',
'ai_chatbot_booking_enabled': '1',
'ai_chatbot_assistant_name': 'Asistente Virtual',
'ai_chatbot_welcome_message': 'Hola, soy el asistente virtual. Puedo orientarte sobre servicios, especialidades, precios y turnos.',
'ai_chatbot_openai_enabled': '0',
'ai_chatbot_openai_model': 'gpt-4.1-mini',
'ai_chatbot_api_key': '',
'ai_chatbot_system_instructions': 'Respondé de forma breve, clara y segura. No brindes diagnóstico médico. Orientá al paciente y sugerí reservar turno cuando corresponda.',
'ai_chatbot_fallback_message': 'Puedo orientarte con especialidades, profesionales, precios y turnos. También puedo ayudarte a iniciar una reserva.',
'ai_chatbot_sync_public_site': '1',
'mercadopago_gateway_enabled': '0',
'mercadopago_access_token': '',
'mercadopago_public_base_url': '',
'mercadopago_currency': 'ARS',
'mercadopago_default_payer_email': '',
'mercadopago_last_test_url': '',
'mercadopago_last_test_preference_id': '',
'smtp_host': '',
'smtp_port': '587',
'smtp_username': '',
'smtp_password': '',
'smtp_use_tls': '1',
'smtp_from_email': '',
'smtp_from_name': 'Book Appointments Pro',
'message_recipe_subject': 'Tu receta electrónica {{ legal_number }}',
'message_recipe_html': '<p>Hola {{ patient_name }},</p><p>Adjuntamos tu receta electrónica emitida por {{ professional_name }}.</p><p><strong>Número legal:</strong> {{ legal_number }}</p><p><strong>CUIR:</strong> {{ cuir }}</p><p>Podés verificarla aquí: <a href="{{ verify_url }}">{{ verify_url }}</a></p>',
'backup_provider': 'local',
'backup_target_path': '',
'backup_retention_years': '3',
'backup_remote_host': '',
'backup_remote_user': '',
'backup_remote_password': '',
'site_primary_color': '#1977cc',
'site_secondary_color': '#0d6efd',
'site_accent_color': '#0dcaf0',
'site_body_font': 'Roboto',
'site_heading_font': 'Poppins',
'site_base_font_size': '16',
'site_topbar_bg': '#1977cc',
'site_topbar_text': '#ffffff',
'site_header_bg': '#ffffff',
'site_header_text': '#2c4964',
'site_nav_text': '#2c4964',
'site_nav_hover': '#1977cc',
'site_hero_bg': '#f3f9fd',
'site_hero_surface': '#ffffff',
'site_section_bg': '#ffffff',
'site_section_muted_bg': '#f7fbff',
'site_card_bg': '#ffffff',
'site_card_text': '#444444',
'site_title_color': '#2c4964',
'site_footer_bg': '#0f2b45',
'site_footer_text': '#ffffff',
'site_button_text': '#ffffff',
'site_border_radius': '20',
'site_popup_enabled': '0',
'site_popup_title': 'Información importante',
'site_popup_html': '<p>Bienvenido al sitio.</p>',
'site_popup_image_path': '',
'site_popup_link_text': '',
'site_popup_link_url': '',
'site_cookie_banner_enabled': '1',
'site_cookie_text': '<p>Usamos cookies técnicas y de experiencia para mejorar el funcionamiento del sitio.</p>',
'site_terms_html': '<h2>Términos y condiciones</h2><p>Completar desde administración.</p>',
'site_privacy_html': '<h2>Privacidad y protección de datos</h2><p>Completar desde administración.</p>',
'admin_sidebar_bg': "#111827",
'admin_sidebar_text': "#cbd5e1",
'admin_sidebar_active_bg': "#243447",
'admin_sidebar_active_text': "#ffffff",
'admin_body_bg': "#f3f6fb",
'admin_surface_bg': "#ffffff",
'admin_text_color': "#1f2937",
'admin_muted_text_color': "#64748b",
'admin_primary_color': "#0d6efd",
'admin_border_radius': "18",
'admin_font_family': "Inter, Roboto, system-ui, sans-serif",
'admin_font_size': "15",
'admin_title_color': "#0f172a",
'admin_footer_bg': "#ffffff",
'admin_modal_bg': "#ffffff",
'admin_section_bg': "#ffffff",
'admin_input_bg': "#ffffff",
'admin_input_border': "#dbe4f0",
'message_practice_subject': "Tu orden de pr\u00E1ctica {{ legal_number }}",
'message_practice_html': "<p>Hola {{ patient_name }},</p><p>Adjuntamos tu orden de pr\u00E1ctica emitida por {{ professional_name }}.</p><p><strong>N\u00FAmero:</strong> {{ legal_number }}</p>",
'message_report_subject': "Tu informe m\u00E9dico {{ legal_number }}",
'message_report_html': "<p>Hola {{ patient_name }},</p><p>Adjuntamos tu informe emitido por {{ professional_name }}.</p><p><strong>N\u00FAmero:</strong> {{ legal_number }}</p>",
'message_result_subject': "Tu resultado m\u00E9dico {{ legal_number }}",
'message_result_html': "<p>Hola {{ patient_name }},</p><p>Adjuntamos tu resultado emitido por {{ professional_name }}.</p><p><strong>N\u00FAmero:</strong> {{ legal_number }}</p>",
}
def role_required(*roles):
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
if not current_user.is_authenticated:
return abort(401)
if current_user.role not in roles:
return abort(403)
return fn(*args, **kwargs)
return wrapper
return decorator
def log_action(action, entity_type, entity_id=None, details=''):
actor = current_user.email if getattr(current_user, 'is_authenticated', False) else 'system/public'
item = AuditLog(
actor_email=actor,
action=action,
entity_type=entity_type,
entity_id=str(entity_id) if entity_id is not None else None,
details=details,
)
db.session.add(item)
db.session.commit()
def system_log_action(action, entity_type, entity_id=None, details=''):
item = AuditLog(
actor_email='system',
action=action,
entity_type=entity_type,
entity_id=str(entity_id) if entity_id is not None else None,
details=details,
)
db.session.add(item)
db.session.commit()
def store_error(function_name: str, exc: Exception, extra: str = ''):
actor = current_user.email if getattr(current_user, 'is_authenticated', False) else 'system/public'
route = request.path if request else None
entry = ErrorLog(
actor_email=actor,
route=route,
function_name=function_name,
error_type=exc.__class__.__name__,
message=str(exc),
stacktrace=traceback.format_exc(),
extra=extra,
)
db.session.add(entry)
db.session.commit()
def safe_json_dumps(value) -> str:
try:
return json.dumps(value, ensure_ascii=False, default=str)
except Exception:
return str(value)
def ensure_default_settings():
changed = False
for key, value in DEFAULT_SETTINGS.items():
if not AppSetting.query.filter_by(key=key).first():
db.session.add(AppSetting(key=key, value=value))
changed = True
if changed:
db.session.commit()
def get_setting(key: str, default: str = '') -> str:
item = AppSetting.query.filter_by(key=key).first()
if item is None:
if key in DEFAULT_SETTINGS:
item = AppSetting(key=key, value=DEFAULT_SETTINGS[key])
db.session.add(item)
db.session.commit()
else:
return default
return item.value if item.value is not None else default
def set_setting(key: str, value: str):
item = AppSetting.query.filter_by(key=key).first()
if item is None:
item = AppSetting(key=key, value=value)
db.session.add(item)
else:
item.value = value
db.session.commit()
def get_bool_setting(key: str, default: bool = False) -> bool:
return get_setting(key, '1' if default else '0') == '1'
def is_sisa_enabled() -> bool:
return get_setting('sisa_enabled', '0') == '1'
def get_site_settings() -> dict:
whatsapp_public_number = sanitize_digits(get_setting('wa_public_number', ''), max_length=32)
whatsapp_public_message = get_setting('wa_public_message', 'Hola, quiero realizar una consulta.')
whatsapp_floating_enabled = get_bool_setting('wa_floating_button_enabled')
whatsapp_floating_url = ''
if whatsapp_floating_enabled and whatsapp_public_number:
whatsapp_floating_url = f"https://wa.me/{whatsapp_public_number}?text={quote_plus(whatsapp_public_message)}"
ai_chatbot_enabled = get_bool_setting('ai_chatbot_enabled')
ai_chatbot_floating_enabled = get_bool_setting('ai_chatbot_floating_enabled')
socials = []
raw_socials = {}
for network, icon in [
('facebook', 'bi-facebook'),
('instagram', 'bi-instagram'),
('x', 'bi-twitter-x'),
('youtube', 'bi-youtube'),
('linkedin', 'bi-linkedin'),
]:
enabled = get_bool_setting(f'site_social_{network}_enabled')
url = get_setting(f'site_social_{network}_url')
raw_socials[network] = {'enabled': enabled, 'url': url, 'icon': icon}
if enabled and url:
socials.append({'network': network, 'url': url, 'icon': icon})
return {
'title': get_setting('site_title', 'Book Appointments Pro'),
'phone': get_setting('site_phone'),
'url': get_setting('site_url', current_app.config.get('BASE_URL', 'http://127.0.0.1:5000')) if current_app else get_setting('site_url'),
'email': get_setting('site_email'),
'logo_path': get_setting('site_logo_path'),
'favicon_path': get_setting('site_favicon_path'),
'country': get_setting('site_country', 'Argentina'),
'province': get_setting('site_province'),
'municipality': get_setting('site_municipality'),
'city': get_setting('site_city'),
'platform_number': get_setting('site_platform_number', '0000'),
'repository_number': get_setting('site_repository_number', '0000'),
'tagline': get_setting('site_tagline', 'Psiquiatría clínica · receta electrónica · verificación pública'),
'seo_title': get_setting('site_seo_title'),
'meta_description': get_setting('site_meta_description', 'Centro médico con reserva online de turnos, atención profesional, historia clínica digital y receta electrónica verificable.'),
'meta_keywords': get_setting('site_meta_keywords'),
'canonical_url': get_setting('site_canonical_url'),
'seo_indexing_enabled': get_bool_setting('site_seo_indexing_enabled', True),
'robots_meta': get_setting('site_robots_meta', 'index,follow') if get_bool_setting('site_seo_indexing_enabled', True) else 'noindex,nofollow',
'lang': get_setting('site_lang', 'es-AR'),
'locale': get_setting('site_locale', 'es_AR'),
'og_title': get_setting('site_og_title'),
'og_description': get_setting('site_og_description'),
'og_image_path': get_setting('site_og_image_path'),
'og_image_alt': get_setting('site_og_image_alt', 'Imagen institucional del sitio'),
'twitter_card': get_setting('site_twitter_card', 'summary_large_image'),
'twitter_site': get_setting('site_twitter_site'),
'twitter_title': get_setting('site_twitter_title'),
'twitter_description': get_setting('site_twitter_description'),
'schema_type': get_setting('site_schema_type', 'MedicalClinic'),
'legal_name': get_setting('site_legal_name'),
'price_range': get_setting('site_price_range', '$$'),
'google_site_verification': get_setting('site_google_site_verification'),
'bing_site_verification': get_setting('site_bing_site_verification'),
'contact_address': get_setting('site_contact_address'),
'contact_map_embed': get_setting('site_contact_map_embed'),
'copyright': get_setting('site_copyright', '© Book Appointments Pro Salud. Todos los derechos reservados.'),
'nav_cta_label': get_setting('site_nav_cta_label', 'Reservar turno'),
'whatsapp_floating_enabled': whatsapp_floating_enabled,
'whatsapp_public_number': whatsapp_public_number,
'whatsapp_public_message': whatsapp_public_message,
'whatsapp_public_label': get_setting('wa_public_label', 'Escribinos por WhatsApp'),
'whatsapp_floating_url': whatsapp_floating_url,
'ai_chatbot_enabled': ai_chatbot_enabled,
'ai_chatbot_floating_enabled': ai_chatbot_floating_enabled,
'ai_chatbot_booking_enabled': get_bool_setting('ai_chatbot_booking_enabled', True),
'ai_chatbot_assistant_name': get_setting('ai_chatbot_assistant_name', 'Asistente Virtual'),
'ai_chatbot_welcome_message': get_setting('ai_chatbot_welcome_message', 'Hola, soy el asistente virtual. Puedo orientarte sobre servicios, especialidades, precios y turnos.'),
'retention_years': get_setting('backup_retention_years', '3'),
'primary_color': get_setting('site_primary_color', '#1977cc'),
'secondary_color': get_setting('site_secondary_color', '#0d6efd'),
'accent_color': get_setting('site_accent_color', '#0dcaf0'),
'body_font': get_setting('site_body_font', 'Roboto'),
'heading_font': get_setting('site_heading_font', 'Poppins'),
'base_font_size': get_setting('site_base_font_size', '16'),
'topbar_bg': get_setting('site_topbar_bg', '#1977cc'),
'topbar_text': get_setting('site_topbar_text', '#ffffff'),
'header_bg': get_setting('site_header_bg', '#ffffff'),
'header_text': get_setting('site_header_text', '#2c4964'),
'nav_text': get_setting('site_nav_text', '#2c4964'),
'nav_hover': get_setting('site_nav_hover', '#1977cc'),
'hero_bg': get_setting('site_hero_bg', '#f3f9fd'),
'hero_surface': get_setting('site_hero_surface', '#ffffff'),
'section_bg': get_setting('site_section_bg', '#ffffff'),
'section_muted_bg': get_setting('site_section_muted_bg', '#f7fbff'),
'card_bg': get_setting('site_card_bg', '#ffffff'),
'card_text': get_setting('site_card_text', '#444444'),
'title_color': get_setting('site_title_color', '#2c4964'),
'footer_bg': get_setting('site_footer_bg', '#0f2b45'),
'footer_text': get_setting('site_footer_text', '#ffffff'),
'button_text': get_setting('site_button_text', '#ffffff'),
'border_radius': get_setting('site_border_radius', '20'),
'popup_enabled': get_bool_setting('site_popup_enabled', False),
'popup_title': get_setting('site_popup_title', 'Información importante'),
'popup_html': get_setting('site_popup_html', '<p>Bienvenido al sitio.</p>'),
'popup_image_path': get_setting('site_popup_image_path'),
'popup_link_text': get_setting('site_popup_link_text'),
'popup_link_url': get_setting('site_popup_link_url'),
'cookie_banner_enabled': get_bool_setting('site_cookie_banner_enabled', True),
'cookie_text': get_setting('site_cookie_text', '<p>Usamos cookies técnicas y de experiencia para mejorar el funcionamiento del sitio.</p>'),
'terms_html': get_setting('site_terms_html', '<h2>Términos y condiciones</h2><p>Completar desde administración.</p>'),
'privacy_html': get_setting('site_privacy_html', '<h2>Privacidad y protección de datos</h2><p>Completar desde administración.</p>'),
'admin_style': {
'sidebar_bg': get_setting('admin_sidebar_bg', '#111827'),
'sidebar_text': get_setting('admin_sidebar_text', '#cbd5e1'),
'sidebar_active_bg': get_setting('admin_sidebar_active_bg', '#243447'),
'sidebar_active_text': get_setting('admin_sidebar_active_text', '#ffffff'),
'body_bg': get_setting('admin_body_bg', '#f3f6fb'),
'surface_bg': get_setting('admin_surface_bg', '#ffffff'),
'text_color': get_setting('admin_text_color', '#1f2937'),
'muted_text_color': get_setting('admin_muted_text_color', '#64748b'),
'primary_color': get_setting('admin_primary_color', '#0d6efd'),
'border_radius': get_setting('admin_border_radius', '18'),
'font_family': get_setting('admin_font_family', 'Inter, Roboto, system-ui, sans-serif'),
'font_size': get_setting('admin_font_size', '15'),
'title_color': get_setting('admin_title_color', '#0f172a'),
'footer_bg': get_setting('admin_footer_bg', '#ffffff'),
'modal_bg': get_setting('admin_modal_bg', '#ffffff'),
'section_bg': get_setting('admin_section_bg', '#ffffff'),
'input_bg': get_setting('admin_input_bg', '#ffffff'),
'input_border': get_setting('admin_input_border', '#dbe4f0'),
},
'socials': socials,
'socials_raw': raw_socials,
}
def generate_public_token():
return token_urlsafe(24)
def hash_payload(value) -> str:
if not isinstance(value, str):
value = safe_json_dumps(value)
return hashlib.sha256(value.encode('utf-8')).hexdigest()
def overlaps(start_a: datetime, end_a: datetime, start_b: datetime, end_b: datetime):
return start_a < end_b and start_b < end_a
def professional_is_on_leave(professional_id: int, target_date: date) -> bool:
return Leave.query.filter(
Leave.professional_id == professional_id,
Leave.start_date <= target_date,
Leave.end_date >= target_date,
).first() is not None
def get_working_blocks(professional_id: int, target_date: date):
return WorkingHour.query.filter_by(
professional_id=professional_id,
weekday=target_date.weekday(),
is_active=True,
).order_by(WorkingHour.start_time.asc()).all()
def appointment_overlaps_existing(professional_id: int, target_date: date, start_at: datetime, end_at: datetime, ignore_appointment_id: int | None = None):
appointments = Appointment.query.filter(
Appointment.professional_id == professional_id,
Appointment.appointment_date == target_date,
Appointment.status.in_(ACTIVE_APPOINTMENT_STATUSES),
).all()
for appt in appointments:
if ignore_appointment_id and appt.id == ignore_appointment_id:
continue
if overlaps(start_at, end_at, appt.starts_at, appt.ends_at):
return True
return False
def ensure_clinical_record(patient: Patient, retention_years: int = 10):
if not patient:
return None
existing = ClinicalRecord.query.filter_by(patient_id=patient.id).first()
if existing:
return existing
today = date.today()
record = ClinicalRecord(
patient_id=patient.id,
legajo_number=(patient.documento or f'LEG-{patient.id}').strip(),
retention_until=date(today.year + retention_years, today.month, today.day),
)
db.session.add(record)
db.session.flush()
return record
def get_frontend_blocks(section: str) -> list[FrontendBlock]:
return FrontendBlock.query.filter_by(section=section, is_active=True).order_by(FrontendBlock.sort_order.asc(), FrontendBlock.id.asc()).all()
def get_frontend_payload() -> dict:
sections = ['navbar_menu', 'about_items', 'stats_items', 'service_cards', 'department_cards', 'faq_items', 'gallery_items', 'contact_items', 'footer_columns']
payload = {section: get_frontend_blocks(section) for section in sections}
return payload
def get_available_slots(professional, service, target_date: date, step_minutes: int = 15):
if not professional or not service or not professional.is_bookable or professional_is_on_leave(professional.id, target_date):
return []
if service not in professional.services:
return []
now = datetime.now()
min_notice_cutoff = now + timedelta(hours=service.min_notice_hours or 0)
slot_delta = timedelta(minutes=step_minutes)
total_duration = timedelta(minutes=service.duration_minutes)
buffer_before = timedelta(minutes=service.buffer_before_minutes or 0)
buffer_after = timedelta(minutes=service.buffer_after_minutes or 0)
blocks = get_working_blocks(professional.id, target_date)
if not blocks:
return []
results = []
for block in blocks:
block_start = datetime.combine(target_date, block.start_time)
block_end = datetime.combine(target_date, block.end_time)
cursor = block_start
while cursor + total_duration <= block_end:
visible_start = cursor
visible_end = cursor + total_duration
blocked_start = visible_start - buffer_before
blocked_end = visible_end + buffer_after
if visible_start >= min_notice_cutoff and not appointment_overlaps_existing(
professional.id,
target_date,
blocked_start,
blocked_end,
):
results.append({
'start': visible_start,
'end': visible_end,
'label': visible_start.strftime('%H:%M'),
})
cursor += slot_delta
unique = []
seen = set()
for slot in results:
if slot['label'] not in seen:
unique.append(slot)
seen.add(slot['label'])
return unique
def choose_first_available_professional(service, target_date: date):
professionals = [p for p in service.professionals if p.is_bookable]
ranked = []
for professional in professionals:
slots = get_available_slots(professional, service, target_date)
if slots:
ranked.append((slots[0]['start'], professional, slots))
ranked.sort(key=lambda item: (item[0], item[1].display_name.lower()))
if ranked:
_, professional, slots = ranked[0]
return professional, slots
return None, []
def choose_round_robin_professional(service, target_date: date):
professionals = [p for p in service.professionals if p.is_bookable]
if not professionals:
return None, []
counts = []
for p in professionals:
future_count = Appointment.query.filter(
Appointment.professional_id == p.id,
Appointment.appointment_date >= date.today(),
Appointment.status.in_(ACTIVE_APPOINTMENT_STATUSES),
).count()
slots = get_available_slots(p, service, target_date)
if slots:
counts.append((future_count, slots[0]['start'], p, slots))
counts.sort(key=lambda item: (item[0], item[1], item[2].display_name.lower()))
if counts:
_, _, professional, slots = counts[0]
return professional, slots
return None, []
def professional_scope_filter(query, professional_profile_id: int | None):
if professional_profile_id:
return query.filter_by(professional_id=professional_profile_id)
return query
def parse_date(value: str, default=None):
try:
return datetime.strptime(value, '%Y-%m-%d').date()
except Exception:
return default
def parse_time(value: str, default=None):
try:
return datetime.strptime(value, '%H:%M').time()
except Exception:
return default
def today_iso():
return date.today().isoformat()
def start_of_week(target: date | None = None):
target = target or date.today()
return target - timedelta(days=target.weekday())
def build_week_dates(target: date | None = None):
monday = start_of_week(target)
return [monday + timedelta(days=i) for i in range(7)]
def get_due_reminders(window_hours: int = 24, professional_id: int | None = None):
now = datetime.now()
limit = now + timedelta(hours=window_hours)
query = Appointment.query.filter(
Appointment.status.in_(ACTIVE_APPOINTMENT_STATUSES),
Appointment.reminder_sent.is_(False),
Appointment.appointment_date >= now.date(),
)
if professional_id:
query = query.filter(Appointment.professional_id == professional_id)
items = []
for appt in query.order_by(Appointment.appointment_date.asc(), Appointment.start_time.asc()).all():
if now <= appt.starts_at <= limit:
items.append(appt)
return items
def digits_only(value: str) -> str:
return re.sub(r'\D+', '', value or '')
def pad_numeric(value: str, width: int) -> str:
digits = digits_only(value)
return digits.zfill(width)[-width:]
def mask_last4_dni(value: str) -> str:
digits = digits_only(value)
if len(digits) <= 4:
return digits
return f"****{digits[-4:]}"
def resolve_jurisdiction_code(value: str) -> str:
raw = (value or '').strip()
if not raw:
return '00'
direct = digits_only(raw)
if len(direct) >= 2:
code = direct[:2]
for item in JURISDICTION_OPTIONS:
if item['code'] == code:
return code
raw_lower = raw.lower()
for item in JURISDICTION_OPTIONS:
if item['name'].lower() == raw_lower:
return item['code']
if raw_lower in item['name'].lower() or item['name'].lower() in raw_lower:
return item['code']
return '00'
def resolve_jurisdiction_name(code_or_value: str) -> str:
code = resolve_jurisdiction_code(code_or_value)
for item in JURISDICTION_OPTIONS:
if item['code'] == code:
return item['name']
return (code_or_value or '').strip()
def generate_prescription_group() -> str:
seed = datetime.utcnow().strftime('%Y%m%d%H%M%S%f')
return seed[:25].ljust(25, '0')
def build_cuir(platform_number: str, repository_number: str, jurisdiction_code: str, prescription_type: str, prescription_subtype: str, prescription_group: str, item_number: str) -> str:
return ''.join([
pad_numeric(platform_number, 4),
pad_numeric(repository_number, 4),
pad_numeric(jurisdiction_code, 2),
pad_numeric(prescription_type, 2) + pad_numeric(prescription_subtype, 2),
pad_numeric(prescription_group, 25),
pad_numeric(item_number, 2),
])
def generate_legal_number() -> str:
return datetime.utcnow().strftime('%Y%m%d%H%M%S%f')[:20]
def save_uploaded_asset(file_storage, upload_dir: str, prefix: str = 'site') -> str:
if not file_storage or not getattr(file_storage, 'filename', ''):
return ''
original_name, ext = validate_uploaded_asset(file_storage, prefix=prefix)
safe_stem = sanitize_identifier(os.path.splitext(original_name)[0], max_length=60) or prefix
safe_name = f"{prefix}_{safe_stem}_{datetime.utcnow().strftime('%Y%m%d%H%M%S%f')}{ext}"
os.makedirs(upload_dir, exist_ok=True)
full_path = os.path.join(upload_dir, safe_name)
file_storage.save(full_path)
return f'uploads/{safe_name}'
def get_smtp_settings() -> dict:
return {
'host': get_setting('smtp_host'),
'port': int(get_setting('smtp_port', '587') or 587),
'username': get_setting('smtp_username'),
'password': get_setting('smtp_password'),
'use_tls': get_bool_setting('smtp_use_tls', True),
'from_email': get_setting('smtp_from_email') or get_setting('site_email'),
'from_name': get_setting('smtp_from_name', 'Book Appointments Pro'),
'recipe_subject': get_setting('message_recipe_subject', 'Tu receta electrónica {{ legal_number }}'),
'recipe_html': get_setting('message_recipe_html', '<p>Adjuntamos tu receta electrónica.</p>'),
'practice_subject': get_setting('message_practice_subject', 'Tu orden de práctica {{ legal_number }}'),
'practice_html': get_setting('message_practice_html', '<p>Adjuntamos tu orden de práctica.</p>'),
'report_subject': get_setting('message_report_subject', 'Tu informe médico {{ legal_number }}'),
'report_html': get_setting('message_report_html', '<p>Adjuntamos tu informe médico.</p>'),
'result_subject': get_setting('message_result_subject', 'Tu resultado médico {{ legal_number }}'),
'result_html': get_setting('message_result_html', '<p>Adjuntamos tu resultado médico.</p>'),
}
def render_message_template(source: str, context: dict) -> str:
source = source or ''
return Template(source).render(**context)
def sha256_file(path: str) -> str:
h = hashlib.sha256()
with open(path, 'rb') as f:
for chunk in iter(lambda: f.read(65536), b''):
h.update(chunk)
return h.hexdigest()
def send_smtp_email(to_email: str, subject: str, html_body: str, attachments: list[dict] | None = None):
settings = get_smtp_settings()
if not settings['host'] or not settings['from_email']:
raise ValueError('SMTP incompleto: faltan host o remitente.')
msg = EmailMessage()
msg['Subject'] = subject
msg['From'] = f"{settings['from_name']} <{settings['from_email']}>" if settings['from_name'] else settings['from_email']
msg['To'] = to_email
msg.set_content('Este mensaje contiene una versión HTML.')
msg.add_alternative(html_body or '<p>Sin contenido.</p>', subtype='html')
for item in attachments or []:
msg.add_attachment(item['content'], maintype=item.get('maintype', 'application'), subtype=item.get('subtype', 'octet-stream'), filename=item.get('filename', 'archivo.bin'))
with smtplib.SMTP(settings['host'], settings['port']) as smtp:
if settings['use_tls']:
smtp.starttls()
if settings['username']:
smtp.login(settings['username'], settings['password'])
smtp.send_message(msg)