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

7
.env.example Normal file
View file

@ -0,0 +1,7 @@
# Telegram Bot Token
# Получите токен у @BotFather в Telegram
# Скопируйте этот файл в .env и укажите ваш токен:
# cp .env.example .env
# Затем отредактируйте .env и вставьте токен ниже:
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here

19
.gitignore vendored Normal file
View file

@ -0,0 +1,19 @@
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
.venv
data/
*.db
*.sqlite
*.sqlite3
.env
.DS_Store
# Но .env.example должен быть в репозитории
!.env.example

24
Dockerfile Normal file
View file

@ -0,0 +1,24 @@
FROM python:3.11-slim
WORKDIR /app
# Установка системных зависимостей
RUN apt-get update && apt-get install -y \
tzdata \
&& rm -rf /var/lib/apt/lists/*
# Установка Python зависимостей
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Копирование кода
COPY backend/ ./backend/
COPY frontend/ ./frontend/
# Переменные окружения
ENV TZ=Europe/London
ENV PYTHONPATH=/app
# Запуск приложения
CMD ["python", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]

112
README.md Normal file
View file

@ -0,0 +1,112 @@
# Сервис расписания для сына
Сервис для управления и отображения расписания занятий с поддержкой веб-интерфейса и Telegram-бота.
## Компоненты
1. **Backend (FastAPI)** - API для работы с расписанием
2. **Публичная веб-страница** - отображение расписания на сегодня/завтра (для планшета)
3. **Веб-админка** - управление расписанием на неделю
4. **Telegram-бот** - просмотр и редактирование расписания, напоминания
## Установка и запуск
### Требования
- Docker и Docker Compose
- Telegram Bot Token (получить у @BotFather)
### Настройка
1. Скопируйте файл `.env.example` в `.env` и укажите токен Telegram-бота:
```bash
cp .env.example .env
# Отредактируйте .env и замените your_telegram_bot_token_here на ваш реальный токен
```
Токен можно получить у [@BotFather](https://t.me/BotFather) в Telegram.
2. Запустите сервис:
```bash
docker compose up -d
```
Сервис будет доступен на:
- Публичная страница: http://localhost:8123/
- Админка: http://localhost:8123/admin
- API: http://localhost:8123/api
### Использование Telegram-бота
1. Найдите вашего бота в Telegram
2. Отправьте команду `/start`
3. Используйте кнопки для просмотра и добавления записей
## Структура данных
### Task (Задача)
- Привязка только к дате, без времени
- Может повторяться еженедельно
- Отображается выше событий со временем
### Event (Занятие)
- Привязка к дате, времени начала и длительности
- Пересечения по времени запрещены
- Имеет напоминания в Telegram (за 5 минут до начала)
## API
### GET /api/schedule?from=YYYY-MM-DD&to=YYYY-MM-DD
Получить расписание в диапазоне дат
### POST /api/events?kind=task
Создать задачу
```json
{
"date": "2024-12-02",
"title": "Название задачи",
"repeat_weekly": false,
"copy_to_weekdays": [2, 3, 4]
}
```
### POST /api/events?kind=event
Создать занятие
```json
{
"date": "2024-12-02",
"start_time": "14:30",
"duration_min": 60,
"title": "Название занятия"
}
```
### PUT /api/events/{id}
Обновить запись
### DELETE /api/events/{id}?scope=one_date|series
Удалить запись (для weekly tasks можно указать область применения)
## Резервное копирование
База данных хранится в `data/schedule.db`. Для резервного копирования:
```bash
# Создать backup
docker compose exec backend python -c "import shutil; shutil.copy2('/app/data/schedule.db', '/app/data/schedule.db.backup')"
# Восстановить из backup
docker compose exec backend python -c "import shutil; shutil.copy2('/app/data/schedule.db.backup', '/app/data/schedule.db')"
```
## Особенности
- Таймзона: Europe/London
- Неделя начинается с понедельника
- Дискретность времени: 15 минут (00, 15, 30, 45)
- Диапазон времени: 08:00-20:00
- Автообновление публичной страницы: каждые 5 минут
- Напоминания в Telegram: за 5 минут до начала события

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

29
docker-compose.yml Normal file
View file

@ -0,0 +1,29 @@
services:
backend:
build: .
ports:
- "8123:8000"
volumes:
- ./data:/app/data
- ./backend:/app/backend
- ./frontend:/app/frontend
env_file:
- .env
environment:
- TZ=Europe/London
- DATABASE_PATH=/app/data/schedule.db
restart: unless-stopped
depends_on:
- init-db
init-db:
image: alpine:latest
volumes:
- ./data:/data
command: sh -c "mkdir -p /data && touch /data/.gitkeep"
restart: "no"
volumes:
db_data:
driver: local

39
frontend/admin/index.html Normal file
View file

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Админка - Расписание</title>
<link rel="stylesheet" href="/admin/static/style.css">
</head>
<body>
<div class="container">
<header>
<h1>Админка расписания</h1>
<div class="week-navigation">
<button id="prev-week">← Предыдущая неделя</button>
<span id="week-range"></span>
<button id="next-week">Следующая неделя →</button>
</div>
</header>
<div class="actions">
<button id="add-task-btn" class="btn btn-primary"> Добавить задачу</button>
<button id="add-event-btn" class="btn btn-primary"> Добавить занятие</button>
</div>
<div id="schedule-grid" class="schedule-grid"></div>
</div>
<!-- Модальное окно для добавления/редактирования -->
<div id="modal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<div id="modal-body"></div>
</div>
</div>
<script src="/admin/static/script.js"></script>
</body>
</html>

View file

@ -0,0 +1,434 @@
let currentWeekStart = null;
const weekdays = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье'];
const weekdaysShort = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
function getTodayInLondon() {
// Получаем текущую дату в таймзоне London
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/London',
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
return formatter.format(now);
}
function getWeekStart(dateStr = null) {
if (!dateStr) {
dateStr = getTodayInLondon();
}
const date = new Date(dateStr + 'T00:00:00');
const day = date.getDay();
const diff = date.getDate() - day + (day === 0 ? -6 : 1); // Понедельник = 1
const monday = new Date(date.setDate(diff));
return monday.toISOString().split('T')[0];
}
function formatDate(dateStr) {
const date = new Date(dateStr + 'T00:00:00');
return `${weekdaysShort[date.getDay()]}, ${date.getDate()}`;
}
function formatDateFull(dateStr) {
const date = new Date(dateStr + 'T00:00:00');
const months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'];
return `${weekdays[date.getDay()]}, ${date.getDate()} ${months[date.getMonth()]}`;
}
function getWeekDates(startDate) {
const dates = [];
const start = new Date(startDate + 'T00:00:00');
for (let i = 0; i < 7; i++) {
const date = new Date(start);
date.setDate(start.getDate() + i);
dates.push(date.toISOString().split('T')[0]);
}
return dates;
}
async function loadSchedule() {
const weekDates = getWeekDates(currentWeekStart);
const fromDate = weekDates[0];
const toDate = weekDates[6];
console.log('Loading schedule from', fromDate, 'to', toDate);
try {
const response = await fetch(`/api/schedule?from_date=${fromDate}&to_date=${toDate}`);
const data = await response.json();
console.log('Received items:', data.items);
renderSchedule(weekDates, data.items);
// Обновляем заголовок недели
const startDate = new Date(weekDates[0] + 'T00:00:00');
const endDate = new Date(weekDates[6] + 'T00:00:00');
document.getElementById('week-range').textContent =
`${formatDateFull(weekDates[0])} - ${formatDateFull(weekDates[6])}`;
} catch (error) {
console.error('Error loading schedule:', error);
}
}
function renderSchedule(weekDates, items) {
const grid = document.getElementById('schedule-grid');
grid.innerHTML = '';
console.log('Rendering schedule for dates:', weekDates);
console.log('All items:', items);
weekDates.forEach(dateStr => {
const dayItems = items.filter(item => {
const match = item.date === dateStr;
if (!match && items.length > 0) {
console.log(`Item date ${item.date} !== ${dateStr}`);
}
return match;
});
console.log(`Date ${dateStr}: ${dayItems.length} items`);
const date = new Date(dateStr + 'T00:00:00');
const column = document.createElement('div');
column.className = 'day-column';
column.innerHTML = `
<div class="day-header">${formatDateFull(dateStr)}</div>
<div class="day-items" data-date="${dateStr}">
${renderDayItems(dayItems)}
</div>
`;
grid.appendChild(column);
});
}
function renderDayItems(items) {
console.log('Rendering items for day:', items);
if (items.length === 0) {
return '<div style="color: #999; font-style: italic; padding: 10px;">Нет записей</div>';
}
const tasks = items.filter(item => item.kind === 'task');
const events = items.filter(item => item.kind === 'event');
console.log('Tasks:', tasks, 'Events:', events);
let html = '';
// Tasks
tasks.forEach(task => {
html += `
<div class="item task" data-id="${task.id}" data-kind="task">
<div class="item-title">${task.title}</div>
${task.repeat_weekly ? '<div style="font-size: 11px; color: #FF9800;">Повторяется</div>' : ''}
<div class="item-actions">
<button class="btn-edit" onclick="editItem(${task.id}, 'task')">Изменить</button>
<button class="btn-delete" onclick="deleteItem(${task.id}, 'task')">Удалить</button>
</div>
</div>
`;
});
// Events
events.sort((a, b) => a.start_time.localeCompare(b.start_time)).forEach(event => {
const endTime = calculateEndTime(event.start_time, event.duration_min);
html += `
<div class="item event" data-id="${event.id}" data-kind="event">
<div class="item-time">${event.start_time}-${endTime}</div>
<div class="item-title">${event.title}</div>
<div class="item-actions">
<button class="btn-edit" onclick="editItem(${event.id}, 'event')">Изменить</button>
<button class="btn-delete" onclick="deleteItem(${event.id}, 'event')">Удалить</button>
</div>
</div>
`;
});
return html;
}
function calculateEndTime(startTime, durationMin) {
const [hour, minute] = startTime.split(':').map(Number);
const totalMinutes = hour * 60 + minute + durationMin;
const endHour = Math.floor(totalMinutes / 60);
const endMinute = totalMinutes % 60;
return `${String(endHour).padStart(2, '0')}:${String(endMinute).padStart(2, '0')}`;
}
function showModal(content) {
document.getElementById('modal-body').innerHTML = content;
document.getElementById('modal').style.display = 'block';
}
function closeModal() {
document.getElementById('modal').style.display = 'none';
}
function showAddTaskModal(selectedDate = null) {
const weekDates = getWeekDates(currentWeekStart);
const today = getTodayInLondon();
const todayDate = new Date(today + 'T00:00:00');
const selectedDateObj = selectedDate ? new Date(selectedDate + 'T00:00:00') : todayDate;
// getDay() возвращает 0=воскресенье, 1=понедельник, ..., 6=суббота
// Конвертируем в формат 0=понедельник, 6=воскресенье
const jsWeekday = selectedDateObj.getDay();
const selectedWeekday = jsWeekday === 0 ? 6 : jsWeekday - 1; // 0=пн, 6=вс
const remainingWeekdays = [];
// Оставшиеся дни недели после выбранного (среда=2, четверг=3, пятница=4, суббота=5, воскресенье=6)
for (let i = selectedWeekday + 1; i <= 6; i++) {
remainingWeekdays.push(i);
}
const content = `
<h2>Добавить задачу</h2>
<form id="add-task-form">
<div class="form-group">
<label>Дата:</label>
<input type="date" id="task-date" value="${selectedDate || today}" required>
</div>
<div class="form-group">
<label>Название:</label>
<textarea id="task-title" required></textarea>
</div>
<div class="form-group">
<div class="checkbox-group">
<input type="checkbox" id="task-repeat-weekly">
<label for="task-repeat-weekly">Повторять каждую неделю</label>
</div>
</div>
<div class="form-group" id="copy-weekdays-group" style="display: none;">
<label>Добавить на другие дни недели:</label>
<div class="weekdays-select">
${remainingWeekdays.map(day => {
// day в формате 0=пн, 6=вс, но weekdaysShort использует JS формат (0=вс)
const jsDay = day === 6 ? 0 : day + 1;
return `
<label>
<input type="checkbox" name="copy-weekday" value="${day}">
${weekdaysShort[jsDay]}
</label>
`;
}).join('')}
</div>
</div>
<div style="margin-top: 20px;">
<button type="submit" class="btn btn-primary">Добавить</button>
<button type="button" class="btn" onclick="closeModal()">Отмена</button>
</div>
</form>
`;
showModal(content);
document.getElementById('task-repeat-weekly').addEventListener('change', function() {
document.getElementById('copy-weekdays-group').style.display =
this.checked ? 'none' : 'block';
});
document.getElementById('add-task-form').addEventListener('submit', async (e) => {
e.preventDefault();
const date = document.getElementById('task-date').value;
const title = document.getElementById('task-title').value;
const repeatWeekly = document.getElementById('task-repeat-weekly').checked;
const copyWeekdays = Array.from(document.querySelectorAll('input[name="copy-weekday"]:checked'))
.map(cb => parseInt(cb.value));
try {
const response = await fetch('/api/events?kind=task', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
date,
title,
repeat_weekly: repeatWeekly,
copy_to_weekdays: copyWeekdays.length > 0 ? copyWeekdays : null
})
});
if (response.ok) {
const result = await response.json();
console.log('Task created:', result);
closeModal();
await loadSchedule();
} else {
const error = await response.json();
console.error('Error creating task:', error);
alert('Ошибка: ' + (error.detail || 'Неизвестная ошибка'));
}
} catch (error) {
console.error('Exception creating task:', error);
alert('Ошибка при добавлении задачи: ' + error.message);
}
});
}
function showAddEventModal(selectedDate = null) {
const today = getTodayInLondon();
const content = `
<h2>Добавить занятие</h2>
<form id="add-event-form">
<div class="form-group">
<label>Дата:</label>
<input type="date" id="event-date" value="${selectedDate || today}" required>
</div>
<div class="form-group">
<label>Время начала:</label>
<div style="display: flex; gap: 10px;">
<select id="event-hour" required>
${Array.from({length: 13}, (_, i) => i + 8).map(h =>
`<option value="${String(h).padStart(2, '0')}">${String(h).padStart(2, '0')}</option>`
).join('')}
</select>
<select id="event-minute" required>
<option value="00">00</option>
<option value="15">15</option>
<option value="30">30</option>
<option value="45">45</option>
</select>
</div>
</div>
<div class="form-group">
<label>Длительность:</label>
<div style="display: flex; gap: 10px;">
<select id="event-duration-hour">
<option value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</select>
<select id="event-duration-minute">
<option value="00">00</option>
<option value="15">15</option>
<option value="30">30</option>
<option value="45">45</option>
</select>
</div>
</div>
<div class="form-group">
<label>Название:</label>
<textarea id="event-title" required></textarea>
</div>
<div style="margin-top: 20px;">
<button type="submit" class="btn btn-primary">Добавить</button>
<button type="button" class="btn" onclick="closeModal()">Отмена</button>
</div>
</form>
`;
showModal(content);
document.getElementById('add-event-form').addEventListener('submit', async (e) => {
e.preventDefault();
const date = document.getElementById('event-date').value;
const hour = document.getElementById('event-hour').value;
const minute = document.getElementById('event-minute').value;
const durHour = parseInt(document.getElementById('event-duration-hour').value);
const durMin = parseInt(document.getElementById('event-duration-minute').value);
const title = document.getElementById('event-title').value;
const durationMin = durHour * 60 + durMin;
if (durationMin === 0) {
alert('Длительность должна быть минимум 15 минут');
return;
}
try {
const response = await fetch('/api/events?kind=event', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
date,
start_time: `${hour}:${minute}`,
duration_min: durationMin,
title
})
});
if (response.ok) {
const result = await response.json();
console.log('Event created:', result);
closeModal();
await loadSchedule();
} else {
const error = await response.json();
console.error('Error creating event:', error);
alert('Ошибка: ' + (error.detail || 'Неизвестная ошибка'));
}
} catch (error) {
console.error('Exception creating event:', error);
alert('Ошибка при добавлении занятия: ' + error.message);
}
});
}
async function editItem(id, kind) {
// TODO: Реализовать редактирование
alert('Редактирование будет реализовано позже');
}
async function deleteItem(id, kind) {
if (!confirm('Удалить эту запись?')) return;
try {
const response = await fetch(`/api/events/${id}`, {
method: 'DELETE'
});
if (response.ok) {
loadSchedule();
} else {
alert('Ошибка при удалении');
}
} catch (error) {
alert('Ошибка при удалении');
console.error(error);
}
}
// Инициализация
document.addEventListener('DOMContentLoaded', () => {
currentWeekStart = getWeekStart();
loadSchedule();
document.getElementById('prev-week').addEventListener('click', () => {
const date = new Date(currentWeekStart + 'T00:00:00');
date.setDate(date.getDate() - 7);
currentWeekStart = date.toISOString().split('T')[0];
loadSchedule();
});
document.getElementById('next-week').addEventListener('click', () => {
const date = new Date(currentWeekStart + 'T00:00:00');
date.setDate(date.getDate() + 7);
currentWeekStart = date.toISOString().split('T')[0];
loadSchedule();
});
document.getElementById('add-task-btn').addEventListener('click', () => showAddTaskModal());
document.getElementById('add-event-btn').addEventListener('click', () => showAddEventModal());
document.querySelector('.close').addEventListener('click', closeModal);
window.addEventListener('click', (e) => {
const modal = document.getElementById('modal');
if (e.target === modal) {
closeModal();
}
});
// Клик по дню для добавления
document.addEventListener('click', (e) => {
if (e.target.closest('.day-items')) {
const date = e.target.closest('.day-items').dataset.date;
if (e.ctrlKey || e.metaKey) {
showAddEventModal(date);
} else if (e.shiftKey) {
showAddTaskModal(date);
}
}
});
});

View file

@ -0,0 +1,264 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
header {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
header h1 {
margin-bottom: 15px;
color: #333;
}
.week-navigation {
display: flex;
align-items: center;
gap: 20px;
}
.week-navigation button {
padding: 8px 16px;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.week-navigation button:hover {
background: #45a049;
}
#week-range {
font-weight: 600;
color: #555;
}
.actions {
margin-bottom: 20px;
display: flex;
gap: 10px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-primary {
background: #2196F3;
color: white;
}
.btn-primary:hover {
background: #0b7dda;
}
.schedule-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 15px;
}
.day-column {
background: white;
border-radius: 8px;
padding: 15px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.day-header {
font-weight: 600;
font-size: 16px;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #4CAF50;
color: #333;
}
.day-items {
min-height: 200px;
}
.item {
background: #f9f9f9;
padding: 10px;
margin-bottom: 8px;
border-radius: 4px;
border-left: 3px solid #4CAF50;
cursor: pointer;
transition: background 0.2s;
}
.item:hover {
background: #f0f0f0;
}
.item.task {
border-left-color: #FF9800;
}
.item.event {
border-left-color: #2196F3;
}
.item-title {
font-weight: 500;
margin-bottom: 4px;
}
.item-time {
font-size: 12px;
color: #666;
}
.item-actions {
margin-top: 8px;
display: flex;
gap: 5px;
}
.item-actions button {
padding: 4px 8px;
font-size: 11px;
border: none;
border-radius: 3px;
cursor: pointer;
}
.btn-edit {
background: #FFC107;
color: #333;
}
.btn-delete {
background: #f44336;
color: white;
}
/* Модальное окно */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
}
.modal-content {
background-color: white;
margin: 5% auto;
padding: 30px;
border-radius: 8px;
width: 90%;
max-width: 500px;
position: relative;
}
.close {
position: absolute;
right: 20px;
top: 15px;
font-size: 28px;
font-weight: bold;
color: #aaa;
cursor: pointer;
}
.close:hover {
color: #000;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #333;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-group textarea {
min-height: 80px;
resize: vertical;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 8px;
}
.checkbox-group input[type="checkbox"] {
width: auto;
}
.weekdays-select {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.weekdays-select label {
display: flex;
align-items: center;
gap: 5px;
font-weight: normal;
}
@media (max-width: 1200px) {
.schedule-grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (max-width: 768px) {
.schedule-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.schedule-grid {
grid-template-columns: 1fr;
}
}

View file

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Расписание</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<div id="today-section" class="day-section">
<h1 class="day-title" id="today-title">Сегодня</h1>
<div id="today-content"></div>
</div>
<div id="tomorrow-section" class="day-section">
<h1 class="day-title" id="tomorrow-title">Завтра</h1>
<div id="tomorrow-content"></div>
</div>
</div>
<script src="/static/script.js"></script>
</body>
</html>

View file

@ -0,0 +1,123 @@
const TZ = 'Europe/London';
function formatDate(dateStr) {
const date = new Date(dateStr + 'T00:00:00');
// getDay() возвращает 0=воскресенье, 1=понедельник, ..., 6=суббота
// Конвертируем в формат где 0=понедельник
const jsDay = date.getDay();
const weekdayIndex = jsDay === 0 ? 6 : jsDay - 1; // 0=пн, 6=вс
const weekdays = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье'];
const months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'];
const weekday = weekdays[weekdayIndex];
const day = date.getDate();
const month = months[date.getMonth()];
return `${weekday}, ${day} ${month}`;
}
function getTodayInLondon() {
// Получаем текущую дату в таймзоне London
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/London',
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
return formatter.format(now);
}
function getTomorrowInLondon() {
const today = getTodayInLondon();
const date = new Date(today);
date.setDate(date.getDate() + 1);
return date.toISOString().split('T')[0];
}
function renderDay(container, titleEl, dateStr, items) {
titleEl.textContent = formatDate(dateStr);
const tasks = items.filter(item => item.kind === 'task');
const events = items.filter(item => item.kind === 'event');
let html = '';
if (tasks.length > 0) {
html += '<div class="tasks-list">';
tasks.forEach(task => {
html += `<div class="task-item">• ${task.title}</div>`;
});
html += '</div>';
}
if (events.length > 0) {
html += '<div class="events-list">';
events.sort((a, b) => a.start_time.localeCompare(b.start_time)).forEach(event => {
const endTime = calculateEndTime(event.start_time, event.duration_min);
html += `<div class="event-item">
<span class="event-time">${event.start_time}-${endTime}</span>
<span class="event-title">${event.title}</span>
</div>`;
});
html += '</div>';
}
if (tasks.length === 0 && events.length === 0) {
html = '<div class="empty-message">На этот день расписания нет.</div>';
}
container.innerHTML = html;
}
function calculateEndTime(startTime, durationMin) {
const [hour, minute] = startTime.split(':').map(Number);
const totalMinutes = hour * 60 + minute + durationMin;
const endHour = Math.floor(totalMinutes / 60);
const endMinute = totalMinutes % 60;
return `${String(endHour).padStart(2, '0')}:${String(endMinute).padStart(2, '0')}`;
}
async function loadSchedule() {
const today = getTodayInLondon();
const tomorrow = getTomorrowInLondon();
console.log('Loading schedule from', today, 'to', tomorrow);
try {
const response = await fetch(`/api/schedule?from_date=${today}&to_date=${tomorrow}`);
const data = await response.json();
console.log('Received items:', data.items);
const todayItems = data.items.filter(item => item.date === today);
const tomorrowItems = data.items.filter(item => item.date === tomorrow);
console.log('Today items:', todayItems);
console.log('Tomorrow items:', tomorrowItems);
renderDay(
document.getElementById('today-content'),
document.getElementById('today-title'),
today,
todayItems
);
renderDay(
document.getElementById('tomorrow-content'),
document.getElementById('tomorrow-title'),
tomorrow,
tomorrowItems
);
} catch (error) {
console.error('Error loading schedule:', error);
}
}
// Загружаем расписание при загрузке страницы
loadSchedule();
// Автообновление каждые 5 минут
setInterval(loadSchedule, 5 * 60 * 1000);

View file

@ -0,0 +1,100 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f5f5f5;
padding: 20px;
font-size: 24px;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.day-section {
background: white;
border-radius: 12px;
padding: 30px;
margin-bottom: 30px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.day-title {
font-size: 36px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 3px solid #4CAF50;
}
.tasks-list {
margin-bottom: 30px;
}
.task-item {
font-size: 28px;
padding: 15px 0;
color: #555;
border-bottom: 1px solid #eee;
}
.task-item:last-child {
border-bottom: none;
}
.events-list {
margin-top: 20px;
}
.event-item {
font-size: 28px;
padding: 15px 0;
color: #333;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
gap: 15px;
}
.event-item:last-child {
border-bottom: none;
}
.event-time {
font-weight: 600;
color: #4CAF50;
min-width: 120px;
}
.event-title {
flex: 1;
}
.empty-message {
color: #999;
font-style: italic;
padding: 20px 0;
}
@media (max-width: 768px) {
body {
font-size: 20px;
padding: 15px;
}
.day-title {
font-size: 28px;
}
.task-item, .event-item {
font-size: 22px;
}
}

8
requirements.txt Normal file
View file

@ -0,0 +1,8 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy==2.0.23
aiosqlite==0.19.0
python-telegram-bot==20.7
python-dateutil==2.8.2
pytz==2023.3