feat: add safe blocker resolution workflow

This commit is contained in:
gti
2026-09-22 18:47:04 -03:00
parent 25af5cf0f0
commit 5180725238
7 changed files with 430 additions and 22 deletions
+149
View File
@@ -0,0 +1,149 @@
"""Validated, auditable context resolution for a single blocked product."""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import select
from .audit import record_audit
from .context_keys import validate_entry
from .models import Agent, BlockedProduct, FiscalContextEntry, Installation, Tenant, Job
WORKFLOW_SCOPES = {"PRODUCT", "GROUP", "COMPANY"}
ENTRY_FIELDS = {"scope", "scope_key", "key", "value", "reason"}
def validate_resolution_input(payload):
"""Validate only the structured context accepted by the blocker workflow."""
if not isinstance(payload, dict):
return None, "resolution input must be an object"
unexpected = set(payload) - ENTRY_FIELDS
if unexpected:
return None, f"unsupported resolution fields: {sorted(unexpected)}"
normalized, error = validate_entry(payload)
if error:
return None, error
if normalized["scope"] not in WORKFLOW_SCOPES:
return None, "scope must be one of ('PRODUCT', 'GROUP', 'COMPANY')"
if not normalized["reason"]:
return None, "reason is required"
return normalized, None
def _company_key(blocker, installation, tenant):
if installation is not None:
return (installation.company_code or installation.company_name or installation.customer_code or "").strip()
return (blocker.company or (tenant.name if tenant else "")).strip()
def _entry_view(row):
if row is None:
return None
return {
"company_key": row.company_key,
"scope": row.scope,
"scope_key": row.scope_key,
"key": row.key,
"value": row.value,
"source": row.source,
"reason": row.reason,
}
def resolve_blocker_with_context(session, blocker_id, admin, payload, request_ip=None):
"""Persist context, enqueue one product reprocess, and audit atomically."""
normalized, error = validate_resolution_input(payload)
if error:
raise ValueError(error)
try:
blocker = session.get(BlockedProduct, blocker_id)
if blocker is None:
raise LookupError("blocker not found")
installation = None
if blocker.installation_id is not None:
installation = session.get(Installation, blocker.installation_id)
if installation is None:
raise LookupError("blocker installation not found")
agent = session.get(Agent, installation.agent_id)
else:
agent = session.scalar(
select(Agent)
.where(Agent.tenant_id == blocker.tenant_id, Agent.enabled.is_(True))
.order_by(Agent.id.asc())
)
if agent is None:
raise LookupError("blocker agent not found")
tenant = session.get(Tenant, blocker.tenant_id)
company_key = _company_key(blocker, installation, tenant)
if not company_key:
raise ValueError("company context is unavailable")
if normalized["scope"] == "PRODUCT" and normalized["scope_key"] != blocker.product_code:
raise ValueError("PRODUCT scope_key must match the blocker product code")
query = select(FiscalContextEntry).where(
FiscalContextEntry.tenant_id == blocker.tenant_id,
FiscalContextEntry.company_key == company_key,
FiscalContextEntry.scope == normalized["scope"],
FiscalContextEntry.scope_key == normalized["scope_key"],
FiscalContextEntry.key == normalized["key"],
)
entry = session.scalar(query)
before = _entry_view(entry)
now = datetime.now(timezone.utc)
if entry is None:
entry = FiscalContextEntry(
tenant_id=blocker.tenant_id,
company_key=company_key,
source="OPERATOR",
updated_at=now,
**normalized,
)
session.add(entry)
else:
entry.value = normalized["value"]
entry.reason = normalized["reason"]
entry.source = "OPERATOR"
entry.updated_at = now
job = Job(agent_id=agent.id, kind="REPROCESS_PRODUCTS", status="PENDING", payload={"codigos": [blocker.product_code]})
session.add(job)
session.flush()
audit = record_audit(
session,
admin_user_id=admin.id,
tenant_id=blocker.tenant_id,
action="RESOLVE_BLOCKER_WITH_CONTEXT",
target_type="blocker",
target_id=str(blocker.id),
context={
"admin_user_id": admin.id,
"tenant_id": blocker.tenant_id,
"client": tenant.name if tenant else None,
"company_key": company_key,
"installation_id": installation.installation_id if installation else None,
"installation_db_id": installation.id if installation else None,
"product_code": blocker.product_code,
"blocker_id": blocker.id,
"key": normalized["key"],
"scope": normalized["scope"],
"scope_key": normalized["scope_key"],
"value": normalized["value"],
"reason": normalized["reason"],
"before": before,
"after": _entry_view(entry),
"reprocess_job_id": job.id,
"request_ip": request_ip,
},
)
session.flush()
session.commit()
return {"blocker_id": blocker.id, "job_id": job.id, "audit_id": audit.id}
except Exception:
session.rollback()
raise
+9 -8
View File
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from .audit import record_audit
from .blocker_resolution import resolve_blocker_with_context
from .control_plane import dashboard_summary, installation_view, list_blockers, list_installations, list_jobs, list_panel_events
from .human_auth import require_csrf, require_human
from .models import Agent, AgentSnapshot, BlockedProduct, Installation, Job, Tenant
@@ -109,19 +110,19 @@ def router(session_factory):
return {"job_id": job.id, "status": job.status}
finally: session.close()
@routes.post("/blockers/{blocker_id}/resolve-context")
@routes.post("/blockers/{blocker_id}/resolve-human")
def resolve_blocker(blocker_id: int, payload: dict, request: Request, identity=Depends(identity)):
session, web_session, admin = identity
try:
require_csrf(web_session, payload.get("csrf_token"))
if payload.get("confirm") is not True or not str(payload.get("context", "")).strip(): raise HTTPException(400, "confirmation and explicit context required")
blocker = session.get(BlockedProduct, blocker_id)
if not blocker: raise HTTPException(404, "blocker not found")
if blocker.resolved_at is None:
blocker.resolved_at = datetime.now(timezone.utc); blocker.resolution_source = "HUMAN_PANEL"
record_audit(session, admin_user_id=admin.id, tenant_id=blocker.tenant_id, action="RESOLVE_BLOCKER", target_type="blocker", target_id=str(blocker_id), context={"context": payload["context"]})
session.commit()
return {"resolved": True, "blocker_id": blocker_id}
entry_payload = {key: payload.get(key) for key in ("scope", "scope_key", "key", "value", "reason")}
try:
return resolve_blocker_with_context(session, blocker_id, admin, entry_payload, request_ip=request.client.host if request.client else None)
except LookupError as error:
raise HTTPException(404, str(error)) from error
except ValueError as error:
raise HTTPException(422, str(error)) from error
finally: session.close()
return routes
+32 -13
View File
@@ -8,9 +8,11 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from .config import settings
from .control_plane import dashboard_summary, list_blockers, list_installations, list_jobs, list_panel_events
from .blocker_resolution import _company_key, resolve_blocker_with_context
from .context_keys import ALLOWED_KEYS, ENUMS
from .control_plane import blocker_detail, dashboard_summary, list_blockers, list_installations, list_jobs, list_panel_events
from .human_auth import create_session, current_session, require_csrf, verify_password
from .models import AdminUser, Installation, Agent, Job
from .models import AdminUser, Agent, BlockedProduct, FiscalContextEntry, Installation, Job, Tenant
from .jobs import create_job
from .audit import record_audit
@@ -116,23 +118,40 @@ def router(session_factory):
try: return render(request, "blockers.html", {"result": list_blockers(session, page=page, page_size=50, blocker_type=blocker_type), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", "")})
finally: session.close()
@routes.post("/blockers/{blocker_id}/resolve")
async def resolve_blocker(request: Request, blocker_id: int):
@routes.get("/blockers/{blocker_id}", response_class=HTMLResponse)
def blocker_page(request: Request, blocker_id: int):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
detail = blocker_detail(session, blocker_id)
if detail is None: return HTMLResponse("Blocker not found", status_code=404)
blocker = session.get(BlockedProduct, blocker_id)
installation = session.get(Installation, blocker.installation_id) if blocker.installation_id else None
tenant = session.get(Tenant, blocker.tenant_id)
company_key = _company_key(blocker, installation, tenant)
current_context = session.scalars(select(FiscalContextEntry).where(FiscalContextEntry.tenant_id == blocker.tenant_id, FiscalContextEntry.company_key == company_key)).all()
value_choices = sorted({value for values in ENUMS.values() for value in values})
return render(request, "blocker_detail.html", {"detail": detail, "auth": auth_data, "context_entries": current_context, "context_keys": sorted(ALLOWED_KEYS), "value_choices": value_choices, "error": None})
finally: session.close()
@routes.post("/blockers/{blocker_id}/resolve-context")
async def resolve_blocker_with_context_route(request: Request, blocker_id: int):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
values = await form_values(request); web_session, admin = auth_data
require_csrf(web_session, values.get("csrf_token"))
if values.get("confirm") != "yes" or not values.get("context", "").strip(): return HTMLResponse("confirmation and context required", status_code=400)
from .models import BlockedProduct
blocker = session.get(BlockedProduct, blocker_id)
if not blocker: return HTMLResponse("Blocker not found", status_code=404)
from datetime import datetime, timezone
blocker.resolved_at = datetime.now(timezone.utc); blocker.resolution_source = "HUMAN_PANEL"
record_audit(session, admin_user_id=admin.id, tenant_id=blocker.tenant_id, action="RESOLVE_BLOCKER", target_type="blocker", target_id=str(blocker_id), context={"context": values["context"]})
session.commit()
payload = {key: values.get(key) for key in ("scope", "scope_key", "key", "value", "reason")}
try:
resolve_blocker_with_context(session, blocker_id, admin, payload, request_ip=request.client.host if request.client else None)
except LookupError:
return HTMLResponse("Blocker not found", status_code=404)
except ValueError as error:
detail = blocker_detail(session, blocker_id)
return render(request, "blocker_detail.html", {"detail": detail, "auth": auth_data, "context_entries": [], "context_keys": sorted(ALLOWED_KEYS), "value_choices": sorted({value for values in ENUMS.values() for value in values}), "error": str(error)}, 422)
return RedirectResponse("/blockers", status_code=303)
finally: session.close()
finally:
session.close()
@routes.get("/jobs", response_class=HTMLResponse)
def jobs(request: Request, page: int = 1, status: str | None = None):