from decimal import Decimal, InvalidOperation from django.core import signing from django.core.cache import cache from django.db import transaction from django.db.models import Q from django.shortcuts import get_object_or_404 from rest_framework import status from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.decorators import action from django_filters.rest_framework import DjangoFilterBackend from rest_framework.viewsets import ModelViewSet from rest_framework.permissions import IsAuthenticated from rest_framework.parsers import FormParser, MultiPartParser, JSONParser from apps.notifications.services import ( notify_workspace_membership_added, notify_workspace_membership_deactivated, notify_workspace_membership_removed, notify_workspace_membership_role_changed, ) from apps.projects.models import ProjectUserRate from apps.projects.services.access import filter_projects_for_user from apps.users.models import User from apps.workspaces.api.permissions import ( CanWorkspaceManageMembers, IsWorkspaceAdmin, IsWorkspaceMember, IsWorkspaceOwner, ) from apps.workspaces.api.serializers import ( PriceUnitSerializer, WorkspaceMembershipSerializer, WorkspaceSerializer, WorkspaceUserRateSerializer, WorkspaceUserRateCreateSerializer, WorkspaceUserRateUpdateSerializer, ) from apps.workspaces.api.filters import WorkspaceFilter, WorkspaceMembershipFilter from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate from apps.workspaces.services import ( WORKSPACE_MEMBERS_VIEW, WORKSPACE_EDIT, WORKSPACE_VIEW, can_assign_workspace_role, can_change_workspace_membership, has_workspace_capability, upsert_workspace_user_rate, update_workspace_user_rate, ) from core.paginations.limit_offset import CustomLimitOffsetPagination from core.services.cache import ( CACHE_NAMESPACE_PRICE_UNITS, CACHE_NAMESPACE_WORKSPACE_MEMBERSHIPS, CACHE_NAMESPACE_WORKSPACE_RATES, get_namespace_version, get_or_set_cache_payload, ) REFERENCE_CACHE_TTL_SECONDS = 60 * 5 PRICE_UNITS_CACHE_TTL_SECONDS = 60 * 60 MEMBER_IMPORT_CACHE_PREFIX = "workspace-member-import" MEMBER_IMPORT_TTL_SECONDS = 60 * 15 MEMBER_IMPORT_MAX_ROWS = 500 def _normalize_digits(value): if value is None: return "" translation = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789") return str(value).translate(translation).strip() def _import_cache_key(token): return f"{MEMBER_IMPORT_CACHE_PREFIX}:{token}" class WorkspaceViewSet(ModelViewSet): serializer_class = WorkspaceSerializer parser_classes = [MultiPartParser, FormParser, JSONParser] pagination_class = CustomLimitOffsetPagination filter_backends = (DjangoFilterBackend, OrderingFilter, SearchFilter) filterset_class = WorkspaceFilter search_fields = ("name", "description") 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 ["list", "retrieve"]: return [IsAuthenticated(), IsWorkspaceMember()] if self.action == "my_rates": return [IsAuthenticated()] 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) def create(self, request, *args, **kwargs): if getattr(request.user, "is_demo", False): return Response( {"detail": "Demo accounts cannot create additional workspaces."}, status=status.HTTP_403_FORBIDDEN, ) return super().create(request, *args, **kwargs) @action(detail=True, methods=["get"], url_path="my-rates") def my_rates(self, request, pk=None): workspace = self.get_object() if not has_workspace_capability(request.user, workspace, WORKSPACE_VIEW): raise PermissionDenied("You do not have access to this workspace.") def serialize_rate(rate): if not rate: return None unit = PriceUnit.objects.filter(code=rate.currency, is_deleted=False).first() return { "id": str(rate.id), "hourly_rate": str(rate.hourly_rate), "currency": rate.currency, "price_unit": PriceUnitSerializer(unit).data if unit else None, "effective_from": rate.effective_from.isoformat() if rate.effective_from else None, } workspace_rate = ( WorkspaceUserRate.objects.filter( workspace=workspace, user=request.user, is_deleted=False, ) .order_by("-effective_from", "-updated_at") .first() ) accessible_projects = list( filter_projects_for_user( request.user, workspace.projects.filter(is_deleted=False).select_related("client"), ).order_by("client__name", "name") ) accessible_project_ids = [project.id for project in accessible_projects] project_rates_by_project_id = {} for rate in ( ProjectUserRate.objects.filter( project_id__in=accessible_project_ids, user=request.user, is_active=True, is_deleted=False, ) .select_related("project", "project__client") .order_by("project_id", "-effective_from", "-updated_at") ): project_rates_by_project_id.setdefault(str(rate.project_id), rate) payload = { "workspace": { "id": str(workspace.id), "name": workspace.name, }, "workspace_rate": serialize_rate(workspace_rate), "accessible_project_count": len(accessible_projects), "project_rates": [ { "project": { "id": str(project.id), "name": project.name, "client": ( {"id": str(project.client_id), "name": project.client.name} if project.client_id and project.client else None ), }, "rate": serialize_rate(project_rates_by_project_id[str(project.id)]), } for project in accessible_projects if str(project.id) in project_rates_by_project_id ], } payload["project_override_count"] = len(payload["project_rates"]) payload["workspace_fallback_project_count"] = max( payload["accessible_project_count"] - payload["project_override_count"], 0, ) return Response(payload) 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 ["list", "retrieve"]: return [IsAuthenticated()] if self.action in ["create", "update", "partial_update"]: return [IsAuthenticated(), CanWorkspaceManageMembers()] if self.action in ["destroy"]: return [IsAuthenticated(), CanWorkspaceManageMembers()] return [IsAuthenticated()] def _ensure_import_permission(self, request, workspace): if getattr(request.user, "is_demo", False): return Response( {"detail": "Demo accounts cannot import workspace members."}, status=status.HTTP_403_FORBIDDEN, ) permission = IsWorkspaceAdmin() if not permission.has_object_permission(request, self, workspace): return Response( {"detail": "You must be a Workspace Admin or Owner to import members."}, status=status.HTTP_403_FORBIDDEN, ) return None def _validate_import_rows(self, request, workspace, rows): if not isinstance(rows, list): rows = [] result_rows = [] seen_mobiles = set() valid_count = 0 invalid_count = 0 allowed_roles = { WorkspaceMembership.Role.ADMIN, WorkspaceMembership.Role.MEMBER, WorkspaceMembership.Role.GUEST, } existing_memberships = { str(membership.user_id): membership for membership in WorkspaceMembership.all_objects.filter( workspace=workspace, is_deleted=False, ) } if len(rows) > MEMBER_IMPORT_MAX_ROWS: return { "can_commit": False, "summary": { "total": len(rows), "valid": 0, "invalid": len(rows), }, "rows": [ { "line": None, "mobile": "", "role": WorkspaceMembership.Role.MEMBER, "hourly_rate": "", "currency": "", "status": "invalid", "action": "none", "user": None, "messages": [f"Import is limited to {MEMBER_IMPORT_MAX_ROWS} rows."], } ], } for index, raw_row in enumerate(rows, start=1): raw_row = raw_row if isinstance(raw_row, dict) else {} line = raw_row.get("line") or index + 1 mobile = _normalize_digits(raw_row.get("mobile")) role = (str(raw_row.get("role") or WorkspaceMembership.Role.MEMBER).strip().lower()) hourly_rate_raw = _normalize_digits(raw_row.get("hourly_rate")) currency = str(raw_row.get("currency") or "").strip().upper() messages = [] user = None normalized_rate = "" if not mobile: messages.append("Mobile is required.") elif mobile in seen_mobiles: messages.append("This mobile appears more than once in the import file.") else: seen_mobiles.add(mobile) user = User.objects.filter(mobile=mobile).first() if not user: messages.append("No user exists with this mobile number.") elif str(user.id) in existing_memberships: messages.append("This user is already a member of the workspace.") if role == WorkspaceMembership.Role.OWNER: messages.append("Owner role cannot be imported.") elif role not in allowed_roles: messages.append("Role must be admin, member, or guest.") elif not can_assign_workspace_role(request.user, workspace, role): messages.append("You do not have permission to assign this role.") has_rate = bool(hourly_rate_raw) has_currency = bool(currency) if has_rate != has_currency: messages.append("Hourly rate and currency must be provided together.") elif has_rate and has_currency: try: parsed_rate = Decimal(hourly_rate_raw.replace(",", "")) if parsed_rate <= Decimal("0"): messages.append("Hourly rate must be greater than zero.") else: normalized_rate = f"{parsed_rate:.2f}" except (InvalidOperation, ValueError): messages.append("Hourly rate must be a valid number.") if not PriceUnit.objects.filter(code=currency, is_deleted=False).exists(): messages.append("Currency is invalid.") row_status = "invalid" if messages else "valid" if messages: invalid_count += 1 else: valid_count += 1 result_rows.append( { "line": line, "mobile": mobile, "role": role, "hourly_rate": normalized_rate, "currency": currency, "status": row_status, "action": "add_member" if row_status == "valid" else "none", "user": ( { "id": str(user.id), "full_name": user.full_name or user.mobile, "mobile": user.mobile, } if user else None ), "messages": messages, } ) return { "can_commit": bool(rows) and invalid_count == 0, "summary": { "total": len(rows), "valid": valid_count, "invalid": invalid_count, }, "rows": result_rows, } def _build_import_response(self, request, workspace, rows, *, include_token): payload = self._validate_import_rows(request, workspace, rows) if include_token and payload["can_commit"]: token = signing.dumps( { "workspace": str(workspace.id), "user": str(request.user.id), }, salt=MEMBER_IMPORT_CACHE_PREFIX, ) cache.set( _import_cache_key(token), { "workspace": str(workspace.id), "user": str(request.user.id), "rows": rows, }, timeout=MEMBER_IMPORT_TTL_SECONDS, ) payload["import_token"] = token else: payload["import_token"] = None return payload @action(detail=False, methods=["post"], url_path="import/validate") def import_validate(self, request): 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, is_deleted=False) permission_response = self._ensure_import_permission(request, workspace) if permission_response is not None: return permission_response payload = self._build_import_response( request, workspace, request.data.get("rows") or [], include_token=True, ) return Response(payload, status=status.HTTP_200_OK) @action(detail=False, methods=["post"], url_path="import/commit") def import_commit(self, request): workspace_id = request.data.get("workspace") import_token = request.data.get("import_token") if not workspace_id: return Response( {"workspace": ["This field is required."]}, status=status.HTTP_400_BAD_REQUEST, ) if not import_token: return Response( {"import_token": ["This field is required."]}, status=status.HTTP_400_BAD_REQUEST, ) workspace = get_object_or_404(Workspace, id=workspace_id, is_deleted=False) permission_response = self._ensure_import_permission(request, workspace) if permission_response is not None: return permission_response try: signed_payload = signing.loads( import_token, salt=MEMBER_IMPORT_CACHE_PREFIX, max_age=MEMBER_IMPORT_TTL_SECONDS, ) except signing.BadSignature: return Response( {"detail": "Import validation has expired. Please validate the file again."}, status=status.HTTP_400_BAD_REQUEST, ) cached_payload = cache.get(_import_cache_key(import_token)) if ( not cached_payload or signed_payload.get("workspace") != str(workspace.id) or signed_payload.get("user") != str(request.user.id) or cached_payload.get("workspace") != str(workspace.id) or cached_payload.get("user") != str(request.user.id) ): return Response( {"detail": "Import validation has expired. Please validate the file again."}, status=status.HTTP_400_BAD_REQUEST, ) rows = cached_payload.get("rows") or [] validation_payload = self._validate_import_rows(request, workspace, rows) if not validation_payload["can_commit"]: validation_payload["import_token"] = None return Response(validation_payload, status=status.HTTP_400_BAD_REQUEST) memberships = [] rate_count = 0 with transaction.atomic(): for row in validation_payload["rows"]: user_id = row["user"]["id"] membership = WorkspaceMembership.objects.create( workspace=workspace, user_id=user_id, role=row["role"], is_active=True, ) memberships.append(membership) notify_workspace_membership_added( actor=request.user, recipient=membership.user, workspace=workspace, role=membership.role, ) if row["hourly_rate"] and row["currency"]: upsert_workspace_user_rate( workspace=workspace, user_id=user_id, hourly_rate=Decimal(row["hourly_rate"]), currency=row["currency"], ) rate_count += 1 cache.delete(_import_cache_key(import_token)) return Response( { "created_memberships": len(memberships), "created_or_updated_rates": rate_count, "memberships": WorkspaceMembershipSerializer( memberships, many=True, context=self.get_serializer_context(), ).data, }, status=status.HTTP_201_CREATED, ) def list(self, request, *args, **kwargs): workspace_id = request.query_params.get("workspace") if not workspace_id: return Response( {"detail": "workspace query parameter is required."}, status=status.HTTP_400_BAD_REQUEST, ) workspace = get_object_or_404(Workspace, id=workspace_id, is_deleted=False) if not has_workspace_capability(request.user, workspace, WORKSPACE_VIEW): return Response( {"detail": "You do not have permission to view workspace members."}, status=status.HTTP_403_FORBIDDEN, ) payload = get_or_set_cache_payload( CACHE_NAMESPACE_WORKSPACE_MEMBERSHIPS, ttl_seconds=REFERENCE_CACHE_TTL_SECONDS, builder=lambda: super(WorkspaceMembershipViewSet, self).list(request, *args, **kwargs).data, user_id=request.user.id, workspace_id=workspace_id, params=request.query_params, ) return Response(payload) 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) if getattr(request.user, "is_demo", False): return Response( {"detail": "Demo accounts cannot add workspace members."}, status=status.HTTP_403_FORBIDDEN, ) 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 ) requested_role = request.data.get("role") if requested_role and not can_assign_workspace_role( request.user, workspace, requested_role, ): return Response( {"detail": "You do not have permission to assign this workspace role."}, status=status.HTTP_403_FORBIDDEN, ) payload = request.data.copy() if payload.get("user") and not payload.get("user_id"): payload["user_id"] = payload.get("user") serializer = self.get_serializer(data=payload) serializer.is_valid(raise_exception=True) membership = serializer.save() notify_workspace_membership_added( actor=request.user, recipient=membership.user, workspace=membership.workspace, role=membership.role, ) return Response( WorkspaceMembershipSerializer(membership, context=self.get_serializer_context()).data, status=status.HTTP_201_CREATED, ) def update(self, request, *args, **kwargs): partial = kwargs.pop("partial", False) membership = self.get_object() previous_role = membership.role previous_is_active = membership.is_active requested_role = request.data.get("role") if not can_change_workspace_membership( request.user, membership, new_role=requested_role, ): return Response( {"detail": "You do not have permission to change this workspace membership."}, status=status.HTTP_403_FORBIDDEN, ) serializer = self.get_serializer(membership, data=request.data, partial=partial) serializer.is_valid(raise_exception=True) updated_membership = serializer.save() if not previous_is_active and updated_membership.is_active: notify_workspace_membership_added( actor=request.user, recipient=updated_membership.user, workspace=updated_membership.workspace, role=updated_membership.role, ) elif previous_is_active and not updated_membership.is_active: notify_workspace_membership_deactivated( actor=request.user, recipient=updated_membership.user, workspace=updated_membership.workspace, role=previous_role, ) elif previous_role != updated_membership.role: notify_workspace_membership_role_changed( actor=request.user, recipient=updated_membership.user, workspace=updated_membership.workspace, previous_role=previous_role, new_role=updated_membership.role, ) return Response( WorkspaceMembershipSerializer( updated_membership, context=self.get_serializer_context(), ).data, status=status.HTTP_200_OK, ) def partial_update(self, request, *args, **kwargs): kwargs["partial"] = True return self.update(request, *args, **kwargs) def destroy(self, request, *args, **kwargs): membership = self.get_object() if not can_change_workspace_membership(request.user, membership): return Response( {"detail": "You do not have permission to remove this workspace membership."}, status=status.HTTP_403_FORBIDDEN, ) recipient = membership.user workspace = membership.workspace role = membership.role membership.delete() notify_workspace_membership_removed( actor=request.user, recipient=recipient, workspace=workspace, role=role, ) return Response(status=status.HTTP_204_NO_CONTENT) class PriceUnitViewSet(ModelViewSet): serializer_class = PriceUnitSerializer permission_classes = [IsAuthenticated] http_method_names = ["get", "head", "options"] pagination_class = None filter_backends = (SearchFilter, OrderingFilter) search_fields = ("code", "name", "local_name", "symbol") ordering_fields = ("code", "name") ordering = ("code",) def get_queryset(self): return PriceUnit.objects.filter(is_deleted=False) def list(self, request, *args, **kwargs): payload = get_or_set_cache_payload( CACHE_NAMESPACE_PRICE_UNITS, ttl_seconds=PRICE_UNITS_CACHE_TTL_SECONDS, builder=lambda: super(PriceUnitViewSet, self).list(request, *args, **kwargs).data, user_id=request.user.id, params=request.query_params, ) return Response(payload) class WorkspaceUserRateViewSet(ModelViewSet): serializer_class = WorkspaceUserRateSerializer pagination_class = CustomLimitOffsetPagination filter_backends = (DjangoFilterBackend, OrderingFilter) filterset_fields = ("workspace", "user", "currency") ordering_fields = ("effective_from", "updated_at", "created_at") ordering = ("-effective_from", "-updated_at") def get_queryset(self): user = self.request.user if not user.is_authenticated: return WorkspaceUserRate.objects.none() return WorkspaceUserRate.objects.filter( workspace__memberships__user=user, workspace__memberships__is_active=True, is_deleted=False, ).distinct() def get_serializer_class(self): if self.action == "create": return WorkspaceUserRateCreateSerializer if self.action in ["update", "partial_update"]: return WorkspaceUserRateUpdateSerializer return WorkspaceUserRateSerializer def _ensure_manage_access(self, user, workspace): if not has_workspace_capability(user, workspace, WORKSPACE_EDIT): raise PermissionDenied("You do not have permission to manage workspace rates.") def list(self, request, *args, **kwargs): workspace_id = request.query_params.get("workspace") if not workspace_id: return Response( {"detail": "workspace query parameter is required."}, status=status.HTTP_400_BAD_REQUEST, ) workspace = get_object_or_404(Workspace, id=workspace_id, is_deleted=False) self._ensure_manage_access(request.user, workspace) payload = get_or_set_cache_payload( CACHE_NAMESPACE_WORKSPACE_RATES, ttl_seconds=REFERENCE_CACHE_TTL_SECONDS, builder=lambda: super(WorkspaceUserRateViewSet, self).list(request, *args, **kwargs).data, user_id=request.user.id, workspace_id=workspace_id, params=request.query_params, extra_versions={ CACHE_NAMESPACE_PRICE_UNITS: get_namespace_version(CACHE_NAMESPACE_PRICE_UNITS), }, ) return Response(payload) def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) workspace = get_object_or_404( Workspace, id=serializer.validated_data["workspace_id"], is_deleted=False, ) self._ensure_manage_access(request.user, workspace) rate = upsert_workspace_user_rate( workspace=workspace, user_id=serializer.validated_data["user_id"], hourly_rate=serializer.validated_data["hourly_rate"], currency=serializer.validated_data.get("currency", "USD"), ) return Response( WorkspaceUserRateSerializer(rate, context=self.get_serializer_context()).data, status=status.HTTP_201_CREATED, ) def update(self, request, *args, **kwargs): rate = self.get_object() self._ensure_manage_access(request.user, rate.workspace) serializer = self.get_serializer(data=request.data, partial=kwargs.pop("partial", False)) serializer.is_valid(raise_exception=True) updated_rate = update_workspace_user_rate(rate, **serializer.validated_data) return Response( WorkspaceUserRateSerializer(updated_rate, context=self.get_serializer_context()).data, status=status.HTTP_200_OK, ) def partial_update(self, request, *args, **kwargs): kwargs["partial"] = True return self.update(request, *args, **kwargs) def destroy(self, request, *args, **kwargs): rate = self.get_object() self._ensure_manage_access(request.user, rate.workspace) rate.delete() return Response(status=status.HTTP_204_NO_CONTENT)