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:
iu
2026-10-11 13:27:21 +03:00
parent b1b0b4a83e
commit 36e96cd519
8 changed files with 891 additions and 6 deletions
+28 -1
View File
@@ -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=... # Release version stamped into the binary (-X main.version). Override with VERSION=...
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) 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: frontend:
cd frontend && npm ci && npm run build cd frontend && npm ci && npm run build
rm -rf backend/static/* rm -rf backend/static/*
@@ -53,6 +61,25 @@ tidy:
test: test:
cd backend && go 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: clean:
rm -rf bin backend/static/* rm -rf bin backend/static/*
touch backend/static/.gitkeep touch backend/static/.gitkeep
+90 -5
View File
@@ -29,11 +29,45 @@ SvelteKit через `go:embed`, запуск через systemd.
cp .env.example .env cp .env.example .env
``` ```
Ключевые переменные: `PG_*`, `S3_*` (обязательны `UsePathStyle=true` и регион Секрет подписи токенов (`JWT_SECRET`) в окружении не задаётся: он генерируется
`garage`), `JWT_ACCESS_TTL`, `JWT_REFRESH_TTL`, `HTTP_ADDR`, при первом старте и хранится в слое метаданных (`app_settings`).
`SSE_HEARTBEAT_INTERVAL`, `SSE_CLIENT_BUFFER`. Секрет подписи токенов не задаётся
в окружении: он генерируется при первом старте и хранится в слое метаданных Все параметры окружения (регистр имён учитывается):
(`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 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 ```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`; проверка — задокументированный запуск наполняет локальный инстанс.
+111
View File
@@ -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
View File
@@ -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())