feat: admin dashboard

This commit is contained in:
3252a8
2026-05-09 22:32:44 +03:00
parent 0a9d929181
commit 6f9a87cf98
13 changed files with 6893 additions and 11 deletions
+2
View File
@@ -7,6 +7,7 @@ from . import message_log_dal
from . import user_billing_dal
from . import ad_dal
from . import security_dal
from . import app_settings_dal
__all__ = (
"user_dal",
@@ -18,6 +19,7 @@ __all__ = (
"user_billing_dal",
"ad_dal",
"security_dal",
"app_settings_dal",
)
+100
View File
@@ -0,0 +1,100 @@
"""Persistent overrides for application settings.
Overrides take priority over `.env` values for keys exposed via the admin
manifest. Values are stored as JSON-encoded text to preserve typing across
strings, booleans, integers and floats.
"""
import json
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import AppSettingOverride
logger = logging.getLogger(__name__)
def _encode(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def _decode(raw: Optional[str]) -> Any:
if raw is None:
return None
try:
return json.loads(raw)
except (TypeError, ValueError):
return raw
async def get_all_overrides(session: AsyncSession) -> Dict[str, Any]:
rows = (await session.execute(select(AppSettingOverride))).scalars().all()
return {row.key: _decode(row.value) for row in rows}
async def get_overrides_with_meta(session: AsyncSession) -> List[Dict[str, Any]]:
rows = (await session.execute(select(AppSettingOverride))).scalars().all()
items: List[Dict[str, Any]] = []
for row in rows:
items.append(
{
"key": row.key,
"value": _decode(row.value),
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
"updated_by": row.updated_by,
}
)
return items
async def upsert_override(
session: AsyncSession,
*,
key: str,
value: Any,
updated_by: Optional[int],
) -> None:
encoded = _encode(value)
now = datetime.now(timezone.utc)
stmt = (
pg_insert(AppSettingOverride)
.values(key=key, value=encoded, updated_at=now, updated_by=updated_by)
.on_conflict_do_update(
index_elements=[AppSettingOverride.key],
set_={
"value": encoded,
"updated_at": now,
"updated_by": updated_by,
},
)
)
await session.execute(stmt)
async def delete_override(session: AsyncSession, key: str) -> bool:
stmt = delete(AppSettingOverride).where(AppSettingOverride.key == key)
result = await session.execute(stmt)
return bool(result.rowcount or 0)
async def bulk_apply(
session: AsyncSession,
*,
updates: Dict[str, Tuple[bool, Any]],
updated_by: Optional[int],
) -> None:
"""Apply a batch of changes. Each entry maps key -> (set_flag, value).
When set_flag is False the override is deleted (revert to env). Otherwise
the value is upserted.
"""
for key, (set_flag, value) in updates.items():
if set_flag:
await upsert_override(session, key=key, value=value, updated_by=updated_by)
else:
await delete_override(session, key)
+8
View File
@@ -70,6 +70,14 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
"PostgreSQL database initialized/checked successfully using SQLAlchemy."
)
try:
from bot.services.settings_override_service import load_overrides_from_db
await load_overrides_from_db(settings, session_factory)
except Exception as e_overrides:
logging.warning(
f"Failed to load setting overrides on startup: {e_overrides}"
)
async with session_factory() as session:
from .dal.panel_sync_dal import get_panel_sync_status, update_panel_sync_status
from sqlalchemy import text
+16
View File
@@ -531,6 +531,22 @@ MIGRATIONS: List[Migration] = [
description="Add tariff catalog columns and traffic accounting tables",
upgrade=_migration_0012_add_tariffs_schema,
),
Migration(
id="0013_add_app_setting_overrides",
description="Persisted runtime overrides for application settings managed via admin webapp",
upgrade=lambda connection: connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS app_setting_overrides (
key VARCHAR(128) PRIMARY KEY,
value TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by BIGINT
)
"""
)
),
),
]
+14
View File
@@ -415,3 +415,17 @@ class AdAttribution(Base):
user = relationship("User")
campaign = relationship("AdCampaign", back_populates="attributions")
class AppSettingOverride(Base):
__tablename__ = "app_setting_overrides"
key = Column(String(128), primary_key=True)
value = Column(Text, nullable=True)
updated_at = Column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
updated_by = Column(BigInteger, nullable=True)