feat: add atomic state management (TripState, Photo, Group)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Photo:
|
||||||
|
id: str
|
||||||
|
original_filename: str
|
||||||
|
local_datetime: str
|
||||||
|
tag: str = "untagged" # untagged | journal | story | skip
|
||||||
|
order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Group:
|
||||||
|
id: str
|
||||||
|
photo_ids: list = field(default_factory=list)
|
||||||
|
entry_type: str = "journal" # journal | story
|
||||||
|
title: str = ""
|
||||||
|
body: str = ""
|
||||||
|
location_city: str = ""
|
||||||
|
location_country: str = ""
|
||||||
|
date: str = ""
|
||||||
|
hero_photo_id: Optional[str] = None
|
||||||
|
shortcode_hints: str = ""
|
||||||
|
status: str = "draft" # draft | written | skipped | exported
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TripState:
|
||||||
|
album_id: str
|
||||||
|
album_name: str
|
||||||
|
grav_trip_slug: str
|
||||||
|
phase: str = "triage"
|
||||||
|
phases_completed: list = field(default_factory=list)
|
||||||
|
phase_stale: list = field(default_factory=list)
|
||||||
|
photos: list = field(default_factory=list)
|
||||||
|
groups: list = field(default_factory=list)
|
||||||
|
notes: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _state_path(album_id: str, app) -> Path:
|
||||||
|
return Path(app.config["STATE_DIR"]) / f"{album_id}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(album_id: str, app) -> Optional[TripState]:
|
||||||
|
path = _state_path(album_id, app)
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
with open(path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
photos = [Photo(**p) for p in data.pop("photos", [])]
|
||||||
|
groups = [Group(**g) for g in data.pop("groups", [])]
|
||||||
|
return TripState(photos=photos, groups=groups, **data)
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(state: TripState, app) -> None:
|
||||||
|
path = _state_path(state.album_id, app)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(".tmp")
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
json.dump(asdict(state), f, indent=2)
|
||||||
|
os.rename(tmp, path)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
from app.state import TripState, Photo, Group, load_state, save_state
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app_ctx(flask_app):
|
||||||
|
with flask_app.app_context():
|
||||||
|
yield flask_app
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_and_load_roundtrip(app_ctx, state_dir):
|
||||||
|
state = TripState(
|
||||||
|
album_id="test-album",
|
||||||
|
album_name="Test",
|
||||||
|
grav_trip_slug="test-trip",
|
||||||
|
photos=[Photo(id="p1", original_filename="a.jpg",
|
||||||
|
local_datetime="2023-01-01T10:00:00")],
|
||||||
|
groups=[],
|
||||||
|
)
|
||||||
|
save_state(state, app_ctx)
|
||||||
|
loaded = load_state("test-album", app_ctx)
|
||||||
|
assert loaded.album_id == "test-album"
|
||||||
|
assert loaded.photos[0].id == "p1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_atomic_write_uses_tmp(app_ctx, state_dir, monkeypatch):
|
||||||
|
written_paths = []
|
||||||
|
real_rename = __import__("os").rename
|
||||||
|
def fake_rename(src, dst):
|
||||||
|
written_paths.append(src)
|
||||||
|
real_rename(src, dst)
|
||||||
|
monkeypatch.setattr("app.state.os.rename", fake_rename)
|
||||||
|
state = TripState(album_id="atomic-test", album_name="X", grav_trip_slug="x")
|
||||||
|
save_state(state, app_ctx)
|
||||||
|
assert any(str(p).endswith(".tmp") for p in written_paths)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_nonexistent_returns_none(app_ctx):
|
||||||
|
assert load_state("no-such-album", app_ctx) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_exported_status_field_preserved(app_ctx):
|
||||||
|
state = TripState(
|
||||||
|
album_id="export-test", album_name="E", grav_trip_slug="e",
|
||||||
|
groups=[Group(id="g1", photo_ids=[], entry_type="journal",
|
||||||
|
status="exported")]
|
||||||
|
)
|
||||||
|
save_state(state, app_ctx)
|
||||||
|
loaded = load_state("export-test", app_ctx)
|
||||||
|
assert loaded.groups[0].status == "exported"
|
||||||
Reference in New Issue
Block a user