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.
9effed12e3
...
8558a71b4c
37 changed files with 133 additions and 835 deletions
|
|
@ -1,10 +0,0 @@
|
||||||
.git
|
|
||||||
.github
|
|
||||||
**/__pycache__
|
|
||||||
.venv
|
|
||||||
venv
|
|
||||||
.env
|
|
||||||
tests
|
|
||||||
docs
|
|
||||||
*.md
|
|
||||||
*.pdf
|
|
||||||
26
.env.example
26
.env.example
|
|
@ -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
|
||||||
34
.github/workflows/ci.yml
vendored
34
.github/workflows/ci.yml
vendored
|
|
@ -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
|
|
||||||
42
.github/workflows/docker.yml
vendored
42
.github/workflows/docker.yml
vendored
|
|
@ -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
7
.gitignore
vendored
|
|
@ -1,7 +0,0 @@
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
.venv/
|
|
||||||
venv/
|
|
||||||
.env
|
|
||||||
.pytest_cache/
|
|
||||||
.ruff_cache/
|
|
||||||
20
Dockerfile
20
Dockerfile
|
|
@ -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 -"]
|
|
||||||
121
README.en.md
121
README.en.md
|
|
@ -1,121 +0,0 @@
|
||||||
# Tablo
|
|
||||||
|
|
||||||
[Русский](README.md) · **English**
|
|
||||||
|
|
||||||
[](https://github.com/EDeev/tablo/actions/workflows/ci.yml)
|
|
||||||
[](https://github.com/EDeev/tablo/actions/workflows/docker.yml)
|
|
||||||
[](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)
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
**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 |
|
|
||||||
|---|---|---|
|
|
||||||
|  |  |  |
|
|
||||||
|
|
||||||
## 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
130
README.md
|
|
@ -1,121 +1,77 @@
|
||||||
# Tablo
|
# Tablo
|
||||||
|
|
||||||
**Русский** · [English](README.en.md)
|
Веб-приложение для работы с учебным расписанием: распознавание расписания из изображения с помощью ИИ, персональные трекеры успеваемости и совместный доступ по ссылке.
|
||||||
|
|
||||||
[](https://github.com/EDeev/tablo/actions/workflows/ci.yml)
|
Сайт: [tablo.deev.su](https://tablo.deev.su)
|
||||||
[](https://github.com/EDeev/tablo/actions/workflows/docker.yml)
|
|
||||||
[](https://github.com/EDeev/tablo/releases)
|
|
||||||
|
|
||||||
Веб-приложение для студентов: по фото расписания ИИ собирает редактируемое расписание, к каждому
|
## Функциональность
|
||||||
предмету можно добавить трекеры успеваемости и поделиться всем по ссылке.
|
|
||||||
|
|
||||||
**Статус:** учебный проект (курсовая работа, 2026), завершён · работает на [tablo.deev.su](https://tablo.deev.su)
|
- Загрузка фотографии или скана расписания и автоматическое извлечение структурированных данных через AI Vision API
|
||||||
|
- Полный CRUD для расписаний, предметов и слотов занятий
|
||||||
|
- 11 типов виджетов трекеров успеваемости (посещаемость, оценки, дедлайны, серии дней и др.)
|
||||||
|
- Дашборд с перетаскиваемыми виджетами на сетке 12 колонок
|
||||||
|
- Совместный доступ по токен-ссылке с тремя режимами: просмотр, редактирование, клонирование как шаблон
|
||||||
|
- Объединение нескольких расписаний с разрешением конфликтов по предметам
|
||||||
|
|
||||||

|
## Технологический стек
|
||||||
|
|
||||||
**Стек:** 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` не работают распознавание расписаний и генерация трекеров. Всё остальное работает:
|
|
||||||
> расписание можно завести и править вручную.
|
|
||||||
|
|
||||||
## Как выглядит
|
|
||||||
|
|
||||||
| Мои расписания | Предмет и трекеры | Телефон |
|
|
||||||
|---|---|---|
|
|
||||||
|  |  |  |
|
|
||||||
|
|
||||||
## Как устроено
|
|
||||||
|
|
||||||
```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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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__)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
33
compose.yaml
33
compose.yaml
|
|
@ -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:
|
|
||||||
|
|
@ -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=`) — только внутри сайта.
|
|
||||||
|
|
@ -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 |
|
|
@ -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"]
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
-r requirements.txt
|
|
||||||
pytest==8.4.2
|
|
||||||
ruff==0.14.0
|
|
||||||
|
|
@ -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
6
run.py
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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})
|
|
||||||
|
|
@ -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']
|
|
||||||
|
|
@ -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)
|
|
||||||
Loading…
Add table
Reference in a new issue