Admin onboarding + BrickGrid + workflow author fix
This commit is contained in:
@@ -161,10 +161,14 @@ async def verify_identity_token(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Проверяет токен из HttpOnly cookie `auth_token`; возвращает Identity."""
|
||||
from database.site_state import mark_site_initialized
|
||||
|
||||
identity = await _identity_from_cookie(session, request)
|
||||
if identity is None:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
await bind_identity_actor(request, session, identity)
|
||||
if getattr(identity, "is_admin", False):
|
||||
await mark_site_initialized(session)
|
||||
return identity
|
||||
|
||||
|
||||
@@ -173,12 +177,15 @@ async def verify_identity_admin(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Проверяет токен из cookie и что identity.is_admin; для админских ручек v2."""
|
||||
from database.site_state import mark_site_initialized
|
||||
|
||||
identity = await _identity_from_cookie(session, request)
|
||||
if identity is None:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
if not identity.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
await bind_identity_actor(request, session, identity)
|
||||
await mark_site_initialized(session)
|
||||
return identity
|
||||
|
||||
|
||||
|
||||
@@ -163,6 +163,15 @@ async def login(
|
||||
except Exception as e:
|
||||
logger.warning("[Auth] Ошибка rate-limit проверки для email-логина: {}", e)
|
||||
result = await idb.login_by_email(session, email, body.password)
|
||||
if not result:
|
||||
from database.setup.web_admin_bootstrap import ensure_web_admin
|
||||
|
||||
try:
|
||||
await ensure_web_admin(session)
|
||||
await session.flush()
|
||||
result = await idb.login_by_email(session, email, body.password)
|
||||
except Exception as exc:
|
||||
logger.warning("[Auth] lazy web-admin bootstrap failed: {}", exc)
|
||||
if not result:
|
||||
try:
|
||||
from core.redis_cache import cache_incr, cache_set
|
||||
@@ -182,6 +191,10 @@ async def login(
|
||||
pass
|
||||
identity, token = result
|
||||
await bind_identity_actor(request, session, identity)
|
||||
if getattr(identity, "is_admin", False):
|
||||
from database.site_state import mark_site_initialized
|
||||
|
||||
await mark_site_initialized(session)
|
||||
logger.info("[Auth] Login success: identity={}, email={}, ip={}, method=password", identity.id, email, ip)
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
@@ -303,6 +316,10 @@ async def login_by_code(
|
||||
)
|
||||
await bind_identity_actor(request, session, identity)
|
||||
token = await idb.issue_token_for_identity(session, identity)
|
||||
if getattr(identity, "is_admin", False):
|
||||
from database.site_state import mark_site_initialized
|
||||
|
||||
await mark_site_initialized(session)
|
||||
logger.info("[Auth] Login success: identity={}, email={}, method=code", identity.id, email_norm)
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
|
||||
@@ -49,6 +49,51 @@ async def logout(
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/me/onboarding/complete", response_model=IdentityResponse)
|
||||
async def onboarding_complete(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Отмечает, что админ прошёл/скипнул онбординг-тур."""
|
||||
from datetime import datetime as _dt
|
||||
|
||||
if identity.onboarding_completed_at is None:
|
||||
identity.onboarding_completed_at = _dt.utcnow()
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
@router.post("/me/onboarding/reset", response_model=IdentityResponse)
|
||||
async def onboarding_reset(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Сбрасывает флаг онбординга — туториал запустится снова."""
|
||||
identity.onboarding_completed_at = None
|
||||
identity.onboarding_stage = "landing"
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
_ONBOARDING_STAGES = {"landing", "header", "cabinet", "flow", "elements", "done"}
|
||||
|
||||
|
||||
@router.post("/me/onboarding/stage", response_model=IdentityResponse)
|
||||
async def onboarding_set_stage(
|
||||
body: dict,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Переводит админа на указанный этап онбординга."""
|
||||
from datetime import datetime as _dt
|
||||
|
||||
stage = str(body.get("stage") or "").strip()
|
||||
if stage not in _ONBOARDING_STAGES:
|
||||
raise HTTPException(status_code=400, detail="Неизвестный этап онбординга")
|
||||
identity.onboarding_stage = stage
|
||||
if stage == "done" and identity.onboarding_completed_at is None:
|
||||
identity.onboarding_completed_at = _dt.utcnow()
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=AccountSummaryResponse)
|
||||
async def auth_summary(
|
||||
request: Request,
|
||||
|
||||
+17
-3
@@ -5,7 +5,10 @@ import time
|
||||
|
||||
import aiohttp
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session
|
||||
|
||||
from config import (
|
||||
BALANCE_BUTTON,
|
||||
@@ -79,12 +82,23 @@ async def version():
|
||||
@router.get("/api/telegram-widget-bot", include_in_schema=True)
|
||||
async def telegram_widget_bot():
|
||||
"""Имя бота и имя проекта для веб-клиента."""
|
||||
bot_username = str(USERNAME_BOT or "").replace("@", "").strip()
|
||||
project_name = (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo"
|
||||
return {
|
||||
"bot_username": USERNAME_BOT.replace("@", ""),
|
||||
"project_name": (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo",
|
||||
"bot_username": bot_username,
|
||||
"project_name": project_name,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/site/init-state", include_in_schema=True)
|
||||
async def site_init_state(session: AsyncSession = Depends(get_session)):
|
||||
"""Прошёл ли сайт первую настройку админом. Используется middleware веб-клиента."""
|
||||
from database.site_state import is_site_initialized
|
||||
|
||||
initialized = await is_site_initialized(session)
|
||||
return {"initialized": bool(initialized)}
|
||||
|
||||
|
||||
@router.get("/api/site-config", include_in_schema=True)
|
||||
async def site_config():
|
||||
"""Настройки витрины и кабинета для веб-клиента (флаги из runtime-конфигов бота)."""
|
||||
|
||||
@@ -15,6 +15,8 @@ class IdentityResponse(BaseModel):
|
||||
is_admin: bool = False
|
||||
email_verified: bool = False
|
||||
password_set: bool = False
|
||||
onboarding_completed: bool = False
|
||||
onboarding_stage: str | None = None
|
||||
created_at: datetime | None
|
||||
updated_at: datetime | None
|
||||
|
||||
|
||||
+8
-8
@@ -22,12 +22,9 @@ def _atexit_shutdown_pools() -> None:
|
||||
shutdown_thread_pool()
|
||||
|
||||
|
||||
class _IgnoreSIGINTProcess(multiprocessing.Process):
|
||||
"""Процесс, игнорирующий SIGINT в воркере, чтобы Ctrl+C не обрывал queue.get() с трейсбеком."""
|
||||
|
||||
def run(self) -> None:
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||
super().run()
|
||||
def _worker_ignore_sigint() -> None:
|
||||
"""Initializer воркера: игнорирует SIGINT, чтобы Ctrl+C не обрывал queue.get() с трейсбеком."""
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||
|
||||
|
||||
def get_thread_pool() -> ThreadPoolExecutor:
|
||||
@@ -62,8 +59,11 @@ def get_process_pool() -> ProcessPoolExecutor:
|
||||
|
||||
size = max(1, min(int(PROCESS_POOL_SIZE), multiprocessing.cpu_count() or 4))
|
||||
ctx = multiprocessing.get_context("spawn")
|
||||
ctx.Process = _IgnoreSIGINTProcess
|
||||
_process_pool = ProcessPoolExecutor(max_workers=size, mp_context=ctx)
|
||||
_process_pool = ProcessPoolExecutor(
|
||||
max_workers=size,
|
||||
mp_context=ctx,
|
||||
initializer=_worker_ignore_sigint,
|
||||
)
|
||||
atexit.register(_atexit_shutdown_pools)
|
||||
logger.debug("[Executor] Пул процессов: {} воркеров", size)
|
||||
return _process_pool
|
||||
|
||||
@@ -1209,6 +1209,22 @@ async def _migration_v21_add_identity_yandex_sub(conn: AsyncConnection) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _migration_v22_add_identity_onboarding_completed_at(conn: AsyncConnection) -> None:
|
||||
logger.info("[schema_upgrade] v22: identities.onboarding_completed_at")
|
||||
if not await _table_exists(conn, "identities"):
|
||||
return
|
||||
if not await _column_exists(conn, "identities", "onboarding_completed_at"):
|
||||
await _exec_ignore(conn, "ALTER TABLE identities ADD COLUMN onboarding_completed_at TIMESTAMP")
|
||||
|
||||
|
||||
async def _migration_v23_add_identity_onboarding_stage(conn: AsyncConnection) -> None:
|
||||
logger.info("[schema_upgrade] v23: identities.onboarding_stage")
|
||||
if not await _table_exists(conn, "identities"):
|
||||
return
|
||||
if not await _column_exists(conn, "identities", "onboarding_stage"):
|
||||
await _exec_ignore(conn, "ALTER TABLE identities ADD COLUMN onboarding_stage VARCHAR(32)")
|
||||
|
||||
|
||||
_MIGRATIONS = [
|
||||
(1, "Добавление users.id", _migration_v1_add_users_id),
|
||||
(2, "Добавление user_id колонок", _migration_v2_add_user_id_columns),
|
||||
@@ -1231,6 +1247,8 @@ _MIGRATIONS = [
|
||||
(19, "keys.tg_id nullable, PK на (user_id, client_id)", _migration_v19_keys_tg_id_nullable),
|
||||
(20, "identities.google_sub", _migration_v20_add_identity_google_sub),
|
||||
(21, "identities.yandex_sub", _migration_v21_add_identity_yandex_sub),
|
||||
(22, "identities.onboarding_completed_at", _migration_v22_add_identity_onboarding_completed_at),
|
||||
(23, "identities.onboarding_stage", _migration_v23_add_identity_onboarding_stage),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -29,5 +29,15 @@ class Identity(DictLikeMixin, Base):
|
||||
password_hash = Column(String(64), nullable=True)
|
||||
email_verified = Column(Boolean, nullable=False, server_default=sql_text("false"))
|
||||
is_admin = Column(Boolean, nullable=False, server_default=sql_text("false"))
|
||||
onboarding_completed_at = Column(DateTime, nullable=True)
|
||||
onboarding_stage = Column(String(32), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
@property
|
||||
def onboarding_completed(self) -> bool:
|
||||
return self.onboarding_completed_at is not None
|
||||
|
||||
@property
|
||||
def password_set(self) -> bool:
|
||||
return bool(self.password_hash)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import WEB_ADMIN_LOGIN, WEB_ADMIN_PASSWORD
|
||||
from database.identities import hash_password
|
||||
from database.models import Identity
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def ensure_web_admin(session: AsyncSession) -> None:
|
||||
"""Bootstrap/sync web-admin identity from config (WEB_ADMIN_LOGIN/WEB_ADMIN_PASSWORD).
|
||||
|
||||
Вызов на старте API: если креды заданы в config.py — upsert Identity с bcrypt-хешем.
|
||||
Если пусто — no-op с предупреждением, сайт останется без админа.
|
||||
"""
|
||||
login = (WEB_ADMIN_LOGIN or "").strip()
|
||||
password = WEB_ADMIN_PASSWORD or ""
|
||||
|
||||
if not (login and password):
|
||||
result = await session.execute(select(Identity).where(Identity.is_admin.is_(True)))
|
||||
has_admin = result.scalars().first() is not None
|
||||
if not has_admin:
|
||||
logger.warning(
|
||||
"[web-admin] Нет web-админа и WEB_ADMIN_LOGIN/WEB_ADMIN_PASSWORD "
|
||||
"не заданы в config.py. Сайт будет недоступен до создания админа."
|
||||
)
|
||||
return
|
||||
|
||||
email = login.lower()
|
||||
result = await session.execute(select(Identity).where(Identity.email == email))
|
||||
identity = result.scalar_one_or_none()
|
||||
password_hash = hash_password(password)
|
||||
if identity is None:
|
||||
identity = Identity(
|
||||
email=email,
|
||||
password_hash=password_hash,
|
||||
is_admin=True,
|
||||
onboarding_stage="landing",
|
||||
)
|
||||
session.add(identity)
|
||||
logger.info("[web-admin] created admin identity {}", email)
|
||||
else:
|
||||
identity.password_hash = password_hash
|
||||
identity.is_admin = True
|
||||
if identity.onboarding_completed_at is None and not identity.onboarding_stage:
|
||||
identity.onboarding_stage = "landing"
|
||||
logger.info("[web-admin] synced password for {}", email)
|
||||
@@ -0,0 +1,31 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
|
||||
_KEY = "SITE_INITIALIZED"
|
||||
|
||||
|
||||
async def is_site_initialized(session: AsyncSession) -> bool:
|
||||
result = await session.execute(select(Setting).where(Setting.key == _KEY))
|
||||
setting = result.scalar_one_or_none()
|
||||
return bool(setting and setting.value is True)
|
||||
|
||||
|
||||
async def mark_site_initialized(session: AsyncSession) -> None:
|
||||
"""Идемпотентно выставляет флаг инициализации сайта."""
|
||||
result = await session.execute(select(Setting).where(Setting.key == _KEY))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting is None:
|
||||
session.add(Setting(key=_KEY, value=True, description="Сайт прошёл первую настройку админом"))
|
||||
elif setting.value is not True:
|
||||
setting.value = True
|
||||
|
||||
|
||||
async def reset_site_initialized(session: AsyncSession) -> None:
|
||||
"""Сброс флага — вызывается при полном ресете сайта через TG-бот."""
|
||||
result = await session.execute(select(Setting).where(Setting.key == _KEY))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting is not None:
|
||||
setting.value = False
|
||||
@@ -36,6 +36,12 @@ def build_settings_web_kb() -> InlineKeyboardBuilder:
|
||||
callback_data=AdminPanelCallback(action="settings_web_url").pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Сбросить сайт к исходнику",
|
||||
callback_data=AdminPanelCallback(action="settings_web_reset_ask").pack(),
|
||||
)
|
||||
)
|
||||
builder.row(build_admin_back_btn("settings"))
|
||||
|
||||
return builder
|
||||
@@ -102,6 +108,72 @@ async def prompt_web_url(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_web_reset_ask"))
|
||||
async def ask_reset_site(callback: CallbackQuery) -> None:
|
||||
text = (
|
||||
"<b>⚠️ Сброс сайта к исходнику</b>\n\n"
|
||||
"Действие удалит:\n"
|
||||
"• все страницы, блоки, темы и варианты\n"
|
||||
"• всех веб-пользователей (включая админа сайта)\n"
|
||||
"• флаг «сайт проинициализирован»\n\n"
|
||||
"Биллинг-данные (пользователи бота, ключи, платежи) не трогаются.\n\n"
|
||||
"После сброса админ сайта пересоздаётся из переменных окружения "
|
||||
"<code>WEB_ADMIN_LOGIN</code> / <code>WEB_ADMIN_PASSWORD</code>.\n\n"
|
||||
"<b>Действие необратимо.</b>"
|
||||
)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="❌ Отмена",
|
||||
callback_data=AdminPanelCallback(action="settings_web").pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⚠️ Да, сбросить сайт",
|
||||
callback_data=AdminPanelCallback(action="settings_web_reset_do").pack(),
|
||||
)
|
||||
)
|
||||
await callback.message.edit_text(text=text, reply_markup=builder.as_markup())
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_web_reset_do"))
|
||||
async def do_reset_site(callback: CallbackQuery, session=None) -> None:
|
||||
await callback.answer()
|
||||
await callback.message.edit_text(text="<b>⏳ Сбрасываю сайт...</b>")
|
||||
|
||||
from middlewares.session import release_session_early
|
||||
from services.site_reset import reset_site
|
||||
|
||||
if session is not None:
|
||||
await release_session_early(session)
|
||||
|
||||
try:
|
||||
async with async_session_maker() as s:
|
||||
await reset_site(s)
|
||||
await s.commit()
|
||||
except Exception as exc:
|
||||
from html import escape as html_escape
|
||||
|
||||
safe = html_escape(str(exc))[:2000]
|
||||
await callback.message.edit_text(
|
||||
text=f"<b>❌ Не удалось сбросить сайт</b>\n\n<code>{safe}</code>",
|
||||
reply_markup=build_settings_web_kb().as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
text = (
|
||||
"<b>✅ Сайт сброшен к исходнику</b>\n\n"
|
||||
"Все веб-страницы, блоки и темы удалены. Админ пересоздан из env.\n"
|
||||
"Откройте сайт и пройдите путь первой установки заново."
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_settings_web_kb().as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.message(WebSettingsState.waiting_for_url)
|
||||
async def set_web_url(message: Message, state: FSMContext) -> None:
|
||||
url = message.text.strip() if message.text else ""
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
from sqlalchemy import delete, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from config import DATABASE_URL, USE_PGBOUNCER
|
||||
from database.models import Identity
|
||||
from database.models.web import (
|
||||
WebBlock,
|
||||
WebCustomElementBuild,
|
||||
WebErrorReport,
|
||||
WebFlow,
|
||||
WebFlowEvent,
|
||||
WebNotification,
|
||||
WebPage,
|
||||
WebPageVariant,
|
||||
WebPageVariantBlock,
|
||||
WebPushSubscription,
|
||||
WebTheme,
|
||||
)
|
||||
from database.site_state import reset_site_initialized
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def reset_site(_session: AsyncSession) -> None:
|
||||
"""Полный сброс web-части: все web_* таблицы + identities.
|
||||
|
||||
Использует отдельный engine без command_timeout — стандартный pool имеет
|
||||
жёсткий 30-сек лимит на запрос, который не переопределяется SET LOCAL
|
||||
(это клиентский asyncpg-таймаут). Для длинных FK-каскадов нужен dedicated
|
||||
коннект с более мягкими настройками.
|
||||
Биллинг-данные (пользователи бота, ключи, платежи) не трогаются.
|
||||
"""
|
||||
connect_args: dict = {}
|
||||
db_url = DATABASE_URL
|
||||
if "+asyncpg" in DATABASE_URL:
|
||||
connect_args["command_timeout"] = 300
|
||||
connect_args["timeout"] = 60
|
||||
if USE_PGBOUNCER:
|
||||
connect_args["prepared_statement_cache_size"] = 0
|
||||
sep = "&" if "?" in db_url else "?"
|
||||
db_url = f"{db_url}{sep}prepared_statement_cache_size=0"
|
||||
|
||||
local_engine = create_async_engine(
|
||||
db_url,
|
||||
pool_pre_ping=True,
|
||||
connect_args=connect_args,
|
||||
)
|
||||
local_session_maker = async_sessionmaker(bind=local_engine, expire_on_commit=False, class_=AsyncSession)
|
||||
try:
|
||||
async with local_session_maker() as session:
|
||||
await _run_reset(session)
|
||||
await session.commit()
|
||||
finally:
|
||||
await local_engine.dispose()
|
||||
|
||||
|
||||
async def _run_reset(session: AsyncSession) -> None:
|
||||
try:
|
||||
await session.execute(text("SET LOCAL statement_timeout = '5min'"))
|
||||
await session.execute(text("SET LOCAL lock_timeout = '30s'"))
|
||||
except Exception as exc:
|
||||
logger.warning("[site-reset] Не удалось задать timeouts (возможно SQLite): {}", exc)
|
||||
|
||||
steps: list[tuple[str, object]] = [
|
||||
("web_page_variant_blocks", delete(WebPageVariantBlock)),
|
||||
("web_blocks", delete(WebBlock)),
|
||||
("web_page_variants", delete(WebPageVariant)),
|
||||
("web_themes", delete(WebTheme)),
|
||||
("web_pages", delete(WebPage)),
|
||||
("web_push_subscriptions", delete(WebPushSubscription)),
|
||||
("web_notifications", delete(WebNotification)),
|
||||
("web_error_reports", delete(WebErrorReport)),
|
||||
("web_flow_events", delete(WebFlowEvent)),
|
||||
("web_custom_element_builds", delete(WebCustomElementBuild)),
|
||||
("web_flows", delete(WebFlow)),
|
||||
("identities", delete(Identity)),
|
||||
]
|
||||
for label, stmt in steps:
|
||||
logger.info("[site-reset] step start: {}", label)
|
||||
if label == "identities":
|
||||
try:
|
||||
activity = await session.execute(
|
||||
text(
|
||||
"SELECT pid, state, wait_event_type, wait_event, "
|
||||
"query_start, LEFT(query, 200) AS query "
|
||||
"FROM pg_stat_activity "
|
||||
"WHERE datname = current_database() AND pid <> pg_backend_pid() "
|
||||
"ORDER BY query_start"
|
||||
)
|
||||
)
|
||||
for row in activity.mappings():
|
||||
logger.info("[site-reset] pg_stat pre-identities: {}", dict(row))
|
||||
except Exception as exc:
|
||||
logger.warning("[site-reset] diag pg_stat failed: {}", exc)
|
||||
try:
|
||||
result = await session.execute(stmt)
|
||||
rowcount = getattr(result, "rowcount", "?")
|
||||
logger.info("[site-reset] step ok: {} (rows={})", label, rowcount)
|
||||
except Exception as exc:
|
||||
logger.error("[site-reset] step FAIL: {} — {}: {}", label, type(exc).__name__, exc)
|
||||
try:
|
||||
activity = await session.execute(
|
||||
text(
|
||||
"SELECT pid, state, wait_event_type, wait_event, "
|
||||
"query_start, LEFT(query, 200) AS query "
|
||||
"FROM pg_stat_activity "
|
||||
"WHERE datname = current_database() AND pid <> pg_backend_pid()"
|
||||
)
|
||||
)
|
||||
for row in activity.mappings():
|
||||
logger.error("[site-reset] pg_stat post-fail: {}", dict(row))
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
await reset_site_initialized(session)
|
||||
logger.info("[site-reset] Веб-часть сайта сброшена к исходному состоянию")
|
||||
Reference in New Issue
Block a user