diff --git a/docker-compose.yml b/docker-compose.yml index 767bd20..657c423 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ services: - TZ=Europe/Ulyanovsk volumes: - /var/run/docker.sock:/var/run/docker.sock + - ./.env:/app/.env:ro - ./main.py:/app/main.py - ./logs:/app/logs - ./exports:/app/exports @@ -35,14 +36,19 @@ services: environment: - PYTHONPATH=/app:/app/services - GOOGLE_CREDS_FILE=/home/matrixhasyou/domovoy_google_creds.json + - GOOGLE_API_KEY=AIzaSyAJMfQ_mRZ1picRXVFNaeFJ7fpAkZxUKyk - TZ=Europe/Ulyanovsk volumes: - /var/run/docker.sock:/var/run/docker.sock + - ./.env:/app/.env:ro + - ./web:/app/web + - ./podcasts:/app/podcasts - ./logs:/app/logs - ./exports:/app/exports - ./data:/app/data - ./handlers:/app/handlers - ./services:/app/services + - /home/matrixhasyou/mutt:/home/matrixhasyou/mutt:ro - ../libs/swarmlib:/app/swarmlib:ro - /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro logging: diff --git a/services/ai_legal_engine.py b/services/ai_legal_engine.py index 7837da2..de18b2e 100644 --- a/services/ai_legal_engine.py +++ b/services/ai_legal_engine.py @@ -1,40 +1,32 @@ #!/usr/bin/env python3 """ -AI Legal Engine 2.0 (Юридический Ядерный Щит Домового). -Работает на базе Gemini 2.0 API с контекстным окном до 2M токенов. -Обеспечивает: -1. Мгновенные ответы жильцам (на простом понятном языке) и юристам (с нормами права). -2. Нативный мультимодальный прием голосовых сообщений (без потерь и галлюцинаций Whisper). -3. Интеграцию с базой писем и судебных дел МКД (Ленком 37). -4. Строгий шлюз безопасности Zero-Spam для чата дома. +Интеллектуальное юридическое ядро МКД «Домовой 2.0» (AI Legal Engine) +Работает на базе Gemini 2.5 Flash / Pro: +- Нативный слух (Gemini Multimodal Audio) без Whisper +- Прямой асинхронный REST API (высокая отказоустойчивость без конфликтов pydantic) +- Разграничение ролей: resident (соседский помощник), lawyer (претензионно-судебный), admin (глубокий аудит) """ import os import sys import logging import asyncio -from pathlib import Path +import aiohttp +import base64 from datetime import datetime -from dotenv import load_dotenv - -# Загружаем окружение -PROJECT_ROOT = Path(__file__).resolve().parent.parent -load_dotenv(PROJECT_ROOT / ".env") - -logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") -log = logging.getLogger("AILegalEngine") - -API_KEY = os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY") +from pathlib import Path +log = logging.getLogger("ai_legal_engine") class AILegalEngine: def __init__(self): - self.api_key = API_KEY + raw_key = os.getenv("GOOGLE_API_KEY", "AIzaSyAJMfQ_mRZ1picRXVFNaeFJ7fpAkZxUKyk") + self.api_key = raw_key.strip("\"'") if raw_key else "" self.client = None self.model_name = "gemini-2.5-flash" self.deep_model_name = "gemini-2.5-pro" - self._init_client() + self._init_sdk_client() - def _init_client(self): + def _init_sdk_client(self): if not self.api_key: log.warning("Gemini API Key missing! AI Legal Engine will run in offline mode.") return @@ -42,9 +34,9 @@ class AILegalEngine: try: from google import genai self.client = genai.Client(api_key=self.api_key) - log.info("Gemini 2.0 Client initialized successfully.") + log.info("Gemini SDK Client initialized.") except Exception as e: - log.error(f"Failed to initialize google.genai: {e}") + log.info(f"Using high-performance direct REST engine (SDK not active: {e})") def get_role_instructions(self, role: str) -> str: if role == "resident": @@ -59,32 +51,69 @@ class AILegalEngine: ) elif role == "lawyer": return ( - "РОЛЬ ДЛЯ ОТВЕТА: 'Судебный юрист в сфере ЖКХ и градостроительства'.\n" - "СТИЛЬ: Строгий, процессуально выверенный, юридически безупречный.\n" + "РОЛЬ ДЛЯ ОТВЕТА: 'Профессиональный юрист по жилищному праву и защите прав потребителей'.\n" + "СТИЛЬ: Строгий, юридически выверенный, аргументированный.\n" "ТРЕБОВАНИЯ:\n" - "1. Обязательные ссылки на статьи Жилищного кодекса РФ, ГК РФ, Постановления Правительства РФ № 354, № 491, № 416.\n" - "2. Указание на сроки удовлетворения требований по закону.\n" - "3. Упоминание релевантных материалов судебных дел дома (Ленком 37) и ответов Прокуратуры/ГЖИ Ульяновской области при наличии.\n" - "4. Проект мотивированной досудебной претензии или жалобы в надзорные органы." + "1. Четкая правовая квалификация действий/бездействия лиц.\n" + "2. Прямые ссылки на статьи законов (ЖК РФ, ГК РФ, 59-ФЗ, ПП РФ № 354, ПП РФ № 491, Госстрой № 170, СанПиН 2.1.3684-21).\n" + "3. Оценка перспектив в суде или надзорных органах (ГЖИ, Прокуратура, Роспотребнадзор).\n" + "4. Готовый проект претензии / жалобы с правовой мотивировкой." ) elif role == "admin": return ( - "РОЛЬ ДЛЯ ОТВЕТА: 'Стратегический аналитик для Председателя совета дома (Александра Тырина)'.\n" - "СТИЛЬ: Аналитическая записка, системный разбор, поиск противоречий.\n" + "РОЛЬ ДЛЯ ОТВЕТА: 'Главный аналитик Совета Дома и стратег судебных процессов'.\n" + "СТИЛЬ: Стратегический, системный, аналитический.\n" "ТРЕБОВАНИЯ:\n" - "1. Сопоставление фактов: что чиновники/УК утверждали ранее и что говорят сейчас.\n" - "2. Поиск финансовых нестыковок, признаков нецелевого расходования средств, фальсификации протоколов ОСС.\n" - "3. Контроль сроков по 59-ФЗ и процессуальных дедлайнов.\n" - "4. Стратегические рекомендации: куда бить (Генпрокуратура, Следственный комитет, суд, ФАС)." + "1. Полный расклад с точки зрения системной выгоды для всего дома.\n" + "2. Выявление уловок УК, скрытых манипуляций со сметами, протоколами ОСС или объемами коммунальных ресурсов.\n" + "3. Сравнение с прецедентами и судебной практикой Ульяновского областного суда и Верховного Суда РФ.\n" + "4. Стратегия принуждения госорганов к исполнению служебных обязанностей по 59-ФЗ и Приказу ГП № 45." ) return "Отвечай вежливо и по делу." + async def _query_rest_api(self, prompt: str, system_instruction: str, model: str, audio_path: str = None) -> str: + """Прямой асинхронный REST запрос к Google Gemini API.""" + url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={self.api_key}" + parts = [] + + # Аудио вложение + if audio_path and os.path.exists(audio_path): + with open(audio_path, "rb") as af: + audio_b64 = base64.b64encode(af.read()).decode("utf-8") + parts.append({ + "inlineData": { + "mimeType": "audio/ogg", + "data": audio_b64 + } + }) + if not prompt: + prompt = "Внимательно прослушай аудиосообщение жильца дома. Точно выдели юридическую суть и дай обоснованный ответ." + + parts.append({"text": prompt}) + + payload = { + "system_instruction": { + "parts": [{"text": system_instruction}] + }, + "contents": [ + {"parts": parts} + ], + "generationConfig": { + "temperature": 0.2 if model == self.deep_model_name else 0.4 + } + } + + async with aiohttp.ClientSession() as session: + async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=45)) as resp: + if resp.status != 200: + err_text = await resp.text() + raise RuntimeError(f"Gemini API HTTP {resp.status}: {err_text[:300]}") + data = await resp.json() + return data["candidates"][0]["content"]["parts"][0]["text"] + async def query(self, prompt: str, role: str = "resident", audio_path: str = None) -> dict: - """ - Основной метод запроса к юридическому ядру. - Поддерживает текст, аудиофайл (голос жильца) и разграничение ролей. - """ - if not self.client: + """Основной метод запроса к юридическому ядру.""" + if not self.api_key: return { "success": False, "text": "ИИ-модуль временно недоступен. Проверьте API ключ.", @@ -99,40 +128,22 @@ class AILegalEngine: f"{self.get_role_instructions(role)}" ) + model_to_use = self.deep_model_name if role in ("lawyer", "admin") else self.model_name + try: - contents = [] - - # 1. Если передано голосовое сообщение — добавляем аудио напрямую - if audio_path and os.path.exists(audio_path): - log.info(f"Processing native audio input: {audio_path}") - # Загружаем аудио через Gemini Files API - uploaded_audio = self.client.files.upload(file=audio_path) - contents.append(uploaded_audio) - if not prompt: - prompt = "Внимательно прослушай голосовое сообщение жильца. Точно расшифруй юридическую суть и дай ответ согласно твоей роли." - - # 2. Добавляем текстовый промпт - contents.append(prompt) - - # Выбор модели - model_to_use = self.deep_model_name if role == "admin" else self.model_name - - # Запрос к Gemini - response = self.client.models.generate_content( + # Используем прямой REST (наивысшая надежность без конфликтов библиотек) + reply_text = await self._query_rest_api( + prompt=prompt, + system_instruction=system_instruction, model=model_to_use, - contents=contents, - config={ - "system_instruction": system_instruction, - "temperature": 0.2 if role in ("lawyer", "admin") else 0.4, - } + audio_path=audio_path ) latency = (datetime.now() - start_time).total_seconds() - reply_text = response.text.strip() if response.text else "Ответ не сформирован." - return { "success": True, - "text": reply_text, + "text": reply_text.strip(), + "answer": reply_text.strip(), "role": role, "latency_sec": round(latency, 2), "model": model_to_use @@ -140,28 +151,24 @@ class AILegalEngine: except Exception as e: latency = (datetime.now() - start_time).total_seconds() - log.error(f"Gemini API Error: {e}") + log.error(f"Gemini Error: {e}") return { "success": False, - "text": f"Ошибка обработки запроса: {e}", + "text": f"Ошибка обработки запроса ИИ: {e}", "role": role, "latency_sec": round(latency, 2) } -# Глобальный инстанс +# Глобальный синглтон юридического ядра для роутеров бота legal_engine = AILegalEngine() if __name__ == "__main__": async def test(): - print("Testing AI Legal Engine 2.0...") - res = await legal_engine.query("Течет крыша на 9 этаже, УК не реагирует 4 дня. Что делать?", role="resident") - print("\n=== RESIDENT RESPONSE ===") - print(f"Latency: {res['latency_sec']}s") - print(res["text"]) - - res_law = await legal_engine.query("Течет крыша на 9 этаже, УК не реагирует 4 дня. Нормативная база?", role="lawyer") - print("\n=== LAWYER RESPONSE ===") - print(f"Latency: {res_law['latency_sec']}s") - print(res_law["text"]) + engine = AILegalEngine() + res = await engine.query("Какие сроки ответа прокуратуры по закону 59-ФЗ?", role="resident") + print("Success:", res["success"]) + print("Latency:", res["latency_sec"]) + print("Model:", res.get("model")) + print("Text snippet:\n", res["text"][:300]) asyncio.run(test()) diff --git a/services/notebooklm_podcast_generator.py b/services/notebooklm_podcast_generator.py new file mode 100644 index 0000000..fb49b51 --- /dev/null +++ b/services/notebooklm_podcast_generator.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +""" +Сервис интеграции с Google NotebookLM и генерации аудио-подкастов (Audio Overviews) +для проекта «Домовой 2.0: Юридический Ядерный Щит МКД». + +Возможности: +1. Автоматическая синхронизация официальных источников (полные законы РФ + тома почты с ЭЦП). +2. Вызов генерации двухголосых аудио-обзоров NotebookLM (Audio Overview). +3. Скачивание аудио-подкастов (.m4a/.mp3) в локальное хранилище. +4. Fallback: Генерация детального сценария аналитического подкаста через Gemini 2.5 Pro. +""" + +import os +import sys +import json +import logging +import subprocess +from pathlib import Path +from typing import Dict, Any, Optional, List + +logger = logging.getLogger("notebooklm_podcast") +logger.setLevel(logging.INFO) + +NOTEBOOKLM_BIN = "/home/matrixhasyou/swarm-services/domovoy-bot/venv-ai/bin/notebooklm" +PODCASTS_DIR = Path("/home/matrixhasyou/swarm-services/domovoy-bot/podcasts") +LEGAL_SOURCES_DIR = Path("/home/matrixhasyou/mutt/legal_sources") +MEGA_VOLUMES_DIR = Path("/home/matrixhasyou/mutt/mega_volumes") +DEFAULT_NOTEBOOK_NAME = "МКД Ливанова 37 (Суды, Прокуратура, ЖКХ)" + +class NotebookLMPodcastGenerator: + def __init__(self, notebook_name: str = DEFAULT_NOTEBOOK_NAME): + self.notebook_name = notebook_name + self.cli_bin = NOTEBOOKLM_BIN + PODCASTS_DIR.mkdir(parents=True, exist_ok=True) + + def is_authenticated(self) -> bool: + """Проверяет, авторизован ли CLI NotebookLM.""" + storage_file = Path(os.path.expanduser("~/.notebooklm/profiles/default/storage_state.json")) + if not storage_file.exists() and not os.getenv("NOTEBOOKLM_AUTH_JSON"): + return False + try: + res = subprocess.run([self.cli_bin, "list"], capture_output=True, text=True, timeout=10) + return res.returncode == 0 and "Not logged in" not in res.stderr and "Not logged in" not in res.stdout + except Exception: + return False + + def list_notebooks(self) -> List[Dict[str, str]]: + """Возвращает список блокнотов из NotebookLM.""" + if not self.is_authenticated(): + return [] + try: + res = subprocess.run([self.cli_bin, "list", "--json"], capture_output=True, text=True, timeout=15) + if res.returncode == 0: + data = json.loads(res.stdout) + return data if isinstance(data, list) else [] + except Exception as e: + logger.error(f"Ошибка получения списка блокнотов: {e}") + return [] + + def get_or_create_notebook(self) -> Optional[str]: + """Находит существующий блокнот дома или создает новый.""" + notebooks = self.list_notebooks() + for nb in notebooks: + if self.notebook_name.lower() in nb.get("title", "").lower(): + return nb.get("id") + + # Создаем новый + try: + res = subprocess.run([self.cli_bin, "create", self.notebook_name], capture_output=True, text=True, timeout=20) + if res.returncode == 0: + # Находим созданный + for nb in self.list_notebooks(): + if self.notebook_name.lower() in nb.get("title", "").lower(): + return nb.get("id") + except Exception as e: + logger.error(f"Ошибка создания блокнота: {e}") + return None + + def upload_source_file(self, file_path: str, notebook_id: Optional[str] = None) -> bool: + """Загружает PDF-файл (закон или том почты) в NotebookLM.""" + if not os.path.exists(file_path): + logger.error(f"Файл не найден: {file_path}") + return False + + args = [self.cli_bin, "source", "add", file_path] + if notebook_id: + args.extend(["--notebook", notebook_id]) + + try: + res = subprocess.run(args, capture_output=True, text=True, timeout=180) + return res.returncode == 0 + except Exception as e: + logger.error(f"Ошибка загрузки источника {file_path}: {e}") + return False + + def sync_all_sources(self) -> Dict[str, Any]: + """Синхронизирует все полные законы и тома мега-PDF с NotebookLM.""" + if not self.is_authenticated(): + return { + "success": False, + "error": "NotebookLM CLI не авторизован. Требуется импорт профиля в ~/.notebooklm/profiles/default/" + } + + nb_id = self.get_or_create_notebook() + if not nb_id: + return {"success": False, "error": "Не удалось создать или получить блокнот дома"} + + uploaded = [] + # 1. Законы РФ + if LEGAL_SOURCES_DIR.exists(): + for pdf in sorted(LEGAL_SOURCES_DIR.glob("*Полный*.pdf")): + if self.upload_source_file(str(pdf), nb_id): + uploaded.append(pdf.name) + + # 2. Мега-тома писем + if MEGA_VOLUMES_DIR.exists(): + for pdf in sorted(MEGA_VOLUMES_DIR.glob("*.pdf")): + if self.upload_source_file(str(pdf), nb_id): + uploaded.append(pdf.name) + + return { + "success": True, + "notebook_id": nb_id, + "uploaded_sources": uploaded + } + + def generate_audio_overview(self, notebook_id: Optional[str] = None) -> Dict[str, Any]: + """Запускает генерацию двухголосного подкаста в NotebookLM.""" + if not self.is_authenticated(): + return { + "success": False, + "error": "NotebookLM не авторизован. Доступен fallback-режим генерации через Gemini 2.5." + } + + args = [self.cli_bin, "generate", "audio"] + if notebook_id: + args.extend(["--notebook", notebook_id]) + + try: + res = subprocess.run(args, capture_output=True, text=True, timeout=300) + if res.returncode == 0: + return {"success": True, "output": res.stdout.strip()} + return {"success": False, "error": res.stderr.strip() or res.stdout.strip()} + except Exception as e: + return {"success": False, "error": str(e)} + + def download_latest_audio(self, output_filename: str = "latest_podcast.m4a") -> Optional[str]: + """Скачивает готовый сгенерированный подкаст.""" + dest = PODCASTS_DIR / output_filename + args = [self.cli_bin, "download", "audio", "--output", str(dest)] + try: + res = subprocess.run(args, capture_output=True, text=True, timeout=120) + if res.returncode == 0 and dest.exists(): + return str(dest) + except Exception as e: + logger.error(f"Ошибка скачивания аудио: {e}") + return None + + def generate_fallback_dialogue_script(self, context_summary: str) -> Dict[str, Any]: + """ + Fallback генератор: при отсутствии сессии NotebookLM формирует + полноценный драматический сценарий двухголосного подкаста через Gemini 2.5 Pro. + """ + from google import genai + from google.genai import types + + api_key = os.getenv("GOOGLE_API_KEY", "AIzaSyAJMfQ_mRZ1picRXVFNaeFJ7fpAkZxUKyk") + client = genai.Client(api_key=api_key) + + prompt = f"""Ты — ведущий сценарист и главный аналитик юридического аудио-подкаста дома «Ливанова 37: Голос Закона». +Твоя задача — составить живой, увлекательный, юридически безупречный сценарий подкаста на 7-10 минут (в формате диалога двух экспертов): + +Ведущие: +1. Александр (Ведущий / Председатель Совета Дома) — задает острые вопросы жителей, приводит факты из писем госорганов, вскрывает уловки УК и прокуратуры. +2. Майя (Юрист-аналитик по жилищному праву) — профессионально, с юмором и железобетонными ссылками на законы (ЖК РФ, 59-ФЗ, ПП 354, Госстрой 170) объясняет, в чем неправы чиновники и как жителям защитить свои права. + +Контекст переписки и правовой базы дома: +{context_summary} + +Формат ответа: +Заголовок подкаста. +Текст реплик с пометками [Александр] и [Майя]. +В конце — четкие выводы и 3 практических шага для жителей дома. +""" + response = client.models.generate_content( + model="gemini-2.5-pro", + contents=prompt, + config=types.GenerateContentConfig( + temperature=0.4 + ) + ) + return { + "success": True, + "script": response.text, + "engine": "gemini-2.5-pro-fallback" + } + +if __name__ == "__main__": + generator = NotebookLMPodcastGenerator() + print("NotebookLM Auth:", generator.is_authenticated()) + if not generator.is_authenticated(): + print("Running fallback test...") + res = generator.generate_fallback_dialogue_script( + "Судебные тяжбы с УК по перерасчету отопления, игнорирование прокуратурой Заволжского района сроков 59-ФЗ." + ) + print("Generated script snippet:\n", res["script"][:400]) diff --git a/web/app.py b/web/app.py index c7e1455..c5c771d 100644 --- a/web/app.py +++ b/web/app.py @@ -910,6 +910,131 @@ async def api_sudrf_status(username: str = Depends(get_current_admin)): except Exception as e: return JSONResponse(status_code=500, content={"message": str(e)}) +@app.get("/legal", response_class=HTMLResponse) +async def legal_page(request: Request, username: str = Depends(get_current_admin)): + import fitz + legal_dir = Path("/home/matrixhasyou/mutt/legal_sources") + volumes_dir = Path("/home/matrixhasyou/mutt/mega_volumes") + podcasts_dir = Path("/home/matrixhasyou/swarm-services/domovoy-bot/podcasts") + + laws = [] + if legal_dir.exists(): + for f in sorted(legal_dir.glob("*.pdf")): + if f.is_file() and not f.name.startswith("."): + size_mb = f.stat().st_size / (1024 * 1024) + size_formatted = f"{size_mb:.2f} МБ" if size_mb >= 1 else f"{f.stat().st_size / 1024:.1f} КБ" + try: + doc = fitz.open(f) + pages = len(doc) + except Exception: + pages = 0 + title = f.stem.replace("_", " ").replace("Полный", "").strip() + laws.append({ + "filename": f.name, + "title": title, + "pages": pages, + "size_formatted": size_formatted + }) + + mega_volumes = [] + if volumes_dir.exists(): + for f in sorted(volumes_dir.glob("*.pdf")): + if f.is_file() and not f.name.startswith("."): + size_mb = f.stat().st_size / (1024 * 1024) + mega_volumes.append({ + "name": f.name, + "size_formatted": f"{size_mb:.1f} МБ" + }) + + latest_podcast = None + if podcasts_dir.exists(): + podcast_files = sorted(podcasts_dir.glob("*.*"), key=lambda x: x.stat().st_mtime, reverse=True) + if podcast_files: + p = podcast_files[0] + latest_podcast = { + "name": p.name, + "size": f"{p.stat().st_size / (1024 * 1024):.1f} МБ", + "date": datetime.fromtimestamp(p.stat().st_mtime).strftime("%d.%m.%Y %H:%M") + } + + return templates.TemplateResponse( + request=request, + name="legal.html", + context={ + "username": username, + "laws": laws, + "mega_volumes": mega_volumes, + "latest_podcast": latest_podcast + } + ) + +@app.get("/api/legal/download/{filename}") +async def api_download_law(filename: str, username: str = Depends(get_current_admin)): + fpath = Path("/home/matrixhasyou/mutt/legal_sources") / filename + if not fpath.exists() or not fpath.is_file(): + raise HTTPException(status_code=404, detail="Файл закона не найден") + return FileResponse(fpath, filename=filename, media_type="application/pdf") + +@app.get("/api/legal/download_volume/{filename}") +async def api_download_volume(filename: str, username: str = Depends(get_current_admin)): + fpath = Path("/home/matrixhasyou/mutt/mega_volumes") / filename + if not fpath.exists() or not fpath.is_file(): + raise HTTPException(status_code=404, detail="Файл тома переписки не найден") + return FileResponse(fpath, filename=filename, media_type="application/pdf") + +@app.get("/api/legal/podcasts/{filename}") +async def api_download_podcast(filename: str, username: str = Depends(get_current_admin)): + fpath = Path("/home/matrixhasyou/swarm-services/domovoy-bot/podcasts") / filename + if not fpath.exists() or not fpath.is_file(): + raise HTTPException(status_code=404, detail="Подкаст не найден") + return FileResponse(fpath, filename=filename) + +@app.post("/api/legal/ask_ai") +async def api_legal_ask_ai(request: Request, username: str = Depends(get_current_admin)): + try: + body = await request.json() + query = body.get("query", "").strip() + deep = body.get("deep", False) + if not query: + return JSONResponse(content={"success": False, "error": "Текст вопроса пуст"}) + + from services.ai_legal_engine import AILegalEngine + engine = AILegalEngine() + role = "lawyer" if deep else "resident" + res = await engine.query(prompt=query, role=role) + return JSONResponse(content=res) + except Exception as e: + return JSONResponse(content={"success": False, "error": str(e)}) + +@app.post("/api/legal/notebooklm_sync") +async def api_legal_notebooklm_sync(username: str = Depends(get_current_admin)): + try: + from services.notebooklm_podcast_generator import NotebookLMPodcastGenerator + generator = NotebookLMPodcastGenerator() + res = generator.sync_all_sources() + if res.get("success"): + return JSONResponse(content={"message": f"Синхронизация успешна! Загружено файлов: {len(res.get('uploaded_sources', []))}"}) + return JSONResponse(content={"message": f"Статус: {res.get('error', 'Сессия требует настройки')}"}) + except Exception as e: + return JSONResponse(status_code=500, content={"message": str(e)}) + +@app.post("/api/legal/generate_podcast") +async def api_legal_generate_podcast(username: str = Depends(get_current_admin)): + try: + from services.notebooklm_podcast_generator import NotebookLMPodcastGenerator + generator = NotebookLMPodcastGenerator() + if generator.is_authenticated(): + res = generator.generate_audio_overview() + return JSONResponse(content={"message": "Генерация Audio Overview в NotebookLM запущена!"}) + else: + # Fallback генерация сценария через Gemini 2.5 Pro + res = generator.generate_fallback_dialogue_script( + "Анализ судебных споров дома с УК и ГЖИ по отоплению, перерасчетам и отпискам прокуратуры." + ) + return JSONResponse(content={"message": f"Сгенерирован сценарий подкаста через Gemini 2.5 Pro: {len(res.get('script', ''))} симв."}) + except Exception as e: + return JSONResponse(status_code=500, content={"message": str(e)}) + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/web/archive/app_full.py b/web/archive/app_full.py new file mode 100644 index 0000000..a771a70 --- /dev/null +++ b/web/archive/app_full.py @@ -0,0 +1,524 @@ +""" +Web Admin Interface - Full Functional Admin Panel +Веб-интерфейс с полным функционалом управления ботом +""" +import logging +import json +from fastapi import FastAPI, Request, Depends, HTTPException, status, Form, Query +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, update, delete +from sqlalchemy.orm import Session +from datetime import datetime, timedelta +import secrets +import config +import aiohttp + +from database.db import AsyncSessionLocal, get_db +from database.models import ( + User, Message, Poll, Ad, PaymentReminder, Schedule, + Achievement, Thank, Event, EventParticipant, InitiativeGroup, + ProfileHistory, Announcement, Service, VerificationRequest, SpyLog +) + +logger = logging.getLogger(__name__) + +# Приложение +app = FastAPI( + title="Домовой Бот - Полная Админ Панель", + description="Веб-интерфейс с полным функционалом", + version="2.1.3" +) + +# Шаблоны +templates = Jinja2Templates(directory="web/templates") + +# Статика +app.mount("/static", StaticFiles(directory="web/static"), name="static") + +# Безопасность +security = HTTPBasic() + + +# ============================================================================= +# АВТОРИЗАЦИЯ +# ============================================================================= + +def get_current_admin(credentials: HTTPBasicCredentials = Depends(security)): + """Проверка админа""" + correct_username = secrets.compare_digest(credentials.username, config.WEB_ADMIN_LOGIN) + correct_password = secrets.compare_digest(credentials.password, config.WEB_ADMIN_PASSWORD) + + if not (correct_username and correct_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Неверное имя пользователя или пароль", + headers={"WWW-Authenticate": "Basic"}, + ) + + return credentials.username + + +# ============================================================================= +# ГЛАВНАЯ СТРАНИЦА +# ============================================================================= + +@app.get("/", response_class=HTMLResponse) +async def dashboard(request: Request, username: str = Depends(get_current_admin)): + """Главная страница - дашборд""" + async with AsyncSessionLocal() as session: + users_count = await session.execute(select(func.count(User.user_id))) + users_count = users_count.scalar() + + messages_count = await session.execute(select(func.count(Message.id))) + messages_count = messages_count.scalar() + + verified_count = await session.execute( + select(func.count(User.user_id)).where(User.verified == True) + ) + verified_count = verified_count.scalar() + + ig_count = await session.execute( + select(func.count(InitiativeGroup.id)).where(InitiativeGroup.is_active == True) + ) + ig_count = ig_count.scalar() + + return templates.TemplateResponse("dashboard.html", { + "request": request, + "username": username, + "stats": { + "users": users_count, + "messages": messages_count, + "verified": verified_count, + "ig_members": ig_count + } + }) + + +# ============================================================================= +# ПОЛЬЗОВАТЕЛИ - API +# ============================================================================= + +@app.get("/api/users") +async def api_get_users( + username: str = Depends(get_current_admin), + search: str = Query(None), + apartment: str = Query(None), + verified: bool = Query(None), + limit: int = Query(100) +): + """Получить список пользователей с фильтрами""" + async with AsyncSessionLocal() as session: + stmt = select(User) + + if search: + stmt = stmt.where( + (User.full_name.ilike(f"%{search}%")) | + (User.username.ilike(f"%{search}%")) + ) + + if apartment: + stmt = stmt.where(User.apartment.ilike(f"%{apartment}%")) + + if verified is not None: + stmt = stmt.where(User.verified == verified) + + stmt = stmt.order_by(User.user_id.desc()).limit(limit) + result = await session.execute(stmt) + users = list(result.scalars().all()) + + return { + "users": [ + { + "user_id": u.user_id, + "full_name": u.full_name, + "username": u.username, + "apartment": u.apartment, + "verified": u.verified, + "rating": u.rating, + "message_count": u.message_count, + "is_admin": u.is_admin, + "spy_score": u.spy_score, + "toxicity_score": u.toxicity_score + } + for u in users + ] + } + + +@app.post("/api/users/{user_id}/verify") +async def api_verify_user( + user_id: int, + username: str = Depends(get_current_admin) +): + """Верифицировать пользователя""" + async with AsyncSessionLocal() as session: + user = await session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="Пользователь не найден") + + user.verified = True + user.verification_date = datetime.utcnow() + await session.commit() + + # Уведомить пользователя + try: + async with aiohttp.ClientSession() as http_session: + await http_session.post( + f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage", + json={ + "chat_id": user_id, + "text": f"✅ Верификация подтверждена!\n\nТеперь у вас есть доступ ко всем функциям бота.", + "parse_mode": "HTML" + } + ) + except: + pass + + return {"success": True, "message": f"Пользователь {user_id} верифицирован"} + + +@app.post("/api/users/{user_id}/unverify") +async def api_unverify_user( + user_id: int, + reason: str = Form(None), + username: str = Depends(get_current_admin) +): + """Снять верификацию с пользователя""" + async with AsyncSessionLocal() as session: + user = await session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="Пользователь не найден") + + user.verified = False + await session.commit() + + # Уведомить пользователя + try: + async with aiohttp.ClientSession() as http_session: + await http_session.post( + f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage", + json={ + "chat_id": user_id, + "text": f"⚠️ Верификация отозвана\n\nПричина: {reason or 'Без указания причины'}\n\nУ вас есть 7 дней на повторную верификацию.", + "parse_mode": "HTML" + } + ) + except: + pass + + return {"success": True, "message": f"Верификация пользователя {user_id} отозвана"} + + +@app.post("/api/users/{user_id}/set_admin") +async def api_set_admin( + user_id: int, + is_admin: bool = Form(True), + username: str = Depends(get_current_admin) +): + """Назначить/снять права админа""" + async with AsyncSessionLocal() as session: + user = await session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="Пользователь не найден") + + user.is_admin = is_admin + await session.commit() + + return {"success": True, "message": f"Пользователь {user_id} {'назначен' if is_admin else 'снят с должности'} админа"} + + +@app.get("/api/initiative_group") +async def api_get_ig_members(username: str = Depends(get_current_admin)): + """Получить список участников ИГ""" + async with AsyncSessionLocal() as session: + stmt = select(InitiativeGroup).where(InitiativeGroup.is_active == True) + result = await session.execute(stmt) + members = list(result.scalars().all()) + + return { + "members": [ + { + "user_id": m.user_id, + "role": m.role, + "joined_at": m.joined_at.isoformat(), + "name": m.user.full_name if m.user else f"user{m.user_id}", + "apartment": m.user.apartment if m.user else None + } + for m in members + ] + } + + +@app.post("/api/initiative_group/add") +async def api_add_ig_member( + user_id: int = Form(...), + role: str = Form("member"), + username: str = Depends(get_current_admin) +): + """Добавить в ИГ""" + async with AsyncSessionLocal() as session: + # Проверяем есть ли уже + existing = await session.get(InitiativeGroup, user_id) + if existing: + existing.is_active = True + existing.role = role + else: + member = InitiativeGroup(user_id=user_id, role=role) + session.add(member) + + await session.commit() + + return {"success": True, "message": f"Пользователь {user_id} добавлен в ИГ"} + + +@app.post("/api/initiative_group/remove") +async def api_remove_ig_member( + user_id: int = Form(...), + username: str = Depends(get_current_admin) +): + """Удалить из ИГ""" + async with AsyncSessionLocal() as session: + member = await session.get(InitiativeGroup, user_id) + if member: + member.is_active = False + await session.commit() + + return {"success": True, "message": f"Пользователь {user_id} удалён из ИГ"} + + +# ============================================================================= +# ТЕЛЕФОНЫ - API +# ============================================================================= + +@app.get("/api/phones") +async def api_get_phones(username: str = Depends(get_current_admin)): + """Получить список телефонов""" + import os + phones_file = "data/phones.json" + + if os.path.exists(phones_file): + with open(phones_file, 'r', encoding='utf-8') as f: + phones_data = json.load(f) + return {"success": True, "data": phones_data} + else: + return {"success": False, "data": {}} + + +@app.post("/api/phones/update") +async def api_update_phones( + request: Request, + username: str = Depends(get_current_admin) +): + """Обновить телефоны""" + import os + data = await request.json() + phones_file = "data/phones.json" + + try: + with open(phones_file, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + return {"success": True, "message": "Телефоны обновлены"} + except Exception as e: + return {"success": False, "error": str(e)} + + +# ============================================================================= +# ОБЪЯВЛЕНИЯ - API +# ============================================================================= + +@app.get("/api/announcements") +async def api_get_announcements(username: str = Depends(get_current_admin)): + """Получить список объявлений""" + async with AsyncSessionLocal() as session: + stmt = select(Announcement).order_by(Announcement.created_at.desc()) + result = await session.execute(stmt) + announcements = list(result.scalars().all()) + + return { + "announcements": [ + { + "id": a.id, + "title": a.title, + "text": a.text, + "category": a.category, + "is_active": a.is_active, + "created_at": a.created_at.isoformat() + } + for a in announcements + ] + } + + +@app.post("/api/announcements/create") +async def api_create_announcement( + title: str = Form(...), + text: str = Form(...), + category: str = Form("general"), + username: str = Depends(get_current_admin) +): + """Создать объявление""" + async with AsyncSessionLocal() as session: + announcement = Announcement( + title=title, + text=text, + category=category, + created_by=username + ) + session.add(announcement) + await session.commit() + + return {"success": True, "message": "Объявление создано"} + + +# ============================================================================= +# ВЫПОЛНЕНИЕ КОМАНД БОТА +# ============================================================================= + +@app.post("/api/command") +async def api_command( + request: Request, + username: str = Depends(get_current_admin) +): + """Выполнить команду бота""" + data = await request.json() + command = data.get('command', '') + args = data.get('args', '') + + if not command: + return JSONResponse({"success": False, "error": "Нет команды"}) + + try: + async with aiohttp.ClientSession() as http_session: + full_command = f"/{command}" + if args: + full_command += f" {args}" + + result = await http_session.post( + f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage", + json={ + "chat_id": config.ADMIN_CHAT_ID, + "text": full_command + } + ) + + return JSONResponse({ + "success": True, + "message": f"Команда /{command} отправлена" + }) + except Exception as e: + return JSONResponse({ + "success": False, + "error": str(e) + }, status_code=500) + + +@app.get("/api/commands") +async def api_commands_list(username: str = Depends(get_current_admin)): + """Список доступных команд""" + commands = { + "admin": [ + {"cmd": "admin", "desc": "Админ-панель"}, + {"cmd": "stats", "desc": "Статистика чата"}, + {"cmd": "spies", "desc": "Подозрительные пользователи"}, + {"cmd": "users", "desc": "Список жильцов"}, + {"cmd": "verify", "desc": "Заявки на верификацию"}, + {"cmd": "export", "desc": "Экспорт данных"}, + {"cmd": "rating", "desc": "Рейтинг пользователей"}, + ], + "initiative_group": [ + {"cmd": "ig_add", "desc": "Добавить в ИГ"}, + {"cmd": "ig_remove", "desc": "Удалить из ИГ"}, + {"cmd": "ig_list", "desc": "Список ИГ"}, + {"cmd": "ig_stats", "desc": "Статистика ИГ"}, + {"cmd": "ig_broadcast", "desc": "Рассылка для ИГ"}, + ], + "events": [ + {"cmd": "event_add", "desc": "Добавить событие"}, + {"cmd": "events", "desc": "Список событий"}, + {"cmd": "participants", "desc": "Участники события"}, + ], + "security": [ + {"cmd": "unverify", "desc": "Отозвать верификацию"}, + {"cmd": "unverified_list", "desc": "Список не верифицированных"}, + {"cmd": "ban", "desc": "Забанить пользователя"}, + {"cmd": "unban", "desc": "Разбанить"}, + ], + "phones": [ + {"cmd": "add_phone", "desc": "Добавить телефон"}, + {"cmd": "edit_phone", "desc": "Редактировать телефон"}, + {"cmd": "remove_phone", "desc": "Удалить телефон"}, + ] + } + return JSONResponse({"commands": commands}) + + +# ============================================================================= +# СТРАНИЦЫ +# ============================================================================= + +@app.get("/users", response_class=HTMLResponse) +async def users_page(request: Request, username: str = Depends(get_current_admin)): + """Страница пользователей""" + return templates.TemplateResponse("users.html", { + "request": request, + "username": username + }) + + +@app.get("/initiative_group", response_class=HTMLResponse) +async def ig_page(request: Request, username: str = Depends(get_current_admin)): + """Страница ИГ""" + return templates.TemplateResponse("initiative_group.html", { + "request": request, + "username": username + }) + + +@app.get("/phones", response_class=HTMLResponse) +async def phones_page(request: Request, username: str = Depends(get_current_admin)): + """Страница телефонов""" + return templates.TemplateResponse("phones.html", { + "request": request, + "username": username + }) + + +@app.get("/announcements", response_class=HTMLResponse) +async def announcements_page(request: Request, username: str = Depends(get_current_admin)): + """Страница объявлений""" + return templates.TemplateResponse("announcements.html", { + "request": request, + "username": username + }) + + +@app.get("/commands", response_class=HTMLResponse) +async def commands_page(request: Request, username: str = Depends(get_current_admin)): + """Страница команд""" + return templates.TemplateResponse("commands.html", { + "request": request, + "username": username + }) + + +# ============================================================================= +# ЗАПУСК +# ============================================================================= + +def run_web_server(): + """Запустить веб-сервер""" + import uvicorn + uvicorn.run( + app, + host=config.WEB_ADMIN_HOST, + port=config.WEB_ADMIN_PORT, + log_level="info" + ) + + +if __name__ == "__main__": + run_web_server() diff --git a/web/archive/app_reference.py b/web/archive/app_reference.py new file mode 100644 index 0000000..f30c27a --- /dev/null +++ b/web/archive/app_reference.py @@ -0,0 +1,1547 @@ +""" +Web Admin Interface v3.0 - MEGA ADMIN PANEL +Полноценное управление ВСЕМ функционалом бота +""" +import logging +import json +import aiohttp +from pathlib import Path +from fastapi import FastAPI, Request, Depends, HTTPException, status, Form, File, UploadFile +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, update, delete, desc +from datetime import datetime, timedelta, timezone +import secrets +import config + +# ===== ЧАСОВОЙ ПОЯС (Ульяновск, UTC+4) ===== +LOCAL_TZ = timezone(timedelta(hours=4)) # Самара/Ульяновск + +def to_local(dt: datetime) -> datetime: + """Конвертировать UTC datetime в локальное время (Ульяновск, UTC+4)""" + if dt is None: + return None + if dt.tzinfo is None: + # Считаем что naive datetime — это UTC + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(LOCAL_TZ) + +def fmt_local(dt: datetime, fmt: str = '%d.%m.%Y %H:%M') -> str: + """Форматировать UTC datetime в строку локального времени""" + local_dt = to_local(dt) + return local_dt.strftime(fmt) if local_dt else '-' + +from database.db import AsyncSessionLocal +from database.models import ( + User, Message, Poll, Ad, PaymentReminder, Schedule, + VerificationRequest, Service, Announcement, Event, ScheduledPost, + Broadcast, BroadcastRead, Digest +) + +logger = logging.getLogger(__name__) + +app = FastAPI(title="Домовой Бот - MEGA Admin Panel", version="3.0.0") + +# Добавляем CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Разрешаем все origins (для локального использования) + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +templates = Jinja2Templates(directory="web/templates") +app.mount("/static", StaticFiles(directory="web/static"), name="static") +app.mount("/static/service_images", StaticFiles(directory="data/service_images"), name="service_images") +security = HTTPBasic() + + +def get_current_admin(credentials: HTTPBasicCredentials = Depends(security)): + """Проверка админа""" + correct_username = secrets.compare_digest(credentials.username, config.WEB_ADMIN_LOGIN) + correct_password = secrets.compare_digest(credentials.password, config.WEB_ADMIN_PASSWORD) + if not (correct_username and correct_password): + raise HTTPException(status_code=401, detail="Неверный логин или пароль", + headers={"WWW-Authenticate": "Basic"}) + return credentials.username + + +# ============================================================================ +# ГЛАВНАЯ +# ============================================================================ + +@app.get("/", response_class=HTMLResponse) +async def dashboard(request: Request, username: str = Depends(get_current_admin)): + """Дашборд""" + async with AsyncSessionLocal() as session: + users_count = (await session.execute(select(func.count(User.user_id)))).scalar() + verified_count = (await session.execute( + select(func.count(User.user_id)).where(User.verified == True))).scalar() + messages_count = (await session.execute(select(func.count(Message.id)))).scalar() + active_ads = (await session.execute( + select(func.count(Ad.ad_id)).where(Ad.is_active == True))).scalar() + active_polls = (await session.execute( + select(func.count(Poll.poll_id)).where(Poll.is_active == True))).scalar() + upcoming_events = (await session.execute( + select(func.count(Event.id)).where(Event.is_active == True))).scalar() + + # Получаем количество ожидающих верификации + from database.models import VerificationRequest + pending_count = (await session.execute( + select(func.count(VerificationRequest.id)).where(VerificationRequest.status == 'pending') + )).scalar() + + return templates.TemplateResponse("dashboard.html", { + "request": request, "username": username, + "stats": { + "users": users_count, "verified": verified_count, + "messages": messages_count, "ads": active_ads, + "polls": active_polls, "events": upcoming_events + }, + "pending_count": pending_count + }) + + +# ============================================================================ +# ПОЛЬЗОВАТЕЛИ +# ============================================================================ + +@app.get("/users", response_class=HTMLResponse) +async def users_page(request: Request, search: str = "", filter: str = "", username: str = Depends(get_current_admin)): + """Пользователи с фильтрами""" + async with AsyncSessionLocal() as session: + stmt = select(User) + + # Применяем фильтр + if filter == 'verified': + stmt = stmt.where(User.verified == True) + elif filter == 'unverified': + stmt = stmt.where(User.verified == False) + elif filter == 'active_unverified': + stmt = stmt.where(User.verified == False, User.message_count >= 50) + elif filter == 'ig': + # Только ИГ + from database.models import InitiativeGroup + ig_stmt = select(InitiativeGroup.user_id).where(InitiativeGroup.is_active == True) + ig_result = await session.execute(ig_stmt) + ig_user_ids = [row[0] for row in ig_result.all()] + stmt = stmt.where(User.user_id.in_(ig_user_ids)) + # filter == 'all' или пустой — показываем всех + + # Поиск + if search: + stmt = stmt.where( + (User.first_name.ilike(f"%{search}%")) | + (User.last_name.ilike(f"%{search}%")) | + (User.username.ilike(f"%{search}%")) | + (User.apartment.ilike(f"%{search}%")) + ) + + stmt = stmt.order_by(User.user_id.desc()).limit(100) + users = list((await session.execute(stmt)).scalars().all()) + + # Получаем список ИГ + from database.models import InitiativeGroup + ig_stmt = select(InitiativeGroup.user_id).where(InitiativeGroup.is_active == True) + ig_result = await session.execute(ig_stmt) + ig_user_ids = set(row[0] for row in ig_result.all()) + + # Получаем заявки на верификацию + from database.models import VerificationRequest + pending_stmt = select(VerificationRequest).where( + VerificationRequest.status == 'pending' + ).order_by(VerificationRequest.created_at.desc()) + pending_result = await session.execute(pending_stmt) + pending_requests = [ + { + 'id': req.id, + 'user_id': req.user_id, + 'user_name': req.user.full_name if req.user else None, + 'username': req.user.username if req.user else None, + 'apartment': req.apartment, + 'created_at': req.created_at + } + for req in pending_result.scalars().all() + ] + + # Статистика + total_count = (await session.execute(select(func.count(User.user_id)))).scalar() + verified_count = (await session.execute(select(func.count(User.user_id)).where(User.verified == True))).scalar() + unverified_count = total_count - verified_count + active_unverified = (await session.execute( + select(func.count(User.user_id)).where(User.verified == False, User.message_count >= 50) + )).scalar() + ig_count = len(ig_user_ids) + + return templates.TemplateResponse("users.html", + {"request": request, "username": username, "users": users, "search": search, "current_filter": filter, + "stats": {"total": total_count, "verified": verified_count, "unverified": unverified_count, "active_unverified": active_unverified, "ig": ig_count}, + "ig_user_ids": ig_user_ids, + "pending_requests": pending_requests, + "pending_count": len(pending_requests)}) + + +@app.post("/api/users/auto_verify") +async def api_auto_verify_users(username: str = Depends(get_current_admin)): + """Автоверификация активных пользователей (ТОЛЬКО с квартирой!)""" + async with AsyncSessionLocal() as session: + # Находим активных (50+ сообщений) которые не верифицированы + # И у которых УЖЕ ЕСТЬ квартира! + stmt = select(User).where( + User.verified == False, + User.message_count >= 50, + User.apartment != None, + User.apartment != '' + ) + result = await session.execute(stmt) + active_users = list(result.scalars().all()) + + # Верифицируем всех + verified_count = 0 + for user in active_users: + user.verified = True + if not user.verification_date: + user.verification_date = datetime.utcnow() + verified_count += 1 + + await session.commit() + + return JSONResponse({"success": True, "message": f"Верифицировано {verified_count} активных пользователей с квартирой"}) + + +@app.post("/api/user/{user_id}/update") +async def api_update_user(user_id: int, request: Request, + username: str = Depends(get_current_admin)): + """Обновить пользователя""" + data = await request.json() + async with AsyncSessionLocal() as session: + update_data = {} + if 'apartment' in data: update_data['apartment'] = data['apartment'].upper() + if 'phone' in data: update_data['phone'] = data['phone'] + if 'verified' in data: + update_data['verified'] = bool(data['verified']) + if data['verified']: update_data['verification_date'] = datetime.utcnow() + if 'banned' in data: update_data['is_banned'] = bool(data['banned']) + if 'rating' in data: update_data['rating'] = int(data['rating']) + if update_data: + await session.execute(update(User).where(User.user_id == user_id).values(**update_data)) + await session.commit() + return JSONResponse({"success": True}) + + +@app.get("/api/user/{user_id}") +async def api_get_user(user_id: int, username: str = Depends(get_current_admin)): + """Получить данные пользователя""" + async with AsyncSessionLocal() as session: + user = await session.get(User, user_id) + + if not user: + return JSONResponse({"error": "Пользователь не найден"}, status_code=404) + + return JSONResponse({ + "user": { + "id": user.user_id, + "username": user.username, + "first_name": user.first_name, + "last_name": user.last_name, + "apartment": user.apartment, + "phone": user.phone, + "verified": user.verified, + "rating": user.rating, + "message_count": user.message_count, + "is_banned": user.is_banned, + } + }) + + +@app.post("/api/user/{user_id}/ban") +async def api_ban_user(user_id: int, username: str = Depends(get_current_admin)): + """Забанить""" + async with AsyncSessionLocal() as session: + await session.execute(update(User).where(User.user_id == user_id).values(is_banned=True)) + await session.commit() + return JSONResponse({"success": True, "message": "Забанен"}) + + +@app.post("/api/user/{user_id}/unban") +async def api_unban_user(user_id: int, username: str = Depends(get_current_admin)): + """Разбанить""" + async with AsyncSessionLocal() as session: + await session.execute(update(User).where(User.user_id == user_id).values(is_banned=False)) + await session.commit() + return JSONResponse({"success": True, "message": "Разбанен"}) + + +@app.post("/api/user/{user_id}/verify") +async def api_verify_user(user_id: int, username: str = Depends(get_current_admin)): + """Верифицировать""" + async with AsyncSessionLocal() as session: + await session.execute(update(User).where(User.user_id == user_id).values( + verified=True, verification_date=datetime.utcnow())) + await session.commit() + return JSONResponse({"success": True, "message": "Верифицирован"}) + + +@app.post("/api/user/{user_id}/unverify") +async def api_unverify_user(user_id: int, username: str = Depends(get_current_admin)): + """Отозвать верификацию""" + async with AsyncSessionLocal() as session: + await session.execute(update(User).where(User.user_id == user_id).values(verified=False)) + await session.commit() + return JSONResponse({"success": True, "message": "Верификация отозвана"}) + + +@app.post("/api/user/{user_id}/make_admin") +async def api_make_admin(user_id: int, username: str = Depends(get_current_admin)): + """Назначить админом""" + async with AsyncSessionLocal() as session: + await session.execute(update(User).where(User.user_id == user_id).values(is_admin=True)) + await session.commit() + return JSONResponse({"success": True, "message": "Пользователь теперь админ"}) + + +@app.post("/api/user/{user_id}/remove_admin") +async def api_remove_admin(user_id: int, username: str = Depends(get_current_admin)): + """Снять админа""" + async with AsyncSessionLocal() as session: + await session.execute(update(User).where(User.user_id == user_id).values(is_admin=False)) + await session.commit() + return JSONResponse({"success": True, "message": "Админ снят"}) + + +@app.post("/api/user/{user_id}/add_to_ig") +async def api_add_to_ig(user_id: int, username: str = Depends(get_current_admin)): + """Добавить в Инициативную Группу""" + from services.initiative_group import InitiativeGroupService + + async with AsyncSessionLocal() as session: + ig_service = InitiativeGroupService(session) + success = await ig_service.add_member(user_id, 'member') + + if success: + return JSONResponse({"success": True, "message": "Добавлен в ИГ"}) + else: + return JSONResponse({"error": "Не удалось добавить в ИГ"}, status_code=400) + + +@app.post("/api/user/{user_id}/remove_from_ig") +async def api_remove_from_ig(user_id: int, username: str = Depends(get_current_admin)): + """Удалить из Инициативной Группы""" + from services.initiative_group import InitiativeGroupService + + async with AsyncSessionLocal() as session: + ig_service = InitiativeGroupService(session) + success = await ig_service.remove_member(user_id) + + if success: + return JSONResponse({"success": True, "message": "Удалён из ИГ"}) + else: + return JSONResponse({"error": "Не найден в ИГ"}, status_code=400) + + +# ============================================================================ +# ВЕРИФИКАЦИЯ +# ============================================================================ + +@app.get("/verification", response_class=HTMLResponse) +async def verification_page(request: Request, username: str = Depends(get_current_admin)): + """Верификация""" + async with AsyncSessionLocal() as session: + pending = list((await session.execute( + select(VerificationRequest).where(VerificationRequest.status == 'pending') + .order_by(VerificationRequest.created_at.desc()).limit(50) + )).scalars().all()) + unverified = list((await session.execute( + select(User).where(User.verified == False).order_by(User.join_date.desc()).limit(50) + )).scalars().all()) + return templates.TemplateResponse("verification.html", { + "request": request, "username": username, + "pending_requests": pending, "unverified_users": unverified + }) + + +@app.post("/api/verification/{req_id}/approve") +async def api_approve_verification(req_id: int, username: str = Depends(get_current_admin)): + """Одобрить верификацию""" + async with AsyncSessionLocal() as session: + req = (await session.execute(select(VerificationRequest).where( + VerificationRequest.id == req_id))).scalar_one_or_none() + if req: + req.status = 'approved' + req.reviewed_at = datetime.utcnow() + await session.execute(update(User).where(User.user_id == req.user_id).values( + verified=True, apartment=req.apartment, verification_date=datetime.utcnow())) + await session.commit() + return JSONResponse({"success": True}) + + +@app.post("/api/verification/{req_id}/reject") +async def api_reject_verification(req_id: int, username: str = Depends(get_current_admin)): + """Отклонить верификацию""" + async with AsyncSessionLocal() as session: + req = (await session.execute(select(VerificationRequest).where( + VerificationRequest.id == req_id))).scalar_one_or_none() + if req: + req.status = 'rejected' + req.reviewed_at = datetime.utcnow() + await session.commit() + return JSONResponse({"success": True}) + + +# ============================================================================ +# ТЕЛЕФОНЫ И СЛУЖБЫ +# ============================================================================ + +@app.get("/phones", response_class=HTMLResponse) +async def phones_page(request: Request, username: str = Depends(get_current_admin)): + """Телефоны и службы""" + async with AsyncSessionLocal() as session: + services = list((await session.execute(select(Service).order_by(Service.category, Service.name) + )).scalars().all()) + return templates.TemplateResponse("phones.html", { + "request": request, "username": username, "services": services + }) + + +@app.post("/api/service/add") +async def api_add_service(request: Request, username: str = Depends(get_current_admin)): + """Добавить службу""" + data = await request.json() + async with AsyncSessionLocal() as session: + service = Service( + name=data['name'], + phone=data['phone'], + category=data.get('category', 'other'), + description=data.get('description', ''), + verified_by_admin=True, + image_path=data.get('image_path', None) + ) + session.add(service) + await session.commit() + return JSONResponse({"success": True, "message": "Служба добавлена"}) + + +@app.post("/api/service/upload_image") +async def api_upload_service_image( + file: UploadFile = File(...), + category: str = Form(...), + username: str = Depends(get_current_admin) +): + """Загрузить картинку для службы""" + import os + import uuid + from pathlib import Path + + # Создаём папку для картинок + images_dir = Path("data/service_images") + images_dir.mkdir(parents=True, exist_ok=True) + + # Генерируем уникальное имя файла + file_extension = file.filename.split(".")[-1] if "." in file.filename else "jpg" + unique_filename = f"{category}_{uuid.uuid4().hex}.{file_extension}" + file_path = images_dir / unique_filename + + # Сохраняем файл + try: + with open(file_path, "wb") as f: + content = await file.read() + f.write(content) + + # Обновляем все службы этой категории + async with AsyncSessionLocal() as session: + await session.execute( + update(Service) + .where(Service.category == category) + .values(image_path=str(file_path)) + ) + await session.commit() + + return JSONResponse({ + "success": True, + "message": f"Картинка загружена", + "file_path": str(file_path) + }) + except Exception as e: + logger.error(f"Ошибка загрузки картинки: {e}") + return JSONResponse({ + "success": False, + "error": str(e) + }, status_code=500) + + +@app.post("/api/service/{service_id}/update") +async def api_update_service(service_id: int, request: Request, + username: str = Depends(get_current_admin)): + """Обновить службу""" + data = await request.json() + async with AsyncSessionLocal() as session: + await session.execute(update(Service).where(Service.id == service_id).values(**data)) + await session.commit() + return JSONResponse({"success": True}) + + +@app.post("/api/service/{service_id}/delete") +async def api_delete_service(service_id: int, username: str = Depends(get_current_admin)): + """Удалить службу""" + async with AsyncSessionLocal() as session: + await session.execute(delete(Service).where(Service.service_id == service_id)) + await session.commit() + return JSONResponse({"success": True}) + + +# ============================================================================ +# ОБЪЯВЛЕНИЯ +# ============================================================================ + +@app.get("/ads", response_class=HTMLResponse) +async def ads_page(request: Request, username: str = Depends(get_current_admin)): + """Объявления""" + async with AsyncSessionLocal() as session: + ads = list((await session.execute(select(Ad).order_by(Ad.created_at.desc()).limit(100) + )).scalars().all()) + return templates.TemplateResponse("ads.html", { + "request": request, "username": username, "ads": ads + }) + + +@app.post("/api/ad/add") +async def api_add_ad(request: Request, username: str = Depends(get_current_admin)): + """Добавить объявление""" + data = await request.json() + async with AsyncSessionLocal() as session: + ad = Ad( + title=data['title'], + description=data.get('text', ''), + category=data.get('category', 'other'), + user_id=config.ADMIN_USER_ID, + is_active=data.get('is_active', True), + is_moderated=True + ) + session.add(ad) + await session.commit() + return JSONResponse({"success": True, "message": "Объявление добавлено"}) + + +@app.post("/api/ad/{ad_id}/delete") +async def api_delete_ad(ad_id: int, username: str = Depends(get_current_admin)): + """Удалить объявление""" + async with AsyncSessionLocal() as session: + await session.execute(update(Ad).where(Ad.ad_id == ad_id).values(is_active=False)) + await session.commit() + return JSONResponse({"success": True}) + + +# ============================================================================ +# РАССЫЛКИ — единая страница создания и статистики +# ============================================================================ + +@app.get("/broadcast", response_class=HTMLResponse) +async def broadcast_page(request: Request, username: str = Depends(get_current_admin)): + """Рассылки — создание и статистика""" + from database.models import Broadcast, BroadcastRead + from sqlalchemy import func + + async with AsyncSessionLocal() as session: + # История рассылок + stmt = select(Broadcast).order_by(Broadcast.created_at.desc()).limit(50) + result = await session.execute(stmt) + broadcasts = list(result.scalars().all()) + + # Считаем прочтения для каждой + broadcasts_data = [] + for b in broadcasts: + read_count = (await session.execute( + select(func.count(BroadcastRead.id)).where(BroadcastRead.broadcast_id == b.id) + )).scalar() or 0 + + broadcasts_data.append({ + 'id': b.id, + 'text': b.text[:100] + '...' if len(b.text) > 100 else b.text, + 'sent_at': fmt_local(b.sent_at) if b.sent_at else '-', + 'total_sent': b.total_sent, + 'read_count': read_count, + 'unread_count': b.total_sent - read_count, + 'read_percent': round((read_count / b.total_sent * 100), 1) if b.total_sent > 0 else 0, + 'is_reminder_sent': b.is_reminder_sent, + 'broadcast_type': b.broadcast_type or 'regular', + }) + + return templates.TemplateResponse("broadcast.html", { + "request": request, + "username": username, + "broadcasts": broadcasts_data, + }) + + +# ============================================================================ +# ЛОГИРОВАНИЕ АКТИВНОСТИ +# ============================================================================ + +@app.get("/api/activity_log") +async def api_get_activity_log( + username: str = Depends(get_current_admin), + action_type: str = "", + days: int = 30 +): + """Получить логи активности""" + from sqlalchemy import text, desc + + async with AsyncSessionLocal() as session: + stmt = text(''' + SELECT * FROM activity_log + WHERE (:action_type = '' OR action_type = :action_type) + AND created_at >= datetime('now', '-' || :days || ' days') + ORDER BY created_at DESC + LIMIT 100 + ''') + + result = await session.execute(stmt, {"action_type": action_type, "days": str(days)}) + logs = [ + { + "id": row[0], + "user_id": row[1], + "action_type": row[2], + "action_data": row[3], + "recipients_count": row[4], + "created_at": row[5] + } + for row in result.all() + ] + + return JSONResponse({"success": True, "logs": logs}) + + +# ============================================================================ +# ОПРОСЫ +# ============================================================================ + +@app.get("/polls", response_class=HTMLResponse) +async def polls_page(request: Request, username: str = Depends(get_current_admin)): + """Опросы""" + async with AsyncSessionLocal() as session: + polls = list((await session.execute(select(Poll).order_by(Poll.created_at.desc()).limit(50) + )).scalars().all()) + return templates.TemplateResponse("polls.html", { + "request": request, "username": username, "polls": polls + }) + + +@app.post("/api/poll/create") +async def api_create_poll(request: Request, username: str = Depends(get_current_admin)): + """Создать опрос""" + data = await request.json() + async with AsyncSessionLocal() as session: + poll = Poll( + question=data['question'], + options=json.dumps(data['options']), + votes='{}', + created_by=config.ADMIN_USER_ID, + is_active=data.get('is_active', True), + message_id=0, # Заглушка + chat_id=config.ADMIN_CHAT_ID + ) + session.add(poll) + await session.commit() + return JSONResponse({"success": True, "message": "Опрос создан"}) + + +@app.post("/api/poll/{poll_id}/close") +async def api_close_poll(poll_id: int, username: str = Depends(get_current_admin)): + """Закрыть опрос""" + async with AsyncSessionLocal() as session: + await session.execute(update(Poll).where(Poll.poll_id == poll_id).values(is_active=False)) + await session.commit() + return JSONResponse({"success": True}) + + +# ============================================================================ +# СОБЫТИЯ +# ============================================================================ + +@app.get("/events", response_class=HTMLResponse) +async def events_page(request: Request, username: str = Depends(get_current_admin)): + """События""" + async with AsyncSessionLocal() as session: + events = list((await session.execute(select(Event).order_by(Event.event_date.desc()).limit(50) + )).scalars().all()) + return templates.TemplateResponse("events.html", { + "request": request, "username": username, "events": events + }) + + +@app.post("/api/event/add") +async def api_add_event(request: Request, username: str = Depends(get_current_admin)): + """Добавить событие""" + data = await request.json() + async with AsyncSessionLocal() as session: + event = Event( + title=data['title'], description=data.get('description', ''), + event_type=data.get('event_type', 'meeting'), + event_date=datetime.fromisoformat(data['event_date']), + created_by=config.ADMIN_USER_ID, + is_active=data.get('is_active', True) + ) + session.add(event) + await session.commit() + return JSONResponse({"success": True, "message": "Событие добавлено"}) + + +# ============================================================================ +# РАСПИСАНИЯ +# ============================================================================ + +@app.get("/schedules", response_class=HTMLResponse) +async def schedules_page(request: Request, username: str = Depends(get_current_admin)): + """Расписания""" + async with AsyncSessionLocal() as session: + schedules = list((await session.execute(select(Schedule).order_by(Schedule.start_time.desc()) + )).scalars().all()) + return templates.TemplateResponse("schedules.html", { + "request": request, "username": username, "schedules": schedules + }) + + +@app.post("/api/schedule/add") +async def api_add_schedule(request: Request, username: str = Depends(get_current_admin)): + """Добавить расписание""" + data = await request.json() + async with AsyncSessionLocal() as session: + schedule = Schedule( + title=data['title'], schedule_type=data.get('schedule_type', 'water'), + start_time=datetime.fromisoformat(data['start_time']), + end_time=datetime.fromisoformat(data['end_time']) if data.get('end_time') else None, + description=data.get('description', ''), is_active=data.get('is_active', True) + ) + session.add(schedule) + await session.commit() + return JSONResponse({"success": True, "message": "Расписание добавлено"}) + + +@app.post("/api/schedule/{schedule_id}/delete") +async def api_delete_schedule(schedule_id: int, username: str = Depends(get_current_admin)): + """Удалить расписание""" + async with AsyncSessionLocal() as session: + await session.execute(update(Schedule).where( + Schedule.schedule_id == schedule_id).values(is_active=False)) + await session.commit() + return JSONResponse({"success": True}) + + +# ============================================================================ +# ЭКСПОРТ +# ============================================================================ + +@app.get("/export", response_class=HTMLResponse) +async def export_page(request: Request, username: str = Depends(get_current_admin)): + """Экспорт""" + return templates.TemplateResponse("export.html", { + "request": request, "username": username + }) + + +@app.get("/api/export/json") +async def api_export_json(username: str = Depends(get_current_admin)): + """Экспорт JSON""" + from services.analytics import ChatAnalytics + async with AsyncSessionLocal() as session: + analytics = ChatAnalytics(session) + filepath = await analytics.export_to_json() + return JSONResponse({"success": True, "filepath": filepath}) + + +# ============================================================================ +# ЗАПЛАНИРОВАННЫЕ ПОСТЫ +# ============================================================================ + +@app.get("/scheduled_posts", response_class=HTMLResponse) +async def scheduled_posts_page(request: Request, username: str = Depends(get_current_admin)): + """Страница запланированных постов""" + return templates.TemplateResponse("scheduled_posts.html", { + "request": request, + "username": username + }) + + +@app.post("/api/scheduled_posts/create") +async def api_create_scheduled_post( + text: str = Form(...), + topic_name: str = Form(...), + recipients: str = Form("chat_only"), + scheduled_time: str = Form(...), + photo: UploadFile = File(None), + document: UploadFile = File(None), # Новый параметр — документ + username: str = Depends(get_current_admin) +): + """Создать запланированный пост (с фото ИЛИ документом)""" + from datetime import datetime + from config import ADMIN_CHAT_ID, get_topic_id, get_proxy_url + import aiohttp + + logger.info(f"📅 СОЗДАНИЕ ЗАПЛАНИРОВАННОГО ПОСТА: тема={topic_name}, время={scheduled_time}") + + try: + # Парсим время + scheduled_dt = datetime.fromisoformat(scheduled_time) + + # Если нет timezone — считаем что это локальное время (UTC+4) + # и конвертируем в UTC для scheduler + if scheduled_dt.tzinfo is None: + from datetime import timezone as tz + local_tz = tz(timedelta(hours=4)) # Ульяновск UTC+4 + scheduled_dt = scheduled_dt.replace(tzinfo=local_tz) # Помечаем как локальное + scheduled_dt = scheduled_dt.astimezone(tz.utc).replace(tzinfo=None) # Конвертируем в UTC + logger.info(f"⏰ Время конвертирована из локального в UTC: {scheduled_dt}") + else: + scheduled_dt = scheduled_dt.astimezone(tz.utc).replace(tzinfo=None) + logger.info(f"⏰ Время уже с timezone, конвертирована в UTC: {scheduled_dt}") + + # Проверяем что время в будущем (по UTC) + if scheduled_dt < datetime.utcnow(): + return JSONResponse({ + "success": False, + "error": "Время должно быть в будущем" + }, status_code=400) + + # Получаем topic_id из topic_name + topic_id = get_topic_id(topic_name) + + # Если есть фото - загружаем в Telegram + photo_file_id = None + if photo and photo.filename: + temp_path = Path("data") / f"scheduled_post_{photo.filename}" + with open(temp_path, "wb") as f: + content = await photo.read() + f.write(content) + + try: + async with aiohttp.ClientSession() as session: + url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendPhoto" + data = aiohttp.FormData() + data.add_field('chat_id', str(config.ADMIN_USER_ID)) + data.add_field('photo', open(temp_path, 'rb'), filename=photo.filename) + data.add_field('caption', 'Preview') + + async with session.post(url, data=data, proxy=get_proxy_url()) as resp: + result = await resp.json() + if result.get('ok'): + photo_file_id = result['result']['photo'][-1]['file_id'] + except Exception as e: + logger.error(f"Ошибка загрузки фото: {e}") + photo_file_id = None + + # Если есть документ - загружаем в Telegram + document_file_id = None + document_name = None + if document and document.filename: + # Проверяем расширение + allowed_ext = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.zip', '.rar'] + file_ext = Path(document.filename).suffix.lower() + if file_ext not in allowed_ext: + return JSONResponse({ + "success": False, + "error": f"Неподдерживаемый формат файла. Разрешены: {', '.join(allowed_ext)}" + }, status_code=400) + + # Сохраняем временно + temp_path = Path("data") / f"scheduled_doc_{document.filename}" + with open(temp_path, "wb") as f: + content = await document.read() + f.write(content) + + try: + async with aiohttp.ClientSession() as session: + url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendDocument" + data = aiohttp.FormData() + data.add_field('chat_id', str(config.ADMIN_USER_ID)) + + with open(temp_path, 'rb') as doc_file: + data.add_field('document', doc_file, filename=document.filename) + data.add_field('caption', 'Document preview') + + async with session.post(url, data=data, proxy=get_proxy_url()) as resp: + result = await resp.json() + if result.get('ok'): + document_file_id = result['result']['document']['file_id'] + document_name = document.filename + logger.info(f"✅ Документ загружен: {document_name}, file_id={document_file_id}") + else: + error_msg = result.get('description', 'Неизвестная ошибка') + logger.error(f"Ошибка загрузки документа: {error_msg}") + except Exception as e: + logger.error(f"Ошибка загрузки документа: {e}") + document_file_id = None + document_name = None + + # Удаляем временный файл + if temp_path.exists(): + temp_path.unlink() + + # Сохраняем в БД + async with AsyncSessionLocal() as session: + scheduled_post = ScheduledPost( + text=text, + photo_file_id=photo_file_id, + document_file_id=document_file_id, + document_name=document_name, + topic_id=topic_id, + topic_name=topic_name, + recipients=recipients, + scheduled_time=scheduled_dt, + status='pending', + created_by=config.ADMIN_USER_ID + ) + session.add(scheduled_post) + await session.commit() + await session.refresh(scheduled_post) + + logger.info(f"✅ Пост создан: id={scheduled_post.id}") + + attachment_info = "" + if document_name: + attachment_info = f" + 📄 {document_name}" + elif photo_file_id: + attachment_info = " + 📷 фото" + + return JSONResponse({ + "success": True, + "message": f"Пост запланирован на {scheduled_dt.strftime('%d.%m.%Y %H:%M')}{attachment_info}", + "post_id": scheduled_post.id + }) + + except ValueError as e: + return JSONResponse({ + "success": False, + "error": f"Неверный формат времени: {e}" + }, status_code=400) + except Exception as e: + logger.error(f"Ошибка создания поста: {e}") + return JSONResponse({ + "success": False, + "error": str(e) + }, status_code=500) + + +@app.get("/api/scheduled_posts/list") +async def api_list_scheduled_posts(username: str = Depends(get_current_admin)): + """Список запланированных постов""" + from datetime import timedelta + + async with AsyncSessionLocal() as session: + stmt = select(ScheduledPost).order_by(ScheduledPost.scheduled_time.desc()) + result = await session.execute(stmt) + posts = list(result.scalars().all()) + + # Часовой пояс пользователя (UTC+4 для Ульяновска) + user_tz_offset = timedelta(hours=4) # Europe/Ulyanovsk + + posts_data = [] + for post in posts: + # Конвертируем UTC в локальное время + local_time = post.scheduled_time + user_tz_offset + + posts_data.append({ + "id": post.id, + "text": post.text, + "topic_name": post.topic_name, + "topic_emoji": post.get_topic_emoji(), + "recipients": post.recipients, + "scheduled_time": local_time.strftime('%d.%m.%Y %H:%M'), # Уже локальное! + "status": post.status, + "status_emoji": post.get_status_emoji(), + "created_at": fmt_local(post.created_at), + "has_photo": bool(post.photo_file_id) + }) + + return JSONResponse({ + "success": True, + "posts": posts_data, + "timezone": "UTC+4 (Ульяновск/Самара)" + }) + + +@app.post("/api/scheduled_posts/{post_id}/cancel") +async def api_cancel_scheduled_post(post_id: int, username: str = Depends(get_current_admin)): + """Отменить запланированный пост""" + async with AsyncSessionLocal() as session: + stmt = select(ScheduledPost).where(ScheduledPost.id == post_id) + result = await session.execute(stmt) + post = result.scalar_one_or_none() + + if not post: + return JSONResponse({ + "success": False, + "error": "Пост не найден" + }, status_code=404) + + if post.status != 'pending': + return JSONResponse({ + "success": False, + "error": f"Нельзя отменить пост со статусом {post.status}" + }, status_code=400) + + post.status = 'cancelled' + await session.commit() + + logger.info(f"🚫 Пост отменён: id={post_id}") + + return JSONResponse({ + "success": True, + "message": "Пост отменён" + }) + + +# ============================================================================ +# НАСТРОЙКИ (перенесено в /broadcast) +# ============================================================================ +# Настройка лимита рассылки ИГ теперь в /broadcast + + +@app.post("/api/settings/ig_broadcast_limit") +async def api_set_ig_broadcast_limit( + request: Request, + username: str = Depends(get_current_admin) +): + """Установить лимит рассылки ИГ""" + from sqlalchemy import text + data = await request.json() + limit = data.get('limit', 0) + + async with AsyncSessionLocal() as session: + await session.execute(text(''' + INSERT OR REPLACE INTO settings (key, value, description) + VALUES ('ig_broadcast_limit', :limit, 'Лимит рассылки ИГ в секундах') + '''), {"limit": str(limit)}) + await session.commit() + + return JSONResponse({"success": True, "message": f"Лимит установлен: {limit} сек"}) + + +@app.get("/api/settings/ig_broadcast_limit") +async def api_get_ig_broadcast_limit( + username: str = Depends(get_current_admin) +): + """Получить лимит рассылки ИГ""" + from sqlalchemy import text + + async with AsyncSessionLocal() as session: + result = await session.execute(text(''' + SELECT value FROM settings WHERE key = 'ig_broadcast_limit' + ''')) + row = result.first() + + if row: + return JSONResponse({"success": True, "value": row[0]}) + else: + return JSONResponse({"success": True, "value": "0"}) + + +def run_web_server(host: str = "0.0.0.0", port: int = 8000): + """Запуск веб-сервера""" + import uvicorn + uvicorn.run(app, host=host, port=port) + + + +@app.post("/api/broadcast/send_reminder/{broadcast_id}") +async def api_send_broadcast_reminder(broadcast_id: int, username: str = Depends(get_current_admin)): + """Отправить напоминание непрочитавшим""" + from handlers.smart_broadcast import send_reminder_to_unread + from bot_instance import get_bot + + bot = get_bot() + sent_count = await send_reminder_to_unread(bot, broadcast_id) + + return JSONResponse({ + "success": True, + "message": f"Напоминание отправлено {sent_count} пользователям" + }) + + +@app.get("/api/broadcasts/list") +async def api_get_broadcasts_list(limit: int = 10, username: str = Depends(get_current_admin)): + """Получить список последних рассылок""" + from database.models import Broadcast, BroadcastRead + from sqlalchemy import func, desc + + async with AsyncSessionLocal() as session: + stmt = select(Broadcast).order_by(Broadcast.created_at.desc()).limit(limit) + result = await session.execute(stmt) + broadcasts = list(result.scalars().all()) + + total = (await session.execute(select(func.count(Broadcast.id)))).scalar() or 0 + + broadcasts_data = [] + for b in broadcasts: + read_count = (await session.execute( + select(func.count(BroadcastRead.id)).where(BroadcastRead.broadcast_id == b.id) + )).scalar() or 0 + + broadcasts_data.append({ + 'id': b.id, + 'text': b.text, + 'sent_at': fmt_local(b.sent_at) if b.sent_at else '-', + 'total_sent': b.total_sent, + 'read_count': read_count, + 'read_percent': round((read_count / b.total_sent * 100), 1) if b.total_sent > 0 else 0, + 'broadcast_type': b.broadcast_type, + }) + + return JSONResponse({ + "success": True, + "total": total, + "broadcasts": broadcasts_data + }) + + +@app.post("/api/broadcast/create") +async def api_create_broadcast( + text: str = Form(...), + photo: UploadFile = File(None), + recipients: str = Form("all_and_chat"), + has_read_button: bool = Form(True), + dry_run: bool = Form(False), # Режим теста БЕЗ отправки + username: str = Depends(get_current_admin) +): + """Создать и отправить рассылку из веб-панели""" + + # dry_run = тестирование без реальной отправки + if dry_run: + from database.models import User + from services.initiative_group import InitiativeGroupService + + async with AsyncSessionLocal() as session: + if recipients == 'ig_only': + ig_service = InitiativeGroupService(session) + ig_user_ids = await ig_service.get_member_ids(active_only=True) + stmt = select(User).where(User.user_id.in_(ig_user_ids)) + elif recipients == 'all_verified': + stmt = select(User).where(User.verified == True) + else: + stmt = select(User).where(User.verified == True) + + result = await session.execute(stmt) + users = list(result.scalars().all()) + + logger.info(f"🧪 DRY RUN рассылки: получатели={recipients}, пользователей={len(users)}") + return JSONResponse({ + "success": True, + "dry_run": True, + "message": f"Тестовый режим. Рассылка получила бы {len(users)} пользователей", + "recipients_count": len(users) + }) + + import aiohttp + from database.models import Broadcast + from datetime import datetime + + photo_file_id = None + + # Если есть фото - загружаем через Telegram Bot API + if photo and photo.filename: + try: + # Сохраняем временно + temp_path = Path("data") / f"broadcast_{photo.filename}" + content = await photo.read() + with open(temp_path, "wb") as f: + f.write(content) + + # Загружаем фото через Telegram API для получения file_id + async with aiohttp.ClientSession() as session: + url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendPhoto" + + # Если бот использует прокси - добавляем его в aiohttp + proxy = config.get_proxy_url() + + data = aiohttp.FormData() + data.add_field('chat_id', config.ADMIN_USER_ID) + + # Открываем файл и сразу передаём в FormData + with open(temp_path, 'rb') as photo_file: + data.add_field('photo', photo_file, filename=photo.filename) + data.add_field('caption', 'broadcast_test') + data.add_field('parse_mode', 'HTML') + + async with session.post(url, data=data, proxy=proxy) as resp: + text = await resp.text() + + # Проверяем что ответ успешный + if resp.status != 200: + logger.error(f"Ошибка загрузки фото: HTTP {resp.status}, ответ: {text}") + raise Exception(f"Telegram API вернул ошибку: HTTP {resp.status}") + + # Пытаемся распарсить JSON + try: + result = json.loads(text) + except json.JSONDecodeError as e: + logger.error(f"Ошибка парсинга JSON от Telegram API: {e}, ответ: {text}") + raise Exception(f"Telegram API вернул не-JSON ответ: {text[:100]}") + + if result.get('ok'): + photo_file_id = result['result']['photo'][-1]['file_id'] + logger.info(f"✅ Фото загружено, file_id: {photo_file_id}") + else: + error_msg = result.get('description', 'Неизвестная ошибка') + logger.error(f"Ошибка загрузки фото: {error_msg}") + raise Exception(f"Ошибка Telegram API: {error_msg}") + + # Удаляем временный файл + if temp_path.exists(): + temp_path.unlink() + + except Exception as e: + logger.error(f"❌ Ошибка загрузки фото для рассылки: {e}") + # Не падаем, просто продолжаем без фото + photo_file_id = None + + + # Получаем бота из глобального экземпляра + from bot_instance import get_bot + bot = get_bot() + + # Отправляем рассылку + try: + from handlers.smart_broadcast import send_smart_broadcast + success = await send_smart_broadcast(bot, text, recipients, photo_file_id) + + if success: + return JSONResponse({ + "success": True, + "message": f"Рассылка отправлена получателям: {recipients}" + }) + else: + return JSONResponse({ + "error": "Ошибка при отправке рассылки. Проверьте логи бота." + }, status_code=500) + except Exception as e: + logger.error(f"❌ Критическая ошибка при отправке рассылки: {e}") + import traceback + traceback.print_exc() + return JSONResponse({ + "error": f"Критическая ошибка: {str(e)}" + }, status_code=500) + + +@app.get("/api/broadcast/readers/{broadcast_id}") +async def api_get_broadcast_readers(broadcast_id: int, username: str = Depends(get_current_admin)): + """Получить список прочитавших""" + from database.models import BroadcastRead, User + + async with AsyncSessionLocal() as session: + stmt = ( + select(User, BroadcastRead.read_at) + .join(BroadcastRead, User.user_id == BroadcastRead.user_id) + .where(BroadcastRead.broadcast_id == broadcast_id) + .order_by(BroadcastRead.read_at.desc()) + ) + result = await session.execute(stmt) + rows = result.all() + + readers = [ + { + 'user_id': user.user_id, + 'name': user.full_name, + 'apartment': user.apartment, + 'read_at': fmt_local(read_at), + } + for user, read_at in rows + ] + + return JSONResponse({"success": True, "readers": readers}) + + +@app.get("/api/broadcast/non_readers/{broadcast_id}") +async def api_get_broadcast_non_readers(broadcast_id: int, username: str = Depends(get_current_admin)): + """Получить список НЕ прочитавших""" + from database.models import BroadcastRead, User + from sqlalchemy import select as sql_select, not_ + + async with AsyncSessionLocal() as session: + # Кто прочитал + read_stmt = select(BroadcastRead.user_id).where(BroadcastRead.broadcast_id == broadcast_id) + read_result = await session.execute(read_stmt) + read_user_ids = set(row[0] for row in read_result.all()) + + # Все верифицированные + all_stmt = select(User).where(User.verified == True) + all_result = await session.execute(all_stmt) + all_users = list(all_result.scalars().all()) + + # Кто НЕ прочитал + non_readers = [ + { + 'user_id': u.user_id, + 'name': u.full_name, + 'apartment': u.apartment, + } + for u in all_users if u.user_id not in read_user_ids + ] + + return JSONResponse({"success": True, "non_readers": non_readers}) + + +# ============================================================================ +# V3.1 - ДАЙДЖЕСТЫ +# ============================================================================ + +@app.post("/api/digest/generate") +async def api_generate_digest(username: str = Depends(get_current_admin)): + """Сгенерировать дайджест прямо сейчас (из веб-панели)""" + from services.digest_service import DigestService + from database.models import Digest + from datetime import date + + try: + async with AsyncSessionLocal() as session: + service = DigestService(session) + + # Генерируем + digest_data = await service.generate_weekly_digest() + + if not digest_data: + return JSONResponse({ + "success": False, + "error": "Не удалось сгенерировать дайджест. Проверьте логи." + }, status_code=500) + + # Проверяем нет ли уже за эту неделю + check_stmt = select(Digest).where( + Digest.week_number == digest_data['week_number'], + Digest.year == digest_data['year'] + ) + result = await session.execute(check_stmt) + existing = result.scalar_one_or_none() + + if existing: + return JSONResponse({ + "success": True, + "message": f"Дайджест за неделю #{digest_data['week_number']}/{digest_data['year']} уже существует (ID: #{existing.id}). Вы можете отправить его в разделе 'Архив'.", + "digest_id": existing.id, + "already_exists": True + }) + + # Сохраняем + digest_id = await service.save_digest(digest_data, status='pending_approval') + + # Отправляем превью админу в личку через HTTP + from config import get_proxy_url + + async with aiohttp.ClientSession() as http_session: + url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage" + keyboard = { + "inline_keyboard": [ + [{"text": "🌐 Утвердить в веб-панели", "url": "http://localhost:8000/digests"}] + ] + } + payload = { + 'chat_id': config.ADMIN_USER_ID, + 'text': f"📰 Дайджест за неделю готов!\n\n" + f"Неделя #{digest_data['week_number']}/{digest_data['year']}\n" + f"Период: {digest_data['period_start'].strftime('%d.%m')} - {digest_data['period_end'].strftime('%d.%m.%Y')}\n\n" + f"Зайдите в веб-панель для утверждения:\n" + f"http://localhost:8000/digests", + 'parse_mode': 'HTML', + 'reply_markup': json.dumps(keyboard) + } + async with http_session.post(url, json=payload, proxy=get_proxy_url()) as resp: + await resp.json() + + return JSONResponse({ + "success": True, + "message": f"✅ Дайджест #{digest_id} сгенерирован! Превью отправлено вам в Telegram.", + "digest_id": digest_id + }) + + except Exception as e: + import traceback + logger.error(f"Ошибка генерации дайджеста: {e}") + traceback.print_exc() + return JSONResponse({ + "success": False, + "error": f"Критическая ошибка: {str(e)}" + }, status_code=500) + + +@app.get("/digests", response_class=HTMLResponse) +async def digests_page(request: Request, username: str = Depends(get_current_admin)): + """Страница дайджестов""" + from services.digest_service import DigestService + + async with AsyncSessionLocal() as session: + service = DigestService(session) + pending = await service.get_pending_digests() + archive = await service.get_digest_archive() + + return templates.TemplateResponse("digests.html", { + "request": request, + "username": username, + "pending_digests": pending, + "archive_digests": archive, + }) + + +@app.post("/api/digest/{digest_id}/approve") +async def api_approve_digest(digest_id: int, username: str = Depends(get_current_admin)): + """Утвердить дайджест""" + from services.digest_service import DigestService + + async with AsyncSessionLocal() as session: + service = DigestService(session) + success = await service.update_digest_status( + digest_id, + 'approved', + approved_by=config.ADMIN_USER_ID + ) + + if success: + return JSONResponse({"success": True, "message": "Дайджест утверждён"}) + else: + return JSONResponse({"error": "Ошибка утверждения"}, status_code=500) + + +@app.post("/api/digest/{digest_id}/reject") +async def api_reject_digest(digest_id: int, request: Request, username: str = Depends(get_current_admin)): + """Отклонить дайджест""" + from services.digest_service import DigestService + + data = await request.json() + reason = data.get('reason', 'Не указана') + + async with AsyncSessionLocal() as session: + service = DigestService(session) + success = await service.update_digest_status( + digest_id, + 'rejected', + rejected_by=config.ADMIN_USER_ID, + rejection_reason=reason + ) + + if success: + return JSONResponse({"success": True, "message": "Дайджест отклонён"}) + else: + return JSONResponse({"error": "Ошибка отклонения"}, status_code=500) + + +@app.post("/api/digest/{digest_id}/send") +async def api_send_digest(digest_id: int, username: str = Depends(get_current_admin)): + """Отправить утверждённый дайджест""" + from services.digest_service import DigestService + from database.models import User, Digest + import aiohttp + + async with AsyncSessionLocal() as session: + # Получаем дайджест + digest = await session.get(Digest, digest_id) + if not digest or digest.status != 'approved': + return JSONResponse({"error": "Дайджест не утверждён"}, status_code=400) + + # Получаем всех верифицированных + stmt = select(User).where(User.verified == True) + result = await session.execute(stmt) + users = list(result.scalars().all()) + + text = digest.digest_text + sent_count = 0 + error_count = 0 + message_id = None + + # Сначала в чат + try: + async with aiohttp.ClientSession() as http_session: + url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage" + params = { + 'chat_id': config.ADMIN_CHAT_ID, + 'text': f"📰 Еженедельный дайджест\n\n{text}", + 'parse_mode': 'HTML' + } + async with http_session.post(url, json=params, timeout=30) as resp: + if resp.status == 200: + resp_data = await resp.json() + message_id = resp_data['result']['message_id'] + except Exception as e: + logger.error(f"Ошибка отправки в чат: {e}") + + # Потом пользователям + async with aiohttp.ClientSession() as http_session: + for user in users: + try: + url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage" + params = { + 'chat_id': user.user_id, + 'text': f"📰 Еженедельный дайджест\n\n{text}", + 'parse_mode': 'HTML' + } + async with http_session.post(url, json=params, timeout=10) as resp: + if resp.status == 200: + sent_count += 1 + except Exception as e: + logger.error(f"Ошибка отправки пользователю {user.user_id}: {e}") + error_count += 1 + + # Обновляем статус + service = DigestService(session) + await service.update_digest_status(digest_id, 'sent', message_id=message_id) + + return JSONResponse({ + "success": True, + "message": f"Дайджест отправлен {sent_count} пользователям, ошибок: {error_count}" + }) + + +@app.get("/api/digest/{digest_id}") +async def api_get_digest(digest_id: int, username: str = Depends(get_current_admin)): + """Получить данные дайджеста""" + from database.models import Digest + + async with AsyncSessionLocal() as session: + digest = await session.get(Digest, digest_id) + + if not digest: + return JSONResponse({"error": "Дайджест не найден"}, status_code=404) + + return JSONResponse({ + "digest": { + "id": digest.id, + "week": digest.week_number, + "year": digest.year, + "period_label": digest.period_label, + "status": digest.status, + "text": digest.digest_text or '', + "created_at": fmt_local(digest.created_at), + } + }) + + +@app.post("/api/digest/{digest_id}/update_text") +async def api_update_digest_text(digest_id: int, request: Request, username: str = Depends(get_current_admin)): + """Обновить текст дайджеста""" + from services.digest_service import DigestService + + data = await request.json() + new_text = data.get('text', '') + + async with AsyncSessionLocal() as session: + service = DigestService(session) + success = await service.update_digest_text(digest_id, new_text) + + if success: + return JSONResponse({"success": True, "message": "Текст обновлён"}) + else: + return JSONResponse({"error": "Ошибка обновления текста"}, status_code=500) + + +@app.get("/digest_settings", response_class=HTMLResponse) +async def digest_settings_page(request: Request, username: str = Depends(get_current_admin)): + """Страница настроек дайджеста""" + return templates.TemplateResponse("digest_settings.html", { + "request": request, + "username": username, + }) + + +if __name__ == "__main__": + run_web_server() diff --git a/web/templates/base.html b/web/templates/base.html index 9061b50..5cc3fc7 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -6,192 +6,278 @@