Files
remnawave-bedolaga-telegram…/app/cabinet/routes/branding.py
T
2026-03-10 14:29:29 +05:00

1027 lines
32 KiB
Python

"""Branding routes for cabinet - logo, project name, and theme colors management."""
import asyncio
import json
import os
from pathlib import Path
from typing import Literal
import structlog
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.system_setting import get_setting_value
from app.database.models import SystemSetting, User
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/branding', tags=['Branding'])
# Directory for storing branding assets
BRANDING_DIR = Path('data/branding')
LOGO_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.svg']
# Settings keys
BRANDING_NAME_KEY = 'CABINET_BRANDING_NAME'
BRANDING_LOGO_KEY = 'CABINET_BRANDING_LOGO' # Stores "custom" or "default"
THEME_COLORS_KEY = 'CABINET_THEME_COLORS' # Stores JSON with theme colors
ENABLED_THEMES_KEY = 'CABINET_ENABLED_THEMES' # Stores JSON with enabled themes {"dark": true, "light": false}
ANIMATION_ENABLED_KEY = 'CABINET_ANIMATION_ENABLED' # Stores "true" or "false"
FULLSCREEN_ENABLED_KEY = 'CABINET_FULLSCREEN_ENABLED' # Stores "true" or "false"
EMAIL_AUTH_ENABLED_KEY = 'CABINET_EMAIL_AUTH_ENABLED' # Stores "true" or "false"
YANDEX_METRIKA_ID_KEY = 'CABINET_YANDEX_METRIKA_ID' # Stores counter ID (numeric string)
GOOGLE_ADS_ID_KEY = 'CABINET_GOOGLE_ADS_ID' # Stores conversion ID (e.g. "AW-123456789")
GOOGLE_ADS_LABEL_KEY = 'CABINET_GOOGLE_ADS_LABEL' # Stores conversion label (alphanumeric)
LITE_MODE_ENABLED_KEY = 'CABINET_LITE_MODE_ENABLED' # Stores "true" or "false"
GIFT_ENABLED_KEY = 'CABINET_GIFT_ENABLED' # Stores "true" or "false"
ANIMATION_CONFIG_KEY = 'CABINET_ANIMATION_CONFIG' # Stores JSON with animation config
TELEGRAM_WIDGET_SIZE_KEY = 'TELEGRAM_WIDGET_SIZE'
TELEGRAM_WIDGET_RADIUS_KEY = 'TELEGRAM_WIDGET_RADIUS'
TELEGRAM_WIDGET_USERPIC_KEY = 'TELEGRAM_WIDGET_USERPIC'
TELEGRAM_WIDGET_REQUEST_ACCESS_KEY = 'TELEGRAM_WIDGET_REQUEST_ACCESS'
TELEGRAM_OIDC_ENABLED_KEY = 'TELEGRAM_OIDC_ENABLED'
TELEGRAM_OIDC_CLIENT_ID_KEY = 'TELEGRAM_OIDC_CLIENT_ID'
# Default animation config
DEFAULT_ANIMATION_CONFIG = {
'enabled': True,
'type': 'aurora',
'settings': {},
'opacity': 1.0,
'blur': 0,
'reducedOnMobile': True,
}
# Allowed image types
ALLOWED_CONTENT_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml'}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB for larger logos
# ============ Schemas ============
class BrandingResponse(BaseModel):
"""Current branding settings."""
name: str
logo_url: str | None = None
logo_letter: str
has_custom_logo: bool
class BrandingNameUpdate(BaseModel):
"""Request to update branding name."""
name: str
class ThemeColorsResponse(BaseModel):
"""Theme colors settings."""
accent: str = '#3b82f6'
darkBackground: str = '#0a0f1a'
darkSurface: str = '#0f172a'
darkText: str = '#f1f5f9'
darkTextSecondary: str = '#94a3b8'
lightBackground: str = '#F7E7CE'
lightSurface: str = '#FEF9F0'
lightText: str = '#1F1A12'
lightTextSecondary: str = '#7D6B48'
success: str = '#22c55e'
warning: str = '#f59e0b'
error: str = '#ef4444'
class ThemeColorsUpdate(BaseModel):
"""Request to update theme colors (partial update allowed)."""
accent: str | None = None
darkBackground: str | None = None
darkSurface: str | None = None
darkText: str | None = None
darkTextSecondary: str | None = None
lightBackground: str | None = None
lightSurface: str | None = None
lightText: str | None = None
lightTextSecondary: str | None = None
success: str | None = None
warning: str | None = None
error: str | None = None
class EnabledThemesResponse(BaseModel):
"""Enabled themes settings."""
dark: bool = True
light: bool = True
class EnabledThemesUpdate(BaseModel):
"""Request to update enabled themes."""
dark: bool | None = None
light: bool | None = None
class AnimationEnabledResponse(BaseModel):
"""Animation enabled setting."""
enabled: bool = True
class AnimationEnabledUpdate(BaseModel):
"""Request to update animation setting."""
enabled: bool
ALLOWED_BG_TYPES = (
'aurora',
'sparkles',
'vortex',
'shooting-stars',
'background-beams',
'background-beams-collision',
'gradient-animation',
'wavy',
'background-lines',
'boxes',
'meteors',
'grid',
'dots',
'spotlight',
'ripple',
'none',
)
MAX_SETTINGS_KEYS = 20
MAX_SETTINGS_VALUE_LEN = 200
def _validate_settings(v: dict) -> dict:
"""Validate settings dict: flat structure, bounded size, no nested objects."""
if len(v) > MAX_SETTINGS_KEYS:
raise ValueError(f'Settings must have at most {MAX_SETTINGS_KEYS} keys')
for key, val in v.items():
if not isinstance(key, str) or len(key) > 50:
raise ValueError('Setting keys must be strings under 50 characters')
if isinstance(val, dict | list):
raise ValueError('Nested objects/arrays not allowed in settings')
if isinstance(val, str) and len(val) > MAX_SETTINGS_VALUE_LEN:
raise ValueError(f'String setting values must be under {MAX_SETTINGS_VALUE_LEN} characters')
return v
class AnimationConfigResponse(BaseModel):
"""Full animation config."""
enabled: bool = True
type: str = 'aurora'
settings: dict = Field(default_factory=dict)
opacity: float = Field(default=1.0, ge=0.0, le=1.0)
blur: float = Field(default=0, ge=0, le=100)
reducedOnMobile: bool = True
class AnimationConfigUpdate(BaseModel):
"""Request to update animation config (partial update)."""
enabled: bool | None = None
type: (
Literal[
'aurora',
'sparkles',
'vortex',
'shooting-stars',
'background-beams',
'background-beams-collision',
'gradient-animation',
'wavy',
'background-lines',
'boxes',
'meteors',
'grid',
'dots',
'spotlight',
'ripple',
'none',
]
| None
) = None
settings: dict | None = None
opacity: float | None = Field(default=None, ge=0.0, le=1.0)
blur: float | None = Field(default=None, ge=0, le=100)
reducedOnMobile: bool | None = None
@field_validator('settings')
@classmethod
def validate_settings(cls, v: dict | None) -> dict | None:
if v is None:
return v
return _validate_settings(v)
class FullscreenEnabledResponse(BaseModel):
"""Fullscreen enabled setting."""
enabled: bool = False
class FullscreenEnabledUpdate(BaseModel):
"""Request to update fullscreen setting."""
enabled: bool
class EmailAuthEnabledResponse(BaseModel):
"""Email auth enabled setting."""
enabled: bool = True
class EmailAuthEnabledUpdate(BaseModel):
"""Request to update email auth setting."""
enabled: bool
class TelegramWidgetConfigResponse(BaseModel):
"""Public Telegram Login Widget configuration."""
bot_username: str
size: Literal['large', 'medium', 'small'] = 'large'
radius: int = Field(default=8, ge=0, le=20)
userpic: bool = True
request_access: bool = True
# OIDC fields (frontend decides which flow to use)
oidc_enabled: bool = False
oidc_client_id: str = ''
class LiteModeEnabledResponse(BaseModel):
"""Lite mode enabled setting."""
enabled: bool = False
class LiteModeEnabledUpdate(BaseModel):
"""Request to update lite mode setting."""
enabled: bool
class GiftEnabledResponse(BaseModel):
"""Gift feature enabled setting."""
enabled: bool = False
class GiftEnabledUpdate(BaseModel):
"""Request to update gift feature setting."""
enabled: bool
class AnalyticsCountersResponse(BaseModel):
"""Analytics counter settings."""
yandex_metrika_id: str = ''
google_ads_id: str = ''
google_ads_label: str = ''
class AnalyticsCountersUpdate(BaseModel):
"""Request to update analytics counters (partial update allowed)."""
yandex_metrika_id: str | None = None
google_ads_id: str | None = None
google_ads_label: str | None = None
# Default theme colors
DEFAULT_THEME_COLORS = {
'accent': '#3b82f6',
'darkBackground': '#0a0f1a',
'darkSurface': '#0f172a',
'darkText': '#f1f5f9',
'darkTextSecondary': '#94a3b8',
'lightBackground': '#F7E7CE',
'lightSurface': '#FEF9F0',
'lightText': '#1F1A12',
'lightTextSecondary': '#7D6B48',
'success': '#22c55e',
'warning': '#f59e0b',
'error': '#ef4444',
}
# ============ Helper Functions ============
def ensure_branding_dir():
"""Ensure branding directory exists."""
BRANDING_DIR.mkdir(parents=True, exist_ok=True)
async def set_setting_value(db: AsyncSession, key: str, value: str):
"""Set a setting value in database."""
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if setting:
setting.value = value
else:
setting = SystemSetting(key=key, value=value)
db.add(setting)
await db.commit()
def get_logo_path() -> Path | None:
"""Get the path to the custom logo file (any supported format)."""
if not BRANDING_DIR.exists():
return None
# Search for logo file with any supported extension
for ext in LOGO_EXTENSIONS:
logo_path = BRANDING_DIR / f'logo{ext}'
if logo_path.exists():
return logo_path
return None
def has_custom_logo() -> bool:
"""Check if a custom logo exists."""
return get_logo_path() is not None
# ============ Routes ============
@router.get('', response_model=BrandingResponse)
async def get_branding(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get current branding settings.
This is a public endpoint - no authentication required.
"""
# Get name from database or use default from env/settings
name = await get_setting_value(db, BRANDING_NAME_KEY)
if name is None: # Only use fallback if not set at all (empty string is valid)
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or os.getenv('VITE_APP_NAME', 'Cabinet')
# Check for custom logo
custom_logo = has_custom_logo()
# Get first letter for logo fallback (use "V" if name is empty)
logo_letter = name[0].upper() if name else 'V'
return BrandingResponse(
name=name,
logo_url='/cabinet/branding/logo' if custom_logo else None,
logo_letter=logo_letter,
has_custom_logo=custom_logo,
)
@router.get('/logo')
async def get_logo():
"""
Get the custom logo image.
Returns 404 if no custom logo is set.
"""
logo_path = get_logo_path()
if logo_path is None or not await asyncio.to_thread(logo_path.exists):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='No custom logo set')
# Determine media type from file extension
suffix = logo_path.suffix.lower()
media_types = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
}
media_type = media_types.get(suffix, 'image/png')
return FileResponse(logo_path, media_type=media_type, headers={'Cache-Control': 'public, max-age=3600'})
@router.put('/name', response_model=BrandingResponse)
async def update_branding_name(
payload: BrandingNameUpdate,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update the project name. Admin only. Empty name allowed (logo only mode)."""
name = payload.name.strip() if payload.name else ''
if len(name) > 50:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Name too long (max 50 characters)')
await set_setting_value(db, BRANDING_NAME_KEY, name)
logger.info('Admin updated branding name to', telegram_id=admin.telegram_id, name=name)
# Return updated branding
custom_logo = has_custom_logo()
logo_letter = name[0].upper() if name else 'C'
return BrandingResponse(
name=name,
logo_url='/cabinet/branding/logo' if custom_logo else None,
logo_letter=logo_letter,
has_custom_logo=custom_logo,
)
@router.post('/logo', response_model=BrandingResponse)
async def upload_logo(
file: UploadFile = File(...),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Upload a custom logo. Admin only."""
# Validate content type
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid file type. Allowed: PNG, JPEG, WebP, SVG'
)
# Read file content
content = await file.read()
# Validate file size
if len(content) > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'File too large. Maximum size: {MAX_FILE_SIZE // 1024 // 1024}MB',
)
# Ensure directory exists
await asyncio.to_thread(ensure_branding_dir)
# Determine file extension from content type
ext_map = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/webp': '.webp',
'image/svg+xml': '.svg',
}
extension = ext_map.get(file.content_type, '.png')
# Remove old logo files with any extension
for old_file in await asyncio.to_thread(lambda: list(BRANDING_DIR.glob('logo.*'))):
await asyncio.to_thread(old_file.unlink)
# Save new logo
logo_path = BRANDING_DIR / f'logo{extension}'
await asyncio.to_thread(logo_path.write_bytes, content)
# Mark that we have a custom logo
await set_setting_value(db, BRANDING_LOGO_KEY, 'custom')
logger.info('Admin uploaded new logo', telegram_id=admin.telegram_id, logo_path=logo_path)
# Get current name for response
name = await get_setting_value(db, BRANDING_NAME_KEY)
if name is None: # Only use fallback if not set at all (empty string is valid)
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or os.getenv('VITE_APP_NAME', 'Cabinet')
logo_letter = name[0].upper() if name else 'C'
return BrandingResponse(
name=name,
logo_url='/cabinet/branding/logo',
logo_letter=logo_letter,
has_custom_logo=True,
)
@router.delete('/logo', response_model=BrandingResponse)
async def delete_logo(
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete custom logo and revert to letter. Admin only."""
# Remove logo files
for old_file in await asyncio.to_thread(lambda: list(BRANDING_DIR.glob('logo.*'))):
await asyncio.to_thread(old_file.unlink)
# Update setting
await set_setting_value(db, BRANDING_LOGO_KEY, 'default')
logger.info('Admin deleted custom logo', telegram_id=admin.telegram_id)
# Get current name for response
name = await get_setting_value(db, BRANDING_NAME_KEY)
if name is None: # Only use fallback if not set at all (empty string is valid)
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or os.getenv('VITE_APP_NAME', 'Cabinet')
logo_letter = name[0].upper() if name else 'C'
return BrandingResponse(
name=name,
logo_url=None,
logo_letter=logo_letter,
has_custom_logo=False,
)
# ============ Theme Colors Routes ============
def validate_hex_color(color: str) -> bool:
"""Validate hex color format."""
if not color or not isinstance(color, str):
return False
if not color.startswith('#'):
return False
hex_part = color[1:]
if len(hex_part) not in (3, 6):
return False
try:
int(hex_part, 16)
return True
except ValueError:
return False
@router.get('/colors', response_model=ThemeColorsResponse)
async def get_theme_colors(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get current theme colors.
This is a public endpoint - no authentication required.
"""
colors_json = await get_setting_value(db, THEME_COLORS_KEY)
if colors_json:
try:
colors = json.loads(colors_json)
# Merge with defaults to ensure all fields exist
merged = {**DEFAULT_THEME_COLORS, **colors}
return ThemeColorsResponse(**merged)
except (json.JSONDecodeError, TypeError):
pass
return ThemeColorsResponse(**DEFAULT_THEME_COLORS)
@router.patch('/colors', response_model=ThemeColorsResponse)
async def update_theme_colors(
payload: ThemeColorsUpdate,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update theme colors. Admin only. Partial update supported."""
# Get current colors
colors_json = await get_setting_value(db, THEME_COLORS_KEY)
current_colors = DEFAULT_THEME_COLORS.copy()
if colors_json:
try:
current_colors.update(json.loads(colors_json))
except (json.JSONDecodeError, TypeError):
pass
# Update with new values (only non-None fields)
update_data = payload.model_dump(exclude_none=True)
# Validate hex colors
for key, value in update_data.items():
if not validate_hex_color(value):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f'Invalid hex color for {key}: {value}')
current_colors.update(update_data)
# Save to database
await set_setting_value(db, THEME_COLORS_KEY, json.dumps(current_colors))
logger.info('Admin updated theme colors', telegram_id=admin.telegram_id, value=list(update_data.keys()))
return ThemeColorsResponse(**current_colors)
@router.post('/colors/reset', response_model=ThemeColorsResponse)
async def reset_theme_colors(
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset theme colors to defaults. Admin only."""
# Save default colors
await set_setting_value(db, THEME_COLORS_KEY, json.dumps(DEFAULT_THEME_COLORS))
logger.info('Admin reset theme colors to defaults', telegram_id=admin.telegram_id)
return ThemeColorsResponse(**DEFAULT_THEME_COLORS)
# ============ Enabled Themes Routes ============
DEFAULT_ENABLED_THEMES = {'dark': True, 'light': True}
@router.get('/themes', response_model=EnabledThemesResponse)
async def get_enabled_themes(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get which themes are enabled.
This is a public endpoint - no authentication required.
"""
themes_json = await get_setting_value(db, ENABLED_THEMES_KEY)
if themes_json:
try:
themes = json.loads(themes_json)
return EnabledThemesResponse(**themes)
except (json.JSONDecodeError, TypeError):
pass
return EnabledThemesResponse(**DEFAULT_ENABLED_THEMES)
@router.patch('/themes', response_model=EnabledThemesResponse)
async def update_enabled_themes(
payload: EnabledThemesUpdate,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update which themes are enabled. Admin only. At least one theme must be enabled."""
# Get current settings
themes_json = await get_setting_value(db, ENABLED_THEMES_KEY)
current_themes = DEFAULT_ENABLED_THEMES.copy()
if themes_json:
try:
current_themes.update(json.loads(themes_json))
except (json.JSONDecodeError, TypeError):
pass
# Update with new values
update_data = payload.model_dump(exclude_none=True)
current_themes.update(update_data)
# Ensure at least one theme is enabled
if not current_themes.get('dark') and not current_themes.get('light'):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='At least one theme must be enabled')
# Save to database
await set_setting_value(db, ENABLED_THEMES_KEY, json.dumps(current_themes))
logger.info('Admin updated enabled themes', telegram_id=admin.telegram_id, current_themes=current_themes)
return EnabledThemesResponse(**current_themes)
# ============ Animation Routes ============
@router.get('/animation', response_model=AnimationEnabledResponse)
async def get_animation_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get animation enabled setting.
This is a public endpoint - no authentication required.
"""
animation_value = await get_setting_value(db, ANIMATION_ENABLED_KEY)
if animation_value is not None:
enabled = animation_value.lower() == 'true'
return AnimationEnabledResponse(enabled=enabled)
# Default: enabled
return AnimationEnabledResponse(enabled=True)
@router.patch('/animation', response_model=AnimationEnabledResponse)
async def update_animation_enabled(
payload: AnimationEnabledUpdate,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update animation enabled setting. Admin only."""
await set_setting_value(db, ANIMATION_ENABLED_KEY, str(payload.enabled).lower())
logger.info('Admin set animation enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return AnimationEnabledResponse(enabled=payload.enabled)
# ============ Animation Config Routes (new JSON-based) ============
@router.get('/animation-config', response_model=AnimationConfigResponse)
async def get_animation_config(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get full animation config. Public endpoint."""
config_value = await get_setting_value(db, ANIMATION_CONFIG_KEY)
if config_value is not None:
try:
config = json.loads(config_value)
return AnimationConfigResponse(**config)
except (json.JSONDecodeError, TypeError):
pass
# Auto-migrate from old ANIMATION_ENABLED_KEY
old_value = await get_setting_value(db, ANIMATION_ENABLED_KEY)
if old_value is not None:
config = {**DEFAULT_ANIMATION_CONFIG, 'enabled': old_value.lower() == 'true'}
await set_setting_value(db, ANIMATION_CONFIG_KEY, json.dumps(config))
return AnimationConfigResponse(**config)
return AnimationConfigResponse(**DEFAULT_ANIMATION_CONFIG)
@router.patch('/animation-config', response_model=AnimationConfigResponse)
async def update_animation_config(
payload: AnimationConfigUpdate,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update animation config (partial update). Admin only."""
# Get current config
config_value = await get_setting_value(db, ANIMATION_CONFIG_KEY)
if config_value:
try:
current = json.loads(config_value)
except (json.JSONDecodeError, TypeError):
current = dict(DEFAULT_ANIMATION_CONFIG)
else:
current = dict(DEFAULT_ANIMATION_CONFIG)
# Merge only provided fields
update_data = payload.model_dump(exclude_none=True)
current.update(update_data)
await set_setting_value(db, ANIMATION_CONFIG_KEY, json.dumps(current))
# Also sync old key for backwards compat
await set_setting_value(db, ANIMATION_ENABLED_KEY, str(current.get('enabled', True)).lower())
logger.info(
'Admin updated animation config',
telegram_id=admin.telegram_id,
type=current.get('type'),
enabled=current.get('enabled'),
)
return AnimationConfigResponse(**current)
# ============ Fullscreen Routes ============
@router.get('/fullscreen', response_model=FullscreenEnabledResponse)
async def get_fullscreen_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get fullscreen enabled setting.
This is a public endpoint - no authentication required.
"""
fullscreen_value = await get_setting_value(db, FULLSCREEN_ENABLED_KEY)
if fullscreen_value is not None:
enabled = fullscreen_value.lower() == 'true'
return FullscreenEnabledResponse(enabled=enabled)
# Default: disabled
return FullscreenEnabledResponse(enabled=False)
@router.patch('/fullscreen', response_model=FullscreenEnabledResponse)
async def update_fullscreen_enabled(
payload: FullscreenEnabledUpdate,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update fullscreen enabled setting. Admin only."""
await set_setting_value(db, FULLSCREEN_ENABLED_KEY, str(payload.enabled).lower())
logger.info('Admin set fullscreen enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return FullscreenEnabledResponse(enabled=payload.enabled)
# ============ Email Auth Routes ============
@router.get('/email-auth', response_model=EmailAuthEnabledResponse)
async def get_email_auth_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get email auth enabled setting.
This is a public endpoint - no authentication required.
Controls whether email registration/login is available.
"""
email_auth_value = await get_setting_value(db, EMAIL_AUTH_ENABLED_KEY)
if email_auth_value is not None:
enabled = email_auth_value.lower() == 'true'
return EmailAuthEnabledResponse(enabled=enabled)
# Default: check config setting
return EmailAuthEnabledResponse(enabled=settings.is_cabinet_email_auth_enabled())
@router.patch('/email-auth', response_model=EmailAuthEnabledResponse)
async def update_email_auth_enabled(
payload: EmailAuthEnabledUpdate,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update email auth enabled setting. Admin only."""
await set_setting_value(db, EMAIL_AUTH_ENABLED_KEY, str(payload.enabled).lower())
logger.info('Admin set email auth enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return EmailAuthEnabledResponse(enabled=payload.enabled)
# ============ Telegram Widget Config Routes ============
@router.get('/telegram-widget', response_model=TelegramWidgetConfigResponse)
async def get_telegram_widget_config(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get Telegram Login Widget configuration.
This is a public endpoint - no authentication required.
Returns widget display settings and bot username for the login page.
"""
bot_username = settings.BOT_USERNAME or ''
size_val = await get_setting_value(db, TELEGRAM_WIDGET_SIZE_KEY)
radius_val = await get_setting_value(db, TELEGRAM_WIDGET_RADIUS_KEY)
userpic_val = await get_setting_value(db, TELEGRAM_WIDGET_USERPIC_KEY)
request_access_val = await get_setting_value(db, TELEGRAM_WIDGET_REQUEST_ACCESS_KEY)
oidc_enabled_val = await get_setting_value(db, TELEGRAM_OIDC_ENABLED_KEY)
oidc_client_id_val = await get_setting_value(db, TELEGRAM_OIDC_CLIENT_ID_KEY)
oidc_client_id = oidc_client_id_val or settings.TELEGRAM_OIDC_CLIENT_ID
oidc_enabled = (
oidc_enabled_val.lower() == 'true' if oidc_enabled_val is not None else settings.TELEGRAM_OIDC_ENABLED
) and bool(oidc_client_id)
return TelegramWidgetConfigResponse(
bot_username=bot_username,
size=size_val if size_val in ('large', 'medium', 'small') else settings.TELEGRAM_WIDGET_SIZE,
radius=max(0, min(int(radius_val), 20))
if radius_val and radius_val.isdigit()
else settings.TELEGRAM_WIDGET_RADIUS,
userpic=userpic_val.lower() == 'true' if userpic_val is not None else settings.TELEGRAM_WIDGET_USERPIC,
request_access=request_access_val.lower() == 'true'
if request_access_val is not None
else settings.TELEGRAM_WIDGET_REQUEST_ACCESS,
oidc_enabled=oidc_enabled,
oidc_client_id=oidc_client_id if oidc_enabled else '',
)
# ============ Analytics Counters Routes ============
@router.get('/analytics', response_model=AnalyticsCountersResponse)
async def get_analytics_counters(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get analytics counter settings.
This is a public endpoint - no authentication required.
"""
yandex_id = await get_setting_value(db, YANDEX_METRIKA_ID_KEY) or ''
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
return AnalyticsCountersResponse(
yandex_metrika_id=yandex_id,
google_ads_id=google_id,
google_ads_label=google_label,
)
@router.patch('/analytics', response_model=AnalyticsCountersResponse)
async def update_analytics_counters(
payload: AnalyticsCountersUpdate,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update analytics counter settings. Admin only. Partial update supported."""
if payload.yandex_metrika_id is not None:
value = payload.yandex_metrika_id.strip()
if value and not value.isdigit():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Yandex Metrika counter ID must be numeric',
)
await set_setting_value(db, YANDEX_METRIKA_ID_KEY, value)
if payload.google_ads_id is not None:
value = payload.google_ads_id.strip()
if value and not value.startswith('AW-'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Google Ads conversion ID must start with AW-',
)
await set_setting_value(db, GOOGLE_ADS_ID_KEY, value)
if payload.google_ads_label is not None:
await set_setting_value(db, GOOGLE_ADS_LABEL_KEY, payload.google_ads_label.strip())
logger.info('Admin updated analytics counters', telegram_id=admin.telegram_id)
# Return current state
yandex_id = await get_setting_value(db, YANDEX_METRIKA_ID_KEY) or ''
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
return AnalyticsCountersResponse(
yandex_metrika_id=yandex_id,
google_ads_id=google_id,
google_ads_label=google_label,
)
# ============ Lite Mode Routes ============
@router.get('/lite-mode', response_model=LiteModeEnabledResponse)
async def get_lite_mode_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get lite mode enabled setting.
This is a public endpoint - no authentication required.
When enabled, shows simplified dashboard with minimal features.
"""
lite_mode_value = await get_setting_value(db, LITE_MODE_ENABLED_KEY)
if lite_mode_value is not None:
enabled = lite_mode_value.lower() == 'true'
return LiteModeEnabledResponse(enabled=enabled)
# Default: disabled
return LiteModeEnabledResponse(enabled=False)
@router.patch('/lite-mode', response_model=LiteModeEnabledResponse)
async def update_lite_mode_enabled(
payload: LiteModeEnabledUpdate,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update lite mode enabled setting. Admin only."""
await set_setting_value(db, LITE_MODE_ENABLED_KEY, str(payload.enabled).lower())
logger.info('Admin set lite mode enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return LiteModeEnabledResponse(enabled=payload.enabled)
# ============ Gift Feature Routes ============
@router.get('/gift-enabled', response_model=GiftEnabledResponse)
async def get_gift_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get gift feature enabled setting. Public endpoint."""
value = await get_setting_value(db, GIFT_ENABLED_KEY)
if value is not None:
enabled = value.lower() == 'true'
return GiftEnabledResponse(enabled=enabled)
return GiftEnabledResponse(enabled=False)
@router.patch('/gift-enabled', response_model=GiftEnabledResponse)
async def update_gift_enabled(
payload: GiftEnabledUpdate,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update gift feature enabled setting. Admin only."""
await set_setting_value(db, GIFT_ENABLED_KEY, str(payload.enabled).lower())
logger.info('Admin set gift enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return GiftEnabledResponse(enabled=payload.enabled)