infrasynth-backend-kit/infrasynth/workflows/views.py
jcv-dev 551b42eab5 feat: production-hardening pass across the kit
Close the gaps between the documented contract (API-STANDARD, TENANCY,
ENTITLEMENTS) and the implementation, and remove committed build artifacts.

Security:
- verify + process inbound webhooks (HMAC/handler verify, size limit,
  timestamp tolerance, idempotency via InboundEvent.external_id)
- real 2FA login flow (pre-auth challenge; tokens only after verify/recovery)
- wire HybridPermission into security/audit views; add API-key rotate and
  users/<id>/permissions|roles endpoints
- tenant-scoped throttling on by default; webhook replay protection
- verify MercadoPago webhook signatures
- login brute-force guard, configurable password policy, real ALTCHA PoW

Correctness:
- apply verified billing webhooks idempotently (subscription/entitlement/
  invoice/PaymentTransaction); scheduled payment lifecycle jobs
- capture audit update diffs automatically; add audit retention purge
- working notification retries, per-channel rate limits, log retention
- pluggable virus scanner, upload-size limit, pipeline toggle
- feature rollout %/environment targeting; settings-driven registrations
- workflow guards (instance cap, route depth, self-assignment, clone on re-entry)
- wire every previously-dead INFRASYNTH_* setting; drop truly dead ones

Delivery:
- README + CHANGELOG; CI format check + coverage gate
- keep test media out of the tree; untrack .coverage, __pycache__,
  egg-info, docs/ and invoice artifacts
2026-09-24 10:41:21 -05:00

263 lines
8.9 KiB
Python

from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .filters import (
NodeAssignmentFilter,
TransitionFilter,
WorkflowFilter,
WorkflowInstanceFilter,
WorkflowNodeFilter,
WorkflowObserverFilter,
)
from .models import (
NodeAssignment,
Transition,
Workflow,
WorkflowInstance,
WorkflowNode,
WorkflowObserver,
)
from .serializers import (
NodeAssignmentSerializer,
TransitionSerializer,
WorkflowInstanceSerializer,
WorkflowNodeSerializer,
WorkflowObserverSerializer,
WorkflowSerializer,
)
class WorkflowViewSet(viewsets.ModelViewSet):
queryset = Workflow.objects.all()
serializer_class = WorkflowSerializer
permission_classes = [IsAuthenticated]
filterset_class = WorkflowFilter
def initial(self, request, *args, **kwargs):
from infrasynth.features.services import FeatureService
if not FeatureService().is_enabled("workflows", user=request.user):
from django.http import Http404
raise Http404()
super().initial(request, *args, **kwargs)
def get_queryset(self):
return Workflow.objects.select_related("created_by").all()
class WorkflowNodeViewSet(viewsets.ModelViewSet):
queryset = WorkflowNode.objects.all()
serializer_class = WorkflowNodeSerializer
permission_classes = [IsAuthenticated]
filterset_class = WorkflowNodeFilter
def initial(self, request, *args, **kwargs):
from infrasynth.features.services import FeatureService
if not FeatureService().is_enabled("workflows", user=request.user):
from django.http import Http404
raise Http404()
super().initial(request, *args, **kwargs)
def get_queryset(self):
return WorkflowNode.objects.select_related("workflow").all()
class TransitionViewSet(viewsets.ModelViewSet):
queryset = Transition.objects.all()
serializer_class = TransitionSerializer
permission_classes = [IsAuthenticated]
filterset_class = TransitionFilter
def initial(self, request, *args, **kwargs):
from infrasynth.features.services import FeatureService
if not FeatureService().is_enabled("workflows", user=request.user):
from django.http import Http404
raise Http404()
super().initial(request, *args, **kwargs)
def get_queryset(self):
return Transition.objects.select_related("from_node", "to_node").all()
class WorkflowInstanceViewSet(viewsets.ModelViewSet):
queryset = WorkflowInstance.objects.all()
serializer_class = WorkflowInstanceSerializer
permission_classes = [IsAuthenticated]
filterset_class = WorkflowInstanceFilter
def initial(self, request, *args, **kwargs):
from infrasynth.features.services import FeatureService
if not FeatureService().is_enabled("workflows", user=request.user):
from django.http import Http404
raise Http404()
super().initial(request, *args, **kwargs)
def get_queryset(self):
return WorkflowInstance.objects.select_related("workflow", "current_node", "owner").all()
def perform_create(self, serializer):
from .engine import WorkflowEngine
workflow = serializer.validated_data["workflow"]
initial_data = serializer.validated_data.get("metadata")
instance = WorkflowEngine().start(workflow.slug, owner=self.request.user, initial_data=initial_data)
serializer.instance = instance
@action(detail=True, methods=["get"])
def route(self, request, pk=None):
instance = self.get_object()
from .engine import WorkflowEngine
return Response({"route": WorkflowEngine().get_route(instance.pk)})
@action(detail=True, methods=["get"])
def state(self, request, pk=None):
instance = self.get_object()
from .engine import WorkflowEngine
return Response({"state": WorkflowEngine().get_node_states(instance.pk)})
@action(detail=True, methods=["post"])
def submit(self, request, pk=None):
instance = self.get_object()
from .engine import WorkflowEngine
assignment_id = request.data.get("assignment_id")
decision = request.data.get("decision")
if not assignment_id or not decision:
return Response(
{"detail": "assignment_id and decision are required."},
status=status.HTTP_400_BAD_REQUEST,
)
engine = WorkflowEngine()
instance = engine.submit_decision(
assignment_id,
decision,
data=request.data.get("data"),
user=request.user,
comments=request.data.get("comments", ""),
)
return Response(
{
"instance_id": instance.pk,
"status": instance.status,
"current_node": (
{"id": instance.current_node_id, "name": instance.current_node.name}
if instance.current_node_id
else None
),
}
)
@action(detail=True, methods=["post"])
def assign(self, request, pk=None):
instance = self.get_object()
from .engine import WorkflowEngine
node_id = request.data.get("node_id")
user_ids = request.data.get("users", [])
if not node_id or not user_ids:
return Response(
{"detail": "node_id and users are required."},
status=status.HTTP_400_BAD_REQUEST,
)
from django.contrib.auth import get_user_model
user_model = get_user_model()
users = user_model.objects.filter(pk__in=user_ids)
try:
assignments = WorkflowEngine().assign_users(
instance.pk,
node_id,
list(users),
is_required=request.data.get("is_required", True),
actor=request.user,
)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
from .serializers import NodeAssignmentSerializer
return Response(
NodeAssignmentSerializer(assignments, many=True).data,
status=status.HTTP_201_CREATED,
)
@action(detail=True, methods=["post", "delete"])
def observers(self, request, pk=None):
instance = self.get_object()
from .engine import WorkflowEngine
from .models import WorkflowObserver
user_id = request.data.get("user_id")
if not user_id:
return Response(
{"detail": "user_id is required."},
status=status.HTTP_400_BAD_REQUEST,
)
engine = WorkflowEngine()
if request.method == "DELETE":
deleted, _ = WorkflowObserver.objects.filter(instance=instance, user_id=user_id).delete()
if not deleted:
return Response(
{"detail": "Observer not found."},
status=status.HTTP_404_NOT_FOUND,
)
return Response(status=status.HTTP_204_NO_CONTENT)
from django.contrib.auth import get_user_model
user_model = get_user_model()
user = user_model.objects.filter(pk=user_id).first()
if user is None:
return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND)
observer = engine.add_observer(instance.pk, user)
from .serializers import WorkflowObserverSerializer
return Response(WorkflowObserverSerializer(observer).data, status=status.HTTP_201_CREATED)
class NodeAssignmentViewSet(viewsets.ModelViewSet):
queryset = NodeAssignment.objects.all()
serializer_class = NodeAssignmentSerializer
permission_classes = [IsAuthenticated]
filterset_class = NodeAssignmentFilter
def initial(self, request, *args, **kwargs):
from infrasynth.features.services import FeatureService
if not FeatureService().is_enabled("workflows", user=request.user):
from django.http import Http404
raise Http404()
super().initial(request, *args, **kwargs)
def get_queryset(self):
return NodeAssignment.objects.select_related("instance", "node", "user").all()
class WorkflowObserverViewSet(viewsets.ModelViewSet):
queryset = WorkflowObserver.objects.all()
serializer_class = WorkflowObserverSerializer
permission_classes = [IsAuthenticated]
filterset_class = WorkflowObserverFilter
def initial(self, request, *args, **kwargs):
from infrasynth.features.services import FeatureService
if not FeatureService().is_enabled("workflows", user=request.user):
from django.http import Http404
raise Http404()
super().initial(request, *args, **kwargs)
def get_queryset(self):
return WorkflowObserver.objects.select_related("instance", "user").all()