feat(topology): add snapshot query logic, API route, and Flask app entry
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
# topology_api/app.py
|
||||||
|
from flask import Flask
|
||||||
|
from topology_api.config import API_HOST, API_PORT, API_DEBUG
|
||||||
|
from topology_api.routes.snapshot import snapshot_bp
|
||||||
|
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.register_blueprint(snapshot_bp)
|
||||||
|
|
||||||
|
@app.route("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = create_app()
|
||||||
|
app.run(host=API_HOST, port=API_PORT, debug=API_DEBUG)
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# topology_api/queries/snapshot_queries.py
|
||||||
|
"""Snapshot resolution and data fetching logic."""
|
||||||
|
from topology_api.db import read_cursor
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_snapshot_time(requested_mission=None, requested_at=None):
|
||||||
|
"""Determine the snapshot timestamp per spec.
|
||||||
|
|
||||||
|
Returns: (snapshot_at: str | None, anchor_source: str)
|
||||||
|
"""
|
||||||
|
with read_cursor() as cur:
|
||||||
|
if requested_at is not None:
|
||||||
|
return requested_at, "explicit_time"
|
||||||
|
|
||||||
|
if requested_mission is not None:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, occurrence_status
|
||||||
|
FROM missions
|
||||||
|
WHERE code = %(code)s
|
||||||
|
""", {"code": requested_mission})
|
||||||
|
mission = cur.fetchone()
|
||||||
|
if mission is None:
|
||||||
|
return None, "mission_not_found"
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT effective_at
|
||||||
|
FROM mission_events
|
||||||
|
WHERE mission_id = %(mid)s
|
||||||
|
ORDER BY effective_at DESC, event_order DESC
|
||||||
|
LIMIT 1
|
||||||
|
""", {"mid": mission["id"]})
|
||||||
|
event = cur.fetchone()
|
||||||
|
if event is None:
|
||||||
|
return None, "mission_has_no_timed_event"
|
||||||
|
|
||||||
|
return event["effective_at"].isoformat(), "mission_latest_event"
|
||||||
|
|
||||||
|
# Neither provided: latest event across all occurred missions
|
||||||
|
cur.execute("""
|
||||||
|
SELECT me.effective_at
|
||||||
|
FROM mission_events me
|
||||||
|
JOIN missions m ON m.id = me.mission_id
|
||||||
|
WHERE m.occurrence_status = 'occurred'
|
||||||
|
ORDER BY me.effective_at DESC, me.event_order DESC
|
||||||
|
LIMIT 1
|
||||||
|
""")
|
||||||
|
event = cur.fetchone()
|
||||||
|
if event is None:
|
||||||
|
return None, "no_events"
|
||||||
|
return event["effective_at"].isoformat(), "latest_recorded_state"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_snapshot(snapshot_at):
|
||||||
|
"""Fetch complete base topology at a given point in time.
|
||||||
|
|
||||||
|
Returns dict with keys: base, components, connections
|
||||||
|
"""
|
||||||
|
result = {"base": None, "components": [], "connections": []}
|
||||||
|
params = {"at": snapshot_at}
|
||||||
|
|
||||||
|
with read_cursor() as cur:
|
||||||
|
# --- base ---
|
||||||
|
cur.execute("SELECT code, name FROM bases WHERE code = 'guanghan'")
|
||||||
|
base = cur.fetchone()
|
||||||
|
if base is None:
|
||||||
|
return result
|
||||||
|
result["base"] = dict(base)
|
||||||
|
|
||||||
|
# --- components present at snapshot_at ---
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.component_key,
|
||||||
|
c.component_type,
|
||||||
|
cv.display_name,
|
||||||
|
cv.summary,
|
||||||
|
cv.description,
|
||||||
|
cv.length_m,
|
||||||
|
cv.width_m,
|
||||||
|
cv.height_m,
|
||||||
|
cv.diameter_m,
|
||||||
|
cv.mass_t,
|
||||||
|
cv.detail_url,
|
||||||
|
cv.properties
|
||||||
|
FROM components c
|
||||||
|
JOIN mission_events e_intro
|
||||||
|
ON e_intro.id = c.introduced_by_event_id
|
||||||
|
LEFT JOIN mission_events e_retire
|
||||||
|
ON e_retire.id = c.retired_by_event_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT cv_sub.*
|
||||||
|
FROM component_versions cv_sub
|
||||||
|
WHERE cv_sub.component_id = c.id
|
||||||
|
AND cv_sub.valid_from <= %(at)s::timestamptz
|
||||||
|
AND (cv_sub.valid_to IS NULL OR %(at)s::timestamptz < cv_sub.valid_to)
|
||||||
|
ORDER BY cv_sub.valid_from DESC
|
||||||
|
LIMIT 1
|
||||||
|
) cv ON true
|
||||||
|
WHERE c.base_id = (SELECT id FROM bases WHERE code = 'guanghan')
|
||||||
|
AND e_intro.effective_at <= %(at)s::timestamptz
|
||||||
|
AND (c.retired_by_event_id IS NULL
|
||||||
|
OR (SELECT effective_at FROM mission_events WHERE id = c.retired_by_event_id) > %(at)s::timestamptz)
|
||||||
|
ORDER BY c.component_key
|
||||||
|
""", params)
|
||||||
|
component_rows = cur.fetchall()
|
||||||
|
|
||||||
|
component_map = {}
|
||||||
|
for row in component_rows:
|
||||||
|
comp = {
|
||||||
|
"key": row["component_key"],
|
||||||
|
"type": row["component_type"],
|
||||||
|
"name": row["display_name"],
|
||||||
|
"summary": row["summary"],
|
||||||
|
"description": row["description"],
|
||||||
|
"detailUrl": row["detail_url"],
|
||||||
|
"dimensions": {},
|
||||||
|
"properties": row["properties"] or {},
|
||||||
|
"ports": [],
|
||||||
|
"states": {},
|
||||||
|
}
|
||||||
|
for dim in ["length_m", "width_m", "height_m", "diameter_m"]:
|
||||||
|
val = row[dim]
|
||||||
|
if val is not None:
|
||||||
|
comp["dimensions"][dim.replace("_m", "M")] = float(val)
|
||||||
|
if row["mass_t"] is not None:
|
||||||
|
comp["dimensions"]["massT"] = float(row["mass_t"])
|
||||||
|
result["components"].append(comp)
|
||||||
|
component_map[row["id"]] = comp
|
||||||
|
|
||||||
|
if not component_map:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# --- ports ---
|
||||||
|
component_ids = tuple(component_map.keys())
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, component_id, port_key, display_name, direction, interface_type
|
||||||
|
FROM ports
|
||||||
|
WHERE component_id IN %(ids)s
|
||||||
|
ORDER BY component_id, port_key
|
||||||
|
""", {"ids": component_ids})
|
||||||
|
port_map = {}
|
||||||
|
for row in cur.fetchall():
|
||||||
|
port = {
|
||||||
|
"key": row["port_key"],
|
||||||
|
"displayName": row["display_name"],
|
||||||
|
"direction": row["direction"],
|
||||||
|
"interfaceType": row["interface_type"],
|
||||||
|
}
|
||||||
|
cid = row["component_id"]
|
||||||
|
if cid in component_map:
|
||||||
|
component_map[cid]["ports"].append(port)
|
||||||
|
port_map[row["id"]] = {
|
||||||
|
"component_key": component_map.get(cid, {}).get("key"),
|
||||||
|
**port,
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- states ---
|
||||||
|
cur.execute("""
|
||||||
|
SELECT component_id, state_kind, state_value, details
|
||||||
|
FROM component_states
|
||||||
|
WHERE component_id IN %(ids)s
|
||||||
|
AND valid_from <= %(at)s::timestamptz
|
||||||
|
AND (valid_to IS NULL OR %(at)s::timestamptz < valid_to)
|
||||||
|
ORDER BY component_id, state_kind
|
||||||
|
""", {"ids": component_ids, "at": snapshot_at})
|
||||||
|
for row in cur.fetchall():
|
||||||
|
comp = component_map.get(row["component_id"])
|
||||||
|
if comp:
|
||||||
|
comp["states"][row["state_kind"]] = row["state_value"]
|
||||||
|
if row["details"]:
|
||||||
|
comp["states"][row["state_kind"] + "Details"] = row["details"]
|
||||||
|
|
||||||
|
# --- connections ---
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
tc.connection_type,
|
||||||
|
tc.properties,
|
||||||
|
pa.component_id AS comp_a_id,
|
||||||
|
pa.port_key AS port_a_key,
|
||||||
|
pb.component_id AS comp_b_id,
|
||||||
|
pb.port_key AS port_b_key
|
||||||
|
FROM topology_connections tc
|
||||||
|
JOIN ports pa ON pa.id = tc.port_a_id
|
||||||
|
JOIN ports pb ON pb.id = tc.port_b_id
|
||||||
|
WHERE tc.base_id = (SELECT id FROM bases WHERE code = 'guanghan')
|
||||||
|
AND tc.valid_from <= %(at)s::timestamptz
|
||||||
|
AND (tc.valid_to IS NULL OR %(at)s::timestamptz < tc.valid_to)
|
||||||
|
AND pa.component_id IN %(ids)s
|
||||||
|
AND pb.component_id IN %(ids)s
|
||||||
|
ORDER BY tc.connection_type, pa.port_key
|
||||||
|
""", {"ids": component_ids, "at": snapshot_at})
|
||||||
|
for row in cur.fetchall():
|
||||||
|
comp_a = component_map.get(row["comp_a_id"])
|
||||||
|
comp_b = component_map.get(row["comp_b_id"])
|
||||||
|
if comp_a and comp_b:
|
||||||
|
result["connections"].append({
|
||||||
|
"type": row["connection_type"],
|
||||||
|
"fromPort": f"{comp_a['key']}.{row['port_a_key']}",
|
||||||
|
"toPort": f"{comp_b['key']}.{row['port_b_key']}",
|
||||||
|
"properties": row["properties"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# topology_api/routes/snapshot.py
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from topology_api.queries.snapshot_queries import resolve_snapshot_time, fetch_snapshot
|
||||||
|
|
||||||
|
snapshot_bp = Blueprint("snapshot", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@snapshot_bp.route("/api/v1/bases/<base_code>/snapshot")
|
||||||
|
def get_snapshot(base_code):
|
||||||
|
if base_code != "guanghan":
|
||||||
|
return jsonify({
|
||||||
|
"error": "base_not_found",
|
||||||
|
"message": f"Base '{base_code}' not found. Only 'guanghan' is available.",
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
requested_mission = request.args.get("mission")
|
||||||
|
requested_at = request.args.get("at")
|
||||||
|
|
||||||
|
snapshot_at, anchor_source = resolve_snapshot_time(
|
||||||
|
requested_mission=requested_mission,
|
||||||
|
requested_at=requested_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
if anchor_source == "mission_not_found":
|
||||||
|
return jsonify({
|
||||||
|
"error": "mission_not_found",
|
||||||
|
"message": f"Mission '{requested_mission}' not found.",
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
if anchor_source == "mission_has_no_timed_event":
|
||||||
|
return jsonify({
|
||||||
|
"error": "mission_has_no_timed_event",
|
||||||
|
"message": f"Mission '{requested_mission}' has no timed events.",
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
if anchor_source == "no_events":
|
||||||
|
return jsonify({
|
||||||
|
"base": {"code": "guanghan", "name": "广寒基地"},
|
||||||
|
"query": {
|
||||||
|
"requestedAt": requested_at,
|
||||||
|
"requestedMission": requested_mission,
|
||||||
|
"snapshotAt": None,
|
||||||
|
"anchorSource": "no_events",
|
||||||
|
},
|
||||||
|
"components": [],
|
||||||
|
"connections": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
data = fetch_snapshot(snapshot_at)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"base": data["base"],
|
||||||
|
"query": {
|
||||||
|
"requestedAt": requested_at,
|
||||||
|
"requestedMission": requested_mission,
|
||||||
|
"snapshotAt": snapshot_at,
|
||||||
|
"anchorSource": anchor_source,
|
||||||
|
},
|
||||||
|
"components": data["components"],
|
||||||
|
"connections": data["connections"],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user