arthexis 0.1.15__py3-none-any.whl → 0.1.16__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.

core/mailer.py CHANGED
@@ -45,11 +45,15 @@ def send(
45
45
  )
46
46
  if attachments:
47
47
  for attachment in attachments:
48
- if not isinstance(attachment, (list, tuple)) or len(attachment) != 3:
49
- raise ValueError(
50
- "attachments must contain (name, content, mimetype) tuples"
51
- )
52
- email.attach(*attachment)
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
- if username and database:
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,8 +1843,8 @@ 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,
@@ -3057,7 +3062,16 @@ class ReleaseManager(Profile):
3057
3062
  ),
3058
3063
  )
3059
3064
  pypi_password = SigilShortAutoField("PyPI password", max_length=200, blank=True)
3060
- pypi_url = SigilShortAutoField("PyPI URL", max_length=200, blank=True)
3065
+ pypi_url = SigilShortAutoField(
3066
+ "PyPI URL",
3067
+ max_length=200,
3068
+ blank=True,
3069
+ help_text=(
3070
+ "Link to the PyPI user profile (for example, https://pypi.org/user/username/). "
3071
+ "Use the account's user page, not a project-specific URL. "
3072
+ "This value is informational and not used for uploads."
3073
+ ),
3074
+ )
3061
3075
  secondary_pypi_url = SigilShortAutoField(
3062
3076
  "Secondary PyPI URL",
3063
3077
  max_length=200,
@@ -3103,6 +3117,13 @@ class ReleaseManager(Profile):
3103
3117
  username = (self.git_username or "").strip()
3104
3118
  password_source = self.git_password or self.github_token or ""
3105
3119
  password = password_source.strip()
3120
+
3121
+ if password and not username and password_source == self.github_token:
3122
+ # GitHub personal access tokens require a username when used for
3123
+ # HTTPS pushes. Default to the recommended ``x-access-token`` so
3124
+ # release managers only need to provide their token.
3125
+ username = "x-access-token"
3126
+
3106
3127
  if username and password:
3107
3128
  return GitCredentials(username=username, password=password)
3108
3129
  return None
@@ -3217,13 +3238,22 @@ class PackageRelease(Entity):
3217
3238
  def dump_fixture(cls) -> None:
3218
3239
  base = Path("core/fixtures")
3219
3240
  base.mkdir(parents=True, exist_ok=True)
3220
- for old in base.glob("releases__*.json"):
3221
- old.unlink()
3241
+ existing = {path.name: path for path in base.glob("releases__*.json")}
3242
+ expected: set[str] = set()
3222
3243
  for release in cls.objects.all():
3223
3244
  name = f"releases__packagerelease_{release.version.replace('.', '_')}.json"
3224
3245
  path = base / name
3225
3246
  data = serializers.serialize("json", [release])
3226
- path.write_text(data)
3247
+ expected.add(name)
3248
+ try:
3249
+ current = path.read_text(encoding="utf-8")
3250
+ except FileNotFoundError:
3251
+ current = None
3252
+ if current != data:
3253
+ path.write_text(data, encoding="utf-8")
3254
+ for old_name, old_path in existing.items():
3255
+ if old_name not in expected and old_path.exists():
3256
+ old_path.unlink()
3227
3257
 
3228
3258
  def __str__(self) -> str: # pragma: no cover - trivial
3229
3259
  return f"{self.package.name} {self.version}"
@@ -3236,7 +3266,18 @@ class PackageRelease(Entity):
3236
3266
  """Return :class:`Credentials` from the associated release manager."""
3237
3267
  manager = self.release_manager or self.package.release_manager
3238
3268
  if manager:
3239
- return manager.to_credentials()
3269
+ creds = manager.to_credentials()
3270
+ if creds and creds.has_auth():
3271
+ return creds
3272
+
3273
+ token = (os.environ.get("PYPI_API_TOKEN") or "").strip()
3274
+ username = (os.environ.get("PYPI_USERNAME") or "").strip()
3275
+ password = (os.environ.get("PYPI_PASSWORD") or "").strip()
3276
+
3277
+ if token:
3278
+ return Credentials(token=token)
3279
+ if username and password:
3280
+ return Credentials(username=username, password=password)
3240
3281
  return None
3241
3282
 
3242
3283
  def get_github_token(self) -> str | None:
@@ -3252,12 +3293,8 @@ class PackageRelease(Entity):
3252
3293
  manager = self.release_manager or self.package.release_manager
3253
3294
  targets: list[RepositoryTarget] = []
3254
3295
 
3255
- primary_url = ""
3256
- if manager and manager.pypi_url:
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()
3296
+ env_primary = os.environ.get("PYPI_REPOSITORY_URL", "")
3297
+ primary_url = env_primary.strip()
3261
3298
 
3262
3299
  primary_creds = self.to_credentials()
3263
3300
  targets.append(
@@ -3381,10 +3418,12 @@ class PackageRelease(Entity):
3381
3418
 
3382
3419
  @classmethod
3383
3420
  def latest(cls):
3384
- """Return the latest release by version."""
3421
+ """Return the latest release by version, preferring active packages."""
3385
3422
  from packaging.version import Version
3386
3423
 
3387
- releases = list(cls.objects.all())
3424
+ releases = list(cls.objects.filter(package__is_active=True))
3425
+ if not releases:
3426
+ releases = list(cls.objects.all())
3388
3427
  if not releases:
3389
3428
  return None
3390
3429
  return max(releases, key=lambda r: Version(r.version))
@@ -3572,6 +3611,8 @@ class Todo(Entity):
3572
3611
  max_length=200, blank=True, default="", validators=[validate_relative_url]
3573
3612
  )
3574
3613
  request_details = models.TextField(blank=True, default="")
3614
+ generated_for_version = models.CharField(max_length=20, blank=True, default="")
3615
+ generated_for_revision = models.CharField(max_length=40, blank=True, default="")
3575
3616
  done_on = models.DateTimeField(null=True, blank=True)
3576
3617
  on_done_condition = ConditionTextField(blank=True, default="")
3577
3618
 
core/release.py CHANGED
@@ -303,6 +303,47 @@ def _git_remote_url(remote: str = "origin") -> Optional[str]:
303
303
  return (proc.stdout or "").strip() or None
304
304
 
305
305
 
306
+ def _git_tag_commit(tag_name: str) -> Optional[str]:
307
+ """Return the commit referenced by ``tag_name`` in the local repository."""
308
+
309
+ for ref in (f"{tag_name}^{{}}", tag_name):
310
+ proc = subprocess.run(
311
+ ["git", "rev-parse", ref],
312
+ capture_output=True,
313
+ text=True,
314
+ check=False,
315
+ )
316
+ if proc.returncode == 0:
317
+ commit = (proc.stdout or "").strip()
318
+ if commit:
319
+ return commit
320
+ return None
321
+
322
+
323
+ def _git_remote_tag_commit(remote: str, tag_name: str) -> Optional[str]:
324
+ """Return the commit referenced by ``tag_name`` on ``remote`` if it exists."""
325
+
326
+ proc = subprocess.run(
327
+ ["git", "ls-remote", "--tags", remote, tag_name],
328
+ capture_output=True,
329
+ text=True,
330
+ check=False,
331
+ )
332
+ if proc.returncode != 0:
333
+ return None
334
+
335
+ commit = None
336
+ for line in (proc.stdout or "").splitlines():
337
+ parts = line.strip().split()
338
+ if len(parts) != 2:
339
+ continue
340
+ sha, ref = parts
341
+ commit = sha
342
+ if ref.endswith("^{}"):
343
+ return sha
344
+ return commit
345
+
346
+
306
347
  def _remote_with_credentials(url: str, creds: GitCredentials) -> Optional[str]:
307
348
  if not creds.has_auth():
308
349
  return None
@@ -335,6 +376,17 @@ def _push_tag(tag_name: str, package: Package) -> None:
335
376
  _run(["git", "push", "origin", tag_name])
336
377
  return
337
378
  except subprocess.CalledProcessError as exc:
379
+ remote_commit = _git_remote_tag_commit("origin", tag_name)
380
+ local_commit = _git_tag_commit(tag_name)
381
+ if remote_commit:
382
+ if local_commit and remote_commit == local_commit:
383
+ # Another process already pushed the tag; treat as success.
384
+ return
385
+ message = (
386
+ "Git rejected tag {tag} because it already exists on the remote. "
387
+ "Delete the remote tag or choose a new version before retrying."
388
+ ).format(tag=tag_name)
389
+ raise ReleaseError(message) from exc
338
390
  if not _git_authentication_missing(exc):
339
391
  raise
340
392
  auth_error = exc
core/system.py CHANGED
@@ -20,10 +20,18 @@ from django.http import HttpResponseRedirect
20
20
  from django.urls import path, reverse
21
21
  from django.utils import timezone
22
22
  from django.utils.formats import date_format
23
+ from django.utils.html import format_html, format_html_join
23
24
  from django.utils.translation import gettext_lazy as _, ngettext
24
25
 
25
26
  from core.auto_upgrade import AUTO_UPGRADE_TASK_NAME, AUTO_UPGRADE_TASK_PATH
26
27
  from core import changelog as changelog_utils
28
+ from core.release import (
29
+ _git_authentication_missing,
30
+ _git_remote_url,
31
+ _manager_git_credentials,
32
+ _remote_with_credentials,
33
+ )
34
+ from core.tasks import check_github_updates
27
35
  from utils import revision
28
36
 
29
37
 
@@ -173,6 +181,120 @@ def _regenerate_changelog() -> None:
173
181
  changelog_path.write_text(content, encoding="utf-8")
174
182
 
175
183
 
184
+ def _format_git_command_output(
185
+ command: list[str], result: subprocess.CompletedProcess[str]
186
+ ) -> str:
187
+ """Return a readable summary of a git command execution."""
188
+
189
+ command_display = "$ " + " ".join(command)
190
+ message_parts = []
191
+ if result.stdout:
192
+ message_parts.append(result.stdout.strip())
193
+ if result.stderr:
194
+ message_parts.append(result.stderr.strip())
195
+ if result.returncode != 0:
196
+ message_parts.append(f"[exit status {result.returncode}]")
197
+ if message_parts:
198
+ return command_display + "\n" + "\n".join(part for part in message_parts if part)
199
+ return command_display
200
+
201
+
202
+ def _git_status() -> str:
203
+ """Return the repository status after attempting to commit."""
204
+
205
+ status_result = subprocess.run(
206
+ ["git", "status", "--short", "--branch"],
207
+ capture_output=True,
208
+ text=True,
209
+ check=False,
210
+ )
211
+ stdout = status_result.stdout.strip()
212
+ stderr = status_result.stderr.strip()
213
+ if stdout and stderr:
214
+ return stdout + "\n" + stderr
215
+ return stdout or stderr
216
+
217
+
218
+ def _commit_changelog() -> tuple[bool, str, str]:
219
+ """Stage, commit, and push the changelog file."""
220
+
221
+ def _retry_push_with_release_credentials(
222
+ command: list[str],
223
+ result: subprocess.CompletedProcess[str],
224
+ ) -> bool:
225
+ exc = subprocess.CalledProcessError(
226
+ result.returncode,
227
+ command,
228
+ output=result.stdout,
229
+ stderr=result.stderr,
230
+ )
231
+ if not _git_authentication_missing(exc):
232
+ return False
233
+
234
+ creds = _manager_git_credentials()
235
+ if not creds or not creds.has_auth():
236
+ return False
237
+
238
+ remote_url = _git_remote_url("origin")
239
+ if not remote_url:
240
+ return False
241
+
242
+ authed_url = _remote_with_credentials(remote_url, creds)
243
+ if not authed_url:
244
+ return False
245
+
246
+ retry_command = ["git", "push", authed_url]
247
+ retry_result = subprocess.run(
248
+ retry_command,
249
+ capture_output=True,
250
+ text=True,
251
+ check=False,
252
+ )
253
+ formatted_retry = _format_git_command_output(retry_command, retry_result)
254
+ if formatted_retry:
255
+ outputs.append(formatted_retry)
256
+ logger.info(
257
+ "Executed %s with exit code %s",
258
+ retry_command,
259
+ retry_result.returncode,
260
+ )
261
+ return retry_result.returncode == 0
262
+
263
+ git_commands: list[list[str]] = [
264
+ ["git", "add", "CHANGELOG.rst"],
265
+ [
266
+ "git",
267
+ "commit",
268
+ "-m",
269
+ "chore: update changelog",
270
+ "--",
271
+ "CHANGELOG.rst",
272
+ ],
273
+ ["git", "push"],
274
+ ]
275
+ outputs: list[str] = []
276
+ success = True
277
+
278
+ for command in git_commands:
279
+ result = subprocess.run(
280
+ command, capture_output=True, text=True, check=False
281
+ )
282
+ formatted = _format_git_command_output(command, result)
283
+ outputs.append(formatted)
284
+ logger.info("Executed %s with exit code %s", command, result.returncode)
285
+ if result.returncode != 0:
286
+ if command[:2] == ["git", "push"] and _retry_push_with_release_credentials(
287
+ command, result
288
+ ):
289
+ continue
290
+ success = False
291
+ break
292
+
293
+ command_output = "\n\n".join(output for output in outputs if output)
294
+ repo_status = _git_status()
295
+ return success, command_output, repo_status
296
+
297
+
176
298
  @dataclass(frozen=True)
177
299
  class SystemField:
178
300
  """Metadata describing a single entry on the system admin page."""
@@ -716,7 +838,14 @@ def _gather_info() -> dict:
716
838
  info["service"] = service_file.read_text().strip() if service_file.exists() else ""
717
839
 
718
840
  mode_file = lock_dir / "nginx_mode.lck"
719
- mode = mode_file.read_text().strip() if mode_file.exists() else "internal"
841
+ if mode_file.exists():
842
+ try:
843
+ raw_mode = mode_file.read_text().strip()
844
+ except OSError:
845
+ raw_mode = ""
846
+ else:
847
+ raw_mode = ""
848
+ mode = raw_mode.lower() or "internal"
720
849
  info["mode"] = mode
721
850
  default_port = 8000 if mode == "public" else 8888
722
851
  detected_port: int | None = None
@@ -882,6 +1011,36 @@ def _system_changelog_report_view(request):
882
1011
  request,
883
1012
  _("Select at least one changelog entry to exclude."),
884
1013
  )
1014
+ elif action == "commit":
1015
+ success, command_output, repo_status = _commit_changelog()
1016
+ details: list[str] = []
1017
+ if command_output:
1018
+ details.append(
1019
+ format_html(
1020
+ "<div class=\"changelog-git-output\"><strong>{}</strong><pre>{}</pre></div>",
1021
+ _("Command log"),
1022
+ command_output,
1023
+ )
1024
+ )
1025
+ if repo_status:
1026
+ details.append(
1027
+ format_html(
1028
+ "<div class=\"changelog-git-status\"><strong>{}</strong><pre>{}</pre></div>",
1029
+ _("Repository status"),
1030
+ repo_status,
1031
+ )
1032
+ )
1033
+ details_html = (
1034
+ format_html_join("", "{}", ((detail,) for detail in details))
1035
+ if details
1036
+ else ""
1037
+ )
1038
+ if success:
1039
+ base_message = _("Committed the changelog and pushed to the current branch.")
1040
+ messages.success(request, format_html("{}{}", base_message, details_html))
1041
+ else:
1042
+ base_message = _("Unable to commit the changelog.")
1043
+ messages.error(request, format_html("{}{}", base_message, details_html))
885
1044
  else:
886
1045
  try:
887
1046
  _regenerate_changelog()
@@ -927,6 +1086,49 @@ def _system_upgrade_report_view(request):
927
1086
  return TemplateResponse(request, "admin/system_upgrade_report.html", context)
928
1087
 
929
1088
 
1089
+ def _trigger_upgrade_check() -> bool:
1090
+ """Return ``True`` when the upgrade check was queued asynchronously."""
1091
+
1092
+ try:
1093
+ check_github_updates.delay()
1094
+ except Exception:
1095
+ logger.exception("Failed to enqueue upgrade check; running synchronously instead")
1096
+ check_github_updates()
1097
+ return False
1098
+ return True
1099
+
1100
+
1101
+ def _system_trigger_upgrade_check_view(request):
1102
+ if request.method != "POST":
1103
+ return HttpResponseRedirect(reverse("admin:system-upgrade-report"))
1104
+
1105
+ try:
1106
+ queued = _trigger_upgrade_check()
1107
+ except Exception as exc: # pragma: no cover - unexpected failure
1108
+ logger.exception("Unable to trigger upgrade check")
1109
+ messages.error(
1110
+ request,
1111
+ _("Unable to trigger an upgrade check: %(error)s")
1112
+ % {"error": str(exc)},
1113
+ )
1114
+ else:
1115
+ if queued:
1116
+ messages.success(
1117
+ request,
1118
+ _("Upgrade check requested. The task will run shortly."),
1119
+ )
1120
+ else:
1121
+ messages.success(
1122
+ request,
1123
+ _(
1124
+ "Upgrade check started locally. Review the auto-upgrade log for"
1125
+ " progress."
1126
+ ),
1127
+ )
1128
+
1129
+ return HttpResponseRedirect(reverse("admin:system-upgrade-report"))
1130
+
1131
+
930
1132
  def patch_admin_system_view() -> None:
931
1133
  """Add custom admin view for system information."""
932
1134
  original_get_urls = admin.site.get_urls
@@ -945,6 +1147,11 @@ def patch_admin_system_view() -> None:
945
1147
  admin.site.admin_view(_system_upgrade_report_view),
946
1148
  name="system-upgrade-report",
947
1149
  ),
1150
+ path(
1151
+ "system/upgrade-report/run-check/",
1152
+ admin.site.admin_view(_system_trigger_upgrade_check_view),
1153
+ name="system-upgrade-run-check",
1154
+ ),
948
1155
  ]
949
1156
  return custom + urls
950
1157
 
core/tasks.py CHANGED
@@ -132,11 +132,15 @@ def check_github_updates() -> None:
132
132
  mode = "version"
133
133
  if mode_file.exists():
134
134
  try:
135
- mode = mode_file.read_text().strip() or "version"
135
+ raw_mode = mode_file.read_text().strip()
136
136
  except (OSError, UnicodeDecodeError):
137
137
  logger.warning(
138
138
  "Failed to read auto-upgrade mode lockfile", exc_info=True
139
139
  )
140
+ else:
141
+ cleaned_mode = raw_mode.lower()
142
+ if cleaned_mode:
143
+ mode = cleaned_mode
140
144
 
141
145
  branch = "main"
142
146
  subprocess.run(["git", "fetch", "origin", branch], cwd=base_dir, check=True)
core/test_system_info.py CHANGED
@@ -55,6 +55,22 @@ class SystemInfoScreenModeTests(SimpleTestCase):
55
55
  lock_dir.rmdir()
56
56
 
57
57
 
58
+ class SystemInfoModeTests(SimpleTestCase):
59
+ def test_public_mode_case_insensitive(self):
60
+ lock_dir = Path(settings.BASE_DIR) / "locks"
61
+ lock_dir.mkdir(exist_ok=True)
62
+ lock_file = lock_dir / "nginx_mode.lck"
63
+ lock_file.write_text("PUBLIC", encoding="utf-8")
64
+ try:
65
+ info = _gather_info()
66
+ self.assertEqual(info["mode"], "public")
67
+ self.assertEqual(info["port"], 8000)
68
+ finally:
69
+ lock_file.unlink()
70
+ if not any(lock_dir.iterdir()):
71
+ lock_dir.rmdir()
72
+
73
+
58
74
  class SystemInfoRevisionTests(SimpleTestCase):
59
75
  @patch("core.system.revision.get_revision", return_value="abcdef1234567890")
60
76
  def test_includes_full_revision(self, mock_revision):