arthexis 0.1.8__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.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
- arthexis-0.1.10.dist-info/RECORD +95 -0
- arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
- config/__init__.py +0 -1
- config/auth_app.py +0 -1
- config/celery.py +1 -2
- config/context_processors.py +1 -1
- config/offline.py +2 -0
- config/settings.py +352 -37
- config/urls.py +71 -6
- core/admin.py +1601 -200
- core/admin_history.py +50 -0
- core/admindocs.py +108 -1
- core/apps.py +161 -3
- core/auto_upgrade.py +57 -0
- core/backends.py +123 -7
- core/entity.py +62 -48
- core/fields.py +98 -0
- core/github_helper.py +25 -0
- core/github_issues.py +172 -0
- core/lcd_screen.py +1 -0
- core/liveupdate.py +25 -0
- core/log_paths.py +100 -0
- core/mailer.py +83 -0
- core/middleware.py +57 -0
- core/models.py +1279 -267
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/reference_utils.py +97 -0
- core/release.py +27 -20
- core/sigil_builder.py +144 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +162 -29
- core/tasks.py +269 -27
- core/test_system_info.py +59 -1
- core/tests.py +644 -73
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +425 -168
- core/views.py +627 -59
- core/widgets.py +51 -0
- core/workgroup_urls.py +7 -3
- core/workgroup_views.py +43 -6
- nodes/actions.py +0 -2
- nodes/admin.py +168 -285
- nodes/apps.py +9 -15
- nodes/backends.py +145 -0
- nodes/lcd.py +24 -10
- nodes/models.py +579 -179
- nodes/tasks.py +1 -5
- nodes/tests.py +894 -130
- nodes/utils.py +13 -2
- nodes/views.py +204 -28
- ocpp/admin.py +212 -63
- ocpp/apps.py +1 -1
- ocpp/consumers.py +642 -68
- ocpp/evcs.py +30 -10
- ocpp/models.py +452 -70
- ocpp/simulator.py +75 -11
- ocpp/store.py +288 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1576 -137
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +701 -123
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +39 -6
- pages/forms.py +131 -0
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +1182 -42
- pages/urls.py +4 -0
- pages/utils.py +0 -1
- pages/views.py +844 -51
- arthexis-0.1.8.dist-info/RECORD +0 -80
- arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
- config/workgroup_app.py +0 -7
- core/checks.py +0 -29
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
core/models.py
CHANGED
|
@@ -4,34 +4,46 @@ from django.contrib.auth.models import (
|
|
|
4
4
|
UserManager as DjangoUserManager,
|
|
5
5
|
)
|
|
6
6
|
from django.db import models
|
|
7
|
+
from django.db.models import Q
|
|
8
|
+
from django.db.models.functions import Lower
|
|
7
9
|
from django.conf import settings
|
|
8
10
|
from django.contrib.auth import get_user_model
|
|
9
11
|
from django.utils.translation import gettext_lazy as _
|
|
10
|
-
from django.core.validators import RegexValidator
|
|
12
|
+
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
|
|
11
13
|
from django.core.exceptions import ValidationError
|
|
12
14
|
from django.apps import apps
|
|
13
|
-
from django.db.models.signals import m2m_changed
|
|
15
|
+
from django.db.models.signals import m2m_changed, post_delete, post_save
|
|
14
16
|
from django.dispatch import receiver
|
|
15
|
-
from datetime import timedelta
|
|
17
|
+
from datetime import time as datetime_time, timedelta
|
|
16
18
|
from django.contrib.contenttypes.models import ContentType
|
|
17
19
|
import hashlib
|
|
18
20
|
import os
|
|
19
21
|
import subprocess
|
|
20
22
|
import secrets
|
|
23
|
+
import re
|
|
21
24
|
from io import BytesIO
|
|
22
25
|
from django.core.files.base import ContentFile
|
|
23
26
|
import qrcode
|
|
24
|
-
import xmlrpc.client
|
|
25
27
|
from django.utils import timezone
|
|
26
28
|
import uuid
|
|
27
29
|
from pathlib import Path
|
|
28
30
|
from django.core import serializers
|
|
31
|
+
from urllib.parse import urlparse
|
|
29
32
|
from utils import revision as revision_utils
|
|
33
|
+
from typing import Type
|
|
34
|
+
from defusedxml import xmlrpc as defused_xmlrpc
|
|
30
35
|
|
|
31
|
-
|
|
36
|
+
defused_xmlrpc.monkey_patch()
|
|
37
|
+
xmlrpc_client = defused_xmlrpc.xmlrpc_client
|
|
38
|
+
|
|
39
|
+
from .entity import Entity, EntityUserManager, EntityManager
|
|
32
40
|
from .release import Package as ReleasePackage, Credentials, DEFAULT_PACKAGE
|
|
33
|
-
from .
|
|
34
|
-
from .fields import
|
|
41
|
+
from . import user_data # noqa: F401 - ensure signal registration
|
|
42
|
+
from .fields import (
|
|
43
|
+
SigilShortAutoField,
|
|
44
|
+
ConditionTextField,
|
|
45
|
+
ConditionCheckResult,
|
|
46
|
+
)
|
|
35
47
|
|
|
36
48
|
|
|
37
49
|
class SecurityGroup(Group):
|
|
@@ -48,6 +60,111 @@ class SecurityGroup(Group):
|
|
|
48
60
|
verbose_name_plural = "Security Groups"
|
|
49
61
|
|
|
50
62
|
|
|
63
|
+
class Profile(Entity):
|
|
64
|
+
"""Abstract base class for user or group scoped configuration."""
|
|
65
|
+
|
|
66
|
+
user = models.OneToOneField(
|
|
67
|
+
settings.AUTH_USER_MODEL,
|
|
68
|
+
null=True,
|
|
69
|
+
blank=True,
|
|
70
|
+
on_delete=models.CASCADE,
|
|
71
|
+
related_name="+",
|
|
72
|
+
)
|
|
73
|
+
group = models.OneToOneField(
|
|
74
|
+
"core.SecurityGroup",
|
|
75
|
+
null=True,
|
|
76
|
+
blank=True,
|
|
77
|
+
on_delete=models.CASCADE,
|
|
78
|
+
related_name="+",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
class Meta:
|
|
82
|
+
abstract = True
|
|
83
|
+
|
|
84
|
+
def clean(self):
|
|
85
|
+
super().clean()
|
|
86
|
+
if self.user_id and self.group_id:
|
|
87
|
+
raise ValidationError(
|
|
88
|
+
{
|
|
89
|
+
"user": _("Select either a user or a security group, not both."),
|
|
90
|
+
"group": _("Select either a user or a security group, not both."),
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
if not self.user_id and not self.group_id:
|
|
94
|
+
raise ValidationError(
|
|
95
|
+
_("Profiles must be assigned to a user or a security group."),
|
|
96
|
+
)
|
|
97
|
+
if self.user_id:
|
|
98
|
+
user_model = get_user_model()
|
|
99
|
+
username_cache = {"value": None}
|
|
100
|
+
|
|
101
|
+
def _resolve_username():
|
|
102
|
+
if username_cache["value"] is not None:
|
|
103
|
+
return username_cache["value"]
|
|
104
|
+
user_obj = getattr(self, "user", None)
|
|
105
|
+
username = getattr(user_obj, "username", None)
|
|
106
|
+
if not username:
|
|
107
|
+
manager = getattr(
|
|
108
|
+
user_model, "all_objects", user_model._default_manager
|
|
109
|
+
)
|
|
110
|
+
username = (
|
|
111
|
+
manager.filter(pk=self.user_id)
|
|
112
|
+
.values_list("username", flat=True)
|
|
113
|
+
.first()
|
|
114
|
+
)
|
|
115
|
+
username_cache["value"] = username
|
|
116
|
+
return username
|
|
117
|
+
|
|
118
|
+
is_restricted = getattr(user_model, "is_profile_restricted_username", None)
|
|
119
|
+
if callable(is_restricted):
|
|
120
|
+
username = _resolve_username()
|
|
121
|
+
if is_restricted(username):
|
|
122
|
+
raise ValidationError(
|
|
123
|
+
{
|
|
124
|
+
"user": _(
|
|
125
|
+
"The %(username)s account cannot have profiles attached."
|
|
126
|
+
)
|
|
127
|
+
% {"username": username}
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
system_username = getattr(user_model, "SYSTEM_USERNAME", None)
|
|
132
|
+
if system_username:
|
|
133
|
+
username = _resolve_username()
|
|
134
|
+
if user_model.is_system_username(username):
|
|
135
|
+
raise ValidationError(
|
|
136
|
+
{
|
|
137
|
+
"user": _(
|
|
138
|
+
"The %(username)s account cannot have profiles attached."
|
|
139
|
+
)
|
|
140
|
+
% {"username": username}
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def owner(self):
|
|
146
|
+
"""Return the assigned user or group."""
|
|
147
|
+
|
|
148
|
+
return self.user if self.user_id else self.group
|
|
149
|
+
|
|
150
|
+
def owner_display(self) -> str:
|
|
151
|
+
"""Return a human readable owner label."""
|
|
152
|
+
|
|
153
|
+
owner = self.owner
|
|
154
|
+
if owner is None: # pragma: no cover - guarded by ``clean``
|
|
155
|
+
return ""
|
|
156
|
+
if hasattr(owner, "get_username"):
|
|
157
|
+
return owner.get_username()
|
|
158
|
+
if hasattr(owner, "name"):
|
|
159
|
+
return owner.name
|
|
160
|
+
return str(owner)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class SigilRootManager(EntityManager):
|
|
164
|
+
def get_by_natural_key(self, prefix: str):
|
|
165
|
+
return self.get(prefix=prefix)
|
|
166
|
+
|
|
167
|
+
|
|
51
168
|
class SigilRoot(Entity):
|
|
52
169
|
class Context(models.TextChoices):
|
|
53
170
|
CONFIG = "config", "Configuration"
|
|
@@ -55,16 +172,32 @@ class SigilRoot(Entity):
|
|
|
55
172
|
|
|
56
173
|
prefix = models.CharField(max_length=50, unique=True)
|
|
57
174
|
context_type = models.CharField(max_length=20, choices=Context.choices)
|
|
175
|
+
content_type = models.ForeignKey(
|
|
176
|
+
ContentType, null=True, blank=True, on_delete=models.CASCADE
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
objects = SigilRootManager()
|
|
58
180
|
|
|
59
181
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
60
182
|
return self.prefix
|
|
61
183
|
|
|
184
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
185
|
+
return (self.prefix,)
|
|
186
|
+
|
|
62
187
|
class Meta:
|
|
63
188
|
verbose_name = "Sigil Root"
|
|
64
189
|
verbose_name_plural = "Sigil Roots"
|
|
65
190
|
|
|
66
191
|
|
|
67
|
-
class
|
|
192
|
+
class CustomSigil(SigilRoot):
|
|
193
|
+
class Meta:
|
|
194
|
+
proxy = True
|
|
195
|
+
app_label = "pages"
|
|
196
|
+
verbose_name = _("Custom Sigil")
|
|
197
|
+
verbose_name_plural = _("Custom Sigils")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class Lead(Entity):
|
|
68
201
|
"""Common request lead information."""
|
|
69
202
|
|
|
70
203
|
user = models.ForeignKey(
|
|
@@ -83,6 +216,9 @@ class Lead(models.Model):
|
|
|
83
216
|
class InviteLead(Lead):
|
|
84
217
|
email = models.EmailField()
|
|
85
218
|
comment = models.TextField(blank=True)
|
|
219
|
+
sent_on = models.DateTimeField(null=True, blank=True)
|
|
220
|
+
error = models.TextField(blank=True)
|
|
221
|
+
mac_address = models.CharField(max_length=17, blank=True)
|
|
86
222
|
|
|
87
223
|
class Meta:
|
|
88
224
|
verbose_name = "Invite Lead"
|
|
@@ -92,156 +228,66 @@ class InviteLead(Lead):
|
|
|
92
228
|
return self.email
|
|
93
229
|
|
|
94
230
|
|
|
95
|
-
class
|
|
96
|
-
"""
|
|
97
|
-
|
|
98
|
-
class State(models.TextChoices):
|
|
99
|
-
COAHUILA = "CO", "Coahuila"
|
|
100
|
-
NUEVO_LEON = "NL", "Nuevo León"
|
|
101
|
-
|
|
102
|
-
COAHUILA_MUNICIPALITIES = [
|
|
103
|
-
"Abasolo",
|
|
104
|
-
"Acuña",
|
|
105
|
-
"Allende",
|
|
106
|
-
"Arteaga",
|
|
107
|
-
"Candela",
|
|
108
|
-
"Castaños",
|
|
109
|
-
"Cuatro Ciénegas",
|
|
110
|
-
"Escobedo",
|
|
111
|
-
"Francisco I. Madero",
|
|
112
|
-
"Frontera",
|
|
113
|
-
"General Cepeda",
|
|
114
|
-
"Guerrero",
|
|
115
|
-
"Hidalgo",
|
|
116
|
-
"Jiménez",
|
|
117
|
-
"Juárez",
|
|
118
|
-
"Lamadrid",
|
|
119
|
-
"Matamoros",
|
|
120
|
-
"Monclova",
|
|
121
|
-
"Morelos",
|
|
122
|
-
"Múzquiz",
|
|
123
|
-
"Nadadores",
|
|
124
|
-
"Nava",
|
|
125
|
-
"Ocampo",
|
|
126
|
-
"Parras",
|
|
127
|
-
"Piedras Negras",
|
|
128
|
-
"Progreso",
|
|
129
|
-
"Ramos Arizpe",
|
|
130
|
-
"Sabinas",
|
|
131
|
-
"Sacramento",
|
|
132
|
-
"Saltillo",
|
|
133
|
-
"San Buenaventura",
|
|
134
|
-
"San Juan de Sabinas",
|
|
135
|
-
"San Pedro",
|
|
136
|
-
"Sierra Mojada",
|
|
137
|
-
"Torreón",
|
|
138
|
-
"Viesca",
|
|
139
|
-
"Villa Unión",
|
|
140
|
-
"Zaragoza",
|
|
141
|
-
]
|
|
231
|
+
class PublicWifiAccess(Entity):
|
|
232
|
+
"""Represent a Wi-Fi lease granted to a client for internet access."""
|
|
142
233
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
"
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
"Cadereyta Jiménez",
|
|
153
|
-
"El Carmen",
|
|
154
|
-
"Cerralvo",
|
|
155
|
-
"Ciénega de Flores",
|
|
156
|
-
"China",
|
|
157
|
-
"Doctor Arroyo",
|
|
158
|
-
"Doctor Coss",
|
|
159
|
-
"Doctor González",
|
|
160
|
-
"Galeana",
|
|
161
|
-
"García",
|
|
162
|
-
"General Bravo",
|
|
163
|
-
"General Escobedo",
|
|
164
|
-
"General Terán",
|
|
165
|
-
"General Treviño",
|
|
166
|
-
"General Zaragoza",
|
|
167
|
-
"General Zuazua",
|
|
168
|
-
"Guadalupe",
|
|
169
|
-
"Los Herreras",
|
|
170
|
-
"Higueras",
|
|
171
|
-
"Hualahuises",
|
|
172
|
-
"Iturbide",
|
|
173
|
-
"Juárez",
|
|
174
|
-
"Lampazos de Naranjo",
|
|
175
|
-
"Linares",
|
|
176
|
-
"Marín",
|
|
177
|
-
"Melchor Ocampo",
|
|
178
|
-
"Mier y Noriega",
|
|
179
|
-
"Mina",
|
|
180
|
-
"Montemorelos",
|
|
181
|
-
"Monterrey",
|
|
182
|
-
"Parás",
|
|
183
|
-
"Pesquería",
|
|
184
|
-
"Los Ramones",
|
|
185
|
-
"Rayones",
|
|
186
|
-
"Sabinas Hidalgo",
|
|
187
|
-
"Salinas Victoria",
|
|
188
|
-
"San Nicolás de los Garza",
|
|
189
|
-
"San Pedro Garza García",
|
|
190
|
-
"Santa Catarina",
|
|
191
|
-
"Santiago",
|
|
192
|
-
"Vallecillo",
|
|
193
|
-
"Villaldama",
|
|
194
|
-
"Hidalgo",
|
|
195
|
-
]
|
|
234
|
+
user = models.ForeignKey(
|
|
235
|
+
settings.AUTH_USER_MODEL,
|
|
236
|
+
on_delete=models.CASCADE,
|
|
237
|
+
related_name="public_wifi_accesses",
|
|
238
|
+
)
|
|
239
|
+
mac_address = models.CharField(max_length=17)
|
|
240
|
+
created_on = models.DateTimeField(auto_now_add=True)
|
|
241
|
+
updated_on = models.DateTimeField(auto_now=True)
|
|
242
|
+
revoked_on = models.DateTimeField(null=True, blank=True)
|
|
196
243
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
244
|
+
class Meta:
|
|
245
|
+
unique_together = ("user", "mac_address")
|
|
246
|
+
verbose_name = "Wi-Fi Lease"
|
|
247
|
+
verbose_name_plural = "Wi-Fi Leases"
|
|
201
248
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
]
|
|
249
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
250
|
+
return f"{self.user} -> {self.mac_address}"
|
|
205
251
|
|
|
206
|
-
street = models.CharField(max_length=255)
|
|
207
|
-
number = models.CharField(max_length=20)
|
|
208
|
-
municipality = models.CharField(max_length=100, choices=MUNICIPALITY_CHOICES)
|
|
209
|
-
state = models.CharField(max_length=2, choices=State.choices)
|
|
210
|
-
postal_code = models.CharField(max_length=10)
|
|
211
252
|
|
|
212
|
-
|
|
213
|
-
|
|
253
|
+
@receiver(post_save, sender=settings.AUTH_USER_MODEL)
|
|
254
|
+
def _revoke_public_wifi_when_inactive(sender, instance, **kwargs):
|
|
255
|
+
if instance.is_active:
|
|
256
|
+
return
|
|
257
|
+
from core import public_wifi
|
|
214
258
|
|
|
215
|
-
|
|
216
|
-
from django.core.exceptions import ValidationError
|
|
259
|
+
public_wifi.revoke_public_access_for_user(instance)
|
|
217
260
|
|
|
218
|
-
allowed = self.MUNICIPALITIES_BY_STATE.get(self.state, [])
|
|
219
|
-
if self.municipality not in allowed:
|
|
220
|
-
raise ValidationError(
|
|
221
|
-
{"municipality": _("Invalid municipality for the selected state")}
|
|
222
|
-
)
|
|
223
261
|
|
|
224
|
-
|
|
225
|
-
|
|
262
|
+
@receiver(post_delete, sender=settings.AUTH_USER_MODEL)
|
|
263
|
+
def _cleanup_public_wifi_on_delete(sender, instance, **kwargs):
|
|
264
|
+
from core import public_wifi
|
|
265
|
+
|
|
266
|
+
public_wifi.revoke_public_access_for_user(instance)
|
|
226
267
|
|
|
227
268
|
|
|
228
269
|
class User(Entity, AbstractUser):
|
|
270
|
+
SYSTEM_USERNAME = "arthexis"
|
|
271
|
+
ADMIN_USERNAME = "admin"
|
|
272
|
+
PROFILE_RESTRICTED_USERNAMES = frozenset()
|
|
273
|
+
|
|
229
274
|
objects = EntityUserManager()
|
|
230
275
|
all_objects = DjangoUserManager()
|
|
231
276
|
"""Custom user model."""
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
)
|
|
238
|
-
address = models.ForeignKey(
|
|
239
|
-
Address,
|
|
277
|
+
birthday = models.DateField(null=True, blank=True)
|
|
278
|
+
data_path = models.CharField(max_length=255, blank=True)
|
|
279
|
+
last_visit_ip_address = models.GenericIPAddressField(null=True, blank=True)
|
|
280
|
+
operate_as = models.ForeignKey(
|
|
281
|
+
"self",
|
|
240
282
|
null=True,
|
|
241
283
|
blank=True,
|
|
242
284
|
on_delete=models.SET_NULL,
|
|
285
|
+
related_name="operated_users",
|
|
286
|
+
help_text=(
|
|
287
|
+
"Operate using another user's permissions when additional authority is "
|
|
288
|
+
"required."
|
|
289
|
+
),
|
|
243
290
|
)
|
|
244
|
-
has_charger = models.BooleanField(default=False)
|
|
245
291
|
is_active = models.BooleanField(
|
|
246
292
|
_("active"),
|
|
247
293
|
default=True,
|
|
@@ -253,15 +299,173 @@ class User(Entity, AbstractUser):
|
|
|
253
299
|
def __str__(self):
|
|
254
300
|
return self.username
|
|
255
301
|
|
|
302
|
+
@classmethod
|
|
303
|
+
def is_system_username(cls, username):
|
|
304
|
+
return bool(username) and username == cls.SYSTEM_USERNAME
|
|
256
305
|
|
|
257
|
-
|
|
258
|
-
|
|
306
|
+
@classmethod
|
|
307
|
+
def is_profile_restricted_username(cls, username):
|
|
308
|
+
return bool(username) and username in cls.PROFILE_RESTRICTED_USERNAMES
|
|
259
309
|
|
|
260
|
-
|
|
310
|
+
@property
|
|
311
|
+
def is_system_user(self) -> bool:
|
|
312
|
+
return self.is_system_username(self.username)
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def is_profile_restricted(self) -> bool:
|
|
316
|
+
return self.is_profile_restricted_username(self.username)
|
|
317
|
+
|
|
318
|
+
def clean(self):
|
|
319
|
+
super().clean()
|
|
320
|
+
if not self.operate_as_id:
|
|
321
|
+
return
|
|
322
|
+
try:
|
|
323
|
+
delegate = self.operate_as
|
|
324
|
+
except type(self).DoesNotExist:
|
|
325
|
+
raise ValidationError({"operate_as": _("Selected user is not available.")})
|
|
326
|
+
errors = []
|
|
327
|
+
if delegate.pk == self.pk:
|
|
328
|
+
errors.append(_("Cannot operate as yourself."))
|
|
329
|
+
if getattr(delegate, "is_deleted", False):
|
|
330
|
+
errors.append(_("Cannot operate as a deleted user."))
|
|
331
|
+
if not self.is_staff:
|
|
332
|
+
errors.append(_("Only staff members may operate as another user."))
|
|
333
|
+
if delegate.is_staff and not self.is_superuser:
|
|
334
|
+
errors.append(_("Only superusers may operate as staff members."))
|
|
335
|
+
if errors:
|
|
336
|
+
raise ValidationError({"operate_as": errors})
|
|
337
|
+
|
|
338
|
+
def _delegate_for_permissions(self):
|
|
339
|
+
if not self.is_staff or not self.operate_as_id:
|
|
340
|
+
return None
|
|
341
|
+
try:
|
|
342
|
+
delegate = self.operate_as
|
|
343
|
+
except type(self).DoesNotExist:
|
|
344
|
+
return None
|
|
345
|
+
if delegate.pk == self.pk:
|
|
346
|
+
return None
|
|
347
|
+
if getattr(delegate, "is_deleted", False):
|
|
348
|
+
return None
|
|
349
|
+
if delegate.is_staff and not self.is_superuser:
|
|
350
|
+
return None
|
|
351
|
+
return delegate
|
|
352
|
+
|
|
353
|
+
def _check_operate_as_chain(self, predicate, visited=None):
|
|
354
|
+
if visited is None:
|
|
355
|
+
visited = set()
|
|
356
|
+
identifier = self.pk or id(self)
|
|
357
|
+
if identifier in visited:
|
|
358
|
+
return False
|
|
359
|
+
visited.add(identifier)
|
|
360
|
+
if predicate(self):
|
|
361
|
+
return True
|
|
362
|
+
delegate = self._delegate_for_permissions()
|
|
363
|
+
if not delegate:
|
|
364
|
+
return False
|
|
365
|
+
return delegate._check_operate_as_chain(predicate, visited)
|
|
366
|
+
|
|
367
|
+
def has_perm(self, perm, obj=None):
|
|
368
|
+
return self._check_operate_as_chain(
|
|
369
|
+
lambda user: super(User, user).has_perm(perm, obj)
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def has_module_perms(self, app_label):
|
|
373
|
+
return self._check_operate_as_chain(
|
|
374
|
+
lambda user: super(User, user).has_module_perms(app_label)
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def _profile_for(self, profile_cls: Type[Profile], user: "User"):
|
|
378
|
+
profile = profile_cls.objects.filter(user=user).first()
|
|
379
|
+
if profile:
|
|
380
|
+
return profile
|
|
381
|
+
group_ids = list(user.groups.values_list("id", flat=True))
|
|
382
|
+
if group_ids:
|
|
383
|
+
return profile_cls.objects.filter(group_id__in=group_ids).first()
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
def get_profile(self, profile_cls: Type[Profile]):
|
|
387
|
+
"""Return the first matching profile for the user or their delegate chain."""
|
|
388
|
+
|
|
389
|
+
if not isinstance(profile_cls, type) or not issubclass(profile_cls, Profile):
|
|
390
|
+
raise TypeError("profile_cls must be a Profile subclass")
|
|
391
|
+
|
|
392
|
+
result = None
|
|
393
|
+
|
|
394
|
+
def predicate(user: "User"):
|
|
395
|
+
nonlocal result
|
|
396
|
+
result = self._profile_for(profile_cls, user)
|
|
397
|
+
return result is not None
|
|
398
|
+
|
|
399
|
+
self._check_operate_as_chain(predicate)
|
|
400
|
+
return result
|
|
401
|
+
|
|
402
|
+
def has_profile(self, profile_cls: Type[Profile]) -> bool:
|
|
403
|
+
"""Return ``True`` when a profile is available for the user or delegate chain."""
|
|
404
|
+
|
|
405
|
+
return self.get_profile(profile_cls) is not None
|
|
406
|
+
|
|
407
|
+
def _direct_profile(self, model_label: str):
|
|
408
|
+
model = apps.get_model("core", model_label)
|
|
409
|
+
try:
|
|
410
|
+
return self.get_profile(model)
|
|
411
|
+
except TypeError:
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
def get_phones_by_priority(self):
|
|
415
|
+
"""Return a list of ``UserPhoneNumber`` instances ordered by priority."""
|
|
416
|
+
|
|
417
|
+
ordered_numbers = self.phone_numbers.order_by("priority", "pk")
|
|
418
|
+
return list(ordered_numbers)
|
|
419
|
+
|
|
420
|
+
def get_phone_numbers_by_priority(self):
|
|
421
|
+
"""Backward-compatible alias for :meth:`get_phones_by_priority`."""
|
|
422
|
+
|
|
423
|
+
return self.get_phones_by_priority()
|
|
424
|
+
|
|
425
|
+
@property
|
|
426
|
+
def release_manager(self):
|
|
427
|
+
return self._direct_profile("ReleaseManager")
|
|
428
|
+
|
|
429
|
+
@property
|
|
430
|
+
def odoo_profile(self):
|
|
431
|
+
return self._direct_profile("OdooProfile")
|
|
432
|
+
|
|
433
|
+
@property
|
|
434
|
+
def assistant_profile(self):
|
|
435
|
+
return self._direct_profile("AssistantProfile")
|
|
436
|
+
|
|
437
|
+
@property
|
|
438
|
+
def chat_profile(self):
|
|
439
|
+
return self.assistant_profile
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
class UserPhoneNumber(Entity):
|
|
443
|
+
"""Store phone numbers associated with a user."""
|
|
444
|
+
|
|
445
|
+
user = models.ForeignKey(
|
|
261
446
|
settings.AUTH_USER_MODEL,
|
|
262
|
-
related_name="odoo_profile",
|
|
263
447
|
on_delete=models.CASCADE,
|
|
448
|
+
related_name="phone_numbers",
|
|
264
449
|
)
|
|
450
|
+
number = models.CharField(
|
|
451
|
+
max_length=20,
|
|
452
|
+
help_text="Contact phone number",
|
|
453
|
+
)
|
|
454
|
+
priority = models.PositiveIntegerField(default=0)
|
|
455
|
+
|
|
456
|
+
class Meta:
|
|
457
|
+
ordering = ("priority", "id")
|
|
458
|
+
verbose_name = "Phone Number"
|
|
459
|
+
verbose_name_plural = "Phone Numbers"
|
|
460
|
+
|
|
461
|
+
def __str__(self): # pragma: no cover - simple representation
|
|
462
|
+
return f"{self.number} ({self.priority})"
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class OdooProfile(Profile):
|
|
466
|
+
"""Store Odoo API credentials for a user."""
|
|
467
|
+
|
|
468
|
+
profile_fields = ("host", "database", "username", "password")
|
|
265
469
|
host = SigilShortAutoField(max_length=255)
|
|
266
470
|
database = SigilShortAutoField(max_length=255)
|
|
267
471
|
username = SigilShortAutoField(max_length=255)
|
|
@@ -295,12 +499,12 @@ class OdooProfile(Entity):
|
|
|
295
499
|
|
|
296
500
|
def verify(self):
|
|
297
501
|
"""Check credentials against Odoo and pull user info."""
|
|
298
|
-
common =
|
|
502
|
+
common = xmlrpc_client.ServerProxy(f"{self.host}/xmlrpc/2/common")
|
|
299
503
|
uid = common.authenticate(self.database, self.username, self.password, {})
|
|
300
504
|
if not uid:
|
|
301
505
|
self._clear_verification()
|
|
302
506
|
raise ValidationError(_("Invalid Odoo credentials"))
|
|
303
|
-
models_proxy =
|
|
507
|
+
models_proxy = xmlrpc_client.ServerProxy(f"{self.host}/xmlrpc/2/object")
|
|
304
508
|
info = models_proxy.execute_kw(
|
|
305
509
|
self.database,
|
|
306
510
|
uid,
|
|
@@ -320,7 +524,7 @@ class OdooProfile(Entity):
|
|
|
320
524
|
def execute(self, model, method, *args, **kwargs):
|
|
321
525
|
"""Execute an Odoo RPC call, invalidating credentials on failure."""
|
|
322
526
|
try:
|
|
323
|
-
client =
|
|
527
|
+
client = xmlrpc_client.ServerProxy(f"{self.host}/xmlrpc/2/object")
|
|
324
528
|
return client.execute_kw(
|
|
325
529
|
self.database,
|
|
326
530
|
self.odoo_uid,
|
|
@@ -336,14 +540,24 @@ class OdooProfile(Entity):
|
|
|
336
540
|
raise
|
|
337
541
|
|
|
338
542
|
def __str__(self): # pragma: no cover - simple representation
|
|
339
|
-
|
|
543
|
+
owner = self.owner_display()
|
|
544
|
+
return f"{owner} @ {self.host}" if owner else self.host
|
|
340
545
|
|
|
341
546
|
class Meta:
|
|
342
|
-
verbose_name = _("Odoo
|
|
343
|
-
verbose_name_plural = _("Odoo
|
|
547
|
+
verbose_name = _("Odoo Employee")
|
|
548
|
+
verbose_name_plural = _("Odoo Employees")
|
|
549
|
+
constraints = [
|
|
550
|
+
models.CheckConstraint(
|
|
551
|
+
check=(
|
|
552
|
+
(Q(user__isnull=False) & Q(group__isnull=True))
|
|
553
|
+
| (Q(user__isnull=True) & Q(group__isnull=False))
|
|
554
|
+
),
|
|
555
|
+
name="odooprofile_requires_owner",
|
|
556
|
+
)
|
|
557
|
+
]
|
|
344
558
|
|
|
345
559
|
|
|
346
|
-
class EmailInbox(
|
|
560
|
+
class EmailInbox(Profile):
|
|
347
561
|
"""Credentials and configuration for connecting to an email mailbox."""
|
|
348
562
|
|
|
349
563
|
IMAP = "imap"
|
|
@@ -353,10 +567,13 @@ class EmailInbox(Entity):
|
|
|
353
567
|
(POP3, "POP3"),
|
|
354
568
|
]
|
|
355
569
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
570
|
+
profile_fields = (
|
|
571
|
+
"username",
|
|
572
|
+
"host",
|
|
573
|
+
"port",
|
|
574
|
+
"password",
|
|
575
|
+
"protocol",
|
|
576
|
+
"use_ssl",
|
|
360
577
|
)
|
|
361
578
|
username = SigilShortAutoField(
|
|
362
579
|
max_length=255,
|
|
@@ -430,9 +647,14 @@ class EmailInbox(Entity):
|
|
|
430
647
|
def _get_body(msg):
|
|
431
648
|
if msg.is_multipart():
|
|
432
649
|
for part in msg.walk():
|
|
433
|
-
if
|
|
650
|
+
if (
|
|
651
|
+
part.get_content_type() == "text/plain"
|
|
652
|
+
and not part.get_filename()
|
|
653
|
+
):
|
|
434
654
|
charset = part.get_content_charset() or "utf-8"
|
|
435
|
-
return part.get_payload(decode=True).decode(
|
|
655
|
+
return part.get_payload(decode=True).decode(
|
|
656
|
+
charset, errors="ignore"
|
|
657
|
+
)
|
|
436
658
|
return ""
|
|
437
659
|
charset = msg.get_content_charset() or "utf-8"
|
|
438
660
|
return msg.get_payload(decode=True).decode(charset, errors="ignore")
|
|
@@ -556,9 +778,7 @@ class EmailCollector(Entity):
|
|
|
556
778
|
fp = EmailArtifact.fingerprint_for(
|
|
557
779
|
msg.get("subject", ""), msg.get("from", ""), msg.get("body", "")
|
|
558
780
|
)
|
|
559
|
-
if EmailArtifact.objects.filter(
|
|
560
|
-
collector=self, fingerprint=fp
|
|
561
|
-
).exists():
|
|
781
|
+
if EmailArtifact.objects.filter(collector=self, fingerprint=fp).exists():
|
|
562
782
|
break
|
|
563
783
|
EmailArtifact.objects.create(
|
|
564
784
|
collector=self,
|
|
@@ -591,65 +811,19 @@ class EmailArtifact(Entity):
|
|
|
591
811
|
import hashlib
|
|
592
812
|
|
|
593
813
|
data = (subject or "") + (sender or "") + (body or "")
|
|
594
|
-
|
|
814
|
+
hasher = hashlib.md5(data.encode("utf-8"), usedforsecurity=False)
|
|
815
|
+
return hasher.hexdigest()
|
|
595
816
|
|
|
596
817
|
class Meta:
|
|
597
818
|
unique_together = ("collector", "fingerprint")
|
|
598
819
|
verbose_name = "Email Artifact"
|
|
599
820
|
verbose_name_plural = "Email Artifacts"
|
|
821
|
+
ordering = ["-id"]
|
|
600
822
|
|
|
601
823
|
|
|
602
|
-
class
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
MASTODON = "mastodon"
|
|
606
|
-
BLUESKY = "bluesky"
|
|
607
|
-
SERVICE_CHOICES = [
|
|
608
|
-
(MASTODON, "Mastodon"),
|
|
609
|
-
(BLUESKY, "Bluesky"),
|
|
610
|
-
]
|
|
611
|
-
|
|
612
|
-
user = models.OneToOneField(
|
|
613
|
-
settings.AUTH_USER_MODEL,
|
|
614
|
-
related_name="fediverse_profile",
|
|
615
|
-
on_delete=models.CASCADE,
|
|
616
|
-
)
|
|
617
|
-
service = models.CharField(max_length=20, choices=SERVICE_CHOICES)
|
|
618
|
-
host = models.CharField(max_length=255)
|
|
619
|
-
handle = models.CharField(max_length=255)
|
|
620
|
-
access_token = models.CharField(max_length=255, blank=True)
|
|
621
|
-
verified_on = models.DateTimeField(null=True, blank=True)
|
|
622
|
-
|
|
623
|
-
def test_connection(self):
|
|
624
|
-
"""Attempt to verify credentials against the configured service."""
|
|
625
|
-
import requests
|
|
626
|
-
|
|
627
|
-
try:
|
|
628
|
-
headers = {}
|
|
629
|
-
if self.access_token:
|
|
630
|
-
headers["Authorization"] = f"Bearer {self.access_token}"
|
|
631
|
-
if self.service == self.MASTODON:
|
|
632
|
-
url = f"https://{self.host}/api/v1/accounts/verify_credentials"
|
|
633
|
-
resp = requests.get(url, headers=headers, timeout=10)
|
|
634
|
-
else: # BLUESKY
|
|
635
|
-
url = f"https://{self.host}/xrpc/app.bsky.actor.getProfile"
|
|
636
|
-
params = {"actor": self.handle}
|
|
637
|
-
resp = requests.get(url, params=params, headers=headers, timeout=10)
|
|
638
|
-
resp.raise_for_status()
|
|
639
|
-
self.verified_on = timezone.now()
|
|
640
|
-
self.save(update_fields=["verified_on"])
|
|
641
|
-
return True
|
|
642
|
-
except Exception as exc:
|
|
643
|
-
self.verified_on = None
|
|
644
|
-
self.save(update_fields=["verified_on"])
|
|
645
|
-
raise ValidationError(str(exc))
|
|
646
|
-
|
|
647
|
-
def __str__(self): # pragma: no cover - simple representation
|
|
648
|
-
return f"{self.user} @ {self.host}"
|
|
649
|
-
|
|
650
|
-
class Meta:
|
|
651
|
-
verbose_name = _("Fediverse Profile")
|
|
652
|
-
verbose_name_plural = _("Fediverse Profiles")
|
|
824
|
+
class ReferenceManager(EntityManager):
|
|
825
|
+
def get_by_natural_key(self, alt_text: str):
|
|
826
|
+
return self.get(alt_text=alt_text)
|
|
653
827
|
|
|
654
828
|
|
|
655
829
|
class Reference(Entity):
|
|
@@ -674,6 +848,9 @@ class Reference(Entity):
|
|
|
674
848
|
include_in_footer = models.BooleanField(
|
|
675
849
|
default=False, verbose_name="Include in Footer"
|
|
676
850
|
)
|
|
851
|
+
show_in_header = models.BooleanField(
|
|
852
|
+
default=False, verbose_name="Show in Header"
|
|
853
|
+
)
|
|
677
854
|
FOOTER_PUBLIC = "public"
|
|
678
855
|
FOOTER_PRIVATE = "private"
|
|
679
856
|
FOOTER_STAFF = "staff"
|
|
@@ -702,12 +879,31 @@ class Reference(Entity):
|
|
|
702
879
|
null=True,
|
|
703
880
|
blank=True,
|
|
704
881
|
)
|
|
882
|
+
sites = models.ManyToManyField(
|
|
883
|
+
"sites.Site",
|
|
884
|
+
blank=True,
|
|
885
|
+
related_name="references",
|
|
886
|
+
)
|
|
887
|
+
roles = models.ManyToManyField(
|
|
888
|
+
"nodes.NodeRole",
|
|
889
|
+
blank=True,
|
|
890
|
+
related_name="references",
|
|
891
|
+
)
|
|
892
|
+
features = models.ManyToManyField(
|
|
893
|
+
"nodes.NodeFeature",
|
|
894
|
+
blank=True,
|
|
895
|
+
related_name="references",
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
objects = ReferenceManager()
|
|
705
899
|
|
|
706
900
|
def save(self, *args, **kwargs):
|
|
707
901
|
if self.pk:
|
|
708
902
|
original = type(self).all_objects.get(pk=self.pk)
|
|
709
903
|
if original.transaction_uuid != self.transaction_uuid:
|
|
710
|
-
raise ValidationError(
|
|
904
|
+
raise ValidationError(
|
|
905
|
+
{"transaction_uuid": "Cannot modify transaction UUID"}
|
|
906
|
+
)
|
|
711
907
|
if not self.image and self.value:
|
|
712
908
|
qr = qrcode.QRCode(box_size=10, border=4)
|
|
713
909
|
qr.add_data(self.value)
|
|
@@ -722,6 +918,10 @@ class Reference(Entity):
|
|
|
722
918
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
723
919
|
return self.alt_text
|
|
724
920
|
|
|
921
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
922
|
+
return (self.alt_text,)
|
|
923
|
+
|
|
924
|
+
|
|
725
925
|
class RFID(Entity):
|
|
726
926
|
"""RFID tag that may be assigned to one account."""
|
|
727
927
|
|
|
@@ -737,6 +937,12 @@ class RFID(Entity):
|
|
|
737
937
|
)
|
|
738
938
|
],
|
|
739
939
|
)
|
|
940
|
+
custom_label = models.CharField(
|
|
941
|
+
max_length=32,
|
|
942
|
+
blank=True,
|
|
943
|
+
verbose_name="Custom Label",
|
|
944
|
+
help_text="Optional custom label for this RFID.",
|
|
945
|
+
)
|
|
740
946
|
key_a = models.CharField(
|
|
741
947
|
max_length=12,
|
|
742
948
|
default="FFFFFFFFFFFF",
|
|
@@ -847,6 +1053,195 @@ class RFID(Entity):
|
|
|
847
1053
|
db_table = "core_rfid"
|
|
848
1054
|
|
|
849
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
|
+
|
|
850
1245
|
class EnergyAccount(Entity):
|
|
851
1246
|
"""Track kW energy credits for a user."""
|
|
852
1247
|
|
|
@@ -869,6 +1264,15 @@ class EnergyAccount(Entity):
|
|
|
869
1264
|
default=False,
|
|
870
1265
|
help_text="Allow transactions even when the balance is zero or negative",
|
|
871
1266
|
)
|
|
1267
|
+
live_subscription_product = models.ForeignKey(
|
|
1268
|
+
"Product",
|
|
1269
|
+
null=True,
|
|
1270
|
+
blank=True,
|
|
1271
|
+
on_delete=models.SET_NULL,
|
|
1272
|
+
related_name="live_subscription_accounts",
|
|
1273
|
+
)
|
|
1274
|
+
live_subscription_start_date = models.DateField(null=True, blank=True)
|
|
1275
|
+
live_subscription_next_renewal = models.DateField(null=True, blank=True)
|
|
872
1276
|
|
|
873
1277
|
def can_authorize(self) -> bool:
|
|
874
1278
|
"""Return True if this account should be authorized for charging."""
|
|
@@ -907,6 +1311,17 @@ class EnergyAccount(Entity):
|
|
|
907
1311
|
def save(self, *args, **kwargs):
|
|
908
1312
|
if self.name:
|
|
909
1313
|
self.name = self.name.upper()
|
|
1314
|
+
if self.live_subscription_product and not self.live_subscription_start_date:
|
|
1315
|
+
self.live_subscription_start_date = timezone.now().date()
|
|
1316
|
+
if (
|
|
1317
|
+
self.live_subscription_product
|
|
1318
|
+
and self.live_subscription_start_date
|
|
1319
|
+
and not self.live_subscription_next_renewal
|
|
1320
|
+
):
|
|
1321
|
+
self.live_subscription_next_renewal = (
|
|
1322
|
+
self.live_subscription_start_date
|
|
1323
|
+
+ timedelta(days=self.live_subscription_product.renewal_period)
|
|
1324
|
+
)
|
|
910
1325
|
super().save(*args, **kwargs)
|
|
911
1326
|
|
|
912
1327
|
def __str__(self): # pragma: no cover - simple representation
|
|
@@ -950,11 +1365,433 @@ class EnergyCredit(Entity):
|
|
|
950
1365
|
db_table = "core_credit"
|
|
951
1366
|
|
|
952
1367
|
|
|
1368
|
+
class ClientReportSchedule(Entity):
|
|
1369
|
+
"""Configuration for recurring :class:`ClientReport` generation."""
|
|
1370
|
+
|
|
1371
|
+
PERIODICITY_NONE = "none"
|
|
1372
|
+
PERIODICITY_DAILY = "daily"
|
|
1373
|
+
PERIODICITY_WEEKLY = "weekly"
|
|
1374
|
+
PERIODICITY_MONTHLY = "monthly"
|
|
1375
|
+
PERIODICITY_CHOICES = [
|
|
1376
|
+
(PERIODICITY_NONE, "One-time"),
|
|
1377
|
+
(PERIODICITY_DAILY, "Daily"),
|
|
1378
|
+
(PERIODICITY_WEEKLY, "Weekly"),
|
|
1379
|
+
(PERIODICITY_MONTHLY, "Monthly"),
|
|
1380
|
+
]
|
|
1381
|
+
|
|
1382
|
+
owner = models.ForeignKey(
|
|
1383
|
+
settings.AUTH_USER_MODEL,
|
|
1384
|
+
on_delete=models.SET_NULL,
|
|
1385
|
+
null=True,
|
|
1386
|
+
blank=True,
|
|
1387
|
+
related_name="client_report_schedules",
|
|
1388
|
+
)
|
|
1389
|
+
created_by = models.ForeignKey(
|
|
1390
|
+
settings.AUTH_USER_MODEL,
|
|
1391
|
+
on_delete=models.SET_NULL,
|
|
1392
|
+
null=True,
|
|
1393
|
+
blank=True,
|
|
1394
|
+
related_name="created_client_report_schedules",
|
|
1395
|
+
)
|
|
1396
|
+
periodicity = models.CharField(
|
|
1397
|
+
max_length=12, choices=PERIODICITY_CHOICES, default=PERIODICITY_NONE
|
|
1398
|
+
)
|
|
1399
|
+
email_recipients = models.JSONField(default=list, blank=True)
|
|
1400
|
+
disable_emails = models.BooleanField(default=False)
|
|
1401
|
+
periodic_task = models.OneToOneField(
|
|
1402
|
+
"django_celery_beat.PeriodicTask",
|
|
1403
|
+
on_delete=models.SET_NULL,
|
|
1404
|
+
null=True,
|
|
1405
|
+
blank=True,
|
|
1406
|
+
related_name="client_report_schedule",
|
|
1407
|
+
)
|
|
1408
|
+
last_generated_on = models.DateTimeField(null=True, blank=True)
|
|
1409
|
+
|
|
1410
|
+
class Meta:
|
|
1411
|
+
verbose_name = "Client Report Schedule"
|
|
1412
|
+
verbose_name_plural = "Client Report Schedules"
|
|
1413
|
+
|
|
1414
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1415
|
+
owner = self.owner.get_username() if self.owner else "Unassigned"
|
|
1416
|
+
return f"Client Report Schedule ({owner})"
|
|
1417
|
+
|
|
1418
|
+
def save(self, *args, **kwargs):
|
|
1419
|
+
sync = kwargs.pop("sync_task", True)
|
|
1420
|
+
super().save(*args, **kwargs)
|
|
1421
|
+
if sync and self.pk:
|
|
1422
|
+
self.sync_periodic_task()
|
|
1423
|
+
|
|
1424
|
+
def delete(self, using=None, keep_parents=False):
|
|
1425
|
+
task_id = self.periodic_task_id
|
|
1426
|
+
super().delete(using=using, keep_parents=keep_parents)
|
|
1427
|
+
if task_id:
|
|
1428
|
+
from django_celery_beat.models import PeriodicTask
|
|
1429
|
+
|
|
1430
|
+
PeriodicTask.objects.filter(pk=task_id).delete()
|
|
1431
|
+
|
|
1432
|
+
def sync_periodic_task(self):
|
|
1433
|
+
"""Ensure the Celery beat schedule matches the configured periodicity."""
|
|
1434
|
+
|
|
1435
|
+
from django_celery_beat.models import CrontabSchedule, PeriodicTask
|
|
1436
|
+
from django.db import transaction
|
|
1437
|
+
import json as _json
|
|
1438
|
+
|
|
1439
|
+
if self.periodicity == self.PERIODICITY_NONE:
|
|
1440
|
+
if self.periodic_task_id:
|
|
1441
|
+
PeriodicTask.objects.filter(pk=self.periodic_task_id).delete()
|
|
1442
|
+
type(self).objects.filter(pk=self.pk).update(periodic_task=None)
|
|
1443
|
+
return
|
|
1444
|
+
|
|
1445
|
+
if self.periodicity == self.PERIODICITY_DAILY:
|
|
1446
|
+
schedule, _ = CrontabSchedule.objects.get_or_create(
|
|
1447
|
+
minute="0",
|
|
1448
|
+
hour="2",
|
|
1449
|
+
day_of_week="*",
|
|
1450
|
+
day_of_month="*",
|
|
1451
|
+
month_of_year="*",
|
|
1452
|
+
)
|
|
1453
|
+
elif self.periodicity == self.PERIODICITY_WEEKLY:
|
|
1454
|
+
schedule, _ = CrontabSchedule.objects.get_or_create(
|
|
1455
|
+
minute="0",
|
|
1456
|
+
hour="3",
|
|
1457
|
+
day_of_week="1",
|
|
1458
|
+
day_of_month="*",
|
|
1459
|
+
month_of_year="*",
|
|
1460
|
+
)
|
|
1461
|
+
else:
|
|
1462
|
+
schedule, _ = CrontabSchedule.objects.get_or_create(
|
|
1463
|
+
minute="0",
|
|
1464
|
+
hour="4",
|
|
1465
|
+
day_of_week="*",
|
|
1466
|
+
day_of_month="1",
|
|
1467
|
+
month_of_year="*",
|
|
1468
|
+
)
|
|
1469
|
+
|
|
1470
|
+
name = f"client_report_schedule_{self.pk}"
|
|
1471
|
+
defaults = {
|
|
1472
|
+
"crontab": schedule,
|
|
1473
|
+
"task": "core.tasks.run_client_report_schedule",
|
|
1474
|
+
"kwargs": _json.dumps({"schedule_id": self.pk}),
|
|
1475
|
+
"enabled": True,
|
|
1476
|
+
}
|
|
1477
|
+
with transaction.atomic():
|
|
1478
|
+
periodic_task, _ = PeriodicTask.objects.update_or_create(
|
|
1479
|
+
name=name, defaults=defaults
|
|
1480
|
+
)
|
|
1481
|
+
if self.periodic_task_id != periodic_task.pk:
|
|
1482
|
+
type(self).objects.filter(pk=self.pk).update(
|
|
1483
|
+
periodic_task=periodic_task
|
|
1484
|
+
)
|
|
1485
|
+
|
|
1486
|
+
def calculate_period(self, reference=None):
|
|
1487
|
+
"""Return the date range covered for the next execution."""
|
|
1488
|
+
|
|
1489
|
+
from django.utils import timezone
|
|
1490
|
+
import datetime as _datetime
|
|
1491
|
+
|
|
1492
|
+
ref_date = reference or timezone.localdate()
|
|
1493
|
+
|
|
1494
|
+
if self.periodicity == self.PERIODICITY_DAILY:
|
|
1495
|
+
end = ref_date - _datetime.timedelta(days=1)
|
|
1496
|
+
start = end
|
|
1497
|
+
elif self.periodicity == self.PERIODICITY_WEEKLY:
|
|
1498
|
+
start_of_week = ref_date - _datetime.timedelta(days=ref_date.weekday())
|
|
1499
|
+
end = start_of_week - _datetime.timedelta(days=1)
|
|
1500
|
+
start = end - _datetime.timedelta(days=6)
|
|
1501
|
+
elif self.periodicity == self.PERIODICITY_MONTHLY:
|
|
1502
|
+
first_of_month = ref_date.replace(day=1)
|
|
1503
|
+
end = first_of_month - _datetime.timedelta(days=1)
|
|
1504
|
+
start = end.replace(day=1)
|
|
1505
|
+
else:
|
|
1506
|
+
raise ValueError("calculate_period called for non-recurring schedule")
|
|
1507
|
+
|
|
1508
|
+
return start, end
|
|
1509
|
+
|
|
1510
|
+
def resolve_recipients(self):
|
|
1511
|
+
"""Return (to, cc) email lists respecting owner fallbacks."""
|
|
1512
|
+
|
|
1513
|
+
from django.contrib.auth import get_user_model
|
|
1514
|
+
|
|
1515
|
+
to: list[str] = []
|
|
1516
|
+
cc: list[str] = []
|
|
1517
|
+
seen: set[str] = set()
|
|
1518
|
+
|
|
1519
|
+
for email in self.email_recipients:
|
|
1520
|
+
normalized = (email or "").strip()
|
|
1521
|
+
if not normalized:
|
|
1522
|
+
continue
|
|
1523
|
+
if normalized.lower() in seen:
|
|
1524
|
+
continue
|
|
1525
|
+
to.append(normalized)
|
|
1526
|
+
seen.add(normalized.lower())
|
|
1527
|
+
|
|
1528
|
+
owner_email = None
|
|
1529
|
+
if self.owner and self.owner.email:
|
|
1530
|
+
candidate = self.owner.email.strip()
|
|
1531
|
+
if candidate:
|
|
1532
|
+
owner_email = candidate
|
|
1533
|
+
|
|
1534
|
+
if to:
|
|
1535
|
+
if owner_email and owner_email.lower() not in seen:
|
|
1536
|
+
cc.append(owner_email)
|
|
1537
|
+
else:
|
|
1538
|
+
if owner_email:
|
|
1539
|
+
to.append(owner_email)
|
|
1540
|
+
seen.add(owner_email.lower())
|
|
1541
|
+
else:
|
|
1542
|
+
admin_email = (
|
|
1543
|
+
get_user_model()
|
|
1544
|
+
.objects.filter(is_superuser=True, is_active=True)
|
|
1545
|
+
.exclude(email="")
|
|
1546
|
+
.values_list("email", flat=True)
|
|
1547
|
+
.first()
|
|
1548
|
+
)
|
|
1549
|
+
if admin_email:
|
|
1550
|
+
to.append(admin_email)
|
|
1551
|
+
seen.add(admin_email.lower())
|
|
1552
|
+
elif settings.DEFAULT_FROM_EMAIL:
|
|
1553
|
+
to.append(settings.DEFAULT_FROM_EMAIL)
|
|
1554
|
+
|
|
1555
|
+
return to, cc
|
|
1556
|
+
|
|
1557
|
+
def get_outbox(self):
|
|
1558
|
+
"""Return the preferred :class:`nodes.models.EmailOutbox` instance."""
|
|
1559
|
+
|
|
1560
|
+
from nodes.models import EmailOutbox, Node
|
|
1561
|
+
|
|
1562
|
+
if self.owner:
|
|
1563
|
+
try:
|
|
1564
|
+
outbox = self.owner.get_profile(EmailOutbox)
|
|
1565
|
+
except Exception: # pragma: no cover - defensive catch
|
|
1566
|
+
outbox = None
|
|
1567
|
+
if outbox:
|
|
1568
|
+
return outbox
|
|
1569
|
+
|
|
1570
|
+
node = Node.get_local()
|
|
1571
|
+
if node:
|
|
1572
|
+
return getattr(node, "email_outbox", None)
|
|
1573
|
+
return None
|
|
1574
|
+
|
|
1575
|
+
def notify_failure(self, message: str):
|
|
1576
|
+
from nodes.models import NetMessage
|
|
1577
|
+
|
|
1578
|
+
NetMessage.broadcast("Client report delivery issue", message)
|
|
1579
|
+
|
|
1580
|
+
def run(self):
|
|
1581
|
+
"""Generate the report, persist it and deliver notifications."""
|
|
1582
|
+
|
|
1583
|
+
from core import mailer
|
|
1584
|
+
|
|
1585
|
+
try:
|
|
1586
|
+
start, end = self.calculate_period()
|
|
1587
|
+
except ValueError:
|
|
1588
|
+
return None
|
|
1589
|
+
|
|
1590
|
+
try:
|
|
1591
|
+
report = ClientReport.generate(
|
|
1592
|
+
start,
|
|
1593
|
+
end,
|
|
1594
|
+
owner=self.owner,
|
|
1595
|
+
schedule=self,
|
|
1596
|
+
recipients=self.email_recipients,
|
|
1597
|
+
disable_emails=self.disable_emails,
|
|
1598
|
+
)
|
|
1599
|
+
export, html_content = report.store_local_copy()
|
|
1600
|
+
except Exception as exc:
|
|
1601
|
+
self.notify_failure(str(exc))
|
|
1602
|
+
raise
|
|
1603
|
+
|
|
1604
|
+
if not self.disable_emails:
|
|
1605
|
+
to, cc = self.resolve_recipients()
|
|
1606
|
+
if not to:
|
|
1607
|
+
self.notify_failure("No recipients available for client report")
|
|
1608
|
+
raise RuntimeError("No recipients available for client report")
|
|
1609
|
+
else:
|
|
1610
|
+
try:
|
|
1611
|
+
attachments = []
|
|
1612
|
+
html_name = Path(export["html_path"]).name
|
|
1613
|
+
attachments.append((html_name, html_content, "text/html"))
|
|
1614
|
+
json_file = Path(settings.BASE_DIR) / export["json_path"]
|
|
1615
|
+
if json_file.exists():
|
|
1616
|
+
attachments.append(
|
|
1617
|
+
(
|
|
1618
|
+
json_file.name,
|
|
1619
|
+
json_file.read_text(encoding="utf-8"),
|
|
1620
|
+
"application/json",
|
|
1621
|
+
)
|
|
1622
|
+
)
|
|
1623
|
+
subject = f"Client report {report.start_date} to {report.end_date}"
|
|
1624
|
+
body = (
|
|
1625
|
+
"Attached is the client report generated for the period "
|
|
1626
|
+
f"{report.start_date} to {report.end_date}."
|
|
1627
|
+
)
|
|
1628
|
+
mailer.send(
|
|
1629
|
+
subject,
|
|
1630
|
+
body,
|
|
1631
|
+
to,
|
|
1632
|
+
outbox=self.get_outbox(),
|
|
1633
|
+
cc=cc,
|
|
1634
|
+
attachments=attachments,
|
|
1635
|
+
)
|
|
1636
|
+
delivered = list(dict.fromkeys(to + (cc or [])))
|
|
1637
|
+
if delivered:
|
|
1638
|
+
type(report).objects.filter(pk=report.pk).update(
|
|
1639
|
+
recipients=delivered
|
|
1640
|
+
)
|
|
1641
|
+
report.recipients = delivered
|
|
1642
|
+
except Exception as exc:
|
|
1643
|
+
self.notify_failure(str(exc))
|
|
1644
|
+
raise
|
|
1645
|
+
|
|
1646
|
+
now = timezone.now()
|
|
1647
|
+
type(self).objects.filter(pk=self.pk).update(last_generated_on=now)
|
|
1648
|
+
self.last_generated_on = now
|
|
1649
|
+
return report
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
class ClientReport(Entity):
|
|
1653
|
+
"""Snapshot of energy usage over a period."""
|
|
1654
|
+
|
|
1655
|
+
start_date = models.DateField()
|
|
1656
|
+
end_date = models.DateField()
|
|
1657
|
+
created_on = models.DateTimeField(auto_now_add=True)
|
|
1658
|
+
data = models.JSONField(default=dict)
|
|
1659
|
+
owner = models.ForeignKey(
|
|
1660
|
+
settings.AUTH_USER_MODEL,
|
|
1661
|
+
on_delete=models.SET_NULL,
|
|
1662
|
+
null=True,
|
|
1663
|
+
blank=True,
|
|
1664
|
+
related_name="client_reports",
|
|
1665
|
+
)
|
|
1666
|
+
schedule = models.ForeignKey(
|
|
1667
|
+
"ClientReportSchedule",
|
|
1668
|
+
on_delete=models.SET_NULL,
|
|
1669
|
+
null=True,
|
|
1670
|
+
blank=True,
|
|
1671
|
+
related_name="reports",
|
|
1672
|
+
)
|
|
1673
|
+
recipients = models.JSONField(default=list, blank=True)
|
|
1674
|
+
disable_emails = models.BooleanField(default=False)
|
|
1675
|
+
|
|
1676
|
+
class Meta:
|
|
1677
|
+
verbose_name = "Client Report"
|
|
1678
|
+
verbose_name_plural = "Client Reports"
|
|
1679
|
+
db_table = "core_client_report"
|
|
1680
|
+
ordering = ["-created_on"]
|
|
1681
|
+
|
|
1682
|
+
@classmethod
|
|
1683
|
+
def generate(
|
|
1684
|
+
cls,
|
|
1685
|
+
start_date,
|
|
1686
|
+
end_date,
|
|
1687
|
+
*,
|
|
1688
|
+
owner=None,
|
|
1689
|
+
schedule=None,
|
|
1690
|
+
recipients: list[str] | None = None,
|
|
1691
|
+
disable_emails: bool = False,
|
|
1692
|
+
):
|
|
1693
|
+
rows = cls.build_rows(start_date, end_date)
|
|
1694
|
+
return cls.objects.create(
|
|
1695
|
+
start_date=start_date,
|
|
1696
|
+
end_date=end_date,
|
|
1697
|
+
data={"rows": rows},
|
|
1698
|
+
owner=owner,
|
|
1699
|
+
schedule=schedule,
|
|
1700
|
+
recipients=list(recipients or []),
|
|
1701
|
+
disable_emails=disable_emails,
|
|
1702
|
+
)
|
|
1703
|
+
|
|
1704
|
+
def store_local_copy(self, html: str | None = None):
|
|
1705
|
+
"""Persist the report data and optional HTML rendering to disk."""
|
|
1706
|
+
|
|
1707
|
+
import json as _json
|
|
1708
|
+
from django.template.loader import render_to_string
|
|
1709
|
+
|
|
1710
|
+
base_dir = Path(settings.BASE_DIR)
|
|
1711
|
+
report_dir = base_dir / "work" / "reports"
|
|
1712
|
+
report_dir.mkdir(parents=True, exist_ok=True)
|
|
1713
|
+
timestamp = timezone.now().strftime("%Y%m%d%H%M%S")
|
|
1714
|
+
identifier = f"client_report_{self.pk}_{timestamp}"
|
|
1715
|
+
|
|
1716
|
+
html_content = html or render_to_string(
|
|
1717
|
+
"core/reports/client_report_email.html", {"report": self}
|
|
1718
|
+
)
|
|
1719
|
+
html_path = report_dir / f"{identifier}.html"
|
|
1720
|
+
html_path.write_text(html_content, encoding="utf-8")
|
|
1721
|
+
|
|
1722
|
+
json_path = report_dir / f"{identifier}.json"
|
|
1723
|
+
json_path.write_text(
|
|
1724
|
+
_json.dumps(self.data, indent=2, default=str), encoding="utf-8"
|
|
1725
|
+
)
|
|
1726
|
+
|
|
1727
|
+
def _relative(path: Path) -> str:
|
|
1728
|
+
try:
|
|
1729
|
+
return str(path.relative_to(base_dir))
|
|
1730
|
+
except ValueError:
|
|
1731
|
+
return str(path)
|
|
1732
|
+
|
|
1733
|
+
export = {
|
|
1734
|
+
"html_path": _relative(html_path),
|
|
1735
|
+
"json_path": _relative(json_path),
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
updated = dict(self.data)
|
|
1739
|
+
updated["export"] = export
|
|
1740
|
+
type(self).objects.filter(pk=self.pk).update(data=updated)
|
|
1741
|
+
self.data = updated
|
|
1742
|
+
return export, html_content
|
|
1743
|
+
|
|
1744
|
+
@staticmethod
|
|
1745
|
+
def build_rows(start_date=None, end_date=None):
|
|
1746
|
+
from collections import defaultdict
|
|
1747
|
+
from ocpp.models import Transaction
|
|
1748
|
+
|
|
1749
|
+
qs = Transaction.objects.exclude(rfid="")
|
|
1750
|
+
if start_date:
|
|
1751
|
+
from datetime import datetime, time, timedelta, timezone as pytimezone
|
|
1752
|
+
|
|
1753
|
+
start_dt = datetime.combine(start_date, time.min, tzinfo=pytimezone.utc)
|
|
1754
|
+
qs = qs.filter(start_time__gte=start_dt)
|
|
1755
|
+
if end_date:
|
|
1756
|
+
from datetime import datetime, time, timedelta, timezone as pytimezone
|
|
1757
|
+
|
|
1758
|
+
end_dt = datetime.combine(
|
|
1759
|
+
end_date + timedelta(days=1), time.min, tzinfo=pytimezone.utc
|
|
1760
|
+
)
|
|
1761
|
+
qs = qs.filter(start_time__lt=end_dt)
|
|
1762
|
+
data = defaultdict(lambda: {"kw": 0.0, "count": 0})
|
|
1763
|
+
for tx in qs:
|
|
1764
|
+
data[tx.rfid]["kw"] += tx.kw
|
|
1765
|
+
data[tx.rfid]["count"] += 1
|
|
1766
|
+
rows = []
|
|
1767
|
+
for rfid_uid, stats in sorted(data.items()):
|
|
1768
|
+
tag = RFID.objects.filter(rfid=rfid_uid).first()
|
|
1769
|
+
if tag:
|
|
1770
|
+
account = tag.energy_accounts.first()
|
|
1771
|
+
if account:
|
|
1772
|
+
subject = account.name
|
|
1773
|
+
else:
|
|
1774
|
+
subject = str(tag.label_id)
|
|
1775
|
+
else:
|
|
1776
|
+
subject = rfid_uid
|
|
1777
|
+
rows.append(
|
|
1778
|
+
{"subject": subject, "kw": stats["kw"], "count": stats["count"]}
|
|
1779
|
+
)
|
|
1780
|
+
return rows
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
class BrandManager(EntityManager):
|
|
1784
|
+
def get_by_natural_key(self, name: str):
|
|
1785
|
+
return self.get(name=name)
|
|
1786
|
+
|
|
1787
|
+
|
|
953
1788
|
class Brand(Entity):
|
|
954
1789
|
"""Vehicle manufacturer or brand."""
|
|
955
1790
|
|
|
956
1791
|
name = models.CharField(max_length=100, unique=True)
|
|
957
1792
|
|
|
1793
|
+
objects = BrandManager()
|
|
1794
|
+
|
|
958
1795
|
class Meta:
|
|
959
1796
|
verbose_name = _("EV Brand")
|
|
960
1797
|
verbose_name_plural = _("EV Brands")
|
|
@@ -962,6 +1799,9 @@ class Brand(Entity):
|
|
|
962
1799
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
963
1800
|
return self.name
|
|
964
1801
|
|
|
1802
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
1803
|
+
return (self.name,)
|
|
1804
|
+
|
|
965
1805
|
@classmethod
|
|
966
1806
|
def from_vin(cls, vin: str) -> "Brand | None":
|
|
967
1807
|
"""Return the brand matching the VIN's WMI prefix."""
|
|
@@ -990,6 +1830,48 @@ class EVModel(Entity):
|
|
|
990
1830
|
|
|
991
1831
|
brand = models.ForeignKey(Brand, on_delete=models.CASCADE, related_name="ev_models")
|
|
992
1832
|
name = models.CharField(max_length=100)
|
|
1833
|
+
battery_capacity_kwh = models.DecimalField(
|
|
1834
|
+
max_digits=6,
|
|
1835
|
+
decimal_places=2,
|
|
1836
|
+
null=True,
|
|
1837
|
+
blank=True,
|
|
1838
|
+
verbose_name="Battery Capacity (kWh)",
|
|
1839
|
+
)
|
|
1840
|
+
est_battery_kwh = models.DecimalField(
|
|
1841
|
+
max_digits=6,
|
|
1842
|
+
decimal_places=2,
|
|
1843
|
+
null=True,
|
|
1844
|
+
blank=True,
|
|
1845
|
+
verbose_name="Estimated Battery (kWh)",
|
|
1846
|
+
)
|
|
1847
|
+
ac_110v_power_kw = models.DecimalField(
|
|
1848
|
+
max_digits=5,
|
|
1849
|
+
decimal_places=2,
|
|
1850
|
+
null=True,
|
|
1851
|
+
blank=True,
|
|
1852
|
+
verbose_name="110V AC (kW)",
|
|
1853
|
+
)
|
|
1854
|
+
ac_220v_power_kw = models.DecimalField(
|
|
1855
|
+
max_digits=5,
|
|
1856
|
+
decimal_places=2,
|
|
1857
|
+
null=True,
|
|
1858
|
+
blank=True,
|
|
1859
|
+
verbose_name="220V AC (kW)",
|
|
1860
|
+
)
|
|
1861
|
+
dc_60_power_kw = models.DecimalField(
|
|
1862
|
+
max_digits=5,
|
|
1863
|
+
decimal_places=2,
|
|
1864
|
+
null=True,
|
|
1865
|
+
blank=True,
|
|
1866
|
+
verbose_name="60kW DC (kW)",
|
|
1867
|
+
)
|
|
1868
|
+
dc_100_power_kw = models.DecimalField(
|
|
1869
|
+
max_digits=5,
|
|
1870
|
+
decimal_places=2,
|
|
1871
|
+
null=True,
|
|
1872
|
+
blank=True,
|
|
1873
|
+
verbose_name="100kW DC (kW)",
|
|
1874
|
+
)
|
|
993
1875
|
|
|
994
1876
|
class Meta:
|
|
995
1877
|
unique_together = ("brand", "name")
|
|
@@ -1021,9 +1903,7 @@ class ElectricVehicle(Entity):
|
|
|
1021
1903
|
related_name="vehicles",
|
|
1022
1904
|
)
|
|
1023
1905
|
vin = models.CharField(max_length=17, unique=True, verbose_name="VIN")
|
|
1024
|
-
license_plate = models.CharField(
|
|
1025
|
-
_("License Plate"), max_length=20, blank=True
|
|
1026
|
-
)
|
|
1906
|
+
license_plate = models.CharField(_("License Plate"), max_length=20, blank=True)
|
|
1027
1907
|
|
|
1028
1908
|
def save(self, *args, **kwargs):
|
|
1029
1909
|
if self.model and not self.brand:
|
|
@@ -1047,30 +1927,16 @@ class Product(Entity):
|
|
|
1047
1927
|
name = models.CharField(max_length=100)
|
|
1048
1928
|
description = models.TextField(blank=True)
|
|
1049
1929
|
renewal_period = models.PositiveIntegerField(help_text="Renewal period in days")
|
|
1930
|
+
odoo_product = models.JSONField(
|
|
1931
|
+
null=True,
|
|
1932
|
+
blank=True,
|
|
1933
|
+
help_text="Selected product from Odoo (id and name)",
|
|
1934
|
+
)
|
|
1050
1935
|
|
|
1051
1936
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1052
1937
|
return self.name
|
|
1053
1938
|
|
|
1054
1939
|
|
|
1055
|
-
class Subscription(Entity):
|
|
1056
|
-
"""An energy account's subscription to a product."""
|
|
1057
|
-
|
|
1058
|
-
account = models.ForeignKey(EnergyAccount, on_delete=models.CASCADE)
|
|
1059
|
-
product = models.ForeignKey(Product, on_delete=models.CASCADE)
|
|
1060
|
-
start_date = models.DateField(auto_now_add=True)
|
|
1061
|
-
next_renewal = models.DateField(blank=True)
|
|
1062
|
-
|
|
1063
|
-
def save(self, *args, **kwargs):
|
|
1064
|
-
if not self.next_renewal:
|
|
1065
|
-
self.next_renewal = self.start_date + timedelta(
|
|
1066
|
-
days=self.product.renewal_period
|
|
1067
|
-
)
|
|
1068
|
-
super().save(*args, **kwargs)
|
|
1069
|
-
|
|
1070
|
-
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1071
|
-
return f"{self.account.user} -> {self.product}"
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
1940
|
class AdminHistory(Entity):
|
|
1075
1941
|
"""Record of recently visited admin changelists for a user."""
|
|
1076
1942
|
|
|
@@ -1093,11 +1959,48 @@ class AdminHistory(Entity):
|
|
|
1093
1959
|
return model._meta.verbose_name_plural if model else self.content_type.name
|
|
1094
1960
|
|
|
1095
1961
|
|
|
1096
|
-
class
|
|
1962
|
+
class ReleaseManagerManager(EntityManager):
|
|
1963
|
+
def get_by_natural_key(self, owner, package=None):
|
|
1964
|
+
owner = owner or ""
|
|
1965
|
+
if owner.startswith("group:"):
|
|
1966
|
+
group_name = owner.split(":", 1)[1]
|
|
1967
|
+
return self.get(group__name=group_name)
|
|
1968
|
+
return self.get(user__username=owner)
|
|
1969
|
+
|
|
1970
|
+
|
|
1971
|
+
class PackageManager(EntityManager):
|
|
1972
|
+
def get_by_natural_key(self, name):
|
|
1973
|
+
return self.get(name=name)
|
|
1974
|
+
|
|
1975
|
+
|
|
1976
|
+
class PackageReleaseManager(EntityManager):
|
|
1977
|
+
def get_by_natural_key(self, package, version):
|
|
1978
|
+
return self.get(package__name=package, version=version)
|
|
1979
|
+
|
|
1980
|
+
|
|
1981
|
+
class ReleaseManager(Profile):
|
|
1097
1982
|
"""Store credentials for publishing packages."""
|
|
1098
1983
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1984
|
+
objects = ReleaseManagerManager()
|
|
1985
|
+
|
|
1986
|
+
def natural_key(self):
|
|
1987
|
+
owner = self.owner_display()
|
|
1988
|
+
if self.group_id and owner:
|
|
1989
|
+
owner = f"group:{owner}"
|
|
1990
|
+
|
|
1991
|
+
pkg_name = ""
|
|
1992
|
+
if self.pk:
|
|
1993
|
+
pkg = self.package_set.first()
|
|
1994
|
+
pkg_name = pkg.name if pkg else ""
|
|
1995
|
+
|
|
1996
|
+
return (owner or "", pkg_name)
|
|
1997
|
+
|
|
1998
|
+
profile_fields = (
|
|
1999
|
+
"pypi_username",
|
|
2000
|
+
"pypi_token",
|
|
2001
|
+
"github_token",
|
|
2002
|
+
"pypi_password",
|
|
2003
|
+
"pypi_url",
|
|
1101
2004
|
)
|
|
1102
2005
|
pypi_username = SigilShortAutoField("PyPI username", max_length=100, blank=True)
|
|
1103
2006
|
pypi_token = SigilShortAutoField("PyPI token", max_length=200, blank=True)
|
|
@@ -1115,34 +2018,43 @@ class ReleaseManager(Entity):
|
|
|
1115
2018
|
class Meta:
|
|
1116
2019
|
verbose_name = "Release Manager"
|
|
1117
2020
|
verbose_name_plural = "Release Managers"
|
|
2021
|
+
constraints = [
|
|
2022
|
+
models.CheckConstraint(
|
|
2023
|
+
check=(
|
|
2024
|
+
(Q(user__isnull=False) & Q(group__isnull=True))
|
|
2025
|
+
| (Q(user__isnull=True) & Q(group__isnull=False))
|
|
2026
|
+
),
|
|
2027
|
+
name="releasemanager_requires_owner",
|
|
2028
|
+
)
|
|
2029
|
+
]
|
|
1118
2030
|
|
|
1119
2031
|
def __str__(self) -> str: # pragma: no cover - trivial
|
|
1120
2032
|
return self.name
|
|
1121
2033
|
|
|
1122
2034
|
@property
|
|
1123
2035
|
def name(self) -> str: # pragma: no cover - simple proxy
|
|
1124
|
-
|
|
2036
|
+
owner = self.owner_display()
|
|
2037
|
+
return owner or ""
|
|
1125
2038
|
|
|
1126
2039
|
def to_credentials(self) -> Credentials | None:
|
|
1127
2040
|
"""Return credentials for this release manager."""
|
|
1128
2041
|
if self.pypi_token:
|
|
1129
2042
|
return Credentials(token=self.pypi_token)
|
|
1130
2043
|
if self.pypi_username and self.pypi_password:
|
|
1131
|
-
return Credentials(
|
|
1132
|
-
username=self.pypi_username, password=self.pypi_password
|
|
1133
|
-
)
|
|
2044
|
+
return Credentials(username=self.pypi_username, password=self.pypi_password)
|
|
1134
2045
|
return None
|
|
1135
2046
|
|
|
1136
2047
|
|
|
1137
2048
|
class Package(Entity):
|
|
1138
2049
|
"""Package details shared across releases."""
|
|
1139
2050
|
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
)
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
)
|
|
2051
|
+
objects = PackageManager()
|
|
2052
|
+
|
|
2053
|
+
def natural_key(self):
|
|
2054
|
+
return (self.name,)
|
|
2055
|
+
|
|
2056
|
+
name = models.CharField(max_length=100, default=DEFAULT_PACKAGE.name, unique=True)
|
|
2057
|
+
description = models.CharField(max_length=255, default=DEFAULT_PACKAGE.description)
|
|
1146
2058
|
author = models.CharField(max_length=100, default=DEFAULT_PACKAGE.author)
|
|
1147
2059
|
email = models.EmailField(default=DEFAULT_PACKAGE.email)
|
|
1148
2060
|
python_requires = models.CharField(
|
|
@@ -1191,9 +2103,15 @@ class Package(Entity):
|
|
|
1191
2103
|
homepage_url=self.homepage_url,
|
|
1192
2104
|
)
|
|
1193
2105
|
|
|
2106
|
+
|
|
1194
2107
|
class PackageRelease(Entity):
|
|
1195
2108
|
"""Store metadata for a specific package version."""
|
|
1196
2109
|
|
|
2110
|
+
objects = PackageReleaseManager()
|
|
2111
|
+
|
|
2112
|
+
def natural_key(self):
|
|
2113
|
+
return (self.package.name, self.version)
|
|
2114
|
+
|
|
1197
2115
|
package = models.ForeignKey(
|
|
1198
2116
|
Package, on_delete=models.CASCADE, related_name="releases"
|
|
1199
2117
|
)
|
|
@@ -1218,10 +2136,15 @@ class PackageRelease(Entity):
|
|
|
1218
2136
|
|
|
1219
2137
|
@classmethod
|
|
1220
2138
|
def dump_fixture(cls) -> None:
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
2139
|
+
base = Path("core/fixtures")
|
|
2140
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
2141
|
+
for old in base.glob("releases__*.json"):
|
|
2142
|
+
old.unlink()
|
|
2143
|
+
for release in cls.objects.all():
|
|
2144
|
+
name = f"releases__packagerelease_{release.version.replace('.', '_')}.json"
|
|
2145
|
+
path = base / name
|
|
2146
|
+
data = serializers.serialize("json", [release])
|
|
2147
|
+
path.write_text(data)
|
|
1225
2148
|
|
|
1226
2149
|
def __str__(self) -> str: # pragma: no cover - trivial
|
|
1227
2150
|
return f"{self.package.name} {self.version}"
|
|
@@ -1300,13 +2223,16 @@ class PackageRelease(Entity):
|
|
|
1300
2223
|
self.save(update_fields=["revision"])
|
|
1301
2224
|
PackageRelease.dump_fixture()
|
|
1302
2225
|
if kwargs.get("git"):
|
|
2226
|
+
from glob import glob
|
|
2227
|
+
|
|
2228
|
+
paths = sorted(glob("core/fixtures/releases__*.json"))
|
|
1303
2229
|
diff = subprocess.run(
|
|
1304
|
-
["git", "status", "--porcelain",
|
|
2230
|
+
["git", "status", "--porcelain", *paths],
|
|
1305
2231
|
capture_output=True,
|
|
1306
2232
|
text=True,
|
|
1307
2233
|
)
|
|
1308
2234
|
if diff.stdout.strip():
|
|
1309
|
-
release_utils._run(["git", "add",
|
|
2235
|
+
release_utils._run(["git", "add", *paths])
|
|
1310
2236
|
release_utils._run(
|
|
1311
2237
|
[
|
|
1312
2238
|
"git",
|
|
@@ -1321,20 +2247,27 @@ class PackageRelease(Entity):
|
|
|
1321
2247
|
def revision_short(self) -> str:
|
|
1322
2248
|
return self.revision[-6:] if self.revision else ""
|
|
1323
2249
|
|
|
2250
|
+
|
|
1324
2251
|
# Ensure each RFID can only be linked to one energy account
|
|
1325
2252
|
@receiver(m2m_changed, sender=EnergyAccount.rfids.through)
|
|
1326
|
-
def _rfid_unique_energy_account(
|
|
2253
|
+
def _rfid_unique_energy_account(
|
|
2254
|
+
sender, instance, action, reverse, model, pk_set, **kwargs
|
|
2255
|
+
):
|
|
1327
2256
|
"""Prevent associating an RFID with more than one energy account."""
|
|
1328
2257
|
if action == "pre_add":
|
|
1329
2258
|
if reverse: # adding energy accounts to an RFID
|
|
1330
2259
|
if instance.energy_accounts.exclude(pk__in=pk_set).exists():
|
|
1331
|
-
raise ValidationError(
|
|
2260
|
+
raise ValidationError(
|
|
2261
|
+
"RFID tags may only be assigned to one energy account."
|
|
2262
|
+
)
|
|
1332
2263
|
else: # adding RFIDs to an energy account
|
|
1333
2264
|
conflict = model.objects.filter(
|
|
1334
2265
|
pk__in=pk_set, energy_accounts__isnull=False
|
|
1335
2266
|
).exclude(energy_accounts=instance)
|
|
1336
2267
|
if conflict.exists():
|
|
1337
|
-
raise ValidationError(
|
|
2268
|
+
raise ValidationError(
|
|
2269
|
+
"RFID tags may only be assigned to one energy account."
|
|
2270
|
+
)
|
|
1338
2271
|
|
|
1339
2272
|
|
|
1340
2273
|
def hash_key(key: str) -> str:
|
|
@@ -1343,7 +2276,7 @@ def hash_key(key: str) -> str:
|
|
|
1343
2276
|
return hashlib.sha256(key.encode()).hexdigest()
|
|
1344
2277
|
|
|
1345
2278
|
|
|
1346
|
-
class
|
|
2279
|
+
class AssistantProfile(Profile):
|
|
1347
2280
|
"""Stores a hashed user key used by the assistant for authentication.
|
|
1348
2281
|
|
|
1349
2282
|
The plain-text ``user_key`` is generated server-side and shown only once.
|
|
@@ -1352,9 +2285,7 @@ class ChatProfile(models.Model):
|
|
|
1352
2285
|
"""
|
|
1353
2286
|
|
|
1354
2287
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
1355
|
-
|
|
1356
|
-
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="chat_profile"
|
|
1357
|
-
)
|
|
2288
|
+
profile_fields = ("user_key_hash", "scopes", "is_active")
|
|
1358
2289
|
user_key_hash = models.CharField(max_length=64, unique=True)
|
|
1359
2290
|
scopes = models.JSONField(default=list, blank=True)
|
|
1360
2291
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
@@ -1362,19 +2293,35 @@ class ChatProfile(models.Model):
|
|
|
1362
2293
|
is_active = models.BooleanField(default=True)
|
|
1363
2294
|
|
|
1364
2295
|
class Meta:
|
|
1365
|
-
db_table = "
|
|
1366
|
-
verbose_name = "
|
|
1367
|
-
verbose_name_plural = "
|
|
2296
|
+
db_table = "workgroup_assistantprofile"
|
|
2297
|
+
verbose_name = "Assistant Profile"
|
|
2298
|
+
verbose_name_plural = "Assistant Profiles"
|
|
2299
|
+
constraints = [
|
|
2300
|
+
models.CheckConstraint(
|
|
2301
|
+
check=(
|
|
2302
|
+
(Q(user__isnull=False) & Q(group__isnull=True))
|
|
2303
|
+
| (Q(user__isnull=True) & Q(group__isnull=False))
|
|
2304
|
+
),
|
|
2305
|
+
name="assistantprofile_requires_owner",
|
|
2306
|
+
)
|
|
2307
|
+
]
|
|
1368
2308
|
|
|
1369
2309
|
@classmethod
|
|
1370
|
-
def issue_key(cls, user) -> tuple["
|
|
2310
|
+
def issue_key(cls, user) -> tuple["AssistantProfile", str]:
|
|
1371
2311
|
"""Create or update a profile and return it with a new plain key."""
|
|
1372
2312
|
|
|
1373
2313
|
key = secrets.token_hex(32)
|
|
1374
2314
|
key_hash = hash_key(key)
|
|
2315
|
+
if user is None:
|
|
2316
|
+
raise ValueError("Assistant profiles require a user instance")
|
|
2317
|
+
|
|
1375
2318
|
profile, _ = cls.objects.update_or_create(
|
|
1376
2319
|
user=user,
|
|
1377
|
-
defaults={
|
|
2320
|
+
defaults={
|
|
2321
|
+
"user_key_hash": key_hash,
|
|
2322
|
+
"last_used_at": None,
|
|
2323
|
+
"is_active": True,
|
|
2324
|
+
},
|
|
1378
2325
|
)
|
|
1379
2326
|
return profile, key
|
|
1380
2327
|
|
|
@@ -1385,4 +2332,69 @@ class ChatProfile(models.Model):
|
|
|
1385
2332
|
self.save(update_fields=["last_used_at"])
|
|
1386
2333
|
|
|
1387
2334
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1388
|
-
|
|
2335
|
+
owner = self.owner_display()
|
|
2336
|
+
return f"AssistantProfile for {owner}" if owner else "AssistantProfile"
|
|
2337
|
+
|
|
2338
|
+
|
|
2339
|
+
def validate_relative_url(value: str) -> None:
|
|
2340
|
+
if not value:
|
|
2341
|
+
return
|
|
2342
|
+
parsed = urlparse(value)
|
|
2343
|
+
if parsed.scheme or parsed.netloc or not value.startswith("/"):
|
|
2344
|
+
raise ValidationError("URL must be relative")
|
|
2345
|
+
|
|
2346
|
+
|
|
2347
|
+
class TodoManager(EntityManager):
|
|
2348
|
+
def get_by_natural_key(self, request: str):
|
|
2349
|
+
return self.get(request=request)
|
|
2350
|
+
|
|
2351
|
+
|
|
2352
|
+
class Todo(Entity):
|
|
2353
|
+
"""Tasks requested for the Release Manager."""
|
|
2354
|
+
|
|
2355
|
+
request = models.CharField(max_length=255)
|
|
2356
|
+
url = models.CharField(
|
|
2357
|
+
max_length=200, blank=True, default="", validators=[validate_relative_url]
|
|
2358
|
+
)
|
|
2359
|
+
request_details = models.TextField(blank=True, default="")
|
|
2360
|
+
done_on = models.DateTimeField(null=True, blank=True)
|
|
2361
|
+
on_done_condition = ConditionTextField(blank=True, default="")
|
|
2362
|
+
|
|
2363
|
+
objects = TodoManager()
|
|
2364
|
+
|
|
2365
|
+
class Meta:
|
|
2366
|
+
verbose_name = "TODO"
|
|
2367
|
+
verbose_name_plural = "TODOs"
|
|
2368
|
+
constraints = [
|
|
2369
|
+
models.UniqueConstraint(
|
|
2370
|
+
Lower("request"),
|
|
2371
|
+
condition=Q(is_deleted=False),
|
|
2372
|
+
name="unique_active_todo_request",
|
|
2373
|
+
)
|
|
2374
|
+
]
|
|
2375
|
+
|
|
2376
|
+
def clean(self):
|
|
2377
|
+
super().clean()
|
|
2378
|
+
if (
|
|
2379
|
+
Todo.objects.filter(request__iexact=self.request, is_deleted=False)
|
|
2380
|
+
.exclude(pk=self.pk)
|
|
2381
|
+
.exists()
|
|
2382
|
+
):
|
|
2383
|
+
raise ValidationError({"request": "Similar TODO already exists."})
|
|
2384
|
+
|
|
2385
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
2386
|
+
return self.request
|
|
2387
|
+
|
|
2388
|
+
def natural_key(self):
|
|
2389
|
+
"""Use the request field as the natural key."""
|
|
2390
|
+
return (self.request,)
|
|
2391
|
+
|
|
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, "")
|