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 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
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 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"])
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user