azimut 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- azimut/__init__.py +3 -0
- azimut/__main__.py +3 -0
- azimut/api/__init__.py +0 -0
- azimut/api/cases.py +237 -0
- azimut/api/drafts.py +129 -0
- azimut/api/files.py +27 -0
- azimut/api/inspect.py +409 -0
- azimut/api/media.py +101 -0
- azimut/api/proofs.py +134 -0
- azimut/api/satellite.py +201 -0
- azimut/cli.py +38 -0
- azimut/config.py +69 -0
- azimut/engine/__init__.py +0 -0
- azimut/engine/geo.py +122 -0
- azimut/engine/inspect.py +742 -0
- azimut/engine/media.py +318 -0
- azimut/engine/satellite.py +73 -0
- azimut/engine/tiles.py +320 -0
- azimut/jobs.py +44 -0
- azimut/server.py +58 -0
- azimut/static/assets/index-BkJajQDU.css +1 -0
- azimut/static/assets/index-rbVNFUtr.js +30 -0
- azimut/static/assets/leaflet-rotate-hAaDqhcJ.js +1 -0
- azimut/static/assets/oxanium-latin-400-normal-BnNz-3Mf.woff +0 -0
- azimut/static/assets/oxanium-latin-400-normal-DcSlRu53.woff2 +0 -0
- azimut/static/assets/oxanium-latin-600-normal-DNQhW0y0.woff +0 -0
- azimut/static/assets/oxanium-latin-600-normal-DW9ldEDP.woff2 +0 -0
- azimut/static/assets/oxanium-latin-700-normal-b_KP-pae.woff +0 -0
- azimut/static/assets/oxanium-latin-700-normal-l0hKEjh4.woff2 +0 -0
- azimut/static/assets/oxanium-latin-ext-400-normal-B1qoybeA.woff2 +0 -0
- azimut/static/assets/oxanium-latin-ext-400-normal-ngSbnDVv.woff +0 -0
- azimut/static/assets/oxanium-latin-ext-600-normal-B2S5WIA4.woff2 +0 -0
- azimut/static/assets/oxanium-latin-ext-600-normal-DywhiVWf.woff +0 -0
- azimut/static/assets/oxanium-latin-ext-700-normal-BSlCClLu.woff2 +0 -0
- azimut/static/assets/oxanium-latin-ext-700-normal-CPlnVieJ.woff +0 -0
- azimut/static/favicon.svg +68 -0
- azimut/static/index.html +14 -0
- azimut/workspace.py +343 -0
- azimut-0.1.0.dist-info/METADATA +125 -0
- azimut-0.1.0.dist-info/RECORD +43 -0
- azimut-0.1.0.dist-info/WHEEL +4 -0
- azimut-0.1.0.dist-info/entry_points.txt +2 -0
- azimut-0.1.0.dist-info/licenses/LICENSE +661 -0
azimut/__init__.py
ADDED
azimut/__main__.py
ADDED
azimut/api/__init__.py
ADDED
|
File without changes
|
azimut/api/cases.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""REST API for case lifecycle, notes, entities and links."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from ..engine import media as media_engine
|
|
11
|
+
from ..engine import satellite as satellite_engine
|
|
12
|
+
from ..workspace import Case, CaseError
|
|
13
|
+
|
|
14
|
+
router = APIRouter(prefix="/api/cases", tags=["cases"])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_case(case_id: str) -> Case:
|
|
18
|
+
try:
|
|
19
|
+
return Case.open(case_id)
|
|
20
|
+
except CaseError as exc:
|
|
21
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _ensure_name_free(name: str, *, exclude_id: str | None = None) -> None:
|
|
25
|
+
"""Reject a case name already taken by another (non-scratch) case, matched
|
|
26
|
+
case-insensitively on the trimmed name. ``exclude_id`` lets a rename keep
|
|
27
|
+
its own name."""
|
|
28
|
+
wanted = name.strip().casefold()
|
|
29
|
+
for c in Case.list_all():
|
|
30
|
+
if c.get("scratch") or c["id"] == exclude_id:
|
|
31
|
+
continue
|
|
32
|
+
if str(c.get("name", "")).strip().casefold() == wanted:
|
|
33
|
+
raise HTTPException(status_code=409, detail=f"a case named '{name}' already exists")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _unlink_inside(case: Case, rel_path: str) -> None:
|
|
37
|
+
try:
|
|
38
|
+
case.resolve_inside(rel_path).unlink(missing_ok=True)
|
|
39
|
+
except CaseError:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CreateCase(BaseModel):
|
|
44
|
+
name: str = Field(min_length=1, max_length=120)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PromoteCase(BaseModel):
|
|
48
|
+
name: str = Field(min_length=1, max_length=120)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Notes(BaseModel):
|
|
52
|
+
text: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class EntityIn(BaseModel):
|
|
56
|
+
type: str = Field(min_length=1, max_length=40)
|
|
57
|
+
label: str = Field(min_length=1, max_length=300)
|
|
58
|
+
attrs: dict[str, Any] = Field(default_factory=dict)
|
|
59
|
+
status: str = "confirmed"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class EntityPatch(BaseModel):
|
|
63
|
+
type: str | None = None
|
|
64
|
+
label: str | None = None
|
|
65
|
+
attrs: dict[str, Any] | None = None
|
|
66
|
+
status: str | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class LinkIn(BaseModel):
|
|
70
|
+
from_id: str
|
|
71
|
+
to_id: str
|
|
72
|
+
type: str = Field(min_length=1, max_length=60)
|
|
73
|
+
status: str = "confirmed"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FolderIn(BaseModel):
|
|
77
|
+
name: str = Field(min_length=1, max_length=120)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@router.get("")
|
|
81
|
+
def list_cases() -> list[dict[str, Any]]:
|
|
82
|
+
return Case.list_all()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@router.post("")
|
|
86
|
+
def create_case(body: CreateCase) -> dict[str, Any]:
|
|
87
|
+
_ensure_name_free(body.name)
|
|
88
|
+
try:
|
|
89
|
+
case = Case.create(body.name)
|
|
90
|
+
except CaseError as exc:
|
|
91
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
92
|
+
return {"id": case.id, **case.read()}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.post("/scratch")
|
|
96
|
+
def create_scratch() -> dict[str, Any]:
|
|
97
|
+
case = Case.create("Scratch session", scratch=True)
|
|
98
|
+
return {"id": case.id, **case.read()}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@router.get("/{case_id}")
|
|
102
|
+
def read_case(case_id: str) -> dict[str, Any]:
|
|
103
|
+
case = get_case(case_id)
|
|
104
|
+
return {"id": case.id, "scratch": case.is_scratch, **case.read()}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@router.post("/{case_id}/promote")
|
|
108
|
+
def promote_case(case_id: str, body: PromoteCase) -> dict[str, Any]:
|
|
109
|
+
case = get_case(case_id)
|
|
110
|
+
_ensure_name_free(body.name)
|
|
111
|
+
try:
|
|
112
|
+
promoted = case.promote(body.name)
|
|
113
|
+
except CaseError as exc:
|
|
114
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
115
|
+
return {"id": promoted.id, **promoted.read()}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@router.patch("/{case_id}")
|
|
119
|
+
def rename_case(case_id: str, body: CreateCase) -> dict[str, Any]:
|
|
120
|
+
case = get_case(case_id)
|
|
121
|
+
_ensure_name_free(body.name, exclude_id=case.id)
|
|
122
|
+
case.rename(body.name)
|
|
123
|
+
return {"id": case.id, **case.read()}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@router.delete("/{case_id}")
|
|
127
|
+
def delete_case(case_id: str) -> dict[str, str]:
|
|
128
|
+
get_case(case_id).delete()
|
|
129
|
+
return {"status": "deleted"}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@router.get("/{case_id}/notes")
|
|
133
|
+
def read_notes(case_id: str) -> dict[str, str]:
|
|
134
|
+
return {"text": get_case(case_id).read_notes()}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@router.put("/{case_id}/notes")
|
|
138
|
+
def write_notes(case_id: str, body: Notes) -> dict[str, str]:
|
|
139
|
+
get_case(case_id).write_notes(body.text)
|
|
140
|
+
return {"status": "saved"}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@router.post("/{case_id}/entities")
|
|
144
|
+
def add_entity(case_id: str, body: EntityIn) -> dict[str, Any]:
|
|
145
|
+
case = get_case(case_id)
|
|
146
|
+
return case.add_entity(
|
|
147
|
+
body.type, body.label, body.attrs, by="user", status=body.status # type: ignore[arg-type]
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@router.patch("/{case_id}/entities/{entity_id}")
|
|
152
|
+
def update_entity(case_id: str, entity_id: str, body: EntityPatch) -> dict[str, Any]:
|
|
153
|
+
case = get_case(case_id)
|
|
154
|
+
patch = {k: v for k, v in body.model_dump().items() if v is not None}
|
|
155
|
+
try:
|
|
156
|
+
return case.update_entity(entity_id, patch)
|
|
157
|
+
except CaseError as exc:
|
|
158
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@router.delete("/{case_id}/entities/{entity_id}")
|
|
162
|
+
def remove_entity(case_id: str, entity_id: str) -> dict[str, str]:
|
|
163
|
+
"""Delete an entity and the on-disk artifact it stands for, so removing a
|
|
164
|
+
row in the sidebar deletes it everywhere it appears (spec §3.5)."""
|
|
165
|
+
case = get_case(case_id)
|
|
166
|
+
entity = next((e for e in case.read()["entities"] if e["id"] == entity_id), None)
|
|
167
|
+
if entity is None:
|
|
168
|
+
raise HTTPException(status_code=404, detail=f"entity '{entity_id}' not found")
|
|
169
|
+
|
|
170
|
+
etype = entity.get("type")
|
|
171
|
+
attrs = entity.get("attrs", {})
|
|
172
|
+
|
|
173
|
+
# media has its own delete that drops file + thumbnail + sidecar + entity
|
|
174
|
+
if etype == "media" and attrs.get("path"):
|
|
175
|
+
try:
|
|
176
|
+
media_engine.delete_media(case, attrs["path"])
|
|
177
|
+
except CaseError as exc:
|
|
178
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
179
|
+
return {"status": "deleted"}
|
|
180
|
+
|
|
181
|
+
# other artifact-backed types: drop the file(s) before removing the row.
|
|
182
|
+
# captures carry the image; a bare place (no path) has nothing on disk.
|
|
183
|
+
if etype in ("capture", "place") and attrs.get("path"):
|
|
184
|
+
satellite_engine.unlink_capture(case, attrs["path"])
|
|
185
|
+
elif etype == "proof" and attrs.get("spec"):
|
|
186
|
+
_unlink_inside(case, attrs["spec"])
|
|
187
|
+
_unlink_inside(case, attrs["spec"].removesuffix(".json") + ".png")
|
|
188
|
+
elif etype == "post" and attrs.get("draft"):
|
|
189
|
+
_unlink_inside(case, attrs["draft"])
|
|
190
|
+
elif etype == "inspect-session" and attrs.get("spec"):
|
|
191
|
+
_unlink_inside(case, attrs["spec"])
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
case.remove_entity(entity_id)
|
|
195
|
+
except CaseError as exc:
|
|
196
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
197
|
+
return {"status": "deleted"}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@router.post("/{case_id}/links")
|
|
201
|
+
def add_link(case_id: str, body: LinkIn) -> dict[str, Any]:
|
|
202
|
+
case = get_case(case_id)
|
|
203
|
+
try:
|
|
204
|
+
return case.add_link(
|
|
205
|
+
body.from_id, body.to_id, body.type, by="user", status=body.status # type: ignore[arg-type]
|
|
206
|
+
)
|
|
207
|
+
except CaseError as exc:
|
|
208
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@router.delete("/{case_id}/links/{link_id}")
|
|
212
|
+
def remove_link(case_id: str, link_id: str) -> dict[str, str]:
|
|
213
|
+
try:
|
|
214
|
+
get_case(case_id).remove_link(link_id)
|
|
215
|
+
except CaseError as exc:
|
|
216
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
217
|
+
return {"status": "deleted"}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@router.get("/{case_id}/folders")
|
|
221
|
+
def list_folders(case_id: str) -> list[str]:
|
|
222
|
+
return get_case(case_id).list_folders()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@router.post("/{case_id}/folders")
|
|
226
|
+
def add_folder(case_id: str, body: FolderIn) -> list[str]:
|
|
227
|
+
case = get_case(case_id)
|
|
228
|
+
try:
|
|
229
|
+
return case.add_folder(body.name)
|
|
230
|
+
except CaseError as exc:
|
|
231
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@router.delete("/{case_id}/folders")
|
|
235
|
+
def remove_folder(case_id: str, name: str) -> list[str]:
|
|
236
|
+
return get_case(case_id).remove_folder(name)
|
|
237
|
+
|
azimut/api/drafts.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""REST API for post drafts: save, list, load, delete.
|
|
2
|
+
|
|
3
|
+
A draft is the re-editable state of the Post Composer (thread of tweets built
|
|
4
|
+
from a geolocation). Drafts live in ``<case>/exports/`` as JSON and are indexed
|
|
5
|
+
as ``post`` entities so they show up in the case sidebar (spec §4 — the case is
|
|
6
|
+
the product). Azimut never posts on your behalf; a draft is prepared here and the
|
|
7
|
+
human publishes it (spec §6 non-goals).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from fastapi import APIRouter, HTTPException
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
|
|
20
|
+
from ..workspace import CaseError
|
|
21
|
+
from .cases import get_case
|
|
22
|
+
|
|
23
|
+
router = APIRouter(prefix="/api", tags=["drafts"])
|
|
24
|
+
|
|
25
|
+
DRAFT_MARKER = 1
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DraftIn(BaseModel):
|
|
29
|
+
name: str | None = None # slug; None → derived from title
|
|
30
|
+
title: str = Field(min_length=1, max_length=200)
|
|
31
|
+
state: dict[str, Any] # opaque Post Composer state (fields + tweets)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _slug(text: str) -> str:
|
|
35
|
+
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|
36
|
+
return slug[:80] or "draft"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _now() -> str:
|
|
40
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.get("/cases/{case_id}/drafts")
|
|
44
|
+
def list_drafts(case_id: str) -> list[dict[str, Any]]:
|
|
45
|
+
case = get_case(case_id)
|
|
46
|
+
drafts = []
|
|
47
|
+
for path in sorted(case.subdir("exports").glob("*.json")):
|
|
48
|
+
try:
|
|
49
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
50
|
+
except (OSError, json.JSONDecodeError):
|
|
51
|
+
continue
|
|
52
|
+
if data.get("azimut_draft") != DRAFT_MARKER:
|
|
53
|
+
continue
|
|
54
|
+
drafts.append(
|
|
55
|
+
{
|
|
56
|
+
"name": path.stem,
|
|
57
|
+
"title": data.get("title", path.stem),
|
|
58
|
+
"updated_at": data.get("updated_at"),
|
|
59
|
+
"created_at": data.get("created_at"),
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
drafts.sort(key=lambda d: d.get("updated_at") or "", reverse=True)
|
|
63
|
+
return drafts
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@router.get("/cases/{case_id}/drafts/{name}")
|
|
67
|
+
def load_draft(case_id: str, name: str) -> dict[str, Any]:
|
|
68
|
+
case = get_case(case_id)
|
|
69
|
+
try:
|
|
70
|
+
path = case.resolve_inside(f"exports/{name}.json")
|
|
71
|
+
except CaseError as exc:
|
|
72
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
73
|
+
if not path.exists():
|
|
74
|
+
raise HTTPException(status_code=404, detail="draft not found")
|
|
75
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@router.post("/cases/{case_id}/drafts")
|
|
79
|
+
def save_draft(case_id: str, body: DraftIn) -> dict[str, Any]:
|
|
80
|
+
case = get_case(case_id)
|
|
81
|
+
name = _slug(body.name or body.title)
|
|
82
|
+
exports_dir = case.subdir("exports")
|
|
83
|
+
|
|
84
|
+
path = exports_dir / f"{name}.json"
|
|
85
|
+
existing_created = None
|
|
86
|
+
if path.exists():
|
|
87
|
+
try:
|
|
88
|
+
existing_created = json.loads(path.read_text(encoding="utf-8")).get("created_at")
|
|
89
|
+
except (OSError, json.JSONDecodeError):
|
|
90
|
+
existing_created = None
|
|
91
|
+
|
|
92
|
+
data = {
|
|
93
|
+
"azimut_draft": DRAFT_MARKER,
|
|
94
|
+
"title": body.title,
|
|
95
|
+
"created_at": existing_created or _now(),
|
|
96
|
+
"updated_at": _now(),
|
|
97
|
+
"state": body.state,
|
|
98
|
+
}
|
|
99
|
+
path.write_text(
|
|
100
|
+
json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# upsert the post entity (analyst action → confirmed)
|
|
104
|
+
existing = case.find_entity(attr="draft", value=f"exports/{name}.json")
|
|
105
|
+
if existing:
|
|
106
|
+
case.update_entity(existing["id"], {"label": body.title})
|
|
107
|
+
else:
|
|
108
|
+
case.add_entity(
|
|
109
|
+
"post",
|
|
110
|
+
body.title,
|
|
111
|
+
attrs={"draft": f"exports/{name}.json"},
|
|
112
|
+
by="post-composer",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return {"name": name, "draft": f"exports/{name}.json"}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@router.delete("/cases/{case_id}/drafts/{name}")
|
|
119
|
+
def delete_draft(case_id: str, name: str) -> dict[str, str]:
|
|
120
|
+
case = get_case(case_id)
|
|
121
|
+
try:
|
|
122
|
+
path = case.resolve_inside(f"exports/{name}.json")
|
|
123
|
+
except CaseError as exc:
|
|
124
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
125
|
+
path.unlink(missing_ok=True)
|
|
126
|
+
entity = case.find_entity(attr="draft", value=f"exports/{name}.json")
|
|
127
|
+
if entity:
|
|
128
|
+
case.remove_entity(entity["id"])
|
|
129
|
+
return {"status": "deleted"}
|
azimut/api/files.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Serve case files (media, thumbnails, satellite crops, proofs) to the UI.
|
|
2
|
+
|
|
3
|
+
Only paths inside a case directory are reachable (Case.resolve_inside refuses
|
|
4
|
+
traversal), and the server itself binds to localhost only.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, HTTPException
|
|
10
|
+
from fastapi.responses import FileResponse
|
|
11
|
+
|
|
12
|
+
from ..workspace import CaseError
|
|
13
|
+
from .cases import get_case
|
|
14
|
+
|
|
15
|
+
router = APIRouter(prefix="/files", tags=["files"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.get("/{case_id}/{rel_path:path}")
|
|
19
|
+
def case_file(case_id: str, rel_path: str) -> FileResponse:
|
|
20
|
+
case = get_case(case_id)
|
|
21
|
+
try:
|
|
22
|
+
path = case.resolve_inside(rel_path)
|
|
23
|
+
except CaseError as exc:
|
|
24
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
25
|
+
if not path.is_file():
|
|
26
|
+
raise HTTPException(status_code=404, detail="file not found")
|
|
27
|
+
return FileResponse(path)
|