mi-proyecto/app/__init__.py

261 lines
18 KiB
Python

import os
import secrets
from flask import Flask, render_template, request, session, abort
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from dotenv import load_dotenv
from flask_socketio import SocketIO
from sqlalchemy import inspect, text as sql_text
load_dotenv()
db = SQLAlchemy()
login_manager = LoginManager()
login_manager.login_view = 'login'
login_manager.login_message = 'Iniciá sesión para continuar.'
socketio = SocketIO(cors_allowed_origins="*")
def ensure_runtime_schema(app):
with app.app_context():
inspector = inspect(db.engine)
def has_column(table_name, column_name):
try:
return any(col.get('name') == column_name for col in inspector.get_columns(table_name))
except Exception:
return False
patches = [
('saas_plan','name',"CREATE TABLE IF NOT EXISTS saas_plan (id INTEGER PRIMARY KEY, created_at DATETIME, updated_at DATETIME, name VARCHAR(140) NOT NULL UNIQUE, description TEXT, monthly_price FLOAT DEFAULT 0, price_per_appointment FLOAT DEFAULT 0, included_professionals INTEGER DEFAULT 1, extra_professional_price FLOAT DEFAULT 0, included_appointments INTEGER DEFAULT 0, active BOOLEAN DEFAULT 1)"),
('institution_subscription','institution_id',"CREATE TABLE IF NOT EXISTS institution_subscription (id INTEGER PRIMARY KEY, created_at DATETIME, updated_at DATETIME, institution_id INTEGER NOT NULL, plan_id INTEGER NOT NULL, start_date DATE, billing_day INTEGER DEFAULT 1, status VARCHAR(30) DEFAULT 'activa', notes TEXT)"),
('saas_invoice','institution_id',"CREATE TABLE IF NOT EXISTS saas_invoice (id INTEGER PRIMARY KEY, created_at DATETIME, updated_at DATETIME, institution_id INTEGER NOT NULL, subscription_id INTEGER, period VARCHAR(7) NOT NULL, issue_date DATE, due_date DATE, monthly_amount FLOAT DEFAULT 0, appointment_count INTEGER DEFAULT 0, appointment_amount FLOAT DEFAULT 0, professional_count INTEGER DEFAULT 0, extra_professional_amount FLOAT DEFAULT 0, total_amount FLOAT DEFAULT 0, paid_amount FLOAT DEFAULT 0, status VARCHAR(30) DEFAULT 'pendiente', payment_method VARCHAR(40), mercadopago_preference_id VARCHAR(160), mercadopago_payment_id VARCHAR(160), notes TEXT)"),
('saas_payment','invoice_id',"CREATE TABLE IF NOT EXISTS saas_payment (id INTEGER PRIMARY KEY, created_at DATETIME, updated_at DATETIME, invoice_id INTEGER NOT NULL, payment_date DATE, amount FLOAT DEFAULT 0, method VARCHAR(40) DEFAULT 'efectivo', reference VARCHAR(160), notes TEXT, created_by_user_id INTEGER)"),
('saas_invoice','mercadopago_status','ALTER TABLE saas_invoice ADD COLUMN mercadopago_status VARCHAR(80)'),
('saas_invoice','mercadopago_status_detail','ALTER TABLE saas_invoice ADD COLUMN mercadopago_status_detail VARCHAR(160)'),
('saas_invoice','mercadopago_init_point','ALTER TABLE saas_invoice ADD COLUMN mercadopago_init_point VARCHAR(600)'),
('saas_invoice','mercadopago_sandbox_init_point','ALTER TABLE saas_invoice ADD COLUMN mercadopago_sandbox_init_point VARCHAR(600)'),
('saas_invoice','mercadopago_payload','ALTER TABLE saas_invoice ADD COLUMN mercadopago_payload TEXT'),
('saas_payment','status',"ALTER TABLE saas_payment ADD COLUMN status VARCHAR(40) DEFAULT 'registrado'"),
('saas_payment','mercadopago_payment_id','ALTER TABLE saas_payment ADD COLUMN mercadopago_payment_id VARCHAR(160)'),
('saas_payment','mercadopago_status','ALTER TABLE saas_payment ADD COLUMN mercadopago_status VARCHAR(80)'),
('saas_payment','mercadopago_status_detail','ALTER TABLE saas_payment ADD COLUMN mercadopago_status_detail VARCHAR(160)'),
('saas_payment','mercadopago_payload','ALTER TABLE saas_payment ADD COLUMN mercadopago_payload TEXT'),
('institution','name',"CREATE TABLE IF NOT EXISTS institution (id INTEGER PRIMARY KEY, created_at DATETIME, updated_at DATETIME, name VARCHAR(180) NOT NULL, cuit VARCHAR(30), domicilio VARCHAR(220), ciudad VARCHAR(120), provincia VARCHAR(120), telefono VARCHAR(80), responsable VARCHAR(160), situacion_juridica VARCHAR(120), nro_habilitacion VARCHAR(120), expediente_autorizacion VARCHAR(160), email VARCHAR(160), telefono_responsable VARCHAR(80), logo_path VARCHAR(255), active BOOLEAN DEFAULT 1)"),
('institution_branch','name',"CREATE TABLE IF NOT EXISTS institution_branch (id INTEGER PRIMARY KEY, created_at DATETIME, updated_at DATETIME, institution_id INTEGER NOT NULL, name VARCHAR(160) NOT NULL, address VARCHAR(220), province VARCHAR(120), city VARCHAR(120), phone VARCHAR(80), email VARCHAR(160), notes TEXT, active BOOLEAN DEFAULT 1)"),
('appointment','branch_id','ALTER TABLE appointment ADD COLUMN branch_id INTEGER'),
('user','institution_id','ALTER TABLE user ADD COLUMN institution_id INTEGER'),
('professional_profile','institution_id','ALTER TABLE professional_profile ADD COLUMN institution_id INTEGER'),
('patient','institution_id','ALTER TABLE patient ADD COLUMN institution_id INTEGER'),
('service','institution_id','ALTER TABLE service ADD COLUMN institution_id INTEGER'),
('appointment','institution_id','ALTER TABLE appointment ADD COLUMN institution_id INTEGER'),
('prescription','institution_id','ALTER TABLE prescription ADD COLUMN institution_id INTEGER'),
('clinical_record','institution_id','ALTER TABLE clinical_record ADD COLUMN institution_id INTEGER'),
('clinical_episode','institution_id','ALTER TABLE clinical_episode ADD COLUMN institution_id INTEGER'),
('clinical_entry','institution_id','ALTER TABLE clinical_entry ADD COLUMN institution_id INTEGER'),
('clinical_episode_template','title',"CREATE TABLE IF NOT EXISTS clinical_episode_template (id INTEGER PRIMARY KEY, created_at DATETIME, updated_at DATETIME, professional_id INTEGER NOT NULL, created_by_user_id INTEGER, title VARCHAR(180) NOT NULL, category VARCHAR(120) DEFAULT 'General', specialty_name VARCHAR(150), reason VARCHAR(255), diagnosis_summary VARCHAR(255), care_level VARCHAR(80) DEFAULT 'Ambulatorio', visibility_scope VARCHAR(30) DEFAULT 'Institucional', notes TEXT, source_episode_id INTEGER, usage_count INTEGER DEFAULT 0, is_active BOOLEAN DEFAULT 1, institution_id INTEGER)"),
('accounting_entry','institution_id','ALTER TABLE accounting_entry ADD COLUMN institution_id INTEGER'),
('chat_conversation','institution_id','ALTER TABLE chat_conversation ADD COLUMN institution_id INTEGER'),
('whatsapp_bot_rule','institution_id','ALTER TABLE whatsapp_bot_rule ADD COLUMN institution_id INTEGER'),
('whatsapp_bot_knowledge','institution_id','ALTER TABLE whatsapp_bot_knowledge ADD COLUMN institution_id INTEGER'),
('whatsapp_message_log','institution_id','ALTER TABLE whatsapp_message_log ADD COLUMN institution_id INTEGER'),
('clinical_record','summary_payload','ALTER TABLE clinical_record ADD COLUMN summary_payload TEXT'),
('clinical_entry','chief_complaint','ALTER TABLE clinical_entry ADD COLUMN chief_complaint VARCHAR(255)'),
('clinical_entry','provisional_diagnosis','ALTER TABLE clinical_entry ADD COLUMN provisional_diagnosis VARCHAR(255)'),
('clinical_entry','specialty_template',"ALTER TABLE clinical_entry ADD COLUMN specialty_template VARCHAR(80) DEFAULT 'general_medicine'"),
('clinical_entry','vitals_payload','ALTER TABLE clinical_entry ADD COLUMN vitals_payload TEXT'),
('clinical_entry','structured_payload','ALTER TABLE clinical_entry ADD COLUMN structured_payload TEXT'),
('clinical_entry','entry_status',"ALTER TABLE clinical_entry ADD COLUMN entry_status VARCHAR(30) DEFAULT 'Firmado'"),
('clinical_entry','locked_at','ALTER TABLE clinical_entry ADD COLUMN locked_at DATETIME'),
('clinical_entry','episode_id','ALTER TABLE clinical_entry ADD COLUMN episode_id INTEGER'),
('clinical_entry','signature_payload','ALTER TABLE clinical_entry ADD COLUMN signature_payload TEXT'),
('clinical_entry','edit_revision','ALTER TABLE clinical_entry ADD COLUMN edit_revision INTEGER DEFAULT 1'),
('clinical_entry','last_edited_at','ALTER TABLE clinical_entry ADD COLUMN last_edited_at DATETIME'),
('clinical_entry','last_edited_by_user_id','ALTER TABLE clinical_entry ADD COLUMN last_edited_by_user_id INTEGER'),
('clinical_attachment','is_patient_visible','ALTER TABLE clinical_attachment ADD COLUMN is_patient_visible BOOLEAN DEFAULT 0'),
('prescription','document_kind',"ALTER TABLE prescription ADD COLUMN document_kind VARCHAR(30) DEFAULT 'recipe'"),
('prescription','document_title','ALTER TABLE prescription ADD COLUMN document_title VARCHAR(255)'),
('prescription','document_body','ALTER TABLE prescription ADD COLUMN document_body TEXT'),
('prescription','portal_visible','ALTER TABLE prescription ADD COLUMN portal_visible BOOLEAN DEFAULT 1'),
('whatsapp_bot_rule','trigger',"CREATE TABLE IF NOT EXISTS whatsapp_bot_rule (id INTEGER PRIMARY KEY, created_at DATETIME, updated_at DATETIME, trigger VARCHAR(120) NOT NULL, title VARCHAR(180), response_html TEXT NOT NULL, match_mode VARCHAR(30) DEFAULT 'contains', sort_order INTEGER DEFAULT 0, is_active BOOLEAN DEFAULT 1)"),
('whatsapp_bot_knowledge','content',"CREATE TABLE IF NOT EXISTS whatsapp_bot_knowledge (id INTEGER PRIMARY KEY, created_at DATETIME, updated_at DATETIME, source_url VARCHAR(500), title VARCHAR(255), content TEXT NOT NULL, keywords VARCHAR(500), is_active BOOLEAN DEFAULT 1)"),
('whatsapp_message_log','wa_id',"CREATE TABLE IF NOT EXISTS whatsapp_message_log (id INTEGER PRIMARY KEY, created_at DATETIME, updated_at DATETIME, wa_id VARCHAR(80), customer_name VARCHAR(180), incoming_text TEXT, outgoing_text TEXT, provider_message_id VARCHAR(120), status VARCHAR(40) DEFAULT 'received', raw_payload TEXT, error TEXT)"),
]
for table_name, column_name, ddl in patches:
if not has_column(table_name, column_name):
db.session.execute(sql_text(ddl))
db.session.commit()
existing_tables = inspect(db.engine).get_table_names()
if 'clinical_attachment' not in existing_tables or 'clinical_episode' not in existing_tables or 'clinical_episode_member' not in existing_tables or 'clinical_episode_template' not in existing_tables or 'institution_branch' not in existing_tables:
db.create_all()
def ensure_default_institution_links():
from .models import Institution, InstitutionBranch, User, ProfessionalProfile, Patient, Service, Appointment, Prescription, ClinicalRecord, ClinicalEpisode, ClinicalEpisodeTemplate, ClinicalEntry, AccountingEntry, ChatConversation, WhatsappBotRule, WhatsappBotKnowledge, WhatsappMessageLog
default = Institution.query.order_by(Institution.id.asc()).first()
if not default:
default = Institution(name='Institución Demo', cuit='00-00000000-0', email='contacto@demo.com', telefono='11-5555-0000', active=True)
db.session.add(default)
db.session.flush()
for model in [User, ProfessionalProfile, Patient, Service, Appointment, Prescription, ClinicalRecord, ClinicalEpisode, ClinicalEpisodeTemplate, ClinicalEntry, AccountingEntry, ChatConversation, WhatsappBotRule, WhatsappBotKnowledge, WhatsappMessageLog]:
if hasattr(model, 'institution_id'):
model.query.filter(model.institution_id.is_(None)).update({model.institution_id: default.id}, synchronize_session=False)
for prof in ProfessionalProfile.query.all():
if prof.user and not prof.user.institution_id and prof.institution_id:
prof.user.institution_id = prof.institution_id
for inst in Institution.query.all():
if InstitutionBranch.query.filter_by(institution_id=inst.id).count() == 0:
db.session.add(InstitutionBranch(
institution_id=inst.id,
name='Sede principal',
address=inst.domicilio,
city=inst.ciudad,
province=inst.provincia,
phone=inst.telefono,
email=inst.email,
active=True,
))
db.session.commit()
def create_app():
app = Flask(__name__)
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key-change-me')
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///book_appointments.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['APP_NAME'] = os.getenv('APP_NAME', 'Book Appointments Pro')
app.config['BASE_URL'] = os.getenv('BASE_URL', 'http://127.0.0.1:5000')
app.config['REMINDER_WINDOW_HOURS'] = int(os.getenv('REMINDER_WINDOW_HOURS', '24'))
app.config['UPLOAD_FOLDER'] = os.path.join(app.root_path, 'static', 'uploads')
app.config['BACKUP_FOLDER'] = os.path.join(app.root_path, '..', 'backups')
app.config['GENERATED_FOLDER'] = os.path.join(app.root_path, 'static', 'generated')
app.config['MAX_CONTENT_LENGTH'] = int(os.getenv('MAX_CONTENT_LENGTH', str(16 * 1024 * 1024)))
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['SESSION_COOKIE_SECURE'] = os.getenv('SESSION_COOKIE_SECURE', '0') == '1'
app.config['REMEMBER_COOKIE_HTTPONLY'] = True
app.config['REMEMBER_COOKIE_SAMESITE'] = 'Lax'
app.config['REMEMBER_COOKIE_SECURE'] = os.getenv('SESSION_COOKIE_SECURE', '0') == '1'
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
os.makedirs(app.config['BACKUP_FOLDER'], exist_ok=True)
os.makedirs(app.config['GENERATED_FOLDER'], exist_ok=True)
db.init_app(app)
socketio.init_app(app)
login_manager.init_app(app)
from .models import User, Patient
from .utils import get_due_reminders, system_log_action, ensure_default_settings, store_error
from .integrations import sync_obras_sociales
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
from .routes import register_routes
register_routes(app)
def ensure_csrf_token():
token = session.get('_csrf_token')
if not token:
token = secrets.token_urlsafe(32)
session['_csrf_token'] = token
return token
@app.context_processor
def inject_security_tokens():
return {'csrf_token': ensure_csrf_token()}
@app.before_request
def validate_csrf_token():
ensure_csrf_token()
if request.path.startswith('/webhooks/whatsapp') or request.path.startswith('/webhooks/mercadopago'):
return
if request.method in {'POST', 'PUT', 'PATCH', 'DELETE'}:
token = request.form.get('csrf_token') or request.headers.get('X-CSRF-Token') or ''
if token != session.get('_csrf_token'):
abort(400, description='Token CSRF inválido o ausente.')
@app.after_request
def apply_security_headers(response):
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
response.headers.setdefault('X-Frame-Options', 'SAMEORIGIN')
response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
response.headers.setdefault('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
response.headers.setdefault('Cross-Origin-Opener-Policy', 'same-origin-allow-popups')
return response
@app.errorhandler(500)
def handle_500(error):
try:
db.session.rollback()
store_error('internal_server_error', error, extra=f'{request.method} {request.path}')
except Exception:
db.session.rollback()
return render_template('error.html', message='Se produjo un error interno. El evento quedó registrado en Logs.'), 500
@app.cli.command('init-db')
def init_db_command():
from .seed import seed_demo_data
with app.app_context():
db.drop_all()
db.create_all()
ensure_default_settings()
seed_demo_data()
print('Base de datos creada y datos demo cargados.')
@app.cli.command('send-reminders')
def send_reminders_command():
with app.app_context():
due = get_due_reminders(app.config['REMINDER_WINDOW_HOURS'])
if not due:
print('No hay recordatorios pendientes dentro de la ventana configurada.')
return
for appt in due:
appt.reminder_sent = True
print(f"REMINDER -> {appt.client_email} | {appt.client_name} | {appt.appointment_date} {appt.start_time.strftime('%H:%M')} | {appt.service.name}")
system_log_action('reminder_sent', 'Appointment', appt.id, f'Recordatorio simulado a {appt.client_email}')
db.session.commit()
print(f'Se marcaron {len(due)} recordatorios como enviados.')
@app.cli.command('sync-obras-sociales')
def sync_obras_sociales_command():
with app.app_context():
ensure_default_settings()
result = sync_obras_sociales()
print(result)
with app.app_context():
db.create_all()
ensure_runtime_schema(app)
ensure_default_settings()
ensure_default_institution_links()
try:
for patient in Patient.query.all():
patient_email = (patient.email or '').strip().lower()
if not patient_email:
continue
user = User.query.filter_by(email=patient_email).first()
if not user:
user = User.query.filter_by(role='client', full_name=patient.nombre_completo).first()
if user and user.role != 'client':
continue
created = False
if not user:
user = User(full_name=patient.nombre_completo, email=patient_email, role='client', is_active_user=True)
user.set_password((patient.documento or 'Cambio1234').strip())
db.session.add(user)
created = True
else:
user.full_name = patient.nombre_completo
user.email = patient_email
user.role = 'client'
user.is_active_user = True
if created:
db.session.flush()
db.session.commit()
except Exception:
db.session.rollback()
return app