arthexis 0.1.22__py3-none-any.whl → 0.1.23__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.23.dist-info}/METADATA +2 -1
- {arthexis-0.1.22.dist-info → arthexis-0.1.23.dist-info}/RECORD +22 -22
- core/admin.py +85 -13
- core/models.py +484 -63
- core/release.py +0 -5
- core/tests.py +29 -1
- core/user_data.py +42 -2
- core/views.py +33 -26
- nodes/admin.py +64 -23
- nodes/models.py +9 -1
- nodes/tests.py +74 -0
- nodes/views.py +100 -48
- ocpp/admin.py +12 -1
- ocpp/models.py +29 -2
- ocpp/tests.py +123 -0
- ocpp/views.py +19 -3
- pages/tests.py +25 -6
- pages/urls.py +5 -0
- pages/views.py +84 -8
- {arthexis-0.1.22.dist-info → arthexis-0.1.23.dist-info}/WHEEL +0 -0
- {arthexis-0.1.22.dist-info → arthexis-0.1.23.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.22.dist-info → arthexis-0.1.23.dist-info}/top_level.txt +0 -0
core/models.py
CHANGED
|
@@ -2946,6 +2946,17 @@ class ClientReportSchedule(Entity):
|
|
|
2946
2946
|
"application/json",
|
|
2947
2947
|
)
|
|
2948
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
|
+
)
|
|
2949
2960
|
subject = f"Client report {report.start_date} to {report.end_date}"
|
|
2950
2961
|
body = (
|
|
2951
2962
|
"Attached is the client report generated for the period "
|
|
@@ -3016,11 +3027,11 @@ class ClientReport(Entity):
|
|
|
3016
3027
|
recipients: list[str] | None = None,
|
|
3017
3028
|
disable_emails: bool = False,
|
|
3018
3029
|
):
|
|
3019
|
-
|
|
3030
|
+
payload = cls.build_rows(start_date, end_date)
|
|
3020
3031
|
return cls.objects.create(
|
|
3021
3032
|
start_date=start_date,
|
|
3022
3033
|
end_date=end_date,
|
|
3023
|
-
data=
|
|
3034
|
+
data=payload,
|
|
3024
3035
|
owner=owner,
|
|
3025
3036
|
schedule=schedule,
|
|
3026
3037
|
recipients=list(recipients or []),
|
|
@@ -3050,15 +3061,13 @@ class ClientReport(Entity):
|
|
|
3050
3061
|
_json.dumps(self.data, indent=2, default=str), encoding="utf-8"
|
|
3051
3062
|
)
|
|
3052
3063
|
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
return str(path.relative_to(base_dir))
|
|
3056
|
-
except ValueError:
|
|
3057
|
-
return str(path)
|
|
3064
|
+
pdf_path = report_dir / f"{identifier}.pdf"
|
|
3065
|
+
self.render_pdf(pdf_path)
|
|
3058
3066
|
|
|
3059
3067
|
export = {
|
|
3060
|
-
"html_path":
|
|
3061
|
-
"json_path":
|
|
3068
|
+
"html_path": ClientReport._relative_to_base(html_path, base_dir),
|
|
3069
|
+
"json_path": ClientReport._relative_to_base(json_path, base_dir),
|
|
3070
|
+
"pdf_path": ClientReport._relative_to_base(pdf_path, base_dir),
|
|
3062
3071
|
}
|
|
3063
3072
|
|
|
3064
3073
|
updated = dict(self.data)
|
|
@@ -3069,28 +3078,32 @@ class ClientReport(Entity):
|
|
|
3069
3078
|
|
|
3070
3079
|
@staticmethod
|
|
3071
3080
|
def build_rows(start_date=None, end_date=None, *, for_display: bool = False):
|
|
3072
|
-
|
|
3081
|
+
dataset = ClientReport._build_dataset(start_date, end_date)
|
|
3082
|
+
if for_display:
|
|
3083
|
+
return ClientReport._normalize_dataset_for_display(dataset)
|
|
3084
|
+
return dataset
|
|
3073
3085
|
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
if start_date:
|
|
3079
|
-
from datetime import datetime, time, timedelta, timezone as pytimezone
|
|
3086
|
+
@staticmethod
|
|
3087
|
+
def _build_dataset(start_date=None, end_date=None):
|
|
3088
|
+
from datetime import datetime, time, timedelta, timezone as pytimezone
|
|
3089
|
+
from ocpp.models import Charger, Transaction
|
|
3080
3090
|
|
|
3091
|
+
qs = Transaction.objects.all()
|
|
3092
|
+
|
|
3093
|
+
start_dt = None
|
|
3094
|
+
end_dt = None
|
|
3095
|
+
if start_date:
|
|
3081
3096
|
start_dt = datetime.combine(start_date, time.min, tzinfo=pytimezone.utc)
|
|
3082
3097
|
qs = qs.filter(start_time__gte=start_dt)
|
|
3083
3098
|
if end_date:
|
|
3084
|
-
from datetime import datetime, time, timedelta, timezone as pytimezone
|
|
3085
|
-
|
|
3086
3099
|
end_dt = datetime.combine(
|
|
3087
3100
|
end_date + timedelta(days=1), time.min, tzinfo=pytimezone.utc
|
|
3088
3101
|
)
|
|
3089
3102
|
qs = qs.filter(start_time__lt=end_dt)
|
|
3090
3103
|
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3104
|
+
qs = qs.select_related("account", "charger").prefetch_related("meter_values")
|
|
3105
|
+
transactions = list(qs.order_by("start_time", "pk"))
|
|
3106
|
+
|
|
3094
3107
|
rfid_values = {tx.rfid for tx in transactions if tx.rfid}
|
|
3095
3108
|
tag_map: dict[str, RFID] = {}
|
|
3096
3109
|
if rfid_values:
|
|
@@ -3101,52 +3114,227 @@ class ClientReport(Entity):
|
|
|
3101
3114
|
)
|
|
3102
3115
|
}
|
|
3103
3116
|
|
|
3104
|
-
|
|
3117
|
+
charger_ids = {
|
|
3118
|
+
tx.charger.charger_id
|
|
3119
|
+
for tx in transactions
|
|
3120
|
+
if getattr(tx, "charger", None) and tx.charger.charger_id
|
|
3121
|
+
}
|
|
3122
|
+
aggregator_map: dict[str, Charger] = {}
|
|
3123
|
+
if charger_ids:
|
|
3124
|
+
aggregator_map = {
|
|
3125
|
+
charger.charger_id: charger
|
|
3126
|
+
for charger in Charger.objects.filter(
|
|
3127
|
+
charger_id__in=charger_ids, connector_id__isnull=True
|
|
3128
|
+
)
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
groups: dict[str, dict[str, Any]] = {}
|
|
3105
3132
|
for tx in transactions:
|
|
3106
|
-
|
|
3107
|
-
if
|
|
3133
|
+
charger = getattr(tx, "charger", None)
|
|
3134
|
+
if charger is None:
|
|
3108
3135
|
continue
|
|
3136
|
+
base_id = charger.charger_id
|
|
3137
|
+
aggregator = aggregator_map.get(base_id) or charger
|
|
3138
|
+
entry = groups.setdefault(
|
|
3139
|
+
base_id,
|
|
3140
|
+
{"charger": aggregator, "transactions": []},
|
|
3141
|
+
)
|
|
3142
|
+
entry["transactions"].append(tx)
|
|
3143
|
+
|
|
3144
|
+
evcs_entries: list[dict[str, Any]] = []
|
|
3145
|
+
total_all_time = 0.0
|
|
3146
|
+
total_period = 0.0
|
|
3147
|
+
|
|
3148
|
+
def _sort_key(tx):
|
|
3149
|
+
anchor = getattr(tx, "start_time", None)
|
|
3150
|
+
if anchor is None:
|
|
3151
|
+
anchor = datetime.min.replace(tzinfo=pytimezone.utc)
|
|
3152
|
+
return (anchor, tx.pk or 0)
|
|
3153
|
+
|
|
3154
|
+
for base_id, info in sorted(groups.items(), key=lambda item: item[0]):
|
|
3155
|
+
aggregator = info["charger"]
|
|
3156
|
+
txs = sorted(info["transactions"], key=_sort_key)
|
|
3157
|
+
total_kw_all = float(getattr(aggregator, "total_kw", 0.0) or 0.0)
|
|
3158
|
+
total_kw_period = 0.0
|
|
3159
|
+
if hasattr(aggregator, "total_kw_for_range"):
|
|
3160
|
+
total_kw_period = float(
|
|
3161
|
+
aggregator.total_kw_for_range(start=start_dt, end=end_dt) or 0.0
|
|
3162
|
+
)
|
|
3163
|
+
total_all_time += total_kw_all
|
|
3164
|
+
total_period += total_kw_period
|
|
3109
3165
|
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
if tag:
|
|
3116
|
-
account = next(iter(tag.energy_accounts.all()), None)
|
|
3117
|
-
if account:
|
|
3118
|
-
subject = account.name
|
|
3119
|
-
else:
|
|
3120
|
-
subject = str(tag.label_id)
|
|
3166
|
+
session_rows: list[dict[str, Any]] = []
|
|
3167
|
+
for tx in txs:
|
|
3168
|
+
session_kw = float(getattr(tx, "kw", 0.0) or 0.0)
|
|
3169
|
+
if session_kw <= 0:
|
|
3170
|
+
continue
|
|
3121
3171
|
|
|
3122
|
-
|
|
3123
|
-
subject = tx.rfid or tx.vid
|
|
3172
|
+
start_kwh, end_kwh = ClientReport._resolve_meter_bounds(tx)
|
|
3124
3173
|
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3174
|
+
connector_number = (
|
|
3175
|
+
tx.connector_id
|
|
3176
|
+
if getattr(tx, "connector_id", None) is not None
|
|
3177
|
+
else getattr(getattr(tx, "charger", None), "connector_id", None)
|
|
3178
|
+
)
|
|
3130
3179
|
|
|
3131
|
-
|
|
3180
|
+
rfid_value = (tx.rfid or "").strip()
|
|
3181
|
+
tag = tag_map.get(rfid_value)
|
|
3182
|
+
label = None
|
|
3183
|
+
account_name = (
|
|
3184
|
+
tx.account.name
|
|
3185
|
+
if tx.account and getattr(tx.account, "name", None)
|
|
3186
|
+
else None
|
|
3187
|
+
)
|
|
3188
|
+
if tag:
|
|
3189
|
+
label = tag.custom_label or str(tag.label_id)
|
|
3190
|
+
if not account_name:
|
|
3191
|
+
account = next(iter(tag.energy_accounts.all()), None)
|
|
3192
|
+
if account and getattr(account, "name", None):
|
|
3193
|
+
account_name = account.name
|
|
3194
|
+
elif rfid_value:
|
|
3195
|
+
label = rfid_value
|
|
3196
|
+
|
|
3197
|
+
session_rows.append(
|
|
3198
|
+
{
|
|
3199
|
+
"connector": connector_number,
|
|
3200
|
+
"rfid_label": label,
|
|
3201
|
+
"account_name": account_name,
|
|
3202
|
+
"start_kwh": start_kwh,
|
|
3203
|
+
"end_kwh": end_kwh,
|
|
3204
|
+
"session_kwh": session_kw,
|
|
3205
|
+
"start": tx.start_time.isoformat()
|
|
3206
|
+
if getattr(tx, "start_time", None)
|
|
3207
|
+
else None,
|
|
3208
|
+
"end": tx.stop_time.isoformat()
|
|
3209
|
+
if getattr(tx, "stop_time", None)
|
|
3210
|
+
else None,
|
|
3211
|
+
}
|
|
3212
|
+
)
|
|
3213
|
+
|
|
3214
|
+
evcs_entries.append(
|
|
3132
3215
|
{
|
|
3133
|
-
"
|
|
3134
|
-
"
|
|
3135
|
-
"
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
"
|
|
3216
|
+
"charger_id": aggregator.pk,
|
|
3217
|
+
"serial_number": aggregator.charger_id,
|
|
3218
|
+
"display_name": aggregator.display_name
|
|
3219
|
+
or aggregator.name
|
|
3220
|
+
or aggregator.charger_id,
|
|
3221
|
+
"total_kw": total_kw_all,
|
|
3222
|
+
"total_kw_period": total_kw_period,
|
|
3223
|
+
"transactions": session_rows,
|
|
3139
3224
|
}
|
|
3140
3225
|
)
|
|
3141
3226
|
|
|
3142
|
-
return
|
|
3227
|
+
return {
|
|
3228
|
+
"schema": "evcs-session/v1",
|
|
3229
|
+
"evcs": evcs_entries,
|
|
3230
|
+
"totals": {
|
|
3231
|
+
"total_kw": total_all_time,
|
|
3232
|
+
"total_kw_period": total_period,
|
|
3233
|
+
},
|
|
3234
|
+
}
|
|
3143
3235
|
|
|
3144
|
-
@
|
|
3145
|
-
def
|
|
3146
|
-
|
|
3147
|
-
|
|
3236
|
+
@staticmethod
|
|
3237
|
+
def _resolve_meter_bounds(tx) -> tuple[float | None, float | None]:
|
|
3238
|
+
def _convert(value):
|
|
3239
|
+
if value in {None, ""}:
|
|
3240
|
+
return None
|
|
3241
|
+
try:
|
|
3242
|
+
return float(value) / 1000.0
|
|
3243
|
+
except (TypeError, ValueError):
|
|
3244
|
+
return None
|
|
3245
|
+
|
|
3246
|
+
start_value = _convert(getattr(tx, "meter_start", None))
|
|
3247
|
+
end_value = _convert(getattr(tx, "meter_stop", None))
|
|
3248
|
+
|
|
3249
|
+
readings_manager = getattr(tx, "meter_values", None)
|
|
3250
|
+
readings = []
|
|
3251
|
+
if readings_manager is not None:
|
|
3252
|
+
readings = [
|
|
3253
|
+
reading
|
|
3254
|
+
for reading in readings_manager.all()
|
|
3255
|
+
if getattr(reading, "energy", None) is not None
|
|
3256
|
+
]
|
|
3257
|
+
if readings:
|
|
3258
|
+
readings.sort(key=lambda item: item.timestamp)
|
|
3259
|
+
if start_value is None:
|
|
3260
|
+
start_value = float(readings[0].energy or 0)
|
|
3261
|
+
if end_value is None:
|
|
3262
|
+
end_value = float(readings[-1].energy or 0)
|
|
3263
|
+
|
|
3264
|
+
return start_value, end_value
|
|
3265
|
+
|
|
3266
|
+
@staticmethod
|
|
3267
|
+
def _normalize_dataset_for_display(dataset: dict[str, Any]):
|
|
3268
|
+
schema = dataset.get("schema")
|
|
3269
|
+
if schema == "evcs-session/v1":
|
|
3270
|
+
from datetime import datetime
|
|
3271
|
+
|
|
3272
|
+
evcs_entries: list[dict[str, Any]] = []
|
|
3273
|
+
for entry in dataset.get("evcs", []):
|
|
3274
|
+
normalized_rows: list[dict[str, Any]] = []
|
|
3275
|
+
for row in entry.get("transactions", []):
|
|
3276
|
+
start_val = row.get("start")
|
|
3277
|
+
end_val = row.get("end")
|
|
3278
|
+
|
|
3279
|
+
start_dt = None
|
|
3280
|
+
if start_val:
|
|
3281
|
+
start_dt = parse_datetime(start_val)
|
|
3282
|
+
if start_dt and timezone.is_naive(start_dt):
|
|
3283
|
+
start_dt = timezone.make_aware(start_dt, timezone.utc)
|
|
3284
|
+
|
|
3285
|
+
end_dt = None
|
|
3286
|
+
if end_val:
|
|
3287
|
+
end_dt = parse_datetime(end_val)
|
|
3288
|
+
if end_dt and timezone.is_naive(end_dt):
|
|
3289
|
+
end_dt = timezone.make_aware(end_dt, timezone.utc)
|
|
3290
|
+
|
|
3291
|
+
normalized_rows.append(
|
|
3292
|
+
{
|
|
3293
|
+
"connector": row.get("connector"),
|
|
3294
|
+
"rfid_label": row.get("rfid_label"),
|
|
3295
|
+
"account_name": row.get("account_name"),
|
|
3296
|
+
"start_kwh": row.get("start_kwh"),
|
|
3297
|
+
"end_kwh": row.get("end_kwh"),
|
|
3298
|
+
"session_kwh": row.get("session_kwh"),
|
|
3299
|
+
"start": start_dt,
|
|
3300
|
+
"end": end_dt,
|
|
3301
|
+
}
|
|
3302
|
+
)
|
|
3303
|
+
|
|
3304
|
+
normalized_rows.sort(
|
|
3305
|
+
key=lambda item: (
|
|
3306
|
+
item["start"]
|
|
3307
|
+
if item["start"] is not None
|
|
3308
|
+
else datetime.min.replace(tzinfo=timezone.utc),
|
|
3309
|
+
item.get("connector") or 0,
|
|
3310
|
+
)
|
|
3311
|
+
)
|
|
3312
|
+
|
|
3313
|
+
evcs_entries.append(
|
|
3314
|
+
{
|
|
3315
|
+
"display_name": entry.get("display_name")
|
|
3316
|
+
or entry.get("serial_number")
|
|
3317
|
+
or "Charge Point",
|
|
3318
|
+
"serial_number": entry.get("serial_number"),
|
|
3319
|
+
"total_kw": entry.get("total_kw", 0.0),
|
|
3320
|
+
"total_kw_period": entry.get("total_kw_period", 0.0),
|
|
3321
|
+
"transactions": normalized_rows,
|
|
3322
|
+
}
|
|
3323
|
+
)
|
|
3324
|
+
|
|
3325
|
+
totals = dataset.get("totals", {})
|
|
3326
|
+
return {
|
|
3327
|
+
"schema": schema,
|
|
3328
|
+
"evcs": evcs_entries,
|
|
3329
|
+
"totals": {
|
|
3330
|
+
"total_kw": totals.get("total_kw", 0.0),
|
|
3331
|
+
"total_kw_period": totals.get("total_kw_period", 0.0),
|
|
3332
|
+
},
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
if schema == "session-list/v1":
|
|
3148
3336
|
parsed: list[dict[str, Any]] = []
|
|
3149
|
-
for row in rows:
|
|
3337
|
+
for row in dataset.get("rows", []):
|
|
3150
3338
|
item = dict(row)
|
|
3151
3339
|
start_val = row.get("start")
|
|
3152
3340
|
end_val = row.get("end")
|
|
@@ -3168,8 +3356,216 @@ class ClientReport(Entity):
|
|
|
3168
3356
|
item["end"] = None
|
|
3169
3357
|
|
|
3170
3358
|
parsed.append(item)
|
|
3171
|
-
|
|
3172
|
-
|
|
3359
|
+
|
|
3360
|
+
return {"schema": schema, "rows": parsed}
|
|
3361
|
+
|
|
3362
|
+
return {"schema": schema, "rows": dataset.get("rows", [])}
|
|
3363
|
+
|
|
3364
|
+
@property
|
|
3365
|
+
def rows_for_display(self):
|
|
3366
|
+
data = self.data or {}
|
|
3367
|
+
return ClientReport._normalize_dataset_for_display(data)
|
|
3368
|
+
|
|
3369
|
+
@staticmethod
|
|
3370
|
+
def _relative_to_base(path: Path, base_dir: Path) -> str:
|
|
3371
|
+
try:
|
|
3372
|
+
return str(path.relative_to(base_dir))
|
|
3373
|
+
except ValueError:
|
|
3374
|
+
return str(path)
|
|
3375
|
+
|
|
3376
|
+
def render_pdf(self, target: Path):
|
|
3377
|
+
from reportlab.lib import colors
|
|
3378
|
+
from reportlab.lib.pagesizes import landscape, letter
|
|
3379
|
+
from reportlab.lib.styles import getSampleStyleSheet
|
|
3380
|
+
from reportlab.lib.units import inch
|
|
3381
|
+
from reportlab.platypus import (
|
|
3382
|
+
Paragraph,
|
|
3383
|
+
SimpleDocTemplate,
|
|
3384
|
+
Spacer,
|
|
3385
|
+
Table,
|
|
3386
|
+
TableStyle,
|
|
3387
|
+
)
|
|
3388
|
+
|
|
3389
|
+
target_path = Path(target)
|
|
3390
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
3391
|
+
|
|
3392
|
+
dataset = self.rows_for_display
|
|
3393
|
+
schema = dataset.get("schema")
|
|
3394
|
+
|
|
3395
|
+
styles = getSampleStyleSheet()
|
|
3396
|
+
title_style = styles["Title"]
|
|
3397
|
+
subtitle_style = styles["Heading2"]
|
|
3398
|
+
normal_style = styles["BodyText"]
|
|
3399
|
+
emphasis_style = styles["Heading3"]
|
|
3400
|
+
|
|
3401
|
+
document = SimpleDocTemplate(
|
|
3402
|
+
str(target_path),
|
|
3403
|
+
pagesize=landscape(letter),
|
|
3404
|
+
leftMargin=0.5 * inch,
|
|
3405
|
+
rightMargin=0.5 * inch,
|
|
3406
|
+
topMargin=0.6 * inch,
|
|
3407
|
+
bottomMargin=0.5 * inch,
|
|
3408
|
+
)
|
|
3409
|
+
|
|
3410
|
+
story: list = []
|
|
3411
|
+
story.append(Paragraph("Consumer Report", title_style))
|
|
3412
|
+
story.append(
|
|
3413
|
+
Paragraph(
|
|
3414
|
+
f"Period: {self.start_date} to {self.end_date}",
|
|
3415
|
+
emphasis_style,
|
|
3416
|
+
)
|
|
3417
|
+
)
|
|
3418
|
+
story.append(Spacer(1, 0.25 * inch))
|
|
3419
|
+
|
|
3420
|
+
if schema == "evcs-session/v1":
|
|
3421
|
+
evcs_entries = dataset.get("evcs", [])
|
|
3422
|
+
if not evcs_entries:
|
|
3423
|
+
story.append(
|
|
3424
|
+
Paragraph(
|
|
3425
|
+
"No charging sessions recorded for the selected period.",
|
|
3426
|
+
normal_style,
|
|
3427
|
+
)
|
|
3428
|
+
)
|
|
3429
|
+
for index, evcs in enumerate(evcs_entries):
|
|
3430
|
+
if index:
|
|
3431
|
+
story.append(Spacer(1, 0.2 * inch))
|
|
3432
|
+
|
|
3433
|
+
display_name = evcs.get("display_name") or "Charge Point"
|
|
3434
|
+
serial_number = evcs.get("serial_number")
|
|
3435
|
+
header_text = display_name
|
|
3436
|
+
if serial_number:
|
|
3437
|
+
header_text = f"{display_name} (Serial: {serial_number})"
|
|
3438
|
+
story.append(Paragraph(header_text, subtitle_style))
|
|
3439
|
+
|
|
3440
|
+
metrics_text = (
|
|
3441
|
+
f"Total kW (all time): {evcs.get('total_kw', 0.0):.2f} | "
|
|
3442
|
+
f"Total kW (period): {evcs.get('total_kw_period', 0.0):.2f}"
|
|
3443
|
+
)
|
|
3444
|
+
story.append(Paragraph(metrics_text, normal_style))
|
|
3445
|
+
story.append(Spacer(1, 0.1 * inch))
|
|
3446
|
+
|
|
3447
|
+
transactions = evcs.get("transactions", [])
|
|
3448
|
+
if transactions:
|
|
3449
|
+
table_data = [
|
|
3450
|
+
[
|
|
3451
|
+
"Connector",
|
|
3452
|
+
"Start kWh",
|
|
3453
|
+
"End kWh",
|
|
3454
|
+
"Session kWh",
|
|
3455
|
+
"Start Time",
|
|
3456
|
+
"End Time",
|
|
3457
|
+
"RFID Label",
|
|
3458
|
+
"Account",
|
|
3459
|
+
]
|
|
3460
|
+
]
|
|
3461
|
+
|
|
3462
|
+
def _format_number(value):
|
|
3463
|
+
return f"{value:.2f}" if value is not None else "—"
|
|
3464
|
+
|
|
3465
|
+
for row in transactions:
|
|
3466
|
+
start_dt = row.get("start")
|
|
3467
|
+
end_dt = row.get("end")
|
|
3468
|
+
start_display = (
|
|
3469
|
+
timezone.localtime(start_dt).strftime("%Y-%m-%d %H:%M")
|
|
3470
|
+
if start_dt
|
|
3471
|
+
else "—"
|
|
3472
|
+
)
|
|
3473
|
+
end_display = (
|
|
3474
|
+
timezone.localtime(end_dt).strftime("%Y-%m-%d %H:%M")
|
|
3475
|
+
if end_dt
|
|
3476
|
+
else "—"
|
|
3477
|
+
)
|
|
3478
|
+
|
|
3479
|
+
table_data.append(
|
|
3480
|
+
[
|
|
3481
|
+
row.get("connector")
|
|
3482
|
+
if row.get("connector") is not None
|
|
3483
|
+
else "—",
|
|
3484
|
+
_format_number(row.get("start_kwh")),
|
|
3485
|
+
_format_number(row.get("end_kwh")),
|
|
3486
|
+
_format_number(row.get("session_kwh")),
|
|
3487
|
+
start_display,
|
|
3488
|
+
end_display,
|
|
3489
|
+
row.get("rfid_label") or "—",
|
|
3490
|
+
row.get("account_name") or "—",
|
|
3491
|
+
]
|
|
3492
|
+
)
|
|
3493
|
+
|
|
3494
|
+
table = Table(table_data, repeatRows=1)
|
|
3495
|
+
table.setStyle(
|
|
3496
|
+
TableStyle(
|
|
3497
|
+
[
|
|
3498
|
+
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#0f172a")),
|
|
3499
|
+
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
|
3500
|
+
("ALIGN", (0, 0), (-1, 0), "CENTER"),
|
|
3501
|
+
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
|
3502
|
+
("FONTSIZE", (0, 0), (-1, 0), 9),
|
|
3503
|
+
(
|
|
3504
|
+
"ROWBACKGROUNDS",
|
|
3505
|
+
(0, 1),
|
|
3506
|
+
(-1, -1),
|
|
3507
|
+
[colors.whitesmoke, colors.HexColor("#eef2ff")],
|
|
3508
|
+
),
|
|
3509
|
+
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
|
|
3510
|
+
("VALIGN", (0, 1), (-1, -1), "MIDDLE"),
|
|
3511
|
+
]
|
|
3512
|
+
)
|
|
3513
|
+
)
|
|
3514
|
+
story.append(table)
|
|
3515
|
+
else:
|
|
3516
|
+
story.append(
|
|
3517
|
+
Paragraph(
|
|
3518
|
+
"No charging sessions recorded for this charge point.",
|
|
3519
|
+
normal_style,
|
|
3520
|
+
)
|
|
3521
|
+
)
|
|
3522
|
+
else:
|
|
3523
|
+
story.append(
|
|
3524
|
+
Paragraph(
|
|
3525
|
+
"No structured data is available for this report.",
|
|
3526
|
+
normal_style,
|
|
3527
|
+
)
|
|
3528
|
+
)
|
|
3529
|
+
|
|
3530
|
+
totals = dataset.get("totals") or {}
|
|
3531
|
+
story.append(Spacer(1, 0.3 * inch))
|
|
3532
|
+
story.append(
|
|
3533
|
+
Paragraph(
|
|
3534
|
+
f"Report totals — Total kW (all time): {totals.get('total_kw', 0.0):.2f}",
|
|
3535
|
+
emphasis_style,
|
|
3536
|
+
)
|
|
3537
|
+
)
|
|
3538
|
+
story.append(
|
|
3539
|
+
Paragraph(
|
|
3540
|
+
f"Total kW during period: {totals.get('total_kw_period', 0.0):.2f}",
|
|
3541
|
+
emphasis_style,
|
|
3542
|
+
)
|
|
3543
|
+
)
|
|
3544
|
+
|
|
3545
|
+
document.build(story)
|
|
3546
|
+
|
|
3547
|
+
def ensure_pdf(self) -> Path:
|
|
3548
|
+
base_dir = Path(settings.BASE_DIR)
|
|
3549
|
+
export = dict((self.data or {}).get("export") or {})
|
|
3550
|
+
pdf_relative = export.get("pdf_path")
|
|
3551
|
+
if pdf_relative:
|
|
3552
|
+
candidate = base_dir / pdf_relative
|
|
3553
|
+
if candidate.exists():
|
|
3554
|
+
return candidate
|
|
3555
|
+
|
|
3556
|
+
report_dir = base_dir / "work" / "reports"
|
|
3557
|
+
report_dir.mkdir(parents=True, exist_ok=True)
|
|
3558
|
+
timestamp = timezone.now().strftime("%Y%m%d%H%M%S")
|
|
3559
|
+
identifier = f"client_report_{self.pk}_{timestamp}"
|
|
3560
|
+
pdf_path = report_dir / f"{identifier}.pdf"
|
|
3561
|
+
self.render_pdf(pdf_path)
|
|
3562
|
+
|
|
3563
|
+
export["pdf_path"] = ClientReport._relative_to_base(pdf_path, base_dir)
|
|
3564
|
+
updated = dict(self.data)
|
|
3565
|
+
updated["export"] = export
|
|
3566
|
+
type(self).objects.filter(pk=self.pk).update(data=updated)
|
|
3567
|
+
self.data = updated
|
|
3568
|
+
return pdf_path
|
|
3173
3569
|
|
|
3174
3570
|
|
|
3175
3571
|
class BrandManager(EntityManager):
|
|
@@ -3622,6 +4018,10 @@ class PackageRelease(Entity):
|
|
|
3622
4018
|
if old_name not in expected and old_path.exists():
|
|
3623
4019
|
old_path.unlink()
|
|
3624
4020
|
|
|
4021
|
+
def delete(self, using=None, keep_parents=False):
|
|
4022
|
+
user_data.delete_user_fixture(self)
|
|
4023
|
+
super().delete(using=using, keep_parents=keep_parents)
|
|
4024
|
+
|
|
3625
4025
|
def __str__(self) -> str: # pragma: no cover - trivial
|
|
3626
4026
|
return f"{self.package.name} {self.version}"
|
|
3627
4027
|
|
|
@@ -3629,10 +4029,27 @@ class PackageRelease(Entity):
|
|
|
3629
4029
|
"""Return a :class:`ReleasePackage` built from the package."""
|
|
3630
4030
|
return self.package.to_package()
|
|
3631
4031
|
|
|
3632
|
-
def to_credentials(
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
4032
|
+
def to_credentials(
|
|
4033
|
+
self, user: models.Model | None = None
|
|
4034
|
+
) -> Credentials | None:
|
|
4035
|
+
"""Return :class:`Credentials` from available release managers."""
|
|
4036
|
+
|
|
4037
|
+
manager_candidates: list[ReleaseManager] = []
|
|
4038
|
+
|
|
4039
|
+
for candidate in (self.release_manager, self.package.release_manager):
|
|
4040
|
+
if candidate and candidate not in manager_candidates:
|
|
4041
|
+
manager_candidates.append(candidate)
|
|
4042
|
+
|
|
4043
|
+
if user is not None and getattr(user, "is_authenticated", False):
|
|
4044
|
+
try:
|
|
4045
|
+
user_manager = ReleaseManager.objects.get(user=user)
|
|
4046
|
+
except ReleaseManager.DoesNotExist:
|
|
4047
|
+
user_manager = None
|
|
4048
|
+
else:
|
|
4049
|
+
if user_manager not in manager_candidates:
|
|
4050
|
+
manager_candidates.append(user_manager)
|
|
4051
|
+
|
|
4052
|
+
for manager in manager_candidates:
|
|
3636
4053
|
creds = manager.to_credentials()
|
|
3637
4054
|
if creds and creds.has_auth():
|
|
3638
4055
|
return creds
|
|
@@ -3654,7 +4071,9 @@ class PackageRelease(Entity):
|
|
|
3654
4071
|
return manager.github_token
|
|
3655
4072
|
return os.environ.get("GITHUB_TOKEN")
|
|
3656
4073
|
|
|
3657
|
-
def build_publish_targets(
|
|
4074
|
+
def build_publish_targets(
|
|
4075
|
+
self, user: models.Model | None = None
|
|
4076
|
+
) -> list[RepositoryTarget]:
|
|
3658
4077
|
"""Return repository targets for publishing this release."""
|
|
3659
4078
|
|
|
3660
4079
|
manager = self.release_manager or self.package.release_manager
|
|
@@ -3663,7 +4082,7 @@ class PackageRelease(Entity):
|
|
|
3663
4082
|
env_primary = os.environ.get("PYPI_REPOSITORY_URL", "")
|
|
3664
4083
|
primary_url = env_primary.strip()
|
|
3665
4084
|
|
|
3666
|
-
primary_creds = self.to_credentials()
|
|
4085
|
+
primary_creds = self.to_credentials(user=user)
|
|
3667
4086
|
targets.append(
|
|
3668
4087
|
RepositoryTarget(
|
|
3669
4088
|
name="PyPI",
|
|
@@ -3805,6 +4224,8 @@ class PackageRelease(Entity):
|
|
|
3805
4224
|
"""
|
|
3806
4225
|
|
|
3807
4226
|
version = (version or "").strip()
|
|
4227
|
+
if version.endswith("+"):
|
|
4228
|
+
version = version.rstrip("+")
|
|
3808
4229
|
revision = (revision or "").strip()
|
|
3809
4230
|
if not version or not revision:
|
|
3810
4231
|
return True
|
core/release.py
CHANGED
|
@@ -520,11 +520,6 @@ def build(
|
|
|
520
520
|
if not version_path.exists():
|
|
521
521
|
raise ReleaseError("VERSION file not found")
|
|
522
522
|
version = version_path.read_text().strip()
|
|
523
|
-
if bump:
|
|
524
|
-
major, minor, patch = map(int, version.split("."))
|
|
525
|
-
patch += 1
|
|
526
|
-
version = f"{major}.{minor}.{patch}"
|
|
527
|
-
version_path.write_text(version + "\n")
|
|
528
523
|
else:
|
|
529
524
|
# Ensure the VERSION file reflects the provided release version
|
|
530
525
|
if version_path.parent != Path("."):
|