arthexis 0.1.7__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.9.dist-info/METADATA +168 -0
- 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 +134 -16
- config/urls.py +71 -3
- core/admin.py +1331 -165
- core/admin_history.py +50 -0
- core/admindocs.py +151 -0
- 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 +1136 -259
- 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 +445 -58
- 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 +17 -0
- core/workgroup_views.py +94 -0
- 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 +4 -3
- 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.7.dist-info/METADATA +0 -126
- arthexis-0.1.7.dist-info/RECORD +0 -77
- arthexis-0.1.7.dist-info/licenses/LICENSE +0 -21
- config/workgroup_app.py +0 -7
- core/checks.py +0 -29
- {arthexis-0.1.7.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
- {arthexis-0.1.7.dist-info → arthexis-0.1.9.dist-info}/top_level.txt +0 -0
core/models.py
CHANGED
|
@@ -4,32 +4,41 @@ 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
|
|
17
19
|
import hashlib
|
|
18
20
|
import os
|
|
19
21
|
import subprocess
|
|
22
|
+
import secrets
|
|
23
|
+
import re
|
|
20
24
|
from io import BytesIO
|
|
21
25
|
from django.core.files.base import ContentFile
|
|
22
26
|
import qrcode
|
|
23
|
-
import xmlrpc.client
|
|
24
27
|
from django.utils import timezone
|
|
25
28
|
import uuid
|
|
26
29
|
from pathlib import Path
|
|
27
30
|
from django.core import serializers
|
|
31
|
+
from urllib.parse import urlparse
|
|
28
32
|
from utils import revision as revision_utils
|
|
33
|
+
from typing import Type
|
|
34
|
+
from defusedxml import xmlrpc as defused_xmlrpc
|
|
29
35
|
|
|
30
|
-
|
|
36
|
+
defused_xmlrpc.monkey_patch()
|
|
37
|
+
xmlrpc_client = defused_xmlrpc.xmlrpc_client
|
|
38
|
+
|
|
39
|
+
from .entity import Entity, EntityUserManager, EntityManager
|
|
31
40
|
from .release import Package as ReleasePackage, Credentials, DEFAULT_PACKAGE
|
|
32
|
-
from .
|
|
41
|
+
from . import user_data # noqa: F401 - ensure signal registration
|
|
33
42
|
from .fields import SigilShortAutoField
|
|
34
43
|
|
|
35
44
|
|
|
@@ -47,6 +56,111 @@ class SecurityGroup(Group):
|
|
|
47
56
|
verbose_name_plural = "Security Groups"
|
|
48
57
|
|
|
49
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
|
+
|
|
50
164
|
class SigilRoot(Entity):
|
|
51
165
|
class Context(models.TextChoices):
|
|
52
166
|
CONFIG = "config", "Configuration"
|
|
@@ -54,16 +168,32 @@ class SigilRoot(Entity):
|
|
|
54
168
|
|
|
55
169
|
prefix = models.CharField(max_length=50, unique=True)
|
|
56
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()
|
|
57
176
|
|
|
58
177
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
59
178
|
return self.prefix
|
|
60
179
|
|
|
180
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
181
|
+
return (self.prefix,)
|
|
182
|
+
|
|
61
183
|
class Meta:
|
|
62
184
|
verbose_name = "Sigil Root"
|
|
63
185
|
verbose_name_plural = "Sigil Roots"
|
|
64
186
|
|
|
65
187
|
|
|
66
|
-
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):
|
|
67
197
|
"""Common request lead information."""
|
|
68
198
|
|
|
69
199
|
user = models.ForeignKey(
|
|
@@ -82,6 +212,9 @@ class Lead(models.Model):
|
|
|
82
212
|
class InviteLead(Lead):
|
|
83
213
|
email = models.EmailField()
|
|
84
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)
|
|
85
218
|
|
|
86
219
|
class Meta:
|
|
87
220
|
verbose_name = "Invite Lead"
|
|
@@ -91,156 +224,66 @@ class InviteLead(Lead):
|
|
|
91
224
|
return self.email
|
|
92
225
|
|
|
93
226
|
|
|
94
|
-
class
|
|
95
|
-
"""
|
|
96
|
-
|
|
97
|
-
class State(models.TextChoices):
|
|
98
|
-
COAHUILA = "CO", "Coahuila"
|
|
99
|
-
NUEVO_LEON = "NL", "Nuevo León"
|
|
100
|
-
|
|
101
|
-
COAHUILA_MUNICIPALITIES = [
|
|
102
|
-
"Abasolo",
|
|
103
|
-
"Acuña",
|
|
104
|
-
"Allende",
|
|
105
|
-
"Arteaga",
|
|
106
|
-
"Candela",
|
|
107
|
-
"Castaños",
|
|
108
|
-
"Cuatro Ciénegas",
|
|
109
|
-
"Escobedo",
|
|
110
|
-
"Francisco I. Madero",
|
|
111
|
-
"Frontera",
|
|
112
|
-
"General Cepeda",
|
|
113
|
-
"Guerrero",
|
|
114
|
-
"Hidalgo",
|
|
115
|
-
"Jiménez",
|
|
116
|
-
"Juárez",
|
|
117
|
-
"Lamadrid",
|
|
118
|
-
"Matamoros",
|
|
119
|
-
"Monclova",
|
|
120
|
-
"Morelos",
|
|
121
|
-
"Múzquiz",
|
|
122
|
-
"Nadadores",
|
|
123
|
-
"Nava",
|
|
124
|
-
"Ocampo",
|
|
125
|
-
"Parras",
|
|
126
|
-
"Piedras Negras",
|
|
127
|
-
"Progreso",
|
|
128
|
-
"Ramos Arizpe",
|
|
129
|
-
"Sabinas",
|
|
130
|
-
"Sacramento",
|
|
131
|
-
"Saltillo",
|
|
132
|
-
"San Buenaventura",
|
|
133
|
-
"San Juan de Sabinas",
|
|
134
|
-
"San Pedro",
|
|
135
|
-
"Sierra Mojada",
|
|
136
|
-
"Torreón",
|
|
137
|
-
"Viesca",
|
|
138
|
-
"Villa Unión",
|
|
139
|
-
"Zaragoza",
|
|
140
|
-
]
|
|
227
|
+
class PublicWifiAccess(Entity):
|
|
228
|
+
"""Allow public Wi-Fi clients onto the wider internet."""
|
|
141
229
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
"
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
"Cadereyta Jiménez",
|
|
152
|
-
"El Carmen",
|
|
153
|
-
"Cerralvo",
|
|
154
|
-
"Ciénega de Flores",
|
|
155
|
-
"China",
|
|
156
|
-
"Doctor Arroyo",
|
|
157
|
-
"Doctor Coss",
|
|
158
|
-
"Doctor González",
|
|
159
|
-
"Galeana",
|
|
160
|
-
"García",
|
|
161
|
-
"General Bravo",
|
|
162
|
-
"General Escobedo",
|
|
163
|
-
"General Terán",
|
|
164
|
-
"General Treviño",
|
|
165
|
-
"General Zaragoza",
|
|
166
|
-
"General Zuazua",
|
|
167
|
-
"Guadalupe",
|
|
168
|
-
"Los Herreras",
|
|
169
|
-
"Higueras",
|
|
170
|
-
"Hualahuises",
|
|
171
|
-
"Iturbide",
|
|
172
|
-
"Juárez",
|
|
173
|
-
"Lampazos de Naranjo",
|
|
174
|
-
"Linares",
|
|
175
|
-
"Marín",
|
|
176
|
-
"Melchor Ocampo",
|
|
177
|
-
"Mier y Noriega",
|
|
178
|
-
"Mina",
|
|
179
|
-
"Montemorelos",
|
|
180
|
-
"Monterrey",
|
|
181
|
-
"Parás",
|
|
182
|
-
"Pesquería",
|
|
183
|
-
"Los Ramones",
|
|
184
|
-
"Rayones",
|
|
185
|
-
"Sabinas Hidalgo",
|
|
186
|
-
"Salinas Victoria",
|
|
187
|
-
"San Nicolás de los Garza",
|
|
188
|
-
"San Pedro Garza García",
|
|
189
|
-
"Santa Catarina",
|
|
190
|
-
"Santiago",
|
|
191
|
-
"Vallecillo",
|
|
192
|
-
"Villaldama",
|
|
193
|
-
"Hidalgo",
|
|
194
|
-
]
|
|
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)
|
|
195
239
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
240
|
+
class Meta:
|
|
241
|
+
unique_together = ("user", "mac_address")
|
|
242
|
+
verbose_name = "Public Wi-Fi Access"
|
|
243
|
+
verbose_name_plural = "Public Wi-Fi Access"
|
|
200
244
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
]
|
|
245
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
246
|
+
return f"{self.user} -> {self.mac_address}"
|
|
204
247
|
|
|
205
|
-
street = models.CharField(max_length=255)
|
|
206
|
-
number = models.CharField(max_length=20)
|
|
207
|
-
municipality = models.CharField(max_length=100, choices=MUNICIPALITY_CHOICES)
|
|
208
|
-
state = models.CharField(max_length=2, choices=State.choices)
|
|
209
|
-
postal_code = models.CharField(max_length=10)
|
|
210
248
|
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
213
254
|
|
|
214
|
-
|
|
215
|
-
from django.core.exceptions import ValidationError
|
|
255
|
+
public_wifi.revoke_public_access_for_user(instance)
|
|
216
256
|
|
|
217
|
-
allowed = self.MUNICIPALITIES_BY_STATE.get(self.state, [])
|
|
218
|
-
if self.municipality not in allowed:
|
|
219
|
-
raise ValidationError(
|
|
220
|
-
{"municipality": _("Invalid municipality for the selected state")}
|
|
221
|
-
)
|
|
222
257
|
|
|
223
|
-
|
|
224
|
-
|
|
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)
|
|
225
263
|
|
|
226
264
|
|
|
227
265
|
class User(Entity, AbstractUser):
|
|
266
|
+
SYSTEM_USERNAME = "arthexis"
|
|
267
|
+
ADMIN_USERNAME = "admin"
|
|
268
|
+
PROFILE_RESTRICTED_USERNAMES = frozenset({SYSTEM_USERNAME, ADMIN_USERNAME})
|
|
269
|
+
|
|
228
270
|
objects = EntityUserManager()
|
|
229
271
|
all_objects = DjangoUserManager()
|
|
230
272
|
"""Custom user model."""
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
)
|
|
237
|
-
address = models.ForeignKey(
|
|
238
|
-
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",
|
|
239
278
|
null=True,
|
|
240
279
|
blank=True,
|
|
241
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
|
+
),
|
|
242
286
|
)
|
|
243
|
-
has_charger = models.BooleanField(default=False)
|
|
244
287
|
is_active = models.BooleanField(
|
|
245
288
|
_("active"),
|
|
246
289
|
default=True,
|
|
@@ -252,15 +295,173 @@ class User(Entity, AbstractUser):
|
|
|
252
295
|
def __str__(self):
|
|
253
296
|
return self.username
|
|
254
297
|
|
|
298
|
+
@classmethod
|
|
299
|
+
def is_system_username(cls, username):
|
|
300
|
+
return bool(username) and username == cls.SYSTEM_USERNAME
|
|
255
301
|
|
|
256
|
-
|
|
257
|
-
|
|
302
|
+
@classmethod
|
|
303
|
+
def is_profile_restricted_username(cls, username):
|
|
304
|
+
return bool(username) and username in cls.PROFILE_RESTRICTED_USERNAMES
|
|
258
305
|
|
|
259
|
-
|
|
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(
|
|
260
442
|
settings.AUTH_USER_MODEL,
|
|
261
|
-
related_name="odoo_profile",
|
|
262
443
|
on_delete=models.CASCADE,
|
|
444
|
+
related_name="phone_numbers",
|
|
263
445
|
)
|
|
446
|
+
number = models.CharField(
|
|
447
|
+
max_length=20,
|
|
448
|
+
help_text="Contact phone number",
|
|
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")
|
|
264
465
|
host = SigilShortAutoField(max_length=255)
|
|
265
466
|
database = SigilShortAutoField(max_length=255)
|
|
266
467
|
username = SigilShortAutoField(max_length=255)
|
|
@@ -294,12 +495,12 @@ class OdooProfile(Entity):
|
|
|
294
495
|
|
|
295
496
|
def verify(self):
|
|
296
497
|
"""Check credentials against Odoo and pull user info."""
|
|
297
|
-
common =
|
|
498
|
+
common = xmlrpc_client.ServerProxy(f"{self.host}/xmlrpc/2/common")
|
|
298
499
|
uid = common.authenticate(self.database, self.username, self.password, {})
|
|
299
500
|
if not uid:
|
|
300
501
|
self._clear_verification()
|
|
301
502
|
raise ValidationError(_("Invalid Odoo credentials"))
|
|
302
|
-
models_proxy =
|
|
503
|
+
models_proxy = xmlrpc_client.ServerProxy(f"{self.host}/xmlrpc/2/object")
|
|
303
504
|
info = models_proxy.execute_kw(
|
|
304
505
|
self.database,
|
|
305
506
|
uid,
|
|
@@ -319,7 +520,7 @@ class OdooProfile(Entity):
|
|
|
319
520
|
def execute(self, model, method, *args, **kwargs):
|
|
320
521
|
"""Execute an Odoo RPC call, invalidating credentials on failure."""
|
|
321
522
|
try:
|
|
322
|
-
client =
|
|
523
|
+
client = xmlrpc_client.ServerProxy(f"{self.host}/xmlrpc/2/object")
|
|
323
524
|
return client.execute_kw(
|
|
324
525
|
self.database,
|
|
325
526
|
self.odoo_uid,
|
|
@@ -335,14 +536,24 @@ class OdooProfile(Entity):
|
|
|
335
536
|
raise
|
|
336
537
|
|
|
337
538
|
def __str__(self): # pragma: no cover - simple representation
|
|
338
|
-
|
|
539
|
+
owner = self.owner_display()
|
|
540
|
+
return f"{owner} @ {self.host}" if owner else self.host
|
|
339
541
|
|
|
340
542
|
class Meta:
|
|
341
|
-
verbose_name = _("Odoo
|
|
342
|
-
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
|
+
]
|
|
343
554
|
|
|
344
555
|
|
|
345
|
-
class EmailInbox(
|
|
556
|
+
class EmailInbox(Profile):
|
|
346
557
|
"""Credentials and configuration for connecting to an email mailbox."""
|
|
347
558
|
|
|
348
559
|
IMAP = "imap"
|
|
@@ -352,10 +563,13 @@ class EmailInbox(Entity):
|
|
|
352
563
|
(POP3, "POP3"),
|
|
353
564
|
]
|
|
354
565
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
566
|
+
profile_fields = (
|
|
567
|
+
"username",
|
|
568
|
+
"host",
|
|
569
|
+
"port",
|
|
570
|
+
"password",
|
|
571
|
+
"protocol",
|
|
572
|
+
"use_ssl",
|
|
359
573
|
)
|
|
360
574
|
username = SigilShortAutoField(
|
|
361
575
|
max_length=255,
|
|
@@ -429,9 +643,14 @@ class EmailInbox(Entity):
|
|
|
429
643
|
def _get_body(msg):
|
|
430
644
|
if msg.is_multipart():
|
|
431
645
|
for part in msg.walk():
|
|
432
|
-
if
|
|
646
|
+
if (
|
|
647
|
+
part.get_content_type() == "text/plain"
|
|
648
|
+
and not part.get_filename()
|
|
649
|
+
):
|
|
433
650
|
charset = part.get_content_charset() or "utf-8"
|
|
434
|
-
return part.get_payload(decode=True).decode(
|
|
651
|
+
return part.get_payload(decode=True).decode(
|
|
652
|
+
charset, errors="ignore"
|
|
653
|
+
)
|
|
435
654
|
return ""
|
|
436
655
|
charset = msg.get_content_charset() or "utf-8"
|
|
437
656
|
return msg.get_payload(decode=True).decode(charset, errors="ignore")
|
|
@@ -555,9 +774,7 @@ class EmailCollector(Entity):
|
|
|
555
774
|
fp = EmailArtifact.fingerprint_for(
|
|
556
775
|
msg.get("subject", ""), msg.get("from", ""), msg.get("body", "")
|
|
557
776
|
)
|
|
558
|
-
if EmailArtifact.objects.filter(
|
|
559
|
-
collector=self, fingerprint=fp
|
|
560
|
-
).exists():
|
|
777
|
+
if EmailArtifact.objects.filter(collector=self, fingerprint=fp).exists():
|
|
561
778
|
break
|
|
562
779
|
EmailArtifact.objects.create(
|
|
563
780
|
collector=self,
|
|
@@ -590,65 +807,19 @@ class EmailArtifact(Entity):
|
|
|
590
807
|
import hashlib
|
|
591
808
|
|
|
592
809
|
data = (subject or "") + (sender or "") + (body or "")
|
|
593
|
-
|
|
810
|
+
hasher = hashlib.md5(data.encode("utf-8"), usedforsecurity=False)
|
|
811
|
+
return hasher.hexdigest()
|
|
594
812
|
|
|
595
813
|
class Meta:
|
|
596
814
|
unique_together = ("collector", "fingerprint")
|
|
597
815
|
verbose_name = "Email Artifact"
|
|
598
816
|
verbose_name_plural = "Email Artifacts"
|
|
817
|
+
ordering = ["-id"]
|
|
599
818
|
|
|
600
819
|
|
|
601
|
-
class
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
MASTODON = "mastodon"
|
|
605
|
-
BLUESKY = "bluesky"
|
|
606
|
-
SERVICE_CHOICES = [
|
|
607
|
-
(MASTODON, "Mastodon"),
|
|
608
|
-
(BLUESKY, "Bluesky"),
|
|
609
|
-
]
|
|
610
|
-
|
|
611
|
-
user = models.OneToOneField(
|
|
612
|
-
settings.AUTH_USER_MODEL,
|
|
613
|
-
related_name="fediverse_profile",
|
|
614
|
-
on_delete=models.CASCADE,
|
|
615
|
-
)
|
|
616
|
-
service = models.CharField(max_length=20, choices=SERVICE_CHOICES)
|
|
617
|
-
host = models.CharField(max_length=255)
|
|
618
|
-
handle = models.CharField(max_length=255)
|
|
619
|
-
access_token = models.CharField(max_length=255, blank=True)
|
|
620
|
-
verified_on = models.DateTimeField(null=True, blank=True)
|
|
621
|
-
|
|
622
|
-
def test_connection(self):
|
|
623
|
-
"""Attempt to verify credentials against the configured service."""
|
|
624
|
-
import requests
|
|
625
|
-
|
|
626
|
-
try:
|
|
627
|
-
headers = {}
|
|
628
|
-
if self.access_token:
|
|
629
|
-
headers["Authorization"] = f"Bearer {self.access_token}"
|
|
630
|
-
if self.service == self.MASTODON:
|
|
631
|
-
url = f"https://{self.host}/api/v1/accounts/verify_credentials"
|
|
632
|
-
resp = requests.get(url, headers=headers, timeout=10)
|
|
633
|
-
else: # BLUESKY
|
|
634
|
-
url = f"https://{self.host}/xrpc/app.bsky.actor.getProfile"
|
|
635
|
-
params = {"actor": self.handle}
|
|
636
|
-
resp = requests.get(url, params=params, headers=headers, timeout=10)
|
|
637
|
-
resp.raise_for_status()
|
|
638
|
-
self.verified_on = timezone.now()
|
|
639
|
-
self.save(update_fields=["verified_on"])
|
|
640
|
-
return True
|
|
641
|
-
except Exception as exc:
|
|
642
|
-
self.verified_on = None
|
|
643
|
-
self.save(update_fields=["verified_on"])
|
|
644
|
-
raise ValidationError(str(exc))
|
|
645
|
-
|
|
646
|
-
def __str__(self): # pragma: no cover - simple representation
|
|
647
|
-
return f"{self.user} @ {self.host}"
|
|
648
|
-
|
|
649
|
-
class Meta:
|
|
650
|
-
verbose_name = _("Fediverse Profile")
|
|
651
|
-
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)
|
|
652
823
|
|
|
653
824
|
|
|
654
825
|
class Reference(Entity):
|
|
@@ -701,12 +872,31 @@ class Reference(Entity):
|
|
|
701
872
|
null=True,
|
|
702
873
|
blank=True,
|
|
703
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()
|
|
704
892
|
|
|
705
893
|
def save(self, *args, **kwargs):
|
|
706
894
|
if self.pk:
|
|
707
895
|
original = type(self).all_objects.get(pk=self.pk)
|
|
708
896
|
if original.transaction_uuid != self.transaction_uuid:
|
|
709
|
-
raise ValidationError(
|
|
897
|
+
raise ValidationError(
|
|
898
|
+
{"transaction_uuid": "Cannot modify transaction UUID"}
|
|
899
|
+
)
|
|
710
900
|
if not self.image and self.value:
|
|
711
901
|
qr = qrcode.QRCode(box_size=10, border=4)
|
|
712
902
|
qr.add_data(self.value)
|
|
@@ -721,6 +911,10 @@ class Reference(Entity):
|
|
|
721
911
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
722
912
|
return self.alt_text
|
|
723
913
|
|
|
914
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
915
|
+
return (self.alt_text,)
|
|
916
|
+
|
|
917
|
+
|
|
724
918
|
class RFID(Entity):
|
|
725
919
|
"""RFID tag that may be assigned to one account."""
|
|
726
920
|
|
|
@@ -736,6 +930,12 @@ class RFID(Entity):
|
|
|
736
930
|
)
|
|
737
931
|
],
|
|
738
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
|
+
)
|
|
739
939
|
key_a = models.CharField(
|
|
740
940
|
max_length=12,
|
|
741
941
|
default="FFFFFFFFFFFF",
|
|
@@ -868,6 +1068,15 @@ class EnergyAccount(Entity):
|
|
|
868
1068
|
default=False,
|
|
869
1069
|
help_text="Allow transactions even when the balance is zero or negative",
|
|
870
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)
|
|
871
1080
|
|
|
872
1081
|
def can_authorize(self) -> bool:
|
|
873
1082
|
"""Return True if this account should be authorized for charging."""
|
|
@@ -906,6 +1115,17 @@ class EnergyAccount(Entity):
|
|
|
906
1115
|
def save(self, *args, **kwargs):
|
|
907
1116
|
if self.name:
|
|
908
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
|
+
)
|
|
909
1129
|
super().save(*args, **kwargs)
|
|
910
1130
|
|
|
911
1131
|
def __str__(self): # pragma: no cover - simple representation
|
|
@@ -949,11 +1169,433 @@ class EnergyCredit(Entity):
|
|
|
949
1169
|
db_table = "core_credit"
|
|
950
1170
|
|
|
951
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
|
+
|
|
952
1592
|
class Brand(Entity):
|
|
953
1593
|
"""Vehicle manufacturer or brand."""
|
|
954
1594
|
|
|
955
1595
|
name = models.CharField(max_length=100, unique=True)
|
|
956
1596
|
|
|
1597
|
+
objects = BrandManager()
|
|
1598
|
+
|
|
957
1599
|
class Meta:
|
|
958
1600
|
verbose_name = _("EV Brand")
|
|
959
1601
|
verbose_name_plural = _("EV Brands")
|
|
@@ -961,6 +1603,9 @@ class Brand(Entity):
|
|
|
961
1603
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
962
1604
|
return self.name
|
|
963
1605
|
|
|
1606
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
1607
|
+
return (self.name,)
|
|
1608
|
+
|
|
964
1609
|
@classmethod
|
|
965
1610
|
def from_vin(cls, vin: str) -> "Brand | None":
|
|
966
1611
|
"""Return the brand matching the VIN's WMI prefix."""
|
|
@@ -989,6 +1634,48 @@ class EVModel(Entity):
|
|
|
989
1634
|
|
|
990
1635
|
brand = models.ForeignKey(Brand, on_delete=models.CASCADE, related_name="ev_models")
|
|
991
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
|
+
)
|
|
992
1679
|
|
|
993
1680
|
class Meta:
|
|
994
1681
|
unique_together = ("brand", "name")
|
|
@@ -1020,9 +1707,7 @@ class ElectricVehicle(Entity):
|
|
|
1020
1707
|
related_name="vehicles",
|
|
1021
1708
|
)
|
|
1022
1709
|
vin = models.CharField(max_length=17, unique=True, verbose_name="VIN")
|
|
1023
|
-
license_plate = models.CharField(
|
|
1024
|
-
_("License Plate"), max_length=20, blank=True
|
|
1025
|
-
)
|
|
1710
|
+
license_plate = models.CharField(_("License Plate"), max_length=20, blank=True)
|
|
1026
1711
|
|
|
1027
1712
|
def save(self, *args, **kwargs):
|
|
1028
1713
|
if self.model and not self.brand:
|
|
@@ -1046,30 +1731,16 @@ class Product(Entity):
|
|
|
1046
1731
|
name = models.CharField(max_length=100)
|
|
1047
1732
|
description = models.TextField(blank=True)
|
|
1048
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
|
+
)
|
|
1049
1739
|
|
|
1050
1740
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1051
1741
|
return self.name
|
|
1052
1742
|
|
|
1053
1743
|
|
|
1054
|
-
class Subscription(Entity):
|
|
1055
|
-
"""An energy account's subscription to a product."""
|
|
1056
|
-
|
|
1057
|
-
account = models.ForeignKey(EnergyAccount, on_delete=models.CASCADE)
|
|
1058
|
-
product = models.ForeignKey(Product, on_delete=models.CASCADE)
|
|
1059
|
-
start_date = models.DateField(auto_now_add=True)
|
|
1060
|
-
next_renewal = models.DateField(blank=True)
|
|
1061
|
-
|
|
1062
|
-
def save(self, *args, **kwargs):
|
|
1063
|
-
if not self.next_renewal:
|
|
1064
|
-
self.next_renewal = self.start_date + timedelta(
|
|
1065
|
-
days=self.product.renewal_period
|
|
1066
|
-
)
|
|
1067
|
-
super().save(*args, **kwargs)
|
|
1068
|
-
|
|
1069
|
-
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1070
|
-
return f"{self.account.user} -> {self.product}"
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
1744
|
class AdminHistory(Entity):
|
|
1074
1745
|
"""Record of recently visited admin changelists for a user."""
|
|
1075
1746
|
|
|
@@ -1092,11 +1763,48 @@ class AdminHistory(Entity):
|
|
|
1092
1763
|
return model._meta.verbose_name_plural if model else self.content_type.name
|
|
1093
1764
|
|
|
1094
1765
|
|
|
1095
|
-
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):
|
|
1096
1786
|
"""Store credentials for publishing packages."""
|
|
1097
1787
|
|
|
1098
|
-
|
|
1099
|
-
|
|
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",
|
|
1100
1808
|
)
|
|
1101
1809
|
pypi_username = SigilShortAutoField("PyPI username", max_length=100, blank=True)
|
|
1102
1810
|
pypi_token = SigilShortAutoField("PyPI token", max_length=200, blank=True)
|
|
@@ -1114,34 +1822,43 @@ class ReleaseManager(Entity):
|
|
|
1114
1822
|
class Meta:
|
|
1115
1823
|
verbose_name = "Release Manager"
|
|
1116
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
|
+
]
|
|
1117
1834
|
|
|
1118
1835
|
def __str__(self) -> str: # pragma: no cover - trivial
|
|
1119
1836
|
return self.name
|
|
1120
1837
|
|
|
1121
1838
|
@property
|
|
1122
1839
|
def name(self) -> str: # pragma: no cover - simple proxy
|
|
1123
|
-
|
|
1840
|
+
owner = self.owner_display()
|
|
1841
|
+
return owner or ""
|
|
1124
1842
|
|
|
1125
1843
|
def to_credentials(self) -> Credentials | None:
|
|
1126
1844
|
"""Return credentials for this release manager."""
|
|
1127
1845
|
if self.pypi_token:
|
|
1128
1846
|
return Credentials(token=self.pypi_token)
|
|
1129
1847
|
if self.pypi_username and self.pypi_password:
|
|
1130
|
-
return Credentials(
|
|
1131
|
-
username=self.pypi_username, password=self.pypi_password
|
|
1132
|
-
)
|
|
1848
|
+
return Credentials(username=self.pypi_username, password=self.pypi_password)
|
|
1133
1849
|
return None
|
|
1134
1850
|
|
|
1135
1851
|
|
|
1136
1852
|
class Package(Entity):
|
|
1137
1853
|
"""Package details shared across releases."""
|
|
1138
1854
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
)
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
)
|
|
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)
|
|
1145
1862
|
author = models.CharField(max_length=100, default=DEFAULT_PACKAGE.author)
|
|
1146
1863
|
email = models.EmailField(default=DEFAULT_PACKAGE.email)
|
|
1147
1864
|
python_requires = models.CharField(
|
|
@@ -1153,14 +1870,30 @@ class Package(Entity):
|
|
|
1153
1870
|
release_manager = models.ForeignKey(
|
|
1154
1871
|
ReleaseManager, on_delete=models.SET_NULL, null=True, blank=True
|
|
1155
1872
|
)
|
|
1873
|
+
is_active = models.BooleanField(
|
|
1874
|
+
default=False,
|
|
1875
|
+
help_text="Designates the active package for version comparisons",
|
|
1876
|
+
)
|
|
1156
1877
|
|
|
1157
1878
|
class Meta:
|
|
1158
1879
|
verbose_name = "Package"
|
|
1159
1880
|
verbose_name_plural = "Packages"
|
|
1881
|
+
constraints = [
|
|
1882
|
+
models.UniqueConstraint(
|
|
1883
|
+
fields=("is_active",),
|
|
1884
|
+
condition=models.Q(is_active=True),
|
|
1885
|
+
name="unique_active_package",
|
|
1886
|
+
)
|
|
1887
|
+
]
|
|
1160
1888
|
|
|
1161
1889
|
def __str__(self) -> str: # pragma: no cover - trivial
|
|
1162
1890
|
return self.name
|
|
1163
1891
|
|
|
1892
|
+
def save(self, *args, **kwargs):
|
|
1893
|
+
if self.is_active:
|
|
1894
|
+
type(self).objects.exclude(pk=self.pk).update(is_active=False)
|
|
1895
|
+
super().save(*args, **kwargs)
|
|
1896
|
+
|
|
1164
1897
|
def to_package(self) -> ReleasePackage:
|
|
1165
1898
|
"""Return a :class:`ReleasePackage` instance from package data."""
|
|
1166
1899
|
return ReleasePackage(
|
|
@@ -1174,9 +1907,15 @@ class Package(Entity):
|
|
|
1174
1907
|
homepage_url=self.homepage_url,
|
|
1175
1908
|
)
|
|
1176
1909
|
|
|
1910
|
+
|
|
1177
1911
|
class PackageRelease(Entity):
|
|
1178
1912
|
"""Store metadata for a specific package version."""
|
|
1179
1913
|
|
|
1914
|
+
objects = PackageReleaseManager()
|
|
1915
|
+
|
|
1916
|
+
def natural_key(self):
|
|
1917
|
+
return (self.package.name, self.version)
|
|
1918
|
+
|
|
1180
1919
|
package = models.ForeignKey(
|
|
1181
1920
|
Package, on_delete=models.CASCADE, related_name="releases"
|
|
1182
1921
|
)
|
|
@@ -1201,10 +1940,15 @@ class PackageRelease(Entity):
|
|
|
1201
1940
|
|
|
1202
1941
|
@classmethod
|
|
1203
1942
|
def dump_fixture(cls) -> None:
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
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)
|
|
1208
1952
|
|
|
1209
1953
|
def __str__(self) -> str: # pragma: no cover - trivial
|
|
1210
1954
|
return f"{self.package.name} {self.version}"
|
|
@@ -1250,11 +1994,13 @@ class PackageRelease(Entity):
|
|
|
1250
1994
|
|
|
1251
1995
|
@property
|
|
1252
1996
|
def is_current(self) -> bool:
|
|
1253
|
-
"""Return ``True``
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1997
|
+
"""Return ``True`` when this release's version matches the VERSION file
|
|
1998
|
+
and its package is active."""
|
|
1999
|
+
version_path = Path("VERSION")
|
|
2000
|
+
if not version_path.exists():
|
|
2001
|
+
return False
|
|
2002
|
+
current_version = version_path.read_text().strip()
|
|
2003
|
+
return current_version == self.version and self.package.is_active
|
|
1258
2004
|
|
|
1259
2005
|
@classmethod
|
|
1260
2006
|
def latest(cls):
|
|
@@ -1281,13 +2027,16 @@ class PackageRelease(Entity):
|
|
|
1281
2027
|
self.save(update_fields=["revision"])
|
|
1282
2028
|
PackageRelease.dump_fixture()
|
|
1283
2029
|
if kwargs.get("git"):
|
|
2030
|
+
from glob import glob
|
|
2031
|
+
|
|
2032
|
+
paths = sorted(glob("core/fixtures/releases__*.json"))
|
|
1284
2033
|
diff = subprocess.run(
|
|
1285
|
-
["git", "status", "--porcelain",
|
|
2034
|
+
["git", "status", "--porcelain", *paths],
|
|
1286
2035
|
capture_output=True,
|
|
1287
2036
|
text=True,
|
|
1288
2037
|
)
|
|
1289
2038
|
if diff.stdout.strip():
|
|
1290
|
-
release_utils._run(["git", "add",
|
|
2039
|
+
release_utils._run(["git", "add", *paths])
|
|
1291
2040
|
release_utils._run(
|
|
1292
2041
|
[
|
|
1293
2042
|
"git",
|
|
@@ -1302,17 +2051,145 @@ class PackageRelease(Entity):
|
|
|
1302
2051
|
def revision_short(self) -> str:
|
|
1303
2052
|
return self.revision[-6:] if self.revision else ""
|
|
1304
2053
|
|
|
2054
|
+
|
|
1305
2055
|
# Ensure each RFID can only be linked to one energy account
|
|
1306
2056
|
@receiver(m2m_changed, sender=EnergyAccount.rfids.through)
|
|
1307
|
-
def _rfid_unique_energy_account(
|
|
2057
|
+
def _rfid_unique_energy_account(
|
|
2058
|
+
sender, instance, action, reverse, model, pk_set, **kwargs
|
|
2059
|
+
):
|
|
1308
2060
|
"""Prevent associating an RFID with more than one energy account."""
|
|
1309
2061
|
if action == "pre_add":
|
|
1310
2062
|
if reverse: # adding energy accounts to an RFID
|
|
1311
2063
|
if instance.energy_accounts.exclude(pk__in=pk_set).exists():
|
|
1312
|
-
raise ValidationError(
|
|
2064
|
+
raise ValidationError(
|
|
2065
|
+
"RFID tags may only be assigned to one energy account."
|
|
2066
|
+
)
|
|
1313
2067
|
else: # adding RFIDs to an energy account
|
|
1314
2068
|
conflict = model.objects.filter(
|
|
1315
2069
|
pk__in=pk_set, energy_accounts__isnull=False
|
|
1316
2070
|
).exclude(energy_accounts=instance)
|
|
1317
2071
|
if conflict.exists():
|
|
1318
|
-
raise ValidationError(
|
|
2072
|
+
raise ValidationError(
|
|
2073
|
+
"RFID tags may only be assigned to one energy account."
|
|
2074
|
+
)
|
|
2075
|
+
|
|
2076
|
+
|
|
2077
|
+
def hash_key(key: str) -> str:
|
|
2078
|
+
"""Return a SHA-256 hash for ``key``."""
|
|
2079
|
+
|
|
2080
|
+
return hashlib.sha256(key.encode()).hexdigest()
|
|
2081
|
+
|
|
2082
|
+
|
|
2083
|
+
class AssistantProfile(Profile):
|
|
2084
|
+
"""Stores a hashed user key used by the assistant for authentication.
|
|
2085
|
+
|
|
2086
|
+
The plain-text ``user_key`` is generated server-side and shown only once.
|
|
2087
|
+
Users must supply this key in the ``Authorization: Bearer <user_key>``
|
|
2088
|
+
header when requesting protected endpoints. Only the hash is stored.
|
|
2089
|
+
"""
|
|
2090
|
+
|
|
2091
|
+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
2092
|
+
profile_fields = ("user_key_hash", "scopes", "is_active")
|
|
2093
|
+
user_key_hash = models.CharField(max_length=64, unique=True)
|
|
2094
|
+
scopes = models.JSONField(default=list, blank=True)
|
|
2095
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
2096
|
+
last_used_at = models.DateTimeField(null=True, blank=True)
|
|
2097
|
+
is_active = models.BooleanField(default=True)
|
|
2098
|
+
|
|
2099
|
+
class Meta:
|
|
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
|
+
]
|
|
2112
|
+
|
|
2113
|
+
@classmethod
|
|
2114
|
+
def issue_key(cls, user) -> tuple["AssistantProfile", str]:
|
|
2115
|
+
"""Create or update a profile and return it with a new plain key."""
|
|
2116
|
+
|
|
2117
|
+
key = secrets.token_hex(32)
|
|
2118
|
+
key_hash = hash_key(key)
|
|
2119
|
+
if user is None:
|
|
2120
|
+
raise ValueError("Assistant profiles require a user instance")
|
|
2121
|
+
|
|
2122
|
+
profile, _ = cls.objects.update_or_create(
|
|
2123
|
+
user=user,
|
|
2124
|
+
defaults={
|
|
2125
|
+
"user_key_hash": key_hash,
|
|
2126
|
+
"last_used_at": None,
|
|
2127
|
+
"is_active": True,
|
|
2128
|
+
},
|
|
2129
|
+
)
|
|
2130
|
+
return profile, key
|
|
2131
|
+
|
|
2132
|
+
def touch(self) -> None:
|
|
2133
|
+
"""Record that the key was used."""
|
|
2134
|
+
|
|
2135
|
+
self.last_used_at = timezone.now()
|
|
2136
|
+
self.save(update_fields=["last_used_at"])
|
|
2137
|
+
|
|
2138
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
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 = []
|