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.
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/METADATA +39 -18
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/RECORD +31 -30
- config/settings.py +7 -0
- config/urls.py +2 -0
- core/admin.py +140 -213
- core/backends.py +3 -1
- core/models.py +612 -207
- core/system.py +67 -2
- core/tasks.py +25 -0
- core/views.py +0 -3
- nodes/admin.py +465 -292
- nodes/models.py +299 -23
- nodes/tasks.py +13 -16
- nodes/tests.py +291 -130
- nodes/urls.py +11 -0
- nodes/utils.py +9 -2
- nodes/views.py +588 -20
- ocpp/admin.py +729 -175
- ocpp/consumers.py +98 -0
- ocpp/models.py +299 -0
- ocpp/network.py +398 -0
- ocpp/tasks.py +177 -1
- ocpp/tests.py +179 -0
- ocpp/views.py +2 -0
- pages/middleware.py +3 -2
- pages/tests.py +40 -0
- pages/utils.py +70 -0
- pages/views.py +64 -32
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/WHEEL +0 -0
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/top_level.txt +0 -0
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.
|
|
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
|
-
|
|
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
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2938
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3031
|
-
|
|
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
|
-
|
|
3054
|
-
|
|
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(
|
|
3081
|
-
|
|
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 {
|
|
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
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3463
|
-
|
|
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
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
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
|
-
|
|
3862
|
+
transactions = evcs.get("transactions", [])
|
|
3863
|
+
if transactions:
|
|
3864
|
+
table_data = [
|
|
3480
3865
|
[
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
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
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
"
|
|
3505
|
-
(
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
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
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
3526
|
-
|
|
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
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
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
|
-
|
|
3950
|
+
document.build(story)
|
|
3546
3951
|
|
|
3547
3952
|
def ensure_pdf(self) -> Path:
|
|
3548
3953
|
base_dir = Path(settings.BASE_DIR)
|