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

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

Potentially problematic release.


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

Files changed (82) hide show
  1. arthexis-0.1.9.dist-info/METADATA +168 -0
  2. arthexis-0.1.9.dist-info/RECORD +92 -0
  3. arthexis-0.1.9.dist-info/licenses/LICENSE +674 -0
  4. config/__init__.py +0 -1
  5. config/auth_app.py +0 -1
  6. config/celery.py +1 -2
  7. config/context_processors.py +1 -1
  8. config/offline.py +2 -0
  9. config/settings.py +134 -16
  10. config/urls.py +71 -3
  11. core/admin.py +1331 -165
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +151 -0
  14. core/apps.py +158 -3
  15. core/backends.py +46 -4
  16. core/entity.py +62 -48
  17. core/fields.py +6 -1
  18. core/github_helper.py +25 -0
  19. core/github_issues.py +172 -0
  20. core/lcd_screen.py +1 -0
  21. core/liveupdate.py +25 -0
  22. core/log_paths.py +100 -0
  23. core/mailer.py +83 -0
  24. core/middleware.py +57 -0
  25. core/models.py +1136 -259
  26. core/notifications.py +11 -1
  27. core/public_wifi.py +227 -0
  28. core/release.py +27 -20
  29. core/sigil_builder.py +131 -0
  30. core/sigil_context.py +20 -0
  31. core/sigil_resolver.py +284 -0
  32. core/system.py +129 -10
  33. core/tasks.py +118 -19
  34. core/test_system_info.py +22 -0
  35. core/tests.py +445 -58
  36. core/tests_liveupdate.py +17 -0
  37. core/urls.py +2 -2
  38. core/user_data.py +329 -167
  39. core/views.py +383 -57
  40. core/widgets.py +51 -0
  41. core/workgroup_urls.py +17 -0
  42. core/workgroup_views.py +94 -0
  43. nodes/actions.py +0 -2
  44. nodes/admin.py +159 -284
  45. nodes/apps.py +9 -15
  46. nodes/backends.py +53 -0
  47. nodes/lcd.py +24 -10
  48. nodes/models.py +375 -178
  49. nodes/tasks.py +1 -5
  50. nodes/tests.py +524 -129
  51. nodes/utils.py +13 -2
  52. nodes/views.py +66 -23
  53. ocpp/admin.py +150 -61
  54. ocpp/apps.py +4 -3
  55. ocpp/consumers.py +432 -69
  56. ocpp/evcs.py +25 -8
  57. ocpp/models.py +408 -68
  58. ocpp/simulator.py +13 -6
  59. ocpp/store.py +258 -30
  60. ocpp/tasks.py +11 -7
  61. ocpp/test_export_import.py +8 -7
  62. ocpp/test_rfid.py +211 -16
  63. ocpp/tests.py +1198 -135
  64. ocpp/transactions_io.py +68 -22
  65. ocpp/urls.py +35 -2
  66. ocpp/views.py +654 -101
  67. pages/admin.py +173 -13
  68. pages/checks.py +0 -1
  69. pages/context_processors.py +19 -6
  70. pages/middleware.py +153 -0
  71. pages/models.py +37 -9
  72. pages/tests.py +759 -40
  73. pages/urls.py +3 -0
  74. pages/utils.py +0 -1
  75. pages/views.py +576 -25
  76. arthexis-0.1.7.dist-info/METADATA +0 -126
  77. arthexis-0.1.7.dist-info/RECORD +0 -77
  78. arthexis-0.1.7.dist-info/licenses/LICENSE +0 -21
  79. config/workgroup_app.py +0 -7
  80. core/checks.py +0 -29
  81. {arthexis-0.1.7.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
  82. {arthexis-0.1.7.dist-info → arthexis-0.1.9.dist-info}/top_level.txt +0 -0
core/admin_history.py ADDED
@@ -0,0 +1,50 @@
1
+ import json
2
+ from django.contrib.admin.options import ModelAdmin
3
+ from django.contrib.admin.models import LogEntry
4
+ from django.utils.encoding import smart_str
5
+
6
+
7
+ def patch_admin_history():
8
+ if getattr(ModelAdmin, "_history_patched", False):
9
+ return
10
+
11
+ def construct_change_message(self, request, form, formsets, add=False):
12
+ fields = []
13
+ if add:
14
+ for name, value in form.cleaned_data.items():
15
+ fields.append({"field": name, "old": None, "new": smart_str(value)})
16
+ if not fields:
17
+ return ""
18
+ return json.dumps({"added": fields})
19
+ for name in form.changed_data:
20
+ fields.append(
21
+ {
22
+ "field": name,
23
+ "old": smart_str(form.initial.get(name)),
24
+ "new": smart_str(form.cleaned_data.get(name)),
25
+ }
26
+ )
27
+ if not fields:
28
+ return ""
29
+ return json.dumps({"changed": fields})
30
+
31
+ def get_change_message(self):
32
+ try:
33
+ data = json.loads(self.change_message)
34
+ except Exception:
35
+ return self.change_message
36
+ if isinstance(data, dict):
37
+ if "added" in data:
38
+ parts = [f"{d['field']}='{d['new']}'" for d in data["added"]]
39
+ return "Added " + ", ".join(parts)
40
+ if "changed" in data:
41
+ parts = [
42
+ f"{d['field']}: '{d['old']}' -> '{d['new']}'"
43
+ for d in data["changed"]
44
+ ]
45
+ return "Changed " + ", ".join(parts)
46
+ return self.change_message
47
+
48
+ ModelAdmin.construct_change_message = construct_change_message
49
+ LogEntry.get_change_message = get_change_message
50
+ ModelAdmin._history_patched = True
core/admindocs.py ADDED
@@ -0,0 +1,151 @@
1
+ import argparse
2
+ import inspect
3
+ from types import SimpleNamespace
4
+
5
+ from django.apps import apps
6
+ from django.contrib import admin
7
+ from django.core.management import get_commands, load_command_class
8
+ from django.contrib.admindocs.views import (
9
+ BaseAdminDocsView,
10
+ user_has_model_view_permission,
11
+ )
12
+ from django.urls import NoReverseMatch, reverse
13
+
14
+
15
+ class CommandsView(BaseAdminDocsView):
16
+ template_name = "admin_doc/commands.html"
17
+
18
+ def get_context_data(self, **kwargs):
19
+ commands = []
20
+ for name, app_name in sorted(get_commands().items()):
21
+ try:
22
+ cmd = load_command_class(app_name, name)
23
+ parser = cmd.create_parser("manage.py", name)
24
+ except Exception: # pragma: no cover - command import issues
25
+ continue
26
+ args = []
27
+ options = []
28
+ for action in parser._actions:
29
+ if isinstance(action, argparse._HelpAction):
30
+ continue
31
+ if action.option_strings:
32
+ options.append(
33
+ {
34
+ "opts": ", ".join(action.option_strings),
35
+ "help": action.help or "",
36
+ }
37
+ )
38
+ else:
39
+ args.append(
40
+ {
41
+ "name": action.metavar or action.dest,
42
+ "help": action.help or "",
43
+ }
44
+ )
45
+ commands.append(
46
+ {
47
+ "name": name,
48
+ "help": getattr(cmd, "help", ""),
49
+ "args": args,
50
+ "options": options,
51
+ }
52
+ )
53
+ return super().get_context_data(**{**kwargs, "commands": commands})
54
+
55
+
56
+ class OrderedModelIndexView(BaseAdminDocsView):
57
+ template_name = "admin_doc/model_index.html"
58
+
59
+ GROUP_OVERRIDES = {
60
+ "ocpp.location": "core",
61
+ "core.rfid": "ocpp",
62
+ "core.package": "teams",
63
+ "core.packagerelease": "teams",
64
+ }
65
+
66
+ def _get_docs_app_config(self, meta):
67
+ override_label = self.GROUP_OVERRIDES.get(meta.label_lower)
68
+ if override_label:
69
+ return apps.get_app_config(override_label)
70
+ return meta.app_config
71
+
72
+ def get_context_data(self, **kwargs):
73
+ models = []
74
+ for m in apps.get_models():
75
+ if user_has_model_view_permission(self.request.user, m._meta):
76
+ meta = m._meta
77
+ meta.docstring = inspect.getdoc(m) or ""
78
+ app_config = self._get_docs_app_config(meta)
79
+ models.append(
80
+ SimpleNamespace(
81
+ app_label=meta.app_label,
82
+ model_name=meta.model_name,
83
+ object_name=meta.object_name,
84
+ docstring=meta.docstring,
85
+ app_config=app_config,
86
+ )
87
+ )
88
+ models.sort(key=lambda m: str(m.app_config.verbose_name))
89
+ return super().get_context_data(**{**kwargs, "models": models})
90
+
91
+
92
+ class ModelGraphIndexView(BaseAdminDocsView):
93
+ template_name = "admin_doc/model_graphs.html"
94
+
95
+ def get_context_data(self, **kwargs):
96
+ sections = {}
97
+ user = self.request.user
98
+
99
+ for model in admin.site._registry:
100
+ meta = model._meta
101
+ if not user_has_model_view_permission(user, meta):
102
+ continue
103
+
104
+ app_config = apps.get_app_config(meta.app_label)
105
+ section = sections.setdefault(
106
+ app_config.label,
107
+ {
108
+ "app_label": app_config.label,
109
+ "verbose_name": str(app_config.verbose_name),
110
+ "models": [],
111
+ },
112
+ )
113
+
114
+ section["models"].append(
115
+ {
116
+ "object_name": meta.object_name,
117
+ "verbose_name": str(meta.verbose_name),
118
+ "doc_url": reverse(
119
+ "django-admindocs-models-detail",
120
+ kwargs={
121
+ "app_label": meta.app_label,
122
+ "model_name": meta.model_name,
123
+ },
124
+ ),
125
+ }
126
+ )
127
+
128
+ graph_sections = []
129
+ for section in sections.values():
130
+ section_models = section["models"]
131
+ section_models.sort(key=lambda model: model["verbose_name"])
132
+
133
+ try:
134
+ app_list_url = reverse("admin:app_list", args=[section["app_label"]])
135
+ except NoReverseMatch:
136
+ app_list_url = ""
137
+
138
+ graph_sections.append(
139
+ {
140
+ **section,
141
+ "graph_url": reverse(
142
+ "admin-model-graph", args=[section["app_label"]]
143
+ ),
144
+ "app_list_url": app_list_url,
145
+ "model_count": len(section_models),
146
+ }
147
+ )
148
+
149
+ graph_sections.sort(key=lambda section: section["verbose_name"])
150
+
151
+ return super().get_context_data(**{**kwargs, "sections": graph_sections})
core/apps.py CHANGED
@@ -1,22 +1,43 @@
1
+ import logging
2
+
1
3
  from django.apps import AppConfig
2
4
  from django.utils.translation import gettext_lazy as _
3
5
 
4
6
 
7
+ logger = logging.getLogger(__name__)
8
+
9
+
5
10
  class CoreConfig(AppConfig):
6
11
  default_auto_field = "django.db.models.BigAutoField"
7
12
  name = "core"
8
13
  verbose_name = _("2. Business")
9
14
 
10
15
  def ready(self): # pragma: no cover - called by Django
16
+ from contextlib import suppress
17
+ from functools import wraps
18
+ import hashlib
19
+ import time
20
+ import traceback
21
+ from pathlib import Path
22
+
23
+ from django.conf import settings
11
24
  from django.contrib.auth import get_user_model
12
25
  from django.db.models.signals import post_migrate
26
+ from django.core.signals import got_request_exception
27
+
28
+ from core.github_helper import report_exception_to_github
29
+ from .entity import Entity
13
30
  from .user_data import (
14
31
  patch_admin_user_datum,
15
32
  patch_admin_user_data_views,
16
33
  )
17
34
  from .system import patch_admin_system_view
18
35
  from .environment import patch_admin_environment_view
19
- from . import checks # noqa: F401
36
+ from .sigil_builder import (
37
+ patch_admin_sigil_builder_view,
38
+ generate_model_sigils,
39
+ )
40
+ from .admin_history import patch_admin_history
20
41
 
21
42
  def create_default_arthexis(**kwargs):
22
43
  User = get_user_model()
@@ -29,13 +50,57 @@ class CoreConfig(AppConfig):
29
50
  )
30
51
 
31
52
  post_migrate.connect(create_default_arthexis, sender=self)
53
+ post_migrate.connect(generate_model_sigils, sender=self)
32
54
  patch_admin_user_datum()
33
55
  patch_admin_user_data_views()
34
56
  patch_admin_system_view()
35
57
  patch_admin_environment_view()
58
+ patch_admin_sigil_builder_view()
59
+ patch_admin_history()
36
60
 
37
- from pathlib import Path
38
- from django.conf import settings
61
+ from django.core.serializers import base as serializer_base
62
+
63
+ if not hasattr(
64
+ serializer_base.DeserializedObject.save, "_entity_fixture_patch"
65
+ ):
66
+ original_save = serializer_base.DeserializedObject.save
67
+
68
+ @wraps(original_save)
69
+ def patched_save(self, save_m2m=True, using=None, **kwargs):
70
+ obj = self.object
71
+ if isinstance(obj, Entity):
72
+ manager = getattr(
73
+ type(obj), "all_objects", type(obj)._default_manager
74
+ )
75
+ if using:
76
+ manager = manager.db_manager(using)
77
+ for fields in obj._unique_field_groups():
78
+ lookup = {}
79
+ for field in fields:
80
+ value = getattr(obj, field.attname)
81
+ if value is None:
82
+ lookup = {}
83
+ break
84
+ lookup[field.attname] = value
85
+ if not lookup:
86
+ continue
87
+ existing = (
88
+ manager.filter(**lookup)
89
+ .only("pk", "is_seed_data", "is_user_data")
90
+ .first()
91
+ )
92
+ if existing is not None:
93
+ obj.pk = existing.pk
94
+ obj.is_seed_data = existing.is_seed_data
95
+ obj.is_user_data = existing.is_user_data
96
+ obj._state.adding = False
97
+ if using:
98
+ obj._state.db = using
99
+ break
100
+ return original_save(self, save_m2m=save_m2m, using=using, **kwargs)
101
+
102
+ patched_save._entity_fixture_patch = True
103
+ serializer_base.DeserializedObject.save = patched_save
39
104
 
40
105
  lock = Path(settings.BASE_DIR) / "locks" / "celery.lck"
41
106
 
@@ -66,3 +131,93 @@ class CoreConfig(AppConfig):
66
131
  pass
67
132
 
68
133
  post_migrate.connect(ensure_email_collector_task, sender=self)
134
+
135
+ from django.db.backends.signals import connection_created
136
+
137
+ def enable_sqlite_wal(**kwargs):
138
+ connection = kwargs.get("connection")
139
+ if connection.vendor == "sqlite":
140
+ cursor = connection.cursor()
141
+ cursor.execute("PRAGMA journal_mode=WAL;")
142
+ cursor.execute("PRAGMA busy_timeout=60000;")
143
+ cursor.close()
144
+
145
+ connection_created.connect(enable_sqlite_wal)
146
+
147
+ def queue_github_issue(sender, request=None, **kwargs):
148
+ if not getattr(settings, "GITHUB_ISSUE_REPORTING_ENABLED", True):
149
+ return
150
+ if request is None:
151
+ return
152
+
153
+ exception = kwargs.get("exception")
154
+ if exception is None:
155
+ return
156
+
157
+ try:
158
+ tb_exc = traceback.TracebackException.from_exception(exception)
159
+ stack = tb_exc.stack
160
+ top_frame = stack[-1] if stack else None
161
+ fingerprint_parts = [
162
+ exception.__class__.__module__,
163
+ exception.__class__.__name__,
164
+ ]
165
+ if top_frame:
166
+ fingerprint_parts.extend(
167
+ [
168
+ top_frame.filename,
169
+ str(top_frame.lineno),
170
+ top_frame.name,
171
+ ]
172
+ )
173
+ fingerprint = hashlib.sha256(
174
+ "|".join(fingerprint_parts).encode("utf-8")
175
+ ).hexdigest()
176
+
177
+ cooldown = getattr(settings, "GITHUB_ISSUE_REPORTING_COOLDOWN", 3600)
178
+ lock_dir = Path(settings.BASE_DIR) / "locks" / "github-issues"
179
+ fingerprint_path = None
180
+ now = time.time()
181
+
182
+ with suppress(OSError):
183
+ lock_dir.mkdir(parents=True, exist_ok=True)
184
+ fingerprint_path = lock_dir / fingerprint
185
+ if fingerprint_path.exists():
186
+ age = now - fingerprint_path.stat().st_mtime
187
+ if age < cooldown:
188
+ return
189
+
190
+ if fingerprint_path is not None:
191
+ with suppress(OSError):
192
+ fingerprint_path.write_text(str(now))
193
+
194
+ user_repr = None
195
+ user = getattr(request, "user", None)
196
+ if user is not None:
197
+ try:
198
+ if getattr(user, "is_authenticated", False):
199
+ user_repr = user.get_username()
200
+ else:
201
+ user_repr = "anonymous"
202
+ except Exception: # pragma: no cover - defensive
203
+ user_repr = str(user)
204
+
205
+ payload = {
206
+ "path": getattr(request, "path", None),
207
+ "method": getattr(request, "method", None),
208
+ "user": user_repr,
209
+ "active_app": getattr(request, "active_app", None),
210
+ "fingerprint": fingerprint,
211
+ "exception_class": f"{exception.__class__.__module__}.{exception.__class__.__name__}",
212
+ "traceback": "".join(tb_exc.format()),
213
+ }
214
+
215
+ report_exception_to_github.delay(payload)
216
+ except Exception: # pragma: no cover - defensive
217
+ logger.exception("Failed to queue GitHub issue from request exception")
218
+
219
+ got_request_exception.connect(
220
+ queue_github_issue,
221
+ dispatch_uid="core.github_issue_reporter",
222
+ weak=False,
223
+ )
core/backends.py CHANGED
@@ -1,8 +1,11 @@
1
1
  """Custom authentication backends for the core app."""
2
2
 
3
+ import contextlib
4
+ import ipaddress
5
+ import socket
6
+
3
7
  from django.contrib.auth import get_user_model
4
8
  from django.contrib.auth.backends import ModelBackend
5
- import ipaddress
6
9
 
7
10
  from .models import EnergyAccount
8
11
 
@@ -32,6 +35,37 @@ class RFIDBackend:
32
35
  return None
33
36
 
34
37
 
38
+ def _collect_local_ip_addresses():
39
+ """Return IP addresses assigned to the current machine."""
40
+
41
+ hosts = {socket.gethostname().strip()}
42
+ with contextlib.suppress(Exception):
43
+ hosts.add(socket.getfqdn().strip())
44
+
45
+ addresses = set()
46
+ for host in filter(None, hosts):
47
+ with contextlib.suppress(OSError):
48
+ _, _, ip_list = socket.gethostbyname_ex(host)
49
+ for candidate in ip_list:
50
+ with contextlib.suppress(ValueError):
51
+ addresses.add(ipaddress.ip_address(candidate))
52
+ with contextlib.suppress(OSError):
53
+ for info in socket.getaddrinfo(host, None, family=socket.AF_UNSPEC):
54
+ sockaddr = info[-1]
55
+ if not sockaddr:
56
+ continue
57
+ raw_address = sockaddr[0]
58
+ if isinstance(raw_address, bytes):
59
+ with contextlib.suppress(UnicodeDecodeError):
60
+ raw_address = raw_address.decode()
61
+ if isinstance(raw_address, str):
62
+ if "%" in raw_address:
63
+ raw_address = raw_address.split("%", 1)[0]
64
+ with contextlib.suppress(ValueError):
65
+ addresses.add(ipaddress.ip_address(raw_address))
66
+ return tuple(sorted(addresses, key=str))
67
+
68
+
35
69
  class LocalhostAdminBackend(ModelBackend):
36
70
  """Allow default admin credentials only from local networks."""
37
71
 
@@ -39,9 +73,8 @@ class LocalhostAdminBackend(ModelBackend):
39
73
  ipaddress.ip_network("::1/128"),
40
74
  ipaddress.ip_network("127.0.0.0/8"),
41
75
  ipaddress.ip_network("192.168.0.0/16"),
42
- ipaddress.ip_network("172.16.0.0/12"),
43
- ipaddress.ip_network("10.42.0.0/16"),
44
76
  ]
77
+ _LOCAL_IPS = _collect_local_ip_addresses()
45
78
 
46
79
  def authenticate(self, request, username=None, password=None, **kwargs):
47
80
  if username == "admin" and password == "admin" and request is not None:
@@ -55,6 +88,8 @@ class LocalhostAdminBackend(ModelBackend):
55
88
  except ValueError:
56
89
  return None
57
90
  allowed = any(ip in net for net in self._ALLOWED_NETWORKS)
91
+ if not allowed and ip in self._LOCAL_IPS:
92
+ allowed = True
58
93
  if not allowed:
59
94
  return None
60
95
  User = get_user_model()
@@ -65,11 +100,19 @@ class LocalhostAdminBackend(ModelBackend):
65
100
  "is_superuser": True,
66
101
  },
67
102
  )
103
+ arthexis_user = (
104
+ User.all_objects.filter(username="arthexis").exclude(pk=user.pk).first()
105
+ )
68
106
  if created:
107
+ if arthexis_user and user.operate_as_id is None:
108
+ user.operate_as = arthexis_user
69
109
  user.set_password("admin")
70
110
  user.save()
71
111
  elif not user.check_password("admin"):
72
112
  return None
113
+ elif arthexis_user and user.operate_as_id is None:
114
+ user.operate_as = arthexis_user
115
+ user.save(update_fields=["operate_as"])
73
116
  return user
74
117
  return super().authenticate(request, username, password, **kwargs)
75
118
 
@@ -79,4 +122,3 @@ class LocalhostAdminBackend(ModelBackend):
79
122
  return User.all_objects.get(pk=user_id)
80
123
  except User.DoesNotExist:
81
124
  return None
82
-
core/entity.py CHANGED
@@ -1,12 +1,9 @@
1
1
  import copy
2
2
  import logging
3
- import os
4
- import re
5
3
 
6
- from django.apps import apps
7
- from django.conf import settings
8
- from django.db import models
9
4
  from django.contrib.auth.models import UserManager as DjangoUserManager
5
+ from django.core.exceptions import FieldDoesNotExist
6
+ from django.db import models
10
7
 
11
8
  logger = logging.getLogger(__name__)
12
9
 
@@ -27,17 +24,14 @@ class EntityManager(models.Manager):
27
24
 
28
25
  class EntityUserManager(DjangoUserManager):
29
26
  def get_queryset(self):
30
- return (
31
- EntityQuerySet(self.model, using=self._db)
32
- .filter(is_deleted=False)
33
- .exclude(username="admin")
34
- )
27
+ return EntityQuerySet(self.model, using=self._db).filter(is_deleted=False)
35
28
 
36
29
 
37
30
  class Entity(models.Model):
38
31
  """Base model providing seed data tracking and soft deletion."""
39
32
 
40
33
  is_seed_data = models.BooleanField(default=False, editable=False)
34
+ is_user_data = models.BooleanField(default=False, editable=False)
41
35
  is_deleted = models.BooleanField(default=False, editable=False)
42
36
 
43
37
  objects = EntityManager()
@@ -60,11 +54,66 @@ class Entity(models.Model):
60
54
  pass
61
55
  else:
62
56
  self.is_seed_data = old.is_seed_data
57
+ self.is_user_data = old.is_user_data
63
58
  super().save(*args, **kwargs)
64
59
 
60
+ @classmethod
61
+ def _unique_field_groups(cls):
62
+ """Return concrete field tuples enforcing uniqueness for this model."""
63
+
64
+ opts = cls._meta
65
+ groups: list[tuple[models.Field, ...]] = []
66
+
67
+ for field in opts.concrete_fields:
68
+ if field.unique and not field.primary_key:
69
+ groups.append((field,))
70
+
71
+ for unique in opts.unique_together:
72
+ fields: list[models.Field] = []
73
+ for name in unique:
74
+ try:
75
+ field = opts.get_field(name)
76
+ except FieldDoesNotExist:
77
+ fields = []
78
+ break
79
+ if not getattr(field, "concrete", False) or field.primary_key:
80
+ fields = []
81
+ break
82
+ fields.append(field)
83
+ if fields:
84
+ groups.append(tuple(fields))
85
+
86
+ for constraint in opts.constraints:
87
+ if not isinstance(constraint, models.UniqueConstraint):
88
+ continue
89
+ if not constraint.fields or constraint.condition is not None:
90
+ continue
91
+ fields = []
92
+ for name in constraint.fields:
93
+ try:
94
+ field = opts.get_field(name)
95
+ except FieldDoesNotExist:
96
+ fields = []
97
+ break
98
+ if not getattr(field, "concrete", False) or field.primary_key:
99
+ fields = []
100
+ break
101
+ fields.append(field)
102
+ if fields:
103
+ groups.append(tuple(fields))
104
+
105
+ unique_groups: list[tuple[models.Field, ...]] = []
106
+ seen: set[tuple[str, ...]] = set()
107
+ for fields in groups:
108
+ key = tuple(field.attname for field in fields)
109
+ if key in seen:
110
+ continue
111
+ seen.add(key)
112
+ unique_groups.append(fields)
113
+ return unique_groups
114
+
65
115
  def resolve_sigils(self, field: str) -> str:
66
116
  """Return ``field`` value with [ROOT.KEY] tokens resolved."""
67
- # Find field ignoring case
68
117
  name = field.lower()
69
118
  fobj = next((f for f in self._meta.fields if f.name.lower() == name), None)
70
119
  if not fobj:
@@ -72,44 +121,9 @@ class Entity(models.Model):
72
121
  value = self.__dict__.get(fobj.attname, "")
73
122
  if value is None:
74
123
  return ""
75
- text = str(value)
76
-
77
- pattern = re.compile(r"\[([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)\]")
78
- SigilRoot = apps.get_model("core", "SigilRoot")
124
+ from .sigil_resolver import resolve_sigils as _resolve
79
125
 
80
- def repl(match):
81
- root_name, key = match.group(1), match.group(2)
82
- try:
83
- root = SigilRoot.objects.get(prefix__iexact=root_name)
84
- if root.context_type == SigilRoot.Context.CONFIG:
85
- if root.prefix.upper() == "ENV":
86
- if key in os.environ:
87
- return os.environ[key]
88
- logger.warning(
89
- "Missing environment variable for sigil [%s.%s]",
90
- root_name,
91
- key,
92
- )
93
- return match.group(0)
94
- if root.prefix.upper() == "SYS":
95
- if hasattr(settings, key):
96
- return str(getattr(settings, key))
97
- logger.warning(
98
- "Missing settings attribute for sigil [%s.%s]",
99
- root_name,
100
- key,
101
- )
102
- return match.group(0)
103
- logger.warning(
104
- "Unresolvable sigil [%s.%s]: unsupported context", root_name, key
105
- )
106
- except SigilRoot.DoesNotExist:
107
- logger.warning("Unknown sigil root [%s]", root_name)
108
- except Exception:
109
- logger.exception("Error resolving sigil [%s.%s]", root_name, key)
110
- return match.group(0)
111
-
112
- return pattern.sub(repl, text)
126
+ return _resolve(str(value), current=self)
113
127
 
114
128
  def delete(self, using=None, keep_parents=False):
115
129
  if self.is_seed_data:
core/fields.py CHANGED
@@ -29,6 +29,12 @@ class _SigilBaseField:
29
29
  def value_from_object(self, obj):
30
30
  return obj.__dict__.get(self.attname)
31
31
 
32
+ def pre_save(self, model_instance, add):
33
+ # ``models.Field.pre_save`` uses ``getattr`` which would resolve the
34
+ # sigil descriptor. Persist the raw database value instead so env-based
35
+ # placeholders remain intact when editing through admin forms.
36
+ return self.value_from_object(model_instance)
37
+
32
38
 
33
39
  class SigilCheckFieldMixin(_SigilBaseField):
34
40
  descriptor_class = _CheckSigilDescriptor
@@ -67,4 +73,3 @@ class SigilShortAutoField(SigilAutoFieldMixin, models.CharField):
67
73
 
68
74
  class SigilLongAutoField(SigilAutoFieldMixin, models.TextField):
69
75
  pass
70
-
core/github_helper.py ADDED
@@ -0,0 +1,25 @@
1
+ """Helpers for reporting exceptions to GitHub."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from celery import shared_task
9
+
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ @shared_task
15
+ def report_exception_to_github(payload: dict[str, Any]) -> None:
16
+ """Send exception context to the GitHub issue helper.
17
+
18
+ The task is intentionally light-weight in this repository. Deployments can
19
+ replace it with an implementation that forwards ``payload`` to the
20
+ automation responsible for creating GitHub issues.
21
+ """
22
+
23
+ logger.info(
24
+ "Queued GitHub issue report for %s", payload.get("fingerprint", "<unknown>")
25
+ )