Assembly Management Rework - Stage 5 & 6 Complete (Stage 4 Pending)

This commit is contained in:
2025-11-02 23:40:33 -07:00
parent fdd95bad23
commit 13f37f39b1
13 changed files with 966 additions and 38 deletions

View File

@@ -12,6 +12,8 @@
# - POST /api/assemblies/dev-mode/switch (Token Authenticated (Admin)) - Enables or disables Dev Mode overrides for the current session.
# - POST /api/assemblies/dev-mode/write (Token Authenticated (Admin+Dev Mode)) - Flushes queued assembly writes immediately.
# - POST /api/assemblies/official/sync (Token Authenticated (Admin+Dev Mode)) - Rebuilds the official domain from staged JSON assemblies.
# - POST /api/assemblies/import (Token Authenticated (Domain write permissions)) - Imports a legacy assembly JSON document into the selected domain.
# - GET /api/assemblies/<assembly_guid>/export (Token Authenticated) - Exports an assembly as legacy JSON with metadata.
# ======================================================
"""Assembly CRUD REST endpoints backed by AssemblyCache."""
@@ -161,6 +163,11 @@ def register_assemblies(app, adapters: "EngineServiceAdapters") -> None:
service = AssemblyAPIService(app, adapters)
blueprint = Blueprint("assemblies", __name__, url_prefix="/api/assemblies")
def _coerce_mapping(value: Any) -> Optional[Dict[str, Any]]:
if isinstance(value, dict):
return value
return None
# ------------------------------------------------------------------
# Collections
# ------------------------------------------------------------------
@@ -406,6 +413,109 @@ def register_assemblies(app, adapters: "EngineServiceAdapters") -> None:
)
return jsonify({"error": "internal server error"}), 500
# ------------------------------------------------------------------
# Import legacy assembly JSON
# ------------------------------------------------------------------
@blueprint.route("/import", methods=["POST"])
def import_assembly():
payload = request.get_json(silent=True) or {}
document = payload.get("document")
if document is None:
document = payload.get("payload")
if document is None:
return jsonify({"error": "missing document"}), 400
domain = service.parse_domain(payload.get("domain")) or AssemblyDomain.USER
user, error = service.require_mutation_for_domain(domain)
pending_guid = str(payload.get("assembly_guid") or "").strip() or None
if error:
detail = error[0].get("message") or error[0].get("error") or "permission denied"
service._audit(
user=user,
action="import",
domain=domain,
assembly_guid=pending_guid,
status="denied",
detail=detail,
)
return jsonify(error[0]), error[1]
try:
record = service.runtime.import_assembly(
domain=domain,
document=document,
assembly_guid=pending_guid,
metadata_override=_coerce_mapping(payload.get("metadata")),
tags_override=_coerce_mapping(payload.get("tags")),
)
record["queue"] = service.runtime.queue_snapshot()
service._audit(
user=user,
action="import",
domain=domain,
assembly_guid=record.get("assembly_guid"),
status="success",
detail="queued",
)
return jsonify(record), 201
except AssemblySerializationError as exc:
service._audit(
user=user,
action="import",
domain=domain,
assembly_guid=pending_guid,
status="failed",
detail=str(exc),
)
return jsonify({"error": str(exc)}), 400
except ValueError as exc:
service._audit(
user=user,
action="import",
domain=domain,
assembly_guid=pending_guid,
status="failed",
detail=str(exc),
)
return jsonify({"error": str(exc)}), 400
except Exception: # pragma: no cover
service.logger.exception("Failed to import assembly.")
service._audit(
user=user,
action="import",
domain=domain,
assembly_guid=pending_guid,
status="error",
detail="internal server error",
)
return jsonify({"error": "internal server error"}), 500
# ------------------------------------------------------------------
# Export legacy assembly JSON
# ------------------------------------------------------------------
@blueprint.route("/<string:assembly_guid>/export", methods=["GET"])
def export_assembly(assembly_guid: str):
user, error = service.require_user()
if error:
return jsonify(error[0]), error[1]
try:
data = service.runtime.export_assembly(assembly_guid)
data["queue"] = service.runtime.queue_snapshot()
service._audit(
user=user,
action="export",
domain=AssemblyAPIService.parse_domain(data.get("domain")),
assembly_guid=assembly_guid,
status="success",
detail="legacy export",
)
return jsonify(data), 200
except ValueError:
return jsonify({"error": "not found"}), 404
except Exception: # pragma: no cover
service.logger.exception("Failed to export assembly %s.", assembly_guid)
return jsonify({"error": "internal server error"}), 500
# ------------------------------------------------------------------
# Dev Mode toggle
# ------------------------------------------------------------------

View File

@@ -282,7 +282,7 @@ def _add_months(dt_tuple: Tuple[int, int, int, int, int, int], months: int = 1)
Handles month-end clamping.
"""
from calendar import monthrange
from datetime import datetime
from datetime import datetime, timezone
y, m, d, hh, mm, ss = dt_tuple
m2 = m + months
@@ -292,28 +292,28 @@ def _add_months(dt_tuple: Tuple[int, int, int, int, int, int], months: int = 1)
last_day = monthrange(y, m2)[1]
d = min(d, last_day)
try:
return int(datetime(y, m2, d, hh, mm, ss).timestamp())
return int(datetime(y, m2, d, hh, mm, ss, tzinfo=timezone.utc).timestamp())
except Exception:
# Fallback to first of month if something odd
return int(datetime(y, m2, 1, hh, mm, ss).timestamp())
return int(datetime(y, m2, 1, hh, mm, ss, tzinfo=timezone.utc).timestamp())
def _add_years(dt_tuple: Tuple[int, int, int, int, int, int], years: int = 1) -> int:
from datetime import datetime
from datetime import datetime, timezone
y, m, d, hh, mm, ss = dt_tuple
y += years
# Handle Feb 29 -> Feb 28 if needed
try:
return int(datetime(y, m, d, hh, mm, ss).timestamp())
return int(datetime(y, m, d, hh, mm, ss, tzinfo=timezone.utc).timestamp())
except Exception:
# clamp day to 28
d2 = 28 if (m == 2 and d > 28) else 1
return int(datetime(y, m, d2, hh, mm, ss).timestamp())
return int(datetime(y, m, d2, hh, mm, ss, tzinfo=timezone.utc).timestamp())
def _to_dt_tuple(ts: int) -> Tuple[int, int, int, int, int, int]:
from datetime import datetime
dt = datetime.utcfromtimestamp(int(ts))
from datetime import datetime, timezone
dt = datetime.fromtimestamp(int(ts), tz=timezone.utc)
return (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second)
@@ -928,35 +928,37 @@ class JobScheduler:
"every_hour": 60 * 60,
}
period = period_map.get(st)
candidate = (last + period) if last else start_ts
while candidate is not None and candidate <= now_ts - 1:
candidate += period
if last is None:
return start_ts
candidate = last + period
return candidate
if st == "daily":
period = 86400
candidate = last + period if last else start_ts
while candidate is not None and candidate <= now_ts - 1:
candidate += period
return candidate
if last is None:
return start_ts
candidate = last + period
return candidate if candidate <= now_ts else candidate
if st == "weekly":
period = 7 * 86400
candidate = last + period if last else start_ts
while candidate is not None and candidate <= now_ts - 1:
candidate += period
return candidate
if last is None:
return start_ts
candidate = last + period
return candidate if candidate <= now_ts else candidate
if st == "monthly":
base = _to_dt_tuple(last) if last else _to_dt_tuple(start_ts)
candidate = _add_months(base, 1 if last else 0)
while candidate <= now_ts - 1:
base = _to_dt_tuple(candidate)
candidate = _add_months(base, 1)
if last is None:
return start_ts
base = _to_dt_tuple(last)
candidate = _add_months(base, 1)
if candidate <= now_ts:
return candidate
return candidate
if st == "yearly":
base = _to_dt_tuple(last) if last else _to_dt_tuple(start_ts)
candidate = _add_years(base, 1 if last else 0)
while candidate <= now_ts - 1:
base = _to_dt_tuple(candidate)
candidate = _add_years(base, 1)
if last is None:
return start_ts
base = _to_dt_tuple(last)
candidate = _add_years(base, 1)
if candidate <= now_ts:
return candidate
return candidate
return None