diff --git a/apps/workspaces/api/views.py b/apps/workspaces/api/views.py index 6250666..8a7a557 100644 --- a/apps/workspaces/api/views.py +++ b/apps/workspaces/api/views.py @@ -1,5 +1,10 @@ -from django.db.models import Q -from django.shortcuts import get_object_or_404 +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 @@ -18,6 +23,7 @@ from apps.notifications.services import ( ) 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, @@ -56,6 +62,20 @@ from core.services.cache import ( 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): @@ -216,6 +236,288 @@ class WorkspaceMembershipViewSet(ModelViewSet): 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: diff --git a/apps/workspaces/tests/test_capabilities.py b/apps/workspaces/tests/test_capabilities.py index a737f5b..9bb1e76 100644 --- a/apps/workspaces/tests/test_capabilities.py +++ b/apps/workspaces/tests/test_capabilities.py @@ -1,4 +1,5 @@ from datetime import timedelta +from decimal import Decimal from django.utils import timezone from rest_framework.test import APITestCase @@ -7,7 +8,13 @@ from apps.clients.models import Client from apps.projects.models import Project from apps.tags.models import Tag from apps.users.models import User -from apps.workspaces.models import Workspace, WorkspaceMembership +from apps.workspaces.models import ( + HourlyRateHistory, + PriceUnit, + Workspace, + WorkspaceMembership, + WorkspaceUserRate, +) class WorkspaceCapabilityTests(APITestCase): @@ -298,6 +305,139 @@ class WorkspaceCapabilityTests(APITestCase): self.assertEqual(update_response.status_code, 403) self.assertEqual(delete_response.status_code, 403) + def test_owner_can_validate_and_commit_member_import_with_rate(self): + PriceUnit.objects.create(code="IRT", name="Toman", local_name="Toman", symbol="Toman") + target = self._user(21) + self.client.force_authenticate(user=self.owner) + + validate_response = self.client.post( + "/api/workspace-memberships/import/validate/", + { + "workspace": str(self.workspace.id), + "rows": [ + { + "line": 2, + "mobile": target.mobile, + "role": "member", + "hourly_rate": "150000", + "currency": "IRT", + } + ], + }, + format="json", + ) + + self.assertEqual(validate_response.status_code, 200) + self.assertTrue(validate_response.data["can_commit"]) + self.assertEqual(validate_response.data["summary"]["valid"], 1) + self.assertTrue(validate_response.data["import_token"]) + + commit_response = self.client.post( + "/api/workspace-memberships/import/commit/", + { + "workspace": str(self.workspace.id), + "import_token": validate_response.data["import_token"], + }, + format="json", + ) + + self.assertEqual(commit_response.status_code, 201) + self.assertEqual(commit_response.data["created_memberships"], 1) + self.assertEqual(commit_response.data["created_or_updated_rates"], 1) + self.assertTrue( + WorkspaceMembership.objects.filter( + workspace=self.workspace, + user=target, + role=WorkspaceMembership.Role.MEMBER, + is_active=True, + ).exists() + ) + rate = WorkspaceUserRate.objects.get(workspace=self.workspace, user=target) + self.assertEqual(rate.hourly_rate, Decimal("150000.00")) + self.assertEqual(rate.currency, "IRT") + self.assertTrue( + HourlyRateHistory.objects.filter( + workspace=self.workspace, + user=target, + hourly_rate=Decimal("150000.00"), + currency="IRT", + ).exists() + ) + + def test_member_import_rejects_invalid_rows(self): + PriceUnit.objects.create(code="USD", name="Dollar", local_name="Dollar", symbol="$") + target = self._user(22) + self.client.force_authenticate(user=self.owner) + + response = self.client.post( + "/api/workspace-memberships/import/validate/", + { + "workspace": str(self.workspace.id), + "rows": [ + {"line": 2, "mobile": "", "role": "member"}, + {"line": 3, "mobile": "09120000000", "role": "member"}, + {"line": 4, "mobile": target.mobile, "role": "owner"}, + {"line": 5, "mobile": target.mobile, "role": "member"}, + {"line": 6, "mobile": self.member.mobile, "role": "member"}, + {"line": 7, "mobile": self.guest.mobile, "role": "guest", "hourly_rate": "10"}, + {"line": 8, "mobile": self.admin.mobile, "role": "admin", "hourly_rate": "0", "currency": "USD"}, + {"line": 9, "mobile": self.extra_owner.mobile, "role": "guest", "hourly_rate": "10", "currency": "XXX"}, + ], + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertFalse(response.data["can_commit"]) + self.assertIsNone(response.data["import_token"]) + self.assertEqual(response.data["summary"]["invalid"], 8) + + def test_admin_import_follows_role_assignment_rules(self): + target = self._user(23) + self.client.force_authenticate(user=self.admin) + + response = self.client.post( + "/api/workspace-memberships/import/validate/", + { + "workspace": str(self.workspace.id), + "rows": [{"line": 2, "mobile": target.mobile, "role": "admin"}], + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertFalse(response.data["can_commit"]) + self.assertIn("permission", response.data["rows"][0]["messages"][0].lower()) + + def test_member_cannot_import_workspace_members(self): + target = self._user(24) + self.client.force_authenticate(user=self.member) + + response = self.client.post( + "/api/workspace-memberships/import/validate/", + { + "workspace": str(self.workspace.id), + "rows": [{"line": 2, "mobile": target.mobile, "role": "member"}], + }, + format="json", + ) + + self.assertEqual(response.status_code, 403) + + def test_import_commit_rejects_expired_token(self): + self.client.force_authenticate(user=self.owner) + + response = self.client.post( + "/api/workspace-memberships/import/commit/", + { + "workspace": str(self.workspace.id), + "import_token": "invalid-token", + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + def test_admin_can_delete_only_owned_clients_tags_and_projects(self): self.client.force_authenticate(user=self.owner) owner_client_response = self.client.post(