feat(reports): refine exports and restore project access

This commit is contained in:
2026-05-14 17:06:35 +03:30
parent 77c07adec8
commit d4a52d6f3b
16 changed files with 1594 additions and 136 deletions

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, replace
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from decimal import Decimal, ROUND_HALF_UP
from typing import Iterable
import jdatetime
@@ -15,6 +15,7 @@ from rest_framework import serializers
from apps.clients.models import Client
from apps.projects.models import Project
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
@@ -90,6 +91,205 @@ def _serialize_rate(amount: Decimal | None, currency: str | None) -> dict | None
}
def _serialize_distinct_rates(entries: list[TimeEntry]) -> 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"))
return [
{"amount": amount, "currency": currency}
for amount, currency in sorted(unique_rates, key=lambda item: (item[1], Decimal(item[0])))
]
def _serialize_rate_periods(entries: list[TimeEntry]) -> list[dict]:
sorted_entries = sorted(entries, key=lambda entry: entry.start_time)
periods: list[dict] = []
current: dict | None = None
for entry in sorted_entries:
if not entry.hourly_rate:
continue
amount = f"{Decimal(entry.hourly_rate).quantize(Decimal('0.01'))}"
currency = entry.currency or "USD"
start_date = _localize_datetime(entry.start_time).date()
end_source = entry.end_time or entry.start_time
end_date = _localize_datetime(end_source).date()
if (
current
and current["amount"] == amount
and current["currency"] == currency
):
if end_date > current["to_date"]:
current["to_date"] = end_date
continue
if current:
periods.append(
{
"amount": current["amount"],
"currency": current["currency"],
"from_date": current["from_date"].isoformat(),
"to_date": current["to_date"].isoformat(),
}
)
current = {
"amount": amount,
"currency": currency,
"from_date": start_date,
"to_date": end_date,
}
if current:
periods.append(
{
"amount": current["amount"],
"currency": current["currency"],
"from_date": current["from_date"].isoformat(),
"to_date": current["to_date"].isoformat(),
}
)
return periods
def _serialize_percentage_rows(shares: dict[str, dict], total_seconds: int) -> list[dict]:
if total_seconds <= 0:
return []
rows = []
for bucket in shares.values():
percentage = (
Decimal(bucket["seconds"]) * Decimal("100") / Decimal(total_seconds)
).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
rows.append(
{
"id": bucket["id"],
"name": bucket["name"],
"percentage": f"{percentage}",
}
)
rows.sort(key=lambda item: (-Decimal(item["percentage"]), item["name"].lower()))
return rows
def _build_user_summary(user, entries: list[TimeEntry]) -> dict:
summary = _summary_from_entries(entries)
project_shares: dict[str, dict] = {}
client_shares: dict[str, dict] = {}
tag_shares: dict[str, dict] = {}
total_seconds = summary["billable_seconds"]
for entry in entries:
if not entry.is_billable:
continue
duration_seconds = get_entry_duration_seconds(entry)
if duration_seconds <= 0:
continue
if entry.project_id:
project_bucket = project_shares.setdefault(
str(entry.project_id),
{"id": str(entry.project_id), "name": entry.project.name, "seconds": 0},
)
project_bucket["seconds"] += duration_seconds
if entry.project and entry.project.client_id:
client_bucket = client_shares.setdefault(
str(entry.project.client_id),
{"id": str(entry.project.client_id), "name": entry.project.client.name, "seconds": 0},
)
client_bucket["seconds"] += duration_seconds
tags = list(entry.tags.all())
if tags:
allocated_seconds = Decimal(duration_seconds) / Decimal(len(tags))
for tag in tags:
tag_bucket = tag_shares.setdefault(
str(tag.id),
{"id": str(tag.id), "name": tag.name, "seconds": Decimal("0")},
)
tag_bucket["seconds"] += allocated_seconds
return {
"user": {
"id": str(user.id),
"name": _user_display(user),
"mobile": user.mobile,
},
"hourly_rates": _serialize_distinct_rates(entries),
"rate_periods": _serialize_rate_periods(entries),
"total_seconds": total_seconds,
"total_duration": summary["total_duration"],
"billable_seconds": summary["billable_seconds"],
"billable_duration": summary["billable_duration"],
"non_billable_seconds": summary["non_billable_seconds"],
"non_billable_duration": summary["non_billable_duration"],
"income_totals": summary["income_totals"],
"project_percentages": _serialize_percentage_rows(project_shares, total_seconds),
"client_percentages": _serialize_percentage_rows(client_shares, total_seconds),
"tag_percentages": _serialize_percentage_rows(tag_shares, total_seconds),
}
def _build_user_summaries(entries: list[TimeEntry]) -> list[dict]:
grouped: dict[str, list[TimeEntry]] = defaultdict(list)
for entry in entries:
grouped[str(entry.user_id)].append(entry)
summaries = [_build_user_summary(grouped_entries[0].user, grouped_entries) for grouped_entries in grouped.values() if grouped_entries]
summaries.sort(key=lambda item: item["user"]["name"].lower())
return summaries
def _build_overall_percentage_payload(entries: list[TimeEntry]) -> dict:
project_shares: dict[str, dict] = {}
client_shares: dict[str, dict] = {}
tag_shares: dict[str, dict] = {}
total_seconds = 0
for entry in entries:
if not entry.is_billable:
continue
duration_seconds = get_entry_duration_seconds(entry)
if duration_seconds <= 0:
continue
total_seconds += duration_seconds
if entry.project_id:
project_bucket = project_shares.setdefault(
str(entry.project_id),
{"id": str(entry.project_id), "name": entry.project.name, "seconds": 0},
)
project_bucket["seconds"] += duration_seconds
if entry.project and entry.project.client_id:
client_bucket = client_shares.setdefault(
str(entry.project.client_id),
{"id": str(entry.project.client_id), "name": entry.project.client.name, "seconds": 0},
)
client_bucket["seconds"] += duration_seconds
tags = list(entry.tags.all())
if tags:
allocated_seconds = Decimal(duration_seconds) / Decimal(len(tags))
for tag in tags:
tag_bucket = tag_shares.setdefault(
str(tag.id),
{"id": str(tag.id), "name": tag.name, "seconds": Decimal("0")},
)
tag_bucket["seconds"] += allocated_seconds
return {
"project_percentages": _serialize_percentage_rows(project_shares, total_seconds),
"client_percentages": _serialize_percentage_rows(client_shares, total_seconds),
"tag_percentages": _serialize_percentage_rows(tag_shares, total_seconds),
}
def _add_income(bucket: defaultdict[str, Decimal], entry: TimeEntry):
if not entry.is_billable or not entry.hourly_rate:
return
@@ -251,6 +451,10 @@ def load_report_filters(actor, raw_data) -> ReportFilters:
raise serializers.ValidationError("Client does not belong to this workspace.")
if project_id and not Project.objects.filter(id=project_id, workspace=workspace).exists():
raise serializers.ValidationError("Project does not belong to this workspace.")
if project_id and not is_workspace_scope:
project = Project.objects.filter(id=project_id, workspace=workspace).first()
if project and not user_has_project_access(actor, project):
raise serializers.ValidationError("Project does not belong to this workspace.")
if tag_ids:
existing_tag_ids = set(Tag.objects.filter(id__in=tag_ids, workspace=workspace).values_list("id", flat=True))
if len(existing_tag_ids) != len(tag_ids):
@@ -471,10 +675,16 @@ def _scope_payload(filters: ReportFilters) -> dict:
}
def _table_report_payload(filters: ReportFilters, entries: list[TimeEntry]) -> dict:
def _table_report_payload(
filters: ReportFilters,
entries: list[TimeEntry],
*,
user_summary: dict | None = None,
user_summaries: list[dict] | None = None,
) -> dict:
summary = _summary_from_entries(entries)
include_latest_rate = not (filters.is_workspace_scope and not filters.user_id)
return {
payload = {
"scope": _scope_payload(filters),
"summary": summary,
"days": _group_daily(entries, include_latest_rate=include_latest_rate),
@@ -482,6 +692,13 @@ def _table_report_payload(filters: ReportFilters, entries: list[TimeEntry]) -> d
"projects": _build_breakdown(entries, "projects"),
"tags": _build_breakdown(entries, "tags"),
}
if filters.is_workspace_scope and not filters.user_id:
payload.update(_build_overall_percentage_payload(entries))
if user_summary is not None:
payload["user_summary"] = user_summary
if user_summaries is not None:
payload["user_summaries"] = user_summaries
return payload
def _group_daily(entries: list[TimeEntry], *, include_latest_rate: bool) -> list[dict]:
@@ -621,7 +838,10 @@ def _build_breakdown(entries: list[TimeEntry], kind: str) -> list[dict]:
def build_table_report(actor, raw_filters) -> dict:
filters = load_report_filters(actor, raw_filters)
entries = list(_base_queryset(filters))
return _table_report_payload(filters, entries)
if filters.is_workspace_scope and not filters.user_id:
return _table_report_payload(filters, entries, user_summaries=_build_user_summaries(entries))
user_summary = _build_user_summary(entries[0].user, entries) if entries and filters.user_id else None
return _table_report_payload(filters, entries, user_summary=user_summary)
def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
@@ -642,7 +862,13 @@ def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
reports: list[dict] = []
for user_id, user_entries in sorted_groups:
user_filters = replace(filters, user_id=user_id)
reports.append(_table_report_payload(user_filters, user_entries))
reports.append(
_table_report_payload(
user_filters,
user_entries,
user_summary=_build_user_summary(user_entries[0].user, user_entries),
)
)
return reports

View File

@@ -24,6 +24,7 @@ TRANSLATIONS = {
"en": {
"report_title": "Workspace Report",
"overall_sheet": "Overall Report",
"users_summary_sheet": "Users Summary",
"workspace": "Workspace",
"period": "Period",
"from_date": "From date",
@@ -38,6 +39,18 @@ TRANSLATIONS = {
"non_billable_hours": "Non-billable hours",
"hourly_rate": "Hourly rate",
"income": "Income",
"working_hours": "Working hours",
"non_working_hours": "Non-working hours",
"hourly_rates": "Hourly rates",
"project_percentages": "Project percentages",
"client_percentages": "Client percentages",
"tag_percentages": "Tag percentages",
"summary_by_user": "Summary by user",
"rate_history": "Hourly rate history",
"from": "From",
"to": "To",
"percentage": "Percentage",
"none": "None",
"daily_summary": "Daily Summary",
"clients": "Clients",
"projects": "Projects",
@@ -50,6 +63,7 @@ TRANSLATIONS = {
"fa": {
"report_title": "گزارش فضای کاری",
"overall_sheet": "گزارش کلی",
"users_summary_sheet": "خلاصه کاربران",
"workspace": "فضای کاری",
"period": "بازه",
"from_date": "از تاریخ",
@@ -63,7 +77,19 @@ TRANSLATIONS = {
"billable_hours": "ساعات کاری",
"non_billable_hours": "ساعات غیر کاری",
"hourly_rate": "نرخ ساعتی",
"income": "درآمد",
"income": "کارکرد",
"working_hours": "ساعات کاری",
"non_working_hours": "ساعات غیرکاری",
"hourly_rates": "نرخ‌های ساعتی",
"project_percentages": "درصد پروژه‌ها",
"client_percentages": "درصد مشتری‌ها",
"tag_percentages": "درصد تگ‌ها",
"summary_by_user": "خلاصه کاربران",
"rate_history": "تاریخچه نرخ ساعتی",
"from": "از",
"to": "تا",
"percentage": "درصد",
"none": "بدون مورد",
"daily_summary": "خلاصه روزانه",
"clients": "مشتریان",
"projects": "پروژه‌ها",

File diff suppressed because it is too large Load Diff