feat(pricing): add workspace user rates and price units

This commit is contained in:
2026-04-26 10:19:04 +03:30
parent f960ca8221
commit fadf898486
19 changed files with 731 additions and 266 deletions

View File

@@ -1,12 +1,10 @@
from rest_framework import serializers from rest_framework import serializers
from core.serializers.base import BaseModelSerializer from core.serializers.base import BaseModelSerializer
from apps.projects.models import ( from apps.projects.models import (
Project, Project,
ProjectMembership, ProjectMembership,
ProjectRate, )
ProjectUserRate, from core.serializers.mini import UserMiniSerializer
)
from core.serializers.mini import UserMiniSerializer
class ProjectMemberInputSerializer(serializers.Serializer): class ProjectMemberInputSerializer(serializers.Serializer):
@@ -94,52 +92,6 @@ class ProjectMembershipCreateSerializer(serializers.Serializer):
role = serializers.ChoiceField(choices=ProjectMembership.Role.choices) role = serializers.ChoiceField(choices=ProjectMembership.Role.choices)
class ProjectMembershipUpdateSerializer(serializers.Serializer): class ProjectMembershipUpdateSerializer(serializers.Serializer):
role = serializers.ChoiceField(choices=ProjectMembership.Role.choices, required=False) role = serializers.ChoiceField(choices=ProjectMembership.Role.choices, required=False)
is_active = serializers.BooleanField(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)

View File

@@ -1,21 +1,17 @@
from django.urls import path, include from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from apps.projects.api.views import ( from apps.projects.api.views import (
ProjectViewSet, ProjectViewSet,
ProjectMembershipViewSet, ProjectMembershipViewSet,
ProjectRateViewSet, )
ProjectUserRateViewSet
)
app_name = "projects" app_name = "projects"
router = DefaultRouter() router = DefaultRouter()
router.register(r"projects", ProjectViewSet, basename="project") router.register(r"projects", ProjectViewSet, basename="project")
router.register(r"memberships", ProjectMembershipViewSet, basename="membership") 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)),
urlpatterns = [ ]
path("", include(router.urls)),
]

View File

@@ -20,28 +20,20 @@ from apps.notifications.services import (
from apps.workspaces.models import Workspace from apps.workspaces.models import Workspace
from apps.clients.models import Client from apps.clients.models import Client
from apps.projects.models import ( from apps.projects.models import (
Project, Project,
ProjectMembership, ProjectMembership,
ProjectRate, )
ProjectUserRate, from apps.projects.api.serializers import (
) ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer,
from apps.projects.api.serializers import ( ProjectMembershipSerializer, ProjectMembershipCreateSerializer, ProjectMembershipUpdateSerializer,
ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer, )
ProjectMembershipSerializer, ProjectMembershipCreateSerializer, ProjectMembershipUpdateSerializer,
ProjectRateSerializer, ProjectRateCreateSerializer, ProjectRateUpdateSerializer,
ProjectUserRateSerializer, ProjectUserRateCreateSerializer, ProjectUserRateUpdateSerializer
)
from apps.projects.api.permissions import IsProjectMember, IsProjectManager from apps.projects.api.permissions import IsProjectMember, IsProjectManager
from apps.projects.services.projects import ( from apps.projects.services.projects import (
create_project, create_project,
update_project, update_project,
toggle_project_archive 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
) )
from apps.projects.services.memberships import add_project_member, update_project_member
from apps.workspaces.services import ( from apps.workspaces.services import (
PROJECTS_ARCHIVE, PROJECTS_ARCHIVE,
PROJECTS_CREATE, PROJECTS_CREATE,
@@ -267,7 +259,7 @@ class BaseProjectNestedViewSet(ModelViewSet):
raise PermissionDenied("You must be a project manager to perform this action.") 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"] filterset_fields = ["project", "user", "role", "is_active"]
def get_queryset(self): def get_queryset(self):
@@ -363,96 +355,3 @@ class ProjectMembershipViewSet(BaseProjectNestedViewSet):
role=role, role=role,
) )
return Response(status=status.HTTP_204_NO_CONTENT) 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)

View File

@@ -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

View File

@@ -1,22 +1,15 @@
from apps.projects.models import ProjectRate, ProjectUserRate from apps.workspaces.models import WorkspaceUserRate
def resolve_rate(user, project): def resolve_rate(user, project):
user_rate = ProjectUserRate.objects.filter( workspace_user_rate = WorkspaceUserRate.objects.filter(
user=user, user=user,
project=project, workspace=project.workspace,
is_active=True, is_active=True,
).order_by("-effective_from").first() is_deleted=False,
).order_by("-effective_from").first()
if user_rate:
return user_rate.hourly_rate, user_rate.currency if workspace_user_rate:
return workspace_user_rate.hourly_rate, workspace_user_rate.currency
project_rate = ProjectRate.objects.filter(
project=project, return None, ""
is_active=True,
).order_by("-effective_from").first()
if project_rate:
return project_rate.hourly_rate, project_rate.currency
return None, "USD"

View File

@@ -1,9 +1,11 @@
from decimal import Decimal
from rest_framework import serializers from rest_framework import serializers
from apps.notifications.services import notify_workspace_membership_added from apps.notifications.services import notify_workspace_membership_added
from apps.users.models import User from apps.users.models import User
from core.serializers.base import BaseModelSerializer 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 from core.serializers.mini import UserMiniSerializer
@@ -73,7 +75,7 @@ class WorkspaceSerializer(BaseModelSerializer):
return workspace return workspace
class WorkspaceMembershipSerializer(BaseModelSerializer): class WorkspaceMembershipSerializer(BaseModelSerializer):
class Meta: class Meta:
model = WorkspaceMembership model = WorkspaceMembership
fields = BaseModelSerializer.Meta.fields + ( fields = BaseModelSerializer.Meta.fields + (
@@ -85,8 +87,73 @@ class WorkspaceMembershipSerializer(BaseModelSerializer):
def to_representation(self, instance): def to_representation(self, instance):
data = super().to_representation(instance) data = super().to_representation(instance)
data["user"] = UserMiniSerializer( data["user"] = UserMiniSerializer(
instance.user, instance.user,
context=self.context context=self.context
).data ).data
return 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

View File

@@ -1,11 +1,18 @@
from django.urls import path, include from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from apps.workspaces.api.views import WorkspaceViewSet, WorkspaceMembershipViewSet from apps.workspaces.api.views import (
PriceUnitViewSet,
router = DefaultRouter() WorkspaceViewSet,
router.register(r'workspaces', WorkspaceViewSet, basename='workspace') WorkspaceMembershipViewSet,
router.register(r'workspace-memberships', WorkspaceMembershipViewSet, basename='workspace-membership') 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 = [ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),

View File

@@ -1,7 +1,8 @@
from django.db.models import Q from django.db.models import Q
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.filters import OrderingFilter, SearchFilter
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
@@ -19,14 +20,24 @@ from apps.workspaces.api.permissions import (
IsWorkspaceMember, IsWorkspaceMember,
IsWorkspaceOwner, 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.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 ( from apps.workspaces.services import (
WORKSPACE_MEMBERS_VIEW, WORKSPACE_MEMBERS_VIEW,
WORKSPACE_EDIT,
can_assign_workspace_role, can_assign_workspace_role,
can_change_workspace_membership, can_change_workspace_membership,
has_workspace_capability, has_workspace_capability,
upsert_workspace_user_rate,
update_workspace_user_rate,
) )
from core.paginations.limit_offset import CustomLimitOffsetPagination from core.paginations.limit_offset import CustomLimitOffsetPagination
@@ -65,7 +76,7 @@ class WorkspaceViewSet(ModelViewSet):
serializer.save(owner=self.request.user) serializer.save(owner=self.request.user)
class WorkspaceMembershipViewSet(ModelViewSet): class WorkspaceMembershipViewSet(ModelViewSet):
serializer_class = WorkspaceMembershipSerializer serializer_class = WorkspaceMembershipSerializer
pagination_class = CustomLimitOffsetPagination pagination_class = CustomLimitOffsetPagination
filter_backends = (DjangoFilterBackend, OrderingFilter, SearchFilter) filter_backends = (DjangoFilterBackend, OrderingFilter, SearchFilter)
@@ -236,3 +247,99 @@ class WorkspaceMembershipViewSet(ModelViewSet):
role=role, role=role,
) )
return Response(status=status.HTTP_204_NO_CONTENT) 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)

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -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}"
)
)

View File

@@ -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",
),
),
]

View File

@@ -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),
),
]

View File

@@ -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"),
),
]

View File

@@ -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),
),
]

View File

@@ -75,3 +75,57 @@ class WorkspaceMembership(BaseModel):
def __str__(self): def __str__(self):
return f"{self.user} @ {self.workspace}" 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"),
]

View File

@@ -33,6 +33,10 @@ from apps.workspaces.services.permissions import (
has_project_capability, has_project_capability,
has_workspace_capability, has_workspace_capability,
) )
from apps.workspaces.services.rates import (
upsert_workspace_user_rate,
update_workspace_user_rate,
)
__all__ = [ __all__ = [
"WORKSPACE_VIEW", "WORKSPACE_VIEW",
@@ -68,4 +72,6 @@ __all__ = [
"can_manage_workspace_members", "can_manage_workspace_members",
"can_assign_workspace_role", "can_assign_workspace_role",
"can_change_workspace_membership", "can_change_workspace_membership",
"upsert_workspace_user_rate",
"update_workspace_user_rate",
] ]

View File

@@ -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

View File

@@ -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