feat(reports): add uncategorized dual-share exports
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from datetime import date, datetime, time, timedelta
|
from datetime import date, datetime, time, timedelta
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
from decimal import ROUND_DOWN, Decimal
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
import jdatetime
|
import jdatetime
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
@@ -39,6 +39,25 @@ ALLOWED_PERIODS = {
|
|||||||
PERIOD_CUSTOM,
|
PERIOD_CUSTOM,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UNCATEGORIZED_IDS = {
|
||||||
|
"clients": "__uncategorized_client__",
|
||||||
|
"projects": "__uncategorized_project__",
|
||||||
|
"tags": "__uncategorized_tag__",
|
||||||
|
}
|
||||||
|
|
||||||
|
UNCATEGORIZED_LABELS = {
|
||||||
|
"en": {
|
||||||
|
"clients": "No client",
|
||||||
|
"projects": "No project",
|
||||||
|
"tags": "No tag",
|
||||||
|
},
|
||||||
|
"fa": {
|
||||||
|
"clients": "\u0628\u062f\u0648\u0646 \u0645\u0634\u062a\u0631\u06cc",
|
||||||
|
"projects": "\u0628\u062f\u0648\u0646 \u067e\u0631\u0648\u0698\u0647",
|
||||||
|
"tags": "\u0628\u062f\u0648\u0646 \u062a\u06af",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _start_of_week(local_date: date) -> date:
|
def _start_of_week(local_date: date) -> date:
|
||||||
days_since_sunday = (local_date.weekday() + 1) % 7
|
days_since_sunday = (local_date.weekday() + 1) % 7
|
||||||
@@ -157,62 +176,209 @@ def _serialize_rate_periods(entries: list[TimeEntry]) -> list[dict]:
|
|||||||
return periods
|
return periods
|
||||||
|
|
||||||
|
|
||||||
def _serialize_percentage_rows(shares: dict[str, dict], total_seconds: int) -> list[dict]:
|
def _uncategorized_label(kind: str, language: str) -> str:
|
||||||
if total_seconds <= 0:
|
resolved_language = language if language in UNCATEGORIZED_LABELS else "en"
|
||||||
return []
|
return UNCATEGORIZED_LABELS[resolved_language][kind]
|
||||||
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:
|
def _share_bucket(bucket_id: str, name: str) -> dict:
|
||||||
summary = _summary_from_entries(entries)
|
return {
|
||||||
project_shares: dict[str, dict] = {}
|
"id": bucket_id,
|
||||||
client_shares: dict[str, dict] = {}
|
"name": name,
|
||||||
tag_shares: dict[str, dict] = {}
|
"seconds": Decimal("0"),
|
||||||
|
"income": _money_map(),
|
||||||
|
}
|
||||||
|
|
||||||
total_seconds = summary["billable_seconds"]
|
|
||||||
|
def _entry_income_payload(entry: TimeEntry) -> tuple[str, Decimal] | None:
|
||||||
|
if not entry.is_billable or not entry.hourly_rate:
|
||||||
|
return None
|
||||||
|
|
||||||
|
duration_seconds = get_entry_duration_seconds(entry)
|
||||||
|
if duration_seconds <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
hourly_rate = Decimal(entry.hourly_rate)
|
||||||
|
income = (hourly_rate * Decimal(duration_seconds) / Decimal(3600)).quantize(Decimal("0.01"))
|
||||||
|
return entry.currency or "USD", income
|
||||||
|
|
||||||
|
|
||||||
|
def _add_money(bucket: defaultdict[str, Decimal], currency: str, amount: Decimal) -> None:
|
||||||
|
bucket[currency] += amount
|
||||||
|
|
||||||
|
|
||||||
|
def _breakdown_targets(entry: TimeEntry, kind: str, language: str) -> list[tuple[str, str]]:
|
||||||
|
if kind == "clients":
|
||||||
|
if entry.project and entry.project.client:
|
||||||
|
return [(str(entry.project.client_id), entry.project.client.name)]
|
||||||
|
return [(UNCATEGORIZED_IDS[kind], _uncategorized_label(kind, language))]
|
||||||
|
|
||||||
|
if kind == "projects":
|
||||||
|
if entry.project:
|
||||||
|
return [(str(entry.project_id), entry.project.name)]
|
||||||
|
return [(UNCATEGORIZED_IDS[kind], _uncategorized_label(kind, language))]
|
||||||
|
|
||||||
|
tags = list(entry.tags.all())
|
||||||
|
if tags:
|
||||||
|
return [(str(tag.id), tag.name) for tag in tags]
|
||||||
|
return [(UNCATEGORIZED_IDS[kind], _uncategorized_label(kind, language))]
|
||||||
|
|
||||||
|
|
||||||
|
def _accumulate_breakdown_shares(entries: list[TimeEntry], kind: str, *, language: str) -> dict[str, dict]:
|
||||||
|
shares: dict[str, dict] = {}
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if not entry.is_billable:
|
if not entry.is_billable:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
duration_seconds = get_entry_duration_seconds(entry)
|
duration_seconds = get_entry_duration_seconds(entry)
|
||||||
if duration_seconds <= 0:
|
if duration_seconds <= 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if entry.project_id:
|
targets = _breakdown_targets(entry, kind, language)
|
||||||
project_bucket = project_shares.setdefault(
|
divisor = Decimal(len(targets)) if kind == "tags" and targets else Decimal("1")
|
||||||
str(entry.project_id),
|
income_payload = _entry_income_payload(entry)
|
||||||
{"id": str(entry.project_id), "name": entry.project.name, "seconds": 0},
|
|
||||||
)
|
|
||||||
project_bucket["seconds"] += duration_seconds
|
|
||||||
|
|
||||||
if entry.project and entry.project.client_id:
|
for bucket_id, bucket_name in targets:
|
||||||
client_bucket = client_shares.setdefault(
|
bucket = shares.setdefault(bucket_id, _share_bucket(bucket_id, bucket_name))
|
||||||
str(entry.project.client_id),
|
bucket["seconds"] += Decimal(duration_seconds) / divisor
|
||||||
{"id": str(entry.project.client_id), "name": entry.project.client.name, "seconds": 0},
|
if income_payload:
|
||||||
)
|
currency, amount = income_payload
|
||||||
client_bucket["seconds"] += duration_seconds
|
_add_money(bucket["income"], currency, amount / divisor)
|
||||||
|
|
||||||
tags = list(entry.tags.all())
|
return shares
|
||||||
if tags:
|
|
||||||
allocated_seconds = Decimal(duration_seconds) / Decimal(len(tags))
|
|
||||||
for tag in tags:
|
def _allocate_percentage_rows(items: list[dict], total_value: Decimal) -> list[dict]:
|
||||||
tag_bucket = tag_shares.setdefault(
|
if total_value <= 0 or not items:
|
||||||
str(tag.id),
|
return []
|
||||||
{"id": str(tag.id), "name": tag.name, "seconds": Decimal("0")},
|
|
||||||
)
|
working_rows: list[dict] = []
|
||||||
tag_bucket["seconds"] += allocated_seconds
|
assigned_total = 0
|
||||||
|
for item in items:
|
||||||
|
value = Decimal(item["value"])
|
||||||
|
raw_percentage = (value * Decimal("100") / total_value) if value > 0 else Decimal("0")
|
||||||
|
floored_percentage = int(raw_percentage.quantize(Decimal("1"), rounding=ROUND_DOWN))
|
||||||
|
assigned_total += floored_percentage
|
||||||
|
working_rows.append(
|
||||||
|
{
|
||||||
|
"id": item["id"],
|
||||||
|
"name": item["name"],
|
||||||
|
"value": value,
|
||||||
|
"percentage": floored_percentage,
|
||||||
|
"remainder": raw_percentage - Decimal(floored_percentage),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
remaining_points = max(0, 100 - assigned_total)
|
||||||
|
for row in sorted(
|
||||||
|
working_rows,
|
||||||
|
key=lambda item: (-item["remainder"], -item["value"], item["name"].lower(), item["id"]),
|
||||||
|
)[:remaining_points]:
|
||||||
|
row["percentage"] += 1
|
||||||
|
|
||||||
|
serialized = [
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"percentage": str(row["percentage"]),
|
||||||
|
}
|
||||||
|
for row in working_rows
|
||||||
|
]
|
||||||
|
serialized.sort(key=lambda item: (-Decimal(item["percentage"]), item["name"].lower()))
|
||||||
|
return serialized
|
||||||
|
|
||||||
|
|
||||||
|
def _single_currency_amount(income_totals: list[dict]) -> tuple[str | None, Decimal] | None:
|
||||||
|
non_zero_totals: list[tuple[str, Decimal]] = []
|
||||||
|
for item in income_totals:
|
||||||
|
amount = Decimal(item["amount"])
|
||||||
|
if amount == 0:
|
||||||
|
continue
|
||||||
|
non_zero_totals.append((item["currency"], amount))
|
||||||
|
|
||||||
|
if not non_zero_totals:
|
||||||
|
return None, Decimal("0")
|
||||||
|
|
||||||
|
currencies = {currency for currency, _ in non_zero_totals}
|
||||||
|
if len(currencies) != 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
currency = non_zero_totals[0][0]
|
||||||
|
total_amount = sum((amount for _, amount in non_zero_totals), Decimal("0"))
|
||||||
|
return currency, total_amount
|
||||||
|
|
||||||
|
|
||||||
|
def _complete_percentage_rows(
|
||||||
|
rows: list[dict],
|
||||||
|
percentage_rows: list[dict],
|
||||||
|
*,
|
||||||
|
unavailable: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
if unavailable:
|
||||||
|
return []
|
||||||
|
|
||||||
|
existing_ids = {row["id"] for row in percentage_rows}
|
||||||
|
completed = percentage_rows + [
|
||||||
|
{"id": row["id"], "name": row["name"], "percentage": "0"}
|
||||||
|
for row in rows
|
||||||
|
if row["id"] not in existing_ids
|
||||||
|
]
|
||||||
|
completed.sort(key=lambda item: (-Decimal(item["percentage"]), item["name"].lower()))
|
||||||
|
return completed
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_time_percentage_rows(rows: list[dict], shares: dict[str, dict]) -> list[dict]:
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"id": bucket["id"],
|
||||||
|
"name": bucket["name"],
|
||||||
|
"value": Decimal(bucket["seconds"]),
|
||||||
|
}
|
||||||
|
for bucket in shares.values()
|
||||||
|
]
|
||||||
|
total_seconds = sum((item["value"] for item in items), Decimal("0"))
|
||||||
|
return _complete_percentage_rows(rows, _allocate_percentage_rows(items, total_seconds))
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_income_percentage_rows(rows: list[dict], shares: dict[str, dict]) -> list[dict]:
|
||||||
|
items: list[dict] = []
|
||||||
|
currencies: set[str] = set()
|
||||||
|
|
||||||
|
for bucket in shares.values():
|
||||||
|
income_totals = _serialize_money_totals(bucket["income"])
|
||||||
|
currency_amount = _single_currency_amount(income_totals)
|
||||||
|
if currency_amount is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
currency, amount = currency_amount
|
||||||
|
if currency:
|
||||||
|
currencies.add(currency)
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": bucket["id"],
|
||||||
|
"name": bucket["name"],
|
||||||
|
"value": amount,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(currencies) > 1:
|
||||||
|
return []
|
||||||
|
|
||||||
|
total_income = sum((item["value"] for item in items), Decimal("0"))
|
||||||
|
if total_income <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return _complete_percentage_rows(rows, _allocate_percentage_rows(items, total_income))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_user_summary(user, entries: list[TimeEntry], *, language: str) -> dict:
|
||||||
|
summary = _summary_from_entries(entries)
|
||||||
|
project_rows = _build_breakdown(entries, "projects", language=language)
|
||||||
|
client_rows = _build_breakdown(entries, "clients", language=language)
|
||||||
|
tag_rows = _build_breakdown(entries, "tags", language=language)
|
||||||
|
project_shares = _accumulate_breakdown_shares(entries, "projects", language=language)
|
||||||
|
client_shares = _accumulate_breakdown_shares(entries, "clients", language=language)
|
||||||
|
tag_shares = _accumulate_breakdown_shares(entries, "tags", language=language)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"user": {
|
"user": {
|
||||||
@@ -222,71 +388,53 @@ def _build_user_summary(user, entries: list[TimeEntry]) -> dict:
|
|||||||
},
|
},
|
||||||
"hourly_rates": _serialize_distinct_rates(entries),
|
"hourly_rates": _serialize_distinct_rates(entries),
|
||||||
"rate_periods": _serialize_rate_periods(entries),
|
"rate_periods": _serialize_rate_periods(entries),
|
||||||
"total_seconds": total_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"],
|
||||||
"billable_duration": summary["billable_duration"],
|
"billable_duration": summary["billable_duration"],
|
||||||
"non_billable_seconds": summary["non_billable_seconds"],
|
"non_billable_seconds": summary["non_billable_seconds"],
|
||||||
"non_billable_duration": summary["non_billable_duration"],
|
"non_billable_duration": summary["non_billable_duration"],
|
||||||
"income_totals": summary["income_totals"],
|
"income_totals": summary["income_totals"],
|
||||||
"project_percentages": _serialize_percentage_rows(project_shares, total_seconds),
|
"project_percentages": _serialize_time_percentage_rows(project_rows, project_shares),
|
||||||
"client_percentages": _serialize_percentage_rows(client_shares, total_seconds),
|
"client_percentages": _serialize_time_percentage_rows(client_rows, client_shares),
|
||||||
"tag_percentages": _serialize_percentage_rows(tag_shares, total_seconds),
|
"tag_percentages": _serialize_time_percentage_rows(tag_rows, tag_shares),
|
||||||
|
"project_income_percentages": _serialize_income_percentage_rows(project_rows, project_shares),
|
||||||
|
"client_income_percentages": _serialize_income_percentage_rows(client_rows, client_shares),
|
||||||
|
"tag_income_percentages": _serialize_income_percentage_rows(tag_rows, tag_shares),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_user_summaries(entries: list[TimeEntry]) -> list[dict]:
|
def _build_user_summaries(entries: list[TimeEntry], *, language: str) -> list[dict]:
|
||||||
grouped: dict[str, list[TimeEntry]] = defaultdict(list)
|
grouped: dict[str, list[TimeEntry]] = defaultdict(list)
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
grouped[str(entry.user_id)].append(entry)
|
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 = [
|
||||||
|
_build_user_summary(grouped_entries[0].user, grouped_entries, language=language)
|
||||||
|
for grouped_entries in grouped.values()
|
||||||
|
if grouped_entries
|
||||||
|
]
|
||||||
summaries.sort(key=lambda item: item["user"]["name"].lower())
|
summaries.sort(key=lambda item: item["user"]["name"].lower())
|
||||||
return summaries
|
return summaries
|
||||||
|
|
||||||
|
|
||||||
def _build_overall_percentage_payload(entries: list[TimeEntry]) -> dict:
|
def _build_overall_percentage_payload(
|
||||||
project_shares: dict[str, dict] = {}
|
entries: list[TimeEntry],
|
||||||
client_shares: dict[str, dict] = {}
|
*,
|
||||||
tag_shares: dict[str, dict] = {}
|
language: str,
|
||||||
total_seconds = 0
|
rows_by_kind: dict[str, list[dict]],
|
||||||
|
) -> dict:
|
||||||
for entry in entries:
|
project_shares = _accumulate_breakdown_shares(entries, "projects", language=language)
|
||||||
if not entry.is_billable:
|
client_shares = _accumulate_breakdown_shares(entries, "clients", language=language)
|
||||||
continue
|
tag_shares = _accumulate_breakdown_shares(entries, "tags", language=language)
|
||||||
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 {
|
return {
|
||||||
"project_percentages": _serialize_percentage_rows(project_shares, total_seconds),
|
"project_percentages": _serialize_time_percentage_rows(rows_by_kind["projects"], project_shares),
|
||||||
"client_percentages": _serialize_percentage_rows(client_shares, total_seconds),
|
"client_percentages": _serialize_time_percentage_rows(rows_by_kind["clients"], client_shares),
|
||||||
"tag_percentages": _serialize_percentage_rows(tag_shares, total_seconds),
|
"tag_percentages": _serialize_time_percentage_rows(rows_by_kind["tags"], tag_shares),
|
||||||
|
"project_income_percentages": _serialize_income_percentage_rows(rows_by_kind["projects"], project_shares),
|
||||||
|
"client_income_percentages": _serialize_income_percentage_rows(rows_by_kind["clients"], client_shares),
|
||||||
|
"tag_income_percentages": _serialize_income_percentage_rows(rows_by_kind["tags"], tag_shares),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -344,7 +492,13 @@ class ReportFilterSerializer(serializers.Serializer):
|
|||||||
language = serializers.ChoiceField(choices=("en", "fa"), required=False, default="en")
|
language = serializers.ChoiceField(choices=("en", "fa"), required=False, default="en")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_period_bounds(period: str, from_date: date | None, to_date: date | None, *, language: str) -> tuple[date, date]:
|
def _resolve_period_bounds(
|
||||||
|
period: str,
|
||||||
|
from_date: date | None,
|
||||||
|
to_date: date | None,
|
||||||
|
*,
|
||||||
|
language: str,
|
||||||
|
) -> tuple[date, date]:
|
||||||
today = timezone.localdate()
|
today = timezone.localdate()
|
||||||
if language == "fa":
|
if language == "fa":
|
||||||
today_jalali = jdatetime.date.fromgregorian(date=today)
|
today_jalali = jdatetime.date.fromgregorian(date=today)
|
||||||
@@ -412,7 +566,11 @@ def load_report_filters(actor, raw_data) -> ReportFilters:
|
|||||||
"user": raw_data.get("user"),
|
"user": raw_data.get("user"),
|
||||||
"client": raw_data.get("client"),
|
"client": raw_data.get("client"),
|
||||||
"project": raw_data.get("project"),
|
"project": raw_data.get("project"),
|
||||||
"tags": raw_data.get("tags") or raw_data.getlist("tags") if hasattr(raw_data, "getlist") else raw_data.get("tags"),
|
"tags": (
|
||||||
|
raw_data.get("tags") or raw_data.getlist("tags")
|
||||||
|
if hasattr(raw_data, "getlist")
|
||||||
|
else raw_data.get("tags")
|
||||||
|
),
|
||||||
"language": raw_data.get("language", "en"),
|
"language": raw_data.get("language", "en"),
|
||||||
}
|
}
|
||||||
if normalized["tags"] and not isinstance(normalized["tags"], list):
|
if normalized["tags"] and not isinstance(normalized["tags"], list):
|
||||||
@@ -476,8 +634,15 @@ def load_report_filters(actor, raw_data) -> ReportFilters:
|
|||||||
|
|
||||||
|
|
||||||
def _base_queryset(filters: ReportFilters) -> QuerySet[TimeEntry]:
|
def _base_queryset(filters: ReportFilters) -> QuerySet[TimeEntry]:
|
||||||
start_dt = timezone.make_aware(datetime.combine(filters.from_date, time.min), timezone.get_current_timezone())
|
current_timezone = timezone.get_current_timezone()
|
||||||
end_dt = timezone.make_aware(datetime.combine(filters.to_date + timedelta(days=1), time.min), timezone.get_current_timezone())
|
start_dt = timezone.make_aware(
|
||||||
|
datetime.combine(filters.from_date, time.min),
|
||||||
|
current_timezone,
|
||||||
|
)
|
||||||
|
end_dt = timezone.make_aware(
|
||||||
|
datetime.combine(filters.to_date + timedelta(days=1), time.min),
|
||||||
|
current_timezone,
|
||||||
|
)
|
||||||
|
|
||||||
queryset = (
|
queryset = (
|
||||||
TimeEntry.objects.filter(
|
TimeEntry.objects.filter(
|
||||||
@@ -660,7 +825,11 @@ def _scope_payload(filters: ReportFilters) -> dict:
|
|||||||
"workspace": {
|
"workspace": {
|
||||||
"id": str(filters.workspace.id),
|
"id": str(filters.workspace.id),
|
||||||
"name": filters.workspace.name,
|
"name": filters.workspace.name,
|
||||||
"thumbnail_path": filters.workspace.thumbnail.path if getattr(filters.workspace, "thumbnail", None) else None,
|
"thumbnail_path": (
|
||||||
|
filters.workspace.thumbnail.path
|
||||||
|
if getattr(filters.workspace, "thumbnail", None)
|
||||||
|
else None
|
||||||
|
),
|
||||||
},
|
},
|
||||||
"period": filters.period,
|
"period": filters.period,
|
||||||
"from_date": filters.from_date.isoformat(),
|
"from_date": filters.from_date.isoformat(),
|
||||||
@@ -684,16 +853,29 @@ def _table_report_payload(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
summary = _summary_from_entries(entries)
|
summary = _summary_from_entries(entries)
|
||||||
include_latest_rate = not (filters.is_workspace_scope and not filters.user_id)
|
include_latest_rate = not (filters.is_workspace_scope and not filters.user_id)
|
||||||
|
client_rows = _build_breakdown(entries, "clients", language=filters.language)
|
||||||
|
project_rows = _build_breakdown(entries, "projects", language=filters.language)
|
||||||
|
tag_rows = _build_breakdown(entries, "tags", language=filters.language)
|
||||||
payload = {
|
payload = {
|
||||||
"scope": _scope_payload(filters),
|
"scope": _scope_payload(filters),
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"days": _group_daily(entries, include_latest_rate=include_latest_rate),
|
"days": _group_daily(entries, include_latest_rate=include_latest_rate),
|
||||||
"clients": _build_breakdown(entries, "clients"),
|
"clients": client_rows,
|
||||||
"projects": _build_breakdown(entries, "projects"),
|
"projects": project_rows,
|
||||||
"tags": _build_breakdown(entries, "tags"),
|
"tags": tag_rows,
|
||||||
}
|
}
|
||||||
if filters.is_workspace_scope and not filters.user_id:
|
if filters.is_workspace_scope and not filters.user_id:
|
||||||
payload.update(_build_overall_percentage_payload(entries))
|
payload.update(
|
||||||
|
_build_overall_percentage_payload(
|
||||||
|
entries,
|
||||||
|
language=filters.language,
|
||||||
|
rows_by_kind={
|
||||||
|
"clients": client_rows,
|
||||||
|
"projects": project_rows,
|
||||||
|
"tags": tag_rows,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
if user_summary is not None:
|
if user_summary is not None:
|
||||||
payload["user_summary"] = user_summary
|
payload["user_summary"] = user_summary
|
||||||
if user_summaries is not None:
|
if user_summaries is not None:
|
||||||
@@ -761,64 +943,31 @@ def _group_daily(entries: list[TimeEntry], *, include_latest_rate: bool) -> list
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def _build_breakdown(entries: list[TimeEntry], kind: str) -> list[dict]:
|
def _build_breakdown(entries: list[TimeEntry], kind: str, *, language: str) -> list[dict]:
|
||||||
data: dict[str, dict] = {}
|
data: dict[str, dict] = {}
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if kind == "clients":
|
|
||||||
if not entry.project or not entry.project.client:
|
|
||||||
continue
|
|
||||||
item_id = str(entry.project.client_id)
|
|
||||||
item_name = entry.project.client.name
|
|
||||||
elif kind == "projects":
|
|
||||||
if not entry.project:
|
|
||||||
continue
|
|
||||||
item_id = str(entry.project_id)
|
|
||||||
item_name = entry.project.name
|
|
||||||
else:
|
|
||||||
if not entry.tags.exists():
|
|
||||||
continue
|
|
||||||
for tag in entry.tags.all():
|
|
||||||
bucket = data.setdefault(
|
|
||||||
str(tag.id),
|
|
||||||
{
|
|
||||||
"id": str(tag.id),
|
|
||||||
"name": tag.name,
|
|
||||||
"billable_seconds": 0,
|
|
||||||
"non_billable_seconds": 0,
|
|
||||||
"total_seconds": 0,
|
|
||||||
"income": _money_map(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
duration_seconds = get_entry_duration_seconds(entry)
|
|
||||||
bucket["total_seconds"] += duration_seconds
|
|
||||||
if entry.is_billable:
|
|
||||||
bucket["billable_seconds"] += duration_seconds
|
|
||||||
else:
|
|
||||||
bucket["non_billable_seconds"] += duration_seconds
|
|
||||||
_add_income(bucket["income"], entry)
|
|
||||||
continue
|
|
||||||
|
|
||||||
bucket = data.setdefault(
|
|
||||||
item_id,
|
|
||||||
{
|
|
||||||
"id": item_id,
|
|
||||||
"name": item_name,
|
|
||||||
"billable_seconds": 0,
|
|
||||||
"non_billable_seconds": 0,
|
|
||||||
"total_seconds": 0,
|
|
||||||
"income": _money_map(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
duration_seconds = get_entry_duration_seconds(entry)
|
duration_seconds = get_entry_duration_seconds(entry)
|
||||||
bucket["total_seconds"] += duration_seconds
|
for item_id, item_name in _breakdown_targets(entry, kind, language):
|
||||||
if entry.is_billable:
|
bucket = data.setdefault(
|
||||||
bucket["billable_seconds"] += duration_seconds
|
item_id,
|
||||||
else:
|
{
|
||||||
bucket["non_billable_seconds"] += duration_seconds
|
"id": item_id,
|
||||||
_add_income(bucket["income"], entry)
|
"name": item_name,
|
||||||
|
"billable_seconds": 0,
|
||||||
|
"non_billable_seconds": 0,
|
||||||
|
"total_seconds": 0,
|
||||||
|
"income": _money_map(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
bucket["total_seconds"] += duration_seconds
|
||||||
|
if entry.is_billable:
|
||||||
|
bucket["billable_seconds"] += duration_seconds
|
||||||
|
else:
|
||||||
|
bucket["non_billable_seconds"] += duration_seconds
|
||||||
|
_add_income(bucket["income"], entry)
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
for item_id, bucket in sorted(data.items(), key=lambda item: item[1]["name"].lower()):
|
for _item_id, bucket in sorted(data.items(), key=lambda item: item[1]["name"].lower()):
|
||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
"id": bucket["id"],
|
"id": bucket["id"],
|
||||||
@@ -839,8 +988,16 @@ 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(filters, entries, user_summaries=_build_user_summaries(entries))
|
return _table_report_payload(
|
||||||
user_summary = _build_user_summary(entries[0].user, entries) if entries and filters.user_id else None
|
filters,
|
||||||
|
entries,
|
||||||
|
user_summaries=_build_user_summaries(entries, language=filters.language),
|
||||||
|
)
|
||||||
|
user_summary = (
|
||||||
|
_build_user_summary(entries[0].user, entries, language=filters.language)
|
||||||
|
if entries and filters.user_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
return _table_report_payload(filters, entries, user_summary=user_summary)
|
return _table_report_payload(filters, entries, user_summary=user_summary)
|
||||||
|
|
||||||
|
|
||||||
@@ -866,7 +1023,11 @@ def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
|
|||||||
_table_report_payload(
|
_table_report_payload(
|
||||||
user_filters,
|
user_filters,
|
||||||
user_entries,
|
user_entries,
|
||||||
user_summary=_build_user_summary(user_entries[0].user, user_entries),
|
user_summary=_build_user_summary(
|
||||||
|
user_entries[0].user,
|
||||||
|
user_entries,
|
||||||
|
language=filters.language,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return reports
|
return reports
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
import jdatetime
|
import jdatetime
|
||||||
from arabic_reshaper import reshape
|
from arabic_reshaper import reshape
|
||||||
from bidi.algorithm import get_display
|
from bidi.algorithm import get_display
|
||||||
|
|
||||||
PERSIAN_DIGITS = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
|
PERSIAN_DIGITS = str.maketrans("0123456789", "\u06f0\u06f1\u06f2\u06f3\u06f4\u06f5\u06f6\u06f7\u06f8\u06f9")
|
||||||
ARABIC_RANGES = (
|
ARABIC_RANGES = (
|
||||||
(0x0600, 0x06FF),
|
(0x0600, 0x06FF),
|
||||||
(0x0750, 0x077F),
|
(0x0750, 0x077F),
|
||||||
@@ -50,6 +50,8 @@ TRANSLATIONS = {
|
|||||||
"from": "From",
|
"from": "From",
|
||||||
"to": "To",
|
"to": "To",
|
||||||
"percentage": "Percentage",
|
"percentage": "Percentage",
|
||||||
|
"hour_percentage": "Hour %",
|
||||||
|
"income_percentage": "Income %",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"daily_summary": "Daily Summary",
|
"daily_summary": "Daily Summary",
|
||||||
"clients": "Clients",
|
"clients": "Clients",
|
||||||
@@ -59,45 +61,53 @@ TRANSLATIONS = {
|
|||||||
"name": "Name",
|
"name": "Name",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"no_data": "No data",
|
"no_data": "No data",
|
||||||
|
"uncategorized_client": "No client",
|
||||||
|
"uncategorized_project": "No project",
|
||||||
|
"uncategorized_tag": "No tag",
|
||||||
},
|
},
|
||||||
"fa": {
|
"fa": {
|
||||||
"report_title": "گزارش فضای کاری",
|
"report_title": "\u06af\u0632\u0627\u0631\u0634 \u0641\u0636\u0627\u06cc \u06a9\u0627\u0631\u06cc",
|
||||||
"overall_sheet": "گزارش کلی",
|
"overall_sheet": "\u06af\u0632\u0627\u0631\u0634 \u06a9\u0644\u06cc",
|
||||||
"users_summary_sheet": "خلاصه کاربران",
|
"users_summary_sheet": "\u062e\u0644\u0627\u0635\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646",
|
||||||
"workspace": "فضای کاری",
|
"workspace": "\u0641\u0636\u0627\u06cc \u06a9\u0627\u0631\u06cc",
|
||||||
"period": "بازه",
|
"period": "\u0628\u0627\u0632\u0647",
|
||||||
"from_date": "از تاریخ",
|
"from_date": "\u0627\u0632 \u062a\u0627\u0631\u06cc\u062e",
|
||||||
"to_date": "تا تاریخ",
|
"to_date": "\u062a\u0627 \u062a\u0627\u0631\u06cc\u062e",
|
||||||
"user": "کاربر",
|
"user": "\u06a9\u0627\u0631\u0628\u0631",
|
||||||
"mobile": "موبایل",
|
"mobile": "\u0645\u0648\u0628\u0627\u06cc\u0644",
|
||||||
"all_users": "همه کاربران",
|
"all_users": "\u0647\u0645\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646",
|
||||||
"generated_at": "تاریخ تولید",
|
"generated_at": "\u062a\u0627\u0631\u06cc\u062e \u062a\u0648\u0644\u06cc\u062f",
|
||||||
"summary": "خلاصه",
|
"summary": "\u062e\u0644\u0627\u0635\u0647",
|
||||||
"total_hours": "کل ساعات",
|
"total_hours": "\u06a9\u0644 \u0633\u0627\u0639\u0627\u062a",
|
||||||
"billable_hours": "ساعات کاری",
|
"billable_hours": "\u0633\u0627\u0639\u0627\u062a \u06a9\u0627\u0631\u06cc",
|
||||||
"non_billable_hours": "ساعات غیر کاری",
|
"non_billable_hours": "\u0633\u0627\u0639\u0627\u062a \u063a\u06cc\u0631 \u06a9\u0627\u0631\u06cc",
|
||||||
"hourly_rate": "نرخ ساعتی",
|
"hourly_rate": "\u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc",
|
||||||
"income": "کارکرد",
|
"income": "\u06a9\u0627\u0631\u06a9\u0631\u062f",
|
||||||
"working_hours": "ساعات کاری",
|
"working_hours": "\u0633\u0627\u0639\u0627\u062a \u06a9\u0627\u0631\u06cc",
|
||||||
"non_working_hours": "ساعات غیرکاری",
|
"non_working_hours": "\u0633\u0627\u0639\u0627\u062a \u063a\u06cc\u0631\u06a9\u0627\u0631\u06cc",
|
||||||
"hourly_rates": "نرخهای ساعتی",
|
"hourly_rates": "\u0646\u0631\u062e\u200c\u0647\u0627\u06cc \u0633\u0627\u0639\u062a\u06cc",
|
||||||
"project_percentages": "درصد پروژهها",
|
"project_percentages": "\u062f\u0631\u0635\u062f \u067e\u0631\u0648\u0698\u0647\u200c\u0647\u0627",
|
||||||
"client_percentages": "درصد مشتریها",
|
"client_percentages": "\u062f\u0631\u0635\u062f \u0645\u0634\u062a\u0631\u06cc\u200c\u0647\u0627",
|
||||||
"tag_percentages": "درصد تگها",
|
"tag_percentages": "\u062f\u0631\u0635\u062f \u062a\u06af\u200c\u0647\u0627",
|
||||||
"summary_by_user": "خلاصه کاربران",
|
"summary_by_user": "\u062e\u0644\u0627\u0635\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646",
|
||||||
"rate_history": "تاریخچه نرخ ساعتی",
|
"rate_history": "\u062a\u0627\u0631\u06cc\u062e\u0686\u0647 \u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc",
|
||||||
"from": "از",
|
"from": "\u0627\u0632",
|
||||||
"to": "تا",
|
"to": "\u062a\u0627",
|
||||||
"percentage": "درصد",
|
"percentage": "\u062f\u0631\u0635\u062f",
|
||||||
"none": "بدون مورد",
|
"hour_percentage": "\u062f\u0631\u0635\u062f \u0633\u0627\u0639\u062a",
|
||||||
"daily_summary": "خلاصه روزانه",
|
"income_percentage": "\u062f\u0631\u0635\u062f \u06a9\u0627\u0631\u06a9\u0631\u062f",
|
||||||
"clients": "مشتریان",
|
"none": "\u0628\u062f\u0648\u0646 \u0645\u0648\u0631\u062f",
|
||||||
"projects": "پروژهها",
|
"daily_summary": "\u062e\u0644\u0627\u0635\u0647 \u0631\u0648\u0632\u0627\u0646\u0647",
|
||||||
"tags": "تگها",
|
"clients": "\u0645\u0634\u062a\u0631\u06cc\u0627\u0646",
|
||||||
"date": "تاریخ",
|
"projects": "\u067e\u0631\u0648\u0698\u0647\u200c\u0647\u0627",
|
||||||
"name": "نام",
|
"tags": "\u062a\u06af\u200c\u0647\u0627",
|
||||||
"total": "جمع",
|
"date": "\u062a\u0627\u0631\u06cc\u062e",
|
||||||
"no_data": "بدون داده",
|
"name": "\u0646\u0627\u0645",
|
||||||
|
"total": "\u062c\u0645\u0639",
|
||||||
|
"no_data": "\u0628\u062f\u0648\u0646 \u062f\u0627\u062f\u0647",
|
||||||
|
"uncategorized_client": "\u0628\u062f\u0648\u0646 \u0645\u0634\u062a\u0631\u06cc",
|
||||||
|
"uncategorized_project": "\u0628\u062f\u0648\u0646 \u067e\u0631\u0648\u0698\u0647",
|
||||||
|
"uncategorized_tag": "\u0628\u062f\u0648\u0646 \u062a\u06af",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,23 +121,23 @@ PERIOD_LABELS = {
|
|||||||
"period": "Custom period",
|
"period": "Custom period",
|
||||||
},
|
},
|
||||||
"fa": {
|
"fa": {
|
||||||
"this_week": "این هفته",
|
"this_week": "\u0627\u06cc\u0646 \u0647\u0641\u062a\u0647",
|
||||||
"this_month": "این ماه",
|
"this_month": "\u0627\u06cc\u0646 \u0645\u0627\u0647",
|
||||||
"this_year": "امسال",
|
"this_year": "\u0627\u0645\u0633\u0627\u0644",
|
||||||
"half_year_first": "نیمه اول سال",
|
"half_year_first": "\u0646\u06cc\u0645\u0647 \u0627\u0648\u0644 \u0633\u0627\u0644",
|
||||||
"half_year_second": "نیمه دوم سال",
|
"half_year_second": "\u0646\u06cc\u0645\u0647 \u062f\u0648\u0645 \u0633\u0627\u0644",
|
||||||
"period": "بازه دلخواه",
|
"period": "\u0628\u0627\u0632\u0647 \u062f\u0644\u062e\u0648\u0627\u0647",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
CURRENCY_LABELS = {
|
CURRENCY_LABELS = {
|
||||||
"USD": {"en": "USD", "fa": "دلار آمریکا"},
|
"USD": {"en": "USD", "fa": "\u062f\u0644\u0627\u0631 \u0622\u0645\u0631\u06cc\u06a9\u0627"},
|
||||||
"EUR": {"en": "EUR", "fa": "یورو"},
|
"EUR": {"en": "EUR", "fa": "\u06cc\u0648\u0631\u0648"},
|
||||||
"GBP": {"en": "GBP", "fa": "پوند"},
|
"GBP": {"en": "GBP", "fa": "\u067e\u0648\u0646\u062f"},
|
||||||
"IRR": {"en": "IRR", "fa": "ریال"},
|
"IRR": {"en": "IRR", "fa": "\u0631\u06cc\u0627\u0644"},
|
||||||
"IRT": {"en": "IRT", "fa": "تومان"},
|
"IRT": {"en": "IRT", "fa": "\u062a\u0648\u0645\u0627\u0646"},
|
||||||
"AED": {"en": "AED", "fa": "درهم"},
|
"AED": {"en": "AED", "fa": "\u062f\u0631\u0647\u0645"},
|
||||||
"TRY": {"en": "TRY", "fa": "لیر"},
|
"TRY": {"en": "TRY", "fa": "\u0644\u06cc\u0631"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -225,7 +235,7 @@ def user_label(user_payload: dict | None, locale: ExportLocale, *, ascii_digits:
|
|||||||
|
|
||||||
|
|
||||||
def safe_sheet_title(title: str, used: Iterable[str]) -> str:
|
def safe_sheet_title(title: str, used: Iterable[str]) -> str:
|
||||||
invalid = set('[]:*?/\\')
|
invalid = set("[]:*?/\\")
|
||||||
sanitized = "".join("-" if char in invalid else char for char in title).strip() or "Sheet"
|
sanitized = "".join("-" if char in invalid else char for char in title).strip() or "Sheet"
|
||||||
base = sanitized[:31]
|
base = sanitized[:31]
|
||||||
used_set = set(used)
|
used_set = set(used)
|
||||||
|
|||||||
@@ -173,7 +173,13 @@ def _append_user_summary_block(worksheet, *, locale: ExportLocale, user_summary:
|
|||||||
[locale.t("working_hours"), locale.format_duration(user_summary["billable_duration"], ascii_digits=True)],
|
[locale.t("working_hours"), locale.format_duration(user_summary["billable_duration"], ascii_digits=True)],
|
||||||
),
|
),
|
||||||
_excel_pair_row(
|
_excel_pair_row(
|
||||||
[locale.t("non_working_hours"), locale.format_duration(user_summary["non_billable_duration"], ascii_digits=True)],
|
[
|
||||||
|
locale.t("non_working_hours"),
|
||||||
|
locale.format_duration(
|
||||||
|
user_summary["non_billable_duration"],
|
||||||
|
ascii_digits=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
_excel_pair_row([locale.t("income"), _money_label_excel(locale, user_summary["income_totals"])]),
|
_excel_pair_row([locale.t("income"), _money_label_excel(locale, user_summary["income_totals"])]),
|
||||||
):
|
):
|
||||||
@@ -184,14 +190,49 @@ def _percentage_display(locale: ExportLocale, rows: list[dict], row_data: dict,
|
|||||||
row_id = str(row_data.get("id")) if row_data.get("id") is not None else None
|
row_id = str(row_data.get("id")) if row_data.get("id") is not None else None
|
||||||
row_name = row_data.get("name")
|
row_name = row_data.get("name")
|
||||||
for row in rows:
|
for row in rows:
|
||||||
value = f"{locale.format_amount(row['percentage'], ascii_digits=ascii_digits)}%"
|
value = _percentage_value(locale, row["percentage"], ascii_digits=ascii_digits)
|
||||||
if row_id is not None and str(row["id"]) == row_id:
|
if row_id is not None and str(row["id"]) == row_id:
|
||||||
return f"\u202B{value}\u202C" if locale.is_rtl and ascii_digits else value
|
return value
|
||||||
if row_name and row["name"] == row_name:
|
if row_name and row["name"] == row_name:
|
||||||
return f"\u202B{value}\u202C" if locale.is_rtl and ascii_digits else value
|
return value
|
||||||
return "-"
|
return "-"
|
||||||
|
|
||||||
|
|
||||||
|
def _percentage_value(locale: ExportLocale, percentage: str, *, ascii_digits: bool = False) -> str:
|
||||||
|
value = f"{locale.format_amount(percentage, ascii_digits=ascii_digits)}%"
|
||||||
|
return f"\u202B{value}\u202C" if locale.is_rtl and ascii_digits else value
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_breakdown_rows(
|
||||||
|
locale: ExportLocale,
|
||||||
|
hour_rows: list[dict],
|
||||||
|
income_rows: list[dict],
|
||||||
|
) -> list[list[str]]:
|
||||||
|
if not hour_rows:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
row["name"],
|
||||||
|
_percentage_value(locale, row["percentage"], ascii_digits=True),
|
||||||
|
_percentage_display(locale, income_rows, row, ascii_digits=True),
|
||||||
|
]
|
||||||
|
for row in hour_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_period_label(locale: ExportLocale, rate_periods: list[dict], *, ascii_digits: bool = False) -> str:
|
||||||
|
if not rate_periods:
|
||||||
|
return locale.t("none")
|
||||||
|
|
||||||
|
first_row = rate_periods[0]
|
||||||
|
last_row = rate_periods[-1]
|
||||||
|
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)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -252,8 +293,16 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) ->
|
|||||||
worksheet.append(_excel_pair_row([locale.t("report_title"), scope["workspace"]["name"]]))
|
worksheet.append(_excel_pair_row([locale.t("report_title"), scope["workspace"]["name"]]))
|
||||||
worksheet.append(_excel_pair_row([locale.t("workspace"), scope["workspace"]["name"]]))
|
worksheet.append(_excel_pair_row([locale.t("workspace"), scope["workspace"]["name"]]))
|
||||||
worksheet.append(_excel_pair_row([locale.t("period"), locale.period_label(scope["period"])]))
|
worksheet.append(_excel_pair_row([locale.t("period"), locale.period_label(scope["period"])]))
|
||||||
worksheet.append(_excel_pair_row([locale.t("from_date"), locale.format_date(scope["from_date"], ascii_digits=True)]))
|
worksheet.append(
|
||||||
worksheet.append(_excel_pair_row([locale.t("to_date"), locale.format_date(scope["to_date"], ascii_digits=True)]))
|
_excel_pair_row(
|
||||||
|
[locale.t("from_date"), locale.format_date(scope["from_date"], ascii_digits=True)],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
worksheet.append(
|
||||||
|
_excel_pair_row(
|
||||||
|
[locale.t("to_date"), locale.format_date(scope["to_date"], ascii_digits=True)],
|
||||||
|
)
|
||||||
|
)
|
||||||
worksheet.append(
|
worksheet.append(
|
||||||
_excel_pair_row(
|
_excel_pair_row(
|
||||||
[
|
[
|
||||||
@@ -266,16 +315,48 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) ->
|
|||||||
_excel_pair_row(
|
_excel_pair_row(
|
||||||
[
|
[
|
||||||
locale.t("mobile"),
|
locale.t("mobile"),
|
||||||
locale.format_number(scope["user"]["mobile"], ascii_digits=True) if scope.get("user") and scope["user"].get("mobile") else "-",
|
(
|
||||||
|
locale.format_number(
|
||||||
|
scope["user"]["mobile"],
|
||||||
|
ascii_digits=True,
|
||||||
|
)
|
||||||
|
if scope.get("user") and scope["user"].get("mobile")
|
||||||
|
else "-"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
worksheet.append(
|
||||||
|
_excel_pair_row(
|
||||||
|
[
|
||||||
|
locale.t("generated_at"),
|
||||||
|
locale.format_date(datetime.now().date(), ascii_digits=True),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
worksheet.append(_excel_pair_row([locale.t("generated_at"), locale.format_date(datetime.now().date(), ascii_digits=True)]))
|
|
||||||
worksheet.append([])
|
worksheet.append([])
|
||||||
_append_merged_heading(worksheet, locale=locale, title=locale.t("summary"), span=2)
|
_append_merged_heading(worksheet, locale=locale, title=locale.t("summary"), span=2)
|
||||||
worksheet.append(_excel_pair_row([locale.t("total_hours"), locale.format_duration(summary["total_duration"], ascii_digits=True)]))
|
worksheet.append(
|
||||||
worksheet.append(_excel_pair_row([locale.t("billable_hours"), locale.format_duration(summary["billable_duration"], ascii_digits=True)]))
|
_excel_pair_row(
|
||||||
worksheet.append(_excel_pair_row([locale.t("non_billable_hours"), locale.format_duration(summary["non_billable_duration"], ascii_digits=True)]))
|
[locale.t("total_hours"), locale.format_duration(summary["total_duration"], ascii_digits=True)],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
worksheet.append(
|
||||||
|
_excel_pair_row(
|
||||||
|
[locale.t("billable_hours"), locale.format_duration(summary["billable_duration"], ascii_digits=True)],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
worksheet.append(
|
||||||
|
_excel_pair_row(
|
||||||
|
[
|
||||||
|
locale.t("non_billable_hours"),
|
||||||
|
locale.format_duration(
|
||||||
|
summary["non_billable_duration"],
|
||||||
|
ascii_digits=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
worksheet.append(_excel_pair_row([locale.t("income"), _money_label_excel(locale, summary["income_totals"])]))
|
worksheet.append(_excel_pair_row([locale.t("income"), _money_label_excel(locale, summary["income_totals"])]))
|
||||||
|
|
||||||
for row_index in range(1, worksheet.max_row + 1):
|
for row_index in range(1, worksheet.max_row + 1):
|
||||||
@@ -284,7 +365,12 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) ->
|
|||||||
if row_index in {1, 10}:
|
if row_index in {1, 10}:
|
||||||
_apply_cell_style(first_cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
|
_apply_cell_style(first_cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
|
||||||
if second_cell.value:
|
if second_cell.value:
|
||||||
_apply_cell_style(second_cell, bold=row_index == 1, fill=HEADER_FILL if row_index == 1 else None, rtl=locale.is_rtl)
|
_apply_cell_style(
|
||||||
|
second_cell,
|
||||||
|
bold=row_index == 1,
|
||||||
|
fill=HEADER_FILL if row_index == 1 else None,
|
||||||
|
rtl=locale.is_rtl,
|
||||||
|
)
|
||||||
elif first_cell.value:
|
elif first_cell.value:
|
||||||
_apply_cell_style(first_cell, bold=False, fill=None, rtl=locale.is_rtl)
|
_apply_cell_style(first_cell, bold=False, fill=None, rtl=locale.is_rtl)
|
||||||
if second_cell.value:
|
if second_cell.value:
|
||||||
@@ -353,25 +439,26 @@ def _append_breakdown_table(
|
|||||||
locale: ExportLocale,
|
locale: ExportLocale,
|
||||||
title_key: str,
|
title_key: str,
|
||||||
rows: list[dict],
|
rows: list[dict],
|
||||||
percentages: list[dict] | None = None,
|
hour_percentages: list[dict] | None = None,
|
||||||
|
income_percentages: list[dict] | None = None,
|
||||||
) -> 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=6 if percentages is not None else 5,
|
span=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("non_billable_hours"),
|
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 [] ),
|
||||||
]
|
]
|
||||||
if percentages is not None:
|
|
||||||
headers.append(locale.t("percentage"))
|
|
||||||
worksheet.append(_excel_table_row(headers))
|
worksheet.append(_excel_table_row(headers))
|
||||||
for cell in worksheet[header_row]:
|
for cell in worksheet[header_row]:
|
||||||
_apply_cell_style(cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
|
_apply_cell_style(cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
|
||||||
@@ -385,15 +472,21 @@ def _append_breakdown_table(
|
|||||||
values = [
|
values = [
|
||||||
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)]
|
||||||
|
if hour_percentages is not None
|
||||||
|
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)]
|
||||||
|
if hour_percentages is not None
|
||||||
|
else []
|
||||||
|
),
|
||||||
]
|
]
|
||||||
if percentages is not None:
|
worksheet.append(_excel_table_row(values))
|
||||||
values.append(_percentage_display(locale, percentages, row, ascii_digits=True))
|
|
||||||
worksheet.append(
|
|
||||||
_excel_table_row(values)
|
|
||||||
)
|
|
||||||
for cell in worksheet[worksheet.max_row]:
|
for cell in worksheet[worksheet.max_row]:
|
||||||
_apply_cell_style(cell, rtl=locale.is_rtl)
|
_apply_cell_style(cell, rtl=locale.is_rtl)
|
||||||
|
|
||||||
@@ -415,21 +508,24 @@ def _append_user_details_block_excel(
|
|||||||
locale=locale,
|
locale=locale,
|
||||||
title_key="clients",
|
title_key="clients",
|
||||||
rows=report_data["clients"],
|
rows=report_data["clients"],
|
||||||
percentages=user_summary["client_percentages"],
|
hour_percentages=user_summary["client_percentages"],
|
||||||
|
income_percentages=user_summary["client_income_percentages"],
|
||||||
)
|
)
|
||||||
_append_breakdown_table(
|
_append_breakdown_table(
|
||||||
worksheet,
|
worksheet,
|
||||||
locale=locale,
|
locale=locale,
|
||||||
title_key="projects",
|
title_key="projects",
|
||||||
rows=report_data["projects"],
|
rows=report_data["projects"],
|
||||||
percentages=user_summary["project_percentages"],
|
hour_percentages=user_summary["project_percentages"],
|
||||||
|
income_percentages=user_summary["project_income_percentages"],
|
||||||
)
|
)
|
||||||
_append_breakdown_table(
|
_append_breakdown_table(
|
||||||
worksheet,
|
worksheet,
|
||||||
locale=locale,
|
locale=locale,
|
||||||
title_key="tags",
|
title_key="tags",
|
||||||
rows=report_data["tags"],
|
rows=report_data["tags"],
|
||||||
percentages=user_summary["tag_percentages"],
|
hour_percentages=user_summary["tag_percentages"],
|
||||||
|
income_percentages=user_summary["tag_income_percentages"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -469,22 +565,28 @@ def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int,
|
|||||||
rate_rows = [
|
rate_rows = [
|
||||||
[
|
[
|
||||||
_rate_period_label(locale, row, ascii_digits=True),
|
_rate_period_label(locale, row, ascii_digits=True),
|
||||||
f"{locale.format_date(row['from_date'], ascii_digits=True)} - {locale.format_date(row['to_date'], 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 [])
|
for row in (summary.get("rate_periods") or [])
|
||||||
]
|
]
|
||||||
client_rows = [
|
client_rows = _summary_breakdown_rows(
|
||||||
[row["name"], f"\u202B{locale.format_amount(row['percentage'], ascii_digits=True)}%\u202C" if locale.is_rtl else f"{locale.format_amount(row['percentage'], ascii_digits=True)}%"]
|
locale,
|
||||||
for row in (summary.get("client_percentages") or [])
|
summary.get("client_percentages") or [],
|
||||||
]
|
summary.get("client_income_percentages") or [],
|
||||||
project_rows = [
|
)
|
||||||
[row["name"], f"\u202B{locale.format_amount(row['percentage'], ascii_digits=True)}%\u202C" if locale.is_rtl else f"{locale.format_amount(row['percentage'], ascii_digits=True)}%"]
|
project_rows = _summary_breakdown_rows(
|
||||||
for row in (summary.get("project_percentages") or [])
|
locale,
|
||||||
]
|
summary.get("project_percentages") or [],
|
||||||
tag_rows = [
|
summary.get("project_income_percentages") or [],
|
||||||
[row["name"], f"\u202B{locale.format_amount(row['percentage'], ascii_digits=True)}%\u202C" if locale.is_rtl else f"{locale.format_amount(row['percentage'], ascii_digits=True)}%"]
|
)
|
||||||
for row in (summary.get("tag_percentages") or [])
|
tag_rows = _summary_breakdown_rows(
|
||||||
]
|
locale,
|
||||||
|
summary.get("tag_percentages") or [],
|
||||||
|
summary.get("tag_income_percentages") or [],
|
||||||
|
)
|
||||||
span = max(len(rate_rows), len(client_rows), len(project_rows), len(tag_rows), 1)
|
span = max(len(rate_rows), len(client_rows), len(project_rows), len(tag_rows), 1)
|
||||||
rows: list[list[str | None]] = []
|
rows: list[list[str | None]] = []
|
||||||
for index in range(span):
|
for index in range(span):
|
||||||
@@ -494,11 +596,11 @@ def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int,
|
|||||||
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,
|
locale.format_duration(summary["non_billable_duration"], ascii_digits=True) if index == 0 else None,
|
||||||
_money_label_excel(locale, summary["income_totals"]) 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]),
|
||||||
*(client_rows[index] if index < len(client_rows) else [None, None]),
|
_money_label_excel(locale, summary["income_totals"]) if index == 0 else None,
|
||||||
*(project_rows[index] if index < len(project_rows) else [None, None]),
|
*(client_rows[index] if index < len(client_rows) else [None, None, None]),
|
||||||
*(tag_rows[index] if index < len(tag_rows) else [None, None]),
|
*(project_rows[index] if index < len(project_rows) else [None, None, None]),
|
||||||
|
*(tag_rows[index] if index < len(tag_rows) else [None, None, None]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return span, rows
|
return span, rows
|
||||||
@@ -560,7 +662,7 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
worksheet,
|
worksheet,
|
||||||
row=15,
|
row=15,
|
||||||
start_col=1,
|
start_col=1,
|
||||||
end_col=13,
|
end_col=16,
|
||||||
value=locale.t("users_summary_sheet"),
|
value=locale.t("users_summary_sheet"),
|
||||||
rtl=locale.is_rtl,
|
rtl=locale.is_rtl,
|
||||||
)
|
)
|
||||||
@@ -569,15 +671,18 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
locale.t("mobile"),
|
locale.t("mobile"),
|
||||||
locale.t("working_hours"),
|
locale.t("working_hours"),
|
||||||
locale.t("non_working_hours"),
|
locale.t("non_working_hours"),
|
||||||
locale.t("income"),
|
|
||||||
locale.t("hourly_rate"),
|
locale.t("hourly_rate"),
|
||||||
locale.t("period"),
|
locale.t("period"),
|
||||||
|
locale.t("income"),
|
||||||
locale.t("clients"),
|
locale.t("clients"),
|
||||||
locale.t("percentage"),
|
locale.t("hour_percentage"),
|
||||||
|
locale.t("income_percentage"),
|
||||||
locale.t("projects"),
|
locale.t("projects"),
|
||||||
locale.t("percentage"),
|
locale.t("hour_percentage"),
|
||||||
|
locale.t("income_percentage"),
|
||||||
locale.t("tags"),
|
locale.t("tags"),
|
||||||
locale.t("percentage"),
|
locale.t("hour_percentage"),
|
||||||
|
locale.t("income_percentage"),
|
||||||
]
|
]
|
||||||
_write_table_row(
|
_write_table_row(
|
||||||
worksheet,
|
worksheet,
|
||||||
@@ -599,33 +704,58 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
values=values,
|
values=values,
|
||||||
rtl=locale.is_rtl,
|
rtl=locale.is_rtl,
|
||||||
)
|
)
|
||||||
for column in range(1, 6):
|
for column in (1, 2, 3, 4, 7):
|
||||||
_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=5, value_present=True)
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=6, value_present=True)
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=6, value_present=True)
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=7, 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=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)
|
||||||
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=10, value_present=True)
|
||||||
|
if len(project_rows) == 1:
|
||||||
_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)
|
||||||
if len(tag_rows) == 1:
|
|
||||||
_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)
|
_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=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 += span
|
||||||
|
|
||||||
current_row += 2
|
current_row += 2
|
||||||
for title_key, rows, percentages in (
|
for title_key, rows, hour_percentages, income_percentages in (
|
||||||
("clients", report_data["clients"], report_data.get("client_percentages")),
|
(
|
||||||
("projects", report_data["projects"], report_data.get("project_percentages")),
|
"clients",
|
||||||
("tags", report_data["tags"], report_data.get("tag_percentages")),
|
report_data["clients"],
|
||||||
|
report_data.get("client_percentages"),
|
||||||
|
report_data.get("client_income_percentages"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"projects",
|
||||||
|
report_data["projects"],
|
||||||
|
report_data.get("project_percentages"),
|
||||||
|
report_data.get("project_income_percentages"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"tags",
|
||||||
|
report_data["tags"],
|
||||||
|
report_data.get("tag_percentages"),
|
||||||
|
report_data.get("tag_income_percentages"),
|
||||||
|
),
|
||||||
):
|
):
|
||||||
_merge_and_style(worksheet, row=current_row, start_col=1, end_col=6, value=locale.t(title_key), rtl=locale.is_rtl)
|
_merge_and_style(
|
||||||
|
worksheet,
|
||||||
|
row=current_row,
|
||||||
|
start_col=1,
|
||||||
|
end_col=7,
|
||||||
|
value=locale.t(title_key),
|
||||||
|
rtl=locale.is_rtl,
|
||||||
|
)
|
||||||
current_row += 1
|
current_row += 1
|
||||||
_write_table_row(
|
_write_table_row(
|
||||||
worksheet,
|
worksheet,
|
||||||
@@ -634,10 +764,11 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
values=[
|
values=[
|
||||||
locale.t("name"),
|
locale.t("name"),
|
||||||
locale.t("billable_hours"),
|
locale.t("billable_hours"),
|
||||||
|
locale.t("hour_percentage"),
|
||||||
locale.t("non_billable_hours"),
|
locale.t("non_billable_hours"),
|
||||||
locale.t("total_hours"),
|
locale.t("total_hours"),
|
||||||
locale.t("income"),
|
locale.t("income"),
|
||||||
locale.t("percentage"),
|
locale.t("income_percentage"),
|
||||||
],
|
],
|
||||||
rtl=locale.is_rtl,
|
rtl=locale.is_rtl,
|
||||||
bold=True,
|
bold=True,
|
||||||
@@ -653,10 +784,11 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
values=[
|
values=[
|
||||||
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),
|
||||||
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, percentages or [], row, ascii_digits=True),
|
_percentage_display(locale, income_percentages or [], row, ascii_digits=True),
|
||||||
],
|
],
|
||||||
rtl=locale.is_rtl,
|
rtl=locale.is_rtl,
|
||||||
)
|
)
|
||||||
@@ -666,7 +798,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],
|
values=[locale.t("no_data"), None, None, None, None, None, None],
|
||||||
rtl=locale.is_rtl,
|
rtl=locale.is_rtl,
|
||||||
)
|
)
|
||||||
current_row += 1
|
current_row += 1
|
||||||
@@ -677,15 +809,18 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
"B": 19.86,
|
"B": 19.86,
|
||||||
"C": 18.0,
|
"C": 18.0,
|
||||||
"D": 17.0,
|
"D": 17.0,
|
||||||
"E": 24.0,
|
"E": 18.0,
|
||||||
"F": 17.57,
|
"F": 26.0,
|
||||||
"G": 32.0,
|
"G": 24.0,
|
||||||
"H": 30.0,
|
"H": 28.0,
|
||||||
"I": 14.0,
|
"I": 14.0,
|
||||||
"J": 32.86,
|
"J": 16.0,
|
||||||
"K": 12.0,
|
"K": 28.0,
|
||||||
"L": 22.0,
|
"L": 14.0,
|
||||||
"M": 12.0,
|
"M": 16.0,
|
||||||
|
"N": 24.0,
|
||||||
|
"O": 14.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
|
||||||
@@ -715,21 +850,48 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -
|
|||||||
locale=locale,
|
locale=locale,
|
||||||
title_key="clients",
|
title_key="clients",
|
||||||
rows=report_data["clients"],
|
rows=report_data["clients"],
|
||||||
percentages=user_summary["client_percentages"] if user_summary else None,
|
hour_percentages=(
|
||||||
|
user_summary["client_percentages"]
|
||||||
|
if user_summary
|
||||||
|
else report_data.get("client_percentages")
|
||||||
|
),
|
||||||
|
income_percentages=(
|
||||||
|
user_summary["client_income_percentages"]
|
||||||
|
if user_summary
|
||||||
|
else report_data.get("client_income_percentages")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
_append_breakdown_table(
|
_append_breakdown_table(
|
||||||
worksheet,
|
worksheet,
|
||||||
locale=locale,
|
locale=locale,
|
||||||
title_key="projects",
|
title_key="projects",
|
||||||
rows=report_data["projects"],
|
rows=report_data["projects"],
|
||||||
percentages=user_summary["project_percentages"] if user_summary else None,
|
hour_percentages=(
|
||||||
|
user_summary["project_percentages"]
|
||||||
|
if user_summary
|
||||||
|
else report_data.get("project_percentages")
|
||||||
|
),
|
||||||
|
income_percentages=(
|
||||||
|
user_summary["project_income_percentages"]
|
||||||
|
if user_summary
|
||||||
|
else report_data.get("project_income_percentages")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
_append_breakdown_table(
|
_append_breakdown_table(
|
||||||
worksheet,
|
worksheet,
|
||||||
locale=locale,
|
locale=locale,
|
||||||
title_key="tags",
|
title_key="tags",
|
||||||
rows=report_data["tags"],
|
rows=report_data["tags"],
|
||||||
percentages=user_summary["tag_percentages"] if user_summary else None,
|
hour_percentages=(
|
||||||
|
user_summary["tag_percentages"]
|
||||||
|
if user_summary
|
||||||
|
else report_data.get("tag_percentages")
|
||||||
|
),
|
||||||
|
income_percentages=(
|
||||||
|
user_summary["tag_income_percentages"]
|
||||||
|
if user_summary
|
||||||
|
else report_data.get("tag_income_percentages")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
_autosize_columns(worksheet)
|
_autosize_columns(worksheet)
|
||||||
|
|
||||||
@@ -764,7 +926,14 @@ def build_excel_report(*, report_data: dict, locale: ExportLocale, per_user_repo
|
|||||||
_render_all_users_overall_excel_sheet(overall_sheet, locale=locale, report_data=report_data)
|
_render_all_users_overall_excel_sheet(overall_sheet, locale=locale, report_data=report_data)
|
||||||
used_titles.add(overall_sheet.title)
|
used_titles.add(overall_sheet.title)
|
||||||
for user_report in per_user_reports:
|
for user_report in per_user_reports:
|
||||||
user_title = safe_sheet_title(user_label(user_report["scope"].get("user"), locale, ascii_digits=True), used_titles)
|
user_title = safe_sheet_title(
|
||||||
|
user_label(
|
||||||
|
user_report["scope"].get("user"),
|
||||||
|
locale,
|
||||||
|
ascii_digits=True,
|
||||||
|
),
|
||||||
|
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)
|
||||||
used_titles.add(user_title)
|
used_titles.add(user_title)
|
||||||
@@ -924,61 +1093,77 @@ def _append_pdf_report_sections(
|
|||||||
for title_key, rows, is_daily in sections:
|
for title_key, rows, is_daily in sections:
|
||||||
story.append(_paragraph(locale.t(title_key), section_style, locale))
|
story.append(_paragraph(locale.t(title_key), section_style, locale))
|
||||||
story.append(Spacer(1, 2 * mm))
|
story.append(Spacer(1, 2 * mm))
|
||||||
header_values = [
|
header_values = (
|
||||||
locale.t("date") if is_daily else locale.t("name"),
|
[
|
||||||
locale.t("billable_hours"),
|
locale.t("date"),
|
||||||
locale.t("non_billable_hours"),
|
locale.t("billable_hours"),
|
||||||
locale.t("total_hours"),
|
locale.t("non_billable_hours"),
|
||||||
*([locale.t("hourly_rate")] if is_daily else []),
|
locale.t("total_hours"),
|
||||||
locale.t("income"),
|
locale.t("hourly_rate"),
|
||||||
]
|
locale.t("income"),
|
||||||
percentage_rows = None
|
]
|
||||||
|
if is_daily
|
||||||
|
else [
|
||||||
|
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"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
hour_percentage_rows = None
|
||||||
|
income_percentage_rows = None
|
||||||
if user_summary and not is_daily:
|
if user_summary and not is_daily:
|
||||||
percentage_rows = user_summary[f"{title_key[:-1]}_percentages"] if title_key != "clients" else user_summary["client_percentages"]
|
prefix = title_key[:-1] if title_key != "clients" else "client"
|
||||||
header_values.append(locale.t("percentage"))
|
hour_percentage_rows = user_summary[f"{prefix}_percentages"]
|
||||||
|
income_percentage_rows = user_summary[f"{prefix}_income_percentages"]
|
||||||
header = _rtl_row(locale, header_values)
|
header = _rtl_row(locale, header_values)
|
||||||
body_rows = _report_table_rows(locale, rows, is_daily=is_daily)
|
body_rows = _report_table_rows(locale, rows, is_daily=is_daily)
|
||||||
if percentage_rows is not None:
|
if hour_percentage_rows is not None:
|
||||||
body_rows = [
|
body_rows = [
|
||||||
_rtl_row(
|
_rtl_row(
|
||||||
locale,
|
locale,
|
||||||
[
|
[
|
||||||
row["name"],
|
row["name"],
|
||||||
locale.format_duration(row["billable_duration"]),
|
locale.format_duration(row["billable_duration"]),
|
||||||
|
_percentage_display(locale, hour_percentage_rows, row),
|
||||||
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, percentage_rows, 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 [_rtl_row(locale, [locale.t("no_data"), "", "", "", "", "", ""])]
|
||||||
table = _styled_table(
|
table = _styled_table(
|
||||||
[header, *body_rows],
|
[header, *body_rows],
|
||||||
locale=locale,
|
locale=locale,
|
||||||
column_widths=(
|
column_widths=(
|
||||||
[
|
[
|
||||||
doc_width * 0.21,
|
doc_width * 0.20,
|
||||||
doc_width * 0.13,
|
doc_width * 0.12,
|
||||||
doc_width * 0.15,
|
doc_width * 0.15,
|
||||||
doc_width * 0.13,
|
doc_width * 0.13,
|
||||||
doc_width * 0.16,
|
doc_width * 0.16,
|
||||||
doc_width * 0.22,
|
doc_width * 0.24,
|
||||||
]
|
]
|
||||||
if is_daily
|
if is_daily
|
||||||
else [
|
else [
|
||||||
*(
|
*(
|
||||||
[
|
[
|
||||||
doc_width * 0.24,
|
doc_width * 0.20,
|
||||||
doc_width * 0.13,
|
doc_width * 0.11,
|
||||||
doc_width * 0.15,
|
doc_width * 0.11,
|
||||||
doc_width * 0.12,
|
doc_width * 0.12,
|
||||||
doc_width * 0.2,
|
doc_width * 0.12,
|
||||||
doc_width * 0.16,
|
doc_width * 0.19,
|
||||||
|
doc_width * 0.15,
|
||||||
]
|
]
|
||||||
if percentage_rows is not None
|
if hour_percentage_rows is not None
|
||||||
else [
|
else [
|
||||||
doc_width * 0.26,
|
doc_width * 0.13,
|
||||||
doc_width * 0.15,
|
doc_width * 0.15,
|
||||||
doc_width * 0.17,
|
doc_width * 0.17,
|
||||||
doc_width * 0.14,
|
doc_width * 0.14,
|
||||||
@@ -1063,7 +1248,14 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
|
|||||||
[locale.t("from_date"), locale.format_date(scope["from_date"])],
|
[locale.t("from_date"), locale.format_date(scope["from_date"])],
|
||||||
[locale.t("to_date"), locale.format_date(scope["to_date"])],
|
[locale.t("to_date"), locale.format_date(scope["to_date"])],
|
||||||
[locale.t("user"), scope["user"]["name"] if scope.get("user") else locale.t("all_users")],
|
[locale.t("user"), scope["user"]["name"] if scope.get("user") else locale.t("all_users")],
|
||||||
[locale.t("mobile"), locale.format_number(scope["user"]["mobile"]) if scope.get("user") and scope["user"].get("mobile") else "-"],
|
[
|
||||||
|
locale.t("mobile"),
|
||||||
|
(
|
||||||
|
locale.format_number(scope["user"]["mobile"])
|
||||||
|
if scope.get("user") and scope["user"].get("mobile")
|
||||||
|
else "-"
|
||||||
|
),
|
||||||
|
],
|
||||||
[locale.t("generated_at"), locale.format_date(datetime.now().date())],
|
[locale.t("generated_at"), locale.format_date(datetime.now().date())],
|
||||||
]
|
]
|
||||||
if locale.is_rtl:
|
if locale.is_rtl:
|
||||||
@@ -1122,6 +1314,8 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
|
|||||||
locale.t("mobile"),
|
locale.t("mobile"),
|
||||||
locale.t("working_hours"),
|
locale.t("working_hours"),
|
||||||
locale.t("non_working_hours"),
|
locale.t("non_working_hours"),
|
||||||
|
locale.t("hourly_rate"),
|
||||||
|
locale.t("period"),
|
||||||
locale.t("income"),
|
locale.t("income"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -1133,6 +1327,8 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
|
|||||||
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"]),
|
locale.format_duration(summary["non_billable_duration"]),
|
||||||
|
_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"]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -1143,11 +1339,13 @@ 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.24,
|
|
||||||
doc.width * 0.18,
|
doc.width * 0.18,
|
||||||
doc.width * 0.18,
|
doc.width * 0.13,
|
||||||
doc.width * 0.18,
|
doc.width * 0.13,
|
||||||
doc.width * 0.22,
|
doc.width * 0.13,
|
||||||
|
doc.width * 0.13,
|
||||||
|
doc.width * 0.16,
|
||||||
|
doc.width * 0.14,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ def make_user_summary(*, name: str, mobile: str):
|
|||||||
"project_percentages": [{"id": "1", "name": "Website", "percentage": "100"}],
|
"project_percentages": [{"id": "1", "name": "Website", "percentage": "100"}],
|
||||||
"client_percentages": [{"id": "1", "name": "Acme", "percentage": "100"}],
|
"client_percentages": [{"id": "1", "name": "Acme", "percentage": "100"}],
|
||||||
"tag_percentages": [{"id": "1", "name": "Design", "percentage": "100"}],
|
"tag_percentages": [{"id": "1", "name": "Design", "percentage": "100"}],
|
||||||
|
"project_income_percentages": [{"id": "1", "name": "Website", "percentage": "100"}],
|
||||||
|
"client_income_percentages": [{"id": "1", "name": "Acme", "percentage": "100"}],
|
||||||
|
"tag_income_percentages": [{"id": "1", "name": "Design", "percentage": "100"}],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -120,23 +123,26 @@ 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:M15", {str(item) for item in summary_sheet.merged_cells.ranges})
|
self.assertIn("A15:P15", {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][:13],
|
tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:16],
|
||||||
(
|
(
|
||||||
"Name",
|
"Name",
|
||||||
"Mobile",
|
"Mobile",
|
||||||
"Working hours",
|
"Working hours",
|
||||||
"Non-working hours",
|
"Non-working hours",
|
||||||
"Income",
|
|
||||||
"Hourly rate",
|
"Hourly rate",
|
||||||
"Period",
|
"Period",
|
||||||
|
"Income",
|
||||||
"Clients",
|
"Clients",
|
||||||
"Percentage",
|
"Hour %",
|
||||||
|
"Income %",
|
||||||
"Projects",
|
"Projects",
|
||||||
"Percentage",
|
"Hour %",
|
||||||
|
"Income %",
|
||||||
"Tags",
|
"Tags",
|
||||||
"Percentage",
|
"Hour %",
|
||||||
|
"Income %",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
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))
|
||||||
@@ -161,6 +167,20 @@ class ReportExporterTests(TestCase):
|
|||||||
daily_row = next(row[:6] for row in user_values if row and "2026/04/12" in row)
|
daily_row = next(row[:6] for row in user_values if row and "2026/04/12" in row)
|
||||||
self.assertEqual(daily_row[4], "15 USD")
|
self.assertEqual(daily_row[4], "15 USD")
|
||||||
|
|
||||||
|
breakdown_header = next(row[:7] for row in user_values if row and row[0] == "Name" and row[2] == "Hour %")
|
||||||
|
self.assertEqual(
|
||||||
|
breakdown_header,
|
||||||
|
(
|
||||||
|
"Name",
|
||||||
|
"Billable hours",
|
||||||
|
"Hour %",
|
||||||
|
"Non-billable hours",
|
||||||
|
"Total hours",
|
||||||
|
"Income",
|
||||||
|
"Income %",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def test_pdf_export_supports_persian_locale(self):
|
def test_pdf_export_supports_persian_locale(self):
|
||||||
locale = build_export_locale("fa")
|
locale = build_export_locale("fa")
|
||||||
report_data = make_report_data(
|
report_data = make_report_data(
|
||||||
@@ -168,7 +188,16 @@ class ReportExporterTests(TestCase):
|
|||||||
)
|
)
|
||||||
report_data["user_summaries"] = [make_user_summary(name="Owner User", mobile="09129990001")]
|
report_data["user_summaries"] = [make_user_summary(name="Owner User", mobile="09129990001")]
|
||||||
per_user_reports = [
|
per_user_reports = [
|
||||||
{**make_report_data(user_name="Owner User", mobile="09129990001"), "user_summary": make_user_summary(name="Owner User", mobile="09129990001")}
|
{
|
||||||
|
**make_report_data(
|
||||||
|
user_name="Owner User",
|
||||||
|
mobile="09129990001",
|
||||||
|
),
|
||||||
|
"user_summary": make_user_summary(
|
||||||
|
name="Owner User",
|
||||||
|
mobile="09129990001",
|
||||||
|
),
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
content = build_pdf_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports)
|
content = build_pdf_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports)
|
||||||
|
|||||||
@@ -147,9 +147,130 @@ class ReportViewTests(APITestCase):
|
|||||||
self.assertEqual(owner_summary["project_percentages"][0]["percentage"], "100")
|
self.assertEqual(owner_summary["project_percentages"][0]["percentage"], "100")
|
||||||
self.assertEqual(owner_summary["client_percentages"][0]["percentage"], "100")
|
self.assertEqual(owner_summary["client_percentages"][0]["percentage"], "100")
|
||||||
self.assertEqual(owner_summary["tag_percentages"][0]["percentage"], "100")
|
self.assertEqual(owner_summary["tag_percentages"][0]["percentage"], "100")
|
||||||
self.assertEqual(member_summary["project_percentages"], [])
|
self.assertEqual(member_summary["project_percentages"][0]["percentage"], "0")
|
||||||
self.assertEqual(member_summary["client_percentages"], [])
|
self.assertEqual(member_summary["client_percentages"][0]["percentage"], "0")
|
||||||
self.assertEqual(member_summary["tag_percentages"], [])
|
self.assertEqual(member_summary["tag_percentages"][0]["percentage"], "0")
|
||||||
|
|
||||||
|
def test_specific_user_report_includes_uncategorized_rows_and_balanced_percentages(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
TimeEntry.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.owner,
|
||||||
|
project=None,
|
||||||
|
description="Uncategorized billable",
|
||||||
|
start_time="2026-04-12T10:00:00+03:30",
|
||||||
|
end_time="2026-04-12T11:00:00+03:30",
|
||||||
|
duration=timedelta(hours=1),
|
||||||
|
is_billable=True,
|
||||||
|
hourly_rate=Decimal("10.00"),
|
||||||
|
currency="USD",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
|
return_value=date(2026, 4, 20),
|
||||||
|
):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/reports/table/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"period": "this_month",
|
||||||
|
"user": str(self.owner.id),
|
||||||
|
"language": "en",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
summary = response.data["user_summary"]
|
||||||
|
self.assertEqual(
|
||||||
|
sum(int(row["percentage"]) for row in summary["project_percentages"]),
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
sum(int(row["percentage"]) for row in summary["client_percentages"]),
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
sum(int(row["percentage"]) for row in summary["tag_percentages"]),
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{row["name"] for row in summary["project_percentages"]},
|
||||||
|
{"Website", "No project"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{row["name"] for row in summary["client_percentages"]},
|
||||||
|
{"Acme", "No client"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{row["name"] for row in summary["tag_percentages"]},
|
||||||
|
{"Design", "No tag"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{row["name"] for row in response.data["projects"]},
|
||||||
|
{"Website", "No project"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{row["name"] for row in response.data["clients"]},
|
||||||
|
{"Acme", "No client"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{row["name"] for row in response.data["tags"]},
|
||||||
|
{"Design", "No tag"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
sum(int(row["percentage"]) for row in summary["project_income_percentages"]),
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
sum(int(row["percentage"]) for row in summary["client_income_percentages"]),
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
sum(int(row["percentage"]) for row in summary["tag_income_percentages"]),
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_income_percentages_are_hidden_for_mixed_currency_breakdowns(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
second_project = Project.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
name="Mobile App",
|
||||||
|
client=self.client_obj,
|
||||||
|
)
|
||||||
|
|
||||||
|
TimeEntry.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.owner,
|
||||||
|
project=second_project,
|
||||||
|
description="EUR work",
|
||||||
|
start_time="2026-04-13T10:00:00+03:30",
|
||||||
|
end_time="2026-04-13T11:00:00+03:30",
|
||||||
|
duration=timedelta(hours=1),
|
||||||
|
is_billable=True,
|
||||||
|
hourly_rate=Decimal("20.00"),
|
||||||
|
currency="EUR",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
|
return_value=date(2026, 4, 20),
|
||||||
|
):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/reports/table/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"period": "this_month",
|
||||||
|
"user": str(self.owner.id),
|
||||||
|
"language": "en",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
summary = response.data["user_summary"]
|
||||||
|
self.assertEqual(summary["project_income_percentages"], [])
|
||||||
|
self.assertEqual(summary["client_income_percentages"], [])
|
||||||
|
|
||||||
def test_daily_rate_uses_latest_billable_entry_snapshot(self):
|
def test_daily_rate_uses_latest_billable_entry_snapshot(self):
|
||||||
self.client.force_authenticate(user=self.owner)
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|||||||
Reference in New Issue
Block a user