Compare commits

..

3 Commits

Author SHA1 Message Date
4d05d4d590 fix(users): trace google oauth redirect mismatches
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-21 19:12:45 +03:30
8d2f876c82 feat(reports): add uncategorized dual-share exports 2026-05-21 19:10:33 +03:30
e234eac26d fix(time-entries): use server time for running timers 2026-05-21 13:01:51 +03:30
11 changed files with 968 additions and 341 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
],
)
)

View File

@@ -68,6 +68,9 @@ def make_user_summary(*, name: str, mobile: str):
"project_percentages": [{"id": "1", "name": "Website", "percentage": "100"}],
"client_percentages": [{"id": "1", "name": "Acme", "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["B1"].value, "Exports")
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(
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",
"Mobile",
"Working hours",
"Non-working hours",
"Income",
"Hourly rate",
"Period",
"Income",
"Clients",
"Percentage",
"Hour %",
"Income %",
"Projects",
"Percentage",
"Hour %",
"Income %",
"Tags",
"Percentage",
"Hour %",
"Income %",
),
)
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)
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):
locale = build_export_locale("fa")
report_data = make_report_data(
@@ -168,7 +188,16 @@ class ReportExporterTests(TestCase):
)
report_data["user_summaries"] = [make_user_summary(name="Owner User", mobile="09129990001")]
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)

View File

@@ -147,9 +147,130 @@ class ReportViewTests(APITestCase):
self.assertEqual(owner_summary["project_percentages"][0]["percentage"], "100")
self.assertEqual(owner_summary["client_percentages"][0]["percentage"], "100")
self.assertEqual(owner_summary["tag_percentages"][0]["percentage"], "100")
self.assertEqual(member_summary["project_percentages"], [])
self.assertEqual(member_summary["client_percentages"], [])
self.assertEqual(member_summary["tag_percentages"], [])
self.assertEqual(member_summary["project_percentages"][0]["percentage"], "0")
self.assertEqual(member_summary["client_percentages"][0]["percentage"], "0")
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):
self.client.force_authenticate(user=self.owner)

View File

@@ -93,7 +93,7 @@ class TimeEntryCreateSerializer(serializers.Serializer):
"""
workspace_id = serializers.UUIDField()
project_id = serializers.UUIDField(required=False, allow_null=True)
start_time = serializers.DateTimeField()
start_time = serializers.DateTimeField(required=False)
end_time = serializers.DateTimeField(required=False, allow_null=True)
description = serializers.CharField(required=False, allow_blank=True, default="")
tags = serializers.ListField(child=serializers.UUIDField(), required=False)
@@ -102,6 +102,12 @@ class TimeEntryCreateSerializer(serializers.Serializer):
def validate(self, attrs):
user = self.context.get("request").user if self.context.get("request") else None
workspace_id = attrs.get("workspace_id")
start_time = attrs.get("start_time")
end_time = attrs.get("end_time")
if end_time is not None and start_time is None:
raise serializers.ValidationError({"start_time": "Start time is required when end time is provided."})
project_id = attrs.pop("project_id", serializers.empty)
if project_id is not serializers.empty:
if project_id is None:

View File

@@ -22,24 +22,29 @@ def _verify_workspace_access(user, workspace_id):
raise PermissionDenied("You do not have access to this workspace.")
def create_time_entry(user, workspace_id, start_time, end_time=None, project=None, tags=None, description="", is_billable=False):
def create_time_entry(user, workspace_id, start_time=None, end_time=None, project=None, tags=None, description="", is_billable=False):
"""
Creates a new time entry. If end_time is None, it acts as a running timer.
"""
_verify_workspace_access(user, workspace_id)
if not end_time:
has_running_timer = TimeEntry.objects.filter(
workspace_id=workspace_id,
user=user,
end_time__isnull=True,
is_deleted=False
).exists()
if has_running_timer:
raise ValidationError({"non_field_errors": "You already have a running timer in this workspace."})
if start_time and end_time and start_time >= end_time:
raise ValidationError({"end_time": "End time must be strictly after start time."})
if not end_time:
has_running_timer = TimeEntry.objects.filter(
workspace_id=workspace_id,
user=user,
end_time__isnull=True,
is_deleted=False
).exists()
if has_running_timer:
raise ValidationError({"non_field_errors": "You already have a running timer in this workspace."})
if start_time is None:
if end_time is not None:
raise ValidationError({"start_time": "Start time is required when end time is provided."})
start_time = timezone.now()
if start_time and end_time and start_time >= end_time:
raise ValidationError({"end_time": "End time must be strictly after start time."})
if project and project.workspace_id != workspace_id:
raise ValidationError({"project": "Project must belong to the same workspace."})

View File

@@ -47,6 +47,18 @@ class TimeEntryServiceTests(TestCase):
self.assertIsNotNone(stopped_entry.end_time)
self.assertIsNotNone(stopped_entry.duration)
def test_create_running_time_entry_defaults_start_time_to_server_now(self):
before = timezone.now()
entry = create_time_entry(
user=self.user,
workspace_id=self.workspace.id,
)
after = timezone.now()
self.assertIsNone(entry.end_time)
self.assertGreaterEqual(entry.start_time, before)
self.assertLessEqual(entry.start_time, after)
def test_update_time_entry_preserves_deleted_project_and_tags(self):
project = Project.objects.create(workspace=self.workspace, name="Deleted project")
tag = Tag.objects.create(

View File

@@ -19,6 +19,28 @@ def make_aware(year, month, day, hour=9, minute=0, second=0):
class TimeEntryViewTests(APITestCase):
def test_create_running_time_entry_without_start_time_uses_server_time(self):
user = User.objects.create_user(mobile="09125555555", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user)
self.client.force_authenticate(user=user)
before = timezone.now()
response = self.client.post(
"/api/time-entries/",
{
"workspace_id": str(workspace.id),
"description": "Running work",
},
format="json",
)
after = timezone.now()
self.assertEqual(response.status_code, 201)
entry = TimeEntry.objects.get(id=response.data["id"])
self.assertIsNone(entry.end_time)
self.assertGreaterEqual(entry.start_time, before)
self.assertLessEqual(entry.start_time, after)
def test_time_entry_list_returns_grouped_payload_for_ended_entries(self):
user = User.objects.create_user(mobile="09126666666", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user)

View File

@@ -1,10 +1,10 @@
from __future__ import annotations
import logging
import secrets
from dataclasses import asdict, dataclass, is_dataclass
from typing import Any
from urllib.parse import urlencode
from urllib.parse import urlparse
from urllib.parse import urlencode, urlparse
import requests
from django.conf import settings
@@ -16,7 +16,6 @@ from apps.users.email_identity import mask_mobile, normalize_email_identity
from apps.users.models import User, UserSocialAccount
from apps.users.services.auth import generate_and_send_otp, get_tokens_for_user
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo"
@@ -27,6 +26,8 @@ GOOGLE_FLOW_TTL_SECONDS = 900
GOOGLE_STATE_CACHE_PREFIX = "google_oauth_state"
GOOGLE_FLOW_CACHE_PREFIX = "google_oauth_flow"
logger = logging.getLogger(__name__)
class GoogleOAuthFlowError(APIException):
status_code = 409
@@ -305,6 +306,16 @@ def exchange_code_for_google_profile(code: str) -> GoogleProfile:
token_response.raise_for_status()
token_payload = token_response.json()
except requests.RequestException as exc:
response = getattr(exc, "response", None)
logger.warning(
"Google token exchange failed",
extra={
"google_status_code": getattr(response, "status_code", None),
"google_response_text": getattr(response, "text", "")[:1000] if response is not None else "",
"google_redirect_uri": getattr(settings, "GOOGLE_OAUTH_REDIRECT_URI", ""),
},
exc_info=True,
)
raise ValidationError({"detail": "Google token exchange failed."}) from exc
access_token = token_payload.get("access_token")
@@ -320,6 +331,15 @@ def exchange_code_for_google_profile(code: str) -> GoogleProfile:
userinfo_response.raise_for_status()
userinfo = userinfo_response.json()
except requests.RequestException as exc:
response = getattr(exc, "response", None)
logger.warning(
"Google user profile lookup failed",
extra={
"google_status_code": getattr(response, "status_code", None),
"google_response_text": getattr(response, "text", "")[:1000] if response is not None else "",
},
exc_info=True,
)
raise ValidationError({"detail": "Google user profile lookup failed."}) from exc
provider_user_id = userinfo.get("sub", "")
@@ -431,7 +451,10 @@ def complete_google_signup(flow: str, mobile: str) -> dict[str, Any]:
user=existing_mobile_user,
mobile=normalized_mobile,
resolution="existing_mobile_claim",
detail="Existing mobile account found. Verify ownership to attach Google and set the verified email address.",
detail=(
"Existing mobile account found. Verify ownership to attach "
"Google and set the verified email address."
),
)
update_google_flow(flow, claim_payload)
return _build_public_google_flow_payload(claim_payload)

View File

@@ -1,18 +1,22 @@
from io import StringIO
from unittest.mock import Mock, patch
from urllib.parse import parse_qs, urlparse
from django.conf import settings
from django.core.cache import cache
from django.core.management import call_command
from django.db import IntegrityError
from django.test import override_settings
from rest_framework.test import APIRequestFactory
from rest_framework import status
from rest_framework.test import APITestCase
from rest_framework.test import APIRequestFactory, APITestCase
from apps.users.api.views import RegisterWithPasswordView
from apps.users.models import User, UserSocialAccount
from apps.users.services.google_oauth import GoogleProfile
from apps.users.services.google_oauth import (
GoogleProfile,
build_google_authorization_url,
exchange_code_for_google_profile,
)
class UserApiViewTests(APITestCase):
@@ -551,6 +555,42 @@ class GoogleOAuthApiTests(APITestCase):
self.assertIn("accounts.google.com", response["Location"])
self.assertIn("state=", response["Location"])
@patch("apps.users.services.google_oauth.requests.get")
@patch("apps.users.services.google_oauth.requests.post")
def test_google_token_exchange_uses_the_same_configured_redirect_uri_as_authorization_url(
self,
requests_post,
requests_get,
):
auth_url = build_google_authorization_url()
parsed_auth_url = urlparse(auth_url)
auth_redirect_uri = parse_qs(parsed_auth_url.query)["redirect_uri"][0]
token_response = Mock()
token_response.raise_for_status.return_value = None
token_response.json.return_value = {"access_token": "google-access-token"}
requests_post.return_value = token_response
userinfo_response = Mock()
userinfo_response.raise_for_status.return_value = None
userinfo_response.json.return_value = {
"sub": "google-sub-redirect-uri",
"email": "redirect@example.com",
"email_verified": True,
"given_name": "Redirect",
"family_name": "Uri",
"picture": "https://example.com/avatar.png",
}
requests_get.return_value = userinfo_response
exchange_code_for_google_profile("google-auth-code")
self.assertEqual(
requests_post.call_args.kwargs["data"]["redirect_uri"],
auth_redirect_uri,
)
self.assertEqual(auth_redirect_uri, settings.GOOGLE_OAUTH_REDIRECT_URI)
@patch("apps.users.services.google_oauth.requests.get")
@patch("apps.users.api.views.exchange_code_for_google_profile")
def test_google_callback_redirects_with_authenticated_flow_for_linked_account(
@@ -995,7 +1035,7 @@ class GoogleOAuthAuditCommandTests(APITestCase):
password="secret123",
email="owner@example.com",
)
other_user = User.objects.create_user(
User.objects.create_user(
mobile="09126660002",
password="secret123",
email="shared@example.com",