arthexis 0.1.23__py3-none-any.whl → 0.1.24__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

core/models.py CHANGED
@@ -8,7 +8,7 @@ from django.db.models import Q
8
8
  from django.db.models.functions import Lower, Length
9
9
  from django.conf import settings
10
10
  from django.contrib.auth import get_user_model
11
- from django.utils.translation import gettext_lazy as _
11
+ from django.utils.translation import gettext_lazy as _, gettext, override
12
12
  from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
13
13
  from django.core.exceptions import ValidationError
14
14
  from django.apps import apps
@@ -33,7 +33,7 @@ import re
33
33
  from io import BytesIO
34
34
  from django.core.files.base import ContentFile
35
35
  import qrcode
36
- from django.utils import timezone
36
+ from django.utils import timezone, formats
37
37
  from django.utils.dateparse import parse_datetime
38
38
  import uuid
39
39
  from pathlib import Path
@@ -51,6 +51,51 @@ xmlrpc_client = defused_xmlrpc.xmlrpc_client
51
51
 
52
52
  logger = logging.getLogger(__name__)
53
53
 
54
+
55
+ def _available_language_codes() -> set[str]:
56
+ return {code.lower() for code, _ in getattr(settings, "LANGUAGES", [])}
57
+
58
+
59
+ def default_report_language() -> str:
60
+ configured = getattr(settings, "LANGUAGE_CODE", "en") or "en"
61
+ configured = configured.replace("_", "-").lower()
62
+ base = configured.split("-", 1)[0]
63
+ available = _available_language_codes()
64
+ if base in available:
65
+ return base
66
+ if configured in available:
67
+ return configured
68
+ if available:
69
+ return next(iter(sorted(available)))
70
+ return "en"
71
+
72
+
73
+ def normalize_report_language(language: str | None) -> str:
74
+ default = default_report_language()
75
+ if not language:
76
+ return default
77
+ candidate = str(language).strip().lower()
78
+ if not candidate:
79
+ return default
80
+ candidate = candidate.replace("_", "-")
81
+ available = _available_language_codes()
82
+ if candidate in available:
83
+ return candidate
84
+ base = candidate.split("-", 1)[0]
85
+ if base in available:
86
+ return base
87
+ return default
88
+
89
+
90
+ def normalize_report_title(title: str | None) -> str:
91
+ value = (title or "").strip()
92
+ if "\r" in value or "\n" in value:
93
+ raise ValidationError(
94
+ _("Report title cannot contain control characters."),
95
+ )
96
+ return value
97
+
98
+
54
99
  from .entity import Entity, EntityUserManager, EntityManager
55
100
  from .release import (
56
101
  Package as ReleasePackage,
@@ -2722,8 +2767,24 @@ class ClientReportSchedule(Entity):
2722
2767
  periodicity = models.CharField(
2723
2768
  max_length=12, choices=PERIODICITY_CHOICES, default=PERIODICITY_NONE
2724
2769
  )
2770
+ language = models.CharField(
2771
+ max_length=12,
2772
+ choices=settings.LANGUAGES,
2773
+ default=default_report_language,
2774
+ )
2775
+ title = models.CharField(
2776
+ max_length=200,
2777
+ blank=True,
2778
+ default="",
2779
+ verbose_name=_("Title"),
2780
+ )
2725
2781
  email_recipients = models.JSONField(default=list, blank=True)
2726
2782
  disable_emails = models.BooleanField(default=False)
2783
+ chargers = models.ManyToManyField(
2784
+ "ocpp.Charger",
2785
+ blank=True,
2786
+ related_name="client_report_schedules",
2787
+ )
2727
2788
  periodic_task = models.OneToOneField(
2728
2789
  "django_celery_beat.PeriodicTask",
2729
2790
  on_delete=models.SET_NULL,
@@ -2737,11 +2798,19 @@ class ClientReportSchedule(Entity):
2737
2798
  verbose_name = "Client Report Schedule"
2738
2799
  verbose_name_plural = "Client Report Schedules"
2739
2800
 
2801
+ @classmethod
2802
+ def label_for_periodicity(cls, value: str) -> str:
2803
+ lookup = dict(cls.PERIODICITY_CHOICES)
2804
+ return lookup.get(value, value)
2805
+
2740
2806
  def __str__(self) -> str: # pragma: no cover - simple representation
2741
2807
  owner = self.owner.get_username() if self.owner else "Unassigned"
2742
2808
  return f"Client Report Schedule ({owner})"
2743
2809
 
2744
2810
  def save(self, *args, **kwargs):
2811
+ if self.language:
2812
+ self.language = normalize_report_language(self.language)
2813
+ self.title = normalize_report_title(self.title)
2745
2814
  sync = kwargs.pop("sync_task", True)
2746
2815
  super().save(*args, **kwargs)
2747
2816
  if sync and self.pk:
@@ -2833,6 +2902,78 @@ class ClientReportSchedule(Entity):
2833
2902
 
2834
2903
  return start, end
2835
2904
 
2905
+ def _advance_period(
2906
+ self, start: datetime_date, end: datetime_date
2907
+ ) -> tuple[datetime_date, datetime_date]:
2908
+ import calendar as _calendar
2909
+ import datetime as _datetime
2910
+
2911
+ if self.periodicity == self.PERIODICITY_DAILY:
2912
+ delta = _datetime.timedelta(days=1)
2913
+ return start + delta, end + delta
2914
+ if self.periodicity == self.PERIODICITY_WEEKLY:
2915
+ delta = _datetime.timedelta(days=7)
2916
+ return start + delta, end + delta
2917
+ if self.periodicity == self.PERIODICITY_MONTHLY:
2918
+ base_start = start.replace(day=1)
2919
+ year = base_start.year
2920
+ month = base_start.month
2921
+ if month == 12:
2922
+ next_year = year + 1
2923
+ next_month = 1
2924
+ else:
2925
+ next_year = year
2926
+ next_month = month + 1
2927
+ next_start = base_start.replace(year=next_year, month=next_month, day=1)
2928
+ last_day = _calendar.monthrange(next_year, next_month)[1]
2929
+ next_end = next_start.replace(day=last_day)
2930
+ return next_start, next_end
2931
+ raise ValueError("advance_period called for non-recurring schedule")
2932
+
2933
+ def iter_pending_periods(self, reference=None):
2934
+ from django.utils import timezone
2935
+
2936
+ if self.periodicity == self.PERIODICITY_NONE:
2937
+ return []
2938
+
2939
+ ref_date = reference or timezone.localdate()
2940
+ try:
2941
+ target_start, target_end = self.calculate_period(reference=ref_date)
2942
+ except ValueError:
2943
+ return []
2944
+
2945
+ reports = self.reports.order_by("start_date", "end_date")
2946
+ last_report = reports.last()
2947
+ if last_report:
2948
+ current_start, current_end = self._advance_period(
2949
+ last_report.start_date, last_report.end_date
2950
+ )
2951
+ else:
2952
+ current_start, current_end = target_start, target_end
2953
+
2954
+ if current_end < current_start:
2955
+ return []
2956
+
2957
+ pending: list[tuple[datetime.date, datetime.date]] = []
2958
+ safety = 0
2959
+ while current_end <= target_end:
2960
+ exists = reports.filter(
2961
+ start_date=current_start, end_date=current_end
2962
+ ).exists()
2963
+ if not exists:
2964
+ pending.append((current_start, current_end))
2965
+ try:
2966
+ current_start, current_end = self._advance_period(
2967
+ current_start, current_end
2968
+ )
2969
+ except ValueError:
2970
+ break
2971
+ safety += 1
2972
+ if safety > 400:
2973
+ break
2974
+
2975
+ return pending
2976
+
2836
2977
  def resolve_recipients(self):
2837
2978
  """Return (to, cc) email lists respecting owner fallbacks."""
2838
2979
 
@@ -2880,38 +3021,27 @@ class ClientReportSchedule(Entity):
2880
3021
 
2881
3022
  return to, cc
2882
3023
 
3024
+ def resolve_reply_to(self) -> list[str]:
3025
+ return ClientReport.resolve_reply_to_for_owner(self.owner)
3026
+
2883
3027
  def get_outbox(self):
2884
3028
  """Return the preferred :class:`nodes.models.EmailOutbox` instance."""
2885
3029
 
2886
- from nodes.models import EmailOutbox, Node
2887
-
2888
- if self.owner:
2889
- try:
2890
- outbox = self.owner.get_profile(EmailOutbox)
2891
- except Exception: # pragma: no cover - defensive catch
2892
- outbox = None
2893
- if outbox:
2894
- return outbox
2895
-
2896
- node = Node.get_local()
2897
- if node:
2898
- return getattr(node, "email_outbox", None)
2899
- return None
3030
+ return ClientReport.resolve_outbox_for_owner(self.owner)
2900
3031
 
2901
3032
  def notify_failure(self, message: str):
2902
3033
  from nodes.models import NetMessage
2903
3034
 
2904
3035
  NetMessage.broadcast("Client report delivery issue", message)
2905
3036
 
2906
- def run(self):
3037
+ def run(self, *, start: datetime_date | None = None, end: datetime_date | None = None):
2907
3038
  """Generate the report, persist it and deliver notifications."""
2908
3039
 
2909
- from core import mailer
2910
-
2911
- try:
2912
- start, end = self.calculate_period()
2913
- except ValueError:
2914
- return None
3040
+ if start is None or end is None:
3041
+ try:
3042
+ start, end = self.calculate_period()
3043
+ except ValueError:
3044
+ return None
2915
3045
 
2916
3046
  try:
2917
3047
  report = ClientReport.generate(
@@ -2921,8 +3051,12 @@ class ClientReportSchedule(Entity):
2921
3051
  schedule=self,
2922
3052
  recipients=self.email_recipients,
2923
3053
  disable_emails=self.disable_emails,
3054
+ chargers=list(self.chargers.all()),
3055
+ language=self.language,
3056
+ title=self.title,
2924
3057
  )
2925
- export, html_content = report.store_local_copy()
3058
+ report.chargers.set(self.chargers.all())
3059
+ report.store_local_copy()
2926
3060
  except Exception as exc:
2927
3061
  self.notify_failure(str(exc))
2928
3062
  raise
@@ -2934,43 +3068,12 @@ class ClientReportSchedule(Entity):
2934
3068
  raise RuntimeError("No recipients available for client report")
2935
3069
  else:
2936
3070
  try:
2937
- attachments = []
2938
- html_name = Path(export["html_path"]).name
2939
- attachments.append((html_name, html_content, "text/html"))
2940
- json_file = Path(settings.BASE_DIR) / export["json_path"]
2941
- if json_file.exists():
2942
- attachments.append(
2943
- (
2944
- json_file.name,
2945
- json_file.read_text(encoding="utf-8"),
2946
- "application/json",
2947
- )
2948
- )
2949
- pdf_path = export.get("pdf_path")
2950
- if pdf_path:
2951
- pdf_file = Path(settings.BASE_DIR) / pdf_path
2952
- if pdf_file.exists():
2953
- attachments.append(
2954
- (
2955
- pdf_file.name,
2956
- pdf_file.read_bytes(),
2957
- "application/pdf",
2958
- )
2959
- )
2960
- subject = f"Client report {report.start_date} to {report.end_date}"
2961
- body = (
2962
- "Attached is the client report generated for the period "
2963
- f"{report.start_date} to {report.end_date}."
2964
- )
2965
- mailer.send(
2966
- subject,
2967
- body,
2968
- to,
2969
- outbox=self.get_outbox(),
3071
+ delivered = report.send_delivery(
3072
+ to=to,
2970
3073
  cc=cc,
2971
- attachments=attachments,
3074
+ outbox=self.get_outbox(),
3075
+ reply_to=self.resolve_reply_to(),
2972
3076
  )
2973
- delivered = list(dict.fromkeys(to + (cc or [])))
2974
3077
  if delivered:
2975
3078
  type(report).objects.filter(pk=report.pk).update(
2976
3079
  recipients=delivered
@@ -2985,6 +3088,14 @@ class ClientReportSchedule(Entity):
2985
3088
  self.last_generated_on = now
2986
3089
  return report
2987
3090
 
3091
+ def generate_missing_reports(self, reference=None):
3092
+ generated: list["ClientReport"] = []
3093
+ for start, end in self.iter_pending_periods(reference=reference):
3094
+ report = self.run(start=start, end=end)
3095
+ if report:
3096
+ generated.append(report)
3097
+ return generated
3098
+
2988
3099
 
2989
3100
  class ClientReport(Entity):
2990
3101
  """Snapshot of energy usage over a period."""
@@ -3007,15 +3118,70 @@ class ClientReport(Entity):
3007
3118
  blank=True,
3008
3119
  related_name="reports",
3009
3120
  )
3121
+ language = models.CharField(
3122
+ max_length=12,
3123
+ choices=settings.LANGUAGES,
3124
+ default=default_report_language,
3125
+ )
3126
+ title = models.CharField(
3127
+ max_length=200,
3128
+ blank=True,
3129
+ default="",
3130
+ verbose_name=_("Title"),
3131
+ )
3010
3132
  recipients = models.JSONField(default=list, blank=True)
3011
3133
  disable_emails = models.BooleanField(default=False)
3134
+ chargers = models.ManyToManyField(
3135
+ "ocpp.Charger",
3136
+ blank=True,
3137
+ related_name="client_reports",
3138
+ )
3012
3139
 
3013
3140
  class Meta:
3014
- verbose_name = "Consumer Report"
3015
- verbose_name_plural = "Consumer Reports"
3141
+ verbose_name = _("Consumer Report")
3142
+ verbose_name_plural = _("Consumer Reports")
3016
3143
  db_table = "core_client_report"
3017
3144
  ordering = ["-created_on"]
3018
3145
 
3146
+ def __str__(self) -> str: # pragma: no cover - simple representation
3147
+ period_type = (
3148
+ self.schedule.periodicity
3149
+ if self.schedule
3150
+ else ClientReportSchedule.PERIODICITY_NONE
3151
+ )
3152
+ return f"{self.start_date} - {self.end_date} ({period_type})"
3153
+
3154
+ @staticmethod
3155
+ def default_language() -> str:
3156
+ return default_report_language()
3157
+
3158
+ @staticmethod
3159
+ def normalize_language(language: str | None) -> str:
3160
+ return normalize_report_language(language)
3161
+
3162
+ @staticmethod
3163
+ def normalize_title(title: str | None) -> str:
3164
+ return normalize_report_title(title)
3165
+
3166
+ def save(self, *args, **kwargs):
3167
+ if self.language:
3168
+ self.language = normalize_report_language(self.language)
3169
+ self.title = self.normalize_title(self.title)
3170
+ super().save(*args, **kwargs)
3171
+
3172
+ @property
3173
+ def periodicity_label(self) -> str:
3174
+ if self.schedule:
3175
+ return self.schedule.get_periodicity_display()
3176
+ return ClientReportSchedule.label_for_periodicity(
3177
+ ClientReportSchedule.PERIODICITY_NONE
3178
+ )
3179
+
3180
+ @property
3181
+ def total_kw_period(self) -> float:
3182
+ totals = (self.rows_for_display or {}).get("totals", {})
3183
+ return float(totals.get("total_kw_period", 0.0) or 0.0)
3184
+
3019
3185
  @classmethod
3020
3186
  def generate(
3021
3187
  cls,
@@ -3026,9 +3192,23 @@ class ClientReport(Entity):
3026
3192
  schedule=None,
3027
3193
  recipients: list[str] | None = None,
3028
3194
  disable_emails: bool = False,
3195
+ chargers=None,
3196
+ language: str | None = None,
3197
+ title: str | None = None,
3029
3198
  ):
3030
- payload = cls.build_rows(start_date, end_date)
3031
- return cls.objects.create(
3199
+ from collections.abc import Iterable as _Iterable
3200
+
3201
+ charger_list = []
3202
+ if chargers:
3203
+ if isinstance(chargers, _Iterable):
3204
+ charger_list = list(chargers)
3205
+ else:
3206
+ charger_list = [chargers]
3207
+
3208
+ payload = cls.build_rows(start_date, end_date, chargers=charger_list)
3209
+ normalized_language = cls.normalize_language(language)
3210
+ title_value = cls.normalize_title(title)
3211
+ report = cls.objects.create(
3032
3212
  start_date=start_date,
3033
3213
  end_date=end_date,
3034
3214
  data=payload,
@@ -3036,7 +3216,12 @@ class ClientReport(Entity):
3036
3216
  schedule=schedule,
3037
3217
  recipients=list(recipients or []),
3038
3218
  disable_emails=disable_emails,
3219
+ language=normalized_language,
3220
+ title=title_value,
3039
3221
  )
3222
+ if charger_list:
3223
+ report.chargers.set(charger_list)
3224
+ return report
3040
3225
 
3041
3226
  def store_local_copy(self, html: str | None = None):
3042
3227
  """Persist the report data and optional HTML rendering to disk."""
@@ -3050,9 +3235,16 @@ class ClientReport(Entity):
3050
3235
  timestamp = timezone.now().strftime("%Y%m%d%H%M%S")
3051
3236
  identifier = f"client_report_{self.pk}_{timestamp}"
3052
3237
 
3053
- html_content = html or render_to_string(
3054
- "core/reports/client_report_email.html", {"report": self}
3055
- )
3238
+ language_code = self.normalize_language(self.language)
3239
+ context = {
3240
+ "report": self,
3241
+ "language_code": language_code,
3242
+ "default_language": type(self).default_language(),
3243
+ }
3244
+ with override(language_code):
3245
+ html_content = html or render_to_string(
3246
+ "core/reports/client_report_email.html", context
3247
+ )
3056
3248
  html_path = report_dir / f"{identifier}.html"
3057
3249
  html_path.write_text(html_content, encoding="utf-8")
3058
3250
 
@@ -3076,15 +3268,86 @@ class ClientReport(Entity):
3076
3268
  self.data = updated
3077
3269
  return export, html_content
3078
3270
 
3271
+ def send_delivery(
3272
+ self,
3273
+ *,
3274
+ to: list[str] | tuple[str, ...],
3275
+ cc: list[str] | tuple[str, ...] | None = None,
3276
+ outbox=None,
3277
+ reply_to: list[str] | None = None,
3278
+ ) -> list[str]:
3279
+ from core import mailer
3280
+
3281
+ recipients = list(to or [])
3282
+ if not recipients:
3283
+ return []
3284
+
3285
+ pdf_path = self.ensure_pdf()
3286
+ attachments = [
3287
+ (pdf_path.name, pdf_path.read_bytes(), "application/pdf"),
3288
+ ]
3289
+
3290
+ language_code = self.normalize_language(self.language)
3291
+ with override(language_code):
3292
+ totals = self.rows_for_display.get("totals", {})
3293
+ start_display = formats.date_format(
3294
+ self.start_date, format="DATE_FORMAT", use_l10n=True
3295
+ )
3296
+ end_display = formats.date_format(
3297
+ self.end_date, format="DATE_FORMAT", use_l10n=True
3298
+ )
3299
+ total_kw_period_label = gettext("Total kW during period")
3300
+ total_kw_all_label = gettext("Total kW (all time)")
3301
+ report_title = self.normalize_title(self.title) or gettext(
3302
+ "Consumer Report"
3303
+ )
3304
+ body_lines = [
3305
+ gettext("%(title)s for %(start)s through %(end)s.")
3306
+ % {"title": report_title, "start": start_display, "end": end_display},
3307
+ f"{total_kw_period_label}: "
3308
+ f"{formats.number_format(totals.get('total_kw_period', 0.0), decimal_pos=2, use_l10n=True)}.",
3309
+ f"{total_kw_all_label}: "
3310
+ f"{formats.number_format(totals.get('total_kw', 0.0), decimal_pos=2, use_l10n=True)}.",
3311
+ ]
3312
+ message = "\n".join(body_lines)
3313
+ subject = gettext("%(title)s %(start)s - %(end)s") % {
3314
+ "title": report_title,
3315
+ "start": start_display,
3316
+ "end": end_display,
3317
+ }
3318
+
3319
+ kwargs = {}
3320
+ if reply_to:
3321
+ kwargs["reply_to"] = reply_to
3322
+
3323
+ mailer.send(
3324
+ subject,
3325
+ message,
3326
+ recipients,
3327
+ outbox=outbox,
3328
+ cc=list(cc or []),
3329
+ attachments=attachments,
3330
+ **kwargs,
3331
+ )
3332
+
3333
+ delivered = list(dict.fromkeys(recipients + list(cc or [])))
3334
+ return delivered
3335
+
3079
3336
  @staticmethod
3080
- def build_rows(start_date=None, end_date=None, *, for_display: bool = False):
3081
- dataset = ClientReport._build_dataset(start_date, end_date)
3337
+ def build_rows(
3338
+ start_date=None,
3339
+ end_date=None,
3340
+ *,
3341
+ for_display: bool = False,
3342
+ chargers=None,
3343
+ ):
3344
+ dataset = ClientReport._build_dataset(start_date, end_date, chargers=chargers)
3082
3345
  if for_display:
3083
3346
  return ClientReport._normalize_dataset_for_display(dataset)
3084
3347
  return dataset
3085
3348
 
3086
3349
  @staticmethod
3087
- def _build_dataset(start_date=None, end_date=None):
3350
+ def _build_dataset(start_date=None, end_date=None, *, chargers=None):
3088
3351
  from datetime import datetime, time, timedelta, timezone as pytimezone
3089
3352
  from ocpp.models import Charger, Transaction
3090
3353
 
@@ -3101,6 +3364,14 @@ class ClientReport(Entity):
3101
3364
  )
3102
3365
  qs = qs.filter(start_time__lt=end_dt)
3103
3366
 
3367
+ selected_base_ids = None
3368
+ if chargers:
3369
+ selected_base_ids = {
3370
+ charger.charger_id for charger in chargers if charger.charger_id
3371
+ }
3372
+ if selected_base_ids:
3373
+ qs = qs.filter(charger__charger_id__in=selected_base_ids)
3374
+
3104
3375
  qs = qs.select_related("account", "charger").prefetch_related("meter_values")
3105
3376
  transactions = list(qs.order_by("start_time", "pk"))
3106
3377
 
@@ -3134,6 +3405,8 @@ class ClientReport(Entity):
3134
3405
  if charger is None:
3135
3406
  continue
3136
3407
  base_id = charger.charger_id
3408
+ if selected_base_ids is not None and base_id not in selected_base_ids:
3409
+ continue
3137
3410
  aggregator = aggregator_map.get(base_id) or charger
3138
3411
  entry = groups.setdefault(
3139
3412
  base_id,
@@ -3224,6 +3497,10 @@ class ClientReport(Entity):
3224
3497
  }
3225
3498
  )
3226
3499
 
3500
+ filters: dict[str, Any] = {}
3501
+ if selected_base_ids:
3502
+ filters["chargers"] = sorted(selected_base_ids)
3503
+
3227
3504
  return {
3228
3505
  "schema": "evcs-session/v1",
3229
3506
  "evcs": evcs_entries,
@@ -3231,6 +3508,7 @@ class ClientReport(Entity):
3231
3508
  "total_kw": total_all_time,
3232
3509
  "total_kw_period": total_period,
3233
3510
  },
3511
+ "filters": filters,
3234
3512
  }
3235
3513
 
3236
3514
  @staticmethod
@@ -3330,6 +3608,7 @@ class ClientReport(Entity):
3330
3608
  "total_kw": totals.get("total_kw", 0.0),
3331
3609
  "total_kw_period": totals.get("total_kw_period", 0.0),
3332
3610
  },
3611
+ "filters": dataset.get("filters", {}),
3333
3612
  }
3334
3613
 
3335
3614
  if schema == "session-list/v1":
@@ -3359,7 +3638,11 @@ class ClientReport(Entity):
3359
3638
 
3360
3639
  return {"schema": schema, "rows": parsed}
3361
3640
 
3362
- return {"schema": schema, "rows": dataset.get("rows", [])}
3641
+ return {
3642
+ "schema": schema,
3643
+ "rows": dataset.get("rows", []),
3644
+ "filters": dataset.get("filters", {}),
3645
+ }
3363
3646
 
3364
3647
  @property
3365
3648
  def rows_for_display(self):
@@ -3373,6 +3656,37 @@ class ClientReport(Entity):
3373
3656
  except ValueError:
3374
3657
  return str(path)
3375
3658
 
3659
+ @staticmethod
3660
+ def resolve_reply_to_for_owner(owner) -> list[str]:
3661
+ if not owner:
3662
+ return []
3663
+ try:
3664
+ inbox = owner.get_profile(EmailInbox)
3665
+ except Exception: # pragma: no cover - defensive catch
3666
+ inbox = None
3667
+ if inbox and getattr(inbox, "username", ""):
3668
+ address = inbox.username.strip()
3669
+ if address:
3670
+ return [address]
3671
+ return []
3672
+
3673
+ @staticmethod
3674
+ def resolve_outbox_for_owner(owner):
3675
+ from nodes.models import EmailOutbox, Node
3676
+
3677
+ if owner:
3678
+ try:
3679
+ outbox = owner.get_profile(EmailOutbox)
3680
+ except Exception: # pragma: no cover - defensive catch
3681
+ outbox = None
3682
+ if outbox:
3683
+ return outbox
3684
+
3685
+ node = Node.get_local()
3686
+ if node:
3687
+ return getattr(node, "email_outbox", None)
3688
+ return None
3689
+
3376
3690
  def render_pdf(self, target: Path):
3377
3691
  from reportlab.lib import colors
3378
3692
  from reportlab.lib.pagesizes import landscape, letter
@@ -3392,157 +3706,182 @@ class ClientReport(Entity):
3392
3706
  dataset = self.rows_for_display
3393
3707
  schema = dataset.get("schema")
3394
3708
 
3395
- styles = getSampleStyleSheet()
3396
- title_style = styles["Title"]
3397
- subtitle_style = styles["Heading2"]
3398
- normal_style = styles["BodyText"]
3399
- emphasis_style = styles["Heading3"]
3400
-
3401
- document = SimpleDocTemplate(
3402
- str(target_path),
3403
- pagesize=landscape(letter),
3404
- leftMargin=0.5 * inch,
3405
- rightMargin=0.5 * inch,
3406
- topMargin=0.6 * inch,
3407
- bottomMargin=0.5 * inch,
3408
- )
3709
+ language_code = self.normalize_language(self.language)
3710
+ with override(language_code):
3711
+ styles = getSampleStyleSheet()
3712
+ title_style = styles["Title"]
3713
+ subtitle_style = styles["Heading2"]
3714
+ normal_style = styles["BodyText"]
3715
+ emphasis_style = styles["Heading3"]
3716
+
3717
+ document = SimpleDocTemplate(
3718
+ str(target_path),
3719
+ pagesize=landscape(letter),
3720
+ leftMargin=0.5 * inch,
3721
+ rightMargin=0.5 * inch,
3722
+ topMargin=0.6 * inch,
3723
+ bottomMargin=0.5 * inch,
3724
+ )
3409
3725
 
3410
- story: list = []
3411
- story.append(Paragraph("Consumer Report", title_style))
3412
- story.append(
3413
- Paragraph(
3414
- f"Period: {self.start_date} to {self.end_date}",
3415
- emphasis_style,
3726
+ story: list = []
3727
+
3728
+ report_title = self.normalize_title(self.title) or gettext(
3729
+ "Consumer Report"
3416
3730
  )
3417
- )
3418
- story.append(Spacer(1, 0.25 * inch))
3731
+ story.append(Paragraph(report_title, title_style))
3419
3732
 
3420
- if schema == "evcs-session/v1":
3421
- evcs_entries = dataset.get("evcs", [])
3422
- if not evcs_entries:
3423
- story.append(
3424
- Paragraph(
3425
- "No charging sessions recorded for the selected period.",
3426
- normal_style,
3427
- )
3733
+ start_display = formats.date_format(
3734
+ self.start_date, format="DATE_FORMAT", use_l10n=True
3735
+ )
3736
+ end_display = formats.date_format(
3737
+ self.end_date, format="DATE_FORMAT", use_l10n=True
3738
+ )
3739
+ story.append(
3740
+ Paragraph(
3741
+ gettext("Period: %(start)s to %(end)s")
3742
+ % {"start": start_display, "end": end_display},
3743
+ emphasis_style,
3428
3744
  )
3429
- for index, evcs in enumerate(evcs_entries):
3430
- if index:
3431
- story.append(Spacer(1, 0.2 * inch))
3432
-
3433
- display_name = evcs.get("display_name") or "Charge Point"
3434
- serial_number = evcs.get("serial_number")
3435
- header_text = display_name
3436
- if serial_number:
3437
- header_text = f"{display_name} (Serial: {serial_number})"
3438
- story.append(Paragraph(header_text, subtitle_style))
3439
-
3440
- metrics_text = (
3441
- f"Total kW (all time): {evcs.get('total_kw', 0.0):.2f} | "
3442
- f"Total kW (period): {evcs.get('total_kw_period', 0.0):.2f}"
3745
+ )
3746
+ story.append(Spacer(1, 0.25 * inch))
3747
+
3748
+ total_kw_all_time_label = gettext("Total kW (all time)")
3749
+ total_kw_period_label = gettext("Total kW (period)")
3750
+ connector_label = gettext("Connector")
3751
+ account_label = gettext("Account")
3752
+ session_kwh_label = gettext("Session kWh")
3753
+ no_sessions_period = gettext(
3754
+ "No charging sessions recorded for the selected period."
3755
+ )
3756
+ no_sessions_point = gettext(
3757
+ "No charging sessions recorded for this charge point."
3758
+ )
3759
+ no_structured_data = gettext(
3760
+ "No structured data is available for this report."
3761
+ )
3762
+ report_totals_label = gettext("Report totals")
3763
+ total_kw_period_line = gettext("Total kW during period")
3764
+
3765
+ def format_datetime(value):
3766
+ if not value:
3767
+ return "—"
3768
+ localized = timezone.localtime(value)
3769
+ return formats.date_format(
3770
+ localized, format="DATETIME_FORMAT", use_l10n=True
3443
3771
  )
3444
- story.append(Paragraph(metrics_text, normal_style))
3445
- story.append(Spacer(1, 0.1 * inch))
3446
-
3447
- transactions = evcs.get("transactions", [])
3448
- if transactions:
3449
- table_data = [
3450
- [
3451
- "Connector",
3452
- "Start kWh",
3453
- "End kWh",
3454
- "Session kWh",
3455
- "Start Time",
3456
- "End Time",
3457
- "RFID Label",
3458
- "Account",
3459
- ]
3460
- ]
3461
-
3462
- def _format_number(value):
3463
- return f"{value:.2f}" if value is not None else "—"
3464
3772
 
3465
- for row in transactions:
3466
- start_dt = row.get("start")
3467
- end_dt = row.get("end")
3468
- start_display = (
3469
- timezone.localtime(start_dt).strftime("%Y-%m-%d %H:%M")
3470
- if start_dt
3471
- else ""
3472
- )
3473
- end_display = (
3474
- timezone.localtime(end_dt).strftime("%Y-%m-%d %H:%M")
3475
- if end_dt
3476
- else "—"
3477
- )
3773
+ def format_decimal(value):
3774
+ if value is None:
3775
+ return ""
3776
+ return formats.number_format(value, decimal_pos=2, use_l10n=True)
3777
+
3778
+ if schema == "evcs-session/v1":
3779
+ evcs_entries = dataset.get("evcs", [])
3780
+ if not evcs_entries:
3781
+ story.append(Paragraph(no_sessions_period, normal_style))
3782
+ for index, evcs in enumerate(evcs_entries):
3783
+ if index:
3784
+ story.append(Spacer(1, 0.2 * inch))
3785
+
3786
+ display_name = evcs.get("display_name") or gettext("Charge Point")
3787
+ serial_number = evcs.get("serial_number")
3788
+ if serial_number:
3789
+ header_text = gettext("%(name)s (Serial: %(serial)s)") % {
3790
+ "name": display_name,
3791
+ "serial": serial_number,
3792
+ }
3793
+ else:
3794
+ header_text = display_name
3795
+ story.append(Paragraph(header_text, subtitle_style))
3796
+
3797
+ metrics_text = (
3798
+ f"{total_kw_all_time_label}: "
3799
+ f"{format_decimal(evcs.get('total_kw', 0.0))} | "
3800
+ f"{total_kw_period_label}: "
3801
+ f"{format_decimal(evcs.get('total_kw_period', 0.0))}"
3802
+ )
3803
+ story.append(Paragraph(metrics_text, normal_style))
3804
+ story.append(Spacer(1, 0.1 * inch))
3478
3805
 
3479
- table_data.append(
3806
+ transactions = evcs.get("transactions", [])
3807
+ if transactions:
3808
+ table_data = [
3480
3809
  [
3481
- row.get("connector")
3482
- if row.get("connector") is not None
3483
- else "—",
3484
- _format_number(row.get("start_kwh")),
3485
- _format_number(row.get("end_kwh")),
3486
- _format_number(row.get("session_kwh")),
3487
- start_display,
3488
- end_display,
3489
- row.get("rfid_label") or "—",
3490
- row.get("account_name") or "—",
3810
+ session_kwh_label,
3811
+ gettext("Session start"),
3812
+ gettext("Session end"),
3813
+ connector_label,
3814
+ gettext("RFID label"),
3815
+ account_label,
3491
3816
  ]
3492
- )
3817
+ ]
3493
3818
 
3494
- table = Table(table_data, repeatRows=1)
3495
- table.setStyle(
3496
- TableStyle(
3497
- [
3498
- ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#0f172a")),
3499
- ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
3500
- ("ALIGN", (0, 0), (-1, 0), "CENTER"),
3501
- ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
3502
- ("FONTSIZE", (0, 0), (-1, 0), 9),
3503
- (
3504
- "ROWBACKGROUNDS",
3505
- (0, 1),
3506
- (-1, -1),
3507
- [colors.whitesmoke, colors.HexColor("#eef2ff")],
3508
- ),
3509
- ("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
3510
- ("VALIGN", (0, 1), (-1, -1), "MIDDLE"),
3511
- ]
3512
- )
3513
- )
3514
- story.append(table)
3515
- else:
3516
- story.append(
3517
- Paragraph(
3518
- "No charging sessions recorded for this charge point.",
3519
- normal_style,
3819
+ for row in transactions:
3820
+ start_dt = row.get("start")
3821
+ end_dt = row.get("end")
3822
+ table_data.append(
3823
+ [
3824
+ format_decimal(row.get("session_kwh")),
3825
+ format_datetime(start_dt),
3826
+ format_datetime(end_dt),
3827
+ row.get("connector")
3828
+ if row.get("connector") is not None
3829
+ else "",
3830
+ row.get("rfid_label") or "—",
3831
+ row.get("account_name") or "—",
3832
+ ]
3833
+ )
3834
+
3835
+ table = Table(table_data, repeatRows=1)
3836
+ table.setStyle(
3837
+ TableStyle(
3838
+ [
3839
+ (
3840
+ "BACKGROUND",
3841
+ (0, 0),
3842
+ (-1, 0),
3843
+ colors.HexColor("#0f172a"),
3844
+ ),
3845
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
3846
+ ("ALIGN", (0, 0), (-1, 0), "CENTER"),
3847
+ ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
3848
+ ("FONTSIZE", (0, 0), (-1, 0), 9),
3849
+ (
3850
+ "ROWBACKGROUNDS",
3851
+ (0, 1),
3852
+ (-1, -1),
3853
+ [colors.whitesmoke, colors.HexColor("#eef2ff")],
3854
+ ),
3855
+ ("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
3856
+ ("VALIGN", (0, 1), (-1, -1), "MIDDLE"),
3857
+ ]
3858
+ )
3520
3859
  )
3521
- )
3522
- else:
3860
+ story.append(table)
3861
+ else:
3862
+ story.append(Paragraph(no_sessions_point, normal_style))
3863
+ else:
3864
+ story.append(Paragraph(no_structured_data, normal_style))
3865
+
3866
+ totals = dataset.get("totals") or {}
3867
+ story.append(Spacer(1, 0.3 * inch))
3868
+ story.append(Paragraph(report_totals_label, emphasis_style))
3523
3869
  story.append(
3524
3870
  Paragraph(
3525
- "No structured data is available for this report.",
3526
- normal_style,
3871
+ f"{total_kw_all_time_label}: "
3872
+ f"{format_decimal(totals.get('total_kw', 0.0))}",
3873
+ emphasis_style,
3527
3874
  )
3528
3875
  )
3529
-
3530
- totals = dataset.get("totals") or {}
3531
- story.append(Spacer(1, 0.3 * inch))
3532
- story.append(
3533
- Paragraph(
3534
- f"Report totals — Total kW (all time): {totals.get('total_kw', 0.0):.2f}",
3535
- emphasis_style,
3536
- )
3537
- )
3538
- story.append(
3539
- Paragraph(
3540
- f"Total kW during period: {totals.get('total_kw_period', 0.0):.2f}",
3541
- emphasis_style,
3876
+ story.append(
3877
+ Paragraph(
3878
+ f"{total_kw_period_line}: "
3879
+ f"{format_decimal(totals.get('total_kw_period', 0.0))}",
3880
+ emphasis_style,
3881
+ )
3542
3882
  )
3543
- )
3544
3883
 
3545
- document.build(story)
3884
+ document.build(story)
3546
3885
 
3547
3886
  def ensure_pdf(self) -> Path:
3548
3887
  base_dir = Path(settings.BASE_DIR)