- 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)
109 lines
4.1 KiB
Python
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)
|