feat(reports): improve summary rates and export formatting
This commit is contained in:
@@ -18,8 +18,7 @@ from apps.projects.models import Project
|
||||
from apps.projects.services.access import user_has_project_access
|
||||
from apps.tags.models import Tag
|
||||
from apps.time_entries.models import TimeEntry
|
||||
from apps.workspaces.models import Workspace
|
||||
from apps.workspaces.models import WorkspaceUserRate
|
||||
from apps.workspaces.models import HourlyRateHistory, Workspace
|
||||
from apps.workspaces.services import WORKSPACE_VIEW, get_workspace_role, has_workspace_capability
|
||||
|
||||
User = get_user_model()
|
||||
@@ -121,113 +120,73 @@ def _serialize_distinct_rates_from_rows(rate_rows: list[dict]) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
def _serialize_rate_periods(entries: list[TimeEntry]) -> list[dict]:
|
||||
sorted_entries = sorted(entries, key=lambda entry: (entry.start_time, entry.end_time or entry.start_time, entry.id))
|
||||
periods: list[dict] = []
|
||||
current: dict | None = None
|
||||
def _serialize_rate_history_rows(*, user, workspace: Workspace, from_date: date, to_date: date) -> list[dict]:
|
||||
current_timezone = timezone.get_current_timezone()
|
||||
period_start = timezone.make_aware(datetime.combine(from_date, time.min), current_timezone)
|
||||
period_end = timezone.make_aware(datetime.combine(to_date + timedelta(days=1), time.min), current_timezone)
|
||||
rows = list(
|
||||
HourlyRateHistory.objects.filter(
|
||||
workspace=workspace,
|
||||
user=user,
|
||||
is_deleted=False,
|
||||
effective_from__lt=period_end,
|
||||
)
|
||||
.select_related("project")
|
||||
.order_by("scope", "project_id", "effective_from", "created_at")
|
||||
)
|
||||
|
||||
for entry in sorted_entries:
|
||||
if not entry.hourly_rate or not entry.start_time:
|
||||
continue
|
||||
grouped: dict[tuple[str, str | None], list[HourlyRateHistory]] = defaultdict(list)
|
||||
for row in rows:
|
||||
grouped[(row.scope, str(row.project_id) if row.project_id else None)].append(row)
|
||||
|
||||
amount = f"{Decimal(entry.hourly_rate).quantize(Decimal('0.01'))}"
|
||||
currency = entry.currency or "USD"
|
||||
start_date = _localize_datetime(entry.start_time).date()
|
||||
end_source = entry.end_time or entry.start_time
|
||||
end_date = _localize_datetime(end_source).date()
|
||||
serialized: list[dict] = []
|
||||
for (_scope, _project_id), history_rows in grouped.items():
|
||||
selected_indexes = {
|
||||
index for index, row in enumerate(history_rows) if row.effective_from >= period_start
|
||||
}
|
||||
previous_indexes = [
|
||||
index for index, row in enumerate(history_rows) if row.effective_from < period_start
|
||||
]
|
||||
if previous_indexes:
|
||||
selected_indexes.add(previous_indexes[-1])
|
||||
|
||||
if (
|
||||
current
|
||||
and current["amount"] == amount
|
||||
and current["currency"] == currency
|
||||
):
|
||||
if end_date > current["to_date"]:
|
||||
current["to_date"] = end_date
|
||||
continue
|
||||
|
||||
if current:
|
||||
periods.append(
|
||||
for index in sorted(selected_indexes):
|
||||
row = history_rows[index]
|
||||
next_row = history_rows[index + 1] if index + 1 < len(history_rows) else None
|
||||
if next_row and next_row.effective_from < period_start:
|
||||
continue
|
||||
from_day = max(_localize_datetime(row.effective_from).date(), from_date)
|
||||
to_day = min(_localize_datetime(next_row.effective_from).date(), to_date) if next_row else None
|
||||
serialized.append(
|
||||
{
|
||||
"amount": current["amount"],
|
||||
"currency": current["currency"],
|
||||
"from_date": current["from_date"].isoformat(),
|
||||
"to_date": current["to_date"].isoformat(),
|
||||
"amount": f"{Decimal(row.hourly_rate).quantize(Decimal('0.01'))}",
|
||||
"currency": row.currency or "USD",
|
||||
"from_date": from_day.isoformat(),
|
||||
"to_date": to_day.isoformat() if to_day else None,
|
||||
"scope": row.scope,
|
||||
"project_name": row.project.name if row.project else None,
|
||||
"is_current": next_row is None,
|
||||
}
|
||||
)
|
||||
|
||||
current = {
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
"from_date": start_date,
|
||||
"to_date": end_date,
|
||||
}
|
||||
|
||||
if current:
|
||||
periods.append(
|
||||
{
|
||||
"amount": current["amount"],
|
||||
"currency": current["currency"],
|
||||
"from_date": current["from_date"].isoformat(),
|
||||
"to_date": current["to_date"].isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
return periods
|
||||
|
||||
|
||||
def _serialize_current_rate_rows(*, user, workspace: Workspace) -> list[dict]:
|
||||
workspace_rate = (
|
||||
WorkspaceUserRate.objects.filter(
|
||||
workspace=workspace,
|
||||
user=user,
|
||||
is_active=True,
|
||||
is_deleted=False,
|
||||
)
|
||||
.order_by("-effective_from", "-updated_at")
|
||||
.first()
|
||||
)
|
||||
if not workspace_rate or not workspace_rate.effective_from:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"amount": f"{Decimal(workspace_rate.hourly_rate).quantize(Decimal('0.01'))}",
|
||||
"currency": workspace_rate.currency or "USD",
|
||||
"from_date": _localize_datetime(workspace_rate.effective_from).date().isoformat(),
|
||||
"to_date": None,
|
||||
"is_current": True,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _merge_rate_history_rows(history_rows: list[dict], current_rows: list[dict]) -> list[dict]:
|
||||
merged = [dict(row) for row in history_rows]
|
||||
latest_indexes = {
|
||||
(row["amount"], row["currency"]): index
|
||||
for index, row in enumerate(merged)
|
||||
}
|
||||
|
||||
for row in current_rows:
|
||||
key = (row["amount"], row["currency"])
|
||||
index = latest_indexes.get(key)
|
||||
if index is not None:
|
||||
merged[index]["to_date"] = None
|
||||
continue
|
||||
|
||||
merged.append(dict(row))
|
||||
latest_indexes[key] = len(merged) - 1
|
||||
|
||||
return sorted(
|
||||
merged,
|
||||
serialized,
|
||||
key=lambda item: (
|
||||
item["from_date"],
|
||||
item["currency"],
|
||||
item["scope"],
|
||||
item.get("project_name") or "",
|
||||
Decimal(item["amount"]),
|
||||
item.get("to_date") or "9999-12-31",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _uncategorized_label(kind: str, language: str) -> str:
|
||||
if language == "fa":
|
||||
return {
|
||||
"clients": "بدون مشتری",
|
||||
"projects": "بدون پروژه",
|
||||
"tags": "بدون تگ",
|
||||
}[kind]
|
||||
resolved_language = language if language in UNCATEGORIZED_LABELS else "en"
|
||||
return UNCATEGORIZED_LABELS[resolved_language][kind]
|
||||
|
||||
@@ -422,11 +381,22 @@ def _serialize_income_percentage_rows(rows: list[dict], shares: dict[str, dict])
|
||||
return _complete_percentage_rows(rows, _allocate_percentage_rows(items, total_income))
|
||||
|
||||
|
||||
def _build_user_summary(user, entries: list[TimeEntry], *, language: str) -> dict:
|
||||
def _build_user_summary(
|
||||
user,
|
||||
entries: list[TimeEntry],
|
||||
*,
|
||||
workspace: Workspace,
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
language: str,
|
||||
) -> dict:
|
||||
summary = _summary_from_entries(entries)
|
||||
historical_rate_rows = _serialize_rate_periods(entries)
|
||||
current_rate_rows = _serialize_current_rate_rows(user=user, workspace=entries[0].workspace)
|
||||
rate_rows = _merge_rate_history_rows(historical_rate_rows, current_rate_rows)
|
||||
rate_rows = _serialize_rate_history_rows(
|
||||
user=user,
|
||||
workspace=workspace,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
)
|
||||
project_rows = _build_breakdown(entries, "projects", language=language)
|
||||
client_rows = _build_breakdown(entries, "clients", language=language)
|
||||
tag_rows = _build_breakdown(entries, "tags", language=language)
|
||||
@@ -458,13 +428,20 @@ def _build_user_summary(user, entries: list[TimeEntry], *, language: str) -> dic
|
||||
}
|
||||
|
||||
|
||||
def _build_user_summaries(entries: list[TimeEntry], *, language: str) -> list[dict]:
|
||||
def _build_user_summaries(entries: list[TimeEntry], *, filters: ReportFilters) -> list[dict]:
|
||||
grouped: dict[str, list[TimeEntry]] = defaultdict(list)
|
||||
for entry in entries:
|
||||
grouped[str(entry.user_id)].append(entry)
|
||||
|
||||
summaries = [
|
||||
_build_user_summary(grouped_entries[0].user, grouped_entries, language=language)
|
||||
_build_user_summary(
|
||||
grouped_entries[0].user,
|
||||
grouped_entries,
|
||||
workspace=filters.workspace,
|
||||
from_date=filters.from_date,
|
||||
to_date=filters.to_date,
|
||||
language=filters.language,
|
||||
)
|
||||
for grouped_entries in grouped.values()
|
||||
if grouped_entries
|
||||
]
|
||||
@@ -799,9 +776,6 @@ def _bucket_key(filters: ReportFilters, local_dt: datetime) -> tuple[str, date]:
|
||||
if filters.period in {PERIOD_THIS_WEEK, PERIOD_THIS_MONTH, PERIOD_CUSTOM}:
|
||||
bucket_date = local_dt.date()
|
||||
return bucket_date.isoformat(), bucket_date
|
||||
if filters.language == "fa":
|
||||
persian_date = jdatetime.date.fromgregorian(date=local_dt.date())
|
||||
return f"{persian_date.year:04d}-{persian_date.month:02d}", local_dt.date()
|
||||
bucket_date = date(local_dt.year, local_dt.month, 1)
|
||||
return bucket_date.strftime("%Y-%m"), bucket_date
|
||||
|
||||
@@ -1045,11 +1019,18 @@ def build_table_report(actor, raw_filters) -> dict:
|
||||
payload = _table_report_payload(
|
||||
filters,
|
||||
entries,
|
||||
user_summaries=_build_user_summaries(entries, language=filters.language),
|
||||
user_summaries=_build_user_summaries(entries, filters=filters),
|
||||
)
|
||||
return payload
|
||||
user_summary = (
|
||||
_build_user_summary(entries[0].user, entries, language=filters.language)
|
||||
_build_user_summary(
|
||||
entries[0].user,
|
||||
entries,
|
||||
workspace=filters.workspace,
|
||||
from_date=filters.from_date,
|
||||
to_date=filters.to_date,
|
||||
language=filters.language,
|
||||
)
|
||||
if entries and filters.user_id
|
||||
else None
|
||||
)
|
||||
@@ -1063,7 +1044,14 @@ def build_user_summary_report(actor, raw_filters) -> dict:
|
||||
|
||||
entries = list(_base_queryset(filters))
|
||||
user_summary = (
|
||||
_build_user_summary(entries[0].user, entries, language=filters.language)
|
||||
_build_user_summary(
|
||||
entries[0].user,
|
||||
entries,
|
||||
workspace=filters.workspace,
|
||||
from_date=filters.from_date,
|
||||
to_date=filters.to_date,
|
||||
language=filters.language,
|
||||
)
|
||||
if entries
|
||||
else None
|
||||
)
|
||||
@@ -1095,6 +1083,9 @@ def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
|
||||
user_summary=_build_user_summary(
|
||||
user_entries[0].user,
|
||||
user_entries,
|
||||
workspace=filters.workspace,
|
||||
from_date=filters.from_date,
|
||||
to_date=filters.to_date,
|
||||
language=filters.language,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -219,7 +219,7 @@ class ExportLocale:
|
||||
|
||||
def format_money_label(self, income_totals: list[dict], *, ascii_digits: bool = False) -> str:
|
||||
if not income_totals:
|
||||
return "-"
|
||||
return self.format_number("0", ascii_digits=ascii_digits)
|
||||
parts = []
|
||||
for item in income_totals:
|
||||
currency = self.currency_label(item["currency"])
|
||||
|
||||
@@ -76,7 +76,7 @@ def _money_label_excel(locale: ExportLocale, income_totals: list[dict]) -> str:
|
||||
|
||||
def _rates_label(locale: ExportLocale, rates: list[dict], *, ascii_digits: bool = False) -> str:
|
||||
if not rates:
|
||||
return "-"
|
||||
return locale.format_number("0", ascii_digits=ascii_digits)
|
||||
items = [
|
||||
f"{locale.format_amount_for_currency(rate['amount'], rate['currency'], ascii_digits=ascii_digits)} {locale.currency_label(rate['currency'])}"
|
||||
for rate in rates
|
||||
@@ -103,13 +103,13 @@ def _rate_period_label(locale: ExportLocale, row: dict, *, ascii_digits: bool =
|
||||
|
||||
def _rate_label(locale: ExportLocale, rate: dict | None) -> str:
|
||||
if not rate:
|
||||
return "-"
|
||||
return locale.format_number("0")
|
||||
return f"{locale.format_amount_for_currency(rate['amount'], rate['currency'])} {locale.currency_label(rate['currency'])}"
|
||||
|
||||
|
||||
def _rate_label_excel(locale: ExportLocale, rate: dict | None) -> str:
|
||||
if not rate:
|
||||
return "-"
|
||||
return locale.format_number("0", ascii_digits=True)
|
||||
value = (
|
||||
f"{locale.format_amount_for_currency(rate['amount'], rate['currency'], ascii_digits=True)} "
|
||||
f"{locale.currency_label(rate['currency'])}"
|
||||
@@ -213,7 +213,14 @@ def _append_user_summary_block(worksheet, *, locale: ExportLocale, user_summary:
|
||||
worksheet.append(row)
|
||||
|
||||
|
||||
def _percentage_display(locale: ExportLocale, rows: list[dict], row_data: dict, *, ascii_digits: bool = False) -> str:
|
||||
def _percentage_display(
|
||||
locale: ExportLocale,
|
||||
rows: list[dict],
|
||||
row_data: dict,
|
||||
*,
|
||||
ascii_digits: bool = False,
|
||||
default: str = "0%",
|
||||
) -> str:
|
||||
row_id = str(row_data.get("id")) if row_data.get("id") is not None else None
|
||||
row_name = row_data.get("name")
|
||||
for row in rows:
|
||||
@@ -222,7 +229,7 @@ def _percentage_display(locale: ExportLocale, rows: list[dict], row_data: dict,
|
||||
return value
|
||||
if row_name and row["name"] == row_name:
|
||||
return value
|
||||
return "-"
|
||||
return default
|
||||
|
||||
|
||||
def _percentage_number(rows: list[dict] | None, row_data: dict) -> float:
|
||||
@@ -277,7 +284,7 @@ def _summary_breakdown_rows(
|
||||
[
|
||||
row["name"],
|
||||
_percentage_value(locale, row["percentage"], ascii_digits=True),
|
||||
_percentage_display(locale, income_rows, row, ascii_digits=True),
|
||||
_percentage_display(locale, income_rows, row, ascii_digits=True, default="-"),
|
||||
]
|
||||
for row in sorted(hour_rows, key=lambda row: (-_percentage_sort_value(row), row.get("name") or ""))
|
||||
]
|
||||
@@ -304,11 +311,11 @@ def _rate_to_label(locale: ExportLocale, to_date: str | None, *, ascii_digits: b
|
||||
|
||||
def _append_rate_history_table_excel(worksheet, *, locale: ExportLocale, user_summary: dict) -> None:
|
||||
worksheet.append([])
|
||||
_append_merged_heading(worksheet, locale=locale, title=locale.t("rate_history"), span=3)
|
||||
_append_merged_heading(worksheet, locale=locale, title=locale.t("rate_history"), span=4)
|
||||
header_row = worksheet.max_row + 1
|
||||
worksheet.append(
|
||||
_excel_table_row(
|
||||
[locale.t("hourly_rate"), locale.t("from"), locale.t("to")],
|
||||
[locale.t("hourly_rate"), locale.t("from"), locale.t("to"), locale.t("project")],
|
||||
)
|
||||
)
|
||||
for cell in worksheet[header_row]:
|
||||
@@ -327,6 +334,7 @@ def _append_rate_history_table_excel(worksheet, *, locale: ExportLocale, user_su
|
||||
_rate_period_label(locale, row, ascii_digits=True),
|
||||
locale.format_date(row["from_date"], ascii_digits=True),
|
||||
_rate_to_label(locale, row.get("to_date"), ascii_digits=True),
|
||||
row.get("project_name") or "-",
|
||||
],
|
||||
)
|
||||
)
|
||||
@@ -562,7 +570,7 @@ def _append_breakdown_table(
|
||||
),
|
||||
_money_label_excel(locale, row["income_totals"]),
|
||||
*(
|
||||
[_percentage_display(locale, income_percentages or [], row, ascii_digits=True)]
|
||||
[_percentage_display(locale, income_percentages or [], row, ascii_digits=True, default="-")]
|
||||
if hour_percentages is not None
|
||||
else []
|
||||
),
|
||||
@@ -669,8 +677,11 @@ def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int,
|
||||
locale.format_duration(summary["billable_duration"], ascii_digits=True) if index == 0 else None,
|
||||
*(rate_rows[index] if index < len(rate_rows) else [None, None]),
|
||||
_money_label_excel(locale, summary["income_totals"]) if index == 0 else None,
|
||||
None,
|
||||
*(client_rows[index] if index < len(client_rows) else [None, None, None]),
|
||||
None,
|
||||
*(project_rows[index] if index < len(project_rows) else [None, None, None]),
|
||||
None,
|
||||
*(tag_rows[index] if index < len(tag_rows) else [None, None, None]),
|
||||
],
|
||||
)
|
||||
@@ -733,7 +744,7 @@ def _render_all_users_overall_excel_sheet(
|
||||
worksheet,
|
||||
row=15,
|
||||
start_col=1,
|
||||
end_col=15,
|
||||
end_col=18,
|
||||
value=locale.t("users_summary_sheet"),
|
||||
rtl=locale.is_rtl,
|
||||
)
|
||||
@@ -744,12 +755,15 @@ def _render_all_users_overall_excel_sheet(
|
||||
locale.t("hourly_rate"),
|
||||
locale.t("period"),
|
||||
locale.t("income"),
|
||||
"",
|
||||
locale.t("clients"),
|
||||
locale.t("hour_percentage"),
|
||||
locale.t("income_percentage"),
|
||||
"",
|
||||
locale.t("projects"),
|
||||
locale.t("hour_percentage"),
|
||||
locale.t("income_percentage"),
|
||||
"",
|
||||
locale.t("tags"),
|
||||
locale.t("hour_percentage"),
|
||||
locale.t("income_percentage"),
|
||||
@@ -784,19 +798,26 @@ def _render_all_users_overall_excel_sheet(
|
||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=4, value_present=True)
|
||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=5, value_present=True)
|
||||
if len(client_rows) == 1:
|
||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=7, 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)
|
||||
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=11, value_present=True)
|
||||
if len(project_rows) == 1:
|
||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=12, value_present=True)
|
||||
if len(tag_rows) == 1:
|
||||
_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=14, value_present=True)
|
||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=15, value_present=True)
|
||||
if len(tag_rows) == 1:
|
||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=16, value_present=True)
|
||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=17, value_present=True)
|
||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=18, value_present=True)
|
||||
current_row += span
|
||||
|
||||
for row_index in range(16, current_row):
|
||||
for column_index in (7, 11, 15):
|
||||
cell = worksheet.cell(row=row_index, column=column_index)
|
||||
cell.value = None
|
||||
cell.fill = PatternFill(fill_type=None)
|
||||
cell.border = Border()
|
||||
|
||||
current_row += 2
|
||||
for title_key, rows, hour_percentages, income_percentages in (
|
||||
(
|
||||
@@ -855,7 +876,7 @@ def _render_all_users_overall_excel_sheet(
|
||||
locale.format_duration(row["billable_duration"], ascii_digits=True),
|
||||
_percentage_display(locale, hour_percentages or [], row, ascii_digits=True),
|
||||
_money_label_excel(locale, row["income_totals"]),
|
||||
_percentage_display(locale, income_percentages or [], row, ascii_digits=True),
|
||||
_percentage_display(locale, income_percentages or [], row, ascii_digits=True, default="-"),
|
||||
],
|
||||
rtl=locale.is_rtl,
|
||||
)
|
||||
@@ -1121,7 +1142,7 @@ def _build_pdf_user_summary_table(locale: ExportLocale, summary: dict, doc_width
|
||||
def _build_pdf_rate_history_table(locale: ExportLocale, summary: dict, doc_width: float) -> Table:
|
||||
rows = summary.get("rate_periods") or []
|
||||
data = [
|
||||
_rtl_row(locale, [locale.t("hourly_rate"), locale.t("from"), locale.t("to")]),
|
||||
_rtl_row(locale, [locale.t("hourly_rate"), locale.t("from"), locale.t("to"), locale.t("project")]),
|
||||
*(
|
||||
_rtl_row(
|
||||
locale,
|
||||
@@ -1129,14 +1150,15 @@ def _build_pdf_rate_history_table(locale: ExportLocale, summary: dict, doc_width
|
||||
_rate_period_label(locale, row),
|
||||
locale.format_date(row["from_date"]),
|
||||
_rate_to_label(locale, row.get("to_date")),
|
||||
row.get("project_name") or "-",
|
||||
],
|
||||
)
|
||||
for row in rows
|
||||
),
|
||||
]
|
||||
if not rows:
|
||||
data.append(_rtl_row(locale, [locale.t("no_data"), "", ""]))
|
||||
fixed_widths = [doc_width * 0.18, doc_width * 0.18]
|
||||
data.append(_rtl_row(locale, [locale.t("no_data"), "", "", ""]))
|
||||
fixed_widths = [doc_width * 0.18, doc_width * 0.18, doc_width * 0.24]
|
||||
column_widths = [doc_width - sum(fixed_widths), *fixed_widths]
|
||||
if locale.is_rtl:
|
||||
column_widths = list(reversed(column_widths))
|
||||
@@ -1227,7 +1249,7 @@ def _append_pdf_report_sections(
|
||||
]
|
||||
),
|
||||
_money_label(locale, row["income_totals"]),
|
||||
_percentage_display(locale, income_percentage_rows or [], row),
|
||||
_percentage_display(locale, income_percentage_rows or [], row, default="-"),
|
||||
],
|
||||
)
|
||||
for row in sorted_rows
|
||||
|
||||
@@ -199,9 +199,9 @@ class ReportExporterTests(TestCase):
|
||||
self.assertEqual(summary_sheet["A1"].value, "Workspace Report")
|
||||
self.assertEqual(summary_sheet["B1"].value, "Exports")
|
||||
self.assertEqual(summary_sheet["A15"].value, "Users Summary")
|
||||
self.assertIn("A15:O15", {str(item) for item in summary_sheet.merged_cells.ranges})
|
||||
self.assertIn("A15:R15", {str(item) for item in summary_sheet.merged_cells.ranges})
|
||||
self.assertEqual(
|
||||
tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:15],
|
||||
tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:18],
|
||||
(
|
||||
"Name",
|
||||
"Mobile",
|
||||
@@ -209,12 +209,15 @@ class ReportExporterTests(TestCase):
|
||||
"Hourly rate",
|
||||
"Period",
|
||||
"Income",
|
||||
None,
|
||||
"Clients",
|
||||
"Hour %",
|
||||
"Income %",
|
||||
None,
|
||||
"Projects",
|
||||
"Hour %",
|
||||
"Income %",
|
||||
None,
|
||||
"Tags",
|
||||
"Hour %",
|
||||
"Income %",
|
||||
|
||||
Reference in New Issue
Block a user