feat: central RTC write modes (DISABLED/DRY_RUN/APPROVAL/ENABLED), R4 suggestion-only, preview/approval, per-record rollback, bulk blocker upsert; 1.2.0

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-24 22:46:05 -03:00
co-authored by Claude Sonnet 5
parent 3c10422e81
commit 42dc172763
50 changed files with 2948 additions and 915 deletions
+102 -21
View File
@@ -8,18 +8,18 @@ from urllib.parse import parse_qs, urlencode
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy import func, select, update
from . import agent_distribution, client_sync, config, enrollment, sso
from .config import settings
from .blocker_resolution import BlockerResolutionUnavailable, _company_key, resolve_blocker_with_context
from .context_keys import ALLOWED_KEYS, ENUMS
from .installation_identity import reference_agent_version
from .panel_presentation import job_kind_label, job_status_label, rtc_status_view
from .panel_presentation import RTC_MODE_EXPLANATION, RTC_MODE_LABELS, job_kind_label, job_status_label, rtc_status_view
from .control_plane import blocker_detail, blocker_filter_summary, dashboard_summary, installation_view, jobs_status_summary, list_blockers, list_installations, list_jobs, list_panel_events
from .human_auth import create_session, current_session, require_csrf, verify_password
from .panel_presentation import format_ui_datetime
from .models import AdminUser, Agent, AgentEnrollment, AgentRelease, AgentSnapshot, BlockedProduct, FiscalContextEntry, Installation, Job, Tenant
from .models import AdminUser, Agent, AgentEnrollment, AgentRelease, AgentSnapshot, BlockedProduct, FiscalContextEntry, Installation, Job, RtcCandidate, RtcWriteAudit, Tenant
from .jobs import create_job
from .audit import record_audit
@@ -296,7 +296,7 @@ def router(session_factory):
if installation.processing_enabled and last_sync is not None:
next_sync = last_sync + timedelta(seconds=installation.sync_interval_seconds)
last_summary = session.scalar(select(Job.result_summary).where(Job.agent_id == agent.id, Job.status == "COMPLETED", Job.kind.in_(("INITIAL_SYNC", "SYNC_INCREMENTAL", "REPROCESS_ALL"))).order_by(Job.id.desc()).limit(1))
return {"rtc": rtc_status_view(last_summary, bool(installation.rtc_auto_apply_authorized)), "rtc_authorized": bool(installation.rtc_auto_apply_authorized), "enabled": bool(installation.processing_enabled), "interval_minutes": installation.sync_interval_seconds // 60,
return {"rtc": rtc_status_view(last_summary, installation.rtc_write_mode), "rtc_mode": installation.rtc_write_mode, "enabled": bool(installation.processing_enabled), "interval_minutes": installation.sync_interval_seconds // 60,
"last_sync": format_ui_datetime(last_sync) if last_sync else None, "next_sync": format_ui_datetime(next_sync) if next_sync else None,
"last_changed": installation.last_sync_changed, "last_job": {"kind": last_job.kind, "status": last_job.status, "id": last_job.id, "kind_label": job_kind_label(last_job.kind), "status_label": job_status_label(last_job.status)} if last_job else None}
@@ -319,30 +319,111 @@ def router(session_factory):
return RedirectResponse(f"/agents/{agent_id}?tab=jobs", status_code=303)
finally: session.close()
@routes.post("/agents/{agent_id}/rtc-auto-apply")
async def rtc_auto_apply_authorization(request: Request, agent_id: int):
"""Central authorization of the automatic cClassTrib fill (default OFF). Enabling requires an explicit typed confirmation; every change is audited."""
def _rtc_target(session, agent_id):
agent = session.get(Agent, agent_id)
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) if agent else None
return agent, installation
@routes.get("/agents/{agent_id}/rtc")
async def rtc_control_page(request: Request, agent_id: int):
"""Central cClassTrib control: write mode, candidate preview (server-side pagination), approvals and audit/rollback."""
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
agent, installation = _rtc_target(session, agent_id)
if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
q = request.query_params
status = q.get("status") or "PENDING"
page = max(1, int(q.get("page", "1") or 1)) if str(q.get("page", "1")).isdigit() else 1
size = 50
base = select(RtcCandidate).where(RtcCandidate.installation_id == installation.id)
counts = dict(session.execute(select(RtcCandidate.status, func.count()).where(RtcCandidate.installation_id == installation.id).group_by(RtcCandidate.status)).all())
shown = base.where(RtcCandidate.status == status) if status in ("PENDING", "APPROVED", "REJECTED", "APPLIED", "STALE") else base
total = int(session.scalar(select(func.count()).select_from(shown.subquery())) or 0)
rows = list(session.scalars(shown.order_by(RtcCandidate.product_code).offset((page - 1) * size).limit(size)))
audits = list(session.scalars(select(RtcWriteAudit).where(RtcWriteAudit.installation_id == installation.id, RtcWriteAudit.outcome.in_(("APPLIED", "ROLLED_BACK"))).order_by(RtcWriteAudit.id.desc()).limit(25)))
ops = _ops_view(session, installation, agent)
return render(request, "rtc_control.html", {"agent_id": agent_id, "installation": installation, "mode": installation.rtc_write_mode, "modes": RTC_MODE_LABELS, "explanation": RTC_MODE_EXPLANATION, "ops": ops,
"rows": rows, "counts": counts, "status": status, "page": page, "pages": max(1, -(-total // size)), "total": total, "audits": audits})
finally: session.close()
@routes.post("/agents/{agent_id}/rtc-mode")
async def rtc_write_mode(request: Request, agent_id: int):
"""Central authorization of the cClassTrib fill, per installation. Default DISABLED; ENABLED needs a typed confirmation; every change is audited."""
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"))
agent = session.get(Agent, agent_id)
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) if agent else None
agent, installation = _rtc_target(session, agent_id)
if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
decision = values.get("decision")
if decision == "authorize":
if (values.get("confirm") or "").strip().upper() != "PREENCHER":
return HTMLResponse("Digite PREENCHER para confirmar a autorização", status_code=422)
installation.rtc_auto_apply_authorized = True
elif decision == "revoke":
installation.rtc_auto_apply_authorized = False
else:
return HTMLResponse("decisão inválida", status_code=422)
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action=f"RTC_AUTO_APPLY_{decision.upper()}", target_type="installation", target_id=installation.installation_id,
context={"authorized": installation.rtc_auto_apply_authorized}, severity="WARNING")
mode = (values.get("mode") or "").strip().upper()
if mode not in RTC_MODE_LABELS:
return HTMLResponse("modo inválido", status_code=422)
if mode == "ENABLED" and (values.get("confirm") or "").strip().upper() != "PREENCHER":
return HTMLResponse("Digite PREENCHER para confirmar o modo automático", status_code=422)
previous = installation.rtc_write_mode
installation.rtc_write_mode = mode
installation.rtc_auto_apply_authorized = mode == "ENABLED"
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="RTC_WRITE_MODE", target_type="installation", target_id=installation.installation_id,
context={"from": previous, "to": mode}, severity="WARNING")
session.commit()
return RedirectResponse(f"/agents/{agent_id}", status_code=303)
return RedirectResponse(f"/agents/{agent_id}/rtc", status_code=303)
finally: session.close()
@routes.post("/agents/{agent_id}/rtc-candidates")
async def rtc_candidate_action(request: Request, agent_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"))
agent, installation = _rtc_target(session, agent_id)
if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
action = values.get("action")
scope = RtcCandidate.installation_id == installation.id
ids = [int(x) for x in parse_qs((await request.body()).decode()).get("ids", []) if x.isdigit()]
select_all = values.get("all") == "1" or request.query_params.get("all") == "1"
changed = 0
if action in ("approve", "reject"):
new, old = ("APPROVED", ("PENDING",)) if action == "approve" else ("REJECTED", ("PENDING", "APPROVED"))
cond = scope & RtcCandidate.status.in_(old) & (RtcCandidate.id.in_(ids) if not select_all else RtcCandidate.status.in_(old))
changed = session.execute(update(RtcCandidate).where(cond).values(status=new)).rowcount
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action=f"RTC_CANDIDATES_{action.upper()}", target_type="installation", target_id=installation.installation_id, context={"changed": changed, "all": select_all}, severity="WARNING")
elif action == "apply_approved":
if installation.rtc_write_mode != "APPROVAL_REQUIRED":
return HTMLResponse("Disponível apenas no modo Aprovação manual", status_code=409)
codes = list(session.scalars(select(RtcCandidate.product_code).where(scope, RtcCandidate.status == "APPROVED").order_by(RtcCandidate.product_code).limit(500)))
if codes:
job = create_job(session, agent, "REPROCESS_PRODUCTS", {"codigos": codes, "reason": "RTC_APPLY_APPROVED", "requested_by": admin.id})
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="RTC_APPLY_APPROVED", target_type="installation", target_id=installation.installation_id, context={"job_id": job.id, "products": len(codes)}, severity="WARNING")
else:
return HTMLResponse("ação inválida", status_code=422)
session.commit()
return RedirectResponse(f"/agents/{agent_id}/rtc", status_code=303)
finally: session.close()
@routes.post("/agents/{agent_id}/rtc-rollback")
async def rtc_rollback_request(request: Request, agent_id: int):
"""Requests the rollback of ONE audit record. The Agent executes it only if the value in the ERP still equals what was written; no free SQL exists."""
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"))
agent, installation = _rtc_target(session, agent_id)
if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
if (values.get("confirm") or "").strip().upper() != "REVERTER":
return HTMLResponse("Digite REVERTER para confirmar", status_code=422)
audit = session.get(RtcWriteAudit, int(values.get("audit_id") or 0))
if audit is None or audit.installation_id != installation.id or audit.action != "APPLY" or audit.outcome != "APPLIED" or audit.rollback_state in ("REQUESTED", "DONE"):
return HTMLResponse("registro não reversível", status_code=409)
audit.rollback_state = "REQUESTED"
job = create_job(session, agent, "REPROCESS_PRODUCTS", {"codigos": [audit.product_code], "reason": "RTC_ROLLBACK", "requested_by": admin.id})
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="RTC_ROLLBACK_REQUESTED", target_type="installation", target_id=installation.installation_id,
context={"audit_id": audit.id, "product_code": audit.product_code, "job_id": job.id}, severity="WARNING")
session.commit()
return RedirectResponse(f"/agents/{agent_id}/rtc", status_code=303)
finally: session.close()
@routes.post("/agents/{agent_id}/sync-now")