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

Compare commits

..

No commits in common. "9effed12e3ec83654ede41048c68f2627a42d5aa" and "8558a71b4cc9a17c617a1948986fdb1fa674b992" have entirely different histories.

37 changed files with 133 additions and 835 deletions

View file

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

View file

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

View file

@ -1,34 +0,0 @@
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

View file

@ -1,42 +0,0 @@
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
View file

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

View file

@ -1,20 +0,0 @@
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 -"]

View file

@ -1,121 +0,0 @@
# 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,121 +1,77 @@
# Tablo # Tablo
**Русский** · [English](README.en.md) Веб-приложение для работы с учебным расписанием: распознавание расписания из изображения с помощью ИИ, персональные трекеры успеваемости и совместный доступ по ссылке.
[![CI](https://github.com/EDeev/tablo/actions/workflows/ci.yml/badge.svg)](https://github.com/EDeev/tablo/actions/workflows/ci.yml) Сайт: [tablo.deev.su](https://tablo.deev.su)
[![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)
Веб-приложение для студентов: по фото расписания ИИ собирает редактируемое расписание, к каждому ## Функциональность
предмету можно добавить трекеры успеваемости и поделиться всем по ссылке.
**Статус:** учебный проект (курсовая работа, 2026), завершён · работает на [tablo.deev.su](https://tablo.deev.su) - Загрузка фотографии или скана расписания и автоматическое извлечение структурированных данных через AI Vision API
- Полный CRUD для расписаний, предметов и слотов занятий
- 11 типов виджетов трекеров успеваемости (посещаемость, оценки, дедлайны, серии дней и др.)
- Дашборд с перетаскиваемыми виджетами на сетке 12 колонок
- Совместный доступ по токен-ссылке с тремя режимами: просмотр, редактирование, клонирование как шаблон
- Объединение нескольких расписаний с разрешением конфликтов по предметам
![Расписание на неделю и трекеры](docs/screenshots/schedule.png) ## Технологический стек
**Стек:** Python 3.12 · Flask · SQLAlchemy + Alembic · PostgreSQL · OpenAI-совместимый Vision API · Playwright · Jinja2 + vanilla JS - **Backend:** Python, Flask, SQLAlchemy, Flask-Migrate, Flask-Login
- **База данных:** 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 && cd tablo git clone https://github.com/EDeev/tablo.git
cp .env.example .env # задайте SECRET_KEY и ключ ИИ-провайдера cd tablo
docker compose up -d python -m venv venv
venv\Scripts\activate # Windows
# source venv/bin/activate # Linux/macOS
pip install -r requirements.txt
``` ```
Откройте `http://localhost:8000`. Compose поднимает приложение и PostgreSQL, миграции применяются Заполните файл `.env` в корне проекта
при старте. Готовый образ: `docker pull ghcr.io/edeev/tablo` или `docker pull dcr.deev.su/edeev/tablo`.
## Установка без Docker Примените миграции и запустите приложение:
Нужны Python 3.11+ и PostgreSQL.
```bash ```bash
python -m venv .venv && source .venv/bin/activate flask db upgrade
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
- [docs/architecture.md](docs/architecture.md) — структура, формат расписания, трекеры, права доступа │ ├── routes/ # Блюпринты (auth, schedules, subjects, shares)
- [docs/deploy.md](docs/deploy.md) — Docker и устройство боевого сервера │ ├── services/ # Бизнес-логика (AI-распознавание, AI-генерация метрик)
- [docs/explanatory_note.pdf](docs/explanatory_note.pdf) — пояснительная записка к курсовой │ └── templates/ # HTML-шаблоны Jinja2
├── 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.
## Лицензия ## Лицензия
Учебный проект (курсовая работа, Московский Политех, 2025/26). Код открыт для изучения, отдельной Этот проект является некоммерческим и распространяется под лицензией MIT.
лицензии нет.
## Автор ## Автор
**Деев Егор Викторович** — [GitHub](https://github.com/EDeev) · [Telegram](https://t.me/DeevEgor) · [egor@deev.space](mailto:egor@deev.space) **Деев Егор Викторович** - Backend Developer
- 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>Сделано с ❤️ — <a href="https://deev.space">deev.space</a></sub></p> <p><sub>Создано с ❤️ от вашего дорогого - deev.space ©</sub></p>
</div> </div>

View file

@ -9,15 +9,9 @@ migrate = Migrate()
login_manager = LoginManager() login_manager = LoginManager()
csrf = CSRFProtect() csrf = CSRFProtect()
def create_app(config_overrides=None): def create_app():
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)

View file

@ -1,20 +0,0 @@
"""Права доступа к расписанию: владелец или редактор по ссылке на редактирование."""
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,22 +1,14 @@
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
class Config:
def _database_url(): SECRET_KEY = os.getenv('SECRET_KEY', 'change-me-in-production')
if os.getenv('DATABASE_URL'): SQLALCHEMY_DATABASE_URI = (
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,5 +2,3 @@ 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,5 +1,3 @@
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
@ -8,13 +6,6 @@ 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:
@ -51,10 +42,8 @@ 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')
if not _is_safe_next(next_page): return redirect(next_page or url_for('schedules.profile'))
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,12 +1,20 @@
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 from flask_login import login_required, current_user
from app.models.schedule import Schedule from app.models.schedule import Schedule
from app.access import can_view as _can_view from app.models.share import Share
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, current_app, render_template, redirect, url_for, 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, SubjectConfig
from app.access import can_edit as _can_edit, can_view as _can_view from app.models.metric import Metric
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,9 +46,8 @@ 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: except Exception as e:
current_app.logger.exception('Ошибка распознавания расписания') flash(f'Ошибка сканирования: {e}', 'danger')
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)
@ -199,9 +198,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 type_data in item.get('types', {}).values(): for tn, type_data in item.get('types', {}).items():
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 slots in date_ranges.values(): for dr, slots in date_ranges.items():
for slot in slots: for slot in slots:
while len(slot) <= idx: while len(slot) <= idx:
slot.append(None) slot.append(None)
@ -443,3 +442,30 @@ 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,10 +2,11 @@ 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 from app.models.schedule import Schedule, SubjectConfig
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,11 +1,10 @@
from flask import Blueprint, current_app, jsonify, render_template, request from flask import Blueprint, render_template, request, jsonify
from flask_login import login_required from flask_login import login_required, current_user
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__)
@ -88,9 +87,8 @@ 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: except Exception as e:
current_app.logger.exception('Ошибка генерации метрики') return jsonify({'error': str(e)}), 500
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,
@ -284,6 +282,15 @@ 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,6 +1,8 @@
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 render_template from flask import current_app, 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, _time_sort_key from app.services.schedule_helpers import _parse_date_range
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: _time_sort_key(s.get('time'))) days[i] = sorted(unique, key=lambda s: s.get('time') or '')
return days return days

View file

@ -12,13 +12,8 @@ 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_month, start_day = int(start_str[3:5]), int(start_str[0:2]) start = date(year, int(start_str[3:5]), int(start_str[0:2]))
end_month, end_day = int(end_str[3:5]), int(end_str[0:2]) end = date(year, 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
@ -41,17 +36,6 @@ 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)
@ -59,7 +43,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(), key=_time_sort_key): for time_key in sorted(by_time.keys()):
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:
@ -103,7 +87,7 @@ def build_day_view(schedule_data: list, target_date: date) -> list:
'day': day_name, 'day': day_name,
}) })
raw.sort(key=lambda x: _time_sort_key(x.get('time'))) raw.sort(key=lambda x: x.get('time') or '')
return _group_by_time(raw) return _group_by_time(raw)

View file

@ -1,33 +0,0 @@
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:

View file

@ -1,63 +0,0 @@
# Как устроен 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=`) — только внутри сайта.

View file

@ -1,25 +0,0 @@
# Развёртывание
## 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`, перезапуск юнита.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

View file

@ -1,17 +0,0 @@
[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"]

View file

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

View file

@ -1,7 +1,5 @@
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
@ -10,5 +8,4 @@ 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.60.0 playwright>=1.40
gunicorn==25.3.0

6
run.py
View file

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

View file

View file

@ -1,99 +0,0 @@
"""Тесты на настоящем 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()

View file

@ -1,76 +0,0 @@
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})

View file

@ -1,36 +0,0 @@
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']

View file

@ -1,50 +0,0 @@
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)