From fadf898486b511a6530689f61a889697f69e4b69 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sun, 26 Apr 2026 10:19:04 +0330 Subject: [PATCH] feat(pricing): add workspace user rates and price units --- apps/projects/api/serializers.py | 68 ++------- apps/projects/api/urls.py | 26 ++-- apps/projects/api/views.py | 123 ++-------------- apps/projects/services/rates.py | 42 ------ apps/time_entries/services/rates.py | 37 ++--- apps/workspaces/api/serializers.py | 81 ++++++++++- apps/workspaces/api/urls.py | 17 ++- apps/workspaces/api/views.py | 117 +++++++++++++++- apps/workspaces/management/__init__.py | 1 + .../management/commands/__init__.py | 1 + .../commands/populate_price_units.py | 44 ++++++ .../migrations/0002_workspaceuserrate.py | 89 ++++++++++++ ...rrate_workspaceuserrate_id_idx_and_more.py | 30 ++++ apps/workspaces/migrations/0004_priceunit.py | 45 ++++++ ...ove_priceunit_priceunit_id_idx_and_more.py | 30 ++++ apps/workspaces/models.py | 54 +++++++ apps/workspaces/services/__init__.py | 6 + apps/workspaces/services/rates.py | 54 +++++++ apps/workspaces/tests/test_rates.py | 132 ++++++++++++++++++ 19 files changed, 731 insertions(+), 266 deletions(-) delete mode 100644 apps/projects/services/rates.py create mode 100644 apps/workspaces/management/__init__.py create mode 100644 apps/workspaces/management/commands/__init__.py create mode 100644 apps/workspaces/management/commands/populate_price_units.py create mode 100644 apps/workspaces/migrations/0002_workspaceuserrate.py create mode 100644 apps/workspaces/migrations/0003_remove_workspaceuserrate_workspaceuserrate_id_idx_and_more.py create mode 100644 apps/workspaces/migrations/0004_priceunit.py create mode 100644 apps/workspaces/migrations/0005_remove_priceunit_priceunit_id_idx_and_more.py create mode 100644 apps/workspaces/services/rates.py create mode 100644 apps/workspaces/tests/test_rates.py diff --git a/apps/projects/api/serializers.py b/apps/projects/api/serializers.py index eca1822..3b4e6a7 100644 --- a/apps/projects/api/serializers.py +++ b/apps/projects/api/serializers.py @@ -1,12 +1,10 @@ -from rest_framework import serializers -from core.serializers.base import BaseModelSerializer -from apps.projects.models import ( - Project, - ProjectMembership, - ProjectRate, - ProjectUserRate, -) -from core.serializers.mini import UserMiniSerializer +from rest_framework import serializers +from core.serializers.base import BaseModelSerializer +from apps.projects.models import ( + Project, + ProjectMembership, +) +from core.serializers.mini import UserMiniSerializer class ProjectMemberInputSerializer(serializers.Serializer): @@ -94,52 +92,6 @@ class ProjectMembershipCreateSerializer(serializers.Serializer): role = serializers.ChoiceField(choices=ProjectMembership.Role.choices) -class ProjectMembershipUpdateSerializer(serializers.Serializer): - role = serializers.ChoiceField(choices=ProjectMembership.Role.choices, required=False) - is_active = serializers.BooleanField(required=False) - - -class ProjectRateSerializer(BaseModelSerializer): - class Meta: - model = ProjectRate - fields = BaseModelSerializer.Meta.fields + ( - "project", - "hourly_rate", - "currency", - ) - read_only_fields = fields - - -class ProjectRateCreateSerializer(serializers.Serializer): - project_id = serializers.UUIDField() - hourly_rate = serializers.DecimalField(max_digits=10, decimal_places=2) - currency = serializers.CharField(max_length=3, default="USD") - - -class ProjectRateUpdateSerializer(serializers.Serializer): - hourly_rate = serializers.DecimalField(max_digits=10, decimal_places=2, required=False) - currency = serializers.CharField(max_length=3, required=False) - - -class ProjectUserRateSerializer(BaseModelSerializer): - class Meta: - model = ProjectUserRate - fields = BaseModelSerializer.Meta.fields + ( - "project", - "user", - "hourly_rate", - "currency", - ) - read_only_fields = fields - - -class ProjectUserRateCreateSerializer(serializers.Serializer): - project_id = serializers.UUIDField() - user_id = serializers.UUIDField() - hourly_rate = serializers.DecimalField(max_digits=10, decimal_places=2) - currency = serializers.CharField(max_length=3, default="USD") - - -class ProjectUserRateUpdateSerializer(serializers.Serializer): - hourly_rate = serializers.DecimalField(max_digits=10, decimal_places=2, required=False) - currency = serializers.CharField(max_length=3, required=False) +class ProjectMembershipUpdateSerializer(serializers.Serializer): + role = serializers.ChoiceField(choices=ProjectMembership.Role.choices, required=False) + is_active = serializers.BooleanField(required=False) diff --git a/apps/projects/api/urls.py b/apps/projects/api/urls.py index e11a8b9..05a8cc2 100644 --- a/apps/projects/api/urls.py +++ b/apps/projects/api/urls.py @@ -1,21 +1,17 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from apps.projects.api.views import ( - ProjectViewSet, - ProjectMembershipViewSet, - ProjectRateViewSet, - ProjectUserRateViewSet -) +from apps.projects.api.views import ( + ProjectViewSet, + ProjectMembershipViewSet, +) app_name = "projects" -router = DefaultRouter() -router.register(r"projects", ProjectViewSet, basename="project") -router.register(r"memberships", ProjectMembershipViewSet, basename="membership") -router.register(r"rates", ProjectRateViewSet, basename="rate") -router.register(r"user-rates", ProjectUserRateViewSet, basename="user-rate") - -urlpatterns = [ - path("", include(router.urls)), -] +router = DefaultRouter() +router.register(r"projects", ProjectViewSet, basename="project") +router.register(r"memberships", ProjectMembershipViewSet, basename="membership") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/apps/projects/api/views.py b/apps/projects/api/views.py index 11f478b..95b34f8 100644 --- a/apps/projects/api/views.py +++ b/apps/projects/api/views.py @@ -20,28 +20,20 @@ from apps.notifications.services import ( from apps.workspaces.models import Workspace from apps.clients.models import Client from apps.projects.models import ( - Project, - ProjectMembership, - ProjectRate, - ProjectUserRate, -) -from apps.projects.api.serializers import ( - ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer, - ProjectMembershipSerializer, ProjectMembershipCreateSerializer, ProjectMembershipUpdateSerializer, - ProjectRateSerializer, ProjectRateCreateSerializer, ProjectRateUpdateSerializer, - ProjectUserRateSerializer, ProjectUserRateCreateSerializer, ProjectUserRateUpdateSerializer -) + Project, + ProjectMembership, +) +from apps.projects.api.serializers import ( + ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer, + ProjectMembershipSerializer, ProjectMembershipCreateSerializer, ProjectMembershipUpdateSerializer, +) from apps.projects.api.permissions import IsProjectMember, IsProjectManager -from apps.projects.services.projects import ( +from apps.projects.services.projects import ( create_project, update_project, - toggle_project_archive -) -from apps.projects.services.memberships import add_project_member, update_project_member -from apps.projects.services.rates import ( - create_project_rate, update_project_rate, - create_project_user_rate, update_project_user_rate + toggle_project_archive ) +from apps.projects.services.memberships import add_project_member, update_project_member from apps.workspaces.services import ( PROJECTS_ARCHIVE, PROJECTS_CREATE, @@ -267,7 +259,7 @@ class BaseProjectNestedViewSet(ModelViewSet): raise PermissionDenied("You must be a project manager to perform this action.") -class ProjectMembershipViewSet(BaseProjectNestedViewSet): +class ProjectMembershipViewSet(BaseProjectNestedViewSet): filterset_fields = ["project", "user", "role", "is_active"] def get_queryset(self): @@ -363,96 +355,3 @@ class ProjectMembershipViewSet(BaseProjectNestedViewSet): role=role, ) return Response(status=status.HTTP_204_NO_CONTENT) - - -class ProjectRateViewSet(BaseProjectNestedViewSet): - filterset_fields = ["project", "currency"] - - def get_queryset(self): - if not self.request.user.is_authenticated: return ProjectRate.objects.none() - return ProjectRate.objects.filter( - project__memberships__user=self.request.user, - project__memberships__is_active=True, - is_deleted=False - ).distinct() - - def get_serializer_class(self): - if self.action == "create": return ProjectRateCreateSerializer - if self.action in ["update", "partial_update"]: return ProjectRateUpdateSerializer - return ProjectRateSerializer - - def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - - project_id = serializer.validated_data["project_id"] - self.verify_manager_access(project_id) - - project = get_object_or_404(Project, id=project_id, is_deleted=False) - rate = create_project_rate( - project=project, - hourly_rate=serializer.validated_data["hourly_rate"], - currency=serializer.validated_data.get("currency", "USD") - ) - return Response(ProjectRateSerializer(rate).data, status=status.HTTP_201_CREATED) - - def update(self, request, *args, **kwargs): - rate = self.get_object() - serializer = self.get_serializer(data=request.data, partial=kwargs.pop("partial", False)) - serializer.is_valid(raise_exception=True) - - updated_rate = update_project_rate(rate, **serializer.validated_data) - return Response(ProjectRateSerializer(updated_rate).data, status=status.HTTP_200_OK) - - def destroy(self, request, *args, **kwargs): - rate = self.get_object() - rate.is_deleted = True - rate.save(update_fields=["is_deleted", "updated_at"]) - return Response(status=status.HTTP_204_NO_CONTENT) - - -class ProjectUserRateViewSet(BaseProjectNestedViewSet): - filterset_fields = ["project", "user", "currency"] - - def get_queryset(self): - if not self.request.user.is_authenticated: return ProjectUserRate.objects.none() - return ProjectUserRate.objects.filter( - project__memberships__user=self.request.user, - project__memberships__is_active=True, - is_deleted=False - ).distinct() - - def get_serializer_class(self): - if self.action == "create": return ProjectUserRateCreateSerializer - if self.action in ["update", "partial_update"]: return ProjectUserRateUpdateSerializer - return ProjectUserRateSerializer - - def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - - project_id = serializer.validated_data["project_id"] - self.verify_manager_access(project_id) - - project = get_object_or_404(Project, id=project_id, is_deleted=False) - user_rate = create_project_user_rate( - project=project, - user_id=serializer.validated_data["user_id"], - hourly_rate=serializer.validated_data["hourly_rate"], - currency=serializer.validated_data.get("currency", "USD") - ) - return Response(ProjectUserRateSerializer(user_rate).data, status=status.HTTP_201_CREATED) - - def update(self, request, *args, **kwargs): - user_rate = self.get_object() - serializer = self.get_serializer(data=request.data, partial=kwargs.pop("partial", False)) - serializer.is_valid(raise_exception=True) - - updated_user_rate = update_project_user_rate(user_rate, **serializer.validated_data) - return Response(ProjectUserRateSerializer(updated_user_rate).data, status=status.HTTP_200_OK) - - def destroy(self, request, *args, **kwargs): - user_rate = self.get_object() - user_rate.is_deleted = True - user_rate.save(update_fields=["is_deleted", "updated_at"]) - return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/projects/services/rates.py b/apps/projects/services/rates.py deleted file mode 100644 index bd81027..0000000 --- a/apps/projects/services/rates.py +++ /dev/null @@ -1,42 +0,0 @@ -from apps.projects.models import ProjectRate, ProjectUserRate - -def create_project_rate(project, hourly_rate, currency="USD"): - return ProjectRate.objects.create( - project=project, - hourly_rate=hourly_rate, - currency=currency - ) - -def update_project_rate(rate_instance, **kwargs): - update_fields = [] - for field, value in kwargs.items(): - if hasattr(rate_instance, field) and getattr(rate_instance, field) != value: - setattr(rate_instance, field, value) - update_fields.append(field) - - if update_fields: - update_fields.append("updated_at") - rate_instance.save(update_fields=update_fields) - - return rate_instance - -def create_project_user_rate(project, user_id, hourly_rate, currency="USD"): - return ProjectUserRate.objects.create( - project=project, - user_id=user_id, - hourly_rate=hourly_rate, - currency=currency - ) - -def update_project_user_rate(user_rate_instance, **kwargs): - update_fields = [] - for field, value in kwargs.items(): - if hasattr(user_rate_instance, field) and getattr(user_rate_instance, field) != value: - setattr(user_rate_instance, field, value) - update_fields.append(field) - - if update_fields: - update_fields.append("updated_at") - user_rate_instance.save(update_fields=update_fields) - - return user_rate_instance diff --git a/apps/time_entries/services/rates.py b/apps/time_entries/services/rates.py index 73b4bf3..f8b2e58 100644 --- a/apps/time_entries/services/rates.py +++ b/apps/time_entries/services/rates.py @@ -1,22 +1,15 @@ -from apps.projects.models import ProjectRate, ProjectUserRate - - -def resolve_rate(user, project): - user_rate = ProjectUserRate.objects.filter( - user=user, - project=project, - is_active=True, - ).order_by("-effective_from").first() - - if user_rate: - return user_rate.hourly_rate, user_rate.currency - - project_rate = ProjectRate.objects.filter( - project=project, - is_active=True, - ).order_by("-effective_from").first() - - if project_rate: - return project_rate.hourly_rate, project_rate.currency - - return None, "USD" +from apps.workspaces.models import WorkspaceUserRate + + +def resolve_rate(user, project): + workspace_user_rate = WorkspaceUserRate.objects.filter( + user=user, + workspace=project.workspace, + is_active=True, + is_deleted=False, + ).order_by("-effective_from").first() + + if workspace_user_rate: + return workspace_user_rate.hourly_rate, workspace_user_rate.currency + + return None, "" diff --git a/apps/workspaces/api/serializers.py b/apps/workspaces/api/serializers.py index 96b2b77..057f7da 100644 --- a/apps/workspaces/api/serializers.py +++ b/apps/workspaces/api/serializers.py @@ -1,9 +1,11 @@ +from decimal import Decimal + from rest_framework import serializers from apps.notifications.services import notify_workspace_membership_added from apps.users.models import User from core.serializers.base import BaseModelSerializer -from apps.workspaces.models import Workspace, WorkspaceMembership +from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate from core.serializers.mini import UserMiniSerializer @@ -73,7 +75,7 @@ class WorkspaceSerializer(BaseModelSerializer): return workspace -class WorkspaceMembershipSerializer(BaseModelSerializer): +class WorkspaceMembershipSerializer(BaseModelSerializer): class Meta: model = WorkspaceMembership fields = BaseModelSerializer.Meta.fields + ( @@ -85,8 +87,73 @@ class WorkspaceMembershipSerializer(BaseModelSerializer): def to_representation(self, instance): data = super().to_representation(instance) - data["user"] = UserMiniSerializer( - instance.user, - context=self.context - ).data - return data + data["user"] = UserMiniSerializer( + instance.user, + context=self.context + ).data + return data + + +class PriceUnitSerializer(BaseModelSerializer): + class Meta: + model = PriceUnit + fields = BaseModelSerializer.Meta.fields + ( + "code", + "name", + "local_name", + "symbol", + ) + read_only_fields = fields + + +class WorkspaceUserRateSerializer(BaseModelSerializer): + user_details = UserMiniSerializer(source="user", read_only=True) + price_unit = serializers.SerializerMethodField() + + class Meta: + model = WorkspaceUserRate + fields = BaseModelSerializer.Meta.fields + ( + "workspace", + "user", + "user_details", + "hourly_rate", + "currency", + "price_unit", + "effective_from", + ) + read_only_fields = fields + + def get_price_unit(self, obj): + unit = PriceUnit.objects.filter(code=obj.currency, is_deleted=False).first() + if not unit: + return None + return PriceUnitSerializer(unit, context=self.context).data + + +class WorkspaceUserRateCreateSerializer(serializers.Serializer): + workspace_id = serializers.UUIDField() + user_id = serializers.UUIDField() + hourly_rate = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=Decimal("0.01")) + currency = serializers.CharField(max_length=3, default="USD") + + def validate_currency(self, value): + code = value.upper() + if not PriceUnit.objects.filter(code=code, is_deleted=False).exists(): + raise serializers.ValidationError("Selected price unit is invalid.") + return code + + +class WorkspaceUserRateUpdateSerializer(serializers.Serializer): + hourly_rate = serializers.DecimalField( + max_digits=10, + decimal_places=2, + min_value=Decimal("0.01"), + required=False, + ) + currency = serializers.CharField(max_length=3, required=False) + + def validate_currency(self, value): + code = value.upper() + if not PriceUnit.objects.filter(code=code, is_deleted=False).exists(): + raise serializers.ValidationError("Selected price unit is invalid.") + return code diff --git a/apps/workspaces/api/urls.py b/apps/workspaces/api/urls.py index f9dc6eb..0ff295b 100644 --- a/apps/workspaces/api/urls.py +++ b/apps/workspaces/api/urls.py @@ -1,11 +1,18 @@ 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') +from apps.workspaces.api.views import ( + PriceUnitViewSet, + WorkspaceViewSet, + WorkspaceMembershipViewSet, + WorkspaceUserRateViewSet, +) + +router = DefaultRouter() +router.register(r'workspaces', WorkspaceViewSet, basename='workspace') +router.register(r'workspace-memberships', WorkspaceMembershipViewSet, basename='workspace-membership') +router.register(r'price-units', PriceUnitViewSet, basename='price-unit') +router.register(r'workspace-user-rates', WorkspaceUserRateViewSet, basename='workspace-user-rate') urlpatterns = [ path('', include(router.urls)), diff --git a/apps/workspaces/api/views.py b/apps/workspaces/api/views.py index ef81853..7c8d871 100644 --- a/apps/workspaces/api/views.py +++ b/apps/workspaces/api/views.py @@ -1,7 +1,8 @@ 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 import status +from rest_framework.exceptions import PermissionDenied +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 @@ -19,14 +20,24 @@ from apps.workspaces.api.permissions import ( IsWorkspaceMember, IsWorkspaceOwner, ) -from apps.workspaces.api.serializers import WorkspaceMembershipSerializer, WorkspaceSerializer +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 Workspace, WorkspaceMembership +from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate from apps.workspaces.services import ( WORKSPACE_MEMBERS_VIEW, + WORKSPACE_EDIT, 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 @@ -65,7 +76,7 @@ class WorkspaceViewSet(ModelViewSet): serializer.save(owner=self.request.user) -class WorkspaceMembershipViewSet(ModelViewSet): +class WorkspaceMembershipViewSet(ModelViewSet): serializer_class = WorkspaceMembershipSerializer pagination_class = CustomLimitOffsetPagination filter_backends = (DjangoFilterBackend, OrderingFilter, SearchFilter) @@ -236,3 +247,99 @@ class WorkspaceMembershipViewSet(ModelViewSet): 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) + + +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) + return super().list(request, *args, **kwargs) + + 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) diff --git a/apps/workspaces/management/__init__.py b/apps/workspaces/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/workspaces/management/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/workspaces/management/commands/__init__.py b/apps/workspaces/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/workspaces/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/workspaces/management/commands/populate_price_units.py b/apps/workspaces/management/commands/populate_price_units.py new file mode 100644 index 0000000..03d415a --- /dev/null +++ b/apps/workspaces/management/commands/populate_price_units.py @@ -0,0 +1,44 @@ +from django.core.management.base import BaseCommand + +from apps.workspaces.models import PriceUnit + + +PRICE_UNITS = [ + {"code": "USD", "name": "US Dollar", "local_name": "دلار آمریکا", "symbol": "$"}, + {"code": "EUR", "name": "Euro", "local_name": "یورو", "symbol": "€"}, + {"code": "GBP", "name": "British Pound", "local_name": "پوند بریتانیا", "symbol": "£"}, + {"code": "AED", "name": "UAE Dirham", "local_name": "درهم امارات", "symbol": "AED"}, + {"code": "TRY", "name": "Turkish Lira", "local_name": "لیر ترکیه", "symbol": "₺"}, + {"code": "IRR", "name": "Iranian Rial", "local_name": "ریال", "symbol": "ریال"}, + {"code": "IRT", "name": "Iranian Toman", "local_name": "تومان", "symbol": "تومان"}, +] + + +class Command(BaseCommand): + help = "Populate popular price units for workspace billing rates." + + def handle(self, *args, **options): + created = 0 + updated = 0 + + for payload in PRICE_UNITS: + unit, was_created = PriceUnit.all_objects.update_or_create( + code=payload["code"], + defaults={ + "name": payload["name"], + "local_name": payload["local_name"], + "symbol": payload["symbol"], + "is_deleted": False, + "deleted_at": None, + }, + ) + if was_created: + created += 1 + else: + updated += 1 + + self.stdout.write( + self.style.SUCCESS( + f"Price units populated. created={created} updated={updated}" + ) + ) diff --git a/apps/workspaces/migrations/0002_workspaceuserrate.py b/apps/workspaces/migrations/0002_workspaceuserrate.py new file mode 100644 index 0000000..e43c41e --- /dev/null +++ b/apps/workspaces/migrations/0002_workspaceuserrate.py @@ -0,0 +1,89 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("workspaces", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="WorkspaceUserRate", + fields=[ + ("id", models.UUIDField(default=uuid.uuid7, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("is_deleted", models.BooleanField(default=False)), + ("is_active", models.BooleanField(default=False)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_workspaces_workspaceuserrate_set", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="updated_workspaces_workspaceuserrate_set", + to=settings.AUTH_USER_MODEL, + ), + ), + ("hourly_rate", models.DecimalField(decimal_places=2, max_digits=10)), + ("currency", models.CharField(default="USD", max_length=3)), + ("effective_from", models.DateTimeField()), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_rates", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="user_rates", + to="workspaces.workspace", + ), + ), + ], + options={ + "db_table": "workspace_user_rate", + "ordering": ("-effective_from",), + }, + ), + migrations.AddIndex( + model_name="workspaceuserrate", + index=models.Index(fields=["id"], name="workspaceuserrate_id_idx"), + ), + migrations.AddIndex( + model_name="workspaceuserrate", + index=models.Index(fields=["workspace"], name="wur_workspace_idx"), + ), + migrations.AddIndex( + model_name="workspaceuserrate", + index=models.Index(fields=["user"], name="wur_user_idx"), + ), + migrations.AddConstraint( + model_name="workspaceuserrate", + constraint=models.UniqueConstraint( + condition=models.Q(("is_deleted", False)), + fields=("workspace", "user"), + name="unique_workspace_user_rate", + ), + ), + ] diff --git a/apps/workspaces/migrations/0003_remove_workspaceuserrate_workspaceuserrate_id_idx_and_more.py b/apps/workspaces/migrations/0003_remove_workspaceuserrate_workspaceuserrate_id_idx_and_more.py new file mode 100644 index 0000000..061036f --- /dev/null +++ b/apps/workspaces/migrations/0003_remove_workspaceuserrate_workspaceuserrate_id_idx_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.12 on 2026-04-26 05:53 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workspaces', '0002_workspaceuserrate'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveIndex( + model_name='workspaceuserrate', + name='workspaceuserrate_id_idx', + ), + migrations.AlterField( + model_name='workspaceuserrate', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='workspaceuserrate', + name='updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/apps/workspaces/migrations/0004_priceunit.py b/apps/workspaces/migrations/0004_priceunit.py new file mode 100644 index 0000000..57041c8 --- /dev/null +++ b/apps/workspaces/migrations/0004_priceunit.py @@ -0,0 +1,45 @@ +import uuid + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("workspaces", "0003_remove_workspaceuserrate_workspaceuserrate_id_idx_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="PriceUnit", + fields=[ + ("id", models.UUIDField(default=uuid.uuid7, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("is_deleted", models.BooleanField(default=False)), + ("is_active", models.BooleanField(default=False)), + ("code", models.CharField(max_length=8, unique=True)), + ("name", models.CharField(max_length=64)), + ("local_name", models.CharField(blank=True, max_length=64)), + ("symbol", models.CharField(blank=True, max_length=16)), + ("created_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="created_workspaces_priceunit_set", to=settings.AUTH_USER_MODEL)), + ("updated_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="updated_workspaces_priceunit_set", to=settings.AUTH_USER_MODEL)), + ], + options={ + "db_table": "price_unit", + "ordering": ("code",), + }, + ), + migrations.AddIndex( + model_name="priceunit", + index=models.Index(fields=["id"], name="priceunit_id_idx"), + ), + migrations.AddIndex( + model_name="priceunit", + index=models.Index(fields=["code"], name="price_unit_code_idx"), + ), + ] diff --git a/apps/workspaces/migrations/0005_remove_priceunit_priceunit_id_idx_and_more.py b/apps/workspaces/migrations/0005_remove_priceunit_priceunit_id_idx_and_more.py new file mode 100644 index 0000000..22bee31 --- /dev/null +++ b/apps/workspaces/migrations/0005_remove_priceunit_priceunit_id_idx_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.12 on 2026-04-26 06:25 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workspaces', '0004_priceunit'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveIndex( + model_name='priceunit', + name='priceunit_id_idx', + ), + migrations.AlterField( + model_name='priceunit', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='priceunit', + name='updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/apps/workspaces/models.py b/apps/workspaces/models.py index f8b49bf..7015c5e 100644 --- a/apps/workspaces/models.py +++ b/apps/workspaces/models.py @@ -75,3 +75,57 @@ class WorkspaceMembership(BaseModel): def __str__(self): return f"{self.user} @ {self.workspace}" + + +class PriceUnit(BaseModel): + code = models.CharField(max_length=8, unique=True) + name = models.CharField(max_length=64) + local_name = models.CharField(max_length=64, blank=True) + symbol = models.CharField(max_length=16, blank=True) + + class Meta: + db_table = "price_unit" + ordering = ("code",) + indexes = [ + models.Index(fields=["code"], name="price_unit_code_idx"), + ] + + def __str__(self): + return self.code + + +class WorkspaceUserRate(BaseModel): + workspace = models.ForeignKey( + Workspace, + on_delete=models.CASCADE, + related_name="user_rates", + ) + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="workspace_rates", + ) + hourly_rate = models.DecimalField( + max_digits=10, + decimal_places=2, + ) + currency = models.CharField( + max_length=3, + default="USD", + ) + effective_from = models.DateTimeField() + + class Meta: + db_table = "workspace_user_rate" + ordering = ("-effective_from",) + constraints = [ + models.UniqueConstraint( + fields=["workspace", "user"], + name="unique_workspace_user_rate", + condition=models.Q(is_deleted=False), + ) + ] + indexes = [ + models.Index(fields=["workspace"], name="wur_workspace_idx"), + models.Index(fields=["user"], name="wur_user_idx"), + ] diff --git a/apps/workspaces/services/__init__.py b/apps/workspaces/services/__init__.py index 5d80052..f9cc9b4 100644 --- a/apps/workspaces/services/__init__.py +++ b/apps/workspaces/services/__init__.py @@ -33,6 +33,10 @@ from apps.workspaces.services.permissions import ( has_project_capability, has_workspace_capability, ) +from apps.workspaces.services.rates import ( + upsert_workspace_user_rate, + update_workspace_user_rate, +) __all__ = [ "WORKSPACE_VIEW", @@ -68,4 +72,6 @@ __all__ = [ "can_manage_workspace_members", "can_assign_workspace_role", "can_change_workspace_membership", + "upsert_workspace_user_rate", + "update_workspace_user_rate", ] diff --git a/apps/workspaces/services/rates.py b/apps/workspaces/services/rates.py new file mode 100644 index 0000000..0fc2cc6 --- /dev/null +++ b/apps/workspaces/services/rates.py @@ -0,0 +1,54 @@ +from django.utils import timezone + +from apps.workspaces.models import WorkspaceUserRate + + +def upsert_workspace_user_rate(workspace, user_id, hourly_rate, currency="USD"): + currency = currency.upper() + rate = WorkspaceUserRate.objects.filter( + workspace=workspace, + user_id=user_id, + is_deleted=False, + ).first() + + if rate: + update_fields = [] + if rate.hourly_rate != hourly_rate: + rate.hourly_rate = hourly_rate + update_fields.append("hourly_rate") + if rate.currency != currency: + rate.currency = currency + update_fields.append("currency") + if not rate.is_active: + rate.is_active = True + update_fields.append("is_active") + if update_fields: + update_fields.append("updated_at") + rate.save(update_fields=update_fields) + return rate + + return WorkspaceUserRate.objects.create( + workspace=workspace, + user_id=user_id, + hourly_rate=hourly_rate, + currency=currency, + effective_from=timezone.now(), + is_active=True, + ) + + +def update_workspace_user_rate(rate_instance, **kwargs): + if "currency" in kwargs and kwargs["currency"]: + kwargs["currency"] = kwargs["currency"].upper() + + update_fields = [] + for field, value in kwargs.items(): + if hasattr(rate_instance, field) and getattr(rate_instance, field) != value: + setattr(rate_instance, field, value) + update_fields.append(field) + + if update_fields: + update_fields.append("updated_at") + rate_instance.save(update_fields=update_fields) + + return rate_instance diff --git a/apps/workspaces/tests/test_rates.py b/apps/workspaces/tests/test_rates.py new file mode 100644 index 0000000..e63e7c0 --- /dev/null +++ b/apps/workspaces/tests/test_rates.py @@ -0,0 +1,132 @@ +from decimal import Decimal + +import pytest +from rest_framework.test import APIClient + +from apps.projects.models import Project, ProjectMembership +from apps.time_entries.services.rates import resolve_rate +from apps.users.models import User +from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate + + +@pytest.fixture() +def api_client(): + return APIClient() + + +@pytest.fixture() +def owner(db): + return User.objects.create_user(mobile="09127770001", password="secret123") + + +@pytest.fixture() +def admin(db): + return User.objects.create_user(mobile="09127770002", password="secret123") + + +@pytest.fixture() +def member(db): + return User.objects.create_user(mobile="09127770003", password="secret123") + + +@pytest.fixture() +def workspace(owner, admin, member): + workspace = Workspace.objects.create(name="Rates", owner=owner) + WorkspaceMembership.objects.create(workspace=workspace, user=admin, role=WorkspaceMembership.Role.ADMIN, is_active=True) + WorkspaceMembership.objects.create(workspace=workspace, user=member, role=WorkspaceMembership.Role.MEMBER, is_active=True) + return workspace + + +@pytest.fixture() +def project(workspace, owner, admin, member): + project = Project.objects.create(workspace=workspace, name="Billing") + ProjectMembership.objects.create(project=project, user=owner, role=ProjectMembership.Role.MANAGER, is_active=True) + ProjectMembership.objects.create(project=project, user=admin, role=ProjectMembership.Role.MANAGER, is_active=True) + ProjectMembership.objects.create(project=project, user=member, role=ProjectMembership.Role.MEMBER, is_active=True) + return project + + +@pytest.fixture() +def price_units(db): + PriceUnit.objects.create(code="USD", name="US Dollar", local_name="دلار آمریکا", symbol="$") + PriceUnit.objects.create(code="EUR", name="Euro", local_name="یورو", symbol="€") + + +def test_resolve_rate_uses_workspace_user_rate(workspace, project, member): + WorkspaceUserRate.objects.create( + workspace=workspace, + user=member, + hourly_rate=Decimal("40.00"), + currency="EUR", + effective_from=project.created_at, + is_active=True, + ) + + hourly_rate, currency = resolve_rate(member, project) + + assert hourly_rate == Decimal("40.00") + assert currency == "EUR" + + +def test_resolve_rate_falls_back_to_workspace_user_rate(workspace, project, member): + WorkspaceUserRate.objects.create( + workspace=workspace, + user=member, + hourly_rate=Decimal("40.00"), + currency="EUR", + effective_from=project.created_at, + is_active=True, + ) + + hourly_rate, currency = resolve_rate(member, project) + + assert hourly_rate == Decimal("40.00") + assert currency == "EUR" + + +def test_admin_can_manage_workspace_user_rates(api_client, admin, member, workspace, price_units): + api_client.force_authenticate(user=admin) + + create_response = api_client.post( + "/api/workspace-user-rates/", + { + "workspace_id": str(workspace.id), + "user_id": str(member.id), + "hourly_rate": "35.50", + "currency": "USD", + }, + format="json", + ) + + assert create_response.status_code == 201 + rate_id = create_response.data["id"] + assert WorkspaceUserRate.objects.filter(id=rate_id, is_deleted=False).exists() + + update_response = api_client.patch( + f"/api/workspace-user-rates/{rate_id}/", + {"hourly_rate": "42.00"}, + format="json", + ) + assert update_response.status_code == 200 + assert update_response.data["hourly_rate"] == "42.00" + + delete_response = api_client.delete(f"/api/workspace-user-rates/{rate_id}/") + assert delete_response.status_code == 204 + assert WorkspaceUserRate.all_objects.get(id=rate_id).is_deleted is True + + +def test_member_cannot_manage_rates(api_client, member, workspace, price_units): + api_client.force_authenticate(user=member) + + workspace_response = api_client.post( + "/api/workspace-user-rates/", + { + "workspace_id": str(workspace.id), + "user_id": str(member.id), + "hourly_rate": "25.00", + "currency": "USD", + }, + format="json", + ) + + assert workspace_response.status_code == 403