diff --git a/services/travel-memories/app/state.py b/services/travel-memories/app/state.py new file mode 100644 index 0000000..c0dc68b --- /dev/null +++ b/services/travel-memories/app/state.py @@ -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) diff --git a/services/travel-memories/tests/test_state.py b/services/travel-memories/tests/test_state.py new file mode 100644 index 0000000..3bba99f --- /dev/null +++ b/services/travel-memories/tests/test_state.py @@ -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"