initial commit
This commit is contained in:
65
apps/workspaces/admin.py
Normal file
65
apps/workspaces/admin.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from core.admins.base import BaseAdmin
|
||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||
|
||||
|
||||
class WorkspaceMembershipInline(admin.TabularInline):
|
||||
model = WorkspaceMembership
|
||||
extra = 0
|
||||
autocomplete_fields = ("user",)
|
||||
|
||||
|
||||
@admin.register(Workspace)
|
||||
class WorkspaceAdmin(BaseAdmin):
|
||||
list_display = (
|
||||
"id",
|
||||
"name",
|
||||
"owner",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"is_deleted",
|
||||
)
|
||||
|
||||
search_fields = (
|
||||
"name",
|
||||
"owner__mobile",
|
||||
)
|
||||
|
||||
list_filter = (
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"is_deleted",
|
||||
)
|
||||
|
||||
autocomplete_fields = ("owner",)
|
||||
|
||||
inlines = (WorkspaceMembershipInline,)
|
||||
|
||||
|
||||
@admin.register(WorkspaceMembership)
|
||||
class WorkspaceMembershipAdmin(BaseAdmin):
|
||||
list_display = (
|
||||
"id",
|
||||
"workspace",
|
||||
"user",
|
||||
"role",
|
||||
"is_active",
|
||||
"created_at",
|
||||
)
|
||||
|
||||
list_filter = (
|
||||
"role",
|
||||
"is_active",
|
||||
"is_deleted",
|
||||
)
|
||||
|
||||
search_fields = (
|
||||
"workspace__name",
|
||||
"user__mobile",
|
||||
)
|
||||
|
||||
autocomplete_fields = (
|
||||
"workspace",
|
||||
"user",
|
||||
)
|
||||
19
apps/workspaces/api/filters.py
Normal file
19
apps/workspaces/api/filters.py
Normal 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"]
|
||||
108
apps/workspaces/api/permissions.py
Normal file
108
apps/workspaces/api/permissions.py
Normal 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()
|
||||
24
apps/workspaces/api/serializers.py
Normal file
24
apps/workspaces/api/serializers.py
Normal 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",
|
||||
)
|
||||
12
apps/workspaces/api/urls.py
Normal file
12
apps/workspaces/api/urls.py
Normal 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)),
|
||||
]
|
||||
104
apps/workspaces/api/views.py
Normal file
104
apps/workspaces/api/views.py
Normal 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)
|
||||
10
apps/workspaces/apps.py
Normal file
10
apps/workspaces/apps.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WorkspacesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.workspaces"
|
||||
verbose_name = "Workspaces"
|
||||
|
||||
def ready(self) -> None:
|
||||
from apps.workspaces import signals
|
||||
0
apps/workspaces/migrations/__init__.py
Normal file
0
apps/workspaces/migrations/__init__.py
Normal file
77
apps/workspaces/models.py
Normal file
77
apps/workspaces/models.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
|
||||
from core.models.base import BaseModel
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Workspace(BaseModel):
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
owner = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="owned_workspaces",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "workspace"
|
||||
ordering = ("-updated_at", "-created_at")
|
||||
indexes = [
|
||||
models.Index(fields=["owner"], name="workspace_owner_idx"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def members(self):
|
||||
return User.objects.filter(
|
||||
workspace_memberships__workspace=self,
|
||||
workspace_memberships__is_active=True,
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceMembership(BaseModel):
|
||||
class Role(models.TextChoices):
|
||||
OWNER = "owner", "Owner"
|
||||
ADMIN = "admin", "Admin"
|
||||
MEMBER = "member", "Member"
|
||||
GUEST = "guest", "Guest"
|
||||
|
||||
workspace = models.ForeignKey(
|
||||
Workspace,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="memberships",
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_memberships",
|
||||
)
|
||||
role = models.CharField(
|
||||
max_length=20,
|
||||
choices=Role.choices,
|
||||
default=Role.MEMBER,
|
||||
)
|
||||
is_active = models.BooleanField(default=True)
|
||||
joined_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "workspace_membership"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["workspace"], name="membership_workspace_idx"),
|
||||
models.Index(fields=["user"], name="membership_user_idx"),
|
||||
]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["workspace", "user"],
|
||||
name="unique_workspace_membership",
|
||||
condition=models.Q(is_deleted=False),
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} @ {self.workspace}"
|
||||
14
apps/workspaces/signals.py
Normal file
14
apps/workspaces/signals.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||
|
||||
|
||||
@receiver(post_save, sender=Workspace)
|
||||
def create_owner_membership(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
WorkspaceMembership.objects.create(
|
||||
workspace=instance,
|
||||
user=instance.owner,
|
||||
role=WorkspaceMembership.Role.OWNER,
|
||||
)
|
||||
Reference in New Issue
Block a user