arthexis 0.1.7__py3-none-any.whl → 0.1.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

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