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
102 lines
3.8 KiB
Python
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)
|