arthexis 0.1.22__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,32 +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
- subject = f"Client report {report.start_date} to {report.end_date}"
2950
- body = (
2951
- "Attached is the client report generated for the period "
2952
- f"{report.start_date} to {report.end_date}."
2953
- )
2954
- mailer.send(
2955
- subject,
2956
- body,
2957
- to,
2958
- outbox=self.get_outbox(),
3071
+ delivered = report.send_delivery(
3072
+ to=to,
2959
3073
  cc=cc,
2960
- attachments=attachments,
3074
+ outbox=self.get_outbox(),
3075
+ reply_to=self.resolve_reply_to(),
2961
3076
  )
2962
- delivered = list(dict.fromkeys(to + (cc or [])))
2963
3077
  if delivered:
2964
3078
  type(report).objects.filter(pk=report.pk).update(
2965
3079
  recipients=delivered
@@ -2974,6 +3088,14 @@ class ClientReportSchedule(Entity):
2974
3088
  self.last_generated_on = now
2975
3089
  return report
2976
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
+
2977
3099
 
2978
3100
  class ClientReport(Entity):
2979
3101
  """Snapshot of energy usage over a period."""
@@ -2996,15 +3118,70 @@ class ClientReport(Entity):
2996
3118
  blank=True,
2997
3119
  related_name="reports",
2998
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
+ )
2999
3132
  recipients = models.JSONField(default=list, blank=True)
3000
3133
  disable_emails = models.BooleanField(default=False)
3134
+ chargers = models.ManyToManyField(
3135
+ "ocpp.Charger",
3136
+ blank=True,
3137
+ related_name="client_reports",
3138
+ )
3001
3139
 
3002
3140
  class Meta:
3003
- verbose_name = "Consumer Report"
3004
- verbose_name_plural = "Consumer Reports"
3141
+ verbose_name = _("Consumer Report")
3142
+ verbose_name_plural = _("Consumer Reports")
3005
3143
  db_table = "core_client_report"
3006
3144
  ordering = ["-created_on"]
3007
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
+
3008
3185
  @classmethod
3009
3186
  def generate(
3010
3187
  cls,
@@ -3015,17 +3192,36 @@ class ClientReport(Entity):
3015
3192
  schedule=None,
3016
3193
  recipients: list[str] | None = None,
3017
3194
  disable_emails: bool = False,
3195
+ chargers=None,
3196
+ language: str | None = None,
3197
+ title: str | None = None,
3018
3198
  ):
3019
- rows = cls.build_rows(start_date, end_date)
3020
- 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(
3021
3212
  start_date=start_date,
3022
3213
  end_date=end_date,
3023
- data={"rows": rows, "schema": "session-list/v1"},
3214
+ data=payload,
3024
3215
  owner=owner,
3025
3216
  schedule=schedule,
3026
3217
  recipients=list(recipients or []),
3027
3218
  disable_emails=disable_emails,
3219
+ language=normalized_language,
3220
+ title=title_value,
3028
3221
  )
3222
+ if charger_list:
3223
+ report.chargers.set(charger_list)
3224
+ return report
3029
3225
 
3030
3226
  def store_local_copy(self, html: str | None = None):
3031
3227
  """Persist the report data and optional HTML rendering to disk."""
@@ -3039,9 +3235,16 @@ class ClientReport(Entity):
3039
3235
  timestamp = timezone.now().strftime("%Y%m%d%H%M%S")
3040
3236
  identifier = f"client_report_{self.pk}_{timestamp}"
3041
3237
 
3042
- html_content = html or render_to_string(
3043
- "core/reports/client_report_email.html", {"report": self}
3044
- )
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
+ )
3045
3248
  html_path = report_dir / f"{identifier}.html"
3046
3249
  html_path.write_text(html_content, encoding="utf-8")
3047
3250
 
@@ -3050,15 +3253,13 @@ class ClientReport(Entity):
3050
3253
  _json.dumps(self.data, indent=2, default=str), encoding="utf-8"
3051
3254
  )
3052
3255
 
3053
- def _relative(path: Path) -> str:
3054
- try:
3055
- return str(path.relative_to(base_dir))
3056
- except ValueError:
3057
- return str(path)
3256
+ pdf_path = report_dir / f"{identifier}.pdf"
3257
+ self.render_pdf(pdf_path)
3058
3258
 
3059
3259
  export = {
3060
- "html_path": _relative(html_path),
3061
- "json_path": _relative(json_path),
3260
+ "html_path": ClientReport._relative_to_base(html_path, base_dir),
3261
+ "json_path": ClientReport._relative_to_base(json_path, base_dir),
3262
+ "pdf_path": ClientReport._relative_to_base(pdf_path, base_dir),
3062
3263
  }
3063
3264
 
3064
3265
  updated = dict(self.data)
@@ -3067,30 +3268,113 @@ class ClientReport(Entity):
3067
3268
  self.data = updated
3068
3269
  return export, html_content
3069
3270
 
3070
- @staticmethod
3071
- def build_rows(start_date=None, end_date=None, *, for_display: bool = False):
3072
- from ocpp.models import Transaction
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
+ }
3073
3318
 
3074
- qs = Transaction.objects.filter(
3075
- (Q(rfid__isnull=False) & ~Q(rfid=""))
3076
- | (Q(vid__isnull=False) & ~Q(vid=""))
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,
3077
3331
  )
3078
- if start_date:
3079
- from datetime import datetime, time, timedelta, timezone as pytimezone
3080
3332
 
3333
+ delivered = list(dict.fromkeys(recipients + list(cc or [])))
3334
+ return delivered
3335
+
3336
+ @staticmethod
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)
3345
+ if for_display:
3346
+ return ClientReport._normalize_dataset_for_display(dataset)
3347
+ return dataset
3348
+
3349
+ @staticmethod
3350
+ def _build_dataset(start_date=None, end_date=None, *, chargers=None):
3351
+ from datetime import datetime, time, timedelta, timezone as pytimezone
3352
+ from ocpp.models import Charger, Transaction
3353
+
3354
+ qs = Transaction.objects.all()
3355
+
3356
+ start_dt = None
3357
+ end_dt = None
3358
+ if start_date:
3081
3359
  start_dt = datetime.combine(start_date, time.min, tzinfo=pytimezone.utc)
3082
3360
  qs = qs.filter(start_time__gte=start_dt)
3083
3361
  if end_date:
3084
- from datetime import datetime, time, timedelta, timezone as pytimezone
3085
-
3086
3362
  end_dt = datetime.combine(
3087
3363
  end_date + timedelta(days=1), time.min, tzinfo=pytimezone.utc
3088
3364
  )
3089
3365
  qs = qs.filter(start_time__lt=end_dt)
3090
3366
 
3091
- transactions = list(
3092
- qs.select_related("account").order_by("-start_time", "-pk")
3093
- )
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
+
3375
+ qs = qs.select_related("account", "charger").prefetch_related("meter_values")
3376
+ transactions = list(qs.order_by("start_time", "pk"))
3377
+
3094
3378
  rfid_values = {tx.rfid for tx in transactions if tx.rfid}
3095
3379
  tag_map: dict[str, RFID] = {}
3096
3380
  if rfid_values:
@@ -3101,52 +3385,235 @@ class ClientReport(Entity):
3101
3385
  )
3102
3386
  }
3103
3387
 
3104
- rows: list[dict[str, Any]] = []
3388
+ charger_ids = {
3389
+ tx.charger.charger_id
3390
+ for tx in transactions
3391
+ if getattr(tx, "charger", None) and tx.charger.charger_id
3392
+ }
3393
+ aggregator_map: dict[str, Charger] = {}
3394
+ if charger_ids:
3395
+ aggregator_map = {
3396
+ charger.charger_id: charger
3397
+ for charger in Charger.objects.filter(
3398
+ charger_id__in=charger_ids, connector_id__isnull=True
3399
+ )
3400
+ }
3401
+
3402
+ groups: dict[str, dict[str, Any]] = {}
3105
3403
  for tx in transactions:
3106
- energy = tx.kw
3107
- if energy <= 0:
3404
+ charger = getattr(tx, "charger", None)
3405
+ if charger is None:
3406
+ continue
3407
+ base_id = charger.charger_id
3408
+ if selected_base_ids is not None and base_id not in selected_base_ids:
3108
3409
  continue
3410
+ aggregator = aggregator_map.get(base_id) or charger
3411
+ entry = groups.setdefault(
3412
+ base_id,
3413
+ {"charger": aggregator, "transactions": []},
3414
+ )
3415
+ entry["transactions"].append(tx)
3416
+
3417
+ evcs_entries: list[dict[str, Any]] = []
3418
+ total_all_time = 0.0
3419
+ total_period = 0.0
3420
+
3421
+ def _sort_key(tx):
3422
+ anchor = getattr(tx, "start_time", None)
3423
+ if anchor is None:
3424
+ anchor = datetime.min.replace(tzinfo=pytimezone.utc)
3425
+ return (anchor, tx.pk or 0)
3426
+
3427
+ for base_id, info in sorted(groups.items(), key=lambda item: item[0]):
3428
+ aggregator = info["charger"]
3429
+ txs = sorted(info["transactions"], key=_sort_key)
3430
+ total_kw_all = float(getattr(aggregator, "total_kw", 0.0) or 0.0)
3431
+ total_kw_period = 0.0
3432
+ if hasattr(aggregator, "total_kw_for_range"):
3433
+ total_kw_period = float(
3434
+ aggregator.total_kw_for_range(start=start_dt, end=end_dt) or 0.0
3435
+ )
3436
+ total_all_time += total_kw_all
3437
+ total_period += total_kw_period
3109
3438
 
3110
- subject = None
3111
- if tx.account and getattr(tx.account, "name", None):
3112
- subject = tx.account.name
3113
- else:
3114
- tag = tag_map.get(tx.rfid)
3115
- if tag:
3116
- account = next(iter(tag.energy_accounts.all()), None)
3117
- if account:
3118
- subject = account.name
3119
- else:
3120
- subject = str(tag.label_id)
3439
+ session_rows: list[dict[str, Any]] = []
3440
+ for tx in txs:
3441
+ session_kw = float(getattr(tx, "kw", 0.0) or 0.0)
3442
+ if session_kw <= 0:
3443
+ continue
3444
+
3445
+ start_kwh, end_kwh = ClientReport._resolve_meter_bounds(tx)
3121
3446
 
3122
- if subject is None:
3123
- subject = tx.rfid or tx.vid
3447
+ connector_number = (
3448
+ tx.connector_id
3449
+ if getattr(tx, "connector_id", None) is not None
3450
+ else getattr(getattr(tx, "charger", None), "connector_id", None)
3451
+ )
3124
3452
 
3125
- start_value = tx.start_time
3126
- end_value = tx.stop_time
3127
- if not for_display:
3128
- start_value = start_value.isoformat()
3129
- end_value = end_value.isoformat() if end_value else None
3453
+ rfid_value = (tx.rfid or "").strip()
3454
+ tag = tag_map.get(rfid_value)
3455
+ label = None
3456
+ account_name = (
3457
+ tx.account.name
3458
+ if tx.account and getattr(tx.account, "name", None)
3459
+ else None
3460
+ )
3461
+ if tag:
3462
+ label = tag.custom_label or str(tag.label_id)
3463
+ if not account_name:
3464
+ account = next(iter(tag.energy_accounts.all()), None)
3465
+ if account and getattr(account, "name", None):
3466
+ account_name = account.name
3467
+ elif rfid_value:
3468
+ label = rfid_value
3469
+
3470
+ session_rows.append(
3471
+ {
3472
+ "connector": connector_number,
3473
+ "rfid_label": label,
3474
+ "account_name": account_name,
3475
+ "start_kwh": start_kwh,
3476
+ "end_kwh": end_kwh,
3477
+ "session_kwh": session_kw,
3478
+ "start": tx.start_time.isoformat()
3479
+ if getattr(tx, "start_time", None)
3480
+ else None,
3481
+ "end": tx.stop_time.isoformat()
3482
+ if getattr(tx, "stop_time", None)
3483
+ else None,
3484
+ }
3485
+ )
3130
3486
 
3131
- rows.append(
3487
+ evcs_entries.append(
3132
3488
  {
3133
- "subject": subject,
3134
- "rfid": tx.rfid,
3135
- "vid": tx.vid,
3136
- "kw": energy,
3137
- "start": start_value,
3138
- "end": end_value,
3489
+ "charger_id": aggregator.pk,
3490
+ "serial_number": aggregator.charger_id,
3491
+ "display_name": aggregator.display_name
3492
+ or aggregator.name
3493
+ or aggregator.charger_id,
3494
+ "total_kw": total_kw_all,
3495
+ "total_kw_period": total_kw_period,
3496
+ "transactions": session_rows,
3139
3497
  }
3140
3498
  )
3141
3499
 
3142
- return rows
3500
+ filters: dict[str, Any] = {}
3501
+ if selected_base_ids:
3502
+ filters["chargers"] = sorted(selected_base_ids)
3503
+
3504
+ return {
3505
+ "schema": "evcs-session/v1",
3506
+ "evcs": evcs_entries,
3507
+ "totals": {
3508
+ "total_kw": total_all_time,
3509
+ "total_kw_period": total_period,
3510
+ },
3511
+ "filters": filters,
3512
+ }
3143
3513
 
3144
- @property
3145
- def rows_for_display(self):
3146
- rows = self.data.get("rows", [])
3147
- if self.data.get("schema") == "session-list/v1":
3514
+ @staticmethod
3515
+ def _resolve_meter_bounds(tx) -> tuple[float | None, float | None]:
3516
+ def _convert(value):
3517
+ if value in {None, ""}:
3518
+ return None
3519
+ try:
3520
+ return float(value) / 1000.0
3521
+ except (TypeError, ValueError):
3522
+ return None
3523
+
3524
+ start_value = _convert(getattr(tx, "meter_start", None))
3525
+ end_value = _convert(getattr(tx, "meter_stop", None))
3526
+
3527
+ readings_manager = getattr(tx, "meter_values", None)
3528
+ readings = []
3529
+ if readings_manager is not None:
3530
+ readings = [
3531
+ reading
3532
+ for reading in readings_manager.all()
3533
+ if getattr(reading, "energy", None) is not None
3534
+ ]
3535
+ if readings:
3536
+ readings.sort(key=lambda item: item.timestamp)
3537
+ if start_value is None:
3538
+ start_value = float(readings[0].energy or 0)
3539
+ if end_value is None:
3540
+ end_value = float(readings[-1].energy or 0)
3541
+
3542
+ return start_value, end_value
3543
+
3544
+ @staticmethod
3545
+ def _normalize_dataset_for_display(dataset: dict[str, Any]):
3546
+ schema = dataset.get("schema")
3547
+ if schema == "evcs-session/v1":
3548
+ from datetime import datetime
3549
+
3550
+ evcs_entries: list[dict[str, Any]] = []
3551
+ for entry in dataset.get("evcs", []):
3552
+ normalized_rows: list[dict[str, Any]] = []
3553
+ for row in entry.get("transactions", []):
3554
+ start_val = row.get("start")
3555
+ end_val = row.get("end")
3556
+
3557
+ start_dt = None
3558
+ if start_val:
3559
+ start_dt = parse_datetime(start_val)
3560
+ if start_dt and timezone.is_naive(start_dt):
3561
+ start_dt = timezone.make_aware(start_dt, timezone.utc)
3562
+
3563
+ end_dt = None
3564
+ if end_val:
3565
+ end_dt = parse_datetime(end_val)
3566
+ if end_dt and timezone.is_naive(end_dt):
3567
+ end_dt = timezone.make_aware(end_dt, timezone.utc)
3568
+
3569
+ normalized_rows.append(
3570
+ {
3571
+ "connector": row.get("connector"),
3572
+ "rfid_label": row.get("rfid_label"),
3573
+ "account_name": row.get("account_name"),
3574
+ "start_kwh": row.get("start_kwh"),
3575
+ "end_kwh": row.get("end_kwh"),
3576
+ "session_kwh": row.get("session_kwh"),
3577
+ "start": start_dt,
3578
+ "end": end_dt,
3579
+ }
3580
+ )
3581
+
3582
+ normalized_rows.sort(
3583
+ key=lambda item: (
3584
+ item["start"]
3585
+ if item["start"] is not None
3586
+ else datetime.min.replace(tzinfo=timezone.utc),
3587
+ item.get("connector") or 0,
3588
+ )
3589
+ )
3590
+
3591
+ evcs_entries.append(
3592
+ {
3593
+ "display_name": entry.get("display_name")
3594
+ or entry.get("serial_number")
3595
+ or "Charge Point",
3596
+ "serial_number": entry.get("serial_number"),
3597
+ "total_kw": entry.get("total_kw", 0.0),
3598
+ "total_kw_period": entry.get("total_kw_period", 0.0),
3599
+ "transactions": normalized_rows,
3600
+ }
3601
+ )
3602
+
3603
+ totals = dataset.get("totals", {})
3604
+ return {
3605
+ "schema": schema,
3606
+ "evcs": evcs_entries,
3607
+ "totals": {
3608
+ "total_kw": totals.get("total_kw", 0.0),
3609
+ "total_kw_period": totals.get("total_kw_period", 0.0),
3610
+ },
3611
+ "filters": dataset.get("filters", {}),
3612
+ }
3613
+
3614
+ if schema == "session-list/v1":
3148
3615
  parsed: list[dict[str, Any]] = []
3149
- for row in rows:
3616
+ for row in dataset.get("rows", []):
3150
3617
  item = dict(row)
3151
3618
  start_val = row.get("start")
3152
3619
  end_val = row.get("end")
@@ -3168,8 +3635,276 @@ class ClientReport(Entity):
3168
3635
  item["end"] = None
3169
3636
 
3170
3637
  parsed.append(item)
3171
- return parsed
3172
- return rows
3638
+
3639
+ return {"schema": schema, "rows": parsed}
3640
+
3641
+ return {
3642
+ "schema": schema,
3643
+ "rows": dataset.get("rows", []),
3644
+ "filters": dataset.get("filters", {}),
3645
+ }
3646
+
3647
+ @property
3648
+ def rows_for_display(self):
3649
+ data = self.data or {}
3650
+ return ClientReport._normalize_dataset_for_display(data)
3651
+
3652
+ @staticmethod
3653
+ def _relative_to_base(path: Path, base_dir: Path) -> str:
3654
+ try:
3655
+ return str(path.relative_to(base_dir))
3656
+ except ValueError:
3657
+ return str(path)
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
+
3690
+ def render_pdf(self, target: Path):
3691
+ from reportlab.lib import colors
3692
+ from reportlab.lib.pagesizes import landscape, letter
3693
+ from reportlab.lib.styles import getSampleStyleSheet
3694
+ from reportlab.lib.units import inch
3695
+ from reportlab.platypus import (
3696
+ Paragraph,
3697
+ SimpleDocTemplate,
3698
+ Spacer,
3699
+ Table,
3700
+ TableStyle,
3701
+ )
3702
+
3703
+ target_path = Path(target)
3704
+ target_path.parent.mkdir(parents=True, exist_ok=True)
3705
+
3706
+ dataset = self.rows_for_display
3707
+ schema = dataset.get("schema")
3708
+
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
+ )
3725
+
3726
+ story: list = []
3727
+
3728
+ report_title = self.normalize_title(self.title) or gettext(
3729
+ "Consumer Report"
3730
+ )
3731
+ story.append(Paragraph(report_title, title_style))
3732
+
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,
3744
+ )
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
3771
+ )
3772
+
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))
3805
+
3806
+ transactions = evcs.get("transactions", [])
3807
+ if transactions:
3808
+ table_data = [
3809
+ [
3810
+ session_kwh_label,
3811
+ gettext("Session start"),
3812
+ gettext("Session end"),
3813
+ connector_label,
3814
+ gettext("RFID label"),
3815
+ account_label,
3816
+ ]
3817
+ ]
3818
+
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
+ )
3859
+ )
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))
3869
+ story.append(
3870
+ Paragraph(
3871
+ f"{total_kw_all_time_label}: "
3872
+ f"{format_decimal(totals.get('total_kw', 0.0))}",
3873
+ emphasis_style,
3874
+ )
3875
+ )
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
+ )
3882
+ )
3883
+
3884
+ document.build(story)
3885
+
3886
+ def ensure_pdf(self) -> Path:
3887
+ base_dir = Path(settings.BASE_DIR)
3888
+ export = dict((self.data or {}).get("export") or {})
3889
+ pdf_relative = export.get("pdf_path")
3890
+ if pdf_relative:
3891
+ candidate = base_dir / pdf_relative
3892
+ if candidate.exists():
3893
+ return candidate
3894
+
3895
+ report_dir = base_dir / "work" / "reports"
3896
+ report_dir.mkdir(parents=True, exist_ok=True)
3897
+ timestamp = timezone.now().strftime("%Y%m%d%H%M%S")
3898
+ identifier = f"client_report_{self.pk}_{timestamp}"
3899
+ pdf_path = report_dir / f"{identifier}.pdf"
3900
+ self.render_pdf(pdf_path)
3901
+
3902
+ export["pdf_path"] = ClientReport._relative_to_base(pdf_path, base_dir)
3903
+ updated = dict(self.data)
3904
+ updated["export"] = export
3905
+ type(self).objects.filter(pk=self.pk).update(data=updated)
3906
+ self.data = updated
3907
+ return pdf_path
3173
3908
 
3174
3909
 
3175
3910
  class BrandManager(EntityManager):
@@ -3622,6 +4357,10 @@ class PackageRelease(Entity):
3622
4357
  if old_name not in expected and old_path.exists():
3623
4358
  old_path.unlink()
3624
4359
 
4360
+ def delete(self, using=None, keep_parents=False):
4361
+ user_data.delete_user_fixture(self)
4362
+ super().delete(using=using, keep_parents=keep_parents)
4363
+
3625
4364
  def __str__(self) -> str: # pragma: no cover - trivial
3626
4365
  return f"{self.package.name} {self.version}"
3627
4366
 
@@ -3629,10 +4368,27 @@ class PackageRelease(Entity):
3629
4368
  """Return a :class:`ReleasePackage` built from the package."""
3630
4369
  return self.package.to_package()
3631
4370
 
3632
- def to_credentials(self) -> Credentials | None:
3633
- """Return :class:`Credentials` from the associated release manager."""
3634
- manager = self.release_manager or self.package.release_manager
3635
- if manager:
4371
+ def to_credentials(
4372
+ self, user: models.Model | None = None
4373
+ ) -> Credentials | None:
4374
+ """Return :class:`Credentials` from available release managers."""
4375
+
4376
+ manager_candidates: list[ReleaseManager] = []
4377
+
4378
+ for candidate in (self.release_manager, self.package.release_manager):
4379
+ if candidate and candidate not in manager_candidates:
4380
+ manager_candidates.append(candidate)
4381
+
4382
+ if user is not None and getattr(user, "is_authenticated", False):
4383
+ try:
4384
+ user_manager = ReleaseManager.objects.get(user=user)
4385
+ except ReleaseManager.DoesNotExist:
4386
+ user_manager = None
4387
+ else:
4388
+ if user_manager not in manager_candidates:
4389
+ manager_candidates.append(user_manager)
4390
+
4391
+ for manager in manager_candidates:
3636
4392
  creds = manager.to_credentials()
3637
4393
  if creds and creds.has_auth():
3638
4394
  return creds
@@ -3654,7 +4410,9 @@ class PackageRelease(Entity):
3654
4410
  return manager.github_token
3655
4411
  return os.environ.get("GITHUB_TOKEN")
3656
4412
 
3657
- def build_publish_targets(self) -> list[RepositoryTarget]:
4413
+ def build_publish_targets(
4414
+ self, user: models.Model | None = None
4415
+ ) -> list[RepositoryTarget]:
3658
4416
  """Return repository targets for publishing this release."""
3659
4417
 
3660
4418
  manager = self.release_manager or self.package.release_manager
@@ -3663,7 +4421,7 @@ class PackageRelease(Entity):
3663
4421
  env_primary = os.environ.get("PYPI_REPOSITORY_URL", "")
3664
4422
  primary_url = env_primary.strip()
3665
4423
 
3666
- primary_creds = self.to_credentials()
4424
+ primary_creds = self.to_credentials(user=user)
3667
4425
  targets.append(
3668
4426
  RepositoryTarget(
3669
4427
  name="PyPI",
@@ -3805,6 +4563,8 @@ class PackageRelease(Entity):
3805
4563
  """
3806
4564
 
3807
4565
  version = (version or "").strip()
4566
+ if version.endswith("+"):
4567
+ version = version.rstrip("+")
3808
4568
  revision = (revision or "").strip()
3809
4569
  if not version or not revision:
3810
4570
  return True