commit af2ea7be068bf3583f56ba19e8ace33ef31ce79a Author: vrubel Date: Tue Dec 30 12:23:42 2025 +0300 Initial commit: Schedule service for son diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0f9c3d7 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Telegram Bot Token +# Получите токен у @BotFather в Telegram +# Скопируйте этот файл в .env и укажите ваш токен: +# cp .env.example .env +# Затем отредактируйте .env и вставьте токен ниже: + +TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c33a15 --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1d1651c --- /dev/null +++ b/Dockerfile @@ -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"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..87351ac --- /dev/null +++ b/README.md @@ -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 минут до начала события + diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api.py b/backend/api.py new file mode 100644 index 0000000..35e05de --- /dev/null +++ b/backend/api.py @@ -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"} + diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..5e46855 --- /dev/null +++ b/backend/database.py @@ -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() + diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..130c2b8 --- /dev/null +++ b/backend/main.py @@ -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()) + diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..fc9eded --- /dev/null +++ b/backend/models.py @@ -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 + diff --git a/backend/telegram_bot.py b/backend/telegram_bot.py new file mode 100644 index 0000000..7f13e41 --- /dev/null +++ b/backend/telegram_bot.py @@ -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) + diff --git a/backend/utils.py b/backend/utils.py new file mode 100644 index 0000000..ef2117b --- /dev/null +++ b/backend/utils.py @@ -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 + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..40f4a3a --- /dev/null +++ b/docker-compose.yml @@ -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 + diff --git a/frontend/admin/index.html b/frontend/admin/index.html new file mode 100644 index 0000000..c7fd182 --- /dev/null +++ b/frontend/admin/index.html @@ -0,0 +1,39 @@ + + + + + + Админка - Расписание + + + +
+
+

Админка расписания

+
+ + + +
+
+ +
+ + +
+ +
+
+ + + + + + + + diff --git a/frontend/admin/static/script.js b/frontend/admin/static/script.js new file mode 100644 index 0000000..165c985 --- /dev/null +++ b/frontend/admin/static/script.js @@ -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 = ` +
${formatDateFull(dateStr)}
+
+ ${renderDayItems(dayItems)} +
+ `; + + grid.appendChild(column); + }); +} + +function renderDayItems(items) { + console.log('Rendering items for day:', items); + if (items.length === 0) { + return '
Нет записей
'; + } + + 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 += ` +
+
${task.title}
+ ${task.repeat_weekly ? '
Повторяется
' : ''} +
+ + +
+
+ `; + }); + + // Events + events.sort((a, b) => a.start_time.localeCompare(b.start_time)).forEach(event => { + const endTime = calculateEndTime(event.start_time, event.duration_min); + html += ` +
+
${event.start_time}-${endTime}
+
${event.title}
+
+ + +
+
+ `; + }); + + 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 = ` +

Добавить задачу

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ + +
+
+ `; + + 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 = ` +

Добавить занятие

+
+
+ + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ `; + + 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); + } + } + }); +}); + diff --git a/frontend/admin/static/style.css b/frontend/admin/static/style.css new file mode 100644 index 0000000..a71cea9 --- /dev/null +++ b/frontend/admin/static/style.css @@ -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; + } +} + diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..a6d55c8 --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,25 @@ + + + + + + Расписание + + + +
+
+

Сегодня

+
+
+ +
+

Завтра

+
+
+
+ + + + + diff --git a/frontend/public/static/script.js b/frontend/public/static/script.js new file mode 100644 index 0000000..5459038 --- /dev/null +++ b/frontend/public/static/script.js @@ -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 += '
'; + tasks.forEach(task => { + html += `
• ${task.title}
`; + }); + html += '
'; + } + + if (events.length > 0) { + html += '
'; + events.sort((a, b) => a.start_time.localeCompare(b.start_time)).forEach(event => { + const endTime = calculateEndTime(event.start_time, event.duration_min); + html += `
+ ${event.start_time}-${endTime} + ${event.title} +
`; + }); + html += '
'; + } + + if (tasks.length === 0 && events.length === 0) { + html = '
На этот день расписания нет.
'; + } + + 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); + diff --git a/frontend/public/static/style.css b/frontend/public/static/style.css new file mode 100644 index 0000000..125b646 --- /dev/null +++ b/frontend/public/static/style.css @@ -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; + } +} + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..597044e --- /dev/null +++ b/requirements.txt @@ -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 +