Add project branding and poster footers
This commit is contained in:
@@ -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
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user