✨ v1.3 - Scheduled Posts: отложенная публикация постов по расписанию
- Новая страница /scheduled_posts в веб-панели - Создание постов с фото/текстом в любую тему форума - Планирование даты и времени публикации - Автоматическая отправка по расписанию (каждую минуту) - Поддержка всех 10 тем форума - Уведомления админа о публикации/ошибках - Миграция БД: таблица scheduled_posts - Тесты: test_scheduled_posts.py Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
1825d26230
commit
fc06ec4a12
8 changed files with 867 additions and 1 deletions
48
database/migrate_add_scheduled_posts.py
Normal file
48
database/migrate_add_scheduled_posts.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""
|
||||
Миграция: Добавить таблицу scheduled_posts для отложенной публикации
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
DB_PATH = 'database/domovoy.db'
|
||||
|
||||
|
||||
def migrate():
|
||||
"""Создать таблицу scheduled_posts"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Проверяем таблицу
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='scheduled_posts'")
|
||||
if not cursor.fetchone():
|
||||
print("➕ Создаём таблицу scheduled_posts...")
|
||||
cursor.execute("""
|
||||
CREATE TABLE scheduled_posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
text TEXT NOT NULL,
|
||||
photo_file_id TEXT,
|
||||
topic_id BIGINT,
|
||||
topic_name TEXT,
|
||||
recipients TEXT DEFAULT 'chat_only',
|
||||
scheduled_time TIMESTAMP NOT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_by BIGINT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
sent_at TIMESTAMP,
|
||||
error_message TEXT,
|
||||
message_id BIGINT,
|
||||
FOREIGN KEY (created_by) REFERENCES users(user_id)
|
||||
)
|
||||
""")
|
||||
cursor.execute("CREATE INDEX ix_scheduled_posts_time ON scheduled_posts(scheduled_time)")
|
||||
cursor.execute("CREATE INDEX ix_scheduled_posts_status ON scheduled_posts(status)")
|
||||
cursor.execute("CREATE INDEX ix_scheduled_posts_topic ON scheduled_posts(topic_name)")
|
||||
conn.commit()
|
||||
print("✅ Таблица scheduled_posts создана")
|
||||
else:
|
||||
print("ℹ️ Таблица scheduled_posts уже существует")
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
migrate()
|
||||
|
|
@ -595,3 +595,63 @@ class InitiativeGroup(Base):
|
|||
|
||||
def __repr__(self):
|
||||
return f"<InitiativeGroup user={self.user_id} role={self.role}>"
|
||||
|
||||
|
||||
class ScheduledPost(Base):
|
||||
"""Запланированные посты для отложенленной публикации"""
|
||||
__tablename__ = 'scheduled_posts'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
text = Column(Text, nullable=False) # Текст поста
|
||||
photo_file_id = Column(String(255), nullable=True) # ID фото в Telegram
|
||||
topic_id = Column(BigInteger, nullable=True) # ID темы форума (message_thread_id)
|
||||
topic_name = Column(String(50), nullable=True) # Название темы ('general', 'memes'...)
|
||||
recipients = Column(String(50), default='chat_only') # Получатели: 'chat_only', 'all_verified', 'all_and_chat'
|
||||
scheduled_time = Column(DateTime, nullable=False) # Когда отправить
|
||||
status = Column(String(20), default='pending') # 'pending', 'sent', 'failed', 'cancelled'
|
||||
created_by = Column(BigInteger, nullable=True) # ID админа создавшего пост
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
sent_at = Column(DateTime, nullable=True) # Когда фактически отправлено
|
||||
error_message = Column(Text, nullable=True) # Текст ошибки если failed
|
||||
message_id = Column(BigInteger, nullable=True) # ID отправленного сообщения в Telegram
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_scheduled_posts_time', 'scheduled_time'),
|
||||
Index('ix_scheduled_posts_status', 'status'),
|
||||
Index('ix_scheduled_posts_topic', 'topic_name'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ScheduledPost {self.id} at {self.scheduled_time} status={self.status}>"
|
||||
|
||||
@property
|
||||
def is_due(self) -> bool:
|
||||
"""Пора ли отправлять пост"""
|
||||
from datetime import datetime
|
||||
return datetime.utcnow() >= self.scheduled_time and self.status == 'pending'
|
||||
|
||||
def get_topic_emoji(self) -> str:
|
||||
"""Получить emoji темы"""
|
||||
emojis = {
|
||||
'general': '💬',
|
||||
'ads': '📢',
|
||||
'memes': '😂',
|
||||
'meetings': '🏛️',
|
||||
'kgm': '🗑️',
|
||||
'lostfound': '🔍',
|
||||
'auto': '🚗',
|
||||
'phones': '📞',
|
||||
'links': '🔗',
|
||||
'uo': '📝',
|
||||
}
|
||||
return emojis.get(self.topic_name, '📝')
|
||||
|
||||
def get_status_emoji(self) -> str:
|
||||
"""Получить emoji статуса"""
|
||||
emojis = {
|
||||
'pending': '⏳',
|
||||
'sent': '✅',
|
||||
'failed': '❌',
|
||||
'cancelled': '🚫',
|
||||
}
|
||||
return emojis.get(self.status, '❓')
|
||||
|
|
|
|||
1
main.py
1
main.py
|
|
@ -163,6 +163,7 @@ from database.db import init_db
|
|||
from handlers import admin_router, users_router, verification_router, chat_monitor_router, security_router, apartments_router, antimat_router, phones_router, polls_router, 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
|
||||
from services.scheduler import Scheduler
|
||||
from services.web_server import WebServerService
|
||||
from database.models import ScheduledPost # Для новой модели
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(
|
||||
|
|
|
|||
|
|
@ -100,6 +100,16 @@ class Scheduler:
|
|||
|
||||
self.scheduler.start()
|
||||
logger.info('✅ Планировщик запущен')
|
||||
|
||||
# Добавляем задачу проверки запланированных постов (каждую минуту)
|
||||
self.scheduler.add_job(
|
||||
self.process_scheduled_posts,
|
||||
CronTrigger.from_crontab('* * * * *'), # Каждую минуту
|
||||
id='scheduled_posts_checker',
|
||||
name='Проверка запланированных постов',
|
||||
replace_existing=True
|
||||
)
|
||||
logger.info('✅ Добавлена задача проверки запланированных постов')
|
||||
|
||||
def stop(self):
|
||||
"""Остановка планировщика"""
|
||||
|
|
@ -281,3 +291,157 @@ class Scheduler:
|
|||
logger.error(f'Не удалось отправить напоминание о событии: {e}')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка напоминаний о событиях: {e}')
|
||||
|
||||
async def process_scheduled_posts(self):
|
||||
"""Проверка и отправка запланированных постов"""
|
||||
try:
|
||||
from database.db import AsyncSessionLocal
|
||||
from database.models import ScheduledPost, User
|
||||
from config import ADMIN_CHAT_ID, get_topic_id
|
||||
from sqlalchemy import update
|
||||
import aiohttp
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Ищем посты которые пора отправить
|
||||
stmt = (
|
||||
select(ScheduledPost)
|
||||
.where(ScheduledPost.status == 'pending')
|
||||
.where(ScheduledPost.scheduled_time <= datetime.utcnow())
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
posts_to_send = list(result.scalars().all())
|
||||
|
||||
if not posts_to_send:
|
||||
return
|
||||
|
||||
logger.info(f"📅 Найдено {len(posts_to_send)} постов для отправки")
|
||||
|
||||
for post in posts_to_send:
|
||||
try:
|
||||
# Отправляем пост
|
||||
message_id = None
|
||||
bot_token = config.BOT_TOKEN
|
||||
base_url = f"https://api.telegram.org/bot{bot_token}"
|
||||
|
||||
# Настройка прокси для aiohttp
|
||||
proxy_url = config.get_proxy_url()
|
||||
connector = None
|
||||
if proxy_url:
|
||||
# Для socks прокси используем special connector
|
||||
if proxy_url.startswith('socks'):
|
||||
from aiohttp_socks import ProxyConnector
|
||||
connector = ProxyConnector.from_url(proxy_url)
|
||||
else:
|
||||
# HTTP прокси
|
||||
connector = aiohttp.TCPConnector()
|
||||
|
||||
if post.photo_file_id:
|
||||
# С фото - отправляем по file_id
|
||||
url = f"{base_url}/sendPhoto"
|
||||
params = {
|
||||
'chat_id': ADMIN_CHAT_ID,
|
||||
'photo': post.photo_file_id,
|
||||
'caption': post.text,
|
||||
'parse_mode': 'HTML'
|
||||
}
|
||||
if post.topic_id:
|
||||
params['message_thread_id'] = post.topic_id
|
||||
|
||||
async with aiohttp.ClientSession(connector=connector) as http_session:
|
||||
async with http_session.post(url, json=params, timeout=30) as resp:
|
||||
if resp.status == 200:
|
||||
result_data = await resp.json()
|
||||
message_id = result_data['result']['message_id']
|
||||
else:
|
||||
error_text = await resp.text()
|
||||
raise Exception(f"Telegram API {resp.status}: {error_text}")
|
||||
else:
|
||||
# Только текст
|
||||
url = f"{base_url}/sendMessage"
|
||||
params = {
|
||||
'chat_id': ADMIN_CHAT_ID,
|
||||
'text': post.text,
|
||||
'parse_mode': 'HTML'
|
||||
}
|
||||
if post.topic_id:
|
||||
params['message_thread_id'] = post.topic_id
|
||||
|
||||
async with aiohttp.ClientSession(connector=connector) as http_session:
|
||||
async with http_session.post(url, json=params, timeout=30) as resp:
|
||||
if resp.status == 200:
|
||||
result_data = await resp.json()
|
||||
message_id = result_data['result']['message_id']
|
||||
else:
|
||||
error_text = await resp.text()
|
||||
raise Exception(f"Telegram API {resp.status}: {error_text}")
|
||||
|
||||
# Если нужно отправить в личку пользователям
|
||||
if post.recipients in ['all_verified', 'all_and_chat']:
|
||||
stmt_users = select(User).where(User.verified == True)
|
||||
result_users = await session.execute(stmt_users)
|
||||
users = list(result_users.scalars().all())
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
if post.photo_file_id:
|
||||
url = f"{base_url}/sendPhoto"
|
||||
params = {
|
||||
'chat_id': user.user_id,
|
||||
'photo': post.photo_file_id,
|
||||
'caption': f"📢 Объявление от администрации\n\n{post.text}",
|
||||
'parse_mode': 'HTML'
|
||||
}
|
||||
else:
|
||||
url = f"{base_url}/sendMessage"
|
||||
params = {
|
||||
'chat_id': user.user_id,
|
||||
'text': f"📢 Объявление от администрации\n\n{post.text}",
|
||||
'parse_mode': 'HTML'
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession(connector=connector) as http_session:
|
||||
async with http_session.post(url, json=params, timeout=10) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(f"Не удалось отправить пользователю {user.user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки пользователю {user.user_id}: {e}")
|
||||
|
||||
# Обновляем статус поста
|
||||
post.status = 'sent'
|
||||
post.sent_at = datetime.utcnow()
|
||||
post.message_id = message_id
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"✅ Пост отправлен: id={post.id}, тема={post.topic_name}")
|
||||
|
||||
# Уведомляем админа
|
||||
await self.bot.send_message(
|
||||
config.ADMIN_USER_ID,
|
||||
f'✅ <b>Пост опубликован!</b>\n\n'
|
||||
f'ID: {post.id}\n'
|
||||
f'Тема: {post.get_topic_emoji()} {post.topic_name}\n'
|
||||
f'Время: {post.scheduled_time.strftime("%d.%m.%Y %H:%M")}\n'
|
||||
f'Статус: {post.get_status_emoji()} {post.status}',
|
||||
parse_mode='HTML'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Ошибка при отправке
|
||||
post.status = 'failed'
|
||||
post.error_message = str(e)[:500]
|
||||
await session.commit()
|
||||
|
||||
logger.error(f"❌ Ошибка отправки поста {post.id}: {e}")
|
||||
|
||||
# Уведомляем админа об ошибке
|
||||
await self.bot.send_message(
|
||||
config.ADMIN_USER_ID,
|
||||
f'❌ <b>Ошибка публикации поста!</b>\n\n'
|
||||
f'ID: {post.id}\n'
|
||||
f'Тема: {post.topic_name}\n'
|
||||
f'Ошибка: {str(e)[:200]}',
|
||||
parse_mode='HTML'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка обработки запланированных постов: {e}')
|
||||
|
|
|
|||
51
test_direct_send.py
Normal file
51
test_direct_send.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Прямой тест отправки в тему
|
||||
"""
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(BASE_DIR))
|
||||
|
||||
from config import BOT_TOKEN, ADMIN_CHAT_ID
|
||||
|
||||
async def test_send_to_topic():
|
||||
"""Тест отправки в тему форума"""
|
||||
|
||||
bot_token = BOT_TOKEN
|
||||
base_url = f"https://api.telegram.org/bot{bot_token}"
|
||||
|
||||
# Текст для отправки
|
||||
text = "<b>🗑️ ТЕСТ Юмор про мусор</b>\n\n<i>Почему мусорный бак всегда полон? Потому что у него нет проблемы с переполнением! 😂</i>"
|
||||
|
||||
# Тема мемов
|
||||
message_thread_id = 6262
|
||||
|
||||
print(f"📤 Отправка в чат {ADMIN_CHAT_ID}, тема {message_thread_id}...")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
url = f"{base_url}/sendMessage"
|
||||
params = {
|
||||
'chat_id': ADMIN_CHAT_ID,
|
||||
'text': text,
|
||||
'parse_mode': 'HTML',
|
||||
'message_thread_id': message_thread_id
|
||||
}
|
||||
|
||||
async with session.post(url, json=params, timeout=30) as resp:
|
||||
print(f"Status: {resp.status}")
|
||||
result = await resp.json()
|
||||
|
||||
if resp.status == 200:
|
||||
print("✅ Успешно отправлено!")
|
||||
print(f"Message ID: {result['result']['message_id']}")
|
||||
else:
|
||||
print(f"❌ Ошибка: {result}")
|
||||
error_text = await resp.text()
|
||||
print(f"Текст ошибки: {error_text}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(test_send_to_topic())
|
||||
83
test_scheduled_posts.py
Normal file
83
test_scheduled_posts.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Тестирование запланированных постов
|
||||
"""
|
||||
import asyncio
|
||||
import aiohttp
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(BASE_DIR))
|
||||
|
||||
from config import WEB_ADMIN_LOGIN, WEB_ADMIN_PASSWORD
|
||||
|
||||
async def test_scheduled_posts():
|
||||
"""Тест создание запланированного поста"""
|
||||
|
||||
# Базовый URL веб-панели
|
||||
base_url = "http://localhost:8000"
|
||||
|
||||
# Данные для аутентификации из конфига
|
||||
auth = aiohttp.BasicAuth(WEB_ADMIN_LOGIN, WEB_ADMIN_PASSWORD)
|
||||
|
||||
print(f"🔐 Используем логин: {WEB_ADMIN_LOGIN}")
|
||||
|
||||
# Время через 2 минуты от сейчас
|
||||
scheduled_time = datetime.now(timezone.utc) + timedelta(minutes=2)
|
||||
|
||||
# Тест 1: Создание поста
|
||||
print("\n📅 Тест 1: Создание запланированного поста...")
|
||||
|
||||
form_data = aiohttp.FormData()
|
||||
form_data.add_field('text', '<b>🗑️ Юмор про мусор #1</b>\n\n<i>Почему мусорный бак всегда полон? Потому что у него нет проблемы с переполнением! 😂</i>')
|
||||
form_data.add_field('topic_name', 'memes')
|
||||
form_data.add_field('recipients', 'chat_only')
|
||||
form_data.add_field('scheduled_time', scheduled_time.isoformat())
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{base_url}/api/scheduled_posts/create",
|
||||
auth=auth,
|
||||
data=form_data
|
||||
) as resp:
|
||||
result = await resp.json()
|
||||
print(f"Ответ: {result}")
|
||||
|
||||
if result.get('success'):
|
||||
print(f"✅ Пост создан! ID: {result.get('post_id')}")
|
||||
else:
|
||||
print(f"❌ Ошибка: {result.get('error')}")
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка запроса: {e}")
|
||||
return
|
||||
|
||||
# Тест 2: Получение списка постов
|
||||
print("\n📋 Тест 2: Получение списка постов...")
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
f"{base_url}/api/scheduled_posts/list",
|
||||
auth=auth
|
||||
) as resp:
|
||||
result = await resp.json()
|
||||
print(f"Найдено постов: {len(result.get('posts', []))}")
|
||||
|
||||
for post in result.get('posts', []):
|
||||
print(f" - ID {post['id']}: {post['topic_name']} в {post['scheduled_time']} [{post['status']}]")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка запроса: {e}")
|
||||
return
|
||||
|
||||
print("\n✅ Тесты завершены!")
|
||||
print(f"⏳ Подожди 2 минуты и проверь что пост опубликовался автоматически!")
|
||||
print(f"📱 Тебе придёт уведомление в Telegram когда пост будет опубликован!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(test_scheduled_posts())
|
||||
173
web/app.py
173
web/app.py
|
|
@ -20,7 +20,7 @@ import config
|
|||
from database.db import AsyncSessionLocal
|
||||
from database.models import (
|
||||
User, Message, Poll, Ad, PaymentReminder, Schedule,
|
||||
VerificationRequest, Service, Announcement, Event
|
||||
VerificationRequest, Service, Announcement, Event, ScheduledPost
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -855,6 +855,177 @@ async def api_export_json(username: str = Depends(get_current_admin)):
|
|||
return JSONResponse({"success": True, "filepath": filepath})
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ЗАПЛАНИРОВАННЫЕ ПОСТЫ
|
||||
# ============================================================================
|
||||
|
||||
@app.get("/scheduled_posts", response_class=HTMLResponse)
|
||||
async def scheduled_posts_page(request: Request, username: str = Depends(get_current_admin)):
|
||||
"""Страница запланированных постов"""
|
||||
return templates.TemplateResponse("scheduled_posts.html", {
|
||||
"request": request,
|
||||
"username": username
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/scheduled_posts/create")
|
||||
async def api_create_scheduled_post(
|
||||
text: str = Form(...),
|
||||
topic_name: str = Form(...),
|
||||
recipients: str = Form("chat_only"),
|
||||
scheduled_time: str = Form(...),
|
||||
photo: UploadFile = File(None),
|
||||
username: str = Depends(get_current_admin)
|
||||
):
|
||||
"""Создать запланированный пост"""
|
||||
from datetime import datetime
|
||||
from config import ADMIN_CHAT_ID, get_topic_id
|
||||
import aiohttp
|
||||
|
||||
logger.info(f"📅 СОЗДАНИЕ ЗАПЛАНИРОВАННОГО ПОСТА: тема={topic_name}, время={scheduled_time}")
|
||||
|
||||
try:
|
||||
# Парсим время
|
||||
scheduled_dt = datetime.fromisoformat(scheduled_time)
|
||||
|
||||
# Убираем timezone если есть
|
||||
if scheduled_dt.tzinfo is not None:
|
||||
scheduled_dt = scheduled_dt.replace(tzinfo=None)
|
||||
|
||||
# Проверяем что время в будущем
|
||||
if scheduled_dt < datetime.utcnow():
|
||||
return JSONResponse({
|
||||
"success": False,
|
||||
"error": "Время должно быть в будущем"
|
||||
}, status_code=400)
|
||||
|
||||
# Получаем topic_id из topic_name
|
||||
topic_id = get_topic_id(topic_name)
|
||||
|
||||
# Если есть фото - загружаем в Telegram
|
||||
photo_file_id = None
|
||||
if photo and photo.filename:
|
||||
temp_path = Path("data") / f"scheduled_post_{photo.filename}"
|
||||
with open(temp_path, "wb") as f:
|
||||
content = await photo.read()
|
||||
f.write(content)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendPhoto"
|
||||
data = aiohttp.FormData()
|
||||
data.add_field('chat_id', config.ADMIN_USER_ID)
|
||||
data.add_field('photo', open(temp_path, 'rb'), filename=photo.filename)
|
||||
data.add_field('caption', 'Preview')
|
||||
|
||||
async with session.post(url, data=data) as resp:
|
||||
result = await resp.json()
|
||||
if result.get('ok'):
|
||||
photo_file_id = result['result']['photo'][-1]['file_id']
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки фото: {e}")
|
||||
# Не блокируем из-за фото
|
||||
photo_file_id = None
|
||||
|
||||
# Сохраняем в БД
|
||||
async with AsyncSessionLocal() as session:
|
||||
scheduled_post = ScheduledPost(
|
||||
text=text,
|
||||
photo_file_id=photo_file_id,
|
||||
topic_id=topic_id,
|
||||
topic_name=topic_name,
|
||||
recipients=recipients,
|
||||
scheduled_time=scheduled_dt,
|
||||
status='pending',
|
||||
created_by=config.ADMIN_USER_ID
|
||||
)
|
||||
session.add(scheduled_post)
|
||||
await session.commit()
|
||||
await session.refresh(scheduled_post)
|
||||
|
||||
logger.info(f"✅ Пост создан: id={scheduled_post.id}")
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": f"Пост запланирован на {scheduled_dt.strftime('%d.%m.%Y %H:%M')}",
|
||||
"post_id": scheduled_post.id
|
||||
})
|
||||
|
||||
except ValueError as e:
|
||||
return JSONResponse({
|
||||
"success": False,
|
||||
"error": f"Неверный формат времени: {e}"
|
||||
}, status_code=400)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания поста: {e}")
|
||||
return JSONResponse({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}, status_code=500)
|
||||
|
||||
|
||||
@app.get("/api/scheduled_posts/list")
|
||||
async def api_list_scheduled_posts(username: str = Depends(get_current_admin)):
|
||||
"""Список запланированных постов"""
|
||||
from datetime import datetime
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
stmt = select(ScheduledPost).order_by(ScheduledPost.scheduled_time.desc())
|
||||
result = await session.execute(stmt)
|
||||
posts = list(result.scalars().all())
|
||||
|
||||
posts_data = []
|
||||
for post in posts:
|
||||
posts_data.append({
|
||||
"id": post.id,
|
||||
"text": post.text,
|
||||
"topic_name": post.topic_name,
|
||||
"topic_emoji": post.get_topic_emoji(),
|
||||
"recipients": post.recipients,
|
||||
"scheduled_time": post.scheduled_time.strftime('%d.%m.%Y %H:%M'),
|
||||
"status": post.status,
|
||||
"status_emoji": post.get_status_emoji(),
|
||||
"created_at": post.created_at.strftime('%d.%m.%Y %H:%M'),
|
||||
"has_photo": bool(post.photo_file_id)
|
||||
})
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"posts": posts_data
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/scheduled_posts/{post_id}/cancel")
|
||||
async def api_cancel_scheduled_post(post_id: int, username: str = Depends(get_current_admin)):
|
||||
"""Отменить запланированный пост"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
stmt = select(ScheduledPost).where(ScheduledPost.id == post_id)
|
||||
result = await session.execute(stmt)
|
||||
post = result.scalar_one_or_none()
|
||||
|
||||
if not post:
|
||||
return JSONResponse({
|
||||
"success": False,
|
||||
"error": "Пост не найден"
|
||||
}, status_code=404)
|
||||
|
||||
if post.status != 'pending':
|
||||
return JSONResponse({
|
||||
"success": False,
|
||||
"error": f"Нельзя отменить пост со статусом {post.status}"
|
||||
}, status_code=400)
|
||||
|
||||
post.status = 'cancelled'
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"🚫 Пост отменён: id={post_id}")
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": "Пост отменён"
|
||||
})
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# НАСТРОЙКИ (перенесено в /broadcast)
|
||||
# ============================================================================
|
||||
|
|
|
|||
288
web/templates/scheduled_posts.html
Normal file
288
web/templates/scheduled_posts.html
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Запланированные посты - Домовой Бот</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">
|
||||
<style>
|
||||
.sidebar { min-height: 100vh; background: linear-gradient(180deg, #2c3e50 0%, #1a252f 100%); }
|
||||
.sidebar .nav-link { color: rgba(255,255,255,0.8); padding: 1rem; }
|
||||
.sidebar .nav-link:hover, .sidebar .nav-link.active { color: white; background-color: rgba(255,255,255,0.1); }
|
||||
.content { padding: 2rem; }
|
||||
#preview { max-width: 400px; margin-top: 1rem; display: none; }
|
||||
#preview img { max-width: 100%; border-radius: 8px; }
|
||||
.post-card { transition: transform 0.2s; }
|
||||
.post-card:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0,0,0,0.1); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-md-2 sidebar p-0">
|
||||
<div class="p-3 text-white text-center">
|
||||
<h4><i class="bi bi-house-door"></i> Домовой Бот</h4>
|
||||
</div>
|
||||
<nav class="nav flex-column">
|
||||
<a class="nav-link" href="/"><i class="bi bi-speedometer2"></i> Дашборд</a>
|
||||
<a class="nav-link" href="/users"><i class="bi bi-people"></i> Пользователи</a>
|
||||
<a class="nav-link" href="/verification"><i class="bi bi-shield-check"></i> Верификация</a>
|
||||
<a class="nav-link" href="/phones"><i class="bi bi-telephone"></i> Телефоны</a>
|
||||
<a class="nav-link" href="/broadcast"><i class="bi bi-broadcast"></i> Рассылки</a>
|
||||
<a class="nav-link active" href="/scheduled_posts"><i class="bi bi-calendar-event"></i> Запланированные посты</a>
|
||||
<a class="nav-link" href="/polls"><i class="bi bi-bar-chart"></i> Опросы</a>
|
||||
<a class="nav-link" href="/events"><i class="bi bi-calendar-event"></i> События</a>
|
||||
<a class="nav-link" href="/schedules"><i class="bi bi-clock"></i> Расписания</a>
|
||||
<a class="nav-link" href="/export"><i class="bi bi-download"></i> Экспорт</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="col-md-10 content">
|
||||
<h2 class="mb-4"><i class="bi bi-calendar2-plus"></i> Запланированные посты</h2>
|
||||
|
||||
<!-- Форма создания поста -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0"><i class="bi bi-plus-circle"></i> Создать новый пост</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="scheduledPostForm" enctype="multipart/form-data">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Текст поста</label>
|
||||
<textarea class="form-control" id="postText" rows="5"
|
||||
placeholder="Введите текст поста..." required></textarea>
|
||||
<small class="text-muted">Поддерживается HTML: <b>жирный</b>, <i>курсив</i>, <emoji></small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Картинка (необязательно)</label>
|
||||
<input type="file" class="form-control" id="postPhoto" accept="image/*"
|
||||
onchange="previewImage()">
|
||||
<small class="text-muted">JPG, PNG до 5MB</small>
|
||||
<div id="preview">
|
||||
<img id="previewImg" alt="Предпросмотр">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Тема форума</label>
|
||||
<select class="form-select" id="postTopic" required>
|
||||
<option value="general">💬 Общий чат</option>
|
||||
<option value="memes">😂 Мемы</option>
|
||||
<option value="ads">📢 Важные объявления</option>
|
||||
<option value="meetings">🏛️ Собрания собственников</option>
|
||||
<option value="kgm">🗑️ Вывоз КГМ</option>
|
||||
<option value="lostfound">🔍 Потеряшки</option>
|
||||
<option value="auto">🚗 Для автовладельцев</option>
|
||||
<option value="phones">📞 Важные телефоны</option>
|
||||
<option value="links">🔗 Ссылки</option>
|
||||
<option value="uo">📝 Переписка с УО</option>
|
||||
</select>
|
||||
<small class="text-muted">В какую тему отправить пост</small>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Получатели</label>
|
||||
<select class="form-select" id="postRecipients">
|
||||
<option value="chat_only">📢 Только в чат</option>
|
||||
<option value="all_verified">✅ Всем верифицированным в личку</option>
|
||||
<option value="all_and_chat">✅ Всем + в чат</option>
|
||||
</select>
|
||||
<small class="text-muted">Кому отправить пост</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Дата публикации</label>
|
||||
<input type="date" class="form-control" id="postDate" required>
|
||||
<small class="text-muted">Когда опубликовать</small>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Время публикации</label>
|
||||
<input type="time" class="form-control" id="postTime" required>
|
||||
<small class="text-muted">Во сколько опубликовать</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<b>Информация:</b> Пост будет автоматически отправлен в выбранную тему форума в указанное время.
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success btn-lg">
|
||||
<i class="bi bi-calendar-check"></i> Запланировать пост
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="result" class="mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Список запланированных постов -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h5 class="mb-0"><i class="bi bi-list-check"></i> Запланированные посты</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="postsList"></div>
|
||||
<div id="noPosts" class="text-center text-muted py-5">
|
||||
<i class="bi bi-calendar-x" style="font-size: 3rem;"></i>
|
||||
<p class="mt-3">Пока нет запланированных постов</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
// Установка минимальной даты (сегодня)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('postDate').setAttribute('min', today);
|
||||
loadPosts();
|
||||
});
|
||||
|
||||
function previewImage() {
|
||||
const file = document.getElementById('postPhoto').files[0];
|
||||
const preview = document.getElementById('preview');
|
||||
const previewImg = document.getElementById('previewImg');
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
previewImg.src = e.target.result;
|
||||
preview.style.display = 'block';
|
||||
}
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
preview.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Отправка формы
|
||||
document.getElementById('scheduledPostForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const text = document.getElementById('postText').value;
|
||||
const photo = document.getElementById('postPhoto').files[0];
|
||||
const topic = document.getElementById('postTopic').value;
|
||||
const recipients = document.getElementById('postRecipients').value;
|
||||
const date = document.getElementById('postDate').value;
|
||||
const time = document.getElementById('postTime').value;
|
||||
const resultDiv = document.getElementById('result');
|
||||
|
||||
if (!text.trim()) {
|
||||
alert('Введите текст поста!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!date || !time) {
|
||||
alert('Выберите дату и время!');
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduledTime = `${date}T${time}:00`;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('text', text);
|
||||
formData.append('topic_name', topic);
|
||||
formData.append('recipients', recipients);
|
||||
formData.append('scheduled_time', scheduledTime);
|
||||
if (photo) {
|
||||
formData.append('photo', photo);
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/scheduled_posts/create', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const result = await r.json();
|
||||
|
||||
if (result.success) {
|
||||
resultDiv.innerHTML = `<div class="alert alert-success">✅ ${result.message}</div>`;
|
||||
document.getElementById('scheduledPostForm').reset();
|
||||
document.getElementById('preview').style.display = 'none';
|
||||
loadPosts();
|
||||
} else {
|
||||
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${result.error || 'Неизвестная'}</div>`;
|
||||
}
|
||||
} catch (err) {
|
||||
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${err.message}</div>`;
|
||||
}
|
||||
});
|
||||
|
||||
// Загрузка постов
|
||||
async function loadPosts() {
|
||||
try {
|
||||
const r = await fetch('/api/scheduled_posts/list');
|
||||
const result = await r.json();
|
||||
|
||||
const postsList = document.getElementById('postsList');
|
||||
const noPosts = document.getElementById('noPosts');
|
||||
|
||||
if (result.success && result.posts.length > 0) {
|
||||
noPosts.style.display = 'none';
|
||||
postsList.innerHTML = '';
|
||||
|
||||
result.posts.forEach(post => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card mb-3 post-card';
|
||||
card.innerHTML = `
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h6 class="mb-1">${post.topic_emoji} ${post.topic_name || 'Без темы'}</h6>
|
||||
<span class="badge bg-${post.status === 'pending' ? 'warning' : post.status === 'sent' ? 'success' : post.status === 'failed' ? 'danger' : 'secondary'}">
|
||||
${post.status_emoji} ${post.status}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mb-2">${post.text.substring(0, 100)}${post.text.length > 100 ? '...' : ''}</p>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">
|
||||
📅 ${post.scheduled_time}
|
||||
</small>
|
||||
${post.status === 'pending' ? `
|
||||
<button class="btn btn-sm btn-danger" onclick="cancelPost(${post.id})">
|
||||
<i class="bi bi-x-circle"></i> Отменить
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
postsList.appendChild(card);
|
||||
});
|
||||
} else {
|
||||
noPosts.style.display = 'block';
|
||||
postsList.innerHTML = '';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки постов:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Отмена поста
|
||||
async function cancelPost(postId) {
|
||||
if (!confirm('Отменить этот пост?')) return;
|
||||
|
||||
try {
|
||||
const r = await fetch(`/api/scheduled_posts/${postId}/cancel`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await r.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('✅ Пост отменён');
|
||||
loadPosts();
|
||||
} else {
|
||||
alert('❌ Ошибка: ' + (result.error || 'Неизвестная'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('❌ Ошибка: ' + err.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in a new issue