Skip to content

Commit 54e9514

Browse files
Copilotnijel
andcommitted
Unify color mappings, use stacked chart for daily view, reuse cnb_mock_rates, add return type annotations
Co-authored-by: nijel <212189+nijel@users.noreply.github.com>
1 parent 8a92792 commit 54e9514

File tree

2 files changed

+80
-104
lines changed

2 files changed

+80
-104
lines changed

weblate_web/crm/tests.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -220,15 +220,11 @@ def setUp(self):
220220
# Create test customer
221221
self.customer = Customer.objects.create(user_id=-1, name="TEST CUSTOMER")
222222

223-
def mock_exchange_rates_for_date(self, date_str):
224-
"""Mock exchange rates for a specific date."""
225-
responses.get(
226-
f"https://api.cnb.cz/cnbapi/exrates/daily?date={date_str}",
227-
json=RATES_JSON,
228-
)
229-
230223
def create_test_invoice(self, year, month, category, amount):
231224
"""Create a test invoice with the specified parameters."""
225+
# Mock exchange rates
226+
cnb_mock_rates()
227+
232228
invoice = Invoice.objects.create(
233229
kind=InvoiceKind.INVOICE,
234230
category=category,

weblate_web/crm/views.py

Lines changed: 77 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -342,19 +342,13 @@ class IncomeView(CRMMixin, TemplateView): # type: ignore[misc]
342342
CHART_PADDING = 60
343343
MIN_CHART_VALUE = Decimal(1)
344344

345-
# Category colors shared across all charts
345+
# Category colors shared across all charts (keyed by category label)
346346
CATEGORY_COLORS = {
347347
"Hosting": "#417690",
348348
"Support": "#79aec8",
349349
"Development / Consultations": "#5b80b2",
350350
"Donation": "#9fc5e8",
351351
}
352-
CATEGORY_COLORS_BY_VALUE = {
353-
InvoiceCategory.HOSTING.value: "#417690",
354-
InvoiceCategory.SUPPORT.value: "#79aec8",
355-
InvoiceCategory.DEVEL.value: "#5b80b2",
356-
InvoiceCategory.DONATE.value: "#9fc5e8",
357-
}
358352

359353
def get_year(self) -> int:
360354
"""Get the year from URL kwargs or default to current year."""
@@ -469,9 +463,13 @@ def generate_svg_pie_chart(self, data: dict[str, Decimal]) -> str: # noqa: PLR0
469463
return "".join(svg_parts)
470464

471465
def generate_svg_stacked_bar_chart( # noqa: PLR0914
472-
self, monthly_data: dict, invoices: list
466+
self, monthly_data: dict, invoices: list, year: int, month: int | None = None
473467
) -> str:
474-
"""Generate a stacked bar chart showing monthly totals by category."""
468+
"""Generate a stacked bar chart showing totals by category.
469+
470+
For yearly view (month=None): shows 12 monthly bars
471+
For monthly view (month=int): shows daily bars for that month
472+
"""
475473
if not monthly_data:
476474
return ""
477475

@@ -484,6 +482,20 @@ def generate_svg_stacked_bar_chart( # noqa: PLR0914
484482
# Pre-calculate invoice totals in EUR
485483
invoice_totals = {inv.pk: inv.total_amount_no_vat for inv in invoices}
486484

485+
# Determine number of bars and labels based on view type
486+
if month:
487+
# Monthly view: show daily bars
488+
num_bars = calendar.monthrange(year, month)[1]
489+
bar_labels = [str(d) for d in range(1, num_bars + 1)]
490+
filter_func = lambda inv, idx: inv.issue_date.day == idx + 1
491+
label_prefix = "Day"
492+
else:
493+
# Yearly view: show monthly bars
494+
num_bars = 12
495+
bar_labels = [f"{m:02d}" for m in range(1, 13)]
496+
filter_func = lambda inv, idx: inv.issue_date.month == idx + 1
497+
label_prefix = ""
498+
487499
# Get max value for scaling
488500
max_value = max(monthly_data.values()) if monthly_data.values() else Decimal(1)
489501
if max_value <= 0:
@@ -494,18 +506,17 @@ def generate_svg_stacked_bar_chart( # noqa: PLR0914
494506
]
495507

496508
# Calculate bar properties
497-
num_bars = 12
498509
bar_spacing = chart_width / (num_bars * 1.5)
499510
bar_width = bar_spacing * 0.8
500511

501-
# Draw each month
502-
for month_idx in range(1, 13):
503-
month_key = f"{month_idx:02d}"
504-
x: float = padding + bar_spacing * (month_idx - 0.5)
512+
# Draw each bar
513+
for idx in range(num_bars):
514+
bar_label = bar_labels[idx]
515+
x: float = padding + bar_spacing * (idx + 0.5)
505516

506-
# Get invoices for this month by category
507-
month_invoices = [
508-
inv for inv in invoices if inv.issue_date.month == month_idx
517+
# Get invoices for this time period by category
518+
period_invoices = [
519+
inv for inv in invoices if filter_func(inv, idx)
509520
]
510521

511522
# Stack bars by category
@@ -514,7 +525,7 @@ def generate_svg_stacked_bar_chart( # noqa: PLR0914
514525
category_total = sum(
515526
(
516527
invoice_totals[inv.pk]
517-
for inv in month_invoices
528+
for inv in period_invoices
518529
if inv.category == category.value
519530
),
520531
start=Decimal(0),
@@ -524,84 +535,30 @@ def generate_svg_stacked_bar_chart( # noqa: PLR0914
524535
bar_height = float(category_total / max_value * chart_height)
525536
y = y_offset - bar_height
526537

538+
title_label = f"{label_prefix} {bar_label}" if label_prefix else bar_label
527539
svg_parts.append(
528540
f'<rect x="{x}" y="{y}" width="{bar_width}" height="{bar_height}" '
529-
f'fill="{self.CATEGORY_COLORS_BY_VALUE.get(category.value, "#999")}" stroke="white" stroke-width="1">'
530-
f"<title>{category.label} - {month_key}: €{category_total:,.0f}</title>"
541+
f'fill="{self.CATEGORY_COLORS.get(category.label, "#999")}" stroke="white" stroke-width="1">'
542+
f"<title>{category.label} - {title_label}: €{category_total:,.0f}</title>"
531543
f"</rect>"
532544
)
533545
y_offset = y
534546

535-
# Month label
547+
# Bar label
536548
label_x = x + bar_width / 2
537549
label_y = height - padding + 15
550+
font_size = "9" if month else "10" # Smaller font for daily view (more bars)
538551
svg_parts.append(
539552
f'<text x="{label_x}" y="{label_y}" text-anchor="middle" '
540-
f'font-size="10" fill="#666">{month_key}</text>'
541-
)
542-
543-
svg_parts.append("</svg>")
544-
return "".join(svg_parts)
545-
546-
def generate_svg_daily_chart( # noqa: PLR0914
547-
self, year: int, month: int, invoices: list
548-
) -> str:
549-
"""Generate a daily bar chart for a specific month."""
550-
width = self.CHART_WIDTH
551-
height = 300
552-
padding = 40
553-
chart_width = width - 2 * padding
554-
chart_height = height - 2 * padding
555-
556-
# Get number of days in month
557-
num_days = calendar.monthrange(year, month)[1]
558-
559-
# Pre-calculate invoice totals in EUR
560-
invoice_totals = {inv.pk: inv.total_amount_no_vat for inv in invoices}
561-
562-
# Calculate daily totals
563-
daily_totals = {}
564-
for day in range(1, num_days + 1):
565-
daily_invoices = [inv for inv in invoices if inv.issue_date.day == day]
566-
daily_totals[day] = sum(
567-
(invoice_totals[inv.pk] for inv in daily_invoices),
568-
start=Decimal(0),
569-
)
570-
571-
max_value = max(daily_totals.values()) if daily_totals.values() else Decimal(1)
572-
if max_value <= 0:
573-
max_value = self.MIN_CHART_VALUE
574-
575-
svg_parts = [
576-
f'<svg width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg" class="daily-chart">',
577-
]
578-
579-
# Draw bars
580-
bar_width = chart_width / num_days * 0.8
581-
for day, value in daily_totals.items():
582-
x: float = padding + (day - 1) * (chart_width / num_days)
583-
bar_height = float(value / max_value * chart_height) if value > 0 else 0
584-
y = height - padding - bar_height
585-
586-
svg_parts.append(
587-
f'<rect x="{x}" y="{y}" width="{bar_width}" height="{bar_height}" '
588-
f'fill="#417690">'
589-
f"<title>Day {day}: €{value:,.0f}</title>"
590-
f"</rect>"
591-
)
592-
593-
# Show all day labels
594-
label_x = x + bar_width / 2
595-
label_y = height - padding + 12
596-
svg_parts.append(
597-
f'<text x="{label_x}" y="{label_y}" text-anchor="middle" '
598-
f'font-size="9" fill="#666">{day}</text>'
553+
f'font-size="{font_size}" fill="#666">{bar_label}</text>'
599554
)
600555

601556
svg_parts.append("</svg>")
602557
return "".join(svg_parts)
603558

604-
def get_income_data(self, year: int, month: int | None = None):
559+
def get_income_data(
560+
self, year: int, month: int | None = None
561+
) -> dict[str, Decimal]:
605562
"""Get income data aggregated by category."""
606563
# Build query with proper database-level filtering
607564
query = Invoice.objects.filter(kind=InvoiceKind.INVOICE, issue_date__year=year)
@@ -629,7 +586,7 @@ def get_income_data(self, year: int, month: int | None = None):
629586

630587
return category_data
631588

632-
def get_monthly_data(self, year: int):
589+
def get_monthly_data(self, year: int) -> tuple[dict[str, Decimal], list]:
633590
"""Get monthly income data for the year."""
634591
# Fetch all invoices for the year at once to avoid 12 separate queries
635592
invoices = list(
@@ -656,7 +613,38 @@ def get_monthly_data(self, year: int):
656613

657614
return monthly_totals, invoices
658615

659-
def get_monthly_category_data(self, year: int):
616+
def get_daily_data(self, year: int, month: int) -> tuple[dict[str, Decimal], list]:
617+
"""Get daily income data for a specific month."""
618+
invoices = list(
619+
Invoice.objects.filter(
620+
kind=InvoiceKind.INVOICE, issue_date__year=year, issue_date__month=month
621+
).prefetch_related("invoiceitem_set")
622+
)
623+
624+
# Pre-calculate totals in EUR
625+
invoice_totals = {inv.pk: inv.total_amount_no_vat for inv in invoices}
626+
627+
# Get number of days in month
628+
num_days = calendar.monthrange(year, month)[1]
629+
630+
# Group by day
631+
daily_totals = {}
632+
for day in range(1, num_days + 1):
633+
total = sum(
634+
(
635+
invoice_totals[inv.pk]
636+
for inv in invoices
637+
if inv.issue_date.day == day
638+
),
639+
start=Decimal(0),
640+
)
641+
daily_totals[str(day)] = total
642+
643+
return daily_totals, invoices
644+
645+
def get_monthly_category_data(
646+
self, year: int
647+
) -> tuple[dict[str, dict[str, Decimal]], list]:
660648
"""Get monthly income data split by category for stacked chart."""
661649
invoices = list(
662650
Invoice.objects.filter(
@@ -708,25 +696,17 @@ def get_context_data(self, **kwargs):
708696
context["pie_chart_svg"] = self.generate_svg_pie_chart(income_data)
709697

710698
if month:
711-
# For monthly view, show daily chart
712-
# Get all invoices for the month
713-
month_invoices = list(
714-
Invoice.objects.filter(
715-
kind=InvoiceKind.INVOICE,
716-
issue_date__year=year,
717-
issue_date__month=month,
718-
).prefetch_related("invoiceitem_set")
719-
)
720-
721-
context["daily_chart_svg"] = self.generate_svg_daily_chart(
722-
year, month, month_invoices
699+
# For monthly view, show daily stacked chart
700+
daily_data, daily_invoices = self.get_daily_data(year, month)
701+
context["daily_chart_svg"] = self.generate_svg_stacked_bar_chart(
702+
daily_data, daily_invoices, year, month
723703
)
724704
context["is_monthly"] = True
725705
else:
726-
# For yearly view, show stacked monthly chart
706+
# For yearly view, show monthly stacked chart
727707
monthly_data, invoices = self.get_monthly_data(year)
728708
context["chart_svg"] = self.generate_svg_stacked_bar_chart(
729-
monthly_data, invoices
709+
monthly_data, invoices, year
730710
)
731711
context["monthly_data"] = monthly_data
732712
context["monthly_category_data"], _ = self.get_monthly_category_data(year)

0 commit comments

Comments
 (0)