metering_showback_routes.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. """Closed, Showback-only WP12 HTTP boundary; no chargeback endpoint exists."""
  2. from __future__ import annotations
  3. from flask import g, jsonify, request
  4. from sqlalchemy.exc import SQLAlchemyError
  5. from app.api.system import bp
  6. from app.core.governance.metering_showback import DatabaseMeteringShowbackService
  7. from app.core.system.permissions import (
  8. METERING_MANAGE,
  9. METERING_READ,
  10. require_permissions,
  11. )
  12. from app.models.result import failed, success
  13. _MAX_BODY_BYTES = 4096
  14. _EVENT_FIELDS = {"schema_version", "event_uid", "event_kind", "occurred_at", "window_start", "window_end", "quantity", "unit", "idempotency_key", "evidence", "mapping", "correction_of"}
  15. _REQUIRED_EVENT_FIELDS = _EVENT_FIELDS - {"correction_of"}
  16. _ALLOCATION_FIELDS = {"schema_version", "rule_uid", "rule_version", "effective_start", "effective_end", "mapping", "allocations"}
  17. _BUDGET_FIELDS = {"schema_version", "budget_uid", "window", "mapping", "limit_micros", "threshold_micros"}
  18. def _service() -> DatabaseMeteringShowbackService:
  19. return DatabaseMeteringShowbackService()
  20. def _response(value: object, status: int = 200):
  21. response = jsonify(success(value, code=status))
  22. response.headers["Cache-Control"] = "no-store"
  23. return response, status
  24. def _failure(status: int = 400):
  25. response = jsonify(failed("metering request rejected", code=status))
  26. response.headers["Cache-Control"] = "no-store"
  27. return response, status
  28. def _event_body() -> dict:
  29. if request.content_length is not None and request.content_length > _MAX_BODY_BYTES:
  30. raise ValueError("metering_body_closed")
  31. value = request.get_json(silent=True)
  32. if not isinstance(value, dict) or set(value) not in (_REQUIRED_EVENT_FIELDS, _EVENT_FIELDS):
  33. raise ValueError("metering_body_closed")
  34. mapping = value.get("mapping")
  35. if not isinstance(mapping, dict) or set(mapping) != {"department", "project", "cost_center"}:
  36. raise ValueError("metering_mapping_closed")
  37. return value
  38. @bp.post("/metering/events")
  39. @require_permissions(METERING_MANAGE)
  40. def record_metering_event():
  41. """record local metering event"""
  42. try:
  43. body = _event_body()
  44. result = _service().record_for_principal(principal_id=str(g.current_user["id"]), body=body)
  45. return _response(result, 201 if not result.get("replay") else 200)
  46. except (KeyError, LookupError, PermissionError, RuntimeError, TypeError, ValueError, SQLAlchemyError):
  47. return _failure()
  48. def _control_body(fields: set[str]) -> dict:
  49. if request.content_length is not None and request.content_length > _MAX_BODY_BYTES:
  50. raise ValueError("metering_body_closed")
  51. value = request.get_json(silent=True)
  52. if not isinstance(value, dict) or set(value) != fields:
  53. raise ValueError("metering_body_closed")
  54. mapping = value.get("mapping")
  55. if not isinstance(mapping, dict) or set(mapping) != {"department", "project", "cost_center"}:
  56. raise ValueError("metering_mapping_closed")
  57. return value
  58. @bp.post("/metering/allocations")
  59. @require_permissions(METERING_MANAGE)
  60. def publish_metering_allocation():
  61. """publish versioned local allocation rule"""
  62. try:
  63. return _response(_service().publish_allocation_for_principal(principal_id=str(g.current_user["id"]), body=_control_body(_ALLOCATION_FIELDS)), 201)
  64. except (KeyError, LookupError, PermissionError, RuntimeError, TypeError, ValueError, SQLAlchemyError):
  65. return _failure()
  66. @bp.post("/metering/budgets")
  67. @require_permissions(METERING_MANAGE)
  68. def evaluate_metering_budget():
  69. """persist disabled-provider budget alert evidence"""
  70. try:
  71. return _response(_service().evaluate_budget_for_principal(principal_id=str(g.current_user["id"]), body=_control_body(_BUDGET_FIELDS)), 201)
  72. except (KeyError, LookupError, PermissionError, RuntimeError, TypeError, ValueError, SQLAlchemyError):
  73. return _failure()
  74. def _window() -> str:
  75. if set(request.args) != {"window"}:
  76. raise ValueError("metering_query_closed")
  77. value = request.args.get("window", "")
  78. if len(value) != 7 or value[4:5] != "-" or not value.replace("-", "").isdigit():
  79. raise ValueError("metering_window_invalid")
  80. return value
  81. @bp.get("/metering/showback")
  82. @require_permissions(METERING_READ)
  83. def get_showback():
  84. """get local showback"""
  85. try:
  86. return _response(_service().showback_for_principal(principal_id=str(g.current_user["id"]), window=_window(), kind="showback"))
  87. except (KeyError, LookupError, PermissionError, RuntimeError, TypeError, ValueError, SQLAlchemyError):
  88. return _failure(403)
  89. @bp.get("/metering/reconciliation")
  90. @require_permissions(METERING_READ)
  91. def get_metering_reconciliation():
  92. """get metering reconciliation"""
  93. try:
  94. return _response(_service().showback_for_principal(principal_id=str(g.current_user["id"]), window=_window(), kind="reconciliation"))
  95. except (KeyError, LookupError, PermissionError, RuntimeError, TypeError, ValueError, SQLAlchemyError):
  96. return _failure(403)
  97. @bp.get("/metering/allocation-replay")
  98. @require_permissions(METERING_READ)
  99. def get_metering_allocation_replay():
  100. """replay one persisted allocation-rule version"""
  101. try:
  102. if set(request.args) != {"window", "rule_uid", "rule_version"}:
  103. raise ValueError("metering_query_closed")
  104. version = request.args.get("rule_version", "")
  105. if not version.isdigit():
  106. raise ValueError("rule_version_invalid")
  107. return _response(_service().allocation_replay_for_principal(principal_id=str(g.current_user["id"]), window=request.args.get("window", ""), rule_uid=request.args.get("rule_uid", ""), rule_version=int(version)))
  108. except (KeyError, LookupError, PermissionError, RuntimeError, TypeError, ValueError, SQLAlchemyError):
  109. return _failure(403)