diff --git a/README.md b/README.md index 2acf704..b83dbf3 100644 --- a/README.md +++ b/README.md @@ -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; остальные загружаются кнопкой «Загрузить ещё». diff --git a/docs/design-spec.md b/docs/design-spec.md deleted file mode 100644 index 860a01a..0000000 --- a/docs/design-spec.md +++ /dev/null @@ -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). Архитектура этого документа является проектным предложением, а не утверждением о готовности перечисленных функций в старом приложении. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md deleted file mode 100644 index cd54ea2..0000000 --- a/docs/implementation-plan.md +++ /dev/null @@ -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-проба содержит `' 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. Вредоносное значение `` остаётся текстом, исполняемый тег не появляется. Проверить отсутствие скрытых полей на списке и деталях. -- [ ] Запустить `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 предпросмотре без входа и прямых служебных запросах. Проверить пользовательские ` +Кабинет · Parser Lab
{% if screen=='catalog' %}
КАБИНЕТ / КОЛЛЕКЦИИ

Коллекции данных

Всего коллекций: {{ datasets|length }}

Загрузить данные
-
{% for d in datasets %}{% else %}{% endfor %}
НазваниеЗаписиПредставленияСтатус
{{ d.title }}
{{ d.filename }}
{{ d.records|length }}{{ d.views|length }}{{ 'Опубликовано' if active[d.id] else 'Черновик' }}Открыть
Коллекций пока нет. Загрузите первый набор данных.
+
{% for d in datasets %}{% else %}{% endfor %}
НазваниеЗаписиПредставленияСтатус
{{ d.title }}
{{ d.filename }}
{{ d.records|length }}{{ d.views|length }}{{ 'Опубликовано' if active[d.id] else 'Черновик' }}Открыть
Коллекций пока нет. Загрузите первый набор данных.
{% elif screen=='import' %}
КОЛЛЕКЦИИ / ИМПОРТ

Импорт данных

Выберите файл, проверьте структуру и сохраните коллекцию.

@@ -12,9 +12,9 @@ {% elif screen=='edit' %}
КОЛЛЕКЦИИ / РЕДАКТОР

{{ draft.title }}

{{ draft.records|length }} записей · {{ draft.filename }}

{% if questionnaire_repair %}
Найдены анкеты: {{ questionnaire_repair.records|length }} участников.

{% if questionnaire_repair.pointer %}Участники находятся внутри {{ questionnaire_repair.pointer }}. Можно разделить их на отдельные записи и автоматически назначить вопросы и ответы. Настройки представлений черновика будут пересозданы; опубликованные версии сохранятся.{% else %}Можно автоматически назначить заголовки участников, вопросы и ответы.{% endif %}

{% endif %}
Предпросмотр ↗Публикации
-
-

О коллекции

Заменить исходный файл
-

Предпросмотр изменений

Первые 5 записей · Опубликованные данные изменятся только после создания новой версии.

+
+

О коллекции

Заменить исходный файл
+

Предпросмотр изменений

Первые 5 записей · Опубликованные данные изменятся только после создания новой версии.

{% elif screen=='versions' %}
КОЛЛЕКЦИИ / ПУБЛИКАЦИИ

{{ draft.title }}

← Настройка
{% for v in versions %}

Версия {{ v.version }} · {{ 'Доступна' if v.active else 'Снята с публикации' }}

{% for k in v.kinds %}{% endfor %}{% if v.active %}{% endif %}
{% else %}

Опубликованных версий пока нет.

{% endfor %} diff --git a/tests/test_browser.py b/tests/test_browser.py index 757a1c0..333c0a9 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -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 diff --git a/tests/test_collection_details.py b/tests/test_collection_details.py new file mode 100644 index 0000000..b56415d --- /dev/null +++ b/tests/test_collection_details.py @@ -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()) diff --git a/tests/test_details_http.py b/tests/test_details_http.py new file mode 100644 index 0000000..713940f --- /dev/null +++ b/tests/test_details_http.py @@ -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())