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