Skip to content

Commit 2dfa845

Browse files
committed
feat: support the new acquisition_type endpoint
1 parent 1ccc8e5 commit 2dfa845

6 files changed

Lines changed: 517 additions & 0 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""acquisitions — allowed acquisition types and scheduled acquisitions.
2+
3+
Public API
4+
----------
5+
Models:
6+
AcquisitionTypeEntry, ScheduledAcquisition, ALLOWED_PLATFORMS
7+
8+
Storage (S3-backed):
9+
add_acquisition_type, get_allowed_types,
10+
add_scheduled_acquisition, get_scheduled_acquisitions, get_scheduled_acquisition
11+
"""
12+
13+
from .models import ALLOWED_PLATFORMS, AcquisitionTypeEntry, ScheduledAcquisition
14+
from .store import (
15+
add_acquisition_type,
16+
add_scheduled_acquisition,
17+
get_allowed_types,
18+
get_scheduled_acquisition,
19+
get_scheduled_acquisitions,
20+
)
21+
22+
__all__ = [
23+
"ALLOWED_PLATFORMS",
24+
"AcquisitionTypeEntry",
25+
"ScheduledAcquisition",
26+
"add_acquisition_type",
27+
"get_allowed_types",
28+
"add_scheduled_acquisition",
29+
"get_scheduled_acquisitions",
30+
"get_scheduled_acquisition",
31+
]
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""FastAPI router for the scheduled-acquisitions REST endpoints.
2+
3+
Routes
4+
------
5+
POST /acquisition-types
6+
Body: {"platform": str, "acquisition_type": str}
7+
Adds a new allowed (platform, acquisition_type) pair.
8+
9+
GET /acquisition-types
10+
Returns all allowed (platform, acquisition_type) pairs.
11+
12+
POST /scheduled-acquisitions
13+
Body: {"subject_id": str, "date": "YYYY-MM-DD", "acquisition_type": str}
14+
Validates acquisition_type against the allowed types, stores the record,
15+
and returns {"uuid": "<uuid>"}.
16+
17+
GET /scheduled-acquisitions?include_past=false
18+
Returns all scheduled acquisitions. By default only acquisitions scheduled
19+
for today or later are returned; pass include_past=true to include all.
20+
21+
GET /scheduled-acquisitions/{acquisition_uuid}
22+
Returns {"subject_id", "date", "platform", "acquisition_type"} for a
23+
single scheduled acquisition.
24+
"""
25+
26+
import logging
27+
28+
from fastapi import APIRouter, Request
29+
from fastapi.responses import JSONResponse
30+
31+
from .store import (
32+
add_acquisition_type,
33+
add_scheduled_acquisition,
34+
get_allowed_types,
35+
get_scheduled_acquisition,
36+
get_scheduled_acquisitions,
37+
)
38+
39+
_logger = logging.getLogger(__name__)
40+
41+
acquisitions_router = APIRouter()
42+
43+
44+
@acquisitions_router.post("/acquisition-types")
45+
async def acquisition_types_post(request: Request):
46+
body = await request.json()
47+
platform = body.get("platform")
48+
acquisition_type = body.get("acquisition_type")
49+
if not platform or not acquisition_type:
50+
return JSONResponse(
51+
status_code=400,
52+
content={"error": "platform and acquisition_type are required"},
53+
)
54+
55+
try:
56+
entry = add_acquisition_type(platform, acquisition_type)
57+
except ValueError as e:
58+
return JSONResponse(status_code=400, content={"error": str(e)})
59+
except Exception as e:
60+
_logger.exception("POST /acquisition-types platform=%s acquisition_type=%s", platform, acquisition_type)
61+
return JSONResponse(status_code=500, content={"error": str(e)})
62+
63+
return JSONResponse(content=entry)
64+
65+
66+
@acquisitions_router.get("/acquisition-types")
67+
async def acquisition_types_get():
68+
try:
69+
entries = get_allowed_types()
70+
except Exception as e:
71+
_logger.exception("GET /acquisition-types")
72+
return JSONResponse(status_code=500, content={"error": str(e)})
73+
return JSONResponse(content=entries)
74+
75+
76+
@acquisitions_router.post("/scheduled-acquisitions")
77+
async def scheduled_acquisitions_post(request: Request):
78+
body = await request.json()
79+
subject_id = body.get("subject_id")
80+
acquisition_date = body.get("date")
81+
acquisition_type = body.get("acquisition_type")
82+
if not subject_id or not acquisition_date or not acquisition_type:
83+
return JSONResponse(
84+
status_code=400,
85+
content={"error": "subject_id, date, and acquisition_type are required"},
86+
)
87+
88+
try:
89+
acquisition_uuid = add_scheduled_acquisition(subject_id, acquisition_date, acquisition_type)
90+
except ValueError as e:
91+
return JSONResponse(status_code=400, content={"error": str(e)})
92+
except Exception as e:
93+
_logger.exception("POST /scheduled-acquisitions subject_id=%s", subject_id)
94+
return JSONResponse(status_code=500, content={"error": str(e)})
95+
96+
return JSONResponse(content={"uuid": acquisition_uuid})
97+
98+
99+
@acquisitions_router.get("/scheduled-acquisitions")
100+
async def scheduled_acquisitions_get(request: Request):
101+
include_past = request.query_params.get("include_past", "false").lower() == "true"
102+
try:
103+
records = get_scheduled_acquisitions(include_past=include_past)
104+
except Exception as e:
105+
_logger.exception("GET /scheduled-acquisitions include_past=%s", include_past)
106+
return JSONResponse(status_code=500, content={"error": str(e)})
107+
return JSONResponse(content=records)
108+
109+
110+
@acquisitions_router.get("/scheduled-acquisitions/{acquisition_uuid}")
111+
async def scheduled_acquisition_get(acquisition_uuid: str):
112+
try:
113+
record = get_scheduled_acquisition(acquisition_uuid)
114+
except Exception as e:
115+
_logger.exception("GET /scheduled-acquisitions/%s", acquisition_uuid)
116+
return JSONResponse(status_code=500, content={"error": str(e)})
117+
118+
if record is None:
119+
return JSONResponse(
120+
status_code=404,
121+
content={"error": f"No scheduled acquisition found for uuid '{acquisition_uuid}'"},
122+
)
123+
124+
return JSONResponse(content=record)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Pydantic models for scheduled-acquisition tracking."""
2+
3+
import datetime
4+
from typing import List
5+
6+
from pydantic import BaseModel, Field
7+
8+
# Placeholder for the fixed list of allowed platform values.
9+
# When empty, platform values are not restricted. Populate this list to
10+
# start enforcing which platforms may be registered.
11+
ALLOWED_PLATFORMS: List[str] = []
12+
13+
14+
class AcquisitionTypeEntry(BaseModel):
15+
"""A single (platform, acquisition_type) pair allowed for scheduling."""
16+
17+
platform: str = Field(..., description="Platform this acquisition type belongs to")
18+
acquisition_type: str = Field(..., description="Descriptive acquisition type string")
19+
20+
21+
class ScheduledAcquisition(BaseModel):
22+
"""A scheduled acquisition registered ahead of time."""
23+
24+
uuid: str = Field(..., description="Unique identifier for this scheduled acquisition")
25+
subject_id: str = Field(..., description="Subject ID for this acquisition")
26+
date: datetime.date = Field(..., description="Scheduled date of the acquisition")
27+
acquisition_type: str = Field(..., description="Acquisition type, checked against allowed types")
28+
platform: str = Field(..., description="Platform resolved from the acquisition_type")
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""S3-backed storage for allowed acquisition types and scheduled acquisitions.
2+
3+
Data is stored as JSON objects in S3 (bucket: ``aind-scratch-data``, prefix:
4+
``aind-metadata-viz-data/``).
5+
6+
Object layout::
7+
8+
aind-metadata-viz-data/allowed_acquisition_types.json
9+
aind-metadata-viz-data/scheduled_acquisitions.json
10+
11+
* ``add_acquisition_type`` appends a new (platform, acquisition_type) pair.
12+
* ``get_allowed_types`` returns all allowed (platform, acquisition_type) pairs.
13+
* ``add_scheduled_acquisition`` validates acquisition_type, generates a uuid,
14+
and stores the record.
15+
* ``get_scheduled_acquisitions`` returns all scheduled acquisitions, optionally
16+
filtered to future-or-today only.
17+
* ``get_scheduled_acquisition`` returns a single record by uuid.
18+
"""
19+
20+
import json
21+
import uuid
22+
from datetime import date
23+
from typing import List, Optional
24+
25+
import boto3
26+
from botocore.exceptions import ClientError
27+
28+
from .models import ALLOWED_PLATFORMS
29+
30+
_S3_BUCKET = "aind-scratch-data"
31+
_S3_PREFIX = "aind-metadata-viz-data"
32+
33+
_ALLOWED_TYPES_KEY = f"{_S3_PREFIX}/allowed_acquisition_types.json"
34+
_SCHEDULED_ACQUISITIONS_KEY = f"{_S3_PREFIX}/scheduled_acquisitions.json"
35+
36+
37+
def _s3():
38+
return boto3.client("s3")
39+
40+
41+
def _put_json(key: str, obj) -> None:
42+
_s3().put_object(
43+
Bucket=_S3_BUCKET,
44+
Key=key,
45+
Body=json.dumps(obj).encode(),
46+
ContentType="application/json",
47+
)
48+
49+
50+
def _get_json(key: str):
51+
try:
52+
response = _s3().get_object(Bucket=_S3_BUCKET, Key=key)
53+
return json.loads(response["Body"].read().decode())
54+
except ClientError as exc:
55+
if exc.response["Error"]["Code"] in ("NoSuchKey", "404"):
56+
return None
57+
raise
58+
59+
60+
def get_allowed_types() -> List[dict]:
61+
"""Return all allowed (platform, acquisition_type) pairs."""
62+
return _get_json(_ALLOWED_TYPES_KEY) or []
63+
64+
65+
def add_acquisition_type(platform: str, acquisition_type: str) -> dict:
66+
"""Add a new allowed (platform, acquisition_type) pair.
67+
68+
Raises ``ValueError`` if ``platform`` is set but not in ``ALLOWED_PLATFORMS``
69+
(only enforced once that list has been populated). Deduplicates on the
70+
exact (platform, acquisition_type) pair.
71+
"""
72+
if ALLOWED_PLATFORMS and platform not in ALLOWED_PLATFORMS:
73+
raise ValueError(f"platform '{platform}' is not one of the allowed platforms: {ALLOWED_PLATFORMS}")
74+
75+
entries = get_allowed_types()
76+
entry = {"platform": platform, "acquisition_type": acquisition_type}
77+
if entry not in entries:
78+
entries.append(entry)
79+
_put_json(_ALLOWED_TYPES_KEY, entries)
80+
return entry
81+
82+
83+
def _find_platform_for_type(acquisition_type: str) -> Optional[str]:
84+
"""Return the platform registered for ``acquisition_type``, or None if not found."""
85+
for entry in get_allowed_types():
86+
if entry["acquisition_type"] == acquisition_type:
87+
return entry["platform"]
88+
return None
89+
90+
91+
def add_scheduled_acquisition(subject_id: str, acquisition_date: date, acquisition_type: str) -> str:
92+
"""Validate ``acquisition_type`` against the allowed types and store a new scheduled acquisition.
93+
94+
Returns the generated uuid for the new record. Raises ``ValueError`` if
95+
``acquisition_type`` is not in the allowed types list.
96+
"""
97+
platform = _find_platform_for_type(acquisition_type)
98+
if platform is None:
99+
raise ValueError(f"acquisition_type '{acquisition_type}' is not an allowed acquisition type")
100+
101+
acquisition_uuid = str(uuid.uuid4())
102+
records = _get_json(_SCHEDULED_ACQUISITIONS_KEY) or {}
103+
records[acquisition_uuid] = {
104+
"subject_id": subject_id,
105+
"date": acquisition_date.isoformat() if isinstance(acquisition_date, date) else acquisition_date,
106+
"acquisition_type": acquisition_type,
107+
"platform": platform,
108+
}
109+
_put_json(_SCHEDULED_ACQUISITIONS_KEY, records)
110+
return acquisition_uuid
111+
112+
113+
def get_scheduled_acquisitions(include_past: bool = False) -> List[dict]:
114+
"""Return all scheduled acquisitions, each including its ``uuid``.
115+
116+
If ``include_past`` is False (default), only acquisitions scheduled for
117+
today or later are returned.
118+
"""
119+
records = _get_json(_SCHEDULED_ACQUISITIONS_KEY) or {}
120+
today = date.today()
121+
results = []
122+
for acquisition_uuid, record in records.items():
123+
if not include_past and date.fromisoformat(record["date"]) < today:
124+
continue
125+
results.append({"uuid": acquisition_uuid, **record})
126+
return results
127+
128+
129+
def get_scheduled_acquisition(acquisition_uuid: str) -> Optional[dict]:
130+
"""Return a single scheduled acquisition record by uuid, or None if not found."""
131+
records = _get_json(_SCHEDULED_ACQUISITIONS_KEY) or {}
132+
return records.get(acquisition_uuid)

src/aind_metadata_viz/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from aind_metadata_viz.endpoints import router
55
from aind_metadata_viz.contributions.handlers import contributions_router
6+
from aind_metadata_viz.acquisitions.handlers import acquisitions_router
67
from aind_metadata_viz.chat import chat_router, mount_mcp_server, summary_router
78

89
app = FastAPI()
@@ -16,6 +17,7 @@
1617

1718
app.include_router(router)
1819
app.include_router(contributions_router)
20+
app.include_router(acquisitions_router)
1921
app.include_router(chat_router)
2022
app.include_router(summary_router)
2123
mount_mcp_server(app)

0 commit comments

Comments
 (0)