MVP сайта
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.git
|
||||||
|
.venv
|
||||||
|
/data/
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache
|
||||||
|
tests
|
||||||
|
docs
|
||||||
|
*.egg-info
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
|
||||||
|
HockeyTeamParse/
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Generate a real .env with: uv run --locked python -m parser_lab.cli init-env
|
||||||
|
LAB_ADMIN_USER=teacher
|
||||||
|
LAB_ADMIN_PASSWORD='REPLACE_WITH_YOUR_PASSWORD'
|
||||||
|
LAB_SECRET=REPLACE_WITH_RANDOM_SECRET_AT_LEAST_32_CHARACTERS
|
||||||
|
LAB_ORIGINS=http://localhost:8080
|
||||||
|
LAB_SECURE_COOKIE=false
|
||||||
|
LAB_PORT=8080
|
||||||
|
LAB_MAX_BYTES=26214400
|
||||||
|
LAB_MAX_RECORDS=50000
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.venv/
|
||||||
|
/data/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
HockeyTeamParse/
|
||||||
|
data/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.8.14 /uv /uvx /bin/
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 LAB_DATA_DIR=/data \
|
||||||
|
UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never UV_NO_CACHE=1 \
|
||||||
|
PATH="/app/.venv/bin:$PATH"
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pyproject.toml uv.lock .python-version ./
|
||||||
|
|
||||||
|
RUN uv sync --locked --no-dev --no-install-project
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
COPY examples ./examples
|
||||||
|
|
||||||
|
RUN uv sync --locked --no-dev --no-editable && useradd --create-home --uid 10001 lab && mkdir /data && chown lab:lab /data
|
||||||
|
|
||||||
|
USER lab
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD uv run --no-sync python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz',timeout=4)"
|
||||||
|
|
||||||
|
CMD ["uv", "run", "--no-sync", "python", "-m", "parser_lab"]
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# Parser Lab
|
||||||
|
|
||||||
|
Учебный сайт для сбора данных: преподаватель импортирует CSV, JSON или JSONL, выбирает оформление и публикует две версии страниц — обычный HTML с пагинацией и HTML с динамической дозагрузкой. Приложение работает на FastAPI, Jinja2, HTML, CSS и небольших JavaScript-модулях без UI-фреймворков. Один аккаунт преподавателя; студентам вход не нужен.
|
||||||
|
|
||||||
|
## Быстрый запуск через Docker
|
||||||
|
|
||||||
|
Нужны Docker Engine с Compose либо Docker Desktop. Запускайте команды из папки проекта.
|
||||||
|
|
||||||
|
1. Скопируйте `.env.example` в `.env` и соберите образ:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose build
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Укажите логин и обычный пароль (не менее 10 символов) в `.env`:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
LAB_ADMIN_USER=teacher
|
||||||
|
LAB_ADMIN_PASSWORD='ваш-пароль-для-входа'
|
||||||
|
```
|
||||||
|
|
||||||
|
Одинарные кавычки сохраняют символы `$`, пробелы и `#`. Если пароль содержит кавычки или обратные слеши, используйте `uv run --locked python -m parser_lab.cli init-env` для создания корректного `.env` вместо ручного копирования шаблона.
|
||||||
|
|
||||||
|
3. Создайте случайный секрет:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose run --rm --no-deps app uv run --no-sync python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Поместите значение в `LAB_SECRET`. Не используйте пароль как секрет.
|
||||||
|
|
||||||
|
4. Для локальной проверки оставьте `LAB_ORIGINS=http://localhost:8080` и `LAB_SECURE_COOKIE=false`. Запустите:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up -d
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Откройте [кабинет](http://localhost:8080/admin). Проверка сервиса: [healthz](http://localhost:8080/healthz).
|
||||||
|
|
||||||
|
Если меняете порт, например на 8087, измените одновременно `LAB_PORT=8087` и `LAB_ORIGINS=http://localhost:8087`. Имя хоста тоже имеет значение: localhost и 127.0.0.1 — разные origin. Через запятую можно указать несколько разрешённых адресов.
|
||||||
|
|
||||||
|
## Размещение в интернете
|
||||||
|
|
||||||
|
Разместите приложение в корне отдельного домена, например `https://lab.example.org`. Установите:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
LAB_ORIGINS=https://lab.example.org
|
||||||
|
LAB_SECURE_COOKIE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Передайте домен на reverse proxy с HTTPS. Пример nginx, если приложение доступно прокси на порту 8080:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
client_max_body_size 26m;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
TLS-сертификат и DNS настраиваются на вашем сервере. Ограничьте прямой доступ к порту приложения средствами сервера или привяжите порт в Compose к 127.0.0.1, если прокси находится на той же машине. Подкаталоги вида `/my-lab/` и несколько реплик приложения не поддерживаются первой версией.
|
||||||
|
|
||||||
|
## Как опубликовать данные
|
||||||
|
|
||||||
|
1. Войдите в кабинет и нажмите «Загрузить данные».
|
||||||
|
2. Загрузите файл. При необходимости выберите кодировку и разделитель CSV либо массив записей внутри JSON.
|
||||||
|
3. Нажмите «Проверить файл»: появятся количество записей, предупреждения и первые 20 строк. Сохраните импорт.
|
||||||
|
4. Выберите представление и включите его. Отметьте поля, задайте подписи, порядок, поиск и категориальный фильтр. Для карточек назначьте заголовок; для сообщений — текст, при желании автора и дату.
|
||||||
|
5. Проверьте живой предпросмотр, сохраните черновик и откройте полный предпросмотр.
|
||||||
|
6. Опубликуйте версию. В разделе публикаций появятся ссылки для студентов.
|
||||||
|
|
||||||
|
Один набор может иметь все четыре представления. Изменения черновика не затрагивают опубликованные данные. Повторная публикация создаёт новые ссылки с номером версии; прежние продолжают работать. Снятие версии с публикации делает её недоступной. Уже скачанные студентом данные, разумеется, не исчезают.
|
||||||
|
|
||||||
|
При замене исходного файла настройки представлений сбрасываются: их нужно проверить заново. Изменение названия после публикации разрешено, изменение адреса набора — нет. Если набор отредактирован в другой вкладке, сохранение сообщит о конфликте, а не затрёт изменения.
|
||||||
|
|
||||||
|
## Форматы файлов
|
||||||
|
|
||||||
|
| Формат | Поддержка |
|
||||||
|
|---|---|
|
||||||
|
| CSV | Первая строка — уникальные непустые заголовки; UTF-8 или Windows-1251; запятая, точка с запятой или табуляция |
|
||||||
|
| JSON | Массив объектов, одиночный объект или выбранный вложенный массив объектов |
|
||||||
|
| JSONL | По одному объекту JSON в каждой непустой строке; UTF-8 |
|
||||||
|
|
||||||
|
Лимиты по умолчанию: 25 МиБ, 50 000 записей, глубина JSON 20. Размер файла и количество записей можно изменить через `LAB_MAX_BYTES` и `LAB_MAX_RECORDS`. Большие наборы требуют больше памяти: текущая версия хранит JSON-снимки в SQLite и загружает снимок для выборки. Проверяйте скорость на своих данных до занятия.
|
||||||
|
|
||||||
|
Дубликаты и исходный порядок сохраняются. Значения CSV остаются строками, чтобы не потерять ведущие нули и исходное представление чисел. В JSON различаются отсутствие поля, null, пустая строка, false и 0. Ошибки формата блокируют импорт; повреждённые строки не пропускаются молча. Пустой набор не публикуется.
|
||||||
|
|
||||||
|
Вложенные поля показаны путями: `/profile/age` означает поле age в объекте profile. Символ `/` в самом имени поля кодируется как `~1`, а `~` — как `~0`. Массивы сохраняются; специальные анкеты могут отображать их как пары вопросов и ответов.
|
||||||
|
|
||||||
|
### Заполненные анкеты
|
||||||
|
|
||||||
|
Для таблицы с колонками `participant, q1, q2` выберите «Колонки с ответами» и замените подписи q1/q2 текстами вопросов.
|
||||||
|
|
||||||
|
Для `examples/data/questionnaires.json` достаточно выбрать файл, нажать «Проверить файл» и «Сохранить и настроить». Приложение распознаёт участников и пары `question`/`answer`, выбирает `/participants` и создаёт готовое представление «Анкеты». Предпросмотр сразу показывает двух участников и их ответы. Распознавание зависит от структуры данных, а не имени файла.
|
||||||
|
|
||||||
|
Если структура неоднозначна, выберите уровень записей вручную. Для ранее загруженного целиком документа в редакторе доступна кнопка «Настроить анкеты автоматически»: она исправляет черновик без повторной загрузки исходного файла. Опубликованные версии сохраняются.
|
||||||
|
|
||||||
|
Сайт отображает готовые данные; он не собирает ответы и не интерпретирует психологические шкалы. Дополнительные примеры синтетические. Хоккейный CSV перенесён из предыдущего проекта.
|
||||||
|
|
||||||
|
## Парсинг
|
||||||
|
|
||||||
|
Адреса имеют вид:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/html/study-abc/v/1/table
|
||||||
|
/interactive/study-abc/v/1/table
|
||||||
|
```
|
||||||
|
|
||||||
|
Для HTML данные находятся в исходном ответе сервера. Пагинация — `?page=2`, следующая ссылка — `a[rel=next]`. Стабильные селекторы:
|
||||||
|
|
||||||
|
| Селектор | Значение |
|
||||||
|
|---|---|
|
||||||
|
| `.record[data-record-id]` | Запись со стабильным в пределах версии id |
|
||||||
|
| `.field[data-field]` | Значение выбранного поля |
|
||||||
|
| `data-value-state` | `missing`, `null` или `value` |
|
||||||
|
| `data-value-type` | Исходный тип: string, number, boolean, object, array, null или missing |
|
||||||
|
| `data-role` | Роль значения: title, text, author или date |
|
||||||
|
| `data-label` | Подпись поля или текст вопроса |
|
||||||
|
| `.detail-link` | Ссылка на отдельную запись |
|
||||||
|
|
||||||
|
Фильтры HTML: `q` — регистронезависимый поиск в назначенном поле, `category` — JSON-представление скалярного значения. Формы на странице формируют правильные параметры автоматически. Поиск не выполняет морфологический анализ. При неправильном номере страницы возвращается 400, при отсутствии страницы или версии — 404.
|
||||||
|
|
||||||
|
В динамическом варианте первые записи также находятся в HTML-ответе. Кнопка «Загрузить ещё», поиск и фильтры обновляют данные обычными HTTP-запросами через JavaScript. Для практики браузерного парсинга используйте Playwright. Оба варианта используют один шаблон, стили и favicon.
|
||||||
|
|
||||||
|
Примеры решений находятся в `examples/parsers/` и не доступны как файлы через публичный сайт:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv sync --locked
|
||||||
|
uv run --locked playwright install chromium
|
||||||
|
uv run --locked python examples/parsers/scrape_html.py "http://localhost:8080/html/SLUG/v/1/table" --output html.jsonl
|
||||||
|
uv run --locked python examples/parsers/scrape_interactive.py "http://localhost:8080/interactive/SLUG/v/1/table" --output interactive.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
Вместо загруженного Chromium можно использовать установленный Chrome: добавьте `--channel chrome` второму парсеру.
|
||||||
|
|
||||||
|
## Обновление и резервная копия
|
||||||
|
|
||||||
|
Данные находятся в named volume `lab-data`, смонтированном в `/data`. `docker compose down` сохраняет том. **Не используйте `down -v`, если данные нужны.**
|
||||||
|
|
||||||
|
Обновление:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose down
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Перед обновлением остановите контейнер и скопируйте весь каталог данных:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose stop app
|
||||||
|
docker compose cp app:/data ./backup-data
|
||||||
|
docker compose start app
|
||||||
|
```
|
||||||
|
|
||||||
|
Храните копию вместе с защищённой копией `.env`. Для восстановления подготовьте отдельный Compose-проект с новым томом, создайте его контейнер (`docker compose create`), скопируйте содержимое backup-data в `/data`, восстановите владельца UID 10001 при необходимости и запустите контейнер. Проверяйте восстановление отдельно от рабочего сайта.
|
||||||
|
|
||||||
|
Импортированные файлы и старые снимки не удаляются автоматически. Следите за размером тома. Безвозвратное удаление истории не входит в интерфейс первой версии.
|
||||||
|
|
||||||
|
## Локальная разработка
|
||||||
|
|
||||||
|
Нужен [uv](https://docs.astral.sh/uv/getting-started/installation/) версии 0.8.14 или новее. Python 3.12 и окружение `.venv` управляются через uv. Запускайте команды из корня проекта:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv sync --locked
|
||||||
|
uv run --locked python -m parser_lab.cli init-env
|
||||||
|
uv run --locked --env-file .env python -m parser_lab
|
||||||
|
```
|
||||||
|
|
||||||
|
`init-env` один раз создаёт `.env` и запрашивает параметры преподавателя. При последующих запусках достаточно последней команды. Активация окружения не требуется.
|
||||||
|
|
||||||
|
Зависимости приложения и группа `dev` описаны в `pyproject.toml`, точные версии зафиксированы в `uv.lock`. Для изменения зависимостей используйте `uv add ИМЯ` или `uv add --dev ИМЯ`; сохраняйте оба файла в Git. Устаревшие `requirements*.lock` больше не используются. Docker собирает production-окружение командой `uv sync --locked --no-dev` и запускает готовое окружение через `uv run --no-sync`.
|
||||||
|
|
||||||
|
Параметры окружения имеют приоритет над локальной `.env`. Пароль LAB_ADMIN_PASSWORD и секрет LAB_SECRET обязательны, приложение откажется запускаться без них. После изменения пароля или секрета перезапустите приложение (для Docker: `docker compose up -d --force-recreate`); старые сессии станут недействительны.
|
||||||
|
|
||||||
|
Тесты:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv run --locked pytest -q
|
||||||
|
# Дополнительные браузерные тесты: LAB_BROWSER_TESTS=1, при необходимости PLAYWRIGHT_CHANNEL=chrome.
|
||||||
|
```
|
||||||
|
|
||||||
|
Старые документы проектирования в `docs/` сохранены как история. Актуальная архитектура — FastAPI + Jinja2 + HTML/CSS/JavaScript; зависимости Rio полностью удалены. Существующие SQLite-данные и опубликованные ссылки совместимы с новой версией. Фактические результаты проверки и ограничения среды — в `docs/verification.md`.
|
||||||
|
|
||||||
|
Справка: [uv в Docker](https://docs.astral.sh/uv/guides/integration/docker/), [запуск команд](https://docs.astral.sh/uv/concepts/projects/run/).
|
||||||
|
|
||||||
|
Пароль хранится в `.env` обычным текстом. Внутренний Argon2-хеш для проверки входа приложение создаёт в памяти при запуске. Если `.env` остался от предыдущей версии, замените строку `LAB_ADMIN_HASH=...` на `LAB_ADMIN_PASSWORD='ваш-пароль'`; прежний хеш больше не используется. `.env` исключён из Git и Docker-образа.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
image: parser-lab:1.0
|
||||||
|
ports:
|
||||||
|
- "${LAB_PORT:-8080}:8080"
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
LAB_DATA_DIR: /data
|
||||||
|
volumes:
|
||||||
|
- lab-data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 30s
|
||||||
|
volumes:
|
||||||
|
lab-data:
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
> Архив первоначального проекта. С 2026-10-01 Rio удалён: приложение использует FastAPI, Jinja2 и HTML/CSS/JavaScript. Актуальное описание — в README.md.
|
||||||
|
|
||||||
|
# Спецификация учебного сайта для парсинга данных
|
||||||
|
|
||||||
|
Документ описывает первую версию сайта для занятий по основам ML, в том числе для психологов. Преподаватель загружает данные неизвестной заранее структуры и публикует их в разных представлениях. Студенты собирают опубликованные записи обычными HTTP-парсерами или браузерной автоматизацией. Реализация начнётся после согласования спецификации и плана.
|
||||||
|
|
||||||
|
## Согласованные требования
|
||||||
|
|
||||||
|
- Один преподаватель, закрытый кабинет на Python Rio, вход по логину и паролю без регистрации.
|
||||||
|
- Публичные учебные страницы без аккаунтов студентов.
|
||||||
|
- Импорт CSV, JSON и JSONL через кабинет.
|
||||||
|
- Таблицы, карточки, сообщения и заполненные анкеты; сбор новых ответов не входит в первую версию.
|
||||||
|
- Два режима доступа к одному набору: HTML для requests, BeautifulSoup и Scrapy; динамический Rio для Playwright и Selenium.
|
||||||
|
- Предпросмотр, черновики, публикация и снятие с публикации.
|
||||||
|
- Docker Compose, постоянное хранение данных и возможность разместить приложение на выбранном сервере.
|
||||||
|
- Современное адаптивное оформление и сохранение исходных пропусков и неоднородных значений.
|
||||||
|
|
||||||
|
Описанные ниже конкретные правила, пределы и адреса являются предлагаемыми решениями для первой версии.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
Один Python-процесс и контейнер обслуживают Rio и HTML-маршруты через FastAPI. Кабинет расположен под `/admin`, динамические страницы под `/interactive`, серверные HTML-страницы под `/html`. Конкретную интеграцию проверяем на закреплённой версии rio-ui до разработки остальных экранов.
|
||||||
|
|
||||||
|
Импорт, хранение, настройка полей и выборка записей образуют общее ядро. Оба механизма отображения получают одинаковые записи и конфигурацию; логика фильтрации и пагинации не дублируется. HTML генерируется сервером из шаблонов с экранированием текста. Полезное содержимое присутствует в исходном HTTP-ответе без запуска JavaScript.
|
||||||
|
|
||||||
|
SQLite хранит наборы, записи, настройки представлений, черновики и публикации. Исходные файлы находятся отдельно в постоянном каталоге. Данные пользователя не размещаются в публичном каталоге статических файлов. Схема базы имеет версию и последовательные миграции.
|
||||||
|
|
||||||
|
Модули разделяются по ответственности: импорт; модель данных и хранилище; публикации; авторизация; кабинет Rio; динамические представления Rio; HTML-маршруты и шаблоны. Запуск приложения не требует pandas для каждого запроса.
|
||||||
|
|
||||||
|
## Путь преподавателя и экраны
|
||||||
|
|
||||||
|
1. **Вход.** Логин и пароль, понятная ошибка без раскрытия существования пользователя, выход из кабинета.
|
||||||
|
2. **Каталог.** Название набора, число записей, статус, время изменения и действия «Редактировать», «Предпросмотр», «Опубликовать», «Снять с публикации».
|
||||||
|
3. **Импорт.** Выбор файла, параметры чтения, список ошибок и первые 20 записей. После подтверждения создаётся черновик; исходный файл сохраняется.
|
||||||
|
4. **Настройка.** Название, описание, адрес набора, представления, поля, подписи и количество записей на странице.
|
||||||
|
5. **Предпросмотр.** Проверка обоих режимов с реальными данными черновика. Предпросмотр доступен только преподавателю.
|
||||||
|
6. **Публикация.** Итоговая проверка, создание версии и ссылки на HTML и Rio для каждого включённого представления.
|
||||||
|
|
||||||
|
Изменения сохраняются явной кнопкой. При уходе с несохранёнными изменениями показывается предупреждение. Замена файла создаёт новую редакцию черновика и требует повторной проверки соответствия полей.
|
||||||
|
|
||||||
|
## Правила импорта
|
||||||
|
|
||||||
|
Начальные настраиваемые ограничения: файл до 25 МиБ, до 50 000 записей, глубина JSON до 20 уровней. Превышение вызывает понятную ошибку до публикации. Это границы приёма, а не обещание конкретной скорости работы; производительность проверяется отдельно на целевом оборудовании.
|
||||||
|
|
||||||
|
### CSV
|
||||||
|
|
||||||
|
Поддерживаются UTF-8 с BOM и без него, а также выбираемая Windows-1251. Преподаватель может указать запятую, точку с запятой или табуляцию; автоматическое предположение видно в предпросмотре. Первая строка содержит заголовки. Пустые и повторяющиеся заголовки блокируют импорт с указанием колонок. Поддерживаются кавычки, разделители и переводы строк внутри значений.
|
||||||
|
|
||||||
|
Значения сохраняются строками, включая ведущие нули, даты и десятичные запятые. Пустая ячейка остаётся пустой строкой. Ошибка количества полей или синтаксиса сообщает позицию; повреждённые строки не пропускаются молча.
|
||||||
|
|
||||||
|
### JSON
|
||||||
|
|
||||||
|
Вход — массив объектов, одиночный объект как одна запись либо объект, внутри которого преподаватель выбирает массив объектов. Выбор вложенного массива не выполняется скрыто. Массив скалярных значений и смесь объектов со скалярами отклоняются с объяснением поддерживаемой структуры. Повторяющиеся ключи объектов и нестандартные NaN/Infinity отклоняются, чтобы исключить потерю значений.
|
||||||
|
|
||||||
|
Объекты и массивы сохраняются вложенными. Поля выбираются деревом структуры; внутри конфигурации используются однозначные пути JSON Pointer. Отсутствующее поле, null, пустая строка, false и 0 остаются различимыми.
|
||||||
|
|
||||||
|
### JSONL
|
||||||
|
|
||||||
|
Каждая непустая строка содержит один JSON-объект. Пустые строки игнорируются с отображением их количества. Некорректная строка блокирует сохранение импорта; сообщение содержит номер строки и причину. Используется UTF-8 с допустимым BOM в начале файла.
|
||||||
|
|
||||||
|
### Общие правила
|
||||||
|
|
||||||
|
Порядок записей сохраняется. Дубликаты и пропуски автоматически не удаляются. Каждая запись получает внутренний идентификатор, который не зависит от наличия уникального поля в файле и стабилен внутри опубликованной версии. Поля определяются по всему принятому набору, а не только по предпросмотру. Если необязательного поля нет в части записей, это отражается при настройке.
|
||||||
|
|
||||||
|
Некорректный импорт не заменяет существующие данные. Файлы хранятся под внутренними именами; пользовательское имя используется только для отображения. Загруженное содержимое считается данными и не исполняется.
|
||||||
|
|
||||||
|
## Представления
|
||||||
|
|
||||||
|
| Вид | Настройка | Отображение |
|
||||||
|
|---|---|---|
|
||||||
|
| Таблица | Произвольные выбранные поля, подписи и порядок колонок | Заголовки и строки, переходы на страницу записи |
|
||||||
|
| Карточки | Заголовок, необязательное описание и дополнительные поля | Сетка карточек с переходом к деталям |
|
||||||
|
| Сообщения | Текст, необязательные автор и дата, дополнительные поля | Линейная лента сообщений без веток обсуждений |
|
||||||
|
| Анкеты | Идентификатор или заголовок участника, выбранные ответы | Заполненный бланк с вопросами и значениями |
|
||||||
|
|
||||||
|
Для анкет поддерживаются два явных способа настройки: колонки как ответы с подписями вопросов либо вложенный массив пар «вопрос — ответ», для которого задаются пути к обоим значениям. Нет расчёта шкал, интерпретации результатов или конструктора опросов.
|
||||||
|
|
||||||
|
Один набор может включать несколько представлений с независимыми настройками. Отсутствие необходимого поля, например текста сообщения, блокирует публикацию этого представления. Необязательные поля можно не назначать. Объекты и массивы в обычной ячейке выводятся как экранированный JSON; специальный способ вывода вложенных анкет применяется только при явной настройке.
|
||||||
|
|
||||||
|
Для отсутствующего значения и null используются разные видимые обозначения; пустая строка остаётся пустой. Их семантика также доступна через атрибут состояния значения в HTML. Исходный тип не определяется по внешнему виду текста.
|
||||||
|
|
||||||
|
## Учебные режимы
|
||||||
|
|
||||||
|
Пример адреса HTML: `/html/study-a/v/1/table?page=2`. Пример Rio: `/interactive/study-a/v/1/table`. У каждого представления есть собственный сегмент адреса. В ссылках содержится версия публикации.
|
||||||
|
|
||||||
|
### Обычный HTML
|
||||||
|
|
||||||
|
Таблицы используют table, thead, tbody, th и td. Остальные представления используют обычные семантические блоки, заголовки и ссылки. Записи и значения имеют документированные устойчивые CSS-классы и идентификаторы. Ни данные, ни переходы не требуют JavaScript, WebSocket или особого User-Agent.
|
||||||
|
|
||||||
|
Предлагаемые размеры страниц: 10, 25, 50, 100; по умолчанию 25. Порядок соответствует импорту. Страницы доступны через реальные ссылки. Нецелый, отрицательный или нулевой номер возвращает 400; номер за пределами набора — 404. Публикация пустого набора запрещена. Индивидуальные страницы записей дают упражнение на переходы по ссылкам.
|
||||||
|
|
||||||
|
### Динамический Rio
|
||||||
|
|
||||||
|
Первая порция содержит настроенное число записей. Кнопка «Загрузить ещё» добавляет следующую порцию, показывает состояние загрузки и исчезает после последней записи. Повторные нажатия во время запроса не создают дубликатов.
|
||||||
|
|
||||||
|
Преподаватель выбирает одно текстовое поле для поиска и, при необходимости, одно поле категориального фильтра. Изменение фильтра сбрасывает список и позицию загрузки. Доступны состояния ожидания, отсутствия результатов и ошибки. Динамические упражнения не включают CAPTCHA, искусственные задержки или противодействие парсерам.
|
||||||
|
|
||||||
|
В HTML те же фильтры передаются query-параметрами и применяются сервером. Для обоих режимов поиск — регистронезависимое вхождение в выбранное текстовое поле, категориальный фильтр — точное совпадение значения. Результат всегда сохраняет исходный порядок.
|
||||||
|
|
||||||
|
Публичная выдача исходного файла и отдельный JSON API не включаются в первую версию. Это учебная организация доступа, а не гарантия, что видимые данные невозможно получить другим способом. Сервер отдаёт только поля, включённые в соответствующее представление; скрытые поля не передаются клиенту внутри полного объекта записи.
|
||||||
|
|
||||||
|
## Публикации и сохранность
|
||||||
|
|
||||||
|
Публикация атомарно создаёт неизменяемый снимок записей и настроек всех включённых представлений. Неудачная операция оставляет предыдущую публикацию доступной. Изменения черновика не затрагивают опубликованные версии.
|
||||||
|
|
||||||
|
Повторная публикация создаёт новый номер и новые ссылки. Старые ссылки продолжают показывать прежний материал до снятия соответствующей версии с публикации. Это предотвращает изменение данных между запросами студенческого парсера. Снятая версия возвращает 404 в обоих режимах; черновик остаётся у преподавателя. В первой версии достаточно снятия с публикации, без интерфейса безвозвратного удаления истории.
|
||||||
|
|
||||||
|
## Внешний вид
|
||||||
|
|
||||||
|
Интерфейс на русском языке: светлый фон, тёмный читаемый текст, спокойный сине-зелёный акцент, единые размеры отступов и контрастные состояния элементов. Кабинет использует боковую навигацию на широком экране и компактную на узком. Основные действия визуально выделены, технические параметры появляются только там, где нужны для выбора.
|
||||||
|
|
||||||
|
Таблицы прокручиваются горизонтально, длинные сообщения переносятся, анкеты сохраняют связь вопроса и ответа. Карточки перестраиваются в одну колонку на телефоне. HTML и Rio имеют общий визуальный стиль, но не требуют идентичной внутренней разметки. Шрифты и необходимые статические ресурсы поставляются с приложением, чтобы интерфейс не зависел от внешнего CDN.
|
||||||
|
|
||||||
|
## Авторизация и Docker
|
||||||
|
|
||||||
|
Учётные данные задаются при развёртывании: имя пользователя и хеш пароля через переменные окружения или файл секрета. Открытая регистрация и стандартный встроенный пароль отсутствуют. Инструкция содержит способ создания хеша и смены пароля. Вход ограничивается по частоте; сессия защищает все операции кабинета, предпросмотр и доступ к исходным файлам. Изменяющие операции проверяют подлинность сессии и защищены от CSRF. Пароли и данные анкет не попадают в журналы запросов.
|
||||||
|
|
||||||
|
Контейнер запускается непривилегированным пользователем, слушает порт 8080 и использует `/data` как постоянный том. SQLite и исходные файлы находятся в этом томе. Порт хоста настраивается через Compose; доступны проверка работоспособности и корректное завершение процесса. При отсутствии настроек входа приложение сообщает ошибку запуска, а не открывает кабинет без защиты.
|
||||||
|
|
||||||
|
HTTPS завершается на внешнем reverse proxy, который передаёт HTTP и WebSocket. Поддерживаемый вариант первой версии — приложение в корне отдельного домена; размещение под произвольным префиксом URL не обещается. При HTTPS cookie сессии получает Secure; для локального HTTP есть явная настройка. Работа нескольких реплик с общей SQLite в первую версию не входит.
|
||||||
|
|
||||||
|
В комплект входят Dockerfile, compose.yaml, пример переменных окружения без секретов и инструкция запуска, обновления и резервного копирования. Простой согласованный способ резервного копирования — остановить контейнер и скопировать весь том; восстановление проверяется отдельно. Данные не встраиваются в образ как изменяемое хранилище.
|
||||||
|
|
||||||
|
## Демонстрационные данные
|
||||||
|
|
||||||
|
Старый хоккейный CSV сохраняется в качестве примера таблицы. Дополнительные небольшие синтетические наборы демонстрируют сообщения, карточки и оба формата заполненных анкет. Примеры включают Unicode, пустые значения, длинный текст и повторяющиеся значения. Демонстрационные наборы импортируются явно и не перезаписывают рабочие данные при перезапуске.
|
||||||
|
|
||||||
|
Старый NiceGUI-проект остаётся источником примера, а не основой нового интерфейса. Совместимость старого адреса `?pageNum=` не входит в первую версию.
|
||||||
|
|
||||||
|
## Проверка и критерии готовности
|
||||||
|
|
||||||
|
1. Преподаватель импортирует каждый поддерживаемый формат без редактирования кода; ошибки показывают причину и расположение.
|
||||||
|
2. Назначение полей работает с произвольными заголовками и вложенными значениями. Несовместимые настройки нельзя опубликовать.
|
||||||
|
3. Для одного набора включаются несколько представлений, и каждое доступно в обоих режимах.
|
||||||
|
4. Парсер requests и BeautifulSoup собирает все видимые записи по ссылкам и страницам без пропусков и дублей. Проверяются значения, а не только количество.
|
||||||
|
5. Парсер Playwright получает те же записи, используя динамическую загрузку и фильтры. При отсутствии фильтров порядок совпадает с HTML.
|
||||||
|
6. Редактирование и повторная публикация не изменяют старые опубликованные ссылки. Черновики, исходные файлы и скрытые поля недоступны студентам.
|
||||||
|
7. Замена контейнера сохраняет данные; резервная копия восстанавливается в чистом окружении.
|
||||||
|
8. Через браузер проверяются все четыре представления, узкий экран, длинные тексты, пустые значения, вход и ошибки загрузки.
|
||||||
|
9. Контейнер собирается из документированной команды; проверяются старт, HTTP, WebSocket, том и завершение.
|
||||||
|
|
||||||
|
Примеры парсеров передаются преподавателю отдельными файлами, без автоматической публикации решений на учебном сайте. Версии Python и библиотек закрепляются после проверки совместимости Rio, FastAPI и контейнера. До этого документация текущего Rio подтверждает направление интеграции, но не заменяет проверку установленной версии.
|
||||||
|
|
||||||
|
## Границы первой версии
|
||||||
|
|
||||||
|
Не входят: несколько преподавателей, аккаунты студентов, сбор ответов, оценивание работ, обучение моделей, произвольный редактор HTML, ветки комментариев, импорт по внешним URL, публичный API выгрузки, горизонтальное масштабирование и автоматическое размещение на конкретном сервере. Объём первой версии заканчивается работающим проверенным Docker-пакетом и инструкциями.
|
||||||
|
|
||||||
|
## Последовательность дальнейшей работы
|
||||||
|
|
||||||
|
После проверки этой спецификации готовится отдельный план реализации с файлами, зависимостями, этапами и проверками. Порядок: совместимость Rio и HTML в одном приложении; хранение и вход; импорт; настройка представлений; публикации и оба режима; оформление; парсеры и проверка Docker. Продуктовый код в рамках подготовки спецификации не изменяется.
|
||||||
|
|
||||||
|
## Основания технических решений
|
||||||
|
|
||||||
|
Изученный старый проект расположен в `P:\Coding\Python\NetologyWebParserSite\HockeyTeamParse`: NiceGUI, pandas, один CSV, страницы по 25 строк, Docker Compose. Окружение с установленным Rio в изученном проекте не найдено.
|
||||||
|
|
||||||
|
Официальные источники Rio, проверенные при обсуждении: [App и as_fastapi](https://github.com/rio-labs/rio/blob/main/rio/app.py), [сервер и динамическая загрузка интерфейса](https://github.com/rio-labs/rio/blob/main/rio/app_server/fastapi_server.py). Архитектура этого документа является проектным предложением, а не утверждением о готовности перечисленных функций в старом приложении.
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
> Архив первоначального проекта. С 2026-10-01 Rio удалён: приложение использует FastAPI, Jinja2 и HTML/CSS/JavaScript. Актуальное описание — в README.md.
|
||||||
|
|
||||||
|
# План реализации учебного сайта для парсинга
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans for implementation in this session, or superpowers:subagent-driven-development if the user chooses delegation. Steps use checkbox syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Создать универсальный учебный сайт с кабинетом преподавателя на Rio, импортом данных, HTML и динамическими упражнениями, готовый к запуску через Docker Compose.
|
||||||
|
|
||||||
|
**Architecture:** Один Python-сервис объединяет Rio и FastAPI с Jinja2. Импорт, выборка, публикации и SQLite общие для обоих режимов; публичные запросы читают неизменяемые версии.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, rio-ui, FastAPI, Jinja2, SQLite через sqlite3, Argon2 для пароля, pytest, requests, BeautifulSoup, Playwright, Docker Compose. Точные версии закрепляются после проверки совместимости в задаче 1.
|
||||||
|
|
||||||
|
**Spec:** [Утверждённая спецификация](design-spec.md).
|
||||||
|
|
||||||
|
## Рабочее место и ограничения
|
||||||
|
|
||||||
|
Новый проект создаётся в `outputs/parser-lab/` относительно текущего рабочего каталога. Все пути ниже относятся к этому корню. Старый проект `P:\Coding\Python\NetologyWebParserSite\HockeyTeamParse` служит источником хоккейного примера и не перезаписывается. Документы копируются в `docs/` нового проекта. Временные результаты проверок находятся в `work/` текущего рабочего каталога. При наличии Git этапы фиксируются отдельными коммитами; отсутствие Git не блокирует сборку и передачу проекта.
|
||||||
|
|
||||||
|
- Один аккаунт преподавателя; без регистрации и аккаунтов студентов.
|
||||||
|
- Четыре представления; оба режима для каждого включённого представления.
|
||||||
|
- Файл до 25 МиБ, до 50 000 записей, глубина JSON до 20 уровней; пределы настраиваемы.
|
||||||
|
- Размеры страниц 10, 25, 50, 100; исходное значение 25. Предпросмотр первых 20 записей.
|
||||||
|
- Unicode, пропуски, дубликаты, порядок и исходные типы сохраняются; CSV остаётся строковым.
|
||||||
|
- Русский интерфейс, светлая тема, адаптивность, локальные ресурсы.
|
||||||
|
- Неизменяемые публикации, отсутствие доступа к черновикам и скрытым полям извне.
|
||||||
|
- Один контейнер, порт 8080, постоянный каталог `/data`, HTTP и WebSocket за reverse proxy.
|
||||||
|
- Нет автоматического деплоя на внешний сервер, обучения моделей или публичного API выгрузки.
|
||||||
|
|
||||||
|
## Особое внимание при проверке
|
||||||
|
|
||||||
|
1. Заголовки JSON с точками, слешами и тильдами не должны путаться с вложенными путями — задачи 2 и 4.
|
||||||
|
2. Значения null, false, 0 и пустая строка не должны схлопываться в отсутствие поля — задачи 2 и 5.
|
||||||
|
3. Замена файла и авария публикации не должны повреждать доступный студентам снимок — задачи 3 и 7.
|
||||||
|
4. Прямой запрос страницы деталей или callback Rio не должен обходить авторизацию либо раскрывать скрытое поле — задачи 6, 8 и 10.
|
||||||
|
5. Две вкладки преподавателя не должны незаметно перезаписывать чужие изменения — задача 3; сохранять черновик с ожидаемой ревизией и показывать конфликт.
|
||||||
|
|
||||||
|
## Карта файлов
|
||||||
|
|
||||||
|
| Файлы | Ответственность |
|
||||||
|
|---|---|
|
||||||
|
| `pyproject.toml`, `requirements.lock`, `requirements-dev.lock` | Зависимости приложения и проверок |
|
||||||
|
| `src/parser_lab/app.py`, `config.py`, `__main__.py` | Сборка сервиса, конфигурация, запуск |
|
||||||
|
| `src/parser_lab/domain.py`, `paths.py` | Общие типы и JSON Pointer |
|
||||||
|
| `src/parser_lab/importing/csv_reader.py`, `json_reader.py`, `service.py` | Разбор файлов и диагностика |
|
||||||
|
| `src/parser_lab/storage/database.py`, `repository.py`, `migrations/001.sql` | База, миграции, транзакции |
|
||||||
|
| `src/parser_lab/views.py`, `query.py`, `publishing.py` | Конфигурация представлений, выборка, публикация |
|
||||||
|
| `src/parser_lab/auth.py`, `cli.py` | Сессии, вход, создание хеша пароля |
|
||||||
|
| `src/parser_lab/web/routes.py`, `templates/`, `static/` | HTML, стили и ресурсы |
|
||||||
|
| `src/parser_lab/rio_ui/admin/`, `rio_ui/public/`, `rio_ui/theme.py` | Кабинет, динамические страницы, тема |
|
||||||
|
| `tests/unit/`, `tests/integration/`, `tests/browser/` | Проверки поведения |
|
||||||
|
| `examples/data/`, `examples/parsers/` | Данные и решения для преподавателя |
|
||||||
|
| `Dockerfile`, `compose.yaml`, `.env.example`, `.dockerignore`, `README.md` | Развёртывание и использование |
|
||||||
|
|
||||||
|
Каждая задача включает запуск своей проверки до реализации, подтверждение ожидаемого сбоя, реализацию, повторную проверку и фиксацию результата. Ошибки настройки тестового окружения не считать ожидаемым провалом поведения.
|
||||||
|
|
||||||
|
## Задача 1 Совместимый каркас сервиса
|
||||||
|
|
||||||
|
**Files:** создать `pyproject.toml`, оба lock-файла, `src/parser_lab/{__init__,app,config,__main__}.py`, `tests/integration/test_app.py`.
|
||||||
|
|
||||||
|
**Interfaces:** `Settings.from_env() -> Settings`; `create_app(settings: Settings) -> FastAPI`. Settings содержит каталог данных, лимиты импорта, имя администратора, хеш пароля, секрет сессий, secure-cookie и доверенные origin. В тестах настройки передаются явно.
|
||||||
|
|
||||||
|
- [ ] Проверить доступные Python и Docker, прочитать API установленной для проекта Rio, выбрать поддерживаемую Python и закрепить совместимые версии. Не обновлять глобальное окружение пользователя.
|
||||||
|
- [ ] Добавить тест `test_html_route_coexists_with_rio`: `client.get('/healthz').status_code == 200`, HTML-проба содержит `'<table'`, запрос динамической страницы возвращает Rio-оболочку. Проверить lifespan при старте и завершении.
|
||||||
|
- [ ] Запустить `python -m pytest tests/integration/test_app.py -q`, подтвердить отсутствие реализации.
|
||||||
|
- [ ] Реализовать app factory, конфигурацию и совмещение пользовательских маршрутов с `rio.App.as_fastapi()` по подтверждённому API. Проба находится в тестовом приложении, а не в рабочей выдаче.
|
||||||
|
- [ ] Повторить тест и открыть динамическую пробу в браузере: компонент отображается и кнопка обновляет значение через WebSocket. Зафиксировать версии, команду запуска и результат.
|
||||||
|
|
||||||
|
Результат: работающий общий сервер; архитектурные ограничения Rio выявлены до создания кабинета.
|
||||||
|
|
||||||
|
## Задача 2 Импорт без потери исходных значений
|
||||||
|
|
||||||
|
**Files:** создать `domain.py`, `paths.py`, `importing/{__init__,csv_reader,json_reader,service}.py`, `tests/unit/test_importing.py`, `tests/unit/test_paths.py`.
|
||||||
|
|
||||||
|
**Interfaces:** `JsonValue` — стандартное JSON-значение; `Record = dict[str, JsonValue]`; `ImportOptions(format, encoding, delimiter, record_pointer)`; `ImportResult(records, fields, warnings)`; `ImportIssue(message, line, column)`; `read_upload(data: bytes, filename: str, options: ImportOptions, settings: Settings) -> ImportResult`; `resolve_pointer(record: Record, pointer: str) -> ResolvedValue(present, value)`.
|
||||||
|
|
||||||
|
- [ ] Добавить проверки: CSV `'id;answer\n001;\n'` даёт `{'id': '001', 'answer': ''}`; JSON сохраняет `False`, `0`, `None`; `resolve_pointer({'a/b': {'~c': 0}}, '/a~1b/~0c').value == 0`; отсутствующий путь имеет `present == False`.
|
||||||
|
- [ ] Добавить параметризованные случаи: UTF-8 BOM, Windows-1251, многострочные CSV-ячейки, плохая длина строки, повторные заголовки и JSON-ключи, JSONL с ошибкой на строке 3, пустые строки JSONL, одиночный объект, вложенный массив, NaN/Infinity, глубина 21, лимиты размера и количества, массив скаляров.
|
||||||
|
- [ ] Выполнить `python -m pytest tests/unit/test_importing.py tests/unit/test_paths.py -q`, проверить ожидаемые провалы.
|
||||||
|
- [ ] Реализовать чтение с `csv` и `json`, строгой обработкой повторяющихся ключей, ограничением размера до разбора и контролем глубины/числа записей. Не угадывать формат по пользовательскому имени без проверки содержимого. JSONL читает построчно; результаты не сохраняются частично.
|
||||||
|
- [ ] Повторить тесты. Проверить объединение полей по всем записям, явный выбор вложенного массива и различение пустого набора от ошибки.
|
||||||
|
|
||||||
|
## Задача 3 Хранение черновиков и исходных файлов
|
||||||
|
|
||||||
|
**Files:** создать `storage/{__init__,database,repository}.py`, `storage/migrations/001.sql`, `tests/integration/test_repository.py`.
|
||||||
|
|
||||||
|
**Interfaces:** `Repository.create_draft(title: str, source: bytes, imported: ImportResult) -> Draft`; `get_draft(dataset_id: str) -> Draft`; `save_draft(draft: Draft, expected_revision: int) -> Draft`; `replace_import(dataset_id: str, source: bytes, imported: ImportResult, expected_revision: int) -> Draft`. `Draft` содержит id, slug, revision, записи, источник, представления, название и описание. Ошибки: `NotFound`, `RevisionConflict`.
|
||||||
|
|
||||||
|
- [ ] Добавить тесты сохранения и повторного открытия SQLite: `reopened.records == imported.records`; повторное сохранение со старой revision вызывает `RevisionConflict`; авария транзакции не меняет старую запись. Проверить, что исходное имя `'../../outside.json'` не влияет на фактический путь хранения.
|
||||||
|
- [ ] Запустить `python -m pytest tests/integration/test_repository.py -q` до реализации.
|
||||||
|
- [ ] Реализовать таблицы datasets, draft_records, sources, views, publications, published_records, sessions, schema_version. Записи хранятся JSON вместе с ordinal и внутренним id. Все SQL параметризованы; опубликованный снимок не зависит от изменяемого черновика.
|
||||||
|
- [ ] Реализовать файлы под UUID, временную запись с последующим переносом в пределах `/data`, транзакционную привязку к базе и очистку несвязанных временных файлов. Замена импорта сбрасывает подтверждение настройки полей.
|
||||||
|
- [ ] Повторить тесты и проверить идемпотентный запуск миграции и откат при ошибке записи файла.
|
||||||
|
|
||||||
|
## Задача 4 Настройки представлений
|
||||||
|
|
||||||
|
**Files:** создать `views.py`, `tests/unit/test_views.py`; дополнить общие модели в `domain.py`.
|
||||||
|
|
||||||
|
**Interfaces:** `ViewConfig(kind, columns, roles, questionnaire_mode, questions_pointer, page_size, search_pointer, category_pointer)`; `FieldConfig(pointer, label)`; `validate_view(config: ViewConfig, records: list[Record]) -> list[ImportIssue]`; `project_record(record: Record, config: ViewConfig) -> DisplayRecord`. `DisplayRecord` содержит только назначенные значения и сведения об их наличии/типе.
|
||||||
|
|
||||||
|
- [ ] Добавить проверки для всех четырёх видов: произвольные подписи колонок; текст сообщения обязателен в конфигурации, автор — нет; анкета из колонок и массива question/answer; отсутствующее значение в отдельной записи допустимо и помечается. Путь, которого нет ни в одной записи, не допускается как обязательная роль.
|
||||||
|
- [ ] Проверить страницу 25 по умолчанию и отклонение 24; выбор только текстового поля для поиска; категориальное поле должно быть скалярным. Проверить, что `secret` отсутствует во всём сериализованном DisplayRecord, когда оно не выбрано.
|
||||||
|
- [ ] Запустить `python -m pytest tests/unit/test_views.py -q` до реализации.
|
||||||
|
- [ ] Реализовать явные конфигурации без эвристического назначения смысла полям. JSON Pointer отвечает за чтение и экранирование имён; структура анкеты не смешивается с универсальным табличным рендером.
|
||||||
|
- [ ] Повторить тесты, включая слеши и тильды в именах полей, смешанные записи и многострочные ответы.
|
||||||
|
|
||||||
|
## Задача 5 Общая выборка и HTML
|
||||||
|
|
||||||
|
**Files:** создать `query.py`, `web/{__init__,routes}.py`, `web/templates/{base,list,detail,table,cards,messages,questionnaire}.html`, `web/static/site.css`, `tests/unit/test_query.py`, `tests/integration/test_html.py`.
|
||||||
|
|
||||||
|
**Interfaces:** `Query(page=1, search='', category=None)`; `select_records(records: list[StoredRecord], config: ViewConfig, query: Query) -> PageResult`; `PageResult(items, total, page, page_size, has_next)`. `StoredRecord(id, ordinal, data)` используется обоими режимами. `register_html_routes(app: FastAPI, repository: Repository) -> None` после задачи 7 читает Publication; до этого HTML тестируется на явной фикстуре снимка.
|
||||||
|
|
||||||
|
- [ ] Добавить тесты выборки: 51 запись и размер 25 дают страницы 25/25/1, ids не пересекаются; поиск регистронезависимый, фильтр точный и сохраняет различие числа 0 и строки `'0'`; порядок стабилен.
|
||||||
|
- [ ] Добавить тест HTML: `BeautifulSoup(response.text, 'html.parser').select('tbody tr')` содержит нужные значения без JavaScript. Вредоносное значение `<script>alert(1)</script>` остаётся текстом, исполняемый тег не появляется. Проверить отсутствие скрытых полей на списке и деталях.
|
||||||
|
- [ ] Запустить `python -m pytest tests/unit/test_query.py tests/integration/test_html.py -q` до реализации.
|
||||||
|
- [ ] Реализовать общий отбор, Jinja2 с autoescape, реальные ссылки пагинации и деталей. CSS-контракт: `.record`, `.field`, `data-record-id`, `data-field`, `data-value-state`; состояния `missing`, `null`, `value`. Ссылки сохраняют фильтры и версию, значения URL кодируются.
|
||||||
|
- [ ] Определить детали по `/html/{slug}/v/{version}/{view}/records/{id}`. Некорректная страница — 400, отсутствующая — 404. Для фильтра без совпадений страница 1 возвращает 200 с пустым состоянием; следующие — 404.
|
||||||
|
- [ ] Повторить тесты и проверить ручным HTTP-запросом, что содержимое и ссылки входят в исходный ответ.
|
||||||
|
|
||||||
|
## Задача 6 Авторизация преподавателя
|
||||||
|
|
||||||
|
**Files:** создать `auth.py`, `cli.py`, `tests/integration/test_auth.py`; дополнить `app.py`, `config.py`.
|
||||||
|
|
||||||
|
**Interfaces:** `AuthService.login(username: str, password: str, client_key: str) -> SessionToken`; `require_admin(token: str) -> AdminSession`; `logout(token: str) -> None`; CLI `python -m parser_lab.cli hash-password` читает пароль скрытым вводом.
|
||||||
|
|
||||||
|
- [ ] Написать тесты: неверный пароль не создаёт сессию; приватный маршрут без сессии отклонён; выход отзывает сессию; изменяющий POST без CSRF отклонён; внешний Origin при WebSocket/callback отклонён; cookie HttpOnly/SameSite и Secure соответствуют настройке.
|
||||||
|
- [ ] Зафиксировать предел входа: 5 неудачных попыток на client_key за 5 минут, далее 429 до истечения окна; срок сессии 12 часов. Добавить тест управляемым временем, без реального ожидания. Не доверять произвольному X-Forwarded-For.
|
||||||
|
- [ ] Запустить `python -m pytest tests/integration/test_auth.py -q` до реализации.
|
||||||
|
- [ ] Реализовать Argon2, случайный токен с хранением только его хеша, проверку срока сессии в каждом защищённом действии. HTML-формы и Rio-сессии используют одну авторизацию; редирект страницы сам по себе не считается защитой callback.
|
||||||
|
- [ ] Повторить тесты, проверить отсутствие паролей в логах и безопасный отказ запуска без обязательных настроек. Изменение учётных данных отзывает прежние сессии.
|
||||||
|
|
||||||
|
## Задача 7 Неизменяемые публикации
|
||||||
|
|
||||||
|
**Files:** создать `publishing.py`, `tests/integration/test_publications.py`; дополнить repository и HTML routes.
|
||||||
|
|
||||||
|
**Interfaces:** `PublishingService.publish(dataset_id: str, expected_revision: int) -> Publication`; `get_publication(slug: str, version: int) -> Publication`; `unpublish(slug: str, version: int) -> None`. `Publication` содержит slug, version, title, description, views и снимок records. Неопубликованная версия вызывает `NotFound`.
|
||||||
|
|
||||||
|
- [ ] Добавить тест: после публикации A и изменения черновика на B `get_publication(slug, 1).records` остаются A; повторная публикация создаёт version 2. Проверить откат при сбое копирования, запрет пустого набора и конфликт revision.
|
||||||
|
- [ ] Запустить `python -m pytest tests/integration/test_publications.py -q` до реализации.
|
||||||
|
- [ ] Реализовать атомарную публикацию всех включённых валидных представлений и отдельное снятие версии. Если одно включённое представление невалидно, публикация целиком отклоняется с указанием причины.
|
||||||
|
- [ ] Закрепить slug после первой публикации, чтобы переименование заголовка не ломало ссылки. Разрешить ASCII lowercase, цифры и дефисы; уникальность проверяет база.
|
||||||
|
- [ ] Подключить HTML к реальным снимкам и повторить тесты: снятая версия и неизвестный id возвращают 404; прежние ссылки остаются прежними после редактирования.
|
||||||
|
|
||||||
|
## Задача 8 Кабинет Rio
|
||||||
|
|
||||||
|
**Files:** создать `rio_ui/admin/{__init__,login,catalog,import_wizard,view_editor,preview,publications}.py`, `rio_ui/theme.py`, `tests/browser/test_admin.py`.
|
||||||
|
|
||||||
|
**Interfaces:** компоненты `LoginPage`, `CatalogPage`, `ImportWizard`, `ViewEditor`, `PreviewPage`, `PublicationsPage` получают сервисы через подтверждённый механизм Rio attachment. Состояние несохранённых изменений принадлежит конкретной сессии, постоянные данные — Repository. Вызовы методов изменения требуют `require_admin` и expected_revision.
|
||||||
|
|
||||||
|
- [ ] Создать браузерные сценарии входа, импорта CSV, выбора полей, чернового предпросмотра, публикации и выхода; до реализации сценарии не находят соответствующие экраны.
|
||||||
|
- [ ] Реализовать каталог и мастер с шагами «Файл», «Проверка», «Отображение», «Предпросмотр». Внешние названия не содержат JSON Pointer; выбор путей показан деревом. Ошибка импорта сохраняет выбранные настройки и объясняет причину.
|
||||||
|
- [ ] Реализовать оба предпросмотра за авторизацией, смену представлений, предупреждение о несохранённых изменениях и сообщение о конфликте двух вкладок. Дублирующие операции отключаются до завершения запроса.
|
||||||
|
- [ ] При работе FileInfo прочитать загруженные байты один раз и хранить до подтверждения/отмены; валидация повторяется на сервере, а не только фильтром расширений. Предпросмотр показывает 20 записей, число всех записей и предупреждения.
|
||||||
|
- [ ] Выполнить `python -m pytest tests/browser/test_admin.py -q` с браузером Playwright и отдельной временной базой. Проверить ручной вход, восстановление после ошибки и невозможность действия после logout в другой вкладке.
|
||||||
|
|
||||||
|
## Задача 9 Динамические упражнения Rio
|
||||||
|
|
||||||
|
**Files:** создать `rio_ui/public/{__init__,listing,detail,renderers}.py`, `tests/browser/test_interactive.py`.
|
||||||
|
|
||||||
|
**Interfaces:** `InteractivePage` загружает Publication по slug/version и использует `select_records` и `project_record`; `RecordPage` показывает только выбранные значения. Состояние страницы хранит фильтры, next_page, generation и флаг loading.
|
||||||
|
|
||||||
|
- [ ] Создать сценарий: 51 запись, размер 25; после двух нажатий «Загрузить ещё» получены 51 уникальных id, кнопки больше нет. Проверить каждое из четырёх представлений.
|
||||||
|
- [ ] Проверить быстрые повторные клики, смену фильтра во время загрузки и снятие публикации при открытой странице. Устаревший ответ фильтра не должен добавляться к новому списку; снятые данные не выдаются следующими запросами.
|
||||||
|
- [ ] Запустить `python -m pytest tests/browser/test_interactive.py -q` до реализации.
|
||||||
|
- [ ] Реализовать дозагрузку, состояния и детали, используя подтверждённые Rio API. Стабильные доступные подписи и роли обязательны; способы установки дополнительных DOM-атрибутов сначала проверить по версии Rio. Не строить тесты на случайных сгенерированных классах.
|
||||||
|
- [ ] Повторить сценарии; проверить прямое открытие вложенного URL, обновление браузера и отсутствие скрытых значений в передаваемых данных.
|
||||||
|
|
||||||
|
## Задача 10 Дизайн и проверка пользовательского сценария
|
||||||
|
|
||||||
|
**Files:** доработать `rio_ui/theme.py`, Rio-компоненты, `web/static/site.css`, HTML-шаблоны; создать `tests/browser/test_layout.py`, `tests/integration/test_visibility.py`.
|
||||||
|
|
||||||
|
- [ ] Реализовать общие цветовые и типографические параметры, боковую навигацию, компактную навигацию, карточки статусов и локальные ресурсы. Не добавлять новые функции ради оформления.
|
||||||
|
- [ ] Проверить 1440×900 и 390×844: основные действия доступны; длинный текст переносится; горизонтально прокручивается таблица, а не вся страница; заголовки и ответы не перекрываются. Сохранить скриншоты в рабочую папку и визуально просмотреть.
|
||||||
|
- [ ] Проверить клавиатурный фокус, подписи полей, ошибки рядом с полями и состояния пустых результатов. Для простых косметических правок использовать визуальную проверку, не тестировать каждую константу стиля.
|
||||||
|
- [ ] Добавить интеграционные тесты скрытых полей на списках, деталях, HTML предпросмотре без входа и прямых служебных запросах. Проверить пользовательские `<script>`, HTML и URL как неисполняемые значения.
|
||||||
|
- [ ] Запустить `python -m pytest tests/integration/test_visibility.py tests/browser/test_layout.py -q`, исправить наблюдаемые проблемы и повторить только затронутые проверки.
|
||||||
|
|
||||||
|
## Задача 11 Примеры и контроль сбора данных
|
||||||
|
|
||||||
|
**Files:** создать `examples/data/{hockey_teams.csv,messages.jsonl,questionnaires.json,profiles.csv}`, `examples/parsers/{scrape_html.py,scrape_interactive.py}`, `tests/integration/test_scraping.py`.
|
||||||
|
|
||||||
|
**Interfaces:** оба CLI принимают URL опубликованного представления и путь результата; выводят JSONL с теми же видимыми полями, record_id и состоянием отсутствующих значений. Решения не монтируются как публичные assets.
|
||||||
|
|
||||||
|
- [ ] Скопировать хоккейный CSV из старого проекта без изменения источника; подготовить синтетические примеры других представлений.
|
||||||
|
- [ ] Создать проверку ожидаемых данных: экспорт обоих парсеров совпадает с проекцией исходных записей, включая порядок, дубликаты значений, missing/null и последнюю неполную страницу.
|
||||||
|
- [ ] Реализовать HTTP-парсер на requests и BeautifulSoup с переходом по ссылкам, динамический — на Playwright с ожиданием состояния/элементов, без фиксированных sleep. Проверить таймауты, HTTP-ошибку и прекращение при снятой версии.
|
||||||
|
- [ ] Выполнить `python -m pytest tests/integration/test_scraping.py -q` на локальном тестовом сервере. Сверить каждый вид в двух режимах; автоматизировать повторяющиеся варианты параметризацией.
|
||||||
|
|
||||||
|
## Задача 12 Docker и передача проекта
|
||||||
|
|
||||||
|
**Files:** создать `Dockerfile`, `compose.yaml`, `.env.example`, `.dockerignore`, `README.md`, `docs/{design-spec,implementation-plan}.md`.
|
||||||
|
|
||||||
|
- [ ] Собрать образ на закреплённой совместимой Python, установить lock-зависимости, включить локальные ресурсы, запускать непривилегированным пользователем. Порт 8080, healthcheck `/healthz`, named volume в `/data`, без исходников и секретов рабочего окружения в образе.
|
||||||
|
- [ ] Проверить `docker compose config` и `docker compose build`; затем `docker compose up -d`. Передавать тестовые секреты безопасным локальным файлом, исключённым из Git и образа.
|
||||||
|
- [ ] Через контейнер пройти импорт и публикацию, HTTP-парсер и Playwright. Выполнить `docker compose down` без `-v`, поднять снова и проверить прежние ссылки и данные.
|
||||||
|
- [ ] Проверить резервное копирование при остановленном контейнере и восстановление в отдельный пустой том, не удаляя рабочий. Зафиксировать права каталога и успешный healthcheck после восстановления.
|
||||||
|
- [ ] Документировать запуск, генерацию хеша, переменные окружения, смену пароля, reverse proxy с WebSocket, сохранность тома, обновление, резервную копию, примеры файлов и парсеров. Не обещать проверку HTTPS, если proxy фактически не запускался.
|
||||||
|
- [ ] Выполнить полный `python -m pytest -q`, сборку и контрольный пользовательский сценарий. Если Docker Engine недоступен, сохранить Docker-файлы, явно отметить непроверенную сборку и не считать Docker-критерий выполненным.
|
||||||
|
|
||||||
|
## Контроль перед завершением
|
||||||
|
|
||||||
|
- [ ] Сопоставить реализованные функции со всеми критериями спецификации, включая отсутствие утечек полей и сохранность версий.
|
||||||
|
- [ ] Проверить весь проект независимым обзором после реализации; устранить подтверждённые дефекты и повторить затронутые проверки.
|
||||||
|
- [ ] Удалить только собственные временные тестовые данные из поставки, остановить временные процессы, сохранить пользовательские данные.
|
||||||
|
- [ ] Передать путь к проекту, инструкцию запуска, проверенные команды и известные ограничения. Публикацию на внешний сервер выполнять только по отдельному запросу.
|
||||||
|
|
||||||
|
## Порядок исполнения
|
||||||
|
|
||||||
|
Задачи выполняются последовательно в этой беседе. Тесно связанные интерфейсы импорта, представлений и публикаций делают этот вариант предпочтительным для первой реализации. Делегирование отдельным агентам возможно по выбору пользователя; оно не требуется для корректного выполнения плана.
|
||||||
|
|
||||||
|
План подготовлен для проверки перед началом реализации. Во время исполнения чекбоксы и результаты обновляются по фактическим проверкам, а не по наличию файлов.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Проверка FastAPI-версии — 2026-10-01
|
||||||
|
|
||||||
|
Рабочий проект: `P:\Coding\Python\NetologyWebParserSite`.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
Rio полностью удалён из кода приложения, pyproject.toml и uv.lock. Сервер — FastAPI; страницы — Jinja2, HTML и CSS; интерактивность — небольшие JavaScript-модули с HTTP-запросами. WebSocket не используется.
|
||||||
|
|
||||||
|
Кабинет поддерживает импорт CSV/JSON/JSONL, выбор вложенного массива JSON, настройку четырёх представлений, живой предпросмотр, сохранение черновика, публикацию и снятие версии. Обычные учебные страницы используют ссылочную пагинацию. Динамические страницы отдают первую порцию данных в HTML и поддерживают дозагрузку через JavaScript.
|
||||||
|
|
||||||
|
Хранилище SQLite, формат публикаций, учётные данные и адреса опубликованных страниц сохранены. Тесты работают с изолированными данными; рабочий `.env` не изменялся. Отдельно от тестов исправлен найденный черновик с точным содержимым примера questionnaires.json: две записи участников и автоматически настроенные анкеты. Прежний черновик сохранён в `data/backups/b2cce4ed61034aaab20242c364c21924-revision-3.json`; опубликованные версии и исходный файл не изменены.
|
||||||
|
|
||||||
|
## Результаты
|
||||||
|
|
||||||
|
- `uv sync --locked`: зависимости установлены, Rio отсутствует.
|
||||||
|
- Полный pytest с `LAB_BROWSER_TESTS=1`, `PLAYWRIGHT_CHANNEL=chrome`: **49 passed**, в том числе 14 реальных браузерных сценариев; 27,97 секунды.
|
||||||
|
- Один сторонний warning: Starlette предупреждает о будущем переходе TestClient с httpx на httpx2. Ошибок тестов нет.
|
||||||
|
- Импорт, предпросмотр, публикация, конфликт ревизий, замена исходника без изменения опубликованной версии, авторизация и CSRF проверены HTTP-тестами.
|
||||||
|
- Браузер проверяет импорт CSV и вложенного JSON, настройку заполненных анкет, блокировку сохранения после неуспешной проверки файла, изменение подписей в предпросмотре и предупреждение о несохранённых изменениях.
|
||||||
|
- Все четыре представления проверены парсерами и Chrome: 51 запись, дозагрузка, поиск, фильтры, отсутствие скрытых полей в публичных данных.
|
||||||
|
- Переход «Parser Lab / Коллекции» и выход из кабинета проверены.
|
||||||
|
- Единый favicon проверен на главной, входе, в кабинете и обоих типах учебных страниц.
|
||||||
|
- Одинаковые размеры, шрифты, цвета и положение элементов обычной/динамической страницы проверены на ширине 1440 и 390 пикселей.
|
||||||
|
- Снимки реального интерфейса кабинета и четырёх форматов проверены на настольном и мобильном экранах. Горизонтальная прокрутка широких таблиц остаётся внутри таблицы.
|
||||||
|
- `uv build`: собраны sdist и wheel с HTML/CSS/JS, шрифтами и favicon.
|
||||||
|
- Отдельное окружение `uv sync --locked --no-dev --no-editable`: установлен 21 пакет. Запуск установленного пакета, healthz, главная, вход, перенаправление из закрытого кабинета и статические ресурсы проверены без Rio и pytest.
|
||||||
|
|
||||||
|
Все проверки выполнялись на временных данных. Тестовые серверы остановлены.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
Из корня проекта:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv sync --locked
|
||||||
|
uv run --locked --env-file .env python -m parser_lab
|
||||||
|
```
|
||||||
|
|
||||||
|
Если предыдущая версия ещё запущена, остановите её перед запуском новой. Повторно создавать `.env` и импортировать существующие данные не нужно.
|
||||||
|
|
||||||
|
Dockerfile и Compose сохранены. Сборка и запуск самого контейнера в этой среде **не проверены**, поскольку Docker недоступен. Проверена отдельная production-установка с теми же uv-зависимостями.
|
||||||
|
|
||||||
|
`design-spec.md` и `implementation-plan.md` помечены как архив первоначальной Rio-архитектуры. Актуальная инструкция — README.md.
|
||||||
|
|
||||||
|
|
||||||
|
## Исправление импорта questionnaires.json
|
||||||
|
|
||||||
|
Браузерный сценарий загружает реальный `examples/data/questionnaires.json`, нажимает «Проверить файл» и «Сохранить и настроить», не выбирая вручную JSON-пути. Проверяются две анкеты A01/A02 и шесть исходных ответов: 8, Прогулка, true, 5, null, false. Импорт и редактор показывают отформатированные анкеты. Отдельный сценарий исправляет старый импорт целого документа кнопкой «Настроить анкеты автоматически», проверяет сохранение после перезагрузки и публикацию.
|
||||||
|
|
||||||
|
HTTP-проверки подтверждают независимость распознавания от имени файла, отказ от автоматического выбора при нескольких подходящих массивах, сохранение опубликованного снимка и исходника при исправлении, а также защиту от устаревшей ревизии. При неизвестной структуре остаётся ручной выбор полей.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
def extract(markup):
|
||||||
|
soup=BeautifulSoup(markup,'html.parser')
|
||||||
|
records=[]
|
||||||
|
def field_data(field):
|
||||||
|
return {'pointer':field['data-field'],'label':field.get('data-label',''),'state':field['data-value-state'],'type':field.get('data-value-type','string'),'text':field.get_text()}
|
||||||
|
for record in soup.select('.record[data-record-id]'):
|
||||||
|
records.append({'id':record['data-record-id'],'fields':[field_data(f) for f in record.select('.field[data-field]:not([data-role])')],
|
||||||
|
'roles':{f['data-role']:field_data(f) for f in record.select('.field[data-role]')}})
|
||||||
|
return records
|
||||||
|
|
||||||
|
def save(records,path):
|
||||||
|
Path(path).write_text(''.join(json.dumps(r,ensure_ascii=False)+'\n' for r in records),encoding='utf-8')
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""python scrape_html.py URL --output records.jsonl"""
|
||||||
|
import argparse
|
||||||
|
from urllib.parse import urljoin,urlsplit
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from common import extract,save
|
||||||
|
|
||||||
|
def scrape(url):
|
||||||
|
records=[]; pages=set(); ids=set(); origin=urlsplit(url).netloc
|
||||||
|
with requests.Session() as session:
|
||||||
|
while url:
|
||||||
|
if url in pages: raise RuntimeError('Зацикленная пагинация')
|
||||||
|
pages.add(url)
|
||||||
|
response=session.get(url,timeout=30); response.raise_for_status()
|
||||||
|
batch=extract(response.text)
|
||||||
|
for record in batch:
|
||||||
|
if record['id'] in ids: raise RuntimeError('Повтор записи между страницами')
|
||||||
|
ids.add(record['id']); records.append(record)
|
||||||
|
next_link=BeautifulSoup(response.text,'html.parser').select_one('a[rel=next]')
|
||||||
|
url=urljoin(url,next_link['href']) if next_link else ''
|
||||||
|
if url and urlsplit(url).netloc!=origin: raise RuntimeError('Ссылка ведёт на другой сервер')
|
||||||
|
return records
|
||||||
|
|
||||||
|
if __name__=='__main__':
|
||||||
|
p=argparse.ArgumentParser();p.add_argument('url');p.add_argument('--output',default='records.jsonl');a=p.parse_args()
|
||||||
|
records=scrape(a.url);save(records,a.output);print(f'Сохранено записей: {len(records)}')
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""python scrape_interactive.py URL --output records.jsonl [--channel chrome]"""
|
||||||
|
import argparse
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
from common import extract,save
|
||||||
|
|
||||||
|
def scrape(url,channel=None):
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser=p.chromium.launch(headless=True,**({'channel':channel} if channel else {}))
|
||||||
|
try:
|
||||||
|
page=browser.new_page()
|
||||||
|
response=page.goto(url)
|
||||||
|
if not response or not response.ok: raise RuntimeError('Публикация недоступна')
|
||||||
|
page.locator('.lab-records').wait_for(timeout=30000)
|
||||||
|
while True:
|
||||||
|
more=page.get_by_role('button',name='Загрузить ещё',exact=True)
|
||||||
|
if not more.is_visible(): break
|
||||||
|
old=page.locator('.record[data-record-id]').count()
|
||||||
|
more.click()
|
||||||
|
page.wait_for_function('(n)=>document.querySelectorAll(".record[data-record-id]").length > n',arg=old,timeout=10000)
|
||||||
|
records=extract(page.content())
|
||||||
|
if len({r['id'] for r in records})!=len(records): raise RuntimeError('Обнаружены дубликаты записей')
|
||||||
|
return records
|
||||||
|
finally: browser.close()
|
||||||
|
|
||||||
|
if __name__=='__main__':
|
||||||
|
p=argparse.ArgumentParser();p.add_argument('url');p.add_argument('--output',default='records.jsonl');p.add_argument('--channel');a=p.parse_args()
|
||||||
|
records=scrape(a.url,a.channel);save(records,a.output);print(f'Сохранено записей: {len(records)}')
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=75"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "parser-lab"
|
||||||
|
version = "1.0.0"
|
||||||
|
requires-python = ">=3.12,<3.13"
|
||||||
|
dependencies = ["fastapi==0.141.1", "python-multipart>=0.0.20", "jinja2==3.1.6", "argon2-cffi==25.1.0", "uvicorn==0.52.4"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
parser_lab = ["web/templates/*.html", "web/static/*"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest==9.1.1",
|
||||||
|
"httpx==0.28.1",
|
||||||
|
"requests==2.34.2",
|
||||||
|
"beautifulsoup4==4.15.0",
|
||||||
|
"playwright==1.63.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
required-version = ">=0.8.14"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Parser Lab — учебные данные в разных представлениях."""
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import uvicorn
|
||||||
|
from .app import create_app
|
||||||
|
if __name__=='__main__':
|
||||||
|
uvicorn.run(create_app(),host='0.0.0.0',port=8080,proxy_headers=False,access_log=False)
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Teacher HTTP routes. Data records and published snapshots never come from client config."""
|
||||||
|
import json
|
||||||
|
from fastapi import Request
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
from .web import render,records_fragment
|
||||||
|
from .importing import read_upload,ImportOptions,parse_json
|
||||||
|
from .paths import field_summary,field_paths,resolve_pointer,path_label
|
||||||
|
from .views import default_view,validate_view,project_record,KINDS
|
||||||
|
from .repository import Conflict
|
||||||
|
from .questionnaires import object_arrays,questionnaire_view,questionnaire_structure
|
||||||
|
|
||||||
|
|
||||||
|
def config_from_payload(draft,payload):
|
||||||
|
if not isinstance(payload,dict) or not isinstance(payload.get('revision'),int):raise ValueError('Некорректная версия черновика')
|
||||||
|
if payload['revision']!=draft['revision']:raise Conflict('Набор изменён в другой вкладке. Откройте его заново.')
|
||||||
|
for key in ('title','description','slug'):
|
||||||
|
if key in payload:
|
||||||
|
if not isinstance(payload[key],str) or len(payload[key])>10000:raise ValueError('Некорректное текстовое поле')
|
||||||
|
draft[key]=payload[key]
|
||||||
|
views=payload.get('views',{})
|
||||||
|
if not isinstance(views,dict) or any(k not in KINDS for k in views):raise ValueError('Неизвестное представление')
|
||||||
|
cleaned={}
|
||||||
|
for kind,source in views.items():
|
||||||
|
if not isinstance(source,dict):raise ValueError('Некорректные настройки представления')
|
||||||
|
view=default_view(kind,[])
|
||||||
|
for key,value in source.items():
|
||||||
|
if key not in view or key=='kind':continue
|
||||||
|
if key=='columns':
|
||||||
|
if not isinstance(value,list) or len(value)>1000:raise ValueError('Некорректный список полей')
|
||||||
|
columns=[]
|
||||||
|
for col in value:
|
||||||
|
if not isinstance(col,dict) or not all(isinstance(col.get(k),str) for k in ('pointer','label')):raise ValueError('Некорректное поле')
|
||||||
|
if len(col['pointer'])>2000 or len(col['label'])>1000:raise ValueError('Слишком длинное поле')
|
||||||
|
columns.append({'pointer':col['pointer'],'label':col['label']})
|
||||||
|
view[key]=columns
|
||||||
|
elif key=='page_size':
|
||||||
|
if value not in (10,25,50,100):raise ValueError('Неверный размер страницы')
|
||||||
|
view[key]=value
|
||||||
|
else:
|
||||||
|
if not isinstance(value,str) or len(value)>2000:raise ValueError('Некорректные настройки поля')
|
||||||
|
view[key]=value
|
||||||
|
cleaned[kind]=view
|
||||||
|
draft['views']=cleaned
|
||||||
|
return draft
|
||||||
|
|
||||||
|
|
||||||
|
def install_admin(app,auth,repo,settings):
|
||||||
|
def page(request,screen,**context):
|
||||||
|
token=auth.from_headers(request.headers)
|
||||||
|
return HTMLResponse(render('admin.html',screen=screen,csrf=auth.csrf(token),username=settings.username,
|
||||||
|
kinds=KINDS,**context),headers={'Cache-Control':'no-store'})
|
||||||
|
|
||||||
|
@app.get('/admin')
|
||||||
|
async def catalog(request:Request):
|
||||||
|
auth.from_headers(request.headers)
|
||||||
|
datasets=repo.list()
|
||||||
|
return page(request,'catalog',datasets=datasets,active={d['id']:any(v['active'] for v in repo.versions(d['slug'])) for d in datasets})
|
||||||
|
|
||||||
|
@app.get('/admin/import')
|
||||||
|
async def import_page(request:Request):
|
||||||
|
auth.from_headers(request.headers)
|
||||||
|
ident=request.query_params.get('replace','')
|
||||||
|
return page(request,'import',replace=repo.get(ident) if ident else None,max_bytes=settings.max_bytes)
|
||||||
|
|
||||||
|
@app.get('/admin/datasets/{ident}')
|
||||||
|
async def editor(request:Request,ident:str):
|
||||||
|
auth.from_headers(request.headers);d=repo.get(ident)
|
||||||
|
data={k:d[k] for k in ('id','title','slug','description','revision','views')}
|
||||||
|
data['fields']=[{'pointer':p,'label':path_label(p),'missing':info['missing']} for p,info in field_summary(d['records']).items()]
|
||||||
|
data['defaults']={k:default_view(k,field_paths(d['records'])[:6]) for k in KINDS}
|
||||||
|
detected=questionnaire_structure(d['records'])
|
||||||
|
if detected and not detected['pointer']:data['defaults']['questionnaire']=detected['view']
|
||||||
|
repair=detected if detected and (detected['pointer'] or 'questionnaire' not in d['views'] or validate_view(d['views']['questionnaire'],d['records'])) else None
|
||||||
|
return page(request,'edit',draft=d,editor_data=data,questionnaire_repair=repair)
|
||||||
|
|
||||||
|
@app.get('/admin/datasets/{ident}/versions')
|
||||||
|
async def versions(request:Request,ident:str):
|
||||||
|
auth.from_headers(request.headers);d=repo.get(ident)
|
||||||
|
versions=repo.versions(d['slug'])
|
||||||
|
for v in versions:v['kinds']=list(repo.publication(d['slug'],v['version'])['views']) if v['active'] else []
|
||||||
|
return page(request,'versions',draft=d,versions=versions)
|
||||||
|
|
||||||
|
async def uploaded(request):
|
||||||
|
form=await request.form(max_files=1,max_fields=12,max_part_size=settings.max_bytes+1)
|
||||||
|
file=form.get('file')
|
||||||
|
if file is None or not hasattr(file,'read'):raise ValueError('Выберите файл')
|
||||||
|
try:raw=await file.read(settings.max_bytes+1)
|
||||||
|
finally:await file.close()
|
||||||
|
filename=file.filename or ''
|
||||||
|
options=ImportOptions(str(form.get('encoding','utf-8-sig')),str(form.get('delimiter','')),str(form.get('pointer','')))
|
||||||
|
result=read_upload(raw,filename,options,max_bytes=settings.max_bytes,max_records=settings.max_records)
|
||||||
|
pointer=options.record_pointer
|
||||||
|
detected=questionnaire_structure(result.records)
|
||||||
|
if not pointer and form.get('root_confirmed')!='1' and filename.lower().endswith('.json') and detected and detected['pointer']:
|
||||||
|
pointer=detected['pointer']
|
||||||
|
result=read_upload(raw,filename,ImportOptions(record_pointer=pointer),max_bytes=settings.max_bytes,max_records=settings.max_records)
|
||||||
|
result.warnings.append(f'Распознаны анкеты: {len(result.records)} участников. Записи выбраны из {pointer}.')
|
||||||
|
return form,raw,filename,result,pointer
|
||||||
|
|
||||||
|
@app.post('/api/admin/inspect')
|
||||||
|
async def inspect_file(request:Request):
|
||||||
|
form,raw,filename,result,pointer=await uploaded(request)
|
||||||
|
arrays=[];choose_record_level=False
|
||||||
|
if filename.lower().endswith('.json'):
|
||||||
|
document=parse_json(raw.decode('utf-8-sig'))
|
||||||
|
arrays=object_arrays(document) if isinstance(document,dict) else ['']
|
||||||
|
choose_record_level=isinstance(document,dict) and bool(arrays) and not pointer and form.get('root_confirmed')!='1'
|
||||||
|
view=questionnaire_view(result.records) or default_view('table',result.fields[:8])
|
||||||
|
return {'count':len(result.records),'fields':result.fields,'warnings':result.warnings,'arrays':arrays,'choose_record_level':choose_record_level,'record_pointer':pointer,
|
||||||
|
'html':records_fragment([project_record(r,view,str(i+1)) for i,r in enumerate(result.records[:20])],view['kind'])}
|
||||||
|
|
||||||
|
@app.post('/api/admin/import')
|
||||||
|
async def import_file(request:Request):
|
||||||
|
form,raw,filename,result,pointer=await uploaded(request)
|
||||||
|
if filename.lower().endswith('.json') and not pointer and form.get('root_confirmed')!='1':
|
||||||
|
document=parse_json(raw.decode('utf-8-sig'))
|
||||||
|
if isinstance(document,dict) and object_arrays(document):
|
||||||
|
raise ValueError('В документе несколько возможных уровней данных. Выберите «Записи внутри JSON» и проверьте файл повторно.')
|
||||||
|
if form.get('replace_id'):
|
||||||
|
d=repo.replace_source(str(form['replace_id']),int(form.get('revision','0')),raw,filename,result.records)
|
||||||
|
else:d=repo.create(str(form.get('title','')),raw,filename,result.records)
|
||||||
|
view=questionnaire_view(result.records)
|
||||||
|
if view:
|
||||||
|
d['views']={'questionnaire':view};d=repo.save(d,d['revision'])
|
||||||
|
return {'id':d['id'],'slug':d['slug'],'revision':d['revision']}
|
||||||
|
|
||||||
|
@app.post('/api/admin/datasets/{ident}/configure-questionnaire')
|
||||||
|
async def configure_questionnaire(request:Request,ident:str):
|
||||||
|
payload=await request.json();d=repo.get(ident)
|
||||||
|
if not isinstance(payload,dict) or type(payload.get('revision')) is not int:raise ValueError('Некорректная версия черновика')
|
||||||
|
if payload['revision']!=d['revision']:raise Conflict('Коллекция изменена. Обновите страницу.')
|
||||||
|
detected=questionnaire_structure(d['records'])
|
||||||
|
if not detected:raise ValueError('Не удалось однозначно определить структуру анкет. Выберите записи при импорте.')
|
||||||
|
if len(detected['records'])>settings.max_records:raise ValueError('Превышен лимит записей')
|
||||||
|
if detected['pointer']:
|
||||||
|
d['records']=detected['records'];d['views']={}
|
||||||
|
d['views']['questionnaire']=detected['view']
|
||||||
|
d=repo.save(d,d['revision'])
|
||||||
|
return {'revision':d['revision'],'count':len(d['records'])}
|
||||||
|
|
||||||
|
@app.put('/api/admin/datasets/{ident}')
|
||||||
|
async def save(request:Request,ident:str):
|
||||||
|
d=config_from_payload(repo.get(ident),await request.json())
|
||||||
|
return {'revision':repo.save(d,d['revision'])['revision']}
|
||||||
|
|
||||||
|
@app.post('/api/admin/datasets/{ident}/preview')
|
||||||
|
async def live_preview(request:Request,ident:str):
|
||||||
|
d=config_from_payload(repo.get(ident),await request.json());kind=request.query_params.get('kind','table')
|
||||||
|
if kind not in d['views']:return {'html':'','errors':['Включите представление, чтобы увидеть результат.']}
|
||||||
|
view=d['views'][kind];errors=validate_view(view,d['records'])
|
||||||
|
return {'errors':errors,'html':'' if errors else records_fragment([project_record(r,view,str(i+1)) for i,r in enumerate(d['records'][:5])],kind)}
|
||||||
|
|
||||||
|
@app.get('/api/admin/datasets/{ident}/fields')
|
||||||
|
async def nested_fields(request:Request,ident:str):
|
||||||
|
auth.from_headers(request.headers);d=repo.get(ident);rows=[]
|
||||||
|
for r in d['records']:
|
||||||
|
_,value=resolve_pointer(r,request.query_params.get('pointer',''))
|
||||||
|
if isinstance(value,list):rows.extend(x for x in value if isinstance(x,dict))
|
||||||
|
return {'fields':field_paths(rows),'scalar_fields':[p for p in field_paths(rows) if not any(isinstance(resolve_pointer(row,p)[1],(dict,list)) for row in rows)]}
|
||||||
|
|
||||||
|
@app.post('/api/admin/datasets/{ident}/publish')
|
||||||
|
async def publish(request:Request,ident:str):
|
||||||
|
payload=await request.json()
|
||||||
|
if not isinstance(payload,dict) or type(payload.get('revision')) is not int:raise ValueError('Некорректная версия черновика')
|
||||||
|
p=repo.publish(ident,payload['revision'])
|
||||||
|
return {'version':p['version']}
|
||||||
|
|
||||||
|
@app.post('/api/admin/datasets/{ident}/unpublish/{version}')
|
||||||
|
async def unpublish(request:Request,ident:str,version:int):
|
||||||
|
d=repo.get(ident);repo.unpublish(d['slug'],version)
|
||||||
|
return {'ok':True}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""FastAPI application: server-rendered pages and small progressive-enhancement APIs."""
|
||||||
|
import secrets
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from starlette.responses import HTMLResponse, RedirectResponse, JSONResponse, Response
|
||||||
|
from starlette.staticfiles import StaticFiles
|
||||||
|
from .config import Settings
|
||||||
|
from .repository import Repository, NotFound, Conflict
|
||||||
|
from .auth import Auth, Unauthorized, RateLimited
|
||||||
|
from .web import render, public_page, ROOT
|
||||||
|
|
||||||
|
|
||||||
|
class Boundary:
|
||||||
|
def __init__(self,app,auth): self.app=app; self.auth=auth
|
||||||
|
|
||||||
|
async def __call__(self,scope,receive,send):
|
||||||
|
if scope['type']!='http': return await self.app(scope,receive,send)
|
||||||
|
headers={k.decode().lower():v.decode() for k,v in scope.get('headers',[])}
|
||||||
|
changing=scope.get('method') in ('POST','PUT','PATCH','DELETE')
|
||||||
|
if changing:
|
||||||
|
if headers.get('origin','').rstrip('/') not in self.auth.settings.origins:
|
||||||
|
return await JSONResponse({'error':'Недопустимый источник запроса'},403)(scope,receive,send)
|
||||||
|
if scope['path'].startswith('/api/admin/'):
|
||||||
|
try: token=self.auth.from_headers(headers)
|
||||||
|
except Unauthorized:
|
||||||
|
return await JSONResponse({'error':'Войдите в кабинет'},401)(scope,receive,send)
|
||||||
|
if not self.auth.valid_csrf(token,headers.get('x-csrf-token','')):
|
||||||
|
return await JSONResponse({'error':'Обновите страницу кабинета'},403)(scope,receive,send)
|
||||||
|
limit=self.auth.settings.max_bytes+65536 if scope['path'] in ('/api/admin/import','/api/admin/inspect') else 1024*1024
|
||||||
|
try: declared=int(headers.get('content-length','0'))
|
||||||
|
except ValueError: return await Response(status_code=400)(scope,receive,send)
|
||||||
|
if declared>limit: return await JSONResponse({'error':'Файл или запрос слишком большой'},413)(scope,receive,send)
|
||||||
|
body=bytearray()
|
||||||
|
while True:
|
||||||
|
event=await receive()
|
||||||
|
if event['type']=='http.disconnect': return
|
||||||
|
body.extend(event.get('body',b''))
|
||||||
|
if len(body)>limit: return await JSONResponse({'error':'Файл или запрос слишком большой'},413)(scope,receive,send)
|
||||||
|
if not event.get('more_body',False): break
|
||||||
|
async def replay(): return {'type':'http.request','body':bytes(body),'more_body':False}
|
||||||
|
return await self.app(scope,replay,send)
|
||||||
|
await self.app(scope,receive,send)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(settings=None):
|
||||||
|
settings=settings or Settings.from_env()
|
||||||
|
repo=Repository(settings.data_dir);auth=Auth(settings,repo)
|
||||||
|
app=FastAPI(title='Parser Lab',docs_url=None,redoc_url=None,openapi_url=None)
|
||||||
|
app.state.repo=repo;app.state.auth=auth
|
||||||
|
app.add_middleware(Boundary,auth=auth)
|
||||||
|
app.mount('/static',StaticFiles(directory=ROOT/'static'),name='static')
|
||||||
|
|
||||||
|
@app.exception_handler(ValueError)
|
||||||
|
async def value_error(request,error):
|
||||||
|
status=404 if isinstance(error,NotFound) else 409 if isinstance(error,Conflict) else 400
|
||||||
|
if request.url.path.startswith('/api/') or request.query_params.get('fragment'):
|
||||||
|
return JSONResponse({'error':str(error)},status)
|
||||||
|
return HTMLResponse(render('error.html',message=str(error)),status)
|
||||||
|
|
||||||
|
@app.exception_handler(Unauthorized)
|
||||||
|
async def unauthorized(request,error):
|
||||||
|
if request.url.path.startswith('/api/'): return JSONResponse({'error':str(error)},401)
|
||||||
|
return RedirectResponse('/login',303)
|
||||||
|
|
||||||
|
@app.get('/healthz')
|
||||||
|
async def health():
|
||||||
|
with repo.connect() as db: db.execute('SELECT 1').fetchone()
|
||||||
|
return {'status':'ok'}
|
||||||
|
|
||||||
|
@app.get('/')
|
||||||
|
async def home(): return HTMLResponse(render('home.html'))
|
||||||
|
|
||||||
|
@app.get('/login')
|
||||||
|
async def login_page():
|
||||||
|
nonce=secrets.token_urlsafe(32)
|
||||||
|
response=HTMLResponse(render('login.html',csrf=auth.csrf(nonce)))
|
||||||
|
response.set_cookie('lab_login',nonce,httponly=True,secure=settings.secure_cookie,samesite='strict',max_age=600)
|
||||||
|
response.headers['Cache-Control']='no-store'
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.post('/login')
|
||||||
|
async def login(request:Request):
|
||||||
|
raw=await request.body()
|
||||||
|
if len(raw)>8192:return Response(status_code=413)
|
||||||
|
form={k:v[0] for k,v in parse_qs(raw.decode()).items()}
|
||||||
|
nonce=request.cookies.get('lab_login','')
|
||||||
|
if not auth.valid_csrf(nonce,form.get('csrf','')):return Response('Обновите страницу входа',status_code=403)
|
||||||
|
try:token=auth.login(form.get('username',''),form.get('password',''),request.client.host)
|
||||||
|
except (Unauthorized,RateLimited) as e:
|
||||||
|
return HTMLResponse(render('login.html',csrf=auth.csrf(nonce),error=str(e)),429 if isinstance(e,RateLimited) else 401)
|
||||||
|
response=RedirectResponse('/admin',303)
|
||||||
|
response.set_cookie('lab_session',token,httponly=True,secure=settings.secure_cookie,samesite='strict',max_age=43200)
|
||||||
|
response.delete_cookie('lab_login')
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.post('/logout')
|
||||||
|
async def logout(request:Request):
|
||||||
|
token=auth.from_headers(request.headers)
|
||||||
|
form=parse_qs((await request.body()).decode())
|
||||||
|
if not auth.valid_csrf(token,form.get('csrf',[''])[0]):return Response(status_code=403)
|
||||||
|
auth.logout(token)
|
||||||
|
response=RedirectResponse('/login',303);response.delete_cookie('lab_session')
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def serve(request,slug,version,kind,record_id=None,mode='html'):
|
||||||
|
p=repo.publication(slug,version)
|
||||||
|
return public_response(request,p,kind,f'/{mode}/{slug}/v/{version}/{kind}',record_id,False,mode=='interactive')
|
||||||
|
|
||||||
|
def public_response(request,p,kind,base,record_id=None,preview=False,live=False):
|
||||||
|
page=int(request.query_params.get('page','1'))
|
||||||
|
search=request.query_params.get('q',''); category=request.query_params.get('category') or None
|
||||||
|
if request.query_params.get('fragment')=='1' and live:
|
||||||
|
from .query import select_records
|
||||||
|
from .web import records_fragment
|
||||||
|
view=p['views'].get(kind)
|
||||||
|
if view is None:raise NotFound('Представление не найдено')
|
||||||
|
result=select_records(p['records'],view,page,search,category)
|
||||||
|
return JSONResponse({'html':records_fragment(result['items'],kind,base),'total':result['total'],
|
||||||
|
'has_next':result['has_next'],'page':page},headers={'Cache-Control':'no-store'})
|
||||||
|
return HTMLResponse(public_page(p,kind,base,page,search,category,record_id,preview,live),headers={'Cache-Control':'no-store'})
|
||||||
|
|
||||||
|
@app.get('/html/{slug}/v/{version}/{kind}')
|
||||||
|
async def listing(request:Request,slug:str,version:int,kind:str):return await serve(request,slug,version,kind)
|
||||||
|
|
||||||
|
@app.get('/html/{slug}/v/{version}/{kind}/records/{record_id}')
|
||||||
|
async def detail(request:Request,slug:str,version:int,kind:str,record_id:str):return await serve(request,slug,version,kind,record_id)
|
||||||
|
|
||||||
|
@app.get('/interactive/{slug}/v/{version}/{kind}')
|
||||||
|
async def interactive(request:Request,slug:str,version:int,kind:str):return await serve(request,slug,version,kind,mode='interactive')
|
||||||
|
|
||||||
|
@app.get('/interactive/{slug}/v/{version}/{kind}/records/{record_id}')
|
||||||
|
async def interactive_detail(request:Request,slug:str,version:int,kind:str,record_id:str):return await serve(request,slug,version,kind,record_id,'interactive')
|
||||||
|
|
||||||
|
@app.get('/preview/{ident}/{kind}')
|
||||||
|
async def preview(request:Request,ident:str,kind:str):
|
||||||
|
auth.from_headers(request.headers)
|
||||||
|
return public_response(request,repo.get(ident),kind,f'/preview/{ident}/{kind}',preview=True)
|
||||||
|
|
||||||
|
@app.get('/preview/{ident}/{kind}/records/{record_id}')
|
||||||
|
async def preview_detail(request:Request,ident:str,kind:str,record_id:str):
|
||||||
|
auth.from_headers(request.headers)
|
||||||
|
return public_response(request,repo.get(ident),kind,f'/preview/{ident}/{kind}',record_id,True)
|
||||||
|
|
||||||
|
@app.get('/dynamic-preview/{ident}/{kind}')
|
||||||
|
async def dynamic_preview(request:Request,ident:str,kind:str):
|
||||||
|
auth.from_headers(request.headers)
|
||||||
|
return public_response(request,repo.get(ident),kind,f'/dynamic-preview/{ident}/{kind}',preview=True,live=True)
|
||||||
|
|
||||||
|
@app.get('/dynamic-preview/{ident}/{kind}/records/{record_id}')
|
||||||
|
async def dynamic_detail(request:Request,ident:str,kind:str,record_id:str):
|
||||||
|
auth.from_headers(request.headers)
|
||||||
|
return public_response(request,repo.get(ident),kind,f'/dynamic-preview/{ident}/{kind}',record_id,True,True)
|
||||||
|
|
||||||
|
from .admin import install_admin
|
||||||
|
install_admin(app,auth,repo,settings)
|
||||||
|
return app
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from http.cookies import SimpleCookie
|
||||||
|
from collections import defaultdict
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import VerificationError, InvalidHashError
|
||||||
|
|
||||||
|
class Unauthorized(ValueError): pass
|
||||||
|
class RateLimited(ValueError): pass
|
||||||
|
|
||||||
|
class Auth:
|
||||||
|
def __init__(self,settings,repo):
|
||||||
|
self.settings=settings; self.repo=repo
|
||||||
|
self.attempts=defaultdict(list)
|
||||||
|
self.password_hash=PasswordHasher().hash(settings.password)
|
||||||
|
credentials=json.dumps([settings.username,settings.password],ensure_ascii=False).encode()
|
||||||
|
self.fingerprint=hmac.new(settings.secret.encode(),credentials,hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
def login(self,username,password,client):
|
||||||
|
now=time.time()
|
||||||
|
tries=[t for t in self.attempts[client] if now-t<300]
|
||||||
|
self.attempts[client]=tries
|
||||||
|
if len(tries)>=5: raise RateLimited('Слишком много попыток. Повторите через 5 минут.')
|
||||||
|
try: good=PasswordHasher().verify(self.password_hash,password)
|
||||||
|
except (VerificationError,InvalidHashError): good=False
|
||||||
|
if not good or not hmac.compare_digest(username.encode(),self.settings.username.encode()):
|
||||||
|
self.attempts[client].append(now)
|
||||||
|
raise Unauthorized('Неверный логин или пароль')
|
||||||
|
self.attempts.pop(client,None)
|
||||||
|
token=secrets.token_urlsafe(32)
|
||||||
|
with self.repo.connect() as db:
|
||||||
|
db.execute('DELETE FROM sessions WHERE expires<? OR fingerprint<>?',(now,self.fingerprint))
|
||||||
|
db.execute('INSERT INTO sessions VALUES (?,?,?)',(self.digest(token),now+43200,self.fingerprint))
|
||||||
|
return token
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def digest(token): return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
def check(self,token):
|
||||||
|
with self.repo.connect() as db:
|
||||||
|
row=db.execute('SELECT 1 FROM sessions WHERE token=? AND expires>? AND fingerprint=?',(self.digest(token),time.time(),self.fingerprint)).fetchone()
|
||||||
|
if not row: raise Unauthorized('Сессия завершена. Войдите снова.')
|
||||||
|
return token
|
||||||
|
|
||||||
|
def from_headers(self,headers):
|
||||||
|
cookies=SimpleCookie()
|
||||||
|
try: cookies.load(headers.get('cookie',''))
|
||||||
|
except Exception: raise Unauthorized('Войдите в кабинет')
|
||||||
|
token=cookies['lab_session'].value if 'lab_session' in cookies else ''
|
||||||
|
return self.check(token)
|
||||||
|
|
||||||
|
def logout(self,token):
|
||||||
|
with self.repo.connect() as db: db.execute('DELETE FROM sessions WHERE token=?',(self.digest(token),))
|
||||||
|
|
||||||
|
def csrf(self,token): return hmac.new(self.settings.secret.encode(),token.encode(),hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
def valid_csrf(self,token,value): return bool(token and value) and hmac.compare_digest(self.csrf(token),value)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import argparse
|
||||||
|
import getpass
|
||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def quote_env(value):
|
||||||
|
# Double-quoted escapes are supported by both uv's dotenvy and Compose.
|
||||||
|
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace('$', '\\$')
|
||||||
|
return '"' + escaped + '"'
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser=argparse.ArgumentParser()
|
||||||
|
parser.add_argument('command',choices=['init-env'])
|
||||||
|
parser.add_argument('--path',default='.env')
|
||||||
|
args=parser.parse_args()
|
||||||
|
path=Path(args.path)
|
||||||
|
if path.exists(): raise SystemExit('Файл уже существует; перезапись запрещена.')
|
||||||
|
user=input('Логин преподавателя [teacher]: ').strip() or 'teacher'
|
||||||
|
password=getpass.getpass('Пароль (не менее 10 символов): ')
|
||||||
|
if len(password)<10 or password!=getpass.getpass('Повторите пароль: '): raise SystemExit('Пароли не совпадают или слишком короткие')
|
||||||
|
path.write_text(f"LAB_ADMIN_USER={quote_env(user)}\nLAB_ADMIN_PASSWORD={quote_env(password)}\nLAB_SECRET={secrets.token_urlsafe(48)}\nLAB_ORIGINS=http://localhost:8080\nLAB_SECURE_COOKIE=false\nLAB_PORT=8080\n",encoding='utf-8')
|
||||||
|
print(f'Создан {path}. Перед публикацией настройте домен и HTTPS по README.')
|
||||||
|
|
||||||
|
if __name__=='__main__': main()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Settings:
|
||||||
|
data_dir: Path
|
||||||
|
username: str
|
||||||
|
password: str = field(repr=False)
|
||||||
|
secret: str = field(repr=False)
|
||||||
|
secure_cookie: bool = True
|
||||||
|
origins: tuple[str,...] = ()
|
||||||
|
max_bytes: int = 25*1024*1024
|
||||||
|
max_records: int = 50000
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls):
|
||||||
|
username=os.environ.get('LAB_ADMIN_USER','')
|
||||||
|
password=os.environ.get('LAB_ADMIN_PASSWORD','')
|
||||||
|
secret=os.environ.get('LAB_SECRET','')
|
||||||
|
if not username or len(password)<10 or len(secret)<32:
|
||||||
|
raise ValueError('Задайте LAB_ADMIN_USER, LAB_ADMIN_PASSWORD (не менее 10 символов) и LAB_SECRET (не менее 32 символов). См. README.')
|
||||||
|
origins=tuple(x.strip().rstrip('/') for x in os.environ.get('LAB_ORIGINS','http://localhost:8080').split(',') if x.strip())
|
||||||
|
return cls(Path(os.environ.get('LAB_DATA_DIR','./data')),username,password,secret,
|
||||||
|
os.environ.get('LAB_SECURE_COOKIE','true').lower()=='true',origins,
|
||||||
|
int(os.environ.get('LAB_MAX_BYTES',25*1024*1024)),int(os.environ.get('LAB_MAX_RECORDS',50000)))
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from .paths import resolve_pointer, field_paths
|
||||||
|
|
||||||
|
class ImportErrorDetail(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportOptions:
|
||||||
|
encoding: str = 'utf-8-sig'
|
||||||
|
delimiter: str = ''
|
||||||
|
record_pointer: str = ''
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportResult:
|
||||||
|
records: list[dict]
|
||||||
|
fields: list[str]
|
||||||
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def _pairs(pairs):
|
||||||
|
result = {}
|
||||||
|
for key, value in pairs:
|
||||||
|
if key in result:
|
||||||
|
raise ImportErrorDetail(f'Повторяющийся ключ: {key}')
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _bad_constant(value):
|
||||||
|
raise ImportErrorDetail(f'Недопустимое число: {value}')
|
||||||
|
|
||||||
|
def parse_json(text):
|
||||||
|
return json.loads(text, object_pairs_hook=_pairs, parse_constant=_bad_constant)
|
||||||
|
|
||||||
|
def _depth(value, level=0):
|
||||||
|
if level > 20:
|
||||||
|
raise ImportErrorDetail('Глубина JSON превышает 20 уровней')
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for v in value.values(): _depth(v, level+1)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for v in value: _depth(v, level+1)
|
||||||
|
|
||||||
|
def read_upload(data, filename, options, *, max_bytes=25*1024*1024, max_records=50000):
|
||||||
|
if not data or len(data)>max_bytes:
|
||||||
|
raise ImportErrorDetail(f'Файл пуст или превышает лимит {max_bytes//1024//1024} МиБ')
|
||||||
|
ext = Path(filename).suffix.lower()
|
||||||
|
if ext not in ('.csv','.json','.jsonl'):
|
||||||
|
raise ImportErrorDetail('Выберите CSV, JSON или JSONL')
|
||||||
|
warnings = []
|
||||||
|
try:
|
||||||
|
text = data.decode(options.encoding if ext=='.csv' else 'utf-8-sig')
|
||||||
|
if ext == '.csv':
|
||||||
|
delimiter = options.delimiter
|
||||||
|
if not delimiter:
|
||||||
|
try: delimiter = csv.Sniffer().sniff(text[:8192], delimiters=',;\t').delimiter
|
||||||
|
except csv.Error: delimiter = ','
|
||||||
|
warnings.append(f'Определён разделитель: {repr(delimiter)}')
|
||||||
|
if delimiter not in (',',';','\t'):
|
||||||
|
raise ImportErrorDetail('Неподдерживаемый разделитель')
|
||||||
|
reader = csv.reader(io.StringIO(text, newline=''), delimiter=delimiter, strict=True)
|
||||||
|
header = next(reader, [])
|
||||||
|
if not header or any(not h.strip() for h in header) or len(set(header))!=len(header):
|
||||||
|
raise ImportErrorDetail('Заголовки должны быть непустыми и уникальными')
|
||||||
|
records = []
|
||||||
|
for row in reader:
|
||||||
|
if len(row)!=len(header):
|
||||||
|
raise ImportErrorDetail(f'Строка {reader.line_num}: число ячеек не совпадает с заголовками')
|
||||||
|
records.append(dict(zip(header,row)))
|
||||||
|
if len(records)>max_records: raise ImportErrorDetail('Превышен лимит записей')
|
||||||
|
elif ext == '.jsonl':
|
||||||
|
records = []; blank = 0
|
||||||
|
for number, line in enumerate(text.splitlines(),1):
|
||||||
|
if not line.strip(): blank+=1; continue
|
||||||
|
try: record = parse_json(line)
|
||||||
|
except (ValueError, RecursionError) as e: raise ImportErrorDetail(f'Строка {number}: {e}') from e
|
||||||
|
if not isinstance(record,dict): raise ImportErrorDetail(f'Строка {number}: ожидается объект')
|
||||||
|
records.append(record)
|
||||||
|
if len(records)>max_records: raise ImportErrorDetail('Превышен лимит записей')
|
||||||
|
if blank: warnings.append(f'Пропущено пустых строк: {blank}')
|
||||||
|
else:
|
||||||
|
value = parse_json(text)
|
||||||
|
_depth(value)
|
||||||
|
if options.record_pointer:
|
||||||
|
found,value = resolve_pointer(value,options.record_pointer)
|
||||||
|
if not found: raise ImportErrorDetail('Выбранный путь отсутствует в JSON')
|
||||||
|
records = [value] if isinstance(value,dict) else value
|
||||||
|
if not isinstance(records,list) or not records or not all(isinstance(x,dict) for x in records):
|
||||||
|
raise ImportErrorDetail('Ожидается непустой массив объектов или один объект')
|
||||||
|
if len(records)>max_records: raise ImportErrorDetail('Превышен лимит записей')
|
||||||
|
for record in records: _depth(record)
|
||||||
|
return ImportResult(records,field_paths(records),warnings)
|
||||||
|
except ImportErrorDetail:
|
||||||
|
raise
|
||||||
|
except (UnicodeError, ValueError, csv.Error, RecursionError) as e:
|
||||||
|
raise ImportErrorDetail(f'Не удалось прочитать файл: {e}') from e
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
def resolve_pointer(record, pointer):
|
||||||
|
if pointer == '':
|
||||||
|
return True, record
|
||||||
|
if not pointer.startswith('/'):
|
||||||
|
return False, None
|
||||||
|
value = record
|
||||||
|
for part in pointer[1:].split('/'):
|
||||||
|
part = part.replace('~1', '/').replace('~0', '~')
|
||||||
|
if isinstance(value, dict) and part in value:
|
||||||
|
value = value[part]
|
||||||
|
elif isinstance(value, list) and part.isdigit() and int(part) < len(value):
|
||||||
|
value = value[int(part)]
|
||||||
|
else:
|
||||||
|
return False, None
|
||||||
|
return True, value
|
||||||
|
|
||||||
|
def field_paths(records):
|
||||||
|
paths = {}
|
||||||
|
def walk(value, prefix):
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key, child in value.items():
|
||||||
|
p = prefix + '/' + key.replace('~','~0').replace('/','~1')
|
||||||
|
paths[p] = None
|
||||||
|
walk(child, p)
|
||||||
|
for record in records:
|
||||||
|
walk(record, '')
|
||||||
|
return list(paths)
|
||||||
|
|
||||||
|
def field_summary(records):
|
||||||
|
result={}
|
||||||
|
for p in field_paths(records):
|
||||||
|
result[p]={'label':p.rsplit('/',1)[-1].replace('~1','/').replace('~0','~'),
|
||||||
|
'parent':p.rsplit('/',1)[0],
|
||||||
|
'missing':sum(not resolve_pointer(r,p)[0] for r in records)}
|
||||||
|
return result
|
||||||
|
|
||||||
|
def path_label(pointer):
|
||||||
|
return ' › '.join(x.replace('~1','/').replace('~0','~') for x in pointer.split('/')[1:])
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import json
|
||||||
|
from .paths import resolve_pointer
|
||||||
|
from .views import project_record
|
||||||
|
from .repository import NotFound
|
||||||
|
|
||||||
|
def select_records(records,view,page=1,search='',category=None):
|
||||||
|
if not isinstance(page,int) or page<1: raise ValueError('Некорректный номер страницы')
|
||||||
|
selected=[]
|
||||||
|
for n,record in enumerate(records,1):
|
||||||
|
if search and view.get('search_pointer'):
|
||||||
|
found,value=resolve_pointer(record,view['search_pointer'])
|
||||||
|
if not found or not isinstance(value,str) or search.casefold() not in value.casefold(): continue
|
||||||
|
if category is not None and view.get('category_pointer'):
|
||||||
|
found,value=resolve_pointer(record,view['category_pointer'])
|
||||||
|
if not found or json.dumps(value,ensure_ascii=False)!=category: continue
|
||||||
|
selected.append((n,record))
|
||||||
|
size=view['page_size']; start=(page-1)*size
|
||||||
|
if page>1 and start>=len(selected): raise NotFound('Страница не найдена')
|
||||||
|
return {'items':[project_record(r,view,n) for n,r in selected[start:start+size]],'total':len(selected),'page':page,'page_size':size,'has_next':start+size<len(selected)}
|
||||||
|
|
||||||
|
def categories(records,view):
|
||||||
|
p=view.get('category_pointer')
|
||||||
|
if not p: return []
|
||||||
|
seen={}
|
||||||
|
for r in records:
|
||||||
|
found,v=resolve_pointer(r,p)
|
||||||
|
if found and not isinstance(v,(dict,list)):
|
||||||
|
key=json.dumps(v,ensure_ascii=False)
|
||||||
|
seen[key]=v if isinstance(v,str) else key
|
||||||
|
return list(seen.items())
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Conservative recognition of questionnaire records, independent of filenames."""
|
||||||
|
from .paths import field_paths, resolve_pointer
|
||||||
|
from .views import default_view, validate_view
|
||||||
|
|
||||||
|
|
||||||
|
def object_arrays(document):
|
||||||
|
return [p for p in field_paths([document])
|
||||||
|
if isinstance(resolve_pointer(document,p)[1],list)
|
||||||
|
and resolve_pointer(document,p)[1]
|
||||||
|
and all(isinstance(row,dict) for row in resolve_pointer(document,p)[1])]
|
||||||
|
|
||||||
|
|
||||||
|
def questionnaire_view(records):
|
||||||
|
candidates=[]
|
||||||
|
for pointer in field_paths(records):
|
||||||
|
arrays=[resolve_pointer(row,pointer)[1] for row in records]
|
||||||
|
if not all(value is None or isinstance(value,list) for value in arrays):continue
|
||||||
|
answers=[item for value in arrays if isinstance(value,list) for item in value]
|
||||||
|
if not answers or not all(isinstance(item,dict) and isinstance(item.get('question'),str) and 'answer' in item for item in answers):continue
|
||||||
|
candidates.append(pointer)
|
||||||
|
if len(candidates)!=1:return None
|
||||||
|
scalars=[p for p in field_paths(records) if all(not isinstance(resolve_pointer(row,p)[1],(dict,list)) for row in records)]
|
||||||
|
title=next((p for p in ('/participant','/name','/id') if p in scalars),scalars[0] if scalars else '')
|
||||||
|
view=default_view('questionnaire',scalars[:6] or candidates)
|
||||||
|
view.update(questionnaire_mode='pairs',questions_pointer=candidates[0],question_pointer='/question',answer_pointer='/answer',title_pointer=title,text_pointer='')
|
||||||
|
return view if not validate_view(view,records) else None
|
||||||
|
|
||||||
|
|
||||||
|
def questionnaire_structure(records):
|
||||||
|
view=questionnaire_view(records)
|
||||||
|
if view:return {'pointer':'','records':records,'view':view}
|
||||||
|
if len(records)!=1:return None
|
||||||
|
candidates=[]
|
||||||
|
for pointer in object_arrays(records[0]):
|
||||||
|
rows=resolve_pointer(records[0],pointer)[1]
|
||||||
|
view=questionnaire_view(rows)
|
||||||
|
if view:candidates.append({'pointer':pointer,'records':rows,'view':view})
|
||||||
|
return candidates[0] if len(candidates)==1 else None
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from .views import validate_view
|
||||||
|
|
||||||
|
class Conflict(ValueError): pass
|
||||||
|
class NotFound(ValueError): pass
|
||||||
|
|
||||||
|
def now(): return datetime.now(timezone.utc).isoformat(timespec='seconds')
|
||||||
|
def encode(value): return json.dumps(value,ensure_ascii=False,allow_nan=False)
|
||||||
|
|
||||||
|
class Repository:
|
||||||
|
def __init__(self, root):
|
||||||
|
self.root=Path(root); self.root.mkdir(parents=True,exist_ok=True)
|
||||||
|
(self.root/'sources').mkdir(exist_ok=True)
|
||||||
|
self.path=self.root/'lab.sqlite3'
|
||||||
|
with self.connect() as db:
|
||||||
|
db.executescript('''
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_version(version INTEGER NOT NULL);
|
||||||
|
INSERT INTO schema_version SELECT 1 WHERE NOT EXISTS(SELECT 1 FROM schema_version);
|
||||||
|
CREATE TABLE IF NOT EXISTS datasets(id TEXT PRIMARY KEY,slug TEXT UNIQUE NOT NULL,revision INTEGER NOT NULL,body TEXT NOT NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS publications(slug TEXT NOT NULL,version INTEGER NOT NULL,active INTEGER NOT NULL,body TEXT NOT NULL,PRIMARY KEY(slug,version));
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions(token TEXT PRIMARY KEY,expires REAL NOT NULL,fingerprint TEXT NOT NULL);
|
||||||
|
''')
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def connect(self):
|
||||||
|
db=sqlite3.connect(self.path,timeout=15)
|
||||||
|
db.row_factory=sqlite3.Row
|
||||||
|
try:
|
||||||
|
with db: yield db
|
||||||
|
finally: db.close()
|
||||||
|
|
||||||
|
def create(self,title,source,filename,records):
|
||||||
|
ident=uuid.uuid4().hex
|
||||||
|
source_path=self.root/'sources'/ident
|
||||||
|
temp=source_path.with_suffix('.tmp')
|
||||||
|
temp.write_bytes(source); temp.replace(source_path)
|
||||||
|
draft={'id':ident,'slug':'study-'+ident[:8],'revision':1,'title':title.strip() or 'Новый набор','description':'',
|
||||||
|
'records':records,'views':{},'source':ident,'filename':Path(filename).name,'updated':now()}
|
||||||
|
try:
|
||||||
|
with self.connect() as db: db.execute('INSERT INTO datasets VALUES (?,?,?,?)',(ident,draft['slug'],1,encode(draft)))
|
||||||
|
except Exception:
|
||||||
|
source_path.unlink(missing_ok=True); raise
|
||||||
|
return draft
|
||||||
|
|
||||||
|
def get(self,ident):
|
||||||
|
with self.connect() as db: row=db.execute('SELECT body FROM datasets WHERE id=?',(ident,)).fetchone()
|
||||||
|
if not row: raise NotFound('Набор не найден')
|
||||||
|
return json.loads(row[0])
|
||||||
|
|
||||||
|
def list(self):
|
||||||
|
with self.connect() as db: rows=db.execute('SELECT body FROM datasets ORDER BY rowid DESC').fetchall()
|
||||||
|
return [json.loads(r[0]) for r in rows]
|
||||||
|
|
||||||
|
def save(self,draft,expected_revision):
|
||||||
|
d=copy.deepcopy(draft)
|
||||||
|
if not re.fullmatch('[a-z0-9]+(?:-[a-z0-9]+)*',d['slug']): raise ValueError('Адрес: латинские строчные буквы, цифры и дефисы')
|
||||||
|
if not d['title'].strip(): raise ValueError('Введите название')
|
||||||
|
d.update(revision=expected_revision+1,updated=now())
|
||||||
|
with self.connect() as db:
|
||||||
|
db.execute('BEGIN IMMEDIATE')
|
||||||
|
old=db.execute('SELECT slug FROM datasets WHERE id=?',(d['id'],)).fetchone()
|
||||||
|
if not old: raise NotFound('Набор не найден')
|
||||||
|
if old[0]!=d['slug'] and db.execute('SELECT 1 FROM publications WHERE slug=?',(old[0],)).fetchone():
|
||||||
|
raise ValueError('После публикации адрес изменить нельзя')
|
||||||
|
try:
|
||||||
|
cur=db.execute('UPDATE datasets SET slug=?,revision=?,body=? WHERE id=? AND revision=?',(d['slug'],d['revision'],encode(d),d['id'],expected_revision))
|
||||||
|
except sqlite3.IntegrityError as e: raise ValueError('Этот адрес уже занят') from e
|
||||||
|
if cur.rowcount!=1: raise Conflict('Набор изменён в другой вкладке. Откройте его заново.')
|
||||||
|
return d
|
||||||
|
|
||||||
|
def replace_source(self,ident,revision,source,filename,records):
|
||||||
|
d=self.get(ident)
|
||||||
|
if d['revision']!=revision: raise Conflict('Набор изменён в другой вкладке')
|
||||||
|
name=uuid.uuid4().hex
|
||||||
|
path=self.root/'sources'/name
|
||||||
|
path.write_bytes(source)
|
||||||
|
d.update(source=name,filename=Path(filename).name,records=records,views={})
|
||||||
|
try: return self.save(d,revision)
|
||||||
|
except Exception: path.unlink(missing_ok=True); raise
|
||||||
|
|
||||||
|
def publish(self,ident,revision):
|
||||||
|
with self.connect() as db:
|
||||||
|
db.execute('BEGIN IMMEDIATE')
|
||||||
|
row=db.execute('SELECT body FROM datasets WHERE id=?',(ident,)).fetchone()
|
||||||
|
if not row: raise NotFound('Набор не найден')
|
||||||
|
d=json.loads(row[0])
|
||||||
|
if d['revision']!=revision: raise Conflict('Черновик изменился. Обновите страницу.')
|
||||||
|
if not d['records'] or not d['views']: raise ValueError('Нужны записи и хотя бы одно представление')
|
||||||
|
errors=[e for v in d['views'].values() for e in validate_view(v,d['records'])]
|
||||||
|
if errors: raise ValueError('; '.join(errors))
|
||||||
|
version=db.execute('SELECT COALESCE(MAX(version),0)+1 FROM publications WHERE slug=?',(d['slug'],)).fetchone()[0]
|
||||||
|
p={k:d[k] for k in ('slug','title','description','records','views')}
|
||||||
|
p.update(version=version,published=now())
|
||||||
|
db.execute('INSERT INTO publications VALUES (?,?,1,?)',(d['slug'],version,encode(p)))
|
||||||
|
return p
|
||||||
|
|
||||||
|
def publication(self,slug,version):
|
||||||
|
with self.connect() as db: row=db.execute('SELECT body FROM publications WHERE slug=? AND version=? AND active=1',(slug,version)).fetchone()
|
||||||
|
if not row: raise NotFound('Публикация недоступна')
|
||||||
|
return json.loads(row[0])
|
||||||
|
|
||||||
|
def versions(self,slug):
|
||||||
|
with self.connect() as db: rows=db.execute('SELECT version,active FROM publications WHERE slug=? ORDER BY version DESC',(slug,)).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def unpublish(self,slug,version):
|
||||||
|
with self.connect() as db: db.execute('UPDATE publications SET active=0 WHERE slug=? AND version=?',(slug,version))
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import json
|
||||||
|
from .paths import resolve_pointer
|
||||||
|
|
||||||
|
KINDS = {'table':'Таблица', 'cards':'Карточки', 'messages':'Сообщения', 'questionnaire':'Анкеты'}
|
||||||
|
|
||||||
|
def default_view(kind, paths):
|
||||||
|
return {'kind':kind, 'columns':[{'pointer':p,'label':p[1:].replace('~1','/').replace('~0','~')} for p in paths],
|
||||||
|
'page_size':25, 'search_pointer':'', 'category_pointer':'', 'questionnaire_mode':'columns',
|
||||||
|
'questions_pointer':'', 'question_pointer':'/question','answer_pointer':'/answer',
|
||||||
|
'title_pointer':paths[0] if paths else '', 'text_pointer':paths[0] if paths else '', 'author_pointer':'','date_pointer':''}
|
||||||
|
|
||||||
|
def validate_view(view, records):
|
||||||
|
errors=[]
|
||||||
|
if view.get('kind') not in KINDS: errors.append('Неизвестное представление')
|
||||||
|
if view.get('page_size') not in (10,25,50,100): errors.append('Размер страницы: 10, 25, 50 или 100')
|
||||||
|
pointers=[c['pointer'] for c in view.get('columns',[])]
|
||||||
|
if not pointers: errors.append('Выберите хотя бы одно поле')
|
||||||
|
roles=['title_pointer'] if view.get('kind')=='cards' else ['text_pointer'] if view.get('kind')=='messages' else []
|
||||||
|
if view.get('kind')=='questionnaire' and view.get('questionnaire_mode')=='pairs':
|
||||||
|
roles.append('questions_pointer')
|
||||||
|
arrays=[resolve_pointer(r,view.get('questions_pointer',''))[1] for r in records]
|
||||||
|
if not any(isinstance(a,list) for a in arrays): errors.append('Путь ответов должен указывать на массив')
|
||||||
|
for a in arrays:
|
||||||
|
if a is not None and (not isinstance(a,list) or any(not isinstance(x,dict) for x in a)):
|
||||||
|
errors.append('Ответы должны быть массивом объектов'); break
|
||||||
|
question_values=[resolve_pointer(x,view.get('question_pointer',''))[1] for a in arrays if isinstance(a,list) for x in a if isinstance(x,dict)]
|
||||||
|
if any(isinstance(value,(list,dict)) for value in question_values):
|
||||||
|
errors.append('Текст вопроса должен быть отдельным значением, а не массивом или объектом. Проверьте уровень записей при импорте: каждый участник должен быть отдельной записью; затем выберите его массив ответов и поле текста вопроса.')
|
||||||
|
for role in ('question_pointer','answer_pointer'):
|
||||||
|
if not view.get(role) or not any(resolve_pointer(x,view[role])[0] for a in arrays if isinstance(a,list) for x in a):
|
||||||
|
errors.append('Выберите поле текста вопроса внутри массива ответов' if role=='question_pointer' else 'Выберите поле значения ответа внутри массива ответов')
|
||||||
|
for role in roles:
|
||||||
|
if not view.get(role): errors.append(f'Назначьте поле {role}')
|
||||||
|
pointers += [view[k] for k in ('title_pointer','text_pointer','author_pointer','date_pointer','search_pointer','category_pointer') if view.get(k)]
|
||||||
|
for p in set(pointers):
|
||||||
|
if not any(resolve_pointer(r,p)[0] for r in records): errors.append(f'Поле отсутствует во всех записях: {p}')
|
||||||
|
for role in ('search_pointer','category_pointer'):
|
||||||
|
p=view.get(role)
|
||||||
|
if p:
|
||||||
|
for r in records:
|
||||||
|
found,value=resolve_pointer(r,p)
|
||||||
|
if found and value is not None and (not isinstance(value,str) if role=='search_pointer' else isinstance(value,(dict,list))):
|
||||||
|
errors.append('Поиск требует текстовое поле; категория — скалярное'); break
|
||||||
|
return errors
|
||||||
|
|
||||||
|
def display_field(record, pointer, label):
|
||||||
|
found,value=resolve_pointer(record,pointer)
|
||||||
|
state='missing' if not found else 'null' if value is None else 'value'
|
||||||
|
text='Нет значения' if not found else value if isinstance(value,str) else json.dumps(value,ensure_ascii=False)
|
||||||
|
value_type='missing' if not found else 'null' if value is None else 'boolean' if isinstance(value,bool) else 'number' if isinstance(value,(int,float)) else 'string' if isinstance(value,str) else 'array' if isinstance(value,list) else 'object'
|
||||||
|
return {'pointer':pointer,'label':label,'state':state,'type':value_type,'text':text}
|
||||||
|
|
||||||
|
def project_record(record, view, record_id):
|
||||||
|
fields=[display_field(record,c['pointer'],c['label']) for c in view['columns']]
|
||||||
|
if view['kind']=='questionnaire' and view.get('questionnaire_mode')=='pairs':
|
||||||
|
_,answers=resolve_pointer(record,view['questions_pointer'])
|
||||||
|
fields=[display_field(a,view['answer_pointer'],display_field(a,view['question_pointer'],'')['text']) for a in (answers or []) if isinstance(a,dict)] if isinstance(answers,list) else []
|
||||||
|
roles={}
|
||||||
|
for role in ('title','text','author','date'):
|
||||||
|
if view.get(role+'_pointer'):
|
||||||
|
roles[role]=display_field(record,view[role+'_pointer'],role)
|
||||||
|
# Only render roles consumed by this representation; never transmit unused role values.
|
||||||
|
allowed={'table':(), 'cards':('title',), 'messages':('text','author','date'), 'questionnaire':('title',)}[view['kind']]
|
||||||
|
role_fields={k:v for k,v in roles.items() if k in allowed}
|
||||||
|
return {'id':str(record_id),'fields':fields,'roles':{k:v['text'] for k,v in role_fields.items()},'role_fields':role_fields}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from jinja2 import Environment,FileSystemLoader,select_autoescape
|
||||||
|
from .query import select_records, categories
|
||||||
|
from .views import project_record, KINDS
|
||||||
|
from .repository import NotFound
|
||||||
|
|
||||||
|
ROOT=Path(__file__).parent/'web'
|
||||||
|
templates=Environment(loader=FileSystemLoader(ROOT/'templates'),autoescape=select_autoescape())
|
||||||
|
|
||||||
|
def render(name,**context):
|
||||||
|
return templates.get_template(name).render(**context)
|
||||||
|
|
||||||
|
def records_fragment(items,kind,base='',detail=False):
|
||||||
|
return render('records.html',items=items,kind=kind,base=base,detail=detail)
|
||||||
|
|
||||||
|
def public_page(publication,kind,base,page=1,search='',category=None,record_id=None,preview=False,live=False):
|
||||||
|
view=publication['views'].get(kind)
|
||||||
|
if view is None: raise NotFound('Представление не найдено')
|
||||||
|
if record_id:
|
||||||
|
if not record_id.isdigit() or not 1<=int(record_id)<=len(publication['records']): raise NotFound('Запись не найдена')
|
||||||
|
result={'items':[project_record(publication['records'][int(record_id)-1],view,record_id)],'total':1,'page':1,'has_next':False}
|
||||||
|
else: result=select_records(publication['records'],view,page,search,category)
|
||||||
|
def url(n):
|
||||||
|
params={'page':n}
|
||||||
|
if search: params['q']=search
|
||||||
|
if category is not None: params['category']=category
|
||||||
|
return base+'?'+urlencode(params)
|
||||||
|
return render('public.html',publication=publication,kind=kind,kind_label=KINDS[kind],view=view,result=result,
|
||||||
|
fragment=records_fragment(result['items'],kind,base,bool(record_id)),preview=preview,base=base,detail=bool(record_id),
|
||||||
|
previous=url(page-1) if page>1 else '',next=url(page+1) if result['has_next'] else '',search=search,
|
||||||
|
category=category,categories=categories(publication['records'],view),live=live)
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,107 @@
|
|||||||
|
(() => {
|
||||||
|
const $ = selector => document.querySelector(selector);
|
||||||
|
const csrf = $('meta[name=csrf-token]').content;
|
||||||
|
const status = $('#status');
|
||||||
|
const show = (text, error=false) => { status.textContent=text;status.hidden=!text;status.className=error?'error':'notice saved'; };
|
||||||
|
async function api(url, method='GET', body) {
|
||||||
|
const options={method,headers:{'X-CSRF-Token':csrf}};
|
||||||
|
if(body instanceof FormData) options.body=body;
|
||||||
|
else if(body!==undefined){options.body=JSON.stringify(body);options.headers['Content-Type']='application/json';}
|
||||||
|
const response=await fetch(url,options), data=await response.json();
|
||||||
|
if(!response.ok)throw Error(data.error||'Не удалось выполнить запрос');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
function node(tag,text,attrs={}) {const e=document.createElement(tag);if(text!==null)e.textContent=text;Object.assign(e,attrs);return e;}
|
||||||
|
let dirty=false;
|
||||||
|
window.addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue='';}});
|
||||||
|
document.addEventListener('click',e=>{const link=e.target.closest('a[href]');if(dirty&&link&&!link.target){if(!confirm('Изменения не сохранены. Покинуть этот экран?'))e.preventDefault();else dirty=false;}});
|
||||||
|
document.querySelector('form[action="/logout"]')?.addEventListener('submit',()=>{dirty=false;});
|
||||||
|
const importForm=$('#import-form');
|
||||||
|
if(importForm) {
|
||||||
|
const save=$('#import-save'),inspect=$('#inspect'),result=$('#import-result');
|
||||||
|
let generation=0,needsRootChoice=false;
|
||||||
|
importForm.addEventListener('change',e=>{
|
||||||
|
if(e.target.name==='root_confirmed'){save.disabled=needsRootChoice&&!e.target.checked;return;}
|
||||||
|
dirty=true;generation++;save.disabled=true;result.replaceChildren();needsRootChoice=false;$('#root-choice').hidden=true;importForm.elements.root_confirmed.checked=false;
|
||||||
|
if(e.target.name==='file') {const file=e.target.files[0];if(file)importForm.elements.title.value=file.name.replace(/\.[^.]+$/,'');importForm.elements.pointer.replaceChildren(new Option('Весь документ / корневой массив',''));}
|
||||||
|
});
|
||||||
|
inspect.onclick=async()=>{
|
||||||
|
const current=generation;save.disabled=true;result.replaceChildren();inspect.disabled=true;
|
||||||
|
try {
|
||||||
|
const data=await api('/api/admin/inspect','POST',new FormData(importForm));
|
||||||
|
if(current!==generation)return;
|
||||||
|
const selected=data.record_pointer??importForm.elements.pointer.value;
|
||||||
|
importForm.elements.pointer.replaceChildren(new Option('Весь документ / корневой массив',''),...data.arrays.filter(Boolean).map(p=>new Option(p,p)));
|
||||||
|
importForm.elements.pointer.value=selected;
|
||||||
|
result.append(node('h2',`${data.count} записей · ${data.fields.length} полей`));
|
||||||
|
for(const warning of data.warnings)result.append(node('p',warning));
|
||||||
|
const preview=node('div',null);preview.innerHTML=data.html;result.append(preview);needsRootChoice=data.choose_record_level;$('#root-choice').hidden=!needsRootChoice;save.disabled=needsRootChoice&&!importForm.elements.root_confirmed.checked;show(needsRootChoice?'В JSON найден вложенный список записей. Выберите его в поле «Записи внутри JSON» и нажмите «Проверить файл» ещё раз. Сейчас весь документ считается одной записью.':'Файл проверен');
|
||||||
|
}catch(error){show(error.message,true);}finally{inspect.disabled=false;}
|
||||||
|
};
|
||||||
|
importForm.onsubmit=async e=>{e.preventDefault();if(save.disabled)return;save.disabled=true;
|
||||||
|
try{const data=await api('/api/admin/import','POST',new FormData(importForm));dirty=false;location.href='/admin/datasets/'+data.id;}
|
||||||
|
catch(error){show(error.message,true);save.disabled=false;}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const source=$('#editor-data');
|
||||||
|
if(source) {
|
||||||
|
const data=JSON.parse(source.textContent),kind=$('#kind'),enabled=$('#enabled'),controls=$('#view-controls');
|
||||||
|
kind.value=Array.from(kind.options).find(option=>data.views[option.value])?.value||'table';
|
||||||
|
let previewTimer,previewSequence=0,editSequence=0;
|
||||||
|
const payload=()=>({revision:data.revision,title:$('#title').value,description:$('#description').value,slug:$('#slug').value,views:data.views});
|
||||||
|
function changed(){editSequence++;previewSequence++;dirty=true;$('#dirty').hidden=false;$('#publish').disabled=true;clearTimeout(previewTimer);previewTimer=setTimeout(preview,200);}
|
||||||
|
async function preview(){const seq=++previewSequence;$('#preview-kind').textContent=kind.selectedOptions[0].text;$('#full-preview').href=`/preview/${data.id}/${kind.value}`;
|
||||||
|
try{const result=await api(`/api/admin/datasets/${data.id}/preview?kind=${kind.value}`,'POST',payload());if(seq!==previewSequence)return;
|
||||||
|
const target=$('#live-preview');target.replaceChildren();
|
||||||
|
if(result.errors.length)target.append(node('p',result.errors.join('\n'),{className:'muted'}));else target.innerHTML=result.html;
|
||||||
|
}catch(error){if(seq===previewSequence)show(error.message,true);}
|
||||||
|
}
|
||||||
|
function select(label,key,options){const wrapper=node('label',label),input=node('select',null);input.setAttribute('aria-label',label);for(const [text,value] of options)input.add(new Option(text,value));input.value=data.views[kind.value][key]??'';
|
||||||
|
input.onchange=()=>{data.views[kind.value][key]=key==='page_size'?Number(input.value):input.value;changed();if(['questionnaire_mode','questions_pointer'].includes(key))renderControls();};wrapper.append(input);controls.append(wrapper);return input;}
|
||||||
|
const options=()=>[['Не выбрано',''],...data.fields.map(f=>[`${f.label} (${f.pointer})`,f.pointer])];
|
||||||
|
function renderControls(){controls.replaceChildren();const view=data.views[kind.value];enabled.checked=!!view;if(!view){preview();return;}
|
||||||
|
select('Записей на странице','page_size',[10,25,50,100].map(n=>[String(n),n]));
|
||||||
|
select('Текстовое поле для поиска','search_pointer',options());select('Поле категорий','category_pointer',options());
|
||||||
|
const roles={table:[],cards:[['Заголовок','title_pointer']],messages:[['Текст сообщения','text_pointer'],['Автор','author_pointer'],['Дата','date_pointer']],questionnaire:[['Заголовок участника','title_pointer']]};
|
||||||
|
for(const [label,key] of roles[kind.value])select(label,key,options());
|
||||||
|
if(kind.value==='questionnaire'){
|
||||||
|
select('Структура анкеты','questionnaire_mode',[['Колонки с ответами','columns'],['Массив вопросов и ответов','pairs']]);
|
||||||
|
if(view.questionnaire_mode==='pairs'){
|
||||||
|
select('Массив ответов','questions_pointer',options());
|
||||||
|
const q=select('Поле вопроса внутри ответа','question_pointer',[[view.question_pointer,view.question_pointer]]),a=select('Поле ответа','answer_pointer',[[view.answer_pointer,view.answer_pointer]]);
|
||||||
|
api(`/api/admin/datasets/${data.id}/fields?pointer=${encodeURIComponent(view.questions_pointer)}`).then(result=>{
|
||||||
|
if(!q.isConnected)return;
|
||||||
|
for(const [input,key] of [[q,'question_pointer'],[a,'answer_pointer']]){input.replaceChildren(new Option('Не выбрано',''),...(key==='question_pointer'?result.scalar_fields:result.fields).map(p=>new Option(p,p)));input.value=view[key];}
|
||||||
|
}).catch(error=>show(error.message,true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(kind.value==='questionnaire'&&view.questionnaire_mode==='pairs'){controls.append(node('p','Подписи вопросов и ответы берутся из выбранного массива.',{className:'muted'}));preview();return;}
|
||||||
|
controls.append(node('h2','Поля и подписи'));
|
||||||
|
for(const field of data.fields){const row=node('label',null,{className:'check-label'}),check=node('input',null,{type:'checkbox',checked:view.columns.some(c=>c.pointer===field.pointer)});
|
||||||
|
row.append(check,node('span',field.label+(field.missing?` · нет в ${field.missing} записях`:''),{title:field.pointer}));
|
||||||
|
check.onchange=()=>{if(check.checked)view.columns.push({pointer:field.pointer,label:field.label});else view.columns=view.columns.filter(c=>c.pointer!==field.pointer);changed();renderControls();};controls.append(row);}
|
||||||
|
controls.append(node('h3','Порядок и подписи'));
|
||||||
|
view.columns.forEach((column,i)=>{
|
||||||
|
const row=node('div',null,{className:'field-order'}),label=node('label','Подпись поля'),input=node('input',null,{value:column.label});
|
||||||
|
input.oninput=()=>{column.label=input.value;changed();};label.append(input);row.append(label);
|
||||||
|
for(const [text,delta] of [['↑',-1],['↓',1]]){const target=i+delta,b=node('button',text,{type:'button',className:'secondary',disabled:target<0||target>=view.columns.length});b.setAttribute('aria-label',delta<0?'Переместить поле выше':'Переместить поле ниже');b.onclick=()=>{[view.columns[i],view.columns[target]]=[view.columns[target],view.columns[i]];changed();renderControls();};row.append(b);}controls.append(row);
|
||||||
|
});preview();
|
||||||
|
}
|
||||||
|
kind.onchange=renderControls;enabled.onchange=()=>{if(enabled.checked)data.views[kind.value]=structuredClone(data.defaults[kind.value]);else delete data.views[kind.value];changed();renderControls();};
|
||||||
|
for(const id of ['title','description','slug'])$('#'+id).oninput=changed;
|
||||||
|
$('#save-draft').onclick=async()=>{const button=$('#save-draft'),savedSequence=editSequence;button.disabled=true;
|
||||||
|
try{const result=await api(`/api/admin/datasets/${data.id}`,'PUT',payload());data.revision=result.revision;dirty=editSequence!==savedSequence;$('#dirty').hidden=!dirty;$('#publish').disabled=dirty;show(dirty?'Черновик сохранён. Более поздние изменения ещё не сохранены.':'Черновик сохранён');}
|
||||||
|
catch(error){show(error.message,true);}finally{button.disabled=false;}
|
||||||
|
};
|
||||||
|
$('#configure-questionnaire')?.addEventListener('click',async e=>{
|
||||||
|
if(dirty){show('Сначала сохраните изменения черновика',true);return;}
|
||||||
|
const button=e.currentTarget;button.disabled=true;
|
||||||
|
try{await api(`/api/admin/datasets/${data.id}/configure-questionnaire`,'POST',{revision:data.revision});location.reload();}
|
||||||
|
catch(error){show(error.message,true);button.disabled=false;}
|
||||||
|
});
|
||||||
|
$('#publish').onclick=async()=>{if(dirty){show('Сначала сохраните черновик',true);return;}try{await api(`/api/admin/datasets/${data.id}/publish`,'POST',{revision:data.revision});location.href=`/admin/datasets/${data.id}/versions`;}catch(error){show(error.message,true);}};
|
||||||
|
renderControls();
|
||||||
|
}
|
||||||
|
$('#publish-version')?.addEventListener('click',async e=>{const b=e.currentTarget;b.disabled=true;try{await api(`/api/admin/datasets/${b.dataset.id}/publish`,'POST',{revision:Number(b.dataset.revision)});location.reload();}catch(error){show(error.message,true);b.disabled=false;}});
|
||||||
|
document.querySelectorAll('[data-unpublish]').forEach(button=>button.onclick=async()=>{button.disabled=true;try{await api(`/api/admin/datasets/${button.dataset.id}/unpublish/${button.dataset.unpublish}`,'POST',{});location.reload();}catch(error){show(error.message,true);button.disabled=false;}});
|
||||||
|
})();
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="5" fill="#305bdd"/><path d="M12 8H8v16h4m8-16h4v16h-4" fill="none" stroke="white" stroke-width="2.5"/></svg>
|
||||||
|
After Width: | Height: | Size: 201 B |
@@ -0,0 +1,31 @@
|
|||||||
|
(() => {
|
||||||
|
const root=document.querySelector('[data-live-collection]');if(!root)return;
|
||||||
|
const form=root.querySelector('.filters'),pagination=root.querySelector('.live-pagination');if(!pagination)return;
|
||||||
|
let page=Number(root.dataset.page)||1,controller,timer,generation=0,busy=false;
|
||||||
|
const button=pagination.querySelector('[data-load-more]');
|
||||||
|
const error=document.createElement('div');error.className='error';error.hidden=true;pagination.before(error);
|
||||||
|
async function load(reset=false){
|
||||||
|
if(!reset&&busy)return;
|
||||||
|
if(reset)controller?.abort();
|
||||||
|
const current=++generation;controller=new AbortController();busy=true;if(button)button.disabled=true;
|
||||||
|
const url=new URL(location.href);url.searchParams.set('fragment','1');url.searchParams.set('page',reset?'1':String(page+1));
|
||||||
|
for(const key of ['q','category']){const value=form?.elements[key]?.value||'';if(value)url.searchParams.set(key,value);else url.searchParams.delete(key);}
|
||||||
|
try{
|
||||||
|
const response=await fetch(url,{signal:controller.signal}),data=await response.json();if(!response.ok)throw Error(data.error||'Не удалось загрузить записи');if(current!==generation)return;
|
||||||
|
const holder=document.createElement('div');holder.innerHTML=data.html;
|
||||||
|
const existing=root.querySelector('.lab-records'),incoming=holder.querySelector('.lab-records');
|
||||||
|
if(reset)existing.replaceWith(incoming);
|
||||||
|
else {const target=existing.querySelector('tbody')||existing.querySelector('.record-grid');incoming.querySelectorAll('.record').forEach(r=>target.append(r));}
|
||||||
|
page=data.page;root.querySelector('.collection-meta b').textContent=data.total;
|
||||||
|
pagination.querySelector('[data-loaded]').textContent=`Показано ${root.querySelectorAll('.record').length} из ${data.total}`;
|
||||||
|
if(button)button.hidden=!data.has_next;pagination.querySelector('[data-complete]').hidden=data.has_next;
|
||||||
|
error.hidden=true;
|
||||||
|
}catch(e){if(e.name!=='AbortError'){error.textContent=e.message;error.hidden=false;}}
|
||||||
|
finally{if(current===generation){busy=false;if(button)button.disabled=false;}}
|
||||||
|
}
|
||||||
|
form?.addEventListener('submit',e=>{e.preventDefault();clearTimeout(timer);load(true);});
|
||||||
|
form?.elements.q?.addEventListener('input',()=>{clearTimeout(timer);controller?.abort();generation++;busy=true;if(button)button.disabled=true;timer=setTimeout(()=>load(true),250);});
|
||||||
|
form?.elements.category?.addEventListener('change',()=>{clearTimeout(timer);load(true);});
|
||||||
|
form?.querySelector('[data-reset]')?.addEventListener('click',e=>{e.preventDefault();clearTimeout(timer);for(const key of ['q','category'])if(form.elements[key])form.elements[key].value='';load(true);});
|
||||||
|
button?.addEventListener('click',()=>load());
|
||||||
|
})();
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
@font-face{font-family:Lab;src:url('/static/Roboto-Regular.ttf');font-display:swap}
|
||||||
|
@font-face{font-family:Lab;src:url('/static/Roboto-Bold.ttf');font-weight:700;font-display:swap}
|
||||||
|
:root{--paper:#f6f7f9;--ink:#20242c;--muted:#626b79;--blue:#305bdd;--line:#dfe3e9;--white:#fff;--soft:#eef2ff}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.55 Lab,system-ui,sans-serif}
|
||||||
|
a{color:var(--blue);text-decoration:none}a:hover{text-decoration:underline}
|
||||||
|
button,a,input,select{outline-offset:4px}button,input,select{font:inherit}
|
||||||
|
button,.button{display:inline-flex;align-items:center;justify-content:center;gap:16px;background:var(--blue);color:#fff;border:1px solid var(--blue);padding:10px 17px;border-radius:5px;font-weight:700;cursor:pointer}
|
||||||
|
button:hover,.button:hover{background:#2349be;text-decoration:none}
|
||||||
|
input,select{width:100%;min-width:0;background:#fff;border:1px solid #bbc3d0;border-radius:5px;padding:10px 12px;color:var(--ink)}
|
||||||
|
input:focus,select:focus{outline:2px solid #aebdf7;border-color:var(--blue)}
|
||||||
|
label{display:flex;flex-direction:column;gap:7px;font-size:12px;font-weight:700}
|
||||||
|
h1,h2,p{margin-top:0}h1{font-size:32px;line-height:1.2;letter-spacing:-.8px;margin-bottom:12px}h2{font-size:20px;line-height:1.35;letter-spacing:-.3px}
|
||||||
|
.topbar{height:64px;padding:0 40px;display:flex;align-items:center;justify-content:space-between;gap:24px;border-bottom:1px solid var(--line);background:#fff}
|
||||||
|
.brand{display:flex;align-items:center;gap:10px;font-size:17px;font-weight:700;letter-spacing:-.4px;color:var(--ink)}
|
||||||
|
.brand:hover{text-decoration:none}.brand-mark{display:grid;place-items:center;width:28px;height:28px;background:var(--blue);color:#fff;border-radius:4px;font:700 16px monospace;letter-spacing:-3px;padding-right:3px}
|
||||||
|
.top-note{font-size:12px;color:var(--muted);margin-right:auto;border-left:1px solid var(--line);padding-left:24px}.quiet-link{font-size:12px;color:var(--muted)}
|
||||||
|
.site-footer{padding:20px 40px;display:flex;justify-content:space-between;color:var(--muted);font-size:11px;border-top:1px solid var(--line);background:#fff}
|
||||||
|
.eyebrow{font-size:11px;letter-spacing:.6px;font-weight:700;color:var(--muted);margin-bottom:10px}
|
||||||
|
.lead{font-size:15px;max-width:760px;color:var(--muted);line-height:1.7}
|
||||||
|
.muted{color:var(--muted);font-size:12px}.error{padding:12px;background:#fff3f3;border-left:3px solid #c84444;color:#922b2b;border-radius:3px}
|
||||||
|
.login-layout{min-height:calc(100vh - 125px);display:grid;place-items:center;padding:56px 24px}
|
||||||
|
.login-card{width:100%;max-width:420px;padding:34px;background:#fff;border:1px solid var(--line);border-radius:8px}
|
||||||
|
.login-card h1{font-size:26px;margin:18px 0 8px}.login-card form{display:flex;flex-direction:column;gap:20px;margin-top:28px}.login-card button{margin-top:4px}.login-foot{font-size:12px;color:var(--muted);margin:24px 0 0;padding-top:20px;border-top:1px solid var(--line)}
|
||||||
|
.home{max-width:1020px;padding:80px 40px 100px;margin:auto;min-height:calc(100vh - 125px)}
|
||||||
|
.home h1{font-size:44px;max-width:600px;margin-bottom:20px}.home-intro{padding-bottom:36px;border-bottom:1px solid var(--line)}
|
||||||
|
.home-guide{display:grid;grid-template-columns:1fr 1fr;gap:56px;margin-top:40px}.home-guide h2{font-size:16px}.home-guide p{color:var(--muted)}
|
||||||
|
.format-list{display:flex;gap:8px;flex-wrap:wrap;margin-top:24px}.format-list span{font:11px monospace;border:1px solid var(--line);padding:5px 9px;border-radius:3px;color:var(--muted);background:#fff}
|
||||||
|
.public-main{max-width:1360px;padding:32px 40px 64px;margin:auto;min-height:calc(100vh - 125px)}
|
||||||
|
.breadcrumb{font-size:12px;color:var(--muted)}.page-heading{display:flex;justify-content:space-between;gap:24px;margin-top:30px}.page-heading h1{font-size:32px}.page-heading .lead{margin-bottom:12px}
|
||||||
|
.version-badge{white-space:nowrap;border:1px solid var(--line);background:#fff;border-radius:4px;padding:5px 9px;font:11px/1.5 monospace;height:fit-content}
|
||||||
|
.collection-meta{display:flex;flex-wrap:wrap;gap:20px;padding:10px 0 24px;color:var(--muted);font-size:12px}.collection-meta b{color:var(--ink);font-weight:700}
|
||||||
|
.filters{display:flex;align-items:end;gap:12px;padding:16px 0;border-top:1px solid var(--line);margin-bottom:14px}.filters label{flex:1;max-width:360px}.filters a{font-size:12px;padding:10px}
|
||||||
|
.table-scroll{max-width:100%;overflow:auto;border:1px solid var(--line);border-radius:5px;background:#fff}
|
||||||
|
table{border-collapse:collapse;width:100%;text-align:left;font-size:13px}th{background:#f0f2f6;font-size:11px;color:#485365;font-weight:700;white-space:nowrap}
|
||||||
|
th,td{padding:11px 16px;border-bottom:1px solid #e8ebf0}td{white-space:pre-wrap;overflow-wrap:anywhere;min-width:100px;max-width:440px}tbody tr:last-child td{border-bottom:0}tbody tr:hover{background:#f7f9ff}
|
||||||
|
.row-index{font:11px monospace;color:#737e8d;width:48px;min-width:48px;border-right:1px solid #e8ebf0}.detail-link{font-size:12px;white-space:nowrap}
|
||||||
|
.record-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.record-grid.single{grid-template-columns:1fr}
|
||||||
|
.record-card{background:#fff;border:1px solid var(--line);border-radius:6px;padding:24px;min-width:0}.record-card h2{overflow-wrap:anywhere;margin:12px 0 20px}.record-top{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:18px}.record-top .eyebrow{margin:0;font:10px monospace;letter-spacing:.4px}
|
||||||
|
.record-card dl{margin:0}.field-pair{padding:12px 0;border-top:1px solid #edf0f4}.field-pair dt{color:var(--muted);font-size:11px;margin-bottom:4px}.field-pair dd{margin:0;white-space:pre-wrap;overflow-wrap:anywhere}
|
||||||
|
.field[data-value-state=missing],.field[data-value-state=null]{color:#747d8a;font-style:italic}
|
||||||
|
.cards .record-card{border-top:3px solid #3d63d8}.cards .field-pair{display:grid;grid-template-columns:1fr 1.2fr;gap:10px}.cards .field-pair dt{margin:2px 0}
|
||||||
|
.messages .record-grid{gap:0;max-width:840px;margin:0 auto;border:1px solid var(--line);border-radius:6px;overflow:hidden}
|
||||||
|
.record-card.messages{border:0;border-bottom:1px solid var(--line);border-radius:0;padding:24px 30px}.record-card.messages:last-child{border-bottom:0}
|
||||||
|
.messages .record-top{margin-bottom:12px}.messages .record-top .eyebrow{font-size:10px}.message-author{display:flex;align-items:center;gap:12px;margin-bottom:16px}.avatar{width:36px;height:36px;border-radius:50%;background:#edf1fb;color:#3456ab;display:grid;place-items:center;font-size:13px;font-weight:700;flex-shrink:0}
|
||||||
|
.message-text{white-space:pre-wrap;overflow-wrap:anywhere;font-size:15px;line-height:1.75;margin:0 0 16px 48px}.messages dl{margin-left:48px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 20px}.messages .field-pair{padding:9px 0}.messages .field-pair dd{font-size:12px;color:#475265}
|
||||||
|
.questionnaire .record-grid{max-width:860px;margin:auto;gap:28px}.record-card.questionnaire{border-radius:2px;padding:30px 36px;border-top:3px solid #555d6c}.questionnaire .record-top{border-bottom:1px solid var(--line);padding-bottom:18px}.questionnaire .record-card h2{font-size:24px}.questionnaire dl{counter-reset:question}.questionnaire .field-pair{position:relative;padding:20px 0 20px 38px}.questionnaire .field-pair:before{counter-increment:question;content:counter(question,decimal-leading-zero);position:absolute;left:0;top:22px;color:#838b97;font:11px monospace}.questionnaire .field-pair dt{font-size:13px;color:#454d59;margin-bottom:10px}.questionnaire .field-pair dd{padding:11px 14px;background:#f5f6f8;border-left:2px solid #d2d7e0;font-size:14px}
|
||||||
|
.pagination{display:flex;justify-content:space-between;align-items:center;margin-top:24px;font-size:12px}.pagination a{border:1px solid var(--line);border-radius:4px;padding:8px 13px;background:#fff}.back-link{display:inline-block;margin-top:24px}.empty{padding:44px 24px;text-align:center;border:1px dashed #bdc6d3;border-radius:5px;color:var(--muted)}
|
||||||
|
.lab-records{font-family:Lab,system-ui,sans-serif;color:var(--ink);line-height:1.55;width:100%}
|
||||||
|
@media(max-width:1000px){.record-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.catalog-grid{grid-template-columns:minmax(100px,2fr) .7fr 1fr;gap:10px}.catalog-formats{display:none}}
|
||||||
|
@media(max-width:750px){.topbar{height:58px;padding:0 18px;gap:16px}.top-note{display:none}.quiet-link{font-size:11px}.brand{font-size:15px}.site-footer{padding:18px;font-size:10px}.login-layout{padding:30px 18px;min-height:calc(100vh - 115px)}.login-card{padding:26px 22px}.home{padding:44px 22px}.home h1{font-size:33px}.home-guide{grid-template-columns:1fr;gap:24px}.public-main{padding:24px 18px 40px}.page-heading h1{font-size:27px}.page-heading{flex-direction:column;gap:4px;margin-top:22px}.version-badge{align-self:flex-start}.collection-meta{padding-top:18px;gap:12px}.record-grid{grid-template-columns:1fr}.record-card{padding:20px}.record-card.messages{padding:20px}.record-card.questionnaire{padding:22px}.message-text,.messages dl{margin-left:0}.filters{flex-wrap:wrap}.filters label{flex-basis:100%;max-width:none}.filters button{flex:1}.catalog-grid{grid-template-columns:minmax(80px,1fr) auto;gap:8px;min-height:62px}.catalog-count{display:none}.catalog-head .catalog-count{display:none}.catalog-title{font-size:12px}.status{font-size:10px}.editor-preview{padding:12px}.editor-preview-head{flex-wrap:wrap}.admin-brand{padding-bottom:4px}.workspace-label{display:none}}
|
||||||
|
|
||||||
|
.live-public{width:100%;min-width:0;color:var(--ink);font:14px/1.55 Lab,system-ui,sans-serif}.live-pagination{gap:16px;flex-wrap:wrap}
|
||||||
|
|
||||||
|
html.static-site{scrollbar-gutter:stable}
|
||||||
|
|
||||||
|
[hidden]{display:none!important}
|
||||||
|
|
||||||
|
/* Server-rendered teacher workspace. */
|
||||||
|
.admin-body{display:flex;min-height:100vh}.admin-sidebar{width:240px;flex-shrink:0;background:#fff;border-right:1px solid var(--line);padding:30px 22px;position:sticky;top:0;height:100vh;display:flex;flex-direction:column}.admin-sidebar nav{display:grid;gap:8px;margin-top:30px}.admin-sidebar nav a{padding:10px 12px;color:var(--ink);border-radius:4px}.admin-sidebar nav a.active{background:var(--soft);color:var(--blue);font-weight:700}.sidebar-bottom{margin-top:auto}.sidebar-bottom form{margin:0}.text-button{background:none;border:none;color:var(--muted);padding:0;font-weight:400}.text-button:hover{background:none;color:var(--blue)}
|
||||||
|
.admin-main{padding:36px;flex:1;min-width:0}.admin-main>#status:not([hidden]){margin-bottom:20px}.admin-main h1{font-size:28px}.admin-heading{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:24px}.dataset-table td{padding:16px;min-width:60px}.dataset-table td:first-child{min-width:220px}.dataset-table td:last-child{min-width:80px}.dataset-table .catalog-title{font-size:14px}.toolbar{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin:24px 0}.toolbar a{font-size:13px}.toolbar button{font-size:13px}.secondary{background:#fff;color:var(--blue);border-color:var(--line)}.secondary:hover{background:var(--soft)}button:disabled{opacity:.5;cursor:default}.form-panel{background:#fff;border:1px solid var(--line);border-radius:6px;padding:22px;margin:20px 0;display:grid;gap:18px}.form-panel h2{font-size:18px;margin:0}.form-panel h3{font-size:13px;font-weight:400;color:var(--muted);margin-bottom:0}.form-panel label{font-size:12px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.file-drop{border:1px dashed #aebbd0;padding:26px;background:#f9faff}.file-drop input{border:0;background:none;padding:10px 0 0}.check-label{display:flex;flex-direction:row;align-items:center;gap:10px;font-weight:400}.check-label input{width:17px;height:17px;flex-shrink:0;accent-color:var(--blue)}.check-label span{overflow-wrap:anywhere}.form-panel textarea{font:inherit;width:100%;resize:vertical;padding:10px;border:1px solid #bbc3d0;border-radius:5px}.editor-layout{display:grid;grid-template-columns:360px minmax(0,1fr);gap:28px;align-items:start}.editor-settings{max-height:calc(100vh - 220px);overflow:auto;padding-right:10px}.editor-settings .form-panel:first-child{margin-top:0}.editor-preview-panel{position:sticky;top:24px;min-width:0}.editor-preview-head{margin-bottom:16px}#view-controls{display:grid;gap:16px}.field-order{display:flex;align-items:end;gap:6px}.field-order label{flex:1;min-width:0}.field-order button{padding:10px;min-width:32px}.editor-preview{min-height:350px}.version-links{display:flex;gap:24px;flex-wrap:wrap;font-size:13px}.version-links strong{min-width:100px}#import-form{max-width:780px}#import-result{margin-top:28px}.form-panel .toolbar{margin:0}
|
||||||
|
@media(max-width:1100px){.admin-sidebar{width:200px}.admin-main{padding:26px}.editor-layout{grid-template-columns:320px minmax(0,1fr);gap:18px}}
|
||||||
|
@media(max-width:850px){.admin-body{display:block}.admin-sidebar{position:static;width:auto;height:auto;padding:18px;border-right:0;border-bottom:1px solid var(--line)}.admin-sidebar nav{display:flex;flex-wrap:wrap;margin-top:10px}.admin-sidebar>.muted{display:none}.sidebar-bottom{display:flex;gap:18px;align-items:center;margin-top:14px}.sidebar-bottom p{margin:0}.admin-main{padding:22px 18px}.admin-heading{align-items:start;flex-direction:column}.editor-layout{display:flex;flex-direction:column}.editor-preview-panel{position:static;order:-1;width:100%}.editor-settings{max-height:none;overflow:visible;padding:0;width:100%}.form-grid{grid-template-columns:1fr}.admin-main h1{font-size:24px}.form-panel{padding:18px}.toolbar{gap:12px}.version-links{flex-direction:column;gap:8px}}
|
||||||
|
|
||||||
|
.catalog-file{font-size:11px;color:var(--muted);margin-top:4px}.status{font-size:11px;border-radius:3px;padding:4px 8px;background:#eef0f4;color:#657083}.status.published{background:#edf5ef;color:#36744c}.notice{padding:12px 16px;background:#edf3ff;border-left:3px solid var(--blue);font-size:13px}.editor-preview-head{display:flex;justify-content:space-between;align-items:center;gap:12px}.editor-preview-head h2{font-size:16px;margin:0}.editor-preview-head span,.preview-foot{font-size:11px;color:var(--muted)}.editor-preview{border:1px solid var(--line);border-radius:6px;background:#f6f7fa;padding:18px;overflow:hidden}.editor-preview .record-grid{grid-template-columns:1fr}.preview-foot{margin-top:12px;line-height:1.6}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<!doctype html><html lang="ru" class="static-site"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Кабинет · Parser Lab</title><link rel="icon" type="image/png" href="/static/favicon.png"><link rel="stylesheet" href="/static/site.css"><meta name="csrf-token" content="{{ csrf }}"><script defer src="/static/admin.js?v=questionnaire-autodetect-1"></script></head>
|
||||||
|
<body class="admin-body"><aside class="admin-sidebar"><a class="brand" href="/admin"><span class="brand-mark">[ ]</span>Parser Lab</a><p class="muted">Кабинет преподавателя</p><nav><a class="{{ 'active' if screen!='import' }}" href="/admin">Мои коллекции</a><a class="{{ 'active' if screen=='import' }}" href="/admin/import">Импорт файла</a></nav><div class="sidebar-bottom"><a href="/">Учебный сайт ↗</a><p class="muted">{{ username }}</p><form method="post" action="/logout"><input type="hidden" name="csrf" value="{{ csrf }}"><button class="text-button" type="submit">Выйти</button></form></div></aside>
|
||||||
|
<main class="admin-main" data-screen="{{ screen }}"><div id="status" role="status" hidden></div>
|
||||||
|
{% if screen=='catalog' %}
|
||||||
|
<div class="admin-heading"><div><div class="eyebrow">КАБИНЕТ / КОЛЛЕКЦИИ</div><h1>Коллекции данных</h1><p class="muted">Всего коллекций: {{ datasets|length }}</p></div><a class="button" href="/admin/import">Загрузить данные</a></div>
|
||||||
|
<div class="table-scroll"><table class="dataset-table"><thead><tr><th>Название</th><th>Записи</th><th>Представления</th><th>Статус</th><th></th></tr></thead><tbody>{% for d in datasets %}<tr><td><div class="catalog-title">{{ d.title }}</div><div class="catalog-file">{{ d.filename }}</div></td><td>{{ d.records|length }}</td><td>{{ d.views|length }}</td><td><span class="status {{ 'published' if active[d.id] }}">{{ 'Опубликовано' if active[d.id] else 'Черновик' }}</span></td><td><a href="/admin/datasets/{{ d.id }}" aria-label="Открыть {{ d.title }}">Открыть</a></td></tr>{% else %}<tr><td colspan="5">Коллекций пока нет. Загрузите первый набор данных.</td></tr>{% endfor %}</tbody></table></div>
|
||||||
|
{% elif screen=='import' %}
|
||||||
|
<div class="eyebrow">КОЛЛЕКЦИИ / ИМПОРТ</div><h1>Импорт данных</h1><p class="muted">Выберите файл, проверьте структуру и сохраните коллекцию.</p>
|
||||||
|
<form id="import-form" class="form-panel"><label class="file-drop">CSV, JSON или JSONL · до {{ (max_bytes/1024/1024)|int }} МиБ<input name="file" type="file" accept=".csv,.json,.jsonl" required></label><label>Название коллекции<input name="title" value="{{ replace.title if replace else '' }}" required></label><div class="form-grid"><label>Кодировка CSV<select name="encoding" aria-label="Кодировка CSV"><option value="utf-8-sig">UTF-8</option><option value="cp1251">Windows-1251</option></select></label><label>Разделитель CSV<select name="delimiter" aria-label="Разделитель CSV"><option value="">Автоматически</option><option value=",">Запятая</option><option value=";">Точка с запятой</option><option value="	">Табуляция</option></select></label></div><label>Записи внутри JSON<select name="pointer" aria-label="Записи внутри JSON"><option value="">Весь документ / корневой массив</option></select></label>
|
||||||
|
{% if replace %}<input type="hidden" name="replace_id" value="{{ replace.id }}"><input type="hidden" name="revision" value="{{ replace.revision }}"><p class="notice">Замена файла сбросит настройки представлений. Опубликованные версии сохранятся.</p>{% endif %}
|
||||||
|
<label id="root-choice" class="check-label notice" hidden><input type="checkbox" name="root_confirmed" value="1">Использовать весь документ как одну запись</label><div class="toolbar"><button id="inspect" type="button">Проверить файл</button><button id="import-save" type="submit" disabled>Сохранить и настроить →</button><a href="/admin">К коллекциям</a></div></form><div id="import-result"></div>
|
||||||
|
{% elif screen=='edit' %}
|
||||||
|
<div class="eyebrow">КОЛЛЕКЦИИ / РЕДАКТОР</div><h1>{{ draft.title }}</h1><p class="muted">{{ draft.records|length }} записей · {{ draft.filename }}</p>
|
||||||
|
{% if questionnaire_repair %}<section class="notice" id="questionnaire-repair"><strong>Найдены анкеты: {{ questionnaire_repair.records|length }} участников.</strong><p>{% if questionnaire_repair.pointer %}Участники находятся внутри {{ questionnaire_repair.pointer }}. Можно разделить их на отдельные записи и автоматически назначить вопросы и ответы. Настройки представлений черновика будут пересозданы; опубликованные версии сохранятся.{% else %}Можно автоматически назначить заголовки участников, вопросы и ответы.{% endif %}</p><button type="button" id="configure-questionnaire">Настроить анкеты автоматически</button></section>{% endif %}<div class="toolbar editor-toolbar"><button id="save-draft">Сохранить черновик</button><button id="publish">Опубликовать новую версию</button><a id="full-preview" target="_blank" rel="noopener">Предпросмотр ↗</a><a href="/admin/datasets/{{ draft.id }}/versions">Публикации</a><span id="dirty" class="muted" hidden>Есть несохранённые изменения</span></div>
|
||||||
|
<div class="editor-layout"><div class="editor-settings"><section class="form-panel"><label>Представление<select id="kind" aria-label="Представление">{% for k,label in kinds.items() %}<option value="{{ k }}">{{ label }}</option>{% endfor %}</select></label><label class="check-label"><input id="enabled" type="checkbox">Включить это представление</label><div id="view-controls"></div></section>
|
||||||
|
<section class="form-panel"><h2>О коллекции</h2><label>Название<input id="title" value="{{ draft.title }}"></label><label>Описание коллекции<textarea id="description" rows="3">{{ draft.description }}</textarea></label><label>Адрес коллекции<input id="slug" value="{{ draft.slug }}"></label><a href="/admin/import?replace={{ draft.id }}">Заменить исходный файл</a></section></div>
|
||||||
|
<section class="editor-preview-panel"><div class="editor-preview-head"><h2>Предпросмотр изменений</h2><span id="preview-kind"></span></div><div class="editor-preview" id="live-preview"></div><p class="preview-foot">Первые 5 записей · Опубликованные данные изменятся только после создания новой версии.</p></section></div><script id="editor-data" type="application/json">{{ editor_data|tojson }}</script>
|
||||||
|
{% elif screen=='versions' %}
|
||||||
|
<div class="eyebrow">КОЛЛЕКЦИИ / ПУБЛИКАЦИИ</div><h1>{{ draft.title }}</h1><div class="toolbar"><a href="/admin/datasets/{{ draft.id }}">← Настройка</a><button id="publish-version" data-id="{{ draft.id }}" data-revision="{{ draft.revision }}">Опубликовать новую версию</button></div>
|
||||||
|
{% for v in versions %}<section class="form-panel"><h2>Версия {{ v.version }} · {{ 'Доступна' if v.active else 'Снята с публикации' }}</h2>{% for k in v.kinds %}<div class="version-links"><strong>{{ kinds[k] }}</strong><a href="/html/{{ draft.slug }}/v/{{ v.version }}/{{ k }}" target="_blank">HTML · BeautifulSoup ↗</a><a href="/interactive/{{ draft.slug }}/v/{{ v.version }}/{{ k }}" target="_blank">Дозагрузка · Playwright ↗</a></div>{% endfor %}{% if v.active %}<button class="secondary" data-unpublish="{{ v.version }}" data-id="{{ draft.id }}">Снять эту версию с публикации</button>{% endif %}</section>{% else %}<p class="empty">Опубликованных версий пока нет.</p>{% endfor %}
|
||||||
|
{% endif %}</main></body></html>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru" class="static-site"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ title|default('Parser Lab') }}</title><link rel="stylesheet" href="/static/site.css"><link rel="icon" type="image/png" href="/static/favicon.png"></head>
|
||||||
|
<body>{% include 'header.html' %}{% block content %}{% endblock %}{% include 'footer.html' %}</body></html>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<main class="public-main" {% if live %}data-live-collection data-page="{{ result.page }}"{% endif %}><a class="breadcrumb" href="/">Parser Lab / Коллекции</a><section class="page-heading"><div><div class="eyebrow">{{ 'ПРЕДПРОСМОТР · ЧЕРНОВИК' if preview else 'УЧЕБНАЯ КОЛЛЕКЦИЯ' }}</div><h1>{{ publication.title }}</h1><p class="lead">{{ publication.description }}</p></div><div class="version-badge">{{ 'Черновик' if preview else 'Версия ' ~ publication.version }}</div></section>
|
||||||
|
<section class="collection-meta"><span><b>{{ result.total }}</b> {{ 'запись' if detail else 'записей' }}</span><span>{{ kind_label }}</span></section>
|
||||||
|
{% if not detail and (view.search_pointer or view.category_pointer) %}<form class="filters" method="get" action="{{ base }}">{% if view.search_pointer %}<label>Поиск<input aria-label="Поиск в коллекции" name="q" value="{{ search }}" placeholder="Найти в коллекции"></label>{% endif %}{% if view.category_pointer %}<label>Категория<select name="category" aria-label="Категория"><option value="">Все значения</option>{% for value,label in categories %}<option value="{{ value }}" {{ 'selected' if category==value else '' }}>{{ label }}</option>{% endfor %}</select></label>{% endif %}<button type="submit">Применить</button><a data-reset href="{{ base }}">Сбросить</a></form>{% endif %}
|
||||||
|
{{ fragment|safe }}
|
||||||
|
{% if message %}<div class="error" role="alert">{{ message }}</div>{% endif %}
|
||||||
|
{% if live and not detail %}<nav class="pagination live-pagination" aria-label="Дозагрузка"><span data-loaded>Показано {{ result['items']|length }} из {{ result.total }}</span><button type="button" data-load-more {{ 'hidden' if not result.has_next }}>Загрузить ещё</button><span class="muted" data-complete {{ 'hidden' if result.has_next }}>Все записи загружены</span></nav>
|
||||||
|
{% elif not detail %}<nav class="pagination" aria-label="Страницы">{% if previous %}<a href="{{ previous }}" rel="prev">← Назад</a>{% else %}<span></span>{% endif %}<span>Страница {{ result.page }}</span>{% if next %}<a href="{{ next }}" rel="next">Следующая →</a>{% else %}<span></span>{% endif %}</nav>{% else %}<a class="back-link" href="{{ base }}">← Все записи</a>{% endif %}</main>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{% extends 'base.html' %}{% block content %}<main class="home"><h1>Страница недоступна</h1><p>{{ message }}</p><a href="/">На главную</a></main>{% endblock %}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<footer class="site-footer"><span>Parser Lab · Учебный практикум</span><span>Таблицы / Сообщения / Анкеты</span></footer>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<header class="topbar"><a class="brand" href="/"><span class="brand-mark">[ ]</span>Parser Lab</a><span class="top-note">Практикум по работе с данными</span><a class="quiet-link" href="/admin">Кабинет преподавателя ↗</a></header>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{% extends 'base.html' %}{% block content %}
|
||||||
|
<main class="home"><section class="home-intro"><div class="eyebrow">PARSER LAB / ПРАКТИКУМ</div><h1>Учебные коллекции данных</h1><p class="lead">Материалы для занятий по веб-парсингу и подготовке данных к машинному обучению.</p><div class="format-list"><span>Таблицы</span><span>Карточки</span><span>Сообщения</span><span>Анкеты</span></div></section><div class="home-guide"><section><h2>Студентам</h2><p>Откройте коллекцию по ссылке из задания. Изучите страницы, соберите записи и сохраните результат для анализа. Регистрация не нужна.</p></section><section><h2>Преподавателю</h2><p>Загрузите CSV, JSON или JSONL, настройте отображение и опубликуйте коллекцию. Ссылки на готовые страницы находятся в кабинете.</p><a class="button" href="/admin">Открыть кабинет →</a></section></div></main>{% endblock %}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{% extends 'base.html' %}{% block content %}
|
||||||
|
<main class="login-layout"><section class="login-card"><span class="brand-mark">[ ]</span><h1>Кабинет преподавателя</h1><p class="muted">Загрузка данных и управление публикациями.</p>
|
||||||
|
{% if error %}<div class="error" role="alert">{{ error }}</div>{% endif %}<form method="post" action="/login"><input type="hidden" name="csrf" value="{{ csrf }}"><label>Логин<input name="username" autocomplete="username" required autofocus></label><label>Пароль<input name="password" type="password" autocomplete="current-password" required></label><button type="submit">Войти в кабинет <span>→</span></button></form><p class="login-foot">Для выполнения задания вход не требуется. Откройте ссылку на коллекцию, которую выдал преподаватель.</p></section></main>{% endblock %}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{% extends 'base.html' %}{% block content %}{% include 'collection.html' %}{% if live %}<script src="/static/live.js" defer></script>{% endif %}{% endblock %}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<div class="lab-records {{ kind }}" data-kind="{{ kind }}">
|
||||||
|
{% if not items %}<div class="empty">По вашему запросу ничего не найдено.</div>{% elif kind == 'table' and not detail %}
|
||||||
|
<div class="table-scroll"><table><thead><tr><th class="row-index">№</th>{% for field in items[0].fields %}<th>{{ field.label }}</th>{% endfor %}<th></th></tr></thead><tbody>{% for record in items %}<tr class="record" data-record-id="{{ record.id }}"><td class="row-index">{{ record.id }}</td>{% for field in record.fields %}<td class="field" data-field="{{ field.pointer }}" data-value-state="{{ field.state }}" data-value-type="{{ field.type }}" data-label="{{ field.label }}">{{ field.text }}</td>{% endfor %}<td>{% if base %}<a class="detail-link" href="{{ base }}/records/{{ record.id }}" aria-label="Открыть запись {{ record.id }}">↗</a>{% endif %}</td></tr>{% endfor %}</tbody></table></div>
|
||||||
|
{% else %}<div class="record-grid {{ 'single' if detail or kind in ['messages','questionnaire'] else '' }}">{% for record in items %}<article class="record record-card {{ kind }}" data-record-id="{{ record.id }}">
|
||||||
|
<div class="record-top"><span class="eyebrow">{{ 'УЧАСТНИК' if kind == 'questionnaire' else 'ЗАПИСЬ' }} {{ record.id }}</span>{% if base and not detail %}<a class="detail-link" href="{{ base }}/records/{{ record.id }}">Открыть ↗</a>{% endif %}</div>
|
||||||
|
{% if kind == 'messages' %}<div class="message-author"><span class="avatar">{{ (record.roles.get('author','А') or 'А')[:1] }}</span><div>{% if record.role_fields.get('author') %}{% set f=record.role_fields.author %}<strong class="field" data-role="author" data-field="{{ f.pointer }}" data-label="Автор" data-value-state="{{ f.state }}" data-value-type="{{ f.type }}">{{ f.text }}</strong>{% else %}<strong>Анонимный автор</strong>{% endif %}{% if record.role_fields.get('date') %}{% set f=record.role_fields.date %}<div class="muted field" data-role="date" data-field="{{ f.pointer }}" data-label="Дата" data-value-state="{{ f.state }}" data-value-type="{{ f.type }}">{{ f.text }}</div>{% endif %}</div></div>{% if record.role_fields.get('text') %}{% set f=record.role_fields.text %}<p class="message-text field" data-role="text" data-field="{{ f.pointer }}" data-label="Текст" data-value-state="{{ f.state }}" data-value-type="{{ f.type }}">{{ f.text }}</p>{% endif %}
|
||||||
|
{% elif record.role_fields.get('title') %}{% set f=record.role_fields.title %}<h2 class="field" data-role="title" data-field="{{ f.pointer }}" data-label="Заголовок" data-value-state="{{ f.state }}" data-value-type="{{ f.type }}">{{ f.text }}</h2>{% endif %}
|
||||||
|
<dl>{% for field in record.fields %}<div class="field-pair"><dt>{{ field.label }}</dt><dd class="field" data-field="{{ field.pointer }}" data-value-state="{{ field.state }}" data-value-type="{{ field.type }}" data-label="{{ field.label }}">{{ field.text }}</dd></div>{% endfor %}</dl></article>{% endfor %}</div>{% endif %}</div>
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Isolated application fixture used by test_browser.py, never production."""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import uvicorn
|
||||||
|
from parser_lab.app import create_app
|
||||||
|
from parser_lab.config import Settings
|
||||||
|
from parser_lab.views import default_view
|
||||||
|
|
||||||
|
port=int(os.environ['LAB_TEST_PORT'])
|
||||||
|
app=create_app(Settings(Path(os.environ['LAB_TEST_DATA']),'teacher','test-browser-password',
|
||||||
|
'browser-tests-only-secret-123456789',False,(f'http://127.0.0.1:{port}',)))
|
||||||
|
records=[{'name':f'Person {n:03}','text':'A walk' if n%3==0 else 'Rest','score':n,'secret':'DO-NOT-PUBLISH'} for n in range(1,52)]
|
||||||
|
d=app.state.repo.create('Browser study',b'[]','fixture.json',records);d['slug']='browser-study'
|
||||||
|
for kind in ('table','cards','messages','questionnaire'):
|
||||||
|
v=default_view(kind,['/name','/text','/score'])
|
||||||
|
v.update(title_pointer='/name',text_pointer='/text',author_pointer='/name',search_pointer='/text',category_pointer='/text')
|
||||||
|
d['views'][kind]=v
|
||||||
|
d=app.state.repo.save(d,1);app.state.repo.publish(d['id'],d['revision'])
|
||||||
|
uvicorn.run(app,host='127.0.0.1',port=port,access_log=False)
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from test_web import env,login
|
||||||
|
|
||||||
|
|
||||||
|
def headers(app,client):
|
||||||
|
return {'origin':'http://testserver','x-csrf-token':app.state.auth.csrf(client.cookies['lab_session'])}
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_configure_preview_publish_and_conflict(env):
|
||||||
|
app,c=env;login(c);h=headers(app,c)
|
||||||
|
files={'file':('study.csv',b'name,score\nAlice,7\nBob,5','text/csv')}
|
||||||
|
assert c.post('/api/admin/inspect',files=files).status_code==403
|
||||||
|
inspection=c.post('/api/admin/inspect',files=files,headers=h)
|
||||||
|
assert inspection.status_code==200 and inspection.json()['count']==2
|
||||||
|
d=c.post('/api/admin/import',files=files,data={'title':'Study'},headers=h).json()
|
||||||
|
from parser_lab.views import default_view
|
||||||
|
config={'revision':d['revision'],'title':'Study','slug':d['slug'],'description':'', 'views':{'table':default_view('table',['/name','/score'])}}
|
||||||
|
preview=c.post(f"/api/admin/datasets/{d['id']}/preview?kind=table",json=config,headers=h)
|
||||||
|
assert preview.status_code==200 and 'Alice' in preview.json()['html']
|
||||||
|
assert app.state.repo.get(d['id'])['views']=={}
|
||||||
|
saved=c.put(f"/api/admin/datasets/{d['id']}",json=config,headers=h)
|
||||||
|
assert saved.status_code==200
|
||||||
|
assert c.put(f"/api/admin/datasets/{d['id']}",json=config,headers=h).status_code==409
|
||||||
|
published=c.post(f"/api/admin/datasets/{d['id']}/publish",json={'revision':saved.json()['revision']},headers=h)
|
||||||
|
assert published.status_code==200
|
||||||
|
url=f"/interactive/{d['slug']}/v/1/table"
|
||||||
|
page=c.get(url);assert page.status_code==200 and 'Alice' in page.text
|
||||||
|
assert '/rio/' not in page.text
|
||||||
|
assert c.get(url+'?fragment=1').json()['total']==2
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rejects_unauthorized_and_bad_config(env):
|
||||||
|
app,c=env
|
||||||
|
assert c.post('/api/admin/inspect',headers={'origin':'http://testserver'}).status_code==401
|
||||||
|
login(c);h=headers(app,c)
|
||||||
|
d=app.state.repo.create('Test',b'{}','a.json',[{'name':'Alice'}])
|
||||||
|
response=c.put(f"/api/admin/datasets/{d['id']}",json={'views':{'table':{'columns':'bad'}},'revision':1},headers=h)
|
||||||
|
assert response.status_code==400
|
||||||
|
assert app.state.repo.get(d['id'])['revision']==1
|
||||||
|
|
||||||
|
|
||||||
|
def test_replacement_preserves_published_snapshot(env):
|
||||||
|
app,c=env;login(c);h=headers(app,c)
|
||||||
|
from parser_lab.views import default_view
|
||||||
|
d=app.state.repo.create('Test',b'name\nAlice','a.csv',[{'name':'Alice'}])
|
||||||
|
d['views']={'table':default_view('table',['/name'])};d=app.state.repo.save(d,1);p=app.state.repo.publish(d['id'],d['revision'])
|
||||||
|
data={'replace_id':d['id'],'revision':str(d['revision'])}
|
||||||
|
replaced=c.post('/api/admin/import',files={'file':('b.csv',b'name\nBob','text/csv')},data=data,headers=h)
|
||||||
|
assert replaced.status_code==200
|
||||||
|
assert app.state.repo.get(d['id'])['views']=={}
|
||||||
|
assert app.state.repo.publication(p['slug'],1)['records']==[{'name':'Alice'}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_rejects_malformed_payload_and_client_cannot_replace_records(env):
|
||||||
|
app,c=env;login(c);h=headers(app,c)
|
||||||
|
d=app.state.repo.create('Test',b'{}','a.json',[{'name':'Alice'}])
|
||||||
|
for payload in ([],{'revision':None},{'revision':True}):
|
||||||
|
assert c.post(f"/api/admin/datasets/{d['id']}/publish",json=payload,headers=h).status_code==400
|
||||||
|
payload={'revision':1,'views':{},'records':[{'name':'Injected'}],'id':'elsewhere'}
|
||||||
|
assert c.put(f"/api/admin/datasets/{d['id']}",json=payload,headers=h).status_code==200
|
||||||
|
assert app.state.repo.get(d['id'])['records']==[{'name':'Alice'}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_example_questionnaires_import_without_manual_paths(env):
|
||||||
|
from pathlib import Path
|
||||||
|
app,c=env;login(c);h=headers(app,c)
|
||||||
|
raw=(Path(__file__).parents[1]/'examples/data/questionnaires.json').read_bytes()
|
||||||
|
files={'file':('anything.json',raw,'application/json')}
|
||||||
|
inspected=c.post('/api/admin/inspect',files=files,headers=h).json()
|
||||||
|
assert inspected['count']==2
|
||||||
|
assert inspected['record_pointer']=='/participants'
|
||||||
|
assert inspected['choose_record_level'] is False
|
||||||
|
response=c.post('/api/admin/import',files=files,headers=h)
|
||||||
|
assert response.status_code==200
|
||||||
|
d=app.state.repo.get(response.json()['id'])
|
||||||
|
assert [r['participant'] for r in d['records']]==['A01','A02']
|
||||||
|
assert d['views']['questionnaire']['questions_pointer']=='/answers'
|
||||||
|
assert d['views']['questionnaire']['title_pointer']=='/participant'
|
||||||
|
assert c.post(f"/api/admin/datasets/{d['id']}/publish",json={'revision':d['revision']},headers=h).status_code==200
|
||||||
|
soup=BeautifulSoup(c.get(f"/html/{d['slug']}/v/1/questionnaire").text,'html.parser')
|
||||||
|
assert len(soup.select('.record'))==2
|
||||||
|
assert [x.text for x in soup.select('.field-pair dd')]==['8','Прогулка','true','5','null','false']
|
||||||
|
assert len(soup.select('.field-pair dt'))==6
|
||||||
|
explicit=c.post('/api/admin/import',files=files,data={'root_confirmed':'1'},headers=h)
|
||||||
|
assert len(app.state.repo.get(explicit.json()['id'])['records'])==1
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_wrong_level_repair_preserves_publication_and_checks_revision(env):
|
||||||
|
from pathlib import Path
|
||||||
|
from parser_lab.importing import read_upload,ImportOptions
|
||||||
|
from parser_lab.views import default_view
|
||||||
|
app,c=env;login(c);h=headers(app,c)
|
||||||
|
raw=(Path(__file__).parents[1]/'examples/data/questionnaires.json').read_bytes()
|
||||||
|
d=app.state.repo.create('Original title',raw,'questionnaires.json',read_upload(raw,'q.json',ImportOptions()).records)
|
||||||
|
d['views']={'table':default_view('table',['/description'])};d=app.state.repo.save(d,1)
|
||||||
|
original=app.state.repo.publish(d['id'],d['revision'])
|
||||||
|
url=f"/api/admin/datasets/{d['id']}/configure-questionnaire"
|
||||||
|
assert c.post(url,json={'revision':1},headers=h).status_code==409
|
||||||
|
assert len(app.state.repo.get(d['id'])['records'])==1
|
||||||
|
response=c.post(url,json={'revision':d['revision']},headers=h)
|
||||||
|
assert response.status_code==200
|
||||||
|
fixed=app.state.repo.get(d['id'])
|
||||||
|
assert len(fixed['records'])==2 and fixed['title']=='Original title'
|
||||||
|
assert fixed['views']['questionnaire']['question_pointer']=='/question'
|
||||||
|
assert app.state.repo.publication(d['slug'],1)==original
|
||||||
|
assert (app.state.repo.root/'sources'/fixed['source']).read_bytes()==raw
|
||||||
|
response=c.post(f"/api/admin/datasets/{d['id']}/preview?kind=questionnaire",json=fixed,headers=h).json()
|
||||||
|
assert response['errors']==[] and 'A01' in response['html'] and 'Прогулка' in response['html']
|
||||||
|
|
||||||
|
|
||||||
|
def test_ambiguous_questionnaire_wrapper_requires_choice(env):
|
||||||
|
import json
|
||||||
|
app,c=env;login(c);h=headers(app,c)
|
||||||
|
rows=[{'participant':'A','answers':[{'question':'Q','answer':0}]}]
|
||||||
|
raw=json.dumps({'first':rows,'second':rows}).encode()
|
||||||
|
files={'file':('ambiguous.json',raw,'application/json')}
|
||||||
|
inspected=c.post('/api/admin/inspect',files=files,headers=h).json()
|
||||||
|
assert inspected['choose_record_level'] and inspected['count']==1
|
||||||
|
assert c.post('/api/admin/import',files=files,headers=h).status_code==400
|
||||||
|
d=c.post('/api/admin/import',files=files,data={'pointer':'/second'},headers=h).json()
|
||||||
|
assert app.state.repo.get(d['id'])['views']['questionnaire']['answer_pointer']=='/answer'
|
||||||
|
|
||||||
|
|
||||||
|
def test_questionnaire_rejects_array_as_question(env):
|
||||||
|
from pathlib import Path
|
||||||
|
from parser_lab.importing import read_upload,ImportOptions
|
||||||
|
from parser_lab.views import default_view
|
||||||
|
app,c=env;login(c);h=headers(app,c)
|
||||||
|
raw=(Path(__file__).parents[1]/'examples/data/questionnaires.json').read_bytes()
|
||||||
|
records=read_upload(raw,'q.json',ImportOptions()).records
|
||||||
|
d=app.state.repo.create('Wrong level',raw,'q.json',records)
|
||||||
|
view=default_view('questionnaire',['/description','/participants'])
|
||||||
|
view.update(questionnaire_mode='pairs',questions_pointer='/participants',question_pointer='/answers',answer_pointer='/answers')
|
||||||
|
config={'revision':1,'views':{'questionnaire':view}}
|
||||||
|
preview=c.post(f"/api/admin/datasets/{d['id']}/preview?kind=questionnaire",json=config,headers=h).json()
|
||||||
|
assert preview['errors'] and preview['html']==''
|
||||||
|
d['views']=config['views'];d=app.state.repo.save(d,1)
|
||||||
|
assert c.post(f"/api/admin/datasets/{d['id']}/publish",json={'revision':d['revision']},headers=h).status_code==400
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
import requests
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import sync_playwright,expect
|
||||||
|
|
||||||
|
pytestmark=pytest.mark.skipif(os.environ.get('LAB_BROWSER_TESTS')!='1',reason='Enable LAB_BROWSER_TESTS=1 for real browser tests')
|
||||||
|
ROOT=Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
@pytest.fixture(scope='module')
|
||||||
|
def live(tmp_path_factory):
|
||||||
|
folder=tmp_path_factory.mktemp('live-server')
|
||||||
|
with socket.socket() as sock:
|
||||||
|
sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
|
||||||
|
env=dict(os.environ,LAB_TEST_PORT=str(port),LAB_TEST_DATA=str(folder/'data'),PYTHONPATH=str(ROOT/'src'),PYTHONIOENCODING='utf-8')
|
||||||
|
with (folder/'server.log').open('w',encoding='utf-8') as log:
|
||||||
|
process=subprocess.Popen([sys.executable,str(ROOT/'tests/browser_server.py')],env=env,stdout=log,stderr=log)
|
||||||
|
base=f'http://127.0.0.1:{port}'
|
||||||
|
try:
|
||||||
|
deadline=time.monotonic()+25
|
||||||
|
while time.monotonic()<deadline:
|
||||||
|
try:
|
||||||
|
if requests.get(base+'/healthz',timeout=.5).ok: break
|
||||||
|
except requests.RequestException: pass
|
||||||
|
if process.poll() is not None: raise RuntimeError((folder/'server.log').read_text('utf-8'))
|
||||||
|
time.sleep(.1)
|
||||||
|
else: raise RuntimeError('Server startup timeout')
|
||||||
|
yield base,folder
|
||||||
|
finally:
|
||||||
|
process.terminate()
|
||||||
|
try: process.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired: process.kill();process.wait()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def page():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
channel=os.environ.get('PLAYWRIGHT_CHANNEL')
|
||||||
|
browser=p.chromium.launch(headless=True,**({'channel':channel} if channel else {}))
|
||||||
|
yield browser.new_page(viewport={'width':1440,'height':1000})
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
def press(page,name,target):
|
||||||
|
page.get_by_role('button',name=name,exact=True).click()
|
||||||
|
expect(target).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_teacher_import_and_publish(page,live):
|
||||||
|
base,folder=live
|
||||||
|
page.goto(base+'/login')
|
||||||
|
page.get_by_label('Логин',exact=True).fill('teacher');page.get_by_label('Пароль',exact=True).fill('test-browser-password')
|
||||||
|
page.get_by_role('button',name='Войти в кабинет').click()
|
||||||
|
expect(page.get_by_text('Коллекции данных',exact=True)).to_be_visible()
|
||||||
|
page.get_by_role('link',name='Загрузить данные',exact=True).click()
|
||||||
|
page.locator('input[type=file]').set_input_files({'name':'browser.csv','mimeType':'text/csv','buffer':b'id,answer\n001,7\n002,5'})
|
||||||
|
page.get_by_role('button',name='Проверить файл',exact=True).click()
|
||||||
|
expect(page.locator('#import-save')).to_be_enabled()
|
||||||
|
press(page,'Сохранить и настроить →',page.get_by_text('Включить это представление',exact=True))
|
||||||
|
page.locator('input[type=checkbox]').first.check()
|
||||||
|
expect(page.get_by_text('Поля и подписи',exact=True)).to_be_visible()
|
||||||
|
press(page,'Сохранить черновик',page.get_by_text('Черновик сохранён',exact=True))
|
||||||
|
with page.expect_popup() as opened:
|
||||||
|
page.get_by_role('link',name='Предпросмотр ↗',exact=True).click()
|
||||||
|
expect(opened.value.locator('.record')).to_have_count(2)
|
||||||
|
opened.value.close()
|
||||||
|
press(page,'Опубликовать новую версию',page.get_by_text('Версия 1 · Доступна',exact=True))
|
||||||
|
url=page.get_by_role('link',name='HTML · BeautifulSoup ↗').first.get_attribute('href')
|
||||||
|
page.goto(base+url)
|
||||||
|
expect(page.locator('.record')).to_have_count(2)
|
||||||
|
assert page.locator('.field').all_text_contents()==['001','7','002','5']
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('kind',['table','cards','messages','questionnaire'])
|
||||||
|
def test_views_filters_mobile_and_scrapers(page,live,kind):
|
||||||
|
base,folder=live
|
||||||
|
sys.path.insert(0,str(ROOT/'examples/parsers'))
|
||||||
|
from scrape_html import scrape as html_scrape
|
||||||
|
from common import extract
|
||||||
|
expected=html_scrape(f'{base}/html/browser-study/v/1/{kind}')
|
||||||
|
assert len(expected)==51 and expected[0]['fields'][0]['text']=='Person 001'
|
||||||
|
assert 'DO-NOT-PUBLISH' not in str(expected)
|
||||||
|
page.goto(f'{base}/interactive/browser-study/v/1/{kind}')
|
||||||
|
expect(page.locator('.record')).to_have_count(25)
|
||||||
|
for count in (50,51):
|
||||||
|
for attempt in range(3):
|
||||||
|
page.get_by_role('button',name='Загрузить ещё',exact=True).click()
|
||||||
|
try: expect(page.locator('.record')).to_have_count(count,timeout=1800);break
|
||||||
|
except AssertionError:
|
||||||
|
if attempt==2: raise
|
||||||
|
assert extract(page.content())==expected
|
||||||
|
page.get_by_label('Поиск в коллекции',exact=True).fill('WALK')
|
||||||
|
expect(page.locator('.record')).to_have_count(17)
|
||||||
|
page.set_viewport_size({'width':390,'height':844})
|
||||||
|
page.wait_for_function('document.documentElement.scrollWidth <= innerWidth+1')
|
||||||
|
page.screenshot(path=str(folder/f'{kind}-mobile.png'),full_page=True)
|
||||||
|
assert 'DO-NOT-PUBLISH' not in page.content()
|
||||||
|
|
||||||
|
def test_playwright_example(live):
|
||||||
|
base,_=live
|
||||||
|
sys.path.insert(0,str(ROOT/'examples/parsers'))
|
||||||
|
from scrape_interactive import scrape
|
||||||
|
records=scrape(f'{base}/interactive/browser-study/v/1/table',os.environ.get('PLAYWRIGHT_CHANNEL'))
|
||||||
|
assert len(records)==51 and [r['id'] for r in records]==[str(n) for n in range(1,52)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_editor_and_mobile_catalog(page,live):
|
||||||
|
base,folder=live
|
||||||
|
page.goto(base+'/login')
|
||||||
|
page.get_by_label('Логин',exact=True).fill('teacher')
|
||||||
|
page.get_by_label('Пароль',exact=True).fill('test-browser-password')
|
||||||
|
page.get_by_role('button',name='Войти в кабинет').click()
|
||||||
|
expect(page.locator('.catalog-title').filter(has_text='Browser study')).to_be_visible()
|
||||||
|
page.screenshot(path=str(folder/'catalog-desktop.png'),full_page=True)
|
||||||
|
page.get_by_role('link',name='Открыть Browser study',exact=True).click()
|
||||||
|
preview=page.locator('.editor-preview')
|
||||||
|
expect(preview.locator('.record')).to_have_count(5)
|
||||||
|
label=page.get_by_label('Подпись поля',exact=True).first
|
||||||
|
label.fill('Имя участника')
|
||||||
|
label.press('Tab')
|
||||||
|
expect(preview.locator('th').filter(has_text='Имя участника')).to_be_visible()
|
||||||
|
assert preview.evaluate('(e)=>e.getBoundingClientRect().right <= e.closest(".editor-layout").getBoundingClientRect().right + 1')
|
||||||
|
page.screenshot(path=str(folder/'editor-desktop.png'),full_page=True)
|
||||||
|
# Live edits have not changed the public snapshot.
|
||||||
|
response=page.request.get(base+'/html/browser-study/v/1/table')
|
||||||
|
assert response.ok and 'Имя участника' not in response.text()
|
||||||
|
page.set_viewport_size({'width':390,'height':844})
|
||||||
|
page.wait_for_function('document.documentElement.scrollWidth <= innerWidth+1',timeout=5000)
|
||||||
|
page.screenshot(path=str(folder/'editor-mobile.png'),full_page=True)
|
||||||
|
page.once('dialog',lambda dialog:dialog.accept())
|
||||||
|
page.get_by_role('link',name='Мои коллекции',exact=True).click()
|
||||||
|
expect(page.get_by_text('Коллекции данных',exact=True)).to_be_visible()
|
||||||
|
page.wait_for_function('document.documentElement.scrollWidth <= innerWidth+1')
|
||||||
|
page.screenshot(path=str(folder/'catalog-mobile.png'),full_page=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_collection_home_link_uses_http_page(page,live):
|
||||||
|
base,_=live
|
||||||
|
page.goto(base+'/interactive/browser-study/v/1/table')
|
||||||
|
expect(page.locator('.record')).to_have_count(25)
|
||||||
|
page.get_by_role('link',name='Parser Lab / Коллекции',exact=True).click()
|
||||||
|
expect(page.get_by_role('heading',name='Учебные коллекции данных',exact=True)).to_be_visible()
|
||||||
|
assert page.url==base+'/'
|
||||||
|
page.go_back()
|
||||||
|
expect(page.locator('.record')).to_have_count(25)
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_opens_http_login_page(page,live):
|
||||||
|
base,_=live
|
||||||
|
page.goto(base+'/login')
|
||||||
|
page.get_by_label('Логин',exact=True).fill('teacher')
|
||||||
|
page.get_by_label('Пароль',exact=True).fill('test-browser-password')
|
||||||
|
page.get_by_role('button',name='Войти в кабинет').click()
|
||||||
|
expect(page.get_by_text('Коллекции данных',exact=True)).to_be_visible()
|
||||||
|
press(page,'Выйти',page.get_by_label('Пароль',exact=True))
|
||||||
|
assert page.url==base+'/login'
|
||||||
|
page.goto(base+'/admin')
|
||||||
|
expect(page.get_by_label('Пароль',exact=True)).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('width',[1440,390])
|
||||||
|
def test_html_interactive_visual_parity(page,live,width):
|
||||||
|
base,folder=live
|
||||||
|
page.set_viewport_size({'width':width,'height':1000})
|
||||||
|
selectors=['.topbar','.brand','.public-main','.page-heading h1','.version-badge','.collection-meta','.filters','.filters input','.filters select','.record:first-child']
|
||||||
|
def metrics():
|
||||||
|
return page.evaluate('''selectors=>Object.fromEntries(selectors.map(selector=>{
|
||||||
|
const e=document.querySelector(selector);if(!e)return [selector,null];
|
||||||
|
const r=e.getBoundingClientRect(),s=getComputedStyle(e);
|
||||||
|
return [selector,{x:r.x,y:r.y,width:r.width,height:r.height,font:s.fontFamily,size:s.fontSize,color:s.color,background:s.backgroundColor}];
|
||||||
|
}))''',selectors)
|
||||||
|
page.goto(base+'/html/browser-study/v/1/table')
|
||||||
|
page.evaluate('document.fonts.ready')
|
||||||
|
expected=metrics()
|
||||||
|
page.screenshot(path=str(folder/f'html-parity-{width}.png'))
|
||||||
|
page.goto(base+'/interactive/browser-study/v/1/table')
|
||||||
|
expect(page.locator('.record')).to_have_count(25)
|
||||||
|
page.evaluate('document.fonts.ready')
|
||||||
|
actual=metrics()
|
||||||
|
page.screenshot(path=str(folder/f'interactive-parity-{width}.png'))
|
||||||
|
for selector in selectors:
|
||||||
|
assert actual[selector] is not None,selector
|
||||||
|
for key in (('x','y','width') if selector=='.public-main' else ('x','y','width','height')):
|
||||||
|
assert abs(actual[selector][key]-expected[selector][key])<=2,(selector,key,actual[selector],expected[selector])
|
||||||
|
for key in ('font','size','color','background'):
|
||||||
|
assert actual[selector][key]==expected[selector][key],(selector,key)
|
||||||
|
page.get_by_label('Поиск в коллекции',exact=True).fill('walk')
|
||||||
|
expect(page.locator('.record')).to_have_count(17)
|
||||||
|
page.get_by_role('link',name='Сбросить',exact=True).click()
|
||||||
|
expect(page.locator('.record')).to_have_count(25)
|
||||||
|
page.get_by_label('Категория',exact=True).select_option(label='Rest')
|
||||||
|
expect(page.locator('.collection-meta b')).to_have_text('34')
|
||||||
|
expect(page.locator('.record')).to_have_count(25)
|
||||||
|
page.get_by_role('button',name='Загрузить ещё',exact=True).click()
|
||||||
|
expect(page.locator('.record')).to_have_count(34)
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_pointer_and_stale_import_preview(page,live):
|
||||||
|
base,_=live
|
||||||
|
page.goto(base+'/login')
|
||||||
|
page.get_by_label('Логин',exact=True).fill('teacher')
|
||||||
|
page.get_by_label('Пароль',exact=True).fill('test-browser-password')
|
||||||
|
page.get_by_role('button',name='Войти в кабинет').click()
|
||||||
|
page.get_by_role('link',name='Загрузить данные',exact=True).click()
|
||||||
|
upload=page.locator('input[type=file]')
|
||||||
|
upload.set_input_files(str(ROOT/'examples/data/questionnaires.json'))
|
||||||
|
page.locator('#inspect').click()
|
||||||
|
expect(page.locator('#import-save')).to_be_enabled()
|
||||||
|
expect(page.get_by_label('Записи внутри JSON',exact=True)).to_have_value('/participants')
|
||||||
|
page.locator('#import-save').click()
|
||||||
|
expect(page.get_by_label('Представление',exact=True)).to_have_value('questionnaire')
|
||||||
|
expect(page.get_by_label('Структура анкеты',exact=True)).to_have_value('pairs')
|
||||||
|
expect(page.get_by_label('Массив ответов',exact=True)).to_have_value('/answers')
|
||||||
|
expect(page.get_by_label('Поле вопроса внутри ответа',exact=True)).to_have_value('/question')
|
||||||
|
expect(page.get_by_label('Поле ответа',exact=True)).to_have_value('/answer')
|
||||||
|
expect(page.locator('.editor-preview .record')).to_have_count(2)
|
||||||
|
expect(page.locator('.editor-preview .field-pair dd')).to_have_text(['8','Прогулка','true','5','null','false'])
|
||||||
|
expect(page.locator('.editor-preview h2')).to_have_text(['A01','A02'])
|
||||||
|
press(page,'Сохранить черновик',page.get_by_text('Черновик сохранён',exact=True))
|
||||||
|
page.get_by_role('link',name='Импорт файла',exact=True).click()
|
||||||
|
upload.set_input_files({'name':'valid.csv','mimeType':'text/csv','buffer':b'id,value\n1,2'})
|
||||||
|
page.locator('#inspect').click()
|
||||||
|
expect(page.locator('#import-save')).to_be_enabled()
|
||||||
|
upload.set_input_files({'name':'broken.json','mimeType':'application/json','buffer':b'{'})
|
||||||
|
expect(page.locator('#import-save')).to_be_disabled()
|
||||||
|
expect(page.locator('#import-result .record')).to_have_count(0)
|
||||||
|
page.locator('#inspect').click()
|
||||||
|
expect(page.locator('#status')).to_have_class('error')
|
||||||
|
expect(page.locator('#import-save')).to_be_disabled()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynamic_page_two_starts_from_requested_page(page,live):
|
||||||
|
base,_=live
|
||||||
|
page.goto(base+'/interactive/browser-study/v/1/table?page=2')
|
||||||
|
expect(page.locator('.record').first).to_have_attribute('data-record-id','26')
|
||||||
|
page.get_by_role('button',name='Загрузить ещё',exact=True).click()
|
||||||
|
expect(page.locator('.record')).to_have_count(26)
|
||||||
|
expect(page.locator('.record').last).to_have_attribute('data-record-id','51')
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_legacy_questionnaire_in_browser(page,live):
|
||||||
|
base,_=live
|
||||||
|
page.goto(base+'/login')
|
||||||
|
page.get_by_label('Логин',exact=True).fill('teacher')
|
||||||
|
page.get_by_label('Пароль',exact=True).fill('test-browser-password')
|
||||||
|
page.get_by_role('button',name='Войти в кабинет').click()
|
||||||
|
csrf=page.locator('meta[name=csrf-token]').get_attribute('content')
|
||||||
|
response=page.request.post(base+'/api/admin/import',headers={'origin':base,'x-csrf-token':csrf},multipart={'file':{'name':'questionnaires.json','mimeType':'application/json','buffer':(ROOT/'examples/data/questionnaires.json').read_bytes()},'root_confirmed':'1','title':'Legacy questionnaire'})
|
||||||
|
assert response.ok
|
||||||
|
page.goto(base+'/admin/datasets/'+response.json()['id'])
|
||||||
|
page.get_by_role('button',name='Настроить анкеты автоматически',exact=True).click()
|
||||||
|
expect(page.locator('.editor-preview .record')).to_have_count(2)
|
||||||
|
expect(page.locator('.editor-preview .field-pair dd')).to_have_text(['8','Прогулка','true','5','null','false'])
|
||||||
|
page.reload()
|
||||||
|
expect(page.locator('.editor-preview h2')).to_have_text(['A01','A02'])
|
||||||
|
page.get_by_role('button',name='Опубликовать новую версию',exact=True).click()
|
||||||
|
expect(page.get_by_text('Версия 1 · Доступна',exact=True)).to_be_visible()
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import pytest
|
||||||
|
from parser_lab.repository import Repository, Conflict, NotFound
|
||||||
|
from parser_lab.views import default_view, validate_view, project_record
|
||||||
|
from parser_lab.query import select_records
|
||||||
|
|
||||||
|
def make_draft(repo):
|
||||||
|
d = repo.create('Study', b'[]', 'source.json', [{'name':'Ann','secret':'hidden'}, {'name':'Bob'}])
|
||||||
|
d['views'] = {'table': default_view('table', ['/name'])}
|
||||||
|
return repo.save(d, d['revision'])
|
||||||
|
|
||||||
|
def test_versions_and_conflicts(tmp_path):
|
||||||
|
repo = Repository(tmp_path)
|
||||||
|
d = make_draft(repo)
|
||||||
|
p = repo.publish(d['id'], d['revision'])
|
||||||
|
old = repo.get(d['id'])
|
||||||
|
d['title'] = 'New title'
|
||||||
|
d['records'][0]['name'] = 'Changed'
|
||||||
|
d = repo.save(d, d['revision'])
|
||||||
|
with pytest.raises(Conflict): repo.save(old, old['revision'])
|
||||||
|
assert repo.publication(p['slug'],1)['records'][0]['name'] == 'Ann'
|
||||||
|
assert repo.publish(d['id'],d['revision'])['version'] == 2
|
||||||
|
assert Repository(tmp_path).get(d['id'])['title'] == 'New title'
|
||||||
|
repo.unpublish(p['slug'],1)
|
||||||
|
with pytest.raises(NotFound): repo.publication(p['slug'],1)
|
||||||
|
|
||||||
|
def test_view_projection_and_pagination():
|
||||||
|
view = default_view('table',['/name'])
|
||||||
|
rows = [{'name':str(i), 'secret':'hidden'} for i in range(51)]
|
||||||
|
assert validate_view(view,rows) == []
|
||||||
|
p = select_records(rows,view,3)
|
||||||
|
assert p['items'][0]['id'] == '51'
|
||||||
|
assert p['total'] == 51 and not p['has_next']
|
||||||
|
assert 'hidden' not in str(project_record(rows[0],view,'1'))
|
||||||
|
with pytest.raises(ValueError): select_records(rows,view,0)
|
||||||
|
with pytest.raises(NotFound): select_records(rows,view,4)
|
||||||
|
|
||||||
|
def test_value_states_and_questionnaires():
|
||||||
|
view = default_view('table',['/x'])
|
||||||
|
got = [project_record(r,view,'1')['fields'][0] for r in [{},{'x':None},{'x':False},{'x':0},{'x':''}]]
|
||||||
|
assert [v['state'] for v in got] == ['missing','null','value','value','value']
|
||||||
|
assert [v['text'] for v in got] == ['Нет значения','null','false','0','']
|
||||||
|
assert [v['type'] for v in got] == ['missing','null','boolean','number','string']
|
||||||
|
q = default_view('questionnaire',['/answers'])
|
||||||
|
q.update(questionnaire_mode='pairs',questions_pointer='/answers',question_pointer='/q',answer_pointer='/a')
|
||||||
|
assert project_record({'answers':[{'q':'Sleep?','a':7}]},q,'1')['fields'][0]['text'] == '7'
|
||||||
|
|
||||||
|
def test_typed_category_and_search():
|
||||||
|
v=default_view('table',['/x']); v.update(search_pointer='/x',category_pointer='/x')
|
||||||
|
assert select_records([{'x':'HELLO'},{'x':'other'}],v,1,'hello')['total']==1
|
||||||
|
assert select_records([{'x':0},{'x':'0'}],v,1,'','0')['total']==1
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import pytest
|
||||||
|
from parser_lab.importing import read_upload, ImportOptions, ImportErrorDetail
|
||||||
|
from parser_lab.paths import resolve_pointer,field_summary
|
||||||
|
|
||||||
|
def test_csv_preserves_strings_and_quotes():
|
||||||
|
r = read_upload('id;answer\n001;"a\nb"\n002;\n'.encode(), 'a.csv', ImportOptions(delimiter=';'))
|
||||||
|
assert r.records == [{'id': '001', 'answer': 'a\nb'}, {'id': '002', 'answer': ''}]
|
||||||
|
|
||||||
|
def test_json_preserves_types_and_nested_selection():
|
||||||
|
r = read_upload(b'{"items":[{"x":null},{"x":false},{"x":0},{"x":""},{}]}', 'a.json', ImportOptions(record_pointer='/items'))
|
||||||
|
assert r.records == [{'x': None}, {'x': False}, {'x': 0}, {'x': ''}, {}]
|
||||||
|
assert resolve_pointer({'a/b': {'~c': 0}}, '/a~1b/~0c') == (True, 0)
|
||||||
|
assert resolve_pointer({}, '/x') == (False, None)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('data,name', [(b'a,a\n1,2','a.csv'), (b'a,b\n1,2,3','a.csv'), (b'{"x":1,"x":2}','a.json'), (b'{"x":NaN}','a.json'), (b'[1,2]','a.json'), (b'[]','a.json')])
|
||||||
|
def test_invalid_inputs_rejected(data, name):
|
||||||
|
with pytest.raises(ImportErrorDetail): read_upload(data, name, ImportOptions())
|
||||||
|
|
||||||
|
def test_jsonl_reports_line_and_blanks():
|
||||||
|
assert read_upload(b'{"a":1}\n\n{"b":2}', 'a.jsonl', ImportOptions()).warnings
|
||||||
|
with pytest.raises(ImportErrorDetail, match='3'):
|
||||||
|
read_upload(b'{"a":1}\n\nBAD', 'a.jsonl', ImportOptions())
|
||||||
|
|
||||||
|
def test_encoding_limits_and_fields():
|
||||||
|
assert read_upload('имя;возраст\nОля;20'.encode('cp1251'), 'a.csv', ImportOptions(encoding='cp1251', delimiter=';')).records[0]['имя'] == 'Оля'
|
||||||
|
assert '/later' in read_upload(b'[{}, {"later":1}]','a.json',ImportOptions()).fields
|
||||||
|
with pytest.raises(ImportErrorDetail): read_upload(b'12345','a.json',ImportOptions(), max_bytes=4)
|
||||||
|
with pytest.raises(ImportErrorDetail): read_upload(b'[{},{}]','a.json',ImportOptions(), max_records=1)
|
||||||
|
with pytest.raises(ImportErrorDetail): read_upload(('[' + '{"x":'*21 + '0' + '}'*21 + ']').encode(),'a.json',ImportOptions())
|
||||||
|
|
||||||
|
def test_field_tree_and_missing_counts():
|
||||||
|
summary=field_summary([{'profile':{'age':20},'a/b':0},{'profile':{}},{}])
|
||||||
|
assert summary['/profile/age']['missing']==2
|
||||||
|
assert summary['/profile/age']['parent']=='/profile'
|
||||||
|
assert summary['/a~1b']['label']=='a/b'
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'examples/parsers'))
|
||||||
|
from common import extract
|
||||||
|
from parser_lab.views import project_record,default_view
|
||||||
|
from parser_lab.web import records_fragment
|
||||||
|
|
||||||
|
def test_message_roles_and_question_labels_are_exported():
|
||||||
|
v=default_view('messages',['/extra']);v.update(text_pointer='/body',author_pointer='/author')
|
||||||
|
r=project_record({'body':'Visible message','author':'Visible author','extra':'Additional'},v,'1')
|
||||||
|
exported=extract(records_fragment([r],'messages'))[0]
|
||||||
|
assert exported['roles']['text']['text']=='Visible message'
|
||||||
|
assert exported['roles']['author']['text']=='Visible author'
|
||||||
|
v=default_view('questionnaire',['/x']);v['columns'][0]['label']='Hours of sleep?'
|
||||||
|
exported=extract(records_fragment([project_record({'x':8},v,'1')],'questionnaire'))[0]
|
||||||
|
assert exported['fields'][0]['label']=='Hours of sleep?'
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from parser_lab.auth import Auth, Unauthorized
|
||||||
|
from parser_lab.config import Settings
|
||||||
|
from parser_lab.repository import Repository
|
||||||
|
from parser_lab import cli
|
||||||
|
|
||||||
|
|
||||||
|
def settings_from_env(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv('LAB_ADMIN_USER', 'teacher')
|
||||||
|
monkeypatch.setenv('LAB_ADMIN_PASSWORD', "мой пароль $ # ' \\ test")
|
||||||
|
monkeypatch.setenv('LAB_SECRET', 'test-secret-at-least-32-characters')
|
||||||
|
monkeypatch.setenv('LAB_DATA_DIR', str(tmp_path / 'data'))
|
||||||
|
monkeypatch.delenv('LAB_ADMIN_HASH', raising=False)
|
||||||
|
return Settings.from_env()
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_env_login_restart_and_change(monkeypatch, tmp_path):
|
||||||
|
settings = settings_from_env(monkeypatch, tmp_path)
|
||||||
|
password = os.environ['LAB_ADMIN_PASSWORD']
|
||||||
|
assert password not in repr(settings)
|
||||||
|
repo = Repository(settings.data_dir)
|
||||||
|
auth = Auth(settings, repo)
|
||||||
|
token = auth.login('teacher', password, 'client')
|
||||||
|
assert Auth(Settings.from_env(), repo).check(token) == token
|
||||||
|
with pytest.raises(Unauthorized):
|
||||||
|
auth.login('teacher', password + 'x', 'client')
|
||||||
|
changed = Auth(replace(settings, password='replacement-password'), repo)
|
||||||
|
with pytest.raises(Unauthorized):
|
||||||
|
changed.check(token)
|
||||||
|
with pytest.raises(Unauthorized):
|
||||||
|
changed.login('teacher', password, 'client')
|
||||||
|
assert changed.login('teacher', 'replacement-password', 'client')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('password', ['', 'short'])
|
||||||
|
def test_invalid_env_password(monkeypatch, tmp_path, password):
|
||||||
|
settings_from_env(monkeypatch, tmp_path)
|
||||||
|
monkeypatch.setenv('LAB_ADMIN_PASSWORD', password)
|
||||||
|
with pytest.raises(ValueError, match='LAB_ADMIN_PASSWORD'):
|
||||||
|
Settings.from_env()
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_env_plaintext_password_loads_with_uv(monkeypatch, tmp_path):
|
||||||
|
path = tmp_path / '.env'
|
||||||
|
password = "тест $HOME ${UNKNOWN} # ' \\ end"
|
||||||
|
monkeypatch.setattr(sys, 'argv', ['cli', 'init-env', '--path', str(path)])
|
||||||
|
monkeypatch.setattr('builtins.input', lambda _: 'teacher')
|
||||||
|
monkeypatch.setattr(cli.getpass, 'getpass', lambda _: password)
|
||||||
|
cli.main()
|
||||||
|
text = path.read_text(encoding='utf-8')
|
||||||
|
assert 'LAB_ADMIN_PASSWORD=' in text and 'LAB_ADMIN_HASH=' not in text
|
||||||
|
env = {k: v for k, v in os.environ.items() if not k.startswith('LAB_')}
|
||||||
|
env['EXPECTED_PASSWORD'] = password
|
||||||
|
result = subprocess.run(
|
||||||
|
['uv', 'run', '--no-project', '--env-file', str(path), sys.executable, '-c',
|
||||||
|
'import os; assert os.environ["LAB_ADMIN_PASSWORD"] == os.environ["EXPECTED_PASSWORD"]'],
|
||||||
|
env=env, capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
with pytest.raises(SystemExit, match='уже существует'):
|
||||||
|
cli.main()
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import re
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
import pytest
|
||||||
|
from parser_lab.app import create_app
|
||||||
|
from parser_lab.config import Settings
|
||||||
|
from parser_lab.views import default_view
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def env(tmp_path):
|
||||||
|
settings=Settings(tmp_path,'teacher','test-password','test-secret-long-enough-123456789',False,('http://testserver',))
|
||||||
|
app=create_app(settings)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
yield app,client
|
||||||
|
|
||||||
|
def login(client):
|
||||||
|
page=client.get('/login')
|
||||||
|
csrf=BeautifulSoup(page.text,'html.parser').select_one('[name=csrf]')['value']
|
||||||
|
return client.post('/login',data={'username':'teacher','password':'test-password','csrf':csrf},headers={'origin':'http://testserver'},follow_redirects=False)
|
||||||
|
|
||||||
|
def test_auth_and_csrf(env):
|
||||||
|
app,c=env
|
||||||
|
assert c.get('/healthz').status_code==200
|
||||||
|
assert c.get('/admin',follow_redirects=False).status_code==303
|
||||||
|
assert c.post('/login',data={'username':'teacher','password':'test-password'}).status_code==403
|
||||||
|
assert login(c).status_code==303
|
||||||
|
assert c.get('/admin').status_code==200
|
||||||
|
assert c.post('/logout',headers={'origin':'https://bad.example'}).status_code==403
|
||||||
|
|
||||||
|
def test_admin_is_server_rendered_for_headless_browser(env):
|
||||||
|
app,c=env
|
||||||
|
login(c)
|
||||||
|
page=c.get('/admin',headers={'user-agent':'Mozilla/5.0 HeadlessChrome/140.0.0.0 Safari/537.36'})
|
||||||
|
assert page.status_code==200 and 'Коллекции данных' in page.text
|
||||||
|
assert '/rio/' not in page.text
|
||||||
|
|
||||||
|
def test_logout_expiry_rate_limit_and_replacement_credentials(env):
|
||||||
|
from parser_lab.auth import Auth, Unauthorized,RateLimited
|
||||||
|
from dataclasses import replace
|
||||||
|
app,c=env; auth=app.state.auth
|
||||||
|
token=auth.login('teacher','test-password','one')
|
||||||
|
assert auth.check(token)==token
|
||||||
|
with app.state.repo.connect() as db: db.execute('UPDATE sessions SET expires=0')
|
||||||
|
with pytest.raises(Unauthorized): auth.check(token)
|
||||||
|
for _ in range(5):
|
||||||
|
with pytest.raises(Unauthorized): auth.login('teacher','bad','two')
|
||||||
|
with pytest.raises(RateLimited): auth.login('teacher','test-password','two')
|
||||||
|
login(c); token=c.cookies['lab_session']
|
||||||
|
assert c.post('/logout',data={'csrf':auth.csrf(token)},headers={'origin':'http://testserver'},follow_redirects=False).status_code==303
|
||||||
|
with pytest.raises(Unauthorized): auth.check(token)
|
||||||
|
token=auth.login('teacher','test-password','three')
|
||||||
|
changed=Auth(replace(auth.settings,secret='changed'),app.state.repo)
|
||||||
|
with pytest.raises(Unauthorized): changed.check(token)
|
||||||
|
|
||||||
|
def test_oversized_upload_rejected_before_parsing(env):
|
||||||
|
app,c=env;login(c)
|
||||||
|
response=c.post('/api/admin/import',content=b'x',headers={'origin':'http://testserver','x-csrf-token':app.state.auth.csrf(c.cookies['lab_session']),'content-length':str(30*1024*1024)})
|
||||||
|
assert response.status_code==413
|
||||||
|
|
||||||
|
def test_html_snapshot_and_escaping(env):
|
||||||
|
app,c=env; repo=app.state.repo
|
||||||
|
d=repo.create('Исследование',b'[]','a.json',[{'text':'<script>alert(1)</script>','secret':'HIDDEN'} for _ in range(51)])
|
||||||
|
d['views']={'table':default_view('table',['/text'])}; d=repo.save(d,1); p=repo.publish(d['id'],d['revision'])
|
||||||
|
base=f"/html/{p['slug']}/v/1/table"
|
||||||
|
page=c.get(base); soup=BeautifulSoup(page.text,'html.parser')
|
||||||
|
assert page.status_code==200 and len(soup.select('tbody tr'))==25
|
||||||
|
assert soup.select_one('tbody td.field').text.strip()=='<script>alert(1)</script>'
|
||||||
|
assert not soup.select('script') and 'HIDDEN' not in page.text
|
||||||
|
assert len(BeautifulSoup(c.get(base+'?page=3').text,'html.parser').select('tbody tr'))==1
|
||||||
|
assert c.get(base+'?page=0').status_code==400
|
||||||
|
assert c.get(base+'?page=no').status_code==400
|
||||||
|
assert c.get(base+'?page=4').status_code==404
|
||||||
|
assert 'HIDDEN' not in c.get(base+'/records/1').text
|
||||||
|
assert c.get(f"/preview/{d['id']}/table",follow_redirects=False).status_code==303
|
||||||
|
repo.unpublish(p['slug'],1)
|
||||||
|
assert c.get(base).status_code==404
|
||||||
|
assert c.get(f"/interactive/{p['slug']}/v/1/table").status_code==404
|
||||||
|
|
||||||
|
def test_preview_filters_match_publication(env):
|
||||||
|
app,c=env;login(c);repo=app.state.repo
|
||||||
|
d=repo.create('Filter',b'[]','a.json',[{'name':'Alice'},{'name':'Bob'}])
|
||||||
|
view=default_view('table',['/name']);view['search_pointer']='/name'
|
||||||
|
d['views']={'table':view};d=repo.save(d,1)
|
||||||
|
response=c.get(f"/preview/{d['id']}/table?q=Alice")
|
||||||
|
assert BeautifulSoup(response.text,'html.parser').select_one('.field').text=='Alice'
|
||||||
|
assert len(BeautifulSoup(response.text,'html.parser').select('.record'))==1
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_pages_share_favicon(env):
|
||||||
|
app,client=env
|
||||||
|
static=client.get('/static/favicon.png')
|
||||||
|
assert static.status_code==200 and static.content.startswith(b'\x89PNG\r\n\x1a\n')
|
||||||
|
repo=app.state.repo
|
||||||
|
d=repo.create('Icons',b'[]','a.json',[{'name':'Alice'}])
|
||||||
|
d['views']={'table':default_view('table',['/name'])};d=repo.save(d,1);repo.publish(d['id'],d['revision'])
|
||||||
|
login(client)
|
||||||
|
for path in ('/','/login','/admin',f"/html/{d['slug']}/v/1/table",f"/interactive/{d['slug']}/v/1/table"):
|
||||||
|
page=BeautifulSoup(client.get(path).text,'html.parser')
|
||||||
|
assert page.select_one('link[rel=icon]')['href']=='/static/favicon.png'
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = "==3.12.*"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-doc"
|
||||||
|
version = "0.0.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.15.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2-cffi"
|
||||||
|
version = "25.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "argon2-cffi-bindings" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2-cffi-bindings"
|
||||||
|
version = "26.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "beautifulsoup4"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "soupsieve" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.7.22"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cffi"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.5.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastapi"
|
||||||
|
version = "0.141.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-doc" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "starlette" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "greenlet"
|
||||||
|
version = "3.5.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/f2/844dc823ff2752ad049caa6b59d57e4572f9c445934b02d3518f4c67197c/greenlet-3.5.6-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee7d9da3bf493909cf811a3f038840cb34fab5ae2956b8a263919f6e289ab188", size = 629529, upload-time = "2026-09-14T15:25:06.354Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/42/b1f8dbc89a53b9e77859fc1ad1627d106fc361daa3ea4bdf43a91ebb4338/greenlet-3.5.6-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:71890d5247020c25c21a6b65202782bfc281d4e6e244842419d30e3492bb6dcc", size = 432385, upload-time = "2026-09-14T15:28:37.369Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.20"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jinja2"
|
||||||
|
version = "3.1.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markupsafe"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parser-lab"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "argon2-cffi" },
|
||||||
|
{ name = "fastapi" },
|
||||||
|
{ name = "jinja2" },
|
||||||
|
{ name = "python-multipart" },
|
||||||
|
{ name = "uvicorn" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "beautifulsoup4" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "playwright" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "requests" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "argon2-cffi", specifier = "==25.1.0" },
|
||||||
|
{ name = "fastapi", specifier = "==0.141.1" },
|
||||||
|
{ name = "jinja2", specifier = "==3.1.6" },
|
||||||
|
{ name = "python-multipart", specifier = ">=0.0.20" },
|
||||||
|
{ name = "uvicorn", specifier = "==0.52.4" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "beautifulsoup4", specifier = "==4.15.0" },
|
||||||
|
{ name = "httpx", specifier = "==0.28.1" },
|
||||||
|
{ name = "playwright", specifier = "==1.63.0" },
|
||||||
|
{ name = "pytest", specifier = "==9.1.1" },
|
||||||
|
{ name = "requests", specifier = "==2.34.2" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "playwright"
|
||||||
|
version = "1.63.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "greenlet" },
|
||||||
|
{ name = "pyee" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/dd/fbb3d34228ad753bc14464d5f2252585b73367539775a88d0a3949cf5a45/playwright-1.63.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:84c540759e8e7f01e690e197e04060f48899d37cea322299f255843273d3385d", size = 44278407, upload-time = "2026-09-15T16:49:06.045Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/9a/948b930b1a8c4ee869e5a139a2b7747caa06aab56a3f09a2f0abdcbda221/playwright-1.63.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fd1aa00631d44d55e56e0975bf3f3f285fac4a9fd2813183f0c12a488f1a1b24", size = 42944679, upload-time = "2026-09-15T16:49:10.039Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/11/dc5c13fa1602371603acd461be47529c1b3513815d3a0dc98f642c291a10/playwright-1.63.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:c89fc4736502a1f0fac2c8ca5d10c0cbc1c669f1f4774a2d8507a43140e4d53f", size = 44278410, upload-time = "2026-09-15T16:49:13.861Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/9c/103a5037789062bdab27c7dca53f3ca6b075b572ab2cd96eec825b3aec4e/playwright-1.63.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ad21bc07516b187965a7521c5cf0df0bd657b17482eaad74335272d35a2b07de", size = 48217159, upload-time = "2026-09-15T16:49:17.404Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/82/3d85505284c5a210f2da6c07b8f757524e79d1fba9cfdafe1eafb766ae59/playwright-1.63.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:354e15b29503565fc598b89f16fbe070459343bef9d7498a93e304864000c6a7", size = 47902708, upload-time = "2026-09-15T16:49:21.055Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/8d/f74ff6b52751859f4b69caf66aa7d4a3c8d6c1f0c7dc9920da103612ee39/playwright-1.63.0-py3-none-win32.whl", hash = "sha256:660c00c62639e31b16700ba5456b351ddba55bb766b7ce8261223aa34928e482", size = 38606075, upload-time = "2026-09-15T16:49:29.546Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/eb/d6b8d92658038e260dbc7dd69fb3fdbe245aac283953de8b50b02bfe5f61/playwright-1.63.0-py3-none-win_amd64.whl", hash = "sha256:2f9a707a6c6c91157ed77bff2b8caeb04b3c8d46e70d585fc134298cbe4b5cc6", size = 38606082, upload-time = "2026-09-15T16:49:32.838Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/75/2432b3e3c7c62103b72d4c4cc8b16a56383ada372bbb0d1278591e988f71/playwright-1.63.0-py3-none-win_arm64.whl", hash = "sha256:1e4a3a838ce22fb68ad17193fcd142a19610d9d70fb9966d2239f5dddc0cc05b", size = 34523554, upload-time = "2026-09-15T16:49:36.455Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycparser"
|
||||||
|
version = "3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic"
|
||||||
|
version = "2.13.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-types" },
|
||||||
|
{ name = "pydantic-core" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.46.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyee"
|
||||||
|
version = "13.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.21.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-multipart"
|
||||||
|
version = "0.0.32"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests"
|
||||||
|
version = "2.34.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "soupsieve"
|
||||||
|
version = "2.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/71/c3/1b817965ac12dc002d7c9cd7dfffdd7d4fbf9b45763ef2ffe7b86ee94670/soupsieve-2.10.tar.gz", hash = "sha256:49e9380d7d2905463583bafe285e818c7366a9ed7b3aee221c1ac79c905d8bc0", size = 124337, upload-time = "2026-09-24T02:36:35.048Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/87/5ed59e1d0290564e2027ed066c52b059892c4637741337fc0967183c8d4d/soupsieve-2.10-py3-none-any.whl", hash = "sha256:8596eb8967d744174820280fa62b4542a2e955bfaccca73ed8a13c6eb8e9b502", size = 38019, upload-time = "2026-09-24T02:36:33.709Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "starlette"
|
||||||
|
version = "1.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-inspection"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvicorn"
|
||||||
|
version = "0.52.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user