Compare commits

...

4 Commits

Author SHA1 Message Date
22e08a099c fix(reports): refine financial export summaries
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-23 20:13:35 +03:30
59cf62bc73 feat(reports): load user summaries on demand 2026-05-23 19:48:32 +03:30
0d6c6a4f09 feat(workspaces): add current user rates endpoint 2026-05-23 19:43:10 +03:30
181a135df9 feat(projects): add project-specific member rates 2026-05-23 18:29:00 +03:30
18 changed files with 946 additions and 149 deletions

View File

@@ -1,6 +1,9 @@
from decimal import Decimal
from rest_framework import serializers
from core.serializers.base import BaseModelSerializer
from apps.projects.models import Project
from apps.workspaces.models import PriceUnit
class ProjectSerializer(BaseModelSerializer):
@@ -54,3 +57,23 @@ class ProjectAccessMutationSerializer(serializers.Serializer):
child=serializers.UUIDField(),
allow_empty=False,
)
class ProjectAccessRateMutationSerializer(serializers.Serializer):
workspace = serializers.UUIDField()
user = serializers.UUIDField()
project = serializers.UUIDField()
hourly_rate = serializers.DecimalField(
max_digits=10,
decimal_places=2,
min_value=Decimal("0.01"),
required=False,
allow_null=True,
)
currency = serializers.CharField(max_length=3, required=False, default="USD")
def validate_currency(self, value):
code = value.upper()
if not PriceUnit.objects.filter(code=code, is_deleted=False).exists():
raise serializers.ValidationError("Selected price unit is invalid.")
return code

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ from apps.reports.api.views import (
ReportDayDetailsView,
ReportExportJobViewSet,
ReportTableView,
ReportUserSummaryView,
)
router = DefaultRouter()
@@ -15,6 +16,6 @@ urlpatterns = [
path("chart/", ReportChartView.as_view(), name="report-chart"),
path("table/", ReportTableView.as_view(), name="report-table"),
path("day-details/", ReportDayDetailsView.as_view(), name="report-day-details"),
path("user-summary/", ReportUserSummaryView.as_view(), name="report-user-summary"),
path("", include(router.urls)),
]

View File

@@ -20,6 +20,7 @@ from apps.reports.services import (
build_chart_report,
build_day_details_report,
build_table_report,
build_user_summary_report,
load_report_filters,
)
from apps.reports.tasks import generate_report_export_task
@@ -83,6 +84,24 @@ class ReportDayDetailsView(APIView):
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(
mixins.CreateModelMixin,
mixins.ListModelMixin,

View File

@@ -2,6 +2,7 @@ from apps.reports.services.aggregation import (
build_chart_report,
build_day_details_report,
build_table_report,
build_user_summary_report,
build_user_scoped_table_reports,
load_report_filters,
)
@@ -10,6 +11,7 @@ __all__ = [
"load_report_filters",
"build_chart_report",
"build_table_report",
"build_user_summary_report",
"build_user_scoped_table_reports",
"build_day_details_report",
]

View File

@@ -19,6 +19,7 @@ from apps.projects.services.access import user_has_project_access
from apps.tags.models import Tag
from apps.time_entries.models import TimeEntry
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
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()
for entry in entries:
if not entry.hourly_rate:
continue
unique_rates.add((f"{Decimal(entry.hourly_rate).quantize(Decimal('0.01'))}", entry.currency or "USD"))
for row in rate_rows:
unique_rates.add((row["amount"], row["currency"]))
return [
{"amount": amount, "currency": currency}
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]:
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] = []
current: dict | None = None
for entry in sorted_entries:
if not entry.hourly_rate:
if not entry.hourly_rate or not entry.start_time:
continue
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
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:
resolved_language = language if language in UNCATEGORIZED_LABELS else "en"
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:
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)
client_rows = _build_breakdown(entries, "clients", 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),
"mobile": user.mobile,
},
"hourly_rates": _serialize_distinct_rates(entries),
"rate_periods": _serialize_rate_periods(entries),
"hourly_rates": _serialize_distinct_rates_from_rows(rate_rows),
"rate_periods": rate_rows,
"total_seconds": summary["billable_seconds"],
"total_duration": summary["total_duration"],
"billable_seconds": summary["billable_seconds"],
@@ -988,11 +1042,12 @@ def build_table_report(actor, raw_filters) -> dict:
filters = load_report_filters(actor, raw_filters)
entries = list(_base_queryset(filters))
if filters.is_workspace_scope and not filters.user_id:
return _table_report_payload(
payload = _table_report_payload(
filters,
entries,
user_summaries=_build_user_summaries(entries, language=filters.language),
)
return payload
user_summary = (
_build_user_summary(entries[0].user, entries, language=filters.language)
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)
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]:
filters = load_report_filters(actor, raw_filters)
if not (filters.is_workspace_scope and not filters.user_id):

View File

@@ -49,9 +49,13 @@ TRANSLATIONS = {
"rate_history": "Hourly rate history",
"from": "From",
"to": "To",
"now": "Now",
"project": "Project",
"percentage": "Percentage",
"hour_percentage": "Hour %",
"income_percentage": "Income %",
"multiple_rates": "Multiple rates - see details",
"variable_rate": "Variable rate",
"none": "None",
"daily_summary": "Daily Summary",
"clients": "Clients",
@@ -93,9 +97,13 @@ TRANSLATIONS = {
"rate_history": "\u062a\u0627\u0631\u06cc\u062e\u0686\u0647 \u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc",
"from": "\u0627\u0632",
"to": "\u062a\u0627",
"now": "\u062d\u0627\u0644",
"project": "\u067e\u0631\u0648\u0698\u0647",
"percentage": "\u062f\u0631\u0635\u062f",
"hour_percentage": "\u062f\u0631\u0635\u062f \u0633\u0627\u0639\u062a",
"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",
"daily_summary": "\u062e\u0644\u0627\u0635\u0647 \u0631\u0648\u0632\u0627\u0646\u0647",
"clients": "\u0645\u0634\u062a\u0631\u06cc\u0627\u0646",
@@ -140,6 +148,8 @@ CURRENCY_LABELS = {
"TRY": {"en": "TRY", "fa": "\u0644\u06cc\u0631"},
}
DECIMAL_TRIM_CURRENCIES = {"IRR", "IRT"}
@dataclass(frozen=True)
class ExportLocale:
@@ -174,6 +184,15 @@ class ExportLocale:
return self.format_number(value, ascii_digits=ascii_digits)
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()
if not raw:
return raw
@@ -189,7 +208,11 @@ class ExportLocale:
grouped_integer = f"{int(integer_part):,}"
formatted = f"{sign}{grouped_integer}"
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:
formatted = f"{formatted}.{trimmed_fraction}"
return self.format_number(formatted, ascii_digits=ascii_digits)
@@ -200,7 +223,9 @@ class ExportLocale:
parts = []
for item in income_totals:
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)
def currency_label(self, code: str | None) -> str:

View File

@@ -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:
if not rates:
return locale.t("none")
return "-"
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
]
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:
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'])}"
)
@@ -104,16 +104,43 @@ def _rate_period_label(locale: ExportLocale, row: dict, *, ascii_digits: bool =
def _rate_label(locale: ExportLocale, rate: dict | None) -> str:
if not rate:
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:
if not rate:
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
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]:
headers = [
locale.t("name"),
@@ -227,12 +254,19 @@ def _summary_period_label(locale: ExportLocale, rate_periods: list[dict], *, asc
first_row = rate_periods[0]
last_row = rate_periods[-1]
last_to_date = last_row.get("to_date")
return (
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:
worksheet.append([])
_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),
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],
hour_percentages: list[dict] | None = None,
income_percentages: list[dict] | None = None,
financial_only: bool = False,
) -> None:
worksheet.append([])
_append_merged_heading(
worksheet,
locale=locale,
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
headers = [
locale.t("name"),
locale.t("billable_hours"),
*( [locale.t("hour_percentage")] if hour_percentages is not None else [] ),
locale.t("non_billable_hours"),
locale.t("total_hours"),
*( [] if financial_only else [locale.t("non_billable_hours"), locale.t("total_hours")] ),
locale.t("income"),
*( [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
else []
),
locale.format_duration(row["non_billable_duration"], ascii_digits=True),
locale.format_duration(row["total_duration"], ascii_digits=True),
*(
[]
if financial_only
else [
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"]),
*(
[_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]]]:
rate_rows = [
[
_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 [])
]
rate_rows = _summary_rate_rows(locale, summary)
client_rows = _summary_breakdown_rows(
locale,
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,
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["non_billable_duration"], ascii_digits=True) if index == 0 else None,
*(rate_rows[index] if index < len(rate_rows) else [None, 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]),
@@ -662,7 +698,7 @@ def _render_all_users_overall_excel_sheet(
worksheet,
row=15,
start_col=1,
end_col=16,
end_col=15,
value=locale.t("users_summary_sheet"),
rtl=locale.is_rtl,
)
@@ -670,7 +706,6 @@ def _render_all_users_overall_excel_sheet(
locale.t("name"),
locale.t("mobile"),
locale.t("working_hours"),
locale.t("non_working_hours"),
locale.t("hourly_rate"),
locale.t("period"),
locale.t("income"),
@@ -704,27 +739,27 @@ def _render_all_users_overall_excel_sheet(
values=values,
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)
rate_rows = user_summary.get("rate_periods") or []
client_rows = user_summary.get("client_percentages") or []
project_rows = user_summary.get("project_percentages") or []
tag_rows = user_summary.get("tag_percentages") or []
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=6, value_present=True)
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=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:
_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=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:
_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=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 += 2
@@ -765,8 +800,6 @@ def _render_all_users_overall_excel_sheet(
locale.t("name"),
locale.t("billable_hours"),
locale.t("hour_percentage"),
locale.t("non_billable_hours"),
locale.t("total_hours"),
locale.t("income"),
locale.t("income_percentage"),
],
@@ -785,8 +818,6 @@ def _render_all_users_overall_excel_sheet(
row["name"],
locale.format_duration(row["billable_duration"], 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"]),
_percentage_display(locale, income_percentages or [], row, ascii_digits=True),
],
@@ -798,7 +829,7 @@ def _render_all_users_overall_excel_sheet(
worksheet,
row=current_row,
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,
)
current_row += 1
@@ -808,25 +839,30 @@ def _render_all_users_overall_excel_sheet(
"A": 31.57,
"B": 19.86,
"C": 18.0,
"D": 17.0,
"E": 18.0,
"F": 26.0,
"G": 24.0,
"H": 28.0,
"I": 14.0,
"J": 16.0,
"K": 28.0,
"L": 14.0,
"M": 16.0,
"N": 24.0,
"O": 14.0,
"P": 16.0,
"D": 18.0,
"E": 26.0,
"F": 24.0,
"G": 28.0,
"H": 14.0,
"I": 16.0,
"J": 28.0,
"K": 14.0,
"L": 16.0,
"M": 24.0,
"N": 14.0,
"O": 16.0,
}
for column, width in overall_widths.items():
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:
worksheet.sheet_view.rightToLeft = True
worksheet.freeze_panes = "E4"
@@ -860,6 +896,7 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -
if user_summary
else report_data.get("client_income_percentages")
),
financial_only=financial_only_breakdowns,
)
_append_breakdown_table(
worksheet,
@@ -876,6 +913,7 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -
if user_summary
else report_data.get("project_income_percentages")
),
financial_only=financial_only_breakdowns,
)
_append_breakdown_table(
worksheet,
@@ -892,6 +930,7 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -
if user_summary
else report_data.get("tag_income_percentages")
),
financial_only=financial_only_breakdowns,
)
_autosize_columns(worksheet)
@@ -935,7 +974,12 @@ def build_excel_report(*, report_data: dict, locale: ExportLocale, per_user_repo
used_titles,
)
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)
else:
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),
locale.format_date(row["from_date"]),
locale.format_date(row["to_date"]),
_rate_to_label(locale, row.get("to_date")),
],
)
for row in rows
@@ -1056,7 +1100,15 @@ def _build_pdf_rate_history_table(locale: ExportLocale, summary: dict, doc_width
]
if not rows:
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:
@@ -1083,6 +1135,7 @@ def _append_pdf_report_sections(
doc_width: float,
section_style: ParagraphStyle,
user_summary: dict | None = None,
financial_only_breakdowns: bool = False,
) -> None:
sections = [
("daily_summary", report_data["days"], True),
@@ -1107,8 +1160,7 @@ def _append_pdf_report_sections(
locale.t("name"),
locale.t("billable_hours"),
locale.t("hour_percentage"),
locale.t("non_billable_hours"),
locale.t("total_hours"),
*( [] if financial_only_breakdowns else [locale.t("non_billable_hours"), locale.t("total_hours")] ),
locale.t("income"),
locale.t("income_percentage"),
]
@@ -1129,49 +1181,69 @@ def _append_pdf_report_sections(
row["name"],
locale.format_duration(row["billable_duration"]),
_percentage_display(locale, hour_percentage_rows, row),
locale.format_duration(row["non_billable_duration"]),
locale.format_duration(row["total_duration"]),
*(
[]
if financial_only_breakdowns
else [
locale.format_duration(row["non_billable_duration"]),
locale.format_duration(row["total_duration"]),
]
),
_money_label(locale, row["income_totals"]),
_percentage_display(locale, income_percentage_rows or [], row),
],
)
for row in rows
] or [_rtl_row(locale, [locale.t("no_data"), "", "", "", "", "", ""])]
] or [
_rtl_row(
locale,
[locale.t("no_data"), "", "", *( [] if financial_only_breakdowns else ["", ""] ), "", ""],
)
]
if is_daily:
column_widths = [
doc_width * 0.20,
doc_width * 0.12,
doc_width * 0.15,
doc_width * 0.13,
doc_width * 0.16,
doc_width * 0.24,
]
elif hour_percentage_rows is not None:
fixed_widths = (
[
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.12,
doc_width * 0.12,
doc_width * 0.19,
doc_width * 0.15,
]
)
column_widths = [doc_width - sum(fixed_widths), *fixed_widths]
if locale.is_rtl:
column_widths = list(reversed(column_widths))
else:
fixed_widths = [
doc_width * 0.15,
doc_width * 0.17,
doc_width * 0.14,
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=(
[
doc_width * 0.20,
doc_width * 0.12,
doc_width * 0.15,
doc_width * 0.13,
doc_width * 0.16,
doc_width * 0.24,
]
if is_daily
else [
*(
[
doc_width * 0.20,
doc_width * 0.11,
doc_width * 0.11,
doc_width * 0.12,
doc_width * 0.12,
doc_width * 0.19,
doc_width * 0.15,
]
if hour_percentage_rows is not None
else [
doc_width * 0.13,
doc_width * 0.15,
doc_width * 0.17,
doc_width * 0.14,
doc_width * 0.28,
]
)
]
),
column_widths=column_widths,
)
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("mobile"),
locale.t("working_hours"),
locale.t("non_working_hours"),
locale.t("hourly_rate"),
locale.t("period"),
locale.t("income"),
],
)
@@ -1326,9 +1396,7 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
summary["user"]["name"],
locale.format_number(summary["user"]["mobile"]),
locale.format_duration(summary["billable_duration"]),
locale.format_duration(summary["non_billable_duration"]),
_rates_label(locale, summary.get("hourly_rates") or []),
_summary_period_label(locale, summary.get("rate_periods") or []),
_pdf_summary_rate_label(locale, summary.get("hourly_rates") or []),
_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],
locale=locale,
column_widths=[
doc.width * 0.18,
doc.width * 0.13,
doc.width * 0.13,
doc.width * 0.13,
doc.width * 0.13,
doc.width * 0.25,
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,
section_style=section_style,
user_summary=user_report.get("user_summary"),
financial_only_breakdowns=True,
)
else:
_append_pdf_report_sections(
@@ -1388,6 +1455,7 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
doc_width=doc.width,
section_style=section_style,
user_summary=report_data.get("user_summary"),
financial_only_breakdowns=False,
)
doc.build(story)

View File

@@ -4,7 +4,12 @@ from django.test import TestCase
from openpyxl import load_workbook
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):
@@ -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):
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):
locale = build_export_locale("en")
report_data = make_report_data(
hourly_rate={"amount": "15.00", "currency": "USD"},
)
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"),
]
per_user_reports = [
@@ -91,7 +149,7 @@ class ReportExporterTests(TestCase):
mobile="09129990001",
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(
@@ -123,14 +181,13 @@ class ReportExporterTests(TestCase):
self.assertEqual(summary_sheet["A1"].value, "Workspace Report")
self.assertEqual(summary_sheet["B1"].value, "Exports")
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(
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",
"Mobile",
"Working hours",
"Non-working hours",
"Hourly rate",
"Period",
"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 "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_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 %")
self.assertEqual(
breakdown_header,
breakdown_header[:5],
(
"Name",
"Billable hours",
"Hour %",
"Non-billable hours",
"Total hours",
"Income",
"Income %",
),

View File

@@ -10,7 +10,7 @@ from apps.projects.models import Project
from apps.tags.models import Tag
from apps.time_entries.models import TimeEntry
from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership
from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate
class ReportViewTests(APITestCase):
@@ -320,6 +320,75 @@ class ReportViewTests(APITestCase):
{"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):
self.client.force_authenticate(user=self.owner)

View File

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

View File

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

View File

@@ -3,7 +3,8 @@ from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.decorators import action
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.viewsets import ModelViewSet
from rest_framework.permissions import IsAuthenticated
@@ -15,6 +16,8 @@ from apps.notifications.services import (
notify_workspace_membership_removed,
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 (
CanWorkspaceManageMembers,
IsWorkspaceAdmin,
@@ -78,6 +81,8 @@ class WorkspaceViewSet(ModelViewSet):
def get_permissions(self):
if self.action in ["list", "retrieve"]:
return [IsAuthenticated(), IsWorkspaceMember()]
if self.action == "my_rates":
return [IsAuthenticated()]
if self.action in ["update", "partial_update"]:
return [IsAuthenticated(), IsWorkspaceAdmin()]
@@ -86,8 +91,86 @@ class WorkspaceViewSet(ModelViewSet):
return [IsAuthenticated()]
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
def perform_create(self, serializer):
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):

View File

@@ -4,6 +4,7 @@ from django.core.cache import cache
from django.test import TestCase
from rest_framework.test import APITestCase
from apps.projects.models import Project, ProjectAccess, ProjectUserRate
from apps.users.models import User
from apps.workspaces.api.permissions import (
CanWorkspaceManageMembers,
@@ -11,7 +12,7 @@ from apps.workspaces.api.permissions import (
IsWorkspaceMember,
IsWorkspaceOwner,
)
from apps.workspaces.models import Workspace, WorkspaceMembership
from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
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))
self.assertEqual(target["role"], WorkspaceMembership.Role.GUEST)
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")

View File

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