arthexis 0.1.23__py3-none-any.whl → 0.1.25__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,
@@ -589,11 +634,21 @@ class OdooProfile(Profile):
589
634
  """Return the display label for this profile."""
590
635
 
591
636
  username = self._resolved_field_value("username")
637
+ database = self._resolved_field_value("database")
638
+ if username and database:
639
+ return f"{username}@{database}"
592
640
  if username:
593
641
  return username
594
- database = self._resolved_field_value("database")
595
642
  return database or ""
596
643
 
644
+ def _profile_name(self) -> str:
645
+ """Return the stored name for this profile without database suffix."""
646
+
647
+ username = self._resolved_field_value("username")
648
+ if username:
649
+ return username
650
+ return self._resolved_field_value("database")
651
+
597
652
  def save(self, *args, **kwargs):
598
653
  if self.pk:
599
654
  old = type(self).all_objects.get(pk=self.pk)
@@ -604,7 +659,7 @@ class OdooProfile(Profile):
604
659
  or old.host != self.host
605
660
  ):
606
661
  self._clear_verification()
607
- computed_name = self._display_identifier()
662
+ computed_name = self._profile_name()
608
663
  update_fields = kwargs.get("update_fields")
609
664
  update_fields_set = set(update_fields) if update_fields is not None else None
610
665
  if computed_name != self.name:
@@ -639,6 +694,7 @@ class OdooProfile(Profile):
639
694
  self.odoo_uid = uid
640
695
  self.email = info.get("email", "")
641
696
  self.verified_on = timezone.now()
697
+ self.name = self._profile_name()
642
698
  self.save(update_fields=["odoo_uid", "name", "email", "verified_on"])
643
699
  return True
644
700
 
@@ -2722,8 +2778,24 @@ class ClientReportSchedule(Entity):
2722
2778
  periodicity = models.CharField(
2723
2779
  max_length=12, choices=PERIODICITY_CHOICES, default=PERIODICITY_NONE
2724
2780
  )
2781
+ language = models.CharField(
2782
+ max_length=12,
2783
+ choices=settings.LANGUAGES,
2784
+ default=default_report_language,
2785
+ )
2786
+ title = models.CharField(
2787
+ max_length=200,
2788
+ blank=True,
2789
+ default="",
2790
+ verbose_name=_("Title"),
2791
+ )
2725
2792
  email_recipients = models.JSONField(default=list, blank=True)
2726
2793
  disable_emails = models.BooleanField(default=False)
2794
+ chargers = models.ManyToManyField(
2795
+ "ocpp.Charger",
2796
+ blank=True,
2797
+ related_name="client_report_schedules",
2798
+ )
2727
2799
  periodic_task = models.OneToOneField(
2728
2800
  "django_celery_beat.PeriodicTask",
2729
2801
  on_delete=models.SET_NULL,
@@ -2737,11 +2809,19 @@ class ClientReportSchedule(Entity):
2737
2809
  verbose_name = "Client Report Schedule"
2738
2810
  verbose_name_plural = "Client Report Schedules"
2739
2811
 
2812
+ @classmethod
2813
+ def label_for_periodicity(cls, value: str) -> str:
2814
+ lookup = dict(cls.PERIODICITY_CHOICES)
2815
+ return lookup.get(value, value)
2816
+
2740
2817
  def __str__(self) -> str: # pragma: no cover - simple representation
2741
2818
  owner = self.owner.get_username() if self.owner else "Unassigned"
2742
2819
  return f"Client Report Schedule ({owner})"
2743
2820
 
2744
2821
  def save(self, *args, **kwargs):
2822
+ if self.language:
2823
+ self.language = normalize_report_language(self.language)
2824
+ self.title = normalize_report_title(self.title)
2745
2825
  sync = kwargs.pop("sync_task", True)
2746
2826
  super().save(*args, **kwargs)
2747
2827
  if sync and self.pk:
@@ -2833,6 +2913,78 @@ class ClientReportSchedule(Entity):
2833
2913
 
2834
2914
  return start, end
2835
2915
 
2916
+ def _advance_period(
2917
+ self, start: datetime_date, end: datetime_date
2918
+ ) -> tuple[datetime_date, datetime_date]:
2919
+ import calendar as _calendar
2920
+ import datetime as _datetime
2921
+
2922
+ if self.periodicity == self.PERIODICITY_DAILY:
2923
+ delta = _datetime.timedelta(days=1)
2924
+ return start + delta, end + delta
2925
+ if self.periodicity == self.PERIODICITY_WEEKLY:
2926
+ delta = _datetime.timedelta(days=7)
2927
+ return start + delta, end + delta
2928
+ if self.periodicity == self.PERIODICITY_MONTHLY:
2929
+ base_start = start.replace(day=1)
2930
+ year = base_start.year
2931
+ month = base_start.month
2932
+ if month == 12:
2933
+ next_year = year + 1
2934
+ next_month = 1
2935
+ else:
2936
+ next_year = year
2937
+ next_month = month + 1
2938
+ next_start = base_start.replace(year=next_year, month=next_month, day=1)
2939
+ last_day = _calendar.monthrange(next_year, next_month)[1]
2940
+ next_end = next_start.replace(day=last_day)
2941
+ return next_start, next_end
2942
+ raise ValueError("advance_period called for non-recurring schedule")
2943
+
2944
+ def iter_pending_periods(self, reference=None):
2945
+ from django.utils import timezone
2946
+
2947
+ if self.periodicity == self.PERIODICITY_NONE:
2948
+ return []
2949
+
2950
+ ref_date = reference or timezone.localdate()
2951
+ try:
2952
+ target_start, target_end = self.calculate_period(reference=ref_date)
2953
+ except ValueError:
2954
+ return []
2955
+
2956
+ reports = self.reports.order_by("start_date", "end_date")
2957
+ last_report = reports.last()
2958
+ if last_report:
2959
+ current_start, current_end = self._advance_period(
2960
+ last_report.start_date, last_report.end_date
2961
+ )
2962
+ else:
2963
+ current_start, current_end = target_start, target_end
2964
+
2965
+ if current_end < current_start:
2966
+ return []
2967
+
2968
+ pending: list[tuple[datetime.date, datetime.date]] = []
2969
+ safety = 0
2970
+ while current_end <= target_end:
2971
+ exists = reports.filter(
2972
+ start_date=current_start, end_date=current_end
2973
+ ).exists()
2974
+ if not exists:
2975
+ pending.append((current_start, current_end))
2976
+ try:
2977
+ current_start, current_end = self._advance_period(
2978
+ current_start, current_end
2979
+ )
2980
+ except ValueError:
2981
+ break
2982
+ safety += 1
2983
+ if safety > 400:
2984
+ break
2985
+
2986
+ return pending
2987
+
2836
2988
  def resolve_recipients(self):
2837
2989
  """Return (to, cc) email lists respecting owner fallbacks."""
2838
2990
 
@@ -2880,38 +3032,27 @@ class ClientReportSchedule(Entity):
2880
3032
 
2881
3033
  return to, cc
2882
3034
 
3035
+ def resolve_reply_to(self) -> list[str]:
3036
+ return ClientReport.resolve_reply_to_for_owner(self.owner)
3037
+
2883
3038
  def get_outbox(self):
2884
3039
  """Return the preferred :class:`nodes.models.EmailOutbox` instance."""
2885
3040
 
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
3041
+ return ClientReport.resolve_outbox_for_owner(self.owner)
2900
3042
 
2901
3043
  def notify_failure(self, message: str):
2902
3044
  from nodes.models import NetMessage
2903
3045
 
2904
3046
  NetMessage.broadcast("Client report delivery issue", message)
2905
3047
 
2906
- def run(self):
3048
+ def run(self, *, start: datetime_date | None = None, end: datetime_date | None = None):
2907
3049
  """Generate the report, persist it and deliver notifications."""
2908
3050
 
2909
- from core import mailer
2910
-
2911
- try:
2912
- start, end = self.calculate_period()
2913
- except ValueError:
2914
- return None
3051
+ if start is None or end is None:
3052
+ try:
3053
+ start, end = self.calculate_period()
3054
+ except ValueError:
3055
+ return None
2915
3056
 
2916
3057
  try:
2917
3058
  report = ClientReport.generate(
@@ -2921,8 +3062,12 @@ class ClientReportSchedule(Entity):
2921
3062
  schedule=self,
2922
3063
  recipients=self.email_recipients,
2923
3064
  disable_emails=self.disable_emails,
3065
+ chargers=list(self.chargers.all()),
3066
+ language=self.language,
3067
+ title=self.title,
2924
3068
  )
2925
- export, html_content = report.store_local_copy()
3069
+ report.chargers.set(self.chargers.all())
3070
+ report.store_local_copy()
2926
3071
  except Exception as exc:
2927
3072
  self.notify_failure(str(exc))
2928
3073
  raise
@@ -2934,43 +3079,12 @@ class ClientReportSchedule(Entity):
2934
3079
  raise RuntimeError("No recipients available for client report")
2935
3080
  else:
2936
3081
  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(),
3082
+ delivered = report.send_delivery(
3083
+ to=to,
2970
3084
  cc=cc,
2971
- attachments=attachments,
3085
+ outbox=self.get_outbox(),
3086
+ reply_to=self.resolve_reply_to(),
2972
3087
  )
2973
- delivered = list(dict.fromkeys(to + (cc or [])))
2974
3088
  if delivered:
2975
3089
  type(report).objects.filter(pk=report.pk).update(
2976
3090
  recipients=delivered
@@ -2985,6 +3099,14 @@ class ClientReportSchedule(Entity):
2985
3099
  self.last_generated_on = now
2986
3100
  return report
2987
3101
 
3102
+ def generate_missing_reports(self, reference=None):
3103
+ generated: list["ClientReport"] = []
3104
+ for start, end in self.iter_pending_periods(reference=reference):
3105
+ report = self.run(start=start, end=end)
3106
+ if report:
3107
+ generated.append(report)
3108
+ return generated
3109
+
2988
3110
 
2989
3111
  class ClientReport(Entity):
2990
3112
  """Snapshot of energy usage over a period."""
@@ -3007,15 +3129,70 @@ class ClientReport(Entity):
3007
3129
  blank=True,
3008
3130
  related_name="reports",
3009
3131
  )
3132
+ language = models.CharField(
3133
+ max_length=12,
3134
+ choices=settings.LANGUAGES,
3135
+ default=default_report_language,
3136
+ )
3137
+ title = models.CharField(
3138
+ max_length=200,
3139
+ blank=True,
3140
+ default="",
3141
+ verbose_name=_("Title"),
3142
+ )
3010
3143
  recipients = models.JSONField(default=list, blank=True)
3011
3144
  disable_emails = models.BooleanField(default=False)
3145
+ chargers = models.ManyToManyField(
3146
+ "ocpp.Charger",
3147
+ blank=True,
3148
+ related_name="client_reports",
3149
+ )
3012
3150
 
3013
3151
  class Meta:
3014
- verbose_name = "Consumer Report"
3015
- verbose_name_plural = "Consumer Reports"
3152
+ verbose_name = _("Consumer Report")
3153
+ verbose_name_plural = _("Consumer Reports")
3016
3154
  db_table = "core_client_report"
3017
3155
  ordering = ["-created_on"]
3018
3156
 
3157
+ def __str__(self) -> str: # pragma: no cover - simple representation
3158
+ period_type = (
3159
+ self.schedule.periodicity
3160
+ if self.schedule
3161
+ else ClientReportSchedule.PERIODICITY_NONE
3162
+ )
3163
+ return f"{self.start_date} - {self.end_date} ({period_type})"
3164
+
3165
+ @staticmethod
3166
+ def default_language() -> str:
3167
+ return default_report_language()
3168
+
3169
+ @staticmethod
3170
+ def normalize_language(language: str | None) -> str:
3171
+ return normalize_report_language(language)
3172
+
3173
+ @staticmethod
3174
+ def normalize_title(title: str | None) -> str:
3175
+ return normalize_report_title(title)
3176
+
3177
+ def save(self, *args, **kwargs):
3178
+ if self.language:
3179
+ self.language = normalize_report_language(self.language)
3180
+ self.title = self.normalize_title(self.title)
3181
+ super().save(*args, **kwargs)
3182
+
3183
+ @property
3184
+ def periodicity_label(self) -> str:
3185
+ if self.schedule:
3186
+ return self.schedule.get_periodicity_display()
3187
+ return ClientReportSchedule.label_for_periodicity(
3188
+ ClientReportSchedule.PERIODICITY_NONE
3189
+ )
3190
+
3191
+ @property
3192
+ def total_kw_period(self) -> float:
3193
+ totals = (self.rows_for_display or {}).get("totals", {})
3194
+ return float(totals.get("total_kw_period", 0.0) or 0.0)
3195
+
3019
3196
  @classmethod
3020
3197
  def generate(
3021
3198
  cls,
@@ -3026,9 +3203,23 @@ class ClientReport(Entity):
3026
3203
  schedule=None,
3027
3204
  recipients: list[str] | None = None,
3028
3205
  disable_emails: bool = False,
3206
+ chargers=None,
3207
+ language: str | None = None,
3208
+ title: str | None = None,
3029
3209
  ):
3030
- payload = cls.build_rows(start_date, end_date)
3031
- return cls.objects.create(
3210
+ from collections.abc import Iterable as _Iterable
3211
+
3212
+ charger_list = []
3213
+ if chargers:
3214
+ if isinstance(chargers, _Iterable):
3215
+ charger_list = list(chargers)
3216
+ else:
3217
+ charger_list = [chargers]
3218
+
3219
+ payload = cls.build_rows(start_date, end_date, chargers=charger_list)
3220
+ normalized_language = cls.normalize_language(language)
3221
+ title_value = cls.normalize_title(title)
3222
+ report = cls.objects.create(
3032
3223
  start_date=start_date,
3033
3224
  end_date=end_date,
3034
3225
  data=payload,
@@ -3036,7 +3227,12 @@ class ClientReport(Entity):
3036
3227
  schedule=schedule,
3037
3228
  recipients=list(recipients or []),
3038
3229
  disable_emails=disable_emails,
3230
+ language=normalized_language,
3231
+ title=title_value,
3039
3232
  )
3233
+ if charger_list:
3234
+ report.chargers.set(charger_list)
3235
+ return report
3040
3236
 
3041
3237
  def store_local_copy(self, html: str | None = None):
3042
3238
  """Persist the report data and optional HTML rendering to disk."""
@@ -3050,9 +3246,16 @@ class ClientReport(Entity):
3050
3246
  timestamp = timezone.now().strftime("%Y%m%d%H%M%S")
3051
3247
  identifier = f"client_report_{self.pk}_{timestamp}"
3052
3248
 
3053
- html_content = html or render_to_string(
3054
- "core/reports/client_report_email.html", {"report": self}
3055
- )
3249
+ language_code = self.normalize_language(self.language)
3250
+ context = {
3251
+ "report": self,
3252
+ "language_code": language_code,
3253
+ "default_language": type(self).default_language(),
3254
+ }
3255
+ with override(language_code):
3256
+ html_content = html or render_to_string(
3257
+ "core/reports/client_report_email.html", context
3258
+ )
3056
3259
  html_path = report_dir / f"{identifier}.html"
3057
3260
  html_path.write_text(html_content, encoding="utf-8")
3058
3261
 
@@ -3076,15 +3279,86 @@ class ClientReport(Entity):
3076
3279
  self.data = updated
3077
3280
  return export, html_content
3078
3281
 
3282
+ def send_delivery(
3283
+ self,
3284
+ *,
3285
+ to: list[str] | tuple[str, ...],
3286
+ cc: list[str] | tuple[str, ...] | None = None,
3287
+ outbox=None,
3288
+ reply_to: list[str] | None = None,
3289
+ ) -> list[str]:
3290
+ from core import mailer
3291
+
3292
+ recipients = list(to or [])
3293
+ if not recipients:
3294
+ return []
3295
+
3296
+ pdf_path = self.ensure_pdf()
3297
+ attachments = [
3298
+ (pdf_path.name, pdf_path.read_bytes(), "application/pdf"),
3299
+ ]
3300
+
3301
+ language_code = self.normalize_language(self.language)
3302
+ with override(language_code):
3303
+ totals = self.rows_for_display.get("totals", {})
3304
+ start_display = formats.date_format(
3305
+ self.start_date, format="DATE_FORMAT", use_l10n=True
3306
+ )
3307
+ end_display = formats.date_format(
3308
+ self.end_date, format="DATE_FORMAT", use_l10n=True
3309
+ )
3310
+ total_kw_period_label = gettext("Total kW during period")
3311
+ total_kw_all_label = gettext("Total kW (all time)")
3312
+ report_title = self.normalize_title(self.title) or gettext(
3313
+ "Consumer Report"
3314
+ )
3315
+ body_lines = [
3316
+ gettext("%(title)s for %(start)s through %(end)s.")
3317
+ % {"title": report_title, "start": start_display, "end": end_display},
3318
+ f"{total_kw_period_label}: "
3319
+ f"{formats.number_format(totals.get('total_kw_period', 0.0), decimal_pos=2, use_l10n=True)}.",
3320
+ f"{total_kw_all_label}: "
3321
+ f"{formats.number_format(totals.get('total_kw', 0.0), decimal_pos=2, use_l10n=True)}.",
3322
+ ]
3323
+ message = "\n".join(body_lines)
3324
+ subject = gettext("%(title)s %(start)s - %(end)s") % {
3325
+ "title": report_title,
3326
+ "start": start_display,
3327
+ "end": end_display,
3328
+ }
3329
+
3330
+ kwargs = {}
3331
+ if reply_to:
3332
+ kwargs["reply_to"] = reply_to
3333
+
3334
+ mailer.send(
3335
+ subject,
3336
+ message,
3337
+ recipients,
3338
+ outbox=outbox,
3339
+ cc=list(cc or []),
3340
+ attachments=attachments,
3341
+ **kwargs,
3342
+ )
3343
+
3344
+ delivered = list(dict.fromkeys(recipients + list(cc or [])))
3345
+ return delivered
3346
+
3079
3347
  @staticmethod
3080
- def build_rows(start_date=None, end_date=None, *, for_display: bool = False):
3081
- dataset = ClientReport._build_dataset(start_date, end_date)
3348
+ def build_rows(
3349
+ start_date=None,
3350
+ end_date=None,
3351
+ *,
3352
+ for_display: bool = False,
3353
+ chargers=None,
3354
+ ):
3355
+ dataset = ClientReport._build_dataset(start_date, end_date, chargers=chargers)
3082
3356
  if for_display:
3083
3357
  return ClientReport._normalize_dataset_for_display(dataset)
3084
3358
  return dataset
3085
3359
 
3086
3360
  @staticmethod
3087
- def _build_dataset(start_date=None, end_date=None):
3361
+ def _build_dataset(start_date=None, end_date=None, *, chargers=None):
3088
3362
  from datetime import datetime, time, timedelta, timezone as pytimezone
3089
3363
  from ocpp.models import Charger, Transaction
3090
3364
 
@@ -3101,6 +3375,14 @@ class ClientReport(Entity):
3101
3375
  )
3102
3376
  qs = qs.filter(start_time__lt=end_dt)
3103
3377
 
3378
+ selected_base_ids = None
3379
+ if chargers:
3380
+ selected_base_ids = {
3381
+ charger.charger_id for charger in chargers if charger.charger_id
3382
+ }
3383
+ if selected_base_ids:
3384
+ qs = qs.filter(charger__charger_id__in=selected_base_ids)
3385
+
3104
3386
  qs = qs.select_related("account", "charger").prefetch_related("meter_values")
3105
3387
  transactions = list(qs.order_by("start_time", "pk"))
3106
3388
 
@@ -3134,6 +3416,8 @@ class ClientReport(Entity):
3134
3416
  if charger is None:
3135
3417
  continue
3136
3418
  base_id = charger.charger_id
3419
+ if selected_base_ids is not None and base_id not in selected_base_ids:
3420
+ continue
3137
3421
  aggregator = aggregator_map.get(base_id) or charger
3138
3422
  entry = groups.setdefault(
3139
3423
  base_id,
@@ -3224,6 +3508,10 @@ class ClientReport(Entity):
3224
3508
  }
3225
3509
  )
3226
3510
 
3511
+ filters: dict[str, Any] = {}
3512
+ if selected_base_ids:
3513
+ filters["chargers"] = sorted(selected_base_ids)
3514
+
3227
3515
  return {
3228
3516
  "schema": "evcs-session/v1",
3229
3517
  "evcs": evcs_entries,
@@ -3231,6 +3519,7 @@ class ClientReport(Entity):
3231
3519
  "total_kw": total_all_time,
3232
3520
  "total_kw_period": total_period,
3233
3521
  },
3522
+ "filters": filters,
3234
3523
  }
3235
3524
 
3236
3525
  @staticmethod
@@ -3263,6 +3552,31 @@ class ClientReport(Entity):
3263
3552
 
3264
3553
  return start_value, end_value
3265
3554
 
3555
+ @staticmethod
3556
+ def _format_session_datetime(value):
3557
+ if not value:
3558
+ return None
3559
+ localized = timezone.localtime(value)
3560
+ date_part = formats.date_format(
3561
+ localized, format="MONTH_DAY_FORMAT", use_l10n=True
3562
+ )
3563
+ time_part = formats.time_format(
3564
+ localized, format="TIME_FORMAT", use_l10n=True
3565
+ )
3566
+ return gettext("%(date)s, %(time)s") % {
3567
+ "date": date_part,
3568
+ "time": time_part,
3569
+ }
3570
+
3571
+ @staticmethod
3572
+ def _calculate_duration_minutes(start, end):
3573
+ if not start or not end:
3574
+ return None
3575
+ total_seconds = (end - start).total_seconds()
3576
+ if total_seconds < 0:
3577
+ return None
3578
+ return int(round(total_seconds / 60.0))
3579
+
3266
3580
  @staticmethod
3267
3581
  def _normalize_dataset_for_display(dataset: dict[str, Any]):
3268
3582
  schema = dataset.get("schema")
@@ -3298,6 +3612,15 @@ class ClientReport(Entity):
3298
3612
  "session_kwh": row.get("session_kwh"),
3299
3613
  "start": start_dt,
3300
3614
  "end": end_dt,
3615
+ "start_display": ClientReport._format_session_datetime(
3616
+ start_dt
3617
+ ),
3618
+ "end_display": ClientReport._format_session_datetime(
3619
+ end_dt
3620
+ ),
3621
+ "duration_minutes": ClientReport._calculate_duration_minutes(
3622
+ start_dt, end_dt
3623
+ ),
3301
3624
  }
3302
3625
  )
3303
3626
 
@@ -3330,6 +3653,7 @@ class ClientReport(Entity):
3330
3653
  "total_kw": totals.get("total_kw", 0.0),
3331
3654
  "total_kw_period": totals.get("total_kw_period", 0.0),
3332
3655
  },
3656
+ "filters": dataset.get("filters", {}),
3333
3657
  }
3334
3658
 
3335
3659
  if schema == "session-list/v1":
@@ -3345,6 +3669,7 @@ class ClientReport(Entity):
3345
3669
  start_dt = timezone.make_aware(start_dt, timezone.utc)
3346
3670
  item["start"] = start_dt
3347
3671
  else:
3672
+ start_dt = None
3348
3673
  item["start"] = None
3349
3674
 
3350
3675
  if end_val:
@@ -3353,13 +3678,24 @@ class ClientReport(Entity):
3353
3678
  end_dt = timezone.make_aware(end_dt, timezone.utc)
3354
3679
  item["end"] = end_dt
3355
3680
  else:
3681
+ end_dt = None
3356
3682
  item["end"] = None
3357
3683
 
3684
+ item["start_display"] = ClientReport._format_session_datetime(start_dt)
3685
+ item["end_display"] = ClientReport._format_session_datetime(end_dt)
3686
+ item["duration_minutes"] = ClientReport._calculate_duration_minutes(
3687
+ start_dt, end_dt
3688
+ )
3689
+
3358
3690
  parsed.append(item)
3359
3691
 
3360
3692
  return {"schema": schema, "rows": parsed}
3361
3693
 
3362
- return {"schema": schema, "rows": dataset.get("rows", [])}
3694
+ return {
3695
+ "schema": schema,
3696
+ "rows": dataset.get("rows", []),
3697
+ "filters": dataset.get("filters", {}),
3698
+ }
3363
3699
 
3364
3700
  @property
3365
3701
  def rows_for_display(self):
@@ -3373,6 +3709,37 @@ class ClientReport(Entity):
3373
3709
  except ValueError:
3374
3710
  return str(path)
3375
3711
 
3712
+ @staticmethod
3713
+ def resolve_reply_to_for_owner(owner) -> list[str]:
3714
+ if not owner:
3715
+ return []
3716
+ try:
3717
+ inbox = owner.get_profile(EmailInbox)
3718
+ except Exception: # pragma: no cover - defensive catch
3719
+ inbox = None
3720
+ if inbox and getattr(inbox, "username", ""):
3721
+ address = inbox.username.strip()
3722
+ if address:
3723
+ return [address]
3724
+ return []
3725
+
3726
+ @staticmethod
3727
+ def resolve_outbox_for_owner(owner):
3728
+ from nodes.models import EmailOutbox, Node
3729
+
3730
+ if owner:
3731
+ try:
3732
+ outbox = owner.get_profile(EmailOutbox)
3733
+ except Exception: # pragma: no cover - defensive catch
3734
+ outbox = None
3735
+ if outbox:
3736
+ return outbox
3737
+
3738
+ node = Node.get_local()
3739
+ if node:
3740
+ return getattr(node, "email_outbox", None)
3741
+ return None
3742
+
3376
3743
  def render_pdf(self, target: Path):
3377
3744
  from reportlab.lib import colors
3378
3745
  from reportlab.lib.pagesizes import landscape, letter
@@ -3392,157 +3759,195 @@ class ClientReport(Entity):
3392
3759
  dataset = self.rows_for_display
3393
3760
  schema = dataset.get("schema")
3394
3761
 
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
- )
3409
-
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,
3762
+ language_code = self.normalize_language(self.language)
3763
+ with override(language_code):
3764
+ styles = getSampleStyleSheet()
3765
+ title_style = styles["Title"]
3766
+ subtitle_style = styles["Heading2"]
3767
+ normal_style = styles["BodyText"]
3768
+ emphasis_style = styles["Heading3"]
3769
+
3770
+ document = SimpleDocTemplate(
3771
+ str(target_path),
3772
+ pagesize=landscape(letter),
3773
+ leftMargin=0.5 * inch,
3774
+ rightMargin=0.5 * inch,
3775
+ topMargin=0.6 * inch,
3776
+ bottomMargin=0.5 * inch,
3416
3777
  )
3417
- )
3418
- story.append(Spacer(1, 0.25 * inch))
3419
3778
 
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
- )
3428
- )
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}"
3443
- )
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
- ]
3779
+ story: list = []
3461
3780
 
3462
- def _format_number(value):
3463
- return f"{value:.2f}" if value is not None else "—"
3781
+ report_title = self.normalize_title(self.title) or gettext(
3782
+ "Consumer Report"
3783
+ )
3784
+ story.append(Paragraph(report_title, title_style))
3464
3785
 
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
- )
3786
+ start_display = formats.date_format(
3787
+ self.start_date, format="DATE_FORMAT", use_l10n=True
3788
+ )
3789
+ end_display = formats.date_format(
3790
+ self.end_date, format="DATE_FORMAT", use_l10n=True
3791
+ )
3792
+ story.append(
3793
+ Paragraph(
3794
+ gettext("Period: %(start)s to %(end)s")
3795
+ % {"start": start_display, "end": end_display},
3796
+ emphasis_style,
3797
+ )
3798
+ )
3799
+ story.append(Spacer(1, 0.25 * inch))
3800
+
3801
+ total_kw_all_time_label = gettext("Total kW (all time)")
3802
+ total_kw_period_label = gettext("Total kW (period)")
3803
+ connector_label = gettext("Connector")
3804
+ account_label = gettext("Account")
3805
+ session_kwh_label = gettext("Session kW")
3806
+ time_label = gettext("Time")
3807
+ no_sessions_period = gettext(
3808
+ "No charging sessions recorded for the selected period."
3809
+ )
3810
+ no_sessions_point = gettext(
3811
+ "No charging sessions recorded for this charge point."
3812
+ )
3813
+ no_structured_data = gettext(
3814
+ "No structured data is available for this report."
3815
+ )
3816
+ report_totals_label = gettext("Report totals")
3817
+ total_kw_period_line = gettext("Total kW during period")
3818
+
3819
+ def format_datetime(value):
3820
+ if not value:
3821
+ return "—"
3822
+ return ClientReport._format_session_datetime(value) or "—"
3823
+
3824
+ def format_decimal(value):
3825
+ if value is None:
3826
+ return "—"
3827
+ return formats.number_format(value, decimal_pos=2, use_l10n=True)
3828
+
3829
+ def format_duration(value):
3830
+ if value is None:
3831
+ return "—"
3832
+ return formats.number_format(value, decimal_pos=0, use_l10n=True)
3833
+
3834
+ if schema == "evcs-session/v1":
3835
+ evcs_entries = dataset.get("evcs", [])
3836
+ if not evcs_entries:
3837
+ story.append(Paragraph(no_sessions_period, normal_style))
3838
+ for index, evcs in enumerate(evcs_entries):
3839
+ if index:
3840
+ story.append(Spacer(1, 0.2 * inch))
3841
+
3842
+ display_name = evcs.get("display_name") or gettext("Charge Point")
3843
+ serial_number = evcs.get("serial_number")
3844
+ if serial_number:
3845
+ header_text = gettext("%(name)s (Serial: %(serial)s)") % {
3846
+ "name": display_name,
3847
+ "serial": serial_number,
3848
+ }
3849
+ else:
3850
+ header_text = display_name
3851
+ story.append(Paragraph(header_text, subtitle_style))
3852
+
3853
+ metrics_text = (
3854
+ f"{total_kw_all_time_label}: "
3855
+ f"{format_decimal(evcs.get('total_kw', 0.0))} | "
3856
+ f"{total_kw_period_label}: "
3857
+ f"{format_decimal(evcs.get('total_kw_period', 0.0))}"
3858
+ )
3859
+ story.append(Paragraph(metrics_text, normal_style))
3860
+ story.append(Spacer(1, 0.1 * inch))
3478
3861
 
3479
- table_data.append(
3862
+ transactions = evcs.get("transactions", [])
3863
+ if transactions:
3864
+ table_data = [
3480
3865
  [
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 "—",
3866
+ session_kwh_label,
3867
+ gettext("Session start"),
3868
+ gettext("Session end"),
3869
+ time_label,
3870
+ connector_label,
3871
+ gettext("RFID label"),
3872
+ account_label,
3491
3873
  ]
3492
- )
3874
+ ]
3493
3875
 
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
- ]
3876
+ for row in transactions:
3877
+ start_dt = row.get("start")
3878
+ end_dt = row.get("end")
3879
+ duration_value = row.get("duration_minutes")
3880
+ table_data.append(
3881
+ [
3882
+ format_decimal(row.get("session_kwh")),
3883
+ format_datetime(start_dt),
3884
+ format_datetime(end_dt),
3885
+ format_duration(duration_value),
3886
+ row.get("connector")
3887
+ if row.get("connector") is not None
3888
+ else "—",
3889
+ row.get("rfid_label") or "—",
3890
+ row.get("account_name") or "—",
3891
+ ]
3892
+ )
3893
+
3894
+ column_count = len(table_data[0])
3895
+ col_width = document.width / column_count if column_count else None
3896
+ table = Table(
3897
+ table_data,
3898
+ repeatRows=1,
3899
+ colWidths=[col_width] * column_count if col_width else None,
3900
+ hAlign="LEFT",
3512
3901
  )
3513
- )
3514
- story.append(table)
3515
- else:
3516
- story.append(
3517
- Paragraph(
3518
- "No charging sessions recorded for this charge point.",
3519
- normal_style,
3902
+ table.setStyle(
3903
+ TableStyle(
3904
+ [
3905
+ (
3906
+ "BACKGROUND",
3907
+ (0, 0),
3908
+ (-1, 0),
3909
+ colors.HexColor("#0f172a"),
3910
+ ),
3911
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
3912
+ ("ALIGN", (0, 0), (-1, 0), "CENTER"),
3913
+ ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
3914
+ ("FONTSIZE", (0, 0), (-1, 0), 9),
3915
+ (
3916
+ "ROWBACKGROUNDS",
3917
+ (0, 1),
3918
+ (-1, -1),
3919
+ [colors.whitesmoke, colors.HexColor("#eef2ff")],
3920
+ ),
3921
+ ("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
3922
+ ("VALIGN", (0, 1), (-1, -1), "MIDDLE"),
3923
+ ]
3924
+ )
3520
3925
  )
3521
- )
3522
- else:
3926
+ story.append(table)
3927
+ else:
3928
+ story.append(Paragraph(no_sessions_point, normal_style))
3929
+ else:
3930
+ story.append(Paragraph(no_structured_data, normal_style))
3931
+
3932
+ totals = dataset.get("totals") or {}
3933
+ story.append(Spacer(1, 0.3 * inch))
3934
+ story.append(Paragraph(report_totals_label, emphasis_style))
3523
3935
  story.append(
3524
3936
  Paragraph(
3525
- "No structured data is available for this report.",
3526
- normal_style,
3937
+ f"{total_kw_all_time_label}: "
3938
+ f"{format_decimal(totals.get('total_kw', 0.0))}",
3939
+ emphasis_style,
3527
3940
  )
3528
3941
  )
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,
3942
+ story.append(
3943
+ Paragraph(
3944
+ f"{total_kw_period_line}: "
3945
+ f"{format_decimal(totals.get('total_kw_period', 0.0))}",
3946
+ emphasis_style,
3947
+ )
3542
3948
  )
3543
- )
3544
3949
 
3545
- document.build(story)
3950
+ document.build(story)
3546
3951
 
3547
3952
  def ensure_pdf(self) -> Path:
3548
3953
  base_dir = Path(settings.BASE_DIR)