Initial commit: Schedule service for son
This commit is contained in:
commit
af2ea7be06
19 changed files with 2270 additions and 0 deletions
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
293
backend/api.py
Normal file
293
backend/api.py
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, or_
|
||||
from datetime import datetime, timedelta
|
||||
import pytz
|
||||
from typing import List, Union
|
||||
|
||||
from backend.database import get_db, Task, Event, WeeklyTaskException
|
||||
from backend.models import (
|
||||
TaskCreate, EventCreate, ScheduleResponse, ScheduleItem,
|
||||
TaskResponse, EventResponse, UpdateRequest
|
||||
)
|
||||
from backend.utils import (
|
||||
get_week_range, check_event_overlap, get_weekday_from_date,
|
||||
materialize_weekly_tasks, format_date_russian
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
TZ = pytz.timezone("Europe/London")
|
||||
|
||||
|
||||
@router.get("/schedule", response_model=ScheduleResponse)
|
||||
async def get_schedule(
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Получить расписание в диапазоне дат"""
|
||||
try:
|
||||
from_dt = datetime.strptime(from_date, "%Y-%m-%d").date()
|
||||
to_dt = datetime.strptime(to_date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
items = []
|
||||
|
||||
# Получаем разовые tasks
|
||||
result = await db.execute(
|
||||
select(Task).where(
|
||||
and_(
|
||||
Task.date >= from_date,
|
||||
Task.date <= to_date,
|
||||
Task.repeat_weekly == False
|
||||
)
|
||||
)
|
||||
)
|
||||
tasks = result.scalars().all()
|
||||
for task in tasks:
|
||||
items.append(ScheduleItem(
|
||||
kind="task",
|
||||
id=task.id,
|
||||
date=task.date,
|
||||
title=task.title,
|
||||
repeat_weekly=False
|
||||
))
|
||||
|
||||
# Получаем events
|
||||
result = await db.execute(
|
||||
select(Event).where(
|
||||
and_(
|
||||
Event.date >= from_date,
|
||||
Event.date <= to_date
|
||||
)
|
||||
)
|
||||
)
|
||||
events = result.scalars().all()
|
||||
for event in events:
|
||||
items.append(ScheduleItem(
|
||||
kind="event",
|
||||
id=event.id,
|
||||
date=event.date,
|
||||
title=event.title,
|
||||
start_time=event.start_time,
|
||||
duration_min=event.duration_min
|
||||
))
|
||||
|
||||
# Материализуем weekly tasks
|
||||
weekly_tasks = await materialize_weekly_tasks(db, from_date, to_date)
|
||||
items.extend(weekly_tasks)
|
||||
|
||||
return ScheduleResponse(items=items)
|
||||
|
||||
|
||||
@router.post("/events")
|
||||
async def create_item(
|
||||
kind: str,
|
||||
data: dict = Body(...),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Создать task или event"""
|
||||
if kind == "task":
|
||||
try:
|
||||
task = TaskCreate(**data)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid task data: {e}")
|
||||
|
||||
# Создаём основную task
|
||||
new_task = Task(
|
||||
date=task.date,
|
||||
title=task.title,
|
||||
repeat_weekly=task.repeat_weekly
|
||||
)
|
||||
|
||||
if task.repeat_weekly:
|
||||
weekday = get_weekday_from_date(task.date)
|
||||
new_task.weekday = weekday
|
||||
|
||||
db.add(new_task)
|
||||
await db.commit()
|
||||
await db.refresh(new_task)
|
||||
|
||||
# Копирование на другие дни недели
|
||||
if task.copy_to_weekdays and not task.repeat_weekly:
|
||||
base_date = datetime.strptime(task.date, "%Y-%m-%d").date()
|
||||
base_weekday = base_date.weekday()
|
||||
for target_weekday in task.copy_to_weekdays:
|
||||
# Вычисляем дату целевого дня недели в той же неделе
|
||||
days_diff = (target_weekday - base_weekday) % 7
|
||||
if days_diff > 0: # только будущие дни
|
||||
target_date = base_date + timedelta(days=days_diff)
|
||||
copy_task = Task(
|
||||
date=target_date.strftime("%Y-%m-%d"),
|
||||
title=task.title,
|
||||
repeat_weekly=False
|
||||
)
|
||||
db.add(copy_task)
|
||||
await db.commit()
|
||||
|
||||
return {"id": new_task.id, "kind": "task"}
|
||||
|
||||
elif kind == "event":
|
||||
try:
|
||||
event = EventCreate(**data)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid event data: {e}")
|
||||
|
||||
# Проверка пересечений
|
||||
overlap = await check_event_overlap(
|
||||
db, event.date, event.start_time, event.duration_min, None
|
||||
)
|
||||
if overlap:
|
||||
raise HTTPException(status_code=400, detail="Нельзя добавить: пересечение по времени")
|
||||
|
||||
new_event = Event(
|
||||
date=event.date,
|
||||
start_time=event.start_time,
|
||||
duration_min=event.duration_min,
|
||||
title=event.title
|
||||
)
|
||||
db.add(new_event)
|
||||
await db.commit()
|
||||
await db.refresh(new_event)
|
||||
|
||||
return {"id": new_event.id, "kind": "event"}
|
||||
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid kind. Use 'task' or 'event'")
|
||||
|
||||
|
||||
@router.put("/events/{item_id}")
|
||||
async def update_item(
|
||||
item_id: int,
|
||||
update: UpdateRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Обновить task или event"""
|
||||
# Пробуем найти как task
|
||||
result = await db.execute(select(Task).where(Task.id == item_id))
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if task:
|
||||
if task.repeat_weekly and update.scope == "one_date":
|
||||
# Создаём исключение для конкретной даты
|
||||
weekday = get_weekday_from_date(task.date)
|
||||
exception = WeeklyTaskException(
|
||||
weekday=weekday,
|
||||
date=task.date,
|
||||
action="replace" if update.title else "delete",
|
||||
replacement_title=update.title
|
||||
)
|
||||
db.add(exception)
|
||||
await db.commit()
|
||||
return {"success": True, "action": "exception_created"}
|
||||
|
||||
if update.title:
|
||||
if task.repeat_weekly and update.scope == "series":
|
||||
# Обновляем все weekly tasks с этим weekday
|
||||
await db.execute(
|
||||
Task.__table__.update()
|
||||
.where(Task.weekday == task.weekday)
|
||||
.values(title=update.title)
|
||||
)
|
||||
else:
|
||||
task.title = update.title
|
||||
|
||||
await db.commit()
|
||||
return {"success": True}
|
||||
|
||||
# Пробуем найти как event
|
||||
result = await db.execute(select(Event).where(Event.id == item_id))
|
||||
event = result.scalar_one_or_none()
|
||||
|
||||
if event:
|
||||
new_date = update.date or event.date
|
||||
new_start_time = update.start_time or event.start_time
|
||||
new_duration = update.duration_min or event.duration_min
|
||||
|
||||
# Проверка пересечений
|
||||
overlap = await check_event_overlap(
|
||||
db, new_date, new_start_time, new_duration, item_id
|
||||
)
|
||||
if overlap:
|
||||
raise HTTPException(status_code=400, detail="Нельзя изменить: пересечение по времени")
|
||||
|
||||
if update.title:
|
||||
event.title = update.title
|
||||
if update.date:
|
||||
event.date = update.date
|
||||
if update.start_time:
|
||||
event.start_time = update.start_time
|
||||
if update.duration_min:
|
||||
event.duration_min = update.duration_min
|
||||
|
||||
await db.commit()
|
||||
return {"success": True}
|
||||
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
|
||||
@router.delete("/events/{item_id}")
|
||||
async def delete_item(
|
||||
item_id: int,
|
||||
scope: str = None,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Удалить task или event"""
|
||||
# Пробуем найти как task
|
||||
result = await db.execute(select(Task).where(Task.id == item_id))
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if task:
|
||||
if task.repeat_weekly:
|
||||
if scope == "one_date":
|
||||
# Создаём исключение на удаление
|
||||
weekday = get_weekday_from_date(task.date)
|
||||
exception = WeeklyTaskException(
|
||||
weekday=weekday,
|
||||
date=task.date,
|
||||
action="delete"
|
||||
)
|
||||
db.add(exception)
|
||||
await db.commit()
|
||||
return {"success": True, "action": "exception_created"}
|
||||
elif scope == "series":
|
||||
# Удаляем все weekly tasks с этим weekday
|
||||
await db.execute(
|
||||
Task.__table__.delete().where(Task.weekday == task.weekday)
|
||||
)
|
||||
await db.commit()
|
||||
return {"success": True}
|
||||
|
||||
await db.delete(task)
|
||||
await db.commit()
|
||||
return {"success": True}
|
||||
|
||||
# Пробуем найти как event
|
||||
result = await db.execute(select(Event).where(Event.id == item_id))
|
||||
event = result.scalar_one_or_none()
|
||||
|
||||
if event:
|
||||
await db.delete(event)
|
||||
await db.commit()
|
||||
return {"success": True}
|
||||
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
|
||||
@router.get("/backup")
|
||||
async def backup_database():
|
||||
"""Создать backup базы данных"""
|
||||
import shutil
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
db_path = os.getenv("DATABASE_PATH", "data/schedule.db")
|
||||
backup_path = f"{db_path}.backup.{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
|
||||
if os.path.exists(db_path):
|
||||
shutil.copy2(db_path, backup_path)
|
||||
return {"success": True, "backup_path": backup_path}
|
||||
return {"success": False, "error": "Database not found"}
|
||||
|
||||
64
backend/database.py
Normal file
64
backend/database.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text
|
||||
import os
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_PATH", "data/schedule.db")
|
||||
# SQLite через aiosqlite требует sqlite+aiosqlite:///
|
||||
DATABASE_URL_ASYNC = f"sqlite+aiosqlite:///{DATABASE_URL}"
|
||||
|
||||
engine = create_async_engine(DATABASE_URL_ASYNC, echo=False)
|
||||
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Task(Base):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
date = Column(String, index=True) # YYYY-MM-DD
|
||||
title = Column(String, nullable=False)
|
||||
repeat_weekly = Column(Boolean, default=False)
|
||||
weekday = Column(Integer) # 0=Monday, 6=Sunday, только для weekly tasks
|
||||
|
||||
|
||||
class Event(Base):
|
||||
__tablename__ = "events"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
date = Column(String, index=True) # YYYY-MM-DD
|
||||
start_time = Column(String) # HH:MM
|
||||
duration_min = Column(Integer, nullable=False)
|
||||
title = Column(String, nullable=False)
|
||||
|
||||
|
||||
class WeeklyTaskException(Base):
|
||||
__tablename__ = "weekly_task_exceptions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
weekday = Column(Integer, nullable=False) # 0=Monday, 6=Sunday
|
||||
date = Column(String, nullable=False) # YYYY-MM-DD
|
||||
action = Column(String, nullable=False) # "delete" или "replace"
|
||||
replacement_title = Column(String) # для action="replace"
|
||||
|
||||
|
||||
class TelegramUser(Base):
|
||||
__tablename__ = "telegram_users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
chat_id = Column(String, unique=True, nullable=False, index=True)
|
||||
|
||||
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
41
backend/main.py
Normal file
41
backend/main.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
import os
|
||||
|
||||
from backend.database import init_db
|
||||
from backend.api import router as api_router
|
||||
from backend.telegram_bot import start_bot
|
||||
|
||||
app = FastAPI(title="Schedule Service")
|
||||
|
||||
# Инициализация БД (асинхронно при старте)
|
||||
|
||||
# API роуты
|
||||
app.include_router(api_router, prefix="/api")
|
||||
|
||||
# Статические файлы для фронтенда
|
||||
frontend_path = os.path.join(os.path.dirname(__file__), "..", "frontend")
|
||||
|
||||
@app.get("/")
|
||||
async def read_root():
|
||||
return FileResponse(os.path.join(frontend_path, "public", "index.html"))
|
||||
|
||||
@app.get("/admin")
|
||||
async def read_admin():
|
||||
return FileResponse(os.path.join(frontend_path, "admin", "index.html"))
|
||||
|
||||
# Статические файлы
|
||||
app.mount("/static", StaticFiles(directory=os.path.join(frontend_path, "public", "static")), name="static")
|
||||
app.mount("/admin/static", StaticFiles(directory=os.path.join(frontend_path, "admin", "static")), name="admin_static")
|
||||
|
||||
# Запуск Telegram-бота в фоне
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
import asyncio
|
||||
# Инициализация БД
|
||||
await init_db()
|
||||
# Запуск бота
|
||||
if os.getenv("TELEGRAM_BOT_TOKEN"):
|
||||
asyncio.create_task(start_bot())
|
||||
|
||||
50
backend/models.py
Normal file
50
backend/models.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from pydantic import BaseModel
|
||||
from typing import Optional, Literal
|
||||
from datetime import date
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
date: str # YYYY-MM-DD
|
||||
title: str
|
||||
repeat_weekly: bool = False
|
||||
copy_to_weekdays: Optional[list[int]] = None # список дней недели для копирования
|
||||
|
||||
class EventCreate(BaseModel):
|
||||
date: str # YYYY-MM-DD
|
||||
start_time: str # HH:MM
|
||||
duration_min: int
|
||||
title: str
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
id: int
|
||||
date: str
|
||||
title: str
|
||||
kind: Literal["task"] = "task"
|
||||
repeat_weekly: bool = False
|
||||
|
||||
class EventResponse(BaseModel):
|
||||
id: int
|
||||
date: str
|
||||
start_time: str
|
||||
duration_min: int
|
||||
title: str
|
||||
kind: Literal["event"] = "event"
|
||||
|
||||
class ScheduleItem(BaseModel):
|
||||
kind: Literal["task", "event"]
|
||||
id: int
|
||||
date: str
|
||||
title: str
|
||||
start_time: Optional[str] = None
|
||||
duration_min: Optional[int] = None
|
||||
repeat_weekly: Optional[bool] = None
|
||||
|
||||
class ScheduleResponse(BaseModel):
|
||||
items: list[ScheduleItem]
|
||||
|
||||
class UpdateRequest(BaseModel):
|
||||
title: Optional[str] = None
|
||||
date: Optional[str] = None
|
||||
start_time: Optional[str] = None
|
||||
duration_min: Optional[int] = None
|
||||
scope: Optional[Literal["one_date", "series"]] = None # для weekly tasks
|
||||
|
||||
492
backend/telegram_bot.py
Normal file
492
backend/telegram_bot.py
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from telegram.ext import (
|
||||
Application, CommandHandler, CallbackQueryHandler, MessageHandler,
|
||||
ContextTypes, ConversationHandler, filters
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime, timedelta
|
||||
import pytz
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from backend.database import AsyncSessionLocal, Task, Event, TelegramUser
|
||||
from backend.utils import get_week_range, format_date_russian, check_event_overlap, get_weekday_from_date
|
||||
from backend.models import TaskCreate, EventCreate
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TZ = pytz.timezone("Europe/London")
|
||||
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||
|
||||
# Состояния для ConversationHandler
|
||||
SELECTING_DATE, SELECTING_HOUR, SELECTING_MINUTE, SELECTING_DURATION_HOUR, SELECTING_DURATION_MIN, ENTERING_TITLE = range(6)
|
||||
SELECTING_TASK_DATE, ENTERING_TASK_TITLE, SELECTING_REPEAT, SELECTING_COPY_DAYS = range(10, 14)
|
||||
|
||||
user_states = {} # Временное хранилище состояний пользователей
|
||||
|
||||
|
||||
async def register_user(chat_id: str):
|
||||
"""Зарегистрировать пользователя для получения напоминаний"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(
|
||||
select(TelegramUser).where(TelegramUser.chat_id == chat_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
user = TelegramUser(chat_id=chat_id)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_schedule_for_date(date_str: str) -> str:
|
||||
"""Получить расписание на дату в текстовом формате"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Получаем tasks (разовые)
|
||||
result = await db.execute(
|
||||
select(Task).where(
|
||||
and_(Task.date == date_str, Task.repeat_weekly == False)
|
||||
)
|
||||
)
|
||||
tasks = result.scalars().all()
|
||||
|
||||
# Получаем weekly tasks для этой даты
|
||||
from backend.utils import materialize_weekly_tasks
|
||||
weekly_items = await materialize_weekly_tasks(db, date_str, date_str)
|
||||
weekly_tasks = [item for item in weekly_items if item.kind == "task"]
|
||||
|
||||
# Получаем events
|
||||
result = await db.execute(
|
||||
select(Event).where(Event.date == date_str)
|
||||
)
|
||||
events = result.scalars().all()
|
||||
events = sorted(events, key=lambda e: e.start_time)
|
||||
|
||||
text = f"📅 {format_date_russian(date_str)}\n\n"
|
||||
|
||||
# Объединяем tasks
|
||||
all_tasks = []
|
||||
for task in tasks:
|
||||
all_tasks.append(task.title)
|
||||
for task_item in weekly_tasks:
|
||||
all_tasks.append(task_item.title)
|
||||
|
||||
if all_tasks:
|
||||
text += "📋 Задачи:\n"
|
||||
for title in all_tasks:
|
||||
text += f" • {title}\n"
|
||||
text += "\n"
|
||||
|
||||
if events:
|
||||
text += "⏰ Занятия:\n"
|
||||
for event in events:
|
||||
end_time = calculate_end_time(event.start_time, event.duration_min)
|
||||
text += f" {event.start_time}-{end_time} {event.title}\n"
|
||||
|
||||
if not all_tasks and not events:
|
||||
text += "На этот день расписания нет.\n"
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def calculate_end_time(start_time: str, duration_min: int) -> str:
|
||||
"""Вычислить время окончания"""
|
||||
hour, minute = map(int, start_time.split(":"))
|
||||
total_minutes = hour * 60 + minute + duration_min
|
||||
end_hour = total_minutes // 60
|
||||
end_minute = total_minutes % 60
|
||||
return f"{end_hour:02d}:{end_minute:02d}"
|
||||
|
||||
|
||||
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Обработчик команды /start"""
|
||||
await register_user(str(update.effective_chat.id))
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("📅 Сегодня", callback_data="schedule_today")],
|
||||
[InlineKeyboardButton("📅 Завтра", callback_data="schedule_tomorrow")],
|
||||
[InlineKeyboardButton("📅 Оставшиеся дни недели", callback_data="schedule_week")],
|
||||
[InlineKeyboardButton("➕ Добавить занятие", callback_data="add_event")],
|
||||
[InlineKeyboardButton("➕ Добавить задачу", callback_data="add_task")],
|
||||
]
|
||||
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||
await update.message.reply_text(
|
||||
"Выберите действие:",
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
|
||||
|
||||
async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Обработчик кнопок"""
|
||||
query = update.callback_query
|
||||
await query.answer()
|
||||
|
||||
chat_id = str(query.from_user.id)
|
||||
today = datetime.now(TZ).date()
|
||||
|
||||
if query.data == "schedule_today":
|
||||
date_str = today.strftime("%Y-%m-%d")
|
||||
text = await get_schedule_for_date(date_str)
|
||||
await query.edit_message_text(text)
|
||||
|
||||
elif query.data == "schedule_tomorrow":
|
||||
tomorrow = today + timedelta(days=1)
|
||||
date_str = tomorrow.strftime("%Y-%m-%d")
|
||||
text = await get_schedule_for_date(date_str)
|
||||
await query.edit_message_text(text)
|
||||
|
||||
elif query.data == "schedule_week":
|
||||
# Оставшиеся дни недели
|
||||
weekday = today.weekday()
|
||||
text = ""
|
||||
for i in range(weekday, 7):
|
||||
date = today + timedelta(days=i - weekday)
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
day_text = await get_schedule_for_date(date_str)
|
||||
text += day_text + "\n"
|
||||
await query.edit_message_text(text[:4000]) # Telegram limit
|
||||
|
||||
elif query.data == "add_event":
|
||||
# Начинаем процесс добавления event
|
||||
user_states[chat_id] = {"action": "add_event", "step": "date"}
|
||||
await show_date_selection(query, "event")
|
||||
return SELECTING_DATE
|
||||
|
||||
elif query.data == "add_task":
|
||||
# Начинаем процесс добавления task
|
||||
user_states[chat_id] = {"action": "add_task", "step": "date"}
|
||||
await show_date_selection(query, "task")
|
||||
return SELECTING_TASK_DATE
|
||||
|
||||
elif query.data.startswith("date_"):
|
||||
# Выбрана дата
|
||||
date_str = query.data.replace("date_", "")
|
||||
if chat_id not in user_states:
|
||||
user_states[chat_id] = {}
|
||||
user_states[chat_id]["date"] = date_str
|
||||
|
||||
if user_states[chat_id].get("action") == "add_event":
|
||||
await show_hour_selection(query)
|
||||
return SELECTING_HOUR
|
||||
else: # add_task
|
||||
await query.edit_message_text("Введите название задачи:")
|
||||
return ENTERING_TASK_TITLE
|
||||
|
||||
elif query.data.startswith("hour_"):
|
||||
hour = query.data.replace("hour_", "")
|
||||
user_states[chat_id]["hour"] = hour
|
||||
await show_minute_selection(query)
|
||||
return SELECTING_MINUTE
|
||||
|
||||
elif query.data.startswith("minute_"):
|
||||
minute = query.data.replace("minute_", "")
|
||||
user_states[chat_id]["minute"] = minute
|
||||
await show_duration_selection(query)
|
||||
return SELECTING_DURATION_HOUR
|
||||
|
||||
elif query.data.startswith("dur_hour_"):
|
||||
dur_hour = int(query.data.replace("dur_hour_", ""))
|
||||
user_states[chat_id]["duration_hour"] = dur_hour
|
||||
await show_duration_minute_selection(query)
|
||||
return SELECTING_DURATION_MIN
|
||||
|
||||
elif query.data.startswith("dur_min_"):
|
||||
dur_min = int(query.data.replace("dur_min_", ""))
|
||||
user_states[chat_id]["duration_minute"] = dur_min
|
||||
await query.edit_message_text("Введите название занятия:")
|
||||
return ENTERING_TITLE
|
||||
|
||||
# Возвращаемся в главное меню
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("📅 Сегодня", callback_data="schedule_today")],
|
||||
[InlineKeyboardButton("📅 Завтра", callback_data="schedule_tomorrow")],
|
||||
[InlineKeyboardButton("📅 Оставшиеся дни недели", callback_data="schedule_week")],
|
||||
[InlineKeyboardButton("➕ Добавить занятие", callback_data="add_event")],
|
||||
[InlineKeyboardButton("➕ Добавить задачу", callback_data="add_task")],
|
||||
]
|
||||
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||
await query.edit_message_text("Выберите действие:", reply_markup=reply_markup)
|
||||
return ConversationHandler.END
|
||||
|
||||
|
||||
async def show_date_selection(query, kind="event"):
|
||||
"""Показать выбор даты"""
|
||||
today = datetime.now(TZ).date()
|
||||
monday = today - timedelta(days=today.weekday())
|
||||
next_monday = monday + timedelta(days=7)
|
||||
|
||||
keyboard = []
|
||||
weekdays = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
|
||||
|
||||
# Текущая неделя
|
||||
for i in range(7):
|
||||
date = monday + timedelta(days=i)
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
label = f"{weekdays[i]} {date.day}"
|
||||
keyboard.append([InlineKeyboardButton(label, callback_data=f"date_{date_str}")])
|
||||
|
||||
# Следующая неделя
|
||||
for i in range(7):
|
||||
date = next_monday + timedelta(days=i)
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
label = f"{weekdays[i]} {date.day}"
|
||||
keyboard.append([InlineKeyboardButton(label, callback_data=f"date_{date_str}")])
|
||||
|
||||
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||
await query.edit_message_text("Выберите дату:", reply_markup=reply_markup)
|
||||
|
||||
|
||||
async def show_hour_selection(query):
|
||||
"""Показать выбор часа"""
|
||||
keyboard = []
|
||||
for hour in range(8, 21):
|
||||
keyboard.append([InlineKeyboardButton(f"{hour:02d}:00", callback_data=f"hour_{hour:02d}")])
|
||||
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||
await query.edit_message_text("Выберите час начала:", reply_markup=reply_markup)
|
||||
|
||||
|
||||
async def show_minute_selection(query):
|
||||
"""Показать выбор минут"""
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("00", callback_data="minute_00")],
|
||||
[InlineKeyboardButton("15", callback_data="minute_15")],
|
||||
[InlineKeyboardButton("30", callback_data="minute_30")],
|
||||
[InlineKeyboardButton("45", callback_data="minute_45")],
|
||||
]
|
||||
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||
await query.edit_message_text("Выберите минуты:", reply_markup=reply_markup)
|
||||
|
||||
|
||||
async def show_duration_selection(query):
|
||||
"""Показать выбор длительности (часы)"""
|
||||
keyboard = []
|
||||
for hour in range(0, 4):
|
||||
keyboard.append([InlineKeyboardButton(f"{hour} ч", callback_data=f"dur_hour_{hour}")])
|
||||
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||
await query.edit_message_text("Выберите длительность (часы):", reply_markup=reply_markup)
|
||||
|
||||
|
||||
async def show_duration_minute_selection(query):
|
||||
"""Показать выбор длительности (минуты)"""
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("00", callback_data="dur_min_00")],
|
||||
[InlineKeyboardButton("15", callback_data="dur_min_15")],
|
||||
[InlineKeyboardButton("30", callback_data="dur_min_30")],
|
||||
[InlineKeyboardButton("45", callback_data="dur_min_45")],
|
||||
]
|
||||
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||
await query.edit_message_text("Выберите длительность (минуты):", reply_markup=reply_markup)
|
||||
|
||||
|
||||
async def handle_event_title(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Обработка ввода названия event"""
|
||||
chat_id = str(update.effective_user.id)
|
||||
title = update.message.text
|
||||
|
||||
state = user_states.get(chat_id, {})
|
||||
if not state:
|
||||
await update.message.reply_text("Ошибка: сессия истекла. Начните заново.")
|
||||
return ConversationHandler.END
|
||||
|
||||
date = state.get("date")
|
||||
hour = state.get("hour")
|
||||
minute = state.get("minute")
|
||||
dur_hour = state.get("duration_hour", 0)
|
||||
dur_min = state.get("duration_minute", 0)
|
||||
|
||||
if not all([date, hour, minute]):
|
||||
await update.message.reply_text("Ошибка: не все данные заполнены. Начните заново.")
|
||||
if chat_id in user_states:
|
||||
del user_states[chat_id]
|
||||
return ConversationHandler.END
|
||||
|
||||
duration_min = dur_hour * 60 + dur_min
|
||||
if duration_min == 0:
|
||||
duration_min = 15 # Минимум 15 минут
|
||||
|
||||
start_time = f"{hour}:{minute}"
|
||||
|
||||
# Создаём event
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Проверка пересечений
|
||||
overlap = await check_event_overlap(db, date, start_time, duration_min, None)
|
||||
if overlap:
|
||||
await update.message.reply_text("❌ Нельзя добавить: пересечение по времени")
|
||||
if chat_id in user_states:
|
||||
del user_states[chat_id]
|
||||
return ConversationHandler.END
|
||||
|
||||
event = Event(
|
||||
date=date,
|
||||
start_time=start_time,
|
||||
duration_min=duration_min,
|
||||
title=title
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
|
||||
await update.message.reply_text(f"✅ Занятие добавлено: {date} {start_time} - {title}")
|
||||
if chat_id in user_states:
|
||||
del user_states[chat_id]
|
||||
return ConversationHandler.END
|
||||
|
||||
|
||||
async def handle_task_title(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Обработка ввода названия task"""
|
||||
chat_id = str(update.effective_user.id)
|
||||
title = update.message.text
|
||||
|
||||
state = user_states.get(chat_id, {})
|
||||
state["title"] = title
|
||||
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("Да, повторять каждую неделю", callback_data="repeat_yes")],
|
||||
[InlineKeyboardButton("Нет, только на эту дату", callback_data="repeat_no")],
|
||||
]
|
||||
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||
await update.message.reply_text(
|
||||
"Повторять эту задачу каждую неделю?",
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
return SELECTING_REPEAT
|
||||
|
||||
|
||||
async def handle_repeat_choice(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Обработка выбора повторения"""
|
||||
query = update.callback_query
|
||||
await query.answer()
|
||||
|
||||
chat_id = str(query.from_user.id)
|
||||
state = user_states.get(chat_id, {})
|
||||
|
||||
if not state:
|
||||
await query.edit_message_text("Ошибка: сессия истекла. Начните заново.")
|
||||
return ConversationHandler.END
|
||||
|
||||
if query.data == "repeat_yes":
|
||||
state["repeat_weekly"] = True
|
||||
# Создаём weekly task
|
||||
async with AsyncSessionLocal() as db:
|
||||
weekday = get_weekday_from_date(state["date"])
|
||||
task = Task(
|
||||
date=state["date"],
|
||||
title=state["title"],
|
||||
repeat_weekly=True,
|
||||
weekday=weekday
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await query.edit_message_text(f"✅ Задача добавлена (повторяется каждую неделю): {state['title']}")
|
||||
else:
|
||||
state["repeat_weekly"] = False
|
||||
# Создаём разовую task
|
||||
async with AsyncSessionLocal() as db:
|
||||
task = Task(
|
||||
date=state["date"],
|
||||
title=state["title"],
|
||||
repeat_weekly=False
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await query.edit_message_text(f"✅ Задача добавлена: {state['title']}")
|
||||
|
||||
del user_states[chat_id]
|
||||
return ConversationHandler.END
|
||||
|
||||
|
||||
async def send_reminders():
|
||||
"""Отправка напоминаний о событиях"""
|
||||
while True:
|
||||
try:
|
||||
now = datetime.now(TZ)
|
||||
target_time = now + timedelta(minutes=5)
|
||||
target_date = target_time.date()
|
||||
target_time_str = target_time.strftime("%H:%M")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Находим события, которые начинаются через 5 минут
|
||||
result = await db.execute(
|
||||
select(Event).where(
|
||||
and_(
|
||||
Event.date == target_date.strftime("%Y-%m-%d"),
|
||||
Event.start_time == target_time_str
|
||||
)
|
||||
)
|
||||
)
|
||||
events = result.scalars().all()
|
||||
|
||||
if events:
|
||||
# Получаем всех пользователей
|
||||
result = await db.execute(select(TelegramUser))
|
||||
users = result.scalars().all()
|
||||
|
||||
for event in events:
|
||||
message = f"🔔 Напоминание: через 5 минут начинается {event.title} ({event.start_time})"
|
||||
for user in users:
|
||||
try:
|
||||
await application.bot.send_message(
|
||||
chat_id=user.chat_id,
|
||||
text=message
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending reminder to {user.chat_id}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in reminder loop: {e}")
|
||||
|
||||
await asyncio.sleep(60) # Проверяем каждую минуту
|
||||
|
||||
|
||||
application = None
|
||||
|
||||
|
||||
async def start_bot():
|
||||
"""Запустить бота"""
|
||||
global application
|
||||
if not BOT_TOKEN:
|
||||
logger.warning("TELEGRAM_BOT_TOKEN not set, bot will not start")
|
||||
return
|
||||
|
||||
application = Application.builder().token(BOT_TOKEN).build()
|
||||
|
||||
# Conversation handler для добавления event
|
||||
event_conv = ConversationHandler(
|
||||
entry_points=[CallbackQueryHandler(button_handler, pattern="^add_event$")],
|
||||
states={
|
||||
SELECTING_DATE: [CallbackQueryHandler(button_handler, pattern="^date_")],
|
||||
SELECTING_HOUR: [CallbackQueryHandler(button_handler, pattern="^hour_")],
|
||||
SELECTING_MINUTE: [CallbackQueryHandler(button_handler, pattern="^minute_")],
|
||||
SELECTING_DURATION_HOUR: [CallbackQueryHandler(button_handler, pattern="^dur_hour_")],
|
||||
SELECTING_DURATION_MIN: [CallbackQueryHandler(button_handler, pattern="^dur_min_")],
|
||||
ENTERING_TITLE: [MessageHandler(filters.TEXT & ~filters.COMMAND, handle_event_title)],
|
||||
},
|
||||
fallbacks=[],
|
||||
name="event_conv",
|
||||
)
|
||||
|
||||
# Conversation handler для добавления task
|
||||
task_conv = ConversationHandler(
|
||||
entry_points=[CallbackQueryHandler(button_handler, pattern="^add_task$")],
|
||||
states={
|
||||
SELECTING_TASK_DATE: [CallbackQueryHandler(button_handler, pattern="^date_")],
|
||||
ENTERING_TASK_TITLE: [MessageHandler(filters.TEXT & ~filters.COMMAND, handle_task_title)],
|
||||
SELECTING_REPEAT: [CallbackQueryHandler(handle_repeat_choice, pattern="^repeat_")],
|
||||
},
|
||||
fallbacks=[],
|
||||
name="task_conv",
|
||||
)
|
||||
|
||||
application.add_handler(CommandHandler("start", start))
|
||||
application.add_handler(event_conv)
|
||||
application.add_handler(task_conv)
|
||||
application.add_handler(CallbackQueryHandler(button_handler))
|
||||
|
||||
# Запуск напоминаний
|
||||
asyncio.create_task(send_reminders())
|
||||
|
||||
# Запускаем бота в фоне
|
||||
await application.initialize()
|
||||
await application.start()
|
||||
await application.updater.start_polling(drop_pending_updates=True)
|
||||
|
||||
146
backend/utils.py
Normal file
146
backend/utils.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
import pytz
|
||||
|
||||
from backend.database import Task, Event, WeeklyTaskException
|
||||
from backend.models import ScheduleItem
|
||||
|
||||
TZ = pytz.timezone("Europe/London")
|
||||
|
||||
|
||||
def get_weekday_from_date(date_str: str) -> int:
|
||||
"""Получить день недели (0=Monday, 6=Sunday) из даты"""
|
||||
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
return dt.weekday()
|
||||
|
||||
|
||||
def get_week_range(date_str: str = None):
|
||||
"""Получить диапазон дат недели (понедельник-воскресенье)"""
|
||||
if date_str:
|
||||
base_date = datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
else:
|
||||
base_date = datetime.now(TZ).date()
|
||||
|
||||
# Находим понедельник
|
||||
days_since_monday = base_date.weekday()
|
||||
monday = base_date - timedelta(days=days_since_monday)
|
||||
sunday = monday + timedelta(days=6)
|
||||
|
||||
return monday.strftime("%Y-%m-%d"), sunday.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def format_date_russian(date_str: str) -> str:
|
||||
"""Форматировать дату в русском формате: 'Вторник, 2 декабря'"""
|
||||
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
|
||||
weekdays = ["Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье"]
|
||||
months = [
|
||||
"января", "февраля", "марта", "апреля", "мая", "июня",
|
||||
"июля", "августа", "сентября", "октября", "ноября", "декабря"
|
||||
]
|
||||
|
||||
weekday = weekdays[dt.weekday()]
|
||||
month = months[dt.month - 1]
|
||||
|
||||
return f"{weekday}, {dt.day} {month}"
|
||||
|
||||
|
||||
async def check_event_overlap(
|
||||
db: AsyncSession,
|
||||
date: str,
|
||||
start_time: str,
|
||||
duration_min: int,
|
||||
exclude_id: int = None
|
||||
) -> bool:
|
||||
"""Проверить пересечение по времени с другими events"""
|
||||
# Парсим время начала
|
||||
start_hour, start_min = map(int, start_time.split(":"))
|
||||
start_minutes = start_hour * 60 + start_min
|
||||
end_minutes = start_minutes + duration_min
|
||||
|
||||
# Получаем все events на эту дату
|
||||
query = select(Event).where(Event.date == date)
|
||||
if exclude_id:
|
||||
query = query.where(Event.id != exclude_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
events = result.scalars().all()
|
||||
|
||||
for event in events:
|
||||
ev_start_hour, ev_start_min = map(int, event.start_time.split(":"))
|
||||
ev_start_minutes = ev_start_hour * 60 + ev_start_min
|
||||
ev_end_minutes = ev_start_minutes + event.duration_min
|
||||
|
||||
# Проверка пересечения интервалов
|
||||
if not (end_minutes <= ev_start_minutes or start_minutes >= ev_end_minutes):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def materialize_weekly_tasks(
|
||||
db: AsyncSession,
|
||||
from_date: str,
|
||||
to_date: str
|
||||
) -> list[ScheduleItem]:
|
||||
"""Материализовать weekly tasks в диапазоне дат"""
|
||||
from backend.database import Task, WeeklyTaskException
|
||||
|
||||
items = []
|
||||
|
||||
# Получаем все weekly tasks
|
||||
result = await db.execute(select(Task).where(Task.repeat_weekly == True))
|
||||
weekly_tasks = result.scalars().all()
|
||||
|
||||
# Получаем все исключения
|
||||
result = await db.execute(
|
||||
select(WeeklyTaskException).where(
|
||||
and_(
|
||||
WeeklyTaskException.date >= from_date,
|
||||
WeeklyTaskException.date <= to_date
|
||||
)
|
||||
)
|
||||
)
|
||||
exceptions = result.scalars().all()
|
||||
exception_dict = {(ex.date, ex.weekday): ex for ex in exceptions}
|
||||
|
||||
from_dt = datetime.strptime(from_date, "%Y-%m-%d").date()
|
||||
to_dt = datetime.strptime(to_date, "%Y-%m-%d").date()
|
||||
|
||||
current_date = from_dt
|
||||
while current_date <= to_dt:
|
||||
date_str = current_date.strftime("%Y-%m-%d")
|
||||
weekday = current_date.weekday()
|
||||
|
||||
for task in weekly_tasks:
|
||||
if task.weekday == weekday:
|
||||
# Проверяем исключения
|
||||
ex_key = (date_str, weekday)
|
||||
if ex_key in exception_dict:
|
||||
exception = exception_dict[ex_key]
|
||||
if exception.action == "delete":
|
||||
continue # Пропускаем эту дату
|
||||
elif exception.action == "replace":
|
||||
items.append(ScheduleItem(
|
||||
kind="task",
|
||||
id=task.id,
|
||||
date=date_str,
|
||||
title=exception.replacement_title,
|
||||
repeat_weekly=True
|
||||
))
|
||||
continue
|
||||
|
||||
# Обычная weekly task
|
||||
items.append(ScheduleItem(
|
||||
kind="task",
|
||||
id=task.id,
|
||||
date=date_str,
|
||||
title=task.title,
|
||||
repeat_weekly=True
|
||||
))
|
||||
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
return items
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue