feat: admin dashboard
This commit is contained in:
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
"""
|
||||
)
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user