feat(reports): add uncategorized dual-share exports
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Iterable
|
||||
from decimal import ROUND_DOWN, Decimal
|
||||
|
||||
import jdatetime
|
||||
from django.contrib.auth import get_user_model
|
||||
@@ -39,6 +39,25 @@ ALLOWED_PERIODS = {
|
||||
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:
|
||||
days_since_sunday = (local_date.weekday() + 1) % 7
|
||||
@@ -157,62 +176,209 @@ def _serialize_rate_periods(entries: list[TimeEntry]) -> list[dict]:
|
||||
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 _uncategorized_label(kind: str, language: str) -> str:
|
||||
resolved_language = language if language in UNCATEGORIZED_LABELS else "en"
|
||||
return UNCATEGORIZED_LABELS[resolved_language][kind]
|
||||
|
||||
|
||||
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] = {}
|
||||
def _share_bucket(bucket_id: str, name: str) -> dict:
|
||||
return {
|
||||
"id": bucket_id,
|
||||
"name": name,
|
||||
"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:
|
||||
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
|
||||
targets = _breakdown_targets(entry, kind, language)
|
||||
divisor = Decimal(len(targets)) if kind == "tags" and targets else Decimal("1")
|
||||
income_payload = _entry_income_payload(entry)
|
||||
|
||||
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
|
||||
for bucket_id, bucket_name in targets:
|
||||
bucket = shares.setdefault(bucket_id, _share_bucket(bucket_id, bucket_name))
|
||||
bucket["seconds"] += Decimal(duration_seconds) / divisor
|
||||
if income_payload:
|
||||
currency, amount = income_payload
|
||||
_add_money(bucket["income"], currency, amount / divisor)
|
||||
|
||||
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 shares
|
||||
|
||||
|
||||
def _allocate_percentage_rows(items: list[dict], total_value: Decimal) -> list[dict]:
|
||||
if total_value <= 0 or not items:
|
||||
return []
|
||||
|
||||
working_rows: list[dict] = []
|
||||
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 {
|
||||
"user": {
|
||||
@@ -222,71 +388,53 @@ def _build_user_summary(user, entries: list[TimeEntry]) -> dict:
|
||||
},
|
||||
"hourly_rates": _serialize_distinct_rates(entries),
|
||||
"rate_periods": _serialize_rate_periods(entries),
|
||||
"total_seconds": total_seconds,
|
||||
"total_seconds": summary["billable_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),
|
||||
"project_percentages": _serialize_time_percentage_rows(project_rows, project_shares),
|
||||
"client_percentages": _serialize_time_percentage_rows(client_rows, client_shares),
|
||||
"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)
|
||||
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 = [
|
||||
_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())
|
||||
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
|
||||
def _build_overall_percentage_payload(
|
||||
entries: list[TimeEntry],
|
||||
*,
|
||||
language: str,
|
||||
rows_by_kind: dict[str, list[dict]],
|
||||
) -> dict:
|
||||
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 {
|
||||
"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),
|
||||
"project_percentages": _serialize_time_percentage_rows(rows_by_kind["projects"], project_shares),
|
||||
"client_percentages": _serialize_time_percentage_rows(rows_by_kind["clients"], client_shares),
|
||||
"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")
|
||||
|
||||
|
||||
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()
|
||||
if language == "fa":
|
||||
today_jalali = jdatetime.date.fromgregorian(date=today)
|
||||
@@ -412,7 +566,11 @@ def load_report_filters(actor, raw_data) -> ReportFilters:
|
||||
"user": raw_data.get("user"),
|
||||
"client": raw_data.get("client"),
|
||||
"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"),
|
||||
}
|
||||
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]:
|
||||
start_dt = timezone.make_aware(datetime.combine(filters.from_date, time.min), timezone.get_current_timezone())
|
||||
end_dt = timezone.make_aware(datetime.combine(filters.to_date + timedelta(days=1), time.min), timezone.get_current_timezone())
|
||||
current_timezone = 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 = (
|
||||
TimeEntry.objects.filter(
|
||||
@@ -660,7 +825,11 @@ def _scope_payload(filters: ReportFilters) -> dict:
|
||||
"workspace": {
|
||||
"id": str(filters.workspace.id),
|
||||
"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,
|
||||
"from_date": filters.from_date.isoformat(),
|
||||
@@ -684,16 +853,29 @@ def _table_report_payload(
|
||||
) -> dict:
|
||||
summary = _summary_from_entries(entries)
|
||||
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 = {
|
||||
"scope": _scope_payload(filters),
|
||||
"summary": summary,
|
||||
"days": _group_daily(entries, include_latest_rate=include_latest_rate),
|
||||
"clients": _build_breakdown(entries, "clients"),
|
||||
"projects": _build_breakdown(entries, "projects"),
|
||||
"tags": _build_breakdown(entries, "tags"),
|
||||
"clients": client_rows,
|
||||
"projects": project_rows,
|
||||
"tags": tag_rows,
|
||||
}
|
||||
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:
|
||||
payload["user_summary"] = user_summary
|
||||
if user_summaries is not None:
|
||||
@@ -761,64 +943,31 @@ def _group_daily(entries: list[TimeEntry], *, include_latest_rate: bool) -> list
|
||||
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] = {}
|
||||
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)
|
||||
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)
|
||||
for item_id, item_name in _breakdown_targets(entry, kind, language):
|
||||
bucket = data.setdefault(
|
||||
item_id,
|
||||
{
|
||||
"id": item_id,
|
||||
"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 = []
|
||||
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(
|
||||
{
|
||||
"id": bucket["id"],
|
||||
@@ -839,8 +988,16 @@ def build_table_report(actor, raw_filters) -> dict:
|
||||
filters = load_report_filters(actor, raw_filters)
|
||||
entries = list(_base_queryset(filters))
|
||||
if filters.is_workspace_scope and not filters.user_id:
|
||||
return _table_report_payload(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_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)
|
||||
|
||||
|
||||
@@ -866,7 +1023,11 @@ def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
|
||||
_table_report_payload(
|
||||
user_filters,
|
||||
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
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
import jdatetime
|
||||
from arabic_reshaper import reshape
|
||||
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 = (
|
||||
(0x0600, 0x06FF),
|
||||
(0x0750, 0x077F),
|
||||
@@ -50,6 +50,8 @@ TRANSLATIONS = {
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"percentage": "Percentage",
|
||||
"hour_percentage": "Hour %",
|
||||
"income_percentage": "Income %",
|
||||
"none": "None",
|
||||
"daily_summary": "Daily Summary",
|
||||
"clients": "Clients",
|
||||
@@ -59,45 +61,53 @@ TRANSLATIONS = {
|
||||
"name": "Name",
|
||||
"total": "Total",
|
||||
"no_data": "No data",
|
||||
"uncategorized_client": "No client",
|
||||
"uncategorized_project": "No project",
|
||||
"uncategorized_tag": "No tag",
|
||||
},
|
||||
"fa": {
|
||||
"report_title": "گزارش فضای کاری",
|
||||
"overall_sheet": "گزارش کلی",
|
||||
"users_summary_sheet": "خلاصه کاربران",
|
||||
"workspace": "فضای کاری",
|
||||
"period": "بازه",
|
||||
"from_date": "از تاریخ",
|
||||
"to_date": "تا تاریخ",
|
||||
"user": "کاربر",
|
||||
"mobile": "موبایل",
|
||||
"all_users": "همه کاربران",
|
||||
"generated_at": "تاریخ تولید",
|
||||
"summary": "خلاصه",
|
||||
"total_hours": "کل ساعات",
|
||||
"billable_hours": "ساعات کاری",
|
||||
"non_billable_hours": "ساعات غیر کاری",
|
||||
"hourly_rate": "نرخ ساعتی",
|
||||
"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": "پروژهها",
|
||||
"tags": "تگها",
|
||||
"date": "تاریخ",
|
||||
"name": "نام",
|
||||
"total": "جمع",
|
||||
"no_data": "بدون داده",
|
||||
"report_title": "\u06af\u0632\u0627\u0631\u0634 \u0641\u0636\u0627\u06cc \u06a9\u0627\u0631\u06cc",
|
||||
"overall_sheet": "\u06af\u0632\u0627\u0631\u0634 \u06a9\u0644\u06cc",
|
||||
"users_summary_sheet": "\u062e\u0644\u0627\u0635\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646",
|
||||
"workspace": "\u0641\u0636\u0627\u06cc \u06a9\u0627\u0631\u06cc",
|
||||
"period": "\u0628\u0627\u0632\u0647",
|
||||
"from_date": "\u0627\u0632 \u062a\u0627\u0631\u06cc\u062e",
|
||||
"to_date": "\u062a\u0627 \u062a\u0627\u0631\u06cc\u062e",
|
||||
"user": "\u06a9\u0627\u0631\u0628\u0631",
|
||||
"mobile": "\u0645\u0648\u0628\u0627\u06cc\u0644",
|
||||
"all_users": "\u0647\u0645\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646",
|
||||
"generated_at": "\u062a\u0627\u0631\u06cc\u062e \u062a\u0648\u0644\u06cc\u062f",
|
||||
"summary": "\u062e\u0644\u0627\u0635\u0647",
|
||||
"total_hours": "\u06a9\u0644 \u0633\u0627\u0639\u0627\u062a",
|
||||
"billable_hours": "\u0633\u0627\u0639\u0627\u062a \u06a9\u0627\u0631\u06cc",
|
||||
"non_billable_hours": "\u0633\u0627\u0639\u0627\u062a \u063a\u06cc\u0631 \u06a9\u0627\u0631\u06cc",
|
||||
"hourly_rate": "\u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc",
|
||||
"income": "\u06a9\u0627\u0631\u06a9\u0631\u062f",
|
||||
"working_hours": "\u0633\u0627\u0639\u0627\u062a \u06a9\u0627\u0631\u06cc",
|
||||
"non_working_hours": "\u0633\u0627\u0639\u0627\u062a \u063a\u06cc\u0631\u06a9\u0627\u0631\u06cc",
|
||||
"hourly_rates": "\u0646\u0631\u062e\u200c\u0647\u0627\u06cc \u0633\u0627\u0639\u062a\u06cc",
|
||||
"project_percentages": "\u062f\u0631\u0635\u062f \u067e\u0631\u0648\u0698\u0647\u200c\u0647\u0627",
|
||||
"client_percentages": "\u062f\u0631\u0635\u062f \u0645\u0634\u062a\u0631\u06cc\u200c\u0647\u0627",
|
||||
"tag_percentages": "\u062f\u0631\u0635\u062f \u062a\u06af\u200c\u0647\u0627",
|
||||
"summary_by_user": "\u062e\u0644\u0627\u0635\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646",
|
||||
"rate_history": "\u062a\u0627\u0631\u06cc\u062e\u0686\u0647 \u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc",
|
||||
"from": "\u0627\u0632",
|
||||
"to": "\u062a\u0627",
|
||||
"percentage": "\u062f\u0631\u0635\u062f",
|
||||
"hour_percentage": "\u062f\u0631\u0635\u062f \u0633\u0627\u0639\u062a",
|
||||
"income_percentage": "\u062f\u0631\u0635\u062f \u06a9\u0627\u0631\u06a9\u0631\u062f",
|
||||
"none": "\u0628\u062f\u0648\u0646 \u0645\u0648\u0631\u062f",
|
||||
"daily_summary": "\u062e\u0644\u0627\u0635\u0647 \u0631\u0648\u0632\u0627\u0646\u0647",
|
||||
"clients": "\u0645\u0634\u062a\u0631\u06cc\u0627\u0646",
|
||||
"projects": "\u067e\u0631\u0648\u0698\u0647\u200c\u0647\u0627",
|
||||
"tags": "\u062a\u06af\u200c\u0647\u0627",
|
||||
"date": "\u062a\u0627\u0631\u06cc\u062e",
|
||||
"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",
|
||||
},
|
||||
"fa": {
|
||||
"this_week": "این هفته",
|
||||
"this_month": "این ماه",
|
||||
"this_year": "امسال",
|
||||
"half_year_first": "نیمه اول سال",
|
||||
"half_year_second": "نیمه دوم سال",
|
||||
"period": "بازه دلخواه",
|
||||
"this_week": "\u0627\u06cc\u0646 \u0647\u0641\u062a\u0647",
|
||||
"this_month": "\u0627\u06cc\u0646 \u0645\u0627\u0647",
|
||||
"this_year": "\u0627\u0645\u0633\u0627\u0644",
|
||||
"half_year_first": "\u0646\u06cc\u0645\u0647 \u0627\u0648\u0644 \u0633\u0627\u0644",
|
||||
"half_year_second": "\u0646\u06cc\u0645\u0647 \u062f\u0648\u0645 \u0633\u0627\u0644",
|
||||
"period": "\u0628\u0627\u0632\u0647 \u062f\u0644\u062e\u0648\u0627\u0647",
|
||||
},
|
||||
}
|
||||
|
||||
CURRENCY_LABELS = {
|
||||
"USD": {"en": "USD", "fa": "دلار آمریکا"},
|
||||
"EUR": {"en": "EUR", "fa": "یورو"},
|
||||
"GBP": {"en": "GBP", "fa": "پوند"},
|
||||
"IRR": {"en": "IRR", "fa": "ریال"},
|
||||
"IRT": {"en": "IRT", "fa": "تومان"},
|
||||
"AED": {"en": "AED", "fa": "درهم"},
|
||||
"TRY": {"en": "TRY", "fa": "لیر"},
|
||||
"USD": {"en": "USD", "fa": "\u062f\u0644\u0627\u0631 \u0622\u0645\u0631\u06cc\u06a9\u0627"},
|
||||
"EUR": {"en": "EUR", "fa": "\u06cc\u0648\u0631\u0648"},
|
||||
"GBP": {"en": "GBP", "fa": "\u067e\u0648\u0646\u062f"},
|
||||
"IRR": {"en": "IRR", "fa": "\u0631\u06cc\u0627\u0644"},
|
||||
"IRT": {"en": "IRT", "fa": "\u062a\u0648\u0645\u0627\u0646"},
|
||||
"AED": {"en": "AED", "fa": "\u062f\u0631\u0647\u0645"},
|
||||
"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:
|
||||
invalid = set('[]:*?/\\')
|
||||
invalid = set("[]:*?/\\")
|
||||
sanitized = "".join("-" if char in invalid else char for char in title).strip() or "Sheet"
|
||||
base = sanitized[:31]
|
||||
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)],
|
||||
),
|
||||
_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"])]),
|
||||
):
|
||||
@@ -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_name = row_data.get("name")
|
||||
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:
|
||||
return f"\u202B{value}\u202C" if locale.is_rtl and ascii_digits else value
|
||||
return value
|
||||
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 "-"
|
||||
|
||||
|
||||
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:
|
||||
worksheet.append([])
|
||||
_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("workspace"), scope["workspace"]["name"]]))
|
||||
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(_excel_pair_row([locale.t("to_date"), locale.format_date(scope["to_date"], ascii_digits=True)]))
|
||||
worksheet.append(
|
||||
_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(
|
||||
_excel_pair_row(
|
||||
[
|
||||
@@ -266,16 +315,48 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) ->
|
||||
_excel_pair_row(
|
||||
[
|
||||
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([])
|
||||
_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(_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("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"])]))
|
||||
|
||||
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}:
|
||||
_apply_cell_style(first_cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
|
||||
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:
|
||||
_apply_cell_style(first_cell, bold=False, fill=None, rtl=locale.is_rtl)
|
||||
if second_cell.value:
|
||||
@@ -353,25 +439,26 @@ def _append_breakdown_table(
|
||||
locale: ExportLocale,
|
||||
title_key: str,
|
||||
rows: list[dict],
|
||||
percentages: list[dict] | None = None,
|
||||
hour_percentages: list[dict] | None = None,
|
||||
income_percentages: list[dict] | None = None,
|
||||
) -> None:
|
||||
worksheet.append([])
|
||||
_append_merged_heading(
|
||||
worksheet,
|
||||
locale=locale,
|
||||
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
|
||||
headers = [
|
||||
locale.t("name"),
|
||||
locale.t("billable_hours"),
|
||||
*( [locale.t("hour_percentage")] if hour_percentages is not None else [] ),
|
||||
locale.t("non_billable_hours"),
|
||||
locale.t("total_hours"),
|
||||
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))
|
||||
for cell in worksheet[header_row]:
|
||||
_apply_cell_style(cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
|
||||
@@ -385,15 +472,21 @@ def _append_breakdown_table(
|
||||
values = [
|
||||
row["name"],
|
||||
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["total_duration"], ascii_digits=True),
|
||||
_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:
|
||||
values.append(_percentage_display(locale, percentages, row, ascii_digits=True))
|
||||
worksheet.append(
|
||||
_excel_table_row(values)
|
||||
)
|
||||
worksheet.append(_excel_table_row(values))
|
||||
for cell in worksheet[worksheet.max_row]:
|
||||
_apply_cell_style(cell, rtl=locale.is_rtl)
|
||||
|
||||
@@ -415,21 +508,24 @@ def _append_user_details_block_excel(
|
||||
locale=locale,
|
||||
title_key="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(
|
||||
worksheet,
|
||||
locale=locale,
|
||||
title_key="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(
|
||||
worksheet,
|
||||
locale=locale,
|
||||
title_key="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_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 [])
|
||||
]
|
||||
client_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)}%"]
|
||||
for row in (summary.get("client_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)}%"]
|
||||
for row in (summary.get("project_percentages") or [])
|
||||
]
|
||||
tag_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)}%"]
|
||||
for row in (summary.get("tag_percentages") or [])
|
||||
]
|
||||
client_rows = _summary_breakdown_rows(
|
||||
locale,
|
||||
summary.get("client_percentages") or [],
|
||||
summary.get("client_income_percentages") or [],
|
||||
)
|
||||
project_rows = _summary_breakdown_rows(
|
||||
locale,
|
||||
summary.get("project_percentages") or [],
|
||||
summary.get("project_income_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)
|
||||
rows: list[list[str | None]] = []
|
||||
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_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,
|
||||
_money_label_excel(locale, summary["income_totals"]) if index == 0 else None,
|
||||
*(rate_rows[index] if index < len(rate_rows) else [None, None]),
|
||||
*(client_rows[index] if index < len(client_rows) else [None, None]),
|
||||
*(project_rows[index] if index < len(project_rows) else [None, None]),
|
||||
*(tag_rows[index] if index < len(tag_rows) else [None, None]),
|
||||
_money_label_excel(locale, summary["income_totals"]) if index == 0 else None,
|
||||
*(client_rows[index] if index < len(client_rows) else [None, None, None]),
|
||||
*(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
|
||||
@@ -560,7 +662,7 @@ def _render_all_users_overall_excel_sheet(
|
||||
worksheet,
|
||||
row=15,
|
||||
start_col=1,
|
||||
end_col=13,
|
||||
end_col=16,
|
||||
value=locale.t("users_summary_sheet"),
|
||||
rtl=locale.is_rtl,
|
||||
)
|
||||
@@ -569,15 +671,18 @@ def _render_all_users_overall_excel_sheet(
|
||||
locale.t("mobile"),
|
||||
locale.t("working_hours"),
|
||||
locale.t("non_working_hours"),
|
||||
locale.t("income"),
|
||||
locale.t("hourly_rate"),
|
||||
locale.t("period"),
|
||||
locale.t("income"),
|
||||
locale.t("clients"),
|
||||
locale.t("percentage"),
|
||||
locale.t("hour_percentage"),
|
||||
locale.t("income_percentage"),
|
||||
locale.t("projects"),
|
||||
locale.t("percentage"),
|
||||
locale.t("hour_percentage"),
|
||||
locale.t("income_percentage"),
|
||||
locale.t("tags"),
|
||||
locale.t("percentage"),
|
||||
locale.t("hour_percentage"),
|
||||
locale.t("income_percentage"),
|
||||
]
|
||||
_write_table_row(
|
||||
worksheet,
|
||||
@@ -599,33 +704,58 @@ def _render_all_users_overall_excel_sheet(
|
||||
values=values,
|
||||
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)
|
||||
rate_rows = user_summary.get("rate_periods") or []
|
||||
client_rows = user_summary.get("client_percentages") or []
|
||||
project_rows = user_summary.get("project_percentages") or []
|
||||
tag_rows = user_summary.get("tag_percentages") or []
|
||||
if len(rate_rows) == 1:
|
||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=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=7, value_present=True)
|
||||
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=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)
|
||||
if len(project_rows) == 1:
|
||||
_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=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 += 2
|
||||
for title_key, rows, percentages in (
|
||||
("clients", report_data["clients"], report_data.get("client_percentages")),
|
||||
("projects", report_data["projects"], report_data.get("project_percentages")),
|
||||
("tags", report_data["tags"], report_data.get("tag_percentages")),
|
||||
for title_key, rows, hour_percentages, income_percentages in (
|
||||
(
|
||||
"clients",
|
||||
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
|
||||
_write_table_row(
|
||||
worksheet,
|
||||
@@ -634,10 +764,11 @@ def _render_all_users_overall_excel_sheet(
|
||||
values=[
|
||||
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("percentage"),
|
||||
locale.t("income_percentage"),
|
||||
],
|
||||
rtl=locale.is_rtl,
|
||||
bold=True,
|
||||
@@ -653,10 +784,11 @@ def _render_all_users_overall_excel_sheet(
|
||||
values=[
|
||||
row["name"],
|
||||
locale.format_duration(row["billable_duration"], ascii_digits=True),
|
||||
_percentage_display(locale, hour_percentages or [], row, ascii_digits=True),
|
||||
locale.format_duration(row["non_billable_duration"], ascii_digits=True),
|
||||
locale.format_duration(row["total_duration"], ascii_digits=True),
|
||||
_money_label_excel(locale, row["income_totals"]),
|
||||
_percentage_display(locale, percentages or [], row, ascii_digits=True),
|
||||
_percentage_display(locale, income_percentages or [], row, ascii_digits=True),
|
||||
],
|
||||
rtl=locale.is_rtl,
|
||||
)
|
||||
@@ -666,7 +798,7 @@ def _render_all_users_overall_excel_sheet(
|
||||
worksheet,
|
||||
row=current_row,
|
||||
start_col=1,
|
||||
values=[locale.t("no_data"), None, None, None, None, None],
|
||||
values=[locale.t("no_data"), None, None, None, None, None, None],
|
||||
rtl=locale.is_rtl,
|
||||
)
|
||||
current_row += 1
|
||||
@@ -677,15 +809,18 @@ def _render_all_users_overall_excel_sheet(
|
||||
"B": 19.86,
|
||||
"C": 18.0,
|
||||
"D": 17.0,
|
||||
"E": 24.0,
|
||||
"F": 17.57,
|
||||
"G": 32.0,
|
||||
"H": 30.0,
|
||||
"E": 18.0,
|
||||
"F": 26.0,
|
||||
"G": 24.0,
|
||||
"H": 28.0,
|
||||
"I": 14.0,
|
||||
"J": 32.86,
|
||||
"K": 12.0,
|
||||
"L": 22.0,
|
||||
"M": 12.0,
|
||||
"J": 16.0,
|
||||
"K": 28.0,
|
||||
"L": 14.0,
|
||||
"M": 16.0,
|
||||
"N": 24.0,
|
||||
"O": 14.0,
|
||||
"P": 16.0,
|
||||
}
|
||||
for column, width in overall_widths.items():
|
||||
worksheet.column_dimensions[column].width = width
|
||||
@@ -715,21 +850,48 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -
|
||||
locale=locale,
|
||||
title_key="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(
|
||||
worksheet,
|
||||
locale=locale,
|
||||
title_key="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(
|
||||
worksheet,
|
||||
locale=locale,
|
||||
title_key="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)
|
||||
|
||||
@@ -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)
|
||||
used_titles.add(overall_sheet.title)
|
||||
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)
|
||||
_render_excel_sheet(worksheet, locale=locale, report_data=user_report)
|
||||
used_titles.add(user_title)
|
||||
@@ -924,61 +1093,77 @@ def _append_pdf_report_sections(
|
||||
for title_key, rows, is_daily in sections:
|
||||
story.append(_paragraph(locale.t(title_key), section_style, locale))
|
||||
story.append(Spacer(1, 2 * mm))
|
||||
header_values = [
|
||||
locale.t("date") if is_daily else locale.t("name"),
|
||||
locale.t("billable_hours"),
|
||||
locale.t("non_billable_hours"),
|
||||
locale.t("total_hours"),
|
||||
*([locale.t("hourly_rate")] if is_daily else []),
|
||||
locale.t("income"),
|
||||
]
|
||||
percentage_rows = None
|
||||
header_values = (
|
||||
[
|
||||
locale.t("date"),
|
||||
locale.t("billable_hours"),
|
||||
locale.t("non_billable_hours"),
|
||||
locale.t("total_hours"),
|
||||
locale.t("hourly_rate"),
|
||||
locale.t("income"),
|
||||
]
|
||||
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:
|
||||
percentage_rows = user_summary[f"{title_key[:-1]}_percentages"] if title_key != "clients" else user_summary["client_percentages"]
|
||||
header_values.append(locale.t("percentage"))
|
||||
prefix = title_key[:-1] if title_key != "clients" else "client"
|
||||
hour_percentage_rows = user_summary[f"{prefix}_percentages"]
|
||||
income_percentage_rows = user_summary[f"{prefix}_income_percentages"]
|
||||
header = _rtl_row(locale, header_values)
|
||||
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 = [
|
||||
_rtl_row(
|
||||
locale,
|
||||
[
|
||||
row["name"],
|
||||
locale.format_duration(row["billable_duration"]),
|
||||
_percentage_display(locale, hour_percentage_rows, row),
|
||||
locale.format_duration(row["non_billable_duration"]),
|
||||
locale.format_duration(row["total_duration"]),
|
||||
_money_label(locale, row["income_totals"]),
|
||||
_percentage_display(locale, percentage_rows, row),
|
||||
_percentage_display(locale, income_percentage_rows or [], row),
|
||||
],
|
||||
)
|
||||
for row in rows
|
||||
] or [_rtl_row(locale, [locale.t("no_data"), "", "", "", "", ""])]
|
||||
] or [_rtl_row(locale, [locale.t("no_data"), "", "", "", "", "", ""])]
|
||||
table = _styled_table(
|
||||
[header, *body_rows],
|
||||
locale=locale,
|
||||
column_widths=(
|
||||
[
|
||||
doc_width * 0.21,
|
||||
doc_width * 0.13,
|
||||
doc_width * 0.20,
|
||||
doc_width * 0.12,
|
||||
doc_width * 0.15,
|
||||
doc_width * 0.13,
|
||||
doc_width * 0.16,
|
||||
doc_width * 0.22,
|
||||
doc_width * 0.24,
|
||||
]
|
||||
if is_daily
|
||||
else [
|
||||
*(
|
||||
[
|
||||
doc_width * 0.24,
|
||||
doc_width * 0.13,
|
||||
doc_width * 0.15,
|
||||
doc_width * 0.20,
|
||||
doc_width * 0.11,
|
||||
doc_width * 0.11,
|
||||
doc_width * 0.12,
|
||||
doc_width * 0.2,
|
||||
doc_width * 0.16,
|
||||
doc_width * 0.12,
|
||||
doc_width * 0.19,
|
||||
doc_width * 0.15,
|
||||
]
|
||||
if percentage_rows is not None
|
||||
if hour_percentage_rows is not None
|
||||
else [
|
||||
doc_width * 0.26,
|
||||
doc_width * 0.13,
|
||||
doc_width * 0.15,
|
||||
doc_width * 0.17,
|
||||
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("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("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())],
|
||||
]
|
||||
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("working_hours"),
|
||||
locale.t("non_working_hours"),
|
||||
locale.t("hourly_rate"),
|
||||
locale.t("period"),
|
||||
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_duration(summary["billable_duration"]),
|
||||
locale.format_duration(summary["non_billable_duration"]),
|
||||
_rates_label(locale, summary.get("hourly_rates") or []),
|
||||
_summary_period_label(locale, summary.get("rate_periods") or []),
|
||||
_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],
|
||||
locale=locale,
|
||||
column_widths=[
|
||||
doc.width * 0.24,
|
||||
doc.width * 0.18,
|
||||
doc.width * 0.18,
|
||||
doc.width * 0.18,
|
||||
doc.width * 0.22,
|
||||
doc.width * 0.13,
|
||||
doc.width * 0.13,
|
||||
doc.width * 0.13,
|
||||
doc.width * 0.13,
|
||||
doc.width * 0.16,
|
||||
doc.width * 0.14,
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user