arthexis 0.1.15__py3-none-any.whl → 0.1.17__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.
- {arthexis-0.1.15.dist-info → arthexis-0.1.17.dist-info}/METADATA +1 -2
- {arthexis-0.1.15.dist-info → arthexis-0.1.17.dist-info}/RECORD +40 -39
- config/settings.py +3 -0
- config/urls.py +5 -0
- core/admin.py +242 -15
- core/admindocs.py +44 -3
- core/apps.py +1 -1
- core/backends.py +46 -8
- core/changelog.py +66 -5
- core/github_issues.py +12 -7
- core/mailer.py +9 -5
- core/models.py +121 -29
- core/release.py +107 -2
- core/system.py +209 -2
- core/tasks.py +5 -7
- core/test_system_info.py +16 -0
- core/tests.py +329 -0
- core/views.py +279 -40
- nodes/admin.py +25 -1
- nodes/models.py +70 -4
- nodes/rfid_sync.py +15 -0
- nodes/tests.py +119 -0
- nodes/utils.py +3 -0
- ocpp/admin.py +92 -10
- ocpp/consumers.py +38 -0
- ocpp/models.py +19 -4
- ocpp/tasks.py +156 -2
- ocpp/test_rfid.py +92 -5
- ocpp/tests.py +243 -1
- ocpp/views.py +23 -5
- pages/admin.py +126 -4
- pages/context_processors.py +20 -1
- pages/models.py +3 -1
- pages/module_defaults.py +156 -0
- pages/tests.py +241 -8
- pages/urls.py +1 -0
- pages/views.py +61 -4
- {arthexis-0.1.15.dist-info → arthexis-0.1.17.dist-info}/WHEEL +0 -0
- {arthexis-0.1.15.dist-info → arthexis-0.1.17.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.15.dist-info → arthexis-0.1.17.dist-info}/top_level.txt +0 -0
core/admindocs.py
CHANGED
|
@@ -9,7 +9,11 @@ from django.contrib.admindocs.views import (
|
|
|
9
9
|
BaseAdminDocsView,
|
|
10
10
|
user_has_model_view_permission,
|
|
11
11
|
)
|
|
12
|
+
from django.shortcuts import render
|
|
13
|
+
from django.template import loader
|
|
12
14
|
from django.urls import NoReverseMatch, reverse
|
|
15
|
+
from django.utils.translation import gettext_lazy as _
|
|
16
|
+
from django.test import signals as test_signals
|
|
13
17
|
|
|
14
18
|
|
|
15
19
|
class CommandsView(BaseAdminDocsView):
|
|
@@ -56,17 +60,27 @@ class CommandsView(BaseAdminDocsView):
|
|
|
56
60
|
class OrderedModelIndexView(BaseAdminDocsView):
|
|
57
61
|
template_name = "admin_doc/model_index.html"
|
|
58
62
|
|
|
63
|
+
USER_MANUALS_APP = SimpleNamespace(
|
|
64
|
+
label="manuals",
|
|
65
|
+
name="manuals",
|
|
66
|
+
verbose_name=_("User Manuals"),
|
|
67
|
+
)
|
|
68
|
+
|
|
59
69
|
GROUP_OVERRIDES = {
|
|
60
70
|
"ocpp.location": "core",
|
|
61
71
|
"core.rfid": "ocpp",
|
|
62
72
|
"core.package": "teams",
|
|
63
73
|
"core.packagerelease": "teams",
|
|
74
|
+
"core.todo": "teams",
|
|
75
|
+
"pages.usermanual": USER_MANUALS_APP,
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
def _get_docs_app_config(self, meta):
|
|
67
|
-
|
|
68
|
-
if
|
|
69
|
-
|
|
79
|
+
override = self.GROUP_OVERRIDES.get(meta.label_lower)
|
|
80
|
+
if override:
|
|
81
|
+
if isinstance(override, str):
|
|
82
|
+
return apps.get_app_config(override)
|
|
83
|
+
return override
|
|
70
84
|
return meta.app_config
|
|
71
85
|
|
|
72
86
|
def get_context_data(self, **kwargs):
|
|
@@ -92,6 +106,33 @@ class OrderedModelIndexView(BaseAdminDocsView):
|
|
|
92
106
|
class ModelGraphIndexView(BaseAdminDocsView):
|
|
93
107
|
template_name = "admin_doc/model_graphs.html"
|
|
94
108
|
|
|
109
|
+
def render_to_response(self, context, **response_kwargs):
|
|
110
|
+
template_name = response_kwargs.pop("template_name", None)
|
|
111
|
+
if template_name is None:
|
|
112
|
+
template_name = self.get_template_names()
|
|
113
|
+
response = render(
|
|
114
|
+
self.request,
|
|
115
|
+
template_name,
|
|
116
|
+
context,
|
|
117
|
+
**response_kwargs,
|
|
118
|
+
)
|
|
119
|
+
if getattr(response, "context", None) is None:
|
|
120
|
+
response.context = context
|
|
121
|
+
if test_signals.template_rendered.receivers:
|
|
122
|
+
if isinstance(template_name, (list, tuple)):
|
|
123
|
+
template = loader.select_template(template_name)
|
|
124
|
+
else:
|
|
125
|
+
template = loader.get_template(template_name)
|
|
126
|
+
signal_context = context
|
|
127
|
+
if self.request is not None and "request" not in signal_context:
|
|
128
|
+
signal_context = {**context, "request": self.request}
|
|
129
|
+
test_signals.template_rendered.send(
|
|
130
|
+
sender=template.__class__,
|
|
131
|
+
template=template,
|
|
132
|
+
context=signal_context,
|
|
133
|
+
)
|
|
134
|
+
return response
|
|
135
|
+
|
|
95
136
|
def get_context_data(self, **kwargs):
|
|
96
137
|
sections = {}
|
|
97
138
|
user = self.request.user
|
core/apps.py
CHANGED
|
@@ -351,6 +351,6 @@ class CoreConfig(AppConfig):
|
|
|
351
351
|
try:
|
|
352
352
|
from .mcp.auto_start import schedule_auto_start
|
|
353
353
|
|
|
354
|
-
schedule_auto_start()
|
|
354
|
+
schedule_auto_start(check_profiles_immediately=False)
|
|
355
355
|
except Exception: # pragma: no cover - defensive
|
|
356
356
|
logger.exception("Failed to schedule MCP auto-start")
|
core/backends.py
CHANGED
|
@@ -5,6 +5,7 @@ import ipaddress
|
|
|
5
5
|
import os
|
|
6
6
|
import socket
|
|
7
7
|
import subprocess
|
|
8
|
+
import sys
|
|
8
9
|
|
|
9
10
|
from django.conf import settings
|
|
10
11
|
from django.contrib.auth import get_user_model
|
|
@@ -42,9 +43,12 @@ class TOTPBackend(ModelBackend):
|
|
|
42
43
|
|
|
43
44
|
device_qs = TOTPDevice.objects.filter(user=user, confirmed=True)
|
|
44
45
|
if TOTP_DEVICE_NAME:
|
|
45
|
-
|
|
46
|
+
device = device_qs.filter(name=TOTP_DEVICE_NAME).order_by("-id").first()
|
|
47
|
+
else:
|
|
48
|
+
device = None
|
|
46
49
|
|
|
47
|
-
device
|
|
50
|
+
if device is None:
|
|
51
|
+
device = device_qs.order_by("-id").first()
|
|
48
52
|
if device is None:
|
|
49
53
|
return None
|
|
50
54
|
|
|
@@ -86,6 +90,7 @@ class RFIDBackend:
|
|
|
86
90
|
env = os.environ.copy()
|
|
87
91
|
env["RFID_VALUE"] = rfid_value
|
|
88
92
|
env["RFID_LABEL_ID"] = str(tag.pk)
|
|
93
|
+
env["RFID_ENDIANNESS"] = getattr(tag, "endianness", RFID.BIG_ENDIAN)
|
|
89
94
|
try:
|
|
90
95
|
completed = subprocess.run(
|
|
91
96
|
command,
|
|
@@ -108,6 +113,20 @@ class RFIDBackend:
|
|
|
108
113
|
.first()
|
|
109
114
|
)
|
|
110
115
|
if account:
|
|
116
|
+
post_command = (getattr(tag, "post_auth_command", "") or "").strip()
|
|
117
|
+
if post_command:
|
|
118
|
+
env = os.environ.copy()
|
|
119
|
+
env["RFID_VALUE"] = rfid_value
|
|
120
|
+
env["RFID_LABEL_ID"] = str(tag.pk)
|
|
121
|
+
env["RFID_ENDIANNESS"] = getattr(tag, "endianness", RFID.BIG_ENDIAN)
|
|
122
|
+
with contextlib.suppress(Exception):
|
|
123
|
+
subprocess.Popen(
|
|
124
|
+
post_command,
|
|
125
|
+
shell=True,
|
|
126
|
+
env=env,
|
|
127
|
+
stdout=subprocess.DEVNULL,
|
|
128
|
+
stderr=subprocess.DEVNULL,
|
|
129
|
+
)
|
|
111
130
|
return account.user
|
|
112
131
|
return None
|
|
113
132
|
|
|
@@ -167,6 +186,19 @@ class LocalhostAdminBackend(ModelBackend):
|
|
|
167
186
|
if getattr(settings, "NODE_ROLE", "") == "Control":
|
|
168
187
|
yield from self._CONTROL_ALLOWED_NETWORKS
|
|
169
188
|
|
|
189
|
+
def _is_test_environment(self, request) -> bool:
|
|
190
|
+
if os.environ.get("PYTEST_CURRENT_TEST"):
|
|
191
|
+
return True
|
|
192
|
+
if any(arg == "test" for arg in sys.argv):
|
|
193
|
+
return True
|
|
194
|
+
executable = os.path.basename(sys.argv[0]) if sys.argv else ""
|
|
195
|
+
if executable in {"pytest", "py.test"}:
|
|
196
|
+
return True
|
|
197
|
+
server_name = ""
|
|
198
|
+
if request is not None:
|
|
199
|
+
server_name = request.META.get("SERVER_NAME", "")
|
|
200
|
+
return server_name.lower() == "testserver"
|
|
201
|
+
|
|
170
202
|
def authenticate(self, request, username=None, password=None, **kwargs):
|
|
171
203
|
if username == "admin" and password == "admin" and request is not None:
|
|
172
204
|
try:
|
|
@@ -179,7 +211,8 @@ class LocalhostAdminBackend(ModelBackend):
|
|
|
179
211
|
try:
|
|
180
212
|
ipaddress.ip_address(host)
|
|
181
213
|
except ValueError:
|
|
182
|
-
|
|
214
|
+
if not self._is_test_environment(request):
|
|
215
|
+
return None
|
|
183
216
|
forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
|
|
184
217
|
if forwarded:
|
|
185
218
|
remote = forwarded.split(",")[0].strip()
|
|
@@ -212,11 +245,16 @@ class LocalhostAdminBackend(ModelBackend):
|
|
|
212
245
|
user.operate_as = arthexis_user
|
|
213
246
|
user.set_password("admin")
|
|
214
247
|
user.save()
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
248
|
+
else:
|
|
249
|
+
if not user.check_password("admin"):
|
|
250
|
+
if not user.password or not user.has_usable_password():
|
|
251
|
+
user.set_password("admin")
|
|
252
|
+
user.save(update_fields=["password"])
|
|
253
|
+
else:
|
|
254
|
+
return None
|
|
255
|
+
if arthexis_user and user.operate_as_id is None:
|
|
256
|
+
user.operate_as = arthexis_user
|
|
257
|
+
user.save(update_fields=["operate_as"])
|
|
220
258
|
return user
|
|
221
259
|
return super().authenticate(request, username, password, **kwargs)
|
|
222
260
|
|
core/changelog.py
CHANGED
|
@@ -154,9 +154,53 @@ def _parse_sections(text: str) -> List[ChangelogSection]:
|
|
|
154
154
|
return sections
|
|
155
155
|
|
|
156
156
|
|
|
157
|
+
def _latest_release_version(previous_text: str) -> Optional[str]:
|
|
158
|
+
for section in _parse_sections(previous_text):
|
|
159
|
+
if section.version:
|
|
160
|
+
return section.version
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _find_release_commit(version: str) -> Optional[str]:
|
|
165
|
+
normalized = version.lstrip("v")
|
|
166
|
+
search_terms = [
|
|
167
|
+
f"Release v{normalized}",
|
|
168
|
+
f"Release {normalized}",
|
|
169
|
+
f"pre-release commit v{normalized}",
|
|
170
|
+
f"pre-release commit {normalized}",
|
|
171
|
+
]
|
|
172
|
+
for term in search_terms:
|
|
173
|
+
proc = subprocess.run(
|
|
174
|
+
[
|
|
175
|
+
"git",
|
|
176
|
+
"log",
|
|
177
|
+
"--max-count=1",
|
|
178
|
+
"--format=%H",
|
|
179
|
+
"--fixed-strings",
|
|
180
|
+
f"--grep={term}",
|
|
181
|
+
],
|
|
182
|
+
capture_output=True,
|
|
183
|
+
text=True,
|
|
184
|
+
check=False,
|
|
185
|
+
)
|
|
186
|
+
sha = proc.stdout.strip()
|
|
187
|
+
if sha:
|
|
188
|
+
return sha.splitlines()[0]
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _resolve_release_commit_from_text(previous_text: str) -> Optional[str]:
|
|
193
|
+
version = _latest_release_version(previous_text)
|
|
194
|
+
if not version:
|
|
195
|
+
return None
|
|
196
|
+
return _find_release_commit(version)
|
|
197
|
+
|
|
198
|
+
|
|
157
199
|
def _merge_sections(
|
|
158
200
|
new_sections: Iterable[ChangelogSection],
|
|
159
201
|
old_sections: Iterable[ChangelogSection],
|
|
202
|
+
*,
|
|
203
|
+
reopen_latest: bool = False,
|
|
160
204
|
) -> List[ChangelogSection]:
|
|
161
205
|
merged = list(new_sections)
|
|
162
206
|
old_sections_list = list(old_sections)
|
|
@@ -199,7 +243,8 @@ def _merge_sections(
|
|
|
199
243
|
existing = version_to_section.get(old.version)
|
|
200
244
|
if existing is None:
|
|
201
245
|
if (
|
|
202
|
-
|
|
246
|
+
reopen_latest
|
|
247
|
+
and first_release_version
|
|
203
248
|
and old.version == first_release_version
|
|
204
249
|
and not reopened_latest_version
|
|
205
250
|
and unreleased_section is not None
|
|
@@ -274,29 +319,45 @@ def _resolve_start_tag(explicit: str | None = None) -> Optional[str]:
|
|
|
274
319
|
return None
|
|
275
320
|
|
|
276
321
|
|
|
277
|
-
def determine_range_spec(
|
|
322
|
+
def determine_range_spec(
|
|
323
|
+
start_tag: str | None = None, *, previous_text: str | None = None
|
|
324
|
+
) -> str:
|
|
278
325
|
"""Return the git range specification to build the changelog."""
|
|
279
326
|
|
|
280
327
|
resolved = _resolve_start_tag(start_tag)
|
|
281
328
|
if resolved:
|
|
282
329
|
return f"{resolved}..HEAD"
|
|
330
|
+
|
|
331
|
+
if previous_text:
|
|
332
|
+
release_commit = _resolve_release_commit_from_text(previous_text)
|
|
333
|
+
if release_commit:
|
|
334
|
+
return f"{release_commit}..HEAD"
|
|
335
|
+
|
|
283
336
|
return "HEAD"
|
|
284
337
|
|
|
285
338
|
|
|
286
339
|
def collect_sections(
|
|
287
|
-
*,
|
|
340
|
+
*,
|
|
341
|
+
range_spec: str = "HEAD",
|
|
342
|
+
previous_text: str | None = None,
|
|
343
|
+
reopen_latest: bool = False,
|
|
288
344
|
) -> List[ChangelogSection]:
|
|
289
345
|
"""Return changelog sections for *range_spec*.
|
|
290
346
|
|
|
291
347
|
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.
|
|
348
|
+
are appended so long as they can be parsed from the existing changelog. Set
|
|
349
|
+
``reopen_latest`` to ``True`` when the caller intends to move the most recent
|
|
350
|
+
release notes back into the ``Unreleased`` section (for example, when
|
|
351
|
+
preparing a release retry before a new tag is created).
|
|
293
352
|
"""
|
|
294
353
|
|
|
295
354
|
commits = _read_commits(range_spec)
|
|
296
355
|
sections = _sections_from_commits(commits)
|
|
297
356
|
if previous_text:
|
|
298
357
|
old_sections = _parse_sections(previous_text)
|
|
299
|
-
sections = _merge_sections(
|
|
358
|
+
sections = _merge_sections(
|
|
359
|
+
sections, old_sections, reopen_latest=reopen_latest
|
|
360
|
+
)
|
|
300
361
|
return sections
|
|
301
362
|
|
|
302
363
|
|
core/github_issues.py
CHANGED
|
@@ -71,13 +71,18 @@ def get_github_token() -> str:
|
|
|
71
71
|
latest_release = PackageRelease.latest()
|
|
72
72
|
if latest_release:
|
|
73
73
|
token = latest_release.get_github_token()
|
|
74
|
-
if token:
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
if token is not None:
|
|
75
|
+
cleaned = token.strip() if isinstance(token, str) else str(token).strip()
|
|
76
|
+
if cleaned:
|
|
77
|
+
return cleaned
|
|
78
|
+
|
|
79
|
+
env_token = os.environ.get("GITHUB_TOKEN")
|
|
80
|
+
if env_token is not None:
|
|
81
|
+
cleaned = env_token.strip() if isinstance(env_token, str) else str(env_token).strip()
|
|
82
|
+
if cleaned:
|
|
83
|
+
return cleaned
|
|
84
|
+
|
|
85
|
+
raise RuntimeError("GitHub token is not configured")
|
|
81
86
|
|
|
82
87
|
|
|
83
88
|
def _ensure_lock_dir() -> None:
|
core/mailer.py
CHANGED
|
@@ -45,11 +45,15 @@ def send(
|
|
|
45
45
|
)
|
|
46
46
|
if attachments:
|
|
47
47
|
for attachment in attachments:
|
|
48
|
-
if
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
if isinstance(attachment, (list, tuple)):
|
|
49
|
+
length = len(attachment)
|
|
50
|
+
if length not in {2, 3}:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
"attachments must contain 2- or 3-item (name, content, mimetype) tuples"
|
|
53
|
+
)
|
|
54
|
+
email.attach(*attachment)
|
|
55
|
+
else:
|
|
56
|
+
email.attach(attachment)
|
|
53
57
|
if content_subtype:
|
|
54
58
|
email.content_subtype = content_subtype
|
|
55
59
|
email.send(fail_silently=fail_silently)
|
core/models.py
CHANGED
|
@@ -586,10 +586,10 @@ class OdooProfile(Profile):
|
|
|
586
586
|
"""Return the display label for this profile."""
|
|
587
587
|
|
|
588
588
|
username = self._resolved_field_value("username")
|
|
589
|
+
if username:
|
|
590
|
+
return username
|
|
589
591
|
database = self._resolved_field_value("database")
|
|
590
|
-
|
|
591
|
-
return f"{username}@{database}"
|
|
592
|
-
return username or database or ""
|
|
592
|
+
return database or ""
|
|
593
593
|
|
|
594
594
|
def save(self, *args, **kwargs):
|
|
595
595
|
if self.pk:
|
|
@@ -1816,17 +1816,22 @@ class RFID(Entity):
|
|
|
1816
1816
|
blank=True,
|
|
1817
1817
|
help_text="Optional command executed during validation.",
|
|
1818
1818
|
)
|
|
1819
|
+
post_auth_command = models.TextField(
|
|
1820
|
+
default="",
|
|
1821
|
+
blank=True,
|
|
1822
|
+
help_text="Optional command executed after successful validation.",
|
|
1823
|
+
)
|
|
1819
1824
|
BLACK = "B"
|
|
1820
1825
|
WHITE = "W"
|
|
1821
1826
|
BLUE = "U"
|
|
1822
1827
|
RED = "R"
|
|
1823
1828
|
GREEN = "G"
|
|
1824
1829
|
COLOR_CHOICES = [
|
|
1825
|
-
(BLACK, "Black"),
|
|
1826
|
-
(WHITE, "White"),
|
|
1827
|
-
(BLUE, "Blue"),
|
|
1828
|
-
(RED, "Red"),
|
|
1829
|
-
(GREEN, "Green"),
|
|
1830
|
+
(BLACK, _("Black")),
|
|
1831
|
+
(WHITE, _("White")),
|
|
1832
|
+
(BLUE, _("Blue")),
|
|
1833
|
+
(RED, _("Red")),
|
|
1834
|
+
(GREEN, _("Green")),
|
|
1830
1835
|
]
|
|
1831
1836
|
SCAN_LABEL_STEP = 10
|
|
1832
1837
|
COPY_LABEL_STEP = 1
|
|
@@ -1838,14 +1843,25 @@ class RFID(Entity):
|
|
|
1838
1843
|
CLASSIC = "CLASSIC"
|
|
1839
1844
|
NTAG215 = "NTAG215"
|
|
1840
1845
|
KIND_CHOICES = [
|
|
1841
|
-
(CLASSIC, "MIFARE Classic"),
|
|
1842
|
-
(NTAG215, "NTAG215"),
|
|
1846
|
+
(CLASSIC, _("MIFARE Classic")),
|
|
1847
|
+
(NTAG215, _("NTAG215")),
|
|
1843
1848
|
]
|
|
1844
1849
|
kind = models.CharField(
|
|
1845
1850
|
max_length=8,
|
|
1846
1851
|
choices=KIND_CHOICES,
|
|
1847
1852
|
default=CLASSIC,
|
|
1848
1853
|
)
|
|
1854
|
+
BIG_ENDIAN = "BIG"
|
|
1855
|
+
LITTLE_ENDIAN = "LITTLE"
|
|
1856
|
+
ENDIANNESS_CHOICES = [
|
|
1857
|
+
(BIG_ENDIAN, _("Big endian")),
|
|
1858
|
+
(LITTLE_ENDIAN, _("Little endian")),
|
|
1859
|
+
]
|
|
1860
|
+
endianness = models.CharField(
|
|
1861
|
+
max_length=6,
|
|
1862
|
+
choices=ENDIANNESS_CHOICES,
|
|
1863
|
+
default=BIG_ENDIAN,
|
|
1864
|
+
)
|
|
1849
1865
|
reference = models.ForeignKey(
|
|
1850
1866
|
"Reference",
|
|
1851
1867
|
null=True,
|
|
@@ -1897,6 +1913,8 @@ class RFID(Entity):
|
|
|
1897
1913
|
self.key_b = self.key_b.upper()
|
|
1898
1914
|
if self.kind:
|
|
1899
1915
|
self.kind = self.kind.upper()
|
|
1916
|
+
if self.endianness:
|
|
1917
|
+
self.endianness = self.normalize_endianness(self.endianness)
|
|
1900
1918
|
super().save(*args, **kwargs)
|
|
1901
1919
|
if not self.allowed:
|
|
1902
1920
|
self.energy_accounts.clear()
|
|
@@ -1904,6 +1922,17 @@ class RFID(Entity):
|
|
|
1904
1922
|
def __str__(self): # pragma: no cover - simple representation
|
|
1905
1923
|
return str(self.label_id)
|
|
1906
1924
|
|
|
1925
|
+
@classmethod
|
|
1926
|
+
def normalize_endianness(cls, value: object) -> str:
|
|
1927
|
+
"""Return a valid endianness value, defaulting to BIG."""
|
|
1928
|
+
|
|
1929
|
+
if isinstance(value, str):
|
|
1930
|
+
candidate = value.strip().upper()
|
|
1931
|
+
valid = {choice[0] for choice in cls.ENDIANNESS_CHOICES}
|
|
1932
|
+
if candidate in valid:
|
|
1933
|
+
return candidate
|
|
1934
|
+
return cls.BIG_ENDIAN
|
|
1935
|
+
|
|
1907
1936
|
@classmethod
|
|
1908
1937
|
def next_scan_label(
|
|
1909
1938
|
cls, *, step: int | None = None, start: int | None = None
|
|
@@ -1966,13 +1995,39 @@ class RFID(Entity):
|
|
|
1966
1995
|
|
|
1967
1996
|
@classmethod
|
|
1968
1997
|
def register_scan(
|
|
1969
|
-
cls,
|
|
1998
|
+
cls,
|
|
1999
|
+
rfid: str,
|
|
2000
|
+
*,
|
|
2001
|
+
kind: str | None = None,
|
|
2002
|
+
endianness: str | None = None,
|
|
1970
2003
|
) -> tuple["RFID", bool]:
|
|
1971
2004
|
"""Return or create an RFID that was detected via scanning."""
|
|
1972
2005
|
|
|
1973
|
-
normalized = (rfid or "").upper()
|
|
1974
|
-
|
|
2006
|
+
normalized = "".join((rfid or "").split()).upper()
|
|
2007
|
+
desired_endianness = cls.normalize_endianness(endianness)
|
|
2008
|
+
alternate = None
|
|
2009
|
+
if normalized and len(normalized) % 2 == 0:
|
|
2010
|
+
bytes_list = [normalized[i : i + 2] for i in range(0, len(normalized), 2)]
|
|
2011
|
+
bytes_list.reverse()
|
|
2012
|
+
alternate_candidate = "".join(bytes_list)
|
|
2013
|
+
if alternate_candidate != normalized:
|
|
2014
|
+
alternate = alternate_candidate
|
|
2015
|
+
|
|
2016
|
+
existing = None
|
|
2017
|
+
if normalized:
|
|
2018
|
+
existing = cls.objects.filter(rfid=normalized).first()
|
|
2019
|
+
if not existing and alternate:
|
|
2020
|
+
existing = cls.objects.filter(rfid=alternate).first()
|
|
1975
2021
|
if existing:
|
|
2022
|
+
update_fields: list[str] = []
|
|
2023
|
+
if normalized and existing.rfid != normalized:
|
|
2024
|
+
existing.rfid = normalized
|
|
2025
|
+
update_fields.append("rfid")
|
|
2026
|
+
if existing.endianness != desired_endianness:
|
|
2027
|
+
existing.endianness = desired_endianness
|
|
2028
|
+
update_fields.append("endianness")
|
|
2029
|
+
if update_fields:
|
|
2030
|
+
existing.save(update_fields=update_fields)
|
|
1976
2031
|
return existing, False
|
|
1977
2032
|
|
|
1978
2033
|
attempts = 0
|
|
@@ -1985,6 +2040,7 @@ class RFID(Entity):
|
|
|
1985
2040
|
"rfid": normalized,
|
|
1986
2041
|
"allowed": True,
|
|
1987
2042
|
"released": False,
|
|
2043
|
+
"endianness": desired_endianness,
|
|
1988
2044
|
}
|
|
1989
2045
|
if kind:
|
|
1990
2046
|
create_kwargs["kind"] = kind
|
|
@@ -3057,7 +3113,16 @@ class ReleaseManager(Profile):
|
|
|
3057
3113
|
),
|
|
3058
3114
|
)
|
|
3059
3115
|
pypi_password = SigilShortAutoField("PyPI password", max_length=200, blank=True)
|
|
3060
|
-
pypi_url = SigilShortAutoField(
|
|
3116
|
+
pypi_url = SigilShortAutoField(
|
|
3117
|
+
"PyPI URL",
|
|
3118
|
+
max_length=200,
|
|
3119
|
+
blank=True,
|
|
3120
|
+
help_text=(
|
|
3121
|
+
"Link to the PyPI user profile (for example, https://pypi.org/user/username/). "
|
|
3122
|
+
"Use the account's user page, not a project-specific URL. "
|
|
3123
|
+
"This value is informational and not used for uploads."
|
|
3124
|
+
),
|
|
3125
|
+
)
|
|
3061
3126
|
secondary_pypi_url = SigilShortAutoField(
|
|
3062
3127
|
"Secondary PyPI URL",
|
|
3063
3128
|
max_length=200,
|
|
@@ -3103,6 +3168,13 @@ class ReleaseManager(Profile):
|
|
|
3103
3168
|
username = (self.git_username or "").strip()
|
|
3104
3169
|
password_source = self.git_password or self.github_token or ""
|
|
3105
3170
|
password = password_source.strip()
|
|
3171
|
+
|
|
3172
|
+
if password and not username and password_source == self.github_token:
|
|
3173
|
+
# GitHub personal access tokens require a username when used for
|
|
3174
|
+
# HTTPS pushes. Default to the recommended ``x-access-token`` so
|
|
3175
|
+
# release managers only need to provide their token.
|
|
3176
|
+
username = "x-access-token"
|
|
3177
|
+
|
|
3106
3178
|
if username and password:
|
|
3107
3179
|
return GitCredentials(username=username, password=password)
|
|
3108
3180
|
return None
|
|
@@ -3217,13 +3289,22 @@ class PackageRelease(Entity):
|
|
|
3217
3289
|
def dump_fixture(cls) -> None:
|
|
3218
3290
|
base = Path("core/fixtures")
|
|
3219
3291
|
base.mkdir(parents=True, exist_ok=True)
|
|
3220
|
-
for
|
|
3221
|
-
|
|
3292
|
+
existing = {path.name: path for path in base.glob("releases__*.json")}
|
|
3293
|
+
expected: set[str] = set()
|
|
3222
3294
|
for release in cls.objects.all():
|
|
3223
3295
|
name = f"releases__packagerelease_{release.version.replace('.', '_')}.json"
|
|
3224
3296
|
path = base / name
|
|
3225
3297
|
data = serializers.serialize("json", [release])
|
|
3226
|
-
|
|
3298
|
+
expected.add(name)
|
|
3299
|
+
try:
|
|
3300
|
+
current = path.read_text(encoding="utf-8")
|
|
3301
|
+
except FileNotFoundError:
|
|
3302
|
+
current = None
|
|
3303
|
+
if current != data:
|
|
3304
|
+
path.write_text(data, encoding="utf-8")
|
|
3305
|
+
for old_name, old_path in existing.items():
|
|
3306
|
+
if old_name not in expected and old_path.exists():
|
|
3307
|
+
old_path.unlink()
|
|
3227
3308
|
|
|
3228
3309
|
def __str__(self) -> str: # pragma: no cover - trivial
|
|
3229
3310
|
return f"{self.package.name} {self.version}"
|
|
@@ -3236,7 +3317,18 @@ class PackageRelease(Entity):
|
|
|
3236
3317
|
"""Return :class:`Credentials` from the associated release manager."""
|
|
3237
3318
|
manager = self.release_manager or self.package.release_manager
|
|
3238
3319
|
if manager:
|
|
3239
|
-
|
|
3320
|
+
creds = manager.to_credentials()
|
|
3321
|
+
if creds and creds.has_auth():
|
|
3322
|
+
return creds
|
|
3323
|
+
|
|
3324
|
+
token = (os.environ.get("PYPI_API_TOKEN") or "").strip()
|
|
3325
|
+
username = (os.environ.get("PYPI_USERNAME") or "").strip()
|
|
3326
|
+
password = (os.environ.get("PYPI_PASSWORD") or "").strip()
|
|
3327
|
+
|
|
3328
|
+
if token:
|
|
3329
|
+
return Credentials(token=token)
|
|
3330
|
+
if username and password:
|
|
3331
|
+
return Credentials(username=username, password=password)
|
|
3240
3332
|
return None
|
|
3241
3333
|
|
|
3242
3334
|
def get_github_token(self) -> str | None:
|
|
@@ -3252,12 +3344,8 @@ class PackageRelease(Entity):
|
|
|
3252
3344
|
manager = self.release_manager or self.package.release_manager
|
|
3253
3345
|
targets: list[RepositoryTarget] = []
|
|
3254
3346
|
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
primary_url = manager.pypi_url.strip()
|
|
3258
|
-
if not primary_url:
|
|
3259
|
-
env_primary = os.environ.get("PYPI_REPOSITORY_URL", "")
|
|
3260
|
-
primary_url = env_primary.strip()
|
|
3347
|
+
env_primary = os.environ.get("PYPI_REPOSITORY_URL", "")
|
|
3348
|
+
primary_url = env_primary.strip()
|
|
3261
3349
|
|
|
3262
3350
|
primary_creds = self.to_credentials()
|
|
3263
3351
|
targets.append(
|
|
@@ -3381,10 +3469,12 @@ class PackageRelease(Entity):
|
|
|
3381
3469
|
|
|
3382
3470
|
@classmethod
|
|
3383
3471
|
def latest(cls):
|
|
3384
|
-
"""Return the latest release by version."""
|
|
3472
|
+
"""Return the latest release by version, preferring active packages."""
|
|
3385
3473
|
from packaging.version import Version
|
|
3386
3474
|
|
|
3387
|
-
releases = list(cls.objects.
|
|
3475
|
+
releases = list(cls.objects.filter(package__is_active=True))
|
|
3476
|
+
if not releases:
|
|
3477
|
+
releases = list(cls.objects.all())
|
|
3388
3478
|
if not releases:
|
|
3389
3479
|
return None
|
|
3390
3480
|
return max(releases, key=lambda r: Version(r.version))
|
|
@@ -3500,7 +3590,8 @@ class AssistantProfile(Profile):
|
|
|
3500
3590
|
"""
|
|
3501
3591
|
|
|
3502
3592
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
3503
|
-
profile_fields = ("user_key_hash", "scopes", "is_active")
|
|
3593
|
+
profile_fields = ("assistant_name", "user_key_hash", "scopes", "is_active")
|
|
3594
|
+
assistant_name = models.CharField(max_length=100, default="Assistant")
|
|
3504
3595
|
user_key_hash = models.CharField(max_length=64, unique=True)
|
|
3505
3596
|
scopes = models.JSONField(default=list, blank=True)
|
|
3506
3597
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
@@ -3547,8 +3638,7 @@ class AssistantProfile(Profile):
|
|
|
3547
3638
|
self.save(update_fields=["last_used_at"])
|
|
3548
3639
|
|
|
3549
3640
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
3550
|
-
|
|
3551
|
-
return f"AssistantProfile for {owner}" if owner else "AssistantProfile"
|
|
3641
|
+
return self.assistant_name or "AssistantProfile"
|
|
3552
3642
|
|
|
3553
3643
|
|
|
3554
3644
|
def validate_relative_url(value: str) -> None:
|
|
@@ -3572,6 +3662,8 @@ class Todo(Entity):
|
|
|
3572
3662
|
max_length=200, blank=True, default="", validators=[validate_relative_url]
|
|
3573
3663
|
)
|
|
3574
3664
|
request_details = models.TextField(blank=True, default="")
|
|
3665
|
+
generated_for_version = models.CharField(max_length=20, blank=True, default="")
|
|
3666
|
+
generated_for_revision = models.CharField(max_length=40, blank=True, default="")
|
|
3575
3667
|
done_on = models.DateTimeField(null=True, blank=True)
|
|
3576
3668
|
on_done_condition = ConditionTextField(blank=True, default="")
|
|
3577
3669
|
|