arthexis 0.1.8__py3-none-any.whl → 0.1.10__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 (84) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
  2. arthexis-0.1.10.dist-info/RECORD +95 -0
  3. arthexis-0.1.10.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 +352 -37
  10. config/urls.py +71 -6
  11. core/admin.py +1601 -200
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +161 -3
  15. core/auto_upgrade.py +57 -0
  16. core/backends.py +123 -7
  17. core/entity.py +62 -48
  18. core/fields.py +98 -0
  19. core/github_helper.py +25 -0
  20. core/github_issues.py +172 -0
  21. core/lcd_screen.py +1 -0
  22. core/liveupdate.py +25 -0
  23. core/log_paths.py +100 -0
  24. core/mailer.py +83 -0
  25. core/middleware.py +57 -0
  26. core/models.py +1279 -267
  27. core/notifications.py +11 -1
  28. core/public_wifi.py +227 -0
  29. core/reference_utils.py +97 -0
  30. core/release.py +27 -20
  31. core/sigil_builder.py +144 -0
  32. core/sigil_context.py +20 -0
  33. core/sigil_resolver.py +284 -0
  34. core/system.py +162 -29
  35. core/tasks.py +269 -27
  36. core/test_system_info.py +59 -1
  37. core/tests.py +644 -73
  38. core/tests_liveupdate.py +17 -0
  39. core/urls.py +2 -2
  40. core/user_data.py +425 -168
  41. core/views.py +627 -59
  42. core/widgets.py +51 -0
  43. core/workgroup_urls.py +7 -3
  44. core/workgroup_views.py +43 -6
  45. nodes/actions.py +0 -2
  46. nodes/admin.py +168 -285
  47. nodes/apps.py +9 -15
  48. nodes/backends.py +145 -0
  49. nodes/lcd.py +24 -10
  50. nodes/models.py +579 -179
  51. nodes/tasks.py +1 -5
  52. nodes/tests.py +894 -130
  53. nodes/utils.py +13 -2
  54. nodes/views.py +204 -28
  55. ocpp/admin.py +212 -63
  56. ocpp/apps.py +1 -1
  57. ocpp/consumers.py +642 -68
  58. ocpp/evcs.py +30 -10
  59. ocpp/models.py +452 -70
  60. ocpp/simulator.py +75 -11
  61. ocpp/store.py +288 -30
  62. ocpp/tasks.py +11 -7
  63. ocpp/test_export_import.py +8 -7
  64. ocpp/test_rfid.py +211 -16
  65. ocpp/tests.py +1576 -137
  66. ocpp/transactions_io.py +68 -22
  67. ocpp/urls.py +35 -2
  68. ocpp/views.py +701 -123
  69. pages/admin.py +173 -13
  70. pages/checks.py +0 -1
  71. pages/context_processors.py +39 -6
  72. pages/forms.py +131 -0
  73. pages/middleware.py +153 -0
  74. pages/models.py +37 -9
  75. pages/tests.py +1182 -42
  76. pages/urls.py +4 -0
  77. pages/utils.py +0 -1
  78. pages/views.py +844 -51
  79. arthexis-0.1.8.dist-info/RECORD +0 -80
  80. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  81. config/workgroup_app.py +0 -7
  82. core/checks.py +0 -29
  83. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
  84. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
core/models.py CHANGED
@@ -4,34 +4,46 @@ 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
- from django.core.validators import RegexValidator
12
+ from django.core.validators import MaxValueValidator, MinValueValidator, 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
- from datetime import timedelta
17
+ from datetime import time as datetime_time, timedelta
16
18
  from django.contrib.contenttypes.models import ContentType
17
19
  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
34
- from .fields import SigilShortAutoField
41
+ from . import user_data # noqa: F401 - ensure signal registration
42
+ from .fields import (
43
+ SigilShortAutoField,
44
+ ConditionTextField,
45
+ ConditionCheckResult,
46
+ )
35
47
 
36
48
 
37
49
  class SecurityGroup(Group):
@@ -48,6 +60,111 @@ class SecurityGroup(Group):
48
60
  verbose_name_plural = "Security Groups"
49
61
 
50
62
 
63
+ class Profile(Entity):
64
+ """Abstract base class for user or group scoped configuration."""
65
+
66
+ user = models.OneToOneField(
67
+ settings.AUTH_USER_MODEL,
68
+ null=True,
69
+ blank=True,
70
+ on_delete=models.CASCADE,
71
+ related_name="+",
72
+ )
73
+ group = models.OneToOneField(
74
+ "core.SecurityGroup",
75
+ null=True,
76
+ blank=True,
77
+ on_delete=models.CASCADE,
78
+ related_name="+",
79
+ )
80
+
81
+ class Meta:
82
+ abstract = True
83
+
84
+ def clean(self):
85
+ super().clean()
86
+ if self.user_id and self.group_id:
87
+ raise ValidationError(
88
+ {
89
+ "user": _("Select either a user or a security group, not both."),
90
+ "group": _("Select either a user or a security group, not both."),
91
+ }
92
+ )
93
+ if not self.user_id and not self.group_id:
94
+ raise ValidationError(
95
+ _("Profiles must be assigned to a user or a security group."),
96
+ )
97
+ if self.user_id:
98
+ user_model = get_user_model()
99
+ username_cache = {"value": None}
100
+
101
+ def _resolve_username():
102
+ if username_cache["value"] is not None:
103
+ return username_cache["value"]
104
+ user_obj = getattr(self, "user", None)
105
+ username = getattr(user_obj, "username", None)
106
+ if not username:
107
+ manager = getattr(
108
+ user_model, "all_objects", user_model._default_manager
109
+ )
110
+ username = (
111
+ manager.filter(pk=self.user_id)
112
+ .values_list("username", flat=True)
113
+ .first()
114
+ )
115
+ username_cache["value"] = username
116
+ return username
117
+
118
+ is_restricted = getattr(user_model, "is_profile_restricted_username", None)
119
+ if callable(is_restricted):
120
+ username = _resolve_username()
121
+ if is_restricted(username):
122
+ raise ValidationError(
123
+ {
124
+ "user": _(
125
+ "The %(username)s account cannot have profiles attached."
126
+ )
127
+ % {"username": username}
128
+ }
129
+ )
130
+ else:
131
+ system_username = getattr(user_model, "SYSTEM_USERNAME", None)
132
+ if system_username:
133
+ username = _resolve_username()
134
+ if user_model.is_system_username(username):
135
+ raise ValidationError(
136
+ {
137
+ "user": _(
138
+ "The %(username)s account cannot have profiles attached."
139
+ )
140
+ % {"username": username}
141
+ }
142
+ )
143
+
144
+ @property
145
+ def owner(self):
146
+ """Return the assigned user or group."""
147
+
148
+ return self.user if self.user_id else self.group
149
+
150
+ def owner_display(self) -> str:
151
+ """Return a human readable owner label."""
152
+
153
+ owner = self.owner
154
+ if owner is None: # pragma: no cover - guarded by ``clean``
155
+ return ""
156
+ if hasattr(owner, "get_username"):
157
+ return owner.get_username()
158
+ if hasattr(owner, "name"):
159
+ return owner.name
160
+ return str(owner)
161
+
162
+
163
+ class SigilRootManager(EntityManager):
164
+ def get_by_natural_key(self, prefix: str):
165
+ return self.get(prefix=prefix)
166
+
167
+
51
168
  class SigilRoot(Entity):
52
169
  class Context(models.TextChoices):
53
170
  CONFIG = "config", "Configuration"
@@ -55,16 +172,32 @@ class SigilRoot(Entity):
55
172
 
56
173
  prefix = models.CharField(max_length=50, unique=True)
57
174
  context_type = models.CharField(max_length=20, choices=Context.choices)
175
+ content_type = models.ForeignKey(
176
+ ContentType, null=True, blank=True, on_delete=models.CASCADE
177
+ )
178
+
179
+ objects = SigilRootManager()
58
180
 
59
181
  def __str__(self) -> str: # pragma: no cover - simple representation
60
182
  return self.prefix
61
183
 
184
+ def natural_key(self): # pragma: no cover - simple representation
185
+ return (self.prefix,)
186
+
62
187
  class Meta:
63
188
  verbose_name = "Sigil Root"
64
189
  verbose_name_plural = "Sigil Roots"
65
190
 
66
191
 
67
- class Lead(models.Model):
192
+ class CustomSigil(SigilRoot):
193
+ class Meta:
194
+ proxy = True
195
+ app_label = "pages"
196
+ verbose_name = _("Custom Sigil")
197
+ verbose_name_plural = _("Custom Sigils")
198
+
199
+
200
+ class Lead(Entity):
68
201
  """Common request lead information."""
69
202
 
70
203
  user = models.ForeignKey(
@@ -83,6 +216,9 @@ class Lead(models.Model):
83
216
  class InviteLead(Lead):
84
217
  email = models.EmailField()
85
218
  comment = models.TextField(blank=True)
219
+ sent_on = models.DateTimeField(null=True, blank=True)
220
+ error = models.TextField(blank=True)
221
+ mac_address = models.CharField(max_length=17, blank=True)
86
222
 
87
223
  class Meta:
88
224
  verbose_name = "Invite Lead"
@@ -92,156 +228,66 @@ class InviteLead(Lead):
92
228
  return self.email
93
229
 
94
230
 
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
- ]
231
+ class PublicWifiAccess(Entity):
232
+ """Represent a Wi-Fi lease granted to a client for internet access."""
142
233
 
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
- ]
234
+ user = models.ForeignKey(
235
+ settings.AUTH_USER_MODEL,
236
+ on_delete=models.CASCADE,
237
+ related_name="public_wifi_accesses",
238
+ )
239
+ mac_address = models.CharField(max_length=17)
240
+ created_on = models.DateTimeField(auto_now_add=True)
241
+ updated_on = models.DateTimeField(auto_now=True)
242
+ revoked_on = models.DateTimeField(null=True, blank=True)
196
243
 
197
- MUNICIPALITIES_BY_STATE = {
198
- State.COAHUILA: COAHUILA_MUNICIPALITIES,
199
- State.NUEVO_LEON: NUEVO_LEON_MUNICIPALITIES,
200
- }
244
+ class Meta:
245
+ unique_together = ("user", "mac_address")
246
+ verbose_name = "Wi-Fi Lease"
247
+ verbose_name_plural = "Wi-Fi Leases"
201
248
 
202
- MUNICIPALITY_CHOICES = [
203
- (name, name) for name in COAHUILA_MUNICIPALITIES + NUEVO_LEON_MUNICIPALITIES
204
- ]
249
+ def __str__(self) -> str: # pragma: no cover - simple representation
250
+ return f"{self.user} -> {self.mac_address}"
205
251
 
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
252
 
212
- class Meta:
213
- verbose_name_plural = _("Addresses")
253
+ @receiver(post_save, sender=settings.AUTH_USER_MODEL)
254
+ def _revoke_public_wifi_when_inactive(sender, instance, **kwargs):
255
+ if instance.is_active:
256
+ return
257
+ from core import public_wifi
214
258
 
215
- def clean(self):
216
- from django.core.exceptions import ValidationError
259
+ public_wifi.revoke_public_access_for_user(instance)
217
260
 
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
261
 
224
- def __str__(self): # pragma: no cover - simple representation
225
- return f"{self.street} {self.number}, {self.municipality}, {self.state}"
262
+ @receiver(post_delete, sender=settings.AUTH_USER_MODEL)
263
+ def _cleanup_public_wifi_on_delete(sender, instance, **kwargs):
264
+ from core import public_wifi
265
+
266
+ public_wifi.revoke_public_access_for_user(instance)
226
267
 
227
268
 
228
269
  class User(Entity, AbstractUser):
270
+ SYSTEM_USERNAME = "arthexis"
271
+ ADMIN_USERNAME = "admin"
272
+ PROFILE_RESTRICTED_USERNAMES = frozenset()
273
+
229
274
  objects = EntityUserManager()
230
275
  all_objects = DjangoUserManager()
231
276
  """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,
277
+ birthday = models.DateField(null=True, blank=True)
278
+ data_path = models.CharField(max_length=255, blank=True)
279
+ last_visit_ip_address = models.GenericIPAddressField(null=True, blank=True)
280
+ operate_as = models.ForeignKey(
281
+ "self",
240
282
  null=True,
241
283
  blank=True,
242
284
  on_delete=models.SET_NULL,
285
+ related_name="operated_users",
286
+ help_text=(
287
+ "Operate using another user's permissions when additional authority is "
288
+ "required."
289
+ ),
243
290
  )
244
- has_charger = models.BooleanField(default=False)
245
291
  is_active = models.BooleanField(
246
292
  _("active"),
247
293
  default=True,
@@ -253,15 +299,173 @@ class User(Entity, AbstractUser):
253
299
  def __str__(self):
254
300
  return self.username
255
301
 
302
+ @classmethod
303
+ def is_system_username(cls, username):
304
+ return bool(username) and username == cls.SYSTEM_USERNAME
256
305
 
257
- class OdooProfile(Entity):
258
- """Store Odoo API credentials for a user."""
306
+ @classmethod
307
+ def is_profile_restricted_username(cls, username):
308
+ return bool(username) and username in cls.PROFILE_RESTRICTED_USERNAMES
259
309
 
260
- user = models.OneToOneField(
310
+ @property
311
+ def is_system_user(self) -> bool:
312
+ return self.is_system_username(self.username)
313
+
314
+ @property
315
+ def is_profile_restricted(self) -> bool:
316
+ return self.is_profile_restricted_username(self.username)
317
+
318
+ def clean(self):
319
+ super().clean()
320
+ if not self.operate_as_id:
321
+ return
322
+ try:
323
+ delegate = self.operate_as
324
+ except type(self).DoesNotExist:
325
+ raise ValidationError({"operate_as": _("Selected user is not available.")})
326
+ errors = []
327
+ if delegate.pk == self.pk:
328
+ errors.append(_("Cannot operate as yourself."))
329
+ if getattr(delegate, "is_deleted", False):
330
+ errors.append(_("Cannot operate as a deleted user."))
331
+ if not self.is_staff:
332
+ errors.append(_("Only staff members may operate as another user."))
333
+ if delegate.is_staff and not self.is_superuser:
334
+ errors.append(_("Only superusers may operate as staff members."))
335
+ if errors:
336
+ raise ValidationError({"operate_as": errors})
337
+
338
+ def _delegate_for_permissions(self):
339
+ if not self.is_staff or not self.operate_as_id:
340
+ return None
341
+ try:
342
+ delegate = self.operate_as
343
+ except type(self).DoesNotExist:
344
+ return None
345
+ if delegate.pk == self.pk:
346
+ return None
347
+ if getattr(delegate, "is_deleted", False):
348
+ return None
349
+ if delegate.is_staff and not self.is_superuser:
350
+ return None
351
+ return delegate
352
+
353
+ def _check_operate_as_chain(self, predicate, visited=None):
354
+ if visited is None:
355
+ visited = set()
356
+ identifier = self.pk or id(self)
357
+ if identifier in visited:
358
+ return False
359
+ visited.add(identifier)
360
+ if predicate(self):
361
+ return True
362
+ delegate = self._delegate_for_permissions()
363
+ if not delegate:
364
+ return False
365
+ return delegate._check_operate_as_chain(predicate, visited)
366
+
367
+ def has_perm(self, perm, obj=None):
368
+ return self._check_operate_as_chain(
369
+ lambda user: super(User, user).has_perm(perm, obj)
370
+ )
371
+
372
+ def has_module_perms(self, app_label):
373
+ return self._check_operate_as_chain(
374
+ lambda user: super(User, user).has_module_perms(app_label)
375
+ )
376
+
377
+ def _profile_for(self, profile_cls: Type[Profile], user: "User"):
378
+ profile = profile_cls.objects.filter(user=user).first()
379
+ if profile:
380
+ return profile
381
+ group_ids = list(user.groups.values_list("id", flat=True))
382
+ if group_ids:
383
+ return profile_cls.objects.filter(group_id__in=group_ids).first()
384
+ return None
385
+
386
+ def get_profile(self, profile_cls: Type[Profile]):
387
+ """Return the first matching profile for the user or their delegate chain."""
388
+
389
+ if not isinstance(profile_cls, type) or not issubclass(profile_cls, Profile):
390
+ raise TypeError("profile_cls must be a Profile subclass")
391
+
392
+ result = None
393
+
394
+ def predicate(user: "User"):
395
+ nonlocal result
396
+ result = self._profile_for(profile_cls, user)
397
+ return result is not None
398
+
399
+ self._check_operate_as_chain(predicate)
400
+ return result
401
+
402
+ def has_profile(self, profile_cls: Type[Profile]) -> bool:
403
+ """Return ``True`` when a profile is available for the user or delegate chain."""
404
+
405
+ return self.get_profile(profile_cls) is not None
406
+
407
+ def _direct_profile(self, model_label: str):
408
+ model = apps.get_model("core", model_label)
409
+ try:
410
+ return self.get_profile(model)
411
+ except TypeError:
412
+ return None
413
+
414
+ def get_phones_by_priority(self):
415
+ """Return a list of ``UserPhoneNumber`` instances ordered by priority."""
416
+
417
+ ordered_numbers = self.phone_numbers.order_by("priority", "pk")
418
+ return list(ordered_numbers)
419
+
420
+ def get_phone_numbers_by_priority(self):
421
+ """Backward-compatible alias for :meth:`get_phones_by_priority`."""
422
+
423
+ return self.get_phones_by_priority()
424
+
425
+ @property
426
+ def release_manager(self):
427
+ return self._direct_profile("ReleaseManager")
428
+
429
+ @property
430
+ def odoo_profile(self):
431
+ return self._direct_profile("OdooProfile")
432
+
433
+ @property
434
+ def assistant_profile(self):
435
+ return self._direct_profile("AssistantProfile")
436
+
437
+ @property
438
+ def chat_profile(self):
439
+ return self.assistant_profile
440
+
441
+
442
+ class UserPhoneNumber(Entity):
443
+ """Store phone numbers associated with a user."""
444
+
445
+ user = models.ForeignKey(
261
446
  settings.AUTH_USER_MODEL,
262
- related_name="odoo_profile",
263
447
  on_delete=models.CASCADE,
448
+ related_name="phone_numbers",
264
449
  )
450
+ number = models.CharField(
451
+ max_length=20,
452
+ help_text="Contact phone number",
453
+ )
454
+ priority = models.PositiveIntegerField(default=0)
455
+
456
+ class Meta:
457
+ ordering = ("priority", "id")
458
+ verbose_name = "Phone Number"
459
+ verbose_name_plural = "Phone Numbers"
460
+
461
+ def __str__(self): # pragma: no cover - simple representation
462
+ return f"{self.number} ({self.priority})"
463
+
464
+
465
+ class OdooProfile(Profile):
466
+ """Store Odoo API credentials for a user."""
467
+
468
+ profile_fields = ("host", "database", "username", "password")
265
469
  host = SigilShortAutoField(max_length=255)
266
470
  database = SigilShortAutoField(max_length=255)
267
471
  username = SigilShortAutoField(max_length=255)
@@ -295,12 +499,12 @@ class OdooProfile(Entity):
295
499
 
296
500
  def verify(self):
297
501
  """Check credentials against Odoo and pull user info."""
298
- common = xmlrpc.client.ServerProxy(f"{self.host}/xmlrpc/2/common")
502
+ common = xmlrpc_client.ServerProxy(f"{self.host}/xmlrpc/2/common")
299
503
  uid = common.authenticate(self.database, self.username, self.password, {})
300
504
  if not uid:
301
505
  self._clear_verification()
302
506
  raise ValidationError(_("Invalid Odoo credentials"))
303
- models_proxy = xmlrpc.client.ServerProxy(f"{self.host}/xmlrpc/2/object")
507
+ models_proxy = xmlrpc_client.ServerProxy(f"{self.host}/xmlrpc/2/object")
304
508
  info = models_proxy.execute_kw(
305
509
  self.database,
306
510
  uid,
@@ -320,7 +524,7 @@ class OdooProfile(Entity):
320
524
  def execute(self, model, method, *args, **kwargs):
321
525
  """Execute an Odoo RPC call, invalidating credentials on failure."""
322
526
  try:
323
- client = xmlrpc.client.ServerProxy(f"{self.host}/xmlrpc/2/object")
527
+ client = xmlrpc_client.ServerProxy(f"{self.host}/xmlrpc/2/object")
324
528
  return client.execute_kw(
325
529
  self.database,
326
530
  self.odoo_uid,
@@ -336,14 +540,24 @@ class OdooProfile(Entity):
336
540
  raise
337
541
 
338
542
  def __str__(self): # pragma: no cover - simple representation
339
- return f"{self.user} @ {self.host}"
543
+ owner = self.owner_display()
544
+ return f"{owner} @ {self.host}" if owner else self.host
340
545
 
341
546
  class Meta:
342
- verbose_name = _("Odoo Profile")
343
- verbose_name_plural = _("Odoo Profiles")
547
+ verbose_name = _("Odoo Employee")
548
+ verbose_name_plural = _("Odoo Employees")
549
+ constraints = [
550
+ models.CheckConstraint(
551
+ check=(
552
+ (Q(user__isnull=False) & Q(group__isnull=True))
553
+ | (Q(user__isnull=True) & Q(group__isnull=False))
554
+ ),
555
+ name="odooprofile_requires_owner",
556
+ )
557
+ ]
344
558
 
345
559
 
346
- class EmailInbox(Entity):
560
+ class EmailInbox(Profile):
347
561
  """Credentials and configuration for connecting to an email mailbox."""
348
562
 
349
563
  IMAP = "imap"
@@ -353,10 +567,13 @@ class EmailInbox(Entity):
353
567
  (POP3, "POP3"),
354
568
  ]
355
569
 
356
- user = models.ForeignKey(
357
- settings.AUTH_USER_MODEL,
358
- related_name="email_inboxes",
359
- on_delete=models.CASCADE,
570
+ profile_fields = (
571
+ "username",
572
+ "host",
573
+ "port",
574
+ "password",
575
+ "protocol",
576
+ "use_ssl",
360
577
  )
361
578
  username = SigilShortAutoField(
362
579
  max_length=255,
@@ -430,9 +647,14 @@ class EmailInbox(Entity):
430
647
  def _get_body(msg):
431
648
  if msg.is_multipart():
432
649
  for part in msg.walk():
433
- if part.get_content_type() == "text/plain" and not part.get_filename():
650
+ if (
651
+ part.get_content_type() == "text/plain"
652
+ and not part.get_filename()
653
+ ):
434
654
  charset = part.get_content_charset() or "utf-8"
435
- return part.get_payload(decode=True).decode(charset, errors="ignore")
655
+ return part.get_payload(decode=True).decode(
656
+ charset, errors="ignore"
657
+ )
436
658
  return ""
437
659
  charset = msg.get_content_charset() or "utf-8"
438
660
  return msg.get_payload(decode=True).decode(charset, errors="ignore")
@@ -556,9 +778,7 @@ class EmailCollector(Entity):
556
778
  fp = EmailArtifact.fingerprint_for(
557
779
  msg.get("subject", ""), msg.get("from", ""), msg.get("body", "")
558
780
  )
559
- if EmailArtifact.objects.filter(
560
- collector=self, fingerprint=fp
561
- ).exists():
781
+ if EmailArtifact.objects.filter(collector=self, fingerprint=fp).exists():
562
782
  break
563
783
  EmailArtifact.objects.create(
564
784
  collector=self,
@@ -591,65 +811,19 @@ class EmailArtifact(Entity):
591
811
  import hashlib
592
812
 
593
813
  data = (subject or "") + (sender or "") + (body or "")
594
- return hashlib.md5(data.encode("utf-8")).hexdigest()
814
+ hasher = hashlib.md5(data.encode("utf-8"), usedforsecurity=False)
815
+ return hasher.hexdigest()
595
816
 
596
817
  class Meta:
597
818
  unique_together = ("collector", "fingerprint")
598
819
  verbose_name = "Email Artifact"
599
820
  verbose_name_plural = "Email Artifacts"
821
+ ordering = ["-id"]
600
822
 
601
823
 
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")
824
+ class ReferenceManager(EntityManager):
825
+ def get_by_natural_key(self, alt_text: str):
826
+ return self.get(alt_text=alt_text)
653
827
 
654
828
 
655
829
  class Reference(Entity):
@@ -674,6 +848,9 @@ class Reference(Entity):
674
848
  include_in_footer = models.BooleanField(
675
849
  default=False, verbose_name="Include in Footer"
676
850
  )
851
+ show_in_header = models.BooleanField(
852
+ default=False, verbose_name="Show in Header"
853
+ )
677
854
  FOOTER_PUBLIC = "public"
678
855
  FOOTER_PRIVATE = "private"
679
856
  FOOTER_STAFF = "staff"
@@ -702,12 +879,31 @@ class Reference(Entity):
702
879
  null=True,
703
880
  blank=True,
704
881
  )
882
+ sites = models.ManyToManyField(
883
+ "sites.Site",
884
+ blank=True,
885
+ related_name="references",
886
+ )
887
+ roles = models.ManyToManyField(
888
+ "nodes.NodeRole",
889
+ blank=True,
890
+ related_name="references",
891
+ )
892
+ features = models.ManyToManyField(
893
+ "nodes.NodeFeature",
894
+ blank=True,
895
+ related_name="references",
896
+ )
897
+
898
+ objects = ReferenceManager()
705
899
 
706
900
  def save(self, *args, **kwargs):
707
901
  if self.pk:
708
902
  original = type(self).all_objects.get(pk=self.pk)
709
903
  if original.transaction_uuid != self.transaction_uuid:
710
- raise ValidationError({"transaction_uuid": "Cannot modify transaction UUID"})
904
+ raise ValidationError(
905
+ {"transaction_uuid": "Cannot modify transaction UUID"}
906
+ )
711
907
  if not self.image and self.value:
712
908
  qr = qrcode.QRCode(box_size=10, border=4)
713
909
  qr.add_data(self.value)
@@ -722,6 +918,10 @@ class Reference(Entity):
722
918
  def __str__(self) -> str: # pragma: no cover - simple representation
723
919
  return self.alt_text
724
920
 
921
+ def natural_key(self): # pragma: no cover - simple representation
922
+ return (self.alt_text,)
923
+
924
+
725
925
  class RFID(Entity):
726
926
  """RFID tag that may be assigned to one account."""
727
927
 
@@ -737,6 +937,12 @@ class RFID(Entity):
737
937
  )
738
938
  ],
739
939
  )
940
+ custom_label = models.CharField(
941
+ max_length=32,
942
+ blank=True,
943
+ verbose_name="Custom Label",
944
+ help_text="Optional custom label for this RFID.",
945
+ )
740
946
  key_a = models.CharField(
741
947
  max_length=12,
742
948
  default="FFFFFFFFFFFF",
@@ -847,6 +1053,195 @@ class RFID(Entity):
847
1053
  db_table = "core_rfid"
848
1054
 
849
1055
 
1056
+ class EnergyTariffManager(EntityManager):
1057
+ def get_by_natural_key(
1058
+ self,
1059
+ year: int,
1060
+ season: str,
1061
+ zone: str,
1062
+ contract_type: str,
1063
+ period: str,
1064
+ unit: str,
1065
+ start_time,
1066
+ end_time,
1067
+ ):
1068
+ if isinstance(start_time, str):
1069
+ start_time = datetime_time.fromisoformat(start_time)
1070
+ if isinstance(end_time, str):
1071
+ end_time = datetime_time.fromisoformat(end_time)
1072
+ return self.get(
1073
+ year=year,
1074
+ season=season,
1075
+ zone=zone,
1076
+ contract_type=contract_type,
1077
+ period=period,
1078
+ unit=unit,
1079
+ start_time=start_time,
1080
+ end_time=end_time,
1081
+ )
1082
+
1083
+
1084
+ class EnergyTariff(Entity):
1085
+ class Zone(models.TextChoices):
1086
+ ONE = "1", _("Zone 1")
1087
+ ONE_A = "1A", _("Zone 1A")
1088
+ ONE_B = "1B", _("Zone 1B")
1089
+ ONE_C = "1C", _("Zone 1C")
1090
+ ONE_D = "1D", _("Zone 1D")
1091
+ ONE_E = "1E", _("Zone 1E")
1092
+ ONE_F = "1F", _("Zone 1F")
1093
+
1094
+ class Season(models.TextChoices):
1095
+ ANNUAL = "annual", _("All year")
1096
+ SUMMER = "summer", _("Summer season")
1097
+ NON_SUMMER = "non_summer", _("Non-summer season")
1098
+
1099
+ class Period(models.TextChoices):
1100
+ FLAT = "flat", _("Flat rate")
1101
+ BASIC = "basic", _("Basic block")
1102
+ INTERMEDIATE_1 = "intermediate_1", _("Intermediate block 1")
1103
+ INTERMEDIATE_2 = "intermediate_2", _("Intermediate block 2")
1104
+ EXCESS = "excess", _("Excess consumption")
1105
+ BASE = "base", _("Base")
1106
+ INTERMEDIATE = "intermediate", _("Intermediate")
1107
+ PEAK = "peak", _("Peak")
1108
+ CRITICAL_PEAK = "critical_peak", _("Critical peak")
1109
+ DEMAND = "demand", _("Demand charge")
1110
+ CAPACITY = "capacity", _("Capacity charge")
1111
+ DISTRIBUTION = "distribution", _("Distribution charge")
1112
+ FIXED = "fixed", _("Fixed charge")
1113
+
1114
+ class ContractType(models.TextChoices):
1115
+ DOMESTIC = "domestic", _("Domestic service (Tarifa 1)")
1116
+ DAC = "dac", _("High consumption domestic (DAC)")
1117
+ PDBT = "pdbt", _("General service low demand (PDBT)")
1118
+ GDBT = "gdbt", _("General service high demand (GDBT)")
1119
+ GDMTO = "gdmto", _("General distribution medium tension (GDMTO)")
1120
+ GDMTH = "gdmth", _("General distribution medium tension hourly (GDMTH)")
1121
+
1122
+ class Unit(models.TextChoices):
1123
+ KWH = "kwh", _("Kilowatt-hour")
1124
+ KW = "kw", _("Kilowatt")
1125
+ MONTH = "month", _("Monthly charge")
1126
+
1127
+ year = models.PositiveIntegerField(
1128
+ validators=[MinValueValidator(2000)],
1129
+ help_text=_("Calendar year when the tariff applies."),
1130
+ )
1131
+ season = models.CharField(
1132
+ max_length=16,
1133
+ choices=Season.choices,
1134
+ default=Season.ANNUAL,
1135
+ help_text=_("Season or applicability window defined by CFE."),
1136
+ )
1137
+ zone = models.CharField(
1138
+ max_length=3,
1139
+ choices=Zone.choices,
1140
+ help_text=_("CFE climate zone associated with the tariff."),
1141
+ )
1142
+ contract_type = models.CharField(
1143
+ max_length=16,
1144
+ choices=ContractType.choices,
1145
+ help_text=_("Type of service contract regulated by CFE."),
1146
+ )
1147
+ period = models.CharField(
1148
+ max_length=32,
1149
+ choices=Period.choices,
1150
+ help_text=_("Tariff block, demand component, or time-of-use period."),
1151
+ )
1152
+ unit = models.CharField(
1153
+ max_length=16,
1154
+ choices=Unit.choices,
1155
+ default=Unit.KWH,
1156
+ help_text=_("Measurement unit for the tariff charge."),
1157
+ )
1158
+ start_time = models.TimeField(
1159
+ help_text=_("Start time for the tariff's applicability window."),
1160
+ )
1161
+ end_time = models.TimeField(
1162
+ help_text=_("End time for the tariff's applicability window."),
1163
+ )
1164
+ price_mxn = models.DecimalField(
1165
+ max_digits=10,
1166
+ decimal_places=4,
1167
+ help_text=_("Customer price per unit in MXN."),
1168
+ )
1169
+ cost_mxn = models.DecimalField(
1170
+ max_digits=10,
1171
+ decimal_places=4,
1172
+ help_text=_("Provider cost per unit in MXN."),
1173
+ )
1174
+ notes = models.TextField(
1175
+ blank=True,
1176
+ default="",
1177
+ help_text=_("Context or special billing conditions published by CFE."),
1178
+ )
1179
+
1180
+ objects = EnergyTariffManager()
1181
+
1182
+ class Meta:
1183
+ verbose_name = _("Energy Tariff")
1184
+ verbose_name_plural = _("Energy Tariffs")
1185
+ ordering = (
1186
+ "-year",
1187
+ "season",
1188
+ "zone",
1189
+ "contract_type",
1190
+ "period",
1191
+ "start_time",
1192
+ )
1193
+ constraints = [
1194
+ models.UniqueConstraint(
1195
+ fields=[
1196
+ "year",
1197
+ "season",
1198
+ "zone",
1199
+ "contract_type",
1200
+ "period",
1201
+ "unit",
1202
+ "start_time",
1203
+ "end_time",
1204
+ ],
1205
+ name="uniq_energy_tariff_schedule",
1206
+ )
1207
+ ]
1208
+ indexes = [
1209
+ models.Index(
1210
+ fields=["year", "season", "zone", "contract_type"],
1211
+ name="energy_tariff_scope_idx",
1212
+ )
1213
+ ]
1214
+
1215
+ def clean(self):
1216
+ super().clean()
1217
+ if self.start_time >= self.end_time:
1218
+ raise ValidationError(
1219
+ {"end_time": _("End time must be after the start time.")}
1220
+ )
1221
+
1222
+ def __str__(self): # pragma: no cover - simple representation
1223
+ return _("%(contract)s %(zone)s %(season)s %(year)s (%(period)s)") % {
1224
+ "contract": self.get_contract_type_display(),
1225
+ "zone": self.zone,
1226
+ "season": self.get_season_display(),
1227
+ "year": self.year,
1228
+ "period": self.get_period_display(),
1229
+ }
1230
+
1231
+ def natural_key(self): # pragma: no cover - simple representation
1232
+ return (
1233
+ self.year,
1234
+ self.season,
1235
+ self.zone,
1236
+ self.contract_type,
1237
+ self.period,
1238
+ self.unit,
1239
+ self.start_time.isoformat(),
1240
+ self.end_time.isoformat(),
1241
+ )
1242
+
1243
+ natural_key.dependencies = [] # type: ignore[attr-defined]
1244
+
850
1245
  class EnergyAccount(Entity):
851
1246
  """Track kW energy credits for a user."""
852
1247
 
@@ -869,6 +1264,15 @@ class EnergyAccount(Entity):
869
1264
  default=False,
870
1265
  help_text="Allow transactions even when the balance is zero or negative",
871
1266
  )
1267
+ live_subscription_product = models.ForeignKey(
1268
+ "Product",
1269
+ null=True,
1270
+ blank=True,
1271
+ on_delete=models.SET_NULL,
1272
+ related_name="live_subscription_accounts",
1273
+ )
1274
+ live_subscription_start_date = models.DateField(null=True, blank=True)
1275
+ live_subscription_next_renewal = models.DateField(null=True, blank=True)
872
1276
 
873
1277
  def can_authorize(self) -> bool:
874
1278
  """Return True if this account should be authorized for charging."""
@@ -907,6 +1311,17 @@ class EnergyAccount(Entity):
907
1311
  def save(self, *args, **kwargs):
908
1312
  if self.name:
909
1313
  self.name = self.name.upper()
1314
+ if self.live_subscription_product and not self.live_subscription_start_date:
1315
+ self.live_subscription_start_date = timezone.now().date()
1316
+ if (
1317
+ self.live_subscription_product
1318
+ and self.live_subscription_start_date
1319
+ and not self.live_subscription_next_renewal
1320
+ ):
1321
+ self.live_subscription_next_renewal = (
1322
+ self.live_subscription_start_date
1323
+ + timedelta(days=self.live_subscription_product.renewal_period)
1324
+ )
910
1325
  super().save(*args, **kwargs)
911
1326
 
912
1327
  def __str__(self): # pragma: no cover - simple representation
@@ -950,11 +1365,433 @@ class EnergyCredit(Entity):
950
1365
  db_table = "core_credit"
951
1366
 
952
1367
 
1368
+ class ClientReportSchedule(Entity):
1369
+ """Configuration for recurring :class:`ClientReport` generation."""
1370
+
1371
+ PERIODICITY_NONE = "none"
1372
+ PERIODICITY_DAILY = "daily"
1373
+ PERIODICITY_WEEKLY = "weekly"
1374
+ PERIODICITY_MONTHLY = "monthly"
1375
+ PERIODICITY_CHOICES = [
1376
+ (PERIODICITY_NONE, "One-time"),
1377
+ (PERIODICITY_DAILY, "Daily"),
1378
+ (PERIODICITY_WEEKLY, "Weekly"),
1379
+ (PERIODICITY_MONTHLY, "Monthly"),
1380
+ ]
1381
+
1382
+ owner = models.ForeignKey(
1383
+ settings.AUTH_USER_MODEL,
1384
+ on_delete=models.SET_NULL,
1385
+ null=True,
1386
+ blank=True,
1387
+ related_name="client_report_schedules",
1388
+ )
1389
+ created_by = models.ForeignKey(
1390
+ settings.AUTH_USER_MODEL,
1391
+ on_delete=models.SET_NULL,
1392
+ null=True,
1393
+ blank=True,
1394
+ related_name="created_client_report_schedules",
1395
+ )
1396
+ periodicity = models.CharField(
1397
+ max_length=12, choices=PERIODICITY_CHOICES, default=PERIODICITY_NONE
1398
+ )
1399
+ email_recipients = models.JSONField(default=list, blank=True)
1400
+ disable_emails = models.BooleanField(default=False)
1401
+ periodic_task = models.OneToOneField(
1402
+ "django_celery_beat.PeriodicTask",
1403
+ on_delete=models.SET_NULL,
1404
+ null=True,
1405
+ blank=True,
1406
+ related_name="client_report_schedule",
1407
+ )
1408
+ last_generated_on = models.DateTimeField(null=True, blank=True)
1409
+
1410
+ class Meta:
1411
+ verbose_name = "Client Report Schedule"
1412
+ verbose_name_plural = "Client Report Schedules"
1413
+
1414
+ def __str__(self) -> str: # pragma: no cover - simple representation
1415
+ owner = self.owner.get_username() if self.owner else "Unassigned"
1416
+ return f"Client Report Schedule ({owner})"
1417
+
1418
+ def save(self, *args, **kwargs):
1419
+ sync = kwargs.pop("sync_task", True)
1420
+ super().save(*args, **kwargs)
1421
+ if sync and self.pk:
1422
+ self.sync_periodic_task()
1423
+
1424
+ def delete(self, using=None, keep_parents=False):
1425
+ task_id = self.periodic_task_id
1426
+ super().delete(using=using, keep_parents=keep_parents)
1427
+ if task_id:
1428
+ from django_celery_beat.models import PeriodicTask
1429
+
1430
+ PeriodicTask.objects.filter(pk=task_id).delete()
1431
+
1432
+ def sync_periodic_task(self):
1433
+ """Ensure the Celery beat schedule matches the configured periodicity."""
1434
+
1435
+ from django_celery_beat.models import CrontabSchedule, PeriodicTask
1436
+ from django.db import transaction
1437
+ import json as _json
1438
+
1439
+ if self.periodicity == self.PERIODICITY_NONE:
1440
+ if self.periodic_task_id:
1441
+ PeriodicTask.objects.filter(pk=self.periodic_task_id).delete()
1442
+ type(self).objects.filter(pk=self.pk).update(periodic_task=None)
1443
+ return
1444
+
1445
+ if self.periodicity == self.PERIODICITY_DAILY:
1446
+ schedule, _ = CrontabSchedule.objects.get_or_create(
1447
+ minute="0",
1448
+ hour="2",
1449
+ day_of_week="*",
1450
+ day_of_month="*",
1451
+ month_of_year="*",
1452
+ )
1453
+ elif self.periodicity == self.PERIODICITY_WEEKLY:
1454
+ schedule, _ = CrontabSchedule.objects.get_or_create(
1455
+ minute="0",
1456
+ hour="3",
1457
+ day_of_week="1",
1458
+ day_of_month="*",
1459
+ month_of_year="*",
1460
+ )
1461
+ else:
1462
+ schedule, _ = CrontabSchedule.objects.get_or_create(
1463
+ minute="0",
1464
+ hour="4",
1465
+ day_of_week="*",
1466
+ day_of_month="1",
1467
+ month_of_year="*",
1468
+ )
1469
+
1470
+ name = f"client_report_schedule_{self.pk}"
1471
+ defaults = {
1472
+ "crontab": schedule,
1473
+ "task": "core.tasks.run_client_report_schedule",
1474
+ "kwargs": _json.dumps({"schedule_id": self.pk}),
1475
+ "enabled": True,
1476
+ }
1477
+ with transaction.atomic():
1478
+ periodic_task, _ = PeriodicTask.objects.update_or_create(
1479
+ name=name, defaults=defaults
1480
+ )
1481
+ if self.periodic_task_id != periodic_task.pk:
1482
+ type(self).objects.filter(pk=self.pk).update(
1483
+ periodic_task=periodic_task
1484
+ )
1485
+
1486
+ def calculate_period(self, reference=None):
1487
+ """Return the date range covered for the next execution."""
1488
+
1489
+ from django.utils import timezone
1490
+ import datetime as _datetime
1491
+
1492
+ ref_date = reference or timezone.localdate()
1493
+
1494
+ if self.periodicity == self.PERIODICITY_DAILY:
1495
+ end = ref_date - _datetime.timedelta(days=1)
1496
+ start = end
1497
+ elif self.periodicity == self.PERIODICITY_WEEKLY:
1498
+ start_of_week = ref_date - _datetime.timedelta(days=ref_date.weekday())
1499
+ end = start_of_week - _datetime.timedelta(days=1)
1500
+ start = end - _datetime.timedelta(days=6)
1501
+ elif self.periodicity == self.PERIODICITY_MONTHLY:
1502
+ first_of_month = ref_date.replace(day=1)
1503
+ end = first_of_month - _datetime.timedelta(days=1)
1504
+ start = end.replace(day=1)
1505
+ else:
1506
+ raise ValueError("calculate_period called for non-recurring schedule")
1507
+
1508
+ return start, end
1509
+
1510
+ def resolve_recipients(self):
1511
+ """Return (to, cc) email lists respecting owner fallbacks."""
1512
+
1513
+ from django.contrib.auth import get_user_model
1514
+
1515
+ to: list[str] = []
1516
+ cc: list[str] = []
1517
+ seen: set[str] = set()
1518
+
1519
+ for email in self.email_recipients:
1520
+ normalized = (email or "").strip()
1521
+ if not normalized:
1522
+ continue
1523
+ if normalized.lower() in seen:
1524
+ continue
1525
+ to.append(normalized)
1526
+ seen.add(normalized.lower())
1527
+
1528
+ owner_email = None
1529
+ if self.owner and self.owner.email:
1530
+ candidate = self.owner.email.strip()
1531
+ if candidate:
1532
+ owner_email = candidate
1533
+
1534
+ if to:
1535
+ if owner_email and owner_email.lower() not in seen:
1536
+ cc.append(owner_email)
1537
+ else:
1538
+ if owner_email:
1539
+ to.append(owner_email)
1540
+ seen.add(owner_email.lower())
1541
+ else:
1542
+ admin_email = (
1543
+ get_user_model()
1544
+ .objects.filter(is_superuser=True, is_active=True)
1545
+ .exclude(email="")
1546
+ .values_list("email", flat=True)
1547
+ .first()
1548
+ )
1549
+ if admin_email:
1550
+ to.append(admin_email)
1551
+ seen.add(admin_email.lower())
1552
+ elif settings.DEFAULT_FROM_EMAIL:
1553
+ to.append(settings.DEFAULT_FROM_EMAIL)
1554
+
1555
+ return to, cc
1556
+
1557
+ def get_outbox(self):
1558
+ """Return the preferred :class:`nodes.models.EmailOutbox` instance."""
1559
+
1560
+ from nodes.models import EmailOutbox, Node
1561
+
1562
+ if self.owner:
1563
+ try:
1564
+ outbox = self.owner.get_profile(EmailOutbox)
1565
+ except Exception: # pragma: no cover - defensive catch
1566
+ outbox = None
1567
+ if outbox:
1568
+ return outbox
1569
+
1570
+ node = Node.get_local()
1571
+ if node:
1572
+ return getattr(node, "email_outbox", None)
1573
+ return None
1574
+
1575
+ def notify_failure(self, message: str):
1576
+ from nodes.models import NetMessage
1577
+
1578
+ NetMessage.broadcast("Client report delivery issue", message)
1579
+
1580
+ def run(self):
1581
+ """Generate the report, persist it and deliver notifications."""
1582
+
1583
+ from core import mailer
1584
+
1585
+ try:
1586
+ start, end = self.calculate_period()
1587
+ except ValueError:
1588
+ return None
1589
+
1590
+ try:
1591
+ report = ClientReport.generate(
1592
+ start,
1593
+ end,
1594
+ owner=self.owner,
1595
+ schedule=self,
1596
+ recipients=self.email_recipients,
1597
+ disable_emails=self.disable_emails,
1598
+ )
1599
+ export, html_content = report.store_local_copy()
1600
+ except Exception as exc:
1601
+ self.notify_failure(str(exc))
1602
+ raise
1603
+
1604
+ if not self.disable_emails:
1605
+ to, cc = self.resolve_recipients()
1606
+ if not to:
1607
+ self.notify_failure("No recipients available for client report")
1608
+ raise RuntimeError("No recipients available for client report")
1609
+ else:
1610
+ try:
1611
+ attachments = []
1612
+ html_name = Path(export["html_path"]).name
1613
+ attachments.append((html_name, html_content, "text/html"))
1614
+ json_file = Path(settings.BASE_DIR) / export["json_path"]
1615
+ if json_file.exists():
1616
+ attachments.append(
1617
+ (
1618
+ json_file.name,
1619
+ json_file.read_text(encoding="utf-8"),
1620
+ "application/json",
1621
+ )
1622
+ )
1623
+ subject = f"Client report {report.start_date} to {report.end_date}"
1624
+ body = (
1625
+ "Attached is the client report generated for the period "
1626
+ f"{report.start_date} to {report.end_date}."
1627
+ )
1628
+ mailer.send(
1629
+ subject,
1630
+ body,
1631
+ to,
1632
+ outbox=self.get_outbox(),
1633
+ cc=cc,
1634
+ attachments=attachments,
1635
+ )
1636
+ delivered = list(dict.fromkeys(to + (cc or [])))
1637
+ if delivered:
1638
+ type(report).objects.filter(pk=report.pk).update(
1639
+ recipients=delivered
1640
+ )
1641
+ report.recipients = delivered
1642
+ except Exception as exc:
1643
+ self.notify_failure(str(exc))
1644
+ raise
1645
+
1646
+ now = timezone.now()
1647
+ type(self).objects.filter(pk=self.pk).update(last_generated_on=now)
1648
+ self.last_generated_on = now
1649
+ return report
1650
+
1651
+
1652
+ class ClientReport(Entity):
1653
+ """Snapshot of energy usage over a period."""
1654
+
1655
+ start_date = models.DateField()
1656
+ end_date = models.DateField()
1657
+ created_on = models.DateTimeField(auto_now_add=True)
1658
+ data = models.JSONField(default=dict)
1659
+ owner = models.ForeignKey(
1660
+ settings.AUTH_USER_MODEL,
1661
+ on_delete=models.SET_NULL,
1662
+ null=True,
1663
+ blank=True,
1664
+ related_name="client_reports",
1665
+ )
1666
+ schedule = models.ForeignKey(
1667
+ "ClientReportSchedule",
1668
+ on_delete=models.SET_NULL,
1669
+ null=True,
1670
+ blank=True,
1671
+ related_name="reports",
1672
+ )
1673
+ recipients = models.JSONField(default=list, blank=True)
1674
+ disable_emails = models.BooleanField(default=False)
1675
+
1676
+ class Meta:
1677
+ verbose_name = "Client Report"
1678
+ verbose_name_plural = "Client Reports"
1679
+ db_table = "core_client_report"
1680
+ ordering = ["-created_on"]
1681
+
1682
+ @classmethod
1683
+ def generate(
1684
+ cls,
1685
+ start_date,
1686
+ end_date,
1687
+ *,
1688
+ owner=None,
1689
+ schedule=None,
1690
+ recipients: list[str] | None = None,
1691
+ disable_emails: bool = False,
1692
+ ):
1693
+ rows = cls.build_rows(start_date, end_date)
1694
+ return cls.objects.create(
1695
+ start_date=start_date,
1696
+ end_date=end_date,
1697
+ data={"rows": rows},
1698
+ owner=owner,
1699
+ schedule=schedule,
1700
+ recipients=list(recipients or []),
1701
+ disable_emails=disable_emails,
1702
+ )
1703
+
1704
+ def store_local_copy(self, html: str | None = None):
1705
+ """Persist the report data and optional HTML rendering to disk."""
1706
+
1707
+ import json as _json
1708
+ from django.template.loader import render_to_string
1709
+
1710
+ base_dir = Path(settings.BASE_DIR)
1711
+ report_dir = base_dir / "work" / "reports"
1712
+ report_dir.mkdir(parents=True, exist_ok=True)
1713
+ timestamp = timezone.now().strftime("%Y%m%d%H%M%S")
1714
+ identifier = f"client_report_{self.pk}_{timestamp}"
1715
+
1716
+ html_content = html or render_to_string(
1717
+ "core/reports/client_report_email.html", {"report": self}
1718
+ )
1719
+ html_path = report_dir / f"{identifier}.html"
1720
+ html_path.write_text(html_content, encoding="utf-8")
1721
+
1722
+ json_path = report_dir / f"{identifier}.json"
1723
+ json_path.write_text(
1724
+ _json.dumps(self.data, indent=2, default=str), encoding="utf-8"
1725
+ )
1726
+
1727
+ def _relative(path: Path) -> str:
1728
+ try:
1729
+ return str(path.relative_to(base_dir))
1730
+ except ValueError:
1731
+ return str(path)
1732
+
1733
+ export = {
1734
+ "html_path": _relative(html_path),
1735
+ "json_path": _relative(json_path),
1736
+ }
1737
+
1738
+ updated = dict(self.data)
1739
+ updated["export"] = export
1740
+ type(self).objects.filter(pk=self.pk).update(data=updated)
1741
+ self.data = updated
1742
+ return export, html_content
1743
+
1744
+ @staticmethod
1745
+ def build_rows(start_date=None, end_date=None):
1746
+ from collections import defaultdict
1747
+ from ocpp.models import Transaction
1748
+
1749
+ qs = Transaction.objects.exclude(rfid="")
1750
+ if start_date:
1751
+ from datetime import datetime, time, timedelta, timezone as pytimezone
1752
+
1753
+ start_dt = datetime.combine(start_date, time.min, tzinfo=pytimezone.utc)
1754
+ qs = qs.filter(start_time__gte=start_dt)
1755
+ if end_date:
1756
+ from datetime import datetime, time, timedelta, timezone as pytimezone
1757
+
1758
+ end_dt = datetime.combine(
1759
+ end_date + timedelta(days=1), time.min, tzinfo=pytimezone.utc
1760
+ )
1761
+ qs = qs.filter(start_time__lt=end_dt)
1762
+ data = defaultdict(lambda: {"kw": 0.0, "count": 0})
1763
+ for tx in qs:
1764
+ data[tx.rfid]["kw"] += tx.kw
1765
+ data[tx.rfid]["count"] += 1
1766
+ rows = []
1767
+ for rfid_uid, stats in sorted(data.items()):
1768
+ tag = RFID.objects.filter(rfid=rfid_uid).first()
1769
+ if tag:
1770
+ account = tag.energy_accounts.first()
1771
+ if account:
1772
+ subject = account.name
1773
+ else:
1774
+ subject = str(tag.label_id)
1775
+ else:
1776
+ subject = rfid_uid
1777
+ rows.append(
1778
+ {"subject": subject, "kw": stats["kw"], "count": stats["count"]}
1779
+ )
1780
+ return rows
1781
+
1782
+
1783
+ class BrandManager(EntityManager):
1784
+ def get_by_natural_key(self, name: str):
1785
+ return self.get(name=name)
1786
+
1787
+
953
1788
  class Brand(Entity):
954
1789
  """Vehicle manufacturer or brand."""
955
1790
 
956
1791
  name = models.CharField(max_length=100, unique=True)
957
1792
 
1793
+ objects = BrandManager()
1794
+
958
1795
  class Meta:
959
1796
  verbose_name = _("EV Brand")
960
1797
  verbose_name_plural = _("EV Brands")
@@ -962,6 +1799,9 @@ class Brand(Entity):
962
1799
  def __str__(self) -> str: # pragma: no cover - simple representation
963
1800
  return self.name
964
1801
 
1802
+ def natural_key(self): # pragma: no cover - simple representation
1803
+ return (self.name,)
1804
+
965
1805
  @classmethod
966
1806
  def from_vin(cls, vin: str) -> "Brand | None":
967
1807
  """Return the brand matching the VIN's WMI prefix."""
@@ -990,6 +1830,48 @@ class EVModel(Entity):
990
1830
 
991
1831
  brand = models.ForeignKey(Brand, on_delete=models.CASCADE, related_name="ev_models")
992
1832
  name = models.CharField(max_length=100)
1833
+ battery_capacity_kwh = models.DecimalField(
1834
+ max_digits=6,
1835
+ decimal_places=2,
1836
+ null=True,
1837
+ blank=True,
1838
+ verbose_name="Battery Capacity (kWh)",
1839
+ )
1840
+ est_battery_kwh = models.DecimalField(
1841
+ max_digits=6,
1842
+ decimal_places=2,
1843
+ null=True,
1844
+ blank=True,
1845
+ verbose_name="Estimated Battery (kWh)",
1846
+ )
1847
+ ac_110v_power_kw = models.DecimalField(
1848
+ max_digits=5,
1849
+ decimal_places=2,
1850
+ null=True,
1851
+ blank=True,
1852
+ verbose_name="110V AC (kW)",
1853
+ )
1854
+ ac_220v_power_kw = models.DecimalField(
1855
+ max_digits=5,
1856
+ decimal_places=2,
1857
+ null=True,
1858
+ blank=True,
1859
+ verbose_name="220V AC (kW)",
1860
+ )
1861
+ dc_60_power_kw = models.DecimalField(
1862
+ max_digits=5,
1863
+ decimal_places=2,
1864
+ null=True,
1865
+ blank=True,
1866
+ verbose_name="60kW DC (kW)",
1867
+ )
1868
+ dc_100_power_kw = models.DecimalField(
1869
+ max_digits=5,
1870
+ decimal_places=2,
1871
+ null=True,
1872
+ blank=True,
1873
+ verbose_name="100kW DC (kW)",
1874
+ )
993
1875
 
994
1876
  class Meta:
995
1877
  unique_together = ("brand", "name")
@@ -1021,9 +1903,7 @@ class ElectricVehicle(Entity):
1021
1903
  related_name="vehicles",
1022
1904
  )
1023
1905
  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
- )
1906
+ license_plate = models.CharField(_("License Plate"), max_length=20, blank=True)
1027
1907
 
1028
1908
  def save(self, *args, **kwargs):
1029
1909
  if self.model and not self.brand:
@@ -1047,30 +1927,16 @@ class Product(Entity):
1047
1927
  name = models.CharField(max_length=100)
1048
1928
  description = models.TextField(blank=True)
1049
1929
  renewal_period = models.PositiveIntegerField(help_text="Renewal period in days")
1930
+ odoo_product = models.JSONField(
1931
+ null=True,
1932
+ blank=True,
1933
+ help_text="Selected product from Odoo (id and name)",
1934
+ )
1050
1935
 
1051
1936
  def __str__(self) -> str: # pragma: no cover - simple representation
1052
1937
  return self.name
1053
1938
 
1054
1939
 
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
1940
  class AdminHistory(Entity):
1075
1941
  """Record of recently visited admin changelists for a user."""
1076
1942
 
@@ -1093,11 +1959,48 @@ class AdminHistory(Entity):
1093
1959
  return model._meta.verbose_name_plural if model else self.content_type.name
1094
1960
 
1095
1961
 
1096
- class ReleaseManager(Entity):
1962
+ class ReleaseManagerManager(EntityManager):
1963
+ def get_by_natural_key(self, owner, package=None):
1964
+ owner = owner or ""
1965
+ if owner.startswith("group:"):
1966
+ group_name = owner.split(":", 1)[1]
1967
+ return self.get(group__name=group_name)
1968
+ return self.get(user__username=owner)
1969
+
1970
+
1971
+ class PackageManager(EntityManager):
1972
+ def get_by_natural_key(self, name):
1973
+ return self.get(name=name)
1974
+
1975
+
1976
+ class PackageReleaseManager(EntityManager):
1977
+ def get_by_natural_key(self, package, version):
1978
+ return self.get(package__name=package, version=version)
1979
+
1980
+
1981
+ class ReleaseManager(Profile):
1097
1982
  """Store credentials for publishing packages."""
1098
1983
 
1099
- user = models.OneToOneField(
1100
- settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="release_manager"
1984
+ objects = ReleaseManagerManager()
1985
+
1986
+ def natural_key(self):
1987
+ owner = self.owner_display()
1988
+ if self.group_id and owner:
1989
+ owner = f"group:{owner}"
1990
+
1991
+ pkg_name = ""
1992
+ if self.pk:
1993
+ pkg = self.package_set.first()
1994
+ pkg_name = pkg.name if pkg else ""
1995
+
1996
+ return (owner or "", pkg_name)
1997
+
1998
+ profile_fields = (
1999
+ "pypi_username",
2000
+ "pypi_token",
2001
+ "github_token",
2002
+ "pypi_password",
2003
+ "pypi_url",
1101
2004
  )
1102
2005
  pypi_username = SigilShortAutoField("PyPI username", max_length=100, blank=True)
1103
2006
  pypi_token = SigilShortAutoField("PyPI token", max_length=200, blank=True)
@@ -1115,34 +2018,43 @@ class ReleaseManager(Entity):
1115
2018
  class Meta:
1116
2019
  verbose_name = "Release Manager"
1117
2020
  verbose_name_plural = "Release Managers"
2021
+ constraints = [
2022
+ models.CheckConstraint(
2023
+ check=(
2024
+ (Q(user__isnull=False) & Q(group__isnull=True))
2025
+ | (Q(user__isnull=True) & Q(group__isnull=False))
2026
+ ),
2027
+ name="releasemanager_requires_owner",
2028
+ )
2029
+ ]
1118
2030
 
1119
2031
  def __str__(self) -> str: # pragma: no cover - trivial
1120
2032
  return self.name
1121
2033
 
1122
2034
  @property
1123
2035
  def name(self) -> str: # pragma: no cover - simple proxy
1124
- return self.user.get_username()
2036
+ owner = self.owner_display()
2037
+ return owner or ""
1125
2038
 
1126
2039
  def to_credentials(self) -> Credentials | None:
1127
2040
  """Return credentials for this release manager."""
1128
2041
  if self.pypi_token:
1129
2042
  return Credentials(token=self.pypi_token)
1130
2043
  if self.pypi_username and self.pypi_password:
1131
- return Credentials(
1132
- username=self.pypi_username, password=self.pypi_password
1133
- )
2044
+ return Credentials(username=self.pypi_username, password=self.pypi_password)
1134
2045
  return None
1135
2046
 
1136
2047
 
1137
2048
  class Package(Entity):
1138
2049
  """Package details shared across releases."""
1139
2050
 
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
- )
2051
+ objects = PackageManager()
2052
+
2053
+ def natural_key(self):
2054
+ return (self.name,)
2055
+
2056
+ name = models.CharField(max_length=100, default=DEFAULT_PACKAGE.name, unique=True)
2057
+ description = models.CharField(max_length=255, default=DEFAULT_PACKAGE.description)
1146
2058
  author = models.CharField(max_length=100, default=DEFAULT_PACKAGE.author)
1147
2059
  email = models.EmailField(default=DEFAULT_PACKAGE.email)
1148
2060
  python_requires = models.CharField(
@@ -1191,9 +2103,15 @@ class Package(Entity):
1191
2103
  homepage_url=self.homepage_url,
1192
2104
  )
1193
2105
 
2106
+
1194
2107
  class PackageRelease(Entity):
1195
2108
  """Store metadata for a specific package version."""
1196
2109
 
2110
+ objects = PackageReleaseManager()
2111
+
2112
+ def natural_key(self):
2113
+ return (self.package.name, self.version)
2114
+
1197
2115
  package = models.ForeignKey(
1198
2116
  Package, on_delete=models.CASCADE, related_name="releases"
1199
2117
  )
@@ -1218,10 +2136,15 @@ class PackageRelease(Entity):
1218
2136
 
1219
2137
  @classmethod
1220
2138
  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)
2139
+ base = Path("core/fixtures")
2140
+ base.mkdir(parents=True, exist_ok=True)
2141
+ for old in base.glob("releases__*.json"):
2142
+ old.unlink()
2143
+ for release in cls.objects.all():
2144
+ name = f"releases__packagerelease_{release.version.replace('.', '_')}.json"
2145
+ path = base / name
2146
+ data = serializers.serialize("json", [release])
2147
+ path.write_text(data)
1225
2148
 
1226
2149
  def __str__(self) -> str: # pragma: no cover - trivial
1227
2150
  return f"{self.package.name} {self.version}"
@@ -1300,13 +2223,16 @@ class PackageRelease(Entity):
1300
2223
  self.save(update_fields=["revision"])
1301
2224
  PackageRelease.dump_fixture()
1302
2225
  if kwargs.get("git"):
2226
+ from glob import glob
2227
+
2228
+ paths = sorted(glob("core/fixtures/releases__*.json"))
1303
2229
  diff = subprocess.run(
1304
- ["git", "status", "--porcelain", "core/fixtures/releases.json"],
2230
+ ["git", "status", "--porcelain", *paths],
1305
2231
  capture_output=True,
1306
2232
  text=True,
1307
2233
  )
1308
2234
  if diff.stdout.strip():
1309
- release_utils._run(["git", "add", "core/fixtures/releases.json"])
2235
+ release_utils._run(["git", "add", *paths])
1310
2236
  release_utils._run(
1311
2237
  [
1312
2238
  "git",
@@ -1321,20 +2247,27 @@ class PackageRelease(Entity):
1321
2247
  def revision_short(self) -> str:
1322
2248
  return self.revision[-6:] if self.revision else ""
1323
2249
 
2250
+
1324
2251
  # Ensure each RFID can only be linked to one energy account
1325
2252
  @receiver(m2m_changed, sender=EnergyAccount.rfids.through)
1326
- def _rfid_unique_energy_account(sender, instance, action, reverse, model, pk_set, **kwargs):
2253
+ def _rfid_unique_energy_account(
2254
+ sender, instance, action, reverse, model, pk_set, **kwargs
2255
+ ):
1327
2256
  """Prevent associating an RFID with more than one energy account."""
1328
2257
  if action == "pre_add":
1329
2258
  if reverse: # adding energy accounts to an RFID
1330
2259
  if instance.energy_accounts.exclude(pk__in=pk_set).exists():
1331
- raise ValidationError("RFID tags may only be assigned to one energy account.")
2260
+ raise ValidationError(
2261
+ "RFID tags may only be assigned to one energy account."
2262
+ )
1332
2263
  else: # adding RFIDs to an energy account
1333
2264
  conflict = model.objects.filter(
1334
2265
  pk__in=pk_set, energy_accounts__isnull=False
1335
2266
  ).exclude(energy_accounts=instance)
1336
2267
  if conflict.exists():
1337
- raise ValidationError("RFID tags may only be assigned to one energy account.")
2268
+ raise ValidationError(
2269
+ "RFID tags may only be assigned to one energy account."
2270
+ )
1338
2271
 
1339
2272
 
1340
2273
  def hash_key(key: str) -> str:
@@ -1343,7 +2276,7 @@ def hash_key(key: str) -> str:
1343
2276
  return hashlib.sha256(key.encode()).hexdigest()
1344
2277
 
1345
2278
 
1346
- class ChatProfile(models.Model):
2279
+ class AssistantProfile(Profile):
1347
2280
  """Stores a hashed user key used by the assistant for authentication.
1348
2281
 
1349
2282
  The plain-text ``user_key`` is generated server-side and shown only once.
@@ -1352,9 +2285,7 @@ class ChatProfile(models.Model):
1352
2285
  """
1353
2286
 
1354
2287
  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
- )
2288
+ profile_fields = ("user_key_hash", "scopes", "is_active")
1358
2289
  user_key_hash = models.CharField(max_length=64, unique=True)
1359
2290
  scopes = models.JSONField(default=list, blank=True)
1360
2291
  created_at = models.DateTimeField(auto_now_add=True)
@@ -1362,19 +2293,35 @@ class ChatProfile(models.Model):
1362
2293
  is_active = models.BooleanField(default=True)
1363
2294
 
1364
2295
  class Meta:
1365
- db_table = "workgroup_chatprofile"
1366
- verbose_name = "Chat Profile"
1367
- verbose_name_plural = "Chat Profiles"
2296
+ db_table = "workgroup_assistantprofile"
2297
+ verbose_name = "Assistant Profile"
2298
+ verbose_name_plural = "Assistant Profiles"
2299
+ constraints = [
2300
+ models.CheckConstraint(
2301
+ check=(
2302
+ (Q(user__isnull=False) & Q(group__isnull=True))
2303
+ | (Q(user__isnull=True) & Q(group__isnull=False))
2304
+ ),
2305
+ name="assistantprofile_requires_owner",
2306
+ )
2307
+ ]
1368
2308
 
1369
2309
  @classmethod
1370
- def issue_key(cls, user) -> tuple["ChatProfile", str]:
2310
+ def issue_key(cls, user) -> tuple["AssistantProfile", str]:
1371
2311
  """Create or update a profile and return it with a new plain key."""
1372
2312
 
1373
2313
  key = secrets.token_hex(32)
1374
2314
  key_hash = hash_key(key)
2315
+ if user is None:
2316
+ raise ValueError("Assistant profiles require a user instance")
2317
+
1375
2318
  profile, _ = cls.objects.update_or_create(
1376
2319
  user=user,
1377
- defaults={"user_key_hash": key_hash, "last_used_at": None, "is_active": True},
2320
+ defaults={
2321
+ "user_key_hash": key_hash,
2322
+ "last_used_at": None,
2323
+ "is_active": True,
2324
+ },
1378
2325
  )
1379
2326
  return profile, key
1380
2327
 
@@ -1385,4 +2332,69 @@ class ChatProfile(models.Model):
1385
2332
  self.save(update_fields=["last_used_at"])
1386
2333
 
1387
2334
  def __str__(self) -> str: # pragma: no cover - simple representation
1388
- return f"ChatProfile for {self.user}"
2335
+ owner = self.owner_display()
2336
+ return f"AssistantProfile for {owner}" if owner else "AssistantProfile"
2337
+
2338
+
2339
+ def validate_relative_url(value: str) -> None:
2340
+ if not value:
2341
+ return
2342
+ parsed = urlparse(value)
2343
+ if parsed.scheme or parsed.netloc or not value.startswith("/"):
2344
+ raise ValidationError("URL must be relative")
2345
+
2346
+
2347
+ class TodoManager(EntityManager):
2348
+ def get_by_natural_key(self, request: str):
2349
+ return self.get(request=request)
2350
+
2351
+
2352
+ class Todo(Entity):
2353
+ """Tasks requested for the Release Manager."""
2354
+
2355
+ request = models.CharField(max_length=255)
2356
+ url = models.CharField(
2357
+ max_length=200, blank=True, default="", validators=[validate_relative_url]
2358
+ )
2359
+ request_details = models.TextField(blank=True, default="")
2360
+ done_on = models.DateTimeField(null=True, blank=True)
2361
+ on_done_condition = ConditionTextField(blank=True, default="")
2362
+
2363
+ objects = TodoManager()
2364
+
2365
+ class Meta:
2366
+ verbose_name = "TODO"
2367
+ verbose_name_plural = "TODOs"
2368
+ constraints = [
2369
+ models.UniqueConstraint(
2370
+ Lower("request"),
2371
+ condition=Q(is_deleted=False),
2372
+ name="unique_active_todo_request",
2373
+ )
2374
+ ]
2375
+
2376
+ def clean(self):
2377
+ super().clean()
2378
+ if (
2379
+ Todo.objects.filter(request__iexact=self.request, is_deleted=False)
2380
+ .exclude(pk=self.pk)
2381
+ .exists()
2382
+ ):
2383
+ raise ValidationError({"request": "Similar TODO already exists."})
2384
+
2385
+ def __str__(self) -> str: # pragma: no cover - simple representation
2386
+ return self.request
2387
+
2388
+ def natural_key(self):
2389
+ """Use the request field as the natural key."""
2390
+ return (self.request,)
2391
+
2392
+ natural_key.dependencies = []
2393
+
2394
+ def check_on_done_condition(self) -> ConditionCheckResult:
2395
+ """Evaluate the ``on_done_condition`` field for this TODO."""
2396
+
2397
+ field = self._meta.get_field("on_done_condition")
2398
+ if isinstance(field, ConditionTextField):
2399
+ return field.evaluate(self)
2400
+ return ConditionCheckResult(True, "")