feat(workspaces): add bulk member import endpoints
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
from django.db.models import Q
|
from decimal import Decimal, InvalidOperation
|
||||||
from django.shortcuts import get_object_or_404
|
|
||||||
|
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 import status
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@@ -18,6 +23,7 @@ from apps.notifications.services import (
|
|||||||
)
|
)
|
||||||
from apps.projects.models import ProjectUserRate
|
from apps.projects.models import ProjectUserRate
|
||||||
from apps.projects.services.access import filter_projects_for_user
|
from apps.projects.services.access import filter_projects_for_user
|
||||||
|
from apps.users.models import User
|
||||||
from apps.workspaces.api.permissions import (
|
from apps.workspaces.api.permissions import (
|
||||||
CanWorkspaceManageMembers,
|
CanWorkspaceManageMembers,
|
||||||
IsWorkspaceAdmin,
|
IsWorkspaceAdmin,
|
||||||
@@ -56,6 +62,20 @@ from core.services.cache import (
|
|||||||
|
|
||||||
REFERENCE_CACHE_TTL_SECONDS = 60 * 5
|
REFERENCE_CACHE_TTL_SECONDS = 60 * 5
|
||||||
PRICE_UNITS_CACHE_TTL_SECONDS = 60 * 60
|
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):
|
class WorkspaceViewSet(ModelViewSet):
|
||||||
@@ -216,6 +236,288 @@ class WorkspaceMembershipViewSet(ModelViewSet):
|
|||||||
|
|
||||||
return [IsAuthenticated()]
|
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):
|
def list(self, request, *args, **kwargs):
|
||||||
workspace_id = request.query_params.get("workspace")
|
workspace_id = request.query_params.get("workspace")
|
||||||
if not workspace_id:
|
if not workspace_id:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
@@ -7,7 +8,13 @@ from apps.clients.models import Client
|
|||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
from apps.tags.models import Tag
|
from apps.tags.models import Tag
|
||||||
from apps.users.models import User
|
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):
|
class WorkspaceCapabilityTests(APITestCase):
|
||||||
@@ -298,6 +305,139 @@ class WorkspaceCapabilityTests(APITestCase):
|
|||||||
self.assertEqual(update_response.status_code, 403)
|
self.assertEqual(update_response.status_code, 403)
|
||||||
self.assertEqual(delete_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):
|
def test_admin_can_delete_only_owned_clients_tags_and_projects(self):
|
||||||
self.client.force_authenticate(user=self.owner)
|
self.client.force_authenticate(user=self.owner)
|
||||||
owner_client_response = self.client.post(
|
owner_client_response = self.client.post(
|
||||||
|
|||||||
Reference in New Issue
Block a user