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.

Files changed (73) hide show
  1. arthexis-0.1.3.dist-info/METADATA +126 -0
  2. arthexis-0.1.3.dist-info/RECORD +73 -0
  3. arthexis-0.1.3.dist-info/WHEEL +5 -0
  4. arthexis-0.1.3.dist-info/licenses/LICENSE +21 -0
  5. arthexis-0.1.3.dist-info/top_level.txt +5 -0
  6. config/__init__.py +6 -0
  7. config/active_app.py +15 -0
  8. config/asgi.py +29 -0
  9. config/auth_app.py +8 -0
  10. config/celery.py +19 -0
  11. config/context_processors.py +68 -0
  12. config/loadenv.py +11 -0
  13. config/logging.py +43 -0
  14. config/middleware.py +25 -0
  15. config/offline.py +47 -0
  16. config/settings.py +374 -0
  17. config/urls.py +91 -0
  18. config/wsgi.py +17 -0
  19. core/__init__.py +0 -0
  20. core/admin.py +830 -0
  21. core/apps.py +67 -0
  22. core/backends.py +82 -0
  23. core/entity.py +97 -0
  24. core/environment.py +43 -0
  25. core/fields.py +70 -0
  26. core/lcd_screen.py +77 -0
  27. core/middleware.py +34 -0
  28. core/models.py +1277 -0
  29. core/notifications.py +95 -0
  30. core/release.py +451 -0
  31. core/system.py +111 -0
  32. core/tasks.py +100 -0
  33. core/tests.py +483 -0
  34. core/urls.py +11 -0
  35. core/user_data.py +333 -0
  36. core/views.py +431 -0
  37. nodes/__init__.py +0 -0
  38. nodes/actions.py +72 -0
  39. nodes/admin.py +347 -0
  40. nodes/apps.py +76 -0
  41. nodes/lcd.py +151 -0
  42. nodes/models.py +577 -0
  43. nodes/tasks.py +50 -0
  44. nodes/tests.py +1072 -0
  45. nodes/urls.py +13 -0
  46. nodes/utils.py +62 -0
  47. nodes/views.py +262 -0
  48. ocpp/__init__.py +0 -0
  49. ocpp/admin.py +392 -0
  50. ocpp/apps.py +24 -0
  51. ocpp/consumers.py +267 -0
  52. ocpp/evcs.py +911 -0
  53. ocpp/models.py +300 -0
  54. ocpp/routing.py +9 -0
  55. ocpp/simulator.py +357 -0
  56. ocpp/store.py +175 -0
  57. ocpp/tasks.py +27 -0
  58. ocpp/test_export_import.py +129 -0
  59. ocpp/test_rfid.py +345 -0
  60. ocpp/tests.py +1229 -0
  61. ocpp/transactions_io.py +119 -0
  62. ocpp/urls.py +17 -0
  63. ocpp/views.py +359 -0
  64. pages/__init__.py +0 -0
  65. pages/admin.py +231 -0
  66. pages/apps.py +10 -0
  67. pages/checks.py +41 -0
  68. pages/context_processors.py +72 -0
  69. pages/models.py +224 -0
  70. pages/tests.py +628 -0
  71. pages/urls.py +17 -0
  72. pages/utils.py +13 -0
  73. 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.")