infrasynth-backend-kit/infrasynth/tenancy/views.py
jcv-dev a2930426b4 feat: tenant configs, live signals, and automatic permission management
- infrasynth.configs: typed multi-tenant config store (registry, service,
  secrets, cache) + public config_changed/config_reset signals and API
- emit the declared-but-dead signals (features flags/overrides, scheduler
  task_completed/task_failed, tenancy tenant_updated, audit model_changed)
  and per-model audit field exclusions
- security: permission catalog (security_permission), Django-style
  model-derived AutoPermission, PermissionRegistry, RoleAssignment,
  global-or-tenant Grant/Revoke, catalog API
- consolidate the permission surface: PermissionRegistry only (drop the
  settings dict), IsAuthenticatedAndPermitted aliases HybridPermission,
  require_permission replaced by required_permissions + require_all
- packaging: add [build-system]; add Forgejo publish workflow (.forgejo)
2026-09-29 17:06:54 -05:00

109 lines
4.1 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.response import Response
from infrasynth.security.permissions import IsAuthenticatedAndPermitted
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 = [IsAuthenticatedAndPermitted]
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
def perform_update(self, serializer):
validated = serializer.validated_data
changes = {
field: validated[field] for field in ("name", "locale", "timezone", "metadata") if field in validated
}
TenantService().update_tenant(serializer.instance, actor=self.request.user, **changes)
@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 = [IsAuthenticatedAndPermitted]
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 = [IsAuthenticatedAndPermitted]
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)