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.
Files changed (43) hide show
  1. azimut/__init__.py +3 -0
  2. azimut/__main__.py +3 -0
  3. azimut/api/__init__.py +0 -0
  4. azimut/api/cases.py +237 -0
  5. azimut/api/drafts.py +129 -0
  6. azimut/api/files.py +27 -0
  7. azimut/api/inspect.py +409 -0
  8. azimut/api/media.py +101 -0
  9. azimut/api/proofs.py +134 -0
  10. azimut/api/satellite.py +201 -0
  11. azimut/cli.py +38 -0
  12. azimut/config.py +69 -0
  13. azimut/engine/__init__.py +0 -0
  14. azimut/engine/geo.py +122 -0
  15. azimut/engine/inspect.py +742 -0
  16. azimut/engine/media.py +318 -0
  17. azimut/engine/satellite.py +73 -0
  18. azimut/engine/tiles.py +320 -0
  19. azimut/jobs.py +44 -0
  20. azimut/server.py +58 -0
  21. azimut/static/assets/index-BkJajQDU.css +1 -0
  22. azimut/static/assets/index-rbVNFUtr.js +30 -0
  23. azimut/static/assets/leaflet-rotate-hAaDqhcJ.js +1 -0
  24. azimut/static/assets/oxanium-latin-400-normal-BnNz-3Mf.woff +0 -0
  25. azimut/static/assets/oxanium-latin-400-normal-DcSlRu53.woff2 +0 -0
  26. azimut/static/assets/oxanium-latin-600-normal-DNQhW0y0.woff +0 -0
  27. azimut/static/assets/oxanium-latin-600-normal-DW9ldEDP.woff2 +0 -0
  28. azimut/static/assets/oxanium-latin-700-normal-b_KP-pae.woff +0 -0
  29. azimut/static/assets/oxanium-latin-700-normal-l0hKEjh4.woff2 +0 -0
  30. azimut/static/assets/oxanium-latin-ext-400-normal-B1qoybeA.woff2 +0 -0
  31. azimut/static/assets/oxanium-latin-ext-400-normal-ngSbnDVv.woff +0 -0
  32. azimut/static/assets/oxanium-latin-ext-600-normal-B2S5WIA4.woff2 +0 -0
  33. azimut/static/assets/oxanium-latin-ext-600-normal-DywhiVWf.woff +0 -0
  34. azimut/static/assets/oxanium-latin-ext-700-normal-BSlCClLu.woff2 +0 -0
  35. azimut/static/assets/oxanium-latin-ext-700-normal-CPlnVieJ.woff +0 -0
  36. azimut/static/favicon.svg +68 -0
  37. azimut/static/index.html +14 -0
  38. azimut/workspace.py +343 -0
  39. azimut-0.1.0.dist-info/METADATA +125 -0
  40. azimut-0.1.0.dist-info/RECORD +43 -0
  41. azimut-0.1.0.dist-info/WHEEL +4 -0
  42. azimut-0.1.0.dist-info/entry_points.txt +2 -0
  43. azimut-0.1.0.dist-info/licenses/LICENSE +661 -0
azimut/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Azimut — the OSINT investigator's workbench."""
2
+
3
+ __version__ = "0.1.0"
azimut/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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)