feat(projects): add project-specific member rates
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from rest_framework import serializers
|
||||
from core.serializers.base import BaseModelSerializer
|
||||
from apps.projects.models import Project
|
||||
from apps.workspaces.models import PriceUnit
|
||||
|
||||
|
||||
class ProjectSerializer(BaseModelSerializer):
|
||||
@@ -54,3 +57,23 @@ class ProjectAccessMutationSerializer(serializers.Serializer):
|
||||
child=serializers.UUIDField(),
|
||||
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
|
||||
|
||||
@@ -16,9 +16,11 @@ from apps.projects.models import Project
|
||||
from apps.projects.api.serializers import (
|
||||
ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer,
|
||||
ProjectAccessMutationSerializer, ProjectAccessQuerySerializer,
|
||||
ProjectAccessRateMutationSerializer,
|
||||
)
|
||||
from apps.projects.api.permissions import IsProjectMember, IsProjectManager
|
||||
from apps.projects.services.access import (
|
||||
build_project_access_item,
|
||||
build_project_access_items,
|
||||
ensure_workspace_project_access,
|
||||
filter_projects_for_user,
|
||||
@@ -26,6 +28,7 @@ from apps.projects.services.access import (
|
||||
grant_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 (
|
||||
create_project,
|
||||
update_project,
|
||||
@@ -231,3 +234,54 @@ class ProjectViewSet(ModelViewSet):
|
||||
project_ids=[str(project_id) for project_id in serializer.validated_data["project_ids"]],
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -5,8 +5,8 @@ from django.db.models import Q, QuerySet
|
||||
from django.utils import timezone
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
|
||||
from apps.projects.models import Project, ProjectAccess
|
||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||
from apps.projects.models import Project, ProjectAccess, ProjectUserRate
|
||||
from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate
|
||||
from apps.workspaces.services import PROJECTS_EDIT, get_workspace_role, has_workspace_capability
|
||||
|
||||
User = get_user_model()
|
||||
@@ -80,29 +80,76 @@ def get_access_managed_membership(workspace: Workspace, user_id: str) -> Workspa
|
||||
return membership
|
||||
|
||||
|
||||
def serialize_rate(rate) -> dict | None:
|
||||
if not rate:
|
||||
return None
|
||||
return {
|
||||
"id": str(rate.id),
|
||||
"hourly_rate": str(rate.hourly_rate),
|
||||
"currency": rate.currency,
|
||||
"effective_from": rate.effective_from.isoformat() if rate.effective_from else None,
|
||||
}
|
||||
|
||||
|
||||
def build_project_access_item(*, project: Project, has_access: bool, workspace_rate, project_rate) -> dict:
|
||||
return {
|
||||
"id": str(project.id),
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"color": project.color,
|
||||
"is_archived": project.is_archived,
|
||||
"client": (
|
||||
{"id": str(project.client_id), "name": project.client.name}
|
||||
if project.client_id and project.client
|
||||
else None
|
||||
),
|
||||
"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 = set(
|
||||
ProjectAccess.objects.filter(project__workspace=workspace, user=target_user).values_list("project_id", flat=True)
|
||||
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 [
|
||||
{
|
||||
"id": str(project.id),
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"color": project.color,
|
||||
"is_archived": project.is_archived,
|
||||
"client": (
|
||||
{"id": str(project.client_id), "name": project.client.name}
|
||||
if project.client_id and project.client
|
||||
else None
|
||||
),
|
||||
"has_access": str(project.id) in {str(project_id) for project_id in explicit_access_ids},
|
||||
}
|
||||
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
|
||||
]
|
||||
|
||||
|
||||
63
apps/projects/services/rates.py
Normal file
63
apps/projects/services/rates.py
Normal 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
|
||||
@@ -1,9 +1,11 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
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.workspaces.models import Workspace, WorkspaceMembership
|
||||
from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
|
||||
|
||||
|
||||
class ProjectViewTests(APITestCase):
|
||||
@@ -15,6 +17,7 @@ class ProjectViewTests(APITestCase):
|
||||
first_name="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(
|
||||
mobile="09121110002",
|
||||
password="secret123",
|
||||
@@ -47,6 +50,14 @@ class ProjectViewTests(APITestCase):
|
||||
cls.first_project = Project.objects.get(name="Alpha")
|
||||
ProjectAccess.objects.create(project=cls.first_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):
|
||||
self.client.force_authenticate(user=self.member)
|
||||
@@ -84,6 +95,9 @@ class ProjectViewTests(APITestCase):
|
||||
items = access_response.data["items"]
|
||||
gamma_item = next(item for item in items if item["id"] == str(self.third_project.id))
|
||||
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(
|
||||
"/api/projects/access/grant/",
|
||||
@@ -114,3 +128,66 @@ class ProjectViewTests(APITestCase):
|
||||
)
|
||||
self.assertEqual(revoke_response.status_code, 200)
|
||||
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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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(
|
||||
user=user,
|
||||
workspace=project.workspace,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
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.time_entries.services.time_entries import (
|
||||
create_time_entry,
|
||||
@@ -12,14 +13,21 @@ from apps.time_entries.services.time_entries import (
|
||||
update_time_entry,
|
||||
)
|
||||
from apps.users.models import User
|
||||
from apps.workspaces.models import Workspace
|
||||
from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate
|
||||
|
||||
|
||||
class TimeEntryServiceTests(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
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)
|
||||
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):
|
||||
create_time_entry(
|
||||
@@ -97,3 +105,36 @@ class TimeEntryServiceTests(TestCase):
|
||||
),
|
||||
[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")
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
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.users.models import User
|
||||
from apps.workspaces.models import (
|
||||
@@ -78,6 +78,53 @@ class WorkspaceRateTests(APITestCase):
|
||||
self.assertIsNone(hourly_rate)
|
||||
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):
|
||||
self.client.force_authenticate(user=self.admin)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user