arthexis 0.1.9__py3-none-any.whl → 0.1.10__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.9.dist-info → arthexis-0.1.10.dist-info}/METADATA +63 -20
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/RECORD +39 -36
- config/settings.py +221 -23
- config/urls.py +6 -0
- core/admin.py +401 -35
- core/apps.py +3 -0
- core/auto_upgrade.py +57 -0
- core/backends.py +77 -3
- core/fields.py +93 -0
- core/models.py +212 -7
- core/reference_utils.py +97 -0
- core/sigil_builder.py +16 -3
- core/system.py +157 -143
- core/tasks.py +151 -8
- core/test_system_info.py +37 -1
- core/tests.py +288 -12
- core/user_data.py +103 -8
- core/views.py +257 -15
- nodes/admin.py +12 -4
- nodes/backends.py +109 -17
- nodes/models.py +205 -2
- nodes/tests.py +370 -1
- nodes/views.py +140 -7
- ocpp/admin.py +63 -3
- ocpp/consumers.py +252 -41
- ocpp/evcs.py +6 -3
- ocpp/models.py +49 -7
- ocpp/simulator.py +62 -5
- ocpp/store.py +30 -0
- ocpp/tests.py +384 -8
- ocpp/views.py +101 -76
- pages/context_processors.py +20 -0
- pages/forms.py +131 -0
- pages/tests.py +434 -13
- pages/urls.py +1 -0
- pages/views.py +334 -92
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
core/auto_upgrade.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Helpers for managing the auto-upgrade scheduler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
AUTO_UPGRADE_TASK_NAME = "auto-upgrade-check"
|
|
11
|
+
AUTO_UPGRADE_TASK_PATH = "core.tasks.check_github_updates"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ensure_auto_upgrade_periodic_task(
|
|
15
|
+
sender=None, *, base_dir: Path | None = None, **kwargs
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Ensure the auto-upgrade periodic task exists.
|
|
18
|
+
|
|
19
|
+
The function is signal-safe so it can be wired to Django's
|
|
20
|
+
``post_migrate`` hook. When called directly the ``sender`` and
|
|
21
|
+
``**kwargs`` parameters are ignored.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
del sender, kwargs # Unused when invoked as a Django signal handler.
|
|
25
|
+
|
|
26
|
+
if base_dir is None:
|
|
27
|
+
base_dir = Path(settings.BASE_DIR)
|
|
28
|
+
else:
|
|
29
|
+
base_dir = Path(base_dir)
|
|
30
|
+
|
|
31
|
+
lock_dir = base_dir / "locks"
|
|
32
|
+
mode_file = lock_dir / "auto_upgrade.lck"
|
|
33
|
+
if not mode_file.exists():
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
try: # pragma: no cover - optional dependency failures
|
|
37
|
+
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
38
|
+
from django.db.utils import OperationalError, ProgrammingError
|
|
39
|
+
except Exception:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
mode = mode_file.read_text().strip() or "version"
|
|
43
|
+
interval_minutes = 5 if mode == "latest" else 10
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
schedule, _ = IntervalSchedule.objects.get_or_create(
|
|
47
|
+
every=interval_minutes, period=IntervalSchedule.MINUTES
|
|
48
|
+
)
|
|
49
|
+
PeriodicTask.objects.update_or_create(
|
|
50
|
+
name=AUTO_UPGRADE_TASK_NAME,
|
|
51
|
+
defaults={
|
|
52
|
+
"interval": schedule,
|
|
53
|
+
"task": AUTO_UPGRADE_TASK_PATH,
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
except (OperationalError, ProgrammingError): # pragma: no cover - DB not ready
|
|
57
|
+
return
|
core/backends.py
CHANGED
|
@@ -4,12 +4,66 @@ import contextlib
|
|
|
4
4
|
import ipaddress
|
|
5
5
|
import socket
|
|
6
6
|
|
|
7
|
+
from django.conf import settings
|
|
7
8
|
from django.contrib.auth import get_user_model
|
|
8
9
|
from django.contrib.auth.backends import ModelBackend
|
|
10
|
+
from django.core.exceptions import DisallowedHost
|
|
11
|
+
from django.http.request import split_domain_port
|
|
12
|
+
from django_otp.plugins.otp_totp.models import TOTPDevice
|
|
9
13
|
|
|
10
14
|
from .models import EnergyAccount
|
|
11
15
|
|
|
12
16
|
|
|
17
|
+
TOTP_DEVICE_NAME = "authenticator"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TOTPBackend(ModelBackend):
|
|
21
|
+
"""Authenticate using a TOTP code from an enrolled authenticator app."""
|
|
22
|
+
|
|
23
|
+
def authenticate(self, request, username=None, otp_token=None, **kwargs):
|
|
24
|
+
if not username or otp_token in (None, ""):
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
token = str(otp_token).strip().replace(" ", "")
|
|
28
|
+
if not token:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
UserModel = get_user_model()
|
|
32
|
+
try:
|
|
33
|
+
user = UserModel._default_manager.get_by_natural_key(username)
|
|
34
|
+
except UserModel.DoesNotExist:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
if not user.is_active:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
device_qs = TOTPDevice.objects.filter(user=user, confirmed=True)
|
|
41
|
+
if TOTP_DEVICE_NAME:
|
|
42
|
+
device_qs = device_qs.filter(name=TOTP_DEVICE_NAME)
|
|
43
|
+
|
|
44
|
+
device = device_qs.order_by("-id").first()
|
|
45
|
+
if device is None:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
verified = device.verify_token(token)
|
|
50
|
+
except Exception:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
if not verified:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
user.otp_device = device
|
|
57
|
+
return user
|
|
58
|
+
|
|
59
|
+
def get_user(self, user_id):
|
|
60
|
+
UserModel = get_user_model()
|
|
61
|
+
try:
|
|
62
|
+
return UserModel._default_manager.get(pk=user_id)
|
|
63
|
+
except UserModel.DoesNotExist:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
13
67
|
class RFIDBackend:
|
|
14
68
|
"""Authenticate using a user's RFID."""
|
|
15
69
|
|
|
@@ -69,15 +123,33 @@ def _collect_local_ip_addresses():
|
|
|
69
123
|
class LocalhostAdminBackend(ModelBackend):
|
|
70
124
|
"""Allow default admin credentials only from local networks."""
|
|
71
125
|
|
|
72
|
-
_ALLOWED_NETWORKS =
|
|
126
|
+
_ALLOWED_NETWORKS = (
|
|
73
127
|
ipaddress.ip_network("::1/128"),
|
|
74
128
|
ipaddress.ip_network("127.0.0.0/8"),
|
|
129
|
+
ipaddress.ip_network("10.42.0.0/16"),
|
|
75
130
|
ipaddress.ip_network("192.168.0.0/16"),
|
|
76
|
-
|
|
131
|
+
)
|
|
132
|
+
_CONTROL_ALLOWED_NETWORKS = (ipaddress.ip_network("10.0.0.0/8"),)
|
|
77
133
|
_LOCAL_IPS = _collect_local_ip_addresses()
|
|
78
134
|
|
|
135
|
+
def _iter_allowed_networks(self):
|
|
136
|
+
yield from self._ALLOWED_NETWORKS
|
|
137
|
+
if getattr(settings, "NODE_ROLE", "") == "Control":
|
|
138
|
+
yield from self._CONTROL_ALLOWED_NETWORKS
|
|
139
|
+
|
|
79
140
|
def authenticate(self, request, username=None, password=None, **kwargs):
|
|
80
141
|
if username == "admin" and password == "admin" and request is not None:
|
|
142
|
+
try:
|
|
143
|
+
host = request.get_host()
|
|
144
|
+
except DisallowedHost:
|
|
145
|
+
return None
|
|
146
|
+
host, _port = split_domain_port(host)
|
|
147
|
+
if host.startswith("[") and host.endswith("]"):
|
|
148
|
+
host = host[1:-1]
|
|
149
|
+
try:
|
|
150
|
+
ipaddress.ip_address(host)
|
|
151
|
+
except ValueError:
|
|
152
|
+
return None
|
|
81
153
|
forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
|
|
82
154
|
if forwarded:
|
|
83
155
|
remote = forwarded.split(",")[0].strip()
|
|
@@ -87,7 +159,7 @@ class LocalhostAdminBackend(ModelBackend):
|
|
|
87
159
|
ip = ipaddress.ip_address(remote)
|
|
88
160
|
except ValueError:
|
|
89
161
|
return None
|
|
90
|
-
allowed = any(ip in net for net in self.
|
|
162
|
+
allowed = any(ip in net for net in self._iter_allowed_networks())
|
|
91
163
|
if not allowed and ip in self._LOCAL_IPS:
|
|
92
164
|
allowed = True
|
|
93
165
|
if not allowed:
|
|
@@ -100,6 +172,8 @@ class LocalhostAdminBackend(ModelBackend):
|
|
|
100
172
|
"is_superuser": True,
|
|
101
173
|
},
|
|
102
174
|
)
|
|
175
|
+
if not created and not user.is_active:
|
|
176
|
+
return None
|
|
103
177
|
arthexis_user = (
|
|
104
178
|
User.all_objects.filter(username="arthexis").exclude(pk=user.pk).first()
|
|
105
179
|
)
|
core/fields.py
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import re
|
|
3
|
+
import sqlite3
|
|
4
|
+
|
|
1
5
|
from django.db import models
|
|
2
6
|
from django.db.models.fields import DeferredAttribute
|
|
7
|
+
from django.utils.translation import gettext_lazy as _
|
|
3
8
|
|
|
4
9
|
|
|
5
10
|
class _BaseSigilDescriptor(DeferredAttribute):
|
|
@@ -73,3 +78,91 @@ class SigilShortAutoField(SigilAutoFieldMixin, models.CharField):
|
|
|
73
78
|
|
|
74
79
|
class SigilLongAutoField(SigilAutoFieldMixin, models.TextField):
|
|
75
80
|
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ConditionEvaluationError(Exception):
|
|
84
|
+
"""Raised when a condition expression cannot be evaluated."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class ConditionCheckResult:
|
|
89
|
+
"""Represents the outcome of evaluating a condition field."""
|
|
90
|
+
|
|
91
|
+
passed: bool
|
|
92
|
+
resolved: str
|
|
93
|
+
error: str | None = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_COMMENT_PATTERN = re.compile(r"(--|/\*)")
|
|
97
|
+
_FORBIDDEN_KEYWORDS = re.compile(
|
|
98
|
+
r"\b(ATTACH|DETACH|ALTER|ANALYZE|CREATE|DROP|INSERT|UPDATE|DELETE|REPLACE|"
|
|
99
|
+
r"VACUUM|TRIGGER|TABLE|INDEX|VIEW|PRAGMA|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|WITH)\b",
|
|
100
|
+
re.IGNORECASE,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _evaluate_sql_condition(expression: str) -> bool:
|
|
105
|
+
"""Evaluate a SQL expression in an isolated SQLite connection."""
|
|
106
|
+
|
|
107
|
+
if ";" in expression:
|
|
108
|
+
raise ConditionEvaluationError(
|
|
109
|
+
_("Semicolons are not allowed in conditions."),
|
|
110
|
+
)
|
|
111
|
+
if _COMMENT_PATTERN.search(expression):
|
|
112
|
+
raise ConditionEvaluationError(
|
|
113
|
+
_("SQL comments are not allowed in conditions."),
|
|
114
|
+
)
|
|
115
|
+
match = _FORBIDDEN_KEYWORDS.search(expression)
|
|
116
|
+
if match:
|
|
117
|
+
raise ConditionEvaluationError(
|
|
118
|
+
_("Disallowed keyword in condition: %(keyword)s")
|
|
119
|
+
% {"keyword": match.group(1)},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
conn = sqlite3.connect(":memory:")
|
|
124
|
+
try:
|
|
125
|
+
conn.execute("PRAGMA trusted_schema = OFF")
|
|
126
|
+
conn.execute("PRAGMA foreign_keys = OFF")
|
|
127
|
+
try:
|
|
128
|
+
conn.enable_load_extension(False)
|
|
129
|
+
except AttributeError:
|
|
130
|
+
# ``enable_load_extension`` is not available on some platforms.
|
|
131
|
+
pass
|
|
132
|
+
cursor = conn.execute(
|
|
133
|
+
f"SELECT CASE WHEN ({expression}) THEN 1 ELSE 0 END"
|
|
134
|
+
)
|
|
135
|
+
row = cursor.fetchone()
|
|
136
|
+
return bool(row[0]) if row else False
|
|
137
|
+
finally:
|
|
138
|
+
conn.close()
|
|
139
|
+
except sqlite3.Error as exc: # pragma: no cover - exact error message varies
|
|
140
|
+
raise ConditionEvaluationError(str(exc)) from exc
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ConditionTextField(models.TextField):
|
|
144
|
+
"""Field storing a conditional SQL expression resolved through [sigils]."""
|
|
145
|
+
|
|
146
|
+
def evaluate(self, instance) -> ConditionCheckResult:
|
|
147
|
+
"""Evaluate the stored expression for ``instance``."""
|
|
148
|
+
|
|
149
|
+
value = self.value_from_object(instance)
|
|
150
|
+
if hasattr(instance, "resolve_sigils"):
|
|
151
|
+
resolved = instance.resolve_sigils(self.name)
|
|
152
|
+
else:
|
|
153
|
+
resolved = value
|
|
154
|
+
|
|
155
|
+
if resolved is None:
|
|
156
|
+
resolved_text = ""
|
|
157
|
+
else:
|
|
158
|
+
resolved_text = str(resolved)
|
|
159
|
+
|
|
160
|
+
resolved_text = resolved_text.strip()
|
|
161
|
+
if not resolved_text:
|
|
162
|
+
return ConditionCheckResult(True, resolved_text)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
passed = _evaluate_sql_condition(resolved_text)
|
|
166
|
+
return ConditionCheckResult(passed, resolved_text)
|
|
167
|
+
except ConditionEvaluationError as exc:
|
|
168
|
+
return ConditionCheckResult(False, resolved_text, str(exc))
|
core/models.py
CHANGED
|
@@ -9,12 +9,12 @@ from django.db.models.functions import Lower
|
|
|
9
9
|
from django.conf import settings
|
|
10
10
|
from django.contrib.auth import get_user_model
|
|
11
11
|
from django.utils.translation import gettext_lazy as _
|
|
12
|
-
from django.core.validators import RegexValidator
|
|
12
|
+
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
|
|
13
13
|
from django.core.exceptions import ValidationError
|
|
14
14
|
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
|
-
from datetime import timedelta
|
|
17
|
+
from datetime import time as datetime_time, timedelta
|
|
18
18
|
from django.contrib.contenttypes.models import ContentType
|
|
19
19
|
import hashlib
|
|
20
20
|
import os
|
|
@@ -39,7 +39,11 @@ xmlrpc_client = defused_xmlrpc.xmlrpc_client
|
|
|
39
39
|
from .entity import Entity, EntityUserManager, EntityManager
|
|
40
40
|
from .release import Package as ReleasePackage, Credentials, DEFAULT_PACKAGE
|
|
41
41
|
from . import user_data # noqa: F401 - ensure signal registration
|
|
42
|
-
from .fields import
|
|
42
|
+
from .fields import (
|
|
43
|
+
SigilShortAutoField,
|
|
44
|
+
ConditionTextField,
|
|
45
|
+
ConditionCheckResult,
|
|
46
|
+
)
|
|
43
47
|
|
|
44
48
|
|
|
45
49
|
class SecurityGroup(Group):
|
|
@@ -225,7 +229,7 @@ class InviteLead(Lead):
|
|
|
225
229
|
|
|
226
230
|
|
|
227
231
|
class PublicWifiAccess(Entity):
|
|
228
|
-
"""
|
|
232
|
+
"""Represent a Wi-Fi lease granted to a client for internet access."""
|
|
229
233
|
|
|
230
234
|
user = models.ForeignKey(
|
|
231
235
|
settings.AUTH_USER_MODEL,
|
|
@@ -239,8 +243,8 @@ class PublicWifiAccess(Entity):
|
|
|
239
243
|
|
|
240
244
|
class Meta:
|
|
241
245
|
unique_together = ("user", "mac_address")
|
|
242
|
-
verbose_name = "
|
|
243
|
-
verbose_name_plural = "
|
|
246
|
+
verbose_name = "Wi-Fi Lease"
|
|
247
|
+
verbose_name_plural = "Wi-Fi Leases"
|
|
244
248
|
|
|
245
249
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
246
250
|
return f"{self.user} -> {self.mac_address}"
|
|
@@ -265,7 +269,7 @@ def _cleanup_public_wifi_on_delete(sender, instance, **kwargs):
|
|
|
265
269
|
class User(Entity, AbstractUser):
|
|
266
270
|
SYSTEM_USERNAME = "arthexis"
|
|
267
271
|
ADMIN_USERNAME = "admin"
|
|
268
|
-
PROFILE_RESTRICTED_USERNAMES = frozenset(
|
|
272
|
+
PROFILE_RESTRICTED_USERNAMES = frozenset()
|
|
269
273
|
|
|
270
274
|
objects = EntityUserManager()
|
|
271
275
|
all_objects = DjangoUserManager()
|
|
@@ -844,6 +848,9 @@ class Reference(Entity):
|
|
|
844
848
|
include_in_footer = models.BooleanField(
|
|
845
849
|
default=False, verbose_name="Include in Footer"
|
|
846
850
|
)
|
|
851
|
+
show_in_header = models.BooleanField(
|
|
852
|
+
default=False, verbose_name="Show in Header"
|
|
853
|
+
)
|
|
847
854
|
FOOTER_PUBLIC = "public"
|
|
848
855
|
FOOTER_PRIVATE = "private"
|
|
849
856
|
FOOTER_STAFF = "staff"
|
|
@@ -1046,6 +1053,195 @@ class RFID(Entity):
|
|
|
1046
1053
|
db_table = "core_rfid"
|
|
1047
1054
|
|
|
1048
1055
|
|
|
1056
|
+
class EnergyTariffManager(EntityManager):
|
|
1057
|
+
def get_by_natural_key(
|
|
1058
|
+
self,
|
|
1059
|
+
year: int,
|
|
1060
|
+
season: str,
|
|
1061
|
+
zone: str,
|
|
1062
|
+
contract_type: str,
|
|
1063
|
+
period: str,
|
|
1064
|
+
unit: str,
|
|
1065
|
+
start_time,
|
|
1066
|
+
end_time,
|
|
1067
|
+
):
|
|
1068
|
+
if isinstance(start_time, str):
|
|
1069
|
+
start_time = datetime_time.fromisoformat(start_time)
|
|
1070
|
+
if isinstance(end_time, str):
|
|
1071
|
+
end_time = datetime_time.fromisoformat(end_time)
|
|
1072
|
+
return self.get(
|
|
1073
|
+
year=year,
|
|
1074
|
+
season=season,
|
|
1075
|
+
zone=zone,
|
|
1076
|
+
contract_type=contract_type,
|
|
1077
|
+
period=period,
|
|
1078
|
+
unit=unit,
|
|
1079
|
+
start_time=start_time,
|
|
1080
|
+
end_time=end_time,
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
class EnergyTariff(Entity):
|
|
1085
|
+
class Zone(models.TextChoices):
|
|
1086
|
+
ONE = "1", _("Zone 1")
|
|
1087
|
+
ONE_A = "1A", _("Zone 1A")
|
|
1088
|
+
ONE_B = "1B", _("Zone 1B")
|
|
1089
|
+
ONE_C = "1C", _("Zone 1C")
|
|
1090
|
+
ONE_D = "1D", _("Zone 1D")
|
|
1091
|
+
ONE_E = "1E", _("Zone 1E")
|
|
1092
|
+
ONE_F = "1F", _("Zone 1F")
|
|
1093
|
+
|
|
1094
|
+
class Season(models.TextChoices):
|
|
1095
|
+
ANNUAL = "annual", _("All year")
|
|
1096
|
+
SUMMER = "summer", _("Summer season")
|
|
1097
|
+
NON_SUMMER = "non_summer", _("Non-summer season")
|
|
1098
|
+
|
|
1099
|
+
class Period(models.TextChoices):
|
|
1100
|
+
FLAT = "flat", _("Flat rate")
|
|
1101
|
+
BASIC = "basic", _("Basic block")
|
|
1102
|
+
INTERMEDIATE_1 = "intermediate_1", _("Intermediate block 1")
|
|
1103
|
+
INTERMEDIATE_2 = "intermediate_2", _("Intermediate block 2")
|
|
1104
|
+
EXCESS = "excess", _("Excess consumption")
|
|
1105
|
+
BASE = "base", _("Base")
|
|
1106
|
+
INTERMEDIATE = "intermediate", _("Intermediate")
|
|
1107
|
+
PEAK = "peak", _("Peak")
|
|
1108
|
+
CRITICAL_PEAK = "critical_peak", _("Critical peak")
|
|
1109
|
+
DEMAND = "demand", _("Demand charge")
|
|
1110
|
+
CAPACITY = "capacity", _("Capacity charge")
|
|
1111
|
+
DISTRIBUTION = "distribution", _("Distribution charge")
|
|
1112
|
+
FIXED = "fixed", _("Fixed charge")
|
|
1113
|
+
|
|
1114
|
+
class ContractType(models.TextChoices):
|
|
1115
|
+
DOMESTIC = "domestic", _("Domestic service (Tarifa 1)")
|
|
1116
|
+
DAC = "dac", _("High consumption domestic (DAC)")
|
|
1117
|
+
PDBT = "pdbt", _("General service low demand (PDBT)")
|
|
1118
|
+
GDBT = "gdbt", _("General service high demand (GDBT)")
|
|
1119
|
+
GDMTO = "gdmto", _("General distribution medium tension (GDMTO)")
|
|
1120
|
+
GDMTH = "gdmth", _("General distribution medium tension hourly (GDMTH)")
|
|
1121
|
+
|
|
1122
|
+
class Unit(models.TextChoices):
|
|
1123
|
+
KWH = "kwh", _("Kilowatt-hour")
|
|
1124
|
+
KW = "kw", _("Kilowatt")
|
|
1125
|
+
MONTH = "month", _("Monthly charge")
|
|
1126
|
+
|
|
1127
|
+
year = models.PositiveIntegerField(
|
|
1128
|
+
validators=[MinValueValidator(2000)],
|
|
1129
|
+
help_text=_("Calendar year when the tariff applies."),
|
|
1130
|
+
)
|
|
1131
|
+
season = models.CharField(
|
|
1132
|
+
max_length=16,
|
|
1133
|
+
choices=Season.choices,
|
|
1134
|
+
default=Season.ANNUAL,
|
|
1135
|
+
help_text=_("Season or applicability window defined by CFE."),
|
|
1136
|
+
)
|
|
1137
|
+
zone = models.CharField(
|
|
1138
|
+
max_length=3,
|
|
1139
|
+
choices=Zone.choices,
|
|
1140
|
+
help_text=_("CFE climate zone associated with the tariff."),
|
|
1141
|
+
)
|
|
1142
|
+
contract_type = models.CharField(
|
|
1143
|
+
max_length=16,
|
|
1144
|
+
choices=ContractType.choices,
|
|
1145
|
+
help_text=_("Type of service contract regulated by CFE."),
|
|
1146
|
+
)
|
|
1147
|
+
period = models.CharField(
|
|
1148
|
+
max_length=32,
|
|
1149
|
+
choices=Period.choices,
|
|
1150
|
+
help_text=_("Tariff block, demand component, or time-of-use period."),
|
|
1151
|
+
)
|
|
1152
|
+
unit = models.CharField(
|
|
1153
|
+
max_length=16,
|
|
1154
|
+
choices=Unit.choices,
|
|
1155
|
+
default=Unit.KWH,
|
|
1156
|
+
help_text=_("Measurement unit for the tariff charge."),
|
|
1157
|
+
)
|
|
1158
|
+
start_time = models.TimeField(
|
|
1159
|
+
help_text=_("Start time for the tariff's applicability window."),
|
|
1160
|
+
)
|
|
1161
|
+
end_time = models.TimeField(
|
|
1162
|
+
help_text=_("End time for the tariff's applicability window."),
|
|
1163
|
+
)
|
|
1164
|
+
price_mxn = models.DecimalField(
|
|
1165
|
+
max_digits=10,
|
|
1166
|
+
decimal_places=4,
|
|
1167
|
+
help_text=_("Customer price per unit in MXN."),
|
|
1168
|
+
)
|
|
1169
|
+
cost_mxn = models.DecimalField(
|
|
1170
|
+
max_digits=10,
|
|
1171
|
+
decimal_places=4,
|
|
1172
|
+
help_text=_("Provider cost per unit in MXN."),
|
|
1173
|
+
)
|
|
1174
|
+
notes = models.TextField(
|
|
1175
|
+
blank=True,
|
|
1176
|
+
default="",
|
|
1177
|
+
help_text=_("Context or special billing conditions published by CFE."),
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1180
|
+
objects = EnergyTariffManager()
|
|
1181
|
+
|
|
1182
|
+
class Meta:
|
|
1183
|
+
verbose_name = _("Energy Tariff")
|
|
1184
|
+
verbose_name_plural = _("Energy Tariffs")
|
|
1185
|
+
ordering = (
|
|
1186
|
+
"-year",
|
|
1187
|
+
"season",
|
|
1188
|
+
"zone",
|
|
1189
|
+
"contract_type",
|
|
1190
|
+
"period",
|
|
1191
|
+
"start_time",
|
|
1192
|
+
)
|
|
1193
|
+
constraints = [
|
|
1194
|
+
models.UniqueConstraint(
|
|
1195
|
+
fields=[
|
|
1196
|
+
"year",
|
|
1197
|
+
"season",
|
|
1198
|
+
"zone",
|
|
1199
|
+
"contract_type",
|
|
1200
|
+
"period",
|
|
1201
|
+
"unit",
|
|
1202
|
+
"start_time",
|
|
1203
|
+
"end_time",
|
|
1204
|
+
],
|
|
1205
|
+
name="uniq_energy_tariff_schedule",
|
|
1206
|
+
)
|
|
1207
|
+
]
|
|
1208
|
+
indexes = [
|
|
1209
|
+
models.Index(
|
|
1210
|
+
fields=["year", "season", "zone", "contract_type"],
|
|
1211
|
+
name="energy_tariff_scope_idx",
|
|
1212
|
+
)
|
|
1213
|
+
]
|
|
1214
|
+
|
|
1215
|
+
def clean(self):
|
|
1216
|
+
super().clean()
|
|
1217
|
+
if self.start_time >= self.end_time:
|
|
1218
|
+
raise ValidationError(
|
|
1219
|
+
{"end_time": _("End time must be after the start time.")}
|
|
1220
|
+
)
|
|
1221
|
+
|
|
1222
|
+
def __str__(self): # pragma: no cover - simple representation
|
|
1223
|
+
return _("%(contract)s %(zone)s %(season)s %(year)s (%(period)s)") % {
|
|
1224
|
+
"contract": self.get_contract_type_display(),
|
|
1225
|
+
"zone": self.zone,
|
|
1226
|
+
"season": self.get_season_display(),
|
|
1227
|
+
"year": self.year,
|
|
1228
|
+
"period": self.get_period_display(),
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
1232
|
+
return (
|
|
1233
|
+
self.year,
|
|
1234
|
+
self.season,
|
|
1235
|
+
self.zone,
|
|
1236
|
+
self.contract_type,
|
|
1237
|
+
self.period,
|
|
1238
|
+
self.unit,
|
|
1239
|
+
self.start_time.isoformat(),
|
|
1240
|
+
self.end_time.isoformat(),
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
natural_key.dependencies = [] # type: ignore[attr-defined]
|
|
1244
|
+
|
|
1049
1245
|
class EnergyAccount(Entity):
|
|
1050
1246
|
"""Track kW energy credits for a user."""
|
|
1051
1247
|
|
|
@@ -2162,6 +2358,7 @@ class Todo(Entity):
|
|
|
2162
2358
|
)
|
|
2163
2359
|
request_details = models.TextField(blank=True, default="")
|
|
2164
2360
|
done_on = models.DateTimeField(null=True, blank=True)
|
|
2361
|
+
on_done_condition = ConditionTextField(blank=True, default="")
|
|
2165
2362
|
|
|
2166
2363
|
objects = TodoManager()
|
|
2167
2364
|
|
|
@@ -2193,3 +2390,11 @@ class Todo(Entity):
|
|
|
2193
2390
|
return (self.request,)
|
|
2194
2391
|
|
|
2195
2392
|
natural_key.dependencies = []
|
|
2393
|
+
|
|
2394
|
+
def check_on_done_condition(self) -> ConditionCheckResult:
|
|
2395
|
+
"""Evaluate the ``on_done_condition`` field for this TODO."""
|
|
2396
|
+
|
|
2397
|
+
field = self._meta.get_field("on_done_condition")
|
|
2398
|
+
if isinstance(field, ConditionTextField):
|
|
2399
|
+
return field.evaluate(self)
|
|
2400
|
+
return ConditionCheckResult(True, "")
|
core/reference_utils.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Utility helpers for working with Reference objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Iterable, TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from django.contrib.sites.models import Site
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING: # pragma: no cover - imported only for type checking
|
|
10
|
+
from django.http import HttpRequest
|
|
11
|
+
from nodes.models import Node
|
|
12
|
+
from .models import Reference
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def filter_visible_references(
|
|
16
|
+
refs: Iterable["Reference"],
|
|
17
|
+
*,
|
|
18
|
+
request: "HttpRequest | None" = None,
|
|
19
|
+
site: Site | None = None,
|
|
20
|
+
node: "Node | None" = None,
|
|
21
|
+
respect_footer_visibility: bool = True,
|
|
22
|
+
) -> list["Reference"]:
|
|
23
|
+
"""Return references visible for the current context."""
|
|
24
|
+
|
|
25
|
+
if site is None and request is not None:
|
|
26
|
+
try:
|
|
27
|
+
host = request.get_host().split(":")[0]
|
|
28
|
+
except Exception:
|
|
29
|
+
host = ""
|
|
30
|
+
if host:
|
|
31
|
+
site = Site.objects.filter(domain__iexact=host).first()
|
|
32
|
+
|
|
33
|
+
site_id = site.pk if site else None
|
|
34
|
+
|
|
35
|
+
if node is None:
|
|
36
|
+
try:
|
|
37
|
+
from nodes.models import Node # imported lazily to avoid circular import
|
|
38
|
+
|
|
39
|
+
node = Node.get_local()
|
|
40
|
+
except Exception:
|
|
41
|
+
node = None
|
|
42
|
+
|
|
43
|
+
node_role_id = getattr(node, "role_id", None)
|
|
44
|
+
node_feature_ids: set[int] = set()
|
|
45
|
+
if node is not None:
|
|
46
|
+
features_manager = getattr(node, "features", None)
|
|
47
|
+
if features_manager is not None:
|
|
48
|
+
try:
|
|
49
|
+
node_feature_ids = set(
|
|
50
|
+
features_manager.values_list("pk", flat=True)
|
|
51
|
+
)
|
|
52
|
+
except Exception:
|
|
53
|
+
node_feature_ids = set()
|
|
54
|
+
|
|
55
|
+
visible_refs: list["Reference"] = []
|
|
56
|
+
for ref in refs:
|
|
57
|
+
required_roles = {role.pk for role in ref.roles.all()}
|
|
58
|
+
required_features = {feature.pk for feature in ref.features.all()}
|
|
59
|
+
required_sites = {current_site.pk for current_site in ref.sites.all()}
|
|
60
|
+
|
|
61
|
+
if required_roles or required_features or required_sites:
|
|
62
|
+
allowed = False
|
|
63
|
+
if required_roles and node_role_id and node_role_id in required_roles:
|
|
64
|
+
allowed = True
|
|
65
|
+
elif (
|
|
66
|
+
required_features
|
|
67
|
+
and node_feature_ids
|
|
68
|
+
and node_feature_ids.intersection(required_features)
|
|
69
|
+
):
|
|
70
|
+
allowed = True
|
|
71
|
+
elif required_sites and site_id and site_id in required_sites:
|
|
72
|
+
allowed = True
|
|
73
|
+
|
|
74
|
+
if not allowed:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
if respect_footer_visibility:
|
|
78
|
+
if ref.footer_visibility == ref.FOOTER_PUBLIC:
|
|
79
|
+
visible_refs.append(ref)
|
|
80
|
+
elif (
|
|
81
|
+
ref.footer_visibility == ref.FOOTER_PRIVATE
|
|
82
|
+
and request
|
|
83
|
+
and request.user.is_authenticated
|
|
84
|
+
):
|
|
85
|
+
visible_refs.append(ref)
|
|
86
|
+
elif (
|
|
87
|
+
ref.footer_visibility == ref.FOOTER_STAFF
|
|
88
|
+
and request
|
|
89
|
+
and request.user.is_authenticated
|
|
90
|
+
and request.user.is_staff
|
|
91
|
+
):
|
|
92
|
+
visible_refs.append(ref)
|
|
93
|
+
else:
|
|
94
|
+
visible_refs.append(ref)
|
|
95
|
+
|
|
96
|
+
return visible_refs
|
|
97
|
+
|
core/sigil_builder.py
CHANGED
|
@@ -88,16 +88,27 @@ def _sigil_builder_view(request):
|
|
|
88
88
|
|
|
89
89
|
sigils_text = ""
|
|
90
90
|
resolved_text = ""
|
|
91
|
+
show_sigils_input = True
|
|
92
|
+
show_result = False
|
|
91
93
|
if request.method == "POST":
|
|
92
94
|
sigils_text = request.POST.get("sigils_text", "")
|
|
95
|
+
source_text = sigils_text
|
|
93
96
|
upload = request.FILES.get("sigils_file")
|
|
94
97
|
if upload:
|
|
95
|
-
|
|
98
|
+
source_text = upload.read().decode("utf-8", errors="ignore")
|
|
99
|
+
show_sigils_input = False
|
|
96
100
|
else:
|
|
97
101
|
single = request.POST.get("sigil", "")
|
|
98
102
|
if single:
|
|
99
|
-
|
|
100
|
-
|
|
103
|
+
source_text = (
|
|
104
|
+
f"[{single}]" if not single.startswith("[") else single
|
|
105
|
+
)
|
|
106
|
+
sigils_text = source_text
|
|
107
|
+
if source_text:
|
|
108
|
+
resolved_text = resolve_sigils_in_text(source_text)
|
|
109
|
+
show_result = True
|
|
110
|
+
if upload:
|
|
111
|
+
sigils_text = ""
|
|
101
112
|
|
|
102
113
|
context = admin.site.each_context(request)
|
|
103
114
|
context.update(
|
|
@@ -108,6 +119,8 @@ def _sigil_builder_view(request):
|
|
|
108
119
|
"auto_fields": auto_fields,
|
|
109
120
|
"sigils_text": sigils_text,
|
|
110
121
|
"resolved_text": resolved_text,
|
|
122
|
+
"show_sigils_input": show_sigils_input,
|
|
123
|
+
"show_result": show_result,
|
|
111
124
|
}
|
|
112
125
|
)
|
|
113
126
|
return TemplateResponse(request, "admin/sigil_builder.html", context)
|