Initial commit: Schedule service for son
This commit is contained in:
commit
af2ea7be06
19 changed files with 2270 additions and 0 deletions
7
.env.example
Normal file
7
.env.example
Normal 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
19
.gitignore
vendored
Normal 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
24
Dockerfile
Normal 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
112
README.md
Normal 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
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
|
||||||
|
|
||||||
29
docker-compose.yml
Normal file
29
docker-compose.yml
Normal 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
39
frontend/admin/index.html
Normal 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">×</span>
|
||||||
|
<div id="modal-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/admin/static/script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
434
frontend/admin/static/script.js
Normal file
434
frontend/admin/static/script.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
264
frontend/admin/static/style.css
Normal file
264
frontend/admin/static/style.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
25
frontend/public/index.html
Normal file
25
frontend/public/index.html
Normal 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>
|
||||||
|
|
||||||
123
frontend/public/static/script.js
Normal file
123
frontend/public/static/script.js
Normal 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);
|
||||||
|
|
||||||
100
frontend/public/static/style.css
Normal file
100
frontend/public/static/style.css
Normal 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
8
requirements.txt
Normal 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
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue