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
*.so
.Python
venv-ai/
venv/
venv-google/
env/

View file

@ -90,7 +90,7 @@ if not ADMIN_USER_ID:
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'))

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View file

@ -12,14 +12,20 @@ services:
- TZ=Europe/Ulyanovsk
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./.env:/app/.env:ro
- ./config.py:/app/config.py:ro
- ./main.py:/app/main.py
- ./logs:/app/logs
- ./exports:/app/exports
- ./data:/app/data
- ./handlers:/app/handlers
- ./keyboards:/app/keyboards
- ./utils:/app/utils
- ./services:/app/services
- ../libs/swarmlib:/app/swarmlib: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:
driver: "json-file"
options:
@ -35,16 +41,28 @@ services:
environment:
- PYTHONPATH=/app:/app/services
- GOOGLE_CREDS_FILE=/home/matrixhasyou/domovoy_google_creds.json
- GOOGLE_API_KEY=AIzaSyAJMfQ_mRZ1picRXVFNaeFJ7fpAkZxUKyk
- MUTT_DIR=/home/matrixhasyou/mutt
- TZ=Europe/Ulyanovsk
volumes:
- /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
- ./exports:/app/exports
- ./data:/app/data
- ./handlers:/app/handlers
- ./keyboards:/app/keyboards
- ./utils:/app/utils
- ./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
- /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:
driver: "json-file"
options:

View file

@ -58,104 +58,8 @@ async def process_swarm_task(message: Message, state: FSMContext):
# ... (код остается прежним)
pass
# =============================================================================
# ЮРИДИЧЕСКИЙ ПОМОЩНИК (NotebookLM)
# =============================================================================
# (Устаревший блок NotebookLM перенесен в специализированный модуль handlers/legal_advisor.py)
@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')
@ -226,16 +130,25 @@ async def cmd_legal_sync(message: Message):
@router.message(Command('admin'))
@router.message(F.text.in_(['⚙️ Управление', '⚙️ Админка', '⚙️ АДМИНКА']))
@admin_only
async def cmd_admin(message: Message):
"""Админ-меню (только в личке!)"""
# Проверяем что это личка
# Если вызвано в группе — тихо удаляем из чата и отправляем админу в ЛС
if message.chat.type != 'private':
await message.answer(
'⚠️ <b>Админ-панель доступна только в личных сообщениях!</b>\n\n'
'Напишите боту в личку: /admin',
parse_mode='HTML'
)
try:
await message.delete()
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'
)
except Exception:
pass
return
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
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')
@router.message(F.text == '🕵️ Шпионы')
@router.message(F.text.in_(['🛡️ Фильтр посторонних', '🕵️ Шпионы', '/spies']))
@admin_only
async def msg_spies_button(message: Message):
"""Кнопка Шпионы"""
"""Кнопка Шпионы / Фильтр посторонних"""
async with AsyncSessionLocal() as session:
spy_detector = SpyDetector(session)
spies = await spy_detector.get_spies()
if not spies:
await message.answer('🟢 Шпионов не обнаружено')
await message.answer('🟢 Посторонних и шпионов не обнаружено')
return
text = '🚨 <b>ШПИОНЫ</b> 🚨\n\n'
text = '🚨 <b>ПОСТОРОННИЕ / ШПИОНЫ</b> 🚨\n\n'
for spy in spies:
name = spy.full_name or f'user{spy.user_id}'
apt = spy.apartment or '?'
@ -279,16 +192,16 @@ async def msg_spies_button(message: Message):
await message.answer(text, parse_mode='HTML')
@router.message(F.text == '👥 Пользователи')
@router.message(F.text.in_(['👥 Список жителей', '👥 Пользователи', '/users']))
@admin_only
async def msg_users_button(message: Message):
"""Кнопка Пользователи"""
"""Кнопка Пользователи / Список жителей"""
async with AsyncSessionLocal() as session:
stmt = select(User).limit(50)
result = await session.execute(stmt)
users = list(result.scalars().all())
text = f'👥 <b>Пользователи</b> (50):\n\n'
text = f'👥 <b>Список жителей</b> (50):\n\n'
for user in users:
status = '✅' if user.verified else '⏳'
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')
@router.message(F.text == '❓ Помощь')
@router.message(F.text.in_(['❓ Справка', '❓ Помощь', '/help']), lambda m: config.is_admin(m.from_user.id))
@admin_only
async def msg_help_button(message: Message):
"""Кнопка Помощь - справка по командам"""
@ -795,6 +708,7 @@ async def cmd_spies(message: Message):
@router.message(Command('export'))
@router.message(Command('экспорт'))
@router.message(F.text.in_(['📥 Выгрузка базы', '📥 Экспорт JSON']))
@admin_only
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 keyboards.inline import get_main_menu, get_phones_menu, get_profile_keyboard, get_weather_keyboard
from services.ai_service import ai_service
from utils.redirect import respond_pm_or_redirect
import config
logger = logging.getLogger(__name__)
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 event.answer()
message = event.message
if (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'):
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)
await respond_pm_or_redirect(
event=event,
text=text,
reply_markup=reply_markup,
photo_path=photo_path,
parse_mode=parse_mode,
start_param=start_param
)
@router.message(Command('cancel'), StateFilter('*'))
@router.message(F.text.lower().in_(['отмена', 'отменить']), StateFilter('*'))
@ -59,9 +48,8 @@ async def global_cancel(message: Message, state: FSMContext):
@router.message(CommandStart())
async def cmd_start(message: Message):
"""Команда /start"""
"""Команда /start: в ЛС открывает меню, в группе тихо перенаправляет в ЛС"""
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
async with AsyncSessionLocal() as session:
@ -88,7 +76,13 @@ async def cmd_start(message: Message):
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')
async def cb_main_menu(callback: CallbackQuery):
@ -110,19 +104,19 @@ async def cb_main_menu(callback: CallbackQuery):
)
@router.message(Command('phones'))
@router.message(F.text.in_(['📞 Важные телефоны', '📞 Телефоны', 'Телефоны']))
@router.callback_query(F.data == 'phones')
async def cb_phones(event):
text = '📞 <b>ЭКСТРЕННЫЕ ТЕЛЕФОНЫ</b>\n\nВыберите категорию ниже:'
text = '📞 <b>ЭКСТРЕННЫЕ И ВАЖНЫЕ ТЕЛЕФОНЫ</b>\n\nВыберите категорию ниже:'
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(F.text.lower() == 'мастера')
@router.message(F.text.in_(['👷 Мастера', 'Мастера', 'мастера']))
async def cmd_masters(message: Message):
"""Выдача списка мастеров по команде /masters или тексту 'мастера'"""
from database.models import Service
from keyboards.inline import get_main_menu
from aiogram.types import FSInputFile
import os
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)
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
text = "📞 <b>СПИСОК ТЕЛЕФОНОВ: МАСТЕРА</b>\n\n"
@ -152,10 +146,7 @@ async def cmd_masters(message: Message):
text += "\n"
photo_path = services[0].image_path if services[0].image_path else None
if photo_path and os.path.exists(photo_path):
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')
await safe_edit_or_reply(message, text, reply_markup=menu_kb, photo_path=photo_path, parse_mode='HTML', start_param='masters')
@router.message(F.text & ~F.text.startswith('/'))
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):
"""Выдача телефонов по категориям"""
from database.models import Service
from aiogram.types import FSInputFile
import os
category_map = {
'phones_avariyka': 'avariyka',
'phones_emergency': 'emergency',
'phones_utility': 'utility',
'phones_police': 'police',
@ -199,9 +190,6 @@ async def cb_phone_category(callback: CallbackQuery):
if not cat_code: return await callback.answer()
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 = (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)
@ -212,23 +200,43 @@ async def cb_phone_category(callback: CallbackQuery):
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)
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:
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:
text += f"🔹 <b>{s.name}</b>\n └ 📱 <code>{s.phone}</code>\n"
if s.description: text += f" └ 📝 <i>{s.description}</i>\n"
text += "\n"
# Если есть фото, придется прислать новое сообщение (edit_text не умеет менять текст на фото)
photo_path = services[0].image_path if services[0].image_path else None
if photo_path and os.path.exists(photo_path):
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')
# Если есть фото у службы
photo_path = None
for s in services:
if s.image_path and os.path.exists(s.image_path):
photo_path = s.image_path
break
await safe_edit_or_reply(callback, text, reply_markup=menu_kb, photo_path=photo_path, parse_mode='HTML')
@router.message(Command('rules'))
@router.callback_query(F.data == 'rules')
@ -334,6 +342,7 @@ class HelpFeedbackState(StatesGroup):
waiting_for_reply = State()
@router.message(Command('help'))
@router.message(F.text.in_(['❓ Справка', '❓ Помощь', 'Справка', 'Помощь']))
@router.callback_query(F.data == 'help')
async def cb_help(event, state: FSMContext):
await state.clear()

View file

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

View file

@ -13,11 +13,11 @@ def get_reply_menu() -> ReplyKeyboardMarkup:
builder = ReplyKeyboardBuilder()
builder.row(
KeyboardButton(text='📞 Телефоны'),
KeyboardButton(text='📞 Важные телефоны'),
KeyboardButton(text='👷 Мастера'),
)
builder.row(
KeyboardButton(text='❓ Помощь'),
KeyboardButton(text='❓ Справка'),
)
# Resize keyboard чтобы не занимал много места
@ -29,12 +29,13 @@ def get_admin_reply_menu() -> ReplyKeyboardMarkup:
builder = ReplyKeyboardBuilder()
builder.row(
KeyboardButton(text='/admin'),
KeyboardButton(text='/stats'),
KeyboardButton(text='⚙️ Управление'),
KeyboardButton(text='📊 Статистика'),
)
builder.row(
KeyboardButton(text='/spies'),
KeyboardButton(text='/export'),
KeyboardButton(text='🛡️ Фильтр посторонних'),
KeyboardButton(text='📥 Выгрузка базы'),
)
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()
scheduler = Scheduler(bot)
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:
await bot.send_message(ADMIN_USER_ID, '✅ <b>LKM37 CORE v4.3 АКТИВИРОВАН</b>')
await send_domovoy_alert("Сервис запущен", "Домовой МКД успешно запущен в автономном коммунальном режиме.")
@ -59,7 +105,12 @@ async def main():
bot = get_bot()
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(users_router)
@ -69,16 +120,12 @@ async def main():
antispam_router, ratelimit_router, schedule_router, ads_router,
payments_router, multicount_router, topics_router, toxicity_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:
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)

View file

@ -26,3 +26,4 @@ google-auth-oauthlib
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):
self.bot = bot
self.scheduler = AsyncIOScheduler()
self.scheduler = AsyncIOScheduler(timezone='Europe/Ulyanovsk')
def start(self):
"""Запуск планировщика"""
@ -141,25 +141,24 @@ class Scheduler:
)
logger.info('✅ Добавлена задача ежедневного прогноза погоды для админа')
# Еженедельный прогноз в чат по понедельникам (в 09:00)
self.scheduler.add_job(
self.monday_chat_weather,
CronTrigger.from_crontab('0 9 * * 1'), # Пн в 09:00
id='monday_chat_weather',
name='Прогноз погоды в чат (Понедельник)',
replace_existing=True
)
logger.info('✅ Добавлена задача прогноза погоды в чат по понедельникам')
# Еженедельный прогноз в чат по четвергам (в 09:00)
self.scheduler.add_job(
self.thursday_chat_weather,
CronTrigger.from_crontab('0 9 * * 4'), # Чт в 09:00
id='thursday_chat_weather',
name='Прогноз погоды в чат (Четверг)',
replace_existing=True
)
logger.info('✅ Добавлена задача прогноза погоды в чат по четвергам')
# [ОТКЛЮЧЕНО ПО ТЗ] Еженедельный прогноз в чат по понедельникам и четвергам отключен для группы дома
# Жильцы начали активнее общаться, прогноз погоды в общей группе больше не требуется.
# Личный прогноз для Александра (daily_admin_weather) выше остается активным.
# self.scheduler.add_job(
# self.monday_chat_weather,
# CronTrigger.from_crontab('0 9 * * 1'),
# id='monday_chat_weather',
# name='Прогноз погоды в чат (Понедельник)',
# replace_existing=True
# )
# self.scheduler.add_job(
# self.thursday_chat_weather,
# CronTrigger.from_crontab('0 9 * * 4'),
# id='thursday_chat_weather',
# name='Прогноз погоды в чат (Четверг)',
# replace_existing=True
# )
logger.info('ℹ️ Прогноз погоды в общий чат дома отключен по требованию председателя')
# Еженедельный дайджест Александра (суббота в 08:00)
self.scheduler.add_job(
@ -550,8 +549,17 @@ class Scheduler:
"""Прогноз в чат по понедельникам (на 3 дня)"""
try:
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()
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(
ADMIN_CHAT_ID,
forecast,
@ -565,8 +573,17 @@ class Scheduler:
"""Прогноз в чат по четвергам (на остаток недели и выходные)"""
try:
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()
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(
ADMIN_CHAT_ID,
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"]
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)
@ -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})
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
sync_time_file = Path(config.MUTT_DIR) / "last_ai_sync.txt"
@ -705,10 +681,6 @@ async def emails_page(request: Request, username: str = Depends(get_current_admi
total_attachments = sum(int(m) for m in matches)
except Exception as e:
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
if audit_data:
@ -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"})
try:
# Run master sync script in background
subprocess.Popen([python_path, master_script], start_new_session=True)
# Run master sync script in background with logging
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."})
except Exception as e:
return JSONResponse(status_code=500, content={"message": str(e)})
@app.post("/api/emails/sync_ai")
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
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"})
try:
# Run sync script in background
subprocess.Popen([python_path, sync_script, master_pdf], start_new_session=True)
# Run sync script in background with logging
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 запущена в фоне. Это займет около минуты."})
except Exception as 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
status_file = Path(config.MUTT_DIR) / "pipeline_status.json"
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
pdf_size_mb = 0.0
if master_pdf_path.exists():
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_inbound = 0
@ -910,6 +889,131 @@ async def api_sudrf_status(username: str = Depends(get_current_admin)):
except Exception as e:
return JSONResponse(status_code=500, content={"message": str(e)})
@app.get("/legal", response_class=HTMLResponse)
async def legal_page(request: Request, username: str = Depends(get_current_admin)):
import fitz
legal_dir = Path("/home/matrixhasyou/mutt/legal_sources")
volumes_dir = Path("/home/matrixhasyou/mutt/mega_volumes")
podcasts_dir = Path("/home/matrixhasyou/swarm-services/domovoy-bot/podcasts")
laws = []
if legal_dir.exists():
for f in sorted(legal_dir.glob("*.pdf")):
if f.is_file() and not f.name.startswith("."):
size_mb = f.stat().st_size / (1024 * 1024)
size_formatted = f"{size_mb:.2f} МБ" if size_mb >= 1 else f"{f.stat().st_size / 1024:.1f} КБ"
try:
doc = fitz.open(f)
pages = len(doc)
except Exception:
pages = 0
title = f.stem.replace("_", " ").replace("Полный", "").strip()
laws.append({
"filename": f.name,
"title": title,
"pages": pages,
"size_formatted": size_formatted
})
mega_volumes = []
if volumes_dir.exists():
for f in sorted(volumes_dir.glob("*.pdf")):
if f.is_file() and not f.name.startswith("."):
size_mb = f.stat().st_size / (1024 * 1024)
mega_volumes.append({
"name": f.name,
"size_formatted": f"{size_mb:.1f} МБ"
})
latest_podcast = None
if podcasts_dir.exists():
podcast_files = sorted(podcasts_dir.glob("*.*"), key=lambda x: x.stat().st_mtime, reverse=True)
if podcast_files:
p = podcast_files[0]
latest_podcast = {
"name": p.name,
"size": f"{p.stat().st_size / (1024 * 1024):.1f} МБ",
"date": datetime.fromtimestamp(p.stat().st_mtime).strftime("%d.%m.%Y %H:%M")
}
return templates.TemplateResponse(
request=request,
name="legal.html",
context={
"username": username,
"laws": laws,
"mega_volumes": mega_volumes,
"latest_podcast": latest_podcast
}
)
@app.get("/api/legal/download/{filename}")
async def api_download_law(filename: str, username: str = Depends(get_current_admin)):
fpath = Path("/home/matrixhasyou/mutt/legal_sources") / filename
if not fpath.exists() or not fpath.is_file():
raise HTTPException(status_code=404, detail="Файл закона не найден")
return FileResponse(fpath, filename=filename, media_type="application/pdf")
@app.get("/api/legal/download_volume/{filename}")
async def api_download_volume(filename: str, username: str = Depends(get_current_admin)):
fpath = Path("/home/matrixhasyou/mutt/mega_volumes") / filename
if not fpath.exists() or not fpath.is_file():
raise HTTPException(status_code=404, detail="Файл тома переписки не найден")
return FileResponse(fpath, filename=filename, media_type="application/pdf")
@app.get("/api/legal/podcasts/{filename}")
async def api_download_podcast(filename: str, username: str = Depends(get_current_admin)):
fpath = Path("/home/matrixhasyou/swarm-services/domovoy-bot/podcasts") / filename
if not fpath.exists() or not fpath.is_file():
raise HTTPException(status_code=404, detail="Подкаст не найден")
return FileResponse(fpath, filename=filename)
@app.post("/api/legal/ask_ai")
async def api_legal_ask_ai(request: Request, username: str = Depends(get_current_admin)):
try:
body = await request.json()
query = body.get("query", "").strip()
deep = body.get("deep", False)
if not query:
return JSONResponse(content={"success": False, "error": "Текст вопроса пуст"})
from services.ai_legal_engine import AILegalEngine
engine = AILegalEngine()
role = "lawyer" if deep else "resident"
res = await engine.query(prompt=query, role=role)
return JSONResponse(content=res)
except Exception as e:
return JSONResponse(content={"success": False, "error": str(e)})
@app.post("/api/legal/notebooklm_sync")
async def api_legal_notebooklm_sync(username: str = Depends(get_current_admin)):
try:
from services.notebooklm_podcast_generator import NotebookLMPodcastGenerator
generator = NotebookLMPodcastGenerator()
res = generator.sync_all_sources()
if res.get("success"):
return JSONResponse(content={"message": f"Синхронизация успешна! Загружено файлов: {len(res.get('uploaded_sources', []))}"})
return JSONResponse(content={"message": f"Статус: {res.get('error', 'Сессия требует настройки')}"})
except Exception as e:
return JSONResponse(status_code=500, content={"message": str(e)})
@app.post("/api/legal/generate_podcast")
async def api_legal_generate_podcast(username: str = Depends(get_current_admin)):
try:
from services.notebooklm_podcast_generator import NotebookLMPodcastGenerator
generator = NotebookLMPodcastGenerator()
if generator.is_authenticated():
res = generator.generate_audio_overview()
return JSONResponse(content={"message": "Генерация Audio Overview в NotebookLM запущена!"})
else:
# Fallback генерация сценария через Gemini 2.5 Pro
res = generator.generate_fallback_dialogue_script(
"Анализ судебных споров дома с УК и ГЖИ по отоплению, перерасчетам и отпискам прокуратуры."
)
return JSONResponse(content={"message": f"Сгенерирован сценарий подкаста через Gemini 2.5 Pro: {len(res.get('script', ''))} симв."})
except Exception as e:
return JSONResponse(status_code=500, content={"message": str(e)})
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

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 */
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
/* CLEAN MINIMALIST THEME FOR DOMOVOY WEB PANEL */
:root {
--bg-main: #06060c;
--bg-gradient: radial-gradient(circle at 50% 0%, #150f30 0%, #06060c 80%);
--panel-bg: rgba(18, 18, 29, 0.45);
--panel-bg-hover: rgba(26, 26, 42, 0.6);
--panel-border: rgba(255, 255, 255, 0.08);
--panel-border-hover: rgba(255, 255, 255, 0.15);
--color-primary: #8a2be2;
--color-primary-glow: rgba(138, 43, 226, 0.35);
--color-success: #00ffcc;
--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);
--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) !important;
background-image: var(--bg-gradient) !important;
background-attachment: fixed !important;
color: var(--color-text-main) !important;
font-family: var(--font-main) !important;
min-height: 100vh;
overflow-x: hidden;
line-height: 1.5;
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;
}
/* Сайдбар */
.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>
<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="/static/css/matrix.css" rel="stylesheet">
<style>
.sidebar { min-height: 100vh; position: sticky; top: 0; overflow-y: auto; }
.content { padding: 2rem; }
:root {
--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 %}
</style>
</head>
<body>
<div id="matrix-canvas-container" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: -1; opacity: 0.1;">
<canvas id="matrix-canvas"></canvas>
</div>
<div class="container-fluid">
<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="/">
<i class="bi bi-terminal"></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_users %}{% endblock %}" href="/users">
<i class="bi bi-people"></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 class="nav-link {% block nav_emails %}{% endblock %}" href="/emails">
<i class="bi bi-envelope-at"></i> ПОЧТА
</a>
</nav>
<div class="layout-container">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<h4>МКД ЛЕНКОМ 37</h4>
</div>
<!-- Main Content -->
<div class="col-md-10 content">
{% block content %}{% endblock %}
</div>
</div>
<nav>
<a class="nav-link {% block nav_dashboard %}{% endblock %}" href="/">
<i class="bi bi-terminal"></i> Дашборд
</a>
<div class="nav-section-title">Правовая база</div>
<a class="nav-link {% block nav_legal %}{% endblock %}" href="/legal">
<i class="bi bi-shield-check"></i> Законы РФ & ИИ-Юрист
</a>
<a class="nav-link {% block nav_emails %}{% endblock %}" href="/emails">
<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>
</nav>
</aside>
<!-- Main Content -->
<main class="main-content">
{% block content %}{% endblock %}
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
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 %}
{% block scripts %}{% endblock %}
</body>
</html>

View file

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

View file

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

View file

@ -1,108 +1,98 @@
{% extends "base.html" %}
{% block title %}Настройки дайджеста - Домовой Бот{% 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 title %}Настройки дайджеста - МКД ЛенКом 37{% endblock %}
{% block nav_digests %}active{% endblock %}
{% block content %}
<div class="digest-settings-container">
<div class="digest-header">
<a href="/digests" class="digest-back-link">← Назад к дайджестам</a>
<h1>⚙️ Настройки дайджеста</h1>
<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>
<div>
<a href="/digests" class="btn-unix">← Назад к дайджестам</a>
</div>
</div>
<div class="digest-card">
<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 class="row g-3" style="max-width: 900px;">
<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="autoGenerate" checked>
<label class="form-check-label" for="autoGenerate">
<b>Автоматическая генерация:</b> каждое воскресенье в 19:00 (время Ульяновск)
</label>
</div>
</div>
</div>
<div class="digest-form-group">
<label>Время генерации</label>
<input type="time" value="19:00" disabled>
</div>
<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="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>
</div>
<div class="digest-card">
<h3>📊 Содержимое дайджеста</h3>
<div class="digest-checkbox-group">
<input type="checkbox" id="includeEvents" checked>
<label for="includeEvents">🔔 События</label>
</div>
<div class="digest-checkbox-group">
<input type="checkbox" id="includePolls" checked>
<label for="includePolls">📊 Опросы</label>
</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 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="digest-card">
<h3>📬 Отправка</h3>
<div class="digest-checkbox-group">
<input type="checkbox" id="sendPreview" checked>
<label for="sendPreview">Отправлять превью админу для утверждения</label>
</div>
<div class="digest-checkbox-group">
<input type="checkbox" id="autoSend">
<label for="autoSend">Публиковать автоматически (без утверждения)</label>
</div>
<div class="digest-checkbox-group">
<input type="checkbox" id="saveArchive" checked>
<label for="saveArchive">Сохранять в архив</label>
</div>
<div class="col-12">
<button class="btn-unix btn-unix-primary" onclick="saveSettings()">Сохранить настройки</button>
</div>
<button class="digest-btn" onclick="saveSettings()">💾 Сохранить настройки</button>
</div>
{% endblock %}
{% block extra_script %}
<script>
function saveSettings() {
alert('✅ Настройки сохранены!');
alert('Настройки успешно сохранены');
}
</script>
{% endblock %}

View file

@ -1,275 +1,250 @@
{% extends "base.html" %}
{% block title %}Еженедельные дайджесты - Домовой Бот{% endblock %}
{% block title %}Еженедельные дайджесты - МКД ЛенКом 37{% endblock %}
{% block nav_digests %}active{% endblock %}
{% block extra_style %}
* { margin: 0; padding: 0; box-sizing: border-box; }
{% endblock %}
{% block content %}
<style>
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
.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); }
.header h1 { font-size: 2em; margin-bottom: 10px; text-shadow: 0 0 10px var(--color-primary-glow); }
.back-link { color: var(--color-success); text-decoration: none; opacity: 0.9; }
.back-link:hover { opacity: 1; text-shadow: 0 0 8px var(--color-success-glow); }
.tabs { display: flex; gap: 10px; margin-bottom: 20px; }
.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); }
.tab:hover { color: var(--color-text-main); background: rgba(255, 255, 255, 0.08); }
.tab.active { background: var(--color-primary); color: white; border-color: var(--color-primary); box-shadow: 0 0 10px var(--color-primary-glow); }
.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 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>
<div>
<button class="btn-unix btn-unix-primary" onclick="generateNow()">
<i class="bi bi-arrow-repeat"></i> Сгенерировать сейчас
</button>
</div>
</div>
<div class="tabs">
<button class="tab active" onclick="showTab('pending')">⏳ Ожидают утверждения</button>
<button class="tab" onclick="showTab('archive')">📚 Архив</button>
</div>
<div class="d-flex gap-2 mb-3">
<button class="btn-unix active" id="tabBtnPending" onclick="showTab('pending')">Ожидают утверждения ({{ pending_digests|length }})</button>
<button class="btn-unix" id="tabBtnArchive" onclick="showTab('archive')">Архив дайджестов</button>
</div>
<div id="pending" class="tab-content active">
{% if pending_digests|length > 0 %}
{% for digest in pending_digests %}
<div class="pending-card">
<h3>📰 Дайджест #{{ digest.week_number }}/{{ digest.year }}</h3>
<div class="meta">
Период: {{ digest.period_label }} | Создан: {{ digest.created_at }} | Статус: <span class="badge badge-pending">{{ digest.status_emoji }} {{ digest.status }}</span>
</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 id="pending" class="tab-content-panel">
{% if pending_digests|length > 0 %}
{% for digest in pending_digests %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Дайджест #{{ digest.week_number }}/{{ digest.year }} (Период: {{ digest.period_label }})</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>
{% endfor %}
{% else %}
<div style="text-align: center; padding: 50px; background: white; border-radius: 8px;">
<h2>📭 Нет ожидающих утверждения дайджестов</h2>
<p style="margin-top: 10px;">Следующий дайджест будет сгенерирован в воскресенье в 09:00</p>
<button class="btn btn-send" style="margin-top: 20px;" onclick="generateNow()">🔄 Сгенерировать сейчас</button>
</div>
{% endif %}
</div>
<div id="archive" class="tab-content">
{% if archive_digests|length > 0 %}
<table>
<thead>
<tr>
<th>Неделя</th>
<th>Период</th>
<th>Статус</th>
<th>Отправлен</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for digest in archive_digests %}
<tr>
<td>#{{ digest.week_number }}/{{ digest.year }}</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>
<button class="btn btn-edit" onclick="viewDigest({{ digest.id }})">👁️ Просмотр</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="text-align: center; padding: 50px; background: white; border-radius: 8px;">
<h2>📚 Архив пуст</h2>
<p style="margin-top: 10px;">Здесь будут отображаться все дайджесты</p>
</div>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="card p-4 text-center">
<div class="mb-2"><i class="bi bi-inbox text-muted" style="font-size: 32px;"></i></div>
<div style="font-weight: bold;">Нет дайджестов, ожидающих утверждения</div>
<div class="small text-muted mt-1">Автоматическая генерация запускается по расписанию каждое воскресенье в 19:00</div>
</div>
{% endif %}
</div>
<!-- Модальное окно для редактирования -->
<div id="editModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>✏️ Редактирование дайджеста</h2>
<button class="close-btn" onclick="closeEditModal()">&times;</button>
</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 id="archive" class="tab-content-panel" style="display: none;">
{% if archive_digests|length > 0 %}
<div class="card">
<div class="card-header">АРХИВ ВЫПУСКОВ</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table mb-0">
<thead>
<tr>
<th style="width: 120px;">Выпуск</th>
<th>Период</th>
<th style="width: 140px;">Статус</th>
<th style="width: 180px;">Отправлен</th>
<th style="width: 100px;">Действия</th>
</tr>
</thead>
<tbody>
{% for digest in archive_digests %}
<tr>
<td><b>#{{ digest.week_number }}/{{ digest.year }}</b></td>
<td>{{ digest.period_label }}</td>
<td>
<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>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% else %}
<div class="card p-4 text-center">
<div class="small text-muted">Архив пока пуст</div>
</div>
{% endif %}
</div>
<!-- Модальное окно для просмотра -->
<div id="viewModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="viewTitle">📰 Дайджест</h2>
<button class="close-btn" onclick="closeViewModal()">&times;</button>
<!-- Модальное окно для редактирования -->
<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="card" style="width: 90%; max-width: 800px; max-height: 90vh; overflow-y: auto; margin: 40px auto; border: 2px solid var(--border-color);">
<div class="card-header d-flex justify-content-between align-items-center">
<span>РЕДАКТИРОВАНИЕ ДАЙДЖЕСТА</span>
<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 id="viewText" style="background: #f8f9fa; padding: 15px; border-radius: 5px; white-space: pre-wrap;"></div>
</div>
</div>
</div>
<script>
let currentDigestId = null;
<!-- Модальное окно для просмотра -->
<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="card" style="width: 90%; max-width: 800px; max-height: 90vh; overflow-y: auto; margin: 40px auto; border: 2px solid var(--border-color);">
<div class="card-header d-flex justify-content-between align-items-center">
<span id="viewTitle">ПРОСМОТР ДАЙДЖЕСТА</span>
<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>
function showTab(tabName) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(tabName).classList.add('active');
}
<script>
let currentDigestId = null;
function approveDigest(id) {
if (confirm('Утвердить этот дайджест?')) {
fetch(`/api/digest/${id}/approve`, { method: 'POST' })
.then(r => r.json())
.then(data => {
alert(data.message);
// Показываем кнопку "Отправить"
document.getElementById(`sendBtn${id}`).style.display = 'inline-block';
location.reload();
});
}
}
function showTab(tabName) {
document.getElementById('pending').style.display = tabName === 'pending' ? 'block' : 'none';
document.getElementById('archive').style.display = tabName === 'archive' ? 'block' : 'none';
document.getElementById('tabBtnPending').className = tabName === 'pending' ? 'btn-unix active' : 'btn-unix';
document.getElementById('tabBtnArchive').className = tabName === 'archive' ? 'btn-unix active' : 'btn-unix';
}
function rejectDigest(id) {
const reason = prompt('Причина отклонения:');
if (reason) {
fetch(`/api/digest/${id}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: reason })
})
.then(r => r.json())
.then(data => {
alert(data.message);
location.reload();
});
}
}
function editDigest(id) {
currentDigestId = id;
fetch(`/api/digest/${id}`)
.then(r => r.json())
.then(data => {
document.getElementById('editText').value = data.digest.text;
document.getElementById('editModal').classList.add('active');
});
}
function saveEdit() {
const newText = document.getElementById('editText').value;
fetch(`/api/digest/${currentDigestId}/update_text`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: newText })
})
function approveDigest(id) {
if (confirm('Утвердить этот дайджест?')) {
fetch(`/api/digest/${id}/approve`, { method: 'POST' })
.then(r => r.json())
.then(data => {
alert(data.message);
closeEditModal();
const sendBtn = document.getElementById(`sendBtn${id}`);
if (sendBtn) sendBtn.style.display = 'inline-block';
location.reload();
});
}
}
}
function closeEditModal() {
document.getElementById('editModal').classList.remove('active');
}
function rejectDigest(id) {
const reason = prompt('Причина отклонения:');
if (reason) {
fetch(`/api/digest/${id}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: reason })
})
.then(r => r.json())
.then(data => {
alert(data.message);
location.reload();
});
}
}
function viewDigest(id) {
fetch(`/api/digest/${id}`)
.then(r => r.json())
.then(data => {
document.getElementById('viewTitle').textContent = `📰 Дайджест #${data.digest.week}/${data.digest.year}`;
document.getElementById('viewText').textContent = data.digest.text;
document.getElementById('viewModal').classList.add('active');
});
}
function editDigest(id) {
currentDigestId = id;
fetch(`/api/digest/${id}`)
.then(r => r.json())
.then(data => {
document.getElementById('editText').value = data.digest.text;
document.getElementById('editModal').style.display = 'flex';
});
}
function closeViewModal() {
document.getElementById('viewModal').classList.remove('active');
}
function saveEdit() {
const newText = document.getElementById('editText').value;
fetch(`/api/digest/${currentDigestId}/update_text`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: newText })
})
.then(r => r.json())
.then(data => {
alert(data.message);
closeEditModal();
location.reload();
});
}
function sendDigest(id) {
if (confirm('Отправить дайджест всем верифицированным жильцам?')) {
fetch(`/api/digest/${id}/send`, { method: 'POST' })
.then(r => r.json())
.then(data => {
alert(data.message);
location.reload();
});
}
}
function closeEditModal() {
document.getElementById('editModal').style.display = 'none';
}
async function generateNow() {
const btn = event.target;
btn.disabled = true;
btn.textContent = '⏳ Генерация...';
try {
const r = await fetch('/api/digest/generate', { method: 'POST' });
const result = await r.json();
if (result.success) {
alert('✅ ' + result.message);
location.reload();
} else {
alert('❌ Ошибка: ' + (result.error || 'Неизвестная'));
btn.disabled = false;
btn.textContent = '🔄 Сгенерировать сейчас';
}
} catch (err) {
alert('❌ Ошибка: ' + err.message);
btn.disabled = false;
btn.textContent = '🔄 Сгенерировать сейчас';
}
function viewDigest(id) {
fetch(`/api/digest/${id}`)
.then(r => r.json())
.then(data => {
document.getElementById('viewTitle').textContent = `Дайджест #${data.digest.week}/${data.digest.year}`;
document.getElementById('viewText').textContent = data.digest.text;
document.getElementById('viewModal').style.display = 'flex';
});
}
function closeViewModal() {
document.getElementById('viewModal').style.display = 'none';
}
function sendDigest(id) {
if (confirm('Отправить дайджест всем верифицированным жильцам?')) {
fetch(`/api/digest/${id}/send`, { method: 'POST' })
.then(r => r.json())
.then(data => {
alert(data.message);
location.reload();
});
}
}
async function generateNow() {
const btn = event.target;
btn.disabled = true;
const oldText = btn.innerHTML;
btn.innerHTML = '⏳ Генерация...';
try {
const r = await fetch('/api/digest/generate', { method: 'POST' });
const result = await r.json();
if (result.success) {
alert('Успешно: ' + result.message);
location.reload();
} else {
alert('Ошибка: ' + (result.error || 'Неизвестная ошибка'));
btn.disabled = false;
btn.innerHTML = oldText;
}
</script>
} catch (err) {
alert('Ошибка: ' + err.message);
btn.disabled = false;
btn.innerHTML = oldText;
}
}
</script>
{% endblock %}

View file

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

View file

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

View file

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

View file

@ -1,102 +1,112 @@
{% extends "base.html" %}
{% block title %}Инфраструктура - Домовой Бот{% endblock %}
{% block title %}Инфраструктура - МКД ЛенКом 37{% endblock %}
{% block nav_infra %}active{% endblock %}
{% 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="small text-muted">Состояние сетевых узлов, замеры интернет-канала и статус фоновых пайплайнов</div>
</div>
<div class="row mb-4">
<div class="row g-3 mb-3">
<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 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">
<table class="table table-dark table-hover small">
<thead>
<tr>
<th>Дата и Время</th>
<th>Download (Mbps)</th>
<th>Upload (Mbps)</th>
<th>Ping (ms)</th>
<th>Сервер</th>
</tr>
</thead>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table mb-0">
<thead>
<tr>
<th>Дата и время</th>
<th>Download (Мбит/с)</th>
<th>Upload (Мбит/с)</th>
<th>Ping (мс)</th>
<th>Сервер замера</th>
</tr>
</thead>
<tbody>
{% for test in speedtests %}
<tr>
<td>{{ test.timestamp }}</td>
<td><b>{{ test.download }}</b></td>
<td><b>{{ test.upload }}</b></td>
<td>{{ test.ping }}</td>
<td class="text-muted">{{ test.server_name }}</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center text-muted py-3">Данных замеров пока нет</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">СЕТЕВЫЕ УЗЛЫ И ХОСТЫ</div>
<div class="card-body p-0">
<table class="table mb-0">
<tbody>
{% for test in speedtests %}
<tr>
<td>{{ test.timestamp }}</td>
<td class="text-info">{{ test.download }}</td>
<td class="text-warning">{{ test.upload }}</td>
<td>{{ test.ping }}</td>
<td class="opacity-50">{{ test.server_name }}</td>
<td style="width: 140px;"><b>NanoPi R3S</b></td>
<td>192.168.10.1 (Роутер / Шлюз)</td>
<td class="text-end"><span class="badge-status badge-success">ONLINE</span></td>
</tr>
<tr>
<td><b>Synology NAS</b></td>
<td>192.168.10.105 (Хранилище / PostgreSQL)</td>
<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>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card bg-dark border-info">
<div class="card-header text-info">УЗЛЫ СЕТИ</div>
<div class="card-body">
<ul class="list-group list-group-flush bg-transparent">
<li class="list-group-item bg-transparent text-white border-secondary">
<span class="badge bg-success">ONLINE</span> NanoPi R3S (192.168.10.1)
</li>
<li class="list-group-item bg-transparent text-white border-secondary">
<span class="badge bg-success">ONLINE</span> Synology NAS (192.168.10.105)
</li>
<li class="list-group-item bg-transparent text-white border-secondary">
<span class="badge bg-success">ONLINE</span> DockerServer (192.168.10.116)
</li>
</ul>
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span>ГИС ЖКХ & СБОРКА АРХИВА</span>
<span id="gisStatusBadge" class="badge-status">СТАТУС: ПРОВЕРКА</span>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card bg-dark border-primary">
<div class="card-header text-primary">ГИС ЖКХ & ЮРИДИЧЕСКИЙ АРХИВ</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<button id="runGisHarvestBtn" class="btn btn-outline-primary flex-grow-1 me-2">
<i class="bi bi-cloud-download"></i> ЗАПУСТИТЬ ЖАТВУ И PDF-СБОРКУ
<div class="d-flex gap-2 mb-2">
<button id="runGisHarvestBtn" class="btn-unix btn-unix-primary flex-grow-1">
<i class="bi bi-cloud-download"></i> Запустить сборку ГИС ЖКХ
</button>
<button id="refreshGisStatusBtn" class="btn-unix">
Обновить статус
</button>
<div id="gisStatusBadge" class="badge bg-secondary p-2">СТАТУС: НЕИЗВЕСТНО</div>
</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>
<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>
{% endblock %}
{% block extra_script %}
<script>
document.getElementById('runSpeedtestBtn').addEventListener('click', async function() {
const btn = this;
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 {
const response = await fetch('/api/infra/run_speedtest', { method: 'POST' });
@ -107,16 +117,16 @@ document.getElementById('runSpeedtestBtn').addEventListener('click', async funct
alert('Ошибка: ' + e);
} finally {
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() {
const btn = this;
if (!confirm('Запустить тотальную перекачку ГИС ЖКХ и сборку PDF? Это займет значительное время.')) return;
if (!confirm('Запустить сборку ГИС ЖКХ? Это займет некоторое время.')) return;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> ЖАТВА В ПРОЦЕССЕ...';
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Процесс запущен...';
try {
const response = await fetch('/api/infra/run_gis_harvest', { method: 'POST' });
@ -126,7 +136,7 @@ document.getElementById('runGisHarvestBtn').addEventListener('click', async func
alert('Ошибка: ' + e);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-cloud-download"></i> ЗАПУСТИТЬ ЖАТВУ И PDF-СБОРКУ';
btn.innerHTML = '<i class="bi bi-cloud-download"></i> Запустить сборку ГИС ЖКХ';
refreshGisStatus();
}
});
@ -140,24 +150,23 @@ async function refreshGisStatus() {
const data = await response.json();
if (data.alive) {
badge.className = 'badge bg-success p-2';
badge.innerText = 'СТАТУС: РАБОТАЕТ';
badge.className = 'badge-status badge-success';
badge.innerText = 'РАБОТАЕТ';
} else {
badge.className = 'badge bg-danger p-2';
badge.innerText = 'СТАТУС: ОСТАНОВЛЕН';
badge.className = 'badge-status';
badge.innerText = 'ОЖИДАНИЕ';
}
logs.innerText = data.log;
logs.innerText = data.log || 'Лог чист';
logs.scrollTop = logs.scrollHeight;
} catch (e) {
badge.className = 'badge bg-warning p-2';
badge.innerText = 'ОШИБКА API';
badge.className = 'badge-status badge-danger';
badge.innerText = 'ОШИБКА';
}
}
document.getElementById('refreshGisStatusBtn').addEventListener('click', refreshGisStatus);
window.addEventListener('load', refreshGisStatus);
// Авто-обновление раз в 30 секунд
setInterval(refreshGisStatus, 30000);
</script>
{% 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,138 +1,138 @@
{% extends "base.html" %}
{% block title %}Телефоны и службы - Домовой Бот{% endblock %}
{% block title %}Телефоны и службы - МКД ЛенКом 37{% endblock %}
{% block nav_phones %}active{% endblock %}
{% 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 %}
{% 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-header border-success">
<h5 class="mb-0"><i class="bi bi-plus-lg"></i> Добавить службу</h5>
<div class="card mb-3">
<div class="card-header">
<i class="bi bi-plus-circle"></i> ДОБАВЛЕНИЕ НОВОЙ СЛУЖБЫ
</div>
<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">
<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 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 class="col-md-2">
<select class="form-select bg-black border-success text-success" id="serviceCategory">
<option value="emergency">🚨 Экстренные</option>
<option value="utility">🔧 ЖКХ</option>
<option value="masters">👷 Мастера</option>
<option value="police">👮 Полиция</option>
<option value="other">📋 Другое</option>
<select class="form-select" id="serviceCategory">
<option value="emergency">Экстренные службы</option>
<option value="utility" selected>ЖКХ и аварийные</option>
<option value="masters">Мастера и ремонт</option>
<option value="police">Полиция и охрана</option>
<option value="other">Прочие контакты</option>
</select>
</div>
<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 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>
</form>
</div>
</div>
<!-- Загрузить картинку -->
<div class="card mb-4 border-success">
<div class="card-header border-success">
<h5 class="mb-0"><i class="bi bi-image"></i> Загрузить картинку для службы</h5>
<div class="card mb-3">
<div class="card-header">
<i class="bi bi-image"></i> ЗАГРУЗКА ИЗОБРАЖЕНИЯ ДЛЯ КАТЕГОРИИ
</div>
<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">
<select class="form-select bg-black border-success text-success" id="imageCategory" required>
<option value="emergency">🚨 Экстренные</option>
<option value="utility">🔧 ЖКХ</option>
<option value="masters">👷 Мастера</option>
<option value="police">👮 Полиция</option>
<option value="other">📋 Другое</option>
<select class="form-select" id="imageCategory" required>
<option value="emergency">Экстренные службы</option>
<option value="utility" selected>ЖКХ и аварийные</option>
<option value="masters">Мастера и ремонт</option>
<option value="police">Полиция и охрана</option>
<option value="other">Прочие контакты</option>
</select>
</div>
<div class="col-md-4">
<input type="file" class="form-control bg-black border-success text-success" id="imageFile" accept="image/*" required>
<small class="text-muted">JPG, PNG до 5MB</small>
<div class="col-md-5">
<input type="file" class="form-control" id="imageFile" accept="image/*" required>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-matrix w-100">Загрузить</button>
<div class="col-md-4">
<button type="submit" class="btn-unix w-100">Загрузить картинку</button>
</div>
</form>
<div id="uploadResult" class="mt-3"></div>
<div id="uploadResult" class="mt-2"></div>
</div>
</div>
<!-- Список служб -->
<div class="card border-success">
<div class="card-body">
<h5 class="text-success opacity-75">СУЩЕСТВУЮЩИЕ СЛУЖБЫ:</h5>
<table class="table table-dark table-hover mb-0">
<thead>
<tr>
<th>Категория</th>
<th>Название</th>
<th>Телефон</th>
<th>Описание</th>
<th>Картинка</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for service in services %}
<tr>
<td>
{% if service.category == 'emergency' %}
<span class="badge bg-danger">🚨 Экстренные</span>
{% elif service.category == 'utility' %}
<span class="badge bg-info">🔧 ЖКХ</span>
{% elif service.category == 'masters' %}
<span class="badge bg-success">👷 Мастера</span>
{% elif service.category == 'police' %}
<span class="badge bg-warning text-dark">👮 Полиция</span>
{% else %}
<span class="badge bg-secondary">Другое</span>
{% endif %}
</td>
<td>{{ service.name }}</td>
<td><a href="tel:{{ service.phone }}">{{ service.phone }}</a></td>
<td>{{ service.description or '—' }}</td>
<td>
{% if service.image_path %}
<img src="/static/service_images/{{ service.image_path.split('/')[-1] }}"
class="service-image-preview" alt="Картинка">
<br><small class="text-muted">✅</small>
{% else %}
<span class="text-muted">❌ Нет</span>
{% endif %}
</td>
<td>
<button class="btn btn-sm btn-danger" onclick="deleteService({{ service.service_id }})">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not services %}
<p class="text-center text-muted">Служб нет</p>
{% endif %}
<div class="card">
<div class="card-header">СПИСОК КОНТАКТОВ В БАЗЕ ДАННЫХ</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table mb-0">
<thead>
<tr>
<th style="width: 150px;">Категория</th>
<th>Название</th>
<th style="width: 160px;">Телефон</th>
<th>Описание</th>
<th style="width: 130px;">Изображение</th>
<th class="text-end" style="width: 90px;">Удалить</th>
</tr>
</thead>
<tbody>
{% for service in services %}
<tr>
<td>
{% if service.category == 'emergency' %}
<span class="badge-status badge-danger">Экстренные</span>
{% elif service.category == 'utility' %}
<span class="badge-status badge-warning">ЖКХ</span>
{% elif service.category == 'masters' %}
<span class="badge-status badge-success">Мастера</span>
{% elif service.category == 'police' %}
<span class="badge-status badge-info">Полиция</span>
{% else %}
<span class="badge-status">Другое</span>
{% endif %}
</td>
<td><b>{{ service.name }}</b></td>
<td><a href="tel:{{ service.phone }}"><b>{{ service.phone }}</b></a></td>
<td>{{ service.description or '—' }}</td>
<td>
{% if service.image_path %}
<img src="/static/service_images/{{ service.image_path.split('/')[-1] }}"
class="service-image-preview" alt="Превью">
{% else %}
<span class="text-muted small">Нет</span>
{% endif %}
</td>
<td class="text-end">
<button class="btn-unix" onclick="deleteService({{ service.service_id }})">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center text-muted py-3">Службы пока не добавлены</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block extra_script %}
<script>
// Добавление службы
document.getElementById('addServiceForm').addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
@ -146,11 +146,10 @@
body: JSON.stringify(data)
});
const result = await r.json();
if (result.success) { alert('✅ Служба добавлена'); location.reload(); }
else { alert('❌ Ошибка: ' + (result.error || 'Неизвестная')); }
if (result.success) { alert('Служба добавлена'); location.reload(); }
else { alert('Ошибка: ' + (result.error || 'Неизвестная')); }
});
// Загрузка картинки
document.getElementById('uploadImageForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData();
@ -158,7 +157,7 @@
formData.append('file', document.getElementById('imageFile').files[0]);
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 {
const r = await fetch('/api/service/upload_image', {
@ -168,20 +167,21 @@
const result = await r.json();
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 {
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) {
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) {
if (!confirm('Удалить службу?')) return;
if (!confirm('Удалить эту службу?')) return;
const r = await fetch(`/api/service/${id}/delete`, { method: 'POST' });
const result = await r.json();
if (result.success) { alert('✅ Удалено'); location.reload(); }
if (result.success) { alert('Удалено'); location.reload(); }
}
</script>
{% endblock %}

View file

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

View file

@ -1,39 +1,50 @@
{% extends "base.html" %}
{% block title %}Жатва ГАС Правосудие - LKM37{% endblock %}
{% block title %}ГАС Правосудие - МКД ЛенКом 37{% endblock %}
{% block nav_sudrf %}active{% endblock %}
{% 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="card bg-dark border-danger h-100">
<div class="card-header border-danger text-danger">
<i class="bi bi-info-circle"></i> ИНСТРУКЦИЯ И УПРАВЛЕНИЕ
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">
ИНСТРУКЦИЯ И УПРАВЛЕНИЕ
</div>
<div class="card-body d-flex flex-column justify-content-between">
<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>
<p class="small text-muted">Нажмите кнопку «Запустить Жатву ГАС Правосудие» ниже. Система откроет скрытый браузер Chromium на виртуальном рабочем столе.</p>
<div class="mb-3">
<div style="font-weight: bold;">Шаг 1: Подготовка сессии</div>
<div class="small text-muted">Нажмите кнопку «Запустить сессию» ниже. Будет открыт браузер на виртуальном рабочем столе.</div>
</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>
<div class="mb-3">
<div style="font-weight: bold;">Шаг 2: Вход через Госуслуги (ЕСИА)</div>
<div class="small text-muted">Перейдите по ссылке VNC и подтвердите вход по СМС в личном кабинете.</div>
</div>
<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 style="font-weight: bold;">Шаг 3: Автоматический экспорт</div>
<div class="small text-muted">После авторизации скрипт перехватит сессию, скачает дела и сформирует PDF.</div>
</div>
</div>
<div class="mt-4 pt-3 border-top border-secondary">
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="text-white">Статус парсера:</span>
<div id="sudrfStatusBadge" class="badge bg-secondary p-2">НЕИЗВЕСТНО</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 btn-danger w-100 py-2">
<i class="bi bi-play-circle-fill"></i> ЗАПУСТИТЬ ЖАТВУ ГАС ПРАВОСУДИЕ
<button id="runSudrfParserBtn" class="btn-unix btn-unix-primary w-100 py-2">
<i class="bi bi-play-circle"></i> Запустить сборку ГАС Правосудие
</button>
</div>
</div>
@ -41,41 +52,36 @@
</div>
<!-- VNC и Логи -->
<div class="col-md-6 mb-4">
<div class="card bg-dark border-info h-100">
<div class="card-header border-info text-info">
<i class="bi bi-display"></i> СЕССИЯ И ЛОГИ СКАНИРОВАНИЯ
<div class="col-md-6">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span>VNC-ЭКРАН И ЖУРНАЛ СКАНИРОВАНИЯ</span>
<button id="refreshSudrfStatusBtn" class="btn-unix">
<i class="bi bi-arrow-clockwise"></i> Обновить
</button>
</div>
<div class="card-body d-flex flex-column">
<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 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="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 class="small text-muted mb-2">
<i>Экран браузера отображается в noVNC сразу после запуска сессии.</i>
</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>
</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>
{% endblock %}
{% block extra_script %}
<script>
document.getElementById('runSudrfParserBtn').addEventListener('click', async function() {
const btn = this;
if (!confirm('Запустить сборку судебного архива ГАС Правосудие? Потребуется войти через Госуслуги во VNC.')) return;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> СЕССИЯ ЗАПУЩЕНА...';
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Сессия запускается...';
try {
const response = await fetch('/api/sudrf/run', { method: 'POST' });
@ -85,7 +91,7 @@ document.getElementById('runSudrfParserBtn').addEventListener('click', async fun
alert('Ошибка при запуске: ' + e);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-play-circle-fill"></i> ЗАПУСТИТЬ ЖАТВУ ГАС ПРАВОСУДИЕ';
btn.innerHTML = '<i class="bi bi-play-circle"></i> Запустить сборку ГАС Правосудие';
refreshSudrfStatus();
}
});
@ -99,25 +105,23 @@ async function refreshSudrfStatus() {
const data = await response.json();
if (data.alive) {
badge.className = 'badge bg-success p-2';
badge.innerText = 'СТАТУС: РАБОТАЕТ';
badge.className = 'badge-status badge-success';
badge.innerText = 'РАБОТАЕТ';
} else {
badge.className = 'badge bg-danger p-2';
badge.innerText = 'СТАТУС: ОСТАНОВЛЕН';
badge.className = 'badge-status';
badge.innerText = 'ОЖИДАНИЕ';
}
logs.innerText = data.log || "Лог пуст или еще не создан.";
logs.innerText = data.log || 'Лог чист';
logs.scrollTop = logs.scrollHeight;
} catch (e) {
badge.className = 'badge bg-warning p-2';
badge.className = 'badge-status badge-warning';
badge.innerText = 'ОШИБКА API';
}
}
document.getElementById('refreshSudrfStatusBtn').addEventListener('click', refreshSudrfStatus);
window.addEventListener('load', refreshSudrfStatus);
// Автообновление логов каждые 15 секунд
setInterval(refreshSudrfStatus, 15000);
setInterval(refreshSudrfStatus, 30000);
</script>
{% endblock %}

View file

@ -1,74 +1,90 @@
{% extends "base.html" %}
{% block title %}Пользователи - LKM37{% endblock %}
{% block title %}Реестр жильцов - МКД ЛенКом 37{% endblock %}
{% block nav_users %}active{% endblock %}
{% 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 %}
<span class="badge bg-info ms-2">
{% if current_filter == 'verified' %}✅ ВЕРИФИЦИРОВАНЫ
{% elif current_filter == 'unverified' %}⏳ ОЖИДАНИЕ
{% elif current_filter == 'ig' %}🔵 TEAM
{% elif current_filter == 'active_unverified' %}🔥 АКТИВНЫ
{% else %}ВСЕ{% endif %}
</span>
<a href="/users" class="btn btn-sm btn-outline-secondary ms-2 text-success border-success">СБРОС</a>
<div>
<span class="badge-status badge-info">
ФИЛЬТР:
{% if current_filter == 'verified' %}ВЕРИФИЦИРОВАНЫ
{% elif current_filter == 'unverified' %}ОЖИДАНИЕ
{% elif current_filter == 'ig' %}ИНИЦИАТИВНАЯ ГРУППА
{% elif current_filter == 'active_unverified' %}АКТИВНЫЕ
{% else %}ВСЕ{% endif %}
</span>
<a href="/users" class="btn-unix ms-2">Сбросить фильтр</a>
</div>
{% endif %}
</h2>
</div>
<!-- Статистика (ПЛОСКИЕ ПЕРЕМЕННЫЕ) -->
<style>
.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="row g-3 mb-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">
<h3>{{ total_count }}</h3>
<small>⚡️ ВСЕГО</small>
<div style="font-size: 22px; font-weight: bold;">{{ total_count }}</div>
<div class="small text-muted">Все пользователи</div>
</div>
</div>
</div>
<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">
<h3>{{ verified_count }}</h3>
<small>✅ ВЕРИФИЦИРОВАНЫ</small>
<div style="font-size: 22px; font-weight: bold;">{{ verified_count }}</div>
<div class="small text-muted">С подтверждением</div>
</div>
</div>
</div>
<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">
<h3>{{ ig_count }}</h3>
<small>🔵 ИНИЦИАТИВНАЯ ГРУППА</small>
<div style="font-size: 22px; font-weight: bold;">{{ ig_count }}</div>
<div class="small text-muted">Инициативная группа</div>
</div>
</div>
</div>
<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">
<h3>{{ active_unverified }}</h3>
<small>🔥 АКТИВНЫЕ ГОСТИ</small>
<div style="font-size: 22px; font-weight: bold;">{{ active_unverified }}</div>
<div class="small text-muted">Без верификации</div>
</div>
</div>
</div>
</div>
<!-- Поиск -->
<div class="card mb-4">
<div class="card-body bg-black border-success">
<form method="GET" action="/users" class="row g-3">
<div class="card mb-3">
<div class="card-body p-2">
<form method="GET" action="/users" class="row g-2 align-items-center">
<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 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>
</form>
</div>
@ -76,20 +92,19 @@
<!-- Таблица -->
<div class="card">
<div class="card-header border-success">СПИСОК_СУБЪЕКТОВ</div>
<div class="card-header">СПИСОК СОБСТВЕННИКОВ И ЖИТЕЛЕЙ</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<table class="table mb-0">
<thead>
<tr class="text-success opacity-50">
<th>ID</th>
<th>ИМЯ</th>
<th>НИК</th>
<th>КВАРТИРА</th>
<th>СТАТУС</th>
<th>ЮРИСТ</th>
<th>РЕЙТИНГ</th>
<th>ДЕЙСТВИЯ</th>
<tr>
<th style="width: 100px;">ID</th>
<th>Имя</th>
<th>Username</th>
<th>Квартира</th>
<th>Статус</th>
<th style="width: 110px;">Доступ Юрист</th>
<th>Рейтинг</th>
</tr>
</thead>
<tbody>
@ -97,23 +112,32 @@
<tr>
<td><code>{{ user.user_id }}</code></td>
<td>{{ user.first_name }} {{ user.last_name or '' }}</td>
<td>@{{ user.username or '—' }}</td>
<td>{{ user.apartment or '—' }}</td>
<td>
{% if user.verified %}<span class="text-success">V</span>{% else %}<span class="text-danger">X</span>{% endif %}
{% if user.user_id in ig_user_ids %}<span class="text-info ms-1">IG</span>{% endif %}
{% if user.username %}
<a href="https://t.me/{{ user.username }}" target="_blank">@{{ user.username }}</a>
{% else %}
<span class="text-muted">—</span>
{% endif %}
</td>
<td><b>{{ user.apartment or '—' }}</b></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"
{% if user.has_ai_access %}checked{% endif %}
onclick="toggleAI({{ user.user_id }})">
</div>
</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>
{% endfor %}
</tbody>