arthexis 0.1.19__py3-none-any.whl → 0.1.21__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.
core/environment.py CHANGED
@@ -1,11 +1,17 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ import re
5
+ import shlex
6
+ import subprocess
7
+ from pathlib import Path
4
8
 
9
+ from django import forms
5
10
  from django.conf import settings
6
11
  from django.contrib import admin
12
+ from django.core.exceptions import PermissionDenied
7
13
  from django.template.response import TemplateResponse
8
- from django.urls import path
14
+ from django.urls import path, reverse
9
15
  from django.utils.translation import gettext_lazy as _
10
16
 
11
17
 
@@ -15,23 +21,248 @@ def _get_django_settings():
15
21
  )
16
22
 
17
23
 
24
+ class NetworkSetupForm(forms.Form):
25
+ prompt_for_password = forms.BooleanField(
26
+ label=_("Prompt for new WiFi password"),
27
+ required=False,
28
+ help_text=_("Add --password to request a password even when one is already configured."),
29
+ )
30
+ access_point_name = forms.CharField(
31
+ label=_("Access point name"),
32
+ required=False,
33
+ max_length=32,
34
+ help_text=_("Use --ap to set the wlan0 access point name."),
35
+ )
36
+ skip_firewall_validation = forms.BooleanField(
37
+ label=_("Skip firewall validation"),
38
+ required=False,
39
+ help_text=_("Add --no-firewall to bypass firewall port checks."),
40
+ )
41
+ skip_access_point_configuration = forms.BooleanField(
42
+ label=_("Skip access point configuration"),
43
+ required=False,
44
+ help_text=_("Add --no-ap to leave the access point configuration unchanged."),
45
+ )
46
+ allow_unsafe_changes = forms.BooleanField(
47
+ label=_("Allow modifying the active internet connection"),
48
+ required=False,
49
+ help_text=_("Include --unsafe to allow changes that may interrupt connectivity."),
50
+ )
51
+ interactive = forms.BooleanField(
52
+ label=_("Prompt before each step"),
53
+ required=False,
54
+ help_text=_("Run the script with --interactive to confirm each action."),
55
+ )
56
+ install_watchdog = forms.BooleanField(
57
+ label=_("Install WiFi watchdog service"),
58
+ required=False,
59
+ initial=True,
60
+ help_text=_("Keep selected to retain the watchdog or clear to add --no-watchdog."),
61
+ )
62
+ vnc_validation = forms.ChoiceField(
63
+ label=_("VNC validation"),
64
+ choices=(
65
+ ("default", _("Use script default (skip validation)")),
66
+ ("require", _("Require that a VNC service is enabled (--vnc)")),
67
+ ),
68
+ initial="default",
69
+ required=True,
70
+ )
71
+ ethernet_subnet = forms.CharField(
72
+ label=_("Ethernet subnet"),
73
+ required=False,
74
+ help_text=_("Provide Z, Z/P (prefix 16 or 24), X.Y.Z, or X.Y.Z/P to supply --subnet."),
75
+ )
76
+ update_ap_password_only = forms.BooleanField(
77
+ label=_("Update access point password only"),
78
+ required=False,
79
+ help_text=_("Use --ap-set-password without running other setup steps."),
80
+ )
81
+
82
+ def clean_ethernet_subnet(self) -> str:
83
+ value = self.cleaned_data.get("ethernet_subnet", "")
84
+ if not value:
85
+ return ""
86
+ raw = value.strip()
87
+ match = re.fullmatch(
88
+ r"(?P<first>\d{1,3})(?:\.(?P<second>\d{1,3})\.(?P<third>\d{1,3}))?(?:/(?P<prefix>\d{1,2}))?",
89
+ raw,
90
+ )
91
+ if not match:
92
+ raise forms.ValidationError(
93
+ _("Enter a subnet in the form Z, Z/P, X.Y.Z, or X.Y.Z/P with prefix 16 or 24."),
94
+ )
95
+ first_octet = int(match.group("first"))
96
+ second = match.group("second")
97
+ third = match.group("third")
98
+ if second is not None and third is not None:
99
+ octets = [first_octet, int(second), int(third)]
100
+ for octet in octets:
101
+ if octet < 0 or octet > 255:
102
+ raise forms.ValidationError(
103
+ _("Subnet octets must be between 0 and 255."),
104
+ )
105
+ if octets[2] > 254:
106
+ raise forms.ValidationError(
107
+ _("The third subnet octet must be between 0 and 254."),
108
+ )
109
+ subnet_value = ".".join(str(octet) for octet in octets)
110
+ else:
111
+ if first_octet < 0 or first_octet > 254:
112
+ raise forms.ValidationError(
113
+ _("Subnet value must be between 0 and 254."),
114
+ )
115
+ subnet_value = str(first_octet)
116
+ prefix_value = match.group("prefix")
117
+ if prefix_value:
118
+ prefix = int(prefix_value)
119
+ if prefix not in {16, 24}:
120
+ raise forms.ValidationError(
121
+ _("Subnet prefix must be 16 or 24."),
122
+ )
123
+ return f"{subnet_value}/{prefix}"
124
+ return subnet_value
125
+
126
+ def clean(self) -> dict:
127
+ cleaned_data = super().clean()
128
+ if cleaned_data.get("update_ap_password_only"):
129
+ other_flags = [
130
+ cleaned_data.get("prompt_for_password"),
131
+ bool(cleaned_data.get("access_point_name")),
132
+ cleaned_data.get("skip_firewall_validation"),
133
+ cleaned_data.get("skip_access_point_configuration"),
134
+ cleaned_data.get("allow_unsafe_changes"),
135
+ cleaned_data.get("interactive"),
136
+ bool(cleaned_data.get("ethernet_subnet")),
137
+ cleaned_data.get("vnc_validation") == "require",
138
+ not cleaned_data.get("install_watchdog", True),
139
+ ]
140
+ if any(other_flags):
141
+ raise forms.ValidationError(
142
+ _(
143
+ "Update access point password only cannot be combined with other network-setup options."
144
+ )
145
+ )
146
+ return cleaned_data
147
+
148
+ def build_command(self, script_path: Path) -> list[str]:
149
+ command = [str(script_path)]
150
+ data = self.cleaned_data
151
+ if data.get("update_ap_password_only"):
152
+ command.append("--ap-set-password")
153
+ return command
154
+ if data.get("prompt_for_password"):
155
+ command.append("--password")
156
+ access_point_name = data.get("access_point_name")
157
+ if access_point_name:
158
+ command.extend(["--ap", access_point_name])
159
+ if data.get("skip_firewall_validation"):
160
+ command.append("--no-firewall")
161
+ if data.get("skip_access_point_configuration"):
162
+ command.append("--no-ap")
163
+ if data.get("allow_unsafe_changes"):
164
+ command.append("--unsafe")
165
+ if data.get("interactive"):
166
+ command.append("--interactive")
167
+ if not data.get("install_watchdog"):
168
+ command.append("--no-watchdog")
169
+ if data.get("vnc_validation") == "require":
170
+ command.append("--vnc")
171
+ ethernet_subnet = data.get("ethernet_subnet")
172
+ if ethernet_subnet:
173
+ command.extend(["--subnet", ethernet_subnet])
174
+ return command
175
+
176
+
18
177
  def _environment_view(request):
19
178
  env_vars = sorted(os.environ.items())
20
179
  context = admin.site.each_context(request)
180
+ environment_tasks: list[dict[str, str]] = []
181
+ if request.user.is_superuser:
182
+ environment_tasks.append(
183
+ {
184
+ "name": _("Run network-setup"),
185
+ "description": _(
186
+ "Configure network services, stage managed NGINX sites, and review script output."
187
+ ),
188
+ "url": reverse("admin:environment-network-setup"),
189
+ }
190
+ )
21
191
  context.update(
22
192
  {
23
- "title": _("Environ"),
193
+ "title": _("Environment"),
24
194
  "env_vars": env_vars,
195
+ "environment_tasks": environment_tasks,
25
196
  }
26
197
  )
27
198
  return TemplateResponse(request, "admin/environment.html", context)
28
199
 
29
200
 
201
+ def _environment_network_setup_view(request):
202
+ if not request.user.is_superuser:
203
+ raise PermissionDenied
204
+
205
+ script_path = Path(settings.BASE_DIR) / "network-setup.sh"
206
+ command_result: dict[str, object] | None = None
207
+
208
+ if request.method == "POST":
209
+ form = NetworkSetupForm(request.POST)
210
+ if form.is_valid():
211
+ command = form.build_command(script_path)
212
+ if not script_path.exists():
213
+ form.add_error(None, _("The network-setup.sh script could not be found."))
214
+ else:
215
+ try:
216
+ completed = subprocess.run(
217
+ command,
218
+ capture_output=True,
219
+ text=True,
220
+ cwd=settings.BASE_DIR,
221
+ check=False,
222
+ )
223
+ except FileNotFoundError:
224
+ form.add_error(None, _("The network-setup.sh script could not be executed."))
225
+ except OSError as exc:
226
+ form.add_error(
227
+ None,
228
+ _("Unable to execute network-setup.sh: %(error)s")
229
+ % {"error": str(exc)},
230
+ )
231
+ else:
232
+ if hasattr(shlex, "join"):
233
+ command_display = shlex.join(command)
234
+ else:
235
+ command_display = " ".join(shlex.quote(part) for part in command)
236
+ command_result = {
237
+ "command": command_display,
238
+ "stdout": completed.stdout,
239
+ "stderr": completed.stderr,
240
+ "returncode": completed.returncode,
241
+ "succeeded": completed.returncode == 0,
242
+ }
243
+ else:
244
+ form = NetworkSetupForm()
245
+
246
+ context = admin.site.each_context(request)
247
+ context.update(
248
+ {
249
+ "title": _("Run network-setup"),
250
+ "form": form,
251
+ "command_result": command_result,
252
+ "task_description": _(
253
+ "Configure script flags, execute network-setup, and review the captured output."
254
+ ),
255
+ "back_url": reverse("admin:environment"),
256
+ }
257
+ )
258
+ return TemplateResponse(request, "admin/environment_network_setup.html", context)
259
+
260
+
30
261
  def _config_view(request):
31
262
  context = admin.site.each_context(request)
32
263
  context.update(
33
264
  {
34
- "title": _("Config"),
265
+ "title": _("Django Settings"),
35
266
  "django_settings": _get_django_settings(),
36
267
  }
37
268
  )
@@ -39,12 +270,17 @@ def _config_view(request):
39
270
 
40
271
 
41
272
  def patch_admin_environment_view() -> None:
42
- """Add custom admin view for environment information."""
273
+ """Register the Environment and Config admin views on the main admin site."""
43
274
  original_get_urls = admin.site.get_urls
44
275
 
45
276
  def get_urls():
46
277
  urls = original_get_urls()
47
278
  custom = [
279
+ path(
280
+ "environment/network-setup/",
281
+ admin.site.admin_view(_environment_network_setup_view),
282
+ name="environment-network-setup",
283
+ ),
48
284
  path(
49
285
  "environment/",
50
286
  admin.site.admin_view(_environment_view),
core/models.py CHANGED
@@ -5,7 +5,7 @@ from django.contrib.auth.models import (
5
5
  )
6
6
  from django.db import DatabaseError, IntegrityError, connections, models, transaction
7
7
  from django.db.models import Q
8
- from django.db.models.functions import Lower
8
+ from django.db.models.functions import Lower, Length
9
9
  from django.conf import settings
10
10
  from django.contrib.auth import get_user_model
11
11
  from django.utils.translation import gettext_lazy as _
@@ -17,12 +17,12 @@ from django.dispatch import receiver
17
17
  from django.views.decorators.debug import sensitive_variables
18
18
  from datetime import time as datetime_time, timedelta
19
19
  import logging
20
+ import json
20
21
  from django.contrib.contenttypes.models import ContentType
21
22
  import hashlib
22
23
  import hmac
23
24
  import os
24
25
  import subprocess
25
- import secrets
26
26
  import re
27
27
  from io import BytesIO
28
28
  from django.core.files.base import ContentFile
@@ -518,18 +518,10 @@ class User(Entity, AbstractUser):
518
518
  def odoo_profile(self):
519
519
  return self._direct_profile("OdooProfile")
520
520
 
521
- @property
522
- def assistant_profile(self):
523
- return self._direct_profile("AssistantProfile")
524
-
525
521
  @property
526
522
  def social_profile(self):
527
523
  return self._direct_profile("SocialProfile")
528
524
 
529
- @property
530
- def chat_profile(self):
531
- return self.assistant_profile
532
-
533
525
 
534
526
  class UserPhoneNumber(Entity):
535
527
  """Store phone numbers associated with a user."""
@@ -1764,6 +1756,7 @@ class RFID(Entity):
1764
1756
  """RFID tag that may be assigned to one account."""
1765
1757
 
1766
1758
  label_id = models.AutoField(primary_key=True, db_column="label_id")
1759
+ MATCH_PREFIX_LENGTH = 8
1767
1760
  rfid = models.CharField(
1768
1761
  max_length=255,
1769
1762
  unique=True,
@@ -1939,6 +1932,108 @@ class RFID(Entity):
1939
1932
  def __str__(self): # pragma: no cover - simple representation
1940
1933
  return str(self.label_id)
1941
1934
 
1935
+ @classmethod
1936
+ def normalize_code(cls, value: str) -> str:
1937
+ """Return ``value`` normalized for comparisons."""
1938
+
1939
+ return "".join((value or "").split()).upper()
1940
+
1941
+ def adopt_rfid(self, candidate: str) -> bool:
1942
+ """Adopt ``candidate`` as the stored RFID if it is a better match."""
1943
+
1944
+ normalized = type(self).normalize_code(candidate)
1945
+ if not normalized:
1946
+ return False
1947
+ current = type(self).normalize_code(self.rfid)
1948
+ if current == normalized:
1949
+ return False
1950
+ if not current:
1951
+ self.rfid = normalized
1952
+ return True
1953
+ reversed_current = type(self).reverse_uid(current)
1954
+ if reversed_current and reversed_current == normalized:
1955
+ self.rfid = normalized
1956
+ return True
1957
+ if len(normalized) < len(current):
1958
+ self.rfid = normalized
1959
+ return True
1960
+ if len(normalized) == len(current) and normalized < current:
1961
+ self.rfid = normalized
1962
+ return True
1963
+ return False
1964
+
1965
+ @classmethod
1966
+ def matching_queryset(cls, value: str) -> models.QuerySet["RFID"]:
1967
+ """Return RFID records matching ``value`` using prefix comparison."""
1968
+
1969
+ normalized = cls.normalize_code(value)
1970
+ if not normalized:
1971
+ return cls.objects.none()
1972
+
1973
+ conditions: list[Q] = []
1974
+ candidate = normalized
1975
+ if candidate:
1976
+ conditions.append(Q(rfid=candidate))
1977
+ alternate = cls.reverse_uid(candidate)
1978
+ if alternate and alternate != candidate:
1979
+ conditions.append(Q(rfid=alternate))
1980
+
1981
+ prefix_length = min(len(candidate), cls.MATCH_PREFIX_LENGTH)
1982
+ if prefix_length:
1983
+ prefix = candidate[:prefix_length]
1984
+ conditions.append(Q(rfid__startswith=prefix))
1985
+ if alternate and alternate != candidate:
1986
+ alt_prefix = alternate[:prefix_length]
1987
+ if alt_prefix:
1988
+ conditions.append(Q(rfid__startswith=alt_prefix))
1989
+
1990
+ query: Q | None = None
1991
+ for condition in conditions:
1992
+ query = condition if query is None else query | condition
1993
+
1994
+ if query is None:
1995
+ return cls.objects.none()
1996
+
1997
+ queryset = cls.objects.filter(query).distinct()
1998
+ return queryset.annotate(rfid_length=Length("rfid")).order_by(
1999
+ "rfid_length", "rfid", "pk"
2000
+ )
2001
+
2002
+ @classmethod
2003
+ def find_match(cls, value: str) -> "RFID | None":
2004
+ """Return the best matching RFID for ``value`` if it exists."""
2005
+
2006
+ return cls.matching_queryset(value).first()
2007
+
2008
+ @classmethod
2009
+ def update_or_create_from_code(
2010
+ cls, value: str, defaults: dict[str, Any] | None = None
2011
+ ) -> tuple["RFID", bool]:
2012
+ """Update or create an RFID using relaxed matching rules."""
2013
+
2014
+ normalized = cls.normalize_code(value)
2015
+ if not normalized:
2016
+ raise ValueError("RFID value is required")
2017
+
2018
+ defaults_map = defaults.copy() if defaults else {}
2019
+ existing = cls.find_match(normalized)
2020
+ if existing:
2021
+ update_fields: set[str] = set()
2022
+ if existing.adopt_rfid(normalized):
2023
+ update_fields.add("rfid")
2024
+ for field_name, new_value in defaults_map.items():
2025
+ if getattr(existing, field_name) != new_value:
2026
+ setattr(existing, field_name, new_value)
2027
+ update_fields.add(field_name)
2028
+ if update_fields:
2029
+ existing.save(update_fields=sorted(update_fields))
2030
+ return existing, False
2031
+
2032
+ create_kwargs = defaults_map
2033
+ create_kwargs["rfid"] = normalized
2034
+ tag = cls.objects.create(**create_kwargs)
2035
+ return tag, True
2036
+
1942
2037
  @classmethod
1943
2038
  def normalize_endianness(cls, value: object) -> str:
1944
2039
  """Return a valid endianness value, defaulting to BIG."""
@@ -2033,25 +2128,12 @@ class RFID(Entity):
2033
2128
  ) -> tuple["RFID", bool]:
2034
2129
  """Return or create an RFID that was detected via scanning."""
2035
2130
 
2036
- normalized = "".join((rfid or "").split()).upper()
2131
+ normalized = cls.normalize_code(rfid)
2037
2132
  desired_endianness = cls.normalize_endianness(endianness)
2038
- alternate = None
2039
- if normalized and len(normalized) % 2 == 0:
2040
- bytes_list = [normalized[i : i + 2] for i in range(0, len(normalized), 2)]
2041
- bytes_list.reverse()
2042
- alternate_candidate = "".join(bytes_list)
2043
- if alternate_candidate != normalized:
2044
- alternate = alternate_candidate
2045
-
2046
- existing = None
2047
- if normalized:
2048
- existing = cls.objects.filter(rfid=normalized).first()
2049
- if not existing and alternate:
2050
- existing = cls.objects.filter(rfid=alternate).first()
2133
+ existing = cls.find_match(normalized)
2051
2134
  if existing:
2052
2135
  update_fields: list[str] = []
2053
- if normalized and existing.rfid != normalized:
2054
- existing.rfid = normalized
2136
+ if existing.adopt_rfid(normalized):
2055
2137
  update_fields.append("rfid")
2056
2138
  if existing.endianness != desired_endianness:
2057
2139
  existing.endianness = desired_endianness
@@ -2079,23 +2161,28 @@ class RFID(Entity):
2079
2161
  tag = cls.objects.create(**create_kwargs)
2080
2162
  cls._reset_label_sequence()
2081
2163
  except IntegrityError:
2082
- existing = cls.objects.filter(rfid=normalized).first()
2164
+ existing = cls.find_match(normalized)
2083
2165
  if existing:
2084
2166
  return existing, False
2085
2167
  else:
2086
2168
  return tag, True
2087
2169
  raise IntegrityError("Unable to allocate label id for scanned RFID")
2088
2170
 
2089
- @staticmethod
2090
- def get_account_by_rfid(value):
2171
+ @classmethod
2172
+ def get_account_by_rfid(cls, value):
2091
2173
  """Return the energy account associated with an RFID code if it exists."""
2092
2174
  try:
2093
2175
  EnergyAccount = apps.get_model("core", "EnergyAccount")
2094
2176
  except LookupError: # pragma: no cover - energy accounts app optional
2095
2177
  return None
2096
- return EnergyAccount.objects.filter(
2097
- rfids__rfid=value.upper(), rfids__allowed=True
2098
- ).first()
2178
+ matches = cls.matching_queryset(value).filter(allowed=True)
2179
+ if not matches.exists():
2180
+ return None
2181
+ return (
2182
+ EnergyAccount.objects.filter(rfids__in=matches)
2183
+ .distinct()
2184
+ .first()
2185
+ )
2099
2186
 
2100
2187
  class Meta:
2101
2188
  verbose_name = "RFID"
@@ -2795,7 +2882,10 @@ class ClientReport(Entity):
2795
2882
  def build_rows(start_date=None, end_date=None, *, for_display: bool = False):
2796
2883
  from ocpp.models import Transaction
2797
2884
 
2798
- qs = Transaction.objects.exclude(rfid="")
2885
+ qs = Transaction.objects.filter(
2886
+ (Q(rfid__isnull=False) & ~Q(rfid=""))
2887
+ | (Q(vid__isnull=False) & ~Q(vid=""))
2888
+ )
2799
2889
  if start_date:
2800
2890
  from datetime import datetime, time, timedelta, timezone as pytimezone
2801
2891
 
@@ -2841,7 +2931,7 @@ class ClientReport(Entity):
2841
2931
  subject = str(tag.label_id)
2842
2932
 
2843
2933
  if subject is None:
2844
- subject = tx.rfid
2934
+ subject = tx.rfid or tx.vid
2845
2935
 
2846
2936
  start_value = tx.start_time
2847
2937
  end_value = tx.stop_time
@@ -2853,6 +2943,7 @@ class ClientReport(Entity):
2853
2943
  {
2854
2944
  "subject": subject,
2855
2945
  "rfid": tx.rfid,
2946
+ "vid": tx.vid,
2856
2947
  "kw": energy,
2857
2948
  "start": start_value,
2858
2949
  "end": end_value,
@@ -3324,7 +3415,13 @@ class PackageRelease(Entity):
3324
3415
  for release in cls.objects.all():
3325
3416
  name = f"releases__packagerelease_{release.version.replace('.', '_')}.json"
3326
3417
  path = base / name
3327
- data = serializers.serialize("json", [release])
3418
+ data = serializers.serialize(
3419
+ "json",
3420
+ [release],
3421
+ use_natural_foreign_keys=True,
3422
+ use_natural_primary_keys=True,
3423
+ )
3424
+ data = json.dumps(json.loads(data), indent=2) + "\n"
3328
3425
  expected.add(name)
3329
3426
  try:
3330
3427
  current = path.read_text(encoding="utf-8")
@@ -3604,73 +3701,6 @@ def _rfid_unique_energy_account(
3604
3701
  "RFID tags may only be assigned to one energy account."
3605
3702
  )
3606
3703
 
3607
-
3608
- def hash_key(key: str) -> str:
3609
- """Return a SHA-256 hash for ``key``."""
3610
-
3611
- return hashlib.sha256(key.encode()).hexdigest()
3612
-
3613
-
3614
- class AssistantProfile(Profile):
3615
- """Stores a hashed user key used by the assistant for authentication.
3616
-
3617
- The plain-text ``user_key`` is generated server-side and shown only once.
3618
- Users must supply this key in the ``Authorization: Bearer <user_key>``
3619
- header when requesting protected endpoints. Only the hash is stored.
3620
- """
3621
-
3622
- id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
3623
- profile_fields = ("assistant_name", "user_key_hash", "scopes", "is_active")
3624
- assistant_name = models.CharField(max_length=100, default="Assistant")
3625
- user_key_hash = models.CharField(max_length=64, unique=True)
3626
- scopes = models.JSONField(default=list, blank=True)
3627
- created_at = models.DateTimeField(auto_now_add=True)
3628
- last_used_at = models.DateTimeField(null=True, blank=True)
3629
- is_active = models.BooleanField(default=True)
3630
-
3631
- class Meta:
3632
- db_table = "workgroup_assistantprofile"
3633
- verbose_name = "Assistant Profile"
3634
- verbose_name_plural = "Assistant Profiles"
3635
- constraints = [
3636
- models.CheckConstraint(
3637
- check=(
3638
- (Q(user__isnull=False) & Q(group__isnull=True))
3639
- | (Q(user__isnull=True) & Q(group__isnull=False))
3640
- ),
3641
- name="assistantprofile_requires_owner",
3642
- )
3643
- ]
3644
-
3645
- @classmethod
3646
- def issue_key(cls, user) -> tuple["AssistantProfile", str]:
3647
- """Create or update a profile and return it with a new plain key."""
3648
-
3649
- key = secrets.token_hex(32)
3650
- key_hash = hash_key(key)
3651
- if user is None:
3652
- raise ValueError("Assistant profiles require a user instance")
3653
-
3654
- profile, _ = cls.objects.update_or_create(
3655
- user=user,
3656
- defaults={
3657
- "user_key_hash": key_hash,
3658
- "last_used_at": None,
3659
- "is_active": True,
3660
- },
3661
- )
3662
- return profile, key
3663
-
3664
- def touch(self) -> None:
3665
- """Record that the key was used."""
3666
-
3667
- self.last_used_at = timezone.now()
3668
- self.save(update_fields=["last_used_at"])
3669
-
3670
- def __str__(self) -> str: # pragma: no cover - simple representation
3671
- return self.assistant_name or "AssistantProfile"
3672
-
3673
-
3674
3704
  def validate_relative_url(value: str) -> None:
3675
3705
  if not value:
3676
3706
  return
core/notifications.py CHANGED
@@ -39,7 +39,7 @@ class NotificationManager:
39
39
  self.lock_file.parent.mkdir(parents=True, exist_ok=True)
40
40
  # ``plyer`` is only available on Windows and can fail when used in
41
41
  # a non-interactive environment (e.g. service or CI).
42
- # Any failure will fallback to logging quietly.
42
+ # Any failure will fall back to logging quietly.
43
43
 
44
44
  def _write_lock_file(self, subject: str, body: str) -> None:
45
45
  self.lock_file.write_text(f"{subject}\n{body}\n", encoding="utf-8")
core/reference_utils.py CHANGED
@@ -70,17 +70,16 @@ def filter_visible_references(
70
70
  required_sites = {current_site.pk for current_site in ref.sites.all()}
71
71
 
72
72
  if required_roles or required_features or required_sites:
73
- allowed = False
74
- if required_roles and node_role_id and node_role_id in required_roles:
75
- allowed = True
76
- elif (
77
- required_features
78
- and node_active_feature_ids
79
- and node_active_feature_ids.intersection(required_features)
80
- ):
81
- allowed = True
82
- elif required_sites and site_id and site_id in required_sites:
83
- allowed = True
73
+ allowed = True
74
+ if required_roles:
75
+ allowed = bool(node_role_id and node_role_id in required_roles)
76
+ if allowed and required_features:
77
+ allowed = bool(
78
+ node_active_feature_ids
79
+ and node_active_feature_ids.intersection(required_features)
80
+ )
81
+ if allowed and required_sites:
82
+ allowed = bool(site_id and site_id in required_sites)
84
83
 
85
84
  if not allowed:
86
85
  continue
core/sigil_builder.py CHANGED
@@ -40,12 +40,12 @@ def _sigil_builder_view(request):
40
40
  {
41
41
  "prefix": "ENV",
42
42
  "url": reverse("admin:environment"),
43
- "label": _("Environ"),
43
+ "label": _("Environment"),
44
44
  },
45
45
  {
46
46
  "prefix": "CONF",
47
47
  "url": reverse("admin:config"),
48
- "label": _("Config"),
48
+ "label": _("Django Settings"),
49
49
  },
50
50
  {
51
51
  "prefix": "SYS",