Add project branding and poster footers

This commit is contained in:
Your Name
2026-08-04 17:06:48 +05:00
parent ad66f2ed73
commit a8db1b379e
10 changed files with 134 additions and 15 deletions
+1
View File
@@ -2,6 +2,7 @@ APP_ENV=production
APP_SECRET_KEY=change-me APP_SECRET_KEY=change-me
ADMIN_SITE_TITLE=Редакторская ADMIN_SITE_TITLE=Редакторская
ADMIN_APP_TITLE=Редакторская ADMIN_APP_TITLE=Редакторская
# Optional fallback favicon path; uploaded per-project favicon takes precedence
ADMIN_FAVICON_PATH= ADMIN_FAVICON_PATH=
ADMIN_BOOTSTRAP_LOGIN=admin ADMIN_BOOTSTRAP_LOGIN=admin
ADMIN_BOOTSTRAP_PASSWORD=change-me-long-password ADMIN_BOOTSTRAP_PASSWORD=change-me-long-password
+5 -1
View File
@@ -17,7 +17,7 @@ Goal: keep one codebase and run separate deployments for each editorial project.
- `ADMIN_SITE_TITLE` - `ADMIN_SITE_TITLE`
- `ADMIN_APP_TITLE` - `ADMIN_APP_TITLE`
- `ADMIN_FAVICON_PATH` - `ADMIN_FAVICON_PATH` as an optional fallback only
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` - `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
- `APP_SECRET_KEY` - `APP_SECRET_KEY`
- `ADMIN_BOOTSTRAP_LOGIN`, `ADMIN_BOOTSTRAP_PASSWORD` - `ADMIN_BOOTSTRAP_LOGIN`, `ADMIN_BOOTSTRAP_PASSWORD`
@@ -35,6 +35,10 @@ Goal: keep one codebase and run separate deployments for each editorial project.
Sources, target groups/channels, poster tokens, schedules, prompts, categories, and enabled workers should stay in `app_settings`, `sources`, `content_categories`, and `worker_controls`. Sources, target groups/channels, poster tokens, schedules, prompts, categories, and enabled workers should stay in `app_settings`, `sources`, `content_categories`, and `worker_controls`.
Project favicon is uploaded from the admin UI and stored per deployment at
`uploads/branding/favicon.png`. If it is absent, the app falls back to
`ADMIN_FAVICON_PATH`, then to the repository `favi.png`.
Common publication hashtags are also per-project database settings: Common publication hashtags are also per-project database settings:
- key: `publication_common_tags` - key: `publication_common_tags`
@@ -0,0 +1,19 @@
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
(
'vk_poster_footer_text',
'""'::jsonb,
'text',
'VK текст перед хэштегами',
'Текст, который добавляется после поста и перед хэштегами только для VK. Можно использовать VK-разметку ссылок: [https://example.com|Текст].',
'VK Poster'
),
(
'tg_poster_footer_text',
'""'::jsonb,
'text',
'TG текст перед хэштегами',
'Текст, который добавляется после поста и перед хэштегами только для Telegram. Для ссылок используй HTML: <a href="https://example.com">Текст</a>.',
'TG Poster'
)
ON CONFLICT (key) DO NOTHING;
+42 -3
View File
@@ -43,17 +43,34 @@ VK_OAUTH_VERIFIER_COOKIE = "vk_oauth_verifier"
VK_OAUTH_STATE_COOKIE = "vk_oauth_state" VK_OAUTH_STATE_COOKIE = "vk_oauth_state"
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = BASE_DIR.parents[1] PROJECT_ROOT = BASE_DIR.parents[1]
FAVICON_PATH = Path(settings.admin_favicon_path).resolve() if settings.admin_favicon_path.strip() else PROJECT_ROOT / "favi.png"
templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
app = FastAPI(title=settings.display_site_title) app = FastAPI(title=settings.display_site_title)
LOCAL_TZ = ZoneInfo("Asia/Yekaterinburg") LOCAL_TZ = ZoneInfo("Asia/Yekaterinburg")
MODEL_CACHE: dict[str, Any] = {"key": "", "at": 0.0, "models": []} MODEL_CACHE: dict[str, Any] = {"key": "", "at": 0.0, "models": []}
UPLOAD_ROOT = Path("uploads").resolve() UPLOAD_ROOT = Path("uploads").resolve()
BRANDING_DIR = UPLOAD_ROOT / "branding"
EDITOR_MEDIA_DIR = UPLOAD_ROOT / "editor_media" EDITOR_MEDIA_DIR = UPLOAD_ROOT / "editor_media"
BRANDING_DIR.mkdir(parents=True, exist_ok=True)
EDITOR_MEDIA_DIR.mkdir(parents=True, exist_ok=True) EDITOR_MEDIA_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_ROOT)), name="uploads") app.mount("/uploads", StaticFiles(directory=str(UPLOAD_ROOT)), name="uploads")
def favicon_path() -> Path:
uploaded = BRANDING_DIR / "favicon.png"
if uploaded.is_file():
return uploaded
if settings.admin_favicon_path.strip():
return Path(settings.admin_favicon_path).resolve()
return PROJECT_ROOT / "favi.png"
def favicon_version() -> int:
try:
return int(favicon_path().stat().st_mtime)
except OSError:
return 0
def public_origin(request: Request) -> str: def public_origin(request: Request) -> str:
configured = settings.vk_oauth_origin.strip().rstrip("/") configured = settings.vk_oauth_origin.strip().rstrip("/")
if configured: if configured:
@@ -72,12 +89,12 @@ def oauth_redirect_uri(request: Request, path: str) -> str:
@app.get("/favi.png") @app.get("/favi.png")
async def favicon_png(): async def favicon_png():
return FileResponse(FAVICON_PATH) return FileResponse(favicon_path())
@app.get("/favicon.ico") @app.get("/favicon.ico")
async def favicon_ico(): async def favicon_ico():
return FileResponse(FAVICON_PATH, media_type="image/png") return FileResponse(favicon_path(), media_type="image/png")
PROVIDER_OPTIONS = [ PROVIDER_OPTIONS = [
{"value": "openrouter", "label": "OpenRouter"}, {"value": "openrouter", "label": "OpenRouter"},
@@ -176,6 +193,7 @@ CATEGORY_TITLES = {
"TG Reactor": "TG-реактор", "TG Reactor": "TG-реактор",
"VK Poster": "VK-постер", "VK Poster": "VK-постер",
"MAX Poster": "MAX-постер", "MAX Poster": "MAX-постер",
"Publishing": "Публикации",
"Daily Report": "Ежедневный отчет", "Daily Report": "Ежедневный отчет",
"Parser": "Парсер", "Parser": "Парсер",
"Uploader": "Аплоадер", "Uploader": "Аплоадер",
@@ -191,6 +209,7 @@ CATEGORY_ORDER = {
"VK Poster": 50, "VK Poster": 50,
"MAX Poster": 51, "MAX Poster": 51,
"Site Poster": 52, "Site Poster": 52,
"Publishing": 53,
"Daily Report": 55, "Daily Report": 55,
"Parser": 60, "Parser": 60,
"VK": 70, "VK": 70,
@@ -252,6 +271,7 @@ SETTING_ORDER = {
"tg_poster_recent_window", "tg_poster_recent_window",
"tg_poster_category_repeat_penalty", "tg_poster_category_repeat_penalty",
"tg_poster_source_repeat_penalty", "tg_poster_source_repeat_penalty",
"tg_poster_footer_text",
], ],
"TG Reactor": [ "TG Reactor": [
"tg_reactor_bot_tokens", "tg_reactor_bot_tokens",
@@ -282,6 +302,10 @@ SETTING_ORDER = {
"vk_poster_category_repeat_penalty", "vk_poster_category_repeat_penalty",
"vk_poster_source_repeat_penalty", "vk_poster_source_repeat_penalty",
"vk_poster_dry_run", "vk_poster_dry_run",
"vk_poster_footer_text",
],
"Publishing": [
"publication_common_tags",
], ],
"MAX Poster": [ "MAX Poster": [
"max_poster_bot_token", "max_poster_bot_token",
@@ -475,6 +499,7 @@ def base_context(request: Request, user: dict | None, **extra: Any) -> dict[str,
"app_env": settings.app_env, "app_env": settings.app_env,
"app_title": settings.display_admin_title, "app_title": settings.display_admin_title,
"site_title": settings.display_site_title, "site_title": settings.display_site_title,
"favicon_version": favicon_version(),
"site_poster_provider_options": SITE_POSTER_PROVIDER_OPTIONS, "site_poster_provider_options": SITE_POSTER_PROVIDER_OPTIONS,
} }
ctx.update(extra) ctx.update(extra)
@@ -3888,6 +3913,20 @@ async def settings_save(request: Request, csrf_token: str = Form(...), key: str
return redirect("/workers") return redirect("/workers")
@app.post("/branding/favicon")
async def branding_favicon_save(request: Request, csrf_token: str = Form(...), favicon_file: UploadFile = File(...)):
user = await get_current_user(request)
if not user:
return redirect("/login")
require_csrf(user, csrf_token)
content = await favicon_file.read()
if not content.startswith(b"\x89PNG\r\n\x1a\n") or len(content) > 1024 * 1024:
return redirect("/workers")
(BRANDING_DIR / "favicon.png").write_bytes(content)
await audit(user["id"], "branding.favicon_update", "branding", None, {"filename": favicon_file.filename})
return redirect("/workers")
@app.get("/logs", response_class=HTMLResponse) @app.get("/logs", response_class=HTMLResponse)
async def logs_page( async def logs_page(
request: Request, request: Request,
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="/favi.png"> <link rel="icon" type="image/png" href="/favi.png?v={{ favicon_version or 0 }}">
<title>{{ title or site_title or "Редакторская" }}</title> <title>{{ title or site_title or "Редакторская" }}</title>
<!-- Fonts: Fira Sans (UI) & Fira Code (Data) --> <!-- Fonts: Fira Sans (UI) & Fira Code (Data) -->
@@ -31,6 +31,12 @@
<i data-lucide="send" class="w-4 h-4"></i> Сохранить и Опубликовать <i data-lucide="send" class="w-4 h-4"></i> Сохранить и Опубликовать
</button> </button>
</div> </div>
{% elif p.editorial_status in ['accepted', 'publish_failed'] %}
<div class="flex gap-2">
<button type="submit" name="action" value="accept" class="btn btn-primary btn-sm">
<i data-lucide="save" class="w-4 h-4"></i> Сохранить правки
</button>
</div>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
+48 -5
View File
@@ -64,7 +64,7 @@
<div class="grid grid-cols-1 xl:grid-cols-2 gap-8 mb-8"> <div class="grid grid-cols-1 xl:grid-cols-2 gap-8 mb-8">
<!-- Categories --> <!-- Categories -->
<details class="card group/details" open> <details class="card group/details" data-workers-details="categories" open>
<summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none"> <summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none">
<div class="flex items-center gap-2 font-bold text-lg text-white"> <div class="flex items-center gap-2 font-bold text-lg text-white">
<i data-lucide="tags" class="w-5 h-5 text-app-primary"></i> Категории публикаций <i data-lucide="tags" class="w-5 h-5 text-app-primary"></i> Категории публикаций
@@ -145,7 +145,7 @@
<div class="flex flex-col gap-8"> <div class="flex flex-col gap-8">
<!-- TG Schedule --> <!-- TG Schedule -->
<details class="card group/details" open> <details class="card group/details" data-workers-details="tg-schedule" open>
<summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none"> <summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none">
<div class="flex items-center gap-2 font-bold text-lg text-white"> <div class="flex items-center gap-2 font-bold text-lg text-white">
<i data-lucide="send" class="w-5 h-5 text-[#0088cc]"></i> Расписание TG-постера <i data-lucide="send" class="w-5 h-5 text-[#0088cc]"></i> Расписание TG-постера
@@ -201,7 +201,7 @@
</details> </details>
<!-- VK Schedule --> <!-- VK Schedule -->
<details class="card group/details" open> <details class="card group/details" data-workers-details="vk-schedule" open>
<summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none"> <summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none">
<div class="flex items-center gap-2 font-bold text-lg text-white"> <div class="flex items-center gap-2 font-bold text-lg text-white">
<i data-lucide="layout-template" class="w-5 h-5 text-[#4680C2]"></i> Расписание VK-постера <i data-lucide="layout-template" class="w-5 h-5 text-[#4680C2]"></i> Расписание VK-постера
@@ -259,6 +259,31 @@
</div> </div>
</div> </div>
<details class="card group/details mb-8" data-workers-details="branding">
<summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none">
<div class="flex items-center gap-2 font-bold text-lg text-white">
<i data-lucide="image" class="w-5 h-5 text-app-primary"></i> Брендинг проекта
</div>
<i data-lucide="chevron-down" class="w-5 h-5 text-app-textMuted transition-transform group-open/details:rotate-180"></i>
</summary>
<div class="p-4 bg-app-bg/30">
<form method="post" action="/branding/favicon" enctype="multipart/form-data" class="flex flex-col md:flex-row md:items-end gap-4">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<div class="w-16 h-16 rounded-lg bg-app-bg border border-app-border flex items-center justify-center overflow-hidden">
<img src="/favi.png?v={{ favicon_version or 0 }}" alt="" class="w-full h-full object-contain">
</div>
<div class="flex-1">
<label class="block text-[10px] uppercase font-bold text-app-textMuted mb-1">Фавикон проекта</label>
<input type="file" name="favicon_file" accept="image/png" class="block w-full text-xs text-app-textMuted file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:text-xs file:font-semibold file:bg-app-border file:text-white hover:file:bg-app-borderFocus cursor-pointer border border-app-border bg-app-bg rounded-md" required>
<div class="text-xs text-app-textMuted mt-2">PNG до 1 МБ. Хранится отдельно для этого проекта в uploads/branding/favicon.png.</div>
</div>
<button class="btn btn-primary btn-sm" type="submit">
<i data-lucide="upload" class="w-4 h-4"></i> Загрузить
</button>
</form>
</div>
</details>
<div class="flex items-center gap-4 mb-6 mt-12"> <div class="flex items-center gap-4 mb-6 mt-12">
<div class="h-px bg-app-border flex-1"></div> <div class="h-px bg-app-border flex-1"></div>
<h2 class="text-2xl font-bold text-white flex items-center gap-3"> <h2 class="text-2xl font-bold text-white flex items-center gap-3">
@@ -269,7 +294,7 @@
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
{% for category, rows in settings|groupby("category") %} {% for category, rows in settings|groupby("category") %}
<details class="card group/details" {% if category == "AI Qualifier" or loop.first %}open{% endif %}> <details class="card group/details" data-workers-details="settings:{{ category }}">
<summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none"> <summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none">
<div class="text-lg font-bold text-white">{{ category_titles.get(category, category) }}</div> <div class="text-lg font-bold text-white">{{ category_titles.get(category, category) }}</div>
<i data-lucide="chevron-down" class="w-5 h-5 text-app-textMuted transition-transform group-open/details:rotate-180"></i> <i data-lucide="chevron-down" class="w-5 h-5 text-app-textMuted transition-transform group-open/details:rotate-180"></i>
@@ -377,16 +402,34 @@
<script> <script>
(() => { (() => {
const scrollKey = "workers-scroll-y"; const scrollKey = "workers-scroll-y";
const detailsKey = "workers-open-details";
const detailItems = Array.from(document.querySelectorAll("details[data-workers-details]"));
const savedDetails = sessionStorage.getItem(detailsKey);
if (savedDetails) {
try {
const openIds = new Set(JSON.parse(savedDetails));
detailItems.forEach((details) => {
details.open = openIds.has(details.dataset.workersDetails);
});
} catch {
sessionStorage.removeItem(detailsKey);
}
}
const restoreY = sessionStorage.getItem(scrollKey); const restoreY = sessionStorage.getItem(scrollKey);
if (restoreY !== null) { if (restoreY !== null) {
sessionStorage.removeItem(scrollKey); sessionStorage.removeItem(scrollKey);
requestAnimationFrame(() => window.scrollTo(0, Number(restoreY) || 0)); requestAnimationFrame(() => window.scrollTo(0, Number(restoreY) || 0));
} }
const saveDetails = () => {
sessionStorage.setItem(detailsKey, JSON.stringify(detailItems.filter((details) => details.open).map((details) => details.dataset.workersDetails)));
};
detailItems.forEach((details) => details.addEventListener("toggle", saveDetails));
document.addEventListener("submit", (event) => { document.addEventListener("submit", (event) => {
const form = event.target; const form = event.target;
if (!(form instanceof HTMLFormElement)) return; if (!(form instanceof HTMLFormElement)) return;
const action = form.getAttribute("action") || ""; const action = form.getAttribute("action") || "";
if (action.startsWith("/workers") || action.startsWith("/settings") || action.includes("-schedule/")) { if (action.startsWith("/workers") || action.startsWith("/settings") || action.startsWith("/branding") || action.includes("-schedule/")) {
saveDetails();
sessionStorage.setItem(scrollKey, String(window.scrollY)); sessionStorage.setItem(scrollKey, String(window.scrollY));
} }
}, true); }, true);
+6 -5
View File
@@ -55,6 +55,7 @@ def build_publication_text(
category_tag: str, category_tag: str,
source_tag: str, source_tag: str,
common_tags: object = "", common_tags: object = "",
footer_text: str = "",
format_title: bool = False, format_title: bool = False,
parse_mode: str | None = None, parse_mode: str | None = None,
) -> str: ) -> str:
@@ -75,8 +76,10 @@ def build_publication_text(
title_idx = idx title_idx = idx
break break
footer = str(footer_text or "").strip()
hashtags = publication_hashtags(category_tag, source_tag, common_tags)
if title_idx is None: if title_idx is None:
return publication_hashtags(category_tag, source_tag, common_tags) return "\n\n".join(part for part in [footer, hashtags] if part)
formatted_lines = [] formatted_lines = []
for idx, line in enumerate(cleaned_lines): for idx, line in enumerate(cleaned_lines):
@@ -101,10 +104,8 @@ def build_publication_text(
raw_body = "\n".join(formatted_lines) raw_body = "\n".join(formatted_lines)
# Collapse multiple consecutive blank lines to at most two newlines (\n\n) # Collapse multiple consecutive blank lines to at most two newlines (\n\n)
normalized_body = re.sub(r"\n{3,}", "\n\n", raw_body).strip() normalized_body = re.sub(r"\n{3,}", "\n\n", raw_body).strip()
hashtags = publication_hashtags(category_tag, source_tag, common_tags) parts = [part for part in [normalized_body, footer, hashtags] if part]
if hashtags: return "\n\n".join(parts)
return f"{normalized_body}\n\n{hashtags}" if normalized_body else hashtags
return normalized_body
def parse_categories(value: object) -> list[str]: def parse_categories(value: object) -> list[str]:
+3
View File
@@ -112,6 +112,7 @@ class TelegramPoster:
self.category_repeat_penalty = 3.0 self.category_repeat_penalty = 3.0
self.source_repeat_penalty = 4.0 self.source_repeat_penalty = 4.0
self.common_tags = "" self.common_tags = ""
self.footer_text = ""
async def init(self) -> None: async def init(self) -> None:
self.pool = await get_pool() self.pool = await get_pool()
@@ -139,6 +140,7 @@ class TelegramPoster:
self.category_repeat_penalty = max(0.0, await fetch_float_setting("tg_poster_category_repeat_penalty", 3.0)) self.category_repeat_penalty = max(0.0, await fetch_float_setting("tg_poster_category_repeat_penalty", 3.0))
self.source_repeat_penalty = max(0.0, await fetch_float_setting("tg_poster_source_repeat_penalty", 4.0)) self.source_repeat_penalty = max(0.0, await fetch_float_setting("tg_poster_source_repeat_penalty", 4.0))
self.common_tags = str(await fetch_setting("publication_common_tags", "") or "").strip() self.common_tags = str(await fetch_setting("publication_common_tags", "") or "").strip()
self.footer_text = str(await fetch_setting("tg_poster_footer_text", "") or "").strip()
logger.info("TG poster config: chat={} schedule={} caption_limit={}", self.chat_id, self.schedule, self.caption_limit) logger.info("TG poster config: chat={} schedule={} caption_limit={}", self.chat_id, self.schedule, self.caption_limit)
async def close(self) -> None: async def close(self) -> None:
@@ -332,6 +334,7 @@ class TelegramPoster:
category_tag, category_tag,
source_tag, source_tag,
self.common_tags, self.common_tags,
self.footer_text,
format_title=True, format_title=True,
parse_mode="html", parse_mode="html",
) )
+3
View File
@@ -73,6 +73,7 @@ class VKPoster:
self.source_repeat_penalty = 4.0 self.source_repeat_penalty = 4.0
self.dry_run = False self.dry_run = False
self.common_tags = "" self.common_tags = ""
self.footer_text = ""
async def init(self) -> None: async def init(self) -> None:
self.pool = await get_pool() self.pool = await get_pool()
@@ -160,6 +161,7 @@ class VKPoster:
self.source_repeat_penalty = max(0.0, await fetch_float_setting("vk_poster_source_repeat_penalty", 4.0)) self.source_repeat_penalty = max(0.0, await fetch_float_setting("vk_poster_source_repeat_penalty", 4.0))
self.dry_run = await fetch_bool_setting("vk_poster_dry_run", False) self.dry_run = await fetch_bool_setting("vk_poster_dry_run", False)
self.common_tags = str(await fetch_setting("publication_common_tags", "") or "").strip() self.common_tags = str(await fetch_setting("publication_common_tags", "") or "").strip()
self.footer_text = str(await fetch_setting("vk_poster_footer_text", "") or "").strip()
if not self.token: if not self.token:
raise RuntimeError("vk_poster_access_token, VK_GROUP_ACCESS_TOKEN and VK_ACCESS_TOKEN are empty") raise RuntimeError("vk_poster_access_token, VK_GROUP_ACCESS_TOKEN and VK_ACCESS_TOKEN are empty")
if not self.owner_id: if not self.owner_id:
@@ -299,6 +301,7 @@ class VKPoster:
category_tag, category_tag,
source_tag, source_tag,
self.common_tags, self.common_tags,
self.footer_text,
)[:VK_MESSAGE_LIMIT] )[:VK_MESSAGE_LIMIT]
async def media_attachment(self, client: VKAPIClient, item: dict[str, Any]) -> str | None: async def media_attachment(self, client: VKAPIClient, item: dict[str, Any]) -> str | None: