arthexis 0.1.8__py3-none-any.whl → 0.1.9__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.9.dist-info}/METADATA +42 -4
- arthexis-0.1.9.dist-info/RECORD +92 -0
- arthexis-0.1.9.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 +133 -16
- config/urls.py +65 -6
- core/admin.py +1226 -191
- core/admin_history.py +50 -0
- core/admindocs.py +108 -1
- core/apps.py +158 -3
- core/backends.py +46 -4
- core/entity.py +62 -48
- core/fields.py +6 -1
- 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 +1071 -264
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/release.py +27 -20
- core/sigil_builder.py +131 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +129 -10
- core/tasks.py +118 -19
- core/test_system_info.py +22 -0
- core/tests.py +358 -63
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +329 -167
- core/views.py +383 -57
- 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 +159 -284
- nodes/apps.py +9 -15
- nodes/backends.py +53 -0
- nodes/lcd.py +24 -10
- nodes/models.py +375 -178
- nodes/tasks.py +1 -5
- nodes/tests.py +524 -129
- nodes/utils.py +13 -2
- nodes/views.py +66 -23
- ocpp/admin.py +150 -61
- ocpp/apps.py +1 -1
- ocpp/consumers.py +432 -69
- ocpp/evcs.py +25 -8
- ocpp/models.py +408 -68
- ocpp/simulator.py +13 -6
- ocpp/store.py +258 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1198 -135
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +654 -101
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +19 -6
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +759 -40
- pages/urls.py +3 -0
- pages/utils.py +0 -1
- pages/views.py +576 -25
- 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.9.dist-info}/WHEEL +0 -0
- {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/top_level.txt +0 -0
core/models.py
CHANGED
|
@@ -4,13 +4,15 @@ 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
12
|
from django.core.validators import 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
17
|
from datetime import timedelta
|
|
16
18
|
from django.contrib.contenttypes.models import ContentType
|
|
@@ -18,19 +20,25 @@ 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 .
|
|
41
|
+
from . import user_data # noqa: F401 - ensure signal registration
|
|
34
42
|
from .fields import SigilShortAutoField
|
|
35
43
|
|
|
36
44
|
|
|
@@ -48,6 +56,111 @@ class SecurityGroup(Group):
|
|
|
48
56
|
verbose_name_plural = "Security Groups"
|
|
49
57
|
|
|
50
58
|
|
|
59
|
+
class Profile(Entity):
|
|
60
|
+
"""Abstract base class for user or group scoped configuration."""
|
|
61
|
+
|
|
62
|
+
user = models.OneToOneField(
|
|
63
|
+
settings.AUTH_USER_MODEL,
|
|
64
|
+
null=True,
|
|
65
|
+
blank=True,
|
|
66
|
+
on_delete=models.CASCADE,
|
|
67
|
+
related_name="+",
|
|
68
|
+
)
|
|
69
|
+
group = models.OneToOneField(
|
|
70
|
+
"core.SecurityGroup",
|
|
71
|
+
null=True,
|
|
72
|
+
blank=True,
|
|
73
|
+
on_delete=models.CASCADE,
|
|
74
|
+
related_name="+",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
class Meta:
|
|
78
|
+
abstract = True
|
|
79
|
+
|
|
80
|
+
def clean(self):
|
|
81
|
+
super().clean()
|
|
82
|
+
if self.user_id and self.group_id:
|
|
83
|
+
raise ValidationError(
|
|
84
|
+
{
|
|
85
|
+
"user": _("Select either a user or a security group, not both."),
|
|
86
|
+
"group": _("Select either a user or a security group, not both."),
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
if not self.user_id and not self.group_id:
|
|
90
|
+
raise ValidationError(
|
|
91
|
+
_("Profiles must be assigned to a user or a security group."),
|
|
92
|
+
)
|
|
93
|
+
if self.user_id:
|
|
94
|
+
user_model = get_user_model()
|
|
95
|
+
username_cache = {"value": None}
|
|
96
|
+
|
|
97
|
+
def _resolve_username():
|
|
98
|
+
if username_cache["value"] is not None:
|
|
99
|
+
return username_cache["value"]
|
|
100
|
+
user_obj = getattr(self, "user", None)
|
|
101
|
+
username = getattr(user_obj, "username", None)
|
|
102
|
+
if not username:
|
|
103
|
+
manager = getattr(
|
|
104
|
+
user_model, "all_objects", user_model._default_manager
|
|
105
|
+
)
|
|
106
|
+
username = (
|
|
107
|
+
manager.filter(pk=self.user_id)
|
|
108
|
+
.values_list("username", flat=True)
|
|
109
|
+
.first()
|
|
110
|
+
)
|
|
111
|
+
username_cache["value"] = username
|
|
112
|
+
return username
|
|
113
|
+
|
|
114
|
+
is_restricted = getattr(user_model, "is_profile_restricted_username", None)
|
|
115
|
+
if callable(is_restricted):
|
|
116
|
+
username = _resolve_username()
|
|
117
|
+
if is_restricted(username):
|
|
118
|
+
raise ValidationError(
|
|
119
|
+
{
|
|
120
|
+
"user": _(
|
|
121
|
+
"The %(username)s account cannot have profiles attached."
|
|
122
|
+
)
|
|
123
|
+
% {"username": username}
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
system_username = getattr(user_model, "SYSTEM_USERNAME", None)
|
|
128
|
+
if system_username:
|
|
129
|
+
username = _resolve_username()
|
|
130
|
+
if user_model.is_system_username(username):
|
|
131
|
+
raise ValidationError(
|
|
132
|
+
{
|
|
133
|
+
"user": _(
|
|
134
|
+
"The %(username)s account cannot have profiles attached."
|
|
135
|
+
)
|
|
136
|
+
% {"username": username}
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def owner(self):
|
|
142
|
+
"""Return the assigned user or group."""
|
|
143
|
+
|
|
144
|
+
return self.user if self.user_id else self.group
|
|
145
|
+
|
|
146
|
+
def owner_display(self) -> str:
|
|
147
|
+
"""Return a human readable owner label."""
|
|
148
|
+
|
|
149
|
+
owner = self.owner
|
|
150
|
+
if owner is None: # pragma: no cover - guarded by ``clean``
|
|
151
|
+
return ""
|
|
152
|
+
if hasattr(owner, "get_username"):
|
|
153
|
+
return owner.get_username()
|
|
154
|
+
if hasattr(owner, "name"):
|
|
155
|
+
return owner.name
|
|
156
|
+
return str(owner)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class SigilRootManager(EntityManager):
|
|
160
|
+
def get_by_natural_key(self, prefix: str):
|
|
161
|
+
return self.get(prefix=prefix)
|
|
162
|
+
|
|
163
|
+
|
|
51
164
|
class SigilRoot(Entity):
|
|
52
165
|
class Context(models.TextChoices):
|
|
53
166
|
CONFIG = "config", "Configuration"
|
|
@@ -55,16 +168,32 @@ class SigilRoot(Entity):
|
|
|
55
168
|
|
|
56
169
|
prefix = models.CharField(max_length=50, unique=True)
|
|
57
170
|
context_type = models.CharField(max_length=20, choices=Context.choices)
|
|
171
|
+
content_type = models.ForeignKey(
|
|
172
|
+
ContentType, null=True, blank=True, on_delete=models.CASCADE
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
objects = SigilRootManager()
|
|
58
176
|
|
|
59
177
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
60
178
|
return self.prefix
|
|
61
179
|
|
|
180
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
181
|
+
return (self.prefix,)
|
|
182
|
+
|
|
62
183
|
class Meta:
|
|
63
184
|
verbose_name = "Sigil Root"
|
|
64
185
|
verbose_name_plural = "Sigil Roots"
|
|
65
186
|
|
|
66
187
|
|
|
67
|
-
class
|
|
188
|
+
class CustomSigil(SigilRoot):
|
|
189
|
+
class Meta:
|
|
190
|
+
proxy = True
|
|
191
|
+
app_label = "pages"
|
|
192
|
+
verbose_name = _("Custom Sigil")
|
|
193
|
+
verbose_name_plural = _("Custom Sigils")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class Lead(Entity):
|
|
68
197
|
"""Common request lead information."""
|
|
69
198
|
|
|
70
199
|
user = models.ForeignKey(
|
|
@@ -83,6 +212,9 @@ class Lead(models.Model):
|
|
|
83
212
|
class InviteLead(Lead):
|
|
84
213
|
email = models.EmailField()
|
|
85
214
|
comment = models.TextField(blank=True)
|
|
215
|
+
sent_on = models.DateTimeField(null=True, blank=True)
|
|
216
|
+
error = models.TextField(blank=True)
|
|
217
|
+
mac_address = models.CharField(max_length=17, blank=True)
|
|
86
218
|
|
|
87
219
|
class Meta:
|
|
88
220
|
verbose_name = "Invite Lead"
|
|
@@ -92,156 +224,66 @@ class InviteLead(Lead):
|
|
|
92
224
|
return self.email
|
|
93
225
|
|
|
94
226
|
|
|
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
|
-
]
|
|
227
|
+
class PublicWifiAccess(Entity):
|
|
228
|
+
"""Allow public Wi-Fi clients onto the wider internet."""
|
|
142
229
|
|
|
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
|
-
]
|
|
230
|
+
user = models.ForeignKey(
|
|
231
|
+
settings.AUTH_USER_MODEL,
|
|
232
|
+
on_delete=models.CASCADE,
|
|
233
|
+
related_name="public_wifi_accesses",
|
|
234
|
+
)
|
|
235
|
+
mac_address = models.CharField(max_length=17)
|
|
236
|
+
created_on = models.DateTimeField(auto_now_add=True)
|
|
237
|
+
updated_on = models.DateTimeField(auto_now=True)
|
|
238
|
+
revoked_on = models.DateTimeField(null=True, blank=True)
|
|
196
239
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
240
|
+
class Meta:
|
|
241
|
+
unique_together = ("user", "mac_address")
|
|
242
|
+
verbose_name = "Public Wi-Fi Access"
|
|
243
|
+
verbose_name_plural = "Public Wi-Fi Access"
|
|
201
244
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
]
|
|
245
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
246
|
+
return f"{self.user} -> {self.mac_address}"
|
|
205
247
|
|
|
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
248
|
|
|
212
|
-
|
|
213
|
-
|
|
249
|
+
@receiver(post_save, sender=settings.AUTH_USER_MODEL)
|
|
250
|
+
def _revoke_public_wifi_when_inactive(sender, instance, **kwargs):
|
|
251
|
+
if instance.is_active:
|
|
252
|
+
return
|
|
253
|
+
from core import public_wifi
|
|
214
254
|
|
|
215
|
-
|
|
216
|
-
from django.core.exceptions import ValidationError
|
|
255
|
+
public_wifi.revoke_public_access_for_user(instance)
|
|
217
256
|
|
|
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
257
|
|
|
224
|
-
|
|
225
|
-
|
|
258
|
+
@receiver(post_delete, sender=settings.AUTH_USER_MODEL)
|
|
259
|
+
def _cleanup_public_wifi_on_delete(sender, instance, **kwargs):
|
|
260
|
+
from core import public_wifi
|
|
261
|
+
|
|
262
|
+
public_wifi.revoke_public_access_for_user(instance)
|
|
226
263
|
|
|
227
264
|
|
|
228
265
|
class User(Entity, AbstractUser):
|
|
266
|
+
SYSTEM_USERNAME = "arthexis"
|
|
267
|
+
ADMIN_USERNAME = "admin"
|
|
268
|
+
PROFILE_RESTRICTED_USERNAMES = frozenset({SYSTEM_USERNAME, ADMIN_USERNAME})
|
|
269
|
+
|
|
229
270
|
objects = EntityUserManager()
|
|
230
271
|
all_objects = DjangoUserManager()
|
|
231
272
|
"""Custom user model."""
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
)
|
|
238
|
-
address = models.ForeignKey(
|
|
239
|
-
Address,
|
|
273
|
+
birthday = models.DateField(null=True, blank=True)
|
|
274
|
+
data_path = models.CharField(max_length=255, blank=True)
|
|
275
|
+
last_visit_ip_address = models.GenericIPAddressField(null=True, blank=True)
|
|
276
|
+
operate_as = models.ForeignKey(
|
|
277
|
+
"self",
|
|
240
278
|
null=True,
|
|
241
279
|
blank=True,
|
|
242
280
|
on_delete=models.SET_NULL,
|
|
281
|
+
related_name="operated_users",
|
|
282
|
+
help_text=(
|
|
283
|
+
"Operate using another user's permissions when additional authority is "
|
|
284
|
+
"required."
|
|
285
|
+
),
|
|
243
286
|
)
|
|
244
|
-
has_charger = models.BooleanField(default=False)
|
|
245
287
|
is_active = models.BooleanField(
|
|
246
288
|
_("active"),
|
|
247
289
|
default=True,
|
|
@@ -253,15 +295,173 @@ class User(Entity, AbstractUser):
|
|
|
253
295
|
def __str__(self):
|
|
254
296
|
return self.username
|
|
255
297
|
|
|
298
|
+
@classmethod
|
|
299
|
+
def is_system_username(cls, username):
|
|
300
|
+
return bool(username) and username == cls.SYSTEM_USERNAME
|
|
256
301
|
|
|
257
|
-
|
|
258
|
-
|
|
302
|
+
@classmethod
|
|
303
|
+
def is_profile_restricted_username(cls, username):
|
|
304
|
+
return bool(username) and username in cls.PROFILE_RESTRICTED_USERNAMES
|
|
259
305
|
|
|
260
|
-
|
|
306
|
+
@property
|
|
307
|
+
def is_system_user(self) -> bool:
|
|
308
|
+
return self.is_system_username(self.username)
|
|
309
|
+
|
|
310
|
+
@property
|
|
311
|
+
def is_profile_restricted(self) -> bool:
|
|
312
|
+
return self.is_profile_restricted_username(self.username)
|
|
313
|
+
|
|
314
|
+
def clean(self):
|
|
315
|
+
super().clean()
|
|
316
|
+
if not self.operate_as_id:
|
|
317
|
+
return
|
|
318
|
+
try:
|
|
319
|
+
delegate = self.operate_as
|
|
320
|
+
except type(self).DoesNotExist:
|
|
321
|
+
raise ValidationError({"operate_as": _("Selected user is not available.")})
|
|
322
|
+
errors = []
|
|
323
|
+
if delegate.pk == self.pk:
|
|
324
|
+
errors.append(_("Cannot operate as yourself."))
|
|
325
|
+
if getattr(delegate, "is_deleted", False):
|
|
326
|
+
errors.append(_("Cannot operate as a deleted user."))
|
|
327
|
+
if not self.is_staff:
|
|
328
|
+
errors.append(_("Only staff members may operate as another user."))
|
|
329
|
+
if delegate.is_staff and not self.is_superuser:
|
|
330
|
+
errors.append(_("Only superusers may operate as staff members."))
|
|
331
|
+
if errors:
|
|
332
|
+
raise ValidationError({"operate_as": errors})
|
|
333
|
+
|
|
334
|
+
def _delegate_for_permissions(self):
|
|
335
|
+
if not self.is_staff or not self.operate_as_id:
|
|
336
|
+
return None
|
|
337
|
+
try:
|
|
338
|
+
delegate = self.operate_as
|
|
339
|
+
except type(self).DoesNotExist:
|
|
340
|
+
return None
|
|
341
|
+
if delegate.pk == self.pk:
|
|
342
|
+
return None
|
|
343
|
+
if getattr(delegate, "is_deleted", False):
|
|
344
|
+
return None
|
|
345
|
+
if delegate.is_staff and not self.is_superuser:
|
|
346
|
+
return None
|
|
347
|
+
return delegate
|
|
348
|
+
|
|
349
|
+
def _check_operate_as_chain(self, predicate, visited=None):
|
|
350
|
+
if visited is None:
|
|
351
|
+
visited = set()
|
|
352
|
+
identifier = self.pk or id(self)
|
|
353
|
+
if identifier in visited:
|
|
354
|
+
return False
|
|
355
|
+
visited.add(identifier)
|
|
356
|
+
if predicate(self):
|
|
357
|
+
return True
|
|
358
|
+
delegate = self._delegate_for_permissions()
|
|
359
|
+
if not delegate:
|
|
360
|
+
return False
|
|
361
|
+
return delegate._check_operate_as_chain(predicate, visited)
|
|
362
|
+
|
|
363
|
+
def has_perm(self, perm, obj=None):
|
|
364
|
+
return self._check_operate_as_chain(
|
|
365
|
+
lambda user: super(User, user).has_perm(perm, obj)
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
def has_module_perms(self, app_label):
|
|
369
|
+
return self._check_operate_as_chain(
|
|
370
|
+
lambda user: super(User, user).has_module_perms(app_label)
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
def _profile_for(self, profile_cls: Type[Profile], user: "User"):
|
|
374
|
+
profile = profile_cls.objects.filter(user=user).first()
|
|
375
|
+
if profile:
|
|
376
|
+
return profile
|
|
377
|
+
group_ids = list(user.groups.values_list("id", flat=True))
|
|
378
|
+
if group_ids:
|
|
379
|
+
return profile_cls.objects.filter(group_id__in=group_ids).first()
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
def get_profile(self, profile_cls: Type[Profile]):
|
|
383
|
+
"""Return the first matching profile for the user or their delegate chain."""
|
|
384
|
+
|
|
385
|
+
if not isinstance(profile_cls, type) or not issubclass(profile_cls, Profile):
|
|
386
|
+
raise TypeError("profile_cls must be a Profile subclass")
|
|
387
|
+
|
|
388
|
+
result = None
|
|
389
|
+
|
|
390
|
+
def predicate(user: "User"):
|
|
391
|
+
nonlocal result
|
|
392
|
+
result = self._profile_for(profile_cls, user)
|
|
393
|
+
return result is not None
|
|
394
|
+
|
|
395
|
+
self._check_operate_as_chain(predicate)
|
|
396
|
+
return result
|
|
397
|
+
|
|
398
|
+
def has_profile(self, profile_cls: Type[Profile]) -> bool:
|
|
399
|
+
"""Return ``True`` when a profile is available for the user or delegate chain."""
|
|
400
|
+
|
|
401
|
+
return self.get_profile(profile_cls) is not None
|
|
402
|
+
|
|
403
|
+
def _direct_profile(self, model_label: str):
|
|
404
|
+
model = apps.get_model("core", model_label)
|
|
405
|
+
try:
|
|
406
|
+
return self.get_profile(model)
|
|
407
|
+
except TypeError:
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
def get_phones_by_priority(self):
|
|
411
|
+
"""Return a list of ``UserPhoneNumber`` instances ordered by priority."""
|
|
412
|
+
|
|
413
|
+
ordered_numbers = self.phone_numbers.order_by("priority", "pk")
|
|
414
|
+
return list(ordered_numbers)
|
|
415
|
+
|
|
416
|
+
def get_phone_numbers_by_priority(self):
|
|
417
|
+
"""Backward-compatible alias for :meth:`get_phones_by_priority`."""
|
|
418
|
+
|
|
419
|
+
return self.get_phones_by_priority()
|
|
420
|
+
|
|
421
|
+
@property
|
|
422
|
+
def release_manager(self):
|
|
423
|
+
return self._direct_profile("ReleaseManager")
|
|
424
|
+
|
|
425
|
+
@property
|
|
426
|
+
def odoo_profile(self):
|
|
427
|
+
return self._direct_profile("OdooProfile")
|
|
428
|
+
|
|
429
|
+
@property
|
|
430
|
+
def assistant_profile(self):
|
|
431
|
+
return self._direct_profile("AssistantProfile")
|
|
432
|
+
|
|
433
|
+
@property
|
|
434
|
+
def chat_profile(self):
|
|
435
|
+
return self.assistant_profile
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
class UserPhoneNumber(Entity):
|
|
439
|
+
"""Store phone numbers associated with a user."""
|
|
440
|
+
|
|
441
|
+
user = models.ForeignKey(
|
|
261
442
|
settings.AUTH_USER_MODEL,
|
|
262
|
-
related_name="odoo_profile",
|
|
263
443
|
on_delete=models.CASCADE,
|
|
444
|
+
related_name="phone_numbers",
|
|
445
|
+
)
|
|
446
|
+
number = models.CharField(
|
|
447
|
+
max_length=20,
|
|
448
|
+
help_text="Contact phone number",
|
|
264
449
|
)
|
|
450
|
+
priority = models.PositiveIntegerField(default=0)
|
|
451
|
+
|
|
452
|
+
class Meta:
|
|
453
|
+
ordering = ("priority", "id")
|
|
454
|
+
verbose_name = "Phone Number"
|
|
455
|
+
verbose_name_plural = "Phone Numbers"
|
|
456
|
+
|
|
457
|
+
def __str__(self): # pragma: no cover - simple representation
|
|
458
|
+
return f"{self.number} ({self.priority})"
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class OdooProfile(Profile):
|
|
462
|
+
"""Store Odoo API credentials for a user."""
|
|
463
|
+
|
|
464
|
+
profile_fields = ("host", "database", "username", "password")
|
|
265
465
|
host = SigilShortAutoField(max_length=255)
|
|
266
466
|
database = SigilShortAutoField(max_length=255)
|
|
267
467
|
username = SigilShortAutoField(max_length=255)
|
|
@@ -295,12 +495,12 @@ class OdooProfile(Entity):
|
|
|
295
495
|
|
|
296
496
|
def verify(self):
|
|
297
497
|
"""Check credentials against Odoo and pull user info."""
|
|
298
|
-
common =
|
|
498
|
+
common = xmlrpc_client.ServerProxy(f"{self.host}/xmlrpc/2/common")
|
|
299
499
|
uid = common.authenticate(self.database, self.username, self.password, {})
|
|
300
500
|
if not uid:
|
|
301
501
|
self._clear_verification()
|
|
302
502
|
raise ValidationError(_("Invalid Odoo credentials"))
|
|
303
|
-
models_proxy =
|
|
503
|
+
models_proxy = xmlrpc_client.ServerProxy(f"{self.host}/xmlrpc/2/object")
|
|
304
504
|
info = models_proxy.execute_kw(
|
|
305
505
|
self.database,
|
|
306
506
|
uid,
|
|
@@ -320,7 +520,7 @@ class OdooProfile(Entity):
|
|
|
320
520
|
def execute(self, model, method, *args, **kwargs):
|
|
321
521
|
"""Execute an Odoo RPC call, invalidating credentials on failure."""
|
|
322
522
|
try:
|
|
323
|
-
client =
|
|
523
|
+
client = xmlrpc_client.ServerProxy(f"{self.host}/xmlrpc/2/object")
|
|
324
524
|
return client.execute_kw(
|
|
325
525
|
self.database,
|
|
326
526
|
self.odoo_uid,
|
|
@@ -336,14 +536,24 @@ class OdooProfile(Entity):
|
|
|
336
536
|
raise
|
|
337
537
|
|
|
338
538
|
def __str__(self): # pragma: no cover - simple representation
|
|
339
|
-
|
|
539
|
+
owner = self.owner_display()
|
|
540
|
+
return f"{owner} @ {self.host}" if owner else self.host
|
|
340
541
|
|
|
341
542
|
class Meta:
|
|
342
|
-
verbose_name = _("Odoo
|
|
343
|
-
verbose_name_plural = _("Odoo
|
|
543
|
+
verbose_name = _("Odoo Employee")
|
|
544
|
+
verbose_name_plural = _("Odoo Employees")
|
|
545
|
+
constraints = [
|
|
546
|
+
models.CheckConstraint(
|
|
547
|
+
check=(
|
|
548
|
+
(Q(user__isnull=False) & Q(group__isnull=True))
|
|
549
|
+
| (Q(user__isnull=True) & Q(group__isnull=False))
|
|
550
|
+
),
|
|
551
|
+
name="odooprofile_requires_owner",
|
|
552
|
+
)
|
|
553
|
+
]
|
|
344
554
|
|
|
345
555
|
|
|
346
|
-
class EmailInbox(
|
|
556
|
+
class EmailInbox(Profile):
|
|
347
557
|
"""Credentials and configuration for connecting to an email mailbox."""
|
|
348
558
|
|
|
349
559
|
IMAP = "imap"
|
|
@@ -353,10 +563,13 @@ class EmailInbox(Entity):
|
|
|
353
563
|
(POP3, "POP3"),
|
|
354
564
|
]
|
|
355
565
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
566
|
+
profile_fields = (
|
|
567
|
+
"username",
|
|
568
|
+
"host",
|
|
569
|
+
"port",
|
|
570
|
+
"password",
|
|
571
|
+
"protocol",
|
|
572
|
+
"use_ssl",
|
|
360
573
|
)
|
|
361
574
|
username = SigilShortAutoField(
|
|
362
575
|
max_length=255,
|
|
@@ -430,9 +643,14 @@ class EmailInbox(Entity):
|
|
|
430
643
|
def _get_body(msg):
|
|
431
644
|
if msg.is_multipart():
|
|
432
645
|
for part in msg.walk():
|
|
433
|
-
if
|
|
646
|
+
if (
|
|
647
|
+
part.get_content_type() == "text/plain"
|
|
648
|
+
and not part.get_filename()
|
|
649
|
+
):
|
|
434
650
|
charset = part.get_content_charset() or "utf-8"
|
|
435
|
-
return part.get_payload(decode=True).decode(
|
|
651
|
+
return part.get_payload(decode=True).decode(
|
|
652
|
+
charset, errors="ignore"
|
|
653
|
+
)
|
|
436
654
|
return ""
|
|
437
655
|
charset = msg.get_content_charset() or "utf-8"
|
|
438
656
|
return msg.get_payload(decode=True).decode(charset, errors="ignore")
|
|
@@ -556,9 +774,7 @@ class EmailCollector(Entity):
|
|
|
556
774
|
fp = EmailArtifact.fingerprint_for(
|
|
557
775
|
msg.get("subject", ""), msg.get("from", ""), msg.get("body", "")
|
|
558
776
|
)
|
|
559
|
-
if EmailArtifact.objects.filter(
|
|
560
|
-
collector=self, fingerprint=fp
|
|
561
|
-
).exists():
|
|
777
|
+
if EmailArtifact.objects.filter(collector=self, fingerprint=fp).exists():
|
|
562
778
|
break
|
|
563
779
|
EmailArtifact.objects.create(
|
|
564
780
|
collector=self,
|
|
@@ -591,65 +807,19 @@ class EmailArtifact(Entity):
|
|
|
591
807
|
import hashlib
|
|
592
808
|
|
|
593
809
|
data = (subject or "") + (sender or "") + (body or "")
|
|
594
|
-
|
|
810
|
+
hasher = hashlib.md5(data.encode("utf-8"), usedforsecurity=False)
|
|
811
|
+
return hasher.hexdigest()
|
|
595
812
|
|
|
596
813
|
class Meta:
|
|
597
814
|
unique_together = ("collector", "fingerprint")
|
|
598
815
|
verbose_name = "Email Artifact"
|
|
599
816
|
verbose_name_plural = "Email Artifacts"
|
|
817
|
+
ordering = ["-id"]
|
|
600
818
|
|
|
601
819
|
|
|
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")
|
|
820
|
+
class ReferenceManager(EntityManager):
|
|
821
|
+
def get_by_natural_key(self, alt_text: str):
|
|
822
|
+
return self.get(alt_text=alt_text)
|
|
653
823
|
|
|
654
824
|
|
|
655
825
|
class Reference(Entity):
|
|
@@ -702,12 +872,31 @@ class Reference(Entity):
|
|
|
702
872
|
null=True,
|
|
703
873
|
blank=True,
|
|
704
874
|
)
|
|
875
|
+
sites = models.ManyToManyField(
|
|
876
|
+
"sites.Site",
|
|
877
|
+
blank=True,
|
|
878
|
+
related_name="references",
|
|
879
|
+
)
|
|
880
|
+
roles = models.ManyToManyField(
|
|
881
|
+
"nodes.NodeRole",
|
|
882
|
+
blank=True,
|
|
883
|
+
related_name="references",
|
|
884
|
+
)
|
|
885
|
+
features = models.ManyToManyField(
|
|
886
|
+
"nodes.NodeFeature",
|
|
887
|
+
blank=True,
|
|
888
|
+
related_name="references",
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
objects = ReferenceManager()
|
|
705
892
|
|
|
706
893
|
def save(self, *args, **kwargs):
|
|
707
894
|
if self.pk:
|
|
708
895
|
original = type(self).all_objects.get(pk=self.pk)
|
|
709
896
|
if original.transaction_uuid != self.transaction_uuid:
|
|
710
|
-
raise ValidationError(
|
|
897
|
+
raise ValidationError(
|
|
898
|
+
{"transaction_uuid": "Cannot modify transaction UUID"}
|
|
899
|
+
)
|
|
711
900
|
if not self.image and self.value:
|
|
712
901
|
qr = qrcode.QRCode(box_size=10, border=4)
|
|
713
902
|
qr.add_data(self.value)
|
|
@@ -722,6 +911,10 @@ class Reference(Entity):
|
|
|
722
911
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
723
912
|
return self.alt_text
|
|
724
913
|
|
|
914
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
915
|
+
return (self.alt_text,)
|
|
916
|
+
|
|
917
|
+
|
|
725
918
|
class RFID(Entity):
|
|
726
919
|
"""RFID tag that may be assigned to one account."""
|
|
727
920
|
|
|
@@ -737,6 +930,12 @@ class RFID(Entity):
|
|
|
737
930
|
)
|
|
738
931
|
],
|
|
739
932
|
)
|
|
933
|
+
custom_label = models.CharField(
|
|
934
|
+
max_length=32,
|
|
935
|
+
blank=True,
|
|
936
|
+
verbose_name="Custom Label",
|
|
937
|
+
help_text="Optional custom label for this RFID.",
|
|
938
|
+
)
|
|
740
939
|
key_a = models.CharField(
|
|
741
940
|
max_length=12,
|
|
742
941
|
default="FFFFFFFFFFFF",
|
|
@@ -869,6 +1068,15 @@ class EnergyAccount(Entity):
|
|
|
869
1068
|
default=False,
|
|
870
1069
|
help_text="Allow transactions even when the balance is zero or negative",
|
|
871
1070
|
)
|
|
1071
|
+
live_subscription_product = models.ForeignKey(
|
|
1072
|
+
"Product",
|
|
1073
|
+
null=True,
|
|
1074
|
+
blank=True,
|
|
1075
|
+
on_delete=models.SET_NULL,
|
|
1076
|
+
related_name="live_subscription_accounts",
|
|
1077
|
+
)
|
|
1078
|
+
live_subscription_start_date = models.DateField(null=True, blank=True)
|
|
1079
|
+
live_subscription_next_renewal = models.DateField(null=True, blank=True)
|
|
872
1080
|
|
|
873
1081
|
def can_authorize(self) -> bool:
|
|
874
1082
|
"""Return True if this account should be authorized for charging."""
|
|
@@ -907,6 +1115,17 @@ class EnergyAccount(Entity):
|
|
|
907
1115
|
def save(self, *args, **kwargs):
|
|
908
1116
|
if self.name:
|
|
909
1117
|
self.name = self.name.upper()
|
|
1118
|
+
if self.live_subscription_product and not self.live_subscription_start_date:
|
|
1119
|
+
self.live_subscription_start_date = timezone.now().date()
|
|
1120
|
+
if (
|
|
1121
|
+
self.live_subscription_product
|
|
1122
|
+
and self.live_subscription_start_date
|
|
1123
|
+
and not self.live_subscription_next_renewal
|
|
1124
|
+
):
|
|
1125
|
+
self.live_subscription_next_renewal = (
|
|
1126
|
+
self.live_subscription_start_date
|
|
1127
|
+
+ timedelta(days=self.live_subscription_product.renewal_period)
|
|
1128
|
+
)
|
|
910
1129
|
super().save(*args, **kwargs)
|
|
911
1130
|
|
|
912
1131
|
def __str__(self): # pragma: no cover - simple representation
|
|
@@ -950,11 +1169,433 @@ class EnergyCredit(Entity):
|
|
|
950
1169
|
db_table = "core_credit"
|
|
951
1170
|
|
|
952
1171
|
|
|
1172
|
+
class ClientReportSchedule(Entity):
|
|
1173
|
+
"""Configuration for recurring :class:`ClientReport` generation."""
|
|
1174
|
+
|
|
1175
|
+
PERIODICITY_NONE = "none"
|
|
1176
|
+
PERIODICITY_DAILY = "daily"
|
|
1177
|
+
PERIODICITY_WEEKLY = "weekly"
|
|
1178
|
+
PERIODICITY_MONTHLY = "monthly"
|
|
1179
|
+
PERIODICITY_CHOICES = [
|
|
1180
|
+
(PERIODICITY_NONE, "One-time"),
|
|
1181
|
+
(PERIODICITY_DAILY, "Daily"),
|
|
1182
|
+
(PERIODICITY_WEEKLY, "Weekly"),
|
|
1183
|
+
(PERIODICITY_MONTHLY, "Monthly"),
|
|
1184
|
+
]
|
|
1185
|
+
|
|
1186
|
+
owner = models.ForeignKey(
|
|
1187
|
+
settings.AUTH_USER_MODEL,
|
|
1188
|
+
on_delete=models.SET_NULL,
|
|
1189
|
+
null=True,
|
|
1190
|
+
blank=True,
|
|
1191
|
+
related_name="client_report_schedules",
|
|
1192
|
+
)
|
|
1193
|
+
created_by = models.ForeignKey(
|
|
1194
|
+
settings.AUTH_USER_MODEL,
|
|
1195
|
+
on_delete=models.SET_NULL,
|
|
1196
|
+
null=True,
|
|
1197
|
+
blank=True,
|
|
1198
|
+
related_name="created_client_report_schedules",
|
|
1199
|
+
)
|
|
1200
|
+
periodicity = models.CharField(
|
|
1201
|
+
max_length=12, choices=PERIODICITY_CHOICES, default=PERIODICITY_NONE
|
|
1202
|
+
)
|
|
1203
|
+
email_recipients = models.JSONField(default=list, blank=True)
|
|
1204
|
+
disable_emails = models.BooleanField(default=False)
|
|
1205
|
+
periodic_task = models.OneToOneField(
|
|
1206
|
+
"django_celery_beat.PeriodicTask",
|
|
1207
|
+
on_delete=models.SET_NULL,
|
|
1208
|
+
null=True,
|
|
1209
|
+
blank=True,
|
|
1210
|
+
related_name="client_report_schedule",
|
|
1211
|
+
)
|
|
1212
|
+
last_generated_on = models.DateTimeField(null=True, blank=True)
|
|
1213
|
+
|
|
1214
|
+
class Meta:
|
|
1215
|
+
verbose_name = "Client Report Schedule"
|
|
1216
|
+
verbose_name_plural = "Client Report Schedules"
|
|
1217
|
+
|
|
1218
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1219
|
+
owner = self.owner.get_username() if self.owner else "Unassigned"
|
|
1220
|
+
return f"Client Report Schedule ({owner})"
|
|
1221
|
+
|
|
1222
|
+
def save(self, *args, **kwargs):
|
|
1223
|
+
sync = kwargs.pop("sync_task", True)
|
|
1224
|
+
super().save(*args, **kwargs)
|
|
1225
|
+
if sync and self.pk:
|
|
1226
|
+
self.sync_periodic_task()
|
|
1227
|
+
|
|
1228
|
+
def delete(self, using=None, keep_parents=False):
|
|
1229
|
+
task_id = self.periodic_task_id
|
|
1230
|
+
super().delete(using=using, keep_parents=keep_parents)
|
|
1231
|
+
if task_id:
|
|
1232
|
+
from django_celery_beat.models import PeriodicTask
|
|
1233
|
+
|
|
1234
|
+
PeriodicTask.objects.filter(pk=task_id).delete()
|
|
1235
|
+
|
|
1236
|
+
def sync_periodic_task(self):
|
|
1237
|
+
"""Ensure the Celery beat schedule matches the configured periodicity."""
|
|
1238
|
+
|
|
1239
|
+
from django_celery_beat.models import CrontabSchedule, PeriodicTask
|
|
1240
|
+
from django.db import transaction
|
|
1241
|
+
import json as _json
|
|
1242
|
+
|
|
1243
|
+
if self.periodicity == self.PERIODICITY_NONE:
|
|
1244
|
+
if self.periodic_task_id:
|
|
1245
|
+
PeriodicTask.objects.filter(pk=self.periodic_task_id).delete()
|
|
1246
|
+
type(self).objects.filter(pk=self.pk).update(periodic_task=None)
|
|
1247
|
+
return
|
|
1248
|
+
|
|
1249
|
+
if self.periodicity == self.PERIODICITY_DAILY:
|
|
1250
|
+
schedule, _ = CrontabSchedule.objects.get_or_create(
|
|
1251
|
+
minute="0",
|
|
1252
|
+
hour="2",
|
|
1253
|
+
day_of_week="*",
|
|
1254
|
+
day_of_month="*",
|
|
1255
|
+
month_of_year="*",
|
|
1256
|
+
)
|
|
1257
|
+
elif self.periodicity == self.PERIODICITY_WEEKLY:
|
|
1258
|
+
schedule, _ = CrontabSchedule.objects.get_or_create(
|
|
1259
|
+
minute="0",
|
|
1260
|
+
hour="3",
|
|
1261
|
+
day_of_week="1",
|
|
1262
|
+
day_of_month="*",
|
|
1263
|
+
month_of_year="*",
|
|
1264
|
+
)
|
|
1265
|
+
else:
|
|
1266
|
+
schedule, _ = CrontabSchedule.objects.get_or_create(
|
|
1267
|
+
minute="0",
|
|
1268
|
+
hour="4",
|
|
1269
|
+
day_of_week="*",
|
|
1270
|
+
day_of_month="1",
|
|
1271
|
+
month_of_year="*",
|
|
1272
|
+
)
|
|
1273
|
+
|
|
1274
|
+
name = f"client_report_schedule_{self.pk}"
|
|
1275
|
+
defaults = {
|
|
1276
|
+
"crontab": schedule,
|
|
1277
|
+
"task": "core.tasks.run_client_report_schedule",
|
|
1278
|
+
"kwargs": _json.dumps({"schedule_id": self.pk}),
|
|
1279
|
+
"enabled": True,
|
|
1280
|
+
}
|
|
1281
|
+
with transaction.atomic():
|
|
1282
|
+
periodic_task, _ = PeriodicTask.objects.update_or_create(
|
|
1283
|
+
name=name, defaults=defaults
|
|
1284
|
+
)
|
|
1285
|
+
if self.periodic_task_id != periodic_task.pk:
|
|
1286
|
+
type(self).objects.filter(pk=self.pk).update(
|
|
1287
|
+
periodic_task=periodic_task
|
|
1288
|
+
)
|
|
1289
|
+
|
|
1290
|
+
def calculate_period(self, reference=None):
|
|
1291
|
+
"""Return the date range covered for the next execution."""
|
|
1292
|
+
|
|
1293
|
+
from django.utils import timezone
|
|
1294
|
+
import datetime as _datetime
|
|
1295
|
+
|
|
1296
|
+
ref_date = reference or timezone.localdate()
|
|
1297
|
+
|
|
1298
|
+
if self.periodicity == self.PERIODICITY_DAILY:
|
|
1299
|
+
end = ref_date - _datetime.timedelta(days=1)
|
|
1300
|
+
start = end
|
|
1301
|
+
elif self.periodicity == self.PERIODICITY_WEEKLY:
|
|
1302
|
+
start_of_week = ref_date - _datetime.timedelta(days=ref_date.weekday())
|
|
1303
|
+
end = start_of_week - _datetime.timedelta(days=1)
|
|
1304
|
+
start = end - _datetime.timedelta(days=6)
|
|
1305
|
+
elif self.periodicity == self.PERIODICITY_MONTHLY:
|
|
1306
|
+
first_of_month = ref_date.replace(day=1)
|
|
1307
|
+
end = first_of_month - _datetime.timedelta(days=1)
|
|
1308
|
+
start = end.replace(day=1)
|
|
1309
|
+
else:
|
|
1310
|
+
raise ValueError("calculate_period called for non-recurring schedule")
|
|
1311
|
+
|
|
1312
|
+
return start, end
|
|
1313
|
+
|
|
1314
|
+
def resolve_recipients(self):
|
|
1315
|
+
"""Return (to, cc) email lists respecting owner fallbacks."""
|
|
1316
|
+
|
|
1317
|
+
from django.contrib.auth import get_user_model
|
|
1318
|
+
|
|
1319
|
+
to: list[str] = []
|
|
1320
|
+
cc: list[str] = []
|
|
1321
|
+
seen: set[str] = set()
|
|
1322
|
+
|
|
1323
|
+
for email in self.email_recipients:
|
|
1324
|
+
normalized = (email or "").strip()
|
|
1325
|
+
if not normalized:
|
|
1326
|
+
continue
|
|
1327
|
+
if normalized.lower() in seen:
|
|
1328
|
+
continue
|
|
1329
|
+
to.append(normalized)
|
|
1330
|
+
seen.add(normalized.lower())
|
|
1331
|
+
|
|
1332
|
+
owner_email = None
|
|
1333
|
+
if self.owner and self.owner.email:
|
|
1334
|
+
candidate = self.owner.email.strip()
|
|
1335
|
+
if candidate:
|
|
1336
|
+
owner_email = candidate
|
|
1337
|
+
|
|
1338
|
+
if to:
|
|
1339
|
+
if owner_email and owner_email.lower() not in seen:
|
|
1340
|
+
cc.append(owner_email)
|
|
1341
|
+
else:
|
|
1342
|
+
if owner_email:
|
|
1343
|
+
to.append(owner_email)
|
|
1344
|
+
seen.add(owner_email.lower())
|
|
1345
|
+
else:
|
|
1346
|
+
admin_email = (
|
|
1347
|
+
get_user_model()
|
|
1348
|
+
.objects.filter(is_superuser=True, is_active=True)
|
|
1349
|
+
.exclude(email="")
|
|
1350
|
+
.values_list("email", flat=True)
|
|
1351
|
+
.first()
|
|
1352
|
+
)
|
|
1353
|
+
if admin_email:
|
|
1354
|
+
to.append(admin_email)
|
|
1355
|
+
seen.add(admin_email.lower())
|
|
1356
|
+
elif settings.DEFAULT_FROM_EMAIL:
|
|
1357
|
+
to.append(settings.DEFAULT_FROM_EMAIL)
|
|
1358
|
+
|
|
1359
|
+
return to, cc
|
|
1360
|
+
|
|
1361
|
+
def get_outbox(self):
|
|
1362
|
+
"""Return the preferred :class:`nodes.models.EmailOutbox` instance."""
|
|
1363
|
+
|
|
1364
|
+
from nodes.models import EmailOutbox, Node
|
|
1365
|
+
|
|
1366
|
+
if self.owner:
|
|
1367
|
+
try:
|
|
1368
|
+
outbox = self.owner.get_profile(EmailOutbox)
|
|
1369
|
+
except Exception: # pragma: no cover - defensive catch
|
|
1370
|
+
outbox = None
|
|
1371
|
+
if outbox:
|
|
1372
|
+
return outbox
|
|
1373
|
+
|
|
1374
|
+
node = Node.get_local()
|
|
1375
|
+
if node:
|
|
1376
|
+
return getattr(node, "email_outbox", None)
|
|
1377
|
+
return None
|
|
1378
|
+
|
|
1379
|
+
def notify_failure(self, message: str):
|
|
1380
|
+
from nodes.models import NetMessage
|
|
1381
|
+
|
|
1382
|
+
NetMessage.broadcast("Client report delivery issue", message)
|
|
1383
|
+
|
|
1384
|
+
def run(self):
|
|
1385
|
+
"""Generate the report, persist it and deliver notifications."""
|
|
1386
|
+
|
|
1387
|
+
from core import mailer
|
|
1388
|
+
|
|
1389
|
+
try:
|
|
1390
|
+
start, end = self.calculate_period()
|
|
1391
|
+
except ValueError:
|
|
1392
|
+
return None
|
|
1393
|
+
|
|
1394
|
+
try:
|
|
1395
|
+
report = ClientReport.generate(
|
|
1396
|
+
start,
|
|
1397
|
+
end,
|
|
1398
|
+
owner=self.owner,
|
|
1399
|
+
schedule=self,
|
|
1400
|
+
recipients=self.email_recipients,
|
|
1401
|
+
disable_emails=self.disable_emails,
|
|
1402
|
+
)
|
|
1403
|
+
export, html_content = report.store_local_copy()
|
|
1404
|
+
except Exception as exc:
|
|
1405
|
+
self.notify_failure(str(exc))
|
|
1406
|
+
raise
|
|
1407
|
+
|
|
1408
|
+
if not self.disable_emails:
|
|
1409
|
+
to, cc = self.resolve_recipients()
|
|
1410
|
+
if not to:
|
|
1411
|
+
self.notify_failure("No recipients available for client report")
|
|
1412
|
+
raise RuntimeError("No recipients available for client report")
|
|
1413
|
+
else:
|
|
1414
|
+
try:
|
|
1415
|
+
attachments = []
|
|
1416
|
+
html_name = Path(export["html_path"]).name
|
|
1417
|
+
attachments.append((html_name, html_content, "text/html"))
|
|
1418
|
+
json_file = Path(settings.BASE_DIR) / export["json_path"]
|
|
1419
|
+
if json_file.exists():
|
|
1420
|
+
attachments.append(
|
|
1421
|
+
(
|
|
1422
|
+
json_file.name,
|
|
1423
|
+
json_file.read_text(encoding="utf-8"),
|
|
1424
|
+
"application/json",
|
|
1425
|
+
)
|
|
1426
|
+
)
|
|
1427
|
+
subject = f"Client report {report.start_date} to {report.end_date}"
|
|
1428
|
+
body = (
|
|
1429
|
+
"Attached is the client report generated for the period "
|
|
1430
|
+
f"{report.start_date} to {report.end_date}."
|
|
1431
|
+
)
|
|
1432
|
+
mailer.send(
|
|
1433
|
+
subject,
|
|
1434
|
+
body,
|
|
1435
|
+
to,
|
|
1436
|
+
outbox=self.get_outbox(),
|
|
1437
|
+
cc=cc,
|
|
1438
|
+
attachments=attachments,
|
|
1439
|
+
)
|
|
1440
|
+
delivered = list(dict.fromkeys(to + (cc or [])))
|
|
1441
|
+
if delivered:
|
|
1442
|
+
type(report).objects.filter(pk=report.pk).update(
|
|
1443
|
+
recipients=delivered
|
|
1444
|
+
)
|
|
1445
|
+
report.recipients = delivered
|
|
1446
|
+
except Exception as exc:
|
|
1447
|
+
self.notify_failure(str(exc))
|
|
1448
|
+
raise
|
|
1449
|
+
|
|
1450
|
+
now = timezone.now()
|
|
1451
|
+
type(self).objects.filter(pk=self.pk).update(last_generated_on=now)
|
|
1452
|
+
self.last_generated_on = now
|
|
1453
|
+
return report
|
|
1454
|
+
|
|
1455
|
+
|
|
1456
|
+
class ClientReport(Entity):
|
|
1457
|
+
"""Snapshot of energy usage over a period."""
|
|
1458
|
+
|
|
1459
|
+
start_date = models.DateField()
|
|
1460
|
+
end_date = models.DateField()
|
|
1461
|
+
created_on = models.DateTimeField(auto_now_add=True)
|
|
1462
|
+
data = models.JSONField(default=dict)
|
|
1463
|
+
owner = models.ForeignKey(
|
|
1464
|
+
settings.AUTH_USER_MODEL,
|
|
1465
|
+
on_delete=models.SET_NULL,
|
|
1466
|
+
null=True,
|
|
1467
|
+
blank=True,
|
|
1468
|
+
related_name="client_reports",
|
|
1469
|
+
)
|
|
1470
|
+
schedule = models.ForeignKey(
|
|
1471
|
+
"ClientReportSchedule",
|
|
1472
|
+
on_delete=models.SET_NULL,
|
|
1473
|
+
null=True,
|
|
1474
|
+
blank=True,
|
|
1475
|
+
related_name="reports",
|
|
1476
|
+
)
|
|
1477
|
+
recipients = models.JSONField(default=list, blank=True)
|
|
1478
|
+
disable_emails = models.BooleanField(default=False)
|
|
1479
|
+
|
|
1480
|
+
class Meta:
|
|
1481
|
+
verbose_name = "Client Report"
|
|
1482
|
+
verbose_name_plural = "Client Reports"
|
|
1483
|
+
db_table = "core_client_report"
|
|
1484
|
+
ordering = ["-created_on"]
|
|
1485
|
+
|
|
1486
|
+
@classmethod
|
|
1487
|
+
def generate(
|
|
1488
|
+
cls,
|
|
1489
|
+
start_date,
|
|
1490
|
+
end_date,
|
|
1491
|
+
*,
|
|
1492
|
+
owner=None,
|
|
1493
|
+
schedule=None,
|
|
1494
|
+
recipients: list[str] | None = None,
|
|
1495
|
+
disable_emails: bool = False,
|
|
1496
|
+
):
|
|
1497
|
+
rows = cls.build_rows(start_date, end_date)
|
|
1498
|
+
return cls.objects.create(
|
|
1499
|
+
start_date=start_date,
|
|
1500
|
+
end_date=end_date,
|
|
1501
|
+
data={"rows": rows},
|
|
1502
|
+
owner=owner,
|
|
1503
|
+
schedule=schedule,
|
|
1504
|
+
recipients=list(recipients or []),
|
|
1505
|
+
disable_emails=disable_emails,
|
|
1506
|
+
)
|
|
1507
|
+
|
|
1508
|
+
def store_local_copy(self, html: str | None = None):
|
|
1509
|
+
"""Persist the report data and optional HTML rendering to disk."""
|
|
1510
|
+
|
|
1511
|
+
import json as _json
|
|
1512
|
+
from django.template.loader import render_to_string
|
|
1513
|
+
|
|
1514
|
+
base_dir = Path(settings.BASE_DIR)
|
|
1515
|
+
report_dir = base_dir / "work" / "reports"
|
|
1516
|
+
report_dir.mkdir(parents=True, exist_ok=True)
|
|
1517
|
+
timestamp = timezone.now().strftime("%Y%m%d%H%M%S")
|
|
1518
|
+
identifier = f"client_report_{self.pk}_{timestamp}"
|
|
1519
|
+
|
|
1520
|
+
html_content = html or render_to_string(
|
|
1521
|
+
"core/reports/client_report_email.html", {"report": self}
|
|
1522
|
+
)
|
|
1523
|
+
html_path = report_dir / f"{identifier}.html"
|
|
1524
|
+
html_path.write_text(html_content, encoding="utf-8")
|
|
1525
|
+
|
|
1526
|
+
json_path = report_dir / f"{identifier}.json"
|
|
1527
|
+
json_path.write_text(
|
|
1528
|
+
_json.dumps(self.data, indent=2, default=str), encoding="utf-8"
|
|
1529
|
+
)
|
|
1530
|
+
|
|
1531
|
+
def _relative(path: Path) -> str:
|
|
1532
|
+
try:
|
|
1533
|
+
return str(path.relative_to(base_dir))
|
|
1534
|
+
except ValueError:
|
|
1535
|
+
return str(path)
|
|
1536
|
+
|
|
1537
|
+
export = {
|
|
1538
|
+
"html_path": _relative(html_path),
|
|
1539
|
+
"json_path": _relative(json_path),
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
updated = dict(self.data)
|
|
1543
|
+
updated["export"] = export
|
|
1544
|
+
type(self).objects.filter(pk=self.pk).update(data=updated)
|
|
1545
|
+
self.data = updated
|
|
1546
|
+
return export, html_content
|
|
1547
|
+
|
|
1548
|
+
@staticmethod
|
|
1549
|
+
def build_rows(start_date=None, end_date=None):
|
|
1550
|
+
from collections import defaultdict
|
|
1551
|
+
from ocpp.models import Transaction
|
|
1552
|
+
|
|
1553
|
+
qs = Transaction.objects.exclude(rfid="")
|
|
1554
|
+
if start_date:
|
|
1555
|
+
from datetime import datetime, time, timedelta, timezone as pytimezone
|
|
1556
|
+
|
|
1557
|
+
start_dt = datetime.combine(start_date, time.min, tzinfo=pytimezone.utc)
|
|
1558
|
+
qs = qs.filter(start_time__gte=start_dt)
|
|
1559
|
+
if end_date:
|
|
1560
|
+
from datetime import datetime, time, timedelta, timezone as pytimezone
|
|
1561
|
+
|
|
1562
|
+
end_dt = datetime.combine(
|
|
1563
|
+
end_date + timedelta(days=1), time.min, tzinfo=pytimezone.utc
|
|
1564
|
+
)
|
|
1565
|
+
qs = qs.filter(start_time__lt=end_dt)
|
|
1566
|
+
data = defaultdict(lambda: {"kw": 0.0, "count": 0})
|
|
1567
|
+
for tx in qs:
|
|
1568
|
+
data[tx.rfid]["kw"] += tx.kw
|
|
1569
|
+
data[tx.rfid]["count"] += 1
|
|
1570
|
+
rows = []
|
|
1571
|
+
for rfid_uid, stats in sorted(data.items()):
|
|
1572
|
+
tag = RFID.objects.filter(rfid=rfid_uid).first()
|
|
1573
|
+
if tag:
|
|
1574
|
+
account = tag.energy_accounts.first()
|
|
1575
|
+
if account:
|
|
1576
|
+
subject = account.name
|
|
1577
|
+
else:
|
|
1578
|
+
subject = str(tag.label_id)
|
|
1579
|
+
else:
|
|
1580
|
+
subject = rfid_uid
|
|
1581
|
+
rows.append(
|
|
1582
|
+
{"subject": subject, "kw": stats["kw"], "count": stats["count"]}
|
|
1583
|
+
)
|
|
1584
|
+
return rows
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
class BrandManager(EntityManager):
|
|
1588
|
+
def get_by_natural_key(self, name: str):
|
|
1589
|
+
return self.get(name=name)
|
|
1590
|
+
|
|
1591
|
+
|
|
953
1592
|
class Brand(Entity):
|
|
954
1593
|
"""Vehicle manufacturer or brand."""
|
|
955
1594
|
|
|
956
1595
|
name = models.CharField(max_length=100, unique=True)
|
|
957
1596
|
|
|
1597
|
+
objects = BrandManager()
|
|
1598
|
+
|
|
958
1599
|
class Meta:
|
|
959
1600
|
verbose_name = _("EV Brand")
|
|
960
1601
|
verbose_name_plural = _("EV Brands")
|
|
@@ -962,6 +1603,9 @@ class Brand(Entity):
|
|
|
962
1603
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
963
1604
|
return self.name
|
|
964
1605
|
|
|
1606
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
1607
|
+
return (self.name,)
|
|
1608
|
+
|
|
965
1609
|
@classmethod
|
|
966
1610
|
def from_vin(cls, vin: str) -> "Brand | None":
|
|
967
1611
|
"""Return the brand matching the VIN's WMI prefix."""
|
|
@@ -990,6 +1634,48 @@ class EVModel(Entity):
|
|
|
990
1634
|
|
|
991
1635
|
brand = models.ForeignKey(Brand, on_delete=models.CASCADE, related_name="ev_models")
|
|
992
1636
|
name = models.CharField(max_length=100)
|
|
1637
|
+
battery_capacity_kwh = models.DecimalField(
|
|
1638
|
+
max_digits=6,
|
|
1639
|
+
decimal_places=2,
|
|
1640
|
+
null=True,
|
|
1641
|
+
blank=True,
|
|
1642
|
+
verbose_name="Battery Capacity (kWh)",
|
|
1643
|
+
)
|
|
1644
|
+
est_battery_kwh = models.DecimalField(
|
|
1645
|
+
max_digits=6,
|
|
1646
|
+
decimal_places=2,
|
|
1647
|
+
null=True,
|
|
1648
|
+
blank=True,
|
|
1649
|
+
verbose_name="Estimated Battery (kWh)",
|
|
1650
|
+
)
|
|
1651
|
+
ac_110v_power_kw = models.DecimalField(
|
|
1652
|
+
max_digits=5,
|
|
1653
|
+
decimal_places=2,
|
|
1654
|
+
null=True,
|
|
1655
|
+
blank=True,
|
|
1656
|
+
verbose_name="110V AC (kW)",
|
|
1657
|
+
)
|
|
1658
|
+
ac_220v_power_kw = models.DecimalField(
|
|
1659
|
+
max_digits=5,
|
|
1660
|
+
decimal_places=2,
|
|
1661
|
+
null=True,
|
|
1662
|
+
blank=True,
|
|
1663
|
+
verbose_name="220V AC (kW)",
|
|
1664
|
+
)
|
|
1665
|
+
dc_60_power_kw = models.DecimalField(
|
|
1666
|
+
max_digits=5,
|
|
1667
|
+
decimal_places=2,
|
|
1668
|
+
null=True,
|
|
1669
|
+
blank=True,
|
|
1670
|
+
verbose_name="60kW DC (kW)",
|
|
1671
|
+
)
|
|
1672
|
+
dc_100_power_kw = models.DecimalField(
|
|
1673
|
+
max_digits=5,
|
|
1674
|
+
decimal_places=2,
|
|
1675
|
+
null=True,
|
|
1676
|
+
blank=True,
|
|
1677
|
+
verbose_name="100kW DC (kW)",
|
|
1678
|
+
)
|
|
993
1679
|
|
|
994
1680
|
class Meta:
|
|
995
1681
|
unique_together = ("brand", "name")
|
|
@@ -1021,9 +1707,7 @@ class ElectricVehicle(Entity):
|
|
|
1021
1707
|
related_name="vehicles",
|
|
1022
1708
|
)
|
|
1023
1709
|
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
|
-
)
|
|
1710
|
+
license_plate = models.CharField(_("License Plate"), max_length=20, blank=True)
|
|
1027
1711
|
|
|
1028
1712
|
def save(self, *args, **kwargs):
|
|
1029
1713
|
if self.model and not self.brand:
|
|
@@ -1047,30 +1731,16 @@ class Product(Entity):
|
|
|
1047
1731
|
name = models.CharField(max_length=100)
|
|
1048
1732
|
description = models.TextField(blank=True)
|
|
1049
1733
|
renewal_period = models.PositiveIntegerField(help_text="Renewal period in days")
|
|
1734
|
+
odoo_product = models.JSONField(
|
|
1735
|
+
null=True,
|
|
1736
|
+
blank=True,
|
|
1737
|
+
help_text="Selected product from Odoo (id and name)",
|
|
1738
|
+
)
|
|
1050
1739
|
|
|
1051
1740
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1052
1741
|
return self.name
|
|
1053
1742
|
|
|
1054
1743
|
|
|
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
1744
|
class AdminHistory(Entity):
|
|
1075
1745
|
"""Record of recently visited admin changelists for a user."""
|
|
1076
1746
|
|
|
@@ -1093,11 +1763,48 @@ class AdminHistory(Entity):
|
|
|
1093
1763
|
return model._meta.verbose_name_plural if model else self.content_type.name
|
|
1094
1764
|
|
|
1095
1765
|
|
|
1096
|
-
class
|
|
1766
|
+
class ReleaseManagerManager(EntityManager):
|
|
1767
|
+
def get_by_natural_key(self, owner, package=None):
|
|
1768
|
+
owner = owner or ""
|
|
1769
|
+
if owner.startswith("group:"):
|
|
1770
|
+
group_name = owner.split(":", 1)[1]
|
|
1771
|
+
return self.get(group__name=group_name)
|
|
1772
|
+
return self.get(user__username=owner)
|
|
1773
|
+
|
|
1774
|
+
|
|
1775
|
+
class PackageManager(EntityManager):
|
|
1776
|
+
def get_by_natural_key(self, name):
|
|
1777
|
+
return self.get(name=name)
|
|
1778
|
+
|
|
1779
|
+
|
|
1780
|
+
class PackageReleaseManager(EntityManager):
|
|
1781
|
+
def get_by_natural_key(self, package, version):
|
|
1782
|
+
return self.get(package__name=package, version=version)
|
|
1783
|
+
|
|
1784
|
+
|
|
1785
|
+
class ReleaseManager(Profile):
|
|
1097
1786
|
"""Store credentials for publishing packages."""
|
|
1098
1787
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1788
|
+
objects = ReleaseManagerManager()
|
|
1789
|
+
|
|
1790
|
+
def natural_key(self):
|
|
1791
|
+
owner = self.owner_display()
|
|
1792
|
+
if self.group_id and owner:
|
|
1793
|
+
owner = f"group:{owner}"
|
|
1794
|
+
|
|
1795
|
+
pkg_name = ""
|
|
1796
|
+
if self.pk:
|
|
1797
|
+
pkg = self.package_set.first()
|
|
1798
|
+
pkg_name = pkg.name if pkg else ""
|
|
1799
|
+
|
|
1800
|
+
return (owner or "", pkg_name)
|
|
1801
|
+
|
|
1802
|
+
profile_fields = (
|
|
1803
|
+
"pypi_username",
|
|
1804
|
+
"pypi_token",
|
|
1805
|
+
"github_token",
|
|
1806
|
+
"pypi_password",
|
|
1807
|
+
"pypi_url",
|
|
1101
1808
|
)
|
|
1102
1809
|
pypi_username = SigilShortAutoField("PyPI username", max_length=100, blank=True)
|
|
1103
1810
|
pypi_token = SigilShortAutoField("PyPI token", max_length=200, blank=True)
|
|
@@ -1115,34 +1822,43 @@ class ReleaseManager(Entity):
|
|
|
1115
1822
|
class Meta:
|
|
1116
1823
|
verbose_name = "Release Manager"
|
|
1117
1824
|
verbose_name_plural = "Release Managers"
|
|
1825
|
+
constraints = [
|
|
1826
|
+
models.CheckConstraint(
|
|
1827
|
+
check=(
|
|
1828
|
+
(Q(user__isnull=False) & Q(group__isnull=True))
|
|
1829
|
+
| (Q(user__isnull=True) & Q(group__isnull=False))
|
|
1830
|
+
),
|
|
1831
|
+
name="releasemanager_requires_owner",
|
|
1832
|
+
)
|
|
1833
|
+
]
|
|
1118
1834
|
|
|
1119
1835
|
def __str__(self) -> str: # pragma: no cover - trivial
|
|
1120
1836
|
return self.name
|
|
1121
1837
|
|
|
1122
1838
|
@property
|
|
1123
1839
|
def name(self) -> str: # pragma: no cover - simple proxy
|
|
1124
|
-
|
|
1840
|
+
owner = self.owner_display()
|
|
1841
|
+
return owner or ""
|
|
1125
1842
|
|
|
1126
1843
|
def to_credentials(self) -> Credentials | None:
|
|
1127
1844
|
"""Return credentials for this release manager."""
|
|
1128
1845
|
if self.pypi_token:
|
|
1129
1846
|
return Credentials(token=self.pypi_token)
|
|
1130
1847
|
if self.pypi_username and self.pypi_password:
|
|
1131
|
-
return Credentials(
|
|
1132
|
-
username=self.pypi_username, password=self.pypi_password
|
|
1133
|
-
)
|
|
1848
|
+
return Credentials(username=self.pypi_username, password=self.pypi_password)
|
|
1134
1849
|
return None
|
|
1135
1850
|
|
|
1136
1851
|
|
|
1137
1852
|
class Package(Entity):
|
|
1138
1853
|
"""Package details shared across releases."""
|
|
1139
1854
|
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
)
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
)
|
|
1855
|
+
objects = PackageManager()
|
|
1856
|
+
|
|
1857
|
+
def natural_key(self):
|
|
1858
|
+
return (self.name,)
|
|
1859
|
+
|
|
1860
|
+
name = models.CharField(max_length=100, default=DEFAULT_PACKAGE.name, unique=True)
|
|
1861
|
+
description = models.CharField(max_length=255, default=DEFAULT_PACKAGE.description)
|
|
1146
1862
|
author = models.CharField(max_length=100, default=DEFAULT_PACKAGE.author)
|
|
1147
1863
|
email = models.EmailField(default=DEFAULT_PACKAGE.email)
|
|
1148
1864
|
python_requires = models.CharField(
|
|
@@ -1191,9 +1907,15 @@ class Package(Entity):
|
|
|
1191
1907
|
homepage_url=self.homepage_url,
|
|
1192
1908
|
)
|
|
1193
1909
|
|
|
1910
|
+
|
|
1194
1911
|
class PackageRelease(Entity):
|
|
1195
1912
|
"""Store metadata for a specific package version."""
|
|
1196
1913
|
|
|
1914
|
+
objects = PackageReleaseManager()
|
|
1915
|
+
|
|
1916
|
+
def natural_key(self):
|
|
1917
|
+
return (self.package.name, self.version)
|
|
1918
|
+
|
|
1197
1919
|
package = models.ForeignKey(
|
|
1198
1920
|
Package, on_delete=models.CASCADE, related_name="releases"
|
|
1199
1921
|
)
|
|
@@ -1218,10 +1940,15 @@ class PackageRelease(Entity):
|
|
|
1218
1940
|
|
|
1219
1941
|
@classmethod
|
|
1220
1942
|
def dump_fixture(cls) -> None:
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1943
|
+
base = Path("core/fixtures")
|
|
1944
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
1945
|
+
for old in base.glob("releases__*.json"):
|
|
1946
|
+
old.unlink()
|
|
1947
|
+
for release in cls.objects.all():
|
|
1948
|
+
name = f"releases__packagerelease_{release.version.replace('.', '_')}.json"
|
|
1949
|
+
path = base / name
|
|
1950
|
+
data = serializers.serialize("json", [release])
|
|
1951
|
+
path.write_text(data)
|
|
1225
1952
|
|
|
1226
1953
|
def __str__(self) -> str: # pragma: no cover - trivial
|
|
1227
1954
|
return f"{self.package.name} {self.version}"
|
|
@@ -1300,13 +2027,16 @@ class PackageRelease(Entity):
|
|
|
1300
2027
|
self.save(update_fields=["revision"])
|
|
1301
2028
|
PackageRelease.dump_fixture()
|
|
1302
2029
|
if kwargs.get("git"):
|
|
2030
|
+
from glob import glob
|
|
2031
|
+
|
|
2032
|
+
paths = sorted(glob("core/fixtures/releases__*.json"))
|
|
1303
2033
|
diff = subprocess.run(
|
|
1304
|
-
["git", "status", "--porcelain",
|
|
2034
|
+
["git", "status", "--porcelain", *paths],
|
|
1305
2035
|
capture_output=True,
|
|
1306
2036
|
text=True,
|
|
1307
2037
|
)
|
|
1308
2038
|
if diff.stdout.strip():
|
|
1309
|
-
release_utils._run(["git", "add",
|
|
2039
|
+
release_utils._run(["git", "add", *paths])
|
|
1310
2040
|
release_utils._run(
|
|
1311
2041
|
[
|
|
1312
2042
|
"git",
|
|
@@ -1321,20 +2051,27 @@ class PackageRelease(Entity):
|
|
|
1321
2051
|
def revision_short(self) -> str:
|
|
1322
2052
|
return self.revision[-6:] if self.revision else ""
|
|
1323
2053
|
|
|
2054
|
+
|
|
1324
2055
|
# Ensure each RFID can only be linked to one energy account
|
|
1325
2056
|
@receiver(m2m_changed, sender=EnergyAccount.rfids.through)
|
|
1326
|
-
def _rfid_unique_energy_account(
|
|
2057
|
+
def _rfid_unique_energy_account(
|
|
2058
|
+
sender, instance, action, reverse, model, pk_set, **kwargs
|
|
2059
|
+
):
|
|
1327
2060
|
"""Prevent associating an RFID with more than one energy account."""
|
|
1328
2061
|
if action == "pre_add":
|
|
1329
2062
|
if reverse: # adding energy accounts to an RFID
|
|
1330
2063
|
if instance.energy_accounts.exclude(pk__in=pk_set).exists():
|
|
1331
|
-
raise ValidationError(
|
|
2064
|
+
raise ValidationError(
|
|
2065
|
+
"RFID tags may only be assigned to one energy account."
|
|
2066
|
+
)
|
|
1332
2067
|
else: # adding RFIDs to an energy account
|
|
1333
2068
|
conflict = model.objects.filter(
|
|
1334
2069
|
pk__in=pk_set, energy_accounts__isnull=False
|
|
1335
2070
|
).exclude(energy_accounts=instance)
|
|
1336
2071
|
if conflict.exists():
|
|
1337
|
-
raise ValidationError(
|
|
2072
|
+
raise ValidationError(
|
|
2073
|
+
"RFID tags may only be assigned to one energy account."
|
|
2074
|
+
)
|
|
1338
2075
|
|
|
1339
2076
|
|
|
1340
2077
|
def hash_key(key: str) -> str:
|
|
@@ -1343,7 +2080,7 @@ def hash_key(key: str) -> str:
|
|
|
1343
2080
|
return hashlib.sha256(key.encode()).hexdigest()
|
|
1344
2081
|
|
|
1345
2082
|
|
|
1346
|
-
class
|
|
2083
|
+
class AssistantProfile(Profile):
|
|
1347
2084
|
"""Stores a hashed user key used by the assistant for authentication.
|
|
1348
2085
|
|
|
1349
2086
|
The plain-text ``user_key`` is generated server-side and shown only once.
|
|
@@ -1352,9 +2089,7 @@ class ChatProfile(models.Model):
|
|
|
1352
2089
|
"""
|
|
1353
2090
|
|
|
1354
2091
|
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
|
-
)
|
|
2092
|
+
profile_fields = ("user_key_hash", "scopes", "is_active")
|
|
1358
2093
|
user_key_hash = models.CharField(max_length=64, unique=True)
|
|
1359
2094
|
scopes = models.JSONField(default=list, blank=True)
|
|
1360
2095
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
@@ -1362,19 +2097,35 @@ class ChatProfile(models.Model):
|
|
|
1362
2097
|
is_active = models.BooleanField(default=True)
|
|
1363
2098
|
|
|
1364
2099
|
class Meta:
|
|
1365
|
-
db_table = "
|
|
1366
|
-
verbose_name = "
|
|
1367
|
-
verbose_name_plural = "
|
|
2100
|
+
db_table = "workgroup_assistantprofile"
|
|
2101
|
+
verbose_name = "Assistant Profile"
|
|
2102
|
+
verbose_name_plural = "Assistant Profiles"
|
|
2103
|
+
constraints = [
|
|
2104
|
+
models.CheckConstraint(
|
|
2105
|
+
check=(
|
|
2106
|
+
(Q(user__isnull=False) & Q(group__isnull=True))
|
|
2107
|
+
| (Q(user__isnull=True) & Q(group__isnull=False))
|
|
2108
|
+
),
|
|
2109
|
+
name="assistantprofile_requires_owner",
|
|
2110
|
+
)
|
|
2111
|
+
]
|
|
1368
2112
|
|
|
1369
2113
|
@classmethod
|
|
1370
|
-
def issue_key(cls, user) -> tuple["
|
|
2114
|
+
def issue_key(cls, user) -> tuple["AssistantProfile", str]:
|
|
1371
2115
|
"""Create or update a profile and return it with a new plain key."""
|
|
1372
2116
|
|
|
1373
2117
|
key = secrets.token_hex(32)
|
|
1374
2118
|
key_hash = hash_key(key)
|
|
2119
|
+
if user is None:
|
|
2120
|
+
raise ValueError("Assistant profiles require a user instance")
|
|
2121
|
+
|
|
1375
2122
|
profile, _ = cls.objects.update_or_create(
|
|
1376
2123
|
user=user,
|
|
1377
|
-
defaults={
|
|
2124
|
+
defaults={
|
|
2125
|
+
"user_key_hash": key_hash,
|
|
2126
|
+
"last_used_at": None,
|
|
2127
|
+
"is_active": True,
|
|
2128
|
+
},
|
|
1378
2129
|
)
|
|
1379
2130
|
return profile, key
|
|
1380
2131
|
|
|
@@ -1385,4 +2136,60 @@ class ChatProfile(models.Model):
|
|
|
1385
2136
|
self.save(update_fields=["last_used_at"])
|
|
1386
2137
|
|
|
1387
2138
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1388
|
-
|
|
2139
|
+
owner = self.owner_display()
|
|
2140
|
+
return f"AssistantProfile for {owner}" if owner else "AssistantProfile"
|
|
2141
|
+
|
|
2142
|
+
|
|
2143
|
+
def validate_relative_url(value: str) -> None:
|
|
2144
|
+
if not value:
|
|
2145
|
+
return
|
|
2146
|
+
parsed = urlparse(value)
|
|
2147
|
+
if parsed.scheme or parsed.netloc or not value.startswith("/"):
|
|
2148
|
+
raise ValidationError("URL must be relative")
|
|
2149
|
+
|
|
2150
|
+
|
|
2151
|
+
class TodoManager(EntityManager):
|
|
2152
|
+
def get_by_natural_key(self, request: str):
|
|
2153
|
+
return self.get(request=request)
|
|
2154
|
+
|
|
2155
|
+
|
|
2156
|
+
class Todo(Entity):
|
|
2157
|
+
"""Tasks requested for the Release Manager."""
|
|
2158
|
+
|
|
2159
|
+
request = models.CharField(max_length=255)
|
|
2160
|
+
url = models.CharField(
|
|
2161
|
+
max_length=200, blank=True, default="", validators=[validate_relative_url]
|
|
2162
|
+
)
|
|
2163
|
+
request_details = models.TextField(blank=True, default="")
|
|
2164
|
+
done_on = models.DateTimeField(null=True, blank=True)
|
|
2165
|
+
|
|
2166
|
+
objects = TodoManager()
|
|
2167
|
+
|
|
2168
|
+
class Meta:
|
|
2169
|
+
verbose_name = "TODO"
|
|
2170
|
+
verbose_name_plural = "TODOs"
|
|
2171
|
+
constraints = [
|
|
2172
|
+
models.UniqueConstraint(
|
|
2173
|
+
Lower("request"),
|
|
2174
|
+
condition=Q(is_deleted=False),
|
|
2175
|
+
name="unique_active_todo_request",
|
|
2176
|
+
)
|
|
2177
|
+
]
|
|
2178
|
+
|
|
2179
|
+
def clean(self):
|
|
2180
|
+
super().clean()
|
|
2181
|
+
if (
|
|
2182
|
+
Todo.objects.filter(request__iexact=self.request, is_deleted=False)
|
|
2183
|
+
.exclude(pk=self.pk)
|
|
2184
|
+
.exists()
|
|
2185
|
+
):
|
|
2186
|
+
raise ValidationError({"request": "Similar TODO already exists."})
|
|
2187
|
+
|
|
2188
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
2189
|
+
return self.request
|
|
2190
|
+
|
|
2191
|
+
def natural_key(self):
|
|
2192
|
+
"""Use the request field as the natural key."""
|
|
2193
|
+
return (self.request,)
|
|
2194
|
+
|
|
2195
|
+
natural_key.dependencies = []
|