feat: custom themes initial

This commit is contained in:
3252a8
2026-05-14 16:33:50 +03:00
parent 46520fb37c
commit b6ee5e8790
79 changed files with 4151 additions and 152 deletions
+26
View File
@@ -7,6 +7,10 @@ from pydantic import BaseModel, Field, ValidationError, computed_field, field_va
from pydantic_settings import BaseSettings, SettingsConfigDict
from config.tariffs_config import TariffsConfig, load_tariffs_config
from config.webapp_themes_config import (
WebappThemesConfig,
resolved_webapp_themes_catalog,
)
def _split_csv(value: Optional[str]) -> List[str]:
@@ -351,6 +355,19 @@ class Settings(BaseSettings):
WEBAPP_SERVER_PORT: int = Field(default=8081)
WEBAPP_TITLE: str = Field(default="Моя подписка")
WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a")
WEBAPP_THEMES_DIR: str = Field(
default="data/themes",
description=(
"Directory with per-theme folders. Each theme lives in "
"<key>/theme.json with optional CSS/assets next to it."
),
)
WEBAPP_DEFAULT_THEME: Optional[str] = Field(
default=None,
description=(
"Override the descriptor-marked default theme when set to an existing theme key."
),
)
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
WEBAPP_LOGO_EMOJI: str = Field(default="🫥")
WEBAPP_LOGO_EMOJI_FONT: str = Field(
@@ -810,6 +827,15 @@ class Settings(BaseSettings):
def tariffs_config(self) -> Optional[TariffsConfig]:
return load_tariffs_config(self.TARIFFS_CONFIG_PATH)
@computed_field
@property
def webapp_themes_catalog(self) -> WebappThemesConfig:
return resolved_webapp_themes_catalog(
primary_accent=self.WEBAPP_PRIMARY_COLOR or "#00fe7a",
env_default_theme=self.WEBAPP_DEFAULT_THEME,
theme_dir=self.WEBAPP_THEMES_DIR,
)
@computed_field
@property
def referral_bonus_inviter(self) -> Dict[int, int]:
+586
View File
@@ -0,0 +1,586 @@
"""File-backed catalog of Web App UI themes."""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field, model_validator
logger = logging.getLogger(__name__)
ColorScheme = Literal["light", "dark"]
class ThemeTokens(BaseModel):
"""CSS design tokens for the subscription Mini App shell."""
model_config = {"extra": "ignore"}
color_scheme: ColorScheme = "dark"
style_preset: Optional[str] = None
accent: Optional[str] = None
bg: Optional[str] = None
panel: Optional[str] = None
panel_2: Optional[str] = None
panel_3: Optional[str] = None
border: Optional[str] = None
border_strong: Optional[str] = None
text: Optional[str] = None
muted: Optional[str] = None
dim: Optional[str] = None
danger: Optional[str] = None
blue: Optional[str] = None
radius: Optional[str] = None
font_sans: Optional[str] = None
font_logo: Optional[str] = None
font_mono: Optional[str] = None
admin_bg: Optional[str] = None
admin_surface: Optional[str] = None
admin_surface_2: Optional[str] = None
admin_elev: Optional[str] = None
admin_border: Optional[str] = None
admin_border_strong: Optional[str] = None
admin_text: Optional[str] = None
admin_muted: Optional[str] = None
admin_dim: Optional[str] = None
class WebappTheme(BaseModel):
"""Single theme descriptor loaded from WEBAPP_THEMES_DIR/<key>/theme.json."""
model_config = {"extra": "ignore"}
key: str = Field(min_length=1, max_length=64)
names: Dict[str, str] = Field(default_factory=dict)
enabled: bool = True
default: bool = False
use_primary_accent: bool = True
use_in_admin: bool = True
css_file: Optional[str] = None
assets_version: int = 1
tokens: ThemeTokens = Field(default_factory=ThemeTokens)
class WebappThemesConfig(BaseModel):
"""Runtime catalog assembled from individual theme descriptor files."""
model_config = {"extra": "ignore"}
default_theme: str = "dark"
themes: List[WebappTheme] = Field(default_factory=list)
@model_validator(mode="after")
def _validate_default_and_keys(self) -> WebappThemesConfig:
keys = [t.key for t in self.themes]
if len(keys) != len(set(keys)):
raise ValueError("duplicate theme keys")
if self.themes and self.default_theme not in keys:
raise ValueError("default_theme must match a theme key")
return self
def theme_by_key(self, key: str) -> Optional[WebappTheme]:
for theme in self.themes:
if theme.key == key:
return theme
return None
def enabled_themes(self) -> List[WebappTheme]:
return [theme for theme in self.themes if theme.enabled]
DEFAULT_THEME_KEYS = ("dark", "light", "windows95", "ascii")
THEME_DESCRIPTOR_FILENAME = "theme.json"
DEFAULT_THEMES_SOURCE_DIR = Path(__file__).resolve().parents[1] / "bot" / "app" / "web" / "themes"
def _safe_theme_key(value: str) -> Optional[str]:
key = str(value or "").strip()
if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", key):
return key
return None
def _theme_dir_path(theme_dir: str | Path, key: str) -> Path:
safe_key = _safe_theme_key(key)
if not safe_key:
raise ValueError(f"invalid theme key: {key!r}")
return Path(theme_dir).expanduser() / safe_key
def _theme_file_path(theme_dir: str | Path, key: str) -> Path:
return _theme_dir_path(theme_dir, key) / THEME_DESCRIPTOR_FILENAME
def default_webapp_theme_css_files() -> Dict[str, str]:
"""Read default theme CSS from repository files."""
out: Dict[str, str] = {}
for key in DEFAULT_THEME_KEYS:
source_dir = DEFAULT_THEMES_SOURCE_DIR / key
for source_path in sorted(source_dir.rglob("*.css")):
rel_path = Path(key) / source_path.relative_to(source_dir)
try:
content = source_path.read_text(encoding="utf-8")
except OSError as exc:
logger.warning(
"Default webapp theme CSS source file is missing: %s (%s)",
source_path,
exc,
)
continue
out[rel_path.as_posix()] = content if content.endswith("\n") else f"{content}\n"
return out
def default_webapp_theme_asset_file(rel_path: str | Path) -> Optional[tuple[bytes, str]]:
"""Read a default theme asset from the repository theme folder."""
relative = Path(rel_path)
if relative.is_absolute() or len(relative.parts) < 2 or ".." in relative.parts:
return None
source_path = (DEFAULT_THEMES_SOURCE_DIR / relative).resolve()
try:
source_path.relative_to(DEFAULT_THEMES_SOURCE_DIR.resolve())
except ValueError:
return None
try:
return source_path.read_bytes(), source_path.suffix.lower()
except OSError:
return None
def default_webapp_theme_descriptors() -> Dict[str, Dict[str, Any]]:
"""Read default theme descriptors from repository files."""
out: Dict[str, Dict[str, Any]] = {}
for key in DEFAULT_THEME_KEYS:
source_path = DEFAULT_THEMES_SOURCE_DIR / key / THEME_DESCRIPTOR_FILENAME
try:
raw = json.loads(source_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
logger.warning(
"Default webapp theme descriptor is missing or invalid: %s (%s)",
source_path,
exc,
)
continue
if not isinstance(raw, dict):
continue
safe_key = _safe_theme_key(str(raw.get("key") or source_path.parent.name))
if safe_key:
raw["key"] = safe_key
out[safe_key] = raw
return out
def _theme_from_descriptor(path: Path, raw: Any) -> Optional[WebappTheme]:
if not isinstance(raw, dict):
logger.warning("Ignoring theme descriptor %s: expected JSON object", path)
return None
data = dict(raw)
data["key"] = data.get("key") or (
path.parent.name if path.name == THEME_DESCRIPTOR_FILENAME else path.stem
)
safe_key = _safe_theme_key(str(data["key"]))
if not safe_key:
logger.warning("Ignoring theme descriptor %s: invalid theme key %r", path, data["key"])
return None
data["key"] = safe_key
try:
return WebappTheme.model_validate(data)
except ValueError as exc:
logger.warning("Ignoring theme descriptor %s: %s", path, exc)
return None
def load_webapp_theme_file(path: str | Path) -> Optional[WebappTheme]:
theme_path = Path(path).expanduser()
try:
raw = json.loads(theme_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
logger.warning("Failed to load webapp theme descriptor from %s: %s", theme_path, exc)
return None
return _theme_from_descriptor(theme_path, raw)
def load_webapp_theme_dir(theme_dir: str | Path) -> List[WebappTheme]:
root = Path(theme_dir).expanduser()
if not root.exists():
return []
themes_by_key: Dict[str, WebappTheme] = {}
for path in sorted(root.glob(f"*/{THEME_DESCRIPTOR_FILENAME}")):
if path.parent.name.startswith("_"):
continue
theme = load_webapp_theme_file(path)
if theme is None:
continue
if theme.key in themes_by_key:
logger.warning("Ignoring duplicate webapp theme key %s from %s", theme.key, path)
continue
themes_by_key[theme.key] = theme
return list(themes_by_key.values())
def _config_with_synced_default_flags(config: WebappThemesConfig) -> WebappThemesConfig:
data = config.model_dump(mode="json", exclude_none=True)
default_theme = str(data.get("default_theme") or "dark")
themes = data.get("themes", [])
for theme in themes:
if isinstance(theme, dict):
theme["default"] = theme.get("key") == default_theme
def _sort_key(item: tuple[int, Any]) -> tuple[int, int]:
idx, theme = item
if not isinstance(theme, dict):
return (len(THEME_DISPLAY_ORDER), idx)
try:
priority = THEME_DISPLAY_ORDER.index(str(theme.get("key") or ""))
except ValueError:
priority = len(THEME_DISPLAY_ORDER)
return (priority, idx)
data["themes"] = [theme for _, theme in sorted(enumerate(themes), key=_sort_key)]
return WebappThemesConfig.model_validate(data)
THEME_DISPLAY_ORDER = ("dark", "light")
def _theme_sort_key(theme: WebappTheme, index: int) -> tuple[int, int]:
try:
priority = THEME_DISPLAY_ORDER.index(theme.key)
except ValueError:
priority = len(THEME_DISPLAY_ORDER)
return (priority, index)
def _sorted_themes(themes: List[WebappTheme]) -> List[WebappTheme]:
return [theme for _, theme in sorted(
((_theme_sort_key(t, i), t) for i, t in enumerate(themes)),
key=lambda pair: pair[0],
)]
def _themes_config_from_list(
default_theme: Optional[str],
themes: List[WebappTheme],
) -> WebappThemesConfig:
keys = {theme.key for theme in themes}
descriptor_default = next(
(theme.key for theme in themes if theme.default and theme.key not in DEFAULT_THEME_KEYS),
None,
) or next((theme.key for theme in themes if theme.default), None)
resolved_default = default_theme or descriptor_default or "dark"
if themes and resolved_default not in keys:
resolved_default = "dark" if "dark" in keys else themes[0].key
config = WebappThemesConfig(default_theme=resolved_default, themes=_sorted_themes(themes))
return _config_with_synced_default_flags(config)
def _write_webapp_theme_file(path: Path, theme: WebappTheme) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
data = theme.model_dump(mode="json", exclude_none=True)
payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
try:
tmp_path.write_text(payload, encoding="utf-8")
tmp_path.replace(path)
except PermissionError:
if tmp_path.exists():
try:
tmp_path.unlink()
except OSError:
pass
path.write_text(payload, encoding="utf-8")
def _copy_default_theme_assets(key: str, target_dir: Path, *, overwrite: bool = False) -> None:
source_dir = DEFAULT_THEMES_SOURCE_DIR / key
if not source_dir.exists():
return
for source_path in sorted(source_dir.rglob("*")):
if not source_path.is_file() or source_path.name == THEME_DESCRIPTOR_FILENAME:
continue
rel_path = source_path.relative_to(source_dir)
target_path = target_dir / rel_path
if target_path.exists() and not overwrite:
continue
try:
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(source_path.read_bytes())
except OSError as exc:
logger.warning("Could not create default webapp theme asset %s: %s", target_path, exc)
def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
style_path = target_dir / "style.css"
try:
style = style_path.read_text(encoding="utf-8")
except OSError:
return True
if key == "light":
return ".theme-key-light.app-shell" not in style
if key == "ascii":
return (
".theme-key-ascii" not in style
or "ascii-spin" not in style
or "ascii-skeleton-scan" not in style
or "ascii-boot-type" not in style
or "Console-style tables" not in style
)
if key != "windows95":
return False
required_icons = (
"arrow-right.png",
"dashboard.png",
"megaphone.png",
"paintbrush.png",
"sliders.png",
"sparkles.png",
"tag.png",
)
return (
"lucide-house" not in style
or "lucide-earth" not in style
or "lucide-circle-check" not in style
or "border-radius: 0 !important" not in style
or any(not (target_dir / "icons" / icon).exists() for icon in required_icons)
)
def ensure_default_webapp_theme_descriptor_files(theme_dir: str | Path | None) -> None:
"""Seed source-controlled default theme folders into the mounted data directory."""
if not theme_dir:
return
root = Path(theme_dir).expanduser()
try:
root.mkdir(parents=True, exist_ok=True)
except OSError as exc:
logger.warning("Could not create webapp themes directory at %s: %s", root, exc)
return
existing_has_default = any(theme.default for theme in load_webapp_theme_dir(root))
existing_by_key = {theme.key: theme for theme in load_webapp_theme_dir(root)}
for key, descriptor in default_webapp_theme_descriptors().items():
theme_dir_path = _theme_dir_path(root, key)
path = _theme_file_path(root, key)
existing = existing_by_key.get(key)
source_assets_version = int(descriptor.get("assets_version") or 1)
should_sync_assets = (
existing is not None
and key in DEFAULT_THEME_KEYS
and (
int(existing.assets_version or 0) < source_assets_version
or _builtin_theme_assets_need_refresh(key, theme_dir_path)
)
)
if not path.exists():
seed_descriptor = dict(descriptor)
if existing_has_default:
seed_descriptor["default"] = False
theme = _theme_from_descriptor(path, seed_descriptor)
if theme is not None:
try:
_write_webapp_theme_file(path, theme)
except OSError as exc:
logger.warning(
"Could not create default webapp theme descriptor %s: %s",
path,
exc,
)
elif should_sync_assets and existing is not None:
data = existing.model_dump(mode="json", exclude_none=True)
data["assets_version"] = source_assets_version
if descriptor.get("css_file"):
data["css_file"] = descriptor["css_file"]
try:
theme = WebappTheme.model_validate(data)
_write_webapp_theme_file(path, theme)
except (OSError, ValueError) as exc:
logger.warning("Could not update default webapp theme descriptor %s: %s", path, exc)
_copy_default_theme_assets(key, theme_dir_path, overwrite=should_sync_assets)
def write_webapp_theme_dir(
theme_dir: str | Path,
config: WebappThemesConfig,
*,
delete_missing: bool = False,
) -> None:
"""Write one theme.json descriptor per theme into WEBAPP_THEMES_DIR/<key>."""
root = Path(theme_dir).expanduser()
root.mkdir(parents=True, exist_ok=True)
normalized = _config_with_synced_default_flags(config)
keep_paths = set()
for theme in normalized.themes:
path = _theme_file_path(root, theme.key)
_write_webapp_theme_file(path, theme)
keep_paths.add(path.resolve())
if not delete_missing:
return
for path in root.glob(f"*/{THEME_DESCRIPTOR_FILENAME}"):
if path.parent.name.startswith("_") or path.resolve() in keep_paths:
continue
try:
path.unlink()
if not any(path.parent.iterdir()):
path.parent.rmdir()
except OSError as exc:
logger.warning("Could not delete removed webapp theme descriptor %s: %s", path, exc)
def ensure_webapp_core_themes(
config: WebappThemesConfig, primary_accent: str
) -> tuple[WebappThemesConfig, bool]:
"""Keep dark, light and Windows 95 themes available without clobbering custom edits."""
data = config.model_dump(mode="json", exclude_none=True)
themes = data.setdefault("themes", [])
by_key = {str(theme.get("key")): theme for theme in themes if isinstance(theme, dict)}
changed = False
for builtin in builtin_webapp_themes_config(primary_accent).themes:
builtin_data = builtin.model_dump(mode="json", exclude_none=True)
existing = by_key.get(builtin.key)
if existing is None:
themes.append(builtin_data)
by_key[builtin.key] = builtin_data
changed = True
continue
if existing.get("enabled") is False:
existing["enabled"] = True
changed = True
if "use_primary_accent" not in existing:
existing["use_primary_accent"] = builtin_data.get("use_primary_accent", True)
changed = True
if "use_in_admin" not in existing:
existing["use_in_admin"] = builtin_data.get("use_in_admin", True)
changed = True
if int(existing.get("assets_version") or 0) < int(builtin_data.get("assets_version") or 1):
existing["assets_version"] = builtin_data.get("assets_version", 1)
changed = True
if builtin.key in {"light", "windows95", "ascii"} and not existing.get("css_file"):
existing["css_file"] = builtin_data.get("css_file")
changed = True
tokens = existing.setdefault("tokens", {})
builtin_tokens = builtin_data.get("tokens", {})
for token_key in ("color_scheme", "style_preset"):
if token_key in builtin_tokens and not tokens.get(token_key):
tokens[token_key] = builtin_tokens[token_key]
changed = True
keys = {str(theme.get("key")) for theme in themes if isinstance(theme, dict)}
if themes and data.get("default_theme") not in keys:
data["default_theme"] = "dark" if "dark" in keys else str(themes[0].get("key") or "dark")
changed = True
normalized = _config_with_synced_default_flags(WebappThemesConfig.model_validate(data))
if normalized.model_dump(mode="json", exclude_none=True) != data:
changed = True
return normalized, changed
def builtin_webapp_themes_config(primary_accent: str) -> WebappThemesConfig:
"""Default catalog backed by repository theme descriptor files."""
accent = (primary_accent or "#00fe7a").strip() or "#00fe7a"
themes: List[WebappTheme] = []
descriptors = default_webapp_theme_descriptors()
for key in DEFAULT_THEME_KEYS:
raw = descriptors.get(key)
if not raw:
continue
theme = _theme_from_descriptor(
DEFAULT_THEMES_SOURCE_DIR / key / THEME_DESCRIPTOR_FILENAME,
raw,
)
if theme is None:
continue
if theme.key == "dark" and not theme.tokens.accent:
theme.tokens.accent = accent
themes.append(theme)
return _themes_config_from_list(None, themes)
def apply_webapp_theme_env_overrides(
config: WebappThemesConfig, env_default_theme: Optional[str]
) -> WebappThemesConfig:
"""If WEBAPP_DEFAULT_THEME is set and matches a theme key, override the default theme."""
raw = (env_default_theme or "").strip()
if not raw:
return config
if config.theme_by_key(raw) is None:
logger.warning("WEBAPP_DEFAULT_THEME=%r ignored: no such theme in catalog", raw)
return config
data = config.model_dump(mode="json", exclude_none=True)
data["default_theme"] = raw
return _config_with_synced_default_flags(WebappThemesConfig.model_validate(data))
def resolved_webapp_themes_catalog(
*,
theme_dir: str | Path,
primary_accent: str,
env_default_theme: Optional[str],
) -> WebappThemesConfig:
"""Load themes from WEBAPP_THEMES_DIR, seeding defaults when possible."""
ensure_default_webapp_theme_descriptor_files(theme_dir)
themes = load_webapp_theme_dir(theme_dir)
config = _themes_config_from_list(None, themes)
config, changed = ensure_webapp_core_themes(config, primary_accent)
if changed:
try:
write_webapp_theme_dir(theme_dir, config, delete_missing=False)
except OSError as exc:
logger.warning("Could not update webapp theme descriptors in %s: %s", theme_dir, exc)
return apply_webapp_theme_env_overrides(config, env_default_theme)
def merge_primary_accent_into_theme_tokens(
theme: WebappTheme, primary_accent: str, *, only_if_token_missing: bool = True
) -> ThemeTokens:
"""Fill accent from WEBAPP_PRIMARY_COLOR when theme tokens omit accent."""
base = theme.tokens.model_copy(deep=True)
accent = (primary_accent or "").strip()
if not accent:
return base
if only_if_token_missing and base.accent:
return base
base.accent = accent
return base
def public_theme_payload(theme: WebappTheme, primary_accent: str) -> Dict[str, object]:
tokens = (
merge_primary_accent_into_theme_tokens(theme, primary_accent)
if theme.use_primary_accent
else theme.tokens
)
payload: Dict[str, object] = {
"key": theme.key,
"names": dict(theme.names),
"enabled": bool(theme.enabled),
"use_primary_accent": bool(theme.use_primary_accent),
"use_in_admin": bool(theme.use_in_admin),
"tokens": tokens.model_dump(mode="json", exclude_none=True),
}
if theme.css_file:
payload["css_file"] = theme.css_file
return payload
def public_themes_catalog_payload(
config: WebappThemesConfig, primary_accent: str, *, enabled_only: bool = False
) -> Dict[str, object]:
themes = [theme for theme in config.themes if not enabled_only or theme.enabled]
return {
"default_theme": config.default_theme,
"themes": [public_theme_payload(theme, primary_accent) for theme in themes],
}