arthexis 0.1.21__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.

core/models.py CHANGED
@@ -15,7 +15,13 @@ from django.apps import apps
15
15
  from django.db.models.signals import m2m_changed, post_delete, post_save
16
16
  from django.dispatch import receiver
17
17
  from django.views.decorators.debug import sensitive_variables
18
- from datetime import time as datetime_time, timedelta
18
+ from datetime import (
19
+ time as datetime_time,
20
+ timedelta,
21
+ datetime as datetime_datetime,
22
+ date as datetime_date,
23
+ timezone as datetime_timezone,
24
+ )
19
25
  import logging
20
26
  import json
21
27
  from django.contrib.contenttypes.models import ContentType
@@ -33,7 +39,8 @@ import uuid
33
39
  from pathlib import Path
34
40
  from django.core import serializers
35
41
  from django.core.management.color import no_style
36
- from urllib.parse import quote_plus, urlparse
42
+ from urllib.parse import quote, quote_plus, urlparse
43
+ from zoneinfo import ZoneInfo
37
44
  from utils import revision as revision_utils
38
45
  from typing import Any, Type
39
46
  from defusedxml import xmlrpc as defused_xmlrpc
@@ -522,6 +529,10 @@ class User(Entity, AbstractUser):
522
529
  def social_profile(self):
523
530
  return self._direct_profile("SocialProfile")
524
531
 
532
+ @property
533
+ def google_calendar_profile(self):
534
+ return self._direct_profile("GoogleCalendarProfile")
535
+
525
536
 
526
537
  class UserPhoneNumber(Entity):
527
538
  """Store phone numbers associated with a user."""
@@ -839,6 +850,184 @@ class OpenPayProfile(Profile):
839
850
  ]
840
851
 
841
852
 
853
+ class GoogleCalendarProfile(Profile):
854
+ """Store Google Calendar configuration for a user or security group."""
855
+
856
+ profile_fields = ("calendar_id", "api_key", "display_name", "timezone")
857
+
858
+ calendar_id = SigilShortAutoField(max_length=255)
859
+ api_key = SigilShortAutoField(max_length=255)
860
+ display_name = models.CharField(max_length=255, blank=True)
861
+ max_events = models.PositiveIntegerField(
862
+ default=5,
863
+ validators=[MinValueValidator(1), MaxValueValidator(20)],
864
+ help_text=_("Number of upcoming events to display (1-20)."),
865
+ )
866
+ timezone = SigilShortAutoField(max_length=100, blank=True)
867
+
868
+ GOOGLE_EVENTS_URL = (
869
+ "https://www.googleapis.com/calendar/v3/calendars/{calendar}/events"
870
+ )
871
+ GOOGLE_EMBED_URL = "https://calendar.google.com/calendar/embed?src={calendar}&ctz={tz}"
872
+
873
+ class Meta:
874
+ verbose_name = _("Google Calendar")
875
+ verbose_name_plural = _("Google Calendars")
876
+ constraints = [
877
+ models.CheckConstraint(
878
+ check=(
879
+ (Q(user__isnull=False) & Q(group__isnull=True))
880
+ | (Q(user__isnull=True) & Q(group__isnull=False))
881
+ ),
882
+ name="googlecalendarprofile_requires_owner",
883
+ )
884
+ ]
885
+
886
+ def __str__(self): # pragma: no cover - simple representation
887
+ label = self.get_display_name()
888
+ return label or self.resolved_calendar_id()
889
+
890
+ def resolved_calendar_id(self) -> str:
891
+ value = self.resolve_sigils("calendar_id")
892
+ return value or self.calendar_id or ""
893
+
894
+ def resolved_api_key(self) -> str:
895
+ value = self.resolve_sigils("api_key")
896
+ return value or self.api_key or ""
897
+
898
+ def resolved_timezone(self) -> str:
899
+ value = self.resolve_sigils("timezone")
900
+ return value or self.timezone or ""
901
+
902
+ def get_timezone(self) -> ZoneInfo:
903
+ tz_name = self.resolved_timezone() or settings.TIME_ZONE
904
+ try:
905
+ return ZoneInfo(tz_name)
906
+ except Exception:
907
+ return ZoneInfo("UTC")
908
+
909
+ def get_display_name(self) -> str:
910
+ value = self.resolve_sigils("display_name")
911
+ if value:
912
+ return value
913
+ if self.display_name:
914
+ return self.display_name
915
+ return ""
916
+
917
+ def build_events_url(self) -> str:
918
+ calendar = self.resolved_calendar_id().strip()
919
+ if not calendar:
920
+ return ""
921
+ encoded = quote(calendar, safe="@")
922
+ return self.GOOGLE_EVENTS_URL.format(calendar=encoded)
923
+
924
+ def build_calendar_url(self) -> str:
925
+ calendar = self.resolved_calendar_id().strip()
926
+ if not calendar:
927
+ return ""
928
+ tz = self.get_timezone().key
929
+ encoded_calendar = quote_plus(calendar)
930
+ encoded_tz = quote_plus(tz)
931
+ return self.GOOGLE_EMBED_URL.format(calendar=encoded_calendar, tz=encoded_tz)
932
+
933
+ def _parse_event_point(self, data: dict) -> tuple[datetime_datetime | None, bool]:
934
+ if not isinstance(data, dict):
935
+ return None, False
936
+
937
+ tz_name = data.get("timeZone")
938
+ default_tz = self.get_timezone()
939
+ tzinfo = default_tz
940
+ if tz_name:
941
+ try:
942
+ tzinfo = ZoneInfo(tz_name)
943
+ except Exception:
944
+ tzinfo = default_tz
945
+
946
+ timestamp = data.get("dateTime")
947
+ if timestamp:
948
+ dt = parse_datetime(timestamp)
949
+ if dt is None:
950
+ try:
951
+ dt = datetime_datetime.fromisoformat(
952
+ timestamp.replace("Z", "+00:00")
953
+ )
954
+ except ValueError:
955
+ dt = None
956
+ if dt is not None and dt.tzinfo is None:
957
+ dt = dt.replace(tzinfo=tzinfo)
958
+ return dt, False
959
+
960
+ date_value = data.get("date")
961
+ if date_value:
962
+ try:
963
+ day = datetime_date.fromisoformat(date_value)
964
+ except ValueError:
965
+ return None, True
966
+ dt = datetime_datetime.combine(day, datetime_time.min, tzinfo=tzinfo)
967
+ return dt, True
968
+
969
+ return None, False
970
+
971
+ def fetch_events(self, *, max_results: int | None = None) -> list[dict[str, object]]:
972
+ calendar_id = self.resolved_calendar_id().strip()
973
+ api_key = self.resolved_api_key().strip()
974
+ if not calendar_id or not api_key:
975
+ return []
976
+
977
+ url = self.build_events_url()
978
+ if not url:
979
+ return []
980
+
981
+ now = timezone.now().astimezone(datetime_timezone.utc).replace(microsecond=0)
982
+ params = {
983
+ "key": api_key,
984
+ "singleEvents": "true",
985
+ "orderBy": "startTime",
986
+ "timeMin": now.isoformat().replace("+00:00", "Z"),
987
+ "maxResults": max_results or self.max_events or 5,
988
+ }
989
+
990
+ try:
991
+ response = requests.get(url, params=params, timeout=10)
992
+ response.raise_for_status()
993
+ payload = response.json()
994
+ except (requests.RequestException, ValueError):
995
+ logger.warning(
996
+ "Failed to fetch Google Calendar events for profile %s", self.pk,
997
+ exc_info=True,
998
+ )
999
+ return []
1000
+
1001
+ items = payload.get("items")
1002
+ if not isinstance(items, list):
1003
+ return []
1004
+
1005
+ events: list[dict[str, object]] = []
1006
+ for item in items:
1007
+ if not isinstance(item, dict):
1008
+ continue
1009
+ start, all_day = self._parse_event_point(item.get("start") or {})
1010
+ end, _ = self._parse_event_point(item.get("end") or {})
1011
+ summary = item.get("summary") or ""
1012
+ link = item.get("htmlLink") or ""
1013
+ location = item.get("location") or ""
1014
+ if start is None:
1015
+ continue
1016
+ events.append(
1017
+ {
1018
+ "summary": summary,
1019
+ "start": start,
1020
+ "end": end,
1021
+ "all_day": all_day,
1022
+ "html_link": link,
1023
+ "location": location,
1024
+ }
1025
+ )
1026
+
1027
+ events.sort(key=lambda event: event.get("start") or timezone.now())
1028
+ return events
1029
+
1030
+
842
1031
  class EmailInbox(Profile):
843
1032
  """Credentials and configuration for connecting to an email mailbox."""
844
1033
 
@@ -2757,6 +2946,17 @@ class ClientReportSchedule(Entity):
2757
2946
  "application/json",
2758
2947
  )
2759
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
+ )
2760
2960
  subject = f"Client report {report.start_date} to {report.end_date}"
2761
2961
  body = (
2762
2962
  "Attached is the client report generated for the period "
@@ -2827,11 +3027,11 @@ class ClientReport(Entity):
2827
3027
  recipients: list[str] | None = None,
2828
3028
  disable_emails: bool = False,
2829
3029
  ):
2830
- rows = cls.build_rows(start_date, end_date)
3030
+ payload = cls.build_rows(start_date, end_date)
2831
3031
  return cls.objects.create(
2832
3032
  start_date=start_date,
2833
3033
  end_date=end_date,
2834
- data={"rows": rows, "schema": "session-list/v1"},
3034
+ data=payload,
2835
3035
  owner=owner,
2836
3036
  schedule=schedule,
2837
3037
  recipients=list(recipients or []),
@@ -2861,15 +3061,13 @@ class ClientReport(Entity):
2861
3061
  _json.dumps(self.data, indent=2, default=str), encoding="utf-8"
2862
3062
  )
2863
3063
 
2864
- def _relative(path: Path) -> str:
2865
- try:
2866
- return str(path.relative_to(base_dir))
2867
- except ValueError:
2868
- return str(path)
3064
+ pdf_path = report_dir / f"{identifier}.pdf"
3065
+ self.render_pdf(pdf_path)
2869
3066
 
2870
3067
  export = {
2871
- "html_path": _relative(html_path),
2872
- "json_path": _relative(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),
2873
3071
  }
2874
3072
 
2875
3073
  updated = dict(self.data)
@@ -2880,28 +3078,32 @@ class ClientReport(Entity):
2880
3078
 
2881
3079
  @staticmethod
2882
3080
  def build_rows(start_date=None, end_date=None, *, for_display: bool = False):
2883
- from ocpp.models import Transaction
3081
+ dataset = ClientReport._build_dataset(start_date, end_date)
3082
+ if for_display:
3083
+ return ClientReport._normalize_dataset_for_display(dataset)
3084
+ return dataset
2884
3085
 
2885
- qs = Transaction.objects.filter(
2886
- (Q(rfid__isnull=False) & ~Q(rfid=""))
2887
- | (Q(vid__isnull=False) & ~Q(vid=""))
2888
- )
2889
- if start_date:
2890
- 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
3090
+
3091
+ qs = Transaction.objects.all()
2891
3092
 
3093
+ start_dt = None
3094
+ end_dt = None
3095
+ if start_date:
2892
3096
  start_dt = datetime.combine(start_date, time.min, tzinfo=pytimezone.utc)
2893
3097
  qs = qs.filter(start_time__gte=start_dt)
2894
3098
  if end_date:
2895
- from datetime import datetime, time, timedelta, timezone as pytimezone
2896
-
2897
3099
  end_dt = datetime.combine(
2898
3100
  end_date + timedelta(days=1), time.min, tzinfo=pytimezone.utc
2899
3101
  )
2900
3102
  qs = qs.filter(start_time__lt=end_dt)
2901
3103
 
2902
- transactions = list(
2903
- qs.select_related("account").order_by("-start_time", "-pk")
2904
- )
3104
+ qs = qs.select_related("account", "charger").prefetch_related("meter_values")
3105
+ transactions = list(qs.order_by("start_time", "pk"))
3106
+
2905
3107
  rfid_values = {tx.rfid for tx in transactions if tx.rfid}
2906
3108
  tag_map: dict[str, RFID] = {}
2907
3109
  if rfid_values:
@@ -2912,52 +3114,227 @@ class ClientReport(Entity):
2912
3114
  )
2913
3115
  }
2914
3116
 
2915
- rows: list[dict[str, Any]] = []
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]] = {}
2916
3132
  for tx in transactions:
2917
- energy = tx.kw
2918
- if energy <= 0:
3133
+ charger = getattr(tx, "charger", None)
3134
+ if charger is None:
2919
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
2920
3165
 
2921
- subject = None
2922
- if tx.account and getattr(tx.account, "name", None):
2923
- subject = tx.account.name
2924
- else:
2925
- tag = tag_map.get(tx.rfid)
2926
- if tag:
2927
- account = next(iter(tag.energy_accounts.all()), None)
2928
- if account:
2929
- subject = account.name
2930
- else:
2931
- 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
2932
3171
 
2933
- if subject is None:
2934
- subject = tx.rfid or tx.vid
3172
+ start_kwh, end_kwh = ClientReport._resolve_meter_bounds(tx)
2935
3173
 
2936
- start_value = tx.start_time
2937
- end_value = tx.stop_time
2938
- if not for_display:
2939
- start_value = start_value.isoformat()
2940
- end_value = end_value.isoformat() if end_value else None
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
+ )
2941
3179
 
2942
- rows.append(
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(
2943
3215
  {
2944
- "subject": subject,
2945
- "rfid": tx.rfid,
2946
- "vid": tx.vid,
2947
- "kw": energy,
2948
- "start": start_value,
2949
- "end": end_value,
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,
2950
3224
  }
2951
3225
  )
2952
3226
 
2953
- return rows
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
+ }
2954
3235
 
2955
- @property
2956
- def rows_for_display(self):
2957
- rows = self.data.get("rows", [])
2958
- if self.data.get("schema") == "session-list/v1":
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":
2959
3336
  parsed: list[dict[str, Any]] = []
2960
- for row in rows:
3337
+ for row in dataset.get("rows", []):
2961
3338
  item = dict(row)
2962
3339
  start_val = row.get("start")
2963
3340
  end_val = row.get("end")
@@ -2979,8 +3356,216 @@ class ClientReport(Entity):
2979
3356
  item["end"] = None
2980
3357
 
2981
3358
  parsed.append(item)
2982
- return parsed
2983
- return rows
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
2984
3569
 
2985
3570
 
2986
3571
  class BrandManager(EntityManager):
@@ -3433,6 +4018,10 @@ class PackageRelease(Entity):
3433
4018
  if old_name not in expected and old_path.exists():
3434
4019
  old_path.unlink()
3435
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
+
3436
4025
  def __str__(self) -> str: # pragma: no cover - trivial
3437
4026
  return f"{self.package.name} {self.version}"
3438
4027
 
@@ -3440,10 +4029,27 @@ class PackageRelease(Entity):
3440
4029
  """Return a :class:`ReleasePackage` built from the package."""
3441
4030
  return self.package.to_package()
3442
4031
 
3443
- def to_credentials(self) -> Credentials | None:
3444
- """Return :class:`Credentials` from the associated release manager."""
3445
- manager = self.release_manager or self.package.release_manager
3446
- if manager:
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:
3447
4053
  creds = manager.to_credentials()
3448
4054
  if creds and creds.has_auth():
3449
4055
  return creds
@@ -3465,7 +4071,9 @@ class PackageRelease(Entity):
3465
4071
  return manager.github_token
3466
4072
  return os.environ.get("GITHUB_TOKEN")
3467
4073
 
3468
- def build_publish_targets(self) -> list[RepositoryTarget]:
4074
+ def build_publish_targets(
4075
+ self, user: models.Model | None = None
4076
+ ) -> list[RepositoryTarget]:
3469
4077
  """Return repository targets for publishing this release."""
3470
4078
 
3471
4079
  manager = self.release_manager or self.package.release_manager
@@ -3474,7 +4082,7 @@ class PackageRelease(Entity):
3474
4082
  env_primary = os.environ.get("PYPI_REPOSITORY_URL", "")
3475
4083
  primary_url = env_primary.strip()
3476
4084
 
3477
- primary_creds = self.to_credentials()
4085
+ primary_creds = self.to_credentials(user=user)
3478
4086
  targets.append(
3479
4087
  RepositoryTarget(
3480
4088
  name="PyPI",
@@ -3616,6 +4224,8 @@ class PackageRelease(Entity):
3616
4224
  """
3617
4225
 
3618
4226
  version = (version or "").strip()
4227
+ if version.endswith("+"):
4228
+ version = version.rstrip("+")
3619
4229
  revision = (revision or "").strip()
3620
4230
  if not version or not revision:
3621
4231
  return True
@@ -3725,7 +4335,38 @@ class Todo(Entity):
3725
4335
  generated_for_version = models.CharField(max_length=20, blank=True, default="")
3726
4336
  generated_for_revision = models.CharField(max_length=40, blank=True, default="")
3727
4337
  done_on = models.DateTimeField(null=True, blank=True)
4338
+ done_node = models.ForeignKey(
4339
+ "nodes.Node",
4340
+ null=True,
4341
+ blank=True,
4342
+ on_delete=models.SET_NULL,
4343
+ related_name="completed_todos",
4344
+ help_text="Node where this TODO was completed.",
4345
+ )
4346
+ done_version = models.CharField(max_length=20, blank=True, default="")
4347
+ done_revision = models.CharField(max_length=40, blank=True, default="")
4348
+ done_username = models.CharField(max_length=150, blank=True, default="")
3728
4349
  on_done_condition = ConditionTextField(blank=True, default="")
4350
+ origin_node = models.ForeignKey(
4351
+ "nodes.Node",
4352
+ null=True,
4353
+ blank=True,
4354
+ on_delete=models.SET_NULL,
4355
+ related_name="originated_todos",
4356
+ help_text="Node where this TODO was generated.",
4357
+ )
4358
+ original_user = models.ForeignKey(
4359
+ settings.AUTH_USER_MODEL,
4360
+ null=True,
4361
+ blank=True,
4362
+ on_delete=models.SET_NULL,
4363
+ related_name="originated_todos",
4364
+ help_text="User responsible for creating this TODO.",
4365
+ )
4366
+ original_user_is_authenticated = models.BooleanField(
4367
+ default=False,
4368
+ help_text="Whether the originating user was authenticated during creation.",
4369
+ )
3729
4370
 
3730
4371
  objects = TodoManager()
3731
4372
 
@@ -3766,6 +4407,203 @@ class Todo(Entity):
3766
4407
  return field.evaluate(self)
3767
4408
  return ConditionCheckResult(True, "")
3768
4409
 
4410
+ def save(self, *args, **kwargs):
4411
+ created = self.pk is None
4412
+ tracked_fields = {
4413
+ "done_on",
4414
+ "done_node",
4415
+ "done_node_id",
4416
+ "done_revision",
4417
+ "done_username",
4418
+ "done_version",
4419
+ "is_deleted",
4420
+ }
4421
+ update_fields = kwargs.get("update_fields")
4422
+ monitor_changes = not created and (
4423
+ update_fields is None or tracked_fields.intersection(update_fields)
4424
+ )
4425
+ previous_state = None
4426
+ if monitor_changes:
4427
+ previous_state = (
4428
+ type(self)
4429
+ .all_objects.filter(pk=self.pk)
4430
+ .values(
4431
+ "done_on",
4432
+ "done_node_id",
4433
+ "done_revision",
4434
+ "done_username",
4435
+ "done_version",
4436
+ "is_deleted",
4437
+ )
4438
+ .first()
4439
+ )
4440
+ super().save(*args, **kwargs)
4441
+
4442
+ if created:
4443
+ return
4444
+
4445
+ previous_done_on = previous_state["done_on"] if previous_state else None
4446
+ previous_is_deleted = previous_state["is_deleted"] if previous_state else False
4447
+ previous_done_node = (
4448
+ previous_state["done_node_id"] if previous_state else None
4449
+ )
4450
+ previous_done_revision = (
4451
+ previous_state["done_revision"] if previous_state else ""
4452
+ )
4453
+ previous_done_username = (
4454
+ previous_state["done_username"] if previous_state else ""
4455
+ )
4456
+ previous_done_version = (
4457
+ previous_state["done_version"] if previous_state else ""
4458
+ )
4459
+ if (
4460
+ previous_done_on == self.done_on
4461
+ and previous_is_deleted == self.is_deleted
4462
+ and previous_done_node == getattr(self, "done_node_id", None)
4463
+ and previous_done_revision == self.done_revision
4464
+ and previous_done_username == self.done_username
4465
+ and previous_done_version == self.done_version
4466
+ ):
4467
+ return
4468
+
4469
+ self._update_fixture_state()
4470
+
4471
+ def populate_done_metadata(self, user=None) -> None:
4472
+ """Populate metadata fields for a completed TODO."""
4473
+
4474
+ node = None
4475
+ try: # pragma: no cover - defensive import guard
4476
+ from nodes.models import Node # type: ignore
4477
+ except Exception: # pragma: no cover - when app not ready
4478
+ Node = None
4479
+
4480
+ if Node is not None:
4481
+ try:
4482
+ node = Node.get_local()
4483
+ except Exception: # pragma: no cover - fallback on errors
4484
+ node = None
4485
+ self.done_node = node if node else None
4486
+
4487
+ version_value = ""
4488
+ revision_value = ""
4489
+ if node is not None:
4490
+ version_value = (node.installed_version or "").strip()
4491
+ revision_value = (node.installed_revision or "").strip()
4492
+
4493
+ if not version_value:
4494
+ version_path = Path(settings.BASE_DIR) / "VERSION"
4495
+ try:
4496
+ version_value = version_path.read_text(encoding="utf-8").strip()
4497
+ except OSError:
4498
+ version_value = ""
4499
+
4500
+ if not revision_value:
4501
+ try:
4502
+ revision_value = revision_utils.get_revision() or ""
4503
+ except Exception: # pragma: no cover - defensive fallback
4504
+ revision_value = ""
4505
+
4506
+ username_value = ""
4507
+ if user is not None and getattr(user, "is_authenticated", False):
4508
+ try:
4509
+ username_value = user.get_username() or ""
4510
+ except Exception: # pragma: no cover - fallback to attribute
4511
+ username_value = getattr(user, "username", "") or ""
4512
+
4513
+ self.done_version = version_value
4514
+ self.done_revision = revision_value
4515
+ self.done_username = username_value
4516
+
4517
+ def _update_fixture_state(self) -> None:
4518
+ if not self.is_seed_data:
4519
+ return
4520
+
4521
+ request_text = (self.request or "").strip()
4522
+ if not request_text:
4523
+ return
4524
+
4525
+ slug = self._fixture_slug(request_text)
4526
+ if not slug:
4527
+ return
4528
+
4529
+ base_dir = Path(settings.BASE_DIR)
4530
+ fixture_path = base_dir / "core" / "fixtures" / f"todo__{slug}.json"
4531
+ if not fixture_path.exists():
4532
+ return
4533
+
4534
+ try:
4535
+ with fixture_path.open("r", encoding="utf-8") as handle:
4536
+ data = json.load(handle)
4537
+ except Exception:
4538
+ logger.exception("Failed to read TODO fixture %s", fixture_path)
4539
+ return
4540
+
4541
+ if not isinstance(data, list):
4542
+ return
4543
+
4544
+ updated = False
4545
+ normalized_request = request_text.lower()
4546
+ for item in data:
4547
+ if not isinstance(item, dict):
4548
+ continue
4549
+ fields = item.get("fields")
4550
+ if not isinstance(fields, dict):
4551
+ continue
4552
+ candidate = (fields.get("request") or "").strip().lower()
4553
+ if candidate != normalized_request:
4554
+ continue
4555
+ if self._apply_fixture_fields(fields):
4556
+ updated = True
4557
+
4558
+ if not updated:
4559
+ return
4560
+
4561
+ content = json.dumps(data, indent=2, ensure_ascii=False)
4562
+ if not content.endswith("\n"):
4563
+ content += "\n"
4564
+
4565
+ try:
4566
+ fixture_path.write_text(content, encoding="utf-8")
4567
+ except OSError:
4568
+ logger.exception("Failed to write TODO fixture %s", fixture_path)
4569
+
4570
+ def _apply_fixture_fields(self, fields: dict[str, object]) -> bool:
4571
+ changed = False
4572
+
4573
+ def _assign(key: str, value: object) -> None:
4574
+ nonlocal changed
4575
+ if fields.get(key) != value:
4576
+ fields[key] = value
4577
+ changed = True
4578
+
4579
+ _assign("request", self.request or "")
4580
+ _assign("url", self.url or "")
4581
+ _assign("request_details", self.request_details or "")
4582
+ _assign("done_version", self.done_version or "")
4583
+ _assign("done_revision", self.done_revision or "")
4584
+ _assign("done_username", self.done_username or "")
4585
+
4586
+ if self.done_on:
4587
+ done_value = timezone.localtime(self.done_on)
4588
+ _assign("done_on", done_value.isoformat())
4589
+ else:
4590
+ if fields.get("done_on") is not None:
4591
+ fields["done_on"] = None
4592
+ changed = True
4593
+
4594
+ if self.is_deleted:
4595
+ _assign("is_deleted", True)
4596
+ elif fields.get("is_deleted"):
4597
+ fields["is_deleted"] = False
4598
+ changed = True
4599
+
4600
+ return changed
4601
+
4602
+ @staticmethod
4603
+ def _fixture_slug(value: str) -> str:
4604
+ slug = re.sub(r"[^a-z0-9]+", "_", value.lower()).strip("_")
4605
+ return slug
4606
+
3769
4607
 
3770
4608
  class TOTPDeviceSettings(models.Model):
3771
4609
  """Per-device configuration options for authenticator enrollments."""