infrasynth-backend-kit/infrasynth/tenancy/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

102 lines
3.8 KiB
Python

from django.shortcuts import get_object_or_404
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from infrasynth.shared.exceptions import NotFoundError, ValidationAppError
from .filters import TenantFilter, TenantInvitationFilter, TenantMembershipFilter
from .models import Tenant, TenantMembership
from .serializers import (
TenantInvitationSerializer,
TenantMembershipSerializer,
TenantSerializer,
)
from .services import TenantService
class TenantViewSet(viewsets.ModelViewSet):
serializer_class = TenantSerializer
permission_classes = [IsAuthenticated]
filterset_class = TenantFilter
def get_queryset(self):
return (
Tenant.objects.filter(memberships__user=self.request.user, memberships__is_active=True)
.distinct()
.order_by("name")
)
def perform_create(self, serializer):
tenant = TenantService().create_tenant(
serializer.validated_data["name"],
self.request.user,
slug=serializer.validated_data.get("slug"),
)
serializer.instance = tenant
@action(detail=True, methods=["get"], url_path="members")
def members(self, request, pk=None):
tenant = self.get_object()
memberships = TenantMembership.objects.filter(tenant=tenant).select_related("user")
return Response(TenantMembershipSerializer(memberships, many=True).data)
@action(detail=True, methods=["post"], url_path="members/invite")
def invite(self, request, pk=None):
tenant = self.get_object()
email = request.data.get("email")
if not email:
raise ValidationAppError("email is required.", code="VALIDATION_FIELD_REQUIRED")
invitation = TenantService().invite(
tenant, email, role=request.data.get("role", "member"), invited_by=request.user
)
return Response(TenantInvitationSerializer(invitation).data, status=status.HTTP_201_CREATED)
@action(
detail=True,
methods=["delete"],
url_path=r"members/(?P<membership_id>[^/.]+)",
)
def remove_member(self, request, pk=None, membership_id=None):
tenant = self.get_object()
membership = get_object_or_404(TenantMembership, tenant=tenant, pk=membership_id)
TenantService().remove_member(tenant, membership.user)
return Response(status=status.HTTP_204_NO_CONTENT)
class TenantMembershipViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
serializer_class = TenantMembershipSerializer
permission_classes = [IsAuthenticated]
filterset_class = TenantMembershipFilter
def get_queryset(self):
return (
TenantMembership.objects.filter(
tenant__memberships__user=self.request.user,
tenant__memberships__is_active=True,
)
.select_related("tenant", "user")
.distinct()
)
class TenantInvitationViewSet(viewsets.GenericViewSet):
serializer_class = TenantInvitationSerializer
permission_classes = [IsAuthenticated]
filterset_class = TenantInvitationFilter
@action(detail=False, methods=["post"], url_path="accept")
def accept(self, request):
token = request.data.get("token")
if not token:
raise ValidationAppError("token is required.", code="VALIDATION_FIELD_REQUIRED")
try:
membership = TenantService().accept_invitation(token, request.user)
except NotFoundError as exc:
return Response(exc.to_dict(), status=exc.status)
return Response(TenantMembershipSerializer(membership).data, status=status.HTTP_200_OK)