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.
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/METADATA +9 -8
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/RECORD +33 -33
- config/settings.py +4 -0
- config/urls.py +5 -0
- core/admin.py +224 -32
- core/environment.py +2 -239
- core/models.py +903 -65
- core/release.py +0 -5
- core/system.py +76 -0
- core/tests.py +181 -9
- core/user_data.py +42 -2
- core/views.py +68 -27
- nodes/admin.py +211 -60
- nodes/apps.py +11 -0
- nodes/models.py +35 -7
- nodes/tests.py +288 -1
- nodes/views.py +101 -48
- ocpp/admin.py +32 -2
- ocpp/consumers.py +1 -0
- ocpp/models.py +52 -3
- ocpp/tasks.py +99 -1
- ocpp/tests.py +350 -2
- ocpp/views.py +300 -6
- pages/admin.py +112 -15
- pages/apps.py +32 -0
- pages/forms.py +31 -8
- pages/models.py +42 -2
- pages/tests.py +386 -28
- pages/urls.py +10 -0
- pages/views.py +347 -18
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/WHEEL +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/top_level.txt +0 -0
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
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
2865
|
-
|
|
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":
|
|
2872
|
-
"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
|
-
|
|
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
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
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
|
-
|
|
2903
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2918
|
-
if
|
|
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
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
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
|
-
|
|
2934
|
-
subject = tx.rfid or tx.vid
|
|
3172
|
+
start_kwh, end_kwh = ClientReport._resolve_meter_bounds(tx)
|
|
2935
3173
|
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
2945
|
-
"
|
|
2946
|
-
"
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
"
|
|
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
|
|
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
|
-
@
|
|
2956
|
-
def
|
|
2957
|
-
|
|
2958
|
-
|
|
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
|
-
|
|
2983
|
-
|
|
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(
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
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(
|
|
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."""
|