Files
qlockify-backend-deployment/apps/workspaces/api/views.py
Amirhossein Khalili 95f5e85e44
Some checks are pending
Backend CI/CD / test (push) Waiting to run
Backend CI/CD / deploy (push) Blocked by required conditions
feat(workspaces): add bulk member import endpoints
2026-06-18 22:53:34 +03:30

793 lines
30 KiB
Python

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)