arthexis 0.1.13__py3-none-any.whl → 0.1.15__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.
Files changed (108) hide show
  1. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/METADATA +224 -221
  2. arthexis-0.1.15.dist-info/RECORD +110 -0
  3. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +43 -43
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -32
  9. config/context_processors.py +67 -69
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +25 -25
  14. config/offline.py +49 -49
  15. config/settings.py +691 -682
  16. config/settings_helpers.py +109 -109
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3795 -2809
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +151 -151
  22. core/apps.py +356 -272
  23. core/auto_upgrade.py +57 -57
  24. core/backends.py +265 -236
  25. core/changelog.py +342 -0
  26. core/entity.py +149 -133
  27. core/environment.py +61 -61
  28. core/fields.py +168 -168
  29. core/form_fields.py +75 -75
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +178 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +114 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3637 -2795
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +108 -108
  42. core/release.py +840 -368
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -149
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +315 -315
  47. core/system.py +952 -493
  48. core/tasks.py +408 -394
  49. core/temp_passwords.py +181 -181
  50. core/test_system_info.py +186 -139
  51. core/tests.py +2168 -1521
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +641 -633
  55. core/views.py +2201 -1417
  56. core/widgets.py +213 -94
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -1161
  60. nodes/apps.py +87 -85
  61. nodes/backends.py +160 -160
  62. nodes/dns.py +203 -203
  63. nodes/feature_checks.py +133 -133
  64. nodes/lcd.py +165 -165
  65. nodes/models.py +1764 -1597
  66. nodes/reports.py +411 -411
  67. nodes/rfid_sync.py +195 -0
  68. nodes/signals.py +18 -0
  69. nodes/tasks.py +46 -46
  70. nodes/tests.py +3830 -3116
  71. nodes/urls.py +15 -14
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -619
  74. ocpp/admin.py +948 -948
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1459
  77. ocpp/evcs.py +844 -844
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -917
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -11
  82. ocpp/simulator.py +745 -745
  83. ocpp/status_display.py +26 -26
  84. ocpp/store.py +601 -541
  85. ocpp/tasks.py +31 -31
  86. ocpp/test_export_import.py +130 -130
  87. ocpp/test_rfid.py +913 -702
  88. ocpp/tests.py +4445 -4094
  89. ocpp/transactions_io.py +189 -189
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1251
  92. pages/admin.py +769 -539
  93. pages/apps.py +10 -10
  94. pages/checks.py +40 -40
  95. pages/context_processors.py +127 -119
  96. pages/defaults.py +13 -13
  97. pages/forms.py +198 -198
  98. pages/middleware.py +209 -153
  99. pages/models.py +643 -426
  100. pages/tasks.py +74 -0
  101. pages/tests.py +3025 -2200
  102. pages/urls.py +26 -25
  103. pages/utils.py +23 -12
  104. pages/views.py +1176 -1128
  105. arthexis-0.1.13.dist-info/RECORD +0 -105
  106. nodes/actions.py +0 -70
  107. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/WHEEL +0 -0
  108. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/top_level.txt +0 -0
core/changelog.py ADDED
@@ -0,0 +1,342 @@
1
+ from __future__ import annotations
2
+
3
+ """Utilities for building and parsing the project changelog."""
4
+
5
+ from dataclasses import dataclass
6
+ import re
7
+ import subprocess
8
+ from typing import Iterable, List, Optional
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Commit:
13
+ """A simplified representation of a git commit."""
14
+
15
+ sha: str
16
+ date: str
17
+ subject: str
18
+
19
+
20
+ @dataclass
21
+ class ChangelogSection:
22
+ """A rendered changelog section."""
23
+
24
+ title: str
25
+ entries: List[str]
26
+ version: Optional[str] = None
27
+ date: Optional[str] = None
28
+
29
+
30
+ _RE_RELEASE = re.compile(
31
+ r"^(?:pre-release commit|Release)\s+v?(?P<version>[0-9A-Za-z][0-9A-Za-z.\-_]*)",
32
+ re.IGNORECASE,
33
+ )
34
+ _RE_TITLE_VERSION = re.compile(r"^v(?P<version>[0-9A-Za-z][0-9A-Za-z.\-_]*)")
35
+ _RE_TITLE_DATE = re.compile(r"\((?P<date>\d{4}-\d{2}-\d{2})\)")
36
+
37
+
38
+ def _read_commits(range_spec: str) -> List[Commit]:
39
+ """Return commits for *range_spec* ordered newest first."""
40
+
41
+ cmd = [
42
+ "git",
43
+ "log",
44
+ range_spec,
45
+ "--no-merges",
46
+ "--date=short",
47
+ "--pretty=format:%H%x00%ad%x00%s",
48
+ ]
49
+ proc = subprocess.run(cmd, capture_output=True, text=True, check=True)
50
+ commits: list[Commit] = []
51
+ for raw in proc.stdout.splitlines():
52
+ parts = raw.split("\x00")
53
+ if len(parts) != 3:
54
+ continue
55
+ sha, date, subject = parts
56
+ commits.append(Commit(sha=sha, date=date, subject=subject))
57
+ return commits
58
+
59
+
60
+ def _extract_release_version(subject: str) -> Optional[str]:
61
+ match = _RE_RELEASE.match(subject)
62
+ if match:
63
+ return match.group("version")
64
+ return None
65
+
66
+
67
+ def _should_include_subject(subject: str) -> bool:
68
+ return len(subject.split()) > 3
69
+
70
+
71
+ def _format_title(version: str, date: Optional[str]) -> str:
72
+ if date:
73
+ return f"v{version} ({date})"
74
+ return f"v{version}"
75
+
76
+
77
+ def _sections_from_commits(commits: Iterable[Commit]) -> List[ChangelogSection]:
78
+ unreleased: list[str] = []
79
+ releases: list[ChangelogSection] = []
80
+ release_map: dict[str, ChangelogSection] = {}
81
+ current_release: ChangelogSection | None = None
82
+
83
+ for commit in commits:
84
+ version = _extract_release_version(commit.subject)
85
+ if version:
86
+ section = release_map.get(version)
87
+ if section is None:
88
+ section = ChangelogSection(
89
+ title=_format_title(version, commit.date),
90
+ entries=[],
91
+ version=version,
92
+ date=commit.date,
93
+ )
94
+ releases.append(section)
95
+ release_map[version] = section
96
+ else:
97
+ if commit.date and not section.date:
98
+ section.date = commit.date
99
+ section.title = _format_title(version, commit.date)
100
+ current_release = section
101
+ continue
102
+ if not _should_include_subject(commit.subject):
103
+ continue
104
+ entry = f"- {commit.sha[:8]} {commit.subject}"
105
+ if current_release is None:
106
+ if entry not in unreleased:
107
+ unreleased.append(entry)
108
+ else:
109
+ if entry not in current_release.entries:
110
+ current_release.entries.append(entry)
111
+
112
+ sections: list[ChangelogSection] = [
113
+ ChangelogSection(title="Unreleased", entries=unreleased, version=None, date=None)
114
+ ]
115
+ sections.extend(releases)
116
+ return sections
117
+
118
+
119
+ def _parse_sections(text: str) -> List[ChangelogSection]:
120
+ lines = text.splitlines()
121
+ sections: list[ChangelogSection] = []
122
+ i = 0
123
+ total = len(lines)
124
+ while i < total:
125
+ title = lines[i]
126
+ underline_index = i + 1
127
+ if underline_index >= total:
128
+ break
129
+ underline = lines[underline_index]
130
+ if set(underline) == {"-"} and len(underline) == len(title):
131
+ entries: list[str] = []
132
+ i = underline_index + 1
133
+ # Skip single blank line immediately after the heading if present.
134
+ if i < total and lines[i] == "":
135
+ i += 1
136
+ while i < total and lines[i] != "":
137
+ entries.append(lines[i])
138
+ i += 1
139
+ version = None
140
+ date = None
141
+ match_version = _RE_TITLE_VERSION.match(title)
142
+ if match_version:
143
+ version = match_version.group("version")
144
+ match_date = _RE_TITLE_DATE.search(title)
145
+ if match_date:
146
+ date = match_date.group("date")
147
+ sections.append(
148
+ ChangelogSection(title=title, entries=entries, version=version, date=date)
149
+ )
150
+ while i < total and lines[i] == "":
151
+ i += 1
152
+ continue
153
+ i += 1
154
+ return sections
155
+
156
+
157
+ def _merge_sections(
158
+ new_sections: Iterable[ChangelogSection],
159
+ old_sections: Iterable[ChangelogSection],
160
+ ) -> List[ChangelogSection]:
161
+ merged = list(new_sections)
162
+ old_sections_list = list(old_sections)
163
+ version_to_section: dict[str, ChangelogSection] = {}
164
+ unreleased_section: ChangelogSection | None = None
165
+
166
+ for section in merged:
167
+ if section.version is None and unreleased_section is None:
168
+ unreleased_section = section
169
+ if section.version:
170
+ version_to_section[section.version] = section
171
+
172
+ first_release_version: str | None = None
173
+ for old in old_sections_list:
174
+ if old.version:
175
+ first_release_version = old.version
176
+ break
177
+
178
+ reopened_latest_version = False
179
+
180
+ for old in old_sections_list:
181
+ if old.version is None:
182
+ if unreleased_section is None:
183
+ unreleased_section = ChangelogSection(
184
+ title=old.title,
185
+ entries=list(old.entries),
186
+ version=None,
187
+ date=None,
188
+ )
189
+ merged.insert(0, unreleased_section)
190
+ else:
191
+ # Preserve the freshly generated ``Unreleased`` entries instead of
192
+ # merging in stale content from the previous changelog text.
193
+ # The older implementation discarded the previous ``Unreleased``
194
+ # notes entirely, so keep that behaviour to avoid resurrecting
195
+ # entries that were already promoted to a tagged release.
196
+ continue
197
+ continue
198
+
199
+ existing = version_to_section.get(old.version)
200
+ if existing is None:
201
+ if (
202
+ first_release_version
203
+ and old.version == first_release_version
204
+ and not reopened_latest_version
205
+ and unreleased_section is not None
206
+ ):
207
+ for entry in old.entries:
208
+ if entry not in unreleased_section.entries:
209
+ unreleased_section.entries.append(entry)
210
+ reopened_latest_version = True
211
+ continue
212
+ copied = ChangelogSection(
213
+ title=old.title,
214
+ entries=list(old.entries),
215
+ version=old.version,
216
+ date=old.date,
217
+ )
218
+ merged.append(copied)
219
+ version_to_section[old.version] = copied
220
+ continue
221
+
222
+ if old.date and not existing.date:
223
+ existing.date = old.date
224
+ existing.title = _format_title(old.version, old.date)
225
+ for entry in old.entries:
226
+ if entry not in existing.entries:
227
+ existing.entries.append(entry)
228
+
229
+ return merged
230
+
231
+
232
+ def _resolve_start_tag(explicit: str | None = None) -> Optional[str]:
233
+ """Return the most recent tag that should seed the changelog range."""
234
+
235
+ if explicit:
236
+ return explicit
237
+
238
+ exact = subprocess.run(
239
+ ["git", "describe", "--tags", "--exact-match", "HEAD"],
240
+ capture_output=True,
241
+ text=True,
242
+ check=False,
243
+ )
244
+ if exact.returncode == 0:
245
+ has_parent = subprocess.run(
246
+ ["git", "rev-parse", "--verify", "HEAD^"],
247
+ capture_output=True,
248
+ text=True,
249
+ check=False,
250
+ )
251
+ if has_parent.returncode == 0:
252
+ previous = subprocess.run(
253
+ ["git", "describe", "--tags", "--abbrev=0", "HEAD^"],
254
+ capture_output=True,
255
+ text=True,
256
+ check=False,
257
+ )
258
+ if previous.returncode == 0:
259
+ tag = previous.stdout.strip()
260
+ if tag:
261
+ return tag
262
+ return None
263
+
264
+ describe = subprocess.run(
265
+ ["git", "describe", "--tags", "--abbrev=0"],
266
+ capture_output=True,
267
+ text=True,
268
+ check=False,
269
+ )
270
+ if describe.returncode == 0:
271
+ tag = describe.stdout.strip()
272
+ if tag:
273
+ return tag
274
+ return None
275
+
276
+
277
+ def determine_range_spec(start_tag: str | None = None) -> str:
278
+ """Return the git range specification to build the changelog."""
279
+
280
+ resolved = _resolve_start_tag(start_tag)
281
+ if resolved:
282
+ return f"{resolved}..HEAD"
283
+ return "HEAD"
284
+
285
+
286
+ def collect_sections(
287
+ *, range_spec: str = "HEAD", previous_text: str | None = None
288
+ ) -> List[ChangelogSection]:
289
+ """Return changelog sections for *range_spec*.
290
+
291
+ When ``previous_text`` is provided, sections not regenerated in the current run
292
+ are appended so long as they can be parsed from the existing changelog.
293
+ """
294
+
295
+ commits = _read_commits(range_spec)
296
+ sections = _sections_from_commits(commits)
297
+ if previous_text:
298
+ old_sections = _parse_sections(previous_text)
299
+ sections = _merge_sections(sections, old_sections)
300
+ return sections
301
+
302
+
303
+ def render_changelog(sections: Iterable[ChangelogSection]) -> str:
304
+ lines: list[str] = ["Changelog", "=========", ""]
305
+ for section in sections:
306
+ lines.append(section.title)
307
+ lines.append("-" * len(section.title))
308
+ lines.append("")
309
+ lines.extend(section.entries)
310
+ lines.append("")
311
+ while lines and lines[-1] == "":
312
+ lines.pop()
313
+ lines.append("")
314
+ return "\n".join(lines)
315
+
316
+
317
+ def extract_release_notes(text: str, version: str) -> str:
318
+ """Return the changelog entries matching *version*.
319
+
320
+ When no dedicated section for the release exists, the ``Unreleased`` section is
321
+ returned instead to capture the pending notes for the current release.
322
+ """
323
+
324
+ sections = _parse_sections(text)
325
+ normalized = version.lstrip("v")
326
+ for section in sections:
327
+ if section.version and section.version.lstrip("v") == normalized:
328
+ return "\n".join(section.entries).strip()
329
+ for section in sections:
330
+ if section.version is None:
331
+ return "\n".join(section.entries).strip()
332
+ return ""
333
+
334
+
335
+ __all__ = [
336
+ "ChangelogSection",
337
+ "Commit",
338
+ "determine_range_spec",
339
+ "collect_sections",
340
+ "extract_release_notes",
341
+ "render_changelog",
342
+ ]
core/entity.py CHANGED
@@ -1,133 +1,149 @@
1
- import copy
2
- import logging
3
-
4
- from django.contrib.auth.models import UserManager as DjangoUserManager
5
- from django.core.exceptions import FieldDoesNotExist
6
- from django.db import models
7
-
8
- logger = logging.getLogger(__name__)
9
-
10
-
11
- class EntityQuerySet(models.QuerySet):
12
- def delete(self): # pragma: no cover - delegates to instance delete
13
- deleted = 0
14
- for obj in self:
15
- obj.delete()
16
- deleted += 1
17
- return deleted, {}
18
-
19
-
20
- class EntityManager(models.Manager):
21
- def get_queryset(self):
22
- return EntityQuerySet(self.model, using=self._db).filter(is_deleted=False)
23
-
24
-
25
- class EntityUserManager(DjangoUserManager):
26
- def get_queryset(self):
27
- return EntityQuerySet(self.model, using=self._db).filter(is_deleted=False)
28
-
29
-
30
- class Entity(models.Model):
31
- """Base model providing seed data tracking and soft deletion."""
32
-
33
- is_seed_data = models.BooleanField(default=False, editable=False)
34
- is_user_data = models.BooleanField(default=False, editable=False)
35
- is_deleted = models.BooleanField(default=False, editable=False)
36
-
37
- objects = EntityManager()
38
- all_objects = models.Manager()
39
-
40
- class Meta:
41
- abstract = True
42
-
43
- def clone(self):
44
- """Return an unsaved copy of this instance."""
45
- new = copy.copy(self)
46
- new.pk = None
47
- return new
48
-
49
- def save(self, *args, **kwargs):
50
- if self.pk:
51
- try:
52
- old = type(self).all_objects.get(pk=self.pk)
53
- except type(self).DoesNotExist:
54
- pass
55
- else:
56
- self.is_seed_data = old.is_seed_data
57
- self.is_user_data = old.is_user_data
58
- super().save(*args, **kwargs)
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
-
115
- def resolve_sigils(self, field: str) -> str:
116
- """Return ``field`` value with [ROOT.KEY] tokens resolved."""
117
- name = field.lower()
118
- fobj = next((f for f in self._meta.fields if f.name.lower() == name), None)
119
- if not fobj:
120
- return ""
121
- value = self.__dict__.get(fobj.attname, "")
122
- if value is None:
123
- return ""
124
- from .sigil_resolver import resolve_sigils as _resolve
125
-
126
- return _resolve(str(value), current=self)
127
-
128
- def delete(self, using=None, keep_parents=False):
129
- if self.is_seed_data:
130
- self.is_deleted = True
131
- self.save(update_fields=["is_deleted"])
132
- else:
133
- super().delete(using=using, keep_parents=keep_parents)
1
+ import copy
2
+ import logging
3
+
4
+ from django.contrib.auth.models import UserManager as DjangoUserManager
5
+ from django.core.exceptions import FieldDoesNotExist
6
+ from django.db import models
7
+ from django.dispatch import Signal
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ user_data_flag_updated = Signal()
13
+
14
+
15
+ class EntityQuerySet(models.QuerySet):
16
+ def delete(self): # pragma: no cover - delegates to instance delete
17
+ deleted = 0
18
+ for obj in self:
19
+ obj.delete()
20
+ deleted += 1
21
+ return deleted, {}
22
+
23
+ def update(self, **kwargs):
24
+ invalidate_user_data_cache = "is_user_data" in kwargs
25
+ updated = super().update(**kwargs)
26
+ if invalidate_user_data_cache and updated:
27
+ user_data_flag_updated.send(sender=self.model)
28
+ return updated
29
+
30
+
31
+ class EntityManager(models.Manager):
32
+ def get_queryset(self):
33
+ return EntityQuerySet(self.model, using=self._db).filter(is_deleted=False)
34
+
35
+
36
+ class EntityAllManager(models.Manager):
37
+ def get_queryset(self):
38
+ return EntityQuerySet(self.model, using=self._db)
39
+
40
+
41
+ class EntityUserManager(DjangoUserManager):
42
+ def get_queryset(self):
43
+ return EntityQuerySet(self.model, using=self._db).filter(is_deleted=False)
44
+
45
+
46
+ class Entity(models.Model):
47
+ """Base model providing seed data tracking and soft deletion."""
48
+
49
+ is_seed_data = models.BooleanField(default=False, editable=False)
50
+ is_user_data = models.BooleanField(default=False, editable=False)
51
+ is_deleted = models.BooleanField(default=False, editable=False)
52
+
53
+ objects = EntityManager()
54
+ all_objects = EntityAllManager()
55
+
56
+ class Meta:
57
+ abstract = True
58
+
59
+ def clone(self):
60
+ """Return an unsaved copy of this instance."""
61
+ new = copy.copy(self)
62
+ new.pk = None
63
+ return new
64
+
65
+ def save(self, *args, **kwargs):
66
+ if self.pk:
67
+ try:
68
+ old = type(self).all_objects.get(pk=self.pk)
69
+ except type(self).DoesNotExist:
70
+ pass
71
+ else:
72
+ self.is_seed_data = old.is_seed_data
73
+ self.is_user_data = old.is_user_data
74
+ super().save(*args, **kwargs)
75
+
76
+ @classmethod
77
+ def _unique_field_groups(cls):
78
+ """Return concrete field tuples enforcing uniqueness for this model."""
79
+
80
+ opts = cls._meta
81
+ groups: list[tuple[models.Field, ...]] = []
82
+
83
+ for field in opts.concrete_fields:
84
+ if field.unique and not field.primary_key:
85
+ groups.append((field,))
86
+
87
+ for unique in opts.unique_together:
88
+ fields: list[models.Field] = []
89
+ for name in unique:
90
+ try:
91
+ field = opts.get_field(name)
92
+ except FieldDoesNotExist:
93
+ fields = []
94
+ break
95
+ if not getattr(field, "concrete", False) or field.primary_key:
96
+ fields = []
97
+ break
98
+ fields.append(field)
99
+ if fields:
100
+ groups.append(tuple(fields))
101
+
102
+ for constraint in opts.constraints:
103
+ if not isinstance(constraint, models.UniqueConstraint):
104
+ continue
105
+ if not constraint.fields or constraint.condition is not None:
106
+ continue
107
+ fields = []
108
+ for name in constraint.fields:
109
+ try:
110
+ field = opts.get_field(name)
111
+ except FieldDoesNotExist:
112
+ fields = []
113
+ break
114
+ if not getattr(field, "concrete", False) or field.primary_key:
115
+ fields = []
116
+ break
117
+ fields.append(field)
118
+ if fields:
119
+ groups.append(tuple(fields))
120
+
121
+ unique_groups: list[tuple[models.Field, ...]] = []
122
+ seen: set[tuple[str, ...]] = set()
123
+ for fields in groups:
124
+ key = tuple(field.attname for field in fields)
125
+ if key in seen:
126
+ continue
127
+ seen.add(key)
128
+ unique_groups.append(fields)
129
+ return unique_groups
130
+
131
+ def resolve_sigils(self, field: str) -> str:
132
+ """Return ``field`` value with [ROOT.KEY] tokens resolved."""
133
+ name = field.lower()
134
+ fobj = next((f for f in self._meta.fields if f.name.lower() == name), None)
135
+ if not fobj:
136
+ return ""
137
+ value = self.__dict__.get(fobj.attname, "")
138
+ if value is None:
139
+ return ""
140
+ from .sigil_resolver import resolve_sigils as _resolve
141
+
142
+ return _resolve(str(value), current=self)
143
+
144
+ def delete(self, using=None, keep_parents=False):
145
+ if self.is_seed_data:
146
+ self.is_deleted = True
147
+ self.save(update_fields=["is_deleted"])
148
+ else:
149
+ super().delete(using=using, keep_parents=keep_parents)