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.
- {arthexis-0.1.22.dist-info → arthexis-0.1.24.dist-info}/METADATA +6 -5
- {arthexis-0.1.22.dist-info → arthexis-0.1.24.dist-info}/RECORD +26 -26
- config/settings.py +4 -0
- core/admin.py +200 -16
- core/models.py +878 -118
- core/release.py +0 -5
- core/tasks.py +25 -0
- core/tests.py +29 -1
- core/user_data.py +42 -2
- core/views.py +33 -26
- nodes/admin.py +153 -132
- nodes/models.py +9 -1
- nodes/tests.py +106 -81
- nodes/urls.py +6 -0
- nodes/views.py +620 -48
- ocpp/admin.py +543 -166
- ocpp/models.py +57 -2
- ocpp/tasks.py +336 -1
- ocpp/tests.py +123 -0
- ocpp/views.py +19 -3
- pages/tests.py +25 -6
- pages/urls.py +5 -0
- pages/views.py +117 -11
- {arthexis-0.1.22.dist-info → arthexis-0.1.24.dist-info}/WHEEL +0 -0
- {arthexis-0.1.22.dist-info → arthexis-0.1.24.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.22.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,32 +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
|
-
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
|
-
|
|
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
|
-
|
|
3020
|
-
|
|
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=
|
|
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
|
-
|
|
3043
|
-
|
|
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
|
-
|
|
3054
|
-
|
|
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":
|
|
3061
|
-
"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
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
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
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
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
|
-
|
|
3092
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3107
|
-
if
|
|
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
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
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
|
-
|
|
3123
|
-
|
|
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
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
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
|
-
|
|
3487
|
+
evcs_entries.append(
|
|
3132
3488
|
{
|
|
3133
|
-
"
|
|
3134
|
-
"
|
|
3135
|
-
"
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
"
|
|
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
|
-
|
|
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
|
-
@
|
|
3145
|
-
def
|
|
3146
|
-
|
|
3147
|
-
|
|
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
|
-
|
|
3172
|
-
|
|
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(
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
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(
|
|
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
|