arthexis 0.1.14__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.
core/system.py CHANGED
@@ -10,17 +10,20 @@ import re
10
10
  import socket
11
11
  import subprocess
12
12
  import shutil
13
+ import logging
13
14
  from typing import Callable, Iterable, Optional
14
15
 
15
16
  from django.conf import settings
16
- from django.contrib import admin
17
+ from django.contrib import admin, messages
17
18
  from django.template.response import TemplateResponse
18
- from django.urls import path
19
+ from django.http import HttpResponseRedirect
20
+ from django.urls import path, reverse
19
21
  from django.utils import timezone
20
22
  from django.utils.formats import date_format
21
- from django.utils.translation import gettext_lazy as _
23
+ from django.utils.translation import gettext_lazy as _, ngettext
22
24
 
23
25
  from core.auto_upgrade import AUTO_UPGRADE_TASK_NAME, AUTO_UPGRADE_TASK_PATH
26
+ from core import changelog as changelog_utils
24
27
  from utils import revision
25
28
 
26
29
 
@@ -29,6 +32,9 @@ AUTO_UPGRADE_SKIP_LOCK_NAME = "auto_upgrade_skip_revisions.lck"
29
32
  AUTO_UPGRADE_LOG_NAME = "auto-upgrade.log"
30
33
 
31
34
 
35
+ logger = logging.getLogger(__name__)
36
+
37
+
32
38
  def _auto_upgrade_mode_file(base_dir: Path) -> Path:
33
39
  return base_dir / "locks" / AUTO_UPGRADE_LOCK_NAME
34
40
 
@@ -41,6 +47,132 @@ def _auto_upgrade_log_file(base_dir: Path) -> Path:
41
47
  return base_dir / "logs" / AUTO_UPGRADE_LOG_NAME
42
48
 
43
49
 
50
+ def _open_changelog_entries() -> list[dict[str, str]]:
51
+ """Return changelog entries that are not yet part of a tagged release."""
52
+
53
+ changelog_path = Path("CHANGELOG.rst")
54
+ try:
55
+ text = changelog_path.read_text(encoding="utf-8")
56
+ except FileNotFoundError:
57
+ return []
58
+ except OSError:
59
+ return []
60
+
61
+ collecting = False
62
+ entries: list[dict[str, str]] = []
63
+ for raw_line in text.splitlines():
64
+ line = raw_line.strip()
65
+ if not collecting:
66
+ if line == "Unreleased":
67
+ collecting = True
68
+ continue
69
+
70
+ if not line:
71
+ if entries:
72
+ break
73
+ continue
74
+
75
+ if set(line) == {"-"}:
76
+ # Underline immediately following the section heading.
77
+ continue
78
+
79
+ if not line.startswith("- "):
80
+ break
81
+
82
+ trimmed = line[2:].strip()
83
+ if not trimmed:
84
+ continue
85
+ parts = trimmed.split(" ", 1)
86
+ sha = parts[0]
87
+ message = parts[1] if len(parts) > 1 else ""
88
+ entries.append({"sha": sha, "message": message})
89
+
90
+ return entries
91
+
92
+
93
+ def _exclude_changelog_entries(shas: Iterable[str]) -> int:
94
+ """Remove entries matching ``shas`` from the changelog.
95
+
96
+ Returns the number of entries removed. Only entries within the
97
+ ``Unreleased`` section are considered.
98
+ """
99
+
100
+ normalized_shas = {sha.strip() for sha in shas if sha and sha.strip()}
101
+ if not normalized_shas:
102
+ return 0
103
+
104
+ changelog_path = Path("CHANGELOG.rst")
105
+ try:
106
+ text = changelog_path.read_text(encoding="utf-8")
107
+ except (FileNotFoundError, OSError):
108
+ return 0
109
+
110
+ lines = text.splitlines(keepends=True)
111
+ new_lines: list[str] = []
112
+ collecting = False
113
+ removed = 0
114
+
115
+ for raw_line in lines:
116
+ stripped = raw_line.strip()
117
+
118
+ if not collecting:
119
+ new_lines.append(raw_line)
120
+ if stripped == "Unreleased":
121
+ collecting = True
122
+ continue
123
+
124
+ if not stripped:
125
+ new_lines.append(raw_line)
126
+ continue
127
+
128
+ if set(stripped) == {"-"}:
129
+ new_lines.append(raw_line)
130
+ continue
131
+
132
+ if not stripped.startswith("- "):
133
+ new_lines.append(raw_line)
134
+ collecting = False
135
+ continue
136
+
137
+ trimmed = stripped[2:].strip()
138
+ if not trimmed:
139
+ new_lines.append(raw_line)
140
+ continue
141
+
142
+ sha = trimmed.split(" ", 1)[0]
143
+ if sha in normalized_shas:
144
+ removed += 1
145
+ normalized_shas.remove(sha)
146
+ continue
147
+
148
+ new_lines.append(raw_line)
149
+
150
+ if removed:
151
+ new_text = "".join(new_lines)
152
+ if not new_text.endswith("\n"):
153
+ new_text += "\n"
154
+ changelog_path.write_text(new_text, encoding="utf-8")
155
+
156
+ return removed
157
+
158
+
159
+ def _regenerate_changelog() -> None:
160
+ """Rebuild the changelog file using recent git commits."""
161
+
162
+ changelog_path = Path("CHANGELOG.rst")
163
+ previous_text = (
164
+ changelog_path.read_text(encoding="utf-8") if changelog_path.exists() else None
165
+ )
166
+ range_spec = changelog_utils.determine_range_spec()
167
+ sections = changelog_utils.collect_sections(
168
+ range_spec=range_spec, previous_text=previous_text
169
+ )
170
+ content = changelog_utils.render_changelog(sections)
171
+ if not content.endswith("\n"):
172
+ content += "\n"
173
+ changelog_path.write_text(content, encoding="utf-8")
174
+
175
+
44
176
  @dataclass(frozen=True)
45
177
  class SystemField:
46
178
  """Metadata describing a single entry on the system admin page."""
@@ -721,6 +853,69 @@ def _system_view(request):
721
853
  return TemplateResponse(request, "admin/system.html", context)
722
854
 
723
855
 
856
+ def _system_changelog_report_view(request):
857
+ if request.method == "POST":
858
+ action = request.POST.get("action")
859
+ if action == "exclude":
860
+ selected_shas = request.POST.getlist("selected_shas")
861
+ removed = _exclude_changelog_entries(selected_shas)
862
+ if removed:
863
+ messages.success(
864
+ request,
865
+ ngettext(
866
+ "Excluded %(count)d changelog entry.",
867
+ "Excluded %(count)d changelog entries.",
868
+ removed,
869
+ )
870
+ % {"count": removed},
871
+ )
872
+ else:
873
+ if selected_shas:
874
+ messages.info(
875
+ request,
876
+ _(
877
+ "The selected changelog entries were not found or have already been excluded."
878
+ ),
879
+ )
880
+ else:
881
+ messages.info(
882
+ request,
883
+ _("Select at least one changelog entry to exclude."),
884
+ )
885
+ else:
886
+ try:
887
+ _regenerate_changelog()
888
+ except subprocess.CalledProcessError as exc:
889
+ logger.exception("Changelog regeneration failed")
890
+ messages.error(
891
+ request,
892
+ _("Unable to recalculate the changelog: %(error)s")
893
+ % {"error": exc.stderr.strip() if exc.stderr else str(exc)},
894
+ )
895
+ except Exception as exc: # pragma: no cover - unexpected failure
896
+ logger.exception("Unexpected error while regenerating changelog")
897
+ messages.error(
898
+ request,
899
+ _("Unable to recalculate the changelog: %(error)s")
900
+ % {"error": str(exc)},
901
+ )
902
+ else:
903
+ messages.success(
904
+ request,
905
+ _("Successfully recalculated the changelog from recent commits."),
906
+ )
907
+ return HttpResponseRedirect(reverse("admin:system-changelog-report"))
908
+
909
+ context = admin.site.each_context(request)
910
+ context.update(
911
+ {
912
+ "title": _("Changelog Report"),
913
+ "open_changelog_entries": _open_changelog_entries(),
914
+ }
915
+ )
916
+ return TemplateResponse(request, "admin/system_changelog_report.html", context)
917
+
918
+
724
919
  def _system_upgrade_report_view(request):
725
920
  context = admin.site.each_context(request)
726
921
  context.update(
@@ -740,6 +935,11 @@ def patch_admin_system_view() -> None:
740
935
  urls = original_get_urls()
741
936
  custom = [
742
937
  path("system/", admin.site.admin_view(_system_view), name="system"),
938
+ path(
939
+ "system/changelog-report/",
940
+ admin.site.admin_view(_system_changelog_report_view),
941
+ name="system-changelog-report",
942
+ ),
743
943
  path(
744
944
  "system/upgrade-report/",
745
945
  admin.site.admin_view(_system_upgrade_report_view),
core/tests.py CHANGED
@@ -1051,6 +1051,79 @@ class ReleaseProcessTests(TestCase):
1051
1051
  requests_get.assert_called_once()
1052
1052
  sync_main.assert_called_once_with(Path("rel.log"))
1053
1053
 
1054
+ @mock.patch("core.views.release_utils.network_available", return_value=False)
1055
+ @mock.patch("core.views._collect_dirty_files")
1056
+ @mock.patch("core.views._sync_with_origin_main")
1057
+ @mock.patch("core.views.subprocess.run")
1058
+ @mock.patch("core.views.release_utils._git_clean", return_value=False)
1059
+ def test_step_check_commits_release_prep_changes(
1060
+ self,
1061
+ git_clean,
1062
+ subprocess_run,
1063
+ sync_main,
1064
+ collect_dirty,
1065
+ network_available,
1066
+ ):
1067
+ fixture_path = next(Path("core/fixtures").glob("releases__*.json"))
1068
+ collect_dirty.return_value = [
1069
+ {
1070
+ "path": str(fixture_path),
1071
+ "status": "M",
1072
+ "status_label": "Modified",
1073
+ },
1074
+ {"path": "CHANGELOG.rst", "status": "M", "status_label": "Modified"},
1075
+ ]
1076
+ subprocess_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
1077
+
1078
+ ctx: dict[str, object] = {}
1079
+ _step_check_version(self.release, ctx, Path("rel.log"))
1080
+
1081
+ add_call = mock.call(
1082
+ ["git", "add", str(fixture_path), "CHANGELOG.rst"],
1083
+ check=True,
1084
+ )
1085
+ commit_call = mock.call(
1086
+ [
1087
+ "git",
1088
+ "commit",
1089
+ "-m",
1090
+ "chore: sync release fixtures and changelog",
1091
+ ],
1092
+ check=True,
1093
+ )
1094
+ self.assertIn(add_call, subprocess_run.call_args_list)
1095
+ self.assertIn(commit_call, subprocess_run.call_args_list)
1096
+ self.assertNotIn("dirty_files", ctx)
1097
+
1098
+ @mock.patch("core.views.release_utils.network_available", return_value=False)
1099
+ @mock.patch("core.views._collect_dirty_files")
1100
+ @mock.patch("core.views._sync_with_origin_main")
1101
+ @mock.patch("core.views.subprocess.run")
1102
+ @mock.patch("core.views.release_utils._git_clean", return_value=False)
1103
+ def test_step_check_commits_changelog_only(
1104
+ self,
1105
+ git_clean,
1106
+ subprocess_run,
1107
+ sync_main,
1108
+ collect_dirty,
1109
+ network_available,
1110
+ ):
1111
+ collect_dirty.return_value = [
1112
+ {"path": "CHANGELOG.rst", "status": "M", "status_label": "Modified"}
1113
+ ]
1114
+ subprocess_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
1115
+
1116
+ ctx: dict[str, object] = {}
1117
+ _step_check_version(self.release, ctx, Path("rel.log"))
1118
+
1119
+ subprocess_run.assert_any_call(
1120
+ ["git", "add", "CHANGELOG.rst"], check=True
1121
+ )
1122
+ subprocess_run.assert_any_call(
1123
+ ["git", "commit", "-m", "docs: refresh changelog"], check=True
1124
+ )
1125
+ self.assertNotIn("dirty_files", ctx)
1126
+
1054
1127
  @mock.patch("core.models.PackageRelease.dump_fixture")
1055
1128
  def test_save_does_not_dump_fixture(self, dump):
1056
1129
  self.release.pypi_url = "https://example.com"
core/views.py CHANGED
@@ -859,7 +859,12 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
859
859
  for f in files
860
860
  if "fixtures" in Path(f).parts and Path(f).suffix == ".json"
861
861
  ]
862
- if files and len(fixture_files) == len(files):
862
+ changelog_dirty = "CHANGELOG.rst" in files
863
+ allowed_dirty_files = set(fixture_files)
864
+ if changelog_dirty:
865
+ allowed_dirty_files.add("CHANGELOG.rst")
866
+
867
+ if files and len(allowed_dirty_files) == len(files):
863
868
  summary = []
864
869
  for f in fixture_files:
865
870
  path = Path(f)
@@ -887,15 +892,36 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
887
892
  summary.append({"path": f, "count": count, "models": models})
888
893
 
889
894
  ctx["fixtures"] = summary
895
+ commit_paths = [*fixture_files]
896
+ if changelog_dirty:
897
+ commit_paths.append("CHANGELOG.rst")
898
+
899
+ log_fragments = []
900
+ if fixture_files:
901
+ log_fragments.append(
902
+ "fixtures " + ", ".join(fixture_files)
903
+ )
904
+ if changelog_dirty:
905
+ log_fragments.append("CHANGELOG.rst")
906
+ details = ", ".join(log_fragments) if log_fragments else "changes"
890
907
  _append_log(
891
908
  log_path,
892
- "Committing fixture changes: " + ", ".join(fixture_files),
909
+ f"Committing release prep changes: {details}",
893
910
  )
894
- subprocess.run(["git", "add", *fixture_files], check=True)
895
- subprocess.run(
896
- ["git", "commit", "-m", "chore: update fixtures"], check=True
911
+ subprocess.run(["git", "add", *commit_paths], check=True)
912
+
913
+ if changelog_dirty and fixture_files:
914
+ commit_message = "chore: sync release fixtures and changelog"
915
+ elif changelog_dirty:
916
+ commit_message = "docs: refresh changelog"
917
+ else:
918
+ commit_message = "chore: update fixtures"
919
+
920
+ subprocess.run(["git", "commit", "-m", commit_message], check=True)
921
+ _append_log(
922
+ log_path,
923
+ f"Release prep changes committed ({commit_message})",
897
924
  )
898
- _append_log(log_path, "Fixture changes committed")
899
925
  ctx.pop("dirty_files", None)
900
926
  ctx.pop("dirty_commit_error", None)
901
927
  retry_sync = True
nodes/models.py CHANGED
@@ -16,7 +16,6 @@ import base64
16
16
  from django.utils import timezone
17
17
  from django.utils.text import slugify
18
18
  from django.conf import settings
19
- from django.contrib.sites.models import Site
20
19
  from datetime import timedelta
21
20
  import uuid
22
21
  import os
@@ -344,7 +343,6 @@ class Node(Entity):
344
343
  if terminal:
345
344
  node.role = terminal
346
345
  node.save(update_fields=["role"])
347
- Site.objects.get_or_create(domain=hostname, defaults={"name": "host"})
348
346
  node.ensure_keys()
349
347
  node.notify_peers_of_update()
350
348
  return node, created
@@ -745,8 +743,10 @@ class Node(Entity):
745
743
  def sync_feature_tasks(self):
746
744
  clipboard_enabled = self.has_feature("clipboard-poll")
747
745
  screenshot_enabled = self.has_feature("screenshot-poll")
746
+ celery_enabled = self.is_local and self.has_feature("celery-queue")
748
747
  self._sync_clipboard_task(clipboard_enabled)
749
748
  self._sync_screenshot_task(screenshot_enabled)
749
+ self._sync_landing_lead_task(celery_enabled)
750
750
 
751
751
  def _sync_clipboard_task(self, enabled: bool):
752
752
  from django_celery_beat.models import IntervalSchedule, PeriodicTask
@@ -792,6 +792,33 @@ class Node(Entity):
792
792
  else:
793
793
  PeriodicTask.objects.filter(name=task_name).delete()
794
794
 
795
+ def _sync_landing_lead_task(self, enabled: bool):
796
+ if not self.is_local:
797
+ return
798
+
799
+ from django_celery_beat.models import CrontabSchedule, PeriodicTask
800
+
801
+ task_name = "pages_purge_landing_leads"
802
+ if enabled:
803
+ schedule, _ = CrontabSchedule.objects.get_or_create(
804
+ minute="0",
805
+ hour="3",
806
+ day_of_week="*",
807
+ day_of_month="*",
808
+ month_of_year="*",
809
+ )
810
+ PeriodicTask.objects.update_or_create(
811
+ name=task_name,
812
+ defaults={
813
+ "crontab": schedule,
814
+ "interval": None,
815
+ "task": "pages.tasks.purge_expired_landing_leads",
816
+ "enabled": True,
817
+ },
818
+ )
819
+ else:
820
+ PeriodicTask.objects.filter(name=task_name).delete()
821
+
795
822
  def send_mail(
796
823
  self,
797
824
  subject: str,
nodes/tests.py CHANGED
@@ -35,7 +35,6 @@ from django.urls import reverse
35
35
  from django.contrib.auth import get_user_model
36
36
  from django.contrib import admin
37
37
  from django.contrib.auth.models import Permission
38
- from django.contrib.sites.models import Site
39
38
  from django_celery_beat.models import IntervalSchedule, PeriodicTask
40
39
  from django.conf import settings
41
40
  from django.utils import timezone
@@ -1271,6 +1270,29 @@ class NodeRegisterCurrentTests(TestCase):
1271
1270
  NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
1272
1271
  self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
1273
1272
 
1273
+ def test_landing_lead_purge_task_syncs_with_celery_feature(self):
1274
+ feature, _ = NodeFeature.objects.get_or_create(
1275
+ slug="celery-queue", defaults={"display": "Celery Queue"}
1276
+ )
1277
+ node, _ = Node.objects.update_or_create(
1278
+ mac_address=Node.get_current_mac(),
1279
+ defaults={
1280
+ "hostname": socket.gethostname(),
1281
+ "address": "127.0.0.1",
1282
+ "port": 9300,
1283
+ "base_path": settings.BASE_DIR,
1284
+ },
1285
+ )
1286
+ PeriodicTask.objects.filter(name="pages_purge_landing_leads").delete()
1287
+ NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
1288
+ self.assertTrue(
1289
+ PeriodicTask.objects.filter(name="pages_purge_landing_leads").exists()
1290
+ )
1291
+ NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
1292
+ self.assertFalse(
1293
+ PeriodicTask.objects.filter(name="pages_purge_landing_leads").exists()
1294
+ )
1295
+
1274
1296
 
1275
1297
  class CheckRegistrationReadyCommandTests(TestCase):
1276
1298
  def test_command_completes_successfully(self):
@@ -1357,8 +1379,6 @@ class NodeAdminTests(TestCase):
1357
1379
  self.assertTrue(priv.exists())
1358
1380
  self.assertTrue(pub.exists())
1359
1381
  self.assertTrue(node.public_key)
1360
- self.assertTrue(Site.objects.filter(domain=hostname, name="host").exists())
1361
-
1362
1382
  def test_register_current_updates_existing_node(self):
1363
1383
  hostname = socket.gethostname()
1364
1384
  Node.objects.create(
pages/admin.py CHANGED
@@ -18,11 +18,13 @@ import ipaddress
18
18
  from django.apps import apps as django_apps
19
19
  from django.conf import settings
20
20
  from django.utils.translation import gettext_lazy as _, ngettext
21
+ from django.core.management import CommandError, call_command
21
22
 
22
23
  from nodes.models import Node
23
24
  from nodes.utils import capture_screenshot, save_screenshot
24
25
 
25
26
  from .forms import UserManualAdminForm
27
+ from .utils import landing_leads_supported
26
28
 
27
29
  from .models import (
28
30
  SiteBadge,
@@ -107,6 +109,49 @@ class SiteAdmin(DjangoSiteAdmin):
107
109
  messages.INFO,
108
110
  )
109
111
 
112
+ def _reload_site_fixtures(self, request):
113
+ fixtures_dir = Path(settings.BASE_DIR) / "core" / "fixtures"
114
+ fixture_paths = sorted(fixtures_dir.glob("references__00_site_*.json"))
115
+ sigil_fixture = fixtures_dir / "sigil_roots__site.json"
116
+ if sigil_fixture.exists():
117
+ fixture_paths.append(sigil_fixture)
118
+
119
+ if not fixture_paths:
120
+ self.message_user(request, _("No site fixtures found."), messages.WARNING)
121
+ return None
122
+
123
+ loaded = 0
124
+ for path in fixture_paths:
125
+ try:
126
+ call_command("loaddata", str(path), verbosity=0)
127
+ except CommandError as exc:
128
+ self.message_user(
129
+ request,
130
+ _("%(fixture)s: %(error)s")
131
+ % {"fixture": path.name, "error": exc},
132
+ messages.ERROR,
133
+ )
134
+ else:
135
+ loaded += 1
136
+
137
+ if loaded:
138
+ message = ngettext(
139
+ "Reloaded %(count)d site fixture.",
140
+ "Reloaded %(count)d site fixtures.",
141
+ loaded,
142
+ ) % {"count": loaded}
143
+ self.message_user(request, message, messages.SUCCESS)
144
+
145
+ return None
146
+
147
+ def reload_site_fixtures(self, request):
148
+ if request.method != "POST":
149
+ return redirect("..")
150
+
151
+ self._reload_site_fixtures(request)
152
+
153
+ return redirect("..")
154
+
110
155
  def get_urls(self):
111
156
  urls = super().get_urls()
112
157
  custom = [
@@ -114,7 +159,12 @@ class SiteAdmin(DjangoSiteAdmin):
114
159
  "register-current/",
115
160
  self.admin_site.admin_view(self.register_current),
116
161
  name="pages_siteproxy_register_current",
117
- )
162
+ ),
163
+ path(
164
+ "reload-site-fixtures/",
165
+ self.admin_site.admin_view(self.reload_site_fixtures),
166
+ name="pages_siteproxy_reload_site_fixtures",
167
+ ),
118
168
  ]
119
169
  return custom + urls
120
170
 
@@ -237,6 +287,17 @@ class LandingLeadAdmin(EntityModelAdmin):
237
287
  ordering = ("-created_on",)
238
288
  date_hierarchy = "created_on"
239
289
 
290
+ def changelist_view(self, request, extra_context=None):
291
+ if not landing_leads_supported():
292
+ self.message_user(
293
+ request,
294
+ _(
295
+ "Landing leads are not being recorded because Celery is not running on this node."
296
+ ),
297
+ messages.WARNING,
298
+ )
299
+ return super().changelist_view(request, extra_context=extra_context)
300
+
240
301
  @admin.display(description=_("Landing"), ordering="landing__label")
241
302
  def landing_label(self, obj):
242
303
  return obj.landing.label
pages/middleware.py CHANGED
@@ -10,6 +10,7 @@ from django.conf import settings
10
10
  from django.urls import Resolver404, resolve
11
11
 
12
12
  from .models import Landing, LandingLead, ViewHistory
13
+ from .utils import landing_leads_supported
13
14
 
14
15
 
15
16
  logger = logging.getLogger(__name__)
@@ -125,6 +126,9 @@ class ViewHistoryMiddleware:
125
126
  if request.method.upper() != "GET":
126
127
  return
127
128
 
129
+ if not landing_leads_supported():
130
+ return
131
+
128
132
  referer = request.META.get("HTTP_REFERER", "") or ""
129
133
  user_agent = request.META.get("HTTP_USER_AGENT", "") or ""
130
134
  ip_address = self._extract_client_ip(request) or None
pages/models.py CHANGED
@@ -1,3 +1,6 @@
1
+ import logging
2
+ from pathlib import Path
3
+
1
4
  from django.db import models
2
5
  from django.db.models import Q
3
6
  from core.entity import Entity
@@ -15,6 +18,10 @@ from django.core.validators import MaxLengthValidator, MaxValueValidator, MinVal
15
18
  from django.core.exceptions import ValidationError
16
19
 
17
20
  from core import github_issues
21
+ from .tasks import create_user_story_github_issue
22
+
23
+
24
+ logger = logging.getLogger(__name__)
18
25
 
19
26
 
20
27
  class ApplicationManager(models.Manager):
@@ -599,6 +606,35 @@ from django.db.models.signals import post_save
599
606
  from django.dispatch import receiver
600
607
 
601
608
 
609
+ def _celery_lock_path() -> Path:
610
+ return Path(settings.BASE_DIR) / "locks" / "celery.lck"
611
+
612
+
613
+ def _is_celery_enabled() -> bool:
614
+ return _celery_lock_path().exists()
615
+
616
+
617
+ @receiver(post_save, sender=UserStory)
618
+ def _queue_low_rating_user_story_issue(
619
+ sender, instance: UserStory, created: bool, raw: bool, **kwargs
620
+ ) -> None:
621
+ if raw or not created:
622
+ return
623
+ if instance.rating >= 5:
624
+ return
625
+ if instance.github_issue_url:
626
+ return
627
+ if not _is_celery_enabled():
628
+ return
629
+
630
+ try:
631
+ create_user_story_github_issue.delay(instance.pk)
632
+ except Exception: # pragma: no cover - logging only
633
+ logger.exception(
634
+ "Failed to enqueue GitHub issue creation for user story %s", instance.pk
635
+ )
636
+
637
+
602
638
  @receiver(post_save, sender=Module)
603
639
  def _create_landings(
604
640
  sender, instance, created, raw, **kwargs