Compare commits
4 Commits
b79fd73403
...
22e08a099c
| Author | SHA1 | Date | |
|---|---|---|---|
| 22e08a099c | |||
| 59cf62bc73 | |||
| 0d6c6a4f09 | |||
| 181a135df9 |
@@ -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"])
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from apps.reports.api.views import (
|
|||||||
ReportDayDetailsView,
|
ReportDayDetailsView,
|
||||||
ReportExportJobViewSet,
|
ReportExportJobViewSet,
|
||||||
ReportTableView,
|
ReportTableView,
|
||||||
|
ReportUserSummaryView,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
@@ -15,6 +16,6 @@ urlpatterns = [
|
|||||||
path("chart/", ReportChartView.as_view(), name="report-chart"),
|
path("chart/", ReportChartView.as_view(), name="report-chart"),
|
||||||
path("table/", ReportTableView.as_view(), name="report-table"),
|
path("table/", ReportTableView.as_view(), name="report-table"),
|
||||||
path("day-details/", ReportDayDetailsView.as_view(), name="report-day-details"),
|
path("day-details/", ReportDayDetailsView.as_view(), name="report-day-details"),
|
||||||
|
path("user-summary/", ReportUserSummaryView.as_view(), name="report-user-summary"),
|
||||||
path("", include(router.urls)),
|
path("", include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from apps.reports.services import (
|
|||||||
build_chart_report,
|
build_chart_report,
|
||||||
build_day_details_report,
|
build_day_details_report,
|
||||||
build_table_report,
|
build_table_report,
|
||||||
|
build_user_summary_report,
|
||||||
load_report_filters,
|
load_report_filters,
|
||||||
)
|
)
|
||||||
from apps.reports.tasks import generate_report_export_task
|
from apps.reports.tasks import generate_report_export_task
|
||||||
@@ -83,6 +84,24 @@ class ReportDayDetailsView(APIView):
|
|||||||
return Response(payload)
|
return Response(payload)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportUserSummaryView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@extend_schema(responses=dict)
|
||||||
|
def get(self, request):
|
||||||
|
workspace_id = request.query_params.get("workspace")
|
||||||
|
payload = get_or_set_cache_payload(
|
||||||
|
CACHE_NAMESPACE_REPORTS,
|
||||||
|
ttl_seconds=REPORT_CACHE_TTL_SECONDS,
|
||||||
|
builder=lambda: build_user_summary_report(request.user, request.query_params),
|
||||||
|
resource="user-summary",
|
||||||
|
user_id=request.user.id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
params=request.query_params,
|
||||||
|
)
|
||||||
|
return Response(payload)
|
||||||
|
|
||||||
|
|
||||||
class ReportExportJobViewSet(
|
class ReportExportJobViewSet(
|
||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
mixins.ListModelMixin,
|
mixins.ListModelMixin,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from apps.reports.services.aggregation import (
|
|||||||
build_chart_report,
|
build_chart_report,
|
||||||
build_day_details_report,
|
build_day_details_report,
|
||||||
build_table_report,
|
build_table_report,
|
||||||
|
build_user_summary_report,
|
||||||
build_user_scoped_table_reports,
|
build_user_scoped_table_reports,
|
||||||
load_report_filters,
|
load_report_filters,
|
||||||
)
|
)
|
||||||
@@ -10,6 +11,7 @@ __all__ = [
|
|||||||
"load_report_filters",
|
"load_report_filters",
|
||||||
"build_chart_report",
|
"build_chart_report",
|
||||||
"build_table_report",
|
"build_table_report",
|
||||||
|
"build_user_summary_report",
|
||||||
"build_user_scoped_table_reports",
|
"build_user_scoped_table_reports",
|
||||||
"build_day_details_report",
|
"build_day_details_report",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from apps.projects.services.access import user_has_project_access
|
|||||||
from apps.tags.models import Tag
|
from apps.tags.models import Tag
|
||||||
from apps.time_entries.models import TimeEntry
|
from apps.time_entries.models import TimeEntry
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace
|
||||||
|
from apps.workspaces.models import WorkspaceUserRate
|
||||||
from apps.workspaces.services import WORKSPACE_VIEW, get_workspace_role, has_workspace_capability
|
from apps.workspaces.services import WORKSPACE_VIEW, get_workspace_role, has_workspace_capability
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -110,12 +111,10 @@ def _serialize_rate(amount: Decimal | None, currency: str | None) -> dict | None
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _serialize_distinct_rates(entries: list[TimeEntry]) -> list[dict]:
|
def _serialize_distinct_rates_from_rows(rate_rows: list[dict]) -> list[dict]:
|
||||||
unique_rates: set[tuple[str, str]] = set()
|
unique_rates: set[tuple[str, str]] = set()
|
||||||
for entry in entries:
|
for row in rate_rows:
|
||||||
if not entry.hourly_rate:
|
unique_rates.add((row["amount"], row["currency"]))
|
||||||
continue
|
|
||||||
unique_rates.add((f"{Decimal(entry.hourly_rate).quantize(Decimal('0.01'))}", entry.currency or "USD"))
|
|
||||||
return [
|
return [
|
||||||
{"amount": amount, "currency": currency}
|
{"amount": amount, "currency": currency}
|
||||||
for amount, currency in sorted(unique_rates, key=lambda item: (item[1], Decimal(item[0])))
|
for amount, currency in sorted(unique_rates, key=lambda item: (item[1], Decimal(item[0])))
|
||||||
@@ -123,12 +122,12 @@ def _serialize_distinct_rates(entries: list[TimeEntry]) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def _serialize_rate_periods(entries: list[TimeEntry]) -> list[dict]:
|
def _serialize_rate_periods(entries: list[TimeEntry]) -> list[dict]:
|
||||||
sorted_entries = sorted(entries, key=lambda entry: entry.start_time)
|
sorted_entries = sorted(entries, key=lambda entry: (entry.start_time, entry.end_time or entry.start_time, entry.id))
|
||||||
periods: list[dict] = []
|
periods: list[dict] = []
|
||||||
current: dict | None = None
|
current: dict | None = None
|
||||||
|
|
||||||
for entry in sorted_entries:
|
for entry in sorted_entries:
|
||||||
if not entry.hourly_rate:
|
if not entry.hourly_rate or not entry.start_time:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
amount = f"{Decimal(entry.hourly_rate).quantize(Decimal('0.01'))}"
|
amount = f"{Decimal(entry.hourly_rate).quantize(Decimal('0.01'))}"
|
||||||
@@ -176,6 +175,58 @@ def _serialize_rate_periods(entries: list[TimeEntry]) -> list[dict]:
|
|||||||
return periods
|
return periods
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_current_rate_rows(*, user, workspace: Workspace) -> list[dict]:
|
||||||
|
workspace_rate = (
|
||||||
|
WorkspaceUserRate.objects.filter(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
.order_by("-effective_from", "-updated_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not workspace_rate or not workspace_rate.effective_from:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"amount": f"{Decimal(workspace_rate.hourly_rate).quantize(Decimal('0.01'))}",
|
||||||
|
"currency": workspace_rate.currency or "USD",
|
||||||
|
"from_date": _localize_datetime(workspace_rate.effective_from).date().isoformat(),
|
||||||
|
"to_date": None,
|
||||||
|
"is_current": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_rate_history_rows(history_rows: list[dict], current_rows: list[dict]) -> list[dict]:
|
||||||
|
merged = [dict(row) for row in history_rows]
|
||||||
|
latest_indexes = {
|
||||||
|
(row["amount"], row["currency"]): index
|
||||||
|
for index, row in enumerate(merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in current_rows:
|
||||||
|
key = (row["amount"], row["currency"])
|
||||||
|
index = latest_indexes.get(key)
|
||||||
|
if index is not None:
|
||||||
|
merged[index]["to_date"] = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
merged.append(dict(row))
|
||||||
|
latest_indexes[key] = len(merged) - 1
|
||||||
|
|
||||||
|
return sorted(
|
||||||
|
merged,
|
||||||
|
key=lambda item: (
|
||||||
|
item["from_date"],
|
||||||
|
item["currency"],
|
||||||
|
Decimal(item["amount"]),
|
||||||
|
item.get("to_date") or "9999-12-31",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _uncategorized_label(kind: str, language: str) -> str:
|
def _uncategorized_label(kind: str, language: str) -> str:
|
||||||
resolved_language = language if language in UNCATEGORIZED_LABELS else "en"
|
resolved_language = language if language in UNCATEGORIZED_LABELS else "en"
|
||||||
return UNCATEGORIZED_LABELS[resolved_language][kind]
|
return UNCATEGORIZED_LABELS[resolved_language][kind]
|
||||||
@@ -373,6 +424,9 @@ def _serialize_income_percentage_rows(rows: list[dict], shares: dict[str, dict])
|
|||||||
|
|
||||||
def _build_user_summary(user, entries: list[TimeEntry], *, language: str) -> dict:
|
def _build_user_summary(user, entries: list[TimeEntry], *, language: str) -> dict:
|
||||||
summary = _summary_from_entries(entries)
|
summary = _summary_from_entries(entries)
|
||||||
|
historical_rate_rows = _serialize_rate_periods(entries)
|
||||||
|
current_rate_rows = _serialize_current_rate_rows(user=user, workspace=entries[0].workspace)
|
||||||
|
rate_rows = _merge_rate_history_rows(historical_rate_rows, current_rate_rows)
|
||||||
project_rows = _build_breakdown(entries, "projects", language=language)
|
project_rows = _build_breakdown(entries, "projects", language=language)
|
||||||
client_rows = _build_breakdown(entries, "clients", language=language)
|
client_rows = _build_breakdown(entries, "clients", language=language)
|
||||||
tag_rows = _build_breakdown(entries, "tags", language=language)
|
tag_rows = _build_breakdown(entries, "tags", language=language)
|
||||||
@@ -386,8 +440,8 @@ def _build_user_summary(user, entries: list[TimeEntry], *, language: str) -> dic
|
|||||||
"name": _user_display(user),
|
"name": _user_display(user),
|
||||||
"mobile": user.mobile,
|
"mobile": user.mobile,
|
||||||
},
|
},
|
||||||
"hourly_rates": _serialize_distinct_rates(entries),
|
"hourly_rates": _serialize_distinct_rates_from_rows(rate_rows),
|
||||||
"rate_periods": _serialize_rate_periods(entries),
|
"rate_periods": rate_rows,
|
||||||
"total_seconds": summary["billable_seconds"],
|
"total_seconds": summary["billable_seconds"],
|
||||||
"total_duration": summary["total_duration"],
|
"total_duration": summary["total_duration"],
|
||||||
"billable_seconds": summary["billable_seconds"],
|
"billable_seconds": summary["billable_seconds"],
|
||||||
@@ -988,11 +1042,12 @@ def build_table_report(actor, raw_filters) -> dict:
|
|||||||
filters = load_report_filters(actor, raw_filters)
|
filters = load_report_filters(actor, raw_filters)
|
||||||
entries = list(_base_queryset(filters))
|
entries = list(_base_queryset(filters))
|
||||||
if filters.is_workspace_scope and not filters.user_id:
|
if filters.is_workspace_scope and not filters.user_id:
|
||||||
return _table_report_payload(
|
payload = _table_report_payload(
|
||||||
filters,
|
filters,
|
||||||
entries,
|
entries,
|
||||||
user_summaries=_build_user_summaries(entries, language=filters.language),
|
user_summaries=_build_user_summaries(entries, language=filters.language),
|
||||||
)
|
)
|
||||||
|
return payload
|
||||||
user_summary = (
|
user_summary = (
|
||||||
_build_user_summary(entries[0].user, entries, language=filters.language)
|
_build_user_summary(entries[0].user, entries, language=filters.language)
|
||||||
if entries and filters.user_id
|
if entries and filters.user_id
|
||||||
@@ -1001,6 +1056,20 @@ def build_table_report(actor, raw_filters) -> dict:
|
|||||||
return _table_report_payload(filters, entries, user_summary=user_summary)
|
return _table_report_payload(filters, entries, user_summary=user_summary)
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_summary_report(actor, raw_filters) -> dict:
|
||||||
|
filters = load_report_filters(actor, raw_filters)
|
||||||
|
if not filters.user_id:
|
||||||
|
raise serializers.ValidationError("A user is required.")
|
||||||
|
|
||||||
|
entries = list(_base_queryset(filters))
|
||||||
|
user_summary = (
|
||||||
|
_build_user_summary(entries[0].user, entries, language=filters.language)
|
||||||
|
if entries
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return _table_report_payload(filters, entries, user_summary=user_summary)
|
||||||
|
|
||||||
|
|
||||||
def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
|
def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
|
||||||
filters = load_report_filters(actor, raw_filters)
|
filters = load_report_filters(actor, raw_filters)
|
||||||
if not (filters.is_workspace_scope and not filters.user_id):
|
if not (filters.is_workspace_scope and not filters.user_id):
|
||||||
|
|||||||
@@ -49,9 +49,13 @@ TRANSLATIONS = {
|
|||||||
"rate_history": "Hourly rate history",
|
"rate_history": "Hourly rate history",
|
||||||
"from": "From",
|
"from": "From",
|
||||||
"to": "To",
|
"to": "To",
|
||||||
|
"now": "Now",
|
||||||
|
"project": "Project",
|
||||||
"percentage": "Percentage",
|
"percentage": "Percentage",
|
||||||
"hour_percentage": "Hour %",
|
"hour_percentage": "Hour %",
|
||||||
"income_percentage": "Income %",
|
"income_percentage": "Income %",
|
||||||
|
"multiple_rates": "Multiple rates - see details",
|
||||||
|
"variable_rate": "Variable rate",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"daily_summary": "Daily Summary",
|
"daily_summary": "Daily Summary",
|
||||||
"clients": "Clients",
|
"clients": "Clients",
|
||||||
@@ -93,9 +97,13 @@ TRANSLATIONS = {
|
|||||||
"rate_history": "\u062a\u0627\u0631\u06cc\u062e\u0686\u0647 \u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc",
|
"rate_history": "\u062a\u0627\u0631\u06cc\u062e\u0686\u0647 \u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc",
|
||||||
"from": "\u0627\u0632",
|
"from": "\u0627\u0632",
|
||||||
"to": "\u062a\u0627",
|
"to": "\u062a\u0627",
|
||||||
|
"now": "\u062d\u0627\u0644",
|
||||||
|
"project": "\u067e\u0631\u0648\u0698\u0647",
|
||||||
"percentage": "\u062f\u0631\u0635\u062f",
|
"percentage": "\u062f\u0631\u0635\u062f",
|
||||||
"hour_percentage": "\u062f\u0631\u0635\u062f \u0633\u0627\u0639\u062a",
|
"hour_percentage": "\u062f\u0631\u0635\u062f \u0633\u0627\u0639\u062a",
|
||||||
"income_percentage": "\u062f\u0631\u0635\u062f \u06a9\u0627\u0631\u06a9\u0631\u062f",
|
"income_percentage": "\u062f\u0631\u0635\u062f \u06a9\u0627\u0631\u06a9\u0631\u062f",
|
||||||
|
"multiple_rates": "\u0686\u0646\u062f \u0646\u0631\u062e - \u062c\u0632\u0626\u06cc\u0627\u062a \u062f\u0631 \u06af\u0632\u0627\u0631\u0634 \u06a9\u0627\u0631\u0628\u0631",
|
||||||
|
"variable_rate": "\u0646\u0631\u062e \u0645\u062a\u063a\u06cc\u0631",
|
||||||
"none": "\u0628\u062f\u0648\u0646 \u0645\u0648\u0631\u062f",
|
"none": "\u0628\u062f\u0648\u0646 \u0645\u0648\u0631\u062f",
|
||||||
"daily_summary": "\u062e\u0644\u0627\u0635\u0647 \u0631\u0648\u0632\u0627\u0646\u0647",
|
"daily_summary": "\u062e\u0644\u0627\u0635\u0647 \u0631\u0648\u0632\u0627\u0646\u0647",
|
||||||
"clients": "\u0645\u0634\u062a\u0631\u06cc\u0627\u0646",
|
"clients": "\u0645\u0634\u062a\u0631\u06cc\u0627\u0646",
|
||||||
@@ -140,6 +148,8 @@ CURRENCY_LABELS = {
|
|||||||
"TRY": {"en": "TRY", "fa": "\u0644\u06cc\u0631"},
|
"TRY": {"en": "TRY", "fa": "\u0644\u06cc\u0631"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DECIMAL_TRIM_CURRENCIES = {"IRR", "IRT"}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ExportLocale:
|
class ExportLocale:
|
||||||
@@ -174,6 +184,15 @@ class ExportLocale:
|
|||||||
return self.format_number(value, ascii_digits=ascii_digits)
|
return self.format_number(value, ascii_digits=ascii_digits)
|
||||||
|
|
||||||
def format_amount(self, value: object, *, ascii_digits: bool = False) -> str:
|
def format_amount(self, value: object, *, ascii_digits: bool = False) -> str:
|
||||||
|
return self.format_amount_for_currency(value, None, ascii_digits=ascii_digits)
|
||||||
|
|
||||||
|
def format_amount_for_currency(
|
||||||
|
self,
|
||||||
|
value: object,
|
||||||
|
currency: str | None,
|
||||||
|
*,
|
||||||
|
ascii_digits: bool = False,
|
||||||
|
) -> str:
|
||||||
raw = str(value).strip()
|
raw = str(value).strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
return raw
|
return raw
|
||||||
@@ -189,7 +208,11 @@ class ExportLocale:
|
|||||||
grouped_integer = f"{int(integer_part):,}"
|
grouped_integer = f"{int(integer_part):,}"
|
||||||
formatted = f"{sign}{grouped_integer}"
|
formatted = f"{sign}{grouped_integer}"
|
||||||
if fractional_part:
|
if fractional_part:
|
||||||
trimmed_fraction = fractional_part.rstrip("0")
|
trimmed_fraction = (
|
||||||
|
""
|
||||||
|
if str(currency or "").upper() in DECIMAL_TRIM_CURRENCIES
|
||||||
|
else fractional_part.rstrip("0")
|
||||||
|
)
|
||||||
if trimmed_fraction:
|
if trimmed_fraction:
|
||||||
formatted = f"{formatted}.{trimmed_fraction}"
|
formatted = f"{formatted}.{trimmed_fraction}"
|
||||||
return self.format_number(formatted, ascii_digits=ascii_digits)
|
return self.format_number(formatted, ascii_digits=ascii_digits)
|
||||||
@@ -200,7 +223,9 @@ class ExportLocale:
|
|||||||
parts = []
|
parts = []
|
||||||
for item in income_totals:
|
for item in income_totals:
|
||||||
currency = self.currency_label(item["currency"])
|
currency = self.currency_label(item["currency"])
|
||||||
parts.append(f"{self.format_amount(item['amount'], ascii_digits=ascii_digits)} {currency}")
|
parts.append(
|
||||||
|
f"{self.format_amount_for_currency(item['amount'], item['currency'], ascii_digits=ascii_digits)} {currency}"
|
||||||
|
)
|
||||||
return " | ".join(parts)
|
return " | ".join(parts)
|
||||||
|
|
||||||
def currency_label(self, code: str | None) -> str:
|
def currency_label(self, code: str | None) -> str:
|
||||||
|
|||||||
@@ -76,9 +76,9 @@ def _money_label_excel(locale: ExportLocale, income_totals: list[dict]) -> str:
|
|||||||
|
|
||||||
def _rates_label(locale: ExportLocale, rates: list[dict], *, ascii_digits: bool = False) -> str:
|
def _rates_label(locale: ExportLocale, rates: list[dict], *, ascii_digits: bool = False) -> str:
|
||||||
if not rates:
|
if not rates:
|
||||||
return locale.t("none")
|
return "-"
|
||||||
items = [
|
items = [
|
||||||
f"{locale.format_amount(rate['amount'], ascii_digits=ascii_digits)} {locale.currency_label(rate['currency'])}"
|
f"{locale.format_amount_for_currency(rate['amount'], rate['currency'], ascii_digits=ascii_digits)} {locale.currency_label(rate['currency'])}"
|
||||||
for rate in rates
|
for rate in rates
|
||||||
]
|
]
|
||||||
return ", ".join(items)
|
return ", ".join(items)
|
||||||
@@ -96,7 +96,7 @@ def _percentages_label(locale: ExportLocale, rows: list[dict], *, ascii_digits:
|
|||||||
|
|
||||||
def _rate_period_label(locale: ExportLocale, row: dict, *, ascii_digits: bool = False) -> str:
|
def _rate_period_label(locale: ExportLocale, row: dict, *, ascii_digits: bool = False) -> str:
|
||||||
return (
|
return (
|
||||||
f"{locale.format_amount(row['amount'], ascii_digits=ascii_digits)} "
|
f"{locale.format_amount_for_currency(row['amount'], row['currency'], ascii_digits=ascii_digits)} "
|
||||||
f"{locale.currency_label(row['currency'])}"
|
f"{locale.currency_label(row['currency'])}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -104,16 +104,43 @@ def _rate_period_label(locale: ExportLocale, row: dict, *, ascii_digits: bool =
|
|||||||
def _rate_label(locale: ExportLocale, rate: dict | None) -> str:
|
def _rate_label(locale: ExportLocale, rate: dict | None) -> str:
|
||||||
if not rate:
|
if not rate:
|
||||||
return "-"
|
return "-"
|
||||||
return f"{locale.format_amount(rate['amount'])} {locale.currency_label(rate['currency'])}"
|
return f"{locale.format_amount_for_currency(rate['amount'], rate['currency'])} {locale.currency_label(rate['currency'])}"
|
||||||
|
|
||||||
|
|
||||||
def _rate_label_excel(locale: ExportLocale, rate: dict | None) -> str:
|
def _rate_label_excel(locale: ExportLocale, rate: dict | None) -> str:
|
||||||
if not rate:
|
if not rate:
|
||||||
return "-"
|
return "-"
|
||||||
value = f"{locale.format_amount(rate['amount'], ascii_digits=True)} {locale.currency_label(rate['currency'])}"
|
value = (
|
||||||
|
f"{locale.format_amount_for_currency(rate['amount'], rate['currency'], ascii_digits=True)} "
|
||||||
|
f"{locale.currency_label(rate['currency'])}"
|
||||||
|
)
|
||||||
return f"\u202B{value}\u202C" if locale.is_rtl else value
|
return f"\u202B{value}\u202C" if locale.is_rtl else value
|
||||||
|
|
||||||
|
|
||||||
|
def _pdf_summary_rate_label(locale: ExportLocale, rates: list[dict]) -> str:
|
||||||
|
if len(rates) > 1:
|
||||||
|
return locale.t("variable_rate")
|
||||||
|
return _rates_label(locale, rates)
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_rate_rows(locale: ExportLocale, summary: dict) -> list[list[str]]:
|
||||||
|
rate_periods = summary.get("rate_periods") or []
|
||||||
|
if not rate_periods:
|
||||||
|
return [[locale.t("none"), locale.t("none")]]
|
||||||
|
if len(rate_periods) > 1:
|
||||||
|
return [[locale.t("variable_rate"), _summary_period_label(locale, rate_periods, ascii_digits=True)]]
|
||||||
|
row = rate_periods[0]
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
_rate_period_label(locale, row, ascii_digits=True),
|
||||||
|
(
|
||||||
|
f"{locale.format_date(row['from_date'], ascii_digits=True)} - "
|
||||||
|
f"{locale.format_date(row['to_date'], ascii_digits=True)}"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _section_headers(locale: ExportLocale) -> list[str]:
|
def _section_headers(locale: ExportLocale) -> list[str]:
|
||||||
headers = [
|
headers = [
|
||||||
locale.t("name"),
|
locale.t("name"),
|
||||||
@@ -227,12 +254,19 @@ def _summary_period_label(locale: ExportLocale, rate_periods: list[dict], *, asc
|
|||||||
|
|
||||||
first_row = rate_periods[0]
|
first_row = rate_periods[0]
|
||||||
last_row = rate_periods[-1]
|
last_row = rate_periods[-1]
|
||||||
|
last_to_date = last_row.get("to_date")
|
||||||
return (
|
return (
|
||||||
f"{locale.format_date(first_row['from_date'], ascii_digits=ascii_digits)} - "
|
f"{locale.format_date(first_row['from_date'], ascii_digits=ascii_digits)} - "
|
||||||
f"{locale.format_date(last_row['to_date'], ascii_digits=ascii_digits)}"
|
f"{(_rate_to_label(locale, last_to_date, ascii_digits=ascii_digits))}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_to_label(locale: ExportLocale, to_date: str | None, *, ascii_digits: bool = False) -> str:
|
||||||
|
if not to_date:
|
||||||
|
return locale.t("now")
|
||||||
|
return locale.format_date(to_date, ascii_digits=ascii_digits)
|
||||||
|
|
||||||
|
|
||||||
def _append_rate_history_table_excel(worksheet, *, locale: ExportLocale, user_summary: dict) -> None:
|
def _append_rate_history_table_excel(worksheet, *, locale: ExportLocale, user_summary: dict) -> None:
|
||||||
worksheet.append([])
|
worksheet.append([])
|
||||||
_append_merged_heading(worksheet, locale=locale, title=locale.t("rate_history"), span=3)
|
_append_merged_heading(worksheet, locale=locale, title=locale.t("rate_history"), span=3)
|
||||||
@@ -257,7 +291,7 @@ def _append_rate_history_table_excel(worksheet, *, locale: ExportLocale, user_su
|
|||||||
[
|
[
|
||||||
_rate_period_label(locale, row, ascii_digits=True),
|
_rate_period_label(locale, row, ascii_digits=True),
|
||||||
locale.format_date(row["from_date"], ascii_digits=True),
|
locale.format_date(row["from_date"], ascii_digits=True),
|
||||||
locale.format_date(row["to_date"], ascii_digits=True),
|
_rate_to_label(locale, row.get("to_date"), ascii_digits=True),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -441,21 +475,27 @@ def _append_breakdown_table(
|
|||||||
rows: list[dict],
|
rows: list[dict],
|
||||||
hour_percentages: list[dict] | None = None,
|
hour_percentages: list[dict] | None = None,
|
||||||
income_percentages: list[dict] | None = None,
|
income_percentages: list[dict] | None = None,
|
||||||
|
financial_only: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
worksheet.append([])
|
worksheet.append([])
|
||||||
_append_merged_heading(
|
_append_merged_heading(
|
||||||
worksheet,
|
worksheet,
|
||||||
locale=locale,
|
locale=locale,
|
||||||
title=locale.t(title_key),
|
title=locale.t(title_key),
|
||||||
span=7 if hour_percentages is not None else 5,
|
span=(
|
||||||
|
5
|
||||||
|
if hour_percentages is not None and financial_only
|
||||||
|
else 7
|
||||||
|
if hour_percentages is not None
|
||||||
|
else 5
|
||||||
|
),
|
||||||
)
|
)
|
||||||
header_row = worksheet.max_row + 1
|
header_row = worksheet.max_row + 1
|
||||||
headers = [
|
headers = [
|
||||||
locale.t("name"),
|
locale.t("name"),
|
||||||
locale.t("billable_hours"),
|
locale.t("billable_hours"),
|
||||||
*( [locale.t("hour_percentage")] if hour_percentages is not None else [] ),
|
*( [locale.t("hour_percentage")] if hour_percentages is not None else [] ),
|
||||||
locale.t("non_billable_hours"),
|
*( [] if financial_only else [locale.t("non_billable_hours"), locale.t("total_hours")] ),
|
||||||
locale.t("total_hours"),
|
|
||||||
locale.t("income"),
|
locale.t("income"),
|
||||||
*( [locale.t("income_percentage")] if hour_percentages is not None else [] ),
|
*( [locale.t("income_percentage")] if hour_percentages is not None else [] ),
|
||||||
]
|
]
|
||||||
@@ -477,8 +517,14 @@ def _append_breakdown_table(
|
|||||||
if hour_percentages is not None
|
if hour_percentages is not None
|
||||||
else []
|
else []
|
||||||
),
|
),
|
||||||
|
*(
|
||||||
|
[]
|
||||||
|
if financial_only
|
||||||
|
else [
|
||||||
locale.format_duration(row["non_billable_duration"], ascii_digits=True),
|
locale.format_duration(row["non_billable_duration"], ascii_digits=True),
|
||||||
locale.format_duration(row["total_duration"], ascii_digits=True),
|
locale.format_duration(row["total_duration"], ascii_digits=True),
|
||||||
|
]
|
||||||
|
),
|
||||||
_money_label_excel(locale, row["income_totals"]),
|
_money_label_excel(locale, row["income_totals"]),
|
||||||
*(
|
*(
|
||||||
[_percentage_display(locale, income_percentages or [], row, ascii_digits=True)]
|
[_percentage_display(locale, income_percentages or [], row, ascii_digits=True)]
|
||||||
@@ -562,16 +608,7 @@ def _write_table_row(
|
|||||||
|
|
||||||
|
|
||||||
def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int, list[list[str | None]]]:
|
def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int, list[list[str | None]]]:
|
||||||
rate_rows = [
|
rate_rows = _summary_rate_rows(locale, summary)
|
||||||
[
|
|
||||||
_rate_period_label(locale, row, ascii_digits=True),
|
|
||||||
(
|
|
||||||
f"{locale.format_date(row['from_date'], ascii_digits=True)} - "
|
|
||||||
f"{locale.format_date(row['to_date'], ascii_digits=True)}"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
for row in (summary.get("rate_periods") or [])
|
|
||||||
]
|
|
||||||
client_rows = _summary_breakdown_rows(
|
client_rows = _summary_breakdown_rows(
|
||||||
locale,
|
locale,
|
||||||
summary.get("client_percentages") or [],
|
summary.get("client_percentages") or [],
|
||||||
@@ -595,7 +632,6 @@ def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int,
|
|||||||
summary["user"]["name"] if index == 0 else None,
|
summary["user"]["name"] if index == 0 else None,
|
||||||
locale.format_number(summary["user"]["mobile"], ascii_digits=True) if index == 0 else None,
|
locale.format_number(summary["user"]["mobile"], ascii_digits=True) if index == 0 else None,
|
||||||
locale.format_duration(summary["billable_duration"], ascii_digits=True) if index == 0 else None,
|
locale.format_duration(summary["billable_duration"], ascii_digits=True) if index == 0 else None,
|
||||||
locale.format_duration(summary["non_billable_duration"], ascii_digits=True) if index == 0 else None,
|
|
||||||
*(rate_rows[index] if index < len(rate_rows) else [None, None]),
|
*(rate_rows[index] if index < len(rate_rows) else [None, None]),
|
||||||
_money_label_excel(locale, summary["income_totals"]) if index == 0 else None,
|
_money_label_excel(locale, summary["income_totals"]) if index == 0 else None,
|
||||||
*(client_rows[index] if index < len(client_rows) else [None, None, None]),
|
*(client_rows[index] if index < len(client_rows) else [None, None, None]),
|
||||||
@@ -662,7 +698,7 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
worksheet,
|
worksheet,
|
||||||
row=15,
|
row=15,
|
||||||
start_col=1,
|
start_col=1,
|
||||||
end_col=16,
|
end_col=15,
|
||||||
value=locale.t("users_summary_sheet"),
|
value=locale.t("users_summary_sheet"),
|
||||||
rtl=locale.is_rtl,
|
rtl=locale.is_rtl,
|
||||||
)
|
)
|
||||||
@@ -670,7 +706,6 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
locale.t("name"),
|
locale.t("name"),
|
||||||
locale.t("mobile"),
|
locale.t("mobile"),
|
||||||
locale.t("working_hours"),
|
locale.t("working_hours"),
|
||||||
locale.t("non_working_hours"),
|
|
||||||
locale.t("hourly_rate"),
|
locale.t("hourly_rate"),
|
||||||
locale.t("period"),
|
locale.t("period"),
|
||||||
locale.t("income"),
|
locale.t("income"),
|
||||||
@@ -704,27 +739,27 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
values=values,
|
values=values,
|
||||||
rtl=locale.is_rtl,
|
rtl=locale.is_rtl,
|
||||||
)
|
)
|
||||||
for column in (1, 2, 3, 4, 7):
|
for column in (1, 2, 3, 6):
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=column)
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=column)
|
||||||
rate_rows = user_summary.get("rate_periods") or []
|
rate_rows = user_summary.get("rate_periods") or []
|
||||||
client_rows = user_summary.get("client_percentages") or []
|
client_rows = user_summary.get("client_percentages") or []
|
||||||
project_rows = user_summary.get("project_percentages") or []
|
project_rows = user_summary.get("project_percentages") or []
|
||||||
tag_rows = user_summary.get("tag_percentages") or []
|
tag_rows = user_summary.get("tag_percentages") or []
|
||||||
if len(rate_rows) == 1:
|
if len(rate_rows) == 1:
|
||||||
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=4, value_present=True)
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=5, value_present=True)
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=5, value_present=True)
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=6, value_present=True)
|
|
||||||
if len(client_rows) == 1:
|
if len(client_rows) == 1:
|
||||||
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=7, value_present=True)
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=8, value_present=True)
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=8, value_present=True)
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=9, value_present=True)
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=9, value_present=True)
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=10, value_present=True)
|
|
||||||
if len(project_rows) == 1:
|
if len(project_rows) == 1:
|
||||||
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=10, value_present=True)
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=11, value_present=True)
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=11, value_present=True)
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=12, value_present=True)
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=12, value_present=True)
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=13, value_present=True)
|
|
||||||
if len(tag_rows) == 1:
|
if len(tag_rows) == 1:
|
||||||
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=13, value_present=True)
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=14, value_present=True)
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=14, value_present=True)
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=15, value_present=True)
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=15, value_present=True)
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=16, value_present=True)
|
|
||||||
current_row += span
|
current_row += span
|
||||||
|
|
||||||
current_row += 2
|
current_row += 2
|
||||||
@@ -765,8 +800,6 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
locale.t("name"),
|
locale.t("name"),
|
||||||
locale.t("billable_hours"),
|
locale.t("billable_hours"),
|
||||||
locale.t("hour_percentage"),
|
locale.t("hour_percentage"),
|
||||||
locale.t("non_billable_hours"),
|
|
||||||
locale.t("total_hours"),
|
|
||||||
locale.t("income"),
|
locale.t("income"),
|
||||||
locale.t("income_percentage"),
|
locale.t("income_percentage"),
|
||||||
],
|
],
|
||||||
@@ -785,8 +818,6 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
row["name"],
|
row["name"],
|
||||||
locale.format_duration(row["billable_duration"], ascii_digits=True),
|
locale.format_duration(row["billable_duration"], ascii_digits=True),
|
||||||
_percentage_display(locale, hour_percentages or [], row, ascii_digits=True),
|
_percentage_display(locale, hour_percentages or [], row, ascii_digits=True),
|
||||||
locale.format_duration(row["non_billable_duration"], ascii_digits=True),
|
|
||||||
locale.format_duration(row["total_duration"], ascii_digits=True),
|
|
||||||
_money_label_excel(locale, row["income_totals"]),
|
_money_label_excel(locale, row["income_totals"]),
|
||||||
_percentage_display(locale, income_percentages or [], row, ascii_digits=True),
|
_percentage_display(locale, income_percentages or [], row, ascii_digits=True),
|
||||||
],
|
],
|
||||||
@@ -798,7 +829,7 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
worksheet,
|
worksheet,
|
||||||
row=current_row,
|
row=current_row,
|
||||||
start_col=1,
|
start_col=1,
|
||||||
values=[locale.t("no_data"), None, None, None, None, None, None],
|
values=[locale.t("no_data"), None, None, None, None],
|
||||||
rtl=locale.is_rtl,
|
rtl=locale.is_rtl,
|
||||||
)
|
)
|
||||||
current_row += 1
|
current_row += 1
|
||||||
@@ -808,25 +839,30 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
"A": 31.57,
|
"A": 31.57,
|
||||||
"B": 19.86,
|
"B": 19.86,
|
||||||
"C": 18.0,
|
"C": 18.0,
|
||||||
"D": 17.0,
|
"D": 18.0,
|
||||||
"E": 18.0,
|
"E": 26.0,
|
||||||
"F": 26.0,
|
"F": 24.0,
|
||||||
"G": 24.0,
|
"G": 28.0,
|
||||||
"H": 28.0,
|
"H": 14.0,
|
||||||
"I": 14.0,
|
"I": 16.0,
|
||||||
"J": 16.0,
|
"J": 28.0,
|
||||||
"K": 28.0,
|
"K": 14.0,
|
||||||
"L": 14.0,
|
"L": 16.0,
|
||||||
"M": 16.0,
|
"M": 24.0,
|
||||||
"N": 24.0,
|
"N": 14.0,
|
||||||
"O": 14.0,
|
"O": 16.0,
|
||||||
"P": 16.0,
|
|
||||||
}
|
}
|
||||||
for column, width in overall_widths.items():
|
for column, width in overall_widths.items():
|
||||||
worksheet.column_dimensions[column].width = width
|
worksheet.column_dimensions[column].width = width
|
||||||
|
|
||||||
|
|
||||||
def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -> None:
|
def _render_excel_sheet(
|
||||||
|
worksheet,
|
||||||
|
*,
|
||||||
|
locale: ExportLocale,
|
||||||
|
report_data: dict,
|
||||||
|
financial_only_breakdowns: bool = False,
|
||||||
|
) -> None:
|
||||||
if locale.is_rtl:
|
if locale.is_rtl:
|
||||||
worksheet.sheet_view.rightToLeft = True
|
worksheet.sheet_view.rightToLeft = True
|
||||||
worksheet.freeze_panes = "E4"
|
worksheet.freeze_panes = "E4"
|
||||||
@@ -860,6 +896,7 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -
|
|||||||
if user_summary
|
if user_summary
|
||||||
else report_data.get("client_income_percentages")
|
else report_data.get("client_income_percentages")
|
||||||
),
|
),
|
||||||
|
financial_only=financial_only_breakdowns,
|
||||||
)
|
)
|
||||||
_append_breakdown_table(
|
_append_breakdown_table(
|
||||||
worksheet,
|
worksheet,
|
||||||
@@ -876,6 +913,7 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -
|
|||||||
if user_summary
|
if user_summary
|
||||||
else report_data.get("project_income_percentages")
|
else report_data.get("project_income_percentages")
|
||||||
),
|
),
|
||||||
|
financial_only=financial_only_breakdowns,
|
||||||
)
|
)
|
||||||
_append_breakdown_table(
|
_append_breakdown_table(
|
||||||
worksheet,
|
worksheet,
|
||||||
@@ -892,6 +930,7 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -
|
|||||||
if user_summary
|
if user_summary
|
||||||
else report_data.get("tag_income_percentages")
|
else report_data.get("tag_income_percentages")
|
||||||
),
|
),
|
||||||
|
financial_only=financial_only_breakdowns,
|
||||||
)
|
)
|
||||||
_autosize_columns(worksheet)
|
_autosize_columns(worksheet)
|
||||||
|
|
||||||
@@ -935,7 +974,12 @@ def build_excel_report(*, report_data: dict, locale: ExportLocale, per_user_repo
|
|||||||
used_titles,
|
used_titles,
|
||||||
)
|
)
|
||||||
worksheet = workbook.create_sheet(title=user_title)
|
worksheet = workbook.create_sheet(title=user_title)
|
||||||
_render_excel_sheet(worksheet, locale=locale, report_data=user_report)
|
_render_excel_sheet(
|
||||||
|
worksheet,
|
||||||
|
locale=locale,
|
||||||
|
report_data=user_report,
|
||||||
|
financial_only_breakdowns=True,
|
||||||
|
)
|
||||||
used_titles.add(user_title)
|
used_titles.add(user_title)
|
||||||
else:
|
else:
|
||||||
overall_sheet = workbook.active
|
overall_sheet = workbook.active
|
||||||
@@ -1048,7 +1092,7 @@ def _build_pdf_rate_history_table(locale: ExportLocale, summary: dict, doc_width
|
|||||||
[
|
[
|
||||||
_rate_period_label(locale, row),
|
_rate_period_label(locale, row),
|
||||||
locale.format_date(row["from_date"]),
|
locale.format_date(row["from_date"]),
|
||||||
locale.format_date(row["to_date"]),
|
_rate_to_label(locale, row.get("to_date")),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
for row in rows
|
for row in rows
|
||||||
@@ -1056,7 +1100,15 @@ def _build_pdf_rate_history_table(locale: ExportLocale, summary: dict, doc_width
|
|||||||
]
|
]
|
||||||
if not rows:
|
if not rows:
|
||||||
data.append(_rtl_row(locale, [locale.t("no_data"), "", ""]))
|
data.append(_rtl_row(locale, [locale.t("no_data"), "", ""]))
|
||||||
return _styled_table(data, locale=locale, column_widths=[doc_width * 0.34, doc_width * 0.33, doc_width * 0.33])
|
fixed_widths = [doc_width * 0.18, doc_width * 0.18]
|
||||||
|
column_widths = [doc_width - sum(fixed_widths), *fixed_widths]
|
||||||
|
if locale.is_rtl:
|
||||||
|
column_widths = list(reversed(column_widths))
|
||||||
|
return _styled_table(
|
||||||
|
data,
|
||||||
|
locale=locale,
|
||||||
|
column_widths=column_widths,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_pdf_percentage_table(locale: ExportLocale, rows: list[dict], doc_width: float) -> Table:
|
def _build_pdf_percentage_table(locale: ExportLocale, rows: list[dict], doc_width: float) -> Table:
|
||||||
@@ -1083,6 +1135,7 @@ def _append_pdf_report_sections(
|
|||||||
doc_width: float,
|
doc_width: float,
|
||||||
section_style: ParagraphStyle,
|
section_style: ParagraphStyle,
|
||||||
user_summary: dict | None = None,
|
user_summary: dict | None = None,
|
||||||
|
financial_only_breakdowns: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
sections = [
|
sections = [
|
||||||
("daily_summary", report_data["days"], True),
|
("daily_summary", report_data["days"], True),
|
||||||
@@ -1107,8 +1160,7 @@ def _append_pdf_report_sections(
|
|||||||
locale.t("name"),
|
locale.t("name"),
|
||||||
locale.t("billable_hours"),
|
locale.t("billable_hours"),
|
||||||
locale.t("hour_percentage"),
|
locale.t("hour_percentage"),
|
||||||
locale.t("non_billable_hours"),
|
*( [] if financial_only_breakdowns else [locale.t("non_billable_hours"), locale.t("total_hours")] ),
|
||||||
locale.t("total_hours"),
|
|
||||||
locale.t("income"),
|
locale.t("income"),
|
||||||
locale.t("income_percentage"),
|
locale.t("income_percentage"),
|
||||||
]
|
]
|
||||||
@@ -1129,19 +1181,27 @@ def _append_pdf_report_sections(
|
|||||||
row["name"],
|
row["name"],
|
||||||
locale.format_duration(row["billable_duration"]),
|
locale.format_duration(row["billable_duration"]),
|
||||||
_percentage_display(locale, hour_percentage_rows, row),
|
_percentage_display(locale, hour_percentage_rows, row),
|
||||||
|
*(
|
||||||
|
[]
|
||||||
|
if financial_only_breakdowns
|
||||||
|
else [
|
||||||
locale.format_duration(row["non_billable_duration"]),
|
locale.format_duration(row["non_billable_duration"]),
|
||||||
locale.format_duration(row["total_duration"]),
|
locale.format_duration(row["total_duration"]),
|
||||||
|
]
|
||||||
|
),
|
||||||
_money_label(locale, row["income_totals"]),
|
_money_label(locale, row["income_totals"]),
|
||||||
_percentage_display(locale, income_percentage_rows or [], row),
|
_percentage_display(locale, income_percentage_rows or [], row),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
for row in rows
|
for row in rows
|
||||||
] or [_rtl_row(locale, [locale.t("no_data"), "", "", "", "", "", ""])]
|
] or [
|
||||||
table = _styled_table(
|
_rtl_row(
|
||||||
[header, *body_rows],
|
locale,
|
||||||
locale=locale,
|
[locale.t("no_data"), "", "", *( [] if financial_only_breakdowns else ["", ""] ), "", ""],
|
||||||
column_widths=(
|
)
|
||||||
[
|
]
|
||||||
|
if is_daily:
|
||||||
|
column_widths = [
|
||||||
doc_width * 0.20,
|
doc_width * 0.20,
|
||||||
doc_width * 0.12,
|
doc_width * 0.12,
|
||||||
doc_width * 0.15,
|
doc_width * 0.15,
|
||||||
@@ -1149,11 +1209,16 @@ def _append_pdf_report_sections(
|
|||||||
doc_width * 0.16,
|
doc_width * 0.16,
|
||||||
doc_width * 0.24,
|
doc_width * 0.24,
|
||||||
]
|
]
|
||||||
if is_daily
|
elif hour_percentage_rows is not None:
|
||||||
else [
|
fixed_widths = (
|
||||||
*(
|
|
||||||
[
|
[
|
||||||
doc_width * 0.20,
|
doc_width * 0.11,
|
||||||
|
doc_width * 0.11,
|
||||||
|
doc_width * 0.19,
|
||||||
|
doc_width * 0.15,
|
||||||
|
]
|
||||||
|
if financial_only_breakdowns
|
||||||
|
else [
|
||||||
doc_width * 0.11,
|
doc_width * 0.11,
|
||||||
doc_width * 0.11,
|
doc_width * 0.11,
|
||||||
doc_width * 0.12,
|
doc_width * 0.12,
|
||||||
@@ -1161,17 +1226,24 @@ def _append_pdf_report_sections(
|
|||||||
doc_width * 0.19,
|
doc_width * 0.19,
|
||||||
doc_width * 0.15,
|
doc_width * 0.15,
|
||||||
]
|
]
|
||||||
if hour_percentage_rows is not None
|
)
|
||||||
else [
|
column_widths = [doc_width - sum(fixed_widths), *fixed_widths]
|
||||||
doc_width * 0.13,
|
if locale.is_rtl:
|
||||||
|
column_widths = list(reversed(column_widths))
|
||||||
|
else:
|
||||||
|
fixed_widths = [
|
||||||
doc_width * 0.15,
|
doc_width * 0.15,
|
||||||
doc_width * 0.17,
|
doc_width * 0.17,
|
||||||
doc_width * 0.14,
|
doc_width * 0.14,
|
||||||
doc_width * 0.28,
|
doc_width * 0.28,
|
||||||
]
|
]
|
||||||
)
|
column_widths = [doc_width - sum(fixed_widths), *fixed_widths]
|
||||||
]
|
if locale.is_rtl:
|
||||||
),
|
column_widths = list(reversed(column_widths))
|
||||||
|
table = _styled_table(
|
||||||
|
[header, *body_rows],
|
||||||
|
locale=locale,
|
||||||
|
column_widths=column_widths,
|
||||||
)
|
)
|
||||||
story.extend([table, Spacer(1, 5 * mm)])
|
story.extend([table, Spacer(1, 5 * mm)])
|
||||||
|
|
||||||
@@ -1313,9 +1385,7 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
|
|||||||
locale.t("name"),
|
locale.t("name"),
|
||||||
locale.t("mobile"),
|
locale.t("mobile"),
|
||||||
locale.t("working_hours"),
|
locale.t("working_hours"),
|
||||||
locale.t("non_working_hours"),
|
|
||||||
locale.t("hourly_rate"),
|
locale.t("hourly_rate"),
|
||||||
locale.t("period"),
|
|
||||||
locale.t("income"),
|
locale.t("income"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -1326,9 +1396,7 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
|
|||||||
summary["user"]["name"],
|
summary["user"]["name"],
|
||||||
locale.format_number(summary["user"]["mobile"]),
|
locale.format_number(summary["user"]["mobile"]),
|
||||||
locale.format_duration(summary["billable_duration"]),
|
locale.format_duration(summary["billable_duration"]),
|
||||||
locale.format_duration(summary["non_billable_duration"]),
|
_pdf_summary_rate_label(locale, summary.get("hourly_rates") or []),
|
||||||
_rates_label(locale, summary.get("hourly_rates") or []),
|
|
||||||
_summary_period_label(locale, summary.get("rate_periods") or []),
|
|
||||||
_money_label(locale, summary["income_totals"]),
|
_money_label(locale, summary["income_totals"]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -1339,13 +1407,11 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
|
|||||||
[user_summary_header, *user_summary_rows],
|
[user_summary_header, *user_summary_rows],
|
||||||
locale=locale,
|
locale=locale,
|
||||||
column_widths=[
|
column_widths=[
|
||||||
doc.width * 0.18,
|
doc.width * 0.25,
|
||||||
doc.width * 0.13,
|
|
||||||
doc.width * 0.13,
|
|
||||||
doc.width * 0.13,
|
|
||||||
doc.width * 0.13,
|
|
||||||
doc.width * 0.16,
|
doc.width * 0.16,
|
||||||
doc.width * 0.14,
|
doc.width * 0.16,
|
||||||
|
doc.width * 0.19,
|
||||||
|
doc.width * 0.24,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1379,6 +1445,7 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
|
|||||||
doc_width=doc.width,
|
doc_width=doc.width,
|
||||||
section_style=section_style,
|
section_style=section_style,
|
||||||
user_summary=user_report.get("user_summary"),
|
user_summary=user_report.get("user_summary"),
|
||||||
|
financial_only_breakdowns=True,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_append_pdf_report_sections(
|
_append_pdf_report_sections(
|
||||||
@@ -1388,6 +1455,7 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
|
|||||||
doc_width=doc.width,
|
doc_width=doc.width,
|
||||||
section_style=section_style,
|
section_style=section_style,
|
||||||
user_summary=report_data.get("user_summary"),
|
user_summary=report_data.get("user_summary"),
|
||||||
|
financial_only_breakdowns=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
doc.build(story)
|
doc.build(story)
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ from django.test import TestCase
|
|||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
from apps.reports.services.export_i18n import build_export_locale
|
from apps.reports.services.export_i18n import build_export_locale
|
||||||
from apps.reports.services.exporters import build_excel_report, build_pdf_report
|
from apps.reports.services.exporters import (
|
||||||
|
_pdf_summary_rate_label,
|
||||||
|
_rate_label,
|
||||||
|
build_excel_report,
|
||||||
|
build_pdf_report,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def make_report_data(*, user_name="Owner User", mobile="09129990001", hourly_rate=None):
|
def make_report_data(*, user_name="Owner User", mobile="09129990001", hourly_rate=None):
|
||||||
@@ -74,14 +79,67 @@ def make_user_summary(*, name: str, mobile: str):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_variable_user_summary(*, name: str, mobile: str):
|
||||||
|
summary = make_user_summary(name=name, mobile=mobile)
|
||||||
|
summary["hourly_rates"] = [
|
||||||
|
{"amount": "15.00", "currency": "USD"},
|
||||||
|
{"amount": "18.00", "currency": "USD"},
|
||||||
|
]
|
||||||
|
summary["rate_periods"] = [
|
||||||
|
{
|
||||||
|
"amount": "15.00",
|
||||||
|
"currency": "USD",
|
||||||
|
"from_date": "2026-04-01",
|
||||||
|
"to_date": "2026-04-14",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"amount": "18.00",
|
||||||
|
"currency": "USD",
|
||||||
|
"from_date": "2026-04-15",
|
||||||
|
"to_date": "2026-04-30",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
class ReportExporterTests(TestCase):
|
class ReportExporterTests(TestCase):
|
||||||
|
def test_export_rate_labels_trim_rial_and_toman_decimals(self):
|
||||||
|
locale = build_export_locale("en")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
_rate_label(locale, {"amount": "1250.75", "currency": "USD"}),
|
||||||
|
"1,250.75 USD",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
_rate_label(locale, {"amount": "1250.75", "currency": "IRR"}),
|
||||||
|
"1,250 IRR",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
_rate_label(locale, {"amount": "9800.50", "currency": "IRT"}),
|
||||||
|
"9,800 IRT",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pdf_summary_uses_multiple_rates_label(self):
|
||||||
|
locale = build_export_locale("en")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
_pdf_summary_rate_label(
|
||||||
|
locale,
|
||||||
|
[
|
||||||
|
{"amount": "15.00", "currency": "USD"},
|
||||||
|
{"amount": "18.00", "currency": "USD"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"Variable rate",
|
||||||
|
)
|
||||||
|
|
||||||
def test_excel_export_adds_per_user_sheets_and_daily_rate_column(self):
|
def test_excel_export_adds_per_user_sheets_and_daily_rate_column(self):
|
||||||
locale = build_export_locale("en")
|
locale = build_export_locale("en")
|
||||||
report_data = make_report_data(
|
report_data = make_report_data(
|
||||||
hourly_rate={"amount": "15.00", "currency": "USD"},
|
hourly_rate={"amount": "15.00", "currency": "USD"},
|
||||||
)
|
)
|
||||||
report_data["user_summaries"] = [
|
report_data["user_summaries"] = [
|
||||||
make_user_summary(name="Owner User", mobile="09129990001"),
|
make_variable_user_summary(name="Owner User", mobile="09129990001"),
|
||||||
make_user_summary(name="Team Mate", mobile="09129990002"),
|
make_user_summary(name="Team Mate", mobile="09129990002"),
|
||||||
]
|
]
|
||||||
per_user_reports = [
|
per_user_reports = [
|
||||||
@@ -91,7 +149,7 @@ class ReportExporterTests(TestCase):
|
|||||||
mobile="09129990001",
|
mobile="09129990001",
|
||||||
hourly_rate={"amount": "15.00", "currency": "USD"},
|
hourly_rate={"amount": "15.00", "currency": "USD"},
|
||||||
),
|
),
|
||||||
"user_summary": make_user_summary(name="Owner User", mobile="09129990001"),
|
"user_summary": make_variable_user_summary(name="Owner User", mobile="09129990001"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
**make_report_data(
|
**make_report_data(
|
||||||
@@ -123,14 +181,13 @@ class ReportExporterTests(TestCase):
|
|||||||
self.assertEqual(summary_sheet["A1"].value, "Workspace Report")
|
self.assertEqual(summary_sheet["A1"].value, "Workspace Report")
|
||||||
self.assertEqual(summary_sheet["B1"].value, "Exports")
|
self.assertEqual(summary_sheet["B1"].value, "Exports")
|
||||||
self.assertEqual(summary_sheet["A15"].value, "Users Summary")
|
self.assertEqual(summary_sheet["A15"].value, "Users Summary")
|
||||||
self.assertIn("A15:P15", {str(item) for item in summary_sheet.merged_cells.ranges})
|
self.assertIn("A15:O15", {str(item) for item in summary_sheet.merged_cells.ranges})
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:16],
|
tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:15],
|
||||||
(
|
(
|
||||||
"Name",
|
"Name",
|
||||||
"Mobile",
|
"Mobile",
|
||||||
"Working hours",
|
"Working hours",
|
||||||
"Non-working hours",
|
|
||||||
"Hourly rate",
|
"Hourly rate",
|
||||||
"Period",
|
"Period",
|
||||||
"Income",
|
"Income",
|
||||||
@@ -147,6 +204,7 @@ class ReportExporterTests(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertTrue(any(row and "Owner User" in row for row in summary_values))
|
self.assertTrue(any(row and "Owner User" in row for row in summary_values))
|
||||||
self.assertTrue(any(row and "09129990001" in row for row in summary_values))
|
self.assertTrue(any(row and "09129990001" in row for row in summary_values))
|
||||||
|
self.assertTrue(any(row and "Variable rate" in row for row in summary_values))
|
||||||
|
|
||||||
user_sheet = workbook[workbook.sheetnames[1]]
|
user_sheet = workbook[workbook.sheetnames[1]]
|
||||||
user_values = list(user_sheet.iter_rows(values_only=True))
|
user_values = list(user_sheet.iter_rows(values_only=True))
|
||||||
@@ -169,13 +227,11 @@ class ReportExporterTests(TestCase):
|
|||||||
|
|
||||||
breakdown_header = next(row[:7] for row in user_values if row and row[0] == "Name" and row[2] == "Hour %")
|
breakdown_header = next(row[:7] for row in user_values if row and row[0] == "Name" and row[2] == "Hour %")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
breakdown_header,
|
breakdown_header[:5],
|
||||||
(
|
(
|
||||||
"Name",
|
"Name",
|
||||||
"Billable hours",
|
"Billable hours",
|
||||||
"Hour %",
|
"Hour %",
|
||||||
"Non-billable hours",
|
|
||||||
"Total hours",
|
|
||||||
"Income",
|
"Income",
|
||||||
"Income %",
|
"Income %",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from apps.projects.models import Project
|
|||||||
from apps.tags.models import Tag
|
from apps.tags.models import Tag
|
||||||
from apps.time_entries.models import TimeEntry
|
from apps.time_entries.models import TimeEntry
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate
|
||||||
|
|
||||||
|
|
||||||
class ReportViewTests(APITestCase):
|
class ReportViewTests(APITestCase):
|
||||||
@@ -320,6 +320,75 @@ class ReportViewTests(APITestCase):
|
|||||||
{"amount": "35.00", "currency": "USD"},
|
{"amount": "35.00", "currency": "USD"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_user_summary_endpoint_keeps_workspace_rate_history_and_marks_current_row_open(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
TimeEntry.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.owner,
|
||||||
|
project=None,
|
||||||
|
description="Legacy workspace rate",
|
||||||
|
start_time="2026-04-08T08:00:00+03:30",
|
||||||
|
end_time="2026-04-08T09:00:00+03:30",
|
||||||
|
duration=timedelta(hours=1),
|
||||||
|
is_billable=True,
|
||||||
|
hourly_rate=Decimal("12.00"),
|
||||||
|
currency="USD",
|
||||||
|
)
|
||||||
|
TimeEntry.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.owner,
|
||||||
|
project=self.project,
|
||||||
|
description="Current project rate",
|
||||||
|
start_time="2026-04-12T08:00:00+03:30",
|
||||||
|
end_time="2026-04-12T10:00:00+03:30",
|
||||||
|
duration=timedelta(hours=2),
|
||||||
|
is_billable=True,
|
||||||
|
hourly_rate=Decimal("25.00"),
|
||||||
|
currency="USD",
|
||||||
|
)
|
||||||
|
WorkspaceUserRate.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.owner,
|
||||||
|
hourly_rate=Decimal("12.00"),
|
||||||
|
currency="USD",
|
||||||
|
effective_from="2026-04-01T00:00:00+03:30",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
|
return_value=date(2026, 4, 20),
|
||||||
|
):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/reports/user-summary/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"period": "this_month",
|
||||||
|
"user": str(self.owner.id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
rate_periods = response.data["user_summary"]["rate_periods"]
|
||||||
|
self.assertEqual(
|
||||||
|
rate_periods,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"amount": "12.00",
|
||||||
|
"currency": "USD",
|
||||||
|
"from_date": "2026-04-08",
|
||||||
|
"to_date": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"amount": "25.00",
|
||||||
|
"currency": "USD",
|
||||||
|
"from_date": "2026-04-10",
|
||||||
|
"to_date": "2026-04-12",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def test_custom_period_longer_than_31_days_is_rejected(self):
|
def test_custom_period_longer_than_31_days_is_rejected(self):
|
||||||
self.client.force_authenticate(user=self.owner)
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +4,7 @@ from rest_framework import status
|
|||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||||
|
from rest_framework.decorators import action
|
||||||
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
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
@@ -15,6 +16,8 @@ from apps.notifications.services import (
|
|||||||
notify_workspace_membership_removed,
|
notify_workspace_membership_removed,
|
||||||
notify_workspace_membership_role_changed,
|
notify_workspace_membership_role_changed,
|
||||||
)
|
)
|
||||||
|
from apps.projects.models import ProjectUserRate
|
||||||
|
from apps.projects.services.access import filter_projects_for_user
|
||||||
from apps.workspaces.api.permissions import (
|
from apps.workspaces.api.permissions import (
|
||||||
CanWorkspaceManageMembers,
|
CanWorkspaceManageMembers,
|
||||||
IsWorkspaceAdmin,
|
IsWorkspaceAdmin,
|
||||||
@@ -78,6 +81,8 @@ class WorkspaceViewSet(ModelViewSet):
|
|||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
if self.action in ["list", "retrieve"]:
|
if self.action in ["list", "retrieve"]:
|
||||||
return [IsAuthenticated(), IsWorkspaceMember()]
|
return [IsAuthenticated(), IsWorkspaceMember()]
|
||||||
|
if self.action == "my_rates":
|
||||||
|
return [IsAuthenticated()]
|
||||||
if self.action in ["update", "partial_update"]:
|
if self.action in ["update", "partial_update"]:
|
||||||
return [IsAuthenticated(), IsWorkspaceAdmin()]
|
return [IsAuthenticated(), IsWorkspaceAdmin()]
|
||||||
|
|
||||||
@@ -89,6 +94,84 @@ class WorkspaceViewSet(ModelViewSet):
|
|||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
serializer.save(owner=self.request.user)
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get"], url_path="my-rates")
|
||||||
|
def my_rates(self, request, pk=None):
|
||||||
|
workspace = self.get_object()
|
||||||
|
if not has_workspace_capability(request.user, workspace, WORKSPACE_VIEW):
|
||||||
|
raise PermissionDenied("You do not have access to this workspace.")
|
||||||
|
|
||||||
|
def serialize_rate(rate):
|
||||||
|
if not rate:
|
||||||
|
return None
|
||||||
|
unit = PriceUnit.objects.filter(code=rate.currency, is_deleted=False).first()
|
||||||
|
return {
|
||||||
|
"id": str(rate.id),
|
||||||
|
"hourly_rate": str(rate.hourly_rate),
|
||||||
|
"currency": rate.currency,
|
||||||
|
"price_unit": PriceUnitSerializer(unit).data if unit else None,
|
||||||
|
"effective_from": rate.effective_from.isoformat() if rate.effective_from else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace_rate = (
|
||||||
|
WorkspaceUserRate.objects.filter(
|
||||||
|
workspace=workspace,
|
||||||
|
user=request.user,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
.order_by("-effective_from", "-updated_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
accessible_projects = list(
|
||||||
|
filter_projects_for_user(
|
||||||
|
request.user,
|
||||||
|
workspace.projects.filter(is_deleted=False).select_related("client"),
|
||||||
|
).order_by("client__name", "name")
|
||||||
|
)
|
||||||
|
accessible_project_ids = [project.id for project in accessible_projects]
|
||||||
|
project_rates_by_project_id = {}
|
||||||
|
for rate in (
|
||||||
|
ProjectUserRate.objects.filter(
|
||||||
|
project_id__in=accessible_project_ids,
|
||||||
|
user=request.user,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
.select_related("project", "project__client")
|
||||||
|
.order_by("project_id", "-effective_from", "-updated_at")
|
||||||
|
):
|
||||||
|
project_rates_by_project_id.setdefault(str(rate.project_id), rate)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"workspace": {
|
||||||
|
"id": str(workspace.id),
|
||||||
|
"name": workspace.name,
|
||||||
|
},
|
||||||
|
"workspace_rate": serialize_rate(workspace_rate),
|
||||||
|
"accessible_project_count": len(accessible_projects),
|
||||||
|
"project_rates": [
|
||||||
|
{
|
||||||
|
"project": {
|
||||||
|
"id": str(project.id),
|
||||||
|
"name": project.name,
|
||||||
|
"client": (
|
||||||
|
{"id": str(project.client_id), "name": project.client.name}
|
||||||
|
if project.client_id and project.client
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"rate": serialize_rate(project_rates_by_project_id[str(project.id)]),
|
||||||
|
}
|
||||||
|
for project in accessible_projects
|
||||||
|
if str(project.id) in project_rates_by_project_id
|
||||||
|
],
|
||||||
|
}
|
||||||
|
payload["project_override_count"] = len(payload["project_rates"])
|
||||||
|
payload["workspace_fallback_project_count"] = max(
|
||||||
|
payload["accessible_project_count"] - payload["project_override_count"],
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
return Response(payload)
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceMembershipViewSet(ModelViewSet):
|
class WorkspaceMembershipViewSet(ModelViewSet):
|
||||||
serializer_class = WorkspaceMembershipSerializer
|
serializer_class = WorkspaceMembershipSerializer
|
||||||
|
|||||||
@@ -4,6 +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, ProjectAccess, ProjectUserRate
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.api.permissions import (
|
from apps.workspaces.api.permissions import (
|
||||||
CanWorkspaceManageMembers,
|
CanWorkspaceManageMembers,
|
||||||
@@ -11,7 +12,7 @@ from apps.workspaces.api.permissions import (
|
|||||||
IsWorkspaceMember,
|
IsWorkspaceMember,
|
||||||
IsWorkspaceOwner,
|
IsWorkspaceOwner,
|
||||||
)
|
)
|
||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
|
||||||
|
|
||||||
|
|
||||||
class WorkspacePermissionTests(TestCase):
|
class WorkspacePermissionTests(TestCase):
|
||||||
@@ -189,3 +190,48 @@ class WorkspaceMembershipCacheTests(APITestCase):
|
|||||||
target = next(item for item in fresh_response.data["items"] if item["id"] == str(self.membership.id))
|
target = next(item for item in fresh_response.data["items"] if item["id"] == str(self.membership.id))
|
||||||
self.assertEqual(target["role"], WorkspaceMembership.Role.GUEST)
|
self.assertEqual(target["role"], WorkspaceMembership.Role.GUEST)
|
||||||
self.assertFalse(target["is_active"])
|
self.assertFalse(target["is_active"])
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceMyRatesApiTests(APITestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.owner = User.objects.create_user(mobile="09127770101", password="secret123")
|
||||||
|
cls.member = User.objects.create_user(mobile="09127770102", password="secret123")
|
||||||
|
cls.workspace = Workspace.objects.create(name="Rates View", owner=cls.owner)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
user=cls.member,
|
||||||
|
role=WorkspaceMembership.Role.MEMBER,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
PriceUnit.objects.create(code="USD", name="US Dollar", local_name="Dollar", symbol="$")
|
||||||
|
cls.project = Project.objects.create(workspace=cls.workspace, name="Mobile App")
|
||||||
|
ProjectAccess.objects.create(project=cls.project, user=cls.member)
|
||||||
|
WorkspaceUserRate.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
user=cls.member,
|
||||||
|
hourly_rate="10.00",
|
||||||
|
currency="USD",
|
||||||
|
effective_from=cls.workspace.created_at,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
ProjectUserRate.objects.create(
|
||||||
|
project=cls.project,
|
||||||
|
user=cls.member,
|
||||||
|
hourly_rate="18.00",
|
||||||
|
currency="USD",
|
||||||
|
effective_from=cls.workspace.created_at,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_member_can_view_own_workspace_and_project_rates(self):
|
||||||
|
self.client.force_authenticate(user=self.member)
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/workspaces/{self.workspace.id}/my-rates/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["workspace_rate"]["hourly_rate"], "10.00")
|
||||||
|
self.assertEqual(response.data["project_override_count"], 1)
|
||||||
|
self.assertEqual(response.data["workspace_fallback_project_count"], 0)
|
||||||
|
self.assertEqual(response.data["project_rates"][0]["project"]["name"], "Mobile App")
|
||||||
|
self.assertEqual(response.data["project_rates"][0]["rate"]["hourly_rate"], "18.00")
|
||||||
|
|||||||
@@ -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