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 __future__ import annotations
from collections import defaultdict from collections import defaultdict
from collections.abc import Iterable
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta
from decimal import Decimal, ROUND_HALF_UP from decimal import ROUND_DOWN, Decimal
from typing import Iterable
import jdatetime import jdatetime
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@@ -39,6 +39,25 @@ ALLOWED_PERIODS = {
PERIOD_CUSTOM, PERIOD_CUSTOM,
} }
UNCATEGORIZED_IDS = {
"clients": "__uncategorized_client__",
"projects": "__uncategorized_project__",
"tags": "__uncategorized_tag__",
}
UNCATEGORIZED_LABELS = {
"en": {
"clients": "No client",
"projects": "No project",
"tags": "No tag",
},
"fa": {
"clients": "\u0628\u062f\u0648\u0646 \u0645\u0634\u062a\u0631\u06cc",
"projects": "\u0628\u062f\u0648\u0646 \u067e\u0631\u0648\u0698\u0647",
"tags": "\u0628\u062f\u0648\u0646 \u062a\u06af",
},
}
def _start_of_week(local_date: date) -> date: def _start_of_week(local_date: date) -> date:
days_since_sunday = (local_date.weekday() + 1) % 7 days_since_sunday = (local_date.weekday() + 1) % 7
@@ -157,62 +176,209 @@ def _serialize_rate_periods(entries: list[TimeEntry]) -> list[dict]:
return periods return periods
def _serialize_percentage_rows(shares: dict[str, dict], total_seconds: int) -> list[dict]: def _uncategorized_label(kind: str, language: str) -> str:
if total_seconds <= 0: resolved_language = language if language in UNCATEGORIZED_LABELS else "en"
return [] return UNCATEGORIZED_LABELS[resolved_language][kind]
rows = []
for bucket in shares.values():
percentage = (
Decimal(bucket["seconds"]) * Decimal("100") / Decimal(total_seconds)
).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
rows.append(
{
"id": bucket["id"],
"name": bucket["name"],
"percentage": f"{percentage}",
}
)
rows.sort(key=lambda item: (-Decimal(item["percentage"]), item["name"].lower()))
return rows
def _build_user_summary(user, entries: list[TimeEntry]) -> dict: def _share_bucket(bucket_id: str, name: str) -> dict:
summary = _summary_from_entries(entries) return {
project_shares: dict[str, dict] = {} "id": bucket_id,
client_shares: dict[str, dict] = {} "name": name,
tag_shares: dict[str, dict] = {} "seconds": Decimal("0"),
"income": _money_map(),
}
total_seconds = summary["billable_seconds"]
def _entry_income_payload(entry: TimeEntry) -> tuple[str, Decimal] | None:
if not entry.is_billable or not entry.hourly_rate:
return None
duration_seconds = get_entry_duration_seconds(entry)
if duration_seconds <= 0:
return None
hourly_rate = Decimal(entry.hourly_rate)
income = (hourly_rate * Decimal(duration_seconds) / Decimal(3600)).quantize(Decimal("0.01"))
return entry.currency or "USD", income
def _add_money(bucket: defaultdict[str, Decimal], currency: str, amount: Decimal) -> None:
bucket[currency] += amount
def _breakdown_targets(entry: TimeEntry, kind: str, language: str) -> list[tuple[str, str]]:
if kind == "clients":
if entry.project and entry.project.client:
return [(str(entry.project.client_id), entry.project.client.name)]
return [(UNCATEGORIZED_IDS[kind], _uncategorized_label(kind, language))]
if kind == "projects":
if entry.project:
return [(str(entry.project_id), entry.project.name)]
return [(UNCATEGORIZED_IDS[kind], _uncategorized_label(kind, language))]
tags = list(entry.tags.all())
if tags:
return [(str(tag.id), tag.name) for tag in tags]
return [(UNCATEGORIZED_IDS[kind], _uncategorized_label(kind, language))]
def _accumulate_breakdown_shares(entries: list[TimeEntry], kind: str, *, language: str) -> dict[str, dict]:
shares: dict[str, dict] = {}
for entry in entries: for entry in entries:
if not entry.is_billable: if not entry.is_billable:
continue continue
duration_seconds = get_entry_duration_seconds(entry) duration_seconds = get_entry_duration_seconds(entry)
if duration_seconds <= 0: if duration_seconds <= 0:
continue continue
if entry.project_id: targets = _breakdown_targets(entry, kind, language)
project_bucket = project_shares.setdefault( divisor = Decimal(len(targets)) if kind == "tags" and targets else Decimal("1")
str(entry.project_id), income_payload = _entry_income_payload(entry)
{"id": str(entry.project_id), "name": entry.project.name, "seconds": 0},
)
project_bucket["seconds"] += duration_seconds
if entry.project and entry.project.client_id: for bucket_id, bucket_name in targets:
client_bucket = client_shares.setdefault( bucket = shares.setdefault(bucket_id, _share_bucket(bucket_id, bucket_name))
str(entry.project.client_id), bucket["seconds"] += Decimal(duration_seconds) / divisor
{"id": str(entry.project.client_id), "name": entry.project.client.name, "seconds": 0}, if income_payload:
) currency, amount = income_payload
client_bucket["seconds"] += duration_seconds _add_money(bucket["income"], currency, amount / divisor)
tags = list(entry.tags.all()) return shares
if tags:
allocated_seconds = Decimal(duration_seconds) / Decimal(len(tags))
for tag in tags: def _allocate_percentage_rows(items: list[dict], total_value: Decimal) -> list[dict]:
tag_bucket = tag_shares.setdefault( if total_value <= 0 or not items:
str(tag.id), return []
{"id": str(tag.id), "name": tag.name, "seconds": Decimal("0")},
) working_rows: list[dict] = []
tag_bucket["seconds"] += allocated_seconds assigned_total = 0
for item in items:
value = Decimal(item["value"])
raw_percentage = (value * Decimal("100") / total_value) if value > 0 else Decimal("0")
floored_percentage = int(raw_percentage.quantize(Decimal("1"), rounding=ROUND_DOWN))
assigned_total += floored_percentage
working_rows.append(
{
"id": item["id"],
"name": item["name"],
"value": value,
"percentage": floored_percentage,
"remainder": raw_percentage - Decimal(floored_percentage),
}
)
remaining_points = max(0, 100 - assigned_total)
for row in sorted(
working_rows,
key=lambda item: (-item["remainder"], -item["value"], item["name"].lower(), item["id"]),
)[:remaining_points]:
row["percentage"] += 1
serialized = [
{
"id": row["id"],
"name": row["name"],
"percentage": str(row["percentage"]),
}
for row in working_rows
]
serialized.sort(key=lambda item: (-Decimal(item["percentage"]), item["name"].lower()))
return serialized
def _single_currency_amount(income_totals: list[dict]) -> tuple[str | None, Decimal] | None:
non_zero_totals: list[tuple[str, Decimal]] = []
for item in income_totals:
amount = Decimal(item["amount"])
if amount == 0:
continue
non_zero_totals.append((item["currency"], amount))
if not non_zero_totals:
return None, Decimal("0")
currencies = {currency for currency, _ in non_zero_totals}
if len(currencies) != 1:
return None
currency = non_zero_totals[0][0]
total_amount = sum((amount for _, amount in non_zero_totals), Decimal("0"))
return currency, total_amount
def _complete_percentage_rows(
rows: list[dict],
percentage_rows: list[dict],
*,
unavailable: bool = False,
) -> list[dict]:
if unavailable:
return []
existing_ids = {row["id"] for row in percentage_rows}
completed = percentage_rows + [
{"id": row["id"], "name": row["name"], "percentage": "0"}
for row in rows
if row["id"] not in existing_ids
]
completed.sort(key=lambda item: (-Decimal(item["percentage"]), item["name"].lower()))
return completed
def _serialize_time_percentage_rows(rows: list[dict], shares: dict[str, dict]) -> list[dict]:
items = [
{
"id": bucket["id"],
"name": bucket["name"],
"value": Decimal(bucket["seconds"]),
}
for bucket in shares.values()
]
total_seconds = sum((item["value"] for item in items), Decimal("0"))
return _complete_percentage_rows(rows, _allocate_percentage_rows(items, total_seconds))
def _serialize_income_percentage_rows(rows: list[dict], shares: dict[str, dict]) -> list[dict]:
items: list[dict] = []
currencies: set[str] = set()
for bucket in shares.values():
income_totals = _serialize_money_totals(bucket["income"])
currency_amount = _single_currency_amount(income_totals)
if currency_amount is None:
return []
currency, amount = currency_amount
if currency:
currencies.add(currency)
items.append(
{
"id": bucket["id"],
"name": bucket["name"],
"value": amount,
}
)
if len(currencies) > 1:
return []
total_income = sum((item["value"] for item in items), Decimal("0"))
if total_income <= 0:
return []
return _complete_percentage_rows(rows, _allocate_percentage_rows(items, total_income))
def _build_user_summary(user, entries: list[TimeEntry], *, language: str) -> dict:
summary = _summary_from_entries(entries)
project_rows = _build_breakdown(entries, "projects", language=language)
client_rows = _build_breakdown(entries, "clients", language=language)
tag_rows = _build_breakdown(entries, "tags", language=language)
project_shares = _accumulate_breakdown_shares(entries, "projects", language=language)
client_shares = _accumulate_breakdown_shares(entries, "clients", language=language)
tag_shares = _accumulate_breakdown_shares(entries, "tags", language=language)
return { return {
"user": { "user": {
@@ -222,71 +388,53 @@ def _build_user_summary(user, entries: list[TimeEntry]) -> dict:
}, },
"hourly_rates": _serialize_distinct_rates(entries), "hourly_rates": _serialize_distinct_rates(entries),
"rate_periods": _serialize_rate_periods(entries), "rate_periods": _serialize_rate_periods(entries),
"total_seconds": total_seconds, "total_seconds": summary["billable_seconds"],
"total_duration": summary["total_duration"], "total_duration": summary["total_duration"],
"billable_seconds": summary["billable_seconds"], "billable_seconds": summary["billable_seconds"],
"billable_duration": summary["billable_duration"], "billable_duration": summary["billable_duration"],
"non_billable_seconds": summary["non_billable_seconds"], "non_billable_seconds": summary["non_billable_seconds"],
"non_billable_duration": summary["non_billable_duration"], "non_billable_duration": summary["non_billable_duration"],
"income_totals": summary["income_totals"], "income_totals": summary["income_totals"],
"project_percentages": _serialize_percentage_rows(project_shares, total_seconds), "project_percentages": _serialize_time_percentage_rows(project_rows, project_shares),
"client_percentages": _serialize_percentage_rows(client_shares, total_seconds), "client_percentages": _serialize_time_percentage_rows(client_rows, client_shares),
"tag_percentages": _serialize_percentage_rows(tag_shares, total_seconds), "tag_percentages": _serialize_time_percentage_rows(tag_rows, tag_shares),
"project_income_percentages": _serialize_income_percentage_rows(project_rows, project_shares),
"client_income_percentages": _serialize_income_percentage_rows(client_rows, client_shares),
"tag_income_percentages": _serialize_income_percentage_rows(tag_rows, tag_shares),
} }
def _build_user_summaries(entries: list[TimeEntry]) -> list[dict]: def _build_user_summaries(entries: list[TimeEntry], *, language: str) -> list[dict]:
grouped: dict[str, list[TimeEntry]] = defaultdict(list) grouped: dict[str, list[TimeEntry]] = defaultdict(list)
for entry in entries: for entry in entries:
grouped[str(entry.user_id)].append(entry) grouped[str(entry.user_id)].append(entry)
summaries = [_build_user_summary(grouped_entries[0].user, grouped_entries) for grouped_entries in grouped.values() if grouped_entries] summaries = [
_build_user_summary(grouped_entries[0].user, grouped_entries, language=language)
for grouped_entries in grouped.values()
if grouped_entries
]
summaries.sort(key=lambda item: item["user"]["name"].lower()) summaries.sort(key=lambda item: item["user"]["name"].lower())
return summaries return summaries
def _build_overall_percentage_payload(entries: list[TimeEntry]) -> dict: def _build_overall_percentage_payload(
project_shares: dict[str, dict] = {} entries: list[TimeEntry],
client_shares: dict[str, dict] = {} *,
tag_shares: dict[str, dict] = {} language: str,
total_seconds = 0 rows_by_kind: dict[str, list[dict]],
) -> dict:
for entry in entries: project_shares = _accumulate_breakdown_shares(entries, "projects", language=language)
if not entry.is_billable: client_shares = _accumulate_breakdown_shares(entries, "clients", language=language)
continue tag_shares = _accumulate_breakdown_shares(entries, "tags", language=language)
duration_seconds = get_entry_duration_seconds(entry)
if duration_seconds <= 0:
continue
total_seconds += duration_seconds
if entry.project_id:
project_bucket = project_shares.setdefault(
str(entry.project_id),
{"id": str(entry.project_id), "name": entry.project.name, "seconds": 0},
)
project_bucket["seconds"] += duration_seconds
if entry.project and entry.project.client_id:
client_bucket = client_shares.setdefault(
str(entry.project.client_id),
{"id": str(entry.project.client_id), "name": entry.project.client.name, "seconds": 0},
)
client_bucket["seconds"] += duration_seconds
tags = list(entry.tags.all())
if tags:
allocated_seconds = Decimal(duration_seconds) / Decimal(len(tags))
for tag in tags:
tag_bucket = tag_shares.setdefault(
str(tag.id),
{"id": str(tag.id), "name": tag.name, "seconds": Decimal("0")},
)
tag_bucket["seconds"] += allocated_seconds
return { return {
"project_percentages": _serialize_percentage_rows(project_shares, total_seconds), "project_percentages": _serialize_time_percentage_rows(rows_by_kind["projects"], project_shares),
"client_percentages": _serialize_percentage_rows(client_shares, total_seconds), "client_percentages": _serialize_time_percentage_rows(rows_by_kind["clients"], client_shares),
"tag_percentages": _serialize_percentage_rows(tag_shares, total_seconds), "tag_percentages": _serialize_time_percentage_rows(rows_by_kind["tags"], tag_shares),
"project_income_percentages": _serialize_income_percentage_rows(rows_by_kind["projects"], project_shares),
"client_income_percentages": _serialize_income_percentage_rows(rows_by_kind["clients"], client_shares),
"tag_income_percentages": _serialize_income_percentage_rows(rows_by_kind["tags"], tag_shares),
} }
@@ -344,7 +492,13 @@ class ReportFilterSerializer(serializers.Serializer):
language = serializers.ChoiceField(choices=("en", "fa"), required=False, default="en") language = serializers.ChoiceField(choices=("en", "fa"), required=False, default="en")
def _resolve_period_bounds(period: str, from_date: date | None, to_date: date | None, *, language: str) -> tuple[date, date]: def _resolve_period_bounds(
period: str,
from_date: date | None,
to_date: date | None,
*,
language: str,
) -> tuple[date, date]:
today = timezone.localdate() today = timezone.localdate()
if language == "fa": if language == "fa":
today_jalali = jdatetime.date.fromgregorian(date=today) today_jalali = jdatetime.date.fromgregorian(date=today)
@@ -412,7 +566,11 @@ def load_report_filters(actor, raw_data) -> ReportFilters:
"user": raw_data.get("user"), "user": raw_data.get("user"),
"client": raw_data.get("client"), "client": raw_data.get("client"),
"project": raw_data.get("project"), "project": raw_data.get("project"),
"tags": raw_data.get("tags") or raw_data.getlist("tags") if hasattr(raw_data, "getlist") else raw_data.get("tags"), "tags": (
raw_data.get("tags") or raw_data.getlist("tags")
if hasattr(raw_data, "getlist")
else raw_data.get("tags")
),
"language": raw_data.get("language", "en"), "language": raw_data.get("language", "en"),
} }
if normalized["tags"] and not isinstance(normalized["tags"], list): if normalized["tags"] and not isinstance(normalized["tags"], list):
@@ -476,8 +634,15 @@ def load_report_filters(actor, raw_data) -> ReportFilters:
def _base_queryset(filters: ReportFilters) -> QuerySet[TimeEntry]: def _base_queryset(filters: ReportFilters) -> QuerySet[TimeEntry]:
start_dt = timezone.make_aware(datetime.combine(filters.from_date, time.min), timezone.get_current_timezone()) current_timezone = timezone.get_current_timezone()
end_dt = timezone.make_aware(datetime.combine(filters.to_date + timedelta(days=1), time.min), timezone.get_current_timezone()) start_dt = timezone.make_aware(
datetime.combine(filters.from_date, time.min),
current_timezone,
)
end_dt = timezone.make_aware(
datetime.combine(filters.to_date + timedelta(days=1), time.min),
current_timezone,
)
queryset = ( queryset = (
TimeEntry.objects.filter( TimeEntry.objects.filter(
@@ -660,7 +825,11 @@ def _scope_payload(filters: ReportFilters) -> dict:
"workspace": { "workspace": {
"id": str(filters.workspace.id), "id": str(filters.workspace.id),
"name": filters.workspace.name, "name": filters.workspace.name,
"thumbnail_path": filters.workspace.thumbnail.path if getattr(filters.workspace, "thumbnail", None) else None, "thumbnail_path": (
filters.workspace.thumbnail.path
if getattr(filters.workspace, "thumbnail", None)
else None
),
}, },
"period": filters.period, "period": filters.period,
"from_date": filters.from_date.isoformat(), "from_date": filters.from_date.isoformat(),
@@ -684,16 +853,29 @@ def _table_report_payload(
) -> dict: ) -> dict:
summary = _summary_from_entries(entries) summary = _summary_from_entries(entries)
include_latest_rate = not (filters.is_workspace_scope and not filters.user_id) include_latest_rate = not (filters.is_workspace_scope and not filters.user_id)
client_rows = _build_breakdown(entries, "clients", language=filters.language)
project_rows = _build_breakdown(entries, "projects", language=filters.language)
tag_rows = _build_breakdown(entries, "tags", language=filters.language)
payload = { payload = {
"scope": _scope_payload(filters), "scope": _scope_payload(filters),
"summary": summary, "summary": summary,
"days": _group_daily(entries, include_latest_rate=include_latest_rate), "days": _group_daily(entries, include_latest_rate=include_latest_rate),
"clients": _build_breakdown(entries, "clients"), "clients": client_rows,
"projects": _build_breakdown(entries, "projects"), "projects": project_rows,
"tags": _build_breakdown(entries, "tags"), "tags": tag_rows,
} }
if filters.is_workspace_scope and not filters.user_id: if filters.is_workspace_scope and not filters.user_id:
payload.update(_build_overall_percentage_payload(entries)) payload.update(
_build_overall_percentage_payload(
entries,
language=filters.language,
rows_by_kind={
"clients": client_rows,
"projects": project_rows,
"tags": tag_rows,
},
)
)
if user_summary is not None: if user_summary is not None:
payload["user_summary"] = user_summary payload["user_summary"] = user_summary
if user_summaries is not None: if user_summaries is not None:
@@ -761,64 +943,31 @@ def _group_daily(entries: list[TimeEntry], *, include_latest_rate: bool) -> list
return rows return rows
def _build_breakdown(entries: list[TimeEntry], kind: str) -> list[dict]: def _build_breakdown(entries: list[TimeEntry], kind: str, *, language: str) -> list[dict]:
data: dict[str, dict] = {} data: dict[str, dict] = {}
for entry in entries: for entry in entries:
if kind == "clients":
if not entry.project or not entry.project.client:
continue
item_id = str(entry.project.client_id)
item_name = entry.project.client.name
elif kind == "projects":
if not entry.project:
continue
item_id = str(entry.project_id)
item_name = entry.project.name
else:
if not entry.tags.exists():
continue
for tag in entry.tags.all():
bucket = data.setdefault(
str(tag.id),
{
"id": str(tag.id),
"name": tag.name,
"billable_seconds": 0,
"non_billable_seconds": 0,
"total_seconds": 0,
"income": _money_map(),
},
)
duration_seconds = get_entry_duration_seconds(entry)
bucket["total_seconds"] += duration_seconds
if entry.is_billable:
bucket["billable_seconds"] += duration_seconds
else:
bucket["non_billable_seconds"] += duration_seconds
_add_income(bucket["income"], entry)
continue
bucket = data.setdefault(
item_id,
{
"id": item_id,
"name": item_name,
"billable_seconds": 0,
"non_billable_seconds": 0,
"total_seconds": 0,
"income": _money_map(),
},
)
duration_seconds = get_entry_duration_seconds(entry) duration_seconds = get_entry_duration_seconds(entry)
bucket["total_seconds"] += duration_seconds for item_id, item_name in _breakdown_targets(entry, kind, language):
if entry.is_billable: bucket = data.setdefault(
bucket["billable_seconds"] += duration_seconds item_id,
else: {
bucket["non_billable_seconds"] += duration_seconds "id": item_id,
_add_income(bucket["income"], entry) "name": item_name,
"billable_seconds": 0,
"non_billable_seconds": 0,
"total_seconds": 0,
"income": _money_map(),
},
)
bucket["total_seconds"] += duration_seconds
if entry.is_billable:
bucket["billable_seconds"] += duration_seconds
else:
bucket["non_billable_seconds"] += duration_seconds
_add_income(bucket["income"], entry)
rows = [] rows = []
for item_id, bucket in sorted(data.items(), key=lambda item: item[1]["name"].lower()): for _item_id, bucket in sorted(data.items(), key=lambda item: item[1]["name"].lower()):
rows.append( rows.append(
{ {
"id": bucket["id"], "id": bucket["id"],
@@ -839,8 +988,16 @@ def build_table_report(actor, raw_filters) -> dict:
filters = load_report_filters(actor, raw_filters) filters = load_report_filters(actor, raw_filters)
entries = list(_base_queryset(filters)) entries = list(_base_queryset(filters))
if filters.is_workspace_scope and not filters.user_id: if filters.is_workspace_scope and not filters.user_id:
return _table_report_payload(filters, entries, user_summaries=_build_user_summaries(entries)) return _table_report_payload(
user_summary = _build_user_summary(entries[0].user, entries) if entries and filters.user_id else None filters,
entries,
user_summaries=_build_user_summaries(entries, language=filters.language),
)
user_summary = (
_build_user_summary(entries[0].user, entries, language=filters.language)
if entries and filters.user_id
else None
)
return _table_report_payload(filters, entries, user_summary=user_summary) return _table_report_payload(filters, entries, user_summary=user_summary)
@@ -866,7 +1023,11 @@ def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
_table_report_payload( _table_report_payload(
user_filters, user_filters,
user_entries, user_entries,
user_summary=_build_user_summary(user_entries[0].user, user_entries), user_summary=_build_user_summary(
user_entries[0].user,
user_entries,
language=filters.language,
),
) )
) )
return reports return reports

View File

@@ -1,16 +1,16 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from pathlib import Path from pathlib import Path
from typing import Iterable
import jdatetime import jdatetime
from arabic_reshaper import reshape from arabic_reshaper import reshape
from bidi.algorithm import get_display from bidi.algorithm import get_display
PERSIAN_DIGITS = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹") PERSIAN_DIGITS = str.maketrans("0123456789", "\u06f0\u06f1\u06f2\u06f3\u06f4\u06f5\u06f6\u06f7\u06f8\u06f9")
ARABIC_RANGES = ( ARABIC_RANGES = (
(0x0600, 0x06FF), (0x0600, 0x06FF),
(0x0750, 0x077F), (0x0750, 0x077F),
@@ -50,6 +50,8 @@ TRANSLATIONS = {
"from": "From", "from": "From",
"to": "To", "to": "To",
"percentage": "Percentage", "percentage": "Percentage",
"hour_percentage": "Hour %",
"income_percentage": "Income %",
"none": "None", "none": "None",
"daily_summary": "Daily Summary", "daily_summary": "Daily Summary",
"clients": "Clients", "clients": "Clients",
@@ -59,45 +61,53 @@ TRANSLATIONS = {
"name": "Name", "name": "Name",
"total": "Total", "total": "Total",
"no_data": "No data", "no_data": "No data",
"uncategorized_client": "No client",
"uncategorized_project": "No project",
"uncategorized_tag": "No tag",
}, },
"fa": { "fa": {
"report_title": "گزارش فضای کاری", "report_title": "\u06af\u0632\u0627\u0631\u0634 \u0641\u0636\u0627\u06cc \u06a9\u0627\u0631\u06cc",
"overall_sheet": "گزارش کلی", "overall_sheet": "\u06af\u0632\u0627\u0631\u0634 \u06a9\u0644\u06cc",
"users_summary_sheet": "خلاصه کاربران", "users_summary_sheet": "\u062e\u0644\u0627\u0635\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646",
"workspace": "فضای کاری", "workspace": "\u0641\u0636\u0627\u06cc \u06a9\u0627\u0631\u06cc",
"period": "بازه", "period": "\u0628\u0627\u0632\u0647",
"from_date": "از تاریخ", "from_date": "\u0627\u0632 \u062a\u0627\u0631\u06cc\u062e",
"to_date": "تا تاریخ", "to_date": "\u062a\u0627 \u062a\u0627\u0631\u06cc\u062e",
"user": "کاربر", "user": "\u06a9\u0627\u0631\u0628\u0631",
"mobile": "موبایل", "mobile": "\u0645\u0648\u0628\u0627\u06cc\u0644",
"all_users": "همه کاربران", "all_users": "\u0647\u0645\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646",
"generated_at": "تاریخ تولید", "generated_at": "\u062a\u0627\u0631\u06cc\u062e \u062a\u0648\u0644\u06cc\u062f",
"summary": "خلاصه", "summary": "\u062e\u0644\u0627\u0635\u0647",
"total_hours": "کل ساعات", "total_hours": "\u06a9\u0644 \u0633\u0627\u0639\u0627\u062a",
"billable_hours": "ساعات کاری", "billable_hours": "\u0633\u0627\u0639\u0627\u062a \u06a9\u0627\u0631\u06cc",
"non_billable_hours": "ساعات غیر کاری", "non_billable_hours": "\u0633\u0627\u0639\u0627\u062a \u063a\u06cc\u0631 \u06a9\u0627\u0631\u06cc",
"hourly_rate": "نرخ ساعتی", "hourly_rate": "\u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc",
"income": "کارکرد", "income": "\u06a9\u0627\u0631\u06a9\u0631\u062f",
"working_hours": "ساعات کاری", "working_hours": "\u0633\u0627\u0639\u0627\u062a \u06a9\u0627\u0631\u06cc",
"non_working_hours": "ساعات غیرکاری", "non_working_hours": "\u0633\u0627\u0639\u0627\u062a \u063a\u06cc\u0631\u06a9\u0627\u0631\u06cc",
"hourly_rates": "نرخ‌های ساعتی", "hourly_rates": "\u0646\u0631\u062e\u200c\u0647\u0627\u06cc \u0633\u0627\u0639\u062a\u06cc",
"project_percentages": "درصد پروژه‌ها", "project_percentages": "\u062f\u0631\u0635\u062f \u067e\u0631\u0648\u0698\u0647\u200c\u0647\u0627",
"client_percentages": "درصد مشتری‌ها", "client_percentages": "\u062f\u0631\u0635\u062f \u0645\u0634\u062a\u0631\u06cc\u200c\u0647\u0627",
"tag_percentages": "درصد تگ‌ها", "tag_percentages": "\u062f\u0631\u0635\u062f \u062a\u06af\u200c\u0647\u0627",
"summary_by_user": "خلاصه کاربران", "summary_by_user": "\u062e\u0644\u0627\u0635\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646",
"rate_history": "تاریخچه نرخ ساعتی", "rate_history": "\u062a\u0627\u0631\u06cc\u062e\u0686\u0647 \u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc",
"from": "از", "from": "\u0627\u0632",
"to": "تا", "to": "\u062a\u0627",
"percentage": "درصد", "percentage": "\u062f\u0631\u0635\u062f",
"none": "بدون مورد", "hour_percentage": "\u062f\u0631\u0635\u062f \u0633\u0627\u0639\u062a",
"daily_summary": "خلاصه روزانه", "income_percentage": "\u062f\u0631\u0635\u062f \u06a9\u0627\u0631\u06a9\u0631\u062f",
"clients": "مشتریان", "none": "\u0628\u062f\u0648\u0646 \u0645\u0648\u0631\u062f",
"projects": "پروژه‌ها", "daily_summary": "\u062e\u0644\u0627\u0635\u0647 \u0631\u0648\u0632\u0627\u0646\u0647",
"tags": "تگ‌ها", "clients": "\u0645\u0634\u062a\u0631\u06cc\u0627\u0646",
"date": "تاریخ", "projects": "\u067e\u0631\u0648\u0698\u0647\u200c\u0647\u0627",
"name": "نام", "tags": "\u062a\u06af\u200c\u0647\u0627",
"total": "جمع", "date": "\u062a\u0627\u0631\u06cc\u062e",
"no_data": "بدون داده", "name": "\u0646\u0627\u0645",
"total": "\u062c\u0645\u0639",
"no_data": "\u0628\u062f\u0648\u0646 \u062f\u0627\u062f\u0647",
"uncategorized_client": "\u0628\u062f\u0648\u0646 \u0645\u0634\u062a\u0631\u06cc",
"uncategorized_project": "\u0628\u062f\u0648\u0646 \u067e\u0631\u0648\u0698\u0647",
"uncategorized_tag": "\u0628\u062f\u0648\u0646 \u062a\u06af",
}, },
} }
@@ -111,23 +121,23 @@ PERIOD_LABELS = {
"period": "Custom period", "period": "Custom period",
}, },
"fa": { "fa": {
"this_week": "این هفته", "this_week": "\u0627\u06cc\u0646 \u0647\u0641\u062a\u0647",
"this_month": "این ماه", "this_month": "\u0627\u06cc\u0646 \u0645\u0627\u0647",
"this_year": "امسال", "this_year": "\u0627\u0645\u0633\u0627\u0644",
"half_year_first": "نیمه اول سال", "half_year_first": "\u0646\u06cc\u0645\u0647 \u0627\u0648\u0644 \u0633\u0627\u0644",
"half_year_second": "نیمه دوم سال", "half_year_second": "\u0646\u06cc\u0645\u0647 \u062f\u0648\u0645 \u0633\u0627\u0644",
"period": "بازه دلخواه", "period": "\u0628\u0627\u0632\u0647 \u062f\u0644\u062e\u0648\u0627\u0647",
}, },
} }
CURRENCY_LABELS = { CURRENCY_LABELS = {
"USD": {"en": "USD", "fa": "دلار آمریکا"}, "USD": {"en": "USD", "fa": "\u062f\u0644\u0627\u0631 \u0622\u0645\u0631\u06cc\u06a9\u0627"},
"EUR": {"en": "EUR", "fa": "یورو"}, "EUR": {"en": "EUR", "fa": "\u06cc\u0648\u0631\u0648"},
"GBP": {"en": "GBP", "fa": "پوند"}, "GBP": {"en": "GBP", "fa": "\u067e\u0648\u0646\u062f"},
"IRR": {"en": "IRR", "fa": "ریال"}, "IRR": {"en": "IRR", "fa": "\u0631\u06cc\u0627\u0644"},
"IRT": {"en": "IRT", "fa": "تومان"}, "IRT": {"en": "IRT", "fa": "\u062a\u0648\u0645\u0627\u0646"},
"AED": {"en": "AED", "fa": "درهم"}, "AED": {"en": "AED", "fa": "\u062f\u0631\u0647\u0645"},
"TRY": {"en": "TRY", "fa": "لیر"}, "TRY": {"en": "TRY", "fa": "\u0644\u06cc\u0631"},
} }
@@ -225,7 +235,7 @@ def user_label(user_payload: dict | None, locale: ExportLocale, *, ascii_digits:
def safe_sheet_title(title: str, used: Iterable[str]) -> str: def safe_sheet_title(title: str, used: Iterable[str]) -> str:
invalid = set('[]:*?/\\') invalid = set("[]:*?/\\")
sanitized = "".join("-" if char in invalid else char for char in title).strip() or "Sheet" sanitized = "".join("-" if char in invalid else char for char in title).strip() or "Sheet"
base = sanitized[:31] base = sanitized[:31]
used_set = set(used) used_set = set(used)

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)], [locale.t("working_hours"), locale.format_duration(user_summary["billable_duration"], ascii_digits=True)],
), ),
_excel_pair_row( _excel_pair_row(
[locale.t("non_working_hours"), locale.format_duration(user_summary["non_billable_duration"], ascii_digits=True)], [
locale.t("non_working_hours"),
locale.format_duration(
user_summary["non_billable_duration"],
ascii_digits=True,
),
],
), ),
_excel_pair_row([locale.t("income"), _money_label_excel(locale, user_summary["income_totals"])]), _excel_pair_row([locale.t("income"), _money_label_excel(locale, user_summary["income_totals"])]),
): ):
@@ -184,14 +190,49 @@ def _percentage_display(locale: ExportLocale, rows: list[dict], row_data: dict,
row_id = str(row_data.get("id")) if row_data.get("id") is not None else None row_id = str(row_data.get("id")) if row_data.get("id") is not None else None
row_name = row_data.get("name") row_name = row_data.get("name")
for row in rows: for row in rows:
value = f"{locale.format_amount(row['percentage'], ascii_digits=ascii_digits)}%" value = _percentage_value(locale, row["percentage"], ascii_digits=ascii_digits)
if row_id is not None and str(row["id"]) == row_id: if row_id is not None and str(row["id"]) == row_id:
return f"\u202B{value}\u202C" if locale.is_rtl and ascii_digits else value return value
if row_name and row["name"] == row_name: if row_name and row["name"] == row_name:
return f"\u202B{value}\u202C" if locale.is_rtl and ascii_digits else value return value
return "-" return "-"
def _percentage_value(locale: ExportLocale, percentage: str, *, ascii_digits: bool = False) -> str:
value = f"{locale.format_amount(percentage, ascii_digits=ascii_digits)}%"
return f"\u202B{value}\u202C" if locale.is_rtl and ascii_digits else value
def _summary_breakdown_rows(
locale: ExportLocale,
hour_rows: list[dict],
income_rows: list[dict],
) -> list[list[str]]:
if not hour_rows:
return []
return [
[
row["name"],
_percentage_value(locale, row["percentage"], ascii_digits=True),
_percentage_display(locale, income_rows, row, ascii_digits=True),
]
for row in hour_rows
]
def _summary_period_label(locale: ExportLocale, rate_periods: list[dict], *, ascii_digits: bool = False) -> str:
if not rate_periods:
return locale.t("none")
first_row = rate_periods[0]
last_row = rate_periods[-1]
return (
f"{locale.format_date(first_row['from_date'], ascii_digits=ascii_digits)} - "
f"{locale.format_date(last_row['to_date'], ascii_digits=ascii_digits)}"
)
def _append_rate_history_table_excel(worksheet, *, locale: ExportLocale, user_summary: dict) -> None: def _append_rate_history_table_excel(worksheet, *, locale: ExportLocale, user_summary: dict) -> None:
worksheet.append([]) worksheet.append([])
_append_merged_heading(worksheet, locale=locale, title=locale.t("rate_history"), span=3) _append_merged_heading(worksheet, locale=locale, title=locale.t("rate_history"), span=3)
@@ -252,8 +293,16 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) ->
worksheet.append(_excel_pair_row([locale.t("report_title"), scope["workspace"]["name"]])) worksheet.append(_excel_pair_row([locale.t("report_title"), scope["workspace"]["name"]]))
worksheet.append(_excel_pair_row([locale.t("workspace"), scope["workspace"]["name"]])) worksheet.append(_excel_pair_row([locale.t("workspace"), scope["workspace"]["name"]]))
worksheet.append(_excel_pair_row([locale.t("period"), locale.period_label(scope["period"])])) worksheet.append(_excel_pair_row([locale.t("period"), locale.period_label(scope["period"])]))
worksheet.append(_excel_pair_row([locale.t("from_date"), locale.format_date(scope["from_date"], ascii_digits=True)])) worksheet.append(
worksheet.append(_excel_pair_row([locale.t("to_date"), locale.format_date(scope["to_date"], ascii_digits=True)])) _excel_pair_row(
[locale.t("from_date"), locale.format_date(scope["from_date"], ascii_digits=True)],
)
)
worksheet.append(
_excel_pair_row(
[locale.t("to_date"), locale.format_date(scope["to_date"], ascii_digits=True)],
)
)
worksheet.append( worksheet.append(
_excel_pair_row( _excel_pair_row(
[ [
@@ -266,16 +315,48 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) ->
_excel_pair_row( _excel_pair_row(
[ [
locale.t("mobile"), locale.t("mobile"),
locale.format_number(scope["user"]["mobile"], ascii_digits=True) if scope.get("user") and scope["user"].get("mobile") else "-", (
locale.format_number(
scope["user"]["mobile"],
ascii_digits=True,
)
if scope.get("user") and scope["user"].get("mobile")
else "-"
),
],
)
)
worksheet.append(
_excel_pair_row(
[
locale.t("generated_at"),
locale.format_date(datetime.now().date(), ascii_digits=True),
], ],
) )
) )
worksheet.append(_excel_pair_row([locale.t("generated_at"), locale.format_date(datetime.now().date(), ascii_digits=True)]))
worksheet.append([]) worksheet.append([])
_append_merged_heading(worksheet, locale=locale, title=locale.t("summary"), span=2) _append_merged_heading(worksheet, locale=locale, title=locale.t("summary"), span=2)
worksheet.append(_excel_pair_row([locale.t("total_hours"), locale.format_duration(summary["total_duration"], ascii_digits=True)])) worksheet.append(
worksheet.append(_excel_pair_row([locale.t("billable_hours"), locale.format_duration(summary["billable_duration"], ascii_digits=True)])) _excel_pair_row(
worksheet.append(_excel_pair_row([locale.t("non_billable_hours"), locale.format_duration(summary["non_billable_duration"], ascii_digits=True)])) [locale.t("total_hours"), locale.format_duration(summary["total_duration"], ascii_digits=True)],
)
)
worksheet.append(
_excel_pair_row(
[locale.t("billable_hours"), locale.format_duration(summary["billable_duration"], ascii_digits=True)],
)
)
worksheet.append(
_excel_pair_row(
[
locale.t("non_billable_hours"),
locale.format_duration(
summary["non_billable_duration"],
ascii_digits=True,
),
],
)
)
worksheet.append(_excel_pair_row([locale.t("income"), _money_label_excel(locale, summary["income_totals"])])) worksheet.append(_excel_pair_row([locale.t("income"), _money_label_excel(locale, summary["income_totals"])]))
for row_index in range(1, worksheet.max_row + 1): for row_index in range(1, worksheet.max_row + 1):
@@ -284,7 +365,12 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) ->
if row_index in {1, 10}: if row_index in {1, 10}:
_apply_cell_style(first_cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl) _apply_cell_style(first_cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
if second_cell.value: if second_cell.value:
_apply_cell_style(second_cell, bold=row_index == 1, fill=HEADER_FILL if row_index == 1 else None, rtl=locale.is_rtl) _apply_cell_style(
second_cell,
bold=row_index == 1,
fill=HEADER_FILL if row_index == 1 else None,
rtl=locale.is_rtl,
)
elif first_cell.value: elif first_cell.value:
_apply_cell_style(first_cell, bold=False, fill=None, rtl=locale.is_rtl) _apply_cell_style(first_cell, bold=False, fill=None, rtl=locale.is_rtl)
if second_cell.value: if second_cell.value:
@@ -353,25 +439,26 @@ def _append_breakdown_table(
locale: ExportLocale, locale: ExportLocale,
title_key: str, title_key: str,
rows: list[dict], rows: list[dict],
percentages: list[dict] | None = None, hour_percentages: list[dict] | None = None,
income_percentages: list[dict] | None = None,
) -> None: ) -> None:
worksheet.append([]) worksheet.append([])
_append_merged_heading( _append_merged_heading(
worksheet, worksheet,
locale=locale, locale=locale,
title=locale.t(title_key), title=locale.t(title_key),
span=6 if percentages is not None else 5, span=7 if hour_percentages is not None else 5,
) )
header_row = worksheet.max_row + 1 header_row = worksheet.max_row + 1
headers = [ headers = [
locale.t("name"), locale.t("name"),
locale.t("billable_hours"), locale.t("billable_hours"),
*( [locale.t("hour_percentage")] if hour_percentages is not None else [] ),
locale.t("non_billable_hours"), locale.t("non_billable_hours"),
locale.t("total_hours"), locale.t("total_hours"),
locale.t("income"), locale.t("income"),
*( [locale.t("income_percentage")] if hour_percentages is not None else [] ),
] ]
if percentages is not None:
headers.append(locale.t("percentage"))
worksheet.append(_excel_table_row(headers)) worksheet.append(_excel_table_row(headers))
for cell in worksheet[header_row]: for cell in worksheet[header_row]:
_apply_cell_style(cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl) _apply_cell_style(cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
@@ -385,15 +472,21 @@ def _append_breakdown_table(
values = [ values = [
row["name"], row["name"],
locale.format_duration(row["billable_duration"], ascii_digits=True), locale.format_duration(row["billable_duration"], ascii_digits=True),
*(
[_percentage_display(locale, hour_percentages or [], row, ascii_digits=True)]
if hour_percentages is not None
else []
),
locale.format_duration(row["non_billable_duration"], ascii_digits=True), locale.format_duration(row["non_billable_duration"], ascii_digits=True),
locale.format_duration(row["total_duration"], ascii_digits=True), locale.format_duration(row["total_duration"], ascii_digits=True),
_money_label_excel(locale, row["income_totals"]), _money_label_excel(locale, row["income_totals"]),
*(
[_percentage_display(locale, income_percentages or [], row, ascii_digits=True)]
if hour_percentages is not None
else []
),
] ]
if percentages is not None: worksheet.append(_excel_table_row(values))
values.append(_percentage_display(locale, percentages, row, ascii_digits=True))
worksheet.append(
_excel_table_row(values)
)
for cell in worksheet[worksheet.max_row]: for cell in worksheet[worksheet.max_row]:
_apply_cell_style(cell, rtl=locale.is_rtl) _apply_cell_style(cell, rtl=locale.is_rtl)
@@ -415,21 +508,24 @@ def _append_user_details_block_excel(
locale=locale, locale=locale,
title_key="clients", title_key="clients",
rows=report_data["clients"], rows=report_data["clients"],
percentages=user_summary["client_percentages"], hour_percentages=user_summary["client_percentages"],
income_percentages=user_summary["client_income_percentages"],
) )
_append_breakdown_table( _append_breakdown_table(
worksheet, worksheet,
locale=locale, locale=locale,
title_key="projects", title_key="projects",
rows=report_data["projects"], rows=report_data["projects"],
percentages=user_summary["project_percentages"], hour_percentages=user_summary["project_percentages"],
income_percentages=user_summary["project_income_percentages"],
) )
_append_breakdown_table( _append_breakdown_table(
worksheet, worksheet,
locale=locale, locale=locale,
title_key="tags", title_key="tags",
rows=report_data["tags"], rows=report_data["tags"],
percentages=user_summary["tag_percentages"], hour_percentages=user_summary["tag_percentages"],
income_percentages=user_summary["tag_income_percentages"],
) )
@@ -469,22 +565,28 @@ def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int,
rate_rows = [ rate_rows = [
[ [
_rate_period_label(locale, row, ascii_digits=True), _rate_period_label(locale, row, ascii_digits=True),
f"{locale.format_date(row['from_date'], ascii_digits=True)} - {locale.format_date(row['to_date'], ascii_digits=True)}", (
f"{locale.format_date(row['from_date'], ascii_digits=True)} - "
f"{locale.format_date(row['to_date'], ascii_digits=True)}"
),
] ]
for row in (summary.get("rate_periods") or []) for row in (summary.get("rate_periods") or [])
] ]
client_rows = [ client_rows = _summary_breakdown_rows(
[row["name"], f"\u202B{locale.format_amount(row['percentage'], ascii_digits=True)}%\u202C" if locale.is_rtl else f"{locale.format_amount(row['percentage'], ascii_digits=True)}%"] locale,
for row in (summary.get("client_percentages") or []) summary.get("client_percentages") or [],
] summary.get("client_income_percentages") or [],
project_rows = [ )
[row["name"], f"\u202B{locale.format_amount(row['percentage'], ascii_digits=True)}%\u202C" if locale.is_rtl else f"{locale.format_amount(row['percentage'], ascii_digits=True)}%"] project_rows = _summary_breakdown_rows(
for row in (summary.get("project_percentages") or []) locale,
] summary.get("project_percentages") or [],
tag_rows = [ summary.get("project_income_percentages") or [],
[row["name"], f"\u202B{locale.format_amount(row['percentage'], ascii_digits=True)}%\u202C" if locale.is_rtl else f"{locale.format_amount(row['percentage'], ascii_digits=True)}%"] )
for row in (summary.get("tag_percentages") or []) tag_rows = _summary_breakdown_rows(
] locale,
summary.get("tag_percentages") or [],
summary.get("tag_income_percentages") or [],
)
span = max(len(rate_rows), len(client_rows), len(project_rows), len(tag_rows), 1) span = max(len(rate_rows), len(client_rows), len(project_rows), len(tag_rows), 1)
rows: list[list[str | None]] = [] rows: list[list[str | None]] = []
for index in range(span): for index in range(span):
@@ -494,11 +596,11 @@ def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int,
locale.format_number(summary["user"]["mobile"], ascii_digits=True) if index == 0 else None, locale.format_number(summary["user"]["mobile"], ascii_digits=True) if index == 0 else None,
locale.format_duration(summary["billable_duration"], ascii_digits=True) if index == 0 else None, locale.format_duration(summary["billable_duration"], ascii_digits=True) if index == 0 else None,
locale.format_duration(summary["non_billable_duration"], ascii_digits=True) if index == 0 else None, locale.format_duration(summary["non_billable_duration"], ascii_digits=True) if index == 0 else None,
_money_label_excel(locale, summary["income_totals"]) if index == 0 else None,
*(rate_rows[index] if index < len(rate_rows) else [None, None]), *(rate_rows[index] if index < len(rate_rows) else [None, None]),
*(client_rows[index] if index < len(client_rows) else [None, None]), _money_label_excel(locale, summary["income_totals"]) if index == 0 else None,
*(project_rows[index] if index < len(project_rows) else [None, None]), *(client_rows[index] if index < len(client_rows) else [None, None, None]),
*(tag_rows[index] if index < len(tag_rows) else [None, None]), *(project_rows[index] if index < len(project_rows) else [None, None, None]),
*(tag_rows[index] if index < len(tag_rows) else [None, None, None]),
], ],
) )
return span, rows return span, rows
@@ -560,7 +662,7 @@ def _render_all_users_overall_excel_sheet(
worksheet, worksheet,
row=15, row=15,
start_col=1, start_col=1,
end_col=13, end_col=16,
value=locale.t("users_summary_sheet"), value=locale.t("users_summary_sheet"),
rtl=locale.is_rtl, rtl=locale.is_rtl,
) )
@@ -569,15 +671,18 @@ def _render_all_users_overall_excel_sheet(
locale.t("mobile"), locale.t("mobile"),
locale.t("working_hours"), locale.t("working_hours"),
locale.t("non_working_hours"), locale.t("non_working_hours"),
locale.t("income"),
locale.t("hourly_rate"), locale.t("hourly_rate"),
locale.t("period"), locale.t("period"),
locale.t("income"),
locale.t("clients"), locale.t("clients"),
locale.t("percentage"), locale.t("hour_percentage"),
locale.t("income_percentage"),
locale.t("projects"), locale.t("projects"),
locale.t("percentage"), locale.t("hour_percentage"),
locale.t("income_percentage"),
locale.t("tags"), locale.t("tags"),
locale.t("percentage"), locale.t("hour_percentage"),
locale.t("income_percentage"),
] ]
_write_table_row( _write_table_row(
worksheet, worksheet,
@@ -599,33 +704,58 @@ def _render_all_users_overall_excel_sheet(
values=values, values=values,
rtl=locale.is_rtl, rtl=locale.is_rtl,
) )
for column in range(1, 6): for column in (1, 2, 3, 4, 7):
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=column) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=column)
rate_rows = user_summary.get("rate_periods") or [] rate_rows = user_summary.get("rate_periods") or []
client_rows = user_summary.get("client_percentages") or [] client_rows = user_summary.get("client_percentages") or []
project_rows = user_summary.get("project_percentages") or [] project_rows = user_summary.get("project_percentages") or []
tag_rows = user_summary.get("tag_percentages") or [] tag_rows = user_summary.get("tag_percentages") or []
if len(rate_rows) == 1: if len(rate_rows) == 1:
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=5, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=6, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=6, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=7, value_present=True)
if len(client_rows) == 1: if len(client_rows) == 1:
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=8, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=8, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=9, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=9, value_present=True)
if len(project_rows) == 1:
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=10, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=10, value_present=True)
if len(project_rows) == 1:
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=11, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=11, value_present=True)
if len(tag_rows) == 1:
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=12, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=12, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=13, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=13, value_present=True)
if len(tag_rows) == 1:
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=14, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=15, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=16, value_present=True)
current_row += span current_row += span
current_row += 2 current_row += 2
for title_key, rows, percentages in ( for title_key, rows, hour_percentages, income_percentages in (
("clients", report_data["clients"], report_data.get("client_percentages")), (
("projects", report_data["projects"], report_data.get("project_percentages")), "clients",
("tags", report_data["tags"], report_data.get("tag_percentages")), report_data["clients"],
report_data.get("client_percentages"),
report_data.get("client_income_percentages"),
),
(
"projects",
report_data["projects"],
report_data.get("project_percentages"),
report_data.get("project_income_percentages"),
),
(
"tags",
report_data["tags"],
report_data.get("tag_percentages"),
report_data.get("tag_income_percentages"),
),
): ):
_merge_and_style(worksheet, row=current_row, start_col=1, end_col=6, value=locale.t(title_key), rtl=locale.is_rtl) _merge_and_style(
worksheet,
row=current_row,
start_col=1,
end_col=7,
value=locale.t(title_key),
rtl=locale.is_rtl,
)
current_row += 1 current_row += 1
_write_table_row( _write_table_row(
worksheet, worksheet,
@@ -634,10 +764,11 @@ def _render_all_users_overall_excel_sheet(
values=[ values=[
locale.t("name"), locale.t("name"),
locale.t("billable_hours"), locale.t("billable_hours"),
locale.t("hour_percentage"),
locale.t("non_billable_hours"), locale.t("non_billable_hours"),
locale.t("total_hours"), locale.t("total_hours"),
locale.t("income"), locale.t("income"),
locale.t("percentage"), locale.t("income_percentage"),
], ],
rtl=locale.is_rtl, rtl=locale.is_rtl,
bold=True, bold=True,
@@ -653,10 +784,11 @@ def _render_all_users_overall_excel_sheet(
values=[ values=[
row["name"], row["name"],
locale.format_duration(row["billable_duration"], ascii_digits=True), locale.format_duration(row["billable_duration"], ascii_digits=True),
_percentage_display(locale, hour_percentages or [], row, ascii_digits=True),
locale.format_duration(row["non_billable_duration"], ascii_digits=True), locale.format_duration(row["non_billable_duration"], ascii_digits=True),
locale.format_duration(row["total_duration"], ascii_digits=True), locale.format_duration(row["total_duration"], ascii_digits=True),
_money_label_excel(locale, row["income_totals"]), _money_label_excel(locale, row["income_totals"]),
_percentage_display(locale, percentages or [], row, ascii_digits=True), _percentage_display(locale, income_percentages or [], row, ascii_digits=True),
], ],
rtl=locale.is_rtl, rtl=locale.is_rtl,
) )
@@ -666,7 +798,7 @@ def _render_all_users_overall_excel_sheet(
worksheet, worksheet,
row=current_row, row=current_row,
start_col=1, start_col=1,
values=[locale.t("no_data"), None, None, None, None, None], values=[locale.t("no_data"), None, None, None, None, None, None],
rtl=locale.is_rtl, rtl=locale.is_rtl,
) )
current_row += 1 current_row += 1
@@ -677,15 +809,18 @@ def _render_all_users_overall_excel_sheet(
"B": 19.86, "B": 19.86,
"C": 18.0, "C": 18.0,
"D": 17.0, "D": 17.0,
"E": 24.0, "E": 18.0,
"F": 17.57, "F": 26.0,
"G": 32.0, "G": 24.0,
"H": 30.0, "H": 28.0,
"I": 14.0, "I": 14.0,
"J": 32.86, "J": 16.0,
"K": 12.0, "K": 28.0,
"L": 22.0, "L": 14.0,
"M": 12.0, "M": 16.0,
"N": 24.0,
"O": 14.0,
"P": 16.0,
} }
for column, width in overall_widths.items(): for column, width in overall_widths.items():
worksheet.column_dimensions[column].width = width worksheet.column_dimensions[column].width = width
@@ -715,21 +850,48 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -
locale=locale, locale=locale,
title_key="clients", title_key="clients",
rows=report_data["clients"], rows=report_data["clients"],
percentages=user_summary["client_percentages"] if user_summary else None, hour_percentages=(
user_summary["client_percentages"]
if user_summary
else report_data.get("client_percentages")
),
income_percentages=(
user_summary["client_income_percentages"]
if user_summary
else report_data.get("client_income_percentages")
),
) )
_append_breakdown_table( _append_breakdown_table(
worksheet, worksheet,
locale=locale, locale=locale,
title_key="projects", title_key="projects",
rows=report_data["projects"], rows=report_data["projects"],
percentages=user_summary["project_percentages"] if user_summary else None, hour_percentages=(
user_summary["project_percentages"]
if user_summary
else report_data.get("project_percentages")
),
income_percentages=(
user_summary["project_income_percentages"]
if user_summary
else report_data.get("project_income_percentages")
),
) )
_append_breakdown_table( _append_breakdown_table(
worksheet, worksheet,
locale=locale, locale=locale,
title_key="tags", title_key="tags",
rows=report_data["tags"], rows=report_data["tags"],
percentages=user_summary["tag_percentages"] if user_summary else None, hour_percentages=(
user_summary["tag_percentages"]
if user_summary
else report_data.get("tag_percentages")
),
income_percentages=(
user_summary["tag_income_percentages"]
if user_summary
else report_data.get("tag_income_percentages")
),
) )
_autosize_columns(worksheet) _autosize_columns(worksheet)
@@ -764,7 +926,14 @@ def build_excel_report(*, report_data: dict, locale: ExportLocale, per_user_repo
_render_all_users_overall_excel_sheet(overall_sheet, locale=locale, report_data=report_data) _render_all_users_overall_excel_sheet(overall_sheet, locale=locale, report_data=report_data)
used_titles.add(overall_sheet.title) used_titles.add(overall_sheet.title)
for user_report in per_user_reports: for user_report in per_user_reports:
user_title = safe_sheet_title(user_label(user_report["scope"].get("user"), locale, ascii_digits=True), used_titles) user_title = safe_sheet_title(
user_label(
user_report["scope"].get("user"),
locale,
ascii_digits=True,
),
used_titles,
)
worksheet = workbook.create_sheet(title=user_title) worksheet = workbook.create_sheet(title=user_title)
_render_excel_sheet(worksheet, locale=locale, report_data=user_report) _render_excel_sheet(worksheet, locale=locale, report_data=user_report)
used_titles.add(user_title) used_titles.add(user_title)
@@ -924,61 +1093,77 @@ def _append_pdf_report_sections(
for title_key, rows, is_daily in sections: for title_key, rows, is_daily in sections:
story.append(_paragraph(locale.t(title_key), section_style, locale)) story.append(_paragraph(locale.t(title_key), section_style, locale))
story.append(Spacer(1, 2 * mm)) story.append(Spacer(1, 2 * mm))
header_values = [ header_values = (
locale.t("date") if is_daily else locale.t("name"), [
locale.t("billable_hours"), locale.t("date"),
locale.t("non_billable_hours"), locale.t("billable_hours"),
locale.t("total_hours"), locale.t("non_billable_hours"),
*([locale.t("hourly_rate")] if is_daily else []), locale.t("total_hours"),
locale.t("income"), locale.t("hourly_rate"),
] locale.t("income"),
percentage_rows = None ]
if is_daily
else [
locale.t("name"),
locale.t("billable_hours"),
locale.t("hour_percentage"),
locale.t("non_billable_hours"),
locale.t("total_hours"),
locale.t("income"),
locale.t("income_percentage"),
]
)
hour_percentage_rows = None
income_percentage_rows = None
if user_summary and not is_daily: if user_summary and not is_daily:
percentage_rows = user_summary[f"{title_key[:-1]}_percentages"] if title_key != "clients" else user_summary["client_percentages"] prefix = title_key[:-1] if title_key != "clients" else "client"
header_values.append(locale.t("percentage")) hour_percentage_rows = user_summary[f"{prefix}_percentages"]
income_percentage_rows = user_summary[f"{prefix}_income_percentages"]
header = _rtl_row(locale, header_values) header = _rtl_row(locale, header_values)
body_rows = _report_table_rows(locale, rows, is_daily=is_daily) body_rows = _report_table_rows(locale, rows, is_daily=is_daily)
if percentage_rows is not None: if hour_percentage_rows is not None:
body_rows = [ body_rows = [
_rtl_row( _rtl_row(
locale, locale,
[ [
row["name"], row["name"],
locale.format_duration(row["billable_duration"]), locale.format_duration(row["billable_duration"]),
_percentage_display(locale, hour_percentage_rows, row),
locale.format_duration(row["non_billable_duration"]), locale.format_duration(row["non_billable_duration"]),
locale.format_duration(row["total_duration"]), locale.format_duration(row["total_duration"]),
_money_label(locale, row["income_totals"]), _money_label(locale, row["income_totals"]),
_percentage_display(locale, percentage_rows, row), _percentage_display(locale, income_percentage_rows or [], row),
], ],
) )
for row in rows for row in rows
] or [_rtl_row(locale, [locale.t("no_data"), "", "", "", "", ""])] ] or [_rtl_row(locale, [locale.t("no_data"), "", "", "", "", "", ""])]
table = _styled_table( table = _styled_table(
[header, *body_rows], [header, *body_rows],
locale=locale, locale=locale,
column_widths=( column_widths=(
[ [
doc_width * 0.21, doc_width * 0.20,
doc_width * 0.13, doc_width * 0.12,
doc_width * 0.15, doc_width * 0.15,
doc_width * 0.13, doc_width * 0.13,
doc_width * 0.16, doc_width * 0.16,
doc_width * 0.22, doc_width * 0.24,
] ]
if is_daily if is_daily
else [ else [
*( *(
[ [
doc_width * 0.24, doc_width * 0.20,
doc_width * 0.13, doc_width * 0.11,
doc_width * 0.15, doc_width * 0.11,
doc_width * 0.12, doc_width * 0.12,
doc_width * 0.2, doc_width * 0.12,
doc_width * 0.16, doc_width * 0.19,
doc_width * 0.15,
] ]
if percentage_rows is not None if hour_percentage_rows is not None
else [ else [
doc_width * 0.26, doc_width * 0.13,
doc_width * 0.15, doc_width * 0.15,
doc_width * 0.17, doc_width * 0.17,
doc_width * 0.14, doc_width * 0.14,
@@ -1063,7 +1248,14 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
[locale.t("from_date"), locale.format_date(scope["from_date"])], [locale.t("from_date"), locale.format_date(scope["from_date"])],
[locale.t("to_date"), locale.format_date(scope["to_date"])], [locale.t("to_date"), locale.format_date(scope["to_date"])],
[locale.t("user"), scope["user"]["name"] if scope.get("user") else locale.t("all_users")], [locale.t("user"), scope["user"]["name"] if scope.get("user") else locale.t("all_users")],
[locale.t("mobile"), locale.format_number(scope["user"]["mobile"]) if scope.get("user") and scope["user"].get("mobile") else "-"], [
locale.t("mobile"),
(
locale.format_number(scope["user"]["mobile"])
if scope.get("user") and scope["user"].get("mobile")
else "-"
),
],
[locale.t("generated_at"), locale.format_date(datetime.now().date())], [locale.t("generated_at"), locale.format_date(datetime.now().date())],
] ]
if locale.is_rtl: if locale.is_rtl:
@@ -1122,6 +1314,8 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
locale.t("mobile"), locale.t("mobile"),
locale.t("working_hours"), locale.t("working_hours"),
locale.t("non_working_hours"), locale.t("non_working_hours"),
locale.t("hourly_rate"),
locale.t("period"),
locale.t("income"), locale.t("income"),
], ],
) )
@@ -1133,6 +1327,8 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
locale.format_number(summary["user"]["mobile"]), locale.format_number(summary["user"]["mobile"]),
locale.format_duration(summary["billable_duration"]), locale.format_duration(summary["billable_duration"]),
locale.format_duration(summary["non_billable_duration"]), locale.format_duration(summary["non_billable_duration"]),
_rates_label(locale, summary.get("hourly_rates") or []),
_summary_period_label(locale, summary.get("rate_periods") or []),
_money_label(locale, summary["income_totals"]), _money_label(locale, summary["income_totals"]),
], ],
) )
@@ -1143,11 +1339,13 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
[user_summary_header, *user_summary_rows], [user_summary_header, *user_summary_rows],
locale=locale, locale=locale,
column_widths=[ column_widths=[
doc.width * 0.24,
doc.width * 0.18, doc.width * 0.18,
doc.width * 0.18, doc.width * 0.13,
doc.width * 0.18, doc.width * 0.13,
doc.width * 0.22, doc.width * 0.13,
doc.width * 0.13,
doc.width * 0.16,
doc.width * 0.14,
], ],
) )
) )

View File

@@ -68,6 +68,9 @@ def make_user_summary(*, name: str, mobile: str):
"project_percentages": [{"id": "1", "name": "Website", "percentage": "100"}], "project_percentages": [{"id": "1", "name": "Website", "percentage": "100"}],
"client_percentages": [{"id": "1", "name": "Acme", "percentage": "100"}], "client_percentages": [{"id": "1", "name": "Acme", "percentage": "100"}],
"tag_percentages": [{"id": "1", "name": "Design", "percentage": "100"}], "tag_percentages": [{"id": "1", "name": "Design", "percentage": "100"}],
"project_income_percentages": [{"id": "1", "name": "Website", "percentage": "100"}],
"client_income_percentages": [{"id": "1", "name": "Acme", "percentage": "100"}],
"tag_income_percentages": [{"id": "1", "name": "Design", "percentage": "100"}],
} }
@@ -120,23 +123,26 @@ class ReportExporterTests(TestCase):
self.assertEqual(summary_sheet["A1"].value, "Workspace Report") self.assertEqual(summary_sheet["A1"].value, "Workspace Report")
self.assertEqual(summary_sheet["B1"].value, "Exports") self.assertEqual(summary_sheet["B1"].value, "Exports")
self.assertEqual(summary_sheet["A15"].value, "Users Summary") self.assertEqual(summary_sheet["A15"].value, "Users Summary")
self.assertIn("A15:M15", {str(item) for item in summary_sheet.merged_cells.ranges}) self.assertIn("A15:P15", {str(item) for item in summary_sheet.merged_cells.ranges})
self.assertEqual( self.assertEqual(
tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:13], tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:16],
( (
"Name", "Name",
"Mobile", "Mobile",
"Working hours", "Working hours",
"Non-working hours", "Non-working hours",
"Income",
"Hourly rate", "Hourly rate",
"Period", "Period",
"Income",
"Clients", "Clients",
"Percentage", "Hour %",
"Income %",
"Projects", "Projects",
"Percentage", "Hour %",
"Income %",
"Tags", "Tags",
"Percentage", "Hour %",
"Income %",
), ),
) )
self.assertTrue(any(row and "Owner User" in row for row in summary_values)) self.assertTrue(any(row and "Owner User" in row for row in summary_values))
@@ -161,6 +167,20 @@ class ReportExporterTests(TestCase):
daily_row = next(row[:6] for row in user_values if row and "2026/04/12" in row) daily_row = next(row[:6] for row in user_values if row and "2026/04/12" in row)
self.assertEqual(daily_row[4], "15 USD") self.assertEqual(daily_row[4], "15 USD")
breakdown_header = next(row[:7] for row in user_values if row and row[0] == "Name" and row[2] == "Hour %")
self.assertEqual(
breakdown_header,
(
"Name",
"Billable hours",
"Hour %",
"Non-billable hours",
"Total hours",
"Income",
"Income %",
),
)
def test_pdf_export_supports_persian_locale(self): def test_pdf_export_supports_persian_locale(self):
locale = build_export_locale("fa") locale = build_export_locale("fa")
report_data = make_report_data( report_data = make_report_data(
@@ -168,7 +188,16 @@ class ReportExporterTests(TestCase):
) )
report_data["user_summaries"] = [make_user_summary(name="Owner User", mobile="09129990001")] report_data["user_summaries"] = [make_user_summary(name="Owner User", mobile="09129990001")]
per_user_reports = [ per_user_reports = [
{**make_report_data(user_name="Owner User", mobile="09129990001"), "user_summary": make_user_summary(name="Owner User", mobile="09129990001")} {
**make_report_data(
user_name="Owner User",
mobile="09129990001",
),
"user_summary": make_user_summary(
name="Owner User",
mobile="09129990001",
),
}
] ]
content = build_pdf_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports) content = build_pdf_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports)

View File

@@ -147,9 +147,130 @@ class ReportViewTests(APITestCase):
self.assertEqual(owner_summary["project_percentages"][0]["percentage"], "100") self.assertEqual(owner_summary["project_percentages"][0]["percentage"], "100")
self.assertEqual(owner_summary["client_percentages"][0]["percentage"], "100") self.assertEqual(owner_summary["client_percentages"][0]["percentage"], "100")
self.assertEqual(owner_summary["tag_percentages"][0]["percentage"], "100") self.assertEqual(owner_summary["tag_percentages"][0]["percentage"], "100")
self.assertEqual(member_summary["project_percentages"], []) self.assertEqual(member_summary["project_percentages"][0]["percentage"], "0")
self.assertEqual(member_summary["client_percentages"], []) self.assertEqual(member_summary["client_percentages"][0]["percentage"], "0")
self.assertEqual(member_summary["tag_percentages"], []) self.assertEqual(member_summary["tag_percentages"][0]["percentage"], "0")
def test_specific_user_report_includes_uncategorized_rows_and_balanced_percentages(self):
self.client.force_authenticate(user=self.owner)
TimeEntry.objects.create(
workspace=self.workspace,
user=self.owner,
project=None,
description="Uncategorized billable",
start_time="2026-04-12T10:00:00+03:30",
end_time="2026-04-12T11:00:00+03:30",
duration=timedelta(hours=1),
is_billable=True,
hourly_rate=Decimal("10.00"),
currency="USD",
)
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
response = self.client.get(
"/api/reports/table/",
{
"workspace": str(self.workspace.id),
"period": "this_month",
"user": str(self.owner.id),
"language": "en",
},
)
self.assertEqual(response.status_code, 200)
summary = response.data["user_summary"]
self.assertEqual(
sum(int(row["percentage"]) for row in summary["project_percentages"]),
100,
)
self.assertEqual(
sum(int(row["percentage"]) for row in summary["client_percentages"]),
100,
)
self.assertEqual(
sum(int(row["percentage"]) for row in summary["tag_percentages"]),
100,
)
self.assertEqual(
{row["name"] for row in summary["project_percentages"]},
{"Website", "No project"},
)
self.assertEqual(
{row["name"] for row in summary["client_percentages"]},
{"Acme", "No client"},
)
self.assertEqual(
{row["name"] for row in summary["tag_percentages"]},
{"Design", "No tag"},
)
self.assertEqual(
{row["name"] for row in response.data["projects"]},
{"Website", "No project"},
)
self.assertEqual(
{row["name"] for row in response.data["clients"]},
{"Acme", "No client"},
)
self.assertEqual(
{row["name"] for row in response.data["tags"]},
{"Design", "No tag"},
)
self.assertEqual(
sum(int(row["percentage"]) for row in summary["project_income_percentages"]),
100,
)
self.assertEqual(
sum(int(row["percentage"]) for row in summary["client_income_percentages"]),
100,
)
self.assertEqual(
sum(int(row["percentage"]) for row in summary["tag_income_percentages"]),
100,
)
def test_income_percentages_are_hidden_for_mixed_currency_breakdowns(self):
self.client.force_authenticate(user=self.owner)
second_project = Project.objects.create(
workspace=self.workspace,
name="Mobile App",
client=self.client_obj,
)
TimeEntry.objects.create(
workspace=self.workspace,
user=self.owner,
project=second_project,
description="EUR work",
start_time="2026-04-13T10:00:00+03:30",
end_time="2026-04-13T11:00:00+03:30",
duration=timedelta(hours=1),
is_billable=True,
hourly_rate=Decimal("20.00"),
currency="EUR",
)
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
response = self.client.get(
"/api/reports/table/",
{
"workspace": str(self.workspace.id),
"period": "this_month",
"user": str(self.owner.id),
"language": "en",
},
)
self.assertEqual(response.status_code, 200)
summary = response.data["user_summary"]
self.assertEqual(summary["project_income_percentages"], [])
self.assertEqual(summary["client_income_percentages"], [])
def test_daily_rate_uses_latest_billable_entry_snapshot(self): def test_daily_rate_uses_latest_billable_entry_snapshot(self):
self.client.force_authenticate(user=self.owner) self.client.force_authenticate(user=self.owner)

View File

@@ -93,7 +93,7 @@ class TimeEntryCreateSerializer(serializers.Serializer):
""" """
workspace_id = serializers.UUIDField() workspace_id = serializers.UUIDField()
project_id = serializers.UUIDField(required=False, allow_null=True) 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) end_time = serializers.DateTimeField(required=False, allow_null=True)
description = serializers.CharField(required=False, allow_blank=True, default="") description = serializers.CharField(required=False, allow_blank=True, default="")
tags = serializers.ListField(child=serializers.UUIDField(), required=False) tags = serializers.ListField(child=serializers.UUIDField(), required=False)
@@ -102,6 +102,12 @@ class TimeEntryCreateSerializer(serializers.Serializer):
def validate(self, attrs): def validate(self, attrs):
user = self.context.get("request").user if self.context.get("request") else None user = self.context.get("request").user if self.context.get("request") else None
workspace_id = attrs.get("workspace_id") 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) project_id = attrs.pop("project_id", serializers.empty)
if project_id is not serializers.empty: if project_id is not serializers.empty:
if project_id is None: 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.") 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. Creates a new time entry. If end_time is None, it acts as a running timer.
""" """
_verify_workspace_access(user, workspace_id) _verify_workspace_access(user, workspace_id)
if not end_time: if not end_time:
has_running_timer = TimeEntry.objects.filter( has_running_timer = TimeEntry.objects.filter(
workspace_id=workspace_id, workspace_id=workspace_id,
user=user, user=user,
end_time__isnull=True, end_time__isnull=True,
is_deleted=False is_deleted=False
).exists() ).exists()
if has_running_timer: if has_running_timer:
raise ValidationError({"non_field_errors": "You already have a running timer in this workspace."}) 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: if start_time is None:
raise ValidationError({"end_time": "End time must be strictly after start time."}) 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: if project and project.workspace_id != workspace_id:
raise ValidationError({"project": "Project must belong to the same workspace."}) 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.end_time)
self.assertIsNotNone(stopped_entry.duration) 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): def test_update_time_entry_preserves_deleted_project_and_tags(self):
project = Project.objects.create(workspace=self.workspace, name="Deleted project") project = Project.objects.create(workspace=self.workspace, name="Deleted project")
tag = Tag.objects.create( 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): 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): def test_time_entry_list_returns_grouped_payload_for_ended_entries(self):
user = User.objects.create_user(mobile="09126666666", password="secret123") user = User.objects.create_user(mobile="09126666666", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user) workspace = Workspace.objects.create(name="Core", owner=user)

View File

@@ -1,10 +1,10 @@
from __future__ import annotations from __future__ import annotations
import logging
import secrets import secrets
from dataclasses import asdict, dataclass, is_dataclass from dataclasses import asdict, dataclass, is_dataclass
from typing import Any from typing import Any
from urllib.parse import urlencode from urllib.parse import urlencode, urlparse
from urllib.parse import urlparse
import requests import requests
from django.conf import settings 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.models import User, UserSocialAccount
from apps.users.services.auth import generate_and_send_otp, get_tokens_for_user 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_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo" 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_STATE_CACHE_PREFIX = "google_oauth_state"
GOOGLE_FLOW_CACHE_PREFIX = "google_oauth_flow" GOOGLE_FLOW_CACHE_PREFIX = "google_oauth_flow"
logger = logging.getLogger(__name__)
class GoogleOAuthFlowError(APIException): class GoogleOAuthFlowError(APIException):
status_code = 409 status_code = 409
@@ -305,6 +306,16 @@ def exchange_code_for_google_profile(code: str) -> GoogleProfile:
token_response.raise_for_status() token_response.raise_for_status()
token_payload = token_response.json() token_payload = token_response.json()
except requests.RequestException as exc: 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 raise ValidationError({"detail": "Google token exchange failed."}) from exc
access_token = token_payload.get("access_token") 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_response.raise_for_status()
userinfo = userinfo_response.json() userinfo = userinfo_response.json()
except requests.RequestException as exc: 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 raise ValidationError({"detail": "Google user profile lookup failed."}) from exc
provider_user_id = userinfo.get("sub", "") 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, user=existing_mobile_user,
mobile=normalized_mobile, mobile=normalized_mobile,
resolution="existing_mobile_claim", 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) update_google_flow(flow, claim_payload)
return _build_public_google_flow_payload(claim_payload) return _build_public_google_flow_payload(claim_payload)

View File

@@ -1,18 +1,22 @@
from io import StringIO from io import StringIO
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from urllib.parse import parse_qs, urlparse
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.management import call_command from django.core.management import call_command
from django.db import IntegrityError from django.db import IntegrityError
from django.test import override_settings from django.test import override_settings
from rest_framework.test import APIRequestFactory
from rest_framework import status 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.api.views import RegisterWithPasswordView
from apps.users.models import User, UserSocialAccount 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): class UserApiViewTests(APITestCase):
@@ -551,6 +555,42 @@ class GoogleOAuthApiTests(APITestCase):
self.assertIn("accounts.google.com", response["Location"]) self.assertIn("accounts.google.com", response["Location"])
self.assertIn("state=", 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.services.google_oauth.requests.get")
@patch("apps.users.api.views.exchange_code_for_google_profile") @patch("apps.users.api.views.exchange_code_for_google_profile")
def test_google_callback_redirects_with_authenticated_flow_for_linked_account( def test_google_callback_redirects_with_authenticated_flow_for_linked_account(
@@ -995,7 +1035,7 @@ class GoogleOAuthAuditCommandTests(APITestCase):
password="secret123", password="secret123",
email="owner@example.com", email="owner@example.com",
) )
other_user = User.objects.create_user( User.objects.create_user(
mobile="09126660002", mobile="09126660002",
password="secret123", password="secret123",
email="shared@example.com", email="shared@example.com",