Compare commits

..

No commits in common. "main" and "v1.0-pre-ai-reborn-20261012" have entirely different histories.

34 changed files with 1810 additions and 5059 deletions

1
.gitignore vendored
View file

@ -4,7 +4,6 @@ __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', '/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')))
MUTT_DIR = os.getenv('MUTT_DIR', '/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,11 +1,5 @@
{
"emergency": [
{
"name": "Аварийная служба (электрика)",
"phone": "54-69-29",
"description": "Круглосуточно при замыканиях в щитках, авариях электросетей и ЧП. Федеральный: 8 (8422) 54-69-29",
"priority": 1
},
{
"name": "Скорая помощь",
"phone": "103",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

View file

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

View file

@ -1,348 +0,0 @@
#!/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,26 +14,37 @@ 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, photo_path: str = None, parse_mode: str = 'HTML', start_param: str = 'start'):
async def safe_edit_or_reply(event, text: str, reply_markup=None, parse_mode='HTML'):
"""
Безопасный ответ: если вызван в группе — перенаправляет в личные сообщения пользователя
и тихо удаляет команду в группе, предотвращая спам.
Безопасное редактирование сообщения.
Если сообщение содержит медиа (фото и т.д.), мы удаляем его и отправляем новое.
"""
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
)
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)
@router.message(Command('cancel'), StateFilter('*'))
@router.message(F.text.lower().in_(['отмена', 'отменить']), StateFilter('*'))
@ -48,8 +59,9 @@ 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:
@ -76,13 +88,7 @@ async def cmd_start(message: Message):
f"Рад видеть тебя снова, {message.from_user.first_name}!\n"
"Чем могу помочь сегодня?")
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')
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')
@router.callback_query(F.data == 'main_menu')
async def cb_main_menu(callback: CallbackQuery):
@ -104,19 +110,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', start_param='phones')
await safe_edit_or_reply(event, text, reply_markup=kb, parse_mode='HTML')
@router.message(Command('masters'))
@router.message(F.text.in_(['👷 Мастера', 'Мастера', 'мастера']))
@router.message(F.text.lower() == 'мастера')
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
@ -135,7 +141,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 safe_edit_or_reply(message, "👷 Данные для этой категории еще не заполнены.", reply_markup=menu_kb, parse_mode='HTML', start_param='masters')
await message.answer("👷 Данные для этой категории еще не заполнены.", reply_markup=menu_kb, parse_mode='HTML')
return
text = "📞 <b>СПИСОК ТЕЛЕФОНОВ: МАСТЕРА</b>\n\n"
@ -146,7 +152,10 @@ async def cmd_masters(message: Message):
text += "\n"
photo_path = services[0].image_path if services[0].image_path else None
await safe_edit_or_reply(message, text, reply_markup=menu_kb, photo_path=photo_path, parse_mode='HTML', start_param='masters')
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')
@router.message(F.text & ~F.text.startswith('/'))
async def chat_with_ai(message: Message):
@ -176,10 +185,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',
@ -190,6 +199,9 @@ 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)
@ -200,43 +212,23 @@ 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)
category_titles = {
'emergency': '🚨 ЭКСТРЕННЫЕ СЛУЖБЫ',
'utility': '🔧 КОММУНАЛЬНЫЕ СЛУЖБЫ И ДИСПЕТЧЕР ЖСС',
'police': '👮 ПОЛИЦИЯ И УЧАСТКОВЫЙ',
'masters': '👷 МАСТЕРА'
}
title = category_titles.get(cat_code, f'📞 СПИСОК ТЕЛЕФОНОВ: {cat_code.upper()}')
text = f"<b>{title}</b>\n\n"
text = f"📞 <b>СПИСОК ТЕЛЕФОНОВ: {cat_code.upper()}</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"
# Если есть фото у службы
photo_path = None
for s in services:
if s.image_path and os.path.exists(s.image_path):
photo_path = s.image_path
break
# Если есть фото, придется прислать новое сообщение (edit_text не умеет менять текст на фото)
photo_path = services[0].image_path if services[0].image_path else None
await safe_edit_or_reply(callback, text, reply_markup=menu_kb, photo_path=photo_path, parse_mode='HTML')
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')
@router.message(Command('rules'))
@router.callback_query(F.data == 'rules')
@ -342,7 +334,6 @@ 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,60 +9,55 @@ 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='main_jurist')
InlineKeyboardButton(text='📞 Телефоны', callback_data='phones'),
InlineKeyboardButton(text='🏢 О доме', callback_data='about_house')
)
# СТРОКА 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='about_house')
InlineKeyboardButton(text='📋 Опросы', callback_data='polls'),
InlineKeyboardButton(text='🎉 События', callback_data='events')
)
# СОВЕТ ДОМА (ИНИЦИАТИВНАЯ ГРУППА)
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='my_profile'),
InlineKeyboardButton(text='❓ Помощь', callback_data='help')
InlineKeyboardButton(text='🏪 Маркет', callback_data='market'),
InlineKeyboardButton(text='🏆 Рейтинг', callback_data='achievements')
)
else:
# ДЛЯ НЕВЕРИФИЦИРОВАННЫХ (ГОСТЕЙ)
# Для гостей кнопка верификации на видном месте
builder.row(
InlineKeyboardButton(text='🏢 Паспорт дома', callback_data='about_house'),
InlineKeyboardButton(text='🔑 Подтвердить квартиру', callback_data='start_verification')
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='❓ Помощь / Связь с админом', callback_data='help')
InlineKeyboardButton(text='👑 [ROOT: УПРАВЛЕНИЕ]', callback_data='superadmin_menu')
)
# СТРОКА 6: СЛУЖЕБНОЕ (НИЗ)
builder.row(
InlineKeyboardButton(text='❓ Помощь', callback_data='help'),
InlineKeyboardButton(text='🌦️ Погода', callback_data='weather'),
InlineKeyboardButton(text='👤 Профиль', callback_data='my_profile')
)
return builder.as_markup()
@ -126,14 +121,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='📥 Выгрузка базы', callback_data='admin_export'),
InlineKeyboardButton(text='📥 Экспорт JSON', callback_data='admin_export'),
InlineKeyboardButton(text='📤 Рассылка', callback_data='admin_broadcast'),
)
builder.row(
@ -163,18 +158,15 @@ def get_phones_menu() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text='⚡ Аварийка (54-69-29)', callback_data='phones_avariyka'),
InlineKeyboardButton(text='🚨 Все экстренные', callback_data='phones_emergency'),
InlineKeyboardButton(text='🚨 Экстренные', callback_data='phones_emergency'),
InlineKeyboardButton(text='🔧 ЖКХ', callback_data='phones_utility'),
)
builder.row(
InlineKeyboardButton(text='🔧 Диспетчер ЖКХ', callback_data='phones_utility'),
InlineKeyboardButton(text='👮 Полиция / Участковый', callback_data='phones_police'),
InlineKeyboardButton(text='👮 Полиция', callback_data='phones_police'),
InlineKeyboardButton(text='👷 Мастера', callback_data='phones_masters'),
)
builder.row(
InlineKeyboardButton(text='👷 Мастера (сантехник и др.)', callback_data='phones_masters'),
)
builder.row(
InlineKeyboardButton(text='🔙 Назад в меню', callback_data='main_menu'),
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,13 +29,12 @@ def get_admin_reply_menu() -> ReplyKeyboardMarkup:
builder = ReplyKeyboardBuilder()
builder.row(
KeyboardButton(text='⚙️ Управление'),
KeyboardButton(text='📊 Статистика'),
KeyboardButton(text='/admin'),
KeyboardButton(text='/stats'),
)
builder.row(
KeyboardButton(text='🛡️ Фильтр посторонних'),
KeyboardButton(text='📥 Выгрузка базы'),
KeyboardButton(text='/spies'),
KeyboardButton(text='/export'),
)
return builder.as_markup(resize_keyboard=True)

61
main.py
View file

@ -49,52 +49,6 @@ 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("Сервис запущен", "Домовой МКД успешно запущен в автономном коммунальном режиме.")
@ -105,12 +59,7 @@ 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)
@ -120,13 +69,17 @@ 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,
smart_broadcast_router, digest_router
events_router, initiative_group_router, mutual_aid_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)
logger.info('🤖 Бот запускается...')

View file

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

View file

@ -1,276 +0,0 @@
#!/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

@ -1,206 +0,0 @@
#!/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(timezone='Europe/Ulyanovsk')
self.scheduler = AsyncIOScheduler()
def start(self):
"""Запуск планировщика"""
@ -141,24 +141,25 @@ class Scheduler:
)
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('ℹ️ Прогноз погоды в общий чат дома отключен по требованию председателя')
# Еженедельный прогноз в чат по понедельникам (в 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('✅ Добавлена задача прогноза погоды в чат по четвергам')
# Еженедельный дайджест Александра (суббота в 08:00)
self.scheduler.add_job(
@ -549,17 +550,8 @@ class Scheduler:
"""Прогноз в чат по понедельникам (на 3 дня)"""
try:
from services.weather import WeatherService
from config import ADMIN_CHAT_ID, ADMIN_USER_ID
from config import ADMIN_CHAT_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,
@ -573,17 +565,8 @@ class Scheduler:
"""Прогноз в чат по четвергам (на остаток недели и выходные)"""
try:
from services.weather import WeatherService
from config import ADMIN_CHAT_ID, ADMIN_USER_ID
from config import ADMIN_CHAT_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,

View file

@ -1,169 +0,0 @@
"""
Универсальный шлюз перенаправления в личные сообщения (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,7 +115,40 @@ 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)
@ -646,16 +679,7 @@ 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"
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)
last_update = datetime.fromtimestamp(master_pdf_path.stat().st_mtime, tz=timezone.utc) if master_pdf_path.exists() else None
# Drive sync status
sync_time_file = Path(config.MUTT_DIR) / "last_ai_sync.txt"
@ -682,6 +706,10 @@ async def emails_page(request: Request, username: str = Depends(get_current_admi
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:
dates = [d.last_email_date for d in audit_data if d.last_email_date]
@ -707,17 +735,15 @@ 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 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)
# Run master sync script in background
subprocess.Popen([python_path, master_script], 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 = "/home/matrixhasyou/domovoy_drive_sync.py" if os.path.exists("/home/matrixhasyou/domovoy_drive_sync.py") else "/app/domovoy_drive_sync.py"
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
master_pdf = str(Path(config.MUTT_DIR) / "TOTAL_ARCHIVE_2025-2026.pdf")
@ -725,10 +751,8 @@ 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 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)
# Run sync script in background
subprocess.Popen([python_path, sync_script, master_pdf], start_new_session=True)
return JSONResponse(content={"message": "Синхронизация с Google Drive запущена в фоне. Это займет около минуты."})
except Exception as e:
return JSONResponse(status_code=500, content={"message": str(e)})
@ -738,14 +762,11 @@ 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
@ -889,131 +910,6 @@ 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)

View file

@ -1,524 +0,0 @@
"""
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()

File diff suppressed because it is too large Load diff

View file

@ -1,21 +1,389 @@
/* CLEAN MINIMALIST THEME FOR DOMOVOY WEB PANEL */
/* 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');
: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;
--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);
}
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;
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;
}
/* Сайдбар */
.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,344 +6,192 @@
<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>
: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;
}
.sidebar { min-height: 100vh; position: sticky; top: 0; overflow-y: auto; }
.content { padding: 2rem; }
{% block extra_style %}{% endblock %}
</style>
</head>
<body>
<div class="layout-container">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<h4>МКД ЛЕНКОМ 37</h4>
<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>
<nav>
<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> Дашборд
<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>
<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> Жильцы (AI доступ)
<i class="bi bi-people"></i> ЖИЛЬЦЫ
</a>
<a class="nav-link {% block nav_verification %}{% endblock %}" href="/verification">
<i class="bi bi-patch-check"></i> Верификация
<i class="bi bi-shield-lock"></i> ВЕРИФИКАЦИЯ
</a>
<a class="nav-link {% block nav_phones %}{% endblock %}" href="/phones">
<i class="bi bi-telephone"></i> Телефоны
<i class="bi bi-telephone"></i> ТЕЛЕФОНЫ
</a>
<div class="nav-section-title">Коммуникация</div>
<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> Оповещения (Zero-Spam)
<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> Запланированные посты
<i class="bi bi-clock-history"></i> ПОСТЫ
</a>
<a class="nav-link {% block nav_digests %}{% endblock %}" href="/digests">
<i class="bi bi-newspaper"></i> Дайджесты
<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="nav-section-title">Сервисы & Инфра</div>
<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-calendar3"></i> Графики
<i class="bi bi-calendar-event"></i> ГРАФИКИ
</a>
<a class="nav-link {% block nav_ads %}{% endblock %}" href="/ads">
<i class="bi bi-shop"></i> Объявления
<i class="bi bi-shop"></i> ОБЪЯВЛЕНИЯ
</a>
<a class="nav-link {% block nav_polls %}{% endblock %}" href="/polls">
<i class="bi bi-check2-square"></i> Опросы
<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> Инфраструктура
<i class="bi bi-cpu"></i> ИНФРАСТРУКТУРА
</a>
<a class="nav-link {% block nav_export %}{% endblock %}" href="/export">
<i class="bi bi-download"></i> Экспорт базы
<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>
</aside>
</div>
<!-- Main Content -->
<main class="main-content">
<div class="col-md-10 content">
{% block content %}{% endblock %}
</main>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
<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 %}
</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: 1px solid var(--border-color); }
.smart-options { display: none; background: #ffffff; padding: 16px; border: 1px solid var(--border-color); margin-top: 12px; }
#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; }
.smart-options.active { display: block; }
.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); }
.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; }
</style>
{% endblock %}
@ -76,8 +76,8 @@
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">🖼️ Использовать готовое фото (Артефакт):</label>
<select class="form-select" id="broadcastArtifactPhoto">
<label class="form-label text-info">🖼️ Использовать готовое фото:</label>
<select class="form-select bg-dark text-info border-info" 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">📄 Использовать готовый документ (Артефакт):</label>
<select class="form-select" id="broadcastArtifactDocument">
<label class="form-label text-warning">📄 Использовать готовый документ:</label>
<select class="form-select bg-dark text-warning border-warning" 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,41 +1,38 @@
{% extends "base.html" %}
{% block title %}Дашборд - МКД ЛенКом 37{% endblock %}
{% block title %}LKM37 Dashboard{% endblock %}
{% block nav_dashboard %}active{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="row mb-4">
<div class="col">
<h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Панель управления МКД ЛенКом 37</h4>
<div class="small text-muted">Сводные показатели системы и текущий статус сервисов</div>
<h2 style="color: var(--matrix-green); text-shadow: 0 0 10px var(--matrix-green);">
>_ ТЕРМИНАЛ_УПРАВЛЕНИЯ_ЛКМ37
</h2>
</div>
</div>
<div class="row g-3">
<div class="row g-4">
<!-- КАРТОЧКА: ЖИЛЬЦЫ -->
<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 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>
<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>
</div>
</div>
</div>
<!-- КАРТОЧКА: ВЕРИФИКАЦИЯ -->
<div class="col-md-3">
<div class="card h-100">
<div class="card-header">ВЕРИФИКАЦИЯ</div>
<div class="card h-100 {% if pending_count > 0 %}border-danger shadow-danger{% endif %}">
<div class="card-body text-center">
<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>
<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>
{% if pending_count > 0 %}
<div class="mt-2">
<span class="badge-status badge-danger">ТРЕБУЕТ ВНИМАНИЯ</span>
</div>
<span class="badge bg-danger">ТРЕБУЕТ ВНИМАНИЯ</span>
{% endif %}
</div>
</div>
@ -43,79 +40,54 @@
<!-- КАРТОЧКА: ОБЪЯВЛЕНИЯ -->
<div class="col-md-3">
<div class="card h-100">
<div class="card-header">ОБЪЯВЛЕНИЯ</div>
<div class="card h-100 border-warning">
<div class="card-body text-center">
<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>
<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>
</div>
</div>
</div>
<!-- КАРТОЧКА: АКТИВНОСТЬ -->
<div class="col-md-3">
<div class="card h-100">
<div class="card-header">СООБЩЕНИЯ</div>
<div class="card h-100 border-cyan">
<div class="card-body text-center">
<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>
<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>
</div>
</div>
</div>
</div>
<div class="row mt-3 g-3">
<div class="row mt-4">
<div class="col-md-8">
<div class="card h-100">
<div class="card-header">СИСТЕМНЫЙ СТАТУС</div>
<div class="card">
<div class="card-header">СИСТЕМНЫЙ_СТАТУС</div>
<div class="card-body">
<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 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>
</div>
</div>
</div>
<div class="col-md-4">
<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>
<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>
</form>
</div>
</div>

View file

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

View file

@ -1,131 +1,156 @@
{% extends "base.html" %}
{% block title %}Еженедельные дайджесты - МКД ЛенКом 37{% endblock %}
{% block title %}Еженедельные дайджесты - Домовой Бот{% endblock %}
{% block nav_digests %}active{% endblock %}
{% block extra_style %}
* { margin: 0; padding: 0; box-sizing: border-box; }
{% endblock %}
{% block content %}
<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>
<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 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 class="tabs">
<button class="tab active" onclick="showTab('pending')">⏳ Ожидают утверждения</button>
<button class="tab" onclick="showTab('archive')">📚 Архив</button>
</div>
<!-- ВКЛАДКА: НА УТВЕРЖДЕНИИ -->
<div id="pending" class="tab-content-panel">
<div id="pending" class="tab-content active">
{% 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 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>
{% 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 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-panel" style="display: none;">
<div id="archive" class="tab-content">
{% 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">
<table>
<thead>
<tr>
<th style="width: 120px;">Выпуск</th>
<th>Неделя</th>
<th>Период</th>
<th style="width: 140px;">Статус</th>
<th style="width: 180px;">Отправлен</th>
<th style="width: 100px;">Действия</th>
<th>Статус</th>
<th>Отправлен</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for digest in archive_digests %}
<tr>
<td><b>#{{ digest.week_number }}/{{ digest.year }}</b></td>
<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>
<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>
<button class="btn btn-edit" 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 style="text-align: center; padding: 50px; background: white; border-radius: 8px;">
<h2>📚 Архив пуст</h2>
<p style="margin-top: 10px;">Здесь будут отображаться все дайджесты</p>
</div>
{% endif %}
</div>
</div>
<!-- Модальное окно для редактирования -->
<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 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>
</div>
</div>
<!-- Модальное окно для просмотра -->
<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 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>
<div id="viewText" style="background: #f8f9fa; padding: 15px; border-radius: 5px; white-space: pre-wrap;"></div>
</div>
</div>
@ -133,10 +158,11 @@
let currentDigestId = null;
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';
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');
}
function approveDigest(id) {
@ -145,8 +171,8 @@ function approveDigest(id) {
.then(r => r.json())
.then(data => {
alert(data.message);
const sendBtn = document.getElementById(`sendBtn${id}`);
if (sendBtn) sendBtn.style.display = 'inline-block';
// Показываем кнопку "Отправить"
document.getElementById(`sendBtn${id}`).style.display = 'inline-block';
location.reload();
});
}
@ -174,7 +200,7 @@ function editDigest(id) {
.then(r => r.json())
.then(data => {
document.getElementById('editText').value = data.digest.text;
document.getElementById('editModal').style.display = 'flex';
document.getElementById('editModal').classList.add('active');
});
}
@ -194,21 +220,21 @@ function saveEdit() {
}
function closeEditModal() {
document.getElementById('editModal').style.display = 'none';
document.getElementById('editModal').classList.remove('active');
}
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('viewTitle').textContent = `📰 Дайджест #${data.digest.week}/${data.digest.year}`;
document.getElementById('viewText').textContent = data.digest.text;
document.getElementById('viewModal').style.display = 'flex';
document.getElementById('viewModal').classList.add('active');
});
}
function closeViewModal() {
document.getElementById('viewModal').style.display = 'none';
document.getElementById('viewModal').classList.remove('active');
}
function sendDigest(id) {
@ -225,25 +251,24 @@ function sendDigest(id) {
async function generateNow() {
const btn = event.target;
btn.disabled = true;
const oldText = btn.innerHTML;
btn.innerHTML = '⏳ Генерация...';
btn.textContent = '⏳ Генерация...';
try {
const r = await fetch('/api/digest/generate', { method: 'POST' });
const result = await r.json();
if (result.success) {
alert('Успешно: ' + result.message);
alert('✅ ' + result.message);
location.reload();
} else {
alert('Ошибка: ' + (result.error || 'Неизвестная ошибка'));
alert('❌ Ошибка: ' + (result.error || 'Неизвестная'));
btn.disabled = false;
btn.innerHTML = oldText;
btn.textContent = '🔄 Сгенерировать сейчас';
}
} catch (err) {
alert('Ошибка: ' + err.message);
alert('❌ Ошибка: ' + err.message);
btn.disabled = false;
btn.innerHTML = oldText;
btn.textContent = '🔄 Сгенерировать сейчас';
}
}
</script>

View file

@ -1,72 +1,79 @@
{% extends "base.html" %}
{% block title %}Реестр писем и аудит - МКД ЛенКом 37{% endblock %}
{% block title %}Email Audit - LKM37 Admin{% endblock %}
{% block nav_emails %}active{% endblock %}
{% block content %}
<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 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>
<div>
<span class="badge-status badge-success">CRON: ACTIVE (04:00)</span>
<div class="col-md-4 text-end">
<span class="badge bg-success p-2">АВТОМАТИЗАЦИЯ_CRON: ACTIVE (04:00)</span>
</div>
</div>
<!-- МИНИ-КАРТОЧКИ АНАЛИТИКИ -->
<div class="row g-3 mb-3">
<!-- МИНИ-КАРТОЧКИ АНАЛИТИКИ (ВИДЖЕТЫ) -->
<div class="row g-3 mb-4">
<!-- КАРТОЧКА 1: ВСЕГО ПИСЕМ -->
<div class="col-md-3">
<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 bg-dark border-success h-100 shadow">
<div class="card-body">
<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 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>
</div>
</div>
</div>
<!-- КАРТОЧКА 2: ОБЪЕМ АРХИВА -->
<div class="col-md-3">
<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 bg-dark border-info h-100 shadow">
<div class="card-body">
<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">
<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">
Создан: {{ fmt_local(last_update) if last_update else '-' }}
</div>
</div>
</div>
</div>
<!-- КАРТОЧКА 3: ВСЕГО ВЛОЖЕНИЙ -->
<div class="col-md-3">
<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 bg-dark border-warning h-100 shadow">
<div class="card-body">
<div style="font-size: 22px; font-weight: bold;" id="statAttachments">{{ total_attachments }}</div>
<div class="small text-muted mt-1">Интегрировано в PDF</div>
<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>
</div>
</div>
<!-- КАРТОЧКА 4: ПОСЛЕДНЯЯ СИНХРОНИЗАЦИЯ -->
<div class="col-md-3">
<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 bg-dark border-primary h-100 shadow">
<div class="card-body">
<div style="font-size: 22px; font-weight: bold;" id="statLastSync">
<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">
{% if last_sync_time %}{{ last_sync_time.strftime('%d.%m.%Y') }}{% else %}-{% endif %}
</div>
<div class="small text-muted mt-1 text-truncate" id="statLastSyncTime">
</h2>
<div class="small text-muted text-truncate" id="statLastSyncTime">
Время: {{ fmt_local(last_sync_time) if last_sync_time else 'Неизвестно' }}
</div>
</div>
@ -74,149 +81,114 @@
</div>
</div>
<div class="row g-3">
<!-- ЛЕВАЯ КОЛОНКА -->
<div class="row g-4">
<!-- ЛЕВАЯ КОЛОНКА: УПРАВЛЕНИЕ И СТАТУС -->
<div class="col-md-4">
<!-- КАРТОЧКА УПРАВЛЕНИЯ -->
<div class="card mb-3">
<div class="card-header">УПРАВЛЕНИЕ АРХИВОМ</div>
<div class="card-body text-center py-3">
{% if has_master_pdf %}
<div class="mb-2">
<i class="bi bi-file-earmark-pdf text-danger" style="font-size: 36px;"></i>
<div class="card mb-4 shadow">
<div class="card-header bg-dark border-bottom border-secondary text-success font-monospace">
[CONTROL] УПРАВЛЕНИЕ_АРХИВОМ
</div>
<div style="font-weight: bold;">ALL_EMAILS_CONSOLIDATED.pdf</div>
<div class="small text-muted mb-3">Архив готов к работе в NotebookLM</div>
<div class="card-body text-center py-4">
{% 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>
<button id="fullSyncBtn" class="btn-unix btn-unix-primary w-100 mb-2">
<i class="bi bi-arrow-repeat"></i> Запустить синхронизацию
<button id="fullSyncBtn" class="btn btn-warning w-100 mb-2">
<i class="bi bi-lightning-charge-fill"></i> ТУРБО СИНХРОНИЗАЦИЯ
</button>
<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 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>
{% else %}
<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> Запустить первый сбор
<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> ЗАПУСТИТЬ ПЕРВЫЙ СБОР
</button>
{% endif %}
</div>
</div>
<!-- СТАТУС ПАЙПЛАЙНА -->
<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 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>
<div class="card-body">
<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 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>
</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">
<div class="card-header">ПОСЛЕДНИЙ ЗАПУСК</div>
<div class="card-body p-2">
<table class="table mb-0">
<tbody>
<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 %}
<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>
[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 %}
<tr>
<td colspan="2" class="text-muted text-center py-2">Запуски еще не производились</td>
</tr>
[INFO] Запуски еще не производились.
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ПРАВАЯ КОЛОНКА: ТАБЛИЦА ВЕДОМСТВ -->
<div class="col-md-8">
<div class="card h-100">
<div class="card-header">СТАТИСТИКА ПО ОРГАНАМ И ВЕДОМСТВАМ</div>
<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-body p-0">
<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;">
<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">
<tr>
<th>Ведомство / Домен</th>
<th class="text-center" style="width: 80px;">Вх.</th>
<th class="text-center" style="width: 80px;">Исх.</th>
<th style="width: 170px;">Последнее письмо</th>
<th class="text-center" style="width: 100px;">Вх.</th>
<th class="text-center" style="width: 100px;">Исх.</th>
<th style="width: 180px;">Последнее письмо</th>
</tr>
</thead>
<tbody>
{% for domain in audit_data %}
<tr>
<td>
<b>{{ domain.domain }}</b>
<div class="small text-muted">{{ domain.description }}</div>
<span class="text-success fw-bold">{{ domain.domain }}</span>
<br><small class="opacity-50 text-white">{{ domain.description }}</small>
</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>
<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>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center text-muted py-3">Данных пока нет. Запустите экспорт.</td>
<td colspan="4" class="text-center opacity-50 py-4">Данных пока нет. Запустите экспорт.</td>
</tr>
{% endfor %}
</tbody>
@ -228,30 +200,32 @@
</div>
<!-- СВЕЖИЕ ПОСТУПЛЕНИЯ В АРХИВ -->
<div class="row mt-3">
<div class="row mt-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">СВЕЖИЕ ПОСТУПЛЕНИЯ В АРХИВ (ПОСЛЕДНИЕ 10)</div>
<div class="card shadow">
<div class="card-header bg-dark border-bottom border-secondary text-info font-monospace">
[FEED] СВЕЖИЕ_ПОСТУПЛЕНИЯ_В_АРХИВ (ПОСЛЕДНИЕ 10)
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table mb-0">
<table class="table table-dark table-hover mb-0" style="font-size: 0.85rem;">
<thead>
<tr>
<th style="width: 170px;">Дата</th>
<th style="width: 220px;">Ведомство</th>
<th style="width: 180px;">Дата</th>
<th style="width: 200px;">Ведомство</th>
<th>Тема письма</th>
</tr>
</thead>
<tbody>
{% for email in latest_emails %}
<tr>
<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>
<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>
</tr>
{% else %}
<tr>
<td colspan="3" class="text-center text-muted py-3">Новых файлов пока нет</td>
<td colspan="3" class="text-center opacity-50 py-3">Новых файлов пока нет</td>
</tr>
{% endfor %}
</tbody>
@ -296,6 +270,7 @@ 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('-');
@ -312,9 +287,12 @@ async function checkPipelineStatus() {
time.textContent = data.updated_at || '-';
if (data.status === 'running') {
step.innerHTML = '<b>' + data.step + '</b>';
card.className = "card border-primary mb-4 shadow";
step.className = "text-info fw-bold";
step.textContent = data.step;
spinner.style.display = 'inline-block';
// Динамический прогресс
let width = '10%';
if (data.step.includes('Сбор')) width = '35%';
else if (data.step.includes('Консолидация')) width = '70%';
@ -322,22 +300,30 @@ 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') {
step.innerHTML = '<span class="badge-status badge-success">Завершен успешно</span>';
card.className = "card border-success mb-4 shadow";
step.className = "text-success fw-bold";
step.innerHTML = '✅ Завершен успешно';
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') {
step.innerHTML = '<span class="badge-status badge-danger">' + (data.step || 'Ошибка') + '</span>';
card.className = "card border-danger mb-4 shadow";
step.className = "text-danger fw-bold";
step.innerHTML = '❌ ' + (data.step || 'Ошибка выполнения');
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%';
@ -365,6 +351,7 @@ 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,26 +1,21 @@
{% extends "base.html" %}
{% block title %}Резервное копирование и экспорт - МКД ЛенКом 37{% endblock %}
{% block title %}Экспорт - Домовой Бот{% endblock %}
{% block nav_export %}active{% endblock %}
{% block content %}
<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>
<h2 class="mb-4" style="color: var(--matrix-green); text-shadow: 0 0 10px var(--matrix-green);">>_ ЭКСПОРТ_ДАННЫХ</h2>
<div class="row g-3">
<div class="row">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">
<i class="bi bi-file-earmark-arrow-up"></i> ЭКСПОРТ ДАННЫХ В JSON И GIT
<div class="card border-success">
<div class="card-header border-success">
<i class="bi bi-file-earmark-json"></i> ЭКСПОРТ В JSON & GITEA
</div>
<div class="card-body">
<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> Запустить полный экспорт сейчас
<p>Экспорт ВСЕХ пользователей и сообщений. Файл будет автоматически отправлен в Gitea.</p>
<button class="btn btn-matrix w-100" onclick="exportJSON()">
<i class="bi bi-cloud-arrow-up"></i> ЗАПУСТИТЬ ПОЛНЫЙ ЭКСПОРТ
</button>
<div id="exportResult" class="mt-3"></div>
</div>
@ -28,41 +23,33 @@
</div>
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">
<i class="bi bi-clock-history"></i> СТАТИСТИКА ВЫГРУЗОК
<div class="card border-success">
<div class="card-header border-success">
<i class="bi bi-graph-up"></i> СТАТИСТИКА ВЫГРУЗОК
</div>
<div class="card-body">
<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>
<div class="h4 text-matrix mb-3">
Всего выгрузок: <span class="badge bg-success">{{ export_stats.total }}</span>
</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 py-2 small"><div class="spinner-border spinner-border-sm me-2"></div>Сбор данных и отправка в Git...</div>';
resultDiv.innerHTML = '<div class="alert alert-info"><div class="spinner-border spinner-border-sm me-2"></div>Сбор данных и отправка в Gitea...</div>';
btn.disabled = true;
try {
@ -71,24 +58,24 @@ async function exportJSON() {
if (response.ok && data.success) {
resultDiv.innerHTML = `
<div class="alert alert-success py-2 small">
<b>Успешно!</b><br>
<div class="alert alert-success">
✅ <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">
Скачать файл локально
Статус 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> СКАЧАТЬ ФАЙЛ ЛОКАЛЬНО
</a>
</div>
<small>Страница обновится через 10 сек...</small>
</div>
`;
setTimeout(() => location.reload(), 10000);
} else {
resultDiv.innerHTML = `<div class="alert alert-danger py-2 small">Ошибка: ${data.error || 'Неизвестная ошибка'}</div>`;
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${data.error || 'Неизвестно'}</div>`;
}
} catch (error) {
resultDiv.innerHTML = `<div class="alert alert-danger py-2 small">Сетевая ошибка: ${error.message}</div>`;
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка сети: ${error.message}</div>`;
} finally {
btn.disabled = false;
}

View file

@ -1,33 +1,28 @@
{% extends "base.html" %}
{% block title %}Файлы и архивы - МКД ЛенКом 37{% endblock %}
{% block title %}Менеджер артефактов - LKM37{% endblock %}
{% block nav_files %}active{% endblock %}
{% block content %}
<div class="mb-3">
<h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Менеджер файлов и медиа-артефактов</h4>
<div class="small text-muted">Хранилище документов, системных архивов и медиа-файлов для рассылок</div>
</div>
<h2 class="mb-4" style="color: var(--matrix-green); text-shadow: 0 0 10px var(--matrix-green);">
>_ ЦЕНТРАЛЬНЫЙ_АРХИВ_ДАННЫХ (FILES_CORE)
</h2>
<!-- Секция загрузки артефактов -->
<div class="row mb-3">
<div class="row mb-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<i class="bi bi-cloud-upload"></i> ЗАГРУЗКА МЕДИА-ФАЙЛА (РЕГИСТРАЦИЯ В TELEGRAM)
</div>
<div class="card bg-black border-success shadow-sm">
<div class="card-body">
<p class="small text-muted mb-3">
Загрузите изображение или документ. Бот сохранит его в Telegram и сгенерирует постоянный <code>file_id</code> для безопасного использования в постах и рассылках.
</p>
<form id="uploadForm" class="row g-2 align-items-center">
<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">
<div class="col-md-9">
<input type="file" id="fileInput" class="form-control">
<input type="file" id="fileInput" class="form-control bg-dark border-success text-success">
</div>
<div class="col-md-3">
<button type="button" id="uploadBtn" onclick="uploadFile()" class="btn-unix btn-unix-primary w-100">
<button type="button" id="uploadBtn" onclick="uploadFile()" class="btn btn-matrix w-100">
<span id="uploadSpinner" class="spinner-border spinner-border-sm d-none" role="status"></span>
Загрузить в бота
ЗАГРУЗИТЬ
</button>
</div>
</form>
@ -37,26 +32,26 @@
</div>
<!-- Таблица артефактов -->
<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> Обновить список
<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> ОБНОВИТЬ
</button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table mb-0">
<table class="table table-dark table-hover mb-0">
<thead>
<tr>
<th>Имя файла</th>
<th style="width: 120px;">Тип</th>
<th>Telegram File ID</th>
<th class="text-end" style="width: 120px;">Действие</th>
<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>
</thead>
<tbody id="artifactsTable">
<!-- Заполняется через JS -->
<!-- Заполняется через API -->
</tbody>
</table>
</div>
@ -64,27 +59,31 @@
</div>
<!-- Таблица системных файлов -->
<div class="card">
<div class="card-header">СИСТЕМНЫЕ АРХИВЫ И ЛОГИ СЕРВЕРА</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-body p-0">
<div class="table-responsive">
<table class="table mb-0">
<table class="table table-dark table-hover mb-0">
<thead>
<tr>
<th>Имя файла</th>
<th style="width: 140px;">Категория</th>
<th style="width: 120px;">Размер</th>
<th class="text-end" style="width: 100px;">Скачать</th>
<tr class="text-muted" style="font-size: 0.8em;">
<th>ИМЯ ФАЙЛА</th>
<th>КАТЕГОРИЯ</th>
<th>РАЗМЕР</th>
<th class="text-end">ДЕЙСТВИЯ</th>
</tr>
</thead>
<tbody id="systemFilesTable">
<!-- Заполняется через JS -->
<!-- Заполняется через API -->
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block extra_script %}
<script>
document.addEventListener('DOMContentLoaded', loadFiles);
@ -110,12 +109,12 @@ async function loadFiles() {
artifacts.forEach(f => {
artifactsTable.innerHTML += `
<tr>
<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="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 class="text-end">
<button class="btn-unix" onclick="copyId('${f.file_id}')">
Копировать ID
<button class="btn btn-sm btn-outline-info" onclick="copyId('${f.file_id}')">
<i class="bi bi-copy"></i> ID
</button>
</td>
</tr>
@ -129,12 +128,12 @@ async function loadFiles() {
systemFiles.forEach(f => {
systemFilesTable.innerHTML += `
<tr>
<td><b>${f.filename}</b></td>
<td><span class="badge-status">${f.file_type}</span></td>
<td>${f.size}</td>
<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 class="text-end">
<a href="${f.path}" class="btn-unix" download>
Скачать
<a href="${f.path}" class="btn btn-sm btn-outline-secondary" download>
<i class="bi bi-download"></i>
</a>
</td>
</tr>
@ -148,7 +147,12 @@ async function loadFiles() {
function copyId(id) {
navigator.clipboard.writeText(id);
alert('File 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);
}
async function uploadFile() {
@ -172,7 +176,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,47 +1,36 @@
{% extends "base.html" %}
{% block title %}Инфраструктура - МКД ЛенКом 37{% endblock %}
{% block title %}Инфраструктура - Домовой Бот{% endblock %}
{% block nav_infra %}active{% endblock %}
{% block content %}
<div class="mb-3">
<h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Мониторинг инфраструктуры и сервисов</h4>
<div class="small text-muted">Состояние сетевых узлов, замеры интернет-канала и статус фоновых пайплайнов</div>
</div>
<h2 class="mb-4"><i class="bi bi-cpu"></i> Мониторинг Инфраструктуры</h2>
<div class="row g-3 mb-3">
<div class="row mb-4">
<div class="col-md-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>ТЕСТЫ СКОРОСТИ ИНТЕРНЕТ-КАНАЛА (ТАРИФ 150 МБИТ/С)</span>
<button id="runSpeedtestBtn" class="btn-unix">
<i class="bi bi-play-fill"></i> Запустить замер сейчас
</button>
<div class="card bg-dark border-success">
<div class="card-header border-success text-success">
<i class="bi bi-speedometer2"></i> ТЕСТ СКОРОСТИ ИНТЕРНЕТА (150 Mbps План)
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table mb-0">
<div class="card-body">
<table class="table table-dark table-hover small">
<thead>
<tr>
<th>Дата и время</th>
<th>Download (Мбит/с)</th>
<th>Upload (Мбит/с)</th>
<th>Ping (мс)</th>
<th>Сервер замера</th>
<th>Дата и Время</th>
<th>Download (Mbps)</th>
<th>Upload (Mbps)</th>
<th>Ping (ms)</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 class="text-info">{{ test.download }}</td>
<td class="text-warning">{{ test.upload }}</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>
<td class="opacity-50">{{ test.server_name }}</td>
</tr>
{% endfor %}
</tbody>
@ -50,63 +39,64 @@
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="row">
<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>
<tr>
<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>
</tbody>
</table>
</div>
</div>
</div>
<div class="col-md-6">
<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 class="card bg-dark border-info">
<div class="card-header text-info">УЗЛЫ СЕТИ</div>
<div class="card-body">
<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> Запустить сборку ГИС ЖКХ
<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>
</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-СБОРКУ
</button>
<button id="refreshGisStatusBtn" class="btn-unix">
Обновить статус
<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>
<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' });
@ -117,16 +107,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('Запустить сборку ГИС ЖКХ? Это займет некоторое время.')) return;
if (!confirm('Запустить тотальную перекачку ГИС ЖКХ и сборку PDF? Это займет значительное время.')) 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' });
@ -136,7 +126,7 @@ document.getElementById('runGisHarvestBtn').addEventListener('click', async func
alert('Ошибка: ' + e);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-cloud-download"></i> Запустить сборку ГИС ЖКХ';
btn.innerHTML = '<i class="bi bi-cloud-download"></i> ЗАПУСТИТЬ ЖАТВУ И PDF-СБОРКУ';
refreshGisStatus();
}
});
@ -150,23 +140,24 @@ async function refreshGisStatus() {
const data = await response.json();
if (data.alive) {
badge.className = 'badge-status badge-success';
badge.innerText = 'РАБОТАЕТ';
badge.className = 'badge bg-success p-2';
badge.innerText = 'СТАТУС: РАБОТАЕТ';
} else {
badge.className = 'badge-status';
badge.innerText = 'ОЖИДАНИЕ';
badge.className = 'badge bg-danger p-2';
badge.innerText = 'СТАТУС: ОСТАНОВЛЕН';
}
logs.innerText = data.log || 'Лог чист';
logs.innerText = data.log;
logs.scrollTop = logs.scrollHeight;
} catch (e) {
badge.className = 'badge-status badge-danger';
badge.innerText = 'ОШИБКА';
badge.className = 'badge bg-warning p-2';
badge.innerText = 'ОШИБКА API';
}
}
document.getElementById('refreshGisStatusBtn').addEventListener('click', refreshGisStatus);
window.addEventListener('load', refreshGisStatus);
// Авто-обновление раз в 30 секунд
setInterval(refreshGisStatus, 30000);
</script>
{% endblock %}

View file

@ -1,217 +0,0 @@
{% 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,91 +1,89 @@
{% extends "base.html" %}
{% block title %}Телефоны и службы - МКД ЛенКом 37{% endblock %}
{% block title %}Телефоны и службы - Домовой Бот{% endblock %}
{% block nav_phones %}active{% endblock %}
{% block extra_style %}
.service-image-preview { max-width: 120px; max-height: 80px; object-fit: contain; border: 1px solid var(--border-light); }
.service-image-preview { max-width: 200px; max-height: 200px; object-fit: contain; }
{% endblock %}
{% block content %}
<div class="mb-3">
<h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Телефоны экстренных и обслуживающих служб</h4>
<div class="small text-muted">Справочник полезных контактов, аварийных служб и мастеров для жителей дома</div>
</div>
<h2 class="mb-4" style="color: var(--matrix-green); text-shadow: 0 0 10px var(--matrix-green);">>_ ТЕЛЕФОНЫ_И_СЛУЖБЫ</h2>
<!-- Добавить службу -->
<div class="card mb-3">
<div class="card-header">
<i class="bi bi-plus-circle"></i> ДОБАВЛЕНИЕ НОВОЙ СЛУЖБЫ
<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>
<div class="card-body">
<form id="addServiceForm" class="row g-2 align-items-center">
<form id="addServiceForm" class="row g-3">
<div class="col-md-3">
<input type="text" class="form-control" id="serviceName" placeholder="Название службы" required>
<input type="text" class="form-control bg-black border-success text-success" id="serviceName" placeholder="Название" required>
</div>
<div class="col-md-2">
<input type="text" class="form-control" id="servicePhone" placeholder="Телефон" required>
<input type="text" class="form-control bg-black border-success text-success" id="servicePhone" placeholder="Телефон" required>
</div>
<div class="col-md-2">
<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 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>
</div>
<div class="col-md-3">
<input type="text" class="form-control" id="serviceDesc" placeholder="Описание / часы работы">
<input type="text" class="form-control bg-black border-success text-success" id="serviceDesc" placeholder="Описание">
</div>
<div class="col-md-2">
<button type="submit" class="btn-unix btn-unix-primary w-100">Добавить</button>
<button type="submit" class="btn btn-matrix w-100">Добавить</button>
</div>
</form>
</div>
</div>
<!-- Загрузить картинку -->
<div class="card mb-3">
<div class="card-header">
<i class="bi bi-image"></i> ЗАГРУЗКА ИЗОБРАЖЕНИЯ ДЛЯ КАТЕГОРИИ
<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>
<div class="card-body">
<form id="uploadImageForm" class="row g-2 align-items-center" enctype="multipart/form-data">
<form id="uploadImageForm" class="row g-3" enctype="multipart/form-data">
<div class="col-md-3">
<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 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>
</div>
<div class="col-md-5">
<input type="file" class="form-control" id="imageFile" accept="image/*" required>
</div>
<div class="col-md-4">
<button type="submit" class="btn-unix w-100">Загрузить картинку</button>
<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>
<div class="col-md-2">
<button type="submit" class="btn btn-matrix w-100">Загрузить</button>
</div>
</form>
<div id="uploadResult" class="mt-2"></div>
<div id="uploadResult" class="mt-3"></div>
</div>
</div>
<!-- Список служб -->
<div class="card">
<div class="card-header">СПИСОК КОНТАКТОВ В БАЗЕ ДАННЫХ</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table mb-0">
<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 style="width: 150px;">Категория</th>
<th>Категория</th>
<th>Название</th>
<th style="width: 160px;">Телефон</th>
<th>Телефон</th>
<th>Описание</th>
<th style="width: 130px;">Изображение</th>
<th class="text-end" style="width: 90px;">Удалить</th>
<th>Картинка</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
@ -93,46 +91,48 @@
<tr>
<td>
{% if service.category == 'emergency' %}
<span class="badge-status badge-danger">Экстренные</span>
<span class="badge bg-danger">🚨 Экстренные</span>
{% elif service.category == 'utility' %}
<span class="badge-status badge-warning">ЖКХ</span>
<span class="badge bg-info">🔧 ЖКХ</span>
{% elif service.category == 'masters' %}
<span class="badge-status badge-success">Мастера</span>
<span class="badge bg-success">👷 Мастера</span>
{% elif service.category == 'police' %}
<span class="badge-status badge-info">Полиция</span>
<span class="badge bg-warning text-dark">👮 Полиция</span>
{% else %}
<span class="badge-status">Другое</span>
<span class="badge bg-secondary">Другое</span>
{% endif %}
</td>
<td><b>{{ service.name }}</b></td>
<td><a href="tel:{{ service.phone }}"><b>{{ service.phone }}</b></a></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="Превью">
class="service-image-preview" alt="Картинка">
<br><small class="text-muted">✅</small>
{% else %}
<span class="text-muted small">Нет</span>
<span class="text-muted">❌ Нет</span>
{% endif %}
</td>
<td class="text-end">
<button class="btn-unix" onclick="deleteService({{ service.service_id }})">
<td>
<button class="btn btn-sm btn-danger" 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>
{% if not services %}
<p class="text-center text-muted">Служб нет</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block extra_script %}
<script>
// Добавление службы
document.getElementById('addServiceForm').addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
@ -146,10 +146,11 @@
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();
@ -157,7 +158,7 @@
formData.append('file', document.getElementById('imageFile').files[0]);
const resultDiv = document.getElementById('uploadResult');
resultDiv.innerHTML = '<div class="alert alert-info py-1 small">Загрузка...</div>';
resultDiv.innerHTML = '<div class="alert alert-info">Загрузка...</div>';
try {
const r = await fetch('/api/service/upload_image', {
@ -167,21 +168,20 @@
const result = await r.json();
if (result.success) {
resultDiv.innerHTML = `<div class="alert alert-success py-1 small">${result.message}</div>`;
setTimeout(() => location.reload(), 1500);
resultDiv.innerHTML = `<div class="alert alert-success">✅ ${result.message}<br>Файл: ${result.file_path}</div>`;
} else {
resultDiv.innerHTML = `<div class="alert alert-danger py-1 small">Ошибка: ${result.error}</div>`;
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${result.error}</div>`;
}
} catch (err) {
resultDiv.innerHTML = `<div class="alert alert-danger py-1 small">Ошибка: ${err.message}</div>`;
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${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">🖼️ Готовое фото (Артефакт):</label>
<select class="form-select" id="artifactPhotoId">
<label class="form-label text-info">🖼️ Готовое фото (Артефакт):</label>
<select class="form-select bg-dark text-info border-info" 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">📄 Готовый документ (Артефакт):</label>
<select class="form-select" id="artifactDocumentId">
<label class="form-label text-warning">📄 Готовый документ (Артефакт):</label>
<select class="form-select bg-dark text-warning border-warning" 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,50 +1,39 @@
{% extends "base.html" %}
{% block title %}ГАС Правосудие - МКД ЛенКом 37{% endblock %}
{% block title %}Жатва ГАС Правосудие - LKM37{% endblock %}
{% block nav_sudrf %}active{% endblock %}
{% block content %}
<div class="mb-3">
<h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Сбор материалов судебных дел (ГАС «Правосудие»)</h4>
<div class="small text-muted">Автоматизированная выгрузка карточек дел, определений и решений для базы NotebookLM</div>
</div>
<h2 class="mb-4"><i class="bi bi-bank text-danger"></i> Жатва ГАС Правосудие</h2>
<div class="row g-3">
<div class="row">
<!-- Инструкция и Статус -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">
ИНСТРУКЦИЯ И УПРАВЛЕНИЕ
<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>
<div class="card-body d-flex flex-column justify-content-between">
<div>
<p class="small text-muted">
Модуль собирает электронные документы судебных процессов по дому (ej.sudrf.ru), преобразует вложения в PDF и загружает в единый аналитический контур.
</p>
<p class="text-white">Этот модуль предназначен для автоматизированного сбора поданных обращений, заявлений и ответов судов с портала ГАС «Правосудие» (ej.sudrf.ru) для последующей склейки в PDF и загрузки в Google Drive (NotebookLM).</p>
<div class="mb-3">
<div style="font-weight: bold;">Шаг 1: Подготовка сессии</div>
<div class="small text-muted">Нажмите кнопку «Запустить сессию» ниже. Будет открыт браузер на виртуальном рабочем столе.</div>
<h5 class="text-danger mt-4"><i class="bi bi-1-circle"></i> Шаг 1: Подготовка сессии</h5>
<p class="small text-muted">Нажмите кнопку «Запустить Жатву ГАС Правосудие» ниже. Система откроет скрытый браузер Chromium на виртуальном рабочем столе.</p>
<h5 class="text-danger mt-3"><i class="bi bi-2-circle"></i> Шаг 2: Авторизация ЕСИА</h5>
<p class="small text-muted">Откройте VNC-браузер по ссылке справа и войдите в свой личный кабинет ГАС «Правосудие» через Госуслуги (потребуется пройти 2FA/SMS).</p>
<h5 class="text-danger mt-3"><i class="bi bi-3-circle"></i> Шаг 3: Автоматический парсинг</h5>
<p class="small text-muted">Как только вход будет обнаружен, скрипт перехватит сессию, соберет список дел, сделает скриншоты, скачает вложения и загрузит сводные PDF на Google Drive.</p>
</div>
<div class="mb-3">
<div style="font-weight: bold;">Шаг 2: Вход через Госуслуги (ЕСИА)</div>
<div class="small text-muted">Перейдите по ссылке VNC и подтвердите вход по СМС в личном кабинете.</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>
<div>
<div style="font-weight: bold;">Шаг 3: Автоматический экспорт</div>
<div class="small text-muted">После авторизации скрипт перехватит сессию, скачает дела и сформирует PDF.</div>
</div>
</div>
<div class="mt-3 pt-3 border-top" style="border-color: var(--border-light) !important;">
<div class="d-flex justify-content-between align-items-center mb-2">
<span>Статус процесса:</span>
<div id="sudrfStatusBadge" class="badge-status">ПРОВЕРКА</div>
</div>
<button id="runSudrfParserBtn" class="btn-unix btn-unix-primary w-100 py-2">
<i class="bi bi-play-circle"></i> Запустить сборку ГАС Правосудие
<button id="runSudrfParserBtn" class="btn btn-danger w-100 py-2">
<i class="bi bi-play-circle-fill"></i> ЗАПУСТИТЬ ЖАТВУ ГАС ПРАВОСУДИЕ
</button>
</div>
</div>
@ -52,36 +41,41 @@
</div>
<!-- VNC и Логи -->
<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 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>
<div class="card-body d-flex flex-column">
<a href="http://192.168.10.116:6080/vnc.html" target="_blank" class="btn-unix w-100 text-center py-2 mb-2">
<i class="bi bi-display"></i> Открыть VNC экран (noVNC)
<a href="http://192.168.10.116:6080/vnc.html" target="_blank" class="btn btn-outline-info w-100 py-3 mb-2">
<i class="bi bi-window-fullscreen"></i> ОТКРЫТЬ VNC ЭКРАН (noVNC)
</a>
<div class="small text-muted mb-2">
<i>Экран браузера отображается в noVNC сразу после запуска сессии.</i>
<div class="alert alert-warning py-2 mb-3 text-warning border-warning" style="font-size: 0.75rem; background: transparent;">
<i class="bi bi-exclamation-triangle"></i> <strong>Внимание:</strong> Если жатва не запущена, VNC-экран будет пустым (черным). Браузер Chromium автоматически откроется на экране сразу после нажатия кнопки «Запустить жатву».
</div>
<div class="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 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>
</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' });
@ -91,7 +85,7 @@ document.getElementById('runSudrfParserBtn').addEventListener('click', async fun
alert('Ошибка при запуске: ' + e);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-play-circle"></i> Запустить сборку ГАС Правосудие';
btn.innerHTML = '<i class="bi bi-play-circle-fill"></i> ЗАПУСТИТЬ ЖАТВУ ГАС ПРАВОСУДИЕ';
refreshSudrfStatus();
}
});
@ -105,23 +99,25 @@ async function refreshSudrfStatus() {
const data = await response.json();
if (data.alive) {
badge.className = 'badge-status badge-success';
badge.innerText = 'РАБОТАЕТ';
badge.className = 'badge bg-success p-2';
badge.innerText = 'СТАТУС: РАБОТАЕТ';
} else {
badge.className = 'badge-status';
badge.innerText = 'ОЖИДАНИЕ';
badge.className = 'badge bg-danger p-2';
badge.innerText = 'СТАТУС: ОСТАНОВЛЕН';
}
logs.innerText = data.log || 'Лог чист';
logs.innerText = data.log || "Лог пуст или еще не создан.";
logs.scrollTop = logs.scrollHeight;
} catch (e) {
badge.className = 'badge-status badge-warning';
badge.className = 'badge bg-warning p-2';
badge.innerText = 'ОШИБКА API';
}
}
document.getElementById('refreshSudrfStatusBtn').addEventListener('click', refreshSudrfStatus);
window.addEventListener('load', refreshSudrfStatus);
setInterval(refreshSudrfStatus, 30000);
// Автообновление логов каждые 15 секунд
setInterval(refreshSudrfStatus, 15000);
</script>
{% endblock %}

View file

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