1
0
Fork 0
mirror of https://github.com/EDeev/tablo.git synced 2026-10-08 04:59:29 +03:00

Compare commits

...

8 commits

Author SHA1 Message Date
9effed12e3 README на русском и английском, документация, скриншоты
- README.md и README.en.md: что это, статус учебного проекта, скриншоты, быстрый старт в
  Docker, установка без Docker, переменные, схема, развёртывание, тесты; лишний Bootstrap
  из стека убран (стили свои);
- docs/architecture.md (структура, формат расписания, трекеры, права) и docs/deploy.md;
- пояснительная записка перенесена в docs/.
2026-10-02 13:37:11 +00:00
999ca04f6e CI: ruff и тесты на PostgreSQL на каждый push; Docker-образ по тегу в GHCR и dcr.deev.su 2026-10-02 13:31:03 +00:00
b7de6912db Docker: образ с Chromium для экспорта, compose вместе с PostgreSQL
- Dockerfile на python:3.12-slim с Playwright/Chromium, миграции при старте, gunicorn,
  непривилегированный пользователь;
- compose.yaml: приложение + PostgreSQL 16 с томом и проверкой готовности;
- .env.example переписан: SECRET_KEY, ИИ-провайдер, DATABASE_URL или DB_*;
- .gitignore.
2026-10-02 13:31:03 +00:00
ac8859d9d3 Тесты на PostgreSQL: права доступа, экспорт, ссылки, вход, разбор периодов и времени
24 теста pytest на настоящей базе (адрес — TEST_DATABASE_URL): владелец, посторонний,
редактор и ссылка на просмотр; экспорт по id только для владельца и редакторов;
регистрация и вход, защита next; периоды через Новый год, сортировка времени,
сборка дня и слияние расписаний. SQLAlchemy и alembic закреплены по продакшену:
SQLAlchemy 2.1 по умолчанию требует другой драйвер PostgreSQL (psycopg 3).
2026-10-02 13:24:18 +00:00
3a21ef916a Вход: переход по ?next= только на страницы этого сайта
После входа приложение перенаправляло на любой адрес из параметра next — ссылкой
вида /login?next=https://… можно было увести пользователя на чужой сайт.
2026-10-02 13:24:18 +00:00
164623417c Безопасность: права на экспорт, обязательный SECRET_KEY, ошибки ИИ не показываются пользователю
- экспорт в JSON/CSV/PDF/PNG пускал любого вошедшего к любому расписанию, у которого
  есть хоть одна ссылка-приглашение (достаточно перебрать id); теперь — только владелец
  и редакторы, как и просмотр по id;
- три копии проверки прав сведены в app/access.py;
- без SECRET_KEY приложение не запускается (кроме отладки и тестов): со значением по
  умолчанию cookie сессии можно было подделать и войти под любым пользователем;
- текст исключения от ИИ-провайдера больше не уходит в интерфейс, ошибка пишется в лог;
- DATABASE_URL как альтернатива DB_*; debug в run.py — из FLASK_DEBUG;
- неиспользуемые импорты и переменные удалены (ruff).
2026-10-02 13:10:20 +00:00
af060982cf Зависимости: playwright и gunicorn закреплены по продакшену, dev-зависимости и настройки ruff/pytest 2026-10-02 13:10:20 +00:00
1db34e9794 Исправления с продакшена: периоды через Новый год, сортировка по времени, debug выключен
- период расписания вида 01.09–03.01 больше не даёт конец раньше начала: конец
  относится к следующему году;
- занятия сортируются по времени начала как по числу, «9:00» больше не оказывается
  после «10:00»;
- run.py запускает сервер без режима отладки.
2026-10-02 13:07:38 +00:00
37 changed files with 835 additions and 133 deletions

10
.dockerignore Normal file
View file

@ -0,0 +1,10 @@
.git
.github
**/__pycache__
.venv
venv
.env
tests
docs
*.md
*.pdf

View file

@ -1,17 +1,15 @@
# ── OpenAI API ────────────────────────────────────────────────── # Flask: длинная случайная строка, без неё приложение не запустится
SECRET_KEY=
# ИИ-провайдер с OpenAI-совместимым API (распознавание фото и генерация трекеров)
OPENAI_API_KEY= OPENAI_API_KEY=
OPENAI_BASE_URL= # OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL= OPENAI_MODEL=gpt-4o
# ── PostgreSQL ───────────────────────────────────────────────── # PostgreSQL: либо DATABASE_URL целиком, либо DB_* по отдельности
# DATABASE_URL=postgresql://tablo:tablo@localhost:5432/tablo
DB_HOST= DB_HOST=localhost
DB_PORT= DB_PORT=5432
DB_NAME= DB_NAME=tablo
DB_USER= DB_USER=tablo
DB_PASSWORD= DB_PASSWORD=tablo
# ── Переменные окружения ───────────────────────────────────────
PYTHONDONTWRITEBYTECODE=1

34
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,34 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: tablo
POSTGRES_PASSWORD: tablo
POSTGRES_DB: tablo_test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U tablo" --health-interval 5s --health-retries 10
env:
TEST_DATABASE_URL: postgresql://tablo:tablo@localhost:5432/tablo_test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: requirements-dev.txt
- run: pip install -r requirements-dev.txt
- name: Ruff
run: ruff check .
- name: Тесты
run: pytest -q

42
.github/workflows/docker.yml vendored Normal file
View file

@ -0,0 +1,42 @@
name: Docker
on:
push:
tags: ["v*"]
workflow_dispatch:
jobs:
image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/login-action@v3
with:
registry: dcr.deev.su
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
- id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/edeev/tablo
dcr.deev.su/edeev/tablo
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
.venv/
venv/
.env
.pytest_cache/
.ruff_cache/

20
Dockerfile Normal file
View file

@ -0,0 +1,20 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
FLASK_APP=run.py \
PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \
&& playwright install --with-deps chromium \
&& rm -rf /var/lib/apt/lists/*
COPY . .
RUN useradd --create-home --uid 1000 app && chown -R app:app /app
USER app
EXPOSE 8000
CMD ["sh", "-c", "flask db upgrade && gunicorn run:app --bind 0.0.0.0:8000 --workers 2 --timeout 120 --access-logfile -"]

121
README.en.md Normal file
View file

@ -0,0 +1,121 @@
# Tablo
[Русский](README.md) · **English**
[![CI](https://github.com/EDeev/tablo/actions/workflows/ci.yml/badge.svg)](https://github.com/EDeev/tablo/actions/workflows/ci.yml)
[![Docker](https://github.com/EDeev/tablo/actions/workflows/docker.yml/badge.svg)](https://github.com/EDeev/tablo/actions/workflows/docker.yml)
[![Release](https://img.shields.io/github/v/release/EDeev/tablo)](https://github.com/EDeev/tablo/releases)
A web app for students: upload a photo of your timetable, and AI turns it into an editable schedule
with per-subject progress trackers you can share by link.
**Status:** coursework project (2026), completed · live at [tablo.deev.su](https://tablo.deev.su)
![Weekly schedule and trackers](docs/screenshots/schedule.png)
**Stack:** Python 3.12 · Flask · SQLAlchemy + Alembic · PostgreSQL · OpenAI-compatible Vision API · Playwright · Jinja2 + vanilla JS
## Features
- Timetable recognition from a photo or scan
- Editing of schedules, subjects and classes; periods may span New Year
- 11 tracker types (attendance, grades, deadlines, day streaks, etc.) on a drag-and-drop grid
- Tracker generation from a description such as "8 labs and an exam"
- Link sharing: view, edit, or copy as a template
- Merging several schedules into one, with classes of the same subject combined
- Export to JSON, CSV, PNG and a print-ready version
## Quick start
```bash
git clone https://github.com/EDeev/tablo.git && cd tablo
cp .env.example .env # set SECRET_KEY and your AI provider key
docker compose up -d
```
Open `http://localhost:8000`. Compose starts the app and PostgreSQL; migrations run on startup.
Prebuilt image: `docker pull ghcr.io/edeev/tablo` or `docker pull dcr.deev.su/edeev/tablo`.
## Installing without Docker
Requires Python 3.11+ and PostgreSQL.
```bash
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
playwright install --with-deps chromium # for PNG export
cp .env.example .env
flask --app run db upgrade
python run.py
```
## Configuration
| Variable | Purpose |
|---|---|
| `SECRET_KEY` | Flask session signing; the app will not start without it |
| `DATABASE_URL` or `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` | PostgreSQL connection |
| `OPENAI_API_KEY` | AI provider key |
| `OPENAI_BASE_URL` | OpenAI-compatible API URL, if not OpenAI |
| `OPENAI_MODEL` | model for recognition and trackers, `gpt-4o` by default |
> [!IMPORTANT]
> Without `OPENAI_API_KEY`, timetable recognition and tracker generation are disabled. Everything else
> works: you can create and edit a schedule manually.
## Screenshots
| My schedules | Subject and trackers | Phone |
|---|---|---|
| ![My schedules](docs/screenshots/profile.png) | ![Subject and trackers](docs/screenshots/subjects.png) | ![Phone](docs/screenshots/mobile.png) |
## How it works
```mermaid
flowchart LR
B[Browser] --> F[Flask: auth · schedules · subjects · shares · export]
F --> S[Services: ai_scan · ai_metrics · merge · export]
F --> P[(PostgreSQL)]
S --> A[OpenAI-compatible API]
S --> C[Chromium via Playwright]
```
Details (in Russian):
- [docs/architecture.md](docs/architecture.md) — structure, schedule format, trackers, access rules
- [docs/deploy.md](docs/deploy.md) — Docker and the production setup
- [docs/explanatory_note.pdf](docs/explanatory_note.pdf) — coursework explanatory note
## Deployment
[tablo.deev.su](https://tablo.deev.su) runs on a VPS: gunicorn under systemd behind nginx with a
Let's Encrypt certificate; PostgreSQL runs on a separate server; AI is Timeweb Cloud AI. GitHub Actions
builds the Docker image on every `v*` tag and publishes it to GitHub Packages and to `dcr.deev.su`.
## Development
```bash
pip install -r requirements-dev.txt
export TEST_DATABASE_URL=postgresql://tablo:tablo@localhost:5432/tablo_test
ruff check . && pytest
```
Tests run against a real PostgreSQL: access rules and export, share links, sign-in and open-redirect
protection, period and time parsing, schedule merging. CI starts the database as a service container
and runs the same checks on every push.
## License
Coursework project (Moscow Polytechnic University, 2025/26). The code is open for study; there is no
separate license.
## Author
**Egor Deev** — [GitHub](https://github.com/EDeev) · [Telegram](https://t.me/DeevEgor) · [egor@deev.space](mailto:egor@deev.space)
---
<div align="center">
<sub>⭐ If you find this project useful, give it a star on GitHub!</sub>
<p><sub>Made with ❤️ — <a href="https://deev.space">deev.space</a></sub></p>
</div>

130
README.md
View file

@ -1,77 +1,121 @@
# Tablo # Tablo
Веб-приложение для работы с учебным расписанием: распознавание расписания из изображения с помощью ИИ, персональные трекеры успеваемости и совместный доступ по ссылке. **Русский** · [English](README.en.md)
Сайт: [tablo.deev.su](https://tablo.deev.su) [![CI](https://github.com/EDeev/tablo/actions/workflows/ci.yml/badge.svg)](https://github.com/EDeev/tablo/actions/workflows/ci.yml)
[![Docker](https://github.com/EDeev/tablo/actions/workflows/docker.yml/badge.svg)](https://github.com/EDeev/tablo/actions/workflows/docker.yml)
[![Release](https://img.shields.io/github/v/release/EDeev/tablo)](https://github.com/EDeev/tablo/releases)
## Функциональность Веб-приложение для студентов: по фото расписания ИИ собирает редактируемое расписание, к каждому
предмету можно добавить трекеры успеваемости и поделиться всем по ссылке.
- Загрузка фотографии или скана расписания и автоматическое извлечение структурированных данных через AI Vision API **Статус:** учебный проект (курсовая работа, 2026), завершён · работает на [tablo.deev.su](https://tablo.deev.su)
- Полный CRUD для расписаний, предметов и слотов занятий
- 11 типов виджетов трекеров успеваемости (посещаемость, оценки, дедлайны, серии дней и др.)
- Дашборд с перетаскиваемыми виджетами на сетке 12 колонок
- Совместный доступ по токен-ссылке с тремя режимами: просмотр, редактирование, клонирование как шаблон
- Объединение нескольких расписаний с разрешением конфликтов по предметам
## Технологический стек ![Расписание на неделю и трекеры](docs/screenshots/schedule.png)
- **Backend:** Python, Flask, SQLAlchemy, Flask-Migrate, Flask-Login **Стек:** Python 3.12 · Flask · SQLAlchemy + Alembic · PostgreSQL · OpenAI-совместимый Vision API · Playwright · Jinja2 + vanilla JS
- **База данных:** PostgreSQL (JSONB для хранения структуры расписания)
- **ИИ:** Gemini 3.1 Flash через OpenAI-совместимый API (Timeweb Cloud AI)
- **Frontend:** Jinja2, Bootstrap 5.3, Vanilla JS, Fetch API
## Установка ## Возможности
**Требования:** Python 3.11+, PostgreSQL - Распознавание расписания по фото или скану
- Редактирование расписаний, предметов и занятий, периоды с переходом через Новый год
- 11 видов трекеров (посещаемость, оценки, дедлайны, серии дней и др.) на сетке с перетаскиванием
- Генерация трекера по описанию: «8 лабораторных и экзамен»
- Доступ по ссылке: просмотр, редактирование или копия как шаблон
- Объединение нескольких расписаний в одно: занятия одного предмета сводятся вместе
- Экспорт в JSON, CSV, PNG и версию для печати
## Быстрый старт
```bash ```bash
git clone https://github.com/EDeev/tablo.git git clone https://github.com/EDeev/tablo.git && cd tablo
cd tablo cp .env.example .env # задайте SECRET_KEY и ключ ИИ-провайдера
python -m venv venv docker compose up -d
venv\Scripts\activate # Windows
# source venv/bin/activate # Linux/macOS
pip install -r requirements.txt
``` ```
Заполните файл `.env` в корне проекта Откройте `http://localhost:8000`. Compose поднимает приложение и PostgreSQL, миграции применяются
при старте. Готовый образ: `docker pull ghcr.io/edeev/tablo` или `docker pull dcr.deev.su/edeev/tablo`.
Примените миграции и запустите приложение: ## Установка без Docker
Нужны Python 3.11+ и PostgreSQL.
```bash ```bash
flask db upgrade python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
playwright install --with-deps chromium # для экспорта в PNG
cp .env.example .env
flask --app run db upgrade
python run.py python run.py
``` ```
Приложение будет доступно по адресу `http://localhost:5000`. ## Конфигурация
## Структура проекта | Переменная | Назначение |
|---|---|
| `SECRET_KEY` | подпись сессий Flask; без неё приложение не запустится |
| `DATABASE_URL` или `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` | подключение к PostgreSQL |
| `OPENAI_API_KEY` | ключ ИИ-провайдера |
| `OPENAI_BASE_URL` | адрес OpenAI-совместимого API, если это не OpenAI |
| `OPENAI_MODEL` | модель для распознавания и трекеров, по умолчанию `gpt-4o` |
> [!IMPORTANT]
> Без `OPENAI_API_KEY` не работают распознавание расписаний и генерация трекеров. Всё остальное работает:
> расписание можно завести и править вручную.
## Как выглядит
| Мои расписания | Предмет и трекеры | Телефон |
|---|---|---|
| ![Мои расписания](docs/screenshots/profile.png) | ![Предмет и трекеры](docs/screenshots/subjects.png) | ![Телефон](docs/screenshots/mobile.png) |
## Как устроено
```mermaid
flowchart LR
B[Браузер] --> F[Flask: auth · schedules · subjects · shares · export]
F --> S[Сервисы: ai_scan · ai_metrics · merge · export]
F --> P[(PostgreSQL)]
S --> A[OpenAI-совместимый API]
S --> C[Chromium через Playwright]
``` ```
tablo/
├── app/ Подробности:
│ ├── models/ # Модели SQLAlchemy
│ ├── routes/ # Блюпринты (auth, schedules, subjects, shares) - [docs/architecture.md](docs/architecture.md) — структура, формат расписания, трекеры, права доступа
│ ├── services/ # Бизнес-логика (AI-распознавание, AI-генерация метрик) - [docs/deploy.md](docs/deploy.md) — Docker и устройство боевого сервера
│ └── templates/ # HTML-шаблоны Jinja2 - [docs/explanatory_note.pdf](docs/explanatory_note.pdf) — пояснительная записка к курсовой
├── migrations/ # Файлы миграций Flask-Migrate
├── requirements.txt ## Развёртывание
└── run.py
[tablo.deev.su](https://tablo.deev.su) работает на VPS: gunicorn под systemd за nginx с сертификатом
Let's Encrypt, PostgreSQL — на отдельном сервере, ИИ — Timeweb Cloud AI. Docker-образ собирает GitHub
Actions на каждый тег `v*` и публикует в GitHub Packages и в реестр `dcr.deev.su`.
## Разработка
```bash
pip install -r requirements-dev.txt
export TEST_DATABASE_URL=postgresql://tablo:tablo@localhost:5432/tablo_test
ruff check . && pytest
``` ```
Тесты идут на настоящем PostgreSQL: права доступа и экспорт, ссылки, вход и защита от открытого
редиректа, разбор периодов и времени, слияние расписаний. CI поднимает базу в сервис-контейнере и
выполняет то же самое на каждый push.
## Лицензия ## Лицензия
Этот проект является некоммерческим и распространяется под лицензией MIT. Учебный проект (курсовая работа, Московский Политех, 2025/26). Код открыт для изучения, отдельной
лицензии нет.
## Автор ## Автор
**Деев Егор Викторович** - Backend Developer **Деев Егор Викторович** — [GitHub](https://github.com/EDeev) · [Telegram](https://t.me/DeevEgor) · [egor@deev.space](mailto:egor@deev.space)
- GitHub: [@EDeev](https://github.com/EDeev)
- Email: egor@deev.space
- Telegram: [@Egor_Deev](https://t.me/Egor_Deev)
--- ---
<div align="center"> <div align="center">
<sub>⭐ Если проект оказался полезным, поставьте звездочку на GitHub!</sub> <sub>⭐ Если проект оказался полезным, поставьте звёздочку на GitHub!</sub>
<p><sub>Создано с ❤️ от вашего дорогого - deev.space ©</sub></p> <p><sub>Сделано с ❤️ — <a href="https://deev.space">deev.space</a></sub></p>
</div> </div>

View file

@ -9,9 +9,15 @@ migrate = Migrate()
login_manager = LoginManager() login_manager = LoginManager()
csrf = CSRFProtect() csrf = CSRFProtect()
def create_app(): def create_app(config_overrides=None):
app = Flask(__name__) app = Flask(__name__)
app.config.from_object('app.config.Config') app.config.from_object('app.config.Config')
if config_overrides:
app.config.update(config_overrides)
if not app.config.get('SECRET_KEY'):
if not (app.debug or app.testing):
raise RuntimeError('Задайте SECRET_KEY: без него cookie сессий можно подделать')
app.config['SECRET_KEY'] = 'dev-only-secret' # noqa: S105 — только для отладки и тестов
db.init_app(app) db.init_app(app)
migrate.init_app(app, db) migrate.init_app(app, db)

20
app/access.py Normal file
View file

@ -0,0 +1,20 @@
"""Права доступа к расписанию: владелец или редактор по ссылке на редактирование."""
from flask_login import current_user
from app.models.schedule import Schedule
from app.models.share import Share, ShareEditor
def can_edit(schedule: Schedule) -> bool:
if not current_user.is_authenticated:
return False
if schedule.user_id == current_user.id:
return True
edit_share = Share.query.filter_by(schedule_id=schedule.id, share_type='edit').first()
if edit_share is None:
return False
return ShareEditor.query.filter_by(share_id=edit_share.id, user_id=current_user.id).first() is not None
# Просмотр по id доступен тем же, кто может редактировать; остальные смотрят по ссылке /shared/<token>
can_view = can_edit

View file

@ -1,14 +1,22 @@
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
class Config:
SECRET_KEY = os.getenv('SECRET_KEY', 'change-me-in-production') def _database_url():
SQLALCHEMY_DATABASE_URI = ( if os.getenv('DATABASE_URL'):
return os.getenv('DATABASE_URL')
return (
f"postgresql://{os.getenv('DB_USER')}:{os.getenv('DB_PASSWORD')}" f"postgresql://{os.getenv('DB_USER')}:{os.getenv('DB_PASSWORD')}"
f"@{os.getenv('DB_HOST')}:{os.getenv('DB_PORT')}/{os.getenv('DB_NAME')}" f"@{os.getenv('DB_HOST')}:{os.getenv('DB_PORT')}/{os.getenv('DB_NAME')}"
) )
class Config:
SECRET_KEY = os.getenv('SECRET_KEY')
SQLALCHEMY_DATABASE_URI = _database_url()
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ENGINE_OPTIONS = { SQLALCHEMY_ENGINE_OPTIONS = {
'pool_pre_ping': True, 'pool_pre_ping': True,

View file

@ -2,3 +2,5 @@ from app.models.user import User
from app.models.schedule import Schedule, SubjectConfig from app.models.schedule import Schedule, SubjectConfig
from app.models.metric import Metric from app.models.metric import Metric
from app.models.share import Share, ShareEditor from app.models.share import Share, ShareEditor
__all__ = ["User", "Schedule", "SubjectConfig", "Metric", "Share", "ShareEditor"]

View file

@ -1,3 +1,5 @@
from urllib.parse import urlsplit
from flask import Blueprint, render_template, redirect, url_for, flash, request from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required, current_user from flask_login import login_user, logout_user, login_required, current_user
from app import db from app import db
@ -6,6 +8,13 @@ from app.models.user import User
bp = Blueprint('auth', __name__) bp = Blueprint('auth', __name__)
def _is_safe_next(target: str) -> bool:
"""Переход после входа — только на страницы этого же сайта (защита от открытого редиректа)."""
parts = urlsplit(target)
return (bool(target) and target.startswith('/') and not target.startswith(('//', '/\\'))
and not parts.netloc and not parts.scheme)
@bp.route('/register', methods=['GET', 'POST']) @bp.route('/register', methods=['GET', 'POST'])
def register(): def register():
if current_user.is_authenticated: if current_user.is_authenticated:
@ -42,8 +51,10 @@ def login():
user = User.query.filter_by(login=login).first() user = User.query.filter_by(login=login).first()
if user and user.check_password(password): if user and user.check_password(password):
login_user(user, remember=bool(request.form.get('remember'))) login_user(user, remember=bool(request.form.get('remember')))
next_page = request.args.get('next') next_page = request.args.get('next', '')
return redirect(next_page or url_for('schedules.profile')) if not _is_safe_next(next_page):
next_page = url_for('schedules.profile')
return redirect(next_page)
flash('Неверный логин или пароль', 'danger') flash('Неверный логин или пароль', 'danger')
return render_template('auth/login.html') return render_template('auth/login.html')

View file

@ -1,20 +1,12 @@
import urllib.parse import urllib.parse
from flask import Blueprint, Response, redirect, url_for, flash from flask import Blueprint, Response, redirect, url_for, flash
from flask_login import login_required, current_user from flask_login import login_required
from app.models.schedule import Schedule from app.models.schedule import Schedule
from app.models.share import Share from app.access import can_view as _can_view
from app import db
bp = Blueprint('export', __name__) bp = Blueprint('export', __name__)
def _can_view(schedule: Schedule) -> bool:
if schedule.user_id == current_user.id:
return True
share = Share.query.filter_by(schedule_id=schedule.id).first()
return share is not None
@bp.route('/schedules/<int:schedule_id>/export/json') @bp.route('/schedules/<int:schedule_id>/export/json')
@login_required @login_required
def export_json(schedule_id: int): def export_json(schedule_id: int):

View file

@ -1,10 +1,10 @@
import copy import copy
from flask import (Blueprint, render_template, redirect, url_for, from flask import (Blueprint, current_app, render_template, redirect, url_for,
flash, request, jsonify) flash, request, jsonify)
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db, csrf from app import db, csrf
from app.models.schedule import Schedule, SubjectConfig from app.models.schedule import Schedule, SubjectConfig
from app.models.metric import Metric from app.access import can_edit as _can_edit, can_view as _can_view
from app.services.ai_scan import scan_image from app.services.ai_scan import scan_image
from app.services.merge import merge_schedules_data from app.services.merge import merge_schedules_data
from app.services.schedule_helpers import ( from app.services.schedule_helpers import (
@ -46,8 +46,9 @@ def upload():
try: try:
image_bytes = file.read() image_bytes = file.read()
data = scan_image(image_bytes, ext, extra_prompt) data = scan_image(image_bytes, ext, extra_prompt)
except Exception as e: except Exception:
flash(f'Ошибка сканирования: {e}', 'danger') current_app.logger.exception('Ошибка распознавания расписания')
flash('Не удалось распознать расписание. Попробуйте другое фото или повторите позже.', 'danger')
return render_template('schedule/upload.html') return render_template('schedule/upload.html')
schedule = Schedule(user_id=current_user.id, name=name, data=data) schedule = Schedule(user_id=current_user.id, name=name, data=data)
@ -198,9 +199,9 @@ def rename_subject_field(schedule_id, subject_name):
def _replace_slot_field(item: dict, idx: int, value): def _replace_slot_field(item: dict, idx: int, value):
"""Меняет поле слота по индексу во всех типах и датах""" """Меняет поле слота по индексу во всех типах и датах"""
for tn, type_data in item.get('types', {}).items(): for type_data in item.get('types', {}).values():
date_ranges = type_data.get('dates', type_data) if isinstance(type_data, dict) and 'dates' in type_data else type_data date_ranges = type_data.get('dates', type_data) if isinstance(type_data, dict) and 'dates' in type_data else type_data
for dr, slots in date_ranges.items(): for slots in date_ranges.values():
for slot in slots: for slot in slots:
while len(slot) <= idx: while len(slot) <= idx:
slot.append(None) slot.append(None)
@ -442,30 +443,3 @@ def delete_slot(schedule_id, subject_name):
db.session.commit() db.session.commit()
return jsonify({'ok': True}) return jsonify({'ok': True})
# ─── Вспомогательные функции доступа ─────────────────────────────────────────
def _can_view(schedule: Schedule) -> bool:
if schedule.user_id == current_user.id:
return True
from app.models.share import ShareEditor, Share
edit_share = Share.query.filter_by(schedule_id=schedule.id, share_type='edit').first()
if edit_share:
editor = ShareEditor.query.filter_by(
share_id=edit_share.id, user_id=current_user.id).first()
if editor:
return True
return False
def _can_edit(schedule: Schedule) -> bool:
if schedule.user_id == current_user.id:
return True
from app.models.share import ShareEditor, Share
edit_share = Share.query.filter_by(schedule_id=schedule.id, share_type='edit').first()
if edit_share:
editor = ShareEditor.query.filter_by(
share_id=edit_share.id, user_id=current_user.id).first()
if editor:
return True
return False

View file

@ -2,11 +2,10 @@ from flask import (Blueprint, render_template, redirect, url_for,
flash, request, jsonify) flash, request, jsonify)
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db, csrf from app import db, csrf
from app.models.schedule import Schedule, SubjectConfig from app.models.schedule import Schedule
from app.models.share import Share, ShareEditor from app.models.share import Share, ShareEditor
from app.models.user import User from app.models.user import User
from app.services.schedule_helpers import build_week_view, get_week_dates, DAYS_DISPLAY, DAYS_RU from app.services.schedule_helpers import build_week_view, get_week_dates, DAYS_DISPLAY, DAYS_RU
from app.services.merge import merge_schedules_data
from datetime import date from datetime import date
bp = Blueprint('shares', __name__) bp = Blueprint('shares', __name__)

View file

@ -1,10 +1,11 @@
from flask import Blueprint, render_template, request, jsonify from flask import Blueprint, current_app, jsonify, render_template, request
from flask_login import login_required, current_user from flask_login import login_required
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
from app import db, csrf from app import db, csrf
from app.models.schedule import Schedule, SubjectConfig, COMPLETION_TYPES from app.models.schedule import Schedule, SubjectConfig, COMPLETION_TYPES
from app.models.metric import Metric, METRIC_TYPES, METRIC_LABELS from app.models.metric import Metric, METRIC_TYPES, METRIC_LABELS
from app.services.ai_metrics import generate_metric_from_prompt from app.services.ai_metrics import generate_metric_from_prompt
from app.access import can_edit as _check_edit_access
bp = Blueprint('subjects', __name__) bp = Blueprint('subjects', __name__)
@ -87,8 +88,9 @@ def add_metric_ai(schedule_id, subject_name):
try: try:
result = generate_metric_from_prompt(subject_name, prompt) result = generate_metric_from_prompt(subject_name, prompt)
except Exception as e: except Exception:
return jsonify({'error': str(e)}), 500 current_app.logger.exception('Ошибка генерации метрики')
return jsonify({'error': 'Не удалось сгенерировать метрику, попробуйте позже'}), 500
cfg = _get_or_create_config(schedule_id, subject_name) cfg = _get_or_create_config(schedule_id, subject_name)
metric = Metric(subject_config_id=cfg.id, metric = Metric(subject_config_id=cfg.id,
@ -282,15 +284,6 @@ def _get_or_create_config(schedule_id: int, subject_name: str) -> SubjectConfig:
return config return config
def _check_edit_access(schedule: Schedule) -> bool:
if schedule.user_id == current_user.id:
return True
from app.models.share import Share, ShareEditor
share = Share.query.filter_by(schedule_id=schedule.id, share_type='edit').first()
if share:
return ShareEditor.query.filter_by(
share_id=share.id, user_id=current_user.id).first() is not None
return False
def _render_metric_html(metric: Metric) -> str: def _render_metric_html(metric: Metric) -> str:

View file

@ -1,8 +1,6 @@
import base64 import base64
import json import json
import os import os
import tempfile
from pathlib import Path
from openai import OpenAI from openai import OpenAI
from dotenv import load_dotenv from dotenv import load_dotenv

View file

@ -2,7 +2,7 @@ import csv
import io import io
import json import json
from flask import current_app, render_template from flask import render_template
DAYS_ORDER = ['понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье'] DAYS_ORDER = ['понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье']
@ -66,7 +66,7 @@ def export_csv(schedule) -> bytes:
def _build_week_view_active(schedule_data: list) -> dict: def _build_week_view_active(schedule_data: list) -> dict:
"""Слоты по дням — только из периодов, которые ещё не закончились на сегодня.""" """Слоты по дням — только из периодов, которые ещё не закончились на сегодня."""
from datetime import date from datetime import date
from app.services.schedule_helpers import _parse_date_range from app.services.schedule_helpers import _parse_date_range, _time_sort_key
today = date.today() today = date.today()
days = {i: [] for i in range(7)} days = {i: [] for i in range(7)}
for item in schedule_data: for item in schedule_data:
@ -106,7 +106,7 @@ def _build_week_view_active(schedule_data: list) -> dict:
if key not in seen: if key not in seen:
seen.add(key) seen.add(key)
unique.append(s) unique.append(s)
days[i] = sorted(unique, key=lambda s: s.get('time') or '') days[i] = sorted(unique, key=lambda s: _time_sort_key(s.get('time')))
return days return days

View file

@ -12,8 +12,13 @@ def _parse_date_range(dr: str):
try: try:
start_str, end_str = dr[:5], dr[6:] start_str, end_str = dr[:5], dr[6:]
year = date.today().year year = date.today().year
start = date(year, int(start_str[3:5]), int(start_str[0:2])) start_month, start_day = int(start_str[3:5]), int(start_str[0:2])
end = date(year, int(end_str[3:5]), int(end_str[0:2])) end_month, end_day = int(end_str[3:5]), int(end_str[0:2])
start = date(year, start_month, start_day)
# Период может пересекать Новый год (например "01.09-03.01") —
# тогда конец периода относится к следующему году
end_year = year if (end_month, end_day) >= (start_month, start_day) else year + 1
end = date(end_year, end_month, end_day)
return start, end return start, end
except Exception: except Exception:
return None, None return None, None
@ -36,6 +41,17 @@ def _merge_same_subject(slots: list) -> dict:
return merged return merged
def _time_sort_key(time_str):
"""Ключ сортировки по времени начала занятия (устойчив к '9:00' вместо '09:00')"""
if time_str:
try:
h, m = time_str.split('-', 1)[0].strip().split(':')
return (int(h), int(m))
except (ValueError, IndexError):
pass
return (99, 99)
def _group_by_time(slots: list) -> list: def _group_by_time(slots: list) -> list:
"""Группирует слоты по времени; один предмет — мёрджит поля; разные предметы — группа""" """Группирует слоты по времени; один предмет — мёрджит поля; разные предметы — группа"""
by_time = defaultdict(list) by_time = defaultdict(list)
@ -43,7 +59,7 @@ def _group_by_time(slots: list) -> list:
by_time[slot.get('time') or ''].append(slot) by_time[slot.get('time') or ''].append(slot)
result = [] result = []
for time_key in sorted(by_time.keys()): for time_key in sorted(by_time.keys(), key=_time_sort_key):
group = by_time[time_key] group = by_time[time_key]
subjects = {s['subject'] for s in group} subjects = {s['subject'] for s in group}
if len(subjects) == 1: if len(subjects) == 1:
@ -87,7 +103,7 @@ def build_day_view(schedule_data: list, target_date: date) -> list:
'day': day_name, 'day': day_name,
}) })
raw.sort(key=lambda x: x.get('time') or '') raw.sort(key=lambda x: _time_sort_key(x.get('time')))
return _group_by_time(raw) return _group_by_time(raw)

33
compose.yaml Normal file
View file

@ -0,0 +1,33 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: tablo
POSTGRES_PASSWORD: ${DB_PASSWORD:-tablo}
POSTGRES_DB: tablo
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tablo"]
interval: 5s
retries: 10
restart: unless-stopped
web:
build: .
image: ghcr.io/edeev/tablo:latest
depends_on:
db:
condition: service_healthy
ports:
- "8000:8000"
env_file:
- path: .env
required: false
environment:
SECRET_KEY: ${SECRET_KEY:-change-me-to-a-long-random-string}
DATABASE_URL: postgresql://tablo:${DB_PASSWORD:-tablo}@db:5432/tablo
restart: unless-stopped
volumes:
pgdata:

63
docs/architecture.md Normal file
View file

@ -0,0 +1,63 @@
# Как устроен Tablo
Flask-приложение с фабрикой `create_app`, блюпринтами и сервисным слоем. База — PostgreSQL
(структура расписания хранится в JSON-поле), миграции — Flask-Migrate (Alembic).
```
app/
__init__.py фабрика приложения, расширения (SQLAlchemy, Migrate, Login, CSRF)
config.py настройки из окружения
access.py права: владелец или редактор по ссылке
models/ User, Schedule, SubjectConfig, Metric, Share, ShareEditor
routes/ auth, schedules, subjects, shares, export
services/
ai_scan.py распознавание расписания по фото (vision-модель)
ai_metrics.py генерация трекера по текстовому запросу
merge.py слияние нескольких расписаний
schedule_helpers.py разбор периодов, сборка дня и недели
export.py PNG и версия для печати через Playwright/Chromium
templates/, static/
migrations/ ревизии Alembic
tests/ pytest на PostgreSQL
```
## Формат расписания
`Schedule.data` — список предметов. У предмета — типы занятий (лекция, практика…), у типа — цвет и
периоды, в периоде — слоты `[день, время, аудитория, преподаватель]`:
```json
[
{
"subject": "Базы данных",
"types": {
"Лекция": {"color": "blue", "dates": {"01.09-31.12": [["понедельник", "09:00-10:30", "АВ-301", "Смирнова Е.А."]]}}
}
}
]
```
Период `ДД.ММ-ДД.ММ` может переходить через Новый год (`01.09-03.01`), «Весь период» — без
ограничений. Время сортируется как число, поэтому `9:00` и `09:00` равнозначны.
## Распознавание и трекеры
`ai_scan.py` отправляет фото и подробный системный промпт в OpenAI-совместимый API и разбирает
ответ в JSON указанного формата. `ai_metrics.py` по запросу вида «8 лабораторных и экзамен» подбирает
тип трекера и его настройки. Ошибки провайдера пишутся в лог, пользователь видит общее сообщение.
Типы трекеров: контрольные точки, накопление баллов, счётчик, этапы, чеклист, посещаемость, оценки
с весами, дедлайны, серия дней, трудозатраты, история оценок. Трекеры лежат на сетке из 12 колонок,
положение и размер сохраняются.
## Доступ
| Кто | Что может |
|---|---|
| владелец | всё, включая экспорт и удаление |
| редактор (добавлен в ссылку на редактирование) | просматривать и править по id, экспортировать |
| любой со ссылкой на просмотр `/shared/<token>` | только смотреть, без входа |
| вошедший пользователь со ссылкой-шаблоном | получить копию расписания себе |
Проверка прав — одна функция `app/access.py`. Формы защищены CSRF (Flask-WTF), пароли хранятся
в виде хешей Werkzeug, переход после входа (`?next=`) — только внутри сайта.

25
docs/deploy.md Normal file
View file

@ -0,0 +1,25 @@
# Развёртывание
## Docker
```bash
cp .env.example .env # SECRET_KEY и ключ ИИ-провайдера
docker compose up -d
```
Поднимаются приложение и PostgreSQL 16 (данные — в томе `pgdata`), миграции применяются при старте.
Готовый образ собирается GitHub Actions на каждый тег `v*`:
```bash
docker pull ghcr.io/edeev/tablo:latest
docker pull dcr.deev.su/edeev/tablo:latest
```
Образ большой (около 1,3 ГБ): в нём Chromium для экспорта расписания в PNG.
## Как работает tablo.deev.su
- gunicorn (2 воркера) под systemd за nginx с сертификатом Let's Encrypt;
- PostgreSQL — на отдельном сервере, подключение через `DB_*`;
- ИИ — Timeweb Cloud AI (OpenAI-совместимый endpoint), модель задаётся `OPENAI_MODEL`;
- обновление: выложить код, `pip install -r requirements.txt`, `flask db upgrade`, перезапуск юнита.

BIN
docs/screenshots/mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

17
pyproject.toml Normal file
View file

@ -0,0 +1,17 @@
[tool.ruff]
target-version = "py311"
line-length = 120
extend-exclude = ["migrations"]
[tool.ruff.lint]
select = ["E", "F", "W", "B", "S"]
ignore = [
"E501", # длинные строки в промптах и шаблонах сообщений
"B904", # raise из except в обработчиках API — осознанно без цепочки
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101", "S105", "S106", "S107"]
[tool.pytest.ini_options]
testpaths = ["tests"]

3
requirements-dev.txt Normal file
View file

@ -0,0 +1,3 @@
-r requirements.txt
pytest==8.4.2
ruff==0.14.0

View file

@ -1,5 +1,7 @@
Flask==3.1.0 Flask==3.1.0
Flask-SQLAlchemy==3.1.1 Flask-SQLAlchemy==3.1.1
SQLAlchemy==2.0.49
alembic==1.18.4
Flask-Migrate==4.0.7 Flask-Migrate==4.0.7
Flask-Login==0.6.3 Flask-Login==0.6.3
Flask-WTF==1.2.2 Flask-WTF==1.2.2
@ -8,4 +10,5 @@ openai==1.58.1
python-dotenv==1.0.1 python-dotenv==1.0.1
Werkzeug==3.1.3 Werkzeug==3.1.3
Pillow==11.0.0 Pillow==11.0.0
playwright>=1.40 playwright==1.60.0
gunicorn==25.3.0

6
run.py
View file

@ -1,9 +1,11 @@
import os
import sys import sys
sys.dont_write_bytecode = True sys.dont_write_bytecode = True
from app import create_app from app import create_app # noqa: E402
app = create_app() app = create_app()
if __name__ == '__main__': if __name__ == '__main__':
app.run(debug=True) app.run(debug=os.getenv('FLASK_DEBUG') == '1')

0
tests/__init__.py Normal file
View file

99
tests/conftest.py Normal file
View file

@ -0,0 +1,99 @@
"""Тесты на настоящем PostgreSQL (JSON-поля): адрес базы — в TEST_DATABASE_URL."""
import os
import pytest
from sqlalchemy import text
from app import create_app, db
from app.models import Schedule, Share, ShareEditor, User
DB_URL = os.getenv('TEST_DATABASE_URL', 'postgresql://tablo:tablo@localhost:5432/tablo_test')
SAMPLE_DATA = [
{
'subject': 'Базы данных',
'types': {
'Лекция': {'color': 'blue', 'dates': {'01.09-31.12': [
['понедельник', '10:40-12:10', 'АВ-301', 'Иванов И.И.'],
['понедельник', '9:00-10:30', 'АВ-301', 'Иванов И.И.'],
]}},
},
},
]
@pytest.fixture(scope='session')
def _app():
app = create_app({
'TESTING': True,
'SECRET_KEY': 'test-secret',
'WTF_CSRF_ENABLED': False,
'SQLALCHEMY_DATABASE_URI': DB_URL,
})
with app.app_context():
db.drop_all()
db.create_all()
yield app
with app.app_context():
db.drop_all()
@pytest.fixture(autouse=True)
def app(_app):
"""Свой контекст приложения на каждый тест, после теста таблицы очищаются."""
ctx = _app.app_context()
ctx.push()
yield _app
db.session.remove()
tables = ', '.join(t.name for t in db.metadata.sorted_tables)
with db.engine.begin() as conn:
conn.execute(text(f'TRUNCATE {tables} RESTART IDENTITY CASCADE'))
ctx.pop()
def make_user(login, password='password123'):
user = User(login=login)
user.set_password(password)
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def owner():
return make_user('owner')
@pytest.fixture
def stranger():
return make_user('stranger')
@pytest.fixture
def schedule(owner):
s = Schedule(user_id=owner.id, name='Весенний семестр', data=SAMPLE_DATA)
db.session.add(s)
db.session.commit()
return s
def login(client, user_login, password='password123'):
return client.post('/login', data={'login': user_login, 'password': password})
@pytest.fixture
def share_edit(schedule, owner):
share = Share(schedule_id=schedule.id, owner_id=owner.id, share_type='edit')
db.session.add(share)
db.session.commit()
return share
def add_editor(share, user):
db.session.add(ShareEditor(share_id=share.id, user_id=user.id))
db.session.commit()
@pytest.fixture
def client(app):
return app.test_client()

76
tests/test_access.py Normal file
View file

@ -0,0 +1,76 @@
import pytest
from app import db
from app.models import Share
from tests.conftest import add_editor, login, make_user
EXPORTS = ['json', 'csv']
def test_owner_sees_schedule(client, owner, schedule):
login(client, 'owner')
assert client.get(f'/schedules/{schedule.id}').status_code == 200
def test_anonymous_is_redirected_to_login(client, schedule):
response = client.get(f'/schedules/{schedule.id}')
assert response.status_code == 302
assert '/login' in response.headers['Location']
def test_stranger_cannot_open_schedule(client, schedule, stranger):
login(client, 'stranger')
response = client.get(f'/schedules/{schedule.id}', follow_redirects=False)
assert response.status_code in (302, 403)
@pytest.mark.parametrize('fmt', EXPORTS)
def test_owner_can_export(client, schedule, fmt):
login(client, 'owner')
response = client.get(f'/schedules/{schedule.id}/export/{fmt}')
assert response.status_code == 200
assert 'Базы данных' in response.get_data(as_text=True)
@pytest.mark.parametrize('fmt', EXPORTS)
def test_view_share_does_not_open_export_by_id(client, schedule, owner, stranger, fmt):
"""Ссылка на просмотр открывает расписание по токену, но не экспорт по id."""
db.session.add(Share(schedule_id=schedule.id, owner_id=owner.id, share_type='view'))
db.session.commit()
login(client, 'stranger')
response = client.get(f'/schedules/{schedule.id}/export/{fmt}')
assert response.status_code == 302
assert 'Базы данных' not in response.get_data(as_text=True)
def test_editor_can_view_and_export(client, schedule, share_edit):
editor = make_user('editor')
add_editor(share_edit, editor)
login(client, 'editor')
assert client.get(f'/schedules/{schedule.id}').status_code == 200
assert client.get(f'/schedules/{schedule.id}/export/json').status_code == 200
def test_view_share_link_works_without_login(client, schedule, owner):
share = Share(schedule_id=schedule.id, owner_id=owner.id, share_type='view')
db.session.add(share)
db.session.commit()
response = client.get(f'/shared/{share.token}')
assert response.status_code == 200
assert 'Базы данных' in response.get_data(as_text=True)
def test_stranger_cannot_rename(client, schedule, stranger):
login(client, 'stranger')
client.post(f'/schedules/{schedule.id}/rename', data={'name': 'Взлом'})
db.session.refresh(schedule)
assert schedule.name == 'Весенний семестр'
def test_app_refuses_to_start_without_secret_key(monkeypatch):
from app import create_app
monkeypatch.delenv('SECRET_KEY', raising=False)
monkeypatch.delenv('FLASK_DEBUG', raising=False)
with pytest.raises(RuntimeError):
create_app({'SECRET_KEY': None})

36
tests/test_auth.py Normal file
View file

@ -0,0 +1,36 @@
from app.models import User
def test_register_and_login(client, app):
response = client.post('/register', data={'login': 'student', 'password': 'secret123', 'confirm': 'secret123'})
assert response.status_code in (200, 302)
assert User.query.filter_by(login='student').one().check_password('secret123')
client.get('/logout')
response = client.post('/login', data={'login': 'student', 'password': 'secret123'})
assert response.status_code == 302
def test_wrong_password_is_rejected(client, owner):
response = client.post('/login', data={'login': 'owner', 'password': 'wrong'})
assert response.status_code == 200
assert client.get('/').status_code == 302
def test_password_is_hashed(owner):
assert owner.password_hash != 'password123'
def test_login_next_redirect_stays_on_site(client, owner):
response = client.post('/login?next=https://evil.example/phish', data={'login': 'owner', 'password': 'password123'})
assert response.status_code == 302
assert 'evil.example' not in response.headers['Location']
def test_login_next_relative_path_is_kept(client, owner):
response = client.post('/login?next=/schedules/upload', data={'login': 'owner', 'password': 'password123'})
assert response.headers['Location'].endswith('/schedules/upload')
def test_login_next_backslash_trick_is_rejected(client, owner):
response = client.post('/login?next=/%5Cevil.example', data={'login': 'owner', 'password': 'password123'})
assert 'evil.example' not in response.headers['Location']

50
tests/test_helpers.py Normal file
View file

@ -0,0 +1,50 @@
from datetime import date
from app.services.merge import merge_schedules_data
from app.services.schedule_helpers import _parse_date_range, _time_sort_key, build_day_view, get_all_subjects
from tests.conftest import SAMPLE_DATA
def test_period_across_new_year_ends_next_year():
start, end = _parse_date_range('01.09-03.01')
assert start < end
assert end.year == start.year + 1
def test_period_within_year():
start, end = _parse_date_range('01.02-31.05')
assert (start.month, end.month) == (2, 5)
assert start.year == end.year
def test_whole_period_and_garbage():
assert _parse_date_range('Весь период') == (None, None)
assert _parse_date_range('ерунда') == (None, None)
def test_time_sort_key_handles_missing_leading_zero():
times = ['10:40-12:10', '9:00-10:30', None, '08:00-09:30']
assert sorted(times, key=_time_sort_key) == ['08:00-09:30', '9:00-10:30', '10:40-12:10', None]
def test_day_view_is_sorted_by_time():
monday = date(date.today().year, 9, 1)
while monday.weekday() != 0:
monday = monday.replace(day=monday.day + 1)
groups = build_day_view(SAMPLE_DATA, monday)
times = [g['time'] if isinstance(g, dict) else g[0]['time'] for g in groups]
assert times == ['9:00-10:30', '10:40-12:10']
def test_merge_combines_slots_without_duplicates():
other = [{'subject': 'Базы данных', 'types': {'Практика': {'color': 'green', 'dates': {
'01.09-31.12': [['среда', '12:20-13:50', 'АВ-210', 'Петров П.П.']]}}}}]
merged = merge_schedules_data([SAMPLE_DATA, SAMPLE_DATA, other])
assert len(merged) == 1
types = merged[0]['types']
assert set(types) == {'Лекция', 'Практика'}
assert len(types['Лекция']['dates']['01.09-31.12']) == 2
def test_get_all_subjects():
assert 'Базы данных' in get_all_subjects(SAMPLE_DATA)