diff --git a/Makefile b/Makefile index 2e8ee55..5be49d1 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index a0ba69d..3ce1ffb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/openspec/changes/archive/2026-10-11-add-seed-data-script/.openspec.yaml b/openspec/changes/archive/2026-10-11-add-seed-data-script/.openspec.yaml new file mode 100644 index 0000000..a369219 --- /dev/null +++ b/openspec/changes/archive/2026-10-11-add-seed-data-script/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/archive/2026-10-11-add-seed-data-script/proposal.md b/openspec/changes/archive/2026-10-11-add-seed-data-script/proposal.md new file mode 100644 index 0000000..c752759 --- /dev/null +++ b/openspec/changes/archive/2026-10-11-add-seed-data-script/proposal.md @@ -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 + + +## Impact + +- Новый файл `scripts/seed.py`; новый раздел в `README.md` с запуском и параметрами. +- Backend, frontend, API и хранилище не меняются — используются только существующие эндпоинты. +- Опциональная внешняя зависимость Pillow (для JPEG+EXIF); ядро работает на стандартной + библиотеке. Требуется запущенный инстанс с существующим пользователем-владельцем. diff --git a/openspec/changes/archive/2026-10-11-add-seed-data-script/specs/dev-seed-data/spec.md b/openspec/changes/archive/2026-10-11-add-seed-data-script/specs/dev-seed-data/spec.md new file mode 100644 index 0000000..c6e3a40 --- /dev/null +++ b/openspec/changes/archive/2026-10-11-add-seed-data-script/specs/dev-seed-data/spec.md @@ -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** используются документированные значения по умолчанию diff --git a/openspec/changes/archive/2026-10-11-add-seed-data-script/tasks.md b/openspec/changes/archive/2026-10-11-add-seed-data-script/tasks.md new file mode 100644 index 0000000..8a4479e --- /dev/null +++ b/openspec/changes/archive/2026-10-11-add-seed-data-script/tasks.md @@ -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`; проверка — задокументированный запуск наполняет локальный инстанс. diff --git a/openspec/specs/dev-seed-data/spec.md b/openspec/specs/dev-seed-data/spec.md new file mode 100644 index 0000000..28e6476 --- /dev/null +++ b/openspec/specs/dev-seed-data/spec.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** используются документированные значения по умолчанию diff --git a/scripts/seed.py b/scripts/seed.py new file mode 100644 index 0000000..2bdb521 --- /dev/null +++ b/scripts/seed.py @@ -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())