feat(reports): refine exports and restore project access
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user