feat(seed): add API test-data seeding script and make testdata target
- scripts/seed.py: stdlib HTTP client, JPEG+EXIF via optional Pillow (PNG fallback), additive-only, dry-run - Makefile: testdata target with ALBUMS/PHOTOS/SEED/TIMEOUT and boolean flags - README: usage section; new dev-seed-data capability spec
This commit is contained in:
@@ -1,8 +1,16 @@
|
||||
.PHONY: build build-release frontend backend run migrate reconcile reconcile-delete tidy test clean
|
||||
.PHONY: build build-release frontend backend run migrate reconcile reconcile-delete tidy test testdata clean
|
||||
|
||||
# Release version stamped into the binary (-X main.version). Override with VERSION=...
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
|
||||
# Seed testdata parameters (see `make testdata`). Counts: ALBUMS, PHOTOS, SEED, TIMEOUT.
|
||||
# Boolean (set to 1 to enable): TAGS, COMMENTS, LIKES, SHARE, INVITES, WAIT, DRY.
|
||||
# Extra passthrough flags: ARGS.
|
||||
ALBUMS ?= 2
|
||||
PHOTOS ?= 10
|
||||
SEED ?= 0
|
||||
TIMEOUT ?= 30
|
||||
|
||||
frontend:
|
||||
cd frontend && npm ci && npm run build
|
||||
rm -rf backend/static/*
|
||||
@@ -53,6 +61,25 @@ tidy:
|
||||
test:
|
||||
cd backend && go test ./...
|
||||
|
||||
# Seed test data into a running instance through the public API (additive only).
|
||||
# Credentials come from the environment: GALO_EMAIL, GALO_PASSWORD
|
||||
# (optional GALO_BASE_URL). Example:
|
||||
# make testdata ALBUMS=3 PHOTOS=5 TAGS=1 LIKES=1 SEED=42
|
||||
testdata:
|
||||
@python3 scripts/seed.py \
|
||||
--albums $(ALBUMS) \
|
||||
--photos-per-album $(PHOTOS) \
|
||||
--seed $(SEED) \
|
||||
--timeout $(TIMEOUT) \
|
||||
$(if $(filter 1,$(TAGS)),--tags,) \
|
||||
$(if $(filter 1,$(COMMENTS)),--comments,) \
|
||||
$(if $(filter 1,$(LIKES)),--likes,) \
|
||||
$(if $(filter 1,$(SHARE)),--share-links,) \
|
||||
$(if $(filter 1,$(INVITES)),--invites,) \
|
||||
$(if $(filter 1,$(WAIT)),--wait-variants,) \
|
||||
$(if $(filter 1,$(DRY)),--dry-run,) \
|
||||
$(ARGS)
|
||||
|
||||
clean:
|
||||
rm -rf bin backend/static/*
|
||||
touch backend/static/.gitkeep
|
||||
|
||||
@@ -29,11 +29,45 @@ SvelteKit через `go:embed`, запуск через systemd.
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Ключевые переменные: `PG_*`, `S3_*` (обязательны `UsePathStyle=true` и регион
|
||||
`garage`), `JWT_ACCESS_TTL`, `JWT_REFRESH_TTL`, `HTTP_ADDR`,
|
||||
`SSE_HEARTBEAT_INTERVAL`, `SSE_CLIENT_BUFFER`. Секрет подписи токенов не задаётся
|
||||
в окружении: он генерируется при первом старте и хранится в слое метаданных
|
||||
(`app_settings`).
|
||||
Секрет подписи токенов (`JWT_SECRET`) в окружении не задаётся: он генерируется
|
||||
при первом старте и хранится в слое метаданных (`app_settings`).
|
||||
|
||||
Все параметры окружения (регистр имён учитывается):
|
||||
|
||||
| Переменная | Значение по умолчанию | Описание |
|
||||
| --- | --- | --- |
|
||||
| `HTTP_ADDR` | `:8080` | Адрес прослушивания HTTP-сервера |
|
||||
| `APP_BASE_URL` | `http://localhost:8080` | Внешний базовый URL приложения (для ссылок и SSE) |
|
||||
| **База данных (метаданные)** | | |
|
||||
| `DB_BACKEND` | `sqlite` | Слой метаданных: `sqlite` или `postgres` |
|
||||
| `SQLITE_PATH` | `./galo.db` | Путь к файлу SQLite (при `DB_BACKEND=sqlite`) |
|
||||
| `PG_HOST` | `pg.localnet` | Хост PostgreSQL |
|
||||
| `PG_PORT` | `5432` | Порт PostgreSQL |
|
||||
| `PG_USER` | `album` | Пользователь PostgreSQL |
|
||||
| `PG_PASSWORD` | *(пусто)* | Пароль PostgreSQL (обязателен при `DB_BACKEND=postgres`) |
|
||||
| `PG_DATABASE` | `galo` | Имя БД PostgreSQL |
|
||||
| `PG_SSLMODE` | `disable` | SSL-режим PostgreSQL |
|
||||
| **Блочное хранилище** | | |
|
||||
| `STORAGE_BACKEND` | `fs` | Бэкенд блобов: `fs` или `s3` |
|
||||
| `FS_ROOT` | `./data` | Корень ФС для блобов (при `STORAGE_BACKEND=fs`) |
|
||||
| `S3_ENDPOINT` | `http://s3.localnet:3900` | Endpoint S3/Garage |
|
||||
| `S3_REGION` | `garage` | Регион S3 |
|
||||
| `S3_ACCESS_KEY` | *(пусто)* | Access key S3 |
|
||||
| `S3_SECRET_KEY` | *(пусто)* | Secret key S3 |
|
||||
| `S3_BUCKET` | `albums` | Имя бакета S3 |
|
||||
| `S3_USE_PATH_STYLE` | `true` | Использовать path-style запросы (обязательно для Garage) |
|
||||
| `OBJECT_URL_TTL` | `1h` | Время жизни подписанных URL объектов |
|
||||
| `URL_SECRET` | *(пусто)* | Секрет подписи URL объектов; при пустом — секрет из `app_settings` |
|
||||
| **Auth / JWT** | | |
|
||||
| `JWT_ACCESS_TTL` | `15m` | Время жизни access-токена |
|
||||
| `JWT_REFRESH_TTL` | `720h` | Время жизни refresh-токена |
|
||||
| **SSE** | | |
|
||||
| `SSE_HEARTBEAT_INTERVAL` | `25s` | Период heartbeat-сообщений каналов SSE |
|
||||
| `SSE_CLIENT_BUFFER` | `64` | Размер буфера клиента SSE (количество сообщений) |
|
||||
| **Фоновый воркер** (генерация вариантов + GC объектов) | | |
|
||||
| `WORKER_INTERVAL` | `5s` | Период проверки воркером очереди задач |
|
||||
| `WORKER_BATCH` | `10` | Размер батча обрабатываемых задач |
|
||||
| `WORKER_PENDING_TTL` | `10m` | TTL задачи в статусе `pending` до повторной попытки |
|
||||
|
||||
Слои данных выбираются параметрами (по умолчанию — локальный режим):
|
||||
|
||||
@@ -96,6 +130,57 @@ curl -s localhost:8080/api/health # {"status":"ok","db":"ok","s3":"ok"}
|
||||
cd frontend && npm run dev # Vite проксирует /api на :8080
|
||||
```
|
||||
|
||||
## Наполнение тестовыми данными
|
||||
|
||||
Скрипт `scripts/seed.py` наполняет запущенный инстанс тестовыми данными через публичный
|
||||
API: создаёт альбомы, генерирует фотографии и, по флагам, добавляет теги, комментарии,
|
||||
лайки, share-ссылки и инвайты. Работает только на добавление — существующие данные не
|
||||
удаляются и не изменяются.
|
||||
|
||||
```bash
|
||||
GALO_EMAIL=owner@example.com GALO_PASSWORD=secret \
|
||||
python3 scripts/seed.py --albums 2 --photos-per-album 10 --tags --likes
|
||||
```
|
||||
|
||||
Ту же операцию можно выполнить через Make-цель (параметры — переменные сборки):
|
||||
|
||||
```bash
|
||||
GALO_EMAIL=owner@example.com GALO_PASSWORD=secret \
|
||||
make testdata ALBUMS=3 PHOTOS=5 TAGS=1 LIKES=1 SEED=42
|
||||
```
|
||||
|
||||
Значения по умолчанию: `ALBUMS=2`, `PHOTOS=10`, `SEED=0`, `TIMEOUT=30`. Булевы
|
||||
параметры включаются значением `1`: `TAGS`, `COMMENTS`, `LIKES`, `SHARE`, `INVITES`,
|
||||
`WAIT`, `DRY`. Дополнительные флаги передаются через `ARGS`.
|
||||
|
||||
Переменные окружения:
|
||||
|
||||
| Переменная | По умолчанию | Описание |
|
||||
| --- | --- | --- |
|
||||
| `GALO_BASE_URL` | `http://localhost:8080` | Базовый URL инстанса |
|
||||
| `GALO_EMAIL` | — | Email владельца (обязательно) |
|
||||
| `GALO_PASSWORD` | — | Пароль владельца (обязательно) |
|
||||
|
||||
Параметры:
|
||||
|
||||
| Флаг | По умолчанию | Описание |
|
||||
| --- | --- | --- |
|
||||
| `--albums` | `2` | Число альбомов |
|
||||
| `--photos-per-album` | `10` | Фотографий в каждом альбоме |
|
||||
| `--seed` | `0` | Seed генератора (воспроизводимость) |
|
||||
| `--tags` | выкл. | Добавлять теги |
|
||||
| `--comments` | выкл. | Добавлять комментарии |
|
||||
| `--likes` | выкл. | Добавлять лайки |
|
||||
| `--share-links` | выкл. | Создавать share-ссылки на альбомы |
|
||||
| `--invites` | выкл. | Создавать инвайты (роль `member`) |
|
||||
| `--wait-variants` | выкл. | Дожидаться генерации превью каждого фото |
|
||||
| `--timeout` | `30` | Таймаут ожидания и сетевых запросов, сек |
|
||||
| `--dry-run` | выкл. | Показать план и не отправлять изменяющих запросов |
|
||||
|
||||
Изображения генерируются на лету: при установленной Pillow — JPEG с EXIF (дата съёмки и
|
||||
GPS), иначе — PNG без EXIF на стандартной библиотеке. Ядро скрипта не требует сторонних
|
||||
пакетов; Pillow — необязательная зависимость.
|
||||
|
||||
## Тесты
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-11
|
||||
@@ -0,0 +1,40 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
Наполнить приложение тестовыми данными можно только вручную: сейчас в локальной базе
|
||||
один пользователь, один альбом и 15 фото, появившихся через UI. Чтобы проверять сетку,
|
||||
лайтбокс, cursor-пагинацию, асинхронную генерацию трёх превью, извлечение EXIF,
|
||||
share-ссылки и SSE-события, нужны повторяемые объёмы данных. Нужен dev-инструмент,
|
||||
который наполняет систему через публичный API, не трогая БД и хранилище напрямую.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Новый standalone-скрипт `scripts/seed.py` на Python: ввод через переменные окружения
|
||||
(базовый URL, email, пароль владельца) и параметры командной строки.
|
||||
- Создание заданного числа альбомов и фотографий от одной учётки-владельца через
|
||||
существующие эндпоинты (`POST /api/albums`, `POST /api/albums/:id/photos`).
|
||||
- Изображения генерируются на лету: при доступной Pillow — JPEG с EXIF
|
||||
(`DateTimeOriginal`, опционально GPS); при отсутствии Pillow — PNG без EXIF на stdlib.
|
||||
- HTTP-клиент — только стандартная библиотека (`urllib`), без обязательных pip-зависимостей.
|
||||
- Опциональные сопутствующие сущности (по флагам, по умолчанию выключены): теги,
|
||||
комментарии, лайки, share-ссылки, инвайты.
|
||||
- Опциональное ожидание генерации вариантов (опрос `GET /api/photos/:id` до `thumb_url`).
|
||||
- Режим только-добавления: скрипт ничего не удаляет и не изменяет; повторные запуски
|
||||
добавляют данные, различимые по метке прогона в названии альбома. Есть `--dry-run`.
|
||||
- Секреты читаются только из окружения и не принимаются аргументами и не логируются.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `dev-seed-data`: dev-инструмент наполнения приложения тестовыми данными через публичный API.
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- Нет: поведение системы для клиента не меняется, API не расширяется; добавляется отдельный dev-инструмент. -->
|
||||
|
||||
## Impact
|
||||
|
||||
- Новый файл `scripts/seed.py`; новый раздел в `README.md` с запуском и параметрами.
|
||||
- Backend, frontend, API и хранилище не меняются — используются только существующие эндпоинты.
|
||||
- Опциональная внешняя зависимость Pillow (для JPEG+EXIF); ядро работает на стандартной
|
||||
библиотеке. Требуется запущенный инстанс с существующим пользователем-владельцем.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
|
||||
Dev-инструмент, который наполняет запущенное приложение тестовыми данными через
|
||||
публичный API: альбомы, сгенерированные фотографии и сопутствующие сущности — без
|
||||
прямого доступа к базе данных и хранилищу.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Запуск и аутентификация
|
||||
Скрипт SHALL получать базовый URL, email и пароль из переменных окружения и SHALL
|
||||
аутентифицироваться через `POST /api/auth/login`, после чего SHALL выполнять все запросы
|
||||
с Bearer-токеном владельца. Пароль SHALL не приниматься аргументами командной строки и
|
||||
SHALL не выводиться в поток вывода.
|
||||
|
||||
#### Scenario: Успешный вход
|
||||
- **WHEN** заданы корректные переменные окружения и скрипт запущен
|
||||
- **THEN** скрипт получает access-токен и приступает к наполнению
|
||||
|
||||
#### Scenario: Неверные учётные данные
|
||||
- **WHEN** вход возвращает 401
|
||||
- **THEN** скрипт завершается с понятным сообщением и ненулевым кодом, не создавая данных
|
||||
|
||||
#### Scenario: Пароль не логируется
|
||||
- **WHEN** скрипт выводит ход выполнения
|
||||
- **THEN** пароль и токены в вывод не попадают
|
||||
|
||||
### Requirement: Создание альбомов и фотографий
|
||||
Скрипт SHALL создавать заданное параметрами число альбомов и фотографий в каждом от имени
|
||||
аутентифицированного владельца через `POST /api/albums` и
|
||||
`POST /api/albums/:id/photos`. Названия альбомов SHALL содержать метку прогона.
|
||||
|
||||
#### Scenario: Наполнение альбомами и фото
|
||||
- **WHEN** скрипт запущен с числом альбомов и фотографий на альбом
|
||||
- **THEN** создаются указанные альбомы и фотографии, а имена альбомов помечены меткой прогона
|
||||
|
||||
#### Scenario: Воспроизводимость
|
||||
- **WHEN** задан числовой seed
|
||||
- **THEN** структура и параметры генерируемых данных детерминированы для этого seed
|
||||
|
||||
### Requirement: Генерация изображений с EXIF
|
||||
Скрипт SHALL генерировать загружаемые изображения на лету. При доступной библиотеке Pillow
|
||||
он SHALL использовать JPEG с EXIF-метаданными (время съёмки, при необходимости GPS); при
|
||||
отсутствии Pillow он SHALL использовать PNG без EXIF, созданный на стандартной библиотеке.
|
||||
|
||||
#### Scenario: Pillow доступна
|
||||
- **WHEN** библиотека Pillow импортируется
|
||||
- **THEN** загружаемые файлы — JPEG с датой съёмки в EXIF
|
||||
|
||||
#### Scenario: Pillow отсутствует
|
||||
- **WHEN** библиотека Pillow недоступна
|
||||
- **THEN** скрипт продолжает работу, загружая PNG без EXIF
|
||||
|
||||
### Requirement: Опциональные сопутствующие сущности
|
||||
Скрипт SHALL создавать теги, комментарии, лайки, share-ссылки и инвайты только при явном
|
||||
включении соответствующими флагами; без флагов эти сущности SHALL не создаваться.
|
||||
|
||||
#### Scenario: Флаги выключены
|
||||
- **WHEN** скрипт запущен без опциональных флагов
|
||||
- **THEN** создаются только альбомы и фотографии
|
||||
|
||||
#### Scenario: Сопутствующие данные включены
|
||||
- **WHEN** включены флаги тегов, комментариев, лайков, share-ссылок или инвайтов
|
||||
- **THEN** соответствующие сущности создаются для части фотографий или альбомов
|
||||
|
||||
### Requirement: Ожидание генерации вариантов
|
||||
При включённом ожидании скрипт SHALL дожидаться появления превью, опрашивая
|
||||
`GET /api/photos/:id` до появления `thumb_url`, с ограничением по таймауту.
|
||||
|
||||
#### Scenario: Варианты сгенерированы
|
||||
- **WHEN** ожидание вариантов включено и варианты появились
|
||||
- **THEN** скрипт продолжает после появления `thumb_url`
|
||||
|
||||
#### Scenario: Таймаут ожидания
|
||||
- **WHEN** варианты не появились за отведённое время
|
||||
- **THEN** скрипт сообщает о таймауте и продолжает, не зависая бесконечно
|
||||
|
||||
### Requirement: Только-добавление и сухой прогон
|
||||
Скрипт SHALL работать в режиме только-добавления: SHALL не удалять и не изменять
|
||||
существующие данные. Повторные запуски SHALL добавлять новые данные, различимые по метке
|
||||
прогона. При сухом прогоне скрипт SHALL сообщать план и SHALL не выполнять изменяющих
|
||||
запросов.
|
||||
|
||||
#### Scenario: Повторный запуск
|
||||
- **WHEN** скрипт запускается повторно
|
||||
- **THEN** существующие данные сохраняются, а новые добавляются с новой меткой прогона
|
||||
|
||||
#### Scenario: Сухой прогон
|
||||
- **WHEN** включён режим сухого прогона
|
||||
- **THEN** скрипт не отправляет запросов, изменяющих данные
|
||||
|
||||
### Requirement: Минимум зависимостей
|
||||
Основной функционал скрипта SHALL работать на стандартной библиотеке Python, включая
|
||||
HTTP-клиент; Pillow SHALL быть необязательной зависимостью.
|
||||
|
||||
#### Scenario: Запуск без внешних пакетов
|
||||
- **WHEN** в окружении нет сторонних пакетов
|
||||
- **THEN** скрипт выполняет базовое наполнение с изображениями PNG без EXIF
|
||||
|
||||
### Requirement: Запуск через Make-цель
|
||||
Проект SHALL предоставлять Make-цель `testdata`, которая запускает скрипт наполнения и
|
||||
передаёт ему числовые и булевы параметры. Цель SHALL не дублировать логику скрипта.
|
||||
|
||||
#### Scenario: Передача параметров
|
||||
- **WHEN** цель запущена с заданными параметрами (число альбомов и фото, seed, булевы флаги)
|
||||
- **THEN** соответствующие значения и флаги передаются в скрипт наполнения
|
||||
|
||||
#### Scenario: Значения по умолчанию
|
||||
- **WHEN** цель запущена без параметров
|
||||
- **THEN** используются документированные значения по умолчанию
|
||||
@@ -0,0 +1,29 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Каркас CLI и HTTP-клиент
|
||||
|
||||
- [x] 1.1 Создать `scripts/seed.py` с разбором аргументов (`--albums`, `--photos-per-album`, `--seed`, `--tags`, `--comments`, `--likes`, `--share-links`, `--invites`, `--wait-variants`, `--timeout`, `--dry-run`) и чтением env (`GALO_BASE_URL`, `GALO_EMAIL`, `GALO_PASSWORD`); проверка — `python3 scripts/seed.py --help` работает без сторонних пакетов.
|
||||
- [x] 1.2 Реализовать HTTP-клиент на `urllib` (JSON, multipart, Bearer, разбор `{error:{code,message}}`); проверка — обработка ошибочного ответа и сборка multipart-тела проверены на мок-данных.
|
||||
- [x] 1.3 Реализовать вход через `POST /api/auth/login` и обработку ошибок (401, сеть) с ненулевым кодом; проверка — запуск с неверным паролем завершается ошибкой и не создаёт данных.
|
||||
|
||||
## 2. Генерация изображений
|
||||
|
||||
- [x] 2.1 Генерировать JPEG с EXIF (время съёмки, опционально GPS) через Pillow; проверка — в ответе `GET /api/photos/:id` в `exif` присутствует `DateTimeOriginal`.
|
||||
- [x] 2.2 Добавить фолбэк PNG на стандартной библиотеке (`zlib`+`struct`) при отсутствии Pillow; проверка — при недоступной Pillow скрипт загружает PNG и фото появляется в списке.
|
||||
|
||||
## 3. Наполнение данными
|
||||
|
||||
- [x] 3.1 Создавать альбомы (`POST /api/albums`) и загружать фото (`POST /api/albums/:id/photos`, multipart) с меткой прогона; проверка — данные видны в `GET /api/albums` и `GET /api/albums/:id/photos`.
|
||||
- [x] 3.2 Реализовать ожидание вариантов (`--wait-variants`): опрос `GET /api/photos/:id` до появления `thumb_url` с таймаутом; проверка — при работающем воркере ожидание завершается, при остановленном — сообщает таймаут без зависания.
|
||||
- [x] 3.3 Реализовать опциональные сущности по флагам: теги (`POST /photos/:id/tags`), комментарии (`POST /photos/:id/comments`), лайки (`POST /photos/:id/likes`), share-ссылки (`POST /albums/:id/share`), инвайты (`POST /albums/:id/invites`); проверка — без флагов ничего лишнего не создаётся, с флагами сущности создаются.
|
||||
- [x] 3.4 Добавить итоговый отчёт и режим `--dry-run`; проверка — сухой прогон не отправляет изменяющих запросов, а отчёт печатает фактическое число созданных сущностей.
|
||||
|
||||
## 4. Документация и интеграционная проверка
|
||||
|
||||
- [x] 4.1 Описать запуск, переменные окружения и параметры в `README.md`; проверка — задокументированный запуск наполняет локальный инстанс.
|
||||
- [x] 4.2 Интеграционная проверка: прогнать скрипт с флагами против локального инстанса и убедиться в наличии альбомов, фото и вариантов, а также в сохранности существующих данных (только-добавление).
|
||||
|
||||
## 5. Make-цель
|
||||
|
||||
- [x] 5.1 Добавить цель `testdata` в `Makefile`, передающую параметры (`ALBUMS`, `PHOTOS`, `SEED`, `TIMEOUT`, булевы `TAGS`/`COMMENTS`/`LIKES`/`SHARE`/`INVITES`/`WAIT`/`DRY`, а также `ARGS`) в скрипт; проверка — `make -n testdata ALBUMS=3 PHOTOS=4 TAGS=1` показывает ожидаемые флаги, `make testdata DRY=1` печатает план без запросов, а `make testdata ALBUMS=1 PHOTOS=1` создаёт данные.
|
||||
- [x] 5.2 Описать `make testdata` в `README.md`; проверка — задокументированный запуск наполняет локальный инстанс.
|
||||
@@ -0,0 +1,111 @@
|
||||
# dev-seed-data Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Dev-инструмент, который наполняет запущенное приложение тестовыми данными через
|
||||
публичный API: альбомы, сгенерированные фотографии и сопутствующие сущности — без
|
||||
прямого доступа к базе данных и хранилищу.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Запуск и аутентификация
|
||||
Скрипт SHALL получать базовый URL, email и пароль из переменных окружения и SHALL
|
||||
аутентифицироваться через `POST /api/auth/login`, после чего SHALL выполнять все запросы
|
||||
с Bearer-токеном владельца. Пароль SHALL не приниматься аргументами командной строки и
|
||||
SHALL не выводиться в поток вывода.
|
||||
|
||||
#### Scenario: Успешный вход
|
||||
- **WHEN** заданы корректные переменные окружения и скрипт запущен
|
||||
- **THEN** скрипт получает access-токен и приступает к наполнению
|
||||
|
||||
#### Scenario: Неверные учётные данные
|
||||
- **WHEN** вход возвращает 401
|
||||
- **THEN** скрипт завершается с понятным сообщением и ненулевым кодом, не создавая данных
|
||||
|
||||
#### Scenario: Пароль не логируется
|
||||
- **WHEN** скрипт выводит ход выполнения
|
||||
- **THEN** пароль и токены в вывод не попадают
|
||||
|
||||
### Requirement: Создание альбомов и фотографий
|
||||
Скрипт SHALL создавать заданное параметрами число альбомов и фотографий в каждом от имени
|
||||
аутентифицированного владельца через `POST /api/albums` и
|
||||
`POST /api/albums/:id/photos`. Названия альбомов SHALL содержать метку прогона.
|
||||
|
||||
#### Scenario: Наполнение альбомами и фото
|
||||
- **WHEN** скрипт запущен с числом альбомов и фотографий на альбом
|
||||
- **THEN** создаются указанные альбомы и фотографии, а имена альбомов помечены меткой прогона
|
||||
|
||||
#### Scenario: Воспроизводимость
|
||||
- **WHEN** задан числовой seed
|
||||
- **THEN** структура и параметры генерируемых данных детерминированы для этого seed
|
||||
|
||||
### Requirement: Генерация изображений с EXIF
|
||||
Скрипт SHALL генерировать загружаемые изображения на лету. При доступной библиотеке Pillow
|
||||
он SHALL использовать JPEG с EXIF-метаданными (время съёмки, при необходимости GPS); при
|
||||
отсутствии Pillow он SHALL использовать PNG без EXIF, созданный на стандартной библиотеке.
|
||||
|
||||
#### Scenario: Pillow доступна
|
||||
- **WHEN** библиотека Pillow импортируется
|
||||
- **THEN** загружаемые файлы — JPEG с датой съёмки в EXIF
|
||||
|
||||
#### Scenario: Pillow отсутствует
|
||||
- **WHEN** библиотека Pillow недоступна
|
||||
- **THEN** скрипт продолжает работу, загружая PNG без EXIF
|
||||
|
||||
### Requirement: Опциональные сопутствующие сущности
|
||||
Скрипт SHALL создавать теги, комментарии, лайки, share-ссылки и инвайты только при явном
|
||||
включении соответствующими флагами; без флагов эти сущности SHALL не создаваться.
|
||||
|
||||
#### Scenario: Флаги выключены
|
||||
- **WHEN** скрипт запущен без опциональных флагов
|
||||
- **THEN** создаются только альбомы и фотографии
|
||||
|
||||
#### Scenario: Сопутствующие данные включены
|
||||
- **WHEN** включены флаги тегов, комментариев, лайков, share-ссылок или инвайтов
|
||||
- **THEN** соответствующие сущности создаются для части фотографий или альбомов
|
||||
|
||||
### Requirement: Ожидание генерации вариантов
|
||||
При включённом ожидании скрипт SHALL дожидаться появления превью, опрашивая
|
||||
`GET /api/photos/:id` до появления `thumb_url`, с ограничением по таймауту.
|
||||
|
||||
#### Scenario: Варианты сгенерированы
|
||||
- **WHEN** ожидание вариантов включено и варианты появились
|
||||
- **THEN** скрипт продолжает после появления `thumb_url`
|
||||
|
||||
#### Scenario: Таймаут ожидания
|
||||
- **WHEN** варианты не появились за отведённое время
|
||||
- **THEN** скрипт сообщает о таймауте и продолжает, не зависая бесконечно
|
||||
|
||||
### Requirement: Только-добавление и сухой прогон
|
||||
Скрипт SHALL работать в режиме только-добавления: SHALL не удалять и не изменять
|
||||
существующие данные. Повторные запуски SHALL добавлять новые данные, различимые по метке
|
||||
прогона. При сухом прогоне скрипт SHALL сообщать план и SHALL не выполнять изменяющих
|
||||
запросов.
|
||||
|
||||
#### Scenario: Повторный запуск
|
||||
- **WHEN** скрипт запускается повторно
|
||||
- **THEN** существующие данные сохраняются, а новые добавляются с новой меткой прогона
|
||||
|
||||
#### Scenario: Сухой прогон
|
||||
- **WHEN** включён режим сухого прогона
|
||||
- **THEN** скрипт не отправляет запросов, изменяющих данные
|
||||
|
||||
### Requirement: Минимум зависимостей
|
||||
Основной функционал скрипта SHALL работать на стандартной библиотеке Python, включая
|
||||
HTTP-клиент; Pillow SHALL быть необязательной зависимостью.
|
||||
|
||||
#### Scenario: Запуск без внешних пакетов
|
||||
- **WHEN** в окружении нет сторонних пакетов
|
||||
- **THEN** скрипт выполняет базовое наполнение с изображениями PNG без EXIF
|
||||
|
||||
### Requirement: Запуск через Make-цель
|
||||
Проект SHALL предоставлять Make-цель `testdata`, которая запускает скрипт наполнения и
|
||||
передаёт ему числовые и булевы параметры. Цель SHALL не дублировать логику скрипта.
|
||||
|
||||
#### Scenario: Передача параметров
|
||||
- **WHEN** цель запущена с заданными параметрами (число альбомов и фото, seed, булевы флаги)
|
||||
- **THEN** соответствующие значения и флаги передаются в скрипт наполнения
|
||||
|
||||
#### Scenario: Значения по умолчанию
|
||||
- **WHEN** цель запущена без параметров
|
||||
- **THEN** используются документированные значения по умолчанию
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Populate a running galo instance with test data through its public API.
|
||||
|
||||
The script logs in as an existing owner account and adds albums, generated
|
||||
photos and (optionally) tags, comments, likes, share links and invites. It is
|
||||
strictly additive: it never deletes or modifies existing data. Repeat runs add
|
||||
new data that is distinguishable by a run tag baked into album titles.
|
||||
|
||||
Core functionality uses only the Python standard library. Pillow is an optional
|
||||
dependency: when present, photos are JPEG with EXIF (capture time and GPS);
|
||||
otherwise they are PNG without EXIF.
|
||||
|
||||
Configuration:
|
||||
GALO_BASE_URL base URL of the instance (default: http://localhost:8080)
|
||||
GALO_EMAIL owner email (required)
|
||||
GALO_PASSWORD owner password (required)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
import zlib
|
||||
from fractions import Fraction
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
HAVE_PIL = True
|
||||
except ImportError: # pragma: no cover - exercised only without Pillow
|
||||
HAVE_PIL = False
|
||||
|
||||
DEFAULT_BASE_URL = "http://localhost:8080"
|
||||
|
||||
COMMENT_TEXTS = [
|
||||
"Какое место!",
|
||||
"Отличный кадр",
|
||||
"Нужно повторить эту поездку",
|
||||
"Здесь был потрясающий свет",
|
||||
"Любимое фото из альбома",
|
||||
]
|
||||
|
||||
TAG_NAMES = ["travel", "summer", "family", "friends", "nature", "city", "sunset"]
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
"""Error returned by the galo API or the transport layer."""
|
||||
|
||||
def __init__(self, status: int, code: str, message: str):
|
||||
super().__init__(f"{status} {code}: {message}")
|
||||
self.status = status
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
def encode_multipart(fields, files, boundary):
|
||||
"""Build a multipart/form-data body.
|
||||
|
||||
fields: iterable of (name, value). files: iterable of
|
||||
(name, filename, content_type, data).
|
||||
"""
|
||||
body = bytearray()
|
||||
for name, value in fields:
|
||||
body += f"--{boundary}\r\n".encode()
|
||||
body += f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode()
|
||||
body += str(value).encode()
|
||||
body += b"\r\n"
|
||||
for name, filename, content_type, data in files:
|
||||
body += f"--{boundary}\r\n".encode()
|
||||
body += (
|
||||
f'Content-Disposition: form-data; name="{name}"; '
|
||||
f'filename="{filename}"\r\n'
|
||||
).encode()
|
||||
body += f"Content-Type: {content_type}\r\n\r\n".encode()
|
||||
body += data
|
||||
body += b"\r\n"
|
||||
body += f"--{boundary}--\r\n".encode()
|
||||
return bytes(body)
|
||||
|
||||
|
||||
class Client:
|
||||
"""Minimal JSON/multipart HTTP client over urllib."""
|
||||
|
||||
def __init__(self, base_url, timeout=30):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.token = None
|
||||
|
||||
def request(self, method, path, *, json_body=None, files=None, fields=None):
|
||||
url = self.base_url + path
|
||||
headers = {"Accept": "application/json"}
|
||||
data = None
|
||||
if json_body is not None:
|
||||
data = json.dumps(json_body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
elif files is not None:
|
||||
boundary = "----galoseed" + uuid.uuid4().hex
|
||||
data = encode_multipart(fields or [], files, boundary)
|
||||
headers["Content-Type"] = (
|
||||
"multipart/form-data; boundary=" + boundary
|
||||
)
|
||||
if self.token:
|
||||
headers["Authorization"] = "Bearer " + self.token
|
||||
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
raw = resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise _http_error(exc)
|
||||
except urllib.error.URLError as exc:
|
||||
raise ApiError(0, "network_error", str(exc.reason))
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _http_error(exc):
|
||||
code, message = "http_error", str(exc.reason)
|
||||
try:
|
||||
payload = json.loads(exc.read())
|
||||
err = payload.get("error") or {}
|
||||
code = err.get("code", code)
|
||||
message = err.get("message", message)
|
||||
except Exception:
|
||||
pass
|
||||
return ApiError(exc.code, code, message)
|
||||
|
||||
|
||||
# --- image generation -------------------------------------------------------
|
||||
|
||||
|
||||
def _rational(value):
|
||||
deg = int(value)
|
||||
minutes = int((value - deg) * 60)
|
||||
seconds = (value - deg - minutes / 60.0) * 3600.0
|
||||
return (Fraction(deg), Fraction(minutes), Fraction(round(seconds * 100), 100))
|
||||
|
||||
|
||||
def generate_jpeg(rng):
|
||||
"""Return JPEG bytes with EXIF capture time (and GPS when accepted)."""
|
||||
width = rng.randint(640, 1600)
|
||||
height = rng.randint(480, 1200)
|
||||
base = (rng.randint(20, 220), rng.randint(20, 220), rng.randint(20, 220))
|
||||
img = Image.new("RGB", (width, height))
|
||||
draw = ImageDraw.Draw(img)
|
||||
for step in range(0, height, 4):
|
||||
shade = (base[0] + step) % 256
|
||||
draw.rectangle(
|
||||
[0, step, width, step + 4],
|
||||
fill=(shade, (base[1] + step // 2) % 256, base[2]),
|
||||
)
|
||||
draw.ellipse(
|
||||
[
|
||||
rng.randint(0, width // 2),
|
||||
rng.randint(0, height // 2),
|
||||
rng.randint(width // 2, width),
|
||||
rng.randint(height // 2, height),
|
||||
],
|
||||
fill=(rng.randint(0, 255), rng.randint(0, 255), rng.randint(0, 255)),
|
||||
)
|
||||
|
||||
taken = "20{:02d}:{:02d}:{:02d} {:02d}:{:02d}:{:02d}".format(
|
||||
rng.randint(10, 24),
|
||||
rng.randint(1, 12),
|
||||
rng.randint(1, 28),
|
||||
rng.randint(0, 23),
|
||||
rng.randint(0, 59),
|
||||
rng.randint(0, 59),
|
||||
)
|
||||
exif = Image.Exif()
|
||||
exif[36867] = taken # DateTimeOriginal
|
||||
exif[36868] = taken # DateTimeDigitized
|
||||
exif[306] = taken # DateTime
|
||||
try:
|
||||
lat = rng.uniform(-60.0, 60.0)
|
||||
lon = rng.uniform(-120.0, 120.0)
|
||||
exif[0x8825] = {
|
||||
1: "N" if lat >= 0 else "S",
|
||||
2: _rational(abs(lat)),
|
||||
3: "E" if lon >= 0 else "W",
|
||||
4: _rational(abs(lon)),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=85, exif=exif)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _png_chunk(tag, data):
|
||||
return (
|
||||
struct.pack(">I", len(data))
|
||||
+ tag
|
||||
+ data
|
||||
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
||||
)
|
||||
|
||||
|
||||
def generate_png(rng):
|
||||
"""Return PNG bytes built with the standard library (no EXIF)."""
|
||||
width, height = 320, 240
|
||||
base = (rng.randint(0, 255), rng.randint(0, 255), rng.randint(0, 255))
|
||||
raw = bytearray()
|
||||
for y in range(height):
|
||||
raw.append(0) # filter type: none
|
||||
row = bytearray()
|
||||
for x in range(width):
|
||||
row += bytes(
|
||||
((base[0] + x) % 256, (base[1] + y) % 256, base[2])
|
||||
)
|
||||
raw += row
|
||||
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
|
||||
return (
|
||||
b"\x89PNG\r\n\x1a\n"
|
||||
+ _png_chunk(b"IHDR", ihdr)
|
||||
+ _png_chunk(b"IDAT", zlib.compress(bytes(raw), 6))
|
||||
+ _png_chunk(b"IEND", b"")
|
||||
)
|
||||
|
||||
|
||||
def make_image(rng):
|
||||
"""Return (filename, content_type, data) for one generated photo."""
|
||||
if HAVE_PIL:
|
||||
return "seed-{}.jpg".format(uuid.uuid4().hex[:8]), "image/jpeg", generate_jpeg(rng)
|
||||
return "seed-{}.png".format(uuid.uuid4().hex[:8]), "image/png", generate_png(rng)
|
||||
|
||||
|
||||
# --- seeding ----------------------------------------------------------------
|
||||
|
||||
|
||||
class Stats:
|
||||
def __init__(self):
|
||||
self.albums = 0
|
||||
self.photos = 0
|
||||
self.tags = 0
|
||||
self.comments = 0
|
||||
self.likes = 0
|
||||
self.share_links = 0
|
||||
self.invites = 0
|
||||
|
||||
def report(self):
|
||||
return (
|
||||
"создано: альбомов={albums}, фото={photos}, тегов={tags}, "
|
||||
"комментариев={comments}, лайков={likes}, "
|
||||
"share-ссылок={share_links}, инвайтов={invites}".format(
|
||||
**self.__dict__
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def login(client, email, password):
|
||||
resp = client.request(
|
||||
"POST", "/api/auth/login", json_body={"email": email, "password": password}
|
||||
)
|
||||
client.token = resp["tokens"]["access_token"]
|
||||
|
||||
|
||||
def create_album(client, title):
|
||||
resp = client.request(
|
||||
"POST",
|
||||
"/api/albums",
|
||||
json_body={"title": title, "description": "Тестовые данные (seed)"},
|
||||
)
|
||||
return resp["album"]["id"]
|
||||
|
||||
|
||||
def upload_photo(client, album_id, rng):
|
||||
filename, content_type, data = make_image(rng)
|
||||
resp = client.request(
|
||||
"POST",
|
||||
"/api/albums/{}/photos".format(album_id),
|
||||
files=[("files", filename, content_type, data)],
|
||||
)
|
||||
return resp["photos"][0]["id"]
|
||||
|
||||
|
||||
def wait_for_variants(client, photo_id, timeout, poll=1.0):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
resp = client.request("GET", "/api/photos/{}".format(photo_id))
|
||||
photo = resp.get("photo") if resp else None
|
||||
if photo and photo.get("thumb_url"):
|
||||
return True
|
||||
time.sleep(poll)
|
||||
return False
|
||||
|
||||
|
||||
def add_tag(client, photo_id, name):
|
||||
client.request(
|
||||
"POST", "/api/photos/{}/tags".format(photo_id), json_body={"name": name}
|
||||
)
|
||||
|
||||
|
||||
def add_comment(client, photo_id, text):
|
||||
client.request(
|
||||
"POST", "/api/photos/{}/comments".format(photo_id), json_body={"text": text}
|
||||
)
|
||||
|
||||
|
||||
def add_like(client, photo_id):
|
||||
client.request("POST", "/api/photos/{}/likes".format(photo_id))
|
||||
|
||||
|
||||
def create_share_link(client, album_id):
|
||||
client.request("POST", "/api/albums/{}/share".format(album_id), json_body={})
|
||||
|
||||
|
||||
def create_invite(client, album_id, role):
|
||||
client.request(
|
||||
"POST",
|
||||
"/api/albums/{}/invites".format(album_id),
|
||||
json_body={"role": role},
|
||||
)
|
||||
|
||||
|
||||
def build_plan(args):
|
||||
photo_style = "JPEG+EXIF (Pillow)" if HAVE_PIL else "PNG без EXIF (stdlib)"
|
||||
lines = [
|
||||
"План наполнения (сухой прогон, запросы не отправляются):",
|
||||
" альбомов: {}".format(args.albums),
|
||||
" фото на альбом: {}".format(args.photos_per_album),
|
||||
" формат фото: {}".format(photo_style),
|
||||
]
|
||||
extras = []
|
||||
if args.tags:
|
||||
extras.append("теги")
|
||||
if args.comments:
|
||||
extras.append("комментарии")
|
||||
if args.likes:
|
||||
extras.append("лайки")
|
||||
if args.share_links:
|
||||
extras.append("share-ссылки")
|
||||
if args.invites:
|
||||
extras.append("инвайты")
|
||||
lines.append(" сопутствующие сущности: {}".format(", ".join(extras) or "нет"))
|
||||
lines.append(" ожидание вариантов: {}".format("да" if args.wait_variants else "нет"))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def run(args, base_url, email, password):
|
||||
if args.dry_run:
|
||||
print(build_plan(args))
|
||||
return 0
|
||||
|
||||
rng = random.Random(args.seed)
|
||||
run_tag = "seed-{}-{}".format(int(time.time()), args.seed)
|
||||
client = Client(base_url, timeout=args.timeout)
|
||||
stats = Stats()
|
||||
|
||||
login(client, email, password)
|
||||
print("Вошел как {} @ {}".format(email, base_url))
|
||||
|
||||
for album_index in range(1, args.albums + 1):
|
||||
album_id = create_album(
|
||||
client, "{} #{}".format(run_tag, album_index)
|
||||
)
|
||||
stats.albums += 1
|
||||
print("Альбом {}: {}".format(album_index, album_id))
|
||||
|
||||
for photo_index in range(1, args.photos_per_album + 1):
|
||||
photo_id = upload_photo(client, album_id, rng)
|
||||
stats.photos += 1
|
||||
|
||||
if args.wait_variants:
|
||||
if wait_for_variants(client, photo_id, args.timeout):
|
||||
pass
|
||||
else:
|
||||
print(
|
||||
" ! Таймаут ожидания вариантов для фото {} "
|
||||
"(продолжаю)".format(photo_id),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if args.tags:
|
||||
add_tag(client, photo_id, rng.choice(TAG_NAMES))
|
||||
stats.tags += 1
|
||||
if args.comments and rng.random() < 0.5:
|
||||
add_comment(client, photo_id, rng.choice(COMMENT_TEXTS))
|
||||
stats.comments += 1
|
||||
if args.likes and rng.random() < 0.5:
|
||||
add_like(client, photo_id)
|
||||
stats.likes += 1
|
||||
|
||||
if args.share_links:
|
||||
create_share_link(client, album_id)
|
||||
stats.share_links += 1
|
||||
if args.invites:
|
||||
create_invite(client, album_id, "member")
|
||||
stats.invites += 1
|
||||
|
||||
print(stats.report())
|
||||
return 0
|
||||
|
||||
|
||||
def parse_args(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Наполнить инстанс galo тестовыми данными через API (только добавление)."
|
||||
)
|
||||
parser.add_argument("--albums", type=int, default=2, help="число альбомов (по умолчанию 2)")
|
||||
parser.add_argument(
|
||||
"--photos-per-album",
|
||||
type=int,
|
||||
default=10,
|
||||
help="фото на альбом (по умолчанию 10)",
|
||||
)
|
||||
parser.add_argument("--seed", type=int, default=0, help="seed генератора (по умолчанию 0)")
|
||||
parser.add_argument("--tags", action="store_true", help="добавлять теги")
|
||||
parser.add_argument("--comments", action="store_true", help="добавлять комментарии")
|
||||
parser.add_argument("--likes", action="store_true", help="добавлять лайки")
|
||||
parser.add_argument("--share-links", action="store_true", help="создавать share-ссылки")
|
||||
parser.add_argument("--invites", action="store_true", help="создавать инвайты (роль member)")
|
||||
parser.add_argument(
|
||||
"--wait-variants",
|
||||
action="store_true",
|
||||
help="дожидаться генерации превью каждого фото",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=float,
|
||||
default=30.0,
|
||||
help="таймаут ожидания вариантов и сетевых запросов, сек (по умолчанию 30)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="показать план и не отправлять изменяющих запросов",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = parse_args(argv)
|
||||
base_url = os.environ.get("GALO_BASE_URL", DEFAULT_BASE_URL)
|
||||
email = os.environ.get("GALO_EMAIL")
|
||||
password = os.environ.get("GALO_PASSWORD")
|
||||
|
||||
if not args.dry_run:
|
||||
missing = [
|
||||
name
|
||||
for name, value in (("GALO_EMAIL", email), ("GALO_PASSWORD", password))
|
||||
if not value
|
||||
]
|
||||
if missing:
|
||||
print(
|
||||
"Не заданы переменные окружения: {}".format(", ".join(missing)),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
try:
|
||||
return run(args, base_url, email or "", password or "")
|
||||
except ApiError as exc:
|
||||
if exc.status == 401:
|
||||
message = "Ошибка аутентификации: проверьте GALO_EMAIL и GALO_PASSWORD"
|
||||
else:
|
||||
message = "Ошибка API: {}".format(exc)
|
||||
print(message, file=sys.stderr)
|
||||
return 1
|
||||
except KeyboardInterrupt:
|
||||
print("Прервано пользователем", file=sys.stderr)
|
||||
return 130
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user