Compare commits

...

6 commits

26 changed files with 1187 additions and 160 deletions

25
.dockerignore Normal file
View file

@ -0,0 +1,25 @@
.git
.gitignore
venv/
venv-google/
logs/
exports/
data/
database/*.db
database/*.db.bak
backups/
nohup.out
scraper_gis/browser_context/
scraper_gis/exports/
**/__pycache__
*.pyc
*.pyo
*.pyd
.export_progress
.phase2_progress
export.log
auditor_run.log
pipeline.log
web.log
web_debug.log
systemd.log

View file

@ -1,12 +1,18 @@
# Использование легкого образа Python
FROM python:3.12-slim
# Установка рабочей директории
WORKDIR /app
# Установка системных зависимостей (если понадобятся для каких-то либ)
RUN apt-get update && apt-get install -y \ ffmpeg
sqlite3 \
# Установка системных зависимостей для ffmpeg, weasyprint (pango/cairo) и LibreOffice
RUN apt-get update && apt-get install -y \
ffmpeg \
libreoffice \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libgdk-pixbuf-2.0-0 \
libffi-dev \
shared-mime-info \
fonts-dejavu \
&& rm -rf /var/lib/apt/lists/*
# Копирование requirements и установка зависимостей

View file

@ -812,3 +812,20 @@ class Artifact(Base):
def __repr__(self):
return f"<Artifact {self.filename} ({self.file_type})>"
class Speedtest(Base):
"""Результаты замеров скорости интернета"""
__tablename__ = 'speedtests'
id = Column(Integer, primary_key=True, autoincrement=True)
timestamp = Column(DateTime, default=datetime.utcnow)
download = Column(Float, nullable=True)
upload = Column(Float, nullable=True)
ping = Column(Float, nullable=True)
server_name = Column(String(255), nullable=True)
result_url = Column(String(255), nullable=True)
def __repr__(self):
return f"<Speedtest #{self.id} DL={self.download} UP={self.upload} Server={self.server_name}>"

View file

@ -3,50 +3,39 @@ version: '3.8'
services:
domovoy-bot:
build: .
container_name: lkm37-core
restart: unless-stopped
env_file:
- .env
container_name: domovoy-bot
network_mode: "host"
restart: always
environment:
- PYTHONPATH=/app:/app/services
volumes:
# Монтируем базу данных и важные данные для сохранения
- ./database:/app/database
- ./logs:/app/logs
- ./data:/app/data
- ./exports:/app/exports
- ./backups:/app/backups
- ./web:/app/web
- ./services/email_auditor/exported_emails:/app/services/email_auditor/exported_emails
network_mode: host
environment:
- TZ=Europe/Moscow
- PYTHONUNBUFFERED=1
- ./data:/app/data
- ../libs/swarmlib:/app/swarmlib:ro
- /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
email_auditor:
build:
context: ./services/email_auditor
dockerfile: Dockerfile
container_name: email-auditor
restart: unless-stopped
env_file:
- .env
domovoy-web:
build: .
container_name: domovoy-web
command: python -m uvicorn web.app:app --host 0.0.0.0 --port 8000
network_mode: "host"
restart: always
environment:
- PYTHONPATH=/app:/app/services
volumes:
- ./database:/app/database
- ./services/email_auditor/exported_emails:/app/exported_emails
network_mode: host
environment:
- TZ=Europe/Moscow
- PYTHONUNBUFFERED=1
- ./logs:/app/logs
- ./exports:/app/exports
- ./data:/app/data
- ../libs/swarmlib:/app/swarmlib:ro
- /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
default:
name: lkm37-net

View file

@ -5,7 +5,7 @@
import logging
from datetime import datetime, timedelta
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InputMediaPhoto
from aiogram.types import Message, CallbackQuery
from aiogram.filters import Command, StateFilter
from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.context import FSMContext
@ -15,7 +15,7 @@ from database.db import AsyncSessionLocal
from database.models import Ad, User
from utils.ui_styles import fmt_msg
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.types import InlineKeyboardButton
import config
logger = logging.getLogger(__name__)
@ -37,17 +37,19 @@ def get_ads_main_menu():
builder.row(InlineKeyboardButton(text="🛒 Все товары", callback_data="ads_view_all"))
builder.row(InlineKeyboardButton(text=" Создать лот", callback_data="ads_create"))
builder.row(InlineKeyboardButton(text="👤 Мои лоты", callback_data="ads_my_items"))
builder.row(InlineKeyboardButton(text="🏠 В главное меню", callback_data="main_menu"))
return builder.as_markup()
def get_categories_kb():
builder = InlineKeyboardBuilder()
cats = [
("Продам", "sell"), ("Куплю", "buy"),
("Отдам", "give"), ("Услуги", "service")
("Продам 💰", "sell"), ("Куплю 🛒", "buy"),
("Отдам 🎁", "give"), ("Услуги 🔧", "service")
]
for name, code in cats:
builder.add(InlineKeyboardButton(text=name, callback_data=f"adcat_{code}"))
builder.adjust(2)
builder.row(InlineKeyboardButton(text="🔙 В маркет", callback_data="ads_back"))
return builder.as_markup()
# =============================================================================
@ -64,6 +66,19 @@ async def cmd_market(message: Message):
)
await message.answer(text, reply_markup=get_ads_main_menu())
@router.callback_query(F.data == "market")
@router.callback_query(F.data == "ads_back")
async def cb_market(callback: CallbackQuery, state: FSMContext):
"""Главный вход в маркет из инлайн-кнопок"""
await state.clear()
text = (
f"{fmt_msg('МАРКЕТ ЛКМ37', 'system')}\n\n"
"Здесь вы можете найти товары от соседей или предложить свои.\n"
"<i>Все лоты проходят модерацию.</i>"
)
from handlers.users import safe_edit_or_reply
await safe_edit_or_reply(callback, text, reply_markup=get_ads_main_menu())
@router.callback_query(F.data == "ads_create")
async def start_ad_creation(callback: CallbackQuery, state: FSMContext):
await callback.message.answer("Выберите категорию для вашего лота:", reply_markup=get_categories_kb())
@ -111,7 +126,7 @@ async def skip_photo(message: Message, state: FSMContext):
await save_ad(message, data, None)
await state.clear()
async def save_ad(message, data, photo_id):
async def save_ad(message: Message, data, photo_id):
async with AsyncSessionLocal() as session:
new_ad = Ad(
user_id=message.from_user.id,
@ -120,25 +135,30 @@ async def save_ad(message, data, photo_id):
description=data['description'],
price=data['price'],
photo_file_id=photo_id,
expires_at=datetime.utcnow() + timedelta(days=30)
expires_at=datetime.utcnow() + timedelta(days=30),
is_moderated=True
)
session.add(new_ad)
await session.commit()
await message.answer(
f"{fmt_msg('ЛОТ СОЗДАН', 'system')}\n\n"
"Ваше объявление отправлено на модерацию. После одобрения оно появится в общем списке."
"Ваше объявление успешно создано и опубликовано в маркете LKM37!"
)
@router.callback_query(F.data == "ads_view_all")
async def view_ads_trigger(callback: CallbackQuery):
await view_ads(callback, page=0)
async def view_ads(callback: CallbackQuery, page: int = 0):
async with AsyncSessionLocal() as session:
stmt = select(Ad).where(Ad.is_active == True, Ad.is_moderated == True).offset(page).limit(1)
stmt = select(Ad).where(Ad.is_active == True, Ad.is_moderated == True).order_by(Ad.created_at.desc()).offset(page).limit(1)
res = await session.execute(stmt)
ad = res.scalar_one_or_none()
if not ad:
return await callback.message.answer("К сожалению, пока лотов нет.")
await callback.answer("К сожалению, пока лотов нет.", show_alert=True)
return
price_text = f"{ad.price}" if ad.price > 0 else "ДАРOM"
text = (
@ -155,11 +175,44 @@ async def view_ads(callback: CallbackQuery, page: int = 0):
else:
kb.row(InlineKeyboardButton(text="🤝 ЗАБРОНИРОВАНО", callback_data="ad_reserved_info"))
kb.row(
InlineKeyboardButton(text="⬅️", callback_data=f"ad_page_{page-1}"),
InlineKeyboardButton(text="➡️", callback_data=f"ad_page_{page+1}")
)
...
# Пагинация
nav_buttons = []
if page > 0:
nav_buttons.append(InlineKeyboardButton(text="⬅️ Предыдущий", callback_data=f"ad_page_{page-1}"))
async with AsyncSessionLocal() as session:
next_stmt = select(Ad).where(Ad.is_active == True, Ad.is_moderated == True).offset(page + 1).limit(1)
next_res = await session.execute(next_stmt)
has_next = next_res.scalar_one_or_none() is not None
if has_next:
nav_buttons.append(InlineKeyboardButton(text="Следующий ➡️", callback_data=f"ad_page_{page+1}"))
if nav_buttons:
kb.row(*nav_buttons)
kb.row(InlineKeyboardButton(text="🔙 В маркет", callback_data="ads_back"))
kb.row(InlineKeyboardButton(text="🏠 В главное меню", callback_data="main_menu"))
try:
await callback.message.delete()
except Exception:
pass
if ad.photo_file_id:
await callback.message.answer_photo(ad.photo_file_id, caption=text, reply_markup=kb.as_markup(), parse_mode='HTML')
else:
await callback.message.answer(text, reply_markup=kb.as_markup(), parse_mode='HTML')
await callback.answer()
@router.callback_query(F.data.startswith("ad_page_"))
async def cb_ad_page(callback: CallbackQuery):
page = int(callback.data.split("_")[2])
if page < 0:
await callback.answer("Это первое объявление.")
return
await view_ads(callback, page=page)
@router.callback_query(F.data.startswith("ad_reserve_"))
async def reserve_ad(callback: CallbackQuery):
ad_id = int(callback.data.split("_")[2])
@ -178,17 +231,63 @@ async def reserve_ad(callback: CallbackQuery):
f"Покупатель: ID{callback.from_user.id} (@{callback.from_user.username or ''})",
parse_mode='HTML'
)
except: pass
except Exception:
pass
await callback.answer("✅ Товар забронирован. Свяжитесь с продавцом!", show_alert=True)
await callback.message.edit_reply_markup(reply_markup=None) # Обновить бы на "Забронировано"
# Обновим клавиатуру
kb = InlineKeyboardBuilder()
kb.row(InlineKeyboardButton(text="📧 Написать продавцу", url=f"tg://user?id={ad.user_id}"))
kb.row(InlineKeyboardButton(text="🤝 ЗАБРОНИРОВАНО", callback_data="ad_reserved_info"))
kb.row(InlineKeyboardButton(text="🔙 В маркет", callback_data="ads_back"))
try:
await callback.message.edit_reply_markup(reply_markup=kb.as_markup())
except Exception:
pass
else:
await callback.answer("❌ Товар уже забронирован или не найден.")
kb.row(InlineKeyboardButton(text="🏠 В меню", callback_data="ads_back"))
await callback.answer("❌ Товар уже забронирован или не найден.", show_alert=True)
if ad.photo_file_id:
await callback.message.answer_photo(ad.photo_file_id, caption=text, reply_markup=kb.as_markup())
else:
await callback.message.answer(text, reply_markup=kb.as_markup())
@router.callback_query(F.data == "ad_reserved_info")
async def cb_reserved_info(callback: CallbackQuery):
await callback.answer("Этот товар уже забронирован кем-то из соседей.", show_alert=True)
@router.callback_query(F.data == "ads_my_items")
async def view_my_ads(callback: CallbackQuery):
user_id = callback.from_user.id
async with AsyncSessionLocal() as session:
stmt = select(Ad).where(Ad.user_id == user_id, Ad.is_active == True).order_by(Ad.created_at.desc())
res = await session.execute(stmt)
ads = res.scalars().all()
if not ads:
await callback.answer("У вас пока нет активных объявлений.", show_alert=True)
return
text = "<b>👤 ВАШИ ОБЪЯВЛЕНИЯ:</b>\n\n"
kb = InlineKeyboardBuilder()
for ad in ads:
status = "🟢 Активно" if ad.is_moderated else "На модерации"
price_text = f"{ad.price}" if ad.price > 0 else "Даром"
text += f"🔹 <b>{ad.title}</b> ({price_text})\n └ Статус: {status}\n\n"
kb.row(
InlineKeyboardButton(text=f"🗑️ Удалить '{ad.title[:15]}'", callback_data=f"ad_delete_{ad.ad_id}")
)
kb.row(InlineKeyboardButton(text="🔙 В маркет", callback_data="ads_back"))
from handlers.users import safe_edit_or_reply
await safe_edit_or_reply(callback, text, reply_markup=kb.as_markup())
await callback.answer()
@router.callback_query(F.data.startswith("ad_delete_"))
async def delete_my_ad(callback: CallbackQuery):
ad_id = int(callback.data.split("_")[2])
async with AsyncSessionLocal() as session:
ad = await session.get(Ad, ad_id)
if ad and ad.user_id == callback.from_user.id:
ad.is_active = False
await session.commit()
await callback.answer("✅ Объявление удалено.", show_alert=True)
else:
await callback.answer("❌ Объявление не найдено или вы не автор.", show_alert=True)
await view_my_ads(callback)

View file

@ -8,6 +8,7 @@ from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.filters import Command, CommandStart, StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy import select, update
from database.db import AsyncSessionLocal
from database.models import User, InitiativeGroup
@ -286,10 +287,16 @@ async def process_location(message: Message):
await message.answer("❌ Ошибка формата. Введите два числа через пробел.")
# Класс состояний обратной связи
class HelpFeedbackState(StatesGroup):
waiting_for_message = State()
waiting_for_reply = State()
@router.message(Command('help'))
@router.callback_query(F.data == 'help')
async def cb_help(event):
text = ("<b>❓ СПРАВКА</b>\n\n"
async def cb_help(event, state: FSMContext):
await state.clear()
text = ("<b>❓ СПРАВКА И ПОДДЕРЖКА</b>\n\n"
"• Используйте кнопки в главном меню для навигации.\n"
"• Если бот не понимает команду, просто напишите вопрос текстом — наш ИИ постарается помочь.\n\n"
"<b>Список команд:</b>\n"
@ -297,8 +304,130 @@ async def cb_help(event):
"/phones — Телефоны\n"
"/about — О доме\n"
"/profile — Твой профиль\n"
"/rules — Правила чата")
"/rules — Правила чата\n\n"
"<i>Вы можете отправить сообщение Инициативной группе / Администрации дома, нажав кнопку ниже.</i>")
kb = InlineKeyboardBuilder()
kb.row(InlineKeyboardButton(text="✍️ Написать администрации", callback_data="help_feedback"))
kb.row(InlineKeyboardButton(text="🏠 В меню", callback_data="main_menu"))
await safe_edit_or_reply(event, text, reply_markup=kb.as_markup(), parse_mode='HTML')
@router.callback_query(F.data == "help_feedback")
async def start_help_feedback(callback: CallbackQuery, state: FSMContext):
await callback.answer()
await state.set_state(HelpFeedbackState.waiting_for_message)
text = ("✍️ <b>ОБРАТНАЯ СВЯЗЬ</b>\n\n"
"Пожалуйста, введите текст вашего сообщения для администрации дома LKM37.\n"
"Вы также можете прикрепить одно фото или документ к вашему сообщению.\n\n"
"<i>Для отмены нажмите кнопку ниже или введите /cancel.</i>")
kb = InlineKeyboardBuilder()
kb.row(InlineKeyboardButton(text="❌ Отмена", callback_data="main_menu"))
await callback.message.answer(text, reply_markup=kb.as_markup(), parse_mode='HTML')
@router.message(HelpFeedbackState.waiting_for_message)
async def process_help_feedback(message: Message, state: FSMContext):
if message.text and message.text.startswith('/'):
if message.text.lower() in ['/cancel', '/start']:
await state.clear()
await message.answer("❌ Отправка сообщения отменена.", reply_markup=get_main_menu())
return
user_id = message.from_user.id
async with AsyncSessionLocal() as session:
user = await session.get(User, user_id)
sender_name = message.from_user.full_name or f"user{user_id}"
username_str = f" (@{message.from_user.username})" if message.from_user.username else ""
apt_str = user.apartment or "Не указана" if user else "Не указана"
admin_text = (f"📬 <b>НОВОЕ ОБРАЩЕНИЕ ОТ ЖИЛЬЦА LKM37!</b>\n\n"
f"👤 Отправитель: <b>{sender_name}</b>{username_str}\n"
f"🏢 Квартира: <b>{apt_str}</b>\n"
f"🆔 ID: <code>{user_id}</code>\n\n"
f"📝 Сообщение:\n{message.text or message.caption or ''}")
kb = InlineKeyboardBuilder()
kb.row(InlineKeyboardButton(text="✍️ Ответить жильцу", callback_data=f"help_reply_{user_id}"))
try:
if message.photo:
await message.bot.send_photo(
chat_id=config.ADMIN_USER_ID,
photo=message.photo[-1].file_id,
caption=admin_text,
reply_markup=kb.as_markup(),
parse_mode='HTML'
)
elif message.document:
await message.bot.send_document(
chat_id=config.ADMIN_USER_ID,
document=message.document.file_id,
caption=admin_text,
reply_markup=kb.as_markup(),
parse_mode='HTML'
)
else:
await message.bot.send_message(
chat_id=config.ADMIN_USER_ID,
text=admin_text,
reply_markup=kb.as_markup(),
parse_mode='HTML'
)
await message.answer("✅ <b>Ваше сообщение успешно отправлено!</b>\n\nАдминистрация ответит вам в ближайшее время.", reply_markup=get_main_menu(), parse_mode='HTML')
except Exception as e:
logger.error(f"Ошибка при отправке фидбека админу: {e}")
await message.answer("❌ Произошла ошибка при отправке сообщения. Пожалуйста, попробуйте позже.", reply_markup=get_main_menu())
await state.clear()
@router.callback_query(F.data.startswith("help_reply_"))
async def start_help_reply(callback: CallbackQuery, state: FSMContext):
if callback.from_user.id != config.ADMIN_USER_ID:
await callback.answer("🔒 Доступ только для суперадмина.", show_alert=True)
return
target_user_id = int(callback.data.split("_")[2])
await state.update_data(target_user_id=target_user_id)
await state.set_state(HelpFeedbackState.waiting_for_reply)
await callback.answer()
await callback.message.answer(f"✍️ Введите ответ для пользователя <code>{target_user_id}</code>:\n\n<i>Для отмены пришлите /cancel.</i>", parse_mode='HTML')
@router.message(HelpFeedbackState.waiting_for_reply)
async def process_help_reply(message: Message, state: FSMContext):
if message.from_user.id != config.ADMIN_USER_ID:
return
if message.text and message.text.lower() in ['/cancel', 'отмена']:
await state.clear()
await message.answer("❌ Ответ отменен.", reply_markup=get_main_menu())
return
data = await state.get_data()
target_user_id = data.get("target_user_id")
if not target_user_id:
await message.answer("❌ Ошибка: пользователь для ответа не найден в контексте.")
await state.clear()
return
reply_text = (f"📩 <b>ОТВЕТ АДМИНИСТРАЦИИ LKM37:</b>\n\n"
f"{message.text}")
try:
await message.bot.send_message(
chat_id=target_user_id,
text=reply_text,
parse_mode='HTML'
)
await message.answer(f"✅ Ответ успешно отправлен пользователю <code>{target_user_id}</code>.", parse_mode='HTML')
except Exception as e:
logger.error(f"Ошибка при отправке ответа пользователю {target_user_id}: {e}")
await message.answer(f"Не удалось отправить сообщение пользователю. Ошибка: {e}")
await state.clear()

View file

@ -26,7 +26,7 @@ def get_main_menu(verified: bool = False, is_ig: bool = False, is_admin: bool =
)
builder.row(
InlineKeyboardButton(text='📋 Опросы', callback_data='polls'),
InlineKeyboardButton(text=' События', callback_data='events')
InlineKeyboardButton(text='🎉 События', callback_data='events')
)
builder.row(
InlineKeyboardButton(text='🏪 Маркет', callback_data='market'),

0
nohup.out Normal file
View file

View file

@ -14,3 +14,5 @@ python-multipart>=0.0.6
pydub
psycopg2-binary>=2.9.9
asyncpg>=0.29.0
google-generativeai

View file

@ -0,0 +1,382 @@
[
{
"text": "Справка по системе",
"href": "https://cdn.dom.gosuslugi.ru/webhelp"
},
{
"text": "Регламенты и инструкции",
"href": "https://dom.gosuslugi.ru/#!/regulations"
},
{
"text": "Все пользователи",
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=1"
},
{
"text": "Граждане",
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=2"
},
{
"text": "Кредитные организации и банковские агенты",
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=9"
},
{
"text": "Органы государственного жилищного надзора",
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=8"
},
{
"text": "Органы государственной власти",
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=3"
},
{
"text": "Органы местного самоуправления",
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=4"
},
{
"text": "Ответственные за реализацию приоритетного проекта \"Формирование комфортной городской среды\"",
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=14"
},
{
"text": "Региональный оператор капитального ремонта",
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=10"
},
{
"text": "Региональный оператор по обращению с твердыми коммунальными отходами",
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=12"
},
{
"text": "Ресурсоснабжающие организации",
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=7"
},
{
"text": "РКЦ, ЕИРЦ и платежные агенты",
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=13"
},
{
"text": "Управляющие организации, ТСЖ, ЖК, ЖСК",
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=6"
},
{
"text": "Федеральные органы власти",
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=5"
},
{
"text": "Фонд содействия реформированию ЖКХ",
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=11"
},
{
"text": "Часто задаваемые вопросы",
"href": "https://dom.gosuslugi.ru/#!/faq"
},
{
"text": "Карта сайта",
"href": "https://dom.gosuslugi.ru/#!/site-map"
},
{
"text": "Версия для слабовидящих",
"href": "https://dom.gosuslugi.ru/special/"
},
{
"text": "Перейти на новый сайт",
"href": "https://portal.dom.gosuslugi.ru/"
},
{
"text": "ГИС ЖКХ",
"href": "https://dom.gosuslugi.ru/#!/"
},
{
"text": "Обращение в службу поддержки",
"href": "https://dom.gosuslugi.ru/#!/support-cabinet"
},
{
"text": "Зарегистрироваться",
"href": "https://dom.gosuslugi.ru/#!/registration-info"
},
{
"text": "Получатели услуг",
"href": "https://dom.gosuslugi.ru/#!/audience/consumers"
},
{
"text": "Поставщики услуг",
"href": "https://dom.gosuslugi.ru/#!/audience/suppliers"
},
{
"text": "Органы власти",
"href": "https://dom.gosuslugi.ru/#!/audience/authorities"
},
{
"text": "Учебники",
"href": "https://dom.gosuslugi.ru/#!/regulations?rubricNames=%D0%A3%D1%87%D0%B5%D0%B1%D0%BD%D0%B8%D0%BA%D0%B8"
},
{
"text": "Видео ГИС ЖКХ",
"href": "https://dom.gosuslugi.ru/#!/videos/search?categories=1"
},
{
"text": "Реестр программ в сфере ЖКХ",
"href": "https://dom.gosuslugi.ru/#!/programs/list"
},
{
"text": "Реестр программ капитального ремонта",
"href": "https://dom.gosuslugi.ru/#!/capital-repair/programs"
},
{
"text": "Федеральный проект «Формирование комфортной городской среды»",
"href": "https://sreda.dom.gosuslugi.ru/"
},
{
"text": "Реестр объектов жилищного фонда",
"href": "https://dom.gosuslugi.ru/#!/houses"
},
{
"text": "Реестры поставщиков информации",
"href": "https://dom.gosuslugi.ru/"
},
{
"text": "Органы государственной власти и местного самоуправления",
"href": "https://dom.gosuslugi.ru/#!/organizations?orgType=7&orgType=12&orgType=4&orgType=6&orgType=17&orgType=5&orgType=8&orgType=10&orgType=16&orgType=28&orgType=29&doSearch=false&restore=false"
},
{
"text": "Региональные операторы капитального ремонта",
"href": "https://dom.gosuslugi.ru/#!/organizations?orgType=14&doSearch=false&restore=false"
},
{
"text": "Организации, осуществляющие управление многоквартирными\n домами",
"href": "https://dom.gosuslugi.ru/#!/organizations?orgType=1&orgType=19&orgType=22&orgType=21&orgType=20&doSearch=false&restore=false"
},
{
"text": "Ресурсоснабжающие организации",
"href": "https://dom.gosuslugi.ru/#!/organizations?orgType=2&doSearch=false&restore=false"
},
{
"text": "Региональные операторы по обращению с твердыми коммунальными\n отходами",
"href": "https://dom.gosuslugi.ru/#!/organizations?orgType=18&doSearch=false&restore=false"
},
{
"text": "Платежные агенты",
"href": "https://dom.gosuslugi.ru/#!/organizations?orgType=24&orgType=25&doSearch=false&restore=false"
},
{
"text": "Банки, иные кредитные организации или органы, осуществляющие\n открытиеи ведение лицевых счетов в соответствии с\n бюджетным законодательством РФ",
"href": "https://dom.gosuslugi.ru/#!/organizations?orgType=26&doSearch=false&restore=false"
},
{
"text": "Иные поставщики информации",
"href": "https://dom.gosuslugi.ru/#!/organizations?orgType=23&orgType=3&orgType=15&orgType=11&orgType=13&orgType=30&doSearch=false&restore=false"
},
{
"text": "Реестр контрольных (надзорных) мероприятий",
"href": "https://dom.gosuslugi.ru/#!/rp"
},
{
"text": "Реестр информации о готовности к отопительному сезону (периоду)",
"href": "https://dom.gosuslugi.ru/#!/rpg/readiness-passports-search-public"
},
{
"text": "Информация о местах накопления",
"href": "https://dom.gosuslugi.ru/#!/public-tko/tko-search"
},
{
"text": "Информация о конкурсах по отбору региональных операторов",
"href": "https://dom.gosuslugi.ru/#!/tkoregop"
},
{
"text": "Сводный федеральный реестр лицензий",
"href": "https://dom.gosuslugi.ru/#!/rls-public/licenses"
},
{
"text": "Реестр дисквалифицированных лиц",
"href": "https://dom.gosuslugi.ru/#!/licenses/person/search"
},
{
"text": "Реестр административных правонарушений",
"href": "https://dom.gosuslugi.ru/#!/public/rap-rp"
},
{
"text": "Перечень органов власти",
"href": "https://dom.gosuslugi.ru/#!/authorities"
},
{
"text": "Реестр решений об изъятии земельных участков",
"href": "https://dom.gosuslugi.ru/#!/withdrawal/search"
},
{
"text": "Перечень населенных пунктов без доступа к сети Интернет",
"href": "https://dom.gosuslugi.ru/#!/places/without/internet"
},
{
"text": "Реестр управляющих организаций и решений об определении управляющих\n организаций",
"href": "https://dom.gosuslugi.ru/#!/tr-mo-pub/registry/organizations"
},
{
"text": "Реестр информации о конкурсах по отбору кредитных организаций",
"href": "https://dom.gosuslugi.ru/#!/capital-repair/tenders"
},
{
"text": "Оснащенность индивидуальными приборами учета",
"href": "https://dom.gosuslugi.ru/#!/individual-meters"
},
{
"text": "Оснащенность общедомовыми приборами учета",
"href": "https://dom.gosuslugi.ru/#!/common-meters"
},
{
"text": "Техническое состояние многоквартирных домов",
"href": "https://dom.gosuslugi.ru/#!/houses-condition"
},
{
"text": "Способы управления многоквартирными домами",
"href": "https://dom.gosuslugi.ru/#!/wdgt-mkd-control-method"
},
{
"text": "Статистика по управлению МКД временными управляющими организациями",
"href": "https://dom.gosuslugi.ru/#!/transitory-mo"
},
{
"text": "Работа с обращениями граждан",
"href": "https://dom.gosuslugi.ru/#!/pafo/appeals"
},
{
"text": "Проверки, проведенные контролирующими органами",
"href": "https://dom.gosuslugi.ru/#!/examinations"
},
{
"text": "Привлечение ресурсоснабжающих организаций к административной ответственности",
"href": "https://dom.gosuslugi.ru/#!/resource-liability"
},
{
"text": "Привлечение управляющих организаций к административной ответственности",
"href": "https://dom.gosuslugi.ru/#!/managing-liability"
},
{
"text": "Способ формирования фонда капитального ремонта",
"href": "https://dom.gosuslugi.ru/#!/crp-fund-forming"
},
{
"text": "Реализация краткосрочных планов капитального ремонта",
"href": "https://dom.gosuslugi.ru/#!/crp-plans-execution"
},
{
"text": "Размещение информации в субъектах РФ",
"href": "https://dom.gosuslugi.ru/#!/implementation-map"
},
{
"text": "Тарифы на оплату коммунальных ресурсов",
"href": "https://dom.gosuslugi.ru/#!/public-tariff/communal-resources/search"
},
{
"text": "Размер платы за содержание жилого помещения",
"href": "https://dom.gosuslugi.ru/#!/public-tariff/extra/CAPITAL_REPAIR/search"
},
{
"text": "Минимальный размер взноса на капитальный ремонт",
"href": "https://dom.gosuslugi.ru/#!/public-tariff/extra/CAPITAL_REPAIR_MIN_CONTRIBUTION/search"
},
{
"text": "Социальная норма потребления электрической энергии\n (мощности)",
"href": "https://dom.gosuslugi.ru/#!/public-tariff/extra/ELECTRICITY_CONSUMPTION_SOCIAL_NORM/search"
},
{
"text": "Размер платы за пользование жилым помещением",
"href": "https://dom.gosuslugi.ru/#!/public-tariff/extra/RENT_PAYMENT/search"
},
{
"text": "Нормативные правовые акты в сфере регулирования тарифов",
"href": "https://dom.gosuslugi.ru/#!/rates"
},
{
"text": "Льготные тарифы на жилищно-коммунальные услуги",
"href": "https://dom.gosuslugi.ru/#!/public-tariff/extra/reduced-search"
},
{
"text": "Индексы изменения платы граждан за ЖКУ",
"href": "https://dom.gosuslugi.ru/#!/public-indices"
},
{
"text": "Компенсации расходов отдельным категориям граждан",
"href": "https://dom.gosuslugi.ru/#!/msp/public/categories/"
},
{
"text": "Субсидии на оплату ЖКУ",
"href": "https://dom.gosuslugi.ru/#!/msp/public/subsidy-provisions/"
},
{
"text": "Нормативные правовые акты в сфере мер социальной поддержки",
"href": "https://dom.gosuslugi.ru/#!/subsidies"
},
{
"text": "Нормативы потребления коммунальных услуг",
"href": "https://dom.gosuslugi.ru/#!/public-rate-consumption/list"
},
{
"text": "Информация о стандартах",
"href": "https://dom.gosuslugi.ru/#!/public-standart"
},
{
"text": "Мероприятия общественного жилищного контроля",
"href": "https://dom.gosuslugi.ru/#!/ojk/search"
},
{
"text": "Информация о плановых перерывах в предоставлении КУ",
"href": "https://dom.gosuslugi.ru/#!/planned-outage/list"
},
{
"text": "Открытые данные",
"href": "https://dom.gosuslugi.ru/#!/open-data"
},
{
"text": "Новости и события",
"href": "https://dom.gosuslugi.ru/#!/news/search"
},
{
"text": "Обращаем внимание пользователей ГИС ЖКХ и \"Госуслуги Дом\"!",
"href": "https://dom.gosuslugi.ru/#!/newsView?newsId=ff437377-2bd9-44c7-831d-53470bf154ff&audience=MAIN_PAGE&fromPlace=main"
},
{
"text": "Уведомление пользователей системы",
"href": "https://dom.gosuslugi.ru/#!/newsView?newsId=f8cba8bd-2025-4246-b433-fe0b8b38ecd1&audience=MAIN_PAGE&fromPlace=main"
},
{
"text": "Перечень организаций жилищно-коммунального хозяйства, участвующих в эксперименте по взысканию задолженности.",
"href": "https://dom.gosuslugi.ru/#!/newsView?newsId=e4a3ee99-0e98-4022-9757-41d440b0e1bb&audience=MAIN_PAGE&fromPlace=main"
},
{
"text": "Пояснение к критериям отбора участников в рамках эксперимента по взысканию задолженности",
"href": "https://dom.gosuslugi.ru/#!/newsView?newsId=8c9898f4-fe19-4f13-a139-214e1973a9a7&audience=MAIN_PAGE&fromPlace=main"
},
{
"text": "Все сервисы",
"href": "https://dom.gosuslugi.ru/#!/eServices"
},
{
"text": "ЖИЛИЩНЫЙ КОДЕКС РФ",
"href": "https://dom.gosuslugi.ru/filestore/publicDownloadServlet?context=contentmanagement&uid=c4f8bc28-c4bb-4253-925c-b85389dc22ca"
},
{
"text": "Федеральный закон от 21.07.2014 № 209-ФЗ \"О государственной информационной системе жилищно-коммунального хозяйства\"",
"href": "https://dom.gosuslugi.ru/filestore/publicDownloadServlet?context=contentmanagement&uid=6e33668f-69e1-43c1-972e-b8553c789b4d"
},
{
"text": "Законодательство по ГИС ЖКХ",
"href": "https://dom.gosuslugi.ru/#!/legislation"
},
{
"text": "Законодательство РФ",
"href": "http://dom.gosuslugi.ru/#!/legislationRF"
},
{
"text": "Все мероприятия",
"href": "https://dom.gosuslugi.ru/#!/events-calendar/search"
},
{
"text": "Обу­ча­ющие ма­тери­алы",
"href": "https://my.dom.gosuslugi.ru/#!/regulations"
},
{
"text": "Все ссылки",
"href": "https://dom.gosuslugi.ru/#!/useful-links"
},
{
"text": "Карта внедрения ГИС ЖКХ в субъектах Российской Федерации",
"href": "https://dom.gosuslugi.ru/#!/implementation-map"
}
]

View file

@ -16,4 +16,4 @@ services:
- TZ=Europe/Ulyanovsk
- TELEGRAM_BOT_TOKEN=8725618164:AAH1tGalq-pw1l0t4P5c0sdLkCVJdh7IE7M
- TELEGRAM_CHAT_ID=197957361
command: ["./start.sh", "ultimate"]
command: ["./start.sh", "interactive"]

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

View file

@ -0,0 +1,87 @@
from playwright.sync_api import sync_playwright
import time
import os
EXPORT_DIR = "/app/exports"
if not os.path.exists(EXPORT_DIR):
os.makedirs(EXPORT_DIR)
print("🚀 Запускаем глубокий скрейпинг Личного кабинета ГИС ЖКХ...")
def handle_download(download):
print(f"📥 Обнаружено скачивание файла: {download.suggested_filename}")
filepath = os.path.join(EXPORT_DIR, download.suggested_filename)
download.save_as(filepath)
print(f"✅ Файл успешно сохранен: {filepath}")
with sync_playwright() as p:
browser = p.chromium.launch_persistent_context(
user_data_dir="/app/browser_context",
headless=True,
accept_downloads=True,
args=[
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--window-size=1920,1080'
]
)
page = browser.pages[0]
# Привязываем обработчик скачиваний
page.on("download", handle_download)
# 1. Переход на главную
print("🌐 Переходим на главную страницу ЛК (dom.gosuslugi.ru)...")
page.goto("https://dom.gosuslugi.ru/#!/main")
page.wait_for_timeout(7000) # Ждем прогрузки дашборда
# Скриншот главной
page.screenshot(path=os.path.join(EXPORT_DIR, "01_Main_Dashboard.png"), full_page=True)
print("📸 Скриншот: Главный дашборд сохранен.")
# 2. Ищем и кликаем по основным разделам
sections = [
{"name": "Обращения", "text": "Обращения"},
{"name": "ОплатаКУ", "text": "Оплата"},
{"name": "Договоры", "text": "Договор"},
{"name": "Голосования", "text": "Голосования"}
]
for i, sec in enumerate(sections, start=2):
print(f"🔍 Ищем раздел: {sec['name']}...")
try:
# Ищем ссылку или кнопку, содержащую нужный текст (игнорируем регистр)
# В SPA-приложениях вроде ГИС ЖКХ элементы часто подгружаются динамически
element = page.locator(f"text=/{sec['text']}/i").first
if element.is_visible():
print(f"✅ Найден раздел {sec['name']}, кликаем...")
element.click()
page.wait_for_timeout(5000) # Ждем прогрузки страницы
# Скриншот раздела
scr_name = f"{i:02d}_{sec['name']}.png"
page.screenshot(path=os.path.join(EXPORT_DIR, scr_name), full_page=True)
print(f"📸 Скриншот раздела сохранен: {scr_name}")
# Поиск кнопок "Скачать" или "Экспорт"
print("⏬ Проверяем наличие вложений для скачивания...")
download_btns = page.locator("text=/скачать|экспорт|download|pdf|docx|zip/i").all()
for btn in download_btns:
if btn.is_visible():
print("Кликаем по кнопке скачивания...")
btn.click()
page.wait_for_timeout(3000) # Даем время на инициализацию загрузки
# Возврат на главную для следующего цикла
page.goto("https://dom.gosuslugi.ru/#!/main")
page.wait_for_timeout(4000)
else:
print(f"⚠️ Раздел {sec['name']} не найден или скрыт.")
except Exception as e:
print(f"❌ Ошибка при переходе в {sec['name']}: {e}")
browser.close()
print("🏁 Скрейпинг завершен! Все файлы в папке /app/exports")

View file

@ -0,0 +1,88 @@
from playwright.sync_api import sync_playwright
import time
import os
import json
EXPORT_DIR = "/app/exports"
API_LOG_FILE = os.path.join(EXPORT_DIR, "api_responses.log")
if not os.path.exists(EXPORT_DIR):
os.makedirs(EXPORT_DIR)
# Очищаем лог перед новым запуском
with open(API_LOG_FILE, "w", encoding="utf-8") as f:
f.write("")
print("🚀 Запускаем Умного Робота-Перехватчика ГИС ЖКХ...")
def handle_response(response):
# Ловим только JSON ответы, чтобы найти внутренние API Госуслуг
if "application/json" in response.headers.get("content-type", ""):
try:
url = response.url
# Игнорируем метрики и мусорные запросы
if "metric" in url or "log" in url or "stat" in url:
return
body = response.json()
# Сохраняем интересные ответы в лог для анализа
# Нас интересуют ответы, где есть списки элементов (массивы) или файлы
if isinstance(body, dict) or isinstance(body, list):
# Просто чтобы не писать гигантские дампы, пишем URL и начало ответа
with open(API_LOG_FILE, "a", encoding="utf-8") as f:
f.write(f"\\n--- URL: {url} ---\\n")
json.dump(body, f, ensure_ascii=False, indent=2)
f.write("\\n")
except Exception as e:
pass # Игнорируем ошибки парсинга (иногда JSON бывает битым или пустым)
with sync_playwright() as p:
browser = p.chromium.launch_persistent_context(
user_data_dir="/app/browser_context",
headless=True,
args=[
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--window-size=1920,1080'
]
)
page = browser.pages[0]
# Включаем прослушку всех ответов от сервера!
page.on("response", handle_response)
print("🌐 Подключаемся к Личному кабинету...")
# Задаем список прямых ссылок (роутов) для обхода
routes = [
{"name": "Main", "url": "https://dom.gosuslugi.ru/#!/main"},
{"name": "Appeals", "url": "https://dom.gosuslugi.ru/#!/appeals"}, # Обращения
{"name": "Bills", "url": "https://dom.gosuslugi.ru/#!/bills"}, # Счета
{"name": "Voting", "url": "https://dom.gosuslugi.ru/#!/voting"} # Голосования
]
for route in routes:
print(f"\\n➡ Переход в раздел: {route['name']}")
try:
page.goto(route['url'])
# Ждем 10 секунд, чтобы API успело отдать все JSON с документами
print("⏳ Ожидание загрузки данных через API (10 сек)...")
page.wait_for_timeout(10000)
scr_name = f"02_{route['name']}_view.png"
page.screenshot(path=os.path.join(EXPORT_DIR, scr_name), full_page=True)
print(f"📸 Скриншот раздела сохранен: {scr_name}")
except Exception as e:
print(f"❌ Ошибка в разделе {route['name']}: {e}")
browser.close()
print("\\n🏁 Сбор данных завершен!")
print(f"📂 Лог API запросов сохранен в: {API_LOG_FILE}")
print("🕵️‍♂️ Теперь мы можем проанализировать этот лог и вытащить оттуда прямые ссылки на скачивание PDF-файлов!")

View file

@ -0,0 +1,28 @@
from playwright.sync_api import sync_playwright
import time
import os
print("🚀 Запускаем тестовый браузер для авторизации...")
with sync_playwright() as p:
browser = p.chromium.launch_persistent_context(
user_data_dir="/app/browser_context",
headless=False,
args=[
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--window-size=1280,1024'
]
)
page = browser.pages[0]
print("🌐 Переходим на dom.gosuslugi.ru...")
page.goto("https://dom.gosuslugi.ru/")
print("⏳ Окно открыто! У тебя есть 5 минут, чтобы авторизоваться через noVNC.")
time.sleep(300)
browser.close()
print("✅ Сессия сохранена, браузер закрыт.")

View file

@ -0,0 +1,54 @@
from playwright.sync_api import sync_playwright
import time
import json
print("🚀 Запускаем Headless-агента для создания карты личного кабинета...")
with sync_playwright() as p:
# Запускаем уже в фоновом (headless) режиме, так как куки сохранены!
browser = p.chromium.launch_persistent_context(
user_data_dir="/app/browser_context",
headless=True,
args=[
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--window-size=1280,1024'
]
)
page = browser.pages[0]
print("🌐 Переходим на главную страницу ЛК (dom.gosuslugi.ru)...")
page.goto("https://dom.gosuslugi.ru/#!/main")
# Ждем 5 секунд, чтобы все скрипты и данные кабинета прогрузились
page.wait_for_timeout(5000)
print("📸 Делаем полноразмерный скриншот кабинета...")
page.screenshot(path="/app/cabinet_home_map.png", full_page=True)
print("🗺️ Составляем карту ссылок (парсим меню)...")
# Выполняем JS прямо на странице, чтобы вытащить структуру
links = page.evaluate('''() => {
let menu_links = [];
// Собираем все осмысленные ссылки
document.querySelectorAll('a').forEach(a => {
let text = a.innerText.trim();
if (a.href && text.length > 0 && a.href.includes('dom.gosuslugi.ru')) {
menu_links.push({text: text, href: a.href});
}
});
return menu_links;
}''')
# Сохраняем карту ссылок в JSON
with open("/app/cabinet_links_map.json", "w", encoding="utf-8") as f:
json.dump(links, f, ensure_ascii=False, indent=2)
print(f"✅ Готово! Найдено уникальных ссылок: {len(links)}")
print("💾 Скриншот: cabinet_home_map.png")
print("💾 Карта: cabinet_links_map.json")
browser.close()

View file

@ -41,7 +41,7 @@ from database.models import (
User, Message as DBMessage, Poll, Ad, PaymentReminder, Schedule,
VerificationRequest, Service, Announcement, Event, ScheduledPost,
Broadcast, BroadcastRead, Digest, InitiativeGroup, EmailAudit, EmailExportLog,
ProfileHistory, Achievement, Thank, SpyLog, Artifact
ProfileHistory, Achievement, Thank, SpyLog, Artifact, Speedtest
)
# ===== ЧАСОВОЙ ПОЯС УЛЬЯНОВСК (UTC+4) =====
@ -165,13 +165,14 @@ async def dashboard(request: Request, username: str = Depends(get_current_admin)
upcoming_events = (await session.execute(select(func.count(Event.id)).where(Event.is_active == True))).scalar()
pending_count = (await session.execute(select(func.count(VerificationRequest.id)).where(VerificationRequest.status == 'pending'))).scalar()
return templates.TemplateResponse("dashboard.html", {
"request": request, "username": username,
"stats": {
"users": users_count, "verified": verified_count,
"messages": messages_count, "ads": active_ads,
"polls": active_polls, "events": upcoming_events
},
return templates.TemplateResponse(request=request, name="dashboard.html", context={
"username": username,
"users_count": users_count,
"verified_count": verified_count,
"messages_count": messages_count,
"active_ads": active_ads,
"active_polls": active_polls,
"upcoming_events": upcoming_events,
"pending_count": pending_count
})
@ -208,8 +209,8 @@ async def users_page(request: Request, search: str = "", filter: str = "", usern
total_count = (await session.execute(select(func.count(User.user_id)))).scalar()
verified_count = (await session.execute(select(func.count(User.user_id)).where(User.verified == True))).scalar()
return templates.TemplateResponse("users.html", {
"request": request, "username": username, "users": users, "search": search, "current_filter": filter,
return templates.TemplateResponse(request=request, name="users.html", context={
"username": username, "users": users, "search": search, "current_filter": filter,
"stats": {"total": total_count, "verified": verified_count, "unverified": total_count - verified_count, "ig": len(ig_user_ids)},
"ig_user_ids": ig_user_ids
})
@ -238,7 +239,7 @@ async def verification_page(request: Request, username: str = Depends(get_curren
async with AsyncSessionLocal() as session:
pending = (await session.execute(select(VerificationRequest).where(VerificationRequest.status == 'pending').order_by(VerificationRequest.created_at.desc()))).scalars().all()
unverified = (await session.execute(select(User).where(User.verified == False).order_by(User.join_date.desc()).limit(50))).scalars().all()
return templates.TemplateResponse("verification.html", {"request": request, "username": username, "pending_requests": pending, "unverified_users": unverified})
return templates.TemplateResponse(request=request, name="verification.html", context={"username": username, "pending_requests": pending, "unverified_users": unverified})
@app.post("/api/verification/{req_id}/approve")
async def api_approve_verification(req_id: int, username: str = Depends(get_current_admin)):
@ -342,31 +343,31 @@ async def api_toggle_ai(user_id: int, username: str = Depends(get_current_admin)
async def phones_page(request: Request, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
services = (await session.execute(select(Service).order_by(Service.category, Service.name))).scalars().all()
return templates.TemplateResponse("phones.html", {"request": request, "username": username, "services": services})
return templates.TemplateResponse(request=request, name="phones.html", context={"username": username, "services": services})
@app.get("/ads", response_class=HTMLResponse)
async def ads_page(request: Request, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
ads = (await session.execute(select(Ad).order_by(Ad.created_at.desc()).limit(100))).scalars().all()
return templates.TemplateResponse("ads.html", {"request": request, "username": username, "ads": ads})
return templates.TemplateResponse(request=request, name="ads.html", context={"username": username, "ads": ads})
@app.get("/polls", response_class=HTMLResponse)
async def polls_page(request: Request, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
polls = (await session.execute(select(Poll).order_by(Poll.created_at.desc()).limit(50))).scalars().all()
return templates.TemplateResponse("polls.html", {"request": request, "username": username, "polls": polls})
return templates.TemplateResponse(request=request, name="polls.html", context={"username": username, "polls": polls})
@app.get("/events", response_class=HTMLResponse)
async def events_page(request: Request, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
events = (await session.execute(select(Event).order_by(Event.event_date.desc()).limit(50))).scalars().all()
return templates.TemplateResponse("events.html", {"request": request, "username": username, "events": events})
return templates.TemplateResponse(request=request, name="events.html", context={"username": username, "events": events})
@app.get("/schedules", response_class=HTMLResponse)
async def schedules_page(request: Request, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
schedules = (await session.execute(select(Schedule).order_by(Schedule.start_time.desc()))).scalars().all()
return templates.TemplateResponse("schedules.html", {"request": request, "username": username, "schedules": schedules})
return templates.TemplateResponse(request=request, name="schedules.html", context={"username": username, "schedules": schedules})
# ============================================================================
# РАССЫЛКИ (SMART BROADCAST)
@ -390,7 +391,7 @@ async def broadcast_page(request: Request, username: str = Depends(get_current_a
'broadcast_type': b.broadcast_type or 'regular',
'is_reminder_sent': b.is_reminder_sent
})
return templates.TemplateResponse("broadcast.html", {"request": request, "username": username, "broadcasts": broadcasts_data, "artifacts": artifacts})
return templates.TemplateResponse(request=request, name="broadcast.html", context={"username": username, "broadcasts": broadcasts_data, "artifacts": artifacts})
@app.post("/api/broadcast/create")
async def api_create_broadcast(
@ -495,7 +496,7 @@ async def api_broadcast_non_readers(broadcast_id: int, username: str = Depends(g
async def scheduled_posts_page(request: Request, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
artifacts = (await session.execute(select(Artifact).order_by(Artifact.filename))).scalars().all()
return templates.TemplateResponse("scheduled_posts.html", {"request": request, "username": username, "artifacts": artifacts})
return templates.TemplateResponse(request=request, name="scheduled_posts.html", context={"username": username, "artifacts": artifacts})
@app.get("/api/scheduled_posts/list")
async def api_list_scheduled_posts(username: str = Depends(get_current_admin)):
@ -565,7 +566,7 @@ async def digests_page(request: Request, username: str = Depends(get_current_adm
pending = (await session.execute(stmt_pending)).scalars().all()
stmt_archive = select(Digest).where(Digest.status.in_(['approved', 'sent', 'rejected'])).order_by(Digest.created_at.desc()).limit(20)
archive = (await session.execute(stmt_archive)).scalars().all()
return templates.TemplateResponse("digests.html", {"request": request, "username": username, "pending_digests": pending, "archive_digests": archive})
return templates.TemplateResponse(request=request, name="digests.html", context={"username": username, "pending_digests": pending, "archive_digests": archive})
@app.post("/api/digest/generate")
async def api_generate_digest(username: str = Depends(get_current_admin)):
@ -589,7 +590,7 @@ async def api_generate_digest(username: str = Depends(get_current_admin)):
@app.get("/files", response_class=HTMLResponse)
async def files_page(request: Request, username: str = Depends(get_current_admin)):
return templates.TemplateResponse("files.html", {"request": request, "username": username})
return templates.TemplateResponse(request=request, name="files.html", context={"username": username})
@app.get("/api/files/list")
async def api_list_files(username: str = Depends(get_current_admin)):
@ -637,7 +638,7 @@ async def export_page(request: Request, username: str = Depends(get_current_admi
async with AsyncSessionLocal() as session:
exporter = ChatExporter(session)
stats = await exporter.get_stats()
return templates.TemplateResponse("export.html", {"request": request, "username": username, "export_stats": stats, "fmt_local": fmt_local})
return templates.TemplateResponse(request=request, name="export.html", context={"username": username, "export_stats": stats, "fmt_local": fmt_local})
@app.get("/api/export/json")
async def api_export_json(username: str = Depends(get_current_admin)):
@ -690,8 +691,8 @@ async def emails_page(request: Request, username: str = Depends(get_current_admi
except:
last_sync_time = datetime.fromtimestamp(sync_time_file.stat().st_mtime, tz=timezone.utc)
return templates.TemplateResponse("emails.html", {
"request": request, "username": username, "audit_data": audit_data,
return templates.TemplateResponse(request=request, name="emails.html", context={
"username": username, "audit_data": audit_data,
"last_log": last_log, "latest_emails": latest_emails,
"has_master_pdf": master_pdf_path.exists(), "last_update": last_update,
"last_sync_time": last_sync_time, "fmt_local": fmt_local
@ -728,26 +729,40 @@ async def api_emails_sync_ai(username: str = Depends(get_current_admin)):
except Exception as e:
return JSONResponse(status_code=500, content={"message": str(e)})
@app.get("/api/emails/pipeline_status")
async def api_emails_pipeline_status(username: str = Depends(get_current_admin)):
import time
status_file = Path("/home/matrixhasyou/mutt/pipeline_status.json")
if not status_file.exists():
return JSONResponse(content={"status": "idle", "step": "Неактивен", "updated_at": "-", "new_emails": 0, "total_emails": 0, "pdf_size_mb": 0.0})
try:
with open(status_file) as f:
data = json.load(f)
mtime = status_file.stat().st_mtime
if data.get("status") == "running" and (time.time() - mtime > 600):
data["status"] = "stuck"
data["step"] = "Процесс завис или был остановлен"
return JSONResponse(content=data)
except Exception as e:
return JSONResponse(content={"status": "error", "message": str(e)})
@app.get("/infra", response_class=HTMLResponse)
async def get_infra_page(request: Request, username: str = Depends(get_current_admin)):
import sqlite3
conn = sqlite3.connect("/home/matrixhasyou/domovoy_bot/database/domovoy.db")
cursor = conn.cursor()
cursor.execute("SELECT timestamp, download, upload, ping, server_name FROM speedtests ORDER BY timestamp DESC LIMIT 10")
rows = cursor.fetchall()
conn.close()
async with AsyncSessionLocal() as session:
stmt = select(Speedtest).order_by(Speedtest.timestamp.desc()).limit(10)
rows = (await session.execute(stmt)).scalars().all()
speedtests = []
for r in rows:
speedtests.append({
"timestamp": r[0],
"download": r[1],
"upload": r[2],
"ping": r[3],
"server_name": r[4]
"timestamp": fmt_local(r.timestamp),
"download": r.download,
"upload": r.upload,
"ping": r.ping,
"server_name": r.server_name
})
return templates.TemplateResponse("infra.html", {"request": request, "username": username, "speedtests": speedtests})
return templates.TemplateResponse(request=request, name="infra.html", context={"username": username, "speedtests": speedtests})
@app.post("/api/infra/run_speedtest")
async def api_run_speedtest(username: str = Depends(get_current_admin)):

View file

@ -67,7 +67,7 @@
<div class="card-header">СИСТЕМНЫЙ_СТАТУС</div>
<div class="card-body">
<div class="p-3 bg-black border border-success font-monospace small">
[OK] Database: sqlite3/domovoy.db<br>
[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>

View file

@ -7,19 +7,21 @@
{% block extra_style %}
<style>
.digest-settings-container { max-width: 900px; }
.digest-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; border-radius: 10px; margin-bottom: 30px; }
.digest-header h1 { font-size: 2em; margin-bottom: 10px; }
.digest-back-link { color: white; text-decoration: none; opacity: 0.9; }
.digest-back-link:hover { opacity: 1; }
.digest-card { background: white; padding: 25px; border-radius: 10px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.digest-card h3 { margin-bottom: 15px; color: #667eea; }
.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; }
.digest-form-group input[type="time"], .digest-form-group select { width: 100%; padding: 10px; border: 2px solid #e2e8f0; border-radius: 5px; font-size: 1em; }
.digest-checkbox-group { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.digest-checkbox-group input[type="checkbox"] { width: 20px; height: 20px; }
.digest-btn { padding: 12px 24px; border: none; border-radius: 5px; cursor: pointer; font-size: 1em; background: #667eea; color: white; }
.digest-btn:hover { background: #5568d3; }
.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 %}

View file

@ -11,44 +11,47 @@
{% block content %}
<style>
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: white; padding: 30px; border-radius: 10px; margin-bottom: 30px; }
.header h1 { font-size: 2em; margin-bottom: 10px; }
.back-link { color: white; text-decoration: none; opacity: 0.9; }
.back-link:hover { opacity: 1; }
.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: white; border: none; border-radius: 8px 8px 0 0; cursor: pointer; font-size: 1em; }
.tab.active { background: #f5576c; color: white; }
.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: white; padding: 25px; border-radius: 10px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); border-left: 5px solid #f5576c; }
.pending-card h3 { color: #f5576c; margin-bottom: 10px; }
.pending-card .meta { color: #666; font-size: 0.9em; margin-bottom: 15px; }
.pending-card .text { background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 15px 0; white-space: pre-wrap; max-height: 300px; overflow-y: auto; }
.btn { padding: 10px 20px; border: none; border-radius: 5px; cursor: pointer; font-size: 0.95em; margin-right: 10px; transition: all 0.2s; }
.btn-approve { background: #48bb78; color: white; }
.btn-approve:hover { background: #38a169; }
.btn-reject { background: #f56565; color: white; }
.btn-reject:hover { background: #e53e3e; }
.btn-edit { background: #ed8936; color: white; }
.btn-edit:hover { background: #dd6b20; }
.btn-send { background: #4299e1; color: white; }
.btn-send:hover { background: #3182ce; }
table { width: 100%; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
th { background: #f5576c; color: white; padding: 15px; text-align: left; }
td { padding: 15px; border-bottom: 1px solid #eee; }
tr:hover { background: #f8f9fa; }
.badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 0.85em; font-weight: 500; }
.badge-pending { background: #feebc8; color: #7c2d12; }
.badge-approved { background: #c6f6d5; color: #22543d; }
.badge-rejected { background: #fed7d7; color: #742a2a; }
.badge-sent { background: #bee3f8; color: #2c5282; }
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; }
.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: white; padding: 30px; border-radius: 10px; max-width: 900px; width: 90%; max-height: 80vh; overflow-y: auto; }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.close-btn { background: none; border: none; font-size: 1.5em; cursor: pointer; }
textarea { width: 100%; min-height: 300px; padding: 15px; border: 2px solid #e2e8f0; border-radius: 5px; font-family: inherit; font-size: 0.95em; }
textarea:focus { outline: none; border-color: #f5576c; }
.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>

View file

@ -124,9 +124,32 @@
</div>
<div class="row mt-4">
<div class="col">
<!-- КАРТОЧКА ДИНАМИЧЕСКОГО СТАТУСА ПАЙПЛАЙНА -->
<div class="col-md-6">
<div class="card border-primary" id="pipelineStatusCard" style="display: none;">
<div class="card-header d-flex justify-content-between align-items-center">
<span>>_ ТЕКУЩИЙ_СТАТУСАЙПЛАЙНА</span>
<span class="spinner-grow spinner-grow-sm text-primary" id="pipelineSpinner" role="status"></span>
</div>
<div class="card-body">
<div class="p-3 bg-black border border-primary font-monospace small">
[STEP] Шаг: <span id="pipelineStep" class="text-info">Ожидание</span> <br>
[TIME] Обновлено: <span id="pipelineTime">-</span> <br>
<div class="progress my-3" style="height: 10px; 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">0</span> <br>
[DATA] Всего в архиве: <span id="pipelineTotalEmails">0</span> <br>
[DATA] Размер архива: <span id="pipelineSize">0.00 MB</span>
</div>
</div>
</div>
</div>
<!-- ЛОГ ПОСЛЕДНЕГО ЗАПУСКА -->
<div class="col-md-6">
<div class="card">
<div class="card-header">ЛОГ_ПОСЛЕДНЕГО_ЗАПУСКА</div>
<div class="card-header">ЛОГ_ПОСЛЕДНЕГО_ЗАПУСКА (БАЗА ДАННЫХ)</div>
<div class="card-body">
<div class="p-3 bg-black border border-success font-monospace small">
{% if last_log %}
@ -144,6 +167,77 @@
</div>
<script>
async function checkPipelineStatus() {
try {
const response = await fetch('/api/emails/pipeline_status');
const data = await response.json();
const card = document.getElementById('pipelineStatusCard');
const step = document.getElementById('pipelineStep');
const time = document.getElementById('pipelineTime');
const progress = document.getElementById('pipelineProgress');
const newEmails = document.getElementById('pipelineNewEmails');
const totalEmails = document.getElementById('pipelineTotalEmails');
const size = document.getElementById('pipelineSize');
const spinner = document.getElementById('pipelineSpinner');
if (data.status === 'running') {
card.style.display = 'block';
card.className = "card border-primary h-100";
step.className = "text-info";
step.textContent = data.step;
time.textContent = data.updated_at;
spinner.style.display = 'inline-block';
// Динамический прогресс
let width = '10%';
if (data.step.includes('Сбор')) width = '35%';
else if (data.step.includes('Консолидация')) width = '70%';
else if (data.step.includes('Google Drive')) width = '90%';
progress.style.width = width;
progress.className = "progress-bar progress-bar-striped progress-bar-animated bg-primary";
newEmails.textContent = data.new_emails;
totalEmails.textContent = data.total_emails;
size.textContent = data.pdf_size_mb + ' MB';
// Опрашиваем часто
setTimeout(checkPipelineStatus, 2000);
} else if (data.status === 'success') {
card.style.display = 'block';
card.className = "card border-success h-100";
step.className = "text-success";
step.innerHTML = '✅ Завершен успешно';
time.textContent = data.updated_at;
spinner.style.display = 'none';
progress.style.width = '100%';
progress.className = "progress-bar bg-success";
newEmails.textContent = data.new_emails;
totalEmails.textContent = data.total_emails;
size.textContent = data.pdf_size_mb + ' MB';
// Скроем статус через 10 секунд
setTimeout(() => { card.style.display = 'none'; }, 10000);
} else if (data.status === 'error' || data.status === 'stuck') {
card.style.display = 'block';
card.className = "card border-danger h-100";
step.className = "text-danger";
step.innerHTML = '❌ ' + data.step;
time.textContent = data.updated_at;
spinner.style.display = 'none';
progress.style.width = '100%';
progress.className = "progress-bar bg-danger";
} else {
card.style.display = 'none';
}
} catch (e) {
console.error('Failed to get status:', e);
}
}
document.addEventListener('DOMContentLoaded', checkPipelineStatus);
document.getElementById('fullSyncBtn').addEventListener('click', async function() {
const btn = this;
if (!confirm('Запустить полный цикл: Сбор почты -> PDF -> G-Drive?')) return;
@ -156,6 +250,8 @@ 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);
} finally {
@ -185,4 +281,3 @@ document.getElementById('syncAiBtn').addEventListener('click', async function()
});
</script>
{% endblock %}

View file

@ -19,36 +19,42 @@
</h2>
<!-- Статистика (ПЛОСКИЕ ПЕРЕМЕННЫЕ) -->
<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 bg-black border-success text-success">
<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">
<h3>{{ total_count }}</h3>
<small>ВСЕГО</small>
<small>⚡️ ВСЕГО</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-black border-success text-success">
<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">
<h3>{{ verified_count }}</h3>
<small>ВЕРИФИЦИРОВАНЫ</small>
<small>ВЕРИФИЦИРОВАНЫ</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-black border-info text-info">
<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">
<h3>{{ ig_count }}</h3>
<small>ИНИЦИАТИВНАЯ ГРУППА</small>
<small>🔵 ИНИЦИАТИВНАЯ ГРУППА</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-black border-warning text-warning">
<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">
<h3>{{ active_unverified }}</h3>
<small>АКТИВНЫЕ ГОСТИ</small>
<small>🔥 АКТИВНЫЕ ГОСТИ</small>
</div>
</div>
</div>