Files
hockey_new/hockey_data/database.py
2026-08-19 15:08:39 +03:00

365 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import os
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from threading import RLock
from typing import Any, Iterator
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.engine import Engine, make_url
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session, sessionmaker
from .models import (
Base,
Coach,
CoachTournament,
Country,
Game,
GameControlState,
GameTeamStatistic,
GameOfficial,
GameRoster,
OperatorSession,
PenaltyType,
Player,
PlayerTournament,
PlayerSeasonStatistic,
Referee,
RefereeTournament,
ShootoutAttempt,
TournamentStanding,
TournamentStandingRow,
TournamentStatisticResource,
TournamentStatisticRow,
TeamSeasonStatistic,
UserPreference,
VmixAssignment,
VmixDevice,
VmixMappingProfile,
VmixMappingField,
MappingContextVariable,
MappingContextValue,
MappingSqlDataSource,
)
class HockeyDatabase:
"""Remote PostgreSQL database layer with additive schema repair.
The hockey application deliberately has no SQLite fallback. A valid remote
PostgreSQL URL must be supplied through ``HOCKEY_DATABASE_URL``.
"""
def __init__(self, base_dir: Path) -> None:
self.base_dir = Path(base_dir)
self.url = self._required_postgresql_url()
parsed_url = make_url(self.url)
self._target = {
"backend": "postgresql",
"driver": parsed_url.drivername,
"host": parsed_url.host or "",
"port": parsed_url.port or 5432,
"database": parsed_url.database or "",
"remote_only": True,
}
self._schema_lock = RLock()
self._last_schema_report: dict[str, Any] = {
"ok": False,
"repaired": [],
"rebuilt": False,
"error": "not_checked",
**self._target,
}
self.engine: Engine = create_engine(
self.url,
future=True,
hide_parameters=True,
pool_pre_ping=True,
pool_size=self._env_int("HOCKEY_DB_POOL_SIZE", 10, 1, 100),
max_overflow=self._env_int("HOCKEY_DB_MAX_OVERFLOW", 20, 0, 200),
pool_timeout=self._env_int("HOCKEY_DB_POOL_TIMEOUT", 30, 1, 300),
pool_recycle=self._env_int("HOCKEY_DB_POOL_RECYCLE", 1800, 60, 86400),
connect_args={
"connect_timeout": self._env_int(
"HOCKEY_DB_CONNECT_TIMEOUT", 10, 1, 120
),
"application_name": "hockey-control-panel",
},
)
self.SessionFactory = sessionmaker(
bind=self.engine,
autoflush=False,
expire_on_commit=False,
future=True,
)
self._verify_connection()
@staticmethod
def _env_int(name: str, fallback: int, low: int, high: int) -> int:
try:
value = int(os.getenv(name, str(fallback)))
except (TypeError, ValueError):
value = fallback
return max(low, min(high, value))
@staticmethod
def _required_postgresql_url() -> str:
raw = os.getenv("HOCKEY_DATABASE_URL", "").strip()
if not raw:
raise RuntimeError(
"HOCKEY_DATABASE_URL не задан. Хоккейный проект работает только "
"с удалённой PostgreSQL-базой. Создайте .env по примеру .env.example."
)
# Accept common aliases, but always use the psycopg 3 driver installed
# by requirements.txt.
if raw.startswith("postgres://"):
raw = "postgresql+psycopg://" + raw[len("postgres://") :]
elif raw.startswith("postgresql://"):
raw = "postgresql+psycopg://" + raw[len("postgresql://") :]
try:
parsed = make_url(raw)
except Exception as error:
raise RuntimeError("Некорректный HOCKEY_DATABASE_URL") from error
if parsed.get_backend_name() != "postgresql":
raise RuntimeError(
"HOCKEY_DATABASE_URL должен указывать на PostgreSQL. "
"SQLite и локальные файлы базы отключены."
)
if not parsed.host or not parsed.database:
raise RuntimeError(
"В HOCKEY_DATABASE_URL должны быть указаны сервер и имя базы данных."
)
if parsed.drivername != "postgresql+psycopg":
parsed = parsed.set(drivername="postgresql+psycopg")
return parsed.render_as_string(hide_password=False)
def _verify_connection(self) -> None:
try:
with self.engine.connect() as connection:
connection.execute(text("SELECT 1"))
except SQLAlchemyError as error:
target = f"{self._target['host']}:{self._target['port']}/{self._target['database']}"
raise RuntimeError(
"Не удалось подключиться к удалённой хоккейной PostgreSQL-базе "
f"{target}. Проверьте HOCKEY_DATABASE_URL, сеть и права пользователя."
) from error
def create_all(self) -> None:
"""Create missing tables and add newly introduced non-key columns."""
with self._schema_lock:
self._verify_connection()
try:
migrated = self._migrate_operator_session_user_id()
Base.metadata.create_all(self.engine)
repaired = migrated + self._repair_game_table()
for model in (
OperatorSession,
Country,
PenaltyType,
Player,
PlayerTournament,
Coach,
CoachTournament,
GameRoster,
GameControlState,
ShootoutAttempt,
Referee,
RefereeTournament,
GameOfficial,
TournamentStanding,
TournamentStandingRow,
TournamentStatisticResource,
TournamentStatisticRow,
TeamSeasonStatistic,
PlayerSeasonStatistic,
GameTeamStatistic,
UserPreference,
VmixDevice,
VmixAssignment,
VmixMappingProfile,
VmixMappingField,
MappingContextVariable,
MappingContextValue,
MappingSqlDataSource,
):
repaired.extend(self._repair_additive_table(model))
self._validate_game_table()
Base.metadata.create_all(self.engine)
self._last_schema_report = {
"ok": True,
"repaired": repaired,
"rebuilt": False,
"error": "",
**self._target,
}
except Exception as error:
self._last_schema_report = {
"ok": False,
"repaired": [],
"rebuilt": False,
"error": str(error),
**self._target,
}
raise
def _migrate_operator_session_user_id(self) -> list[str]:
"""Rename the legacy browser identity column without losing sessions."""
table_name = OperatorSession.__tablename__
inspector = inspect(self.engine)
if table_name not in inspector.get_table_names():
return []
columns = {column["name"] for column in inspector.get_columns(table_name)}
if "user_id" not in columns:
return []
if "wfl_user_id" in columns:
with self.engine.begin() as connection:
connection.execute(
text(
f'UPDATE "{table_name}" SET "wfl_user_id" = "user_id" '
'WHERE "wfl_user_id" = \'\' '
'AND "user_id" NOT LIKE \'browser-%\''
)
)
connection.execute(
text(
f'UPDATE "{table_name}" SET "status" = \'closed\' '
'WHERE "wfl_user_id" = \'\' OR "user_id" LIKE \'browser-%\''
)
)
connection.execute(
text('DROP INDEX IF EXISTS "ix_hockey_sessions_user_status"')
)
connection.execute(
text(f'ALTER TABLE "{table_name}" DROP COLUMN "user_id"')
)
return [f"{table_name}.dropped_legacy_user_id"]
with self.engine.begin() as connection:
connection.execute(
text(
f'ALTER TABLE "{table_name}" '
'RENAME COLUMN "user_id" TO "wfl_user_id"'
)
)
return [f"{table_name}.user_id->wfl_user_id"]
def schema_status(self) -> dict[str, Any]:
return dict(self._last_schema_report)
def ensure_game_schema(self) -> dict[str, Any]:
self.create_all()
return self.schema_status()
def _repair_game_table(self) -> list[str]:
inspector = inspect(self.engine)
if Game.__tablename__ not in inspector.get_table_names():
Game.__table__.create(self.engine, checkfirst=True)
return ["created:hockey_games"]
existing = {
column["name"] for column in inspector.get_columns(Game.__tablename__)
}
repaired: list[str] = []
with self.engine.begin() as connection:
for column in Game.__table__.columns:
if column.name in existing:
continue
if column.primary_key:
raise RuntimeError(
f"В таблице отсутствует ключевой столбец {column.name}"
)
connection.execute(
text(
f'ALTER TABLE "{Game.__tablename__}" '
f'ADD COLUMN "{column.name}" {self._column_add_ddl(column)}'
)
)
repaired.append(column.name)
return repaired
def _repair_additive_table(self, model: Any) -> list[str]:
table = model.__table__
inspector = inspect(self.engine)
if table.name not in inspector.get_table_names():
table.create(self.engine, checkfirst=True)
return [f"created:{table.name}"]
existing = {column["name"] for column in inspector.get_columns(table.name)}
repaired: list[str] = []
with self.engine.begin() as connection:
for column in table.columns:
if column.name in existing:
continue
if column.primary_key:
raise RuntimeError(
f"Cannot add missing primary key column {table.name}.{column.name}"
)
connection.execute(
text(
f'ALTER TABLE "{table.name}" '
f'ADD COLUMN "{column.name}" {self._column_add_ddl(column)}'
)
)
repaired.append(f"{table.name}.{column.name}")
return repaired
def _column_add_ddl(self, column: Any) -> str:
sql_type = column.type.compile(dialect=self.engine.dialect)
parts = [sql_type]
if not column.nullable:
parts.append("NOT NULL")
default_sql = self._safe_default_sql(column)
if default_sql is not None:
parts.append(f"DEFAULT {default_sql}")
return " ".join(parts)
@staticmethod
def _safe_default_sql(column: Any) -> str | None:
try:
python_type: type[Any] | None = column.type.python_type
except (AttributeError, NotImplementedError):
python_type = None
if column.nullable:
return None
if python_type is bool:
return "FALSE"
if python_type in {int, float}:
return "0"
if python_type is datetime:
return "CURRENT_TIMESTAMP"
return "''"
def _validate_game_table(self) -> None:
inspector = inspect(self.engine)
existing = {
column["name"] for column in inspector.get_columns(Game.__tablename__)
}
expected = {column.name for column in Game.__table__.columns}
missing = sorted(expected - existing)
if missing:
raise RuntimeError(
"Не удалось обновить таблицу hockey_games. "
f"Отсутствуют поля: {', '.join(missing)}"
)
with self.engine.connect() as connection:
connection.execute(text('SELECT * FROM "hockey_games" LIMIT 1'))
@contextmanager
def session(self) -> Iterator[Session]:
session = self.SessionFactory()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()