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

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

Potentially problematic release.


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

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