feat(projects): add project-specific member rates

This commit is contained in:
2026-05-23 18:29:00 +03:30
parent b79fd73403
commit 181a135df9
8 changed files with 381 additions and 22 deletions

View File

@@ -1,6 +1,9 @@
from decimal import Decimal
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 Project from apps.projects.models import Project
from apps.workspaces.models import PriceUnit
class ProjectSerializer(BaseModelSerializer): class ProjectSerializer(BaseModelSerializer):
@@ -54,3 +57,23 @@ class ProjectAccessMutationSerializer(serializers.Serializer):
child=serializers.UUIDField(), child=serializers.UUIDField(),
allow_empty=False, allow_empty=False,
) )
class ProjectAccessRateMutationSerializer(serializers.Serializer):
workspace = serializers.UUIDField()
user = serializers.UUIDField()
project = serializers.UUIDField()
hourly_rate = serializers.DecimalField(
max_digits=10,
decimal_places=2,
min_value=Decimal("0.01"),
required=False,
allow_null=True,
)
currency = serializers.CharField(max_length=3, required=False, 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

View File

@@ -16,9 +16,11 @@ from apps.projects.models import Project
from apps.projects.api.serializers import ( from apps.projects.api.serializers import (
ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer, ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer,
ProjectAccessMutationSerializer, ProjectAccessQuerySerializer, ProjectAccessMutationSerializer, ProjectAccessQuerySerializer,
ProjectAccessRateMutationSerializer,
) )
from apps.projects.api.permissions import IsProjectMember, IsProjectManager from apps.projects.api.permissions import IsProjectMember, IsProjectManager
from apps.projects.services.access import ( from apps.projects.services.access import (
build_project_access_item,
build_project_access_items, build_project_access_items,
ensure_workspace_project_access, ensure_workspace_project_access,
filter_projects_for_user, filter_projects_for_user,
@@ -26,6 +28,7 @@ from apps.projects.services.access import (
grant_project_accesses, grant_project_accesses,
revoke_project_accesses, revoke_project_accesses,
) )
from apps.projects.services.rates import get_current_project_user_rate, remove_project_user_rate, upsert_project_user_rate
from apps.projects.services.projects import ( from apps.projects.services.projects import (
create_project, create_project,
update_project, update_project,
@@ -231,3 +234,54 @@ class ProjectViewSet(ModelViewSet):
project_ids=[str(project_id) for project_id in serializer.validated_data["project_ids"]], project_ids=[str(project_id) for project_id in serializer.validated_data["project_ids"]],
) )
return Response({"changed": changed}, status=status.HTTP_200_OK) return Response({"changed": changed}, status=status.HTTP_200_OK)
@action(detail=False, methods=["post"], url_path="access/rate")
def set_access_rate(self, request):
serializer = ProjectAccessRateMutationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
workspace = get_object_or_404(
Workspace,
id=serializer.validated_data["workspace"],
is_deleted=False,
)
ensure_workspace_project_access(request.user, workspace)
membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"]))
project = get_object_or_404(
Project,
id=serializer.validated_data["project"],
workspace=workspace,
is_deleted=False,
)
has_access = membership.user.project_accesses.filter(project=project).exists()
if not has_access:
return Response(
{"detail": "Grant project access before setting a project-specific rate."},
status=status.HTTP_400_BAD_REQUEST,
)
removed = serializer.validated_data.get("hourly_rate") is None
if removed:
remove_project_user_rate(project=project, user=membership.user)
else:
upsert_project_user_rate(
project=project,
user=membership.user,
hourly_rate=serializer.validated_data["hourly_rate"],
currency=serializer.validated_data.get("currency", "USD"),
)
workspace_rate = (
workspace.user_rates.filter(user=membership.user, is_deleted=False)
.order_by("-effective_from", "-updated_at")
.first()
)
project_rate = get_current_project_user_rate(project=project, user=membership.user)
item = build_project_access_item(
project=project,
has_access=True,
workspace_rate=workspace_rate,
project_rate=project_rate,
)
return Response({"removed": removed, "item": item}, status=status.HTTP_200_OK)

View File

@@ -5,8 +5,8 @@ from django.db.models import Q, QuerySet
from django.utils import timezone from django.utils import timezone
from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.exceptions import PermissionDenied, ValidationError
from apps.projects.models import Project, ProjectAccess from apps.projects.models import Project, ProjectAccess, ProjectUserRate
from apps.workspaces.models import Workspace, WorkspaceMembership from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate
from apps.workspaces.services import PROJECTS_EDIT, get_workspace_role, has_workspace_capability from apps.workspaces.services import PROJECTS_EDIT, get_workspace_role, has_workspace_capability
User = get_user_model() User = get_user_model()
@@ -80,17 +80,19 @@ def get_access_managed_membership(workspace: Workspace, user_id: str) -> Workspa
return membership return membership
def build_project_access_items(*, workspace: Workspace, target_user) -> list[dict]: def serialize_rate(rate) -> dict | None:
explicit_access_ids = set( if not rate:
ProjectAccess.objects.filter(project__workspace=workspace, user=target_user).values_list("project_id", flat=True) return None
) return {
projects = ( "id": str(rate.id),
Project.objects.filter(workspace=workspace, is_deleted=False) "hourly_rate": str(rate.hourly_rate),
.select_related("client") "currency": rate.currency,
.order_by("client__name", "name") "effective_from": rate.effective_from.isoformat() if rate.effective_from else None,
) }
return [
{
def build_project_access_item(*, project: Project, has_access: bool, workspace_rate, project_rate) -> dict:
return {
"id": str(project.id), "id": str(project.id),
"name": project.name, "name": project.name,
"description": project.description, "description": project.description,
@@ -101,8 +103,53 @@ def build_project_access_items(*, workspace: Workspace, target_user) -> list[dic
if project.client_id and project.client if project.client_id and project.client
else None else None
), ),
"has_access": str(project.id) in {str(project_id) for project_id in explicit_access_ids}, "has_access": has_access,
"workspace_rate": serialize_rate(workspace_rate),
"project_rate": serialize_rate(project_rate),
} }
def build_project_access_items(*, workspace: Workspace, target_user) -> list[dict]:
explicit_access_ids = {
str(project_id)
for project_id in ProjectAccess.objects.filter(
project__workspace=workspace,
user=target_user,
).values_list("project_id", flat=True)
}
workspace_rate = (
WorkspaceUserRate.objects.filter(
workspace=workspace,
user=target_user,
is_deleted=False,
)
.order_by("-effective_from", "-updated_at")
.first()
)
project_rates: dict[str, ProjectUserRate] = {}
for rate in (
ProjectUserRate.objects.filter(
project__workspace=workspace,
user=target_user,
is_active=True,
is_deleted=False,
)
.select_related("project")
.order_by("project_id", "-effective_from", "-updated_at")
):
project_rates.setdefault(str(rate.project_id), rate)
projects = (
Project.objects.filter(workspace=workspace, is_deleted=False)
.select_related("client")
.order_by("client__name", "name")
)
return [
build_project_access_item(
project=project,
has_access=str(project.id) in explicit_access_ids,
workspace_rate=workspace_rate,
project_rate=project_rates.get(str(project.id)),
)
for project in projects for project in projects
] ]

View File

@@ -0,0 +1,63 @@
from django.utils import timezone
from apps.projects.models import ProjectUserRate
def get_current_project_user_rate(*, project, user):
return (
ProjectUserRate.objects.filter(
project=project,
user=user,
is_active=True,
is_deleted=False,
)
.order_by("-effective_from", "-updated_at")
.first()
)
def upsert_project_user_rate(*, project, user, hourly_rate, currency="USD"):
currency = currency.upper()
rate = (
ProjectUserRate.all_objects.filter(
project=project,
user=user,
)
.order_by("-updated_at", "-created_at")
.first()
)
if rate:
update_fields = []
if rate.is_deleted:
rate.restore()
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 ProjectUserRate.objects.create(
project=project,
user=user,
hourly_rate=hourly_rate,
currency=currency,
effective_from=timezone.now(),
is_active=True,
)
def remove_project_user_rate(*, project, user):
rate = get_current_project_user_rate(project=project, user=user)
if not rate:
return False
rate.delete()
return True

View File

@@ -1,9 +1,11 @@
from decimal import Decimal
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from apps.clients.models import Client from apps.clients.models import Client
from apps.projects.models import Project, ProjectAccess from apps.projects.models import Project, ProjectAccess, ProjectUserRate
from apps.users.models import User from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
class ProjectViewTests(APITestCase): class ProjectViewTests(APITestCase):
@@ -15,6 +17,7 @@ class ProjectViewTests(APITestCase):
first_name="Owner", first_name="Owner",
) )
cls.workspace = Workspace.objects.create(name="Projects", owner=cls.owner) cls.workspace = Workspace.objects.create(name="Projects", owner=cls.owner)
PriceUnit.objects.create(code="USD", name="US Dollar", local_name="Dollar", symbol="$")
cls.member = User.objects.create_user( cls.member = User.objects.create_user(
mobile="09121110002", mobile="09121110002",
password="secret123", password="secret123",
@@ -47,6 +50,14 @@ class ProjectViewTests(APITestCase):
cls.first_project = Project.objects.get(name="Alpha") cls.first_project = Project.objects.get(name="Alpha")
ProjectAccess.objects.create(project=cls.first_project, user=cls.member) ProjectAccess.objects.create(project=cls.first_project, user=cls.member)
ProjectAccess.objects.create(project=cls.second_project, user=cls.member) ProjectAccess.objects.create(project=cls.second_project, user=cls.member)
WorkspaceUserRate.objects.create(
workspace=cls.workspace,
user=cls.member,
hourly_rate=Decimal("25.00"),
currency="USD",
effective_from=cls.workspace.created_at,
is_active=True,
)
def test_project_list_supports_multi_client_filter(self): def test_project_list_supports_multi_client_filter(self):
self.client.force_authenticate(user=self.member) self.client.force_authenticate(user=self.member)
@@ -84,6 +95,9 @@ class ProjectViewTests(APITestCase):
items = access_response.data["items"] items = access_response.data["items"]
gamma_item = next(item for item in items if item["id"] == str(self.third_project.id)) gamma_item = next(item for item in items if item["id"] == str(self.third_project.id))
self.assertFalse(gamma_item["has_access"]) self.assertFalse(gamma_item["has_access"])
alpha_item = next(item for item in items if item["id"] == str(self.first_project.id))
self.assertEqual(alpha_item["workspace_rate"]["hourly_rate"], "25.00")
self.assertIsNone(alpha_item["project_rate"])
grant_response = self.client.post( grant_response = self.client.post(
"/api/projects/access/grant/", "/api/projects/access/grant/",
@@ -114,3 +128,66 @@ class ProjectViewTests(APITestCase):
) )
self.assertEqual(revoke_response.status_code, 200) self.assertEqual(revoke_response.status_code, 200)
self.assertFalse(ProjectAccess.objects.filter(project=self.first_project, user=self.member).exists()) self.assertFalse(ProjectAccess.objects.filter(project=self.first_project, user=self.member).exists())
def test_project_access_rate_endpoint_saves_override_and_keeps_it_dormant_after_revoke(self):
self.client.force_authenticate(user=self.owner)
save_response = self.client.post(
"/api/projects/access/rate/",
{
"workspace": str(self.workspace.id),
"user": str(self.member.id),
"project": str(self.first_project.id),
"hourly_rate": "44.50",
"currency": "USD",
},
format="json",
)
self.assertEqual(save_response.status_code, 200)
self.assertFalse(save_response.data["removed"])
self.assertEqual(save_response.data["item"]["project_rate"]["hourly_rate"], "44.50")
self.assertTrue(
ProjectUserRate.objects.filter(project=self.first_project, user=self.member, is_deleted=False).exists()
)
revoke_response = self.client.post(
"/api/projects/access/revoke/",
{
"workspace": str(self.workspace.id),
"user": str(self.member.id),
"project_ids": [str(self.first_project.id)],
},
format="json",
)
self.assertEqual(revoke_response.status_code, 200)
self.assertTrue(
ProjectUserRate.objects.filter(project=self.first_project, user=self.member, is_deleted=False).exists()
)
access_response = self.client.get(
"/api/projects/access/",
{"workspace": str(self.workspace.id), "user": str(self.member.id)},
)
self.assertEqual(access_response.status_code, 200)
alpha_item = next(item for item in access_response.data["items"] if item["id"] == str(self.first_project.id))
self.assertFalse(alpha_item["has_access"])
self.assertEqual(alpha_item["project_rate"]["hourly_rate"], "44.50")
def test_project_access_rate_endpoint_rejects_projects_without_access(self):
self.client.force_authenticate(user=self.owner)
response = self.client.post(
"/api/projects/access/rate/",
{
"workspace": str(self.workspace.id),
"user": str(self.member.id),
"project": str(self.third_project.id),
"hourly_rate": "44.50",
"currency": "USD",
},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertIn("Grant project access", response.data["detail"])

View File

@@ -1,7 +1,14 @@
from apps.projects.services.access import user_has_project_access
from apps.projects.services.rates import get_current_project_user_rate
from apps.workspaces.models import WorkspaceUserRate from apps.workspaces.models import WorkspaceUserRate
def resolve_rate(user, project): def resolve_rate(user, project):
if user_has_project_access(user, project):
project_user_rate = get_current_project_user_rate(project=project, user=user)
if project_user_rate:
return project_user_rate.hourly_rate, project_user_rate.currency
workspace_user_rate = WorkspaceUserRate.objects.filter( workspace_user_rate = WorkspaceUserRate.objects.filter(
user=user, user=user,
workspace=project.workspace, workspace=project.workspace,

View File

@@ -1,10 +1,11 @@
from datetime import timedelta from datetime import timedelta
from decimal import Decimal
from django.test import TestCase from django.test import TestCase
from django.utils import timezone from django.utils import timezone
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from apps.projects.models import Project from apps.projects.models import Project, ProjectAccess, ProjectUserRate
from apps.tags.models import Tag from apps.tags.models import Tag
from apps.time_entries.services.time_entries import ( from apps.time_entries.services.time_entries import (
create_time_entry, create_time_entry,
@@ -12,14 +13,21 @@ from apps.time_entries.services.time_entries import (
update_time_entry, update_time_entry,
) )
from apps.users.models import User from apps.users.models import User
from apps.workspaces.models import Workspace from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate
class TimeEntryServiceTests(TestCase): class TimeEntryServiceTests(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.user = User.objects.create_user(mobile="09121111111", password="secret123") cls.user = User.objects.create_user(mobile="09121111111", password="secret123")
cls.member = User.objects.create_user(mobile="09121111112", password="secret123")
cls.workspace = Workspace.objects.create(name="Core", owner=cls.user) cls.workspace = Workspace.objects.create(name="Core", owner=cls.user)
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
def test_create_time_entry_allows_only_one_running_timer_per_workspace(self): def test_create_time_entry_allows_only_one_running_timer_per_workspace(self):
create_time_entry( create_time_entry(
@@ -97,3 +105,36 @@ class TimeEntryServiceTests(TestCase):
), ),
[tag.id], [tag.id],
) )
def test_create_billable_time_entry_uses_project_user_rate_override(self):
project = Project.objects.create(workspace=self.workspace, name="Override project")
ProjectAccess.objects.create(project=project, user=self.member)
WorkspaceUserRate.objects.create(
workspace=self.workspace,
user=self.member,
hourly_rate=Decimal("10.00"),
currency="USD",
effective_from=self.workspace.created_at,
is_active=True,
)
ProjectUserRate.objects.create(
project=project,
user=self.member,
hourly_rate=Decimal("20.00"),
currency="EUR",
effective_from=self.workspace.created_at,
is_active=True,
)
entry = create_time_entry(
user=self.member,
workspace_id=self.workspace.id,
start_time=timezone.now() - timedelta(minutes=30),
end_time=timezone.now(),
project=project,
description="Billable work",
is_billable=True,
)
self.assertEqual(entry.hourly_rate, Decimal("20.00"))
self.assertEqual(entry.currency, "EUR")

View File

@@ -4,7 +4,7 @@ from django.core.cache import cache
from django.test import TestCase from django.test import TestCase
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from apps.projects.models import Project from apps.projects.models import Project, ProjectAccess, ProjectUserRate
from apps.time_entries.services.rates import resolve_rate from apps.time_entries.services.rates import resolve_rate
from apps.users.models import User from apps.users.models import User
from apps.workspaces.models import ( from apps.workspaces.models import (
@@ -78,6 +78,53 @@ class WorkspaceRateTests(APITestCase):
self.assertIsNone(hourly_rate) self.assertIsNone(hourly_rate)
self.assertEqual(currency, "") self.assertEqual(currency, "")
def test_resolve_rate_prefers_project_user_rate_when_member_has_access(self):
WorkspaceUserRate.objects.create(
workspace=self.workspace,
user=self.member,
hourly_rate=Decimal("40.00"),
currency="EUR",
effective_from=self.project.created_at,
is_active=True,
)
ProjectAccess.objects.create(project=self.project, user=self.member)
ProjectUserRate.objects.create(
project=self.project,
user=self.member,
hourly_rate=Decimal("55.00"),
currency="USD",
effective_from=self.project.created_at,
is_active=True,
)
hourly_rate, currency = resolve_rate(self.member, self.project)
self.assertEqual(hourly_rate, Decimal("55.00"))
self.assertEqual(currency, "USD")
def test_resolve_rate_ignores_project_user_rate_without_access(self):
WorkspaceUserRate.objects.create(
workspace=self.workspace,
user=self.member,
hourly_rate=Decimal("40.00"),
currency="EUR",
effective_from=self.project.created_at,
is_active=True,
)
ProjectUserRate.objects.create(
project=self.project,
user=self.member,
hourly_rate=Decimal("55.00"),
currency="USD",
effective_from=self.project.created_at,
is_active=True,
)
hourly_rate, currency = resolve_rate(self.member, self.project)
self.assertEqual(hourly_rate, Decimal("40.00"))
self.assertEqual(currency, "EUR")
def test_admin_can_manage_workspace_user_rates(self): def test_admin_can_manage_workspace_user_rates(self):
self.client.force_authenticate(user=self.admin) self.client.force_authenticate(user=self.admin)