Обновил структуру проекта
This commit is contained in:
@@ -1,167 +1,102 @@
|
||||
# Parser Lab
|
||||
|
||||
Учебный сайт для сбора данных: преподаватель импортирует CSV, JSON или JSONL, выбирает оформление и публикует две версии страниц — обычный HTML с пагинацией и HTML с динамической дозагрузкой. Приложение работает на FastAPI, Jinja2, HTML, CSS и небольших JavaScript-модулях без UI-фреймворков. Один аккаунт преподавателя; студентам вход не нужен.
|
||||
Parser Lab — сайт для учебных заданий по веб-парсингу. Преподаватель загружает CSV, JSON или JSONL, настраивает отображение данных и публикует страницы для студентов. Каждая коллекция доступна в двух вариантах: обычный HTML с пагинацией и страница с динамической дозагрузкой.
|
||||
|
||||
## Быстрый запуск через Docker
|
||||
Есть четыре представления: таблица, карточки, сообщения и анкеты. Поля в списке и в раскрытой записи можно настраивать отдельно. Для преподавателя предусмотрен один аккаунт; публичные страницы открываются без входа.
|
||||
|
||||
Нужны Docker Engine с Compose либо Docker Desktop. Запускайте команды из папки проекта.
|
||||
Приложение написано на Python 3.12, FastAPI и Jinja2. Интерфейс — HTML, CSS и JavaScript, данные хранятся в SQLite. Отдельный сервер базы данных не нужен.
|
||||
|
||||
1. Скопируйте `.env.example` в `.env` и соберите образ:
|
||||
## Запуск через Docker Compose
|
||||
|
||||
```sh
|
||||
docker compose build
|
||||
```
|
||||
Нужен Docker с командой `docker compose`: Docker Engine с Compose plugin или Docker Desktop. Python и uv на хосте не требуются. Все команды ниже выполняются из корня репозитория. Примеры рассчитаны на Bash; в Windows можно использовать WSL2.
|
||||
|
||||
2. Укажите логин и обычный пароль (не менее 10 символов) в `.env`:
|
||||
### 1. Подготовьте конфигурацию и соберите образ
|
||||
|
||||
```dotenv
|
||||
LAB_ADMIN_USER=teacher
|
||||
LAB_ADMIN_PASSWORD='ваш-пароль-для-входа'
|
||||
```
|
||||
При первом запуске создайте `.env` из шаблона:
|
||||
|
||||
Одинарные кавычки сохраняют символы `$`, пробелы и `#`. Если пароль содержит кавычки или обратные слеши, используйте `uv run --locked python -m parser_lab.cli init-env` для создания корректного `.env` вместо ручного копирования шаблона.
|
||||
```sh
|
||||
cp .env.example .env
|
||||
docker compose build
|
||||
```
|
||||
|
||||
3. Создайте случайный секрет:
|
||||
Если `.env` уже настроен, повторно копировать шаблон не нужно.
|
||||
|
||||
```sh
|
||||
docker compose run --rm --no-deps app uv run --no-sync python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
```
|
||||
### 2. Задайте пароль и секрет
|
||||
|
||||
Поместите значение в `LAB_SECRET`. Не используйте пароль как секрет.
|
||||
Сгенерируйте секрет для сессий:
|
||||
|
||||
4. Для локальной проверки оставьте `LAB_ORIGINS=http://localhost:8080` и `LAB_SECURE_COOKIE=false`. Запустите:
|
||||
```sh
|
||||
docker run --rm parser-lab:1.0 python -c 'import secrets; print(secrets.token_urlsafe(48))'
|
||||
```
|
||||
|
||||
```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`. Установите:
|
||||
Откройте `.env`. Замените значения `LAB_ADMIN_PASSWORD` и `LAB_SECRET` своими:
|
||||
|
||||
```dotenv
|
||||
LAB_ORIGINS=https://lab.example.org
|
||||
LAB_SECURE_COOKIE=true
|
||||
LAB_ADMIN_USER=teacher
|
||||
LAB_ADMIN_PASSWORD='ЗАМЕНИТЕ_НА_СВОЙ_ПАРОЛЬ'
|
||||
LAB_SECRET=ВСТАВЬТЕ_СГЕНЕРИРОВАННЫЙ_СЕКРЕТ
|
||||
LAB_ORIGINS=http://localhost:8080
|
||||
LAB_SECURE_COOKIE=false
|
||||
LAB_PORT=8080
|
||||
```
|
||||
|
||||
Передайте домен на reverse proxy с HTTPS. Пример nginx, если приложение доступно прокси на порту 8080:
|
||||
Пароль должен содержать не менее 10 символов, секрет — не менее 32. Это разные значения. В `.env` пароль хранится обычным текстом; файл исключён из Git и сборки образа.
|
||||
|
||||
```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;
|
||||
}
|
||||
```
|
||||
Одинарные кавычки сохраняют `$`, `#` и пробелы в пароле. Правила экранирования описаны в [документации Compose](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/#env-file-syntax). При локальной установке файл можно создать интерактивной командой `init-env`, приведённой ниже.
|
||||
|
||||
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/` и не доступны как файлы через публичный сайт:
|
||||
### 3. Запустите приложение
|
||||
|
||||
```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
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Перед обновлением остановите контейнер и скопируйте весь каталог данных:
|
||||
Кабинет преподавателя: [http://localhost:8080/admin](http://localhost:8080/admin). Войдите с логином и паролем из `.env`.
|
||||
|
||||
## Запуск через Docker без Compose
|
||||
|
||||
Соберите тот же образ:
|
||||
|
||||
```sh
|
||||
docker compose stop app
|
||||
docker compose cp app:/data ./backup-data
|
||||
docker compose start app
|
||||
docker build -t parser-lab:1.0 .
|
||||
```
|
||||
|
||||
Храните копию вместе с защищённой копией `.env`. Для восстановления подготовьте отдельный Compose-проект с новым томом, создайте его контейнер (`docker compose create`), скопируйте содержимое backup-data в `/data`, восстановите владельца UID 10001 при необходимости и запустите контейнер. Проверяйте восстановление отдельно от рабочего сайта.
|
||||
Создайте и заполните `.env`, как описано выше. Команда генерации секрета также работает без Compose.
|
||||
|
||||
Импортированные файлы и старые снимки не удаляются автоматически. Следите за размером тома. Безвозвратное удаление истории не входит в интерфейс первой версии.
|
||||
Создайте том и запустите контейнер:
|
||||
|
||||
## Локальная разработка
|
||||
```sh
|
||||
docker volume create parser-lab-data
|
||||
|
||||
Нужен [uv](https://docs.astral.sh/uv/getting-started/installation/) версии 0.8.14 или новее. Python 3.12 и окружение `.venv` управляются через uv. Запускайте команды из корня проекта:
|
||||
docker run -d \
|
||||
--name parser-lab \
|
||||
--restart unless-stopped \
|
||||
--stop-timeout 30 \
|
||||
-p 8080:8080 \
|
||||
--mount type=volume,source=parser-lab-data,target=/data \
|
||||
--mount "type=bind,source=$(pwd)/.env,target=/app/.env,readonly" \
|
||||
parser-lab:1.0 \
|
||||
uv run --no-sync --env-file /app/.env python -m parser_lab
|
||||
```
|
||||
|
||||
Здесь `.env` подключается [как файл только для чтения](https://docs.docker.com/engine/storage/bind-mounts/) и загружается через uv. Так сохраняется обработка кавычек в пароле. Файл должен существовать и быть доступен на чтение пользователю контейнера с UID 10001.
|
||||
|
||||
Адрес кабинета тот же: [http://localhost:8080/admin](http://localhost:8080/admin). Управление контейнером:
|
||||
|
||||
```sh
|
||||
docker stop parser-lab
|
||||
docker start parser-lab
|
||||
```
|
||||
|
||||
При таком запуске `LAB_PORT` не используется: внешний порт задаётся параметром `-p`. Для порта 8087 укажите `-p 8087:8080` и `LAB_ORIGINS=http://localhost:8087` в `.env`.
|
||||
|
||||
Том `parser-lab-data` из этого примера и том Compose — разные хранилища. Данные между ними автоматически не переносятся.
|
||||
|
||||
## Локальный запуск без Docker
|
||||
|
||||
Установите [uv](https://docs.astral.sh/uv/getting-started/installation/) версии 0.8.14 или новее. Проект использует Python 3.12; uv может установить его и создать виртуальное окружение.
|
||||
|
||||
Из корня репозитория выполните:
|
||||
|
||||
```sh
|
||||
uv sync --locked
|
||||
@@ -169,21 +104,190 @@ uv run --locked python -m parser_lab.cli init-env
|
||||
uv run --locked --env-file .env python -m parser_lab
|
||||
```
|
||||
|
||||
`init-env` один раз создаёт `.env` и запрашивает параметры преподавателя. При последующих запусках достаточно последней команды. Активация окружения не требуется.
|
||||
`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`.
|
||||
Кабинет доступен по адресу [http://localhost:8080/admin](http://localhost:8080/admin). Для остановки нажмите `Ctrl+C`. При следующих запусках достаточно последней команды; активировать `.venv` вручную не нужно.
|
||||
|
||||
Параметры окружения имеют приоритет над локальной `.env`. Пароль LAB_ADMIN_PASSWORD и секрет LAB_SECRET обязательны, приложение откажется запускаться без них. После изменения пароля или секрета перезапустите приложение (для Docker: `docker compose up -d --force-recreate`); старые сессии станут недействительны.
|
||||
По умолчанию данные сохраняются в `./data`. Каталог создаётся автоматически. [Параметры окружения имеют приоритет над значениями из `.env`](https://docs.astral.sh/uv/concepts/configuration-files/#env).
|
||||
|
||||
Тесты:
|
||||
Для разработки с перезапуском при изменении кода:
|
||||
|
||||
```sh
|
||||
uv run --locked pytest -q
|
||||
# Дополнительные браузерные тесты: LAB_BROWSER_TESTS=1, при необходимости PLAYWRIGHT_CHANNEL=chrome.
|
||||
uv run --locked --env-file .env uvicorn parser_lab.app:create_app \
|
||||
--factory --host 127.0.0.1 --port 8080 --reload
|
||||
```
|
||||
|
||||
Старые документы проектирования в `docs/` сохранены как история. Актуальная архитектура — FastAPI + Jinja2 + HTML/CSS/JavaScript; зависимости Rio полностью удалены. Существующие SQLite-данные и опубликованные ссылки совместимы с новой версией. Фактические результаты проверки и ограничения среды — в `docs/verification.md`.
|
||||
При обычном запуске через `python -m parser_lab` адрес сервера — `0.0.0.0:8080`. Переменная `LAB_PORT` его не меняет. Если нужен другой порт, используйте команду uvicorn с нужным `--port` и измените `LAB_ORIGINS`.
|
||||
|
||||
Справка: [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-образа.
|
||||
| Переменная | Назначение |
|
||||
| --- | --- |
|
||||
| `LAB_ADMIN_USER` | Логин преподавателя. Обязателен. |
|
||||
| `LAB_ADMIN_PASSWORD` | Пароль, не менее 10 символов. Обязателен. |
|
||||
| `LAB_SECRET` | Случайный секрет сессий, не менее 32 символов. Обязателен. |
|
||||
| `LAB_ORIGINS` | Разрешённые адреса сайта через запятую, включая протокол и порт. По умолчанию `http://localhost:8080`. |
|
||||
| `LAB_SECURE_COOKIE` | `false` для локального HTTP, `true` для HTTPS. Если не задана, используется `true`. |
|
||||
| `LAB_PORT` | Внешний порт в Compose. По умолчанию 8080. |
|
||||
| `LAB_DATA_DIR` | Каталог данных. По умолчанию `./data`, в Docker — `/data`. Compose задаёт `/data` явно. |
|
||||
| `LAB_MAX_BYTES` | Максимальный размер загружаемого файла. По умолчанию 26214400 байт, то есть 25 МиБ. |
|
||||
| `LAB_MAX_RECORDS` | Максимальное число записей в импорте. По умолчанию 50000. |
|
||||
|
||||
`localhost` и `127.0.0.1` считаются разными адресами. Если используете оба, добавьте оба в `LAB_ORIGINS`:
|
||||
|
||||
```dotenv
|
||||
LAB_ORIGINS=http://localhost:8080,http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
После изменения `.env` перезапустите локальный процесс или пересоздайте контейнер Compose:
|
||||
|
||||
```sh
|
||||
docker compose up -d --force-recreate
|
||||
```
|
||||
|
||||
Для Docker без Compose остановите и удалите контейнер, затем повторите команду `docker run` с тем же томом. Это также заново подключит изменённый `.env`. После смены логина, пароля или секрета прежние сессии перестанут действовать.
|
||||
|
||||
## Развёртывание на сервере
|
||||
|
||||
Приложение рассчитано на один экземпляр и размещение в корне отдельного домена, например `https://lab.example.org`. Запуск в подкаталоге вроде `/parser/` не поддерживается.
|
||||
|
||||
1. Настройте DNS домена на сервер и HTTPS в обратном прокси.
|
||||
2. Запустите приложение одним из способов выше.
|
||||
3. Укажите публичный адрес в `.env`:
|
||||
|
||||
```dotenv
|
||||
LAB_ORIGINS=https://lab.example.org
|
||||
LAB_SECURE_COOKIE=true
|
||||
```
|
||||
|
||||
4. Если nginx работает на том же хосте, ограничьте публикацию порта адресом `127.0.0.1`. В `compose.yaml` замените строку в `ports` на:
|
||||
|
||||
```yaml
|
||||
- "127.0.0.1:${LAB_PORT:-8080}:8080"
|
||||
```
|
||||
|
||||
Для обычного Docker используйте `-p 127.0.0.1:8080:8080`. Для локального процесса — uvicorn с `--host 127.0.0.1`.
|
||||
|
||||
5. Добавьте в HTTPS-блок `server` nginx:
|
||||
|
||||
```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;
|
||||
}
|
||||
```
|
||||
|
||||
6. Примените конфигурацию nginx и перезапустите приложение. Проверьте `/healthz`, вход в кабинет и загрузку небольшого файла через домен.
|
||||
|
||||
Этот фрагмент предполагает уже настроенный TLS-сертификат. Если прокси работает в другом контейнере, подключите оба сервиса к общей сети и укажите адрес приложения в ней: `127.0.0.1` внутри контейнера прокси указывает на сам прокси.
|
||||
|
||||
## Работа с коллекциями
|
||||
|
||||
1. Войдите в кабинет и нажмите «Загрузить данные».
|
||||
2. Выберите файл. Для CSV при необходимости укажите кодировку и разделитель; для вложенного JSON — список, из которого нужно брать записи.
|
||||
3. Нажмите «Проверить файл», проверьте первые строки и сохраните импорт.
|
||||
4. Включите нужное представление, выберите поля, подписи и порядок. Для карточек назначьте заголовок, для сообщений — текст.
|
||||
5. Чтобы раскрытая запись содержала больше информации, выберите «Настройка полей → В полной записи» и включите «Отдельные поля полной записи».
|
||||
6. Проверьте результат через «Предпросмотр данных», сохраните черновик и опубликуйте новую версию. Ссылки появятся в разделе «Публикации».
|
||||
|
||||
Предпросмотр показывает текущие изменения. Ссылка «Предпросмотр» открывает сохранённый черновик. Публикации хранятся отдельно: редактирование черновика не меняет страницы, которые уже доступны студентам. Новая публикация получает новый номер и новые ссылки; прежние работают, пока версия не снята с публикации.
|
||||
|
||||
Замена исходного файла сбрасывает настройки представлений. После первой публикации адрес коллекции изменить нельзя.
|
||||
|
||||
Кнопка «Удалить коллекцию» удаляет черновик, все публикации и связанные исходные файлы. Действие требует подтверждения и не имеет отмены. Если файл не удалось удалить, приложение сообщит об этом и повторит очистку при следующем запуске. Промежуточные исходники от замен в старых версиях могли потерять связь с коллекцией; такие файлы автоматически не удаляются.
|
||||
|
||||
### Форматы данных
|
||||
|
||||
| Формат | Требования |
|
||||
| --- | --- |
|
||||
| CSV | Первая строка — уникальные непустые заголовки. Кодировка UTF-8 или Windows-1251. Разделитель — запятая, точка с запятой или табуляция. |
|
||||
| JSON | Массив объектов, один объект или выбранный вложенный массив объектов. |
|
||||
| JSONL | Один объект JSON на строку, кодировка UTF-8. Пустые строки пропускаются. |
|
||||
|
||||
Порядок записей и дубликаты сохраняются. Значения CSV остаются строками, в том числе числа с ведущими нулями. В JSON различаются отсутствующее поле, `null`, пустая строка, `false` и `0`. Максимальная глубина JSON — 20. Ошибки формата блокируют импорт; пустую коллекцию опубликовать нельзя.
|
||||
|
||||
Вложенные поля обозначаются путями: `/profile/age` — поле `age` внутри `profile`. Массивы вопросов с полями `question` и `answer` приложение умеет распознавать как анкеты. Для неоднозначной структуры уровень записей и поля можно выбрать вручную. Сайт отображает загруженные ответы, но не собирает новые.
|
||||
|
||||
## Обновление и резервные копии
|
||||
|
||||
SQLite и исходные файлы находятся в одном каталоге. Локально это `data/`, в контейнере — `/data`. Compose сохраняет его в томе с именем вида `<имя-проекта>_lab-data`.
|
||||
|
||||
Перед обновлением остановите приложение и скопируйте весь каталог данных. Для каждой копии используйте новый каталог. Пример для Compose:
|
||||
|
||||
```sh
|
||||
mkdir -p backups/before-update
|
||||
docker compose stop app
|
||||
docker compose cp app:/data/. ./backups/before-update/
|
||||
docker compose start app
|
||||
```
|
||||
|
||||
Для обычного Docker аналогичные команды — `docker stop parser-lab`, `docker cp parser-lab:/data/. ./backups/before-update/` и `docker start parser-lab`. При локальном запуске остановите процесс и скопируйте `data/`. Отдельно сохраните `.env` в защищённом месте.
|
||||
|
||||
После получения новой версии исходного кода обновите Compose-развёртывание:
|
||||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Для Docker без Compose пересоберите образ, остановите и удалите контейнер, затем повторите команду `docker run`, сохранив прежний том `parser-lab-data`. Локально выполните `uv sync --locked` и перезапустите приложение.
|
||||
|
||||
Восстановление копии в **новую установку Compose с пустым томом**:
|
||||
|
||||
```sh
|
||||
docker compose create app
|
||||
docker compose cp ./backups/before-update/. app:/data/
|
||||
docker compose run --rm --no-deps --user root app chown -R 10001:10001 /data
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Перед этим подготовьте образ и `.env`. После восстановления проверьте вход в кабинет и открытие опубликованной коллекции.
|
||||
|
||||
## Для разработки и учебных заданий
|
||||
|
||||
Основные файлы проекта:
|
||||
|
||||
```text
|
||||
src/parser_lab/
|
||||
app.py HTTP-маршруты и запуск приложения
|
||||
admin.py Импорт и настройки коллекций
|
||||
repository.py SQLite, исходные файлы и публикации
|
||||
importing.py Чтение CSV, JSON и JSONL
|
||||
views.py Выбор полей и подготовка записей
|
||||
web/templates/ Шаблоны страниц
|
||||
web/static/ Стили и JavaScript
|
||||
examples/parsers/ Примеры парсеров
|
||||
tests/ Проверки приложения
|
||||
```
|
||||
|
||||
Установка зависимостей и запуск тестов:
|
||||
|
||||
```sh
|
||||
uv sync --locked
|
||||
uv run --locked pytest -q
|
||||
```
|
||||
|
||||
Браузерные тесты запускаются отдельно:
|
||||
|
||||
```sh
|
||||
uv run --locked playwright install chromium
|
||||
LAB_BROWSER_TESTS=1 uv run --locked pytest -q tests/test_browser.py
|
||||
```
|
||||
|
||||
При использовании установленного Chrome задайте `PLAYWRIGHT_CHANNEL=chrome`. Фикстура браузерных тестов также поддерживает `PLAYWRIGHT_EXECUTABLE_PATH` для явного пути к браузеру. Несколько тестов анкет ожидают файл `examples/data/questionnaires.json`; если его нет в рабочей копии, эти проверки завершатся с `FileNotFoundError`.
|
||||
|
||||
Примеры парсеров принимают ссылку на опубликованную коллекцию:
|
||||
|
||||
```sh
|
||||
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
|
||||
```
|
||||
|
||||
Замените `SLUG` и номер версии значениями из ссылки в кабинете. Для второго примера нужен браузер Playwright; установленный Chrome можно выбрать аргументом `--channel chrome`.
|
||||
|
||||
В HTML записи доступны по селектору `.record[data-record-id]`, значения — `.field[data-field]`. Ссылка `.detail-link` открывает полную запись, `a[rel=next]` ведёт на следующую страницу. Атрибуты `data-label`, `data-value-state` и `data-value-type` содержат подпись, состояние и тип значения. В динамическом варианте первые записи тоже находятся в HTML; остальные загружаются кнопкой «Загрузить ещё».
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
> Архив первоначального проекта. С 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). Архитектура этого документа является проектным предложением, а не утверждением о готовности перечисленных функций в старом приложении.
|
||||
@@ -1,210 +0,0 @@
|
||||
> Архив первоначального проекта. С 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-критерий выполненным.
|
||||
|
||||
## Контроль перед завершением
|
||||
|
||||
- [ ] Сопоставить реализованные функции со всеми критериями спецификации, включая отсутствие утечек полей и сохранность версий.
|
||||
- [ ] Проверить весь проект независимым обзором после реализации; устранить подтверждённые дефекты и повторить затронутые проверки.
|
||||
- [ ] Удалить только собственные временные тестовые данные из поставки, остановить временные процессы, сохранить пользовательские данные.
|
||||
- [ ] Передать путь к проекту, инструкцию запуска, проверенные команды и известные ограничения. Публикацию на внешний сервер выполнять только по отдельному запросу.
|
||||
|
||||
## Порядок исполнения
|
||||
|
||||
Задачи выполняются последовательно в этой беседе. Тесно связанные интерфейсы импорта, представлений и публикаций делают этот вариант предпочтительным для первой реализации. Делегирование отдельным агентам возможно по выбору пользователя; оно не требуется для корректного выполнения плана.
|
||||
|
||||
План подготовлен для проверки перед началом реализации. Во время исполнения чекбоксы и результаты обновляются по фактическим проверкам, а не по наличию файлов.
|
||||
@@ -1,50 +0,0 @@
|
||||
# Проверка 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-проверки подтверждают независимость распознавания от имени файла, отказ от автоматического выбора при нескольких подходящих массивах, сохранение опубликованного снимка и исходника при исправлении, а также защиту от устаревшей ревизии. При неизвестной структуре остаётся ручной выбор полей.
|
||||
+13
-2
@@ -25,7 +25,9 @@ def config_from_payload(draft,payload):
|
||||
view=default_view(kind,[])
|
||||
for key,value in source.items():
|
||||
if key not in view or key=='kind':continue
|
||||
if key=='columns':
|
||||
if key in ('columns','detail_columns'):
|
||||
if key=='detail_columns' and value is None:
|
||||
view[key]=None;continue
|
||||
if not isinstance(value,list) or len(value)>1000:raise ValueError('Некорректный список полей')
|
||||
columns=[]
|
||||
for col in value:
|
||||
@@ -143,12 +145,21 @@ def install_admin(app,auth,repo,settings):
|
||||
d=config_from_payload(repo.get(ident),await request.json())
|
||||
return {'revision':repo.save(d,d['revision'])['revision']}
|
||||
|
||||
@app.delete('/api/admin/datasets/{ident}')
|
||||
async def delete_dataset(request:Request,ident:str):
|
||||
payload=await request.json()
|
||||
if not isinstance(payload,dict) or type(payload.get('revision')) is not int:raise ValueError('Некорректная версия черновика')
|
||||
cleaned=repo.delete(ident,payload['revision'])
|
||||
return {'ok':True,'warning':'' if cleaned else 'Коллекция и публикации удалены, но часть исходных файлов пока не удалось удалить. Проверьте права доступа к каталогу данных. Очистка повторится при следующем запуске приложения.'}
|
||||
|
||||
@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)}
|
||||
detail=request.query_params.get('detail')=='1'
|
||||
rows=d['records'][:1 if detail else 5]
|
||||
return {'errors':errors,'html':'' if errors else records_fragment([project_record(r,view,str(i+1),detail=detail) for i,r in enumerate(rows)],kind,detail=detail)}
|
||||
|
||||
@app.get('/api/admin/datasets/{ident}/fields')
|
||||
async def nested_fields(request:Request,ident:str):
|
||||
|
||||
@@ -25,8 +25,10 @@ class Repository:
|
||||
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 pending_source_deletions(source TEXT PRIMARY KEY);
|
||||
CREATE TABLE IF NOT EXISTS sessions(token TEXT PRIMARY KEY,expires REAL NOT NULL,fingerprint TEXT NOT NULL);
|
||||
''')
|
||||
self.cleanup_sources()
|
||||
|
||||
@contextmanager
|
||||
def connect(self):
|
||||
@@ -42,7 +44,7 @@ class Repository:
|
||||
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()}
|
||||
'records':records,'views':{},'source':ident,'sources':[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:
|
||||
@@ -81,10 +83,44 @@ class Repository:
|
||||
name=uuid.uuid4().hex
|
||||
path=self.root/'sources'/name
|
||||
path.write_bytes(source)
|
||||
d['sources']=list(dict.fromkeys([*d.get('sources',[]),d['id'],d['source'],name]))
|
||||
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 delete(self,ident,expected_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']!=expected_revision: raise Conflict('Набор изменён в другой вкладке. Откройте его заново.')
|
||||
sources=set(d.get('sources',[])) | {d['id'],d['source']}
|
||||
# Source names are generated UUIDs; never use client paths for deletion.
|
||||
if any(not re.fullmatch('[0-9a-f]{32}',name) for name in sources):
|
||||
raise ValueError('Некорректное имя исходного файла')
|
||||
db.execute('DELETE FROM publications WHERE slug=?',(d['slug'],))
|
||||
db.execute('DELETE FROM datasets WHERE id=?',(ident,))
|
||||
db.executemany('INSERT OR IGNORE INTO pending_source_deletions VALUES (?)',[(name,) for name in sources])
|
||||
return self.cleanup_sources(sources)
|
||||
|
||||
def cleanup_sources(self,sources=None):
|
||||
# Commit collection removal first. Failed file cleanup remains durable and
|
||||
# is retried on startup, without resurrecting a partially deleted dataset.
|
||||
complete=True
|
||||
with self.connect() as db:
|
||||
db.execute('BEGIN IMMEDIATE')
|
||||
names=[row[0] for row in db.execute('SELECT source FROM pending_source_deletions')]
|
||||
for name in names:
|
||||
if sources is not None and name not in sources: continue
|
||||
if not re.fullmatch('[0-9a-f]{32}',name):
|
||||
complete=False;continue
|
||||
try: (self.root/'sources'/name).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
complete=False;continue
|
||||
db.execute('DELETE FROM pending_source_deletions WHERE source=?',(name,))
|
||||
return complete
|
||||
|
||||
def publish(self,ident,revision):
|
||||
with self.connect() as db:
|
||||
db.execute('BEGIN IMMEDIATE')
|
||||
|
||||
+12
-4
@@ -5,7 +5,7 @@ KINDS = {'table':'Таблица', 'cards':'Карточки', 'messages':'Со
|
||||
|
||||
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',
|
||||
'detail_columns':None, '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':''}
|
||||
|
||||
@@ -15,6 +15,11 @@ def validate_view(view, records):
|
||||
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('Выберите хотя бы одно поле')
|
||||
detail_columns=view.get('detail_columns')
|
||||
if detail_columns is not None:
|
||||
if not detail_columns and not (view.get('kind')=='questionnaire' and view.get('questionnaire_mode')=='pairs'):
|
||||
errors.append('Выберите хотя бы одно поле для полной записи')
|
||||
pointers += [c['pointer'] for c in detail_columns]
|
||||
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')
|
||||
@@ -50,11 +55,14 @@ def display_field(record, pointer, label):
|
||||
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']]
|
||||
def project_record(record, view, record_id, detail=False):
|
||||
separate_detail=detail and view.get('detail_columns') is not None
|
||||
columns=view['detail_columns'] if separate_detail else view['columns']
|
||||
fields=[display_field(record,c['pointer'],c['label']) for c in 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 []
|
||||
answer_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 []
|
||||
fields=(fields if separate_detail else [])+answer_fields
|
||||
roles={}
|
||||
for role in ('title','text','author','date'):
|
||||
if view.get(role+'_pointer'):
|
||||
|
||||
@@ -19,7 +19,7 @@ def public_page(publication,kind,base,page=1,search='',category=None,record_id=N
|
||||
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}
|
||||
result={'items':[project_record(publication['records'][int(record_id)-1],view,record_id,detail=True)],'total':1,'page':1,'has_next':False}
|
||||
else: result=select_records(publication['records'],view,page,search,category)
|
||||
def url(n):
|
||||
params={'page':n}
|
||||
|
||||
@@ -45,13 +45,13 @@
|
||||
}
|
||||
const source=$('#editor-data');
|
||||
if(source) {
|
||||
const data=JSON.parse(source.textContent),kind=$('#kind'),enabled=$('#enabled'),controls=$('#view-controls');
|
||||
const data=JSON.parse(source.textContent),kind=$('#kind'),enabled=$('#enabled'),controls=$('#view-controls'),fieldScope=$('#fields-scope'),previewMode=$('#preview-mode');
|
||||
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;
|
||||
async function preview(){const seq=++previewSequence,detail=previewMode.value==='detail';$('#preview-count').textContent=detail?'Первая запись':'Первые 5 записей';$('#preview-kind').textContent=kind.selectedOptions[0].text;$('#full-preview').href=`/preview/${data.id}/${kind.value}${detail?'/records/1':''}`;
|
||||
try{const result=await api(`/api/admin/datasets/${data.id}/preview?kind=${kind.value}&detail=${detail?'1':'0'}`,'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);}
|
||||
@@ -75,22 +75,38 @@
|
||||
}).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)});
|
||||
const pairs=kind.value==='questionnaire'&&view.questionnaire_mode==='pairs';
|
||||
const detail=fieldScope.value==='detail';
|
||||
if(pairs)controls.append(node('p','Подписи вопросов и ответы берутся из выбранного массива. В полной записи можно добавить сведения об участнике.',{className:'muted'}));
|
||||
if(detail){
|
||||
const row=node('label',null,{className:'check-label'}),check=node('input',null,{type:'checkbox',checked:view.detail_columns!=null});
|
||||
row.append(check,node('span','Отдельные поля полной записи'));controls.append(row);
|
||||
check.onchange=()=>{view.detail_columns=check.checked?structuredClone(pairs?[]:view.columns):null;changed();renderControls();};
|
||||
if(view.detail_columns==null){controls.append(node('p','Сейчас используются те же поля, что и в списке. Включите отдельные поля, чтобы добавить больше информации.',{className:'muted'}));preview();return;}
|
||||
}else if(pairs){preview();return;}
|
||||
renderFields(view,detail?'detail_columns':'columns',detail?'Поля полной записи':'Поля таблицы / списка');
|
||||
preview();
|
||||
}
|
||||
function renderFields(view,key,title){
|
||||
controls.append(node('h2',title));
|
||||
const all=node('button','Выбрать все поля',{type:'button',className:'secondary'});
|
||||
all.onclick=()=>{for(const field of data.fields)if(!view[key].some(c=>c.pointer===field.pointer))view[key].push({pointer:field.pointer,label:field.label});changed();renderControls();};controls.append(all);
|
||||
for(const field of data.fields){const row=node('label',null,{className:'check-label'}),check=node('input',null,{type:'checkbox',checked:view[key].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);}
|
||||
check.onchange=()=>{if(check.checked)view[key].push({pointer:field.pointer,label:field.label});else view[key]=view[key].filter(c=>c.pointer!==field.pointer);changed();renderControls();};controls.append(row);}
|
||||
controls.append(node('h3','Порядок и подписи'));
|
||||
view.columns.forEach((column,i)=>{
|
||||
view[key].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();
|
||||
for(const [text,delta] of [['↑',-1],['↓',1]]){const target=i+delta,b=node('button',text,{type:'button',className:'secondary',disabled:target<0||target>=view[key].length});b.setAttribute('aria-label',delta<0?'Переместить поле выше':'Переместить поле ниже');b.onclick=()=>{[view[key][i],view[key][target]]=[view[key][target],view[key][i]];changed();renderControls();};row.append(b);}controls.append(row);
|
||||
});
|
||||
}
|
||||
fieldScope.onchange=()=>{previewMode.value=fieldScope.value;renderControls();};
|
||||
previewMode.onchange=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?'Черновик сохранён. Более поздние изменения ещё не сохранены.':'Черновик сохранён');}
|
||||
$('#save-draft').onclick=async()=>{const button=$('#save-draft'),savedSequence=editSequence;button.disabled=true;clearTimeout(previewTimer);previewSequence++;
|
||||
try{const result=await api(`/api/admin/datasets/${data.id}`,'PUT',payload());data.revision=result.revision;clearTimeout(previewTimer);previewSequence++;preview();document.querySelectorAll('[data-delete]').forEach(b=>{b.dataset.revision=String(result.revision);b.dataset.title=$('#title').value;});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=>{
|
||||
@@ -102,6 +118,12 @@
|
||||
$('#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();
|
||||
}
|
||||
document.querySelectorAll('[data-delete]').forEach(button=>button.onclick=async()=>{
|
||||
if(!confirm(`Удалить коллекцию «${button.dataset.title}»? Будут удалены данные, связанные исходные файлы и все опубликованные версии. Ссылки перестанут работать. Отменить удаление нельзя.`))return;
|
||||
button.disabled=true;
|
||||
try{const result=await api(`/api/admin/datasets/${button.dataset.delete}`,'DELETE',{revision:Number(button.dataset.revision)});if(result.warning)alert(result.warning);dirty=false;location.href='/admin';}
|
||||
catch(error){show(error.message,true);button.disabled=false;}
|
||||
});
|
||||
$('#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;}});
|
||||
})();
|
||||
|
||||
@@ -63,3 +63,5 @@ html.static-site{scrollbar-gutter:stable}
|
||||
@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}
|
||||
|
||||
button.danger{color:#a12626;border-color:#d7a5a5;background:#fff}button.danger:hover{background:#fff1f1}.preview-mode{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:14px;font-size:12px}.preview-mode select{max-width:260px}.dataset-table .danger{margin-left:16px}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!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>
|
||||
<!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=details-delete-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>
|
||||
<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> <button type="button" class="text-button danger" data-delete="{{ d.id }}" data-revision="{{ d.revision }}" data-title="{{ d.title }}" aria-label="Удалить {{ d.title }}">Удалить</button></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>
|
||||
@@ -12,9 +12,9 @@
|
||||
{% 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>
|
||||
<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><label>Настройка полей<select id="fields-scope" aria-label="Настройка полей"><option value="list">В таблице / списке</option><option value="detail">В полной записи</option></select></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><button type="button" class="secondary danger" data-delete="{{ draft.id }}" data-revision="{{ draft.revision }}" data-title="{{ draft.title }}">Удалить коллекцию</button></section></div>
|
||||
<section class="editor-preview-panel"><div class="editor-preview-head"><h2>Предпросмотр изменений</h2><span id="preview-kind"></span></div><label class="preview-mode">Предпросмотр данных<select id="preview-mode" aria-label="Предпросмотр данных"><option value="list">Таблица / список</option><option value="detail">Полная запись</option></select></label><div class="editor-preview" id="live-preview"></div><p class="preview-foot"><span id="preview-count">Первые 5 записей</span> · Опубликованные данные изменятся только после создания новой версии.</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 %}
|
||||
|
||||
+48
-2
@@ -39,7 +39,7 @@ def live(tmp_path_factory):
|
||||
def page():
|
||||
with sync_playwright() as p:
|
||||
channel=os.environ.get('PLAYWRIGHT_CHANNEL')
|
||||
browser=p.chromium.launch(headless=True,**({'channel':channel} if channel else {}))
|
||||
browser=p.chromium.launch(headless=True,executable_path=os.environ.get('PLAYWRIGHT_EXECUTABLE_PATH'),**({'channel':channel} if channel else {}))
|
||||
yield browser.new_page(viewport={'width':1440,'height':1000})
|
||||
browser.close()
|
||||
|
||||
@@ -60,7 +60,7 @@ def test_teacher_import_and_publish(page,live):
|
||||
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()
|
||||
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()
|
||||
@@ -256,3 +256,49 @@ def test_repair_legacy_questionnaire_in_browser(page,live):
|
||||
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()
|
||||
|
||||
|
||||
def test_independent_detail_fields_and_delete_collection(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()
|
||||
page.goto(base + '/admin/import')
|
||||
page.locator('input[type=file]').set_input_files({
|
||||
'name': 'details.csv', 'mimeType': 'text/csv',
|
||||
'buffer': b'name,bio,secret\nAlice,BIOGRAPHY,PRIVATE'})
|
||||
page.get_by_role('button', name='Проверить файл', exact=True).click()
|
||||
expect(page.locator('#import-save')).to_be_enabled()
|
||||
page.locator('#import-save').click()
|
||||
page.get_by_label('Включить это представление').check()
|
||||
page.get_by_label('bio', exact=True).uncheck()
|
||||
page.get_by_label('secret', exact=True).uncheck()
|
||||
expect(page.locator('#live-preview')).not_to_contain_text('BIOGRAPHY')
|
||||
page.get_by_label('Настройка полей').select_option('detail')
|
||||
page.get_by_label('Отдельные поля полной записи').check()
|
||||
page.get_by_label('bio', exact=True).check()
|
||||
expect(page.locator('#live-preview')).to_contain_text('BIOGRAPHY')
|
||||
expect(page.locator('#live-preview')).not_to_contain_text('PRIVATE')
|
||||
page.get_by_role('button', name='Сохранить черновик', exact=True).click()
|
||||
expect(page.locator('#status')).to_have_text('Черновик сохранён')
|
||||
page.screenshot(path=str(folder / 'detail-editor.png'), full_page=True)
|
||||
editor_url = page.url
|
||||
page.reload()
|
||||
page.get_by_label('Предпросмотр данных').select_option('detail')
|
||||
expect(page.locator('#live-preview')).to_contain_text('BIOGRAPHY')
|
||||
page.get_by_role('button', name='Опубликовать новую версию', exact=True).click()
|
||||
public_url = page.get_by_role('link', name='HTML · BeautifulSoup ↗').get_attribute('href')
|
||||
page.goto(base + public_url)
|
||||
expect(page.locator('.lab-records')).not_to_contain_text('BIOGRAPHY')
|
||||
page.locator('.detail-link').click()
|
||||
expect(page.locator('.lab-records')).to_contain_text('BIOGRAPHY')
|
||||
expect(page.locator('.lab-records')).not_to_contain_text('PRIVATE')
|
||||
page.goto(editor_url)
|
||||
page.once('dialog', lambda dialog: dialog.dismiss())
|
||||
page.get_by_role('button', name='Удалить коллекцию', exact=True).click()
|
||||
expect(page).to_have_url(editor_url)
|
||||
page.once('dialog', lambda dialog: dialog.accept())
|
||||
page.get_by_role('button', name='Удалить коллекцию', exact=True).click()
|
||||
expect(page).to_have_url(base + '/admin')
|
||||
assert page.request.get(base + public_url).status == 404
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import copy
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from parser_lab.admin import config_from_payload
|
||||
from parser_lab.repository import Repository, Conflict, NotFound
|
||||
from parser_lab.views import default_view, project_record, validate_view
|
||||
from parser_lab.web import public_page
|
||||
|
||||
|
||||
def test_detail_fields_are_independent_and_old_views_keep_their_fields():
|
||||
row = {'name': 'Alice', 'bio': 'Detailed biography', 'secret': 'PRIVATE'}
|
||||
view = default_view('table', ['/name'])
|
||||
view['detail_columns'] = [{'pointer': '/bio', 'label': 'Biography'}]
|
||||
publication = {'title': 'Study', 'description': '', 'version': 1,
|
||||
'records': [row], 'views': {'table': view}}
|
||||
listing = public_page(publication, 'table', '/collection')
|
||||
detail = public_page(publication, 'table', '/collection', record_id='1')
|
||||
assert 'Detailed biography' not in listing
|
||||
assert 'Detailed biography' in detail and 'Biography' in detail
|
||||
assert 'PRIVATE' not in detail
|
||||
assert [f['pointer'] for f in project_record(row, view, 1)['fields']] == ['/name']
|
||||
del view['detail_columns']
|
||||
legacy = public_page(publication, 'table', '/collection', record_id='1')
|
||||
assert 'Alice' in legacy and 'Detailed biography' not in legacy
|
||||
|
||||
|
||||
def test_detail_config_survives_save_and_validates_fields():
|
||||
draft = {'revision': 1, 'records': [{'name': 'Alice', 'bio': 'About'}]}
|
||||
view = default_view('table', ['/name'])
|
||||
view['detail_columns'] = [{'pointer': '/bio', 'label': 'About'}]
|
||||
result = config_from_payload(copy.deepcopy(draft), {'revision': 1, 'views': {'table': view}})
|
||||
assert result['views']['table']['detail_columns'] == [{'pointer': '/bio', 'label': 'About'}]
|
||||
view['detail_columns'] = [{'pointer': '/unknown', 'label': 'Unknown'}]
|
||||
assert validate_view(view, draft['records'])
|
||||
view['detail_columns'] = []
|
||||
assert validate_view(view, draft['records'])
|
||||
view['detail_columns'] = 'bad'
|
||||
with pytest.raises(ValueError):
|
||||
config_from_payload(copy.deepcopy(draft), {'revision': 1, 'views': {'table': view}})
|
||||
|
||||
|
||||
def test_questionnaire_detail_fields_do_not_replace_answers():
|
||||
row = {'name': 'Alice', 'bio': 'About Alice', 'answers': [{'question': 'Sleep?', 'answer': 7}]}
|
||||
view = default_view('questionnaire', ['/name'])
|
||||
view.update(questionnaire_mode='pairs', questions_pointer='/answers',
|
||||
detail_columns=[{'pointer': '/bio', 'label': 'Bio'}])
|
||||
fields = project_record(row, view, 1, detail=True)['fields']
|
||||
assert [(f['label'], f['text']) for f in fields] == [('Bio', 'About Alice'), ('Sleep?', '7')]
|
||||
|
||||
|
||||
def test_delete_removes_versions_and_sources_but_preserves_other_datasets(tmp_path):
|
||||
repo = Repository(tmp_path)
|
||||
d = repo.create('Delete me', b'old', 'old.json', [{'name': 'Alice'}])
|
||||
other = repo.create('Keep me', b'keep', 'keep.json', [{'name': 'Bob'}])
|
||||
d['views'] = {'table': default_view('table', ['/name'])}
|
||||
d = repo.save(d, d['revision'])
|
||||
repo.publish(d['id'], d['revision'])
|
||||
repo.unpublish(d['slug'], 1)
|
||||
for name in ('middle', 'latest'):
|
||||
d = repo.replace_source(d['id'], d['revision'], name.encode(), name + '.json', [{'name': name}])
|
||||
with pytest.raises(Conflict):
|
||||
repo.delete(d['id'], d['revision'] - 1)
|
||||
assert len(list((tmp_path / 'sources').iterdir())) == 4
|
||||
repo.delete(d['id'], d['revision'])
|
||||
with pytest.raises(NotFound):
|
||||
repo.get(d['id'])
|
||||
assert repo.versions(d['slug']) == []
|
||||
assert repo.get(other['id'])['title'] == 'Keep me'
|
||||
assert [p.read_bytes() for p in (tmp_path / 'sources').iterdir()] == [b'keep']
|
||||
with pytest.raises(NotFound):
|
||||
repo.delete(d['id'], d['revision'])
|
||||
|
||||
|
||||
def test_delete_retries_failed_source_cleanup_on_restart(tmp_path, monkeypatch):
|
||||
repo = Repository(tmp_path)
|
||||
d = repo.create('Delete', b'old', 'old.json', [{'name': 'Alice'}])
|
||||
d = repo.replace_source(d['id'], d['revision'], b'new', 'new.json', [{'name': 'Bob'}])
|
||||
unlink = Path.unlink
|
||||
|
||||
def blocked_source(path, *args, **kwargs):
|
||||
if path.name == d['source']:
|
||||
raise PermissionError('File temporarily unavailable')
|
||||
return unlink(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, 'unlink', blocked_source)
|
||||
assert repo.delete(d['id'], d['revision']) is False
|
||||
with pytest.raises(NotFound):
|
||||
repo.get(d['id'])
|
||||
assert (tmp_path / 'sources' / d['source']).read_bytes() == b'new'
|
||||
monkeypatch.undo()
|
||||
restarted = Repository(tmp_path)
|
||||
assert restarted.list() == []
|
||||
assert not list((tmp_path / 'sources').iterdir())
|
||||
@@ -0,0 +1,52 @@
|
||||
|
||||
from test_web import env, login
|
||||
from test_admin_http import headers
|
||||
from parser_lab.views import default_view
|
||||
|
||||
|
||||
def test_detail_configuration_preview_and_publication(env):
|
||||
app, c = env
|
||||
login(c)
|
||||
h = headers(app, c)
|
||||
repo = app.state.repo
|
||||
d = repo.create('Study', b'[]', 'study.json', [{'name': 'Alice', 'bio': 'BIOGRAPHY', 'secret': 'HIDDEN'}])
|
||||
view = default_view('table', ['/name'])
|
||||
view['detail_columns'] = [{'pointer': '/bio', 'label': 'Biography'}]
|
||||
payload = {'revision': d['revision'], 'views': {'table': view}}
|
||||
endpoint = f"/api/admin/datasets/{d['id']}"
|
||||
preview = c.post(endpoint + '/preview?kind=table&detail=1', json=payload, headers=h).json()
|
||||
assert 'BIOGRAPHY' in preview['html'] and 'HIDDEN' not in preview['html']
|
||||
saved = c.put(endpoint, json=payload, headers=h).json()
|
||||
assert c.post(endpoint + '/publish', json=saved, headers=h).status_code == 200
|
||||
for base in (f"/preview/{d['id']}/table", f"/dynamic-preview/{d['id']}/table",
|
||||
f"/html/{d['slug']}/v/1/table", f"/interactive/{d['slug']}/v/1/table"):
|
||||
assert 'BIOGRAPHY' not in c.get(base).text
|
||||
detail = c.get(base + '/records/1')
|
||||
assert detail.status_code == 200
|
||||
assert 'BIOGRAPHY' in detail.text and 'HIDDEN' not in detail.text
|
||||
# Editing the draft cannot change a published snapshot.
|
||||
payload.update(revision=saved['revision'])
|
||||
payload['views']['table']['detail_columns'] = [{'pointer': '/name', 'label': 'Name'}]
|
||||
assert c.put(endpoint, json=payload, headers=h).status_code == 200
|
||||
assert 'BIOGRAPHY' in c.get(f"/html/{d['slug']}/v/1/table/records/1").text
|
||||
|
||||
|
||||
def test_delete_requires_auth_csrf_and_current_revision(env):
|
||||
app, c = env
|
||||
repo = app.state.repo
|
||||
d = repo.create('Delete', b'[]', 'study.json', [{'name': 'Alice'}])
|
||||
d['views'] = {'table': default_view('table', ['/name'])}
|
||||
d = repo.save(d, d['revision'])
|
||||
repo.publish(d['id'], d['revision'])
|
||||
endpoint = f"/api/admin/datasets/{d['id']}"
|
||||
assert c.request('DELETE', endpoint, headers={'origin': 'http://testserver'}, json={'revision': d['revision']}).status_code == 401
|
||||
login(c)
|
||||
h = headers(app, c)
|
||||
assert c.request('DELETE', endpoint, headers={'origin': 'http://testserver'}, json={'revision': d['revision']}).status_code == 403
|
||||
for payload in ([], {'revision': True}, {'revision': '2'}):
|
||||
assert c.request('DELETE', endpoint, headers=h, json=payload).status_code == 400
|
||||
assert c.request('DELETE', endpoint, headers=h, json={'revision': 1}).status_code == 409
|
||||
assert c.request('DELETE', endpoint, headers=h, json={'revision': d['revision']}).status_code == 200
|
||||
assert c.get(f"/html/{d['slug']}/v/1/table").status_code == 404
|
||||
assert c.get(f"/admin/datasets/{d['id']}").status_code == 404
|
||||
assert not list((repo.root / 'sources').iterdir())
|
||||
Reference in New Issue
Block a user