Initial commit: Schedule service for son

This commit is contained in:
vrubel 2025-12-30 12:23:42 +03:00
commit af2ea7be06
19 changed files with 2270 additions and 0 deletions

0
backend/__init__.py Normal file
View file

293
backend/api.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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