mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-16 03:25:48 -07:00
Assembly Management Rework - Stage 5 & 6 Complete (Stage 4 Pending)
This commit is contained in:
@@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user