diff --git a/.env.example b/.env.example index 571ed14..065b990 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/MULTI_PROJECT.md b/MULTI_PROJECT.md index 743d2f1..c831f07 100644 --- a/MULTI_PROJECT.md +++ b/MULTI_PROJECT.md @@ -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` diff --git a/db/migrations/042_platform_footer_text.sql b/db/migrations/042_platform_footer_text.sql new file mode 100644 index 0000000..fe5c41b --- /dev/null +++ b/db/migrations/042_platform_footer_text.sql @@ -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: Текст.', + 'TG Poster' + ) +ON CONFLICT (key) DO NOTHING; diff --git a/src/vk_parser_app/admin.py b/src/vk_parser_app/admin.py index 9f2e5fc..5f88186 100644 --- a/src/vk_parser_app/admin.py +++ b/src/vk_parser_app/admin.py @@ -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, diff --git a/src/vk_parser_app/templates/base.html b/src/vk_parser_app/templates/base.html index 2d2646c..8933db4 100644 --- a/src/vk_parser_app/templates/base.html +++ b/src/vk_parser_app/templates/base.html @@ -3,7 +3,7 @@ - + {{ title or site_title or "Редакторская" }} diff --git a/src/vk_parser_app/templates/editor_post_actions.html b/src/vk_parser_app/templates/editor_post_actions.html index dda31bb..19d44bf 100644 --- a/src/vk_parser_app/templates/editor_post_actions.html +++ b/src/vk_parser_app/templates/editor_post_actions.html @@ -31,6 +31,12 @@ Сохранить и Опубликовать + {% elif p.editorial_status in ['accepted', 'publish_failed'] %} +
+ +
{% endif %} {% endif %} diff --git a/src/vk_parser_app/templates/workers.html b/src/vk_parser_app/templates/workers.html index d45ba29..93c0952 100644 --- a/src/vk_parser_app/templates/workers.html +++ b/src/vk_parser_app/templates/workers.html @@ -64,7 +64,7 @@
-
+
Категории публикаций @@ -145,7 +145,7 @@
-
+
Расписание TG-постера @@ -201,7 +201,7 @@
-
+
Расписание VK-постера @@ -259,6 +259,31 @@
+
+ +
+ Брендинг проекта +
+ +
+
+
+ +
+ +
+
+ + +
PNG до 1 МБ. Хранится отдельно для этого проекта в uploads/branding/favicon.png.
+
+ +
+
+
+

@@ -269,7 +294,7 @@
{% for category, rows in settings|groupby("category") %} -
+
{{ category_titles.get(category, category) }}
@@ -377,16 +402,34 @@