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
ADMIN_SITE_TITLE=Редакторская
ADMIN_APP_TITLE=Редакторская
# Optional fallback favicon path; uploaded per-project favicon takes precedence
ADMIN_FAVICON_PATH=
ADMIN_BOOTSTRAP_LOGIN=admin
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_APP_TITLE`
- `ADMIN_FAVICON_PATH`
- `ADMIN_FAVICON_PATH` as an optional fallback only
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
- `APP_SECRET_KEY`
- `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`.
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:
- 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"
BASE_DIR = Path(__file__).resolve().parent
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"))
app = FastAPI(title=settings.display_site_title)
LOCAL_TZ = ZoneInfo("Asia/Yekaterinburg")
MODEL_CACHE: dict[str, Any] = {"key": "", "at": 0.0, "models": []}
UPLOAD_ROOT = Path("uploads").resolve()
BRANDING_DIR = UPLOAD_ROOT / "branding"
EDITOR_MEDIA_DIR = UPLOAD_ROOT / "editor_media"
BRANDING_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")
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:
configured = settings.vk_oauth_origin.strip().rstrip("/")
if configured:
@@ -72,12 +89,12 @@ def oauth_redirect_uri(request: Request, path: str) -> str:
@app.get("/favi.png")
async def favicon_png():
return FileResponse(FAVICON_PATH)
return FileResponse(favicon_path())
@app.get("/favicon.ico")
async def favicon_ico():
return FileResponse(FAVICON_PATH, media_type="image/png")
return FileResponse(favicon_path(), media_type="image/png")
PROVIDER_OPTIONS = [
{"value": "openrouter", "label": "OpenRouter"},
@@ -176,6 +193,7 @@ CATEGORY_TITLES = {
"TG Reactor": "TG-реактор",
"VK Poster": "VK-постер",
"MAX Poster": "MAX-постер",
"Publishing": "Публикации",
"Daily Report": "Ежедневный отчет",
"Parser": "Парсер",
"Uploader": "Аплоадер",
@@ -191,6 +209,7 @@ CATEGORY_ORDER = {
"VK Poster": 50,
"MAX Poster": 51,
"Site Poster": 52,
"Publishing": 53,
"Daily Report": 55,
"Parser": 60,
"VK": 70,
@@ -252,6 +271,7 @@ SETTING_ORDER = {
"tg_poster_recent_window",
"tg_poster_category_repeat_penalty",
"tg_poster_source_repeat_penalty",
"tg_poster_footer_text",
],
"TG Reactor": [
"tg_reactor_bot_tokens",
@@ -282,6 +302,10 @@ SETTING_ORDER = {
"vk_poster_category_repeat_penalty",
"vk_poster_source_repeat_penalty",
"vk_poster_dry_run",
"vk_poster_footer_text",
],
"Publishing": [
"publication_common_tags",
],
"MAX Poster": [
"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_title": settings.display_admin_title,
"site_title": settings.display_site_title,
"favicon_version": favicon_version(),
"site_poster_provider_options": SITE_POSTER_PROVIDER_OPTIONS,
}
ctx.update(extra)
@@ -3888,6 +3913,20 @@ async def settings_save(request: Request, csrf_token: str = Form(...), key: str
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)
async def logs_page(
request: Request,
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<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>
<!-- Fonts: Fira Sans (UI) & Fira Code (Data) -->
@@ -31,6 +31,12 @@
<i data-lucide="send" class="w-4 h-4"></i> Сохранить и Опубликовать
</button>
</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 %}
</div>
{% endif %}
+48 -5
View File
@@ -64,7 +64,7 @@
<div class="grid grid-cols-1 xl:grid-cols-2 gap-8 mb-8">
<!-- 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">
<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> Категории публикаций
@@ -145,7 +145,7 @@
<div class="flex flex-col gap-8">
<!-- 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">
<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-постера
@@ -201,7 +201,7 @@
</details>
<!-- 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">
<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-постера
@@ -259,6 +259,31 @@
</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="h-px bg-app-border flex-1"></div>
<h2 class="text-2xl font-bold text-white flex items-center gap-3">
@@ -269,7 +294,7 @@
<div class="flex flex-col gap-6">
{% 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">
<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>
@@ -377,16 +402,34 @@
<script>
(() => {
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);
if (restoreY !== null) {
sessionStorage.removeItem(scrollKey);
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) => {
const form = event.target;
if (!(form instanceof HTMLFormElement)) return;
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));
}
}, true);
+6 -5
View File
@@ -55,6 +55,7 @@ def build_publication_text(
category_tag: str,
source_tag: str,
common_tags: object = "",
footer_text: str = "",
format_title: bool = False,
parse_mode: str | None = None,
) -> str:
@@ -75,8 +76,10 @@ def build_publication_text(
title_idx = idx
break
footer = str(footer_text or "").strip()
hashtags = publication_hashtags(category_tag, source_tag, common_tags)
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 = []
for idx, line in enumerate(cleaned_lines):
@@ -101,10 +104,8 @@ def build_publication_text(
raw_body = "\n".join(formatted_lines)
# Collapse multiple consecutive blank lines to at most two newlines (\n\n)
normalized_body = re.sub(r"\n{3,}", "\n\n", raw_body).strip()
hashtags = publication_hashtags(category_tag, source_tag, common_tags)
if hashtags:
return f"{normalized_body}\n\n{hashtags}" if normalized_body else hashtags
return normalized_body
parts = [part for part in [normalized_body, footer, hashtags] if part]
return "\n\n".join(parts)
def parse_categories(value: object) -> list[str]:
+3
View File
@@ -112,6 +112,7 @@ class TelegramPoster:
self.category_repeat_penalty = 3.0
self.source_repeat_penalty = 4.0
self.common_tags = ""
self.footer_text = ""
async def init(self) -> None:
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.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.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)
async def close(self) -> None:
@@ -332,6 +334,7 @@ class TelegramPoster:
category_tag,
source_tag,
self.common_tags,
self.footer_text,
format_title=True,
parse_mode="html",
)
+3
View File
@@ -73,6 +73,7 @@ class VKPoster:
self.source_repeat_penalty = 4.0
self.dry_run = False
self.common_tags = ""
self.footer_text = ""
async def init(self) -> None:
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.dry_run = await fetch_bool_setting("vk_poster_dry_run", False)
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:
raise RuntimeError("vk_poster_access_token, VK_GROUP_ACCESS_TOKEN and VK_ACCESS_TOKEN are empty")
if not self.owner_id:
@@ -299,6 +301,7 @@ class VKPoster:
category_tag,
source_tag,
self.common_tags,
self.footer_text,
)[:VK_MESSAGE_LIMIT]
async def media_attachment(self, client: VKAPIClient, item: dict[str, Any]) -> str | None: