558 lines
18 KiB
Python
558 lines
18 KiB
Python
#!/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 (one of GALO_EMAIL/GALO_PHONE required)
|
|
GALO_PHONE owner phone, E.164 (one of GALO_EMAIL/GALO_PHONE required)
|
|
GALO_PASSWORD owner password (required)
|
|
|
|
Login uses the single-identifier contract: the email is preferred, otherwise the
|
|
phone is used.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import io
|
|
import json
|
|
import os
|
|
import random
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
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
|
|
|
|
HAVE_FFMPEG = shutil.which("ffmpeg") is not None
|
|
|
|
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)
|
|
|
|
|
|
def generate_video(rng):
|
|
"""Return (filename, content_type, data) for one generated video.
|
|
|
|
Uses ffmpeg's synthetic source so the fixture is small and deterministic.
|
|
Returns None when ffmpeg is unavailable.
|
|
"""
|
|
if not HAVE_FFMPEG:
|
|
return None
|
|
seconds = rng.randint(1, 3)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
path = os.path.join(tmp, "seed.mp4")
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-v", "error",
|
|
"-y",
|
|
"-f", "lavfi",
|
|
"-i", "testsrc=size=320x240:rate=15",
|
|
"-t", str(seconds),
|
|
"-pix_fmt", "yuv420p",
|
|
"-c:v", "libx264",
|
|
"-preset", "veryfast",
|
|
path,
|
|
]
|
|
try:
|
|
subprocess.run(cmd, check=True, capture_output=True)
|
|
except (subprocess.CalledProcessError, OSError):
|
|
return None
|
|
with open(path, "rb") as fh:
|
|
data = fh.read()
|
|
return "seed-{}.mp4".format(uuid.uuid4().hex[:8]), "video/mp4", data
|
|
|
|
|
|
# --- seeding ----------------------------------------------------------------
|
|
|
|
|
|
class Stats:
|
|
def __init__(self):
|
|
self.albums = 0
|
|
self.photos = 0
|
|
self.videos = 0
|
|
self.tags = 0
|
|
self.comments = 0
|
|
self.likes = 0
|
|
self.share_links = 0
|
|
self.invites = 0
|
|
|
|
def report(self):
|
|
return (
|
|
"создано: альбомов={albums}, фото={photos}, видео={videos}, "
|
|
"тегов={tags}, комментариев={comments}, лайков={likes}, "
|
|
"share-ссылок={share_links}, инвайтов={invites}".format(
|
|
**self.__dict__
|
|
)
|
|
)
|
|
|
|
|
|
def login(client, identifier, password):
|
|
resp = client.request(
|
|
"POST",
|
|
"/api/auth/login",
|
|
json_body={"identifier": identifier, "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 upload_video(client, album_id, rng):
|
|
"""Upload one video fixture. Returns the photo id, or None when skipped."""
|
|
fixture = generate_video(rng)
|
|
if fixture is None:
|
|
return None
|
|
filename, content_type, data = fixture
|
|
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 "нет"))
|
|
video_state = "нет"
|
|
if args.video:
|
|
video_state = "да" if HAVE_FFMPEG else "да, но ffmpeg не найден (пропуск)"
|
|
lines.append(" видео-фикстуры: {}".format(video_state))
|
|
return "\n".join(lines)
|
|
|
|
|
|
def run(args, base_url, identifier, 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, identifier, password)
|
|
print("Вошел как {} @ {}".format(identifier, base_url))
|
|
|
|
if args.video and not HAVE_FFMPEG:
|
|
print(
|
|
" ! ffmpeg не найден: видео-фикстуры пропускаются",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
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
|
|
if args.video and upload_video(client, album_id, rng) is not None:
|
|
stats.videos += 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(
|
|
"--video",
|
|
action="store_true",
|
|
help="загружать видео-фикстуры (требует ffmpeg; иначе пропускаются)",
|
|
)
|
|
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")
|
|
phone = os.environ.get("GALO_PHONE")
|
|
password = os.environ.get("GALO_PASSWORD")
|
|
identifier = email or phone
|
|
|
|
if not args.dry_run:
|
|
if not identifier:
|
|
print(
|
|
"Не задан идентификатор входа: укажите GALO_EMAIL или GALO_PHONE",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
if not password:
|
|
print("Не задана переменная окружения: GALO_PASSWORD", file=sys.stderr)
|
|
return 2
|
|
|
|
try:
|
|
return run(args, base_url, identifier or "", password or "")
|
|
except ApiError as exc:
|
|
if exc.status == 401:
|
|
message = (
|
|
"Ошибка аутентификации: проверьте GALO_EMAIL/GALO_PHONE и 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())
|