initial commit

This commit is contained in:
2026-03-11 17:12:28 +08:00
commit 5d1e1cb7cb
61 changed files with 2971 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
import django_filters as filters
from apps.workspaces.models import Workspace, WorkspaceMembership
from core.filters.base import BaseFilterSet
class WorkspaceFilter(BaseFilterSet):
class Meta:
model = Workspace
fields = ["owner"]
class WorkspaceMembershipFilter(BaseFilterSet):
role = filters.MultipleChoiceFilter(choices=WorkspaceMembership.Role.choices)
joined_after = filters.DateTimeFilter(field_name="joined_at", lookup_expr="gte")
joined_before = filters.DateTimeFilter(field_name="joined_at", lookup_expr="lte")
class Meta:
model = WorkspaceMembership
fields = ["workspace", "user", "role", "is_active"]

View File

@@ -0,0 +1,108 @@
from rest_framework import permissions
from apps.workspaces.models import Workspace, WorkspaceMembership
class IsWorkspaceOwner(permissions.BasePermission):
"""
Permission check:
- User must be the explicit 'owner' on the Workspace model.
- OR User must have a WorkspaceMembership with the 'OWNER' role.
"""
message = "Access denied. You must be the Workspace Owner to perform this action."
def has_object_permission(self, request, view, obj):
if not request.user or not request.user.is_authenticated:
return False
if isinstance(obj, Workspace):
workspace = obj
elif isinstance(obj, WorkspaceMembership):
workspace = obj.workspace
elif hasattr(obj, 'workspace'):
workspace = obj.workspace
else:
return False
if workspace.owner == request.user:
return True
return WorkspaceMembership.objects.filter(
workspace=workspace,
user=request.user,
role=WorkspaceMembership.Role.OWNER,
is_active=True
).exists()
class IsWorkspaceAdmin(permissions.BasePermission):
"""
Permission check:
- User's role in the workspace is either 'ADMIN' or 'OWNER'.
"""
message = "Access denied. You must be a Workspace Admin or Owner to perform this action."
def has_object_permission(self, request, view, obj):
if not request.user or not request.user.is_authenticated:
return False
if isinstance(obj, Workspace):
workspace = obj
elif isinstance(obj, WorkspaceMembership):
workspace = obj.workspace
elif hasattr(obj, 'workspace'):
workspace = obj.workspace
else:
return False
if workspace.owner == request.user:
return True
allowed_roles = [
WorkspaceMembership.Role.OWNER,
WorkspaceMembership.Role.ADMIN,
]
return WorkspaceMembership.objects.filter(
workspace=workspace,
user=request.user,
role__in=allowed_roles,
is_active=True
).exists()
class IsWorkspaceMember(permissions.BasePermission):
"""
Permission check:
- User's role in the workspace is 'OWNER', 'ADMIN', or 'MEMBER'.
"""
message = "Access denied. You must be an active member of this workspace."
def has_object_permission(self, request, view, obj):
if not request.user or not request.user.is_authenticated:
return False
if isinstance(obj, Workspace):
workspace = obj
elif isinstance(obj, WorkspaceMembership):
workspace = obj.workspace
elif hasattr(obj, 'workspace'):
workspace = obj.workspace
else:
return False
if workspace.owner == request.user:
return True
allowed_roles = [
WorkspaceMembership.Role.OWNER,
WorkspaceMembership.Role.ADMIN,
WorkspaceMembership.Role.MEMBER,
]
return WorkspaceMembership.objects.filter(
workspace=workspace,
user=request.user,
role__in=allowed_roles,
is_active=True
).exists()

View File

@@ -0,0 +1,24 @@
from rest_framework import serializers
from core.serializers.base import BaseModelSerializer
from apps.workspaces.models import Workspace, WorkspaceMembership
class WorkspaceSerializer(BaseModelSerializer):
class Meta:
model = Workspace
fields = BaseModelSerializer.Meta.fields + (
"name",
"description",
)
class WorkspaceMembershipSerializer(BaseModelSerializer):
class Meta:
model = WorkspaceMembership
fields = BaseModelSerializer.Meta.fields + (
"workspace",
"user",
"role",
"is_active",
)

View File

@@ -0,0 +1,12 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from apps.workspaces.api.views import WorkspaceViewSet, WorkspaceMembershipViewSet
router = DefaultRouter()
router.register(r'workspaces', WorkspaceViewSet, basename='workspace')
router.register(r'workspace-memberships', WorkspaceMembershipViewSet, basename='workspace-membership')
urlpatterns = [
path('', include(router.urls)),
]

View File

@@ -0,0 +1,104 @@
from django.db.models import Q
from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.response import Response
from rest_framework.filters import OrderingFilter, SearchFilter
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.viewsets import ModelViewSet
from rest_framework.permissions import IsAuthenticated
from apps.workspaces.api.permissions import IsWorkspaceOwner, IsWorkspaceAdmin
from apps.workspaces.api.serializers import WorkspaceMembershipSerializer, WorkspaceSerializer
from apps.workspaces.api.filters import WorkspaceFilter, WorkspaceMembershipFilter
from apps.workspaces.models import Workspace, WorkspaceMembership
from core.paginations.limit_offset import CustomLimitOffsetPagination
class WorkspaceViewSet(ModelViewSet):
serializer_class = WorkspaceSerializer
pagination_class = CustomLimitOffsetPagination
filter_backends = (DjangoFilterBackend, OrderingFilter, SearchFilter)
filterset_class = WorkspaceFilter
search_fields = ("name", "description", "owner__username", "owner__email")
ordering_fields = ("created_at", "updated_at", "name")
ordering = ("-updated_at", "-created_at")
def get_queryset(self):
user = self.request.user
if not user.is_authenticated:
return Workspace.objects.none()
return Workspace.objects.filter(
Q(owner=user) |
Q(memberships__user=user, memberships__is_active=True)
).distinct()
def get_permissions(self):
if self.action in ["update", "partial_update"]:
return [IsAuthenticated(), IsWorkspaceAdmin()]
elif self.action == "destroy":
return [IsAuthenticated(), IsWorkspaceOwner()]
return [IsAuthenticated()]
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
class WorkspaceMembershipViewSet(ModelViewSet):
serializer_class = WorkspaceMembershipSerializer
pagination_class = CustomLimitOffsetPagination
filter_backends = (DjangoFilterBackend, OrderingFilter, SearchFilter)
filterset_class = WorkspaceMembershipFilter
search_fields = (
"user__mobile",
"user__email",
"user__first_name",
"user__last_name",
"workspace__name"
)
ordering_fields = ("joined_at", "created_at", "role")
ordering = ("-created_at",)
def get_queryset(self):
user = self.request.user
if not user.is_authenticated:
return WorkspaceMembership.objects.none()
return WorkspaceMembership.objects.filter(
Q(workspace__owner=user) |
Q(workspace__memberships__user=user, workspace__memberships__is_active=True)
).distinct()
def get_permissions(self):
if self.action in ["update", "partial_update"]:
return [IsAuthenticated(), IsWorkspaceAdmin()]
if self.action in ["destroy"]:
return [IsAuthenticated(), IsWorkspaceOwner()]
return [IsAuthenticated()]
def create(self, request, *args, **kwargs):
"""
Overridden to check permissions manually.
Because the membership object doesn't exist yet, standard DRF object-level
permissions won't catch payload-level workspace violations.
"""
workspace_id = request.data.get("workspace")
if not workspace_id:
return Response(
{"workspace": ["This field is required."]},
status=status.HTTP_400_BAD_REQUEST
)
workspace = get_object_or_404(Workspace, id=workspace_id)
permission = IsWorkspaceAdmin()
if not permission.has_object_permission(request, self, workspace):
return Response(
{"detail": "You must be a Workspace Admin or Owner to add members."},
status=status.HTTP_403_FORBIDDEN
)
return super().create(request, *args, **kwargs)