arthexis 0.1.3__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.3.dist-info/METADATA +126 -0
- arthexis-0.1.3.dist-info/RECORD +73 -0
- arthexis-0.1.3.dist-info/WHEEL +5 -0
- arthexis-0.1.3.dist-info/licenses/LICENSE +21 -0
- arthexis-0.1.3.dist-info/top_level.txt +5 -0
- config/__init__.py +6 -0
- config/active_app.py +15 -0
- config/asgi.py +29 -0
- config/auth_app.py +8 -0
- config/celery.py +19 -0
- config/context_processors.py +68 -0
- config/loadenv.py +11 -0
- config/logging.py +43 -0
- config/middleware.py +25 -0
- config/offline.py +47 -0
- config/settings.py +374 -0
- config/urls.py +91 -0
- config/wsgi.py +17 -0
- core/__init__.py +0 -0
- core/admin.py +830 -0
- core/apps.py +67 -0
- core/backends.py +82 -0
- core/entity.py +97 -0
- core/environment.py +43 -0
- core/fields.py +70 -0
- core/lcd_screen.py +77 -0
- core/middleware.py +34 -0
- core/models.py +1277 -0
- core/notifications.py +95 -0
- core/release.py +451 -0
- core/system.py +111 -0
- core/tasks.py +100 -0
- core/tests.py +483 -0
- core/urls.py +11 -0
- core/user_data.py +333 -0
- core/views.py +431 -0
- nodes/__init__.py +0 -0
- nodes/actions.py +72 -0
- nodes/admin.py +347 -0
- nodes/apps.py +76 -0
- nodes/lcd.py +151 -0
- nodes/models.py +577 -0
- nodes/tasks.py +50 -0
- nodes/tests.py +1072 -0
- nodes/urls.py +13 -0
- nodes/utils.py +62 -0
- nodes/views.py +262 -0
- ocpp/__init__.py +0 -0
- ocpp/admin.py +392 -0
- ocpp/apps.py +24 -0
- ocpp/consumers.py +267 -0
- ocpp/evcs.py +911 -0
- ocpp/models.py +300 -0
- ocpp/routing.py +9 -0
- ocpp/simulator.py +357 -0
- ocpp/store.py +175 -0
- ocpp/tasks.py +27 -0
- ocpp/test_export_import.py +129 -0
- ocpp/test_rfid.py +345 -0
- ocpp/tests.py +1229 -0
- ocpp/transactions_io.py +119 -0
- ocpp/urls.py +17 -0
- ocpp/views.py +359 -0
- pages/__init__.py +0 -0
- pages/admin.py +231 -0
- pages/apps.py +10 -0
- pages/checks.py +41 -0
- pages/context_processors.py +72 -0
- pages/models.py +224 -0
- pages/tests.py +628 -0
- pages/urls.py +17 -0
- pages/utils.py +13 -0
- pages/views.py +191 -0
core/models.py
ADDED
|
@@ -0,0 +1,1277 @@
|
|
|
1
|
+
from django.contrib.auth.models import (
|
|
2
|
+
AbstractUser,
|
|
3
|
+
Group,
|
|
4
|
+
UserManager as DjangoUserManager,
|
|
5
|
+
)
|
|
6
|
+
from django.db import models
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
from django.contrib.auth import get_user_model
|
|
9
|
+
from django.utils.translation import gettext_lazy as _
|
|
10
|
+
from django.core.validators import RegexValidator
|
|
11
|
+
from django.core.exceptions import ValidationError
|
|
12
|
+
from django.apps import apps
|
|
13
|
+
from django.db.models.signals import m2m_changed, post_delete
|
|
14
|
+
from django.dispatch import receiver
|
|
15
|
+
from datetime import timedelta
|
|
16
|
+
from django.contrib.contenttypes.models import ContentType
|
|
17
|
+
import hashlib
|
|
18
|
+
import os
|
|
19
|
+
from io import BytesIO
|
|
20
|
+
from django.core.files.base import ContentFile
|
|
21
|
+
import qrcode
|
|
22
|
+
import xmlrpc.client
|
|
23
|
+
from django.utils import timezone
|
|
24
|
+
import uuid
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from django.core import serializers
|
|
27
|
+
from utils import revision as revision_utils
|
|
28
|
+
|
|
29
|
+
from .entity import Entity, EntityUserManager
|
|
30
|
+
from .release import Package as ReleasePackage, Credentials, DEFAULT_PACKAGE
|
|
31
|
+
from .user_data import UserDatum # noqa: F401 - ensure model registration
|
|
32
|
+
from .fields import SigilShortAutoField
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SecurityGroup(Group):
|
|
36
|
+
parent = models.ForeignKey(
|
|
37
|
+
"self",
|
|
38
|
+
null=True,
|
|
39
|
+
blank=True,
|
|
40
|
+
on_delete=models.SET_NULL,
|
|
41
|
+
related_name="children",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
class Meta:
|
|
45
|
+
verbose_name = "Security Group"
|
|
46
|
+
verbose_name_plural = "Security Groups"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SigilRoot(Entity):
|
|
50
|
+
class Context(models.TextChoices):
|
|
51
|
+
CONFIG = "config", "Configuration"
|
|
52
|
+
ENTITY = "entity", "Entity"
|
|
53
|
+
|
|
54
|
+
prefix = models.CharField(max_length=50, unique=True)
|
|
55
|
+
context_type = models.CharField(max_length=20, choices=Context.choices)
|
|
56
|
+
|
|
57
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
58
|
+
return self.prefix
|
|
59
|
+
|
|
60
|
+
class Meta:
|
|
61
|
+
verbose_name = "Sigil Root"
|
|
62
|
+
verbose_name_plural = "Sigil Roots"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Address(Entity):
|
|
66
|
+
"""Physical location information for a user."""
|
|
67
|
+
|
|
68
|
+
class State(models.TextChoices):
|
|
69
|
+
COAHUILA = "CO", "Coahuila"
|
|
70
|
+
NUEVO_LEON = "NL", "Nuevo León"
|
|
71
|
+
|
|
72
|
+
COAHUILA_MUNICIPALITIES = [
|
|
73
|
+
"Abasolo",
|
|
74
|
+
"Acuña",
|
|
75
|
+
"Allende",
|
|
76
|
+
"Arteaga",
|
|
77
|
+
"Candela",
|
|
78
|
+
"Castaños",
|
|
79
|
+
"Cuatro Ciénegas",
|
|
80
|
+
"Escobedo",
|
|
81
|
+
"Francisco I. Madero",
|
|
82
|
+
"Frontera",
|
|
83
|
+
"General Cepeda",
|
|
84
|
+
"Guerrero",
|
|
85
|
+
"Hidalgo",
|
|
86
|
+
"Jiménez",
|
|
87
|
+
"Juárez",
|
|
88
|
+
"Lamadrid",
|
|
89
|
+
"Matamoros",
|
|
90
|
+
"Monclova",
|
|
91
|
+
"Morelos",
|
|
92
|
+
"Múzquiz",
|
|
93
|
+
"Nadadores",
|
|
94
|
+
"Nava",
|
|
95
|
+
"Ocampo",
|
|
96
|
+
"Parras",
|
|
97
|
+
"Piedras Negras",
|
|
98
|
+
"Progreso",
|
|
99
|
+
"Ramos Arizpe",
|
|
100
|
+
"Sabinas",
|
|
101
|
+
"Sacramento",
|
|
102
|
+
"Saltillo",
|
|
103
|
+
"San Buenaventura",
|
|
104
|
+
"San Juan de Sabinas",
|
|
105
|
+
"San Pedro",
|
|
106
|
+
"Sierra Mojada",
|
|
107
|
+
"Torreón",
|
|
108
|
+
"Viesca",
|
|
109
|
+
"Villa Unión",
|
|
110
|
+
"Zaragoza",
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
NUEVO_LEON_MUNICIPALITIES = [
|
|
114
|
+
"Abasolo",
|
|
115
|
+
"Agualeguas",
|
|
116
|
+
"Los Aldamas",
|
|
117
|
+
"Allende",
|
|
118
|
+
"Anáhuac",
|
|
119
|
+
"Apodaca",
|
|
120
|
+
"Aramberri",
|
|
121
|
+
"Bustamante",
|
|
122
|
+
"Cadereyta Jiménez",
|
|
123
|
+
"El Carmen",
|
|
124
|
+
"Cerralvo",
|
|
125
|
+
"Ciénega de Flores",
|
|
126
|
+
"China",
|
|
127
|
+
"Doctor Arroyo",
|
|
128
|
+
"Doctor Coss",
|
|
129
|
+
"Doctor González",
|
|
130
|
+
"Galeana",
|
|
131
|
+
"García",
|
|
132
|
+
"General Bravo",
|
|
133
|
+
"General Escobedo",
|
|
134
|
+
"General Terán",
|
|
135
|
+
"General Treviño",
|
|
136
|
+
"General Zaragoza",
|
|
137
|
+
"General Zuazua",
|
|
138
|
+
"Guadalupe",
|
|
139
|
+
"Los Herreras",
|
|
140
|
+
"Higueras",
|
|
141
|
+
"Hualahuises",
|
|
142
|
+
"Iturbide",
|
|
143
|
+
"Juárez",
|
|
144
|
+
"Lampazos de Naranjo",
|
|
145
|
+
"Linares",
|
|
146
|
+
"Marín",
|
|
147
|
+
"Melchor Ocampo",
|
|
148
|
+
"Mier y Noriega",
|
|
149
|
+
"Mina",
|
|
150
|
+
"Montemorelos",
|
|
151
|
+
"Monterrey",
|
|
152
|
+
"Parás",
|
|
153
|
+
"Pesquería",
|
|
154
|
+
"Los Ramones",
|
|
155
|
+
"Rayones",
|
|
156
|
+
"Sabinas Hidalgo",
|
|
157
|
+
"Salinas Victoria",
|
|
158
|
+
"San Nicolás de los Garza",
|
|
159
|
+
"San Pedro Garza García",
|
|
160
|
+
"Santa Catarina",
|
|
161
|
+
"Santiago",
|
|
162
|
+
"Vallecillo",
|
|
163
|
+
"Villaldama",
|
|
164
|
+
"Hidalgo",
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
MUNICIPALITIES_BY_STATE = {
|
|
168
|
+
State.COAHUILA: COAHUILA_MUNICIPALITIES,
|
|
169
|
+
State.NUEVO_LEON: NUEVO_LEON_MUNICIPALITIES,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
MUNICIPALITY_CHOICES = [
|
|
173
|
+
(name, name) for name in COAHUILA_MUNICIPALITIES + NUEVO_LEON_MUNICIPALITIES
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
street = models.CharField(max_length=255)
|
|
177
|
+
number = models.CharField(max_length=20)
|
|
178
|
+
municipality = models.CharField(max_length=100, choices=MUNICIPALITY_CHOICES)
|
|
179
|
+
state = models.CharField(max_length=2, choices=State.choices)
|
|
180
|
+
postal_code = models.CharField(max_length=10)
|
|
181
|
+
|
|
182
|
+
class Meta:
|
|
183
|
+
verbose_name_plural = _("Addresses")
|
|
184
|
+
|
|
185
|
+
def clean(self):
|
|
186
|
+
from django.core.exceptions import ValidationError
|
|
187
|
+
|
|
188
|
+
allowed = self.MUNICIPALITIES_BY_STATE.get(self.state, [])
|
|
189
|
+
if self.municipality not in allowed:
|
|
190
|
+
raise ValidationError(
|
|
191
|
+
{"municipality": _("Invalid municipality for the selected state")}
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def __str__(self): # pragma: no cover - simple representation
|
|
195
|
+
return f"{self.street} {self.number}, {self.municipality}, {self.state}"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class User(Entity, AbstractUser):
|
|
199
|
+
objects = EntityUserManager()
|
|
200
|
+
all_objects = DjangoUserManager()
|
|
201
|
+
"""Custom user model."""
|
|
202
|
+
|
|
203
|
+
phone_number = models.CharField(
|
|
204
|
+
max_length=20,
|
|
205
|
+
blank=True,
|
|
206
|
+
help_text="Optional contact phone number",
|
|
207
|
+
)
|
|
208
|
+
address = models.ForeignKey(
|
|
209
|
+
Address,
|
|
210
|
+
null=True,
|
|
211
|
+
blank=True,
|
|
212
|
+
on_delete=models.SET_NULL,
|
|
213
|
+
)
|
|
214
|
+
has_charger = models.BooleanField(default=False)
|
|
215
|
+
is_active = models.BooleanField(
|
|
216
|
+
_("active"),
|
|
217
|
+
default=True,
|
|
218
|
+
help_text=(
|
|
219
|
+
"Designates whether this user should be treated as active. Unselect this instead of deleting energy accounts."
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def __str__(self):
|
|
224
|
+
return self.username
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class OdooProfile(Entity):
|
|
228
|
+
"""Store Odoo API credentials for a user."""
|
|
229
|
+
|
|
230
|
+
user = models.OneToOneField(
|
|
231
|
+
settings.AUTH_USER_MODEL,
|
|
232
|
+
related_name="odoo_profile",
|
|
233
|
+
on_delete=models.CASCADE,
|
|
234
|
+
)
|
|
235
|
+
host = SigilShortAutoField(max_length=255)
|
|
236
|
+
database = SigilShortAutoField(max_length=255)
|
|
237
|
+
username = SigilShortAutoField(max_length=255)
|
|
238
|
+
password = SigilShortAutoField(max_length=255)
|
|
239
|
+
verified_on = models.DateTimeField(null=True, blank=True)
|
|
240
|
+
odoo_uid = models.PositiveIntegerField(null=True, blank=True, editable=False)
|
|
241
|
+
name = models.CharField(max_length=255, blank=True, editable=False)
|
|
242
|
+
email = models.EmailField(blank=True, editable=False)
|
|
243
|
+
|
|
244
|
+
def _clear_verification(self):
|
|
245
|
+
self.verified_on = None
|
|
246
|
+
self.odoo_uid = None
|
|
247
|
+
self.name = ""
|
|
248
|
+
self.email = ""
|
|
249
|
+
|
|
250
|
+
def save(self, *args, **kwargs):
|
|
251
|
+
if self.pk:
|
|
252
|
+
old = type(self).all_objects.get(pk=self.pk)
|
|
253
|
+
if (
|
|
254
|
+
old.username != self.username
|
|
255
|
+
or old.password != self.password
|
|
256
|
+
or old.database != self.database
|
|
257
|
+
or old.host != self.host
|
|
258
|
+
):
|
|
259
|
+
self._clear_verification()
|
|
260
|
+
super().save(*args, **kwargs)
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def is_verified(self):
|
|
264
|
+
return self.verified_on is not None
|
|
265
|
+
|
|
266
|
+
def verify(self):
|
|
267
|
+
"""Check credentials against Odoo and pull user info."""
|
|
268
|
+
common = xmlrpc.client.ServerProxy(f"{self.host}/xmlrpc/2/common")
|
|
269
|
+
uid = common.authenticate(self.database, self.username, self.password, {})
|
|
270
|
+
if not uid:
|
|
271
|
+
self._clear_verification()
|
|
272
|
+
raise ValidationError(_("Invalid Odoo credentials"))
|
|
273
|
+
models_proxy = xmlrpc.client.ServerProxy(f"{self.host}/xmlrpc/2/object")
|
|
274
|
+
info = models_proxy.execute_kw(
|
|
275
|
+
self.database,
|
|
276
|
+
uid,
|
|
277
|
+
self.password,
|
|
278
|
+
"res.users",
|
|
279
|
+
"read",
|
|
280
|
+
[uid],
|
|
281
|
+
{"fields": ["name", "email"]},
|
|
282
|
+
)[0]
|
|
283
|
+
self.odoo_uid = uid
|
|
284
|
+
self.name = info.get("name", "")
|
|
285
|
+
self.email = info.get("email", "")
|
|
286
|
+
self.verified_on = timezone.now()
|
|
287
|
+
self.save(update_fields=["odoo_uid", "name", "email", "verified_on"])
|
|
288
|
+
return True
|
|
289
|
+
|
|
290
|
+
def execute(self, model, method, *args, **kwargs):
|
|
291
|
+
"""Execute an Odoo RPC call, invalidating credentials on failure."""
|
|
292
|
+
try:
|
|
293
|
+
client = xmlrpc.client.ServerProxy(f"{self.host}/xmlrpc/2/object")
|
|
294
|
+
return client.execute_kw(
|
|
295
|
+
self.database,
|
|
296
|
+
self.odoo_uid,
|
|
297
|
+
self.password,
|
|
298
|
+
model,
|
|
299
|
+
method,
|
|
300
|
+
args,
|
|
301
|
+
kwargs,
|
|
302
|
+
)
|
|
303
|
+
except Exception:
|
|
304
|
+
self._clear_verification()
|
|
305
|
+
self.save(update_fields=["verified_on"])
|
|
306
|
+
raise
|
|
307
|
+
|
|
308
|
+
def __str__(self): # pragma: no cover - simple representation
|
|
309
|
+
return f"{self.user} @ {self.host}"
|
|
310
|
+
|
|
311
|
+
class Meta:
|
|
312
|
+
verbose_name = _("Odoo Profile")
|
|
313
|
+
verbose_name_plural = _("Odoo Profiles")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class EmailInbox(Entity):
|
|
317
|
+
"""Credentials and configuration for connecting to an email mailbox."""
|
|
318
|
+
|
|
319
|
+
IMAP = "imap"
|
|
320
|
+
POP3 = "pop3"
|
|
321
|
+
PROTOCOL_CHOICES = [
|
|
322
|
+
(IMAP, "IMAP"),
|
|
323
|
+
(POP3, "POP3"),
|
|
324
|
+
]
|
|
325
|
+
|
|
326
|
+
user = models.ForeignKey(
|
|
327
|
+
settings.AUTH_USER_MODEL,
|
|
328
|
+
related_name="email_inboxes",
|
|
329
|
+
on_delete=models.CASCADE,
|
|
330
|
+
)
|
|
331
|
+
username = SigilShortAutoField(
|
|
332
|
+
max_length=255,
|
|
333
|
+
help_text="Login name for the mailbox",
|
|
334
|
+
)
|
|
335
|
+
host = SigilShortAutoField(
|
|
336
|
+
max_length=255,
|
|
337
|
+
help_text=(
|
|
338
|
+
"Examples: Gmail IMAP 'imap.gmail.com', Gmail POP3 'pop.gmail.com',"
|
|
339
|
+
" GoDaddy IMAP 'imap.secureserver.net', GoDaddy POP3 'pop.secureserver.net'"
|
|
340
|
+
),
|
|
341
|
+
)
|
|
342
|
+
port = models.PositiveIntegerField(
|
|
343
|
+
default=993,
|
|
344
|
+
help_text=(
|
|
345
|
+
"Common ports: Gmail IMAP 993, Gmail POP3 995, "
|
|
346
|
+
"GoDaddy IMAP 993, GoDaddy POP3 995"
|
|
347
|
+
),
|
|
348
|
+
)
|
|
349
|
+
password = SigilShortAutoField(max_length=255)
|
|
350
|
+
protocol = SigilShortAutoField(
|
|
351
|
+
max_length=5,
|
|
352
|
+
choices=PROTOCOL_CHOICES,
|
|
353
|
+
default=IMAP,
|
|
354
|
+
help_text=(
|
|
355
|
+
"IMAP keeps emails on the server for access across devices; "
|
|
356
|
+
"POP3 downloads messages to a single device and may remove them from the server"
|
|
357
|
+
),
|
|
358
|
+
)
|
|
359
|
+
use_ssl = models.BooleanField(default=True)
|
|
360
|
+
|
|
361
|
+
class Meta:
|
|
362
|
+
verbose_name = "Email Inbox"
|
|
363
|
+
verbose_name_plural = "Email Inboxes"
|
|
364
|
+
|
|
365
|
+
def test_connection(self):
|
|
366
|
+
"""Attempt to connect to the configured mailbox."""
|
|
367
|
+
try:
|
|
368
|
+
if self.protocol == self.IMAP:
|
|
369
|
+
import imaplib
|
|
370
|
+
|
|
371
|
+
conn = (
|
|
372
|
+
imaplib.IMAP4_SSL(self.host, self.port)
|
|
373
|
+
if self.use_ssl
|
|
374
|
+
else imaplib.IMAP4(self.host, self.port)
|
|
375
|
+
)
|
|
376
|
+
conn.login(self.username, self.password)
|
|
377
|
+
conn.logout()
|
|
378
|
+
else:
|
|
379
|
+
import poplib
|
|
380
|
+
|
|
381
|
+
conn = (
|
|
382
|
+
poplib.POP3_SSL(self.host, self.port)
|
|
383
|
+
if self.use_ssl
|
|
384
|
+
else poplib.POP3(self.host, self.port)
|
|
385
|
+
)
|
|
386
|
+
conn.user(self.username)
|
|
387
|
+
conn.pass_(self.password)
|
|
388
|
+
conn.quit()
|
|
389
|
+
return True
|
|
390
|
+
except Exception as exc:
|
|
391
|
+
raise ValidationError(str(exc))
|
|
392
|
+
|
|
393
|
+
def search_messages(self, subject="", from_address="", body="", limit: int = 10):
|
|
394
|
+
"""Retrieve up to ``limit`` recent messages matching the filters.
|
|
395
|
+
|
|
396
|
+
Parameters are case-insensitive fragments. Results are returned as a list
|
|
397
|
+
of dictionaries with ``subject``, ``from`` and ``body`` keys.
|
|
398
|
+
"""
|
|
399
|
+
|
|
400
|
+
def _get_body(msg):
|
|
401
|
+
if msg.is_multipart():
|
|
402
|
+
for part in msg.walk():
|
|
403
|
+
if part.get_content_type() == "text/plain" and not part.get_filename():
|
|
404
|
+
charset = part.get_content_charset() or "utf-8"
|
|
405
|
+
return part.get_payload(decode=True).decode(charset, errors="ignore")
|
|
406
|
+
return ""
|
|
407
|
+
charset = msg.get_content_charset() or "utf-8"
|
|
408
|
+
return msg.get_payload(decode=True).decode(charset, errors="ignore")
|
|
409
|
+
|
|
410
|
+
if self.protocol == self.IMAP:
|
|
411
|
+
import imaplib
|
|
412
|
+
import email
|
|
413
|
+
|
|
414
|
+
conn = (
|
|
415
|
+
imaplib.IMAP4_SSL(self.host, self.port)
|
|
416
|
+
if self.use_ssl
|
|
417
|
+
else imaplib.IMAP4(self.host, self.port)
|
|
418
|
+
)
|
|
419
|
+
conn.login(self.username, self.password)
|
|
420
|
+
conn.select("INBOX")
|
|
421
|
+
criteria = []
|
|
422
|
+
if subject:
|
|
423
|
+
criteria.extend(["SUBJECT", f'"{subject}"'])
|
|
424
|
+
if from_address:
|
|
425
|
+
criteria.extend(["FROM", f'"{from_address}"'])
|
|
426
|
+
if body:
|
|
427
|
+
criteria.extend(["TEXT", f'"{body}"'])
|
|
428
|
+
if not criteria:
|
|
429
|
+
criteria = ["ALL"]
|
|
430
|
+
typ, data = conn.search(None, *criteria)
|
|
431
|
+
ids = data[0].split()[-limit:]
|
|
432
|
+
messages = []
|
|
433
|
+
for mid in ids:
|
|
434
|
+
typ, msg_data = conn.fetch(mid, "(RFC822)")
|
|
435
|
+
msg = email.message_from_bytes(msg_data[0][1])
|
|
436
|
+
messages.append(
|
|
437
|
+
{
|
|
438
|
+
"subject": msg.get("Subject", ""),
|
|
439
|
+
"from": msg.get("From", ""),
|
|
440
|
+
"body": _get_body(msg),
|
|
441
|
+
}
|
|
442
|
+
)
|
|
443
|
+
conn.logout()
|
|
444
|
+
return list(reversed(messages))
|
|
445
|
+
|
|
446
|
+
import poplib
|
|
447
|
+
import email
|
|
448
|
+
|
|
449
|
+
conn = (
|
|
450
|
+
poplib.POP3_SSL(self.host, self.port)
|
|
451
|
+
if self.use_ssl
|
|
452
|
+
else poplib.POP3(self.host, self.port)
|
|
453
|
+
)
|
|
454
|
+
conn.user(self.username)
|
|
455
|
+
conn.pass_(self.password)
|
|
456
|
+
count = len(conn.list()[1])
|
|
457
|
+
messages = []
|
|
458
|
+
for i in range(count, 0, -1):
|
|
459
|
+
resp, lines, octets = conn.retr(i)
|
|
460
|
+
msg = email.message_from_bytes(b"\n".join(lines))
|
|
461
|
+
subj = msg.get("Subject", "")
|
|
462
|
+
frm = msg.get("From", "")
|
|
463
|
+
body_text = _get_body(msg)
|
|
464
|
+
if subject and subject.lower() not in subj.lower():
|
|
465
|
+
continue
|
|
466
|
+
if from_address and from_address.lower() not in frm.lower():
|
|
467
|
+
continue
|
|
468
|
+
if body and body.lower() not in body_text.lower():
|
|
469
|
+
continue
|
|
470
|
+
messages.append({"subject": subj, "from": frm, "body": body_text})
|
|
471
|
+
if len(messages) >= limit:
|
|
472
|
+
break
|
|
473
|
+
conn.quit()
|
|
474
|
+
return messages
|
|
475
|
+
|
|
476
|
+
def __str__(self): # pragma: no cover - simple representation
|
|
477
|
+
return f"{self.username}@{self.host}"
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
class EmailCollector(Entity):
|
|
481
|
+
"""Search an inbox for matching messages and extract data via sigils."""
|
|
482
|
+
|
|
483
|
+
inbox = models.ForeignKey(
|
|
484
|
+
"EmailInbox",
|
|
485
|
+
related_name="collectors",
|
|
486
|
+
on_delete=models.CASCADE,
|
|
487
|
+
)
|
|
488
|
+
subject = models.CharField(max_length=255, blank=True)
|
|
489
|
+
sender = models.CharField(max_length=255, blank=True)
|
|
490
|
+
body = models.CharField(max_length=255, blank=True)
|
|
491
|
+
fragment = models.CharField(
|
|
492
|
+
max_length=255,
|
|
493
|
+
blank=True,
|
|
494
|
+
help_text="Pattern with [sigils] to extract values from the body.",
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
def _parse_sigils(self, text: str) -> dict[str, str]:
|
|
498
|
+
"""Extract values from ``text`` according to ``fragment`` sigils."""
|
|
499
|
+
if not self.fragment:
|
|
500
|
+
return {}
|
|
501
|
+
import re
|
|
502
|
+
|
|
503
|
+
parts = re.split(r"\[([^\]]+)\]", self.fragment)
|
|
504
|
+
pattern = ""
|
|
505
|
+
for idx, part in enumerate(parts):
|
|
506
|
+
if idx % 2 == 0:
|
|
507
|
+
pattern += re.escape(part)
|
|
508
|
+
else:
|
|
509
|
+
pattern += f"(?P<{part}>.+)"
|
|
510
|
+
match = re.search(pattern, text)
|
|
511
|
+
if not match:
|
|
512
|
+
return {}
|
|
513
|
+
return {k: v.strip() for k, v in match.groupdict().items()}
|
|
514
|
+
|
|
515
|
+
def collect(self, limit: int = 10) -> None:
|
|
516
|
+
"""Poll the inbox and store new artifacts until an existing one is found."""
|
|
517
|
+
from .models import EmailArtifact
|
|
518
|
+
|
|
519
|
+
messages = self.inbox.search_messages(
|
|
520
|
+
subject=self.subject,
|
|
521
|
+
from_address=self.sender,
|
|
522
|
+
body=self.body,
|
|
523
|
+
limit=limit,
|
|
524
|
+
)
|
|
525
|
+
for msg in messages:
|
|
526
|
+
fp = EmailArtifact.fingerprint_for(
|
|
527
|
+
msg.get("subject", ""), msg.get("from", ""), msg.get("body", "")
|
|
528
|
+
)
|
|
529
|
+
if EmailArtifact.objects.filter(
|
|
530
|
+
collector=self, fingerprint=fp
|
|
531
|
+
).exists():
|
|
532
|
+
break
|
|
533
|
+
EmailArtifact.objects.create(
|
|
534
|
+
collector=self,
|
|
535
|
+
subject=msg.get("subject", ""),
|
|
536
|
+
sender=msg.get("from", ""),
|
|
537
|
+
body=msg.get("body", ""),
|
|
538
|
+
sigils=self._parse_sigils(msg.get("body", "")),
|
|
539
|
+
fingerprint=fp,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
class Meta:
|
|
543
|
+
verbose_name = _("Email Collector")
|
|
544
|
+
verbose_name_plural = _("Email Collectors")
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
class EmailArtifact(Entity):
|
|
548
|
+
"""Store messages discovered by :class:`EmailCollector`."""
|
|
549
|
+
|
|
550
|
+
collector = models.ForeignKey(
|
|
551
|
+
EmailCollector, related_name="artifacts", on_delete=models.CASCADE
|
|
552
|
+
)
|
|
553
|
+
subject = models.CharField(max_length=255)
|
|
554
|
+
sender = models.CharField(max_length=255)
|
|
555
|
+
body = models.TextField(blank=True)
|
|
556
|
+
sigils = models.JSONField(default=dict)
|
|
557
|
+
fingerprint = models.CharField(max_length=32)
|
|
558
|
+
|
|
559
|
+
@staticmethod
|
|
560
|
+
def fingerprint_for(subject: str, sender: str, body: str) -> str:
|
|
561
|
+
import hashlib
|
|
562
|
+
|
|
563
|
+
data = (subject or "") + (sender or "") + (body or "")
|
|
564
|
+
return hashlib.md5(data.encode("utf-8")).hexdigest()
|
|
565
|
+
|
|
566
|
+
class Meta:
|
|
567
|
+
unique_together = ("collector", "fingerprint")
|
|
568
|
+
verbose_name = "Email Artifact"
|
|
569
|
+
verbose_name_plural = "Email Artifacts"
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
class FediverseProfile(Entity):
|
|
573
|
+
"""Configuration for connecting to fediverse services."""
|
|
574
|
+
|
|
575
|
+
MASTODON = "mastodon"
|
|
576
|
+
BLUESKY = "bluesky"
|
|
577
|
+
SERVICE_CHOICES = [
|
|
578
|
+
(MASTODON, "Mastodon"),
|
|
579
|
+
(BLUESKY, "Bluesky"),
|
|
580
|
+
]
|
|
581
|
+
|
|
582
|
+
user = models.OneToOneField(
|
|
583
|
+
settings.AUTH_USER_MODEL,
|
|
584
|
+
related_name="fediverse_profile",
|
|
585
|
+
on_delete=models.CASCADE,
|
|
586
|
+
)
|
|
587
|
+
service = models.CharField(max_length=20, choices=SERVICE_CHOICES)
|
|
588
|
+
host = models.CharField(max_length=255)
|
|
589
|
+
handle = models.CharField(max_length=255)
|
|
590
|
+
access_token = models.CharField(max_length=255, blank=True)
|
|
591
|
+
verified_on = models.DateTimeField(null=True, blank=True)
|
|
592
|
+
|
|
593
|
+
def test_connection(self):
|
|
594
|
+
"""Attempt to verify credentials against the configured service."""
|
|
595
|
+
import requests
|
|
596
|
+
|
|
597
|
+
try:
|
|
598
|
+
headers = {}
|
|
599
|
+
if self.access_token:
|
|
600
|
+
headers["Authorization"] = f"Bearer {self.access_token}"
|
|
601
|
+
if self.service == self.MASTODON:
|
|
602
|
+
url = f"https://{self.host}/api/v1/accounts/verify_credentials"
|
|
603
|
+
resp = requests.get(url, headers=headers, timeout=10)
|
|
604
|
+
else: # BLUESKY
|
|
605
|
+
url = f"https://{self.host}/xrpc/app.bsky.actor.getProfile"
|
|
606
|
+
params = {"actor": self.handle}
|
|
607
|
+
resp = requests.get(url, params=params, headers=headers, timeout=10)
|
|
608
|
+
resp.raise_for_status()
|
|
609
|
+
self.verified_on = timezone.now()
|
|
610
|
+
self.save(update_fields=["verified_on"])
|
|
611
|
+
return True
|
|
612
|
+
except Exception as exc:
|
|
613
|
+
self.verified_on = None
|
|
614
|
+
self.save(update_fields=["verified_on"])
|
|
615
|
+
raise ValidationError(str(exc))
|
|
616
|
+
|
|
617
|
+
def __str__(self): # pragma: no cover - simple representation
|
|
618
|
+
return f"{self.user} @ {self.host}"
|
|
619
|
+
|
|
620
|
+
class Meta:
|
|
621
|
+
verbose_name = _("Fediverse Profile")
|
|
622
|
+
verbose_name_plural = _("Fediverse Profiles")
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
class Reference(Entity):
|
|
626
|
+
"""Store a piece of reference content which can be text or an image."""
|
|
627
|
+
|
|
628
|
+
TEXT = "text"
|
|
629
|
+
IMAGE = "image"
|
|
630
|
+
CONTENT_TYPE_CHOICES = [
|
|
631
|
+
(TEXT, "Text"),
|
|
632
|
+
(IMAGE, "Image"),
|
|
633
|
+
]
|
|
634
|
+
|
|
635
|
+
content_type = models.CharField(
|
|
636
|
+
max_length=5, choices=CONTENT_TYPE_CHOICES, default=TEXT
|
|
637
|
+
)
|
|
638
|
+
alt_text = models.CharField("Title / Alt Text", max_length=500)
|
|
639
|
+
value = models.TextField(blank=True)
|
|
640
|
+
file = models.FileField(upload_to="refs/", blank=True)
|
|
641
|
+
image = models.ImageField(upload_to="refs/qr/", blank=True)
|
|
642
|
+
uses = models.PositiveIntegerField(default=0)
|
|
643
|
+
method = models.CharField(max_length=50, default="qr")
|
|
644
|
+
include_in_footer = models.BooleanField(
|
|
645
|
+
default=False, verbose_name="Include in Footer"
|
|
646
|
+
)
|
|
647
|
+
FOOTER_PUBLIC = "public"
|
|
648
|
+
FOOTER_PRIVATE = "private"
|
|
649
|
+
FOOTER_STAFF = "staff"
|
|
650
|
+
FOOTER_VISIBILITY_CHOICES = [
|
|
651
|
+
(FOOTER_PUBLIC, "Public"),
|
|
652
|
+
(FOOTER_PRIVATE, "Private"),
|
|
653
|
+
(FOOTER_STAFF, "Staff"),
|
|
654
|
+
]
|
|
655
|
+
footer_visibility = models.CharField(
|
|
656
|
+
max_length=7,
|
|
657
|
+
choices=FOOTER_VISIBILITY_CHOICES,
|
|
658
|
+
default=FOOTER_PUBLIC,
|
|
659
|
+
verbose_name="Footer visibility",
|
|
660
|
+
)
|
|
661
|
+
transaction_uuid = models.UUIDField(
|
|
662
|
+
default=uuid.uuid4,
|
|
663
|
+
editable=True,
|
|
664
|
+
db_index=True,
|
|
665
|
+
verbose_name="transaction UUID",
|
|
666
|
+
)
|
|
667
|
+
created = models.DateTimeField(auto_now_add=True)
|
|
668
|
+
author = models.ForeignKey(
|
|
669
|
+
settings.AUTH_USER_MODEL,
|
|
670
|
+
on_delete=models.CASCADE,
|
|
671
|
+
related_name="references",
|
|
672
|
+
null=True,
|
|
673
|
+
blank=True,
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
def save(self, *args, **kwargs):
|
|
677
|
+
if self.pk:
|
|
678
|
+
original = type(self).all_objects.get(pk=self.pk)
|
|
679
|
+
if original.transaction_uuid != self.transaction_uuid:
|
|
680
|
+
raise ValidationError({"transaction_uuid": "Cannot modify transaction UUID"})
|
|
681
|
+
if not self.image and self.value:
|
|
682
|
+
qr = qrcode.QRCode(box_size=10, border=4)
|
|
683
|
+
qr.add_data(self.value)
|
|
684
|
+
qr.make(fit=True)
|
|
685
|
+
img = qr.make_image(fill_color="black", back_color="white")
|
|
686
|
+
buffer = BytesIO()
|
|
687
|
+
img.save(buffer, format="PNG")
|
|
688
|
+
filename = hashlib.sha256(self.value.encode()).hexdigest()[:16] + ".png"
|
|
689
|
+
self.image.save(filename, ContentFile(buffer.getvalue()), save=False)
|
|
690
|
+
super().save(*args, **kwargs)
|
|
691
|
+
|
|
692
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
693
|
+
return self.alt_text
|
|
694
|
+
|
|
695
|
+
class RFID(Entity):
|
|
696
|
+
"""RFID tag that may be assigned to one account."""
|
|
697
|
+
|
|
698
|
+
label_id = models.AutoField(primary_key=True, db_column="label_id")
|
|
699
|
+
rfid = models.CharField(
|
|
700
|
+
max_length=255,
|
|
701
|
+
unique=True,
|
|
702
|
+
verbose_name="RFID",
|
|
703
|
+
validators=[
|
|
704
|
+
RegexValidator(
|
|
705
|
+
r"^[0-9A-Fa-f]+$",
|
|
706
|
+
message="RFID must be hexadecimal digits",
|
|
707
|
+
)
|
|
708
|
+
],
|
|
709
|
+
)
|
|
710
|
+
key_a = models.CharField(
|
|
711
|
+
max_length=12,
|
|
712
|
+
default="FFFFFFFFFFFF",
|
|
713
|
+
validators=[
|
|
714
|
+
RegexValidator(
|
|
715
|
+
r"^[0-9A-Fa-f]{12}$",
|
|
716
|
+
message="Key must be 12 hexadecimal digits",
|
|
717
|
+
)
|
|
718
|
+
],
|
|
719
|
+
verbose_name="Key A",
|
|
720
|
+
)
|
|
721
|
+
key_b = models.CharField(
|
|
722
|
+
max_length=12,
|
|
723
|
+
default="FFFFFFFFFFFF",
|
|
724
|
+
validators=[
|
|
725
|
+
RegexValidator(
|
|
726
|
+
r"^[0-9A-Fa-f]{12}$",
|
|
727
|
+
message="Key must be 12 hexadecimal digits",
|
|
728
|
+
)
|
|
729
|
+
],
|
|
730
|
+
verbose_name="Key B",
|
|
731
|
+
)
|
|
732
|
+
data = models.JSONField(
|
|
733
|
+
default=list,
|
|
734
|
+
blank=True,
|
|
735
|
+
help_text="Sector and block data",
|
|
736
|
+
)
|
|
737
|
+
key_a_verified = models.BooleanField(default=False)
|
|
738
|
+
key_b_verified = models.BooleanField(default=False)
|
|
739
|
+
allowed = models.BooleanField(default=True)
|
|
740
|
+
BLACK = "B"
|
|
741
|
+
WHITE = "W"
|
|
742
|
+
BLUE = "U"
|
|
743
|
+
RED = "R"
|
|
744
|
+
GREEN = "G"
|
|
745
|
+
COLOR_CHOICES = [
|
|
746
|
+
(BLACK, "Black"),
|
|
747
|
+
(WHITE, "White"),
|
|
748
|
+
(BLUE, "Blue"),
|
|
749
|
+
(RED, "Red"),
|
|
750
|
+
(GREEN, "Green"),
|
|
751
|
+
]
|
|
752
|
+
color = models.CharField(
|
|
753
|
+
max_length=1,
|
|
754
|
+
choices=COLOR_CHOICES,
|
|
755
|
+
default=BLACK,
|
|
756
|
+
)
|
|
757
|
+
CLASSIC = "CLASSIC"
|
|
758
|
+
NTAG215 = "NTAG215"
|
|
759
|
+
KIND_CHOICES = [
|
|
760
|
+
(CLASSIC, "MIFARE Classic"),
|
|
761
|
+
(NTAG215, "NTAG215"),
|
|
762
|
+
]
|
|
763
|
+
kind = models.CharField(
|
|
764
|
+
max_length=8,
|
|
765
|
+
choices=KIND_CHOICES,
|
|
766
|
+
default=CLASSIC,
|
|
767
|
+
)
|
|
768
|
+
reference = models.ForeignKey(
|
|
769
|
+
"Reference",
|
|
770
|
+
null=True,
|
|
771
|
+
blank=True,
|
|
772
|
+
on_delete=models.SET_NULL,
|
|
773
|
+
related_name="rfids",
|
|
774
|
+
help_text="Optional reference for this RFID.",
|
|
775
|
+
)
|
|
776
|
+
released = models.BooleanField(default=False)
|
|
777
|
+
added_on = models.DateTimeField(auto_now_add=True)
|
|
778
|
+
last_seen_on = models.DateTimeField(null=True, blank=True)
|
|
779
|
+
|
|
780
|
+
def save(self, *args, **kwargs):
|
|
781
|
+
if self.pk:
|
|
782
|
+
old = type(self).objects.filter(pk=self.pk).values("key_a", "key_b").first()
|
|
783
|
+
if old:
|
|
784
|
+
if self.key_a and old["key_a"] != self.key_a.upper():
|
|
785
|
+
self.key_a_verified = False
|
|
786
|
+
if self.key_b and old["key_b"] != self.key_b.upper():
|
|
787
|
+
self.key_b_verified = False
|
|
788
|
+
if self.rfid:
|
|
789
|
+
self.rfid = self.rfid.upper()
|
|
790
|
+
if self.key_a:
|
|
791
|
+
self.key_a = self.key_a.upper()
|
|
792
|
+
if self.key_b:
|
|
793
|
+
self.key_b = self.key_b.upper()
|
|
794
|
+
if self.kind:
|
|
795
|
+
self.kind = self.kind.upper()
|
|
796
|
+
super().save(*args, **kwargs)
|
|
797
|
+
if not self.allowed:
|
|
798
|
+
self.energy_accounts.clear()
|
|
799
|
+
|
|
800
|
+
def __str__(self): # pragma: no cover - simple representation
|
|
801
|
+
return str(self.label_id)
|
|
802
|
+
|
|
803
|
+
@staticmethod
|
|
804
|
+
def get_account_by_rfid(value):
|
|
805
|
+
"""Return the energy account associated with an RFID code if it exists."""
|
|
806
|
+
try:
|
|
807
|
+
EnergyAccount = apps.get_model("core", "EnergyAccount")
|
|
808
|
+
except LookupError: # pragma: no cover - energy accounts app optional
|
|
809
|
+
return None
|
|
810
|
+
return EnergyAccount.objects.filter(
|
|
811
|
+
rfids__rfid=value.upper(), rfids__allowed=True
|
|
812
|
+
).first()
|
|
813
|
+
|
|
814
|
+
class Meta:
|
|
815
|
+
verbose_name = "RFID"
|
|
816
|
+
verbose_name_plural = "RFIDs"
|
|
817
|
+
db_table = "core_rfid"
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
class EnergyAccount(Entity):
|
|
821
|
+
"""Track kW energy credits for a user."""
|
|
822
|
+
|
|
823
|
+
name = models.CharField(max_length=100, unique=True)
|
|
824
|
+
user = models.OneToOneField(
|
|
825
|
+
get_user_model(),
|
|
826
|
+
on_delete=models.CASCADE,
|
|
827
|
+
related_name="energy_account",
|
|
828
|
+
null=True,
|
|
829
|
+
blank=True,
|
|
830
|
+
)
|
|
831
|
+
rfids = models.ManyToManyField(
|
|
832
|
+
"RFID",
|
|
833
|
+
blank=True,
|
|
834
|
+
related_name="energy_accounts",
|
|
835
|
+
db_table="core_account_rfids",
|
|
836
|
+
verbose_name="RFIDs",
|
|
837
|
+
)
|
|
838
|
+
service_account = models.BooleanField(
|
|
839
|
+
default=False,
|
|
840
|
+
help_text="Allow transactions even when the balance is zero or negative",
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
def can_authorize(self) -> bool:
|
|
844
|
+
"""Return True if this account should be authorized for charging."""
|
|
845
|
+
return self.service_account or self.balance_kw > 0
|
|
846
|
+
|
|
847
|
+
@property
|
|
848
|
+
def credits_kw(self):
|
|
849
|
+
"""Total kW energy credits added to the energy account."""
|
|
850
|
+
from django.db.models import Sum
|
|
851
|
+
from decimal import Decimal
|
|
852
|
+
|
|
853
|
+
total = self.credits.aggregate(total=Sum("amount_kw"))["total"]
|
|
854
|
+
return total if total is not None else Decimal("0")
|
|
855
|
+
|
|
856
|
+
@property
|
|
857
|
+
def total_kw_spent(self):
|
|
858
|
+
"""Total kW consumed across all transactions."""
|
|
859
|
+
from django.db.models import F, Sum, ExpressionWrapper, FloatField
|
|
860
|
+
from decimal import Decimal
|
|
861
|
+
|
|
862
|
+
expr = ExpressionWrapper(
|
|
863
|
+
F("meter_stop") - F("meter_start"), output_field=FloatField()
|
|
864
|
+
)
|
|
865
|
+
total = self.transactions.filter(
|
|
866
|
+
meter_start__isnull=False, meter_stop__isnull=False
|
|
867
|
+
).aggregate(total=Sum(expr))["total"]
|
|
868
|
+
if total is None:
|
|
869
|
+
return Decimal("0")
|
|
870
|
+
return Decimal(str(total))
|
|
871
|
+
|
|
872
|
+
@property
|
|
873
|
+
def balance_kw(self):
|
|
874
|
+
"""Remaining kW available for the energy account."""
|
|
875
|
+
return self.credits_kw - self.total_kw_spent
|
|
876
|
+
|
|
877
|
+
def save(self, *args, **kwargs):
|
|
878
|
+
if self.name:
|
|
879
|
+
self.name = self.name.upper()
|
|
880
|
+
super().save(*args, **kwargs)
|
|
881
|
+
|
|
882
|
+
def __str__(self): # pragma: no cover - simple representation
|
|
883
|
+
return self.name
|
|
884
|
+
|
|
885
|
+
class Meta:
|
|
886
|
+
verbose_name = "Energy Account"
|
|
887
|
+
verbose_name_plural = "Energy Accounts"
|
|
888
|
+
db_table = "core_account"
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
class EnergyCredit(Entity):
|
|
892
|
+
"""Energy credits added to an energy account."""
|
|
893
|
+
|
|
894
|
+
account = models.ForeignKey(
|
|
895
|
+
EnergyAccount, on_delete=models.CASCADE, related_name="credits"
|
|
896
|
+
)
|
|
897
|
+
amount_kw = models.DecimalField(
|
|
898
|
+
max_digits=10, decimal_places=2, verbose_name="Energy (kW)"
|
|
899
|
+
)
|
|
900
|
+
created_by = models.ForeignKey(
|
|
901
|
+
settings.AUTH_USER_MODEL,
|
|
902
|
+
null=True,
|
|
903
|
+
blank=True,
|
|
904
|
+
on_delete=models.SET_NULL,
|
|
905
|
+
related_name="credit_entries",
|
|
906
|
+
)
|
|
907
|
+
created_on = models.DateTimeField(auto_now_add=True)
|
|
908
|
+
|
|
909
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
910
|
+
user = (
|
|
911
|
+
self.account.user
|
|
912
|
+
if self.account.user
|
|
913
|
+
else f"Energy Account {self.account_id}"
|
|
914
|
+
)
|
|
915
|
+
return f"{self.amount_kw} kW for {user}"
|
|
916
|
+
|
|
917
|
+
class Meta:
|
|
918
|
+
verbose_name = "Energy Credit"
|
|
919
|
+
verbose_name_plural = "Energy Credits"
|
|
920
|
+
db_table = "core_credit"
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
class Brand(Entity):
|
|
924
|
+
"""Vehicle manufacturer or brand."""
|
|
925
|
+
|
|
926
|
+
name = models.CharField(max_length=100, unique=True)
|
|
927
|
+
|
|
928
|
+
class Meta:
|
|
929
|
+
verbose_name = _("EV Brand")
|
|
930
|
+
verbose_name_plural = _("EV Brands")
|
|
931
|
+
|
|
932
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
933
|
+
return self.name
|
|
934
|
+
|
|
935
|
+
@classmethod
|
|
936
|
+
def from_vin(cls, vin: str) -> "Brand | None":
|
|
937
|
+
"""Return the brand matching the VIN's WMI prefix."""
|
|
938
|
+
if not vin:
|
|
939
|
+
return None
|
|
940
|
+
prefix = vin[:3].upper()
|
|
941
|
+
return cls.objects.filter(wmi_codes__code=prefix).first()
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
class WMICode(Entity):
|
|
945
|
+
"""World Manufacturer Identifier code for a brand."""
|
|
946
|
+
|
|
947
|
+
brand = models.ForeignKey(Brand, on_delete=models.CASCADE, related_name="wmi_codes")
|
|
948
|
+
code = models.CharField(max_length=3, unique=True)
|
|
949
|
+
|
|
950
|
+
class Meta:
|
|
951
|
+
verbose_name = _("WMI Code")
|
|
952
|
+
verbose_name_plural = _("WMI Codes")
|
|
953
|
+
|
|
954
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
955
|
+
return self.code
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
class EVModel(Entity):
|
|
959
|
+
"""Specific electric vehicle model for a brand."""
|
|
960
|
+
|
|
961
|
+
brand = models.ForeignKey(Brand, on_delete=models.CASCADE, related_name="ev_models")
|
|
962
|
+
name = models.CharField(max_length=100)
|
|
963
|
+
|
|
964
|
+
class Meta:
|
|
965
|
+
unique_together = ("brand", "name")
|
|
966
|
+
verbose_name = _("EV Model")
|
|
967
|
+
verbose_name_plural = _("EV Models")
|
|
968
|
+
|
|
969
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
970
|
+
return f"{self.brand} {self.name}" if self.brand else self.name
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
class ElectricVehicle(Entity):
|
|
974
|
+
"""Electric vehicle associated with an Energy Account."""
|
|
975
|
+
|
|
976
|
+
account = models.ForeignKey(
|
|
977
|
+
EnergyAccount, on_delete=models.CASCADE, related_name="vehicles"
|
|
978
|
+
)
|
|
979
|
+
brand = models.ForeignKey(
|
|
980
|
+
Brand,
|
|
981
|
+
on_delete=models.SET_NULL,
|
|
982
|
+
null=True,
|
|
983
|
+
blank=True,
|
|
984
|
+
related_name="vehicles",
|
|
985
|
+
)
|
|
986
|
+
model = models.ForeignKey(
|
|
987
|
+
EVModel,
|
|
988
|
+
on_delete=models.SET_NULL,
|
|
989
|
+
null=True,
|
|
990
|
+
blank=True,
|
|
991
|
+
related_name="vehicles",
|
|
992
|
+
)
|
|
993
|
+
vin = models.CharField(max_length=17, unique=True, verbose_name="VIN")
|
|
994
|
+
license_plate = models.CharField(
|
|
995
|
+
_("License Plate"), max_length=20, blank=True
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
def save(self, *args, **kwargs):
|
|
999
|
+
if self.model and not self.brand:
|
|
1000
|
+
self.brand = self.model.brand
|
|
1001
|
+
super().save(*args, **kwargs)
|
|
1002
|
+
|
|
1003
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1004
|
+
brand_name = self.brand.name if self.brand else ""
|
|
1005
|
+
model_name = self.model.name if self.model else ""
|
|
1006
|
+
parts = " ".join(p for p in [brand_name, model_name] if p)
|
|
1007
|
+
return f"{parts} ({self.vin})" if parts else self.vin
|
|
1008
|
+
|
|
1009
|
+
class Meta:
|
|
1010
|
+
verbose_name = _("Electric Vehicle")
|
|
1011
|
+
verbose_name_plural = _("Electric Vehicles")
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
class Product(Entity):
|
|
1015
|
+
"""A product that users can subscribe to."""
|
|
1016
|
+
|
|
1017
|
+
name = models.CharField(max_length=100)
|
|
1018
|
+
description = models.TextField(blank=True)
|
|
1019
|
+
renewal_period = models.PositiveIntegerField(help_text="Renewal period in days")
|
|
1020
|
+
|
|
1021
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1022
|
+
return self.name
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
class Subscription(Entity):
|
|
1026
|
+
"""An energy account's subscription to a product."""
|
|
1027
|
+
|
|
1028
|
+
account = models.ForeignKey(EnergyAccount, on_delete=models.CASCADE)
|
|
1029
|
+
product = models.ForeignKey(Product, on_delete=models.CASCADE)
|
|
1030
|
+
start_date = models.DateField(auto_now_add=True)
|
|
1031
|
+
next_renewal = models.DateField(blank=True)
|
|
1032
|
+
|
|
1033
|
+
def save(self, *args, **kwargs):
|
|
1034
|
+
if not self.next_renewal:
|
|
1035
|
+
self.next_renewal = self.start_date + timedelta(
|
|
1036
|
+
days=self.product.renewal_period
|
|
1037
|
+
)
|
|
1038
|
+
super().save(*args, **kwargs)
|
|
1039
|
+
|
|
1040
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1041
|
+
return f"{self.account.user} -> {self.product}"
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
class AdminHistory(Entity):
|
|
1045
|
+
"""Record of recently visited admin changelists for a user."""
|
|
1046
|
+
|
|
1047
|
+
user = models.ForeignKey(
|
|
1048
|
+
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="admin_history"
|
|
1049
|
+
)
|
|
1050
|
+
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
|
1051
|
+
url = models.TextField()
|
|
1052
|
+
visited_at = models.DateTimeField(auto_now=True)
|
|
1053
|
+
|
|
1054
|
+
class Meta:
|
|
1055
|
+
ordering = ["-visited_at"]
|
|
1056
|
+
unique_together = ("user", "url")
|
|
1057
|
+
verbose_name = "Admin History"
|
|
1058
|
+
verbose_name_plural = "Admin Histories"
|
|
1059
|
+
|
|
1060
|
+
@property
|
|
1061
|
+
def admin_label(self) -> str: # pragma: no cover - simple representation
|
|
1062
|
+
model = self.content_type.model_class()
|
|
1063
|
+
return model._meta.verbose_name_plural if model else self.content_type.name
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
class ReleaseManager(Entity):
|
|
1067
|
+
"""Store credentials for publishing packages."""
|
|
1068
|
+
|
|
1069
|
+
user = models.OneToOneField(
|
|
1070
|
+
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="release_manager"
|
|
1071
|
+
)
|
|
1072
|
+
pypi_username = SigilShortAutoField("PyPI username", max_length=100, blank=True)
|
|
1073
|
+
pypi_token = SigilShortAutoField("PyPI token", max_length=200, blank=True)
|
|
1074
|
+
github_token = SigilShortAutoField(
|
|
1075
|
+
max_length=200,
|
|
1076
|
+
blank=True,
|
|
1077
|
+
help_text=(
|
|
1078
|
+
"Personal access token used to create GitHub pull requests. "
|
|
1079
|
+
"Used before the GITHUB_TOKEN environment variable."
|
|
1080
|
+
),
|
|
1081
|
+
)
|
|
1082
|
+
pypi_password = SigilShortAutoField("PyPI password", max_length=200, blank=True)
|
|
1083
|
+
pypi_url = SigilShortAutoField("PyPI URL", max_length=200, blank=True)
|
|
1084
|
+
|
|
1085
|
+
class Meta:
|
|
1086
|
+
verbose_name = "Release Manager"
|
|
1087
|
+
verbose_name_plural = "Release Managers"
|
|
1088
|
+
|
|
1089
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
1090
|
+
return self.name
|
|
1091
|
+
|
|
1092
|
+
@property
|
|
1093
|
+
def name(self) -> str: # pragma: no cover - simple proxy
|
|
1094
|
+
return self.user.get_username()
|
|
1095
|
+
|
|
1096
|
+
def to_credentials(self) -> Credentials | None:
|
|
1097
|
+
"""Return credentials for this release manager."""
|
|
1098
|
+
if self.pypi_token:
|
|
1099
|
+
return Credentials(token=self.pypi_token)
|
|
1100
|
+
if self.pypi_username and self.pypi_password:
|
|
1101
|
+
return Credentials(
|
|
1102
|
+
username=self.pypi_username, password=self.pypi_password
|
|
1103
|
+
)
|
|
1104
|
+
return None
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
class Package(Entity):
|
|
1108
|
+
"""Package details shared across releases."""
|
|
1109
|
+
|
|
1110
|
+
name = models.CharField(
|
|
1111
|
+
max_length=100, default=DEFAULT_PACKAGE.name, unique=True
|
|
1112
|
+
)
|
|
1113
|
+
description = models.CharField(
|
|
1114
|
+
max_length=255, default=DEFAULT_PACKAGE.description
|
|
1115
|
+
)
|
|
1116
|
+
author = models.CharField(max_length=100, default=DEFAULT_PACKAGE.author)
|
|
1117
|
+
email = models.EmailField(default=DEFAULT_PACKAGE.email)
|
|
1118
|
+
python_requires = models.CharField(
|
|
1119
|
+
max_length=20, default=DEFAULT_PACKAGE.python_requires
|
|
1120
|
+
)
|
|
1121
|
+
license = models.CharField(max_length=100, default=DEFAULT_PACKAGE.license)
|
|
1122
|
+
repository_url = models.URLField(default=DEFAULT_PACKAGE.repository_url)
|
|
1123
|
+
homepage_url = models.URLField(default=DEFAULT_PACKAGE.homepage_url)
|
|
1124
|
+
release_manager = models.ForeignKey(
|
|
1125
|
+
ReleaseManager, on_delete=models.SET_NULL, null=True, blank=True
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
class Meta:
|
|
1129
|
+
verbose_name = "Package"
|
|
1130
|
+
verbose_name_plural = "Packages"
|
|
1131
|
+
|
|
1132
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
1133
|
+
return self.name
|
|
1134
|
+
|
|
1135
|
+
def to_package(self) -> ReleasePackage:
|
|
1136
|
+
"""Return a :class:`ReleasePackage` instance from package data."""
|
|
1137
|
+
return ReleasePackage(
|
|
1138
|
+
name=self.name,
|
|
1139
|
+
description=self.description,
|
|
1140
|
+
author=self.author,
|
|
1141
|
+
email=self.email,
|
|
1142
|
+
python_requires=self.python_requires,
|
|
1143
|
+
license=self.license,
|
|
1144
|
+
repository_url=self.repository_url,
|
|
1145
|
+
homepage_url=self.homepage_url,
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
class PackageRelease(Entity):
|
|
1149
|
+
"""Store metadata for a specific package version."""
|
|
1150
|
+
|
|
1151
|
+
package = models.ForeignKey(
|
|
1152
|
+
Package, on_delete=models.CASCADE, related_name="releases"
|
|
1153
|
+
)
|
|
1154
|
+
release_manager = models.ForeignKey(
|
|
1155
|
+
ReleaseManager, on_delete=models.SET_NULL, null=True, blank=True
|
|
1156
|
+
)
|
|
1157
|
+
version = models.CharField(max_length=20, default="0.0.0")
|
|
1158
|
+
revision = models.CharField(
|
|
1159
|
+
max_length=40, blank=True, default=revision_utils.get_revision, editable=False
|
|
1160
|
+
)
|
|
1161
|
+
pypi_url = models.URLField("PyPI URL", blank=True, editable=False)
|
|
1162
|
+
pr_url = models.URLField("PR URL", blank=True, editable=False)
|
|
1163
|
+
|
|
1164
|
+
class Meta:
|
|
1165
|
+
verbose_name = "Package Release"
|
|
1166
|
+
verbose_name_plural = "Package Releases"
|
|
1167
|
+
get_latest_by = "version"
|
|
1168
|
+
constraints = [
|
|
1169
|
+
models.UniqueConstraint(
|
|
1170
|
+
fields=("package", "version"), name="unique_package_version"
|
|
1171
|
+
)
|
|
1172
|
+
]
|
|
1173
|
+
|
|
1174
|
+
@classmethod
|
|
1175
|
+
def dump_fixture(cls) -> None:
|
|
1176
|
+
path = Path("core/fixtures/releases.json")
|
|
1177
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1178
|
+
data = serializers.serialize("json", cls.objects.all())
|
|
1179
|
+
path.write_text(data)
|
|
1180
|
+
|
|
1181
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
1182
|
+
return f"{self.package.name} {self.version}"
|
|
1183
|
+
|
|
1184
|
+
def to_package(self) -> ReleasePackage:
|
|
1185
|
+
"""Return a :class:`ReleasePackage` built from the package."""
|
|
1186
|
+
return self.package.to_package()
|
|
1187
|
+
|
|
1188
|
+
def to_credentials(self) -> Credentials | None:
|
|
1189
|
+
"""Return :class:`Credentials` from the associated release manager."""
|
|
1190
|
+
manager = self.release_manager or self.package.release_manager
|
|
1191
|
+
if manager:
|
|
1192
|
+
return manager.to_credentials()
|
|
1193
|
+
return None
|
|
1194
|
+
|
|
1195
|
+
def get_github_token(self) -> str | None:
|
|
1196
|
+
"""Return GitHub token from the associated release manager or environment."""
|
|
1197
|
+
manager = self.release_manager or self.package.release_manager
|
|
1198
|
+
if manager and manager.github_token:
|
|
1199
|
+
return manager.github_token
|
|
1200
|
+
return os.environ.get("GITHUB_TOKEN")
|
|
1201
|
+
|
|
1202
|
+
@property
|
|
1203
|
+
def migration_number(self) -> int:
|
|
1204
|
+
"""Return the migration number derived from the version bits."""
|
|
1205
|
+
from packaging.version import Version
|
|
1206
|
+
|
|
1207
|
+
v = Version(self.version)
|
|
1208
|
+
return (v.major << 2) | (v.minor << 1) | v.micro
|
|
1209
|
+
|
|
1210
|
+
@staticmethod
|
|
1211
|
+
def version_from_migration(number: int) -> str:
|
|
1212
|
+
"""Return version string encoded by ``number``."""
|
|
1213
|
+
major = (number >> 2) & 0x3FFFFF
|
|
1214
|
+
minor = (number >> 1) & 0x1
|
|
1215
|
+
patch = number & 0x1
|
|
1216
|
+
return f"{major}.{minor}.{patch}"
|
|
1217
|
+
|
|
1218
|
+
@property
|
|
1219
|
+
def is_published(self) -> bool:
|
|
1220
|
+
"""Return ``True`` if this release has been published."""
|
|
1221
|
+
return bool(self.pypi_url)
|
|
1222
|
+
|
|
1223
|
+
@property
|
|
1224
|
+
def is_current(self) -> bool:
|
|
1225
|
+
"""Return ``True`` if this release matches the current revision."""
|
|
1226
|
+
from utils import revision as revision_utils
|
|
1227
|
+
|
|
1228
|
+
current = revision_utils.get_revision()
|
|
1229
|
+
return bool(current) and current == self.revision
|
|
1230
|
+
|
|
1231
|
+
@classmethod
|
|
1232
|
+
def latest(cls):
|
|
1233
|
+
"""Return the latest release by version."""
|
|
1234
|
+
from packaging.version import Version
|
|
1235
|
+
|
|
1236
|
+
releases = list(cls.objects.all())
|
|
1237
|
+
if not releases:
|
|
1238
|
+
return None
|
|
1239
|
+
return max(releases, key=lambda r: Version(r.version))
|
|
1240
|
+
|
|
1241
|
+
def build(self, **kwargs) -> None:
|
|
1242
|
+
"""Wrapper around :func:`core.release.build` for convenience."""
|
|
1243
|
+
from . import release as release_utils
|
|
1244
|
+
from utils import revision as revision_utils
|
|
1245
|
+
|
|
1246
|
+
release_utils.build(
|
|
1247
|
+
package=self.to_package(),
|
|
1248
|
+
version=self.version,
|
|
1249
|
+
creds=self.to_credentials(),
|
|
1250
|
+
**kwargs,
|
|
1251
|
+
)
|
|
1252
|
+
self.revision = revision_utils.get_revision()
|
|
1253
|
+
self.save(update_fields=["revision"])
|
|
1254
|
+
|
|
1255
|
+
@property
|
|
1256
|
+
def revision_short(self) -> str:
|
|
1257
|
+
return self.revision[-6:] if self.revision else ""
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
@receiver(post_delete, sender=PackageRelease)
|
|
1261
|
+
def _delete_release_fixture(sender, instance, **kwargs) -> None:
|
|
1262
|
+
PackageRelease.dump_fixture()
|
|
1263
|
+
|
|
1264
|
+
# Ensure each RFID can only be linked to one energy account
|
|
1265
|
+
@receiver(m2m_changed, sender=EnergyAccount.rfids.through)
|
|
1266
|
+
def _rfid_unique_energy_account(sender, instance, action, reverse, model, pk_set, **kwargs):
|
|
1267
|
+
"""Prevent associating an RFID with more than one energy account."""
|
|
1268
|
+
if action == "pre_add":
|
|
1269
|
+
if reverse: # adding energy accounts to an RFID
|
|
1270
|
+
if instance.energy_accounts.exclude(pk__in=pk_set).exists():
|
|
1271
|
+
raise ValidationError("RFID tags may only be assigned to one energy account.")
|
|
1272
|
+
else: # adding RFIDs to an energy account
|
|
1273
|
+
conflict = model.objects.filter(
|
|
1274
|
+
pk__in=pk_set, energy_accounts__isnull=False
|
|
1275
|
+
).exclude(energy_accounts=instance)
|
|
1276
|
+
if conflict.exists():
|
|
1277
|
+
raise ValidationError("RFID tags may only be assigned to one energy account.")
|