Add assembly endpoints and approval flows

This commit is contained in:
2025-10-23 00:31:48 -06:00
parent 4bc529aaf4
commit 82210408ca
11 changed files with 1510 additions and 1 deletions

View File

@@ -19,6 +19,8 @@ from . import (
sites,
devices,
credentials,
assemblies,
server_info,
)
_REGISTRARS = (
@@ -34,6 +36,8 @@ _REGISTRARS = (
sites.register,
devices.register,
credentials.register,
assemblies.register,
server_info.register,
)

View File

@@ -113,4 +113,61 @@ def list_device_approvals() -> object:
return jsonify({"approvals": [record.to_dict() for record in records]})
@blueprint.route("/device-approvals/<approval_id>/approve", methods=["POST"])
def approve_device_approval(approval_id: str) -> object:
guard = _require_admin()
if guard:
return guard
payload = request.get_json(silent=True) or {}
guid = payload.get("guid")
resolution_raw = payload.get("conflict_resolution") or payload.get("resolution")
resolution = resolution_raw.strip().lower() if isinstance(resolution_raw, str) else None
actor = session.get("username") if isinstance(session.get("username"), str) else None
try:
result = _admin_service().approve_device_approval(
approval_id,
actor=actor,
guid=guid,
conflict_resolution=resolution,
)
except LookupError:
return jsonify({"error": "not_found"}), 404
except ValueError as exc:
code = str(exc)
if code == "approval_not_pending":
return jsonify({"error": "approval_not_pending"}), 409
if code == "conflict_resolution_required":
return jsonify({"error": "conflict_resolution_required"}), 409
if code == "invalid_guid":
return jsonify({"error": "invalid_guid"}), 400
raise
response = jsonify(result.to_dict())
response.status_code = 200
return response
@blueprint.route("/device-approvals/<approval_id>/deny", methods=["POST"])
def deny_device_approval(approval_id: str) -> object:
guard = _require_admin()
if guard:
return guard
actor = session.get("username") if isinstance(session.get("username"), str) else None
try:
result = _admin_service().deny_device_approval(approval_id, actor=actor)
except LookupError:
return jsonify({"error": "not_found"}), 404
except ValueError as exc:
if str(exc) == "approval_not_pending":
return jsonify({"error": "approval_not_pending"}), 409
raise
return jsonify(result.to_dict())
__all__ = ["register", "blueprint"]

View File

@@ -0,0 +1,182 @@
"""HTTP endpoints for assembly management."""
from __future__ import annotations
from flask import Blueprint, Flask, current_app, jsonify, request
from Data.Engine.services.container import EngineServiceContainer
blueprint = Blueprint("engine_assemblies", __name__)
def register(app: Flask, _services: EngineServiceContainer) -> None:
if "engine_assemblies" not in app.blueprints:
app.register_blueprint(blueprint)
def _services() -> EngineServiceContainer:
services = current_app.extensions.get("engine_services")
if services is None: # pragma: no cover - defensive
raise RuntimeError("engine services not initialized")
return services
def _assembly_service():
return _services().assembly_service
def _value_error_response(exc: ValueError):
code = str(exc)
if code == "invalid_island":
return jsonify({"error": "invalid island"}), 400
if code == "path_required":
return jsonify({"error": "path required"}), 400
if code == "invalid_kind":
return jsonify({"error": "invalid kind"}), 400
if code == "invalid_destination":
return jsonify({"error": "invalid destination"}), 400
if code == "invalid_path":
return jsonify({"error": "invalid path"}), 400
if code == "cannot_delete_root":
return jsonify({"error": "cannot delete root"}), 400
return jsonify({"error": code or "invalid request"}), 400
def _not_found_response(exc: FileNotFoundError):
code = str(exc)
if code == "file_not_found":
return jsonify({"error": "file not found"}), 404
if code == "folder_not_found":
return jsonify({"error": "folder not found"}), 404
return jsonify({"error": "not found"}), 404
@blueprint.route("/api/assembly/list", methods=["GET"])
def list_assemblies() -> object:
island = (request.args.get("island") or "").strip()
try:
listing = _assembly_service().list_items(island)
except ValueError as exc:
return _value_error_response(exc)
return jsonify(listing.to_dict())
@blueprint.route("/api/assembly/load", methods=["GET"])
def load_assembly() -> object:
island = (request.args.get("island") or "").strip()
rel_path = (request.args.get("path") or "").strip()
try:
result = _assembly_service().load_item(island, rel_path)
except ValueError as exc:
return _value_error_response(exc)
except FileNotFoundError as exc:
return _not_found_response(exc)
return jsonify(result.to_dict())
@blueprint.route("/api/assembly/create", methods=["POST"])
def create_assembly() -> object:
payload = request.get_json(silent=True) or {}
island = (payload.get("island") or "").strip()
kind = (payload.get("kind") or "").strip().lower()
rel_path = (payload.get("path") or "").strip()
content = payload.get("content")
item_type = payload.get("type")
try:
result = _assembly_service().create_item(
island,
kind=kind,
rel_path=rel_path,
content=content,
item_type=item_type if isinstance(item_type, str) else None,
)
except ValueError as exc:
return _value_error_response(exc)
return jsonify(result.to_dict())
@blueprint.route("/api/assembly/edit", methods=["POST"])
def edit_assembly() -> object:
payload = request.get_json(silent=True) or {}
island = (payload.get("island") or "").strip()
rel_path = (payload.get("path") or "").strip()
content = payload.get("content")
item_type = payload.get("type")
try:
result = _assembly_service().edit_item(
island,
rel_path=rel_path,
content=content,
item_type=item_type if isinstance(item_type, str) else None,
)
except ValueError as exc:
return _value_error_response(exc)
except FileNotFoundError as exc:
return _not_found_response(exc)
return jsonify(result.to_dict())
@blueprint.route("/api/assembly/rename", methods=["POST"])
def rename_assembly() -> object:
payload = request.get_json(silent=True) or {}
island = (payload.get("island") or "").strip()
kind = (payload.get("kind") or "").strip().lower()
rel_path = (payload.get("path") or "").strip()
new_name = (payload.get("new_name") or "").strip()
item_type = payload.get("type")
try:
result = _assembly_service().rename_item(
island,
kind=kind,
rel_path=rel_path,
new_name=new_name,
item_type=item_type if isinstance(item_type, str) else None,
)
except ValueError as exc:
return _value_error_response(exc)
except FileNotFoundError as exc:
return _not_found_response(exc)
return jsonify(result.to_dict())
@blueprint.route("/api/assembly/move", methods=["POST"])
def move_assembly() -> object:
payload = request.get_json(silent=True) or {}
island = (payload.get("island") or "").strip()
rel_path = (payload.get("path") or "").strip()
new_path = (payload.get("new_path") or "").strip()
kind = (payload.get("kind") or "").strip().lower()
try:
result = _assembly_service().move_item(
island,
rel_path=rel_path,
new_path=new_path,
kind=kind,
)
except ValueError as exc:
return _value_error_response(exc)
except FileNotFoundError as exc:
return _not_found_response(exc)
return jsonify(result.to_dict())
@blueprint.route("/api/assembly/delete", methods=["POST"])
def delete_assembly() -> object:
payload = request.get_json(silent=True) or {}
island = (payload.get("island") or "").strip()
rel_path = (payload.get("path") or "").strip()
kind = (payload.get("kind") or "").strip().lower()
try:
result = _assembly_service().delete_item(
island,
rel_path=rel_path,
kind=kind,
)
except ValueError as exc:
return _value_error_response(exc)
except FileNotFoundError as exc:
return _not_found_response(exc)
return jsonify(result.to_dict())
__all__ = ["register", "blueprint"]

View File

@@ -0,0 +1,53 @@
"""Server metadata endpoints."""
from __future__ import annotations
from datetime import datetime, timezone
from flask import Blueprint, Flask, jsonify
from Data.Engine.services.container import EngineServiceContainer
blueprint = Blueprint("engine_server_info", __name__)
def register(app: Flask, _services: EngineServiceContainer) -> None:
if "engine_server_info" not in app.blueprints:
app.register_blueprint(blueprint)
@blueprint.route("/api/server/time", methods=["GET"])
def server_time() -> object:
now_local = datetime.now().astimezone()
now_utc = datetime.now(timezone.utc)
tzinfo = now_local.tzinfo
offset = tzinfo.utcoffset(now_local) if tzinfo else None
def _ordinal(n: int) -> str:
if 11 <= (n % 100) <= 13:
suffix = "th"
else:
suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
return f"{n}{suffix}"
month = now_local.strftime("%B")
day_disp = _ordinal(now_local.day)
year = now_local.strftime("%Y")
hour24 = now_local.hour
hour12 = hour24 % 12 or 12
minute = now_local.minute
ampm = "AM" if hour24 < 12 else "PM"
display = f"{month} {day_disp} {year} @ {hour12}:{minute:02d}{ampm}"
payload = {
"epoch": int(now_local.timestamp()),
"iso": now_local.isoformat(),
"utc_iso": now_utc.isoformat().replace("+00:00", "Z"),
"timezone": str(tzinfo) if tzinfo else "",
"offset_seconds": int(offset.total_seconds()) if offset else 0,
"display": display,
}
return jsonify(payload)
__all__ = ["register", "blueprint"]