Fix admin import chain and tests
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from backend import database as backend_database
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CaseRecord:
|
||||
id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
age: int | None = None
|
||||
gender: str | None = None
|
||||
health_flags: list[str] = field(default_factory=list)
|
||||
elapsed_hours: int | None = None
|
||||
last_known_place: str | None = None
|
||||
direction: str | None = None
|
||||
terrain_primary: str | None = None
|
||||
weather: str | None = None
|
||||
resources: list[str] = field(default_factory=list)
|
||||
gps_lat: float | None = None
|
||||
gps_lon: float | None = None
|
||||
notes: str | None = None
|
||||
status: str = 'new'
|
||||
found_alive: bool | None = None
|
||||
found_distance_km: float | None = None
|
||||
found_direction: str | None = None
|
||||
found_location_type: str | None = None
|
||||
found_lat: float | None = None
|
||||
found_lon: float | None = None
|
||||
who_found: str | None = None
|
||||
raw_text: str | None = None
|
||||
analysis_log: str | None = None
|
||||
result: dict[str, Any] | None = None
|
||||
|
||||
def to_summary(self) -> dict[str, Any]:
|
||||
return {
|
||||
'id': self.id,
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'updated_at': self.updated_at.isoformat(),
|
||||
'age': self.age,
|
||||
'gender': self.gender,
|
||||
'status': self.status,
|
||||
'found_alive': self.found_alive,
|
||||
'found_distance_km': self.found_distance_km,
|
||||
'found_direction': self.found_direction,
|
||||
'found_location_type': self.found_location_type,
|
||||
'who_found': self.who_found,
|
||||
'last_known_place': self.last_known_place,
|
||||
'direction': self.direction,
|
||||
'terrain_primary': self.terrain_primary,
|
||||
'weather': self.weather,
|
||||
}
|
||||
|
||||
def to_detail(self) -> dict[str, Any]:
|
||||
data = self.to_summary()
|
||||
data.update({
|
||||
'health_flags': list(self.health_flags),
|
||||
'elapsed_hours': self.elapsed_hours,
|
||||
'resources': list(self.resources),
|
||||
'gps_lat': self.gps_lat,
|
||||
'gps_lon': self.gps_lon,
|
||||
'notes': self.notes,
|
||||
'found_lat': self.found_lat,
|
||||
'found_lon': self.found_lon,
|
||||
'raw_text': self.raw_text,
|
||||
'analysis_log': self.analysis_log,
|
||||
'result': self.result,
|
||||
})
|
||||
return data
|
||||
|
||||
|
||||
class TestDB:
|
||||
def __init__(self) -> None:
|
||||
self._cases: dict[str, CaseRecord] = {}
|
||||
|
||||
def create_case(self, **data: Any) -> CaseRecord:
|
||||
now = utcnow()
|
||||
data.pop('mobile', None)
|
||||
data.setdefault('status', 'new')
|
||||
case = CaseRecord(id=str(uuid4()), created_at=now, updated_at=now, **data)
|
||||
self._cases[case.id] = case
|
||||
return case
|
||||
|
||||
def list_cases(self) -> list[CaseRecord]:
|
||||
return sorted(self._cases.values(), key=lambda c: c.created_at, reverse=True)
|
||||
|
||||
def get_case(self, case_id: str) -> CaseRecord | None:
|
||||
return self._cases.get(case_id)
|
||||
|
||||
def update_case(self, case_id: str, **patch: Any) -> CaseRecord | None:
|
||||
case = self._cases.get(case_id)
|
||||
if not case:
|
||||
return None
|
||||
for key, value in patch.items():
|
||||
if hasattr(case, key) and value is not None:
|
||||
setattr(case, key, value)
|
||||
case.updated_at = utcnow()
|
||||
return case
|
||||
|
||||
def delete_case(self, case_id: str) -> bool:
|
||||
return self._cases.pop(case_id, None) is not None
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
cases = list(self._cases.values())
|
||||
total = len(cases)
|
||||
found_alive = sum(1 for c in cases if c.found_alive is True)
|
||||
found_deceased = sum(1 for c in cases if c.found_alive is False)
|
||||
unknown = total - found_alive - found_deceased
|
||||
distances = [c.found_distance_km for c in cases if c.found_distance_km is not None]
|
||||
median_distance = None
|
||||
if distances:
|
||||
distances = sorted(distances)
|
||||
mid = len(distances) // 2
|
||||
if len(distances) % 2:
|
||||
median_distance = distances[mid]
|
||||
else:
|
||||
median_distance = (distances[mid - 1] + distances[mid]) / 2
|
||||
return {
|
||||
'total_cases': total,
|
||||
'found_alive_count': found_alive,
|
||||
'found_deceased_count': found_deceased,
|
||||
'unknown_outcome_count': unknown,
|
||||
'median_found_distance_km': median_distance,
|
||||
'recent_activity_count': sum(1 for c in cases if (utcnow() - c.updated_at).days < 7),
|
||||
}
|
||||
|
||||
def heatmap(self) -> list[dict[str, Any]]:
|
||||
heat = []
|
||||
for case in self._cases.values():
|
||||
if case.gps_lat is not None and case.gps_lon is not None:
|
||||
heat.append({'lat': case.gps_lat, 'lon': case.gps_lon, 'weight': 1})
|
||||
return heat
|
||||
|
||||
|
||||
db = TestDB()
|
||||
backend_database.db = db
|
||||
|
||||
services_pkg = types.ModuleType('backend.services')
|
||||
services_pkg.__path__ = []
|
||||
stats_mod = types.ModuleType('backend.services.stats_service')
|
||||
stats_mod.summary = db.stats
|
||||
stats_mod.heatmap = db.heatmap
|
||||
services_pkg.stats_service = stats_mod
|
||||
sys.modules['backend.services'] = services_pkg
|
||||
sys.modules['backend.services.stats_service'] = stats_mod
|
||||
@@ -0,0 +1,73 @@
|
||||
|
||||
from backend.database import db
|
||||
from backend.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def setup_function() -> None:
|
||||
db._cases.clear() # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_admin_cases_paginates_and_sorts_newest_first() -> None:
|
||||
db.create_case(age=10, gender='male', status='new')
|
||||
db.create_case(age=20, gender='female', status='new')
|
||||
third = db.create_case(age=30, gender='male', status='new')
|
||||
|
||||
response = client.get('/api/v1/admin/cases?page=1&page_size=2')
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload['total'] == 3
|
||||
assert payload['page'] == 1
|
||||
assert payload['page_size'] == 2
|
||||
assert [item['id'] for item in payload['items']] == [third.id, payload['items'][1]['id']]
|
||||
|
||||
|
||||
def test_admin_cases_can_filter_by_status_and_age_range() -> None:
|
||||
db.create_case(age=14, gender='male', status='new')
|
||||
analyzed_young = db.create_case(age=19, gender='female', status='analyzed')
|
||||
db.create_case(age=24, gender='male', status='analyzed')
|
||||
db.create_case(age=29, gender='female', status='closed')
|
||||
|
||||
response = client.get('/api/v1/admin/cases?status=analyzed&age_min=18&age_max=21')
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload['total'] == 1
|
||||
assert [item['id'] for item in payload['items']] == [analyzed_young.id]
|
||||
assert payload['items'][0]['age'] == 19
|
||||
assert payload['items'][0]['status'] == 'analyzed'
|
||||
|
||||
|
||||
def test_admin_case_patch_accepts_outcome_fields_and_note_alias() -> None:
|
||||
case = db.create_case(
|
||||
age=17,
|
||||
gender='female',
|
||||
status='analyzed',
|
||||
last_known_place='Forest edge',
|
||||
notes='initial note',
|
||||
)
|
||||
|
||||
response = client.patch(
|
||||
f'/api/v1/admin/cases/{case.id}',
|
||||
json={
|
||||
'found_alive': True,
|
||||
'found_distance_km': 3.5,
|
||||
'found_lat': 54.1,
|
||||
'found_lon': 37.2,
|
||||
'who_found': 'Search team 4',
|
||||
'note': 'Found near the logging road',
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload['found_alive'] is True
|
||||
assert payload['found_distance_km'] == 3.5
|
||||
assert payload['found_lat'] == 54.1
|
||||
assert payload['found_lon'] == 37.2
|
||||
assert payload['who_found'] == 'Search team 4'
|
||||
assert payload['notes'] == 'Found near the logging road'
|
||||
assert payload['last_known_place'] == 'Forest edge'
|
||||
@@ -0,0 +1,40 @@
|
||||
|
||||
from backend.database import db
|
||||
from backend.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def setup_function() -> None:
|
||||
db._cases.clear() # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_admin_dashboard_reflects_outcomes_and_distance_median() -> None:
|
||||
db.create_case(age=12, gender='female', status='new')
|
||||
db.create_case(age=17, gender='male', status='analyzed', found_alive=True, found_distance_km=2.0)
|
||||
db.create_case(age=19, gender='female', status='closed', found_alive=False, found_distance_km=6.0)
|
||||
|
||||
response = client.get('/api/v1/admin/dashboard')
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload['total_cases'] == 3
|
||||
assert payload['found_alive_count'] == 1
|
||||
assert payload['found_deceased_count'] == 1
|
||||
assert payload['unknown_outcome_count'] == 1
|
||||
assert payload['median_found_distance_km'] == 4.0
|
||||
assert payload['recent_activity_count'] == 3
|
||||
|
||||
|
||||
def test_admin_dashboard_is_zero_safe_when_no_cases_exist() -> None:
|
||||
response = client.get('/api/v1/admin/dashboard')
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload['total_cases'] == 0
|
||||
assert payload['found_alive_count'] == 0
|
||||
assert payload['found_deceased_count'] == 0
|
||||
assert payload['unknown_outcome_count'] == 0
|
||||
assert payload['median_found_distance_km'] is None
|
||||
assert payload['recent_activity_count'] == 0
|
||||
@@ -0,0 +1,73 @@
|
||||
|
||||
from io import BytesIO
|
||||
import zipfile
|
||||
|
||||
from backend.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def build_docx(paragraphs: list[str]) -> BytesIO:
|
||||
document_xml = """<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
{paras}
|
||||
<w:sectPr>
|
||||
<w:pgSz w:w="11906" w:h="16838"/>
|
||||
<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="708" w:footer="708" w:gutter="0"/>
|
||||
</w:sectPr>
|
||||
</w:body>
|
||||
</w:document>
|
||||
"""
|
||||
paras = "\n".join(f"<w:p><w:r><w:t>{paragraph}</w:t></w:r></w:p>" for paragraph in paragraphs)
|
||||
buffer = BytesIO()
|
||||
with zipfile.ZipFile(buffer, 'w', compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr('[Content_Types].xml', """<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
</Types>
|
||||
""")
|
||||
archive.writestr('_rels/.rels', """<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
</Relationships>
|
||||
""")
|
||||
archive.writestr('word/document.xml', document_xml.format(paras=paras))
|
||||
buffer.seek(0)
|
||||
buffer.name = 'sample-report.docx'
|
||||
return buffer
|
||||
|
||||
|
||||
def test_admin_parse_doc_extracts_case_preview_from_docx() -> None:
|
||||
file_obj = build_docx([
|
||||
'Case ID: 124',
|
||||
'Age: 16',
|
||||
'Gender: female',
|
||||
'Status: analyzed',
|
||||
'Found alive: yes',
|
||||
'Found distance km: 3.5',
|
||||
'Who found: Search team 4',
|
||||
'Notes: Found near the logging road',
|
||||
])
|
||||
|
||||
response = client.post(
|
||||
'/api/v1/admin/parse-doc',
|
||||
files={'file': ('sample-report.docx', file_obj, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload['parsed'] is True
|
||||
assert payload['filename'] == 'sample-report.docx'
|
||||
assert payload['preview']['case_id'] == '124'
|
||||
assert payload['preview']['case']['age'] == 16
|
||||
assert payload['preview']['case']['gender'] == 'female'
|
||||
assert payload['preview']['case']['status'] == 'analyzed'
|
||||
assert payload['preview']['case']['found_alive'] is True
|
||||
assert payload['preview']['case']['found_distance_km'] == 3.5
|
||||
assert payload['preview']['case']['who_found'] == 'Search team 4'
|
||||
assert payload['preview']['case']['notes'] == 'Found near the logging road'
|
||||
assert 'Case ID: 124' in payload['raw_text']
|
||||
Reference in New Issue
Block a user