1341 lines
53 KiB
Python
1341 lines
53 KiB
Python
from pathlib import Path
|
||
import json
|
||
import os
|
||
import re
|
||
import threading
|
||
import uuid
|
||
|
||
import bcrypt
|
||
from fastapi import Depends, FastAPI, Form, HTTPException, Query, Request
|
||
from fastapi.responses import JSONResponse, RedirectResponse, Response
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.templating import Jinja2Templates
|
||
from starlette.middleware.sessions import SessionMiddleware
|
||
|
||
from ai_core.image_generator import IMAGE_SIZES, IMAGE_TEMPLATES, STYLE_PRESETS, ImageGenerator, detect_ext, get_sizes_for_model
|
||
from ai_core.neural_network import ContentGenerator
|
||
from ai_core.social_analyzer import analyze_posts_with_ai, detect_platform, fetch_telegram_posts
|
||
from ai_core.telegram_publisher import (
|
||
check_bot_is_admin,
|
||
publish_post,
|
||
resolve_chat,
|
||
)
|
||
from cabinet_service.brand_doc_generator import generate_brand_docx
|
||
from config import SECRET_KEY
|
||
from database import (
|
||
add_post_revision,
|
||
create_task,
|
||
create_web_user,
|
||
delete_brand_survey,
|
||
delete_channel,
|
||
delete_media_by_id,
|
||
delete_plan_item,
|
||
delete_post_by_id,
|
||
delete_post_image,
|
||
delete_user_by_id,
|
||
get_brand_survey_by_id,
|
||
get_brand_surveys_for_user,
|
||
get_channel_by_id,
|
||
get_channels_for_user,
|
||
get_image_provider_settings,
|
||
get_latest_brand_survey_for_user,
|
||
get_media_by_id_for_user,
|
||
get_media_for_user,
|
||
get_plan_for_user,
|
||
get_plan_stats_for_user,
|
||
get_post_by_id_for_user,
|
||
get_post_images,
|
||
get_post_revisions,
|
||
get_posts_for_user,
|
||
get_provider_settings,
|
||
get_stats_for_user,
|
||
get_task,
|
||
get_text_provider_settings,
|
||
get_user_by_email,
|
||
get_user_by_id,
|
||
is_channel_already_added,
|
||
mark_post_published,
|
||
PROVIDER_PRESETS,
|
||
replace_plan_for_user,
|
||
save_brand_survey,
|
||
save_channel,
|
||
save_media,
|
||
save_post_for_user,
|
||
save_post_image,
|
||
save_provider_settings,
|
||
update_plan_status,
|
||
update_post_content,
|
||
update_task_status,
|
||
update_user_field_by_id,
|
||
)
|
||
|
||
BASE_DIR = Path(__file__).resolve().parent
|
||
MEDIA_DIR = BASE_DIR / "media"
|
||
MEDIA_DIR.mkdir(exist_ok=True)
|
||
|
||
app = FastAPI(title="Личный кабинет")
|
||
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
|
||
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||
app.mount("/media", StaticFiles(directory=str(MEDIA_DIR)), name="media")
|
||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||
|
||
content_gen = ContentGenerator()
|
||
image_gen = ImageGenerator()
|
||
|
||
|
||
def get_user_content_gen(user: dict) -> ContentGenerator:
|
||
"""Возвращает ContentGenerator с настройками text-провайдера пользователя."""
|
||
settings = get_text_provider_settings(user["id"])
|
||
if settings and settings.get("api_key"):
|
||
return ContentGenerator(provider_settings=settings)
|
||
return content_gen
|
||
|
||
|
||
def get_user_image_gen(user: dict) -> ImageGenerator:
|
||
"""Возвращает ImageGenerator с настройками image-провайдера пользователя."""
|
||
settings = get_image_provider_settings(user["id"])
|
||
if settings and settings.get("api_key"):
|
||
return ImageGenerator(provider_settings=settings)
|
||
return image_gen
|
||
|
||
PLATFORMS = [
|
||
("instagram", "Instagram"),
|
||
("linkedin", "LinkedIn"),
|
||
("telegram", "Telegram"),
|
||
("twitter", "Twitter/X"),
|
||
("ad", "Рекламный пост"),
|
||
]
|
||
|
||
PLATFORM_LABELS = dict(PLATFORMS)
|
||
|
||
def platform_label(platform: str) -> str:
|
||
return PLATFORM_LABELS.get(platform, platform)
|
||
|
||
|
||
class NotAuthenticated(Exception):
|
||
pass
|
||
|
||
|
||
@app.exception_handler(NotAuthenticated)
|
||
async def not_authenticated_handler(request: Request, exc: NotAuthenticated):
|
||
return RedirectResponse(url="/login", status_code=303)
|
||
|
||
|
||
def current_user(request: Request) -> dict:
|
||
user_id = request.session.get("user_id")
|
||
if not user_id:
|
||
raise NotAuthenticated()
|
||
user = get_user_by_id(user_id)
|
||
if not user:
|
||
request.session.clear()
|
||
raise NotAuthenticated()
|
||
return user
|
||
|
||
|
||
def flash(request: Request, message: str):
|
||
request.session["flash"] = message
|
||
|
||
|
||
def render(request: Request, template: str, status_code: int = 200, **context):
|
||
context.setdefault("user", None)
|
||
context["flash_message"] = request.session.pop("flash", None)
|
||
return templates.TemplateResponse(request, template, context, status_code=status_code)
|
||
|
||
|
||
def business_name_of(user: dict) -> str:
|
||
"""Возвращает название вида бизнеса. Оставлено для совместимости (профили без медиа-образа)."""
|
||
from keyboards.reply_keyboards import BUSINESS_TYPES
|
||
return BUSINESS_TYPES.get(str(user.get("business_type")), {}).get("name", "Не указан")
|
||
|
||
|
||
@app.get("/health")
|
||
def health():
|
||
return {"status": "ok"}
|
||
|
||
|
||
@app.get("/")
|
||
def root(request: Request):
|
||
# Посадочная страница с описанием сервиса
|
||
return render(request, "landing.html")
|
||
|
||
|
||
@app.get("/presentation-tz")
|
||
def presentation_tz(request: Request):
|
||
# Техническое задание на создание презентации сервиса
|
||
return render(request, "presentation_tz.html")
|
||
|
||
|
||
# === РЕГИСТРАЦИЯ ===
|
||
|
||
@app.get("/register")
|
||
def register_form(request: Request):
|
||
if request.session.get("user_id"):
|
||
return RedirectResponse("/dashboard", status_code=303)
|
||
return render(request, "register.html")
|
||
|
||
|
||
@app.post("/register")
|
||
def register_submit(
|
||
request: Request,
|
||
name: str = Form(...),
|
||
phone: str = Form(...),
|
||
email: str = Form(...),
|
||
password: str = Form(...),
|
||
password_confirm: str = Form(...),
|
||
):
|
||
email = email.strip().lower()
|
||
errors = []
|
||
if len(password) < 8:
|
||
errors.append("Пароль должен быть не короче 8 символов.")
|
||
if password != password_confirm:
|
||
errors.append("Пароли не совпадают.")
|
||
if "@" not in email or "." not in email.split("@")[-1]:
|
||
errors.append("Введи корректный email.")
|
||
if not errors and get_user_by_email(email):
|
||
errors.append("Пользователь с таким email уже зарегистрирован.")
|
||
|
||
if errors:
|
||
return render(
|
||
request, "register.html", status_code=400,
|
||
errors=errors,
|
||
form={"name": name, "phone": phone, "email": email},
|
||
)
|
||
|
||
password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||
user_id = create_web_user(email, password_hash, name, phone)
|
||
request.session["user_id"] = user_id
|
||
flash(request, "Регистрация завершена! Добро пожаловать.")
|
||
return RedirectResponse("/dashboard", status_code=303)
|
||
|
||
|
||
# === ВХОД / ВЫХОД ===
|
||
|
||
@app.get("/login")
|
||
def login_form(request: Request):
|
||
if request.session.get("user_id"):
|
||
return RedirectResponse("/dashboard", status_code=303)
|
||
return render(request, "login.html")
|
||
|
||
|
||
@app.post("/login")
|
||
def login_submit(request: Request, email: str = Form(...), password: str = Form(...)):
|
||
email = email.strip().lower()
|
||
user = get_user_by_email(email)
|
||
valid = (
|
||
user is not None
|
||
and user["password_hash"]
|
||
and bcrypt.checkpw(password.encode("utf-8"), user["password_hash"].encode("utf-8"))
|
||
)
|
||
if not valid:
|
||
return render(
|
||
request, "login.html", status_code=400,
|
||
errors=["Неверный email или пароль."], form={"email": email},
|
||
)
|
||
|
||
request.session["user_id"] = user["id"]
|
||
return RedirectResponse("/dashboard", status_code=303)
|
||
|
||
|
||
@app.post("/logout")
|
||
def logout(request: Request):
|
||
request.session.clear()
|
||
return RedirectResponse("/login", status_code=303)
|
||
|
||
|
||
# === КАБИНЕТ ===
|
||
|
||
@app.get("/dashboard")
|
||
def dashboard(request: Request, user: dict = Depends(current_user)):
|
||
latest_survey = get_latest_brand_survey_for_user(user["id"])
|
||
return render(request, "dashboard.html", user=user, latest_survey=latest_survey)
|
||
|
||
|
||
@app.get("/profile/edit")
|
||
def profile_edit_form(request: Request, user: dict = Depends(current_user)):
|
||
provider = get_provider_settings(user["id"])
|
||
return render(request, "profile_edit.html", user=user,
|
||
provider=provider, provider_presets=PROVIDER_PRESETS)
|
||
|
||
|
||
@app.post("/profile/edit")
|
||
def profile_edit_submit(
|
||
request: Request,
|
||
name: str = Form(...),
|
||
phone: str = Form(...),
|
||
user: dict = Depends(current_user),
|
||
):
|
||
update_user_field_by_id(user["id"], "name", name)
|
||
update_user_field_by_id(user["id"], "phone", phone)
|
||
flash(request, "Профиль обновлён.")
|
||
return RedirectResponse("/dashboard", status_code=303)
|
||
|
||
|
||
# === НАСТРОЙКИ ПРОВАЙДЕРА ИИ ===
|
||
|
||
@app.get("/provider")
|
||
def provider_form(request: Request, user: dict = Depends(current_user)):
|
||
provider = get_provider_settings(user["id"])
|
||
return render(request, "provider.html", user=user,
|
||
provider=provider, provider_presets=PROVIDER_PRESETS)
|
||
|
||
|
||
@app.post("/provider")
|
||
def provider_submit(
|
||
request: Request,
|
||
# Text provider
|
||
text_provider_type: str = Form(...),
|
||
text_api_key: str = Form(""),
|
||
text_base_url: str = Form(""),
|
||
text_model: str = Form(""),
|
||
text_use_global: str = Form(""),
|
||
# Image provider
|
||
image_provider_type: str = Form(...),
|
||
image_api_key: str = Form(""),
|
||
image_base_url: str = Form(""),
|
||
image_model: str = Form(""),
|
||
image_use_global: str = Form(""),
|
||
user: dict = Depends(current_user),
|
||
):
|
||
errors = []
|
||
|
||
# Валидация text-провайдера
|
||
if text_provider_type not in PROVIDER_PRESETS:
|
||
errors.append("Выберите провайдера для текста.")
|
||
text_preset = PROVIDER_PRESETS.get(text_provider_type, {})
|
||
if not text_use_global and text_preset.get("needs_key") and not text_api_key.strip():
|
||
errors.append("Укажите API ключ для текстового провайдера или выберите «Использовать глобальные настройки».")
|
||
if not text_use_global and text_provider_type == "custom" and not text_base_url.strip():
|
||
errors.append("Для кастомного текстового провайдера укажите Base URL.")
|
||
|
||
# Валидация image-провайдера
|
||
if image_provider_type not in PROVIDER_PRESETS:
|
||
errors.append("Выберите провайдера для изображений.")
|
||
image_preset = PROVIDER_PRESETS.get(image_provider_type, {})
|
||
if not image_use_global and image_preset.get("needs_key") and not image_api_key.strip():
|
||
errors.append("Укажите API ключ для провайдера изображений или выберите «Использовать глобальные настройки».")
|
||
if not image_use_global and image_provider_type == "custom" and not image_base_url.strip():
|
||
errors.append("Для кастомного image-провайдера укажите Base URL.")
|
||
if not image_preset.get("supports_images", True) and not image_use_global:
|
||
errors.append(f"Провайдер «{image_preset.get('name')}» не поддерживает генерацию изображений. Выберите другой.")
|
||
|
||
if errors:
|
||
return render(
|
||
request, "provider.html", status_code=400,
|
||
user=user, provider_presets=PROVIDER_PRESETS, errors=errors,
|
||
form={
|
||
"text_provider_type": text_provider_type, "text_api_key": text_api_key,
|
||
"text_base_url": text_base_url, "text_model": text_model,
|
||
"image_provider_type": image_provider_type, "image_api_key": image_api_key,
|
||
"image_base_url": image_base_url, "image_model": image_model,
|
||
},
|
||
)
|
||
|
||
# Если "использовать глобальные" — сохраняем пустой ключ (будет использован глобальный)
|
||
final_text_key = "" if text_use_global else text_api_key.strip()
|
||
final_image_key = "" if image_use_global else image_api_key.strip()
|
||
|
||
# Подставляем дефолтные значения из пресетов
|
||
final_text_base = text_base_url.strip() or text_preset.get("base_url", "")
|
||
final_text_model = text_model.strip() or text_preset.get("text_model", "")
|
||
final_image_base = image_base_url.strip() or image_preset.get("base_url", "")
|
||
final_image_model = image_model.strip() or image_preset.get("image_model", "")
|
||
|
||
save_provider_settings(
|
||
user["id"],
|
||
text_provider_type, final_text_key, final_text_base, final_text_model,
|
||
image_provider_type, final_image_key, final_image_base, final_image_model,
|
||
)
|
||
flash(request, "Провайдеры ИИ настроены: текст — «%s», изображения — «%s»." % (
|
||
text_preset.get("name", text_provider_type),
|
||
image_preset.get("name", image_provider_type),
|
||
))
|
||
return RedirectResponse("/provider", status_code=303)
|
||
|
||
|
||
@app.get("/generate")
|
||
def generate_form(request: Request, user: dict = Depends(current_user),
|
||
plan_id: str = Query(""), platform: str = Query(""),
|
||
idea: str = Query("")):
|
||
latest_survey = get_latest_brand_survey_for_user(user["id"])
|
||
surveys = get_brand_surveys_for_user(user["id"])
|
||
prefill = None
|
||
if plan_id:
|
||
prefill = {"plan_id": plan_id, "platform": platform, "idea": idea}
|
||
return render(request, "generate.html", user=user, platforms=PLATFORMS,
|
||
latest_survey=latest_survey, surveys=surveys, prefill=prefill)
|
||
|
||
|
||
@app.post("/generate")
|
||
def generate_submit(
|
||
request: Request,
|
||
platform: str = Form(...),
|
||
style: str = Form(...),
|
||
wishes: str = Form(""),
|
||
survey_id: str = Form(""),
|
||
user: dict = Depends(current_user),
|
||
):
|
||
# Выбираем опрос медиа-образа: явно указанный или последний
|
||
brand_data = None
|
||
if survey_id:
|
||
try:
|
||
sid = int(survey_id)
|
||
except ValueError:
|
||
sid = None
|
||
if sid:
|
||
survey = get_brand_survey_by_id(sid, user["id"])
|
||
if survey:
|
||
brand_data = survey["data"]
|
||
if not brand_data:
|
||
latest = get_latest_brand_survey_for_user(user["id"])
|
||
if latest:
|
||
brand_data = latest["data"]
|
||
|
||
if not brand_data:
|
||
# Нет медиа-образа — возвращаем форму с подсказкой
|
||
surveys = get_brand_surveys_for_user(user["id"])
|
||
return render(
|
||
request, "generate.html", status_code=400,
|
||
user=user, platforms=PLATFORMS, surveys=surveys,
|
||
latest_survey=None,
|
||
errors=["Сначала создайте медиа-образ — посты генерируются на его основе."],
|
||
)
|
||
|
||
user_gen = get_user_content_gen(user)
|
||
try:
|
||
content = user_gen.generate_post_from_brand_profile(brand_data, platform, style, wishes)
|
||
except Exception as e:
|
||
surveys = get_brand_surveys_for_user(user["id"])
|
||
latest_survey = get_latest_brand_survey_for_user(user["id"])
|
||
return render(
|
||
request, "generate.html", status_code=502,
|
||
user=user, platforms=PLATFORMS, surveys=surveys,
|
||
latest_survey=latest_survey,
|
||
errors=[f"Ошибка генерации: {e}. Проверьте настройки провайдера в разделе «ИИ»."],
|
||
form={"platform": platform, "style": style, "wishes": wishes},
|
||
)
|
||
save_post_for_user(user["id"], platform, content, style, wishes)
|
||
latest_survey = get_latest_brand_survey_for_user(user["id"])
|
||
return render(
|
||
request, "generate.html", user=user, platforms=PLATFORMS, result=content,
|
||
latest_survey=latest_survey,
|
||
form={"platform": platform, "style": style, "wishes": wishes},
|
||
)
|
||
|
||
@app.get("/history")
|
||
def history(request: Request, user: dict = Depends(current_user)):
|
||
posts = get_posts_for_user(user["id"], limit=50)
|
||
# Добавляем изображения к каждому посту
|
||
for p in posts:
|
||
p["images"] = get_post_images(p["id"], user["id"])
|
||
return render(request, "history.html", user=user, posts=posts, platform_label=platform_label)
|
||
|
||
|
||
@app.get("/posts/{post_id}")
|
||
def post_detail(request: Request, post_id: int, user: dict = Depends(current_user)):
|
||
post = get_post_by_id_for_user(post_id, user["id"])
|
||
if not post:
|
||
raise HTTPException(status_code=404, detail="Пост не найден")
|
||
images = get_post_images(post_id, user["id"])
|
||
revisions = get_post_revisions(post_id, user["id"])
|
||
return render(request, "post_detail.html", user=user, post=post,
|
||
images=images, revisions=revisions,
|
||
platform_label=platform_label, templates=IMAGE_TEMPLATES)
|
||
|
||
|
||
@app.get("/posts/{post_id}/edit")
|
||
def post_edit_form(request: Request, post_id: int, user: dict = Depends(current_user)):
|
||
post = get_post_by_id_for_user(post_id, user["id"])
|
||
if not post:
|
||
raise HTTPException(status_code=404, detail="Пост не найден")
|
||
return render(request, "post_edit.html", user=user, post=post, platform_label=platform_label)
|
||
|
||
|
||
@app.post("/posts/{post_id}/edit")
|
||
def post_edit_submit(
|
||
request: Request,
|
||
post_id: int,
|
||
content: str = Form(...),
|
||
user: dict = Depends(current_user),
|
||
):
|
||
post = get_post_by_id_for_user(post_id, user["id"])
|
||
if not post:
|
||
raise HTTPException(status_code=404, detail="Пост не найден")
|
||
content = content.strip()
|
||
if not content:
|
||
return render(
|
||
request, "post_edit.html", status_code=400,
|
||
user=user, post=post, platform_label=platform_label,
|
||
errors=["Текст поста не может быть пустым."],
|
||
)
|
||
update_post_content(user["id"], post_id, content)
|
||
add_post_revision(post_id, user["id"], content)
|
||
flash(request, "Пост обновлён.")
|
||
return RedirectResponse(f"/posts/{post_id}", status_code=303)
|
||
|
||
|
||
@app.post("/api/posts/{post_id}/edit-text")
|
||
def api_post_edit_text(
|
||
post_id: int,
|
||
content: str = Form(...),
|
||
user: dict = Depends(current_user),
|
||
):
|
||
"""AJAX-сохранение текста поста (inline-редактор). Сохраняет ревизию."""
|
||
post = get_post_by_id_for_user(post_id, user["id"])
|
||
if not post:
|
||
raise HTTPException(status_code=404, detail="Пост не найден")
|
||
content = content.strip()
|
||
if not content:
|
||
return JSONResponse({"error": "Текст не может быть пустым"}, status_code=400)
|
||
|
||
update_post_content(user["id"], post_id, content)
|
||
add_post_revision(post_id, user["id"], content)
|
||
|
||
return JSONResponse({"ok": True, "content": content})
|
||
|
||
|
||
@app.post("/posts/{post_id}/delete")
|
||
def post_delete(request: Request, post_id: int, user: dict = Depends(current_user)):
|
||
post = get_post_by_id_for_user(post_id, user["id"])
|
||
if not post:
|
||
raise HTTPException(status_code=404, detail="Пост не найден")
|
||
# Удаляем изображения поста
|
||
images = get_post_images(post_id, user["id"])
|
||
for img in images:
|
||
fp = MEDIA_DIR / img["filename"]
|
||
if fp.exists():
|
||
fp.unlink()
|
||
delete_post_by_id(user["id"], post_id)
|
||
flash(request, "Пост удалён.")
|
||
return RedirectResponse("/history", status_code=303)
|
||
|
||
|
||
# === ИЗОБРАЖЕНИЯ ПОСТА ===
|
||
|
||
@app.post("/posts/{post_id}/images/upload")
|
||
async def post_image_upload(request: Request, post_id: int, user: dict = Depends(current_user)):
|
||
post = get_post_by_id_for_user(post_id, user["id"])
|
||
if not post:
|
||
raise HTTPException(status_code=404, detail="Пост не найден")
|
||
|
||
form = await request.form()
|
||
files = form.getlist("files")
|
||
if not files:
|
||
flash(request, "Выберите файлы для загрузки.")
|
||
return RedirectResponse(f"/posts/{post_id}", status_code=303)
|
||
|
||
uploaded = 0
|
||
for upload in files:
|
||
if not upload or not upload.filename:
|
||
continue
|
||
raw = await upload.read()
|
||
if not raw:
|
||
continue
|
||
ext = detect_ext(raw)
|
||
filename = f"{uuid.uuid4().hex}{ext}"
|
||
(MEDIA_DIR / filename).write_bytes(raw)
|
||
save_post_image(post_id, user["id"], filename, source="upload")
|
||
uploaded += 1
|
||
|
||
if uploaded:
|
||
flash(request, f"Загружено изображений: {uploaded}")
|
||
else:
|
||
flash(request, "Не удалось загрузить изображения.")
|
||
return RedirectResponse(f"/posts/{post_id}", status_code=303)
|
||
|
||
|
||
@app.post("/posts/{post_id}/images/generate")
|
||
def post_image_generate(request: Request, post_id: int, user: dict = Depends(current_user)):
|
||
post = get_post_by_id_for_user(post_id, user["id"])
|
||
if not post:
|
||
raise HTTPException(status_code=404, detail="Пост не найден")
|
||
|
||
# Генерируем промпт из текста поста
|
||
user_img_gen = get_user_image_gen(user)
|
||
# Берём первые 500 символов поста как основу для промпта
|
||
post_text = (post["content"] or "")[:500]
|
||
prompt = f"Create a professional social media image for this post. Post text: {post_text}. Style: modern, clean, eye-catching."
|
||
|
||
try:
|
||
image_bytes = user_img_gen.generate(prompt, "square")
|
||
except Exception as e:
|
||
flash(request, f"Ошибка генерации изображения: {e}")
|
||
return RedirectResponse(f"/posts/{post_id}", status_code=303)
|
||
|
||
ext = detect_ext(image_bytes)
|
||
filename = f"{uuid.uuid4().hex}{ext}"
|
||
(MEDIA_DIR / filename).write_bytes(image_bytes)
|
||
save_post_image(post_id, user["id"], filename, source="generated")
|
||
flash(request, "Изображение сгенерировано.")
|
||
return RedirectResponse(f"/posts/{post_id}", status_code=303)
|
||
|
||
|
||
@app.post("/posts/{post_id}/images/{image_id}/delete")
|
||
def post_image_delete(request: Request, post_id: int, image_id: int, user: dict = Depends(current_user)):
|
||
post = get_post_by_id_for_user(post_id, user["id"])
|
||
if not post:
|
||
raise HTTPException(status_code=404, detail="Пост не найден")
|
||
filename = delete_post_image(image_id, user["id"])
|
||
if filename:
|
||
fp = MEDIA_DIR / filename
|
||
if fp.exists():
|
||
fp.unlink()
|
||
flash(request, "Изображение удалено.")
|
||
return RedirectResponse(f"/posts/{post_id}", status_code=303)
|
||
|
||
|
||
@app.get("/plan")
|
||
def plan_form(request: Request, user: dict = Depends(current_user)):
|
||
items = get_plan_for_user(user["id"])
|
||
status_counts = get_plan_stats_for_user(user["id"])
|
||
return render(
|
||
request, "plan.html",
|
||
user=user, plan=items, status_counts=status_counts, platform_label=platform_label,
|
||
)
|
||
|
||
|
||
@app.post("/plan")
|
||
def plan_submit(request: Request, days: int = Form(7), user: dict = Depends(current_user)):
|
||
days = max(1, min(days, 14))
|
||
user_gen = get_user_content_gen(user)
|
||
latest = get_latest_brand_survey_for_user(user["id"])
|
||
if latest:
|
||
plan = user_gen.generate_content_plan_from_brand_profile(latest["data"], days)
|
||
else:
|
||
# Fallback — простой шаблонный план без медиа-образа
|
||
plan = user_gen.generate_content_plan("бизнес", "не указано", days)
|
||
replace_plan_for_user(user["id"], plan)
|
||
flash(request, f"Контент-план на {days} дн. сохранён.")
|
||
return RedirectResponse("/plan", status_code=303)
|
||
|
||
|
||
@app.post("/plan/{item_id}/status")
|
||
def plan_status_submit(
|
||
request: Request,
|
||
item_id: int,
|
||
status: str = Form(...),
|
||
user: dict = Depends(current_user),
|
||
):
|
||
try:
|
||
update_plan_status(user["id"], item_id, status)
|
||
flash(request, "Статус пункта обновлён.")
|
||
except ValueError:
|
||
flash(request, "Недопустимый статус.")
|
||
return RedirectResponse("/plan", status_code=303)
|
||
|
||
|
||
@app.post("/plan/{item_id}/delete")
|
||
def plan_item_delete(request: Request, item_id: int, user: dict = Depends(current_user)):
|
||
delete_plan_item(user["id"], item_id)
|
||
flash(request, "Пункт плана удалён.")
|
||
return RedirectResponse("/plan", status_code=303)
|
||
|
||
|
||
@app.get("/stats")
|
||
def stats(request: Request, user: dict = Depends(current_user)):
|
||
return render(request, "stats.html", user=user, stats=get_stats_for_user(user["id"]))
|
||
|
||
|
||
@app.get("/delete-account")
|
||
def delete_confirm_form(request: Request, user: dict = Depends(current_user)):
|
||
return render(request, "delete_confirm.html", user=user)
|
||
|
||
|
||
@app.post("/delete-account")
|
||
def delete_account_submit(
|
||
request: Request, confirm: str = Form(""), user: dict = Depends(current_user)
|
||
):
|
||
if confirm != "yes":
|
||
return RedirectResponse("/dashboard", status_code=303)
|
||
delete_user_by_id(user["id"])
|
||
request.session.clear()
|
||
return RedirectResponse("/login", status_code=303)
|
||
|
||
|
||
# === ГЕНЕРАЦИЯ КАРТИНОК ===
|
||
|
||
@app.get("/images")
|
||
def images_gallery(request: Request, user: dict = Depends(current_user)):
|
||
items = get_media_for_user(user["id"])
|
||
return render(request, "images.html", user=user, images=items)
|
||
|
||
|
||
@app.get("/images/generate")
|
||
def image_generate_form(request: Request, user: dict = Depends(current_user)):
|
||
# Размеры зависят от модели image-провайдера
|
||
img_settings = get_image_provider_settings(user["id"])
|
||
if img_settings and img_settings.get("image_model"):
|
||
sizes = get_sizes_for_model(img_settings["image_model"])
|
||
else:
|
||
sizes = IMAGE_SIZES
|
||
return render(
|
||
request, "image_generate.html",
|
||
user=user, sizes=sizes, styles=STYLE_PRESETS, templates=IMAGE_TEMPLATES,
|
||
)
|
||
|
||
|
||
@app.post("/images/generate")
|
||
def image_generate_submit(
|
||
request: Request,
|
||
prompt: str = Form(...),
|
||
size: str = Form("square"),
|
||
style: str = Form("none"),
|
||
user: dict = Depends(current_user),
|
||
):
|
||
# Размеры зависят от модели
|
||
img_settings = get_image_provider_settings(user["id"])
|
||
if img_settings and img_settings.get("image_model"):
|
||
sizes = get_sizes_for_model(img_settings["image_model"])
|
||
else:
|
||
sizes = IMAGE_SIZES
|
||
|
||
prompt = prompt.strip()
|
||
if not prompt:
|
||
return render(
|
||
request, "image_generate.html", status_code=400,
|
||
user=user, sizes=sizes, styles=STYLE_PRESETS,
|
||
form={"prompt": prompt, "size": size, "style": style},
|
||
errors=["Опиши, что нужно нарисовать."],
|
||
)
|
||
|
||
user_img_gen = get_user_image_gen(user)
|
||
full_prompt = user_img_gen.build_prompt(prompt, style)
|
||
try:
|
||
image_bytes = user_img_gen.generate(full_prompt, size)
|
||
except Exception as e:
|
||
return render(
|
||
request, "image_generate.html", status_code=502,
|
||
user=user, sizes=sizes, styles=STYLE_PRESETS,
|
||
form={"prompt": prompt, "size": size, "style": style},
|
||
errors=[f"Ошибка генерации картинки: {e}"],
|
||
)
|
||
|
||
filename = f"{uuid.uuid4().hex}{detect_ext(image_bytes)}"
|
||
(MEDIA_DIR / filename).write_bytes(image_bytes)
|
||
save_media(user["id"], prompt, style, filename)
|
||
flash(request, "Картинка сгенерирована и сохранена в библиотеку.")
|
||
return RedirectResponse("/images", status_code=303)
|
||
|
||
|
||
@app.post("/images/{media_id}/delete")
|
||
def image_delete(request: Request, media_id: int, user: dict = Depends(current_user)):
|
||
item = get_media_by_id_for_user(media_id, user["id"])
|
||
if not item:
|
||
raise HTTPException(status_code=404, detail="Картинка не найдена")
|
||
filename = delete_media_by_id(user["id"], media_id)
|
||
if filename:
|
||
file_path = MEDIA_DIR / filename
|
||
if file_path.exists():
|
||
file_path.unlink()
|
||
flash(request, "Картинка удалена.")
|
||
return RedirectResponse("/images", status_code=303)
|
||
|
||
|
||
# === ОПРОСЫ МЕДИА-ОБРАЗА ===
|
||
|
||
@app.get("/surveys")
|
||
def survey_list(request: Request, user: dict = Depends(current_user)):
|
||
surveys = get_brand_surveys_for_user(user["id"])
|
||
return render(request, "survey_list.html", user=user, surveys=surveys)
|
||
|
||
|
||
@app.get("/survey")
|
||
def survey_form(request: Request, user: dict = Depends(current_user)):
|
||
return render(request, "survey.html", user=user)
|
||
|
||
|
||
@app.get("/survey/import")
|
||
def survey_import_form(request: Request, user: dict = Depends(current_user)):
|
||
return render(request, "survey_import.html", user=user)
|
||
|
||
|
||
@app.post("/survey/import")
|
||
async def survey_import_submit(request: Request, user: dict = Depends(current_user)):
|
||
import json as _json
|
||
from docx import Document as _DocxDocument
|
||
from io import BytesIO as _BytesIO
|
||
|
||
form = await request.form()
|
||
upload = form.get("file")
|
||
if not upload or not upload.filename:
|
||
return render(
|
||
request, "survey_import.html", status_code=400,
|
||
user=user, errors=["Выберите файл для загрузки."],
|
||
)
|
||
|
||
filename = upload.filename.lower()
|
||
raw = await upload.read()
|
||
|
||
# --- JSON: прямой импорт ---
|
||
if filename.endswith(".json"):
|
||
try:
|
||
data = _json.loads(raw.decode("utf-8"))
|
||
except Exception as e:
|
||
return render(
|
||
request, "survey_import.html", status_code=400,
|
||
user=user, errors=[f"Ошибка чтения JSON: {e}"],
|
||
)
|
||
# Поддерживаем два формата: {"data": {...}, "title": "..."} или просто {...}
|
||
if isinstance(data, dict) and "data" in data and isinstance(data["data"], dict):
|
||
title = (data.get("title") or "Импортированный образ").strip()[:200]
|
||
survey_data = data["data"]
|
||
elif isinstance(data, dict):
|
||
title = (data.get("client_name") or data.get("title") or "Импортированный образ").strip()[:200]
|
||
survey_data = data
|
||
else:
|
||
return render(
|
||
request, "survey_import.html", status_code=400,
|
||
user=user, errors=["JSON должен быть объектом с полями опроса."],
|
||
)
|
||
# Гарантируем структуры
|
||
survey_data.setdefault("channels", [])
|
||
survey_data.setdefault("funnel", [])
|
||
survey_id = save_brand_survey(user["id"], title, survey_data)
|
||
flash(request, f"Медиа-образ «{title}» импортирован из JSON.")
|
||
return RedirectResponse(f"/surveys/{survey_id}", status_code=303)
|
||
|
||
# --- DOCX: разбор через PiAPI ---
|
||
if filename.endswith(".docx"):
|
||
try:
|
||
doc = _DocxDocument(_BytesIO(raw))
|
||
except Exception as e:
|
||
return render(
|
||
request, "survey_import.html", status_code=400,
|
||
user=user, errors=[f"Ошибка чтения .docx: {e}"],
|
||
)
|
||
|
||
# Извлекаем весь текст
|
||
lines = []
|
||
for para in doc.paragraphs:
|
||
text = para.text.strip()
|
||
if text:
|
||
lines.append(text)
|
||
# Текст из таблиц
|
||
for table in doc.tables:
|
||
for row in table.rows:
|
||
cells = [cell.text.strip() for cell in row.cells]
|
||
lines.append(" | ".join(cells))
|
||
doc_text = "\n".join(lines)
|
||
|
||
if not doc_text.strip():
|
||
return render(
|
||
request, "survey_import.html", status_code=400,
|
||
user=user, errors=["Документ пустой — нет текста для разбора."],
|
||
)
|
||
|
||
# Разбор через PiAPI
|
||
survey_data = content_gen.parse_brand_doc_to_survey(doc_text)
|
||
if not survey_data:
|
||
return render(
|
||
request, "survey_import.html", status_code=502,
|
||
user=user,
|
||
errors=["Не удалось разобрать документ. Проверьте, что PiAPI ключ настроен, и попробуйте снова."],
|
||
)
|
||
|
||
title = (survey_data.get("client_name") or "Импортированный образ").strip()[:200]
|
||
survey_id = save_brand_survey(user["id"], title, survey_data)
|
||
flash(request, f"Медиа-образ «{title}» импортирован из .docx.")
|
||
return RedirectResponse(f"/surveys/{survey_id}", status_code=303)
|
||
|
||
return render(
|
||
request, "survey_import.html", status_code=400,
|
||
user=user, errors=["Поддерживаются только файлы .docx и .json"],
|
||
)
|
||
|
||
|
||
@app.post("/survey/submit")
|
||
async def survey_submit(request: Request, user: dict = Depends(current_user)):
|
||
body = await request.json()
|
||
title = (body.get("title") or "Без названия").strip()[:200]
|
||
data = body.get("data") or {}
|
||
survey_id = save_brand_survey(user["id"], title, data)
|
||
return {"id": survey_id, "ok": True}
|
||
|
||
|
||
@app.get("/surveys/{survey_id}")
|
||
def survey_view(request: Request, survey_id: int, user: dict = Depends(current_user)):
|
||
survey = get_brand_survey_by_id(survey_id, user["id"])
|
||
if not survey:
|
||
raise HTTPException(status_code=404, detail="Опрос не найден")
|
||
return render(request, "survey_view.html", user=user, survey=survey)
|
||
|
||
|
||
@app.get("/surveys/{survey_id}/export")
|
||
def survey_export(request: Request, survey_id: int, user: dict = Depends(current_user)):
|
||
survey = get_brand_survey_by_id(survey_id, user["id"])
|
||
if not survey:
|
||
raise HTTPException(status_code=404, detail="Опрос не найден")
|
||
docx_bytes = generate_brand_docx(survey["data"])
|
||
# ASCII-имя для Content-Disposition (latin-1), кириллица не допускается в заголовках
|
||
safe_title = "".join(c if c.isascii() and c.isalnum() else "_" for c in survey["title"])[:80]
|
||
if not safe_title or safe_title == "_" * len(safe_title):
|
||
safe_title = "brand"
|
||
filename = f"Strategiya_brenda_{safe_title}.docx"
|
||
return Response(
|
||
content=docx_bytes,
|
||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||
)
|
||
|
||
|
||
@app.post("/surveys/{survey_id}/delete")
|
||
def survey_delete(request: Request, survey_id: int, user: dict = Depends(current_user)):
|
||
survey = get_brand_survey_by_id(survey_id, user["id"])
|
||
if not survey:
|
||
raise HTTPException(status_code=404, detail="Опрос не найден")
|
||
delete_brand_survey(user["id"], survey_id)
|
||
flash(request, "Опрос удалён.")
|
||
return RedirectResponse("/surveys", status_code=303)
|
||
|
||
|
||
# === КАНАЛЫ ДЛЯ ПУБЛИКАЦИИ ===
|
||
|
||
@app.get("/channels")
|
||
def channels_list(request: Request, user: dict = Depends(current_user)):
|
||
channels = get_channels_for_user(user["id"])
|
||
return render(request, "channels.html", user=user, channels=channels)
|
||
|
||
|
||
@app.get("/channels/add")
|
||
def channel_add_form(request: Request, user: dict = Depends(current_user)):
|
||
return render(request, "channel_add.html", user=user)
|
||
|
||
|
||
@app.post("/channels/add")
|
||
def channel_add_submit(
|
||
request: Request,
|
||
chat_input: str = Form(...),
|
||
user: dict = Depends(current_user),
|
||
):
|
||
chat_input = chat_input.strip()
|
||
if not chat_input:
|
||
return render(
|
||
request, "channel_add.html", status_code=400,
|
||
user=user, errors=["Введите @username или ID канала/чата."],
|
||
form={"chat_input": chat_input},
|
||
)
|
||
|
||
# 1. Разрешаем чат через Telegram API
|
||
info = resolve_chat(chat_input)
|
||
if "error" in info:
|
||
return render(
|
||
request, "channel_add.html", status_code=400,
|
||
user=user, errors=[f"Не удалось найти чат: {info['error']}"],
|
||
form={"chat_input": chat_input},
|
||
)
|
||
|
||
chat_id = info["chat_id"]
|
||
title = info["title"]
|
||
username = info["username"]
|
||
chat_type = info["type"]
|
||
|
||
# 2. Проверяем, что бот — админ
|
||
is_admin, admin_error = check_bot_is_admin(chat_id)
|
||
if not is_admin:
|
||
return render(
|
||
request, "channel_add.html", status_code=400,
|
||
user=user, errors=[admin_error or "Бот не админ в этом чате."],
|
||
form={"chat_input": chat_input},
|
||
)
|
||
|
||
# 3. Проверяем, не добавлен ли уже
|
||
if is_channel_already_added(user["id"], chat_id):
|
||
return render(
|
||
request, "channel_add.html", status_code=400,
|
||
user=user, errors=[f"Канал «{title}» уже добавлен."],
|
||
form={"chat_input": chat_input},
|
||
)
|
||
|
||
# 4. Сохраняем
|
||
save_channel(user["id"], chat_id, title, username, chat_type)
|
||
flash(request, f"Канал «{title}» добавлен.")
|
||
return RedirectResponse("/channels", status_code=303)
|
||
|
||
|
||
@app.post("/channels/{channel_id}/delete")
|
||
def channel_delete(request: Request, channel_id: int, user: dict = Depends(current_user)):
|
||
channel = get_channel_by_id(channel_id, user["id"])
|
||
if not channel:
|
||
raise HTTPException(status_code=404, detail="Канал не найден")
|
||
delete_channel(user["id"], channel_id)
|
||
flash(request, f"Канал «{channel['title']}» удалён.")
|
||
return RedirectResponse("/channels", status_code=303)
|
||
|
||
|
||
# === ПУБЛИКАЦИЯ ПОСТА ===
|
||
|
||
@app.get("/posts/{post_id}/publish")
|
||
def publish_form(request: Request, post_id: int, user: dict = Depends(current_user)):
|
||
post = get_post_by_id_for_user(post_id, user["id"])
|
||
if not post:
|
||
raise HTTPException(status_code=404, detail="Пост не найден")
|
||
channels = get_channels_for_user(user["id"])
|
||
return render(request, "publish.html", user=user, post=post, channels=channels,
|
||
platform_label=platform_label)
|
||
|
||
|
||
@app.post("/posts/{post_id}/publish")
|
||
def publish_submit(
|
||
request: Request,
|
||
post_id: int,
|
||
channel_id: str = Form(...),
|
||
user: dict = Depends(current_user),
|
||
):
|
||
post = get_post_by_id_for_user(post_id, user["id"])
|
||
if not post:
|
||
raise HTTPException(status_code=404, detail="Пост не найден")
|
||
|
||
try:
|
||
cid = int(channel_id)
|
||
except ValueError:
|
||
cid = None
|
||
if not cid:
|
||
channels = get_channels_for_user(user["id"])
|
||
return render(
|
||
request, "publish.html", status_code=400,
|
||
user=user, post=post, channels=channels, platform_label=platform_label,
|
||
errors=["Выберите канал для публикации."],
|
||
)
|
||
|
||
channel = get_channel_by_id(cid, user["id"])
|
||
if not channel:
|
||
raise HTTPException(status_code=404, detail="Канал не найден")
|
||
|
||
# Берём первую картинку поста (если есть) для публикации в Telegram
|
||
image_bytes = None
|
||
images = get_post_images(post_id, user["id"])
|
||
if images:
|
||
fp = MEDIA_DIR / images[0]["filename"]
|
||
if fp.exists():
|
||
image_bytes = fp.read_bytes()
|
||
|
||
ok, message = publish_post(channel["chat_id"], post["content"], image_bytes=image_bytes)
|
||
if ok:
|
||
mark_post_published(post_id, user["id"], channel["title"])
|
||
flash(request, f"✅ Пост опубликован в «{channel['title']}»")
|
||
else:
|
||
flash(request, f"❌ Ошибка публикации: {message}")
|
||
|
||
return RedirectResponse(f"/posts/{post_id}", status_code=303)
|
||
|
||
|
||
# === ФОНОВАЯ ГЕНЕРАЦИЯ (AJAX) ===
|
||
|
||
def _run_post_generation(task_id, user_id, platform, style, wishes, survey_id, plan_id=None):
|
||
"""Фоновая генерация поста."""
|
||
try:
|
||
update_task_status(task_id, 'running')
|
||
brand_data = None
|
||
if survey_id:
|
||
survey = get_brand_survey_by_id(survey_id, user_id)
|
||
if survey:
|
||
brand_data = survey["data"]
|
||
if not brand_data:
|
||
latest = get_latest_brand_survey_for_user(user_id)
|
||
if latest:
|
||
brand_data = latest["data"]
|
||
if not brand_data:
|
||
update_task_status(task_id, 'error', error="Нет медиа-образа. Создайте медиа-образ и повторите.")
|
||
return
|
||
settings = get_text_provider_settings(user_id)
|
||
gen = ContentGenerator(provider_settings=settings) if settings and settings.get("api_key") else content_gen
|
||
content = gen.generate_post_from_brand_profile(brand_data, platform, style, wishes)
|
||
post_id = save_post_for_user(user_id, platform, content, style, wishes)
|
||
# Если пост создан из плана — ставим статус «опубликовано»
|
||
if plan_id:
|
||
try:
|
||
update_plan_status(user_id, int(plan_id), "published")
|
||
except (ValueError, Exception) as e:
|
||
print(f"⚠️ Не удалось обновить статус плана {plan_id}: {e}")
|
||
update_task_status(task_id, 'done', result_json=json.dumps({"post_id": post_id, "content": content}))
|
||
except Exception as e:
|
||
update_task_status(task_id, 'error', error=str(e))
|
||
|
||
|
||
def _run_plan_generation(task_id, user_id, days):
|
||
"""Фоновая генерация контент-плана."""
|
||
try:
|
||
update_task_status(task_id, 'running')
|
||
settings = get_text_provider_settings(user_id)
|
||
gen = ContentGenerator(provider_settings=settings) if settings and settings.get("api_key") else content_gen
|
||
latest = get_latest_brand_survey_for_user(user_id)
|
||
if latest:
|
||
plan = gen.generate_content_plan_from_brand_profile(latest["data"], days)
|
||
else:
|
||
plan = gen.generate_content_plan("бизнес", "не указано", days)
|
||
replace_plan_for_user(user_id, plan)
|
||
update_task_status(task_id, 'done', result_json=json.dumps({"days": days, "items": len(plan)}))
|
||
except Exception as e:
|
||
update_task_status(task_id, 'error', error=str(e))
|
||
|
||
|
||
def _run_modify_post(task_id, user_id, post_id, instruction):
|
||
"""Фоновая модификация поста по инструкции пользователя."""
|
||
try:
|
||
update_task_status(task_id, 'running')
|
||
|
||
post = get_post_by_id_for_user(post_id, user_id)
|
||
if not post:
|
||
update_task_status(task_id, 'error', error="Пост не найден")
|
||
return
|
||
|
||
if not instruction.strip():
|
||
update_task_status(task_id, 'error', error="Инструкция не может быть пустой")
|
||
return
|
||
|
||
# Получаем AI-провайдер
|
||
settings = get_text_provider_settings(user_id)
|
||
gen = ContentGenerator(provider_settings=settings) if settings and settings.get("api_key") else content_gen
|
||
|
||
new_content = gen.modify_post(post["content"], instruction.strip())
|
||
|
||
# Сохраняем новую версию
|
||
update_post_content(user_id, post_id, new_content)
|
||
add_post_revision(post_id, user_id, new_content)
|
||
|
||
update_task_status(task_id, 'done', result_json=json.dumps({
|
||
"content": new_content,
|
||
"instruction": instruction.strip(),
|
||
}, ensure_ascii=False))
|
||
|
||
except Exception as e:
|
||
update_task_status(task_id, 'error', error=str(e))
|
||
|
||
|
||
def _run_social_analysis(task_id, user_id, url, manual_posts):
|
||
"""Фоновый анализ профиля соцсети."""
|
||
try:
|
||
update_task_status(task_id, 'running')
|
||
|
||
platform_info = detect_platform(url)
|
||
posts = []
|
||
|
||
# Если пользователь вставил тексты постов вручную
|
||
if manual_posts and manual_posts.strip():
|
||
blocks = re.split(r'\n{2,}', manual_posts.strip())
|
||
for block in blocks:
|
||
block = block.strip()
|
||
if len(block) > 20:
|
||
posts.append({"text": block, "date": ""})
|
||
|
||
# Если URL — Telegram, загружаем посты автоматически
|
||
if not posts and platform_info["platform"] == "telegram" and platform_info["fetchable"]:
|
||
posts = fetch_telegram_posts(platform_info["channel"])
|
||
|
||
if not posts:
|
||
update_task_status(task_id, 'error',
|
||
error="Не удалось загрузить посты. Для VK и Tenchat вставьте тексты постов вручную (по 2+ постов через пустую строку).")
|
||
return
|
||
|
||
# AI-анализ
|
||
settings = get_text_provider_settings(user_id)
|
||
result = analyze_posts_with_ai(posts, provider_settings=settings)
|
||
|
||
if "error" in result:
|
||
update_task_status(task_id, 'error', error=result["error"])
|
||
return
|
||
|
||
result["platform_info"] = platform_info
|
||
result["posts_count"] = len(posts)
|
||
update_task_status(task_id, 'done', result_json=json.dumps(result, ensure_ascii=False))
|
||
|
||
except Exception as e:
|
||
update_task_status(task_id, 'error', error=str(e))
|
||
|
||
|
||
def _run_image_generation(task_id, user_id, prompt, size, style, reference_image_bytes=None):
|
||
"""Фоновая генерация картинки."""
|
||
try:
|
||
update_task_status(task_id, 'running')
|
||
settings = get_image_provider_settings(user_id)
|
||
gen = ImageGenerator(provider_settings=settings) if settings and settings.get("api_key") else image_gen
|
||
full_prompt = gen.build_prompt(prompt, style)
|
||
if reference_image_bytes:
|
||
# Для image-to-image нужен chat/completions — используем text-провайдер
|
||
text_settings = get_text_provider_settings(user_id)
|
||
image_bytes = gen.generate_with_reference(full_prompt, reference_image_bytes, size, chat_settings=text_settings)
|
||
else:
|
||
image_bytes = gen.generate(full_prompt, size)
|
||
filename = f"{uuid.uuid4().hex}{detect_ext(image_bytes)}"
|
||
(MEDIA_DIR / filename).write_bytes(image_bytes)
|
||
save_media(user_id, prompt, style, filename)
|
||
update_task_status(task_id, 'done', result_json=json.dumps({"filename": filename}))
|
||
except Exception as e:
|
||
update_task_status(task_id, 'error', error=str(e))
|
||
|
||
|
||
def _run_analyze_post_image(task_id, user_id, post_id):
|
||
"""Фоновый анализ поста — предлагает концепции изображений."""
|
||
try:
|
||
update_task_status(task_id, 'running')
|
||
post = get_post_by_id_for_user(post_id, user_id)
|
||
if not post:
|
||
update_task_status(task_id, 'error', error="Пост не найден")
|
||
return
|
||
post_text = (post["content"] or "")[:2000]
|
||
|
||
# Получаем медиа-образ для контекста бренда
|
||
brand_data = None
|
||
latest = get_latest_brand_survey_for_user(user_id)
|
||
if latest:
|
||
brand_data = latest["data"]
|
||
|
||
settings = get_text_provider_settings(user_id)
|
||
gen = ContentGenerator(provider_settings=settings) if settings and settings.get("api_key") else content_gen
|
||
concepts = gen.suggest_image_concepts(post_text, brand_data)
|
||
update_task_status(task_id, 'done', result_json=json.dumps({"concepts": concepts}))
|
||
except Exception as e:
|
||
update_task_status(task_id, 'error', error=str(e))
|
||
|
||
|
||
def _run_post_image_generation(task_id, user_id, post_id, template_key=None, custom_prompt=None, custom_style=None, custom_size=None):
|
||
"""Фоновая генерация картинки к посту."""
|
||
try:
|
||
update_task_status(task_id, 'running')
|
||
post = get_post_by_id_for_user(post_id, user_id)
|
||
if not post:
|
||
update_task_status(task_id, 'error', error="Пост не найден")
|
||
return
|
||
settings = get_image_provider_settings(user_id)
|
||
gen = ImageGenerator(provider_settings=settings) if settings and settings.get("api_key") else image_gen
|
||
|
||
post_text = (post["content"] or "")[:500]
|
||
|
||
if custom_prompt:
|
||
# Концепт от ИИ — используем его промпт + текст поста
|
||
prompt = f"{custom_prompt}. Post text for context: {post_text}"
|
||
size = custom_size or "wide"
|
||
style = custom_style or "none"
|
||
elif template_key:
|
||
# Ищем шаблон по ключу
|
||
tpl = None
|
||
for t in IMAGE_TEMPLATES:
|
||
if t["key"] == template_key:
|
||
tpl = t
|
||
break
|
||
if tpl:
|
||
prompt = tpl["prompt_template"].replace("{post_text}", post_text)
|
||
size = tpl["size"]
|
||
style = tpl["style"]
|
||
else:
|
||
prompt = f"Create a professional social media image for this post. Post text: {post_text}. Style: modern, clean, eye-catching."
|
||
size = "square"
|
||
style = None
|
||
else:
|
||
# Старое поведение — авто-промпт
|
||
prompt = f"Create a professional social media image for this post. Post text: {post_text}. Style: modern, clean, eye-catching."
|
||
size = "square"
|
||
style = None
|
||
|
||
if style:
|
||
prompt = gen.build_prompt(prompt, style)
|
||
|
||
image_bytes = gen.generate(prompt, size)
|
||
ext = detect_ext(image_bytes)
|
||
filename = f"{uuid.uuid4().hex}{ext}"
|
||
(MEDIA_DIR / filename).write_bytes(image_bytes)
|
||
save_post_image(post_id, user_id, filename, source="generated")
|
||
update_task_status(task_id, 'done', result_json=json.dumps({"filename": filename}))
|
||
except Exception as e:
|
||
update_task_status(task_id, 'error', error=str(e))
|
||
|
||
|
||
@app.post("/api/tasks")
|
||
async def api_start_task(request: Request, user: dict = Depends(current_user)):
|
||
"""Запускает фоновую задачу. Возвращает task_id."""
|
||
uid = user["id"]
|
||
form = await request.form()
|
||
task_type_val = form.get("task_type", "")
|
||
task_id = create_task(uid, task_type_val)
|
||
|
||
if task_type_val == "generate_post":
|
||
platform = form.get("platform", "telegram")
|
||
style = form.get("style", "")
|
||
wishes = form.get("wishes", "")
|
||
survey_id = form.get("survey_id", "")
|
||
plan_id = form.get("plan_id", "") or None
|
||
t = threading.Thread(target=_run_post_generation, args=(task_id, uid, platform, style, wishes, survey_id, plan_id), daemon=True)
|
||
t.start()
|
||
|
||
elif task_type_val == "generate_plan":
|
||
days = int(form.get("days", 7))
|
||
days = max(1, min(days, 14))
|
||
t = threading.Thread(target=_run_plan_generation, args=(task_id, uid, days), daemon=True)
|
||
t.start()
|
||
|
||
elif task_type_val == "generate_image":
|
||
prompt = form.get("prompt", "")
|
||
size = form.get("size", "square")
|
||
style = form.get("style", "none")
|
||
ref_file = form.get("reference_image")
|
||
ref_bytes = None
|
||
if ref_file and hasattr(ref_file, "read"):
|
||
ref_bytes = await ref_file.read()
|
||
if len(ref_bytes) < 100:
|
||
ref_bytes = None # слишком маленький, пропускаем
|
||
t = threading.Thread(target=_run_image_generation, args=(task_id, uid, prompt, size, style, ref_bytes), daemon=True)
|
||
t.start()
|
||
|
||
elif task_type_val == "generate_post_image":
|
||
post_id = int(form.get("post_id", 0))
|
||
template_key = form.get("template_key", "") or None
|
||
t = threading.Thread(target=_run_post_image_generation, args=(task_id, uid, post_id, template_key), daemon=True)
|
||
t.start()
|
||
|
||
elif task_type_val == "analyze_post_image":
|
||
post_id = int(form.get("post_id", 0))
|
||
t = threading.Thread(target=_run_analyze_post_image, args=(task_id, uid, post_id), daemon=True)
|
||
t.start()
|
||
|
||
elif task_type_val == "generate_post_image_with_concept":
|
||
post_id = int(form.get("post_id", 0))
|
||
concept_prompt = form.get("concept_prompt", "")
|
||
concept_style = form.get("concept_style", "none")
|
||
concept_size = form.get("concept_size", "square")
|
||
t = threading.Thread(
|
||
target=_run_post_image_generation,
|
||
args=(task_id, uid, post_id),
|
||
kwargs={"custom_prompt": concept_prompt, "custom_style": concept_style, "custom_size": concept_size},
|
||
daemon=True,
|
||
)
|
||
t.start()
|
||
|
||
elif task_type_val == "analyze_social_profile":
|
||
url = form.get("url", "")
|
||
manual_posts = form.get("manual_posts", "")
|
||
t = threading.Thread(
|
||
target=_run_social_analysis,
|
||
args=(task_id, uid, url, manual_posts),
|
||
daemon=True,
|
||
)
|
||
t.start()
|
||
|
||
elif task_type_val == "modify_post":
|
||
post_id = int(form.get("post_id", 0))
|
||
instruction = form.get("instruction", "")
|
||
t = threading.Thread(
|
||
target=_run_modify_post,
|
||
args=(task_id, uid, post_id, instruction),
|
||
daemon=True,
|
||
)
|
||
t.start()
|
||
|
||
return {"task_id": task_id, "status": "pending"}
|
||
|
||
|
||
@app.get("/api/tasks/{task_id}")
|
||
def api_task_status(task_id: int, user: dict = Depends(current_user)):
|
||
"""Возвращает статус задачи."""
|
||
task = get_task(task_id)
|
||
if not task or task["user_id"] != user["id"]:
|
||
raise HTTPException(status_code=404, detail="Задача не найдена")
|
||
result = {
|
||
"task_id": task["id"],
|
||
"status": task["status"],
|
||
"task_type": task["task_type"],
|
||
}
|
||
if task["status"] == "done" and task["result_json"]:
|
||
result["result"] = json.loads(task["result_json"])
|
||
if task["error"]:
|
||
result["error"] = task["error"]
|
||
return result
|