feat(reports): add daily rate to report tables and exports
This commit is contained in:
@@ -81,6 +81,15 @@ def _serialize_money_totals(values: dict[str, Decimal]) -> list[dict]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_rate(amount: Decimal | None, currency: str | None) -> dict | None:
|
||||||
|
if amount is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"amount": f"{Decimal(amount).quantize(Decimal('0.01'))}",
|
||||||
|
"currency": currency or "USD",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _add_income(bucket: defaultdict[str, Decimal], entry: TimeEntry):
|
def _add_income(bucket: defaultdict[str, Decimal], entry: TimeEntry):
|
||||||
if not entry.is_billable or not entry.hourly_rate:
|
if not entry.is_billable or not entry.hourly_rate:
|
||||||
return
|
return
|
||||||
@@ -438,17 +447,18 @@ def _scope_payload(filters: ReportFilters) -> dict:
|
|||||||
|
|
||||||
def _table_report_payload(filters: ReportFilters, entries: list[TimeEntry]) -> dict:
|
def _table_report_payload(filters: ReportFilters, entries: list[TimeEntry]) -> dict:
|
||||||
summary = _summary_from_entries(entries)
|
summary = _summary_from_entries(entries)
|
||||||
|
include_latest_rate = not (filters.is_workspace_scope and not filters.user_id)
|
||||||
return {
|
return {
|
||||||
"scope": _scope_payload(filters),
|
"scope": _scope_payload(filters),
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"days": _group_daily(entries),
|
"days": _group_daily(entries, include_latest_rate=include_latest_rate),
|
||||||
"clients": _build_breakdown(entries, "clients"),
|
"clients": _build_breakdown(entries, "clients"),
|
||||||
"projects": _build_breakdown(entries, "projects"),
|
"projects": _build_breakdown(entries, "projects"),
|
||||||
"tags": _build_breakdown(entries, "tags"),
|
"tags": _build_breakdown(entries, "tags"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _group_daily(entries: list[TimeEntry]) -> list[dict]:
|
def _group_daily(entries: list[TimeEntry], *, include_latest_rate: bool) -> list[dict]:
|
||||||
by_day: dict[str, dict] = {}
|
by_day: dict[str, dict] = {}
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
local_start = _localize_datetime(entry.start_time)
|
local_start = _localize_datetime(entry.start_time)
|
||||||
@@ -461,6 +471,9 @@ def _group_daily(entries: list[TimeEntry]) -> list[dict]:
|
|||||||
"non_billable_seconds": 0,
|
"non_billable_seconds": 0,
|
||||||
"total_seconds": 0,
|
"total_seconds": 0,
|
||||||
"income": _money_map(),
|
"income": _money_map(),
|
||||||
|
"latest_rate_amount": None,
|
||||||
|
"latest_rate_currency": None,
|
||||||
|
"latest_rate_timestamp": None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
duration_seconds = get_entry_duration_seconds(entry)
|
duration_seconds = get_entry_duration_seconds(entry)
|
||||||
@@ -470,6 +483,18 @@ def _group_daily(entries: list[TimeEntry]) -> list[dict]:
|
|||||||
else:
|
else:
|
||||||
day_bucket["non_billable_seconds"] += duration_seconds
|
day_bucket["non_billable_seconds"] += duration_seconds
|
||||||
_add_income(day_bucket["income"], entry)
|
_add_income(day_bucket["income"], entry)
|
||||||
|
if (
|
||||||
|
include_latest_rate
|
||||||
|
and entry.is_billable
|
||||||
|
and entry.hourly_rate
|
||||||
|
and (
|
||||||
|
day_bucket["latest_rate_timestamp"] is None
|
||||||
|
or local_start >= day_bucket["latest_rate_timestamp"]
|
||||||
|
)
|
||||||
|
):
|
||||||
|
day_bucket["latest_rate_amount"] = Decimal(entry.hourly_rate)
|
||||||
|
day_bucket["latest_rate_currency"] = entry.currency or "USD"
|
||||||
|
day_bucket["latest_rate_timestamp"] = local_start
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
for day_key in sorted(by_day.keys()):
|
for day_key in sorted(by_day.keys()):
|
||||||
@@ -483,6 +508,10 @@ def _group_daily(entries: list[TimeEntry]) -> list[dict]:
|
|||||||
"billable_duration": _format_duration_seconds(bucket["billable_seconds"]),
|
"billable_duration": _format_duration_seconds(bucket["billable_seconds"]),
|
||||||
"non_billable_duration": _format_duration_seconds(bucket["non_billable_seconds"]),
|
"non_billable_duration": _format_duration_seconds(bucket["non_billable_seconds"]),
|
||||||
"total_duration": _format_duration_seconds(bucket["total_seconds"]),
|
"total_duration": _format_duration_seconds(bucket["total_seconds"]),
|
||||||
|
"latest_hourly_rate": _serialize_rate(
|
||||||
|
bucket["latest_rate_amount"],
|
||||||
|
bucket["latest_rate_currency"],
|
||||||
|
) if include_latest_rate else None,
|
||||||
"income_totals": _serialize_money_totals(bucket["income"]),
|
"income_totals": _serialize_money_totals(bucket["income"]),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,12 +29,14 @@ TRANSLATIONS = {
|
|||||||
"from_date": "From date",
|
"from_date": "From date",
|
||||||
"to_date": "To date",
|
"to_date": "To date",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
|
"mobile": "Mobile",
|
||||||
"all_users": "All users",
|
"all_users": "All users",
|
||||||
"generated_at": "Generated at",
|
"generated_at": "Generated at",
|
||||||
"summary": "Summary",
|
"summary": "Summary",
|
||||||
"total_hours": "Total hours",
|
"total_hours": "Total hours",
|
||||||
"billable_hours": "Billable hours",
|
"billable_hours": "Billable hours",
|
||||||
"non_billable_hours": "Non-billable hours",
|
"non_billable_hours": "Non-billable hours",
|
||||||
|
"hourly_rate": "Hourly rate",
|
||||||
"income": "Income",
|
"income": "Income",
|
||||||
"daily_summary": "Daily Summary",
|
"daily_summary": "Daily Summary",
|
||||||
"clients": "Clients",
|
"clients": "Clients",
|
||||||
@@ -53,12 +55,14 @@ TRANSLATIONS = {
|
|||||||
"from_date": "از تاریخ",
|
"from_date": "از تاریخ",
|
||||||
"to_date": "تا تاریخ",
|
"to_date": "تا تاریخ",
|
||||||
"user": "کاربر",
|
"user": "کاربر",
|
||||||
|
"mobile": "موبایل",
|
||||||
"all_users": "همه کاربران",
|
"all_users": "همه کاربران",
|
||||||
"generated_at": "تاریخ تولید",
|
"generated_at": "تاریخ تولید",
|
||||||
"summary": "خلاصه",
|
"summary": "خلاصه",
|
||||||
"total_hours": "کل ساعات",
|
"total_hours": "کل ساعات",
|
||||||
"billable_hours": "ساعات کاری",
|
"billable_hours": "ساعات کاری",
|
||||||
"non_billable_hours": "ساعات غیر کاری",
|
"non_billable_hours": "ساعات غیر کاری",
|
||||||
|
"hourly_rate": "نرخ ساعتی",
|
||||||
"income": "درآمد",
|
"income": "درآمد",
|
||||||
"daily_summary": "خلاصه روزانه",
|
"daily_summary": "خلاصه روزانه",
|
||||||
"clients": "مشتریان",
|
"clients": "مشتریان",
|
||||||
|
|||||||
@@ -63,6 +63,18 @@ def _money_label_excel(locale: ExportLocale, income_totals: list[dict]) -> str:
|
|||||||
return locale.format_money_label(income_totals, ascii_digits=True)
|
return locale.format_money_label(income_totals, ascii_digits=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_label(locale: ExportLocale, rate: dict | None) -> str:
|
||||||
|
if not rate:
|
||||||
|
return "-"
|
||||||
|
return f"{locale.format_amount(rate['amount'])} {locale.currency_label(rate['currency'])}"
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_label_excel(locale: ExportLocale, rate: dict | None) -> str:
|
||||||
|
if not rate:
|
||||||
|
return "-"
|
||||||
|
return f"{locale.format_amount(rate['amount'], ascii_digits=True)} {locale.currency_label(rate['currency'])}"
|
||||||
|
|
||||||
|
|
||||||
def _section_headers(locale: ExportLocale) -> list[str]:
|
def _section_headers(locale: ExportLocale) -> list[str]:
|
||||||
headers = [
|
headers = [
|
||||||
locale.t("name"),
|
locale.t("name"),
|
||||||
@@ -87,7 +99,24 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) ->
|
|||||||
worksheet.append(_rtl_row(locale, [locale.t("period"), locale.period_label(scope["period"])]))
|
worksheet.append(_rtl_row(locale, [locale.t("period"), locale.period_label(scope["period"])]))
|
||||||
worksheet.append(_rtl_row(locale, [locale.t("from_date"), locale.format_date(scope["from_date"], ascii_digits=True)]))
|
worksheet.append(_rtl_row(locale, [locale.t("from_date"), locale.format_date(scope["from_date"], ascii_digits=True)]))
|
||||||
worksheet.append(_rtl_row(locale, [locale.t("to_date"), locale.format_date(scope["to_date"], ascii_digits=True)]))
|
worksheet.append(_rtl_row(locale, [locale.t("to_date"), locale.format_date(scope["to_date"], ascii_digits=True)]))
|
||||||
worksheet.append(_rtl_row(locale, [locale.t("user"), user_label(scope.get("user"), locale, ascii_digits=True)]))
|
worksheet.append(
|
||||||
|
_rtl_row(
|
||||||
|
locale,
|
||||||
|
[
|
||||||
|
locale.t("user"),
|
||||||
|
scope["user"]["name"] if scope.get("user") else locale.t("all_users"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
worksheet.append(
|
||||||
|
_rtl_row(
|
||||||
|
locale,
|
||||||
|
[
|
||||||
|
locale.t("mobile"),
|
||||||
|
locale.format_number(scope["user"]["mobile"], ascii_digits=True) if scope.get("user") and scope["user"].get("mobile") else "-",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
worksheet.append(_rtl_row(locale, [locale.t("generated_at"), locale.format_date(datetime.now().date(), ascii_digits=True)]))
|
worksheet.append(_rtl_row(locale, [locale.t("generated_at"), locale.format_date(datetime.now().date(), ascii_digits=True)]))
|
||||||
worksheet.append([])
|
worksheet.append([])
|
||||||
worksheet.append([locale.t("summary")])
|
worksheet.append([locale.t("summary")])
|
||||||
@@ -99,12 +128,12 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) ->
|
|||||||
for row_index in range(1, worksheet.max_row + 1):
|
for row_index in range(1, worksheet.max_row + 1):
|
||||||
first_cell = worksheet.cell(row=row_index, column=1)
|
first_cell = worksheet.cell(row=row_index, column=1)
|
||||||
second_cell = worksheet.cell(row=row_index, column=2)
|
second_cell = worksheet.cell(row=row_index, column=2)
|
||||||
if row_index in {1, 9}:
|
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=SECTION_FILL if row_index == 8 else 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:
|
||||||
_apply_cell_style(second_cell, rtl=locale.is_rtl)
|
_apply_cell_style(second_cell, rtl=locale.is_rtl)
|
||||||
|
|
||||||
@@ -121,6 +150,7 @@ def _append_daily_table(worksheet, *, locale: ExportLocale, report_data: dict) -
|
|||||||
locale.t("billable_hours"),
|
locale.t("billable_hours"),
|
||||||
locale.t("non_billable_hours"),
|
locale.t("non_billable_hours"),
|
||||||
locale.t("total_hours"),
|
locale.t("total_hours"),
|
||||||
|
locale.t("hourly_rate"),
|
||||||
locale.t("income"),
|
locale.t("income"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -142,6 +172,7 @@ def _append_daily_table(worksheet, *, locale: ExportLocale, report_data: dict) -
|
|||||||
locale.format_duration(row["billable_duration"], ascii_digits=True),
|
locale.format_duration(row["billable_duration"], 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),
|
||||||
|
_rate_label_excel(locale, row.get("latest_hourly_rate")),
|
||||||
_money_label_excel(locale, row["income_totals"]),
|
_money_label_excel(locale, row["income_totals"]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -157,6 +188,7 @@ def _append_daily_table(worksheet, *, locale: ExportLocale, report_data: dict) -
|
|||||||
locale.format_duration(report_data["summary"]["billable_duration"], ascii_digits=True),
|
locale.format_duration(report_data["summary"]["billable_duration"], ascii_digits=True),
|
||||||
locale.format_duration(report_data["summary"]["non_billable_duration"], ascii_digits=True),
|
locale.format_duration(report_data["summary"]["non_billable_duration"], ascii_digits=True),
|
||||||
locale.format_duration(report_data["summary"]["total_duration"], ascii_digits=True),
|
locale.format_duration(report_data["summary"]["total_duration"], ascii_digits=True),
|
||||||
|
"-",
|
||||||
_money_label_excel(locale, report_data["summary"]["income_totals"]),
|
_money_label_excel(locale, report_data["summary"]["income_totals"]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -261,9 +293,10 @@ def _styled_table(data: list[list[str]], *, locale: ExportLocale, column_widths:
|
|||||||
return table
|
return table
|
||||||
|
|
||||||
|
|
||||||
def _report_table_rows(locale: ExportLocale, rows: list[dict]) -> list[list[str]]:
|
def _report_table_rows(locale: ExportLocale, rows: list[dict], *, is_daily: bool) -> list[list[str]]:
|
||||||
if not rows:
|
if not rows:
|
||||||
return [_rtl_row(locale, [locale.t("no_data"), "", "", "", ""])]
|
column_count = 6 if is_daily else 5
|
||||||
|
return [_rtl_row(locale, [locale.t("no_data"), *([""] * (column_count - 1))])]
|
||||||
return [
|
return [
|
||||||
_rtl_row(
|
_rtl_row(
|
||||||
locale,
|
locale,
|
||||||
@@ -272,6 +305,7 @@ def _report_table_rows(locale: ExportLocale, rows: list[dict]) -> list[list[str]
|
|||||||
locale.format_duration(row["billable_duration"]),
|
locale.format_duration(row["billable_duration"]),
|
||||||
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"]),
|
||||||
|
*([_rate_label(locale, row.get("latest_hourly_rate"))] if is_daily else []),
|
||||||
_money_label(locale, row["income_totals"]),
|
_money_label(locale, row["income_totals"]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -360,7 +394,8 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale) -> bytes:
|
|||||||
[locale.t("period"), locale.period_label(scope["period"])],
|
[locale.t("period"), locale.period_label(scope["period"])],
|
||||||
[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"), user_label(scope.get("user"), locale)],
|
[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("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:
|
||||||
@@ -425,19 +460,31 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale) -> bytes:
|
|||||||
locale.t("billable_hours"),
|
locale.t("billable_hours"),
|
||||||
locale.t("non_billable_hours"),
|
locale.t("non_billable_hours"),
|
||||||
locale.t("total_hours"),
|
locale.t("total_hours"),
|
||||||
|
*( [locale.t("hourly_rate")] if is_daily else [] ),
|
||||||
locale.t("income"),
|
locale.t("income"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
table = _styled_table(
|
table = _styled_table(
|
||||||
[header, *_report_table_rows(locale, rows)],
|
[header, *_report_table_rows(locale, rows, is_daily=is_daily)],
|
||||||
locale=locale,
|
locale=locale,
|
||||||
column_widths=[
|
column_widths=(
|
||||||
|
[
|
||||||
|
doc.width * 0.21,
|
||||||
|
doc.width * 0.13,
|
||||||
|
doc.width * 0.15,
|
||||||
|
doc.width * 0.13,
|
||||||
|
doc.width * 0.16,
|
||||||
|
doc.width * 0.22,
|
||||||
|
]
|
||||||
|
if is_daily
|
||||||
|
else [
|
||||||
doc.width * 0.26,
|
doc.width * 0.26,
|
||||||
doc.width * 0.15,
|
doc.width * 0.15,
|
||||||
doc.width * 0.17,
|
doc.width * 0.17,
|
||||||
doc.width * 0.14,
|
doc.width * 0.14,
|
||||||
doc.width * 0.28,
|
doc.width * 0.28,
|
||||||
],
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
story.extend([table, Spacer(1, 5 * mm)])
|
story.extend([table, Spacer(1, 5 * mm)])
|
||||||
|
|
||||||
|
|||||||
@@ -171,6 +171,53 @@ def test_generate_excel_export_adds_per_user_sheets_for_all_users_scope(
|
|||||||
assert len(workbook.sheetnames) == 3
|
assert len(workbook.sheetnames) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_excel_export_includes_daily_rate_column_and_split_user_meta(
|
||||||
|
fake_redis,
|
||||||
|
workspace,
|
||||||
|
owner,
|
||||||
|
time_entry,
|
||||||
|
):
|
||||||
|
job = ReportExportJob.objects.create(
|
||||||
|
requesting_user=owner,
|
||||||
|
workspace=workspace,
|
||||||
|
export_type=ReportExportJob.ExportType.EXCEL,
|
||||||
|
filters={
|
||||||
|
"workspace": str(workspace.id),
|
||||||
|
"period": "this_month",
|
||||||
|
"from_date": "2026-04-01",
|
||||||
|
"to_date": "2026-04-30",
|
||||||
|
"user": str(owner.id),
|
||||||
|
"client": None,
|
||||||
|
"project": None,
|
||||||
|
"tags": [],
|
||||||
|
"language": "en",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
generate_report_export_task(str(job.id))
|
||||||
|
job.refresh_from_db()
|
||||||
|
|
||||||
|
workbook = load_workbook(BytesIO(job.file.read()))
|
||||||
|
worksheet = workbook.active
|
||||||
|
values = list(worksheet.iter_rows(values_only=True))
|
||||||
|
|
||||||
|
assert any(row[:2] == ("User", "Owner User") for row in values if row)
|
||||||
|
assert any(row[:2] == ("Mobile", "09129990001") for row in values if row)
|
||||||
|
|
||||||
|
daily_header = next(row[:6] for row in values if row and row[0] == "Date")
|
||||||
|
assert daily_header == (
|
||||||
|
"Date",
|
||||||
|
"Billable hours",
|
||||||
|
"Non-billable hours",
|
||||||
|
"Total hours",
|
||||||
|
"Hourly rate",
|
||||||
|
"Income",
|
||||||
|
)
|
||||||
|
|
||||||
|
daily_row = next(row[:6] for row in values if row and row[0] == "2026/04/12")
|
||||||
|
assert daily_row[4] == "15 USD"
|
||||||
|
|
||||||
|
|
||||||
def test_generate_pdf_export_supports_persian_locale(fake_redis, workspace, owner, time_entry):
|
def test_generate_pdf_export_supports_persian_locale(fake_redis, workspace, owner, time_entry):
|
||||||
job = ReportExportJob.objects.create(
|
job = ReportExportJob.objects.create(
|
||||||
requesting_user=owner,
|
requesting_user=owner,
|
||||||
|
|||||||
@@ -108,6 +108,48 @@ def test_admin_can_request_combined_table_report(api_client, admin, workspace, t
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.data["summary"]["total_duration"] == "03:00:00"
|
assert response.data["summary"]["total_duration"] == "03:00:00"
|
||||||
assert len(response.data["days"]) == 2
|
assert len(response.data["days"]) == 2
|
||||||
|
assert response.data["days"][0]["latest_hourly_rate"] is None
|
||||||
|
assert response.data["days"][1]["latest_hourly_rate"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_rate_uses_latest_billable_entry_snapshot(api_client, owner, workspace, project):
|
||||||
|
api_client.force_authenticate(user=owner)
|
||||||
|
|
||||||
|
TimeEntry.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=owner,
|
||||||
|
project=project,
|
||||||
|
description="Morning work",
|
||||||
|
start_time="2026-04-15T08:00:00+03:30",
|
||||||
|
end_time="2026-04-15T09:00:00+03:30",
|
||||||
|
duration=timedelta(hours=1),
|
||||||
|
is_billable=True,
|
||||||
|
hourly_rate=Decimal("20.00"),
|
||||||
|
currency="USD",
|
||||||
|
)
|
||||||
|
TimeEntry.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=owner,
|
||||||
|
project=project,
|
||||||
|
description="Later work",
|
||||||
|
start_time="2026-04-15T13:00:00+03:30",
|
||||||
|
end_time="2026-04-15T15:00:00+03:30",
|
||||||
|
duration=timedelta(hours=2),
|
||||||
|
is_billable=True,
|
||||||
|
hourly_rate=Decimal("35.00"),
|
||||||
|
currency="USD",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = api_client.get(
|
||||||
|
"/api/reports/table/",
|
||||||
|
{"workspace": str(workspace.id), "period": "this_month", "user": str(owner.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["days"][0]["latest_hourly_rate"] == {
|
||||||
|
"amount": "35.00",
|
||||||
|
"currency": "USD",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_custom_period_longer_than_31_days_is_rejected(api_client, owner, workspace):
|
def test_custom_period_longer_than_31_days_is_rejected(api_client, owner, workspace):
|
||||||
|
|||||||
Reference in New Issue
Block a user