Compare commits

...

19 commits

Author SHA1 Message Date
Admin
d0606d6bde feat(security): Zero-Spam PM redirect, RBAC menu filter, add Emergency Electrician service with card 2026-10-22 21:54:21 +04:00
Admin
b9acc30bf4 style(legal): purge internal labels, remove question echo, clean status messages 2026-10-15 20:11:23 +04:00
Admin
1abadd9081 fix(legal): exclude gemini hallucination fallback, enforce strict NotebookLM RAG with STT for voice 2026-10-15 20:01:24 +04:00
Admin
36c5e51566 fix(legal): increase NotebookLM query timeout from 90s to 180s for deep audits 2026-10-15 12:02:45 +04:00
Admin
fca335423e feat(ui): standardize reply and inline menu button names in LKM37 UNIX-minimalism style 2026-10-13 21:59:51 +04:00
Admin
8b88344fac fix(scheduler): disable public chat weather broadcasts for LenKom37 2026-10-13 20:52:48 +04:00
Admin
6d530c9d97 style(web): standardize all web templates to clean light minimalist theme 2026-10-13 19:52:03 +04:00
Admin
3c31a6a529 feat(legal): connect NotebookLM LenKom37 Lawyer notebook and update legal UX 2026-10-13 19:24:33 +04:00
Admin
5c437b78b0 fix(legal): add model cascade fallback on 503/429 high demand spikes 2026-10-13 14:55:13 +04:00
Admin
2924765624 feat(legal): interactive buttons after jurist answer (ask next / exit) 2026-10-13 14:40:25 +04:00
Admin
b582a579aa fix(legal): японский минимализм в ответах юриста (убраны самоназвания, титулы, плашки источников и задержек) 2026-10-13 14:20:04 +04:00
Admin
8cb5ee7036 fix(web): запуск синхронизации почты с веб-кнопки (rw-монтирование mutt, MUTT_DIR env, поддержка split PDF) 2026-10-13 13:56:38 +04:00
Admin
99fd5ebe88 feat(security): ограничение ИИ-юриста только для админа и пользователей с правом has_ai_access 2026-10-13 12:28:44 +04:00
Admin
401fb3b32a feat(legal): прямой RAG через NotebookLM (Разбор ответов строго по ЖК РФ) без галлюцинаций 2026-10-13 12:13:53 +04:00
Admin
0dcea20739 fix(legal): NotebookLM priority query, fix model name to gemini-2.5-flash fallback 2026-10-13 11:17:07 +04:00
Admin
0aac30187b fix(bot): приоритет роутера legal_advisor, кнопка в меню и всеядные команды 2026-10-13 10:45:48 +04:00
Admin
d4f572cbb4 fix(naming): канонический адрес и блокнот МКД ЛенКом 37 (вместо ошибочного Ливанова) 2026-10-13 09:28:29 +04:00
Admin
c49003bf39 feat(ui): Lissyara UNIX-Minimalism redesign and NotebookLM podcast integration 2026-10-13 09:20:35 +04:00
Admin
6a4610748e feat(ai): integrate AI Legal Engine 2.0 with Gemini 2.5 and multimodal voice support 2026-10-12 23:17:17 +04:00
34 changed files with 5020 additions and 1771 deletions

1
.gitignore vendored
View file

@ -4,6 +4,7 @@ __pycache__/
*$py.class *$py.class
*.so *.so
.Python .Python
venv-ai/
venv/ venv/
venv-google/ venv-google/
env/ env/

View file

@ -90,7 +90,7 @@ if not ADMIN_USER_ID:
SAFETY_MODE = os.getenv('SAFETY_MODE', 'production').lower() # 'production' или 'safe' SAFETY_MODE = os.getenv('SAFETY_MODE', 'production').lower() # 'production' или 'safe'
# ===== ДИНАМИЧЕСКИЕ ПУТИ (ПЕРЕНОСИМОСТЬ) ===== # ===== ДИНАМИЧЕСКИЕ ПУТИ (ПЕРЕНОСИМОСТЬ) =====
MUTT_DIR = os.getenv('MUTT_DIR', '/app/mutt' if os.path.exists('/app/mutt') else str(BASE_DIR.parent.parent / 'mutt')) MUTT_DIR = os.getenv('MUTT_DIR', '/home/matrixhasyou/mutt' if os.path.exists('/home/matrixhasyou/mutt') else ('/app/mutt' if os.path.exists('/app/mutt') else str(BASE_DIR.parent.parent / 'mutt')))
GOOGLE_CREDS_FILE = os.getenv('GOOGLE_CREDS_FILE', '/app/config/google_creds.json' if os.path.exists('/app/config/google_creds.json') else str(BASE_DIR.parent.parent / 'domovoy_google_creds.json')) GOOGLE_CREDS_FILE = os.getenv('GOOGLE_CREDS_FILE', '/app/config/google_creds.json' if os.path.exists('/app/config/google_creds.json') else str(BASE_DIR.parent.parent / 'domovoy_google_creds.json'))

View file

@ -1,5 +1,11 @@
{ {
"emergency": [ "emergency": [
{
"name": "Аварийная служба (электрика)",
"phone": "54-69-29",
"description": "Круглосуточно при замыканиях в щитках, авариях электросетей и ЧП. Федеральный: 8 (8422) 54-69-29",
"priority": 1
},
{ {
"name": "Скорая помощь", "name": "Скорая помощь",
"phone": "103", "phone": "103",

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View file

@ -12,14 +12,20 @@ services:
- TZ=Europe/Ulyanovsk - TZ=Europe/Ulyanovsk
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ./.env:/app/.env:ro
- ./config.py:/app/config.py:ro
- ./main.py:/app/main.py - ./main.py:/app/main.py
- ./logs:/app/logs - ./logs:/app/logs
- ./exports:/app/exports - ./exports:/app/exports
- ./data:/app/data - ./data:/app/data
- ./handlers:/app/handlers - ./handlers:/app/handlers
- ./keyboards:/app/keyboards
- ./utils:/app/utils
- ./services:/app/services - ./services:/app/services
- ../libs/swarmlib:/app/swarmlib:ro - ../libs/swarmlib:/app/swarmlib:ro
- /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro - /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro
- /home/matrixhasyou/.notebooklm:/root/.notebooklm:rw
- ./venv-ai:/app/venv-ai:ro
logging: logging:
driver: "json-file" driver: "json-file"
options: options:
@ -35,16 +41,28 @@ services:
environment: environment:
- PYTHONPATH=/app:/app/services - PYTHONPATH=/app:/app/services
- GOOGLE_CREDS_FILE=/home/matrixhasyou/domovoy_google_creds.json - GOOGLE_CREDS_FILE=/home/matrixhasyou/domovoy_google_creds.json
- GOOGLE_API_KEY=AIzaSyAJMfQ_mRZ1picRXVFNaeFJ7fpAkZxUKyk
- MUTT_DIR=/home/matrixhasyou/mutt
- TZ=Europe/Ulyanovsk - TZ=Europe/Ulyanovsk
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ./.env:/app/.env:ro
- ./config.py:/app/config.py:ro
- ./web:/app/web
- ./podcasts:/app/podcasts
- ./logs:/app/logs - ./logs:/app/logs
- ./exports:/app/exports - ./exports:/app/exports
- ./data:/app/data - ./data:/app/data
- ./handlers:/app/handlers - ./handlers:/app/handlers
- ./keyboards:/app/keyboards
- ./utils:/app/utils
- ./services:/app/services - ./services:/app/services
- /home/matrixhasyou/mutt:/home/matrixhasyou/mutt:rw
- /home/matrixhasyou/domovoy_drive_sync.py:/home/matrixhasyou/domovoy_drive_sync.py:ro
- ../libs/swarmlib:/app/swarmlib:ro - ../libs/swarmlib:/app/swarmlib:ro
- /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro - /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro
- /home/matrixhasyou/.notebooklm:/root/.notebooklm:rw
- ./venv-ai:/app/venv-ai:ro
logging: logging:
driver: "json-file" driver: "json-file"
options: options:

View file

@ -58,104 +58,8 @@ async def process_swarm_task(message: Message, state: FSMContext):
# ... (код остается прежним) # ... (код остается прежним)
pass pass
# ============================================================================= # (Устаревший блок NotebookLM перенесен в специализированный модуль handlers/legal_advisor.py)
# ЮРИДИЧЕСКИЙ ПОМОЩНИК (NotebookLM)
# =============================================================================
@router.message(Command('jurist'))
@router.message(F.text == '⚖️ Юрист (ЖК РФ)')
@admin_only
async def cmd_jurist(message: Message, state: FSMContext):
"""Активация юридического помощника"""
await message.answer(
"⚖️ <b>ЮРИДИЧЕСКИЙ ПОМОЩНИК (ЖК РФ)</b>\n\n"
"Напишите ваш вопрос для анализа на основе судебных дел и ЖК РФ.\n\n"
"<i>Я проанализирую ваш запрос через NotebookLM.</i>",
parse_mode='HTML'
)
await state.set_state(JuristState.waiting_for_question)
@router.callback_query(F.data == 'admin_jurist')
@admin_callback_only
async def cb_admin_jurist(callback: CallbackQuery, state: FSMContext):
"""Callback-активация юридического помощника"""
await callback.answer()
await callback.message.answer(
"⚖️ <b>ЮРИДИЧЕСКИЙ ПОМОЩНИК (ЖК РФ)</b>\n\n"
"Напишите ваш вопрос для анализа на основе судебных дел и ЖК РФ.\n\n"
"<i>Я проанализирую ваш запрос через NotebookLM.</i>",
parse_mode='HTML'
)
await state.set_state(JuristState.waiting_for_question)
@router.message(JuristState.waiting_for_question)
@admin_only
async def process_jurist_question(message: Message, state: FSMContext):
"""Обработка вопроса к юристу через NotebookLM"""
status_msg = await message.answer(
"⏳ <i>Отправляю запрос в NotebookLM, анализирую судебные материалы и законы (это займет около 10-15 секунд)...</i>",
parse_mode='HTML'
)
import asyncio
import os
# Динамический поиск бинарника nlm
nlm_bin = "nlm"
for p in ["/app/bin/nlm", "/usr/local/bin/nlm", os.path.expanduser("~/.local/bin/nlm"), "/home/matrixhasyou/.local/bin/nlm"]:
if os.path.exists(p):
nlm_bin = p
break
cmd = [
nlm_bin, "query", "notebook",
"00ed7d92-1ab2-4159-ac72-1d0c4be21377",
message.text
]
env = os.environ.copy()
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env
)
stdout, stderr = await proc.communicate()
output = stdout.decode('utf-8').strip()
error = stderr.decode('utf-8').strip()
# Убираем предупреждения о новой версии из вывода nlm
if "Update available:" in output:
output = output.split("Update available:")[0].strip()
if proc.returncode == 0:
import html
import re
# Заменяем <br>, <br/>, <br /> на переносы строк
clean_output = output.replace("<br>", "\n").replace("<br/>", "\n").replace("<br />", "\n")
# Экранируем спецсимволы для parse_mode='HTML'
safe_output = html.escape(clean_output)
# Форматируем **жирный** текст -> <b>жирный</b>
safe_output = re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', safe_output, flags=re.DOTALL)
# Форматируем [ссылка](url) -> <a href="\2">\1</a>
safe_output = re.sub(r'\[(.*?)\]\((.*?)\)', r'<a href="\2">\1</a>', safe_output, flags=re.DOTALL)
if len(safe_output) > 4000:
parts = [safe_output[i:i+4000] for i in range(0, len(safe_output), 4000)]
await status_msg.edit_text(f"⚖️ <b>Анализ NotebookLM (часть 1):</b>\n\n{parts[0]}", parse_mode='HTML')
for part in parts[1:]:
await message.answer(part, parse_mode='HTML')
else:
await status_msg.edit_text(f"⚖️ <b>Анализ NotebookLM:</b>\n\n{safe_output}", parse_mode='HTML')
else:
await status_msg.edit_text(f"❌ <b>Ошибка запроса к NotebookLM:</b>\n{error or output}")
except Exception as e:
await status_msg.edit_text(f"❌ <b>Внутренняя ошибка бота:</b> {str(e)}")
finally:
await state.clear()
@router.message(F.text & ~F.text.startswith('/') & F.chat.type == 'private') @router.message(F.text & ~F.text.startswith('/') & F.chat.type == 'private')
@ -226,16 +130,25 @@ async def cmd_legal_sync(message: Message):
@router.message(Command('admin')) @router.message(Command('admin'))
@router.message(F.text.in_(['⚙️ Управление', '⚙️ Админка', '⚙️ АДМИНКА']))
@admin_only @admin_only
async def cmd_admin(message: Message): async def cmd_admin(message: Message):
"""Админ-меню (только в личке!)""" """Админ-меню (только в личке!)"""
# Проверяем что это личка # Если вызвано в группе — тихо удаляем из чата и отправляем админу в ЛС
if message.chat.type != 'private': if message.chat.type != 'private':
await message.answer( try:
'⚠️ <b>Админ-панель доступна только в личных сообщениях!</b>\n\n' await message.delete()
'Напишите боту в личку: /admin', except Exception:
pass
try:
await message.bot.send_message(
chat_id=message.from_user.id,
text='🛡️ <b>Панель администратора</b>\n\nВыберите раздел:',
reply_markup=get_admin_main_menu(),
parse_mode='HTML' parse_mode='HTML'
) )
except Exception:
pass
return return
await message.answer( await message.answer(
@ -246,7 +159,7 @@ async def cmd_admin(message: Message):
) )
@router.message(F.text == '📊 Статистика') @router.message(F.text.in_(['📊 Статистика', '/stats']))
@admin_only @admin_only
async def msg_stats_button(message: Message): async def msg_stats_button(message: Message):
"""Кнопка Статистика""" """Кнопка Статистика"""
@ -258,19 +171,19 @@ async def msg_stats_button(message: Message):
await message.answer(text, parse_mode='HTML') await message.answer(text, parse_mode='HTML')
@router.message(F.text == '🕵️ Шпионы') @router.message(F.text.in_(['🛡️ Фильтр посторонних', '🕵️ Шпионы', '/spies']))
@admin_only @admin_only
async def msg_spies_button(message: Message): async def msg_spies_button(message: Message):
"""Кнопка Шпионы""" """Кнопка Шпионы / Фильтр посторонних"""
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
spy_detector = SpyDetector(session) spy_detector = SpyDetector(session)
spies = await spy_detector.get_spies() spies = await spy_detector.get_spies()
if not spies: if not spies:
await message.answer('🟢 Шпионов не обнаружено') await message.answer('🟢 Посторонних и шпионов не обнаружено')
return return
text = '🚨 <b>ШПИОНЫ</b> 🚨\n\n' text = '🚨 <b>ПОСТОРОННИЕ / ШПИОНЫ</b> 🚨\n\n'
for spy in spies: for spy in spies:
name = spy.full_name or f'user{spy.user_id}' name = spy.full_name or f'user{spy.user_id}'
apt = spy.apartment or '?' apt = spy.apartment or '?'
@ -279,16 +192,16 @@ async def msg_spies_button(message: Message):
await message.answer(text, parse_mode='HTML') await message.answer(text, parse_mode='HTML')
@router.message(F.text == '👥 Пользователи') @router.message(F.text.in_(['👥 Список жителей', '👥 Пользователи', '/users']))
@admin_only @admin_only
async def msg_users_button(message: Message): async def msg_users_button(message: Message):
"""Кнопка Пользователи""" """Кнопка Пользователи / Список жителей"""
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
stmt = select(User).limit(50) stmt = select(User).limit(50)
result = await session.execute(stmt) result = await session.execute(stmt)
users = list(result.scalars().all()) users = list(result.scalars().all())
text = f'👥 <b>Пользователи</b> (50):\n\n' text = f'👥 <b>Список жителей</b> (50):\n\n'
for user in users: for user in users:
status = '✅' if user.verified else '⏳' status = '✅' if user.verified else '⏳'
name = user.full_name or f'user{user.user_id}' name = user.full_name or f'user{user.user_id}'
@ -300,7 +213,7 @@ async def msg_users_button(message: Message):
await message.answer(text, parse_mode='HTML') await message.answer(text, parse_mode='HTML')
@router.message(F.text == '❓ Помощь') @router.message(F.text.in_(['❓ Справка', '❓ Помощь', '/help']), lambda m: config.is_admin(m.from_user.id))
@admin_only @admin_only
async def msg_help_button(message: Message): async def msg_help_button(message: Message):
"""Кнопка Помощь - справка по командам""" """Кнопка Помощь - справка по командам"""
@ -795,6 +708,7 @@ async def cmd_spies(message: Message):
@router.message(Command('export')) @router.message(Command('export'))
@router.message(Command('экспорт')) @router.message(Command('экспорт'))
@router.message(F.text.in_(['📥 Выгрузка базы', '📥 Экспорт JSON']))
@admin_only @admin_only
async def cmd_export(message: Message): async def cmd_export(message: Message):
"""Экспорт данных""" """Экспорт данных"""

348
handlers/legal_advisor.py Normal file
View file

@ -0,0 +1,348 @@
#!/usr/bin/env python3
"""
Legal Advisor Router (Юридический Помощник Домового 2.0).
Обрабатывает текстовые и голосовые юридические вопросы жильцов и совета дома.
Поддерживает ролевую модель:
- Жилец: простой бытовой язык + бланки заявлений.
- Юрист (has_ai_access=1): статьи ЖК РФ, судебная практика, досудебные претензии.
- Админ (Александр Тырин): стратегический анализ, сопоставление ответов чиновников.
Строгий шлюз Zero-Spam: ответы выдаются в личку либо направляются на одобрение админу.
"""
import os
import re
import html
import logging
from pathlib import Path
from aiogram import Router, F, Bot
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy import select
from config import ADMIN_USER_ID
from database.db import AsyncSessionLocal
from database.models import User
from services.ai_legal_engine import legal_engine
log = logging.getLogger("LegalAdvisorHandler")
router = Router()
TEMP_VOICE_DIR = Path(__file__).resolve().parent.parent / "temp_voice"
TEMP_VOICE_DIR.mkdir(parents=True, exist_ok=True)
class LegalQuestionState(StatesGroup):
waiting_for_question = State()
def format_telegram_html(text: str) -> str:
"""Безопасное форматирование текста Markdown -> HTML для Telegram."""
clean = text.replace("<br>", "\n").replace("<br/>", "\n")
safe = html.escape(clean)
# **жирный** -> <b>жирный</b>
safe = re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', safe, flags=re.DOTALL)
# *курсив* -> <i>курсив</i>
safe = re.sub(r'\*(.*?)\*', r'<i>\1</i>', safe, flags=re.DOTALL)
# [ссылка](url) -> <a href="url">ссылка</a>
safe = re.sub(r'\[(.*?)\]\((https?://.*?)\)', r'<a href="\2">\1</a>', safe, flags=re.DOTALL)
return safe
async def send_split_message(message: Message, header: str, body: str):
"""Отправляет длинный ответ аккуратными частями с защитой от разрыва тегов."""
# Разбиваем исходный текст по параграфам
paragraphs = body.split("\n\n")
chunks = []
current_chunk = []
current_len = 0
for p in paragraphs:
p_len = len(p) + 2
if current_len + p_len > 3200 and current_chunk:
chunks.append("\n\n".join(current_chunk))
current_chunk = [p]
current_len = p_len
else:
current_chunk.append(p)
current_len += p_len
if current_chunk:
chunks.append("\n\n".join(current_chunk))
if not chunks:
chunks = [body]
for idx, chunk in enumerate(chunks, 1):
if idx == 1:
if header:
part_header = f"{header}\n\n" if len(chunks) == 1 else f"{header} (Часть 1/{len(chunks)}):\n\n"
else:
part_header = "" if len(chunks) == 1 else f"<i>(Часть 1/{len(chunks)}):</i>\n\n"
else:
part_header = f"<i>(Продолжение {idx}/{len(chunks)}):</i>\n\n"
part_text = f"{part_header}{format_telegram_html(chunk)}"
# Прикрепляем инлайн-кнопки только к последней части ответа
reply_markup = get_legal_nav_keyboard() if idx == len(chunks) else None
try:
await message.answer(part_text, parse_mode='HTML', reply_markup=reply_markup)
except Exception as e:
log.warning(f"HTML send failed ({e}), falling back to plain text")
if header:
plain_header = header if idx == 1 else f"(Продолжение {idx}/{len(chunks)}):"
await message.answer(f"{plain_header}\n\n{chunk}", reply_markup=reply_markup)
else:
plain_header = "" if idx == 1 else f"(Продолжение {idx}/{len(chunks)}):\n\n"
await message.answer(f"{plain_header}{chunk}", reply_markup=reply_markup)
def get_legal_nav_keyboard() -> InlineKeyboardMarkup:
"""Инлайн-кнопки навигации после ответа юриста."""
kb = [
[
InlineKeyboardButton(text="📝 Задать следующий вопрос", callback_data="jurist_ask_next"),
InlineKeyboardButton(text="🚪 Выход из режима юриста", callback_data="jurist_exit")
]
]
return InlineKeyboardMarkup(inline_keyboard=kb)
async def determine_user_role(user_id: int) -> str:
"""Определяет роль пользователя в системе."""
if user_id == ADMIN_USER_ID:
return "admin"
try:
async with AsyncSessionLocal() as session:
stmt = select(User).where(User.user_id == user_id)
user = (await session.execute(stmt)).scalar_one_or_none()
if user and (user.is_admin or user.has_ai_access):
return "lawyer"
except Exception as e:
log.warning(f"Failed to fetch user {user_id} role: {e}")
return "resident"
# === КОМАНДЫ АКТИВАЦИИ ===
@router.message(Command("jurist", "юрист", "вопрос", "help_law"))
@router.message(F.text.casefold().in_(["/юрист", "/jurist", "/вопрос", "юрист", "⚖️ юрист (жк рф)"]))
@router.message(F.text.casefold().startswith(("/юрист", "/jurist")))
async def cmd_legal_advisor(message: Message, state: FSMContext, bot: Bot):
# Защита от спама в группе: в группе тихо удаляем и перенаправляем в ЛС
if message.chat.type in ('group', 'supergroup'):
try:
await message.delete()
except Exception:
pass
try:
await bot.send_message(
chat_id=message.from_user.id,
text="⚖️ <b>Юридический помощник ЖК РФ</b> доступен здесь в личных сообщениях.\nДля активации доступа обратитесь к @ktybdsq.",
parse_mode='HTML'
)
except Exception:
pass
return
role = await determine_user_role(message.from_user.id)
if role not in ("admin", "lawyer"):
await message.answer(
"Для использования юридического помощника обратитесь к @ktybdsq.",
parse_mode='HTML'
)
return
# Проверяем, не задал ли пользователь вопрос прямо в команде: /юрист Какой срок ответа?
parts = (message.text or "").split(maxsplit=1)
if len(parts) > 1 and parts[0].casefold() in ("/юрист", "/jurist", "/вопрос"):
inline_question = parts[1].strip()
if len(inline_question) > 3:
status_msg = await message.answer("⏳ <i>Изучаю документы, сметы, отчеты и решения дома № 37...</i>", parse_mode='HTML')
result = await legal_engine.query(prompt=inline_question, role=role)
await status_msg.delete()
if result.get("success"):
await send_split_message(message, "", result["text"])
await state.set_state(LegalQuestionState.waiting_for_question)
else:
await message.answer(f"{result.get('text')}", parse_mode='HTML')
return
text = (
"⚖️ <b>Юридический помощник (Дом № 37)</b>\n\n"
"Напишите ваш вопрос или отправьте голосовое сообщение.\n"
"<i>Ответ формируется по сметам, отчетам, договорам и решениям ОСС дома.</i>"
)
await message.answer(text, parse_mode='HTML')
await state.set_state(LegalQuestionState.waiting_for_question)
@router.callback_query(F.data.in_(["admin_jurist", "jurist_ai", "main_jurist"]))
async def cb_legal_advisor(callback: CallbackQuery, state: FSMContext):
await callback.answer()
role = await determine_user_role(callback.from_user.id)
if role not in ("admin", "lawyer"):
await callback.message.answer(
"Для использования юридического помощника обратитесь к @ktybdsq.",
parse_mode='HTML'
)
return
text = (
"⚖️ <b>Юридический помощник (Дом № 37)</b>\n\n"
"Напишите ваш вопрос или отправьте голосовое сообщение.\n"
"<i>Ответ формируется по сметам, отчетам, договорам и решениям ОСС дома.</i>"
)
await callback.message.answer(text, parse_mode='HTML')
await state.set_state(LegalQuestionState.waiting_for_question)
# === КНОПКИ НАВИГАЦИИ ПОСЛЕ ОТВЕТА ЮРИСТА ===
@router.callback_query(F.data == "jurist_ask_next")
async def cb_jurist_ask_next(callback: CallbackQuery, state: FSMContext):
"""Пользователь хочет задать следующий вопрос юристу"""
await callback.answer()
role = await determine_user_role(callback.from_user.id)
if role not in ("admin", "lawyer"):
await callback.message.answer("🔒 <b>Доступ ограничен.</b>", parse_mode='HTML')
await state.clear()
return
await state.set_state(LegalQuestionState.waiting_for_question)
await callback.message.answer(
"⚖️ <b>Режим юриста активен.</b>\n\n"
"Напишите ваш следующий вопрос или отправьте голосовое сообщение:",
parse_mode='HTML'
)
@router.callback_query(F.data == "jurist_exit")
async def cb_jurist_exit(callback: CallbackQuery, state: FSMContext):
"""Выход из режима юриста с возвратом в главное меню"""
await callback.answer("Режим юриста завершён")
await state.clear()
user_id = callback.from_user.id
try:
from keyboards.inline import get_main_menu
from database.models import InitiativeGroup
import config
async with AsyncSessionLocal() as session:
stmt = select(User).where(User.user_id == user_id)
user = (await session.execute(stmt)).scalar_one_or_none()
ig_stmt = select(InitiativeGroup).where(InitiativeGroup.user_id == user_id, InitiativeGroup.is_active == True)
is_ig = (await session.execute(ig_stmt)).scalar_one_or_none() is not None
is_admin = (user.is_admin if user else False) or (user_id == config.ADMIN_USER_ID)
is_verified = user.verified if user else False
await callback.message.answer(
"🚪 <b>Вы вышли из режима юриста.</b>\n\nГлавное меню:",
reply_markup=get_main_menu(
verified=is_verified,
is_ig=is_ig,
is_admin=is_admin,
is_superadmin=(user_id == config.ADMIN_USER_ID)
),
parse_mode='HTML'
)
except Exception as e:
log.warning(f"Failed to show main menu on jurist exit: {e}")
await callback.message.answer("🚪 <b>Вы вышли из режима юриста.</b>", parse_mode='HTML')
# === ОБРАБОТКА ТЕКСТОВЫХ ВОПРОСОВ ===
@router.message(LegalQuestionState.waiting_for_question, F.text)
async def process_text_legal_question(message: Message, state: FSMContext, bot: Bot):
user_id = message.from_user.id
role = await determine_user_role(user_id)
if role not in ("admin", "lawyer"):
await message.answer(
"Для использования юридического помощника обратитесь к @ktybdsq.",
parse_mode='HTML'
)
await state.clear()
return
question = message.text
status_msg = await message.answer(
"⏳ <i>Изучаю документы, сметы, отчеты и решения дома № 37...</i>",
parse_mode='HTML'
)
result = await legal_engine.query(prompt=question, role=role)
await status_msg.delete()
if not result.get("success"):
await message.answer(f"{result.get('text')}", parse_mode='HTML')
return
# Защита от спама: если вопрос задан в групповом чате
if message.chat.type in ("group", "supergroup"):
# Ответ отправляется жильцу в ЛИЧКУ
try:
await bot.send_message(
chat_id=user_id,
text=format_telegram_html(result['text']),
parse_mode='HTML',
reply_markup=get_legal_nav_keyboard()
)
await message.reply(
"💬 <b>Ответ отправлен вам в личные сообщения</b>, чтобы не перегружать общий чат дома.",
parse_mode='HTML'
)
except Exception:
await message.reply(
"💬 Пожалуйста, откройте диалог с ботом (@lkmthreeseven_bot) и нажмите /start, чтобы получить ответ.",
parse_mode='HTML'
)
else:
# В личном диалоге отвечаем напрямую без лишних префиксов
await send_split_message(message, "", result["text"])
# Оставляем состояние активным, чтобы жилец мог сразу задать следующий вопрос без клика
await state.set_state(LegalQuestionState.waiting_for_question)
# === ОБРАБОТКА ГОЛОСОВЫХ ВОПРОСОВ (НАТИВНОЕ МУЛЬТИМОДАЛЬНОЕ РАСПОЗНАВАНИЕ) ===
@router.message(LegalQuestionState.waiting_for_question, F.voice)
@router.message(F.voice & F.chat.type == "private")
async def process_voice_legal_question(message: Message, state: FSMContext, bot: Bot):
user_id = message.from_user.id
role = await determine_user_role(user_id)
if role not in ("admin", "lawyer"):
await message.answer(
"Для использования юридического помощника обратитесь к @ktybdsq.",
parse_mode='HTML'
)
await state.clear()
return
status_msg = await message.answer(
"🎙 <i>Распознаю аудио и сверяю с документами дома № 37...</i>",
parse_mode='HTML'
)
voice_file_path = None
try:
# Скачиваем голосовой файл
file_info = await bot.get_file(message.voice.file_id)
voice_file_path = str(TEMP_VOICE_DIR / f"voice_{user_id}_{int(message.date.timestamp())}.ogg")
await bot.download_file(file_info.file_path, destination=voice_file_path)
# Передаем в legal_engine (Gemini транскрибирует аудио в текст, затем поиск по базе дома)
result = await legal_engine.query(prompt="", role=role, audio_path=voice_file_path)
await status_msg.delete()
if not result.get("success"):
await message.answer(f"{result.get('text')}", parse_mode='HTML')
return
# Отправляем чистый ответ без дублирования вопроса и без лишних плашек
await send_split_message(message, "", result["text"])
await state.set_state(LegalQuestionState.waiting_for_question)
except Exception as e:
log.error(f"Voice processing error: {e}")
await status_msg.edit_text(f"❌ <b>Ошибка обработки голосового сообщения:</b> {e}")
finally:
# Всегда зачищаем временный аудиофайл
if voice_file_path and os.path.exists(voice_file_path):
try: os.unlink(voice_file_path)
except: pass

View file

@ -14,37 +14,26 @@ from database.db import AsyncSessionLocal
from database.models import User, InitiativeGroup from database.models import User, InitiativeGroup
from keyboards.inline import get_main_menu, get_phones_menu, get_profile_keyboard, get_weather_keyboard from keyboards.inline import get_main_menu, get_phones_menu, get_profile_keyboard, get_weather_keyboard
from services.ai_service import ai_service from services.ai_service import ai_service
from utils.redirect import respond_pm_or_redirect
import config import config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = Router() router = Router()
async def safe_edit_or_reply(event, text: str, reply_markup=None, parse_mode='HTML'): async def safe_edit_or_reply(event, text: str, reply_markup=None, photo_path: str = None, parse_mode: str = 'HTML', start_param: str = 'start'):
""" """
Безопасное редактирование сообщения. Безопасный ответ: если вызван в группе — перенаправляет в личные сообщения пользователя
Если сообщение содержит медиа (фото и т.д.), мы удаляем его и отправляем новое. и тихо удаляет команду в группе, предотвращая спам.
""" """
if isinstance(event, CallbackQuery): await respond_pm_or_redirect(
await event.answer() event=event,
message = event.message text=text,
if (hasattr(message, 'photo') and message.photo) or \ reply_markup=reply_markup,
(hasattr(message, 'video') and message.video) or \ photo_path=photo_path,
(hasattr(message, 'document') and message.document) or \ parse_mode=parse_mode,
(hasattr(message, 'content_type') and message.content_type != 'text'): start_param=start_param
try: )
await message.delete()
except Exception as e:
logger.warning(f"Не удалось удалить сообщение с медиа: {e}")
await message.answer(text, reply_markup=reply_markup, parse_mode=parse_mode)
else:
try:
await message.edit_text(text, reply_markup=reply_markup, parse_mode=parse_mode)
except Exception as e:
logger.warning(f"Ошибка edit_text: {e}. Отправляем новое сообщение.")
await message.answer(text, reply_markup=reply_markup, parse_mode=parse_mode)
elif isinstance(event, Message):
await event.answer(text, reply_markup=reply_markup, parse_mode=parse_mode)
@router.message(Command('cancel'), StateFilter('*')) @router.message(Command('cancel'), StateFilter('*'))
@router.message(F.text.lower().in_(['отмена', 'отменить']), StateFilter('*')) @router.message(F.text.lower().in_(['отмена', 'отменить']), StateFilter('*'))
@ -59,9 +48,8 @@ async def global_cancel(message: Message, state: FSMContext):
@router.message(CommandStart()) @router.message(CommandStart())
async def cmd_start(message: Message): async def cmd_start(message: Message):
"""Команда /start""" """Команда /start: в ЛС открывает меню, в группе тихо перенаправляет в ЛС"""
from utils.ui_styles import fmt_msg, get_level_badge, get_user_level_key from utils.ui_styles import fmt_msg, get_level_badge, get_user_level_key
if message.chat.type != 'private': return
user_id = message.from_user.id user_id = message.from_user.id
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
@ -88,7 +76,13 @@ async def cmd_start(message: Message):
f"Рад видеть тебя снова, {message.from_user.first_name}!\n" f"Рад видеть тебя снова, {message.from_user.first_name}!\n"
"Чем могу помочь сегодня?") "Чем могу помочь сегодня?")
await message.answer(welcome_text, reply_markup=get_main_menu(verified=user.verified, is_ig=is_ig, is_admin=(user.is_admin or user_id == config.ADMIN_USER_ID), is_superadmin=(user_id == config.ADMIN_USER_ID)), parse_mode='HTML') menu_kb = get_main_menu(
verified=user.verified if user else False,
is_ig=is_ig,
is_admin=(user.is_admin or user_id == config.ADMIN_USER_ID) if user else (user_id == config.ADMIN_USER_ID),
is_superadmin=(user_id == config.ADMIN_USER_ID)
)
await safe_edit_or_reply(message, welcome_text, reply_markup=menu_kb, parse_mode='HTML', start_param='start')
@router.callback_query(F.data == 'main_menu') @router.callback_query(F.data == 'main_menu')
async def cb_main_menu(callback: CallbackQuery): async def cb_main_menu(callback: CallbackQuery):
@ -110,19 +104,19 @@ async def cb_main_menu(callback: CallbackQuery):
) )
@router.message(Command('phones')) @router.message(Command('phones'))
@router.message(F.text.in_(['📞 Важные телефоны', '📞 Телефоны', 'Телефоны']))
@router.callback_query(F.data == 'phones') @router.callback_query(F.data == 'phones')
async def cb_phones(event): async def cb_phones(event):
text = '📞 <b>ЭКСТРЕННЫЕ ТЕЛЕФОНЫ</b>\n\nВыберите категорию ниже:' text = '📞 <b>ЭКСТРЕННЫЕ И ВАЖНЫЕ ТЕЛЕФОНЫ</b>\n\nВыберите категорию ниже:'
kb = get_phones_menu() kb = get_phones_menu()
await safe_edit_or_reply(event, text, reply_markup=kb, parse_mode='HTML') await safe_edit_or_reply(event, text, reply_markup=kb, parse_mode='HTML', start_param='phones')
@router.message(Command('masters')) @router.message(Command('masters'))
@router.message(F.text.lower() == 'мастера') @router.message(F.text.in_(['👷 Мастера', 'Мастера', 'мастера']))
async def cmd_masters(message: Message): async def cmd_masters(message: Message):
"""Выдача списка мастеров по команде /masters или тексту 'мастера'""" """Выдача списка мастеров по команде /masters или тексту 'мастера'"""
from database.models import Service from database.models import Service
from keyboards.inline import get_main_menu from keyboards.inline import get_main_menu
from aiogram.types import FSInputFile
import os import os
user_id = message.from_user.id user_id = message.from_user.id
@ -141,7 +135,7 @@ async def cmd_masters(message: Message):
menu_kb = get_main_menu(verified=verified, is_ig=is_ig, is_admin=is_admin, is_superadmin=is_superadmin) menu_kb = get_main_menu(verified=verified, is_ig=is_ig, is_admin=is_admin, is_superadmin=is_superadmin)
if not services: if not services:
await message.answer("👷 Данные для этой категории еще не заполнены.", reply_markup=menu_kb, parse_mode='HTML') await safe_edit_or_reply(message, "👷 Данные для этой категории еще не заполнены.", reply_markup=menu_kb, parse_mode='HTML', start_param='masters')
return return
text = "📞 <b>СПИСОК ТЕЛЕФОНОВ: МАСТЕРА</b>\n\n" text = "📞 <b>СПИСОК ТЕЛЕФОНОВ: МАСТЕРА</b>\n\n"
@ -152,10 +146,7 @@ async def cmd_masters(message: Message):
text += "\n" text += "\n"
photo_path = services[0].image_path if services[0].image_path else None photo_path = services[0].image_path if services[0].image_path else None
if photo_path and os.path.exists(photo_path): await safe_edit_or_reply(message, text, reply_markup=menu_kb, photo_path=photo_path, parse_mode='HTML', start_param='masters')
await message.answer_photo(FSInputFile(photo_path), caption=text, reply_markup=menu_kb, parse_mode='HTML')
else:
await message.answer(text, reply_markup=menu_kb, parse_mode='HTML')
@router.message(F.text & ~F.text.startswith('/')) @router.message(F.text & ~F.text.startswith('/'))
async def chat_with_ai(message: Message): async def chat_with_ai(message: Message):
@ -185,10 +176,10 @@ async def chat_with_ai(message: Message):
async def cb_phone_category(callback: CallbackQuery): async def cb_phone_category(callback: CallbackQuery):
"""Выдача телефонов по категориям""" """Выдача телефонов по категориям"""
from database.models import Service from database.models import Service
from aiogram.types import FSInputFile
import os import os
category_map = { category_map = {
'phones_avariyka': 'avariyka',
'phones_emergency': 'emergency', 'phones_emergency': 'emergency',
'phones_utility': 'utility', 'phones_utility': 'utility',
'phones_police': 'police', 'phones_police': 'police',
@ -199,9 +190,6 @@ async def cb_phone_category(callback: CallbackQuery):
if not cat_code: return await callback.answer() if not cat_code: return await callback.answer()
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
stmt = select(Service).where(Service.category == cat_code).order_by(Service.name)
services = list((await session.execute(stmt)).scalars().all())
user_stmt = select(User).where(User.user_id == callback.from_user.id) user_stmt = select(User).where(User.user_id == callback.from_user.id)
user = (await session.execute(user_stmt)).scalar_one_or_none() user = (await session.execute(user_stmt)).scalar_one_or_none()
ig_stmt = select(InitiativeGroup).where(InitiativeGroup.user_id == callback.from_user.id, InitiativeGroup.is_active == True) ig_stmt = select(InitiativeGroup).where(InitiativeGroup.user_id == callback.from_user.id, InitiativeGroup.is_active == True)
@ -212,23 +200,43 @@ async def cb_phone_category(callback: CallbackQuery):
verified = user.verified if user else False verified = user.verified if user else False
menu_kb = get_main_menu(verified=verified, is_ig=is_ig, is_admin=is_admin, is_superadmin=is_superadmin) menu_kb = get_main_menu(verified=verified, is_ig=is_ig, is_admin=is_admin, is_superadmin=is_superadmin)
if cat_code == 'avariyka':
text = (
"⚡ <b>АВАРИЙНАЯ СЛУЖБА (ЭЛЕКТРИКА И ЩИТКИ)</b>\n\n"
"📞 Городской: <code>54-69-29</code>\n"
"📱 Федеральный: <code>8 (8422) 54-69-29</code>\n\n"
"📝 <i>Круглосуточный вызов при замыканиях в щитках, обгорании проводки, авариях электросетей дома ЛКМ37.</i>"
)
photo_path = 'data/service_images/emergency_avariyka.png'
return await safe_edit_or_reply(callback, text, reply_markup=menu_kb, photo_path=photo_path, parse_mode='HTML')
stmt = select(Service).where(Service.category == cat_code).order_by(Service.service_id.desc() if cat_code == 'emergency' else Service.name)
services = list((await session.execute(stmt)).scalars().all())
if not services: if not services:
return await safe_edit_or_reply(callback, "👷 Данные для этой категории еще не заполнены.", reply_markup=menu_kb) return await safe_edit_or_reply(callback, "👷 Данные для этой категории еще не заполнены.", reply_markup=menu_kb)
text = f"📞 <b>СПИСОК ТЕЛЕФОНОВ: {cat_code.upper()}</b>\n\n" category_titles = {
'emergency': '🚨 ЭКСТРЕННЫЕ СЛУЖБЫ',
'utility': '🔧 КОММУНАЛЬНЫЕ СЛУЖБЫ И ДИСПЕТЧЕР ЖСС',
'police': '👮 ПОЛИЦИЯ И УЧАСТКОВЫЙ',
'masters': '👷 МАСТЕРА'
}
title = category_titles.get(cat_code, f'📞 СПИСОК ТЕЛЕФОНОВ: {cat_code.upper()}')
text = f"<b>{title}</b>\n\n"
for s in services: for s in services:
text += f"🔹 <b>{s.name}</b>\n └ 📱 <code>{s.phone}</code>\n" text += f"🔹 <b>{s.name}</b>\n └ 📱 <code>{s.phone}</code>\n"
if s.description: text += f" └ 📝 <i>{s.description}</i>\n" if s.description: text += f" └ 📝 <i>{s.description}</i>\n"
text += "\n" text += "\n"
# Если есть фото, придется прислать новое сообщение (edit_text не умеет менять текст на фото) # Если есть фото у службы
photo_path = services[0].image_path if services[0].image_path else None photo_path = None
for s in services:
if s.image_path and os.path.exists(s.image_path):
photo_path = s.image_path
break
if photo_path and os.path.exists(photo_path): await safe_edit_or_reply(callback, text, reply_markup=menu_kb, photo_path=photo_path, parse_mode='HTML')
await callback.message.delete()
await callback.message.answer_photo(FSInputFile(photo_path), caption=text, reply_markup=menu_kb, parse_mode='HTML')
else:
await safe_edit_or_reply(callback, text, reply_markup=menu_kb, parse_mode='HTML')
@router.message(Command('rules')) @router.message(Command('rules'))
@router.callback_query(F.data == 'rules') @router.callback_query(F.data == 'rules')
@ -334,6 +342,7 @@ class HelpFeedbackState(StatesGroup):
waiting_for_reply = State() waiting_for_reply = State()
@router.message(Command('help')) @router.message(Command('help'))
@router.message(F.text.in_(['❓ Справка', '❓ Помощь', 'Справка', 'Помощь']))
@router.callback_query(F.data == 'help') @router.callback_query(F.data == 'help')
async def cb_help(event, state: FSMContext): async def cb_help(event, state: FSMContext):
await state.clear() await state.clear()

View file

@ -9,55 +9,60 @@ from utils.ui_styles import LEVEL_EMOJI
def get_main_menu(verified: bool = False, is_ig: bool = False, is_admin: bool = False, is_superadmin: bool = False) -> InlineKeyboardMarkup: def get_main_menu(verified: bool = False, is_ig: bool = False, is_admin: bool = False, is_superadmin: bool = False) -> InlineKeyboardMarkup:
""" """
Стабильное главное меню LKM37 (Grid 2xN) Стабильное главное меню LKM37 (Grid 2xN)
Строгая фильтрация по ролям:
- Гость: база + подтверждение квартиры
- Жилец: база + коммунальные сервисы дома
- Совет Дома: + Совет Дома
- Суперадмин: + Панель управления
""" """
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
# СТРОКА 1: БАЗА (ВСЕГДА ТУТ) # СТРОКА 1: БАЗА (ДОСТУПНА ВСЕМ)
builder.row( builder.row(
InlineKeyboardButton(text='📞 Телефоны', callback_data='phones'), InlineKeyboardButton(text='📞 Экстренные службы', callback_data='phones'),
InlineKeyboardButton(text='🏢 О доме', callback_data='about_house') InlineKeyboardButton(text='⚖️ Юрист (ЖК РФ)', callback_data='main_jurist')
) )
# СТРОКА 2-3: ДЛЯ ВЕРИФИЦИРОВАННЫХ (ФИКСИРОВАННОЕ МЕСТО)
if verified: if verified:
# ДЛЯ ВЕРИФИЦИРОВАННЫХ ЖИЛЬЦОВ
builder.row( builder.row(
InlineKeyboardButton(text='📅 График', callback_data='schedule'), InlineKeyboardButton(text='⚠️ Отключения', callback_data='schedule'),
InlineKeyboardButton(text='💰 Платежи', callback_data='payments') InlineKeyboardButton(text='💳 Оплата ЖКХ', callback_data='payments')
) )
builder.row( builder.row(
InlineKeyboardButton(text='📋 Опросы', callback_data='polls'), InlineKeyboardButton(text='🗳️ Опросы дома', callback_data='polls'),
InlineKeyboardButton(text='🎉 События', callback_data='events') InlineKeyboardButton(text='🏢 Паспорт дома', callback_data='about_house')
)
builder.row(
InlineKeyboardButton(text='🏪 Маркет', callback_data='market'),
InlineKeyboardButton(text='🏆 Рейтинг', callback_data='achievements')
)
else:
# Для гостей кнопка верификации на видном месте
builder.row(
InlineKeyboardButton(text='🛡️ ВЕРИФИКАЦИЯ', callback_data='start_verification')
) )
# СТРОКА 4: ИНИЦИАТИВНАЯ ГРУППА И АДМИН # СОВЕТ ДОМА (ИНИЦИАТИВНАЯ ГРУППА)
if is_ig or is_admin:
buttons = []
if is_ig: if is_ig:
buttons.append(InlineKeyboardButton(text='🎯 ТИМ-МЕНЮ', callback_data='ig_menu')) builder.row(
if is_admin: InlineKeyboardButton(text='👥 Совет Дома', callback_data='ig_menu')
buttons.append(InlineKeyboardButton(text='⚙️ АДМИНКА', callback_data='admin_menu')) )
builder.row(*buttons)
# СТРОКА 5: СУПЕР-АДМИН (ТЫ) # АДМИНИСТРИРОВАНИЕ (ТОЛЬКО ДЛЯ АДМИНОВ / СУПЕРАДМИНА)
if is_superadmin: if is_superadmin:
builder.row( builder.row(
InlineKeyboardButton(text='👑 [ROOT: УПРАВЛЕНИЕ]', callback_data='superadmin_menu') InlineKeyboardButton(text='🛡️ Панель управления', callback_data='superadmin_menu')
)
elif is_admin:
builder.row(
InlineKeyboardButton(text='⚙️ Управление', callback_data='admin_menu')
) )
# СТРОКА 6: СЛУЖЕБНОЕ (НИЗ) # СЛУЖЕБНОЕ ВНИЗУ
builder.row( builder.row(
InlineKeyboardButton(text='❓ Помощь', callback_data='help'), InlineKeyboardButton(text='👤 Моя квартира', callback_data='my_profile'),
InlineKeyboardButton(text='🌦️ Погода', callback_data='weather'), InlineKeyboardButton(text='❓ Помощь', callback_data='help')
InlineKeyboardButton(text='👤 Профиль', callback_data='my_profile') )
else:
# ДЛЯ НЕВЕРИФИЦИРОВАННЫХ (ГОСТЕЙ)
builder.row(
InlineKeyboardButton(text='🏢 Паспорт дома', callback_data='about_house'),
InlineKeyboardButton(text='🔑 Подтвердить квартиру', callback_data='start_verification')
)
builder.row(
InlineKeyboardButton(text='❓ Помощь / Связь с админом', callback_data='help')
) )
return builder.as_markup() return builder.as_markup()
@ -121,14 +126,14 @@ def get_admin_menu() -> InlineKeyboardMarkup:
builder.row( builder.row(
InlineKeyboardButton(text='📊 Статистика', callback_data='admin_stats'), InlineKeyboardButton(text='📊 Статистика', callback_data='admin_stats'),
InlineKeyboardButton(text='🕵️ Шпионы', callback_data='admin_spies'), InlineKeyboardButton(text='🛡️ Фильтр посторонних', callback_data='admin_spies'),
) )
builder.row( builder.row(
InlineKeyboardButton(text='👥 Пользователи', callback_data='admin_users'), InlineKeyboardButton(text='👥 Список жителей', callback_data='admin_users'),
InlineKeyboardButton(text='⏳ Непроверенные', callback_data='admin_unverified'), InlineKeyboardButton(text='⏳ Ожидают проверки', callback_data='admin_unverified'),
) )
builder.row( builder.row(
InlineKeyboardButton(text='📥 Экспорт JSON', callback_data='admin_export'), InlineKeyboardButton(text='📥 Выгрузка базы', callback_data='admin_export'),
InlineKeyboardButton(text='📤 Рассылка', callback_data='admin_broadcast'), InlineKeyboardButton(text='📤 Рассылка', callback_data='admin_broadcast'),
) )
builder.row( builder.row(
@ -158,15 +163,18 @@ def get_phones_menu() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.row( builder.row(
InlineKeyboardButton(text='🚨 Экстренные', callback_data='phones_emergency'), InlineKeyboardButton(text='⚡ Аварийка (54-69-29)', callback_data='phones_avariyka'),
InlineKeyboardButton(text='🔧 ЖКХ', callback_data='phones_utility'), InlineKeyboardButton(text='🚨 Все экстренные', callback_data='phones_emergency'),
) )
builder.row( builder.row(
InlineKeyboardButton(text='👮 Полиция', callback_data='phones_police'), InlineKeyboardButton(text='🔧 Диспетчер ЖКХ', callback_data='phones_utility'),
InlineKeyboardButton(text='👷 Мастера', callback_data='phones_masters'), InlineKeyboardButton(text='👮 Полиция / Участковый', callback_data='phones_police'),
) )
builder.row( builder.row(
InlineKeyboardButton(text='🔙 Назад', callback_data='main_menu'), InlineKeyboardButton(text='👷 Мастера (сантехник и др.)', callback_data='phones_masters'),
)
builder.row(
InlineKeyboardButton(text='🔙 Назад в меню', callback_data='main_menu'),
) )
return builder.as_markup() return builder.as_markup()

View file

@ -13,11 +13,11 @@ def get_reply_menu() -> ReplyKeyboardMarkup:
builder = ReplyKeyboardBuilder() builder = ReplyKeyboardBuilder()
builder.row( builder.row(
KeyboardButton(text='📞 Телефоны'), KeyboardButton(text='📞 Важные телефоны'),
KeyboardButton(text='👷 Мастера'), KeyboardButton(text='👷 Мастера'),
) )
builder.row( builder.row(
KeyboardButton(text='❓ Помощь'), KeyboardButton(text='❓ Справка'),
) )
# Resize keyboard чтобы не занимал много места # Resize keyboard чтобы не занимал много места
@ -29,12 +29,13 @@ def get_admin_reply_menu() -> ReplyKeyboardMarkup:
builder = ReplyKeyboardBuilder() builder = ReplyKeyboardBuilder()
builder.row( builder.row(
KeyboardButton(text='/admin'), KeyboardButton(text='⚙️ Управление'),
KeyboardButton(text='/stats'), KeyboardButton(text='📊 Статистика'),
) )
builder.row( builder.row(
KeyboardButton(text='/spies'), KeyboardButton(text='🛡️ Фильтр посторонних'),
KeyboardButton(text='/export'), KeyboardButton(text='📥 Выгрузка базы'),
) )
return builder.as_markup(resize_keyboard=True) return builder.as_markup(resize_keyboard=True)

61
main.py
View file

@ -49,6 +49,52 @@ async def on_startup(bot: Bot):
await init_db() await init_db()
scheduler = Scheduler(bot) scheduler = Scheduler(bot)
scheduler.start() scheduler.start()
try:
from aiogram.types import (
BotCommand, BotCommandScopeDefault,
BotCommandScopeAllGroupChats, BotCommandScopeAllPrivateChats,
BotCommandScopeChat
)
# 1. Для всех групповых чатов (публичный чат дома) - только базовая справка, без админок
await bot.set_my_commands([
BotCommand(command="phones", description="📞 Экстренные службы (в ЛС)"),
BotCommand(command="help", description="❓ Справка (в ЛС)")
], scope=BotCommandScopeAllGroupChats())
# 2. Для всех личных чатов с жильцами
await bot.set_my_commands([
BotCommand(command="start", description="Главное меню дома"),
BotCommand(command="phones", description="📞 Экстренные службы"),
BotCommand(command="jurist", description="⚖️ Юрист & Разбор ЖК РФ"),
BotCommand(command="help", description="❓ Помощь и навигация")
], scope=BotCommandScopeAllPrivateChats())
# 3. Персонально для суперадмина дома (Александр Тырин)
if ADMIN_USER_ID:
try:
await bot.set_my_commands([
BotCommand(command="admin", description="🛡️ Админ-панель дома"),
BotCommand(command="start", description="Главное меню дома"),
BotCommand(command="phones", description="📞 Экстренные службы"),
BotCommand(command="jurist", description="⚖️ Юрист & Разбор ЖК РФ"),
BotCommand(command="stats", description="📊 Статистика дома"),
BotCommand(command="help", description="❓ Помощь и навигация")
], scope=BotCommandScopeChat(chat_id=ADMIN_USER_ID))
except Exception as admin_cmd_err:
logger.warning(f"Failed to set admin commands scope: {admin_cmd_err}")
# 4. Дефолтный fallback
await bot.set_my_commands([
BotCommand(command="start", description="Главное меню дома"),
BotCommand(command="phones", description="📞 Экстренные телефоны"),
BotCommand(command="help", description="❓ Помощь")
], scope=BotCommandScopeDefault())
logger.info("✅ Telegram BotCommand scopes successfully updated.")
except Exception as e:
logger.warning(f"Failed to set bot commands: {e}")
try: try:
await bot.send_message(ADMIN_USER_ID, '✅ <b>LKM37 CORE v4.3 АКТИВИРОВАН</b>') await bot.send_message(ADMIN_USER_ID, '✅ <b>LKM37 CORE v4.3 АКТИВИРОВАН</b>')
await send_domovoy_alert("Сервис запущен", "Домовой МКД успешно запущен в автономном коммунальном режиме.") await send_domovoy_alert("Сервис запущен", "Домовой МКД успешно запущен в автономном коммунальном режиме.")
@ -59,7 +105,12 @@ async def main():
bot = get_bot() bot = get_bot()
dp = Dispatcher(storage=MemoryStorage()) dp = Dispatcher(storage=MemoryStorage())
# Регистрация роутеров МКД # Регистрация роутеров МКД (Юрист в наивысшем приоритете)
from handlers.legal_advisor import router as legal_advisor_router
from handlers.smart_broadcast import router as smart_broadcast_router
from handlers.digest import router as digest_router
dp.include_router(legal_advisor_router)
dp.include_router(admin_router) dp.include_router(admin_router)
dp.include_router(users_router) dp.include_router(users_router)
@ -69,17 +120,13 @@ async def main():
antispam_router, ratelimit_router, schedule_router, ads_router, antispam_router, ratelimit_router, schedule_router, ads_router,
payments_router, multicount_router, topics_router, toxicity_router, payments_router, multicount_router, topics_router, toxicity_router,
profile_history_router, achievements_router, thanks_router, profile_history_router, achievements_router, thanks_router,
events_router, initiative_group_router, mutual_aid_router events_router, initiative_group_router, mutual_aid_router,
smart_broadcast_router, digest_router
] ]
for r in other_routers: for r in other_routers:
dp.include_router(r) dp.include_router(r)
from handlers.smart_broadcast import router as smart_broadcast_router
from handlers.digest import router as digest_router
dp.include_router(smart_broadcast_router)
dp.include_router(digest_router)
dp.startup.register(on_startup) dp.startup.register(on_startup)
logger.info('🤖 Бот запускается...') logger.info('🤖 Бот запускается...')

View file

@ -26,3 +26,4 @@ google-auth-oauthlib
edge-tts>=7.0.0 edge-tts>=7.0.0
notebooklm-py>=0.8.5

276
services/ai_legal_engine.py Normal file
View file

@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""
Интеллектуальное юридическое ядро МКД «Домовой 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
import aiohttp
import base64
from datetime import datetime
from pathlib import Path
log = logging.getLogger("ai_legal_engine")
class AILegalEngine:
def __init__(self):
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-flash"
self._init_sdk_client()
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
try:
from google import genai
self.client = genai.Client(api_key=self.api_key)
log.info("Gemini SDK Client initialized.")
except Exception as 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":
return (
"СТИЛЬ ОТВЕТА: Спокойный, понятный, без бюрократического канцелярита.\n"
"ТРЕБОВАНИЯ:\n"
"1. Суть ситуации простыми словами (1-2 абзаца).\n"
"2. Пошаговый порядок действий (Шаг 1, Шаг 2, Шаг 3).\n"
"3. Кто несет ответственность (УК, застройщик, поставщик ресурсов, совет дома).\n"
"4. Образец краткого заявления при необходимости."
)
else:
return (
"СТИЛЬ ОТВЕТА: Сугубо юридический, строгий, аргументированный.\n"
"ТРЕБОВАНИЯ:\n"
"1. Правовая квалификация ситуации со ссылками на статьи нормативных актов РФ (ЖК РФ, ГК РФ, 59-ФЗ, ЗоЗПП, ПП РФ № 354, ПП РФ № 491, Госстрой № 170, СанПиН).\n"
"2. Оценка рисков, перспектив в надзорных органах и суде.\n"
"3. Четкий алгоритм действий (досудебная претензия, жалоба в ГЖИ / прокуратуру, иск).\n"
"4. Готовый образец мотивировочной части претензии или заявления при необходимости."
)
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 transcribe_audio(self, audio_path: str) -> str:
"""Транскрибирует аудиосообщение жильца в текст через Gemini Multimodal Audio (Voice-to-Text)."""
if not self.api_key or not audio_path or not os.path.exists(audio_path):
return ""
prompt = (
"Дословно расшифруй аудиосообщение на русском языке. "
"Не добавляй пояснений, не отвечай на вопрос и не искажай слова жильца. "
"Выведи ТОЛЬКО расшифрованный текст."
)
system_instruction = "Ты — высокоточный модуль транскрибации речи (Speech-to-Text). Верни только произнесенный текст."
for model_to_use in [self.model_name, "gemini-flash-latest", "gemini-2.5-flash"]:
try:
transcription = await self._query_rest_api(
prompt=prompt,
system_instruction=system_instruction,
model=model_to_use,
audio_path=audio_path
)
if transcription and transcription.strip():
return transcription.strip()
except Exception as e:
log.warning(f"Voice transcription attempt failed on {model_to_use}: {e}")
await asyncio.sleep(0.5)
return ""
async def query_notebooklm(self, prompt: str) -> dict:
"""Прямой запрос к блокноту NotebookLM «ЛенКом37 Юрист» (RAG по документам дома № 37)."""
import shutil
notebook_id = os.getenv("NOTEBOOKLM_ID", "59c41b23-5858-4a74-bf60-4ce17e0ca710")
candidates = [
"/usr/local/bin/notebooklm",
shutil.which("notebooklm"),
"/home/matrixhasyou/swarm-services/domovoy-bot/venv-ai/bin/notebooklm",
"notebooklm"
]
nb_bin = None
for b in candidates:
if b and os.path.exists(b) and os.access(b, os.X_OK):
try:
with open(b, "rb") as f:
first_line = f.readline()
if first_line.startswith(b"#!"):
interp = first_line[2:].strip().decode("utf-8", errors="ignore")
if not os.path.exists(interp):
continue
except Exception:
pass
nb_bin = b
break
if not nb_bin:
nb_bin = "/usr/local/bin/notebooklm" if os.path.exists("/usr/local/bin/notebooklm") else "notebooklm"
cmd = [
nb_bin, "ask",
"-n", notebook_id,
prompt
]
env = os.environ.copy()
env["HOME"] = "/root" if os.path.exists("/root/.notebooklm") else os.environ.get("HOME", "/root")
env["SSL_CERT_FILE"] = "/etc/ssl/certs/ca-certificates.crt"
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env
)
try:
# Увеличенный лимит ожидания (до 300 сек для глубокого RAG-анализа смет и решений)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300.0)
except asyncio.TimeoutError:
try: proc.kill()
except: pass
return {
"success": False,
"error": "Таймаут обработки запроса к блокноту документов дома (более 300 сек)",
"source": "notebooklm"
}
out_str = stdout.decode('utf-8', errors='ignore').strip()
err_str = stderr.decode('utf-8', errors='ignore').strip()
if proc.returncode == 0 and out_str and "Authentication Error" not in out_str and "Not logged in" not in out_str:
lines = [
l for l in out_str.splitlines()
if not l.startswith("Continuing conversation")
and not l.startswith("Resumed conversation")
]
cleaned = "\n".join(lines).strip()
if cleaned.startswith("Answer:"):
cleaned = cleaned[len("Answer:"):].strip()
return {"success": True, "text": cleaned, "source": "notebooklm"}
else:
log.warning(f"NotebookLM non-zero or auth err: code={proc.returncode}, out={out_str[:250]}, err={err_str[:250]}")
return {"success": False, "error": err_str or out_str, "source": "notebooklm"}
except Exception as e:
log.error(f"NotebookLM exec exception: {e}")
return {"success": False, "error": str(e), "source": "notebooklm"}
async def query(self, prompt: str, role: str = "resident", audio_path: str = None) -> dict:
"""
Канонический метод запроса юридического помощника МКД «Домовой 2.0»:
- Все ответы формируются СТРОГО на основе документов дома № 37 из блокнота NotebookLM.
- Никаких общих абстрактных ответов или галлюцинаций Gemini!
- При отправке аудиосообщения: Gemini выполняет ИСКЛЮЧИТЕЛЬНО распознавание голоса в текст (STT),
после чего текст вопроса направляется в NotebookLM.
"""
start_time = datetime.now()
transcribed_text = None
# 1. Если передано аудиосообщение — транскрибируем его через Gemini Multimodal в текст
if audio_path:
log.info(f"Received audio voice message ({audio_path}), transcribing via Gemini STT...")
transcribed_text = await self.transcribe_audio(audio_path)
if not transcribed_text:
return {
"success": False,
"text": "❌ <b>Не удалось распознать голосовое сообщение.</b> Пожалуйста, напишите ваш вопрос текстом.",
"role": role,
"latency_sec": round((datetime.now() - start_time).total_seconds(), 2)
}
log.info(f"Audio voice successfully transcribed: '{transcribed_text[:120]}...'")
prompt = transcribed_text
if not prompt or not prompt.strip():
return {
"success": False,
"text": "❌ Вопрос пустой. Пожалуйста, сформулируйте юридический вопрос.",
"role": role,
"latency_sec": 0.0
}
# 2. Обращение СТРОГО к блокноту NotebookLM (RAG по сметам, договорам и протоколам ОСС)
nlm_res = await self.query_notebooklm(prompt)
latency = round((datetime.now() - start_time).total_seconds(), 2)
if nlm_res.get("success"):
return {
"success": True,
"text": nlm_res["text"],
"answer": nlm_res["text"],
"role": role,
"latency_sec": latency,
"model": "NotebookLM: ЛенКом37 Юрист (Документы дома № 37)",
"transcribed_question": transcribed_text
}
else:
err_msg = nlm_res.get("error", "Неизвестная ошибка")
log.error(f"NotebookLM legal query failed: {err_msg}")
# Никакого fallback на обычный Gemini! Выдаем честное уведомление об обновлении базы
return {
"success": False,
"text": "❌ <b>База документов дома № 37 временно обновляет сессию.</b>",
"role": role,
"latency_sec": latency,
"error_detail": err_msg
}
# Глобальный синглтон юридического ядра для роутеров бота
legal_engine = AILegalEngine()
if __name__ == "__main__":
async def test():
engine = AILegalEngine()
res = await engine.query("Сравни за что мы голосуем на общих собраниях - за обрезку или глубокую обрезку деревьев во дворе. А в отчетах - как списывают?")
print("Success:", res["success"])
print("Latency:", res["latency_sec"])
print("Model:", res.get("model"))
print("Text snippet:\n", res["text"][:300])
asyncio.run(test())

View file

@ -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 = "Разбор ответов строго по ЖК РФ"
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-flash",
contents=prompt,
config=types.GenerateContentConfig(
temperature=0.4
)
)
return {
"success": True,
"script": response.text,
"engine": "gemini-2.5-flash-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])

View file

@ -21,7 +21,7 @@ class Scheduler:
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
self.scheduler = AsyncIOScheduler() self.scheduler = AsyncIOScheduler(timezone='Europe/Ulyanovsk')
def start(self): def start(self):
"""Запуск планировщика""" """Запуск планировщика"""
@ -141,25 +141,24 @@ class Scheduler:
) )
logger.info('✅ Добавлена задача ежедневного прогноза погоды для админа') logger.info('✅ Добавлена задача ежедневного прогноза погоды для админа')
# Еженедельный прогноз в чат по понедельникам (в 09:00) # [ОТКЛЮЧЕНО ПО ТЗ] Еженедельный прогноз в чат по понедельникам и четвергам отключен для группы дома
self.scheduler.add_job( # Жильцы начали активнее общаться, прогноз погоды в общей группе больше не требуется.
self.monday_chat_weather, # Личный прогноз для Александра (daily_admin_weather) выше остается активным.
CronTrigger.from_crontab('0 9 * * 1'), # Пн в 09:00 # self.scheduler.add_job(
id='monday_chat_weather', # self.monday_chat_weather,
name='Прогноз погоды в чат (Понедельник)', # CronTrigger.from_crontab('0 9 * * 1'),
replace_existing=True # id='monday_chat_weather',
) # name='Прогноз погоды в чат (Понедельник)',
logger.info('✅ Добавлена задача прогноза погоды в чат по понедельникам') # replace_existing=True
# )
# Еженедельный прогноз в чат по четвергам (в 09:00) # self.scheduler.add_job(
self.scheduler.add_job( # self.thursday_chat_weather,
self.thursday_chat_weather, # CronTrigger.from_crontab('0 9 * * 4'),
CronTrigger.from_crontab('0 9 * * 4'), # Чт в 09:00 # id='thursday_chat_weather',
id='thursday_chat_weather', # name='Прогноз погоды в чат (Четверг)',
name='Прогноз погоды в чат (Четверг)', # replace_existing=True
replace_existing=True # )
) logger.info('ℹ️ Прогноз погоды в общий чат дома отключен по требованию председателя')
logger.info('✅ Добавлена задача прогноза погоды в чат по четвергам')
# Еженедельный дайджест Александра (суббота в 08:00) # Еженедельный дайджест Александра (суббота в 08:00)
self.scheduler.add_job( self.scheduler.add_job(
@ -550,8 +549,17 @@ class Scheduler:
"""Прогноз в чат по понедельникам (на 3 дня)""" """Прогноз в чат по понедельникам (на 3 дня)"""
try: try:
from services.weather import WeatherService from services.weather import WeatherService
from config import ADMIN_CHAT_ID from config import ADMIN_CHAT_ID, ADMIN_USER_ID
forecast = await WeatherService.get_3day_forecast() forecast = await WeatherService.get_3day_forecast()
if not forecast or forecast.startswith("❌"):
logger.warning(f"Monday weather forecast failed: {forecast}")
await self.bot.send_message(
ADMIN_USER_ID,
f"⚠️ <b>Внимание:</b> Не удалось сгенерировать прогноз погоды на понедельник для общего чата:\n{forecast}",
parse_mode='HTML'
)
return
await self.bot.send_message( await self.bot.send_message(
ADMIN_CHAT_ID, ADMIN_CHAT_ID,
forecast, forecast,
@ -565,8 +573,17 @@ class Scheduler:
"""Прогноз в чат по четвергам (на остаток недели и выходные)""" """Прогноз в чат по четвергам (на остаток недели и выходные)"""
try: try:
from services.weather import WeatherService from services.weather import WeatherService
from config import ADMIN_CHAT_ID from config import ADMIN_CHAT_ID, ADMIN_USER_ID
forecast = await WeatherService.get_thursday_weekend_broadcast() forecast = await WeatherService.get_thursday_weekend_broadcast()
if not forecast or forecast.startswith("❌"):
logger.warning(f"Thursday weather forecast failed: {forecast}")
await self.bot.send_message(
ADMIN_USER_ID,
f"⚠️ <b>Внимание:</b> Не удалось сгенерировать прогноз погоды на четверг для общего чата:\n{forecast}",
parse_mode='HTML'
)
return
await self.bot.send_message( await self.bot.send_message(
ADMIN_CHAT_ID, ADMIN_CHAT_ID,
forecast, forecast,

169
utils/redirect.py Normal file
View file

@ -0,0 +1,169 @@
"""
Универсальный шлюз перенаправления в личные сообщения (Zero-Spam Redirect)
Все ответы на команды и меню отправляются строго в ЛС, не засоряя группу.
"""
import asyncio
import logging
import os
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton, FSInputFile
logger = logging.getLogger(__name__)
async def delete_after_delay(msg: Message, delay: int = 15):
"""Фоновое удаление временного уведомления через delay секунд"""
try:
await asyncio.sleep(delay)
await msg.delete()
except Exception:
pass
async def respond_pm_or_redirect(
event: Message | CallbackQuery,
text: str,
reply_markup=None,
photo_path: str = None,
parse_mode: str = 'HTML',
start_param: str = 'start'
):
"""
Отправляет контент пользователю строго в личные сообщения.
В групповом чате команда тихо удаляется, а пользователю отправляется карточка в ЛС.
Если бот не может написать в ЛС первым (пользователь не нажал /start),
выводится короткая временная плашка с кнопкой перехода в ЛС, удаляемая через 15 сек.
"""
bot = event.bot
# --- Обработка CallbackQuery ---
if isinstance(event, CallbackQuery):
message = event.message
# Если инлайн-кнопку нажали в группе
if message and message.chat.type in ('group', 'supergroup'):
user_id = event.from_user.id
try:
if photo_path and os.path.exists(photo_path):
await bot.send_photo(
chat_id=user_id,
photo=FSInputFile(photo_path),
caption=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
else:
await bot.send_message(
chat_id=user_id,
text=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
await event.answer("Отправил вам в личные сообщения 📩", show_alert=False)
except Exception as e:
logger.info(f"Cannot PM user {user_id} on callback: {e}")
await event.answer(
"Пожалуйста, сначала откройте диалог с ботом в личке (@lkmthreeseven_bot)!",
show_alert=True
)
return
# Если инлайн-кнопку нажали в ЛС
await event.answer()
has_media = False
if message:
has_media = bool(
(hasattr(message, 'photo') and message.photo) or
(hasattr(message, 'video') and message.video) or
(hasattr(message, 'document') and message.document) or
(hasattr(message, 'content_type') and message.content_type != 'text')
)
if has_media or (photo_path and os.path.exists(photo_path)):
if message:
try:
await message.delete()
except Exception:
pass
if photo_path and os.path.exists(photo_path):
await bot.send_photo(
chat_id=event.from_user.id,
photo=FSInputFile(photo_path),
caption=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
else:
await bot.send_message(
chat_id=event.from_user.id,
text=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
else:
try:
await message.edit_text(text, reply_markup=reply_markup, parse_mode=parse_mode)
except Exception:
await message.answer(text, reply_markup=reply_markup, parse_mode=parse_mode)
return
# --- Обработка Message ---
if isinstance(event, Message):
user_id = event.from_user.id
# Если сообщение получено в личке — штатный ответ
if event.chat.type == 'private':
if photo_path and os.path.exists(photo_path):
await event.answer_photo(
FSInputFile(photo_path),
caption=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
else:
await event.answer(text, reply_markup=reply_markup, parse_mode=parse_mode)
return
# Если сообщение получено в группе или супергруппе:
# 1. Пытаемся тихо удалить сообщение-команду жильца
try:
await event.delete()
except Exception:
pass
# 2. Отправляем карточку/меню пользователю в ЛС
sent_to_pm = False
try:
if photo_path and os.path.exists(photo_path):
await bot.send_photo(
chat_id=user_id,
photo=FSInputFile(photo_path),
caption=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
else:
await bot.send_message(
chat_id=user_id,
text=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
sent_to_pm = True
except Exception as e:
logger.info(f"Cannot PM user {user_id}: {e}")
# 3. Если в личку отправить не удалось (пользователь не начинал диалог с ботом)
if not sent_to_pm:
bot_user = await bot.get_me()
url = f"https://t.me/{bot_user.username}?start={start_param}"
kb = InlineKeyboardMarkup(inline_keyboard=[[
InlineKeyboardButton(text="💬 Открыть в личке", url=url)
]])
user_mention = event.from_user.mention_html()
hint = await bot.send_message(
chat_id=event.chat.id,
text=f"👋 {user_mention}, чтобы не спамить в общий чат, перейдите в диалог с ботом:",
reply_markup=kb,
parse_mode='HTML'
)
asyncio.create_task(delete_after_delay(hint, 15))

View file

@ -115,40 +115,7 @@ async def upload_to_tg(content, filename, content_type):
file_id = msg["photo"][-1]["file_id"] if file_type == "photo" else msg["document"]["file_id"] file_id = msg["photo"][-1]["file_id"] if file_type == "photo" else msg["document"]["file_id"]
return file_id, file_type return file_id, file_type
@app.post("/api/emails/sync_ai")
async def api_sync_emails_to_ai(username: str = Depends(get_current_admin)):
sync_script = "/app/domovoy_drive_sync.py" if os.path.exists("/app/domovoy_drive_sync.py") else str(Path(config.MUTT_DIR).parent / "domovoy_drive_sync.py")
python_path = sys.executable
try:
# Запуск синхронизации
process = subprocess.run(
[python_path, sync_script],
capture_output=True, text=True, timeout=300
)
if process.returncode == 0:
# Уведомляем админа в ТГ
msg = "🤖 <b>AI Синхронизация Успешна!</b>\n\n📄 Архив почты обновлен на Google Drive.\n👉 Не забудь нажать <b>'Sync'</b> в NotebookLM!"
# Отправка через API Telegram (упрощенно)
async with aiohttp.ClientSession() as session:
url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
params = {
"chat_id": config.ADMIN_USER_ID,
"text": msg,
"parse_mode": "HTML"
}
# Используем прокси если нужно
proxy = f"http://{config.PROXY_HOST}:{config.PROXY_PORT}" if config.USE_PROXY else None
await session.get(url, params=params, proxy=proxy)
return {"success": True, "message": "Синхронизация завершена, уведомление отправлено."}
else:
return {"success": False, "error": process.stderr or process.stdout}
except Exception as e:
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
# ============================================================================ # ============================================================================
# ГЛАВНАЯ ПАНЕЛЬ (DASHBOARD) # ГЛАВНАЯ ПАНЕЛЬ (DASHBOARD)
@ -679,7 +646,16 @@ async def emails_page(request: Request, username: str = Depends(get_current_admi
latest_emails.append({"date": "-", "domain": "other", "subject": f.stem}) latest_emails.append({"date": "-", "domain": "other", "subject": f.stem})
master_pdf_path = Path(config.MUTT_DIR) / "ALL_EMAILS_CONSOLIDATED.pdf" master_pdf_path = Path(config.MUTT_DIR) / "ALL_EMAILS_CONSOLIDATED.pdf"
last_update = datetime.fromtimestamp(master_pdf_path.stat().st_mtime, tz=timezone.utc) if master_pdf_path.exists() else None parts = list(Path(config.MUTT_DIR).glob("ALL_EMAILS_CONSOLIDATED_part*.pdf"))
has_master_pdf = master_pdf_path.exists() or bool(parts)
pdf_size_mb = 0.0
last_update = None
if master_pdf_path.exists():
pdf_size_mb = round(master_pdf_path.stat().st_size / (1024 * 1024), 2)
last_update = datetime.fromtimestamp(master_pdf_path.stat().st_mtime, tz=timezone.utc)
elif parts:
pdf_size_mb = round(sum(p.stat().st_size for p in parts) / (1024 * 1024), 2)
last_update = datetime.fromtimestamp(max(p.stat().st_mtime for p in parts), tz=timezone.utc)
# Drive sync status # Drive sync status
sync_time_file = Path(config.MUTT_DIR) / "last_ai_sync.txt" sync_time_file = Path(config.MUTT_DIR) / "last_ai_sync.txt"
@ -706,10 +682,6 @@ async def emails_page(request: Request, username: str = Depends(get_current_admi
except Exception as e: except Exception as e:
pass pass
pdf_size_mb = 0.0
if master_pdf_path.exists():
pdf_size_mb = round(master_pdf_path.stat().st_size / (1024 * 1024), 2)
last_email_date = None last_email_date = None
if audit_data: if audit_data:
dates = [d.last_email_date for d in audit_data if d.last_email_date] dates = [d.last_email_date for d in audit_data if d.last_email_date]
@ -735,15 +707,17 @@ async def api_emails_run_full_sync(username: str = Depends(get_current_admin)):
return JSONResponse(status_code=404, content={"message": "Master sync script not found"}) return JSONResponse(status_code=404, content={"message": "Master sync script not found"})
try: try:
# Run master sync script in background # Run master sync script in background with logging
subprocess.Popen([python_path, master_script], start_new_session=True) log_path = Path(config.MUTT_DIR) / "master_sync.log"
log_file = open(log_path, "a")
subprocess.Popen([python_path, master_script], stdout=log_file, stderr=log_file, start_new_session=True)
return JSONResponse(content={"message": "Полная синхронизация (Сбор -> PDF -> G-Drive) запущена в фоне. Следите за уведомлениями в Telegram."}) return JSONResponse(content={"message": "Полная синхронизация (Сбор -> PDF -> G-Drive) запущена в фоне. Следите за уведомлениями в Telegram."})
except Exception as e: except Exception as e:
return JSONResponse(status_code=500, content={"message": str(e)}) return JSONResponse(status_code=500, content={"message": str(e)})
@app.post("/api/emails/sync_ai") @app.post("/api/emails/sync_ai")
async def api_emails_sync_ai(username: str = Depends(get_current_admin)): async def api_emails_sync_ai(username: str = Depends(get_current_admin)):
sync_script = "/app/domovoy_drive_sync.py" if os.path.exists("/app/domovoy_drive_sync.py") else str(Path(config.MUTT_DIR).parent / "domovoy_drive_sync.py") sync_script = "/home/matrixhasyou/domovoy_drive_sync.py" if os.path.exists("/home/matrixhasyou/domovoy_drive_sync.py") else "/app/domovoy_drive_sync.py"
python_path = sys.executable python_path = sys.executable
master_pdf = str(Path(config.MUTT_DIR) / "TOTAL_ARCHIVE_2025-2026.pdf") master_pdf = str(Path(config.MUTT_DIR) / "TOTAL_ARCHIVE_2025-2026.pdf")
@ -751,8 +725,10 @@ async def api_emails_sync_ai(username: str = Depends(get_current_admin)):
return JSONResponse(status_code=404, content={"message": "Sync script not found"}) return JSONResponse(status_code=404, content={"message": "Sync script not found"})
try: try:
# Run sync script in background # Run sync script in background with logging
subprocess.Popen([python_path, sync_script, master_pdf], start_new_session=True) log_path = Path(config.MUTT_DIR) / "master_sync.log"
log_file = open(log_path, "a")
subprocess.Popen([python_path, sync_script, master_pdf], stdout=log_file, stderr=log_file, start_new_session=True)
return JSONResponse(content={"message": "Синхронизация с Google Drive запущена в фоне. Это займет около минуты."}) return JSONResponse(content={"message": "Синхронизация с Google Drive запущена в фоне. Это займет около минуты."})
except Exception as e: except Exception as e:
return JSONResponse(status_code=500, content={"message": str(e)}) return JSONResponse(status_code=500, content={"message": str(e)})
@ -762,11 +738,14 @@ async def api_emails_pipeline_status(username: str = Depends(get_current_admin))
import time import time
status_file = Path(config.MUTT_DIR) / "pipeline_status.json" status_file = Path(config.MUTT_DIR) / "pipeline_status.json"
master_pdf_path = Path(config.MUTT_DIR) / "ALL_EMAILS_CONSOLIDATED.pdf" master_pdf_path = Path(config.MUTT_DIR) / "ALL_EMAILS_CONSOLIDATED.pdf"
parts = list(Path(config.MUTT_DIR).glob("ALL_EMAILS_CONSOLIDATED_part*.pdf"))
# Calculate fallback values # Calculate fallback values
pdf_size_mb = 0.0 pdf_size_mb = 0.0
if master_pdf_path.exists(): if master_pdf_path.exists():
pdf_size_mb = round(master_pdf_path.stat().st_size / (1024 * 1024), 2) pdf_size_mb = round(master_pdf_path.stat().st_size / (1024 * 1024), 2)
elif parts:
pdf_size_mb = round(sum(p.stat().st_size for p in parts) / (1024 * 1024), 2)
total_emails = 0 total_emails = 0
total_inbound = 0 total_inbound = 0
@ -910,6 +889,131 @@ async def api_sudrf_status(username: str = Depends(get_current_admin)):
except Exception as e: except Exception as e:
return JSONResponse(status_code=500, content={"message": str(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__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000) uvicorn.run(app, host="0.0.0.0", port=8000)

524
web/archive/app_full.py Normal file
View file

@ -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"✅ <b>Верификация подтверждена!</b>\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"⚠️ <b>Верификация отозвана</b>\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()

1547
web/archive/app_reference.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,389 +1,21 @@
/* GLASSMORPHISM DARK UI FOR DOMOVOY WEB PANEL */ /* CLEAN MINIMALIST THEME FOR DOMOVOY WEB PANEL */
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
:root { :root {
--bg-main: #06060c; --bg-main: #f4f4f4;
--bg-gradient: radial-gradient(circle at 50% 0%, #150f30 0%, #06060c 80%); --bg-panel: #ffffff;
--panel-bg: rgba(18, 18, 29, 0.45); --bg-header: #ece9d8;
--panel-bg-hover: rgba(26, 26, 42, 0.6); --border-color: #707680;
--panel-border: rgba(255, 255, 255, 0.08); --border-light: #cccccc;
--panel-border-hover: rgba(255, 255, 255, 0.15); --text-main: #111111;
--text-muted: #555555;
--color-primary: #8a2be2; --link-color: #0000cc;
--color-primary-glow: rgba(138, 43, 226, 0.35); --link-hover: #cc0000;
--color-success: #00ffcc; --badge-bg: #e8e8e8;
--color-success-glow: rgba(0, 255, 204, 0.25);
--color-warning: #ffb300;
--color-warning-glow: rgba(255, 179, 0, 0.25);
--color-danger: #ff2d55;
--color-danger-glow: rgba(255, 45, 85, 0.25);
--color-info: #00bfff;
--color-info-glow: rgba(0, 191, 255, 0.25);
--color-text-main: #f5f5f7;
--color-text-muted: #8e8e93;
--color-text-dark: #3a3a3c;
--font-main: 'Outfit', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--shadow-neon: 0 8px 32px 0 rgba(0, 0, 0, 0.4), 0 0 16px 0 var(--color-primary-glow);
--transition-smooth: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
} }
body { body {
background-color: var(--bg-main) !important; background-color: var(--bg-main);
background-image: var(--bg-gradient) !important; color: var(--text-main);
background-attachment: fixed !important; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
color: var(--color-text-main) !important; font-size: 13px;
font-family: var(--font-main) !important; line-height: 1.45;
min-height: 100vh;
overflow-x: hidden;
line-height: 1.5;
} }
/* Сайдбар */
.sidebar {
background: rgba(10, 10, 18, 0.3) !important;
backdrop-filter: blur(20px) !important;
-webkit-backdrop-filter: blur(20px) !important;
border-right: 1px solid var(--panel-border) !important;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.3) !important;
}
.sidebar .nav-link {
color: var(--color-text-muted) !important;
border-left: 3px solid transparent !important;
transition: var(--transition-smooth) !important;
padding: 0.75rem 1.5rem !important;
font-weight: 500 !important;
text-transform: uppercase !important;
font-family: var(--font-main) !important;
letter-spacing: 0.5px !important;
}
.sidebar .nav-link:hover {
color: var(--color-text-main) !important;
background: rgba(255, 255, 255, 0.03) !important;
border-left-color: rgba(255, 255, 255, 0.2) !important;
}
.sidebar .nav-link.active {
color: var(--color-success) !important;
background: rgba(0, 255, 204, 0.05) !important;
border-left-color: var(--color-success) !important;
text-shadow: 0 0 10px var(--color-success-glow) !important;
}
.sidebar .nav-link i, .sidebar .nav-link bi {
margin-right: 0.5rem !important;
font-size: 1.1rem !important;
}
/* Карточки */
.card {
background: var(--panel-bg) !important;
backdrop-filter: blur(12px) !important;
-webkit-backdrop-filter: blur(12px) !important;
border: 1px solid var(--panel-border) !important;
border-radius: 16px !important;
transition: var(--transition-smooth) !important;
color: var(--color-text-main) !important;
overflow: hidden !important;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15) !important;
}
.card:hover {
background: var(--panel-bg-hover) !important;
border-color: var(--panel-border-hover) !important;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4) !important;
transform: translateY(-2px) !important;
}
.card-header {
background: rgba(255, 255, 255, 0.02) !important;
border-bottom: 1px solid var(--panel-border) !important;
font-weight: 600 !important;
text-transform: uppercase !important;
letter-spacing: 0.5px !important;
padding: 1rem 1.25rem !important;
color: var(--color-text-main) !important;
}
.card-body {
padding: 1.25rem !important;
}
/* Переопределение старых рамок и фонов */
.border-success, .border-warning, .border-cyan, .border-danger {
border-color: var(--panel-border) !important;
}
.card.border-success:hover, .card.border-warning:hover, .card.border-cyan:hover {
border-color: var(--panel-border-hover) !important;
}
.card.border-danger {
border-color: rgba(255, 45, 85, 0.3) !important;
}
.card.border-danger:hover {
border-color: var(--color-danger) !important;
box-shadow: 0 8px 30px rgba(255, 45, 85, 0.15) !important;
}
/* Таблицы */
.table {
color: var(--color-text-main) !important;
background-color: transparent !important;
}
.table > :not(caption) > * > * {
background-color: transparent !important;
color: var(--color-text-main) !important;
border-bottom: 1px solid var(--panel-border) !important;
}
.table > thead > tr > th {
color: var(--color-text-muted) !important;
font-weight: 600 !important;
text-transform: uppercase !important;
font-size: 0.8rem !important;
letter-spacing: 0.5px !important;
border-bottom: 2px solid var(--panel-border) !important;
}
.table-hover tbody tr:hover td {
background-color: rgba(255, 255, 255, 0.02) !important;
color: var(--color-text-main) !important;
}
/* Кнопки */
.btn {
border-radius: 8px !important;
font-weight: 500 !important;
transition: var(--transition-smooth) !important;
text-transform: uppercase !important;
letter-spacing: 0.5px !important;
}
.btn-matrix, .btn-outline-success {
background-color: transparent !important;
border: 1px solid var(--color-success) !important;
color: var(--color-success) !important;
}
.btn-matrix:hover, .btn-outline-success:hover, .btn-outline-success:active, .btn-outline-success:focus {
background-color: var(--color-success) !important;
color: #000 !important;
border-color: var(--color-success) !important;
box-shadow: 0 0 15px var(--color-success-glow) !important;
}
.btn-outline-primary {
border-color: var(--color-primary) !important;
color: var(--color-text-main) !important;
}
.btn-outline-primary:hover {
background-color: var(--color-primary) !important;
color: var(--color-text-main) !important;
box-shadow: 0 0 15px var(--color-primary-glow) !important;
}
.btn-outline-danger {
border-color: var(--color-danger) !important;
color: var(--color-danger) !important;
background-color: transparent !important;
}
.btn-outline-danger:hover {
background-color: var(--color-danger) !important;
color: #fff !important;
box-shadow: 0 0 15px var(--color-danger-glow) !important;
}
.btn-outline-warning {
border-color: var(--color-warning) !important;
color: var(--color-warning) !important;
background-color: transparent !important;
}
.btn-outline-warning:hover {
background-color: var(--color-warning) !important;
color: #000 !important;
box-shadow: 0 0 15px var(--color-warning-glow) !important;
}
.btn-outline-info {
border-color: var(--color-info) !important;
color: var(--color-info) !important;
background-color: transparent !important;
}
.btn-outline-info:hover {
background-color: var(--color-info) !important;
color: #000 !important;
box-shadow: 0 0 15px var(--color-info-glow) !important;
}
/* Формы */
.form-control, .form-select {
background-color: rgba(255, 255, 255, 0.03) !important;
border: 1px solid var(--panel-border) !important;
color: var(--color-text-main) !important;
border-radius: 8px !important;
transition: var(--transition-smooth) !important;
}
.form-control:focus, .form-select:focus {
background-color: rgba(255, 255, 255, 0.05) !important;
border-color: var(--color-primary) !important;
color: var(--color-text-main) !important;
box-shadow: 0 0 8px var(--color-primary-glow) !important;
}
.form-control::placeholder {
color: var(--color-text-muted) !important;
opacity: 0.6 !important;
}
.bg-black {
background-color: rgba(10, 10, 15, 0.5) !important;
border: 1px solid var(--panel-border) !important;
}
/* Ссылки */
a {
color: var(--color-success);
text-decoration: none;
transition: var(--transition-smooth);
}
a:hover {
color: #fff;
text-shadow: 0 0 8px var(--color-success-glow);
}
/* Баджи */
.badge {
border-radius: 6px !important;
font-weight: 500 !important;
padding: 0.4em 0.6em !important;
}
.bg-success {
background-color: rgba(0, 255, 204, 0.15) !important;
color: var(--color-success) !important;
border: 1px solid var(--color-success) !important;
}
.bg-danger {
background-color: rgba(255, 45, 85, 0.15) !important;
color: var(--color-danger) !important;
border: 1px solid var(--color-danger) !important;
}
.bg-warning {
background-color: rgba(255, 179, 0, 0.15) !important;
color: var(--color-warning) !important;
border: 1px solid var(--color-warning) !important;
}
.bg-info {
background-color: rgba(0, 191, 255, 0.15) !important;
color: var(--color-info) !important;
border: 1px solid var(--color-info) !important;
}
/* Утилиты и кастомный контент */
hr {
border-color: var(--panel-border) !important;
opacity: 0.5 !important;
}
pre, code {
font-family: var(--font-mono) !important;
}
/* Кастомный Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: rgba(10, 10, 15, 0.5);
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.15);
}
/* Дополнения для соответствия Swarm Dashboard */
.glass-panel {
background: var(--panel-bg) !important;
backdrop-filter: blur(12px) !important;
-webkit-backdrop-filter: blur(12px) !important;
border: 1px solid var(--panel-border) !important;
border-radius: 16px !important;
padding: 24px !important;
transition: var(--transition-smooth) !important;
}
.glass-panel:hover {
background: var(--panel-bg-hover) !important;
border-color: var(--panel-border-hover) !important;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4) !important;
}
@keyframes neonPulseSuccess {
0%, 100% { box-shadow: 0 0 8px var(--color-success-glow); }
50% { box-shadow: 0 0 18px rgba(0, 255, 204, 0.6); }
}
@keyframes neonPulseDanger {
0%, 100% { box-shadow: 0 0 8px var(--color-danger-glow); }
50% { box-shadow: 0 0 18px rgba(255, 45, 85, 0.6); }
}
@keyframes neonPulsePrimary {
0%, 100% { box-shadow: 0 0 8px var(--color-primary-glow); }
50% { box-shadow: 0 0 18px rgba(138, 43, 226, 0.6); }
}
.glow-success {
animation: neonPulseSuccess 3s infinite alternate !important;
}
.glow-danger {
animation: neonPulseDanger 3s infinite alternate !important;
}
.glow-primary {
animation: neonPulsePrimary 3s infinite alternate !important;
}
.text-gradient {
background: linear-gradient(135deg, #fff 0%, var(--color-text-muted) 100%) !important;
-webkit-background-clip: text !important;
-webkit-text-fill-color: transparent !important;
}
.text-gradient-neon {
background: linear-gradient(135deg, #00ffcc 0%, #8a2be2 100%) !important;
-webkit-background-clip: text !important;
-webkit-text-fill-color: transparent !important;
}
.flex-center {
display: flex !important;
align-items: center !important;
justify-content: center !important;
}

View file

@ -6,192 +6,344 @@
<title>{% block title %}LKM37 CORE{% endblock %}</title> <title>{% block title %}LKM37 CORE{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<link href="/static/css/matrix.css" rel="stylesheet">
<style> <style>
.sidebar { min-height: 100vh; position: sticky; top: 0; overflow-y: auto; } :root {
.content { padding: 2rem; } --bg-main: #f4f4f4;
--bg-panel: #ffffff;
--bg-header: #ece9d8;
--border-color: #707680;
--border-light: #cccccc;
--text-main: #111111;
--text-muted: #555555;
--link-color: #0000cc;
--link-hover: #cc0000;
--badge-bg: #e8e8e8;
}
body {
background-color: var(--bg-main);
color: var(--text-main);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 13px;
line-height: 1.45;
margin: 0;
padding: 0;
}
a {
color: var(--link-color);
text-decoration: none;
}
a:hover {
color: var(--link-hover);
text-decoration: underline;
}
.layout-container {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 260px;
background-color: var(--bg-panel);
border-right: 1px solid var(--border-color);
padding: 12px;
flex-shrink: 0;
}
.sidebar-header {
background-color: var(--bg-header);
border: 1px solid var(--border-color);
padding: 8px 12px;
margin-bottom: 12px;
text-align: center;
}
.sidebar-header h4 {
font-size: 15px;
font-weight: bold;
margin: 0;
letter-spacing: 0.5px;
}
.sidebar-header small {
font-size: 11px;
color: var(--text-muted);
}
.nav-section-title {
font-size: 11px;
font-weight: bold;
color: var(--text-muted);
text-transform: uppercase;
margin-top: 10px;
margin-bottom: 4px;
padding-left: 6px;
border-left: 3px solid var(--border-color);
}
.nav-link {
display: block;
padding: 4px 8px;
color: var(--text-main) !important;
border: 1px solid transparent;
border-radius: 0;
margin-bottom: 2px;
font-size: 12px;
}
.nav-link:hover {
background-color: #eaeaea;
border-color: var(--border-light);
text-decoration: none;
}
.nav-link.active {
background-color: var(--bg-header);
border-color: var(--border-color);
font-weight: bold;
}
.nav-link i {
margin-right: 6px;
font-size: 13px;
}
.main-content {
flex-grow: 1;
padding: 16px 24px;
overflow-x: auto;
}
.card, .panel {
background-color: var(--bg-panel);
border: 1px solid var(--border-color);
border-radius: 0;
margin-bottom: 16px;
}
.card-header, .panel-header {
background-color: var(--bg-header);
border-bottom: 1px solid var(--border-color);
padding: 6px 12px;
font-weight: bold;
font-size: 13px;
border-radius: 0 !important;
}
.card-body, .panel-body {
padding: 12px;
}
table {
border-collapse: collapse;
width: 100%;
background-color: var(--bg-panel);
font-size: 12px;
}
table th, table td {
border: 1px solid var(--border-light);
padding: 5px 8px;
text-align: left;
}
table th {
background-color: var(--bg-header);
border-color: var(--border-color);
font-weight: bold;
}
table tr:hover {
background-color: #fafafa;
}
.btn-unix {
display: inline-block;
font-family: inherit;
font-size: 12px;
padding: 3px 8px;
background-color: #e8e8e8;
color: #000000;
border: 1px solid var(--border-color);
border-radius: 0;
cursor: pointer;
text-decoration: none;
}
.btn-unix:hover {
background-color: #d8d8d8;
color: #000000;
text-decoration: none;
}
.btn-unix-primary {
background-color: #002b49;
color: #ffffff;
border-color: #001828;
}
.btn-unix-primary:hover {
background-color: #003d66;
color: #ffffff;
}
pre, code {
font-family: inherit;
background-color: #e8e8e8;
border: 1px solid var(--border-light);
padding: 2px 4px;
font-size: 12px;
color: #111111;
}
pre {
padding: 8px;
overflow-x: auto;
}
.badge-status {
display: inline-block;
padding: 2px 6px;
border: 1px solid var(--border-color);
background-color: var(--badge-bg);
font-size: 11px;
font-weight: bold;
}
.badge-success { background-color: #e2f0d9 !important; border-color: #70ad47 !important; color: #385723 !important; }
.badge-warning { background-color: #fff2cc !important; border-color: #ffc000 !important; color: #806000 !important; }
.badge-danger { background-color: #fce4d6 !important; border-color: #ed7d31 !important; color: #c65911 !important; }
.badge-info { background-color: #e6f2ff !important; border-color: #66a3ff !important; color: #004080 !important; }
/* Глобальные переопределения Bootstrap и утилит для светлого стиля */
.card, .panel {
background-color: var(--bg-panel) !important;
border: 1px solid var(--border-color) !important;
border-radius: 0 !important;
box-shadow: none !important;
color: var(--text-main) !important;
}
.card-header, .panel-header {
background-color: var(--bg-header) !important;
border-bottom: 1px solid var(--border-color) !important;
color: var(--text-main) !important;
font-weight: bold !important;
border-radius: 0 !important;
}
.bg-black, .bg-dark {
background-color: #ffffff !important;
color: var(--text-main) !important;
border-color: var(--border-color) !important;
}
.text-white {
color: var(--text-main) !important;
}
.text-matrix, .text-cyan, .text-success, .text-info {
color: var(--text-main) !important;
}
.btn-matrix {
background-color: #e8e8e8 !important;
color: #000000 !important;
border: 1px solid var(--border-color) !important;
border-radius: 0 !important;
font-family: inherit !important;
font-size: 12px !important;
box-shadow: none !important;
text-shadow: none !important;
}
.btn-matrix:hover {
background-color: #d8d8d8 !important;
color: #000000 !important;
}
input, select, textarea, .form-control, .form-select {
background-color: #ffffff !important;
color: var(--text-main) !important;
border: 1px solid var(--border-color) !important;
border-radius: 0 !important;
font-family: inherit !important;
font-size: 12px !important;
box-shadow: none !important;
}
input:focus, select:focus, textarea:focus, .form-control:focus, .form-select:focus {
background-color: #ffffff !important;
color: var(--text-main) !important;
border-color: #0000cc !important;
box-shadow: none !important;
outline: none !important;
}
h1, h2, h3, h4, h5, h6 {
color: var(--text-main) !important;
text-shadow: none !important;
}
.shadow, .shadow-sm, .shadow-lg, .shadow-danger {
box-shadow: none !important;
}
{% block extra_style %}{% endblock %} {% block extra_style %}{% endblock %}
</style> </style>
</head> </head>
<body> <body>
<div id="matrix-canvas-container" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: -1; opacity: 0.1;"> <div class="layout-container">
<canvas id="matrix-canvas"></canvas> <!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<h4>МКД ЛЕНКОМ 37</h4>
</div> </div>
<div class="container-fluid"> <nav>
<div class="row">
<!-- Sidebar (ВОССТАНОВЛЕННОЕ ПОЛНОЕ МЕНЮ) -->
<div class="col-md-2 sidebar p-0">
<div class="p-3 text-center border-bottom" style="border-color: var(--panel-border) !important;">
<h4 class="mb-0" style="color: var(--color-success); text-shadow: 0 0 15px var(--color-success-glow); font-weight: 700; letter-spacing: 1px;">LKM37</h4>
<small class="text-success opacity-50" style="font-family: var(--font-mono); font-size: 0.7rem; letter-spacing: 0.5px;">CORE SYSTEM v4.5</small>
</div>
<nav class="nav flex-column mt-3">
<a class="nav-link {% block nav_dashboard %}{% endblock %}" href="/"> <a class="nav-link {% block nav_dashboard %}{% endblock %}" href="/">
<i class="bi bi-terminal"></i> ДАШБОРД <i class="bi bi-terminal"></i> Дашборд
</a> </a>
<div class="mt-3 px-3"><small class="text-success opacity-50 text-uppercase" style="font-size: 0.6em;">Жильцы</small></div> <div class="nav-section-title">Правовая база</div>
<a class="nav-link {% block nav_users %}{% endblock %}" href="/users"> <a class="nav-link {% block nav_legal %}{% endblock %}" href="/legal">
<i class="bi bi-people"></i> ЖИЛЬЦЫ <i class="bi bi-shield-check"></i> Законы РФ & ИИ-Юрист
</a>
<a class="nav-link {% block nav_verification %}{% endblock %}" href="/verification">
<i class="bi bi-shield-lock"></i> ВЕРИФИКАЦИЯ
</a>
<a class="nav-link {% block nav_phones %}{% endblock %}" href="/phones">
<i class="bi bi-telephone"></i> ТЕЛЕФОНЫ
</a>
<div class="mt-3 px-3"><small class="text-success opacity-50 text-uppercase" style="font-size: 0.6em;">Контент</small></div>
<a class="nav-link {% block nav_broadcast %}{% endblock %}" href="/broadcast">
<i class="bi bi-broadcast"></i> РАССЫЛКА
</a>
<a class="nav-link {% block nav_scheduled_posts %}{% endblock %}" href="/scheduled_posts">
<i class="bi bi-clock-history"></i> ПОСТЫ
</a>
<a class="nav-link {% block nav_digests %}{% endblock %}" href="/digests">
<i class="bi bi-file-earmark-text"></i> ДАЙДЖЕСТЫ
</a>
<a class="nav-link {% block nav_files %}{% endblock %}" href="/files">
<i class="bi bi-folder2-open"></i> ФАЙЛЫ
</a>
<div class="mt-3 px-3"><small class="text-success opacity-50 text-uppercase" style="font-size: 0.6em;">Сервисы</small></div>
<a class="nav-link {% block nav_schedules %}{% endblock %}" href="/schedules">
<i class="bi bi-calendar-event"></i> ГРАФИКИ
</a>
<a class="nav-link {% block nav_ads %}{% endblock %}" href="/ads">
<i class="bi bi-shop"></i> ОБЪЯВЛЕНИЯ
</a>
<a class="nav-link {% block nav_polls %}{% endblock %}" href="/polls">
<i class="bi bi-check2-square"></i> ОПРОСЫ
</a>
<a class="nav-link {% block nav_events %}{% endblock %}" href="/events">
<i class="bi bi-calendar3"></i> СОБЫТИЯ
</a>
<div class="mt-3 px-3"><small class="text-success opacity-50 text-uppercase" style="font-size: 0.6em;">Юрист</small></div>
<a class="nav-link {% block nav_sudrf %}{% endblock %}" href="/sudrf">
<i class="bi bi-bank text-danger"></i> ЖАТВА ГАС
</a>
<div class="mt-3 px-3"><small class="text-success opacity-50 text-uppercase" style="font-size: 0.6em;">Система</small></div>
<a class="nav-link {% block nav_infra %}{% endblock %}" href="/infra">
<i class="bi bi-cpu"></i> ИНФРАСТРУКТУРА
</a>
<a class="nav-link {% block nav_export %}{% endblock %}" href="/export">
<i class="bi bi-download"></i> ЭКСПОРТ
</a> </a>
<a class="nav-link {% block nav_emails %}{% endblock %}" href="/emails"> <a class="nav-link {% block nav_emails %}{% endblock %}" href="/emails">
<i class="bi bi-envelope-at"></i> ПОЧТА <i class="bi bi-envelope-check"></i> Реестр писем & ЭЦП
</a>
<a class="nav-link {% block nav_sudrf %}{% endblock %}" href="/sudrf">
<i class="bi bi-bank"></i> Суды ГАС Правосудие
</a>
<div class="nav-section-title">Жильцы & Доступ</div>
<a class="nav-link {% block nav_users %}{% endblock %}" href="/users">
<i class="bi bi-people"></i> Жильцы (AI доступ)
</a>
<a class="nav-link {% block nav_verification %}{% endblock %}" href="/verification">
<i class="bi bi-patch-check"></i> Верификация
</a>
<a class="nav-link {% block nav_phones %}{% endblock %}" href="/phones">
<i class="bi bi-telephone"></i> Телефоны
</a>
<div class="nav-section-title">Коммуникация</div>
<a class="nav-link {% block nav_broadcast %}{% endblock %}" href="/broadcast">
<i class="bi bi-broadcast"></i> Оповещения (Zero-Spam)
</a>
<a class="nav-link {% block nav_scheduled_posts %}{% endblock %}" href="/scheduled_posts">
<i class="bi bi-clock-history"></i> Запланированные посты
</a>
<a class="nav-link {% block nav_digests %}{% endblock %}" href="/digests">
<i class="bi bi-newspaper"></i> Дайджесты
</a>
<div class="nav-section-title">Сервисы & Инфра</div>
<a class="nav-link {% block nav_files %}{% endblock %}" href="/files">
<i class="bi bi-folder2-open"></i> Файлы и архивы
</a>
<a class="nav-link {% block nav_schedules %}{% endblock %}" href="/schedules">
<i class="bi bi-calendar3"></i> Графики
</a>
<a class="nav-link {% block nav_ads %}{% endblock %}" href="/ads">
<i class="bi bi-shop"></i> Объявления
</a>
<a class="nav-link {% block nav_polls %}{% endblock %}" href="/polls">
<i class="bi bi-check2-square"></i> Опросы
</a>
<a class="nav-link {% block nav_infra %}{% endblock %}" href="/infra">
<i class="bi bi-cpu"></i> Инфраструктура
</a>
<a class="nav-link {% block nav_export %}{% endblock %}" href="/export">
<i class="bi bi-download"></i> Экспорт базы
</a> </a>
</nav> </nav>
</div> </aside>
<!-- Main Content --> <!-- Main Content -->
<div class="col-md-10 content"> <main class="main-content">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </main>
</div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script> {% block scripts %}{% endblock %}
const canvas = document.getElementById('matrix-canvas');
const ctx = canvas.getContext('2d');
let width = canvas.width = window.innerWidth;
let height = canvas.height = window.innerHeight;
const particles = [];
const properties = {
bgColor: '#06060c',
particleColor: 'rgba(138, 43, 226, 0.25)',
lineColor: 'rgba(0, 255, 204, 0.05)',
particleRadius: 2.5,
particleCount: 70,
maxVelocity: 0.4,
lineLength: 140
};
window.addEventListener('resize', function() {
width = canvas.width = window.innerWidth;
height = canvas.height = window.innerHeight;
});
class Particle {
constructor() {
this.x = Math.random() * width;
this.y = Math.random() * height;
this.velocityPosition = {
x: (Math.random() - 0.5) * properties.maxVelocity,
y: (Math.random() - 0.5) * properties.maxVelocity
}
}
position() {
if (this.x + this.velocityPosition.x > width || this.x + this.velocityPosition.x < 0) {
this.velocityPosition.x = -this.velocityPosition.x;
}
if (this.y + this.velocityPosition.y > height || this.y + this.velocityPosition.y < 0) {
this.velocityPosition.y = -this.velocityPosition.y;
}
this.x += this.velocityPosition.x;
this.y += this.velocityPosition.y;
}
reDraw() {
ctx.beginPath();
ctx.arc(this.x, this.y, properties.particleRadius, 0, Math.PI * 2);
ctx.closePath();
ctx.fillStyle = properties.particleColor;
ctx.fill();
}
}
function drawLines() {
let x1, y1, x2, y2, length, opacity;
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
x1 = particles[i].x;
y1 = particles[i].y;
x2 = particles[j].x;
y2 = particles[j].y;
length = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
if (length < properties.lineLength) {
opacity = 1 - (length / properties.lineLength);
ctx.strokeStyle = `rgba(0, 255, 204, ${opacity * 0.06})`;
ctx.lineWidth = '0.5';
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.closePath();
ctx.stroke();
}
}
}
}
function init() {
for (let i = 0; i < properties.particleCount; i++) {
particles.push(new Particle());
}
loop();
}
function loop() {
ctx.fillStyle = properties.bgColor;
ctx.fillRect(0, 0, width, height);
for (let i = 0; i < particles.length; i++) {
particles[i].position();
particles[i].reDraw();
}
drawLines();
requestAnimationFrame(loop);
}
init();
</script>
{% block extra_script %}{% endblock %}
</body> </body>
</html> </html>

View file

@ -7,24 +7,24 @@
{% block extra_style %} {% block extra_style %}
<style> <style>
#preview { max-width: 400px; margin-top: 1rem; display: none; } #preview { max-width: 400px; margin-top: 1rem; display: none; }
#preview img { max-width: 100%; border-radius: 8px; } #preview img { max-width: 100%; border: 1px solid var(--border-color); }
.smart-options { display: none; background: #f0f7ff; padding: 20px; border-radius: 8px; margin-top: 15px; border: 2px solid #667eea; } .smart-options { display: none; background: #ffffff; padding: 16px; border: 1px solid var(--border-color); margin-top: 12px; }
.smart-options.active { display: block; } .smart-options.active { display: block; }
.broadcast-type-selector { display: flex; gap: 15px; margin-bottom: 20px; } .broadcast-type-selector { display: flex; gap: 12px; margin-bottom: 16px; }
.broadcast-type-option { flex: 1; padding: 20px; border: 2px solid #e2e8f0; border-radius: 8px; cursor: pointer; transition: all 0.3s; text-align: center; } .broadcast-type-option { flex: 1; padding: 14px; border: 1px solid var(--border-color); background: #ffffff; cursor: pointer; text-align: center; }
.broadcast-type-option:hover { border-color: #667eea; background: #f8f9ff; } .broadcast-type-option:hover { background: #f0f0f0; }
.broadcast-type-option.active { border-color: #667eea; background: #eef2ff; } .broadcast-type-option.active { border: 2px solid #0000cc; background: #ece9d8; font-weight: bold; }
.broadcast-type-option h4 { margin-bottom: 10px; } .broadcast-type-option h4 { margin-bottom: 6px; font-size: 14px; }
.broadcast-type-option .icon { font-size: 2.5em; margin-bottom: 10px; } .broadcast-type-option .icon { font-size: 20px; margin-bottom: 6px; }
.nav-tabs .nav-link { color: #495057; } .nav-tabs .nav-link { color: var(--text-main); border-radius: 0; }
.nav-tabs .nav-link.active { font-weight: bold; color: #667eea; } .nav-tabs .nav-link.active { font-weight: bold; border-color: var(--border-color) var(--border-color) #ffffff; }
.broadcast-item { background: white; padding: 15px; border-radius: 8px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } .broadcast-item { background: white; padding: 12px; border: 1px solid var(--border-color); margin-bottom: 10px; }
.badge-smart { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; } .badge-smart { background: #e2f0d9; border: 1px solid #70ad47; color: #385723; font-weight: bold; }
.badge-regular { background: #6c757d; color: white; } .badge-regular { background: #e8e8e8; border: 1px solid var(--border-color); color: #111111; }
.progress-mini { width: 100px; height: 8px; background: #e2e8f0; border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; margin-left: 5px; } .progress-mini { width: 100px; height: 8px; background: #e8e8e8; border: 1px solid var(--border-light); display: inline-block; vertical-align: middle; margin-left: 5px; }
.progress-mini-fill { height: 100%; background: linear-gradient(90deg, #48bb78, #38a169); } .progress-mini-fill { height: 100%; background: #385723; }
.readers-table { font-size: 0.9em; } .readers-table { font-size: 12px; }
.readers-table th { background: #f8f9fa; } .readers-table th { background: var(--bg-header); }
</style> </style>
{% endblock %} {% endblock %}
@ -76,8 +76,8 @@
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label class="form-label text-info">🖼️ Использовать готовое фото:</label> <label class="form-label">🖼️ Использовать готовое фото (Артефакт):</label>
<select class="form-select bg-dark text-info border-info" id="broadcastArtifactPhoto"> <select class="form-select" id="broadcastArtifactPhoto">
<option value="">-- Не выбрано (или загрузить новое) --</option> <option value="">-- Не выбрано (или загрузить новое) --</option>
{% for a in artifacts if a.file_type == 'photo' %} {% for a in artifacts if a.file_type == 'photo' %}
<option value="{{ a.file_id }}">{{ a.filename }}</option> <option value="{{ a.file_id }}">{{ a.filename }}</option>
@ -87,8 +87,8 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label class="form-label text-warning">📄 Использовать готовый документ:</label> <label class="form-label">📄 Использовать готовый документ (Артефакт):</label>
<select class="form-select bg-dark text-warning border-warning" id="broadcastArtifactDocument"> <select class="form-select" id="broadcastArtifactDocument">
<option value="">-- Не выбрано (или загрузить новое) --</option> <option value="">-- Не выбрано (или загрузить новое) --</option>
{% for a in artifacts if a.file_type == 'document' %} {% for a in artifacts if a.file_type == 'document' %}
<option value="{{ a.file_id }}">{{ a.filename }}</option> <option value="{{ a.file_id }}">{{ a.filename }}</option>

View file

@ -1,38 +1,41 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}LKM37 Dashboard{% endblock %} {% block title %}Дашборд - МКД ЛенКом 37{% endblock %}
{% block nav_dashboard %}active{% endblock %} {% block nav_dashboard %}active{% endblock %}
{% block content %} {% block content %}
<div class="row mb-4"> <div class="row mb-3">
<div class="col"> <div class="col">
<h2 style="color: var(--matrix-green); text-shadow: 0 0 10px var(--matrix-green);"> <h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Панель управления МКД ЛенКом 37</h4>
>_ ТЕРМИНАЛ_УПРАВЛЕНИЯ_ЛКМ37 <div class="small text-muted">Сводные показатели системы и текущий статус сервисов</div>
</h2>
</div> </div>
</div> </div>
<div class="row g-4"> <div class="row g-3">
<!-- КАРТОЧКА: ЖИЛЬЦЫ --> <!-- КАРТОЧКА: ЖИЛЬЦЫ -->
<div class="col-md-3"> <div class="col-md-3">
<div class="card h-100"> <div class="card h-100">
<div class="card-header">ЖИЛЬЦЫ</div>
<div class="card-body text-center"> <div class="card-body text-center">
<i class="bi bi-people-fill display-4 mb-2"></i> <i class="bi bi-people mb-2" style="font-size: 28px; color: #555555;"></i>
<h5 class="card-title opacity-50">СУБЪЕКТЫ</h5> <div class="small text-muted">Всего зарегистрировано</div>
<h2 class="display-6">{{ users_count }}</h2> <div style="font-size: 24px; font-weight: bold; margin-top: 4px;">{{ users_count }}</div>
</div> </div>
</div> </div>
</div> </div>
<!-- КАРТОЧКА: ВЕРИФИКАЦИЯ --> <!-- КАРТОЧКА: ВЕРИФИКАЦИЯ -->
<div class="col-md-3"> <div class="col-md-3">
<div class="card h-100 {% if pending_count > 0 %}border-danger shadow-danger{% endif %}"> <div class="card h-100">
<div class="card-header">ВЕРИФИКАЦИЯ</div>
<div class="card-body text-center"> <div class="card-body text-center">
<i class="bi bi-shield-lock-fill display-4 mb-2"></i> <i class="bi bi-shield-lock mb-2" style="font-size: 28px; color: #555555;"></i>
<h5 class="card-title opacity-50">ВЕРИФИКАЦИЯ</h5> <div class="small text-muted">Заявок на проверку</div>
<h2 class="display-6">{{ pending_count }}</h2> <div style="font-size: 24px; font-weight: bold; margin-top: 4px;">{{ pending_count }}</div>
{% if pending_count > 0 %} {% if pending_count > 0 %}
<span class="badge bg-danger">ТРЕБУЕТ ВНИМАНИЯ</span> <div class="mt-2">
<span class="badge-status badge-danger">ТРЕБУЕТ ВНИМАНИЯ</span>
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -40,54 +43,79 @@
<!-- КАРТОЧКА: ОБЪЯВЛЕНИЯ --> <!-- КАРТОЧКА: ОБЪЯВЛЕНИЯ -->
<div class="col-md-3"> <div class="col-md-3">
<div class="card h-100 border-warning"> <div class="card h-100">
<div class="card-header">ОБЪЯВЛЕНИЯ</div>
<div class="card-body text-center"> <div class="card-body text-center">
<i class="bi bi-shop display-4 mb-2"></i> <i class="bi bi-shop mb-2" style="font-size: 28px; color: #555555;"></i>
<h5 class="card-title opacity-50">МАРКЕТ</h5> <div class="small text-muted">Активные публикации</div>
<h2 class="display-6 text-warning">{{ active_ads }}</h2> <div style="font-size: 24px; font-weight: bold; margin-top: 4px;">{{ active_ads }}</div>
</div> </div>
</div> </div>
</div> </div>
<!-- КАРТОЧКА: АКТИВНОСТЬ --> <!-- КАРТОЧКА: АКТИВНОСТЬ -->
<div class="col-md-3"> <div class="col-md-3">
<div class="card h-100 border-cyan"> <div class="card h-100">
<div class="card-header">СООБЩЕНИЯ</div>
<div class="card-body text-center"> <div class="card-body text-center">
<i class="bi bi-activity display-4 mb-2"></i> <i class="bi bi-activity mb-2" style="font-size: 28px; color: #555555;"></i>
<h5 class="card-title opacity-50">СООБЩЕНИЯ</h5> <div class="small text-muted">Активность чата</div>
<h2 class="display-6" style="color: var(--matrix-cyan);">{{ messages_count }}</h2> <div style="font-size: 24px; font-weight: bold; margin-top: 4px;">{{ messages_count }}</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="row mt-4"> <div class="row mt-3 g-3">
<div class="col-md-8"> <div class="col-md-8">
<div class="card"> <div class="card h-100">
<div class="card-header">СИСТЕМНЫЙ_СТАТУС</div> <div class="card-header">СИСТЕМНЫЙ СТАТУС</div>
<div class="card-body"> <div class="card-body">
<div class="p-3 bg-black border border-success font-monospace small"> <table class="table mb-0">
[OK] Database: PostgreSQL (Synology NAS)<br> <tbody>
[OK] Telegram: @lkmthreeseven_bot<br> <tr>
[OK] Proxy: Active (127.0.0.1:10808)<br> <td style="width: 220px; font-weight: bold;">База данных:</td>
[OK] Node: {{ username }}@server<br> <td>PostgreSQL (Synology NAS 192.168.10.105:5433) <span class="badge-status badge-success">OK</span></td>
<hr class="border-success"> </tr>
[INFO] Всего верифицировано: {{ verified_count }} жильцов.<br> <tr>
[INFO] Активных опросов: {{ active_polls }}<br> <td style="font-weight: bold;">Telegram-бот:</td>
[INFO] Предстоящих событий: {{ upcoming_events }} <td>@lkmthreeseven_bot (DomovoyKontrolBot) <span class="badge-status badge-success">ONLINE</span></td>
</div> </tr>
<tr>
<td style="font-weight: bold;">Юридический модуль:</td>
<td>Google NotebookLM (аккаунт corteskelsey814, 14 источников) <span class="badge-status badge-success">READY</span></td>
</tr>
<tr>
<td style="font-weight: bold;">Текущий узел:</td>
<td>{{ username }}@DockerServer (192.168.10.116)</td>
</tr>
<tr>
<td style="font-weight: bold;">Верифицировано жильцов:</td>
<td>{{ verified_count }} чел.</td>
</tr>
<tr>
<td style="font-weight: bold;">Активных опросов:</td>
<td>{{ active_polls }}</td>
</tr>
<tr>
<td style="font-weight: bold;">Предстоящих событий:</td>
<td>{{ upcoming_events }}</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="card"> <div class="card h-100">
<div class="card-header">БЫСТРЫЕ_ДЕЙСТВИЯ</div> <div class="card-header">ДЕЙСТВИЯ</div>
<div class="card-body"> <div class="card-body d-flex flex-column gap-2">
<a href="/broadcast" class="btn btn-matrix w-100 mb-2">НОВАЯ РАССЫЛКА</a> <a href="/broadcast" class="btn-unix btn-unix-primary text-center">Новая рассылка</a>
<a href="/verification" class="btn btn-outline-success w-100 btn-sm mb-2">ПРОВЕРИТЬ ЗАЯВКИ</a> <a href="/verification" class="btn-unix text-center">Проверить заявки жильцов</a>
<form action="/api/digest/generate" method="post"> <a href="/legal" class="btn-unix text-center">База законов и NotebookLM</a>
<button type="submit" class="btn btn-outline-info w-100 btn-sm">ДАЙДЖЕСТ (ТЕСТ)</button> <form action="/api/digest/generate" method="post" class="mt-auto">
<button type="submit" class="btn-unix w-100">Тестовая генерация дайджеста</button>
</form> </form>
</div> </div>
</div> </div>

View file

@ -1,108 +1,98 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Настройки дайджеста - Домовой Бот{% endblock %} {% block title %}Настройки дайджеста - МКД ЛенКом 37{% endblock %}
{% block nav_digests %}active{% endblock %}
{% block nav_digest_settings %}active{% endblock %}
{% block extra_style %}
<style>
.digest-settings-container { max-width: 900px; }
.digest-header { background: linear-gradient(135deg, var(--color-primary) 0%, rgba(10, 10, 15, 0.7) 100%); border: 1px solid var(--panel-border); color: white; padding: 30px; border-radius: 16px; margin-bottom: 30px; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); box-shadow: 0 4px 20px rgba(0,0,0,0.3); }
.digest-header h1 { font-size: 2em; margin-bottom: 10px; text-shadow: 0 0 10px var(--color-primary-glow); }
.digest-back-link { color: var(--color-success); text-decoration: none; opacity: 0.9; transition: var(--transition-smooth); }
.digest-back-link:hover { opacity: 1; text-shadow: 0 0 8px var(--color-success-glow); }
.digest-card { background: var(--panel-bg); border: 1px solid var(--panel-border); padding: 25px; border-radius: 16px; margin-bottom: 20px; box-shadow: 0 4px 20px rgba(0,0,0,0.2); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); transition: var(--transition-smooth); }
.digest-card:hover { border-color: var(--panel-border-hover); background: var(--panel-bg-hover); }
.digest-card h3 { margin-bottom: 15px; color: var(--color-success); text-shadow: 0 0 5px var(--color-success-glow); }
.digest-form-group { margin-bottom: 20px; }
.digest-form-group label { display: block; margin-bottom: 5px; font-weight: 500; color: var(--color-text-main); }
.digest-form-group input[type="time"], .digest-form-group select { width: 100%; padding: 10px; border: 1px solid var(--panel-border); border-radius: 8px; background: rgba(0, 0, 0, 0.3); color: var(--color-text-main); font-size: 1em; transition: var(--transition-smooth); }
.digest-form-group input[type="time"]:focus, .digest-form-group select:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 8px var(--color-primary-glow); }
.digest-checkbox-group { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; color: var(--color-text-main); }
.digest-checkbox-group input[type="checkbox"] { width: 20px; height: 20px; accent-color: var(--color-success); cursor: pointer; }
.digest-btn { padding: 12px 24px; border: none; border-radius: 8px; cursor: pointer; font-size: 1em; background: var(--color-success); color: #000; font-weight: 600; transition: var(--transition-smooth); }
.digest-btn:hover { background: #00cca3; box-shadow: 0 0 10px var(--color-success-glow); }
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="digest-settings-container"> <div class="d-flex justify-content-between align-items-center mb-3">
<div class="digest-header"> <div>
<a href="/digests" class="digest-back-link">← Назад к дайджестам</a> <h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Настройки генерации дайджеста</h4>
<h1>⚙️ Настройки дайджеста</h1> <div class="small text-muted">Конфигурация автоматического сбора событий и публикации</div>
</div> </div>
<div>
<div class="digest-card"> <a href="/digests" class="btn-unix">← Назад к дайджестам</a>
<h3>📅 Расписание</h3>
<div class="digest-form-group">
<label>Автоматическая генерация</label>
<div class="digest-checkbox-group">
<input type="checkbox" id="autoGenerate" checked>
<label for="autoGenerate">Включено (каждое воскресенье в 19:00)</label>
</div>
</div>
<div class="digest-form-group">
<label>Время генерации</label>
<input type="time" value="19:00" disabled>
</div> </div>
</div> </div>
<div class="digest-card"> <div class="row g-3" style="max-width: 900px;">
<h3>📊 Содержимое дайджеста</h3> <div class="col-12">
<div class="digest-checkbox-group"> <div class="card">
<input type="checkbox" id="includeEvents" checked> <div class="card-header">РАСПИСАНИЕ И АВТОМАТИЗАЦИЯ</div>
<label for="includeEvents">🔔 События</label> <div class="card-body">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="autoGenerate" checked>
<label class="form-check-label" for="autoGenerate">
<b>Автоматическая генерация:</b> каждое воскресенье в 19:00 (время Ульяновск)
</label>
</div> </div>
<div class="digest-checkbox-group">
<input type="checkbox" id="includePolls" checked>
<label for="includePolls">📊 Опросы</label>
</div> </div>
<div class="digest-checkbox-group">
<input type="checkbox" id="includeAds" checked>
<label for="includeAds">📢 Объявления</label>
</div>
<div class="digest-checkbox-group">
<input type="checkbox" id="includeTopUsers" checked>
<label for="includeTopUsers">🏆 Топ активных жильцов</label>
</div>
<div class="digest-checkbox-group">
<input type="checkbox" id="includeSchedule" checked>
<label for="includeSchedule">⏰ Запланированные отключения</label>
</div>
<div class="digest-checkbox-group">
<input type="checkbox" id="includeVerifications" checked>
<label for="includeVerifications">✅ Верификации</label>
</div>
<div class="digest-checkbox-group">
<input type="checkbox" id="includeStats" checked>
<label for="includeStats">📈 Статистика чата</label>
</div> </div>
</div> </div>
<div class="digest-card"> <div class="col-12">
<h3>📬 Отправка</h3> <div class="card">
<div class="digest-checkbox-group"> <div class="card-header">РАЗДЕЛЫ ДАЙДЖЕСТА</div>
<input type="checkbox" id="sendPreview" checked> <div class="card-body">
<label for="sendPreview">Отправлять превью админу для утверждения</label> <div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="includeEvents" checked>
<label class="form-check-label" for="includeEvents">События и встречи дома</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="includePolls" checked>
<label class="form-check-label" for="includePolls">Опросы и голосования собственников</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="includeAds" checked>
<label class="form-check-label" for="includeAds">Доска объявлений</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="includeTopUsers" checked>
<label class="form-check-label" for="includeTopUsers">Топ активных жильцов</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="includeSchedule" checked>
<label class="form-check-label" for="includeSchedule">График плановых отключений ЖКХ</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="includeVerifications" checked>
<label class="form-check-label" for="includeVerifications">Статистика новых верификаций</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="includeStats" checked>
<label class="form-check-label" for="includeStats">Сводная статистика активности чата</label>
</div> </div>
<div class="digest-checkbox-group">
<input type="checkbox" id="autoSend">
<label for="autoSend">Публиковать автоматически (без утверждения)</label>
</div> </div>
<div class="digest-checkbox-group">
<input type="checkbox" id="saveArchive" checked>
<label for="saveArchive">Сохранять в архив</label>
</div> </div>
</div> </div>
<button class="digest-btn" onclick="saveSettings()">💾 Сохранить настройки</button> <div class="col-12">
<div class="card">
<div class="card-header">РЕЖИМ ПУБЛИКАЦИИ</div>
<div class="card-body">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="sendPreview" checked>
<label class="form-check-label" for="sendPreview">Отправлять превью председателю на утверждение перед рассылкой</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="autoSend">
<label class="form-check-label" for="autoSend">Публиковать напрямую без ручного подтверждения</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="saveArchive" checked>
<label class="form-check-label" for="saveArchive">Сохранять выпуск в архив базы данных</label>
</div>
</div>
</div>
</div>
<div class="col-12">
<button class="btn-unix btn-unix-primary" onclick="saveSettings()">Сохранить настройки</button>
</div>
</div> </div>
{% endblock %}
{% block extra_script %}
<script> <script>
function saveSettings() { function saveSettings() {
alert('✅ Настройки сохранены!'); alert('Настройки успешно сохранены');
} }
</script> </script>
{% endblock %} {% endblock %}

View file

@ -1,156 +1,131 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Еженедельные дайджесты - Домовой Бот{% endblock %} {% block title %}Еженедельные дайджесты - МКД ЛенКом 37{% endblock %}
{% block nav_digests %}active{% endblock %} {% block nav_digests %}active{% endblock %}
{% block extra_style %}
* { margin: 0; padding: 0; box-sizing: border-box; }
{% endblock %}
{% block content %} {% block content %}
<style> <div class="d-flex justify-content-between align-items-center mb-3">
.container { max-width: 1400px; margin: 0 auto; padding: 20px; } <div>
.header { background: linear-gradient(135deg, var(--color-primary) 0%, rgba(10, 10, 15, 0.7) 100%); border: 1px solid var(--panel-border); color: white; padding: 30px; border-radius: 16px; margin-bottom: 30px; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); box-shadow: 0 4px 20px rgba(0,0,0,0.3); } <h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Еженедельные дайджесты дома</h4>
.header h1 { font-size: 2em; margin-bottom: 10px; text-shadow: 0 0 10px var(--color-primary-glow); } <div class="small text-muted">Утверждение проектов, ручная корректировка и архив публикаций</div>
.back-link { color: var(--color-success); text-decoration: none; opacity: 0.9; } </div>
.back-link:hover { opacity: 1; text-shadow: 0 0 8px var(--color-success-glow); } <div>
.tabs { display: flex; gap: 10px; margin-bottom: 20px; } <button class="btn-unix btn-unix-primary" onclick="generateNow()">
.tab { padding: 12px 24px; background: rgba(255, 255, 255, 0.05); color: var(--color-text-muted); border: 1px solid var(--panel-border); border-radius: 8px 8px 0 0; cursor: pointer; font-size: 1em; transition: var(--transition-smooth); } <i class="bi bi-arrow-repeat"></i> Сгенерировать сейчас
.tab:hover { color: var(--color-text-main); background: rgba(255, 255, 255, 0.08); } </button>
.tab.active { background: var(--color-primary); color: white; border-color: var(--color-primary); box-shadow: 0 0 10px var(--color-primary-glow); } </div>
.tab-content { display: none; }
.tab-content.active { display: block; }
.pending-card { background: var(--panel-bg); padding: 25px; border-radius: 16px; margin-bottom: 20px; box-shadow: 0 4px 20px rgba(0,0,0,0.2); border: 1px solid var(--panel-border); border-left: 5px solid var(--color-primary); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); transition: var(--transition-smooth); }
.pending-card:hover { border-color: var(--panel-border-hover); background: var(--panel-bg-hover); }
.pending-card h3 { color: var(--color-success); margin-bottom: 10px; text-shadow: 0 0 5px var(--color-success-glow); }
.pending-card .meta { color: var(--color-text-muted); font-size: 0.9em; margin-bottom: 15px; }
.pending-card .text { background: rgba(0, 0, 0, 0.3); color: var(--color-text-main); border: 1px solid var(--panel-border); padding: 15px; border-radius: 8px; margin: 15px 0; white-space: pre-wrap; max-height: 300px; overflow-y: auto; font-family: var(--font-mono); }
.btn { padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-size: 0.95em; margin-right: 10px; transition: var(--transition-smooth); }
.btn-approve { background: var(--color-success); color: #000; font-weight: 600; }
.btn-approve:hover { background: #00cca3; box-shadow: 0 0 10px var(--color-success-glow); }
.btn-reject { background: var(--color-danger); color: white; }
.btn-reject:hover { background: #e0244a; box-shadow: 0 0 10px var(--color-danger-glow); }
.btn-edit { background: var(--color-warning); color: #000; font-weight: 600; }
.btn-edit:hover { background: #d99800; box-shadow: 0 0 10px var(--color-warning-glow); }
.btn-send { background: var(--color-info); color: #000; font-weight: 600; }
.btn-send:hover { background: #0099cc; box-shadow: 0 0 10px var(--color-info-glow); }
table { width: 100%; background: var(--panel-bg); border-radius: 16px; overflow: hidden; box-shadow: 0 4px 20px rgba(0,0,0,0.2); border: 1px solid var(--panel-border); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
th { background: rgba(138, 43, 226, 0.2); color: var(--color-success); padding: 15px; text-align: left; border-bottom: 2px solid var(--panel-border); font-weight: 600; text-transform: uppercase; font-size: 0.85em; letter-spacing: 0.5px; }
td { padding: 15px; border-bottom: 1px solid var(--panel-border); color: var(--color-text-main); }
tr:hover { background: rgba(255, 255, 255, 0.02); }
.badge { display: inline-block; padding: 4px 8px; border-radius: 6px; font-size: 0.85em; font-weight: 500; border: 1px solid transparent; }
.badge-pending { background: rgba(255, 179, 0, 0.15); color: var(--color-warning); border-color: var(--color-warning); }
.badge-approved { background: rgba(0, 255, 204, 0.15); color: var(--color-success); border-color: var(--color-success); }
.badge-rejected { background: rgba(255, 45, 85, 0.15); color: var(--color-danger); border-color: var(--color-danger); }
.badge-sent { background: rgba(0, 191, 255, 0.15); color: var(--color-info); border-color: var(--color-info); }
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 1000; backdrop-filter: blur(8px); }
.modal.active { display: flex; align-items: center; justify-content: center; }
.modal-content { background: var(--bg-main); border: 1px solid var(--panel-border); color: var(--color-text-main); padding: 30px; border-radius: 16px; max-width: 900px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 0 15px var(--color-primary-glow); }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; border-bottom: 1px solid var(--panel-border); padding-bottom: 15px; }
.close-btn { background: none; border: none; font-size: 1.5em; cursor: pointer; color: var(--color-text-muted); }
.close-btn:hover { color: var(--color-text-main); }
textarea { width: 100%; min-height: 300px; padding: 15px; border: 1px solid var(--panel-border); border-radius: 8px; background: rgba(0, 0, 0, 0.3); color: var(--color-text-main); font-family: var(--font-mono); font-size: 0.95em; transition: var(--transition-smooth); }
textarea:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 8px var(--color-primary-glow); }
</style>
</head>
<body>
<div class="container">
<div class="header">
<a href="/" class="back-link">← На главную</a>
<h1>📰 Еженедельные дайджесты</h1>
<p>Утверждение, редактирование и архив</p>
</div> </div>
<div class="tabs"> <div class="d-flex gap-2 mb-3">
<button class="tab active" onclick="showTab('pending')">⏳ Ожидают утверждения</button> <button class="btn-unix active" id="tabBtnPending" onclick="showTab('pending')">Ожидают утверждения ({{ pending_digests|length }})</button>
<button class="tab" onclick="showTab('archive')">📚 Архив</button> <button class="btn-unix" id="tabBtnArchive" onclick="showTab('archive')">Архив дайджестов</button>
</div> </div>
<div id="pending" class="tab-content active"> <!-- ВКЛАДКА: НА УТВЕРЖДЕНИИ -->
<div id="pending" class="tab-content-panel">
{% if pending_digests|length > 0 %} {% if pending_digests|length > 0 %}
{% for digest in pending_digests %} {% for digest in pending_digests %}
<div class="pending-card"> <div class="card mb-3">
<h3>📰 Дайджест #{{ digest.week_number }}/{{ digest.year }}</h3> <div class="card-header d-flex justify-content-between align-items-center">
<div class="meta"> <span>Дайджест #{{ digest.week_number }}/{{ digest.year }} (Период: {{ digest.period_label }})</span>
Период: {{ digest.period_label }} | Создан: {{ digest.created_at }} | Статус: <span class="badge badge-pending">{{ digest.status_emoji }} {{ digest.status }}</span> <span class="badge-status badge-warning">{{ digest.status_emoji }} {{ digest.status.upper() }}</span>
</div>
<div class="card-body">
<div class="small text-muted mb-2">Создан: {{ digest.created_at }}</div>
<pre style="white-space: pre-wrap; max-height: 250px; background-color: #ffffff; border: 1px solid var(--border-color);">{{ digest.digest_text }}</pre>
<div class="d-flex gap-2 mt-3">
<button class="btn-unix btn-unix-primary" onclick="approveDigest({{ digest.id }})">Утвердить</button>
<button class="btn-unix" onclick="editDigest({{ digest.id }})">Редактировать</button>
<button class="btn-unix" onclick="rejectDigest({{ digest.id }})">Отклонить</button>
<button class="btn-unix" onclick="sendDigest({{ digest.id }})" id="sendBtn{{ digest.id }}" style="display:none;">Отправить жильцам</button>
</div> </div>
<div class="text">{{ digest.digest_text }}</div>
<div style="margin-top: 15px;">
<button class="btn btn-approve" onclick="approveDigest({{ digest.id }})">✅ Утвердить</button>
<button class="btn btn-edit" onclick="editDigest({{ digest.id }})">✏️ Редактировать</button>
<button class="btn btn-reject" onclick="rejectDigest({{ digest.id }})">❌ Отклонить</button>
<button class="btn btn-send" onclick="sendDigest({{ digest.id }})" id="sendBtn{{ digest.id }}" style="display:none;">📨 Отправить</button>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<div style="text-align: center; padding: 50px; background: white; border-radius: 8px;"> <div class="card p-4 text-center">
<h2>📭 Нет ожидающих утверждения дайджестов</h2> <div class="mb-2"><i class="bi bi-inbox text-muted" style="font-size: 32px;"></i></div>
<p style="margin-top: 10px;">Следующий дайджест будет сгенерирован в воскресенье в 09:00</p> <div style="font-weight: bold;">Нет дайджестов, ожидающих утверждения</div>
<button class="btn btn-send" style="margin-top: 20px;" onclick="generateNow()">🔄 Сгенерировать сейчас</button> <div class="small text-muted mt-1">Автоматическая генерация запускается по расписанию каждое воскресенье в 19:00</div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div id="archive" class="tab-content"> <!-- ВКЛАДКА: АРХИВ -->
<div id="archive" class="tab-content-panel" style="display: none;">
{% if archive_digests|length > 0 %} {% if archive_digests|length > 0 %}
<table> <div class="card">
<div class="card-header">АРХИВ ВЫПУСКОВ</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table mb-0">
<thead> <thead>
<tr> <tr>
<th>Неделя</th> <th style="width: 120px;">Выпуск</th>
<th>Период</th> <th>Период</th>
<th>Статус</th> <th style="width: 140px;">Статус</th>
<th>Отправлен</th> <th style="width: 180px;">Отправлен</th>
<th>Действия</th> <th style="width: 100px;">Действия</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for digest in archive_digests %} {% for digest in archive_digests %}
<tr> <tr>
<td>#{{ digest.week_number }}/{{ digest.year }}</td> <td><b>#{{ digest.week_number }}/{{ digest.year }}</b></td>
<td>{{ digest.period_label }}</td> <td>{{ digest.period_label }}</td>
<td><span class="badge badge-{{ digest.status }}">{{ digest.status_emoji }} {{ digest.status }}</span></td>
<td>{{ digest.sent_at }}</td>
<td> <td>
<button class="btn btn-edit" onclick="viewDigest({{ digest.id }})">👁️ Просмотр</button> <span class="badge-status {% if digest.status == 'approved' or digest.status == 'sent' %}badge-success{% elif digest.status == 'rejected' %}badge-danger{% else %}badge-warning{% endif %}">
{{ digest.status_emoji }} {{ digest.status }}
</span>
</td>
<td>{{ digest.sent_at or '—' }}</td>
<td>
<button class="btn-unix" onclick="viewDigest({{ digest.id }})">Открыть</button>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
</div>
</div>
{% else %} {% else %}
<div style="text-align: center; padding: 50px; background: white; border-radius: 8px;"> <div class="card p-4 text-center">
<h2>📚 Архив пуст</h2> <div class="small text-muted">Архив пока пуст</div>
<p style="margin-top: 10px;">Здесь будут отображаться все дайджесты</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div>
<!-- Модальное окно для редактирования --> <!-- Модальное окно для редактирования -->
<div id="editModal" class="modal"> <div id="editModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.4); z-index: 1050; align-items: center; justify-content: center;">
<div class="modal-content"> <div class="card" style="width: 90%; max-width: 800px; max-height: 90vh; overflow-y: auto; margin: 40px auto; border: 2px solid var(--border-color);">
<div class="modal-header"> <div class="card-header d-flex justify-content-between align-items-center">
<h2>✏️ Редактирование дайджеста</h2> <span>РЕДАКТИРОВАНИЕ ДАЙДЖЕСТА</span>
<button class="close-btn" onclick="closeEditModal()">&times;</button> <button class="btn-unix" onclick="closeEditModal()">&times;</button>
</div>
<div class="card-body">
<textarea id="editText" class="form-control" style="width: 100%; min-height: 350px; font-family: inherit; font-size: 12px;"></textarea>
<div class="d-flex gap-2 mt-3">
<button class="btn-unix btn-unix-primary" onclick="saveEdit()">Сохранить изменения</button>
<button class="btn-unix" onclick="closeEditModal()">Отмена</button>
</div> </div>
<textarea id="editText"></textarea>
<div style="margin-top: 15px;">
<button class="btn btn-approve" onclick="saveEdit()">💾 Сохранить</button>
<button class="btn btn-reject" onclick="closeEditModal()">Отмена</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Модальное окно для просмотра --> <!-- Модальное окно для просмотра -->
<div id="viewModal" class="modal"> <div id="viewModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.4); z-index: 1050; align-items: center; justify-content: center;">
<div class="modal-content"> <div class="card" style="width: 90%; max-width: 800px; max-height: 90vh; overflow-y: auto; margin: 40px auto; border: 2px solid var(--border-color);">
<div class="modal-header"> <div class="card-header d-flex justify-content-between align-items-center">
<h2 id="viewTitle">📰 Дайджест</h2> <span id="viewTitle">ПРОСМОТР ДАЙДЖЕСТА</span>
<button class="close-btn" onclick="closeViewModal()">&times;</button> <button class="btn-unix" onclick="closeViewModal()">&times;</button>
</div>
<div class="card-body">
<pre id="viewText" style="white-space: pre-wrap; background-color: #ffffff; border: 1px solid var(--border-color);"></pre>
<div class="mt-3">
<button class="btn-unix" onclick="closeViewModal()">Закрыть</button>
</div>
</div> </div>
<div id="viewText" style="background: #f8f9fa; padding: 15px; border-radius: 5px; white-space: pre-wrap;"></div>
</div> </div>
</div> </div>
@ -158,11 +133,10 @@
let currentDigestId = null; let currentDigestId = null;
function showTab(tabName) { function showTab(tabName) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.getElementById('pending').style.display = tabName === 'pending' ? 'block' : 'none';
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); document.getElementById('archive').style.display = tabName === 'archive' ? 'block' : 'none';
document.getElementById('tabBtnPending').className = tabName === 'pending' ? 'btn-unix active' : 'btn-unix';
event.target.classList.add('active'); document.getElementById('tabBtnArchive').className = tabName === 'archive' ? 'btn-unix active' : 'btn-unix';
document.getElementById(tabName).classList.add('active');
} }
function approveDigest(id) { function approveDigest(id) {
@ -171,8 +145,8 @@
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
alert(data.message); alert(data.message);
// Показываем кнопку "Отправить" const sendBtn = document.getElementById(`sendBtn${id}`);
document.getElementById(`sendBtn${id}`).style.display = 'inline-block'; if (sendBtn) sendBtn.style.display = 'inline-block';
location.reload(); location.reload();
}); });
} }
@ -200,7 +174,7 @@
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
document.getElementById('editText').value = data.digest.text; document.getElementById('editText').value = data.digest.text;
document.getElementById('editModal').classList.add('active'); document.getElementById('editModal').style.display = 'flex';
}); });
} }
@ -220,21 +194,21 @@
} }
function closeEditModal() { function closeEditModal() {
document.getElementById('editModal').classList.remove('active'); document.getElementById('editModal').style.display = 'none';
} }
function viewDigest(id) { function viewDigest(id) {
fetch(`/api/digest/${id}`) fetch(`/api/digest/${id}`)
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
document.getElementById('viewTitle').textContent = `📰 Дайджест #${data.digest.week}/${data.digest.year}`; document.getElementById('viewTitle').textContent = `Дайджест #${data.digest.week}/${data.digest.year}`;
document.getElementById('viewText').textContent = data.digest.text; document.getElementById('viewText').textContent = data.digest.text;
document.getElementById('viewModal').classList.add('active'); document.getElementById('viewModal').style.display = 'flex';
}); });
} }
function closeViewModal() { function closeViewModal() {
document.getElementById('viewModal').classList.remove('active'); document.getElementById('viewModal').style.display = 'none';
} }
function sendDigest(id) { function sendDigest(id) {
@ -251,24 +225,25 @@
async function generateNow() { async function generateNow() {
const btn = event.target; const btn = event.target;
btn.disabled = true; btn.disabled = true;
btn.textContent = '⏳ Генерация...'; const oldText = btn.innerHTML;
btn.innerHTML = '⏳ Генерация...';
try { try {
const r = await fetch('/api/digest/generate', { method: 'POST' }); const r = await fetch('/api/digest/generate', { method: 'POST' });
const result = await r.json(); const result = await r.json();
if (result.success) { if (result.success) {
alert('✅ ' + result.message); alert('Успешно: ' + result.message);
location.reload(); location.reload();
} else { } else {
alert('❌ Ошибка: ' + (result.error || 'Неизвестная')); alert('Ошибка: ' + (result.error || 'Неизвестная ошибка'));
btn.disabled = false; btn.disabled = false;
btn.textContent = '🔄 Сгенерировать сейчас'; btn.innerHTML = oldText;
} }
} catch (err) { } catch (err) {
alert('❌ Ошибка: ' + err.message); alert('Ошибка: ' + err.message);
btn.disabled = false; btn.disabled = false;
btn.textContent = '🔄 Сгенерировать сейчас'; btn.innerHTML = oldText;
} }
} }
</script> </script>

View file

@ -1,79 +1,72 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Email Audit - LKM37 Admin{% endblock %} {% block title %}Реестр писем и аудит - МКД ЛенКом 37{% endblock %}
{% block nav_emails %}active{% endblock %} {% block nav_emails %}active{% endblock %}
{% block content %} {% block content %}
<!-- ЗАГОЛОВОК --> <div class="d-flex justify-content-between align-items-center mb-3">
<div class="row mb-4"> <div>
<div class="col-md-8"> <h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Реестр официальной переписки и судебный аудит</h4>
<h2 style="color: var(--matrix-green); text-shadow: 0 0 10px var(--matrix-green);"> <div class="small text-muted">Архив писем, обращений и ответов надзорных органов дома № 37</div>
>_ АУДИТ_ВЗАИМОДЕЙСТВИЯ_С_ОРГАНАМИ
</h2>
</div> </div>
<div class="col-md-4 text-end"> <div>
<span class="badge bg-success p-2">АВТОМАТИЗАЦИЯ_CRON: ACTIVE (04:00)</span> <span class="badge-status badge-success">CRON: ACTIVE (04:00)</span>
</div> </div>
</div> </div>
<!-- МИНИ-КАРТОЧКИ АНАЛИТИКИ (ВИДЖЕТЫ) --> <!-- МИНИ-КАРТОЧКИ АНАЛИТИКИ -->
<div class="row g-3 mb-4"> <div class="row g-3 mb-3">
<!-- КАРТОЧКА 1: ВСЕГО ПИСЕМ -->
<div class="col-md-3"> <div class="col-md-3">
<div class="card bg-dark border-success h-100 shadow"> <div class="card h-100">
<div class="card-header d-flex justify-content-between">
<span>ВСЕГО ПИСЕМ</span>
<i class="bi bi-envelope-open"></i>
</div>
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-center"> <div style="font-size: 22px; font-weight: bold;" id="statTotalEmails">{{ total_emails }}</div>
<h6 class="text-success text-uppercase small font-monospace mb-0">>_ ВСЕГО_ПИСЕМ</h6> <div class="small text-muted mt-1">
<i class="bi bi-envelope-open text-success fs-4"></i> Входящие: <b id="statInboundEmails">{{ total_inbound }}</b> / Исходящие: <b id="statOutboundEmails">{{ total_outbound }}</b>
</div>
<h2 class="display-6 font-monospace my-2 text-white" id="statTotalEmails">{{ total_emails }}</h2>
<div class="small text-muted">
Вх: <span id="statInboundEmails" class="text-success fw-bold">{{ total_inbound }}</span> /
Исх: <span id="statOutboundEmails" class="text-info fw-bold">{{ total_outbound }}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- КАРТОЧКА 2: ОБЪЕМ АРХИВА -->
<div class="col-md-3"> <div class="col-md-3">
<div class="card bg-dark border-info h-100 shadow"> <div class="card h-100">
<div class="card-body"> <div class="card-header d-flex justify-content-between">
<div class="d-flex justify-content-between align-items-center"> <span>ОБЪЕМ АРХИВА</span>
<h6 class="text-info text-uppercase small font-monospace mb-0">>_ ОБЪЕМ_АРХИВА</h6> <i class="bi bi-file-earmark-pdf"></i>
<i class="bi bi-file-earmark-pdf text-info fs-4"></i>
</div> </div>
<h2 class="display-6 font-monospace my-2 text-white" id="statPdfSize">{{ pdf_size_mb }} MB</h2> <div class="card-body">
<div class="small text-muted text-truncate" id="statLastUpdate"> <div style="font-size: 22px; font-weight: bold;" id="statPdfSize">{{ pdf_size_mb }} MB</div>
<div class="small text-muted mt-1 text-truncate" id="statLastUpdate">
Создан: {{ fmt_local(last_update) if last_update else '-' }} Создан: {{ fmt_local(last_update) if last_update else '-' }}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- КАРТОЧКА 3: ВСЕГО ВЛОЖЕНИЙ -->
<div class="col-md-3"> <div class="col-md-3">
<div class="card bg-dark border-warning h-100 shadow"> <div class="card h-100">
<div class="card-header d-flex justify-content-between">
<span>ВЛОЖЕНИЯ</span>
<i class="bi bi-paperclip"></i>
</div>
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-center"> <div style="font-size: 22px; font-weight: bold;" id="statAttachments">{{ total_attachments }}</div>
<h6 class="text-warning text-uppercase small font-monospace mb-0">>_ ВШИТЫХ_ВЛОЖЕНИЙ</h6> <div class="small text-muted mt-1">Интегрировано в PDF</div>
<i class="bi bi-paperclip text-warning fs-4"></i>
</div>
<h2 class="display-6 font-monospace my-2 text-white" id="statAttachments">{{ total_attachments }}</h2>
<div class="small text-muted">Интегрировано в листы PDF</div>
</div> </div>
</div> </div>
</div> </div>
<!-- КАРТОЧКА 4: ПОСЛЕДНЯЯ СИНХРОНИЗАЦИЯ -->
<div class="col-md-3"> <div class="col-md-3">
<div class="card bg-dark border-primary h-100 shadow"> <div class="card h-100">
<div class="card-body"> <div class="card-header d-flex justify-content-between">
<div class="d-flex justify-content-between align-items-center"> <span>DRIVE СИНХРОНИЗАЦИЯ</span>
<h6 class="text-primary text-uppercase small font-monospace mb-0">>_ DRIVE_SYNC</h6> <i class="bi bi-cloud-check"></i>
<i class="bi bi-google-play text-primary fs-4"></i>
</div> </div>
<h2 class="display-6 font-monospace my-2 text-white" id="statLastSync"> <div class="card-body">
<div style="font-size: 22px; font-weight: bold;" id="statLastSync">
{% if last_sync_time %}{{ last_sync_time.strftime('%d.%m.%Y') }}{% else %}-{% endif %} {% if last_sync_time %}{{ last_sync_time.strftime('%d.%m.%Y') }}{% else %}-{% endif %}
</h2> </div>
<div class="small text-muted text-truncate" id="statLastSyncTime"> <div class="small text-muted mt-1 text-truncate" id="statLastSyncTime">
Время: {{ fmt_local(last_sync_time) if last_sync_time else 'Неизвестно' }} Время: {{ fmt_local(last_sync_time) if last_sync_time else 'Неизвестно' }}
</div> </div>
</div> </div>
@ -81,114 +74,149 @@
</div> </div>
</div> </div>
<div class="row g-4"> <div class="row g-3">
<!-- ЛЕВАЯ КОЛОНКА: УПРАВЛЕНИЕ И СТАТУС --> <!-- ЛЕВАЯ КОЛОНКА -->
<div class="col-md-4"> <div class="col-md-4">
<!-- КАРТОЧКА УПРАВЛЕНИЯ --> <!-- КАРТОЧКА УПРАВЛЕНИЯ -->
<div class="card mb-4 shadow"> <div class="card mb-3">
<div class="card-header bg-dark border-bottom border-secondary text-success font-monospace"> <div class="card-header">УПРАВЛЕНИЕ АРХИВОМ</div>
[CONTROL] УПРАВЛЕНИЕ_АРХИВОМ <div class="card-body text-center py-3">
</div>
<div class="card-body text-center py-4">
{% if has_master_pdf %} {% if has_master_pdf %}
<i class="bi bi-file-earmark-pdf-fill display-2 text-danger mb-3"></i> <div class="mb-2">
<h6 class="text-white">ALL_EMAILS_CONSOLIDATED.pdf</h6> <i class="bi bi-file-earmark-pdf text-danger" style="font-size: 36px;"></i>
<p class="small opacity-50 mb-4">Архив готов к использованию в NotebookLM</p> </div>
<div style="font-weight: bold;">ALL_EMAILS_CONSOLIDATED.pdf</div>
<div class="small text-muted mb-3">Архив готов к работе в NotebookLM</div>
<button id="fullSyncBtn" class="btn btn-warning w-100 mb-2"> <button id="fullSyncBtn" class="btn-unix btn-unix-primary w-100 mb-2">
<i class="bi bi-lightning-charge-fill"></i> ТУРБО СИНХРОНИЗАЦИЯ <i class="bi bi-arrow-repeat"></i> Запустить синхронизацию
</button> </button>
<div class="dropdown w-100"> <div class="d-flex gap-2">
<button class="btn btn-outline-secondary dropdown-toggle w-100" type="button" data-bs-toggle="dropdown"> <a href="/static/email_archive/TOTAL_ARCHIVE_2025-2026.pdf" class="btn-unix w-50 text-center" download>Скачать PDF</a>
ДОПОЛНИТЕЛЬНО <button id="syncAiBtn" class="btn-unix w-50">В Google Drive</button>
</button>
<ul class="dropdown-menu dropdown-menu-dark w-100">
<li><a href="/static/email_archive/TOTAL_ARCHIVE_2025-2026.pdf" class="dropdown-item" download>СКАЧАТЬ АРХИВ</a></li>
<li><button id="syncAiBtn" class="dropdown-item">ТОЛЬКО UPLOAD В DRIVE</button></li>
</ul>
</div> </div>
{% else %} {% else %}
<i class="bi bi-file-earmark-x display-2 opacity-20 mb-3"></i> <div class="mb-2">
<p class="text-warning">Архив еще не сформирован</p> <i class="bi bi-file-earmark-x text-muted" style="font-size: 36px;"></i>
<button id="fullSyncBtn" class="btn btn-warning w-100 mb-2"> </div>
<i class="bi bi-lightning-charge-fill"></i> ЗАПУСТИТЬ ПЕРВЫЙ СБОР <div class="small text-muted mb-3">Архив еще не сформирован</div>
<button id="fullSyncBtn" class="btn-unix btn-unix-primary w-100">
<i class="bi bi-arrow-repeat"></i> Запустить первый сбор
</button> </button>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- СТАТУС ПАЙПЛАЙНА (ПОСТОЯННЫЙ) --> <!-- СТАТУС ПАЙПЛАЙНА -->
<div class="card border-primary mb-4 shadow" id="pipelineStatusCard"> <div class="card mb-3" id="pipelineStatusCard">
<div class="card-header bg-dark border-bottom border-primary d-flex justify-content-between align-items-center text-primary font-monospace"> <div class="card-header d-flex justify-content-between align-items-center">
<span>[PIPELINE] МОНИТОРИНГ_ПРОЦЕССА</span> <span>МОНИТОРИНГ ПРОЦЕССА</span>
<span class="spinner-grow spinner-grow-sm text-primary" id="pipelineSpinner" style="display: none;" role="status"></span> <span class="spinner-border spinner-border-sm" id="pipelineSpinner" style="display: none;" role="status"></span>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="p-3 bg-black border border-secondary font-monospace small"> <table class="table mb-0">
[STEP] Шаг: <span id="pipelineStep" class="text-info fw-bold">Ожидание</span> <br> <tbody>
[TIME] Обновлено: <span id="pipelineTime">-</span> <br> <tr>
<div class="progress my-3" style="height: 12px; background-color: #111;"> <td style="width: 120px; font-weight: bold;">Шаг:</td>
<div id="pipelineProgress" class="progress-bar progress-bar-striped progress-bar-animated bg-primary" role="progressbar" style="width: 0%"></div> <td><span id="pipelineStep">Ожидание</span></td>
</div> </tr>
[DATA] Новых писем: <span id="pipelineNewEmails" class="text-success fw-bold">0</span> <br> <tr>
[DATA] Всего в архиве: <span id="pipelineTotalEmails" class="fw-bold">0</span> <br> <td style="font-weight: bold;">Обновлено:</td>
[DATA] Размер файла: <span id="pipelineSize" class="fw-bold">0.00 MB</span> <td><span id="pipelineTime">-</span></td>
</tr>
<tr>
<td style="font-weight: bold;">Прогресс:</td>
<td>
<div class="progress" style="height: 10px; background-color: #e8e8e8; border-radius: 0;">
<div id="pipelineProgress" class="progress-bar bg-primary" role="progressbar" style="width: 0%; border-radius: 0;"></div>
</div> </div>
</td>
</tr>
<tr>
<td style="font-weight: bold;">Новых писем:</td>
<td><span id="pipelineNewEmails">0</span></td>
</tr>
<tr>
<td style="font-weight: bold;">В архиве:</td>
<td><span id="pipelineTotalEmails">0</span></td>
</tr>
<tr>
<td style="font-weight: bold;">Размер:</td>
<td><span id="pipelineSize">0.00 MB</span></td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
<!-- ЛОГ ПОСЛЕДНЕГО ЗАПУСКА --> <!-- ЛОГ ПОСЛЕДНЕГО ЗАПУСКА -->
<div class="card shadow"> <div class="card">
<div class="card-header bg-dark border-bottom border-secondary text-warning font-monospace"> <div class="card-header">ПОСЛЕДНИЙ ЗАПУСК</div>
[LOG] ЛОГ_ПОСЛЕДНЕГО_ЗАПУСКА <div class="card-body p-2">
</div> <table class="table mb-0">
<div class="card-body"> <tbody>
<div class="p-3 bg-black border border-warning font-monospace small">
{% if last_log %} {% if last_log %}
[TIME] Начат: {{ fmt_local(last_log.start_time) }} <br> <tr>
[TIME] Завершен: {{ fmt_local(last_log.end_time) if last_log.end_time else 'В ПРОЦЕССЕ...' }} <br> <td style="font-weight: bold;">Начат:</td>
[STATUS] Статус: <span class="{% if last_log.status == 'success' %}text-success{% elif last_log.status == 'error' %}text-danger{% else %}text-warning{% endif %} fw-bold">{{ last_log.status.upper() }}</span> <br> <td>{{ fmt_local(last_log.start_time) }}</td>
[DATA] Новых писем: {{ last_log.new_emails_count }} </tr>
<tr>
<td style="font-weight: bold;">Завершен:</td>
<td>{{ fmt_local(last_log.end_time) if last_log.end_time else 'В процессе...' }}</td>
</tr>
<tr>
<td style="font-weight: bold;">Статус:</td>
<td>
<span class="badge-status {% if last_log.status == 'success' %}badge-success{% elif last_log.status == 'error' %}badge-danger{% else %}badge-warning{% endif %}">
{{ last_log.status.upper() }}
</span>
</td>
</tr>
<tr>
<td style="font-weight: bold;">Новых писем:</td>
<td>{{ last_log.new_emails_count }}</td>
</tr>
{% else %} {% else %}
[INFO] Запуски еще не производились. <tr>
<td colspan="2" class="text-muted text-center py-2">Запуски еще не производились</td>
</tr>
{% endif %} {% endif %}
</div> </tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
<!-- ПРАВАЯ КОЛОНКА: ТАБЛИЦА ВЕДОМСТВ --> <!-- ПРАВАЯ КОЛОНКА: ТАБЛИЦА ВЕДОМСТВ -->
<div class="col-md-8"> <div class="col-md-8">
<div class="card h-100 shadow"> <div class="card h-100">
<div class="card-header bg-dark border-bottom border-secondary text-success font-monospace"> <div class="card-header">СТАТИСТИКА ПО ОРГАНАМ И ВЕДОМСТВАМ</div>
[STATS] СТАТИСТИКА_ПО_ВЕДОМСТВАМ_И_ОРГАНАМ
</div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive" style="max-height: 700px; overflow-y: auto;"> <div class="table-responsive" style="max-height: 650px; overflow-y: auto;">
<table class="table table-dark table-hover mb-0 align-middle"> <table class="table mb-0">
<thead class="sticky-top bg-dark"> <thead style="position: sticky; top: 0; z-index: 1;">
<tr> <tr>
<th>Ведомство / Домен</th> <th>Ведомство / Домен</th>
<th class="text-center" style="width: 100px;">Вх.</th> <th class="text-center" style="width: 80px;">Вх.</th>
<th class="text-center" style="width: 100px;">Исх.</th> <th class="text-center" style="width: 80px;">Исх.</th>
<th style="width: 180px;">Последнее письмо</th> <th style="width: 170px;">Последнее письмо</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for domain in audit_data %} {% for domain in audit_data %}
<tr> <tr>
<td> <td>
<span class="text-success fw-bold">{{ domain.domain }}</span> <b>{{ domain.domain }}</b>
<br><small class="opacity-50 text-white">{{ domain.description }}</small> <div class="small text-muted">{{ domain.description }}</div>
</td> </td>
<td class="text-center font-monospace text-success fw-bold">{{ domain.inbound_count }}</td> <td class="text-center"><b>{{ domain.inbound_count }}</b></td>
<td class="text-center font-monospace text-info fw-bold">{{ domain.outbound_count }}</td> <td class="text-center"><b>{{ domain.outbound_count }}</b></td>
<td class="font-monospace">{{ domain.last_email_date.strftime('%d.%m.%Y %H:%M') if domain.last_email_date else '-' }}</td> <td>{{ domain.last_email_date.strftime('%d.%m.%Y %H:%M') if domain.last_email_date else '-' }}</td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="4" class="text-center opacity-50 py-4">Данных пока нет. Запустите экспорт.</td> <td colspan="4" class="text-center text-muted py-3">Данных пока нет. Запустите экспорт.</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -200,32 +228,30 @@
</div> </div>
<!-- СВЕЖИЕ ПОСТУПЛЕНИЯ В АРХИВ --> <!-- СВЕЖИЕ ПОСТУПЛЕНИЯ В АРХИВ -->
<div class="row mt-4"> <div class="row mt-3">
<div class="col-md-12"> <div class="col-md-12">
<div class="card shadow"> <div class="card">
<div class="card-header bg-dark border-bottom border-secondary text-info font-monospace"> <div class="card-header">СВЕЖИЕ ПОСТУПЛЕНИЯ В АРХИВ (ПОСЛЕДНИЕ 10)</div>
[FEED] СВЕЖИЕ_ПОСТУПЛЕНИЯ_В_АРХИВ (ПОСЛЕДНИЕ 10)
</div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-dark table-hover mb-0" style="font-size: 0.85rem;"> <table class="table mb-0">
<thead> <thead>
<tr> <tr>
<th style="width: 180px;">Дата</th> <th style="width: 170px;">Дата</th>
<th style="width: 200px;">Ведомство</th> <th style="width: 220px;">Ведомство</th>
<th>Тема письма</th> <th>Тема письма</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for email in latest_emails %} {% for email in latest_emails %}
<tr> <tr>
<td><code class="text-info font-monospace">{{ email.date }}</code></td> <td><code>{{ email.date }}</code></td>
<td><span class="badge bg-secondary font-monospace">{{ email.domain }}</span></td> <td><span class="badge-status">{{ email.domain }}</span></td>
<td class="text-truncate text-white" style="max-width: 500px;">{{ email.subject }}</td> <td class="text-truncate" style="max-width: 550px;">{{ email.subject }}</td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="3" class="text-center opacity-50 py-3">Новых файлов пока нет</td> <td colspan="3" class="text-center text-muted py-3">Новых файлов пока нет</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -270,7 +296,6 @@ async function checkPipelineStatus() {
if (data.last_sync_time && data.last_sync_time !== '-') { if (data.last_sync_time && data.last_sync_time !== '-') {
if (widgetLastSync) { if (widgetLastSync) {
// Извлекаем только дату из YYYY-MM-DD HH:MM:SS
const parts = data.last_sync_time.split(' '); const parts = data.last_sync_time.split(' ');
if (parts[0]) { if (parts[0]) {
const dateParts = parts[0].split('-'); const dateParts = parts[0].split('-');
@ -287,12 +312,9 @@ async function checkPipelineStatus() {
time.textContent = data.updated_at || '-'; time.textContent = data.updated_at || '-';
if (data.status === 'running') { if (data.status === 'running') {
card.className = "card border-primary mb-4 shadow"; step.innerHTML = '<b>' + data.step + '</b>';
step.className = "text-info fw-bold";
step.textContent = data.step;
spinner.style.display = 'inline-block'; spinner.style.display = 'inline-block';
// Динамический прогресс
let width = '10%'; let width = '10%';
if (data.step.includes('Сбор')) width = '35%'; if (data.step.includes('Сбор')) width = '35%';
else if (data.step.includes('Консолидация')) width = '70%'; else if (data.step.includes('Консолидация')) width = '70%';
@ -300,30 +322,22 @@ async function checkPipelineStatus() {
progress.style.width = width; progress.style.width = width;
progress.className = "progress-bar progress-bar-striped progress-bar-animated bg-primary"; progress.className = "progress-bar progress-bar-striped progress-bar-animated bg-primary";
// Опрашиваем часто при работе
setTimeout(checkPipelineStatus, 2000); setTimeout(checkPipelineStatus, 2000);
} else if (data.status === 'success') { } else if (data.status === 'success') {
card.className = "card border-success mb-4 shadow"; step.innerHTML = '<span class="badge-status badge-success">Завершен успешно</span>';
step.className = "text-success fw-bold";
step.innerHTML = '✅ Завершен успешно';
spinner.style.display = 'none'; spinner.style.display = 'none';
progress.style.width = '100%'; progress.style.width = '100%';
progress.className = "progress-bar bg-success"; progress.className = "progress-bar bg-success";
// Опрашиваем реже в простое
setTimeout(checkPipelineStatus, 15000); setTimeout(checkPipelineStatus, 15000);
} else if (data.status === 'error' || data.status === 'stuck') { } else if (data.status === 'error' || data.status === 'stuck') {
card.className = "card border-danger mb-4 shadow"; step.innerHTML = '<span class="badge-status badge-danger">' + (data.step || 'Ошибка') + '</span>';
step.className = "text-danger fw-bold";
step.innerHTML = '❌ ' + (data.step || 'Ошибка выполнения');
spinner.style.display = 'none'; spinner.style.display = 'none';
progress.style.width = '100%'; progress.style.width = '100%';
progress.className = "progress-bar bg-danger"; progress.className = "progress-bar bg-danger";
setTimeout(checkPipelineStatus, 15000); setTimeout(checkPipelineStatus, 15000);
} else { } else {
card.className = "card border-secondary mb-4 shadow";
step.className = "text-muted";
step.textContent = "Неактивен"; step.textContent = "Неактивен";
spinner.style.display = 'none'; spinner.style.display = 'none';
progress.style.width = '0%'; progress.style.width = '0%';
@ -351,7 +365,6 @@ document.getElementById('fullSyncBtn').addEventListener('click', async function(
const response = await fetch('/api/emails/run_full_sync', { method: 'POST' }); const response = await fetch('/api/emails/run_full_sync', { method: 'POST' });
const result = await response.json(); const result = await response.json();
alert(result.message); alert(result.message);
// Запускаем мониторинг статуса
setTimeout(checkPipelineStatus, 1000); setTimeout(checkPipelineStatus, 1000);
} catch (e) { } catch (e) {
alert('Ошибка: ' + e); alert('Ошибка: ' + e);

View file

@ -1,21 +1,26 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Экспорт - Домовой Бот{% endblock %} {% block title %}Резервное копирование и экспорт - МКД ЛенКом 37{% endblock %}
{% block nav_export %}active{% endblock %} {% block nav_export %}active{% endblock %}
{% block content %} {% block content %}
<h2 class="mb-4" style="color: var(--matrix-green); text-shadow: 0 0 10px var(--matrix-green);">>_ ЭКСПОРТ_ДАННЫХ</h2> <div class="mb-3">
<h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Резервное копирование и экспорт базы</h4>
<div class="small text-muted">Формирование дампов базы данных и синхронизация с локальным Git-сервером (Forgejo)</div>
</div>
<div class="row"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<div class="card border-success"> <div class="card h-100">
<div class="card-header border-success"> <div class="card-header">
<i class="bi bi-file-earmark-json"></i> ЭКСПОРТ В JSON & GITEA <i class="bi bi-file-earmark-arrow-up"></i> ЭКСПОРТ ДАННЫХ В JSON И GIT
</div> </div>
<div class="card-body"> <div class="card-body">
<p>Экспорт ВСЕХ пользователей и сообщений. Файл будет автоматически отправлен в Gitea.</p> <p class="small text-muted mb-3">
<button class="btn btn-matrix w-100" onclick="exportJSON()"> Экспорт всех учетных записей жильцов, истории сообщений и настроек. Файл формируется в формате JSON и автоматически фиксируется коммитом в Git.
<i class="bi bi-cloud-arrow-up"></i> ЗАПУСТИТЬ ПОЛНЫЙ ЭКСПОРТ </p>
<button class="btn-unix btn-unix-primary w-100" onclick="exportJSON()">
<i class="bi bi-arrow-up-circle"></i> Запустить полный экспорт сейчас
</button> </button>
<div id="exportResult" class="mt-3"></div> <div id="exportResult" class="mt-3"></div>
</div> </div>
@ -23,33 +28,41 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="card border-success"> <div class="card h-100">
<div class="card-header border-success"> <div class="card-header">
<i class="bi bi-graph-up"></i> СТАТИСТИКА ВЫГРУЗОК <i class="bi bi-clock-history"></i> СТАТИСТИКА ВЫГРУЗОК
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="h4 text-matrix mb-3"> <table class="table mb-3">
Всего выгрузок: <span class="badge bg-success">{{ export_stats.total }}</span> <tbody>
</div> <tr>
<p>Последняя выгрузка:<br> <td style="font-weight: bold; width: 180px;">Всего выгрузок:</td>
<span class="text-success">{{ fmt_local(export_stats.last_date) if export_stats.last_date else 'Никогда' }}</span></p> <td><span class="badge-status badge-success">{{ export_stats.total }}</span></td>
<hr class="border-success"> </tr>
<p class="text-muted small"> <tr>
Автоматический экспорт выполняется раз в неделю (вс, 03:00) и пушится в репозиторий. <td style="font-weight: bold;">Последняя выгрузка:</td>
</p> <td>{{ fmt_local(export_stats.last_date) if export_stats.last_date else 'Никогда' }}</td>
</tr>
<tr>
<td style="font-weight: bold;">Авторасписание:</td>
<td>Каждое воскресенье в 03:00 (cron)</td>
</tr>
</tbody>
</table>
<div class="small text-muted">
Архивы сохраняются локально в каталог <code>exports/</code> и отправляются в репозиторий на Synology NAS.
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %}
{% block extra_script %}
<script> <script>
async function exportJSON() { async function exportJSON() {
const resultDiv = document.getElementById('exportResult'); const resultDiv = document.getElementById('exportResult');
const btn = document.querySelector('button[onclick="exportJSON()"]'); const btn = document.querySelector('button[onclick="exportJSON()"]');
resultDiv.innerHTML = '<div class="alert alert-info"><div class="spinner-border spinner-border-sm me-2"></div>Сбор данных и отправка в Gitea...</div>'; resultDiv.innerHTML = '<div class="alert alert-info py-2 small"><div class="spinner-border spinner-border-sm me-2"></div>Сбор данных и отправка в Git...</div>';
btn.disabled = true; btn.disabled = true;
try { try {
@ -58,24 +71,24 @@
if (response.ok && data.success) { if (response.ok && data.success) {
resultDiv.innerHTML = ` resultDiv.innerHTML = `
<div class="alert alert-success"> <div class="alert alert-success py-2 small">
✅ <b>Успех!</b><br> <b>Успешно!</b><br>
Файл: <code>${data.filename}</code><br> Файл: <code>${data.filename}</code><br>
Сообщений: ${data.messages}<br> Сообщений: ${data.messages}<br>
Статус Git: ${data.git ? '🚀 Отправлено в Gitea' : '❌ Ошибка Git'}<br> Статус Git: ${data.git ? 'Синхронизировано в Forgejo' : 'Ошибка Git'}<br>
<br> <div class="mt-2">
<a href="/exports/${data.filename}" class="btn btn-outline-success btn-sm w-100 mb-2"> <a href="/exports/${data.filename}" class="btn-unix">
<i class="bi bi-download"></i> СКАЧАТЬ ФАЙЛ ЛОКАЛЬНО Скачать файл локально
</a> </a>
<small>Страница обновится через 10 сек...</small> </div>
</div> </div>
`; `;
setTimeout(() => location.reload(), 10000); setTimeout(() => location.reload(), 10000);
} else { } else {
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${data.error || 'Неизвестно'}</div>`; resultDiv.innerHTML = `<div class="alert alert-danger py-2 small">Ошибка: ${data.error || 'Неизвестная ошибка'}</div>`;
} }
} catch (error) { } catch (error) {
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка сети: ${error.message}</div>`; resultDiv.innerHTML = `<div class="alert alert-danger py-2 small">Сетевая ошибка: ${error.message}</div>`;
} finally { } finally {
btn.disabled = false; btn.disabled = false;
} }

View file

@ -1,28 +1,33 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Менеджер артефактов - LKM37{% endblock %} {% block title %}Файлы и архивы - МКД ЛенКом 37{% endblock %}
{% block nav_files %}active{% endblock %} {% block nav_files %}active{% endblock %}
{% block content %} {% block content %}
<h2 class="mb-4" style="color: var(--matrix-green); text-shadow: 0 0 10px var(--matrix-green);"> <div class="mb-3">
>_ ЦЕНТРАЛЬНЫЙ_АРХИВ_ДАННЫХ (FILES_CORE) <h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Менеджер файлов и медиа-артефактов</h4>
</h2> <div class="small text-muted">Хранилище документов, системных архивов и медиа-файлов для рассылок</div>
</div>
<!-- Секция загрузки артефактов --> <!-- Секция загрузки артефактов -->
<div class="row mb-4"> <div class="row mb-3">
<div class="col-md-12"> <div class="col-md-12">
<div class="card bg-black border-success shadow-sm"> <div class="card">
<div class="card-header">
<i class="bi bi-cloud-upload"></i> ЗАГРУЗКА МЕДИА-ФАЙЛА (РЕГИСТРАЦИЯ В TELEGRAM)
</div>
<div class="card-body"> <div class="card-body">
<h5 class="text-success mb-3"><i class="bi bi-cloud-upload"></i> РЕГИСТРАЦИЯ НОВОГО АРТЕФАКТА В TELEGRAM</h5> <p class="small text-muted mb-3">
<p class="small text-muted mb-3">Загрузите файл сюда, и бот отправит его вам в личку, чтобы получить постоянный <code>file_id</code>. Вы сможете использовать этот ID в рассылках и постах многократно.</p> Загрузите изображение или документ. Бот сохранит его в Telegram и сгенерирует постоянный <code>file_id</code> для безопасного использования в постах и рассылках.
<form id="uploadForm" class="row g-3"> </p>
<form id="uploadForm" class="row g-2 align-items-center">
<div class="col-md-9"> <div class="col-md-9">
<input type="file" id="fileInput" class="form-control bg-dark border-success text-success"> <input type="file" id="fileInput" class="form-control">
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<button type="button" id="uploadBtn" onclick="uploadFile()" class="btn btn-matrix w-100"> <button type="button" id="uploadBtn" onclick="uploadFile()" class="btn-unix btn-unix-primary w-100">
<span id="uploadSpinner" class="spinner-border spinner-border-sm d-none" role="status"></span> <span id="uploadSpinner" class="spinner-border spinner-border-sm d-none" role="status"></span>
ЗАГРУЗИТЬ Загрузить в бота
</button> </button>
</div> </div>
</form> </form>
@ -32,26 +37,26 @@
</div> </div>
<!-- Таблица артефактов --> <!-- Таблица артефактов -->
<div class="card mb-4 border-info"> <div class="card mb-3">
<div class="card-header border-info bg-info bg-opacity-10 d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<span class="text-info fw-bold">💠 АРТЕФАКТЫ TELEGRAM (ДЛЯ РАССЫЛОК)</span> <span>ЗАРЕГИСТРИРОВАННЫЕ МЕДИА-АРТЕФАКТЫ TELEGRAM</span>
<button class="btn btn-outline-info btn-sm" onclick="loadFiles()"> <button class="btn-unix" onclick="loadFiles()">
<i class="bi bi-arrow-clockwise"></i> ОБНОВИТЬ <i class="bi bi-arrow-clockwise"></i> Обновить список
</button> </button>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-dark table-hover mb-0"> <table class="table mb-0">
<thead> <thead>
<tr class="text-info opacity-75" style="font-size: 0.8em;"> <tr>
<th>ИМЯ ФАЙЛА</th> <th>Имя файла</th>
<th>ТИП</th> <th style="width: 120px;">Тип</th>
<th>TELEGRAM_FILE_ID</th> <th>Telegram File ID</th>
<th class="text-end">ДЕЙСТВИЯ</th> <th class="text-end" style="width: 120px;">Действие</th>
</tr> </tr>
</thead> </thead>
<tbody id="artifactsTable"> <tbody id="artifactsTable">
<!-- Заполняется через API --> <!-- Заполняется через JS -->
</tbody> </tbody>
</table> </table>
</div> </div>
@ -59,31 +64,27 @@
</div> </div>
<!-- Таблица системных файлов --> <!-- Таблица системных файлов -->
<div class="card border-secondary"> <div class="card">
<div class="card-header border-secondary bg-secondary bg-opacity-10"> <div class="card-header">СИСТЕМНЫЕ АРХИВЫ И ЛОГИ СЕРВЕРА</div>
<span class="text-secondary fw-bold">📁 СИСТЕМНЫЕ ФАЙЛЫ (ЛОГИ И ЭКСПОРТЫ)</span>
</div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-dark table-hover mb-0"> <table class="table mb-0">
<thead> <thead>
<tr class="text-muted" style="font-size: 0.8em;"> <tr>
<th>ИМЯ ФАЙЛА</th> <th>Имя файла</th>
<th>КАТЕГОРИЯ</th> <th style="width: 140px;">Категория</th>
<th>РАЗМЕР</th> <th style="width: 120px;">Размер</th>
<th class="text-end">ДЕЙСТВИЯ</th> <th class="text-end" style="width: 100px;">Скачать</th>
</tr> </tr>
</thead> </thead>
<tbody id="systemFilesTable"> <tbody id="systemFilesTable">
<!-- Заполняется через API --> <!-- Заполняется через JS -->
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
{% endblock %}
{% block extra_script %}
<script> <script>
document.addEventListener('DOMContentLoaded', loadFiles); document.addEventListener('DOMContentLoaded', loadFiles);
@ -109,12 +110,12 @@ async function loadFiles() {
artifacts.forEach(f => { artifacts.forEach(f => {
artifactsTable.innerHTML += ` artifactsTable.innerHTML += `
<tr> <tr>
<td class="font-monospace text-info">${f.filename}</td> <td><b>${f.filename}</b></td>
<td><span class="badge bg-info text-dark">${f.file_type}</span></td> <td><span class="badge-status">${f.file_type}</span></td>
<td><code class="text-success small" style="word-break: break-all;">${f.file_id}</code></td> <td><code style="word-break: break-all;">${f.file_id}</code></td>
<td class="text-end"> <td class="text-end">
<button class="btn btn-sm btn-outline-info" onclick="copyId('${f.file_id}')"> <button class="btn-unix" onclick="copyId('${f.file_id}')">
<i class="bi bi-copy"></i> ID Копировать ID
</button> </button>
</td> </td>
</tr> </tr>
@ -128,12 +129,12 @@ async function loadFiles() {
systemFiles.forEach(f => { systemFiles.forEach(f => {
systemFilesTable.innerHTML += ` systemFilesTable.innerHTML += `
<tr> <tr>
<td class="font-monospace">${f.filename}</td> <td><b>${f.filename}</b></td>
<td><span class="badge bg-secondary">${f.file_type}</span></td> <td><span class="badge-status">${f.file_type}</span></td>
<td class="text-muted">${f.size}</td> <td>${f.size}</td>
<td class="text-end"> <td class="text-end">
<a href="${f.path}" class="btn btn-sm btn-outline-secondary" download> <a href="${f.path}" class="btn-unix" download>
<i class="bi bi-download"></i> Скачать
</a> </a>
</td> </td>
</tr> </tr>
@ -147,12 +148,7 @@ async function loadFiles() {
function copyId(id) { function copyId(id) {
navigator.clipboard.writeText(id); navigator.clipboard.writeText(id);
const toast = document.createElement('div'); alert('File ID скопирован в буфер обмена');
toast.className = 'position-fixed bottom-0 end-0 p-3';
toast.style.zIndex = '11';
toast.innerHTML = `<div class="alert alert-success">FILE_ID скопирован!</div>`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2000);
} }
async function uploadFile() { async function uploadFile() {
@ -176,7 +172,7 @@ async function uploadFile() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
alert('Файл успешно зарегистрирован в Telegram!\nFILE_ID: ' + result.file_id); alert('Файл успешно зарегистрирован в Telegram!\nFile ID: ' + result.file_id);
location.reload(); location.reload();
} else { } else {
alert('Ошибка при регистрации: ' + result.error); alert('Ошибка при регистрации: ' + result.error);

View file

@ -1,36 +1,47 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Инфраструктура - Домовой Бот{% endblock %} {% block title %}Инфраструктура - МКД ЛенКом 37{% endblock %}
{% block nav_infra %}active{% endblock %} {% block nav_infra %}active{% endblock %}
{% block content %} {% block content %}
<h2 class="mb-4"><i class="bi bi-cpu"></i> Мониторинг Инфраструктуры</h2> <div class="mb-3">
<h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Мониторинг инфраструктуры и сервисов</h4>
<div class="row mb-4"> <div class="small text-muted">Состояние сетевых узлов, замеры интернет-канала и статус фоновых пайплайнов</div>
<div class="col-md-12">
<div class="card bg-dark border-success">
<div class="card-header border-success text-success">
<i class="bi bi-speedometer2"></i> ТЕСТ СКОРОСТИ ИНТЕРНЕТА (150 Mbps План)
</div> </div>
<div class="card-body">
<table class="table table-dark table-hover small"> <div class="row g-3 mb-3">
<div class="col-md-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>ТЕСТЫ СКОРОСТИ ИНТЕРНЕТ-КАНАЛА (ТАРИФ 150 МБИТ/С)</span>
<button id="runSpeedtestBtn" class="btn-unix">
<i class="bi bi-play-fill"></i> Запустить замер сейчас
</button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table mb-0">
<thead> <thead>
<tr> <tr>
<th>Дата и Время</th> <th>Дата и время</th>
<th>Download (Mbps)</th> <th>Download (Мбит/с)</th>
<th>Upload (Mbps)</th> <th>Upload (Мбит/с)</th>
<th>Ping (ms)</th> <th>Ping (мс)</th>
<th>Сервер</th> <th>Сервер замера</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for test in speedtests %} {% for test in speedtests %}
<tr> <tr>
<td>{{ test.timestamp }}</td> <td>{{ test.timestamp }}</td>
<td class="text-info">{{ test.download }}</td> <td><b>{{ test.download }}</b></td>
<td class="text-warning">{{ test.upload }}</td> <td><b>{{ test.upload }}</b></td>
<td>{{ test.ping }}</td> <td>{{ test.ping }}</td>
<td class="opacity-50">{{ test.server_name }}</td> <td class="text-muted">{{ test.server_name }}</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center text-muted py-3">Данных замеров пока нет</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -39,64 +50,63 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="row"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<div class="card bg-dark border-info"> <div class="card h-100">
<div class="card-header text-info">УЗЛЫ СЕТИ</div> <div class="card-header">СЕТЕВЫЕ УЗЛЫ И ХОСТЫ</div>
<div class="card-body"> <div class="card-body p-0">
<ul class="list-group list-group-flush bg-transparent"> <table class="table mb-0">
<li class="list-group-item bg-transparent text-white border-secondary"> <tbody>
<span class="badge bg-success">ONLINE</span> NanoPi R3S (192.168.10.1) <tr>
</li> <td style="width: 140px;"><b>NanoPi R3S</b></td>
<li class="list-group-item bg-transparent text-white border-secondary"> <td>192.168.10.1 (Роутер / Шлюз)</td>
<span class="badge bg-success">ONLINE</span> Synology NAS (192.168.10.105) <td class="text-end"><span class="badge-status badge-success">ONLINE</span></td>
</li> </tr>
<li class="list-group-item bg-transparent text-white border-secondary"> <tr>
<span class="badge bg-success">ONLINE</span> DockerServer (192.168.10.116) <td><b>Synology NAS</b></td>
</li> <td>192.168.10.105 (Хранилище / PostgreSQL)</td>
</ul> <td class="text-end"><span class="badge-status badge-success">ONLINE</span></td>
</tr>
<tr>
<td><b>DockerServer</b></td>
<td>192.168.10.116 (Domovoy / Maya Bot)</td>
<td class="text-end"><span class="badge-status badge-success">ONLINE</span></td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="card bg-dark border-primary"> <div class="card h-100">
<div class="card-header text-primary">ГИС ЖКХ & ЮРИДИЧЕСКИЙ АРХИВ</div> <div class="card-header d-flex justify-content-between align-items-center">
<span>ГИС ЖКХ & СБОРКА АРХИВА</span>
<span id="gisStatusBadge" class="badge-status">СТАТУС: ПРОВЕРКА</span>
</div>
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex gap-2 mb-2">
<button id="runGisHarvestBtn" class="btn btn-outline-primary flex-grow-1 me-2"> <button id="runGisHarvestBtn" class="btn-unix btn-unix-primary flex-grow-1">
<i class="bi bi-cloud-download"></i> ЗАПУСТИТЬ ЖАТВУ И PDF-СБОРКУ <i class="bi bi-cloud-download"></i> Запустить сборку ГИС ЖКХ
</button> </button>
<div id="gisStatusBadge" class="badge bg-secondary p-2">СТАТУС: НЕИЗВЕСТНО</div> <button id="refreshGisStatusBtn" class="btn-unix">
</div> Обновить статус
<div class="mb-2 d-flex justify-content-between">
<span class="small text-muted">ПОСЛЕДНИЕ ЛОГИ ЖНЕЦА:</span>
<button id="refreshGisStatusBtn" class="btn btn-sm btn-link p-0 text-primary">ОБНОВИТЬ СТАТУС</button>
</div>
<pre id="gisLogs" class="bg-black text-success p-2 small border border-secondary" style="height: 200px; overflow-y: scroll; font-size: 0.7rem;"></pre>
<div class="small opacity-50 mt-2">Выполняет: Выкачка (Overwrite) -> Теггирование -> Склейка -> Google Drive</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card bg-dark border-warning">
<div class="card-header text-warning">ДЕЙСТВИЯ</div>
<div class="card-body">
<button id="runSpeedtestBtn" class="btn btn-outline-warning w-100 mb-2">
<i class="bi bi-play-fill"></i> ЗАПУСТИТЬ ТЕСТ СКОРОСТИ СЕЙЧАС
</button> </button>
</div> </div>
<div class="small text-muted mb-1">Лог выполнения процесса:</div>
<pre id="gisLogs" style="height: 160px; overflow-y: scroll; background-color: #ffffff; border: 1px solid var(--border-color); font-size: 11px;"></pre>
</div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %}
{% block extra_script %}
<script> <script>
document.getElementById('runSpeedtestBtn').addEventListener('click', async function() { document.getElementById('runSpeedtestBtn').addEventListener('click', async function() {
const btn = this; const btn = this;
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> ТЕСТИРУЮ (60с)...'; btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Замер (до 60 сек)...';
try { try {
const response = await fetch('/api/infra/run_speedtest', { method: 'POST' }); const response = await fetch('/api/infra/run_speedtest', { method: 'POST' });
@ -107,16 +117,16 @@ document.getElementById('runSpeedtestBtn').addEventListener('click', async funct
alert('Ошибка: ' + e); alert('Ошибка: ' + e);
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.innerHTML = '<i class="bi bi-play-fill"></i> ЗАПУСТИТЬ ТЕСТ СКОРОСТИ СЕЙЧАС'; btn.innerHTML = '<i class="bi bi-play-fill"></i> Запустить замер сейчас';
} }
}); });
document.getElementById('runGisHarvestBtn').addEventListener('click', async function() { document.getElementById('runGisHarvestBtn').addEventListener('click', async function() {
const btn = this; const btn = this;
if (!confirm('Запустить тотальную перекачку ГИС ЖКХ и сборку PDF? Это займет значительное время.')) return; if (!confirm('Запустить сборку ГИС ЖКХ? Это займет некоторое время.')) return;
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> ЖАТВА В ПРОЦЕССЕ...'; btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Процесс запущен...';
try { try {
const response = await fetch('/api/infra/run_gis_harvest', { method: 'POST' }); const response = await fetch('/api/infra/run_gis_harvest', { method: 'POST' });
@ -126,7 +136,7 @@ document.getElementById('runGisHarvestBtn').addEventListener('click', async func
alert('Ошибка: ' + e); alert('Ошибка: ' + e);
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.innerHTML = '<i class="bi bi-cloud-download"></i> ЗАПУСТИТЬ ЖАТВУ И PDF-СБОРКУ'; btn.innerHTML = '<i class="bi bi-cloud-download"></i> Запустить сборку ГИС ЖКХ';
refreshGisStatus(); refreshGisStatus();
} }
}); });
@ -140,24 +150,23 @@ async function refreshGisStatus() {
const data = await response.json(); const data = await response.json();
if (data.alive) { if (data.alive) {
badge.className = 'badge bg-success p-2'; badge.className = 'badge-status badge-success';
badge.innerText = 'СТАТУС: РАБОТАЕТ'; badge.innerText = 'РАБОТАЕТ';
} else { } else {
badge.className = 'badge bg-danger p-2'; badge.className = 'badge-status';
badge.innerText = 'СТАТУС: ОСТАНОВЛЕН'; badge.innerText = 'ОЖИДАНИЕ';
} }
logs.innerText = data.log; logs.innerText = data.log || 'Лог чист';
logs.scrollTop = logs.scrollHeight; logs.scrollTop = logs.scrollHeight;
} catch (e) { } catch (e) {
badge.className = 'badge bg-warning p-2'; badge.className = 'badge-status badge-danger';
badge.innerText = 'ОШИБКА API'; badge.innerText = 'ОШИБКА';
} }
} }
document.getElementById('refreshGisStatusBtn').addEventListener('click', refreshGisStatus); document.getElementById('refreshGisStatusBtn').addEventListener('click', refreshGisStatus);
window.addEventListener('load', refreshGisStatus); window.addEventListener('load', refreshGisStatus);
// Авто-обновление раз в 30 секунд
setInterval(refreshGisStatus, 30000); setInterval(refreshGisStatus, 30000);
</script> </script>
{% endblock %} {% endblock %}

217
web/templates/legal.html Normal file
View file

@ -0,0 +1,217 @@
{% extends "base.html" %}
{% block title %}Нормативно-правовая база & ИИ-Юрист | LKM37{% endblock %}
{% block nav_legal %}active{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="m-0" style="font-size: 18px; font-weight: bold;">НОРМАТИВНО-ПРАВОВАЯ БАЗА РФ & ИИ-ЮРИСТ</h3>
<div>
<button class="btn-unix btn-unix-primary" onclick="syncNotebookLM()">
<i class="bi bi-arrow-repeat"></i> Синхронизация с NotebookLM
</button>
<button class="btn-unix" onclick="generatePodcast()">
<i class="bi bi-mic"></i> Сгенерировать подкаст (Audio Overview)
</button>
</div>
</div>
<div class="row">
<!-- Левая колонка: Реестр полных законов РФ -->
<div class="col-md-7">
<div class="panel">
<div class="panel-header d-flex justify-content-between align-items-center">
<span>ПОЛНЫЕ ОФИЦИАЛЬНЫЕ КОДЕКСЫ И НОРМАТИВНЫЕ АКТЫ РФ (100% ТЕКСТ)</span>
<span class="badge-status badge-success">{{ laws|length }} документов</span>
</div>
<div class="panel-body p-0">
<table>
<thead>
<tr>
<th style="width: 45px;">№</th>
<th>Наименование нормативно-правового акта</th>
<th style="width: 75px;">Стр.</th>
<th style="width: 85px;">Размер</th>
<th style="width: 100px;">Действие</th>
</tr>
</thead>
<tbody>
{% for law in laws %}
<tr>
<td>{{ loop.index }}</td>
<td>
<strong>{{ law.title }}</strong><br>
<small style="color: var(--text-muted);">{{ law.filename }}</small>
</td>
<td>{{ law.pages }}</td>
<td>{{ law.size_formatted }}</td>
<td>
<a href="/api/legal/download/{{ law.filename }}" class="btn-unix" download>
<i class="bi bi-download"></i> PDF
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Карточка томов переписки -->
<div class="panel">
<div class="panel-header d-flex justify-content-between align-items-center">
<span>СВОДНЫЕ ТОМА ПЕРЕПИСКИ С ГОСОРГАНАМИ & ШТАМПЫ ЭЦП</span>
<span class="badge-status badge-success">{{ mega_volumes|length }} тома</span>
</div>
<div class="panel-body p-0">
<table>
<thead>
<tr>
<th>Том переписки</th>
<th>Размер</th>
<th>Оглавление TOC</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
{% for vol in mega_volumes %}
<tr>
<td><strong>{{ vol.name }}</strong></td>
<td>{{ vol.size_formatted }}</td>
<td><span class="badge-status badge-success">Вшито в PDF</span></td>
<td>
<a href="/api/legal/download_volume/{{ vol.name }}" class="btn-unix" download>
<i class="bi bi-download"></i> Скачать
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Правая колонка: ИИ-Консультант и Генератор Подкастов -->
<div class="col-md-5">
<!-- Интерактивный AI-Юрист -->
<div class="panel">
<div class="panel-header">
<i class="bi bi-cpu"></i> АВТОНОМНЫЙ ИИ-КОНСУЛЬТАНТ (GEMINI 2.5)
</div>
<div class="panel-body">
<p style="font-size: 11px; color: var(--text-muted); margin-bottom: 8px;">
Контекст: Полный свод ЖК РФ, ГК РФ, 59-ФЗ, ПП 354, ПП 491, Госстрой 170 + 610 писем дома с верифицированными ЭЦП.
</p>
<div class="mb-2">
<label style="font-weight: bold; font-size: 11px;">Вопрос / Фабула ситуации:</label>
<textarea id="ai-query" class="form-control" rows="4" style="font-family: inherit; font-size: 12px; border: 1px solid var(--border-color); border-radius: 0;" placeholder="Например: УК отказалась делать перерасчет за отопление в подъезде 4, утверждая что перерыв не превышал допустимой нормы. На какие пункты ПП 354 и Госстроя 170 сослаться в претензии?"></textarea>
</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<input type="checkbox" id="ai-deep-mode">
<label for="ai-deep-mode" style="font-size: 11px; cursor: pointer;">Глубокий анализ (Gemini 2.5 Pro)</label>
</div>
<button class="btn-unix btn-unix-primary" onclick="askAiLawyer()">
<i class="bi bi-send"></i> Сформировать позицию
</button>
</div>
<div id="ai-response-box" style="display: none; margin-top: 12px; border-top: 1px dashed var(--border-color); padding-top: 10px;">
<div class="d-flex justify-content-between align-items-center mb-1">
<strong style="font-size: 11px;">ОТВЕТ ИИ-ЮРИСТА:</strong>
<span id="ai-model-tag" class="badge-status badge-success">Flash</span>
</div>
<div id="ai-response-text" style="background-color: #fafafa; border: 1px solid var(--border-light); padding: 8px; font-size: 11px; white-space: pre-wrap; max-height: 400px; overflow-y: auto;"></div>
</div>
</div>
</div>
<!-- Аудио-подкасты NotebookLM -->
<div class="panel">
<div class="panel-header">
<i class="bi bi-soundwave"></i> АУДИО-ПОДКАСТЫ ДОМА (AUDIO OVERVIEWS)
</div>
<div class="panel-body">
<p style="font-size: 11px; color: var(--text-muted); margin-bottom: 8px;">
Автоматическая генерация аналитических подкастов диалога двух экспертов по делам дома.
</p>
<div id="podcast-status-box">
{% if latest_podcast %}
<div class="mb-2">
<strong>Последний подкаст:</strong> {{ latest_podcast.name }}<br>
<small style="color: var(--text-muted);">Размер: {{ latest_podcast.size }} | Дата: {{ latest_podcast.date }}</small>
</div>
<audio controls style="width: 100%; margin-bottom: 8px;">
<source src="/api/legal/podcasts/{{ latest_podcast.name }}" type="audio/mp4">
Ваш браузер не поддерживает аудио.
</audio>
{% else %}
<p style="font-size: 11px; font-style: italic; color: var(--text-muted);">
Подкасты еще не генерировались. Нажмите кнопку генерации выше.
</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function askAiLawyer() {
const query = document.getElementById('ai-query').value.trim();
if (!query) {
alert('Пожалуйста, введите текст вопроса');
return;
}
const deep = document.getElementById('ai-deep-mode').checked;
const resBox = document.getElementById('ai-response-box');
const resText = document.getElementById('ai-response-text');
const tag = document.getElementById('ai-model-tag');
resBox.style.display = 'block';
resText.innerText = 'Анализ правовой базы и судебной практики... Пожалуйста, подождите.';
tag.innerText = deep ? 'Gemini 2.5 Pro' : 'Gemini 2.5 Flash';
try {
const resp = await fetch('/api/legal/ask_ai', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({query: query, deep: deep})
});
const data = await resp.json();
if (data.success) {
resText.innerText = data.answer;
} else {
resText.innerText = 'Ошибка: ' + (data.error || 'Неизвестная ошибка');
}
} catch (e) {
resText.innerText = 'Сетевая ошибка обращения к ИИ-Юристу: ' + e;
}
}
async function syncNotebookLM() {
if (!confirm('Запустить полную синхронизацию законов и томов переписки с Google NotebookLM?')) return;
try {
const resp = await fetch('/api/legal/notebooklm_sync', {method: 'POST'});
const data = await resp.json();
alert(data.message || 'Синхронизация завершена!');
} catch (e) {
alert('Ошибка запуска синхронизации: ' + e);
}
}
async function generatePodcast() {
if (!confirm('Запустить генерацию Audio Overview подкаста в NotebookLM?')) return;
try {
const resp = await fetch('/api/legal/generate_podcast', {method: 'POST'});
const data = await resp.json();
alert(data.message || 'Задача генерации подкаста запущена!');
} catch (e) {
alert('Ошибка запуска генерации подкаста: ' + e);
}
}
</script>
{% endblock %}

View file

@ -1,89 +1,91 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Телефоны и службы - Домовой Бот{% endblock %} {% block title %}Телефоны и службы - МКД ЛенКом 37{% endblock %}
{% block nav_phones %}active{% endblock %} {% block nav_phones %}active{% endblock %}
{% block extra_style %} {% block extra_style %}
.service-image-preview { max-width: 200px; max-height: 200px; object-fit: contain; } .service-image-preview { max-width: 120px; max-height: 80px; object-fit: contain; border: 1px solid var(--border-light); }
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h2 class="mb-4" style="color: var(--matrix-green); text-shadow: 0 0 10px var(--matrix-green);">>_ ТЕЛЕФОНЫ_И_СЛУЖБЫ</h2> <div class="mb-3">
<h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Телефоны экстренных и обслуживающих служб</h4>
<div class="small text-muted">Справочник полезных контактов, аварийных служб и мастеров для жителей дома</div>
</div>
<!-- Добавить службу --> <!-- Добавить службу -->
<div class="card mb-4 border-success"> <div class="card mb-3">
<div class="card-header border-success"> <div class="card-header">
<h5 class="mb-0"><i class="bi bi-plus-lg"></i> Добавить службу</h5> <i class="bi bi-plus-circle"></i> ДОБАВЛЕНИЕ НОВОЙ СЛУЖБЫ
</div> </div>
<div class="card-body"> <div class="card-body">
<form id="addServiceForm" class="row g-3"> <form id="addServiceForm" class="row g-2 align-items-center">
<div class="col-md-3"> <div class="col-md-3">
<input type="text" class="form-control bg-black border-success text-success" id="serviceName" placeholder="Название" required> <input type="text" class="form-control" id="serviceName" placeholder="Название службы" required>
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<input type="text" class="form-control bg-black border-success text-success" id="servicePhone" placeholder="Телефон" required> <input type="text" class="form-control" id="servicePhone" placeholder="Телефон" required>
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<select class="form-select bg-black border-success text-success" id="serviceCategory"> <select class="form-select" id="serviceCategory">
<option value="emergency">🚨 Экстренные</option> <option value="emergency">Экстренные службы</option>
<option value="utility">🔧 ЖКХ</option> <option value="utility" selected>ЖКХ и аварийные</option>
<option value="masters">👷 Мастера</option> <option value="masters">Мастера и ремонт</option>
<option value="police">👮 Полиция</option> <option value="police">Полиция и охрана</option>
<option value="other">📋 Другое</option> <option value="other">Прочие контакты</option>
</select> </select>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<input type="text" class="form-control bg-black border-success text-success" id="serviceDesc" placeholder="Описание"> <input type="text" class="form-control" id="serviceDesc" placeholder="Описание / часы работы">
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<button type="submit" class="btn btn-matrix w-100">Добавить</button> <button type="submit" class="btn-unix btn-unix-primary w-100">Добавить</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<!-- Загрузить картинку --> <!-- Загрузить картинку -->
<div class="card mb-4 border-success"> <div class="card mb-3">
<div class="card-header border-success"> <div class="card-header">
<h5 class="mb-0"><i class="bi bi-image"></i> Загрузить картинку для службы</h5> <i class="bi bi-image"></i> ЗАГРУЗКА ИЗОБРАЖЕНИЯ ДЛЯ КАТЕГОРИИ
</div> </div>
<div class="card-body"> <div class="card-body">
<form id="uploadImageForm" class="row g-3" enctype="multipart/form-data"> <form id="uploadImageForm" class="row g-2 align-items-center" enctype="multipart/form-data">
<div class="col-md-3"> <div class="col-md-3">
<select class="form-select bg-black border-success text-success" id="imageCategory" required> <select class="form-select" id="imageCategory" required>
<option value="emergency">🚨 Экстренные</option> <option value="emergency">Экстренные службы</option>
<option value="utility">🔧 ЖКХ</option> <option value="utility" selected>ЖКХ и аварийные</option>
<option value="masters">👷 Мастера</option> <option value="masters">Мастера и ремонт</option>
<option value="police">👮 Полиция</option> <option value="police">Полиция и охрана</option>
<option value="other">📋 Другое</option> <option value="other">Прочие контакты</option>
</select> </select>
</div> </div>
<div class="col-md-4"> <div class="col-md-5">
<input type="file" class="form-control bg-black border-success text-success" id="imageFile" accept="image/*" required> <input type="file" class="form-control" id="imageFile" accept="image/*" required>
<small class="text-muted">JPG, PNG до 5MB</small>
</div> </div>
<div class="col-md-2"> <div class="col-md-4">
<button type="submit" class="btn btn-matrix w-100">Загрузить</button> <button type="submit" class="btn-unix w-100">Загрузить картинку</button>
</div> </div>
</form> </form>
<div id="uploadResult" class="mt-3"></div> <div id="uploadResult" class="mt-2"></div>
</div> </div>
</div> </div>
<!-- Список служб --> <!-- Список служб -->
<div class="card border-success"> <div class="card">
<div class="card-body"> <div class="card-header">СПИСОК КОНТАКТОВ В БАЗЕ ДАННЫХ</div>
<h5 class="text-success opacity-75">СУЩЕСТВУЮЩИЕ СЛУЖБЫ:</h5> <div class="card-body p-0">
<table class="table table-dark table-hover mb-0"> <div class="table-responsive">
<table class="table mb-0">
<thead> <thead>
<tr> <tr>
<th>Категория</th> <th style="width: 150px;">Категория</th>
<th>Название</th> <th>Название</th>
<th>Телефон</th> <th style="width: 160px;">Телефон</th>
<th>Описание</th> <th>Описание</th>
<th>Картинка</th> <th style="width: 130px;">Изображение</th>
<th>Действия</th> <th class="text-end" style="width: 90px;">Удалить</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -91,48 +93,46 @@
<tr> <tr>
<td> <td>
{% if service.category == 'emergency' %} {% if service.category == 'emergency' %}
<span class="badge bg-danger">🚨 Экстренные</span> <span class="badge-status badge-danger">Экстренные</span>
{% elif service.category == 'utility' %} {% elif service.category == 'utility' %}
<span class="badge bg-info">🔧 ЖКХ</span> <span class="badge-status badge-warning">ЖКХ</span>
{% elif service.category == 'masters' %} {% elif service.category == 'masters' %}
<span class="badge bg-success">👷 Мастера</span> <span class="badge-status badge-success">Мастера</span>
{% elif service.category == 'police' %} {% elif service.category == 'police' %}
<span class="badge bg-warning text-dark">👮 Полиция</span> <span class="badge-status badge-info">Полиция</span>
{% else %} {% else %}
<span class="badge bg-secondary">Другое</span> <span class="badge-status">Другое</span>
{% endif %} {% endif %}
</td> </td>
<td>{{ service.name }}</td> <td><b>{{ service.name }}</b></td>
<td><a href="tel:{{ service.phone }}">{{ service.phone }}</a></td> <td><a href="tel:{{ service.phone }}"><b>{{ service.phone }}</b></a></td>
<td>{{ service.description or '—' }}</td> <td>{{ service.description or '—' }}</td>
<td> <td>
{% if service.image_path %} {% if service.image_path %}
<img src="/static/service_images/{{ service.image_path.split('/')[-1] }}" <img src="/static/service_images/{{ service.image_path.split('/')[-1] }}"
class="service-image-preview" alt="Картинка"> class="service-image-preview" alt="Превью">
<br><small class="text-muted">✅</small>
{% else %} {% else %}
<span class="text-muted">❌ Нет</span> <span class="text-muted small">Нет</span>
{% endif %} {% endif %}
</td> </td>
<td> <td class="text-end">
<button class="btn btn-sm btn-danger" onclick="deleteService({{ service.service_id }})"> <button class="btn-unix" onclick="deleteService({{ service.service_id }})">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
</td> </td>
</tr> </tr>
{% else %}
<tr>
<td colspan="6" class="text-center text-muted py-3">Службы пока не добавлены</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% if not services %}
<p class="text-center text-muted">Служб нет</p>
{% endif %}
</div> </div>
</div> </div>
{% endblock %} </div>
{% block extra_script %}
<script> <script>
// Добавление службы
document.getElementById('addServiceForm').addEventListener('submit', async (e) => { document.getElementById('addServiceForm').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const data = { const data = {
@ -146,11 +146,10 @@
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
const result = await r.json(); const result = await r.json();
if (result.success) { alert('✅ Служба добавлена'); location.reload(); } if (result.success) { alert('Служба добавлена'); location.reload(); }
else { alert('❌ Ошибка: ' + (result.error || 'Неизвестная')); } else { alert('Ошибка: ' + (result.error || 'Неизвестная')); }
}); });
// Загрузка картинки
document.getElementById('uploadImageForm').addEventListener('submit', async (e) => { document.getElementById('uploadImageForm').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const formData = new FormData(); const formData = new FormData();
@ -158,7 +157,7 @@
formData.append('file', document.getElementById('imageFile').files[0]); formData.append('file', document.getElementById('imageFile').files[0]);
const resultDiv = document.getElementById('uploadResult'); const resultDiv = document.getElementById('uploadResult');
resultDiv.innerHTML = '<div class="alert alert-info">Загрузка...</div>'; resultDiv.innerHTML = '<div class="alert alert-info py-1 small">Загрузка...</div>';
try { try {
const r = await fetch('/api/service/upload_image', { const r = await fetch('/api/service/upload_image', {
@ -168,20 +167,21 @@
const result = await r.json(); const result = await r.json();
if (result.success) { if (result.success) {
resultDiv.innerHTML = `<div class="alert alert-success">✅ ${result.message}<br>Файл: ${result.file_path}</div>`; resultDiv.innerHTML = `<div class="alert alert-success py-1 small">${result.message}</div>`;
setTimeout(() => location.reload(), 1500);
} else { } else {
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${result.error}</div>`; resultDiv.innerHTML = `<div class="alert alert-danger py-1 small">Ошибка: ${result.error}</div>`;
} }
} catch (err) { } catch (err) {
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${err.message}</div>`; resultDiv.innerHTML = `<div class="alert alert-danger py-1 small">Ошибка: ${err.message}</div>`;
} }
}); });
async function deleteService(id) { async function deleteService(id) {
if (!confirm('Удалить службу?')) return; if (!confirm('Удалить эту службу?')) return;
const r = await fetch(`/api/service/${id}/delete`, { method: 'POST' }); const r = await fetch(`/api/service/${id}/delete`, { method: 'POST' });
const result = await r.json(); const result = await r.json();
if (result.success) { alert('✅ Удалено'); location.reload(); } if (result.success) { alert('Удалено'); location.reload(); }
} }
</script> </script>
{% endblock %} {% endblock %}

View file

@ -30,8 +30,8 @@
<div class="row"> <div class="row">
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label class="form-label text-info">🖼️ Готовое фото (Артефакт):</label> <label class="form-label">🖼️ Готовое фото (Артефакт):</label>
<select class="form-select bg-dark text-info border-info" id="artifactPhotoId"> <select class="form-select" id="artifactPhotoId">
<option value="">-- Не выбрано --</option> <option value="">-- Не выбрано --</option>
{% for a in artifacts if a.file_type == 'photo' %} {% for a in artifacts if a.file_type == 'photo' %}
<option value="{{ a.file_id }}">{{ a.filename }}</option> <option value="{{ a.file_id }}">{{ a.filename }}</option>
@ -39,8 +39,8 @@
</select> </select>
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label class="form-label text-warning">📄 Готовый документ (Артефакт):</label> <label class="form-label">📄 Готовый документ (Артефакт):</label>
<select class="form-select bg-dark text-warning border-warning" id="artifactDocumentId"> <select class="form-select" id="artifactDocumentId">
<option value="">-- Не выбрано --</option> <option value="">-- Не выбрано --</option>
{% for a in artifacts if a.file_type == 'document' %} {% for a in artifacts if a.file_type == 'document' %}
<option value="{{ a.file_id }}">{{ a.filename }}</option> <option value="{{ a.file_id }}">{{ a.filename }}</option>

View file

@ -1,39 +1,50 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Жатва ГАС Правосудие - LKM37{% endblock %} {% block title %}ГАС Правосудие - МКД ЛенКом 37{% endblock %}
{% block nav_sudrf %}active{% endblock %} {% block nav_sudrf %}active{% endblock %}
{% block content %} {% block content %}
<h2 class="mb-4"><i class="bi bi-bank text-danger"></i> Жатва ГАС Правосудие</h2> <div class="mb-3">
<h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Сбор материалов судебных дел (ГАС «Правосудие»)</h4>
<div class="small text-muted">Автоматизированная выгрузка карточек дел, определений и решений для базы NotebookLM</div>
</div>
<div class="row"> <div class="row g-3">
<!-- Инструкция и Статус --> <!-- Инструкция и Статус -->
<div class="col-md-6 mb-4"> <div class="col-md-6">
<div class="card bg-dark border-danger h-100"> <div class="card h-100">
<div class="card-header border-danger text-danger"> <div class="card-header">
<i class="bi bi-info-circle"></i> ИНСТРУКЦИЯ И УПРАВЛЕНИЕ ИНСТРУКЦИЯ И УПРАВЛЕНИЕ
</div> </div>
<div class="card-body d-flex flex-column justify-content-between"> <div class="card-body d-flex flex-column justify-content-between">
<div> <div>
<p class="text-white">Этот модуль предназначен для автоматизированного сбора поданных обращений, заявлений и ответов судов с портала ГАС «Правосудие» (ej.sudrf.ru) для последующей склейки в PDF и загрузки в Google Drive (NotebookLM).</p> <p class="small text-muted">
Модуль собирает электронные документы судебных процессов по дому (ej.sudrf.ru), преобразует вложения в PDF и загружает в единый аналитический контур.
</p>
<h5 class="text-danger mt-4"><i class="bi bi-1-circle"></i> Шаг 1: Подготовка сессии</h5> <div class="mb-3">
<p class="small text-muted">Нажмите кнопку «Запустить Жатву ГАС Правосудие» ниже. Система откроет скрытый браузер Chromium на виртуальном рабочем столе.</p> <div style="font-weight: bold;">Шаг 1: Подготовка сессии</div>
<div class="small text-muted">Нажмите кнопку «Запустить сессию» ниже. Будет открыт браузер на виртуальном рабочем столе.</div>
<h5 class="text-danger mt-3"><i class="bi bi-2-circle"></i> Шаг 2: Авторизация ЕСИА</h5>
<p class="small text-muted">Откройте VNC-браузер по ссылке справа и войдите в свой личный кабинет ГАС «Правосудие» через Госуслуги (потребуется пройти 2FA/SMS).</p>
<h5 class="text-danger mt-3"><i class="bi bi-3-circle"></i> Шаг 3: Автоматический парсинг</h5>
<p class="small text-muted">Как только вход будет обнаружен, скрипт перехватит сессию, соберет список дел, сделает скриншоты, скачает вложения и загрузит сводные PDF на Google Drive.</p>
</div> </div>
<div class="mt-4 pt-3 border-top border-secondary"> <div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-3"> <div style="font-weight: bold;">Шаг 2: Вход через Госуслуги (ЕСИА)</div>
<span class="text-white">Статус парсера:</span> <div class="small text-muted">Перейдите по ссылке VNC и подтвердите вход по СМС в личном кабинете.</div>
<div id="sudrfStatusBadge" class="badge bg-secondary p-2">НЕИЗВЕСТНО</div>
</div> </div>
<button id="runSudrfParserBtn" class="btn btn-danger w-100 py-2">
<i class="bi bi-play-circle-fill"></i> ЗАПУСТИТЬ ЖАТВУ ГАС ПРАВОСУДИЕ <div>
<div style="font-weight: bold;">Шаг 3: Автоматический экспорт</div>
<div class="small text-muted">После авторизации скрипт перехватит сессию, скачает дела и сформирует PDF.</div>
</div>
</div>
<div class="mt-3 pt-3 border-top" style="border-color: var(--border-light) !important;">
<div class="d-flex justify-content-between align-items-center mb-2">
<span>Статус процесса:</span>
<div id="sudrfStatusBadge" class="badge-status">ПРОВЕРКА</div>
</div>
<button id="runSudrfParserBtn" class="btn-unix btn-unix-primary w-100 py-2">
<i class="bi bi-play-circle"></i> Запустить сборку ГАС Правосудие
</button> </button>
</div> </div>
</div> </div>
@ -41,41 +52,36 @@
</div> </div>
<!-- VNC и Логи --> <!-- VNC и Логи -->
<div class="col-md-6 mb-4"> <div class="col-md-6">
<div class="card bg-dark border-info h-100"> <div class="card h-100">
<div class="card-header border-info text-info"> <div class="card-header d-flex justify-content-between align-items-center">
<i class="bi bi-display"></i> СЕССИЯ И ЛОГИ СКАНИРОВАНИЯ <span>VNC-ЭКРАН И ЖУРНАЛ СКАНИРОВАНИЯ</span>
</div> <button id="refreshSudrfStatusBtn" class="btn-unix">
<div class="card-body d-flex flex-column"> <i class="bi bi-arrow-clockwise"></i> Обновить
<a href="http://192.168.10.116:6080/vnc.html" target="_blank" class="btn btn-outline-info w-100 py-3 mb-2">
<i class="bi bi-window-fullscreen"></i> ОТКРЫТЬ VNC ЭКРАН (noVNC)
</a>
<div class="alert alert-warning py-2 mb-3 text-warning border-warning" style="font-size: 0.75rem; background: transparent;">
<i class="bi bi-exclamation-triangle"></i> <strong>Внимание:</strong> Если жатва не запущена, VNC-экран будет пустым (черным). Браузер Chromium автоматически откроется на экране сразу после нажатия кнопки «Запустить жатву».
</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="small text-muted">ЛОГИ В РЕАЛЬНОМ ВРЕМЕНИ:</span>
<button id="refreshSudrfStatusBtn" class="btn btn-sm btn-outline-secondary py-0 px-2" style="font-size: 0.75rem;">
<i class="bi bi-arrow-clockwise"></i> ОБНОВИТЬ
</button> </button>
</div> </div>
<div class="card-body d-flex flex-column">
<a href="http://192.168.10.116:6080/vnc.html" target="_blank" class="btn-unix w-100 text-center py-2 mb-2">
<i class="bi bi-display"></i> Открыть VNC экран (noVNC)
</a>
<div class="small text-muted mb-2">
<i>Экран браузера отображается в noVNC сразу после запуска сессии.</i>
</div>
<pre id="sudrfLogs" class="bg-black text-danger p-3 small border border-secondary flex-grow-1" style="min-height: 250px; overflow-y: scroll; font-family: monospace; font-size: 0.75rem;"></pre> <div class="small text-muted mb-1">Журнал работы:</div>
<pre id="sudrfLogs" class="flex-grow-1 mb-0" style="min-height: 220px; overflow-y: scroll; background-color: #ffffff; border: 1px solid var(--border-color); font-size: 11px;"></pre>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %}
{% block extra_script %}
<script> <script>
document.getElementById('runSudrfParserBtn').addEventListener('click', async function() { document.getElementById('runSudrfParserBtn').addEventListener('click', async function() {
const btn = this; const btn = this;
if (!confirm('Запустить сборку судебного архива ГАС Правосудие? Потребуется войти через Госуслуги во VNC.')) return; if (!confirm('Запустить сборку судебного архива ГАС Правосудие? Потребуется войти через Госуслуги во VNC.')) return;
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> СЕССИЯ ЗАПУЩЕНА...'; btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Сессия запускается...';
try { try {
const response = await fetch('/api/sudrf/run', { method: 'POST' }); const response = await fetch('/api/sudrf/run', { method: 'POST' });
@ -85,7 +91,7 @@ document.getElementById('runSudrfParserBtn').addEventListener('click', async fun
alert('Ошибка при запуске: ' + e); alert('Ошибка при запуске: ' + e);
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.innerHTML = '<i class="bi bi-play-circle-fill"></i> ЗАПУСТИТЬ ЖАТВУ ГАС ПРАВОСУДИЕ'; btn.innerHTML = '<i class="bi bi-play-circle"></i> Запустить сборку ГАС Правосудие';
refreshSudrfStatus(); refreshSudrfStatus();
} }
}); });
@ -99,25 +105,23 @@ async function refreshSudrfStatus() {
const data = await response.json(); const data = await response.json();
if (data.alive) { if (data.alive) {
badge.className = 'badge bg-success p-2'; badge.className = 'badge-status badge-success';
badge.innerText = 'СТАТУС: РАБОТАЕТ'; badge.innerText = 'РАБОТАЕТ';
} else { } else {
badge.className = 'badge bg-danger p-2'; badge.className = 'badge-status';
badge.innerText = 'СТАТУС: ОСТАНОВЛЕН'; badge.innerText = 'ОЖИДАНИЕ';
} }
logs.innerText = data.log || "Лог пуст или еще не создан."; logs.innerText = data.log || 'Лог чист';
logs.scrollTop = logs.scrollHeight; logs.scrollTop = logs.scrollHeight;
} catch (e) { } catch (e) {
badge.className = 'badge bg-warning p-2'; badge.className = 'badge-status badge-warning';
badge.innerText = 'ОШИБКА API'; badge.innerText = 'ОШИБКА API';
} }
} }
document.getElementById('refreshSudrfStatusBtn').addEventListener('click', refreshSudrfStatus); document.getElementById('refreshSudrfStatusBtn').addEventListener('click', refreshSudrfStatus);
window.addEventListener('load', refreshSudrfStatus); window.addEventListener('load', refreshSudrfStatus);
// Автообновление логов каждые 15 секунд setInterval(refreshSudrfStatus, 30000);
setInterval(refreshSudrfStatus, 15000);
</script> </script>
{% endblock %} {% endblock %}

View file

@ -1,74 +1,90 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Пользователи - LKM37{% endblock %} {% block title %}Реестр жильцов - МКД ЛенКом 37{% endblock %}
{% block nav_users %}active{% endblock %} {% block nav_users %}active{% endblock %}
{% block content %} {% block content %}
<h2 class="mb-4" style="color: var(--matrix-green);"> <div class="d-flex justify-content-between align-items-center mb-3">
>_ РЕЕСТР_СУБЪЕКТОВ <div>
<h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Реестр жильцов и доступ к ИИ</h4>
<div class="small text-muted">Управление верификацией и статусом доступа к юридическому модулю</div>
</div>
{% if current_filter %} {% if current_filter %}
<span class="badge bg-info ms-2"> <div>
{% if current_filter == 'verified' %}✅ ВЕРИФИЦИРОВАНЫ <span class="badge-status badge-info">
{% elif current_filter == 'unverified' %}⏳ ОЖИДАНИЕ ФИЛЬТР:
{% elif current_filter == 'ig' %}🔵 TEAM {% if current_filter == 'verified' %}ВЕРИФИЦИРОВАНЫ
{% elif current_filter == 'active_unverified' %}🔥 АКТИВНЫ {% elif current_filter == 'unverified' %}ОЖИДАНИЕ
{% elif current_filter == 'ig' %}ИНИЦИАТИВНАЯ ГРУППА
{% elif current_filter == 'active_unverified' %}АКТИВНЫЕ
{% else %}ВСЕ{% endif %} {% else %}ВСЕ{% endif %}
</span> </span>
<a href="/users" class="btn btn-sm btn-outline-secondary ms-2 text-success border-success">СБРОС</a> <a href="/users" class="btn-unix ms-2">Сбросить фильтр</a>
</div>
{% endif %} {% endif %}
</h2> </div>
<!-- Статистика (ПЛОСКИЕ ПЕРЕМЕННЫЕ) --> <!-- Статистика и фильтры -->
<style> <div class="row g-3 mb-3">
.filter-card { cursor: pointer; transition: var(--transition-smooth) !important; }
.filter-card:hover { transform: translateY(-4px) !important; }
.filter-card.active-filter { box-shadow: 0 0 15px currentColor !important; border-width: 2px !important; }
</style>
<div class="row mb-4">
<div class="col-md-3"> <div class="col-md-3">
<div class="card bg-black border-success text-success filter-card {% if not current_filter %}active-filter{% endif %}" onclick="location.href='/users'"> <div class="card h-100" style="cursor: pointer;" onclick="location.href='/users'">
<div class="card-header d-flex justify-content-between">
<span>ВСЕГО</span>
<i class="bi bi-people"></i>
</div>
<div class="card-body text-center"> <div class="card-body text-center">
<h3>{{ total_count }}</h3> <div style="font-size: 22px; font-weight: bold;">{{ total_count }}</div>
<small>⚡️ ВСЕГО</small> <div class="small text-muted">Все пользователи</div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<div class="card bg-black border-success text-success filter-card {% if current_filter == 'verified' %}active-filter{% endif %}" onclick="location.href='/users?filter=verified'"> <div class="card h-100" style="cursor: pointer;" onclick="location.href='/users?filter=verified'">
<div class="card-header d-flex justify-content-between">
<span>ВЕРИФИЦИРОВАНЫ</span>
<i class="bi bi-patch-check"></i>
</div>
<div class="card-body text-center"> <div class="card-body text-center">
<h3>{{ verified_count }}</h3> <div style="font-size: 22px; font-weight: bold;">{{ verified_count }}</div>
<small>✅ ВЕРИФИЦИРОВАНЫ</small> <div class="small text-muted">С подтверждением</div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<div class="card bg-black border-info text-info filter-card {% if current_filter == 'ig' %}active-filter{% endif %}" onclick="location.href='/users?filter=ig'"> <div class="card h-100" style="cursor: pointer;" onclick="location.href='/users?filter=ig'">
<div class="card-header d-flex justify-content-between">
<span>СОВЕТ ДОМА / ИГ</span>
<i class="bi bi-shield-check"></i>
</div>
<div class="card-body text-center"> <div class="card-body text-center">
<h3>{{ ig_count }}</h3> <div style="font-size: 22px; font-weight: bold;">{{ ig_count }}</div>
<small>🔵 ИНИЦИАТИВНАЯ ГРУППА</small> <div class="small text-muted">Инициативная группа</div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<div class="card bg-black border-warning text-warning filter-card {% if current_filter == 'active_unverified' %}active-filter{% endif %}" onclick="location.href='/users?filter=active_unverified'"> <div class="card h-100" style="cursor: pointer;" onclick="location.href='/users?filter=active_unverified'">
<div class="card-header d-flex justify-content-between">
<span>ГОСТИ</span>
<i class="bi bi-chat-dots"></i>
</div>
<div class="card-body text-center"> <div class="card-body text-center">
<h3>{{ active_unverified }}</h3> <div style="font-size: 22px; font-weight: bold;">{{ active_unverified }}</div>
<small>🔥 АКТИВНЫЕ ГОСТИ</small> <div class="small text-muted">Без верификации</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Поиск --> <!-- Поиск -->
<div class="card mb-4"> <div class="card mb-3">
<div class="card-body bg-black border-success"> <div class="card-body p-2">
<form method="GET" action="/users" class="row g-3"> <form method="GET" action="/users" class="row g-2 align-items-center">
<div class="col-md-10"> <div class="col-md-10">
<input type="text" class="form-control bg-dark border-success text-success" name="search" placeholder="Поиск по ID, имени, квартире..." value="{{ search }}"> <input type="text" class="form-control" name="search" placeholder="Поиск по ID, имени, квартире..." value="{{ search }}">
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<button type="submit" class="btn btn-matrix w-100">ПОИСК</button> <button type="submit" class="btn-unix btn-unix-primary w-100">Найти</button>
</div> </div>
</form> </form>
</div> </div>
@ -76,20 +92,19 @@
<!-- Таблица --> <!-- Таблица -->
<div class="card"> <div class="card">
<div class="card-header border-success">СПИСОК_СУБЪЕКТОВ</div> <div class="card-header">СПИСОК СОБСТВЕННИКОВ И ЖИТЕЛЕЙ</div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-dark table-hover mb-0"> <table class="table mb-0">
<thead> <thead>
<tr class="text-success opacity-50"> <tr>
<th>ID</th> <th style="width: 100px;">ID</th>
<th>ИМЯ</th> <th>Имя</th>
<th>НИК</th> <th>Username</th>
<th>КВАРТИРА</th> <th>Квартира</th>
<th>СТАТУС</th> <th>Статус</th>
<th>ЮРИСТ</th> <th style="width: 110px;">Доступ Юрист</th>
<th>РЕЙТИНГ</th> <th>Рейтинг</th>
<th>ДЕЙСТВИЯ</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -97,23 +112,32 @@
<tr> <tr>
<td><code>{{ user.user_id }}</code></td> <td><code>{{ user.user_id }}</code></td>
<td>{{ user.first_name }} {{ user.last_name or '' }}</td> <td>{{ user.first_name }} {{ user.last_name or '' }}</td>
<td>@{{ user.username or '—' }}</td>
<td>{{ user.apartment or '—' }}</td>
<td> <td>
{% if user.verified %}<span class="text-success">V</span>{% else %}<span class="text-danger">X</span>{% endif %} {% if user.username %}
{% if user.user_id in ig_user_ids %}<span class="text-info ms-1">IG</span>{% endif %} <a href="https://t.me/{{ user.username }}" target="_blank">@{{ user.username }}</a>
{% else %}
<span class="text-muted">—</span>
{% endif %}
</td> </td>
<td><b>{{ user.apartment or '—' }}</b></td>
<td> <td>
<div class="form-check form-switch"> {% if user.verified %}
<span class="badge-status badge-success">Верифицирован</span>
{% else %}
<span class="badge-status badge-warning">Не подтвержден</span>
{% endif %}
{% if user.user_id in ig_user_ids %}
<span class="badge-status badge-info ms-1">ИГ</span>
{% endif %}
</td>
<td class="text-center">
<div class="form-check form-switch d-inline-block">
<input class="form-check-input" type="checkbox" role="switch" <input class="form-check-input" type="checkbox" role="switch"
{% if user.has_ai_access %}checked{% endif %} {% if user.has_ai_access %}checked{% endif %}
onclick="toggleAI({{ user.user_id }})"> onclick="toggleAI({{ user.user_id }})">
</div> </div>
</td> </td>
<td>{{ user.rating }}</td> <td>{{ user.rating }}</td>
<td>
<button class="btn btn-sm btn-outline-success" onclick="editUser({{ user.user_id }})"><i class="bi bi-pencil"></i></button>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>