arthexis 0.1.23__py3-none-any.whl → 0.1.24__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- {arthexis-0.1.23.dist-info → arthexis-0.1.24.dist-info}/METADATA +5 -5
- {arthexis-0.1.23.dist-info → arthexis-0.1.24.dist-info}/RECORD +17 -17
- config/settings.py +4 -0
- core/admin.py +139 -27
- core/models.py +543 -204
- core/tasks.py +25 -0
- nodes/admin.py +152 -172
- nodes/tests.py +80 -129
- nodes/urls.py +6 -0
- nodes/views.py +520 -0
- ocpp/admin.py +541 -175
- ocpp/models.py +28 -0
- ocpp/tasks.py +336 -1
- pages/views.py +60 -30
- {arthexis-0.1.23.dist-info → arthexis-0.1.24.dist-info}/WHEEL +0 -0
- {arthexis-0.1.23.dist-info → arthexis-0.1.24.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.23.dist-info → arthexis-0.1.24.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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
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
|
-
|
|
3058
|
+
report.chargers.set(self.chargers.all())
|
|
3059
|
+
report.store_local_copy()
|
|
2926
3060
|
except Exception as exc:
|
|
2927
3061
|
self.notify_failure(str(exc))
|
|
2928
3062
|
raise
|
|
@@ -2934,43 +3068,12 @@ class ClientReportSchedule(Entity):
|
|
|
2934
3068
|
raise RuntimeError("No recipients available for client report")
|
|
2935
3069
|
else:
|
|
2936
3070
|
try:
|
|
2937
|
-
|
|
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(),
|
|
3071
|
+
delivered = report.send_delivery(
|
|
3072
|
+
to=to,
|
|
2970
3073
|
cc=cc,
|
|
2971
|
-
|
|
3074
|
+
outbox=self.get_outbox(),
|
|
3075
|
+
reply_to=self.resolve_reply_to(),
|
|
2972
3076
|
)
|
|
2973
|
-
delivered = list(dict.fromkeys(to + (cc or [])))
|
|
2974
3077
|
if delivered:
|
|
2975
3078
|
type(report).objects.filter(pk=report.pk).update(
|
|
2976
3079
|
recipients=delivered
|
|
@@ -2985,6 +3088,14 @@ class ClientReportSchedule(Entity):
|
|
|
2985
3088
|
self.last_generated_on = now
|
|
2986
3089
|
return report
|
|
2987
3090
|
|
|
3091
|
+
def generate_missing_reports(self, reference=None):
|
|
3092
|
+
generated: list["ClientReport"] = []
|
|
3093
|
+
for start, end in self.iter_pending_periods(reference=reference):
|
|
3094
|
+
report = self.run(start=start, end=end)
|
|
3095
|
+
if report:
|
|
3096
|
+
generated.append(report)
|
|
3097
|
+
return generated
|
|
3098
|
+
|
|
2988
3099
|
|
|
2989
3100
|
class ClientReport(Entity):
|
|
2990
3101
|
"""Snapshot of energy usage over a period."""
|
|
@@ -3007,15 +3118,70 @@ class ClientReport(Entity):
|
|
|
3007
3118
|
blank=True,
|
|
3008
3119
|
related_name="reports",
|
|
3009
3120
|
)
|
|
3121
|
+
language = models.CharField(
|
|
3122
|
+
max_length=12,
|
|
3123
|
+
choices=settings.LANGUAGES,
|
|
3124
|
+
default=default_report_language,
|
|
3125
|
+
)
|
|
3126
|
+
title = models.CharField(
|
|
3127
|
+
max_length=200,
|
|
3128
|
+
blank=True,
|
|
3129
|
+
default="",
|
|
3130
|
+
verbose_name=_("Title"),
|
|
3131
|
+
)
|
|
3010
3132
|
recipients = models.JSONField(default=list, blank=True)
|
|
3011
3133
|
disable_emails = models.BooleanField(default=False)
|
|
3134
|
+
chargers = models.ManyToManyField(
|
|
3135
|
+
"ocpp.Charger",
|
|
3136
|
+
blank=True,
|
|
3137
|
+
related_name="client_reports",
|
|
3138
|
+
)
|
|
3012
3139
|
|
|
3013
3140
|
class Meta:
|
|
3014
|
-
verbose_name = "Consumer Report"
|
|
3015
|
-
verbose_name_plural = "Consumer Reports"
|
|
3141
|
+
verbose_name = _("Consumer Report")
|
|
3142
|
+
verbose_name_plural = _("Consumer Reports")
|
|
3016
3143
|
db_table = "core_client_report"
|
|
3017
3144
|
ordering = ["-created_on"]
|
|
3018
3145
|
|
|
3146
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
3147
|
+
period_type = (
|
|
3148
|
+
self.schedule.periodicity
|
|
3149
|
+
if self.schedule
|
|
3150
|
+
else ClientReportSchedule.PERIODICITY_NONE
|
|
3151
|
+
)
|
|
3152
|
+
return f"{self.start_date} - {self.end_date} ({period_type})"
|
|
3153
|
+
|
|
3154
|
+
@staticmethod
|
|
3155
|
+
def default_language() -> str:
|
|
3156
|
+
return default_report_language()
|
|
3157
|
+
|
|
3158
|
+
@staticmethod
|
|
3159
|
+
def normalize_language(language: str | None) -> str:
|
|
3160
|
+
return normalize_report_language(language)
|
|
3161
|
+
|
|
3162
|
+
@staticmethod
|
|
3163
|
+
def normalize_title(title: str | None) -> str:
|
|
3164
|
+
return normalize_report_title(title)
|
|
3165
|
+
|
|
3166
|
+
def save(self, *args, **kwargs):
|
|
3167
|
+
if self.language:
|
|
3168
|
+
self.language = normalize_report_language(self.language)
|
|
3169
|
+
self.title = self.normalize_title(self.title)
|
|
3170
|
+
super().save(*args, **kwargs)
|
|
3171
|
+
|
|
3172
|
+
@property
|
|
3173
|
+
def periodicity_label(self) -> str:
|
|
3174
|
+
if self.schedule:
|
|
3175
|
+
return self.schedule.get_periodicity_display()
|
|
3176
|
+
return ClientReportSchedule.label_for_periodicity(
|
|
3177
|
+
ClientReportSchedule.PERIODICITY_NONE
|
|
3178
|
+
)
|
|
3179
|
+
|
|
3180
|
+
@property
|
|
3181
|
+
def total_kw_period(self) -> float:
|
|
3182
|
+
totals = (self.rows_for_display or {}).get("totals", {})
|
|
3183
|
+
return float(totals.get("total_kw_period", 0.0) or 0.0)
|
|
3184
|
+
|
|
3019
3185
|
@classmethod
|
|
3020
3186
|
def generate(
|
|
3021
3187
|
cls,
|
|
@@ -3026,9 +3192,23 @@ class ClientReport(Entity):
|
|
|
3026
3192
|
schedule=None,
|
|
3027
3193
|
recipients: list[str] | None = None,
|
|
3028
3194
|
disable_emails: bool = False,
|
|
3195
|
+
chargers=None,
|
|
3196
|
+
language: str | None = None,
|
|
3197
|
+
title: str | None = None,
|
|
3029
3198
|
):
|
|
3030
|
-
|
|
3031
|
-
|
|
3199
|
+
from collections.abc import Iterable as _Iterable
|
|
3200
|
+
|
|
3201
|
+
charger_list = []
|
|
3202
|
+
if chargers:
|
|
3203
|
+
if isinstance(chargers, _Iterable):
|
|
3204
|
+
charger_list = list(chargers)
|
|
3205
|
+
else:
|
|
3206
|
+
charger_list = [chargers]
|
|
3207
|
+
|
|
3208
|
+
payload = cls.build_rows(start_date, end_date, chargers=charger_list)
|
|
3209
|
+
normalized_language = cls.normalize_language(language)
|
|
3210
|
+
title_value = cls.normalize_title(title)
|
|
3211
|
+
report = cls.objects.create(
|
|
3032
3212
|
start_date=start_date,
|
|
3033
3213
|
end_date=end_date,
|
|
3034
3214
|
data=payload,
|
|
@@ -3036,7 +3216,12 @@ class ClientReport(Entity):
|
|
|
3036
3216
|
schedule=schedule,
|
|
3037
3217
|
recipients=list(recipients or []),
|
|
3038
3218
|
disable_emails=disable_emails,
|
|
3219
|
+
language=normalized_language,
|
|
3220
|
+
title=title_value,
|
|
3039
3221
|
)
|
|
3222
|
+
if charger_list:
|
|
3223
|
+
report.chargers.set(charger_list)
|
|
3224
|
+
return report
|
|
3040
3225
|
|
|
3041
3226
|
def store_local_copy(self, html: str | None = None):
|
|
3042
3227
|
"""Persist the report data and optional HTML rendering to disk."""
|
|
@@ -3050,9 +3235,16 @@ class ClientReport(Entity):
|
|
|
3050
3235
|
timestamp = timezone.now().strftime("%Y%m%d%H%M%S")
|
|
3051
3236
|
identifier = f"client_report_{self.pk}_{timestamp}"
|
|
3052
3237
|
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3238
|
+
language_code = self.normalize_language(self.language)
|
|
3239
|
+
context = {
|
|
3240
|
+
"report": self,
|
|
3241
|
+
"language_code": language_code,
|
|
3242
|
+
"default_language": type(self).default_language(),
|
|
3243
|
+
}
|
|
3244
|
+
with override(language_code):
|
|
3245
|
+
html_content = html or render_to_string(
|
|
3246
|
+
"core/reports/client_report_email.html", context
|
|
3247
|
+
)
|
|
3056
3248
|
html_path = report_dir / f"{identifier}.html"
|
|
3057
3249
|
html_path.write_text(html_content, encoding="utf-8")
|
|
3058
3250
|
|
|
@@ -3076,15 +3268,86 @@ class ClientReport(Entity):
|
|
|
3076
3268
|
self.data = updated
|
|
3077
3269
|
return export, html_content
|
|
3078
3270
|
|
|
3271
|
+
def send_delivery(
|
|
3272
|
+
self,
|
|
3273
|
+
*,
|
|
3274
|
+
to: list[str] | tuple[str, ...],
|
|
3275
|
+
cc: list[str] | tuple[str, ...] | None = None,
|
|
3276
|
+
outbox=None,
|
|
3277
|
+
reply_to: list[str] | None = None,
|
|
3278
|
+
) -> list[str]:
|
|
3279
|
+
from core import mailer
|
|
3280
|
+
|
|
3281
|
+
recipients = list(to or [])
|
|
3282
|
+
if not recipients:
|
|
3283
|
+
return []
|
|
3284
|
+
|
|
3285
|
+
pdf_path = self.ensure_pdf()
|
|
3286
|
+
attachments = [
|
|
3287
|
+
(pdf_path.name, pdf_path.read_bytes(), "application/pdf"),
|
|
3288
|
+
]
|
|
3289
|
+
|
|
3290
|
+
language_code = self.normalize_language(self.language)
|
|
3291
|
+
with override(language_code):
|
|
3292
|
+
totals = self.rows_for_display.get("totals", {})
|
|
3293
|
+
start_display = formats.date_format(
|
|
3294
|
+
self.start_date, format="DATE_FORMAT", use_l10n=True
|
|
3295
|
+
)
|
|
3296
|
+
end_display = formats.date_format(
|
|
3297
|
+
self.end_date, format="DATE_FORMAT", use_l10n=True
|
|
3298
|
+
)
|
|
3299
|
+
total_kw_period_label = gettext("Total kW during period")
|
|
3300
|
+
total_kw_all_label = gettext("Total kW (all time)")
|
|
3301
|
+
report_title = self.normalize_title(self.title) or gettext(
|
|
3302
|
+
"Consumer Report"
|
|
3303
|
+
)
|
|
3304
|
+
body_lines = [
|
|
3305
|
+
gettext("%(title)s for %(start)s through %(end)s.")
|
|
3306
|
+
% {"title": report_title, "start": start_display, "end": end_display},
|
|
3307
|
+
f"{total_kw_period_label}: "
|
|
3308
|
+
f"{formats.number_format(totals.get('total_kw_period', 0.0), decimal_pos=2, use_l10n=True)}.",
|
|
3309
|
+
f"{total_kw_all_label}: "
|
|
3310
|
+
f"{formats.number_format(totals.get('total_kw', 0.0), decimal_pos=2, use_l10n=True)}.",
|
|
3311
|
+
]
|
|
3312
|
+
message = "\n".join(body_lines)
|
|
3313
|
+
subject = gettext("%(title)s %(start)s - %(end)s") % {
|
|
3314
|
+
"title": report_title,
|
|
3315
|
+
"start": start_display,
|
|
3316
|
+
"end": end_display,
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
kwargs = {}
|
|
3320
|
+
if reply_to:
|
|
3321
|
+
kwargs["reply_to"] = reply_to
|
|
3322
|
+
|
|
3323
|
+
mailer.send(
|
|
3324
|
+
subject,
|
|
3325
|
+
message,
|
|
3326
|
+
recipients,
|
|
3327
|
+
outbox=outbox,
|
|
3328
|
+
cc=list(cc or []),
|
|
3329
|
+
attachments=attachments,
|
|
3330
|
+
**kwargs,
|
|
3331
|
+
)
|
|
3332
|
+
|
|
3333
|
+
delivered = list(dict.fromkeys(recipients + list(cc or [])))
|
|
3334
|
+
return delivered
|
|
3335
|
+
|
|
3079
3336
|
@staticmethod
|
|
3080
|
-
def build_rows(
|
|
3081
|
-
|
|
3337
|
+
def build_rows(
|
|
3338
|
+
start_date=None,
|
|
3339
|
+
end_date=None,
|
|
3340
|
+
*,
|
|
3341
|
+
for_display: bool = False,
|
|
3342
|
+
chargers=None,
|
|
3343
|
+
):
|
|
3344
|
+
dataset = ClientReport._build_dataset(start_date, end_date, chargers=chargers)
|
|
3082
3345
|
if for_display:
|
|
3083
3346
|
return ClientReport._normalize_dataset_for_display(dataset)
|
|
3084
3347
|
return dataset
|
|
3085
3348
|
|
|
3086
3349
|
@staticmethod
|
|
3087
|
-
def _build_dataset(start_date=None, end_date=None):
|
|
3350
|
+
def _build_dataset(start_date=None, end_date=None, *, chargers=None):
|
|
3088
3351
|
from datetime import datetime, time, timedelta, timezone as pytimezone
|
|
3089
3352
|
from ocpp.models import Charger, Transaction
|
|
3090
3353
|
|
|
@@ -3101,6 +3364,14 @@ class ClientReport(Entity):
|
|
|
3101
3364
|
)
|
|
3102
3365
|
qs = qs.filter(start_time__lt=end_dt)
|
|
3103
3366
|
|
|
3367
|
+
selected_base_ids = None
|
|
3368
|
+
if chargers:
|
|
3369
|
+
selected_base_ids = {
|
|
3370
|
+
charger.charger_id for charger in chargers if charger.charger_id
|
|
3371
|
+
}
|
|
3372
|
+
if selected_base_ids:
|
|
3373
|
+
qs = qs.filter(charger__charger_id__in=selected_base_ids)
|
|
3374
|
+
|
|
3104
3375
|
qs = qs.select_related("account", "charger").prefetch_related("meter_values")
|
|
3105
3376
|
transactions = list(qs.order_by("start_time", "pk"))
|
|
3106
3377
|
|
|
@@ -3134,6 +3405,8 @@ class ClientReport(Entity):
|
|
|
3134
3405
|
if charger is None:
|
|
3135
3406
|
continue
|
|
3136
3407
|
base_id = charger.charger_id
|
|
3408
|
+
if selected_base_ids is not None and base_id not in selected_base_ids:
|
|
3409
|
+
continue
|
|
3137
3410
|
aggregator = aggregator_map.get(base_id) or charger
|
|
3138
3411
|
entry = groups.setdefault(
|
|
3139
3412
|
base_id,
|
|
@@ -3224,6 +3497,10 @@ class ClientReport(Entity):
|
|
|
3224
3497
|
}
|
|
3225
3498
|
)
|
|
3226
3499
|
|
|
3500
|
+
filters: dict[str, Any] = {}
|
|
3501
|
+
if selected_base_ids:
|
|
3502
|
+
filters["chargers"] = sorted(selected_base_ids)
|
|
3503
|
+
|
|
3227
3504
|
return {
|
|
3228
3505
|
"schema": "evcs-session/v1",
|
|
3229
3506
|
"evcs": evcs_entries,
|
|
@@ -3231,6 +3508,7 @@ class ClientReport(Entity):
|
|
|
3231
3508
|
"total_kw": total_all_time,
|
|
3232
3509
|
"total_kw_period": total_period,
|
|
3233
3510
|
},
|
|
3511
|
+
"filters": filters,
|
|
3234
3512
|
}
|
|
3235
3513
|
|
|
3236
3514
|
@staticmethod
|
|
@@ -3330,6 +3608,7 @@ class ClientReport(Entity):
|
|
|
3330
3608
|
"total_kw": totals.get("total_kw", 0.0),
|
|
3331
3609
|
"total_kw_period": totals.get("total_kw_period", 0.0),
|
|
3332
3610
|
},
|
|
3611
|
+
"filters": dataset.get("filters", {}),
|
|
3333
3612
|
}
|
|
3334
3613
|
|
|
3335
3614
|
if schema == "session-list/v1":
|
|
@@ -3359,7 +3638,11 @@ class ClientReport(Entity):
|
|
|
3359
3638
|
|
|
3360
3639
|
return {"schema": schema, "rows": parsed}
|
|
3361
3640
|
|
|
3362
|
-
return {
|
|
3641
|
+
return {
|
|
3642
|
+
"schema": schema,
|
|
3643
|
+
"rows": dataset.get("rows", []),
|
|
3644
|
+
"filters": dataset.get("filters", {}),
|
|
3645
|
+
}
|
|
3363
3646
|
|
|
3364
3647
|
@property
|
|
3365
3648
|
def rows_for_display(self):
|
|
@@ -3373,6 +3656,37 @@ class ClientReport(Entity):
|
|
|
3373
3656
|
except ValueError:
|
|
3374
3657
|
return str(path)
|
|
3375
3658
|
|
|
3659
|
+
@staticmethod
|
|
3660
|
+
def resolve_reply_to_for_owner(owner) -> list[str]:
|
|
3661
|
+
if not owner:
|
|
3662
|
+
return []
|
|
3663
|
+
try:
|
|
3664
|
+
inbox = owner.get_profile(EmailInbox)
|
|
3665
|
+
except Exception: # pragma: no cover - defensive catch
|
|
3666
|
+
inbox = None
|
|
3667
|
+
if inbox and getattr(inbox, "username", ""):
|
|
3668
|
+
address = inbox.username.strip()
|
|
3669
|
+
if address:
|
|
3670
|
+
return [address]
|
|
3671
|
+
return []
|
|
3672
|
+
|
|
3673
|
+
@staticmethod
|
|
3674
|
+
def resolve_outbox_for_owner(owner):
|
|
3675
|
+
from nodes.models import EmailOutbox, Node
|
|
3676
|
+
|
|
3677
|
+
if owner:
|
|
3678
|
+
try:
|
|
3679
|
+
outbox = owner.get_profile(EmailOutbox)
|
|
3680
|
+
except Exception: # pragma: no cover - defensive catch
|
|
3681
|
+
outbox = None
|
|
3682
|
+
if outbox:
|
|
3683
|
+
return outbox
|
|
3684
|
+
|
|
3685
|
+
node = Node.get_local()
|
|
3686
|
+
if node:
|
|
3687
|
+
return getattr(node, "email_outbox", None)
|
|
3688
|
+
return None
|
|
3689
|
+
|
|
3376
3690
|
def render_pdf(self, target: Path):
|
|
3377
3691
|
from reportlab.lib import colors
|
|
3378
3692
|
from reportlab.lib.pagesizes import landscape, letter
|
|
@@ -3392,157 +3706,182 @@ class ClientReport(Entity):
|
|
|
3392
3706
|
dataset = self.rows_for_display
|
|
3393
3707
|
schema = dataset.get("schema")
|
|
3394
3708
|
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3709
|
+
language_code = self.normalize_language(self.language)
|
|
3710
|
+
with override(language_code):
|
|
3711
|
+
styles = getSampleStyleSheet()
|
|
3712
|
+
title_style = styles["Title"]
|
|
3713
|
+
subtitle_style = styles["Heading2"]
|
|
3714
|
+
normal_style = styles["BodyText"]
|
|
3715
|
+
emphasis_style = styles["Heading3"]
|
|
3716
|
+
|
|
3717
|
+
document = SimpleDocTemplate(
|
|
3718
|
+
str(target_path),
|
|
3719
|
+
pagesize=landscape(letter),
|
|
3720
|
+
leftMargin=0.5 * inch,
|
|
3721
|
+
rightMargin=0.5 * inch,
|
|
3722
|
+
topMargin=0.6 * inch,
|
|
3723
|
+
bottomMargin=0.5 * inch,
|
|
3724
|
+
)
|
|
3409
3725
|
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
f"Period: {self.start_date} to {self.end_date}",
|
|
3415
|
-
emphasis_style,
|
|
3726
|
+
story: list = []
|
|
3727
|
+
|
|
3728
|
+
report_title = self.normalize_title(self.title) or gettext(
|
|
3729
|
+
"Consumer Report"
|
|
3416
3730
|
)
|
|
3417
|
-
|
|
3418
|
-
story.append(Spacer(1, 0.25 * inch))
|
|
3731
|
+
story.append(Paragraph(report_title, title_style))
|
|
3419
3732
|
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3733
|
+
start_display = formats.date_format(
|
|
3734
|
+
self.start_date, format="DATE_FORMAT", use_l10n=True
|
|
3735
|
+
)
|
|
3736
|
+
end_display = formats.date_format(
|
|
3737
|
+
self.end_date, format="DATE_FORMAT", use_l10n=True
|
|
3738
|
+
)
|
|
3739
|
+
story.append(
|
|
3740
|
+
Paragraph(
|
|
3741
|
+
gettext("Period: %(start)s to %(end)s")
|
|
3742
|
+
% {"start": start_display, "end": end_display},
|
|
3743
|
+
emphasis_style,
|
|
3428
3744
|
)
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3745
|
+
)
|
|
3746
|
+
story.append(Spacer(1, 0.25 * inch))
|
|
3747
|
+
|
|
3748
|
+
total_kw_all_time_label = gettext("Total kW (all time)")
|
|
3749
|
+
total_kw_period_label = gettext("Total kW (period)")
|
|
3750
|
+
connector_label = gettext("Connector")
|
|
3751
|
+
account_label = gettext("Account")
|
|
3752
|
+
session_kwh_label = gettext("Session kWh")
|
|
3753
|
+
no_sessions_period = gettext(
|
|
3754
|
+
"No charging sessions recorded for the selected period."
|
|
3755
|
+
)
|
|
3756
|
+
no_sessions_point = gettext(
|
|
3757
|
+
"No charging sessions recorded for this charge point."
|
|
3758
|
+
)
|
|
3759
|
+
no_structured_data = gettext(
|
|
3760
|
+
"No structured data is available for this report."
|
|
3761
|
+
)
|
|
3762
|
+
report_totals_label = gettext("Report totals")
|
|
3763
|
+
total_kw_period_line = gettext("Total kW during period")
|
|
3764
|
+
|
|
3765
|
+
def format_datetime(value):
|
|
3766
|
+
if not value:
|
|
3767
|
+
return "—"
|
|
3768
|
+
localized = timezone.localtime(value)
|
|
3769
|
+
return formats.date_format(
|
|
3770
|
+
localized, format="DATETIME_FORMAT", use_l10n=True
|
|
3443
3771
|
)
|
|
3444
|
-
story.append(Paragraph(metrics_text, normal_style))
|
|
3445
|
-
story.append(Spacer(1, 0.1 * inch))
|
|
3446
|
-
|
|
3447
|
-
transactions = evcs.get("transactions", [])
|
|
3448
|
-
if transactions:
|
|
3449
|
-
table_data = [
|
|
3450
|
-
[
|
|
3451
|
-
"Connector",
|
|
3452
|
-
"Start kWh",
|
|
3453
|
-
"End kWh",
|
|
3454
|
-
"Session kWh",
|
|
3455
|
-
"Start Time",
|
|
3456
|
-
"End Time",
|
|
3457
|
-
"RFID Label",
|
|
3458
|
-
"Account",
|
|
3459
|
-
]
|
|
3460
|
-
]
|
|
3461
|
-
|
|
3462
|
-
def _format_number(value):
|
|
3463
|
-
return f"{value:.2f}" if value is not None else "—"
|
|
3464
3772
|
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3773
|
+
def format_decimal(value):
|
|
3774
|
+
if value is None:
|
|
3775
|
+
return "—"
|
|
3776
|
+
return formats.number_format(value, decimal_pos=2, use_l10n=True)
|
|
3777
|
+
|
|
3778
|
+
if schema == "evcs-session/v1":
|
|
3779
|
+
evcs_entries = dataset.get("evcs", [])
|
|
3780
|
+
if not evcs_entries:
|
|
3781
|
+
story.append(Paragraph(no_sessions_period, normal_style))
|
|
3782
|
+
for index, evcs in enumerate(evcs_entries):
|
|
3783
|
+
if index:
|
|
3784
|
+
story.append(Spacer(1, 0.2 * inch))
|
|
3785
|
+
|
|
3786
|
+
display_name = evcs.get("display_name") or gettext("Charge Point")
|
|
3787
|
+
serial_number = evcs.get("serial_number")
|
|
3788
|
+
if serial_number:
|
|
3789
|
+
header_text = gettext("%(name)s (Serial: %(serial)s)") % {
|
|
3790
|
+
"name": display_name,
|
|
3791
|
+
"serial": serial_number,
|
|
3792
|
+
}
|
|
3793
|
+
else:
|
|
3794
|
+
header_text = display_name
|
|
3795
|
+
story.append(Paragraph(header_text, subtitle_style))
|
|
3796
|
+
|
|
3797
|
+
metrics_text = (
|
|
3798
|
+
f"{total_kw_all_time_label}: "
|
|
3799
|
+
f"{format_decimal(evcs.get('total_kw', 0.0))} | "
|
|
3800
|
+
f"{total_kw_period_label}: "
|
|
3801
|
+
f"{format_decimal(evcs.get('total_kw_period', 0.0))}"
|
|
3802
|
+
)
|
|
3803
|
+
story.append(Paragraph(metrics_text, normal_style))
|
|
3804
|
+
story.append(Spacer(1, 0.1 * inch))
|
|
3478
3805
|
|
|
3479
|
-
|
|
3806
|
+
transactions = evcs.get("transactions", [])
|
|
3807
|
+
if transactions:
|
|
3808
|
+
table_data = [
|
|
3480
3809
|
[
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
start_display,
|
|
3488
|
-
end_display,
|
|
3489
|
-
row.get("rfid_label") or "—",
|
|
3490
|
-
row.get("account_name") or "—",
|
|
3810
|
+
session_kwh_label,
|
|
3811
|
+
gettext("Session start"),
|
|
3812
|
+
gettext("Session end"),
|
|
3813
|
+
connector_label,
|
|
3814
|
+
gettext("RFID label"),
|
|
3815
|
+
account_label,
|
|
3491
3816
|
]
|
|
3492
|
-
|
|
3817
|
+
]
|
|
3493
3818
|
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
"
|
|
3505
|
-
(
|
|
3506
|
-
(
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3819
|
+
for row in transactions:
|
|
3820
|
+
start_dt = row.get("start")
|
|
3821
|
+
end_dt = row.get("end")
|
|
3822
|
+
table_data.append(
|
|
3823
|
+
[
|
|
3824
|
+
format_decimal(row.get("session_kwh")),
|
|
3825
|
+
format_datetime(start_dt),
|
|
3826
|
+
format_datetime(end_dt),
|
|
3827
|
+
row.get("connector")
|
|
3828
|
+
if row.get("connector") is not None
|
|
3829
|
+
else "—",
|
|
3830
|
+
row.get("rfid_label") or "—",
|
|
3831
|
+
row.get("account_name") or "—",
|
|
3832
|
+
]
|
|
3833
|
+
)
|
|
3834
|
+
|
|
3835
|
+
table = Table(table_data, repeatRows=1)
|
|
3836
|
+
table.setStyle(
|
|
3837
|
+
TableStyle(
|
|
3838
|
+
[
|
|
3839
|
+
(
|
|
3840
|
+
"BACKGROUND",
|
|
3841
|
+
(0, 0),
|
|
3842
|
+
(-1, 0),
|
|
3843
|
+
colors.HexColor("#0f172a"),
|
|
3844
|
+
),
|
|
3845
|
+
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
|
3846
|
+
("ALIGN", (0, 0), (-1, 0), "CENTER"),
|
|
3847
|
+
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
|
3848
|
+
("FONTSIZE", (0, 0), (-1, 0), 9),
|
|
3849
|
+
(
|
|
3850
|
+
"ROWBACKGROUNDS",
|
|
3851
|
+
(0, 1),
|
|
3852
|
+
(-1, -1),
|
|
3853
|
+
[colors.whitesmoke, colors.HexColor("#eef2ff")],
|
|
3854
|
+
),
|
|
3855
|
+
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
|
|
3856
|
+
("VALIGN", (0, 1), (-1, -1), "MIDDLE"),
|
|
3857
|
+
]
|
|
3858
|
+
)
|
|
3520
3859
|
)
|
|
3521
|
-
|
|
3522
|
-
|
|
3860
|
+
story.append(table)
|
|
3861
|
+
else:
|
|
3862
|
+
story.append(Paragraph(no_sessions_point, normal_style))
|
|
3863
|
+
else:
|
|
3864
|
+
story.append(Paragraph(no_structured_data, normal_style))
|
|
3865
|
+
|
|
3866
|
+
totals = dataset.get("totals") or {}
|
|
3867
|
+
story.append(Spacer(1, 0.3 * inch))
|
|
3868
|
+
story.append(Paragraph(report_totals_label, emphasis_style))
|
|
3523
3869
|
story.append(
|
|
3524
3870
|
Paragraph(
|
|
3525
|
-
"
|
|
3526
|
-
|
|
3871
|
+
f"{total_kw_all_time_label}: "
|
|
3872
|
+
f"{format_decimal(totals.get('total_kw', 0.0))}",
|
|
3873
|
+
emphasis_style,
|
|
3527
3874
|
)
|
|
3528
3875
|
)
|
|
3529
|
-
|
|
3530
|
-
|
|
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,
|
|
3876
|
+
story.append(
|
|
3877
|
+
Paragraph(
|
|
3878
|
+
f"{total_kw_period_line}: "
|
|
3879
|
+
f"{format_decimal(totals.get('total_kw_period', 0.0))}",
|
|
3880
|
+
emphasis_style,
|
|
3881
|
+
)
|
|
3542
3882
|
)
|
|
3543
|
-
)
|
|
3544
3883
|
|
|
3545
|
-
|
|
3884
|
+
document.build(story)
|
|
3546
3885
|
|
|
3547
3886
|
def ensure_pdf(self) -> Path:
|
|
3548
3887
|
base_dir = Path(settings.BASE_DIR)
|