import importlib.util import logging import os import sys from logging.config import fileConfig from urllib.parse import quote_plus from alembic import context from alembic.operations import ops from core.config import settings from core.database import Base from sqlalchemy import engine_from_config, pool from sqlalchemy.engine.url import make_url logger = logging.getLogger(__name__) # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config def _strip_env_url(raw: str) -> str: """Quita espacios/comillas típicos de secretos CI (.env, Gitea).""" url = raw.strip().strip('"').strip("'") return url def _normalize_alembic_sqlalchemy_url(url: str) -> str: """Alembic usa el driver síncrono psycopg2; normaliza DSN típicos de app/tests.""" url = _strip_env_url(url) if url.startswith("postgresql+asyncpg://"): return url.replace("postgresql+asyncpg://", "postgresql+psycopg2://", 1) if url.startswith("postgresql+psycopg2://"): return url if url.startswith("postgresql://"): return url.replace("postgresql://", "postgresql+psycopg2://", 1) if url.startswith("postgres://"): return url.replace("postgres://", "postgresql+psycopg2://", 1) return url def _validate_sqlalchemy_url(url: str, env_key: str) -> None: """Misma validación que create_engine; evita urlparse (falla con esquemas tipo postgresql+psycopg2).""" try: make_url(url) except Exception as e: raise RuntimeError( f"{env_key} no es una URL de SQLAlchemy válida. " "Ejemplo: postgresql://usuario:clave@127.0.0.1:5432/nombre_bd" ) from e def _reject_documentation_placeholder_host(url: str, source: str) -> None: """ Evita el error críptico de DNS: muchos ejemplos usan @host:5432 como texto literal. """ try: parsed = make_url(url) except Exception: return h = (parsed.host or "").strip().lower() if h == "host": raise RuntimeError( f"{source}: el hostname \"host\" es un placeholder de documentación, no un servidor real. " "Usa el host alcanzable desde el runner (IP, nombre DNS, servicio en docker-compose, " "o host.docker.internal si act corre en contenedor y Postgres en tu máquina)." ) def get_database_url(): """Obtiene la URL de la base de datos (PostgreSQL) desde variables de entorno o alembic.ini.""" # CI / pytest: misma URL que los tests (secret TEST_DATABASE_URL) o DATABASE_URL explícita. # CRÍTICO: debe ser tupla con coma final si un solo elemento: ("X",) — si no, ("X") es str y el for # itera caracteres; env_key "_" + os.environ["_"] (común en shells) rompe con URL inválida. for env_key in ("TEST_DATABASE_URL", "DATABASE_URL"): raw = os.environ.get(env_key) if raw and raw.strip(): normalized = _normalize_alembic_sqlalchemy_url(raw) _validate_sqlalchemy_url(normalized, env_key) _reject_documentation_placeholder_host(normalized, env_key) return normalized # Construcción desde settings (CORE_DB_* en .env / entorno) host = settings.CORE_DB_HOST db = settings.CORE_DB_NAME user = settings.CORE_DB_USER password = settings.CORE_DB_PASSWORD port = settings.CORE_DB_PORT if host and db and user and password: try: encoded_user = quote_plus(user) encoded_password = quote_plus(password) encoded_db = quote_plus(db) built = f"postgresql+psycopg2://{encoded_user}:{encoded_password}@{host}:{port}/{encoded_db}" _reject_documentation_placeholder_host(built, "CORE_DB_HOST") return built except Exception as e: logger.error(f"Error al construir URL: {e}") # Fallback al archivo de configuración url = config.get_main_option("sqlalchemy.url") if not url or "${" in url or "%(" in url: raise RuntimeError( "No se ha configurado la cadena de conexión a PostgreSQL. " "Define TEST_DATABASE_URL o DATABASE_URL, o variables CORE_DB_*; " "sqlalchemy.url en alembic.ini con placeholders ${...} no está soportado." ) _reject_documentation_placeholder_host(url, "alembic.ini sqlalchemy.url") return url # Configurar la URL de la base de datos database_url = get_database_url() # Debug: mostrar la URL (sin la contraseña) if os.environ.get("ALEMBIC_DEBUG"): # Ocultar la contraseña para el debug en la URL try: before, after = database_url.split("@", 1) if ":" in before: before = before.split(":", 1)[0] + ":***" debug_url = before + "@" + after except Exception: debug_url = "postgresql://***:***@***" logger.error("Error al ocultar la contraseña en la URL para debug.") config.set_main_option("sqlalchemy.url", database_url) # Interpret the config file for Python logging. # This line sets up loggers basically. if config.config_file_name is not None: fileConfig(config.config_file_name) # Ajusta la ruta para que puedas importar core y módulos BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) sys.path.insert(0, BASE_DIR) # Configuración de Alembic config = context.config fileConfig(config.config_file_name) target_metadata = Base.metadata def include_object(object_, name, type_, reflected, compare_to): """ Keep all objects in autogenerate. FK noise is cleaned in process_revision_directives. """ return True def _fk_drop_signature(op_): if not isinstance(op_, ops.DropConstraintOp): return None if getattr(op_, "constraint_type", None) != "foreignkey": return None return ( getattr(op_, "schema", None), getattr(op_, "table_name", None), getattr(op_, "constraint_name", None), ) def _fk_create_signature(op_): if not isinstance(op_, ops.CreateForeignKeyOp): return None local_cols = tuple(getattr(op_, "local_cols", ()) or ()) remote_cols = tuple(getattr(op_, "remote_cols", ()) or ()) return ( getattr(op_, "source_schema", None), getattr(op_, "source_table", None), getattr(op_, "referent_schema", None), getattr(op_, "referent_table", None), local_cols, remote_cols, ) def _drop_to_create_match(drop_op, create_op): if not isinstance(drop_op, ops.DropConstraintOp): return False if not isinstance(create_op, ops.CreateForeignKeyOp): return False if getattr(drop_op, "constraint_type", None) != "foreignkey": return False def _normalize_schema(value): # PostgreSQL reports default schema inconsistently as None/public. return "public" if value in (None, "") else value # Prefer structural comparison using Alembic's reverse op when available. reverse_create = getattr(drop_op, "_reverse", None) if isinstance(reverse_create, ops.CreateForeignKeyOp): return ( _normalize_schema(getattr(reverse_create, "source_schema", None)) == _normalize_schema(getattr(create_op, "source_schema", None)) and getattr(reverse_create, "source_table", None) == getattr(create_op, "source_table", None) and _normalize_schema(getattr(reverse_create, "referent_schema", None)) == _normalize_schema(getattr(create_op, "referent_schema", None)) and getattr(reverse_create, "referent_table", None) == getattr(create_op, "referent_table", None) and tuple(getattr(reverse_create, "local_cols", ()) or ()) == tuple(getattr(create_op, "local_cols", ()) or ()) and tuple(getattr(reverse_create, "remote_cols", ()) or ()) == tuple(getattr(create_op, "remote_cols", ()) or ()) ) # Fallback for older op payloads: compare source table/schema and name. return ( _normalize_schema(getattr(drop_op, "schema", None)) == _normalize_schema(getattr(create_op, "source_schema", None)) and getattr(drop_op, "table_name", None) == getattr(create_op, "source_table", None) and getattr(drop_op, "constraint_name", None) == getattr(create_op, "constraint_name", None) ) def _prune_fk_churn(container): if not hasattr(container, "ops"): return # First recurse into nested containers. for op_ in list(container.ops): _prune_fk_churn(op_) table_ops = container.ops kept_ops = [] consumed_indexes = set() for i, op_i in enumerate(table_ops): if i in consumed_indexes: continue if isinstance(op_i, ops.DropConstraintOp) and getattr(op_i, "constraint_type", None) == "foreignkey": matched_j = None for j in range(i + 1, len(table_ops)): if j in consumed_indexes: continue op_j = table_ops[j] if _drop_to_create_match(op_i, op_j): matched_j = j break if matched_j is not None: # Drop + recreate same FK detected; remove both. consumed_indexes.add(i) consumed_indexes.add(matched_j) continue kept_ops.append(op_i) container.ops = kept_ops def process_revision_directives(context_, revision, directives): """ Remove autogenerate noise where Alembic emits drop/create for equivalent FKs. Real FK changes are preserved. """ if not directives: return script = directives[0] _prune_fk_churn(script.upgrade_ops) _prune_fk_churn(script.downgrade_ops) def import_models_from_dir(dir_path: str): """Importa recursivamente cualquier archivo models.py desde dir_path y archivos en directorios models/""" import sys def _load_module(module_path: str): rel_path = os.path.relpath(module_path, BASE_DIR) module_name = rel_path.replace(os.sep, ".").replace(".py", "") # Skip if already loaded to avoid duplicate SQLAlchemy table registrations if module_name in sys.modules: return spec = importlib.util.spec_from_file_location(module_name, module_path) mod = importlib.util.module_from_spec(spec) # Register in sys.modules before exec so transitive imports resolve correctly sys.modules[module_name] = mod spec.loader.exec_module(mod) for root, dirs, files in os.walk(dir_path): # Importar archivos models.py directos if "models.py" in files: try: _load_module(os.path.join(root, "models.py")) except Exception as e: logger.warning(f"No se pudo importar {os.path.join(root, 'models.py')}: {e}") # Importar todos los archivos .py en directorios llamados "models" if os.path.basename(root) == "models": for file in files: if file.endswith(".py") and not file.startswith("__"): try: _load_module(os.path.join(root, file)) except Exception as e: logger.warning(f"No se pudo importar {os.path.join(root, file)}: {e}") # Importar todos los models dentro de api/v1/modules y api/v1/modules/uploads modules_dir = os.path.join(BASE_DIR, "api", "v1", "modules") import_models_from_dir(modules_dir) # Tablas declaradas fuera de models.py / carpeta models/ (autogenerate) # Agrega aquí imports de models que no estén en archivos models.py estándar. def run_migrations_offline() -> None: """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, compare_type=True, include_schemas=True, include_object=include_object, process_revision_directives=process_revision_directives, literal_binds=True, dialect_opts={"paramstyle": "named"}, ) with context.begin_transaction(): context.run_migrations() def run_migrations_online() -> None: """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ connectable = engine_from_config( config.get_section(config.config_ini_section, {}), prefix="sqlalchemy.", poolclass=pool.NullPool, ) with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, compare_type=True, include_schemas=True, include_object=include_object, process_revision_directives=process_revision_directives, ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online()