arthexis 0.1.9__py3-none-any.whl → 0.1.10__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/tests.py CHANGED
@@ -17,9 +17,11 @@ import subprocess
17
17
  from glob import glob
18
18
  from datetime import timedelta
19
19
  import tempfile
20
+ from urllib.parse import quote
20
21
 
21
22
  from django.utils import timezone
22
23
  from django.contrib.auth.models import Permission
24
+ from django.contrib.messages import get_messages
23
25
  from .models import (
24
26
  User,
25
27
  UserPhoneNumber,
@@ -51,6 +53,7 @@ from django.core.management import call_command
51
53
  from django.db import IntegrityError
52
54
  from .backends import LocalhostAdminBackend
53
55
  from core.views import _step_check_version, _step_promote_build, _step_publish
56
+ from core import views as core_views
54
57
  from core import public_wifi
55
58
 
56
59
 
@@ -901,6 +904,139 @@ class ReleaseProcessTests(TestCase):
901
904
  self.assertEqual(count_file.read_text(), "1")
902
905
 
903
906
 
907
+ class ReleaseProgressSyncTests(TestCase):
908
+ def setUp(self):
909
+ self.client = Client()
910
+ self.user = User.objects.create_superuser("admin", "admin@example.com", "pw")
911
+ self.client.force_login(self.user)
912
+ self.package = Package.objects.get(name="arthexis")
913
+ self.version_path = Path("VERSION")
914
+ self.original_version = self.version_path.read_text(encoding="utf-8")
915
+ self.version_path.write_text("1.2.3", encoding="utf-8")
916
+
917
+ def tearDown(self):
918
+ self.version_path.write_text(self.original_version, encoding="utf-8")
919
+
920
+ @mock.patch("core.views.PackageRelease.dump_fixture")
921
+ @mock.patch("core.views.revision.get_revision", return_value="abc123")
922
+ def test_unpublished_release_syncs_version_and_revision(
923
+ self, get_revision, dump_fixture
924
+ ):
925
+ release = PackageRelease.objects.create(
926
+ package=self.package,
927
+ version="1.0.0",
928
+ )
929
+ release.revision = "oldrev"
930
+ release.save(update_fields=["revision"])
931
+
932
+ url = reverse("release-progress", args=[release.pk, "publish"])
933
+ response = self.client.get(url)
934
+
935
+ self.assertEqual(response.status_code, 200)
936
+ release.refresh_from_db()
937
+ self.assertEqual(release.version, "1.2.4")
938
+ self.assertEqual(release.revision, "abc123")
939
+ dump_fixture.assert_called_once()
940
+
941
+ def test_published_release_not_current_returns_404(self):
942
+ release = PackageRelease.objects.create(
943
+ package=self.package,
944
+ version="1.2.4",
945
+ pypi_url="https://example.com",
946
+ )
947
+
948
+ url = reverse("release-progress", args=[release.pk, "publish"])
949
+ response = self.client.get(url)
950
+
951
+ self.assertEqual(response.status_code, 404)
952
+
953
+
954
+ class ReleaseProgressFixtureVisibilityTests(TestCase):
955
+ def setUp(self):
956
+ self.client = Client()
957
+ self.user = User.objects.create_superuser(
958
+ "fixture-check", "fixture@example.com", "pw"
959
+ )
960
+ self.client.force_login(self.user)
961
+ current_version = Path("VERSION").read_text(encoding="utf-8").strip()
962
+ package = Package.objects.filter(is_active=True).first()
963
+ if package is None:
964
+ package = Package.objects.create(name="fixturepkg", is_active=True)
965
+ try:
966
+ self.release = PackageRelease.objects.get(
967
+ package=package, version=current_version
968
+ )
969
+ except PackageRelease.DoesNotExist:
970
+ self.release = PackageRelease.objects.create(
971
+ package=package, version=current_version
972
+ )
973
+ self.session_key = f"release_publish_{self.release.pk}"
974
+ self.log_name = f"{self.release.package.name}-{self.release.version}.log"
975
+ self.lock_path = Path("locks") / f"{self.session_key}.json"
976
+ self.restart_path = Path("locks") / f"{self.session_key}.restarts"
977
+ self.log_path = Path("logs") / self.log_name
978
+ for path in (self.lock_path, self.restart_path, self.log_path):
979
+ if path.exists():
980
+ path.unlink()
981
+ try:
982
+ self.fixture_step_index = next(
983
+ idx
984
+ for idx, (name, _) in enumerate(core_views.PUBLISH_STEPS)
985
+ if name == core_views.FIXTURE_REVIEW_STEP_NAME
986
+ )
987
+ except StopIteration: # pragma: no cover - defensive guard
988
+ self.fail("Fixture review step not configured in publish steps")
989
+ self.url = reverse("release-progress", args=[self.release.pk, "publish"])
990
+
991
+ def tearDown(self):
992
+ session = self.client.session
993
+ if self.session_key in session:
994
+ session.pop(self.session_key)
995
+ session.save()
996
+ for path in (self.lock_path, self.restart_path, self.log_path):
997
+ if path.exists():
998
+ path.unlink()
999
+ super().tearDown()
1000
+
1001
+ def _set_session(self, step: int, fixtures: list[dict]):
1002
+ session = self.client.session
1003
+ session[self.session_key] = {
1004
+ "step": step,
1005
+ "fixtures": fixtures,
1006
+ "log": self.log_name,
1007
+ "started": True,
1008
+ }
1009
+ session.save()
1010
+
1011
+ def test_fixture_summary_visible_until_migration_step(self):
1012
+ fixtures = [
1013
+ {
1014
+ "path": "core/fixtures/example.json",
1015
+ "count": 2,
1016
+ "models": ["core.Model"],
1017
+ }
1018
+ ]
1019
+ self._set_session(self.fixture_step_index, fixtures)
1020
+ response = self.client.get(self.url)
1021
+ self.assertEqual(response.status_code, 200)
1022
+ self.assertEqual(response.context["fixtures"], fixtures)
1023
+ self.assertContains(response, "Fixture changes")
1024
+
1025
+ def test_fixture_summary_hidden_after_migration_step(self):
1026
+ fixtures = [
1027
+ {
1028
+ "path": "core/fixtures/example.json",
1029
+ "count": 2,
1030
+ "models": ["core.Model"],
1031
+ }
1032
+ ]
1033
+ self._set_session(self.fixture_step_index + 1, fixtures)
1034
+ response = self.client.get(self.url)
1035
+ self.assertEqual(response.status_code, 200)
1036
+ self.assertIsNone(response.context["fixtures"])
1037
+ self.assertNotContains(response, "Fixture changes")
1038
+
1039
+
904
1040
  class PackageReleaseAdminActionTests(TestCase):
905
1041
  def setUp(self):
906
1042
  self.factory = RequestFactory()
@@ -982,6 +1118,40 @@ class PackageReleaseCurrentTests(TestCase):
982
1118
  self.assertFalse(self.release.is_current)
983
1119
 
984
1120
 
1121
+ class PackageReleaseChangelistTests(TestCase):
1122
+ def setUp(self):
1123
+ self.client = Client()
1124
+ User.objects.create_superuser("admin", "admin@example.com", "pw")
1125
+ self.client.force_login(User.objects.get(username="admin"))
1126
+
1127
+ def test_prepare_next_release_button_present(self):
1128
+ response = self.client.get(reverse("admin:core_packagerelease_changelist"))
1129
+ prepare_url = reverse(
1130
+ "admin:core_packagerelease_actions", args=["prepare_next_release"]
1131
+ )
1132
+ self.assertContains(response, prepare_url, html=False)
1133
+
1134
+ def test_refresh_from_pypi_button_present(self):
1135
+ response = self.client.get(reverse("admin:core_packagerelease_changelist"))
1136
+ refresh_url = reverse(
1137
+ "admin:core_packagerelease_actions", args=["refresh_from_pypi"]
1138
+ )
1139
+ self.assertContains(response, refresh_url, html=False)
1140
+
1141
+ def test_prepare_next_release_action_creates_release(self):
1142
+ package = Package.objects.get(name="arthexis")
1143
+ PackageRelease.all_objects.filter(package=package).delete()
1144
+ response = self.client.post(
1145
+ reverse(
1146
+ "admin:core_packagerelease_actions", args=["prepare_next_release"]
1147
+ )
1148
+ )
1149
+ self.assertEqual(response.status_code, 302)
1150
+ self.assertTrue(
1151
+ PackageRelease.all_objects.filter(package=package).exists()
1152
+ )
1153
+
1154
+
985
1155
  class PackageAdminPrepareNextReleaseTests(TestCase):
986
1156
  def setUp(self):
987
1157
  self.factory = RequestFactory()
@@ -1000,24 +1170,18 @@ class PackageAdminPrepareNextReleaseTests(TestCase):
1000
1170
  )
1001
1171
 
1002
1172
 
1003
- class PackageReleaseChangelistTests(TestCase):
1173
+ class PackageAdminChangeViewTests(TestCase):
1004
1174
  def setUp(self):
1005
1175
  self.client = Client()
1006
1176
  User.objects.create_superuser("admin", "admin@example.com", "pw")
1007
1177
  self.client.force_login(User.objects.get(username="admin"))
1178
+ self.package = Package.objects.get(name="arthexis")
1008
1179
 
1009
- def test_prepare_next_release_button_present(self):
1010
- response = self.client.get(reverse("admin:core_packagerelease_changelist"))
1011
- self.assertContains(
1012
- response, reverse("admin:core_package_prepare_next_release"), html=False
1180
+ def test_prepare_next_release_button_visible_on_change_view(self):
1181
+ response = self.client.get(
1182
+ reverse("admin:core_package_change", args=[self.package.pk])
1013
1183
  )
1014
-
1015
- def test_refresh_from_pypi_button_present(self):
1016
- response = self.client.get(reverse("admin:core_packagerelease_changelist"))
1017
- refresh_url = reverse(
1018
- "admin:core_packagerelease_actions", args=["refresh_from_pypi"]
1019
- )
1020
- self.assertContains(response, refresh_url, html=False)
1184
+ self.assertContains(response, "Prepare next Release")
1021
1185
 
1022
1186
 
1023
1187
  class TodoDoneTests(TestCase):
@@ -1034,6 +1198,118 @@ class TodoDoneTests(TestCase):
1034
1198
  self.assertIsNotNone(todo.done_on)
1035
1199
  self.assertFalse(todo.is_deleted)
1036
1200
 
1201
+ def test_mark_done_condition_failure_shows_message(self):
1202
+ todo = Todo.objects.create(
1203
+ request="Task",
1204
+ on_done_condition="1 = 0",
1205
+ )
1206
+ resp = self.client.post(reverse("todo-done", args=[todo.pk]))
1207
+ self.assertRedirects(resp, reverse("admin:index"))
1208
+ messages = [m.message for m in get_messages(resp.wsgi_request)]
1209
+ self.assertTrue(messages)
1210
+ self.assertIn("1 = 0", messages[0])
1211
+ todo.refresh_from_db()
1212
+ self.assertIsNone(todo.done_on)
1213
+
1214
+ def test_mark_done_condition_invalid_expression(self):
1215
+ todo = Todo.objects.create(
1216
+ request="Task",
1217
+ on_done_condition="1; SELECT 1",
1218
+ )
1219
+ resp = self.client.post(reverse("todo-done", args=[todo.pk]))
1220
+ self.assertRedirects(resp, reverse("admin:index"))
1221
+ messages = [m.message for m in get_messages(resp.wsgi_request)]
1222
+ self.assertTrue(messages)
1223
+ self.assertIn("Semicolons", messages[0])
1224
+ todo.refresh_from_db()
1225
+ self.assertIsNone(todo.done_on)
1226
+
1227
+ def test_mark_done_condition_resolves_sigils(self):
1228
+ todo = Todo.objects.create(
1229
+ request="Task",
1230
+ on_done_condition="[TEST]",
1231
+ )
1232
+ with mock.patch.object(Todo, "resolve_sigils", return_value="1 = 1") as resolver:
1233
+ resp = self.client.post(reverse("todo-done", args=[todo.pk]))
1234
+ self.assertRedirects(resp, reverse("admin:index"))
1235
+ resolver.assert_called_once_with("on_done_condition")
1236
+ todo.refresh_from_db()
1237
+ self.assertIsNotNone(todo.done_on)
1238
+
1239
+ def test_mark_done_respects_next_parameter(self):
1240
+ todo = Todo.objects.create(request="Task")
1241
+ next_url = reverse("admin:index") + "?section=todos"
1242
+ resp = self.client.post(
1243
+ reverse("todo-done", args=[todo.pk]),
1244
+ {"next": next_url},
1245
+ )
1246
+ self.assertRedirects(resp, next_url, target_status_code=200)
1247
+ todo.refresh_from_db()
1248
+ self.assertIsNotNone(todo.done_on)
1249
+
1250
+ def test_mark_done_rejects_external_next(self):
1251
+ todo = Todo.objects.create(request="Task")
1252
+ resp = self.client.post(
1253
+ reverse("todo-done", args=[todo.pk]),
1254
+ {"next": "https://example.com/"},
1255
+ )
1256
+ self.assertRedirects(resp, reverse("admin:index"))
1257
+ todo.refresh_from_db()
1258
+ self.assertIsNotNone(todo.done_on)
1259
+
1260
+
1261
+ class TodoFocusViewTests(TestCase):
1262
+ def setUp(self):
1263
+ self.client = Client()
1264
+ User.objects.create_superuser("admin", "admin@example.com", "pw")
1265
+ self.client.force_login(User.objects.get(username="admin"))
1266
+
1267
+ def test_focus_view_renders_requested_page(self):
1268
+ todo = Todo.objects.create(request="Task", url="/docs/")
1269
+ next_url = reverse("admin:index")
1270
+ resp = self.client.get(
1271
+ f"{reverse('todo-focus', args=[todo.pk])}?next={quote(next_url)}"
1272
+ )
1273
+ self.assertEqual(resp.status_code, 200)
1274
+ self.assertContains(resp, todo.request)
1275
+ self.assertEqual(resp["X-Frame-Options"], "SAMEORIGIN")
1276
+ self.assertContains(resp, f'src="{todo.url}"')
1277
+ self.assertContains(resp, "Done")
1278
+ self.assertContains(resp, "Back")
1279
+
1280
+ def test_focus_view_uses_admin_change_when_no_url(self):
1281
+ todo = Todo.objects.create(request="Task")
1282
+ resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
1283
+ change_url = reverse("admin:core_todo_change", args=[todo.pk])
1284
+ self.assertContains(resp, f'src="{change_url}"')
1285
+
1286
+ def test_focus_view_sanitizes_loopback_absolute_url(self):
1287
+ todo = Todo.objects.create(
1288
+ request="Task",
1289
+ url="http://127.0.0.1:8000/docs/?section=chart",
1290
+ )
1291
+ resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
1292
+ self.assertContains(resp, 'src="/docs/?section=chart"')
1293
+
1294
+ def test_focus_view_rejects_external_absolute_url(self):
1295
+ todo = Todo.objects.create(
1296
+ request="Task",
1297
+ url="https://outside.invalid/external/",
1298
+ )
1299
+ resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
1300
+ change_url = reverse("admin:core_todo_change", args=[todo.pk])
1301
+ self.assertContains(resp, f'src="{change_url}"')
1302
+
1303
+ def test_focus_view_redirects_if_todo_completed(self):
1304
+ todo = Todo.objects.create(request="Task")
1305
+ todo.done_on = timezone.now()
1306
+ todo.save(update_fields=["done_on"])
1307
+ next_url = reverse("admin:index")
1308
+ resp = self.client.get(
1309
+ f"{reverse('todo-focus', args=[todo.pk])}?next={quote(next_url)}"
1310
+ )
1311
+ self.assertRedirects(resp, next_url, target_status_code=200)
1312
+
1037
1313
 
1038
1314
  class TodoUrlValidationTests(TestCase):
1039
1315
  def test_relative_url_valid(self):
core/user_data.py CHANGED
@@ -16,6 +16,7 @@ from django.dispatch import receiver
16
16
  from django.http import HttpResponse, HttpResponseRedirect
17
17
  from django.template.response import TemplateResponse
18
18
  from django.urls import path, reverse
19
+ from django.utils.functional import LazyObject
19
20
  from django.utils.translation import gettext as _
20
21
 
21
22
  from .entity import Entity
@@ -39,7 +40,14 @@ def _username_for(user) -> str:
39
40
 
40
41
 
41
42
  def _user_allows_user_data(user) -> bool:
42
- return bool(user) and not getattr(user, "is_profile_restricted", False)
43
+ if not user:
44
+ return False
45
+ username = _username_for(user)
46
+ UserModel = get_user_model()
47
+ system_username = getattr(UserModel, "SYSTEM_USERNAME", "")
48
+ if system_username and username == system_username:
49
+ return True
50
+ return not getattr(user, "is_profile_restricted", False)
43
51
 
44
52
 
45
53
  def _data_dir(user) -> Path:
@@ -93,18 +101,68 @@ def _seed_fixture_path(instance) -> Path | None:
93
101
  return None
94
102
 
95
103
 
104
+ def _coerce_user(candidate, user_model):
105
+ if candidate is None:
106
+ return None
107
+ if isinstance(candidate, user_model):
108
+ return candidate
109
+ if isinstance(candidate, LazyObject):
110
+ try:
111
+ candidate._setup()
112
+ except Exception:
113
+ return None
114
+ return _coerce_user(candidate._wrapped, user_model)
115
+ return None
116
+
117
+
118
+ def _select_fixture_user(candidate, user_model):
119
+ user = _coerce_user(candidate, user_model)
120
+ visited: set[int] = set()
121
+ while user is not None:
122
+ identifier = user.pk or id(user)
123
+ if identifier in visited:
124
+ break
125
+ visited.add(identifier)
126
+ username = _username_for(user)
127
+ admin_username = getattr(user_model, "ADMIN_USERNAME", "")
128
+ if admin_username and username == admin_username:
129
+ try:
130
+ delegate = getattr(user, "operate_as", None)
131
+ except user_model.DoesNotExist:
132
+ delegate = None
133
+ else:
134
+ delegate = _coerce_user(delegate, user_model)
135
+ if delegate is not None and delegate is not user:
136
+ user = delegate
137
+ continue
138
+ if _user_allows_user_data(user):
139
+ return user
140
+ try:
141
+ delegate = getattr(user, "operate_as", None)
142
+ except user_model.DoesNotExist:
143
+ delegate = None
144
+ user = _coerce_user(delegate, user_model)
145
+ return None
146
+
147
+
96
148
  def _resolve_fixture_user(instance, fallback=None):
97
149
  UserModel = get_user_model()
98
150
  owner = getattr(instance, "user", None)
99
- if isinstance(owner, UserModel):
100
- return owner
151
+ selected = _select_fixture_user(owner, UserModel)
152
+ if selected is not None:
153
+ return selected
101
154
  if hasattr(instance, "owner"):
102
155
  try:
103
156
  owner_value = instance.owner
104
157
  except Exception:
105
158
  owner_value = None
106
- if isinstance(owner_value, UserModel):
107
- return owner_value
159
+ else:
160
+ selected = _select_fixture_user(owner_value, UserModel)
161
+ if selected is not None:
162
+ return selected
163
+ selected = _select_fixture_user(fallback, UserModel)
164
+ if selected is not None:
165
+ return selected
108
166
  return fallback
109
167
 
110
168
 
@@ -230,6 +288,30 @@ def _is_user_fixture(path: Path) -> bool:
230
288
  return len(parts) >= 2 and parts[1].lower() == "user"
231
289
 
232
290
 
291
+ def _get_request_ip(request) -> str:
292
+ """Return the best-effort client IP for ``request``."""
293
+
294
+ if request is None:
295
+ return ""
296
+
297
+ meta = getattr(request, "META", None)
298
+ if not getattr(meta, "get", None):
299
+ return ""
300
+
301
+ forwarded = meta.get("HTTP_X_FORWARDED_FOR")
302
+ if forwarded:
303
+ for value in str(forwarded).split(","):
304
+ candidate = value.strip()
305
+ if candidate:
306
+ return candidate
307
+
308
+ remote = meta.get("REMOTE_ADDR")
309
+ if remote:
310
+ return str(remote).strip()
311
+
312
+ return ""
313
+
314
+
233
315
  _shared_fixtures_loaded = False
234
316
 
235
317
 
@@ -260,6 +342,18 @@ def load_user_fixtures(user, *, include_shared: bool = False) -> None:
260
342
  def _on_login(sender, request, user, **kwargs):
261
343
  load_user_fixtures(user, include_shared=not _shared_fixtures_loaded)
262
344
 
345
+ if not (
346
+ getattr(user, "is_staff", False) or getattr(user, "is_superuser", False)
347
+ ):
348
+ return
349
+
350
+ username = _username_for(user) or "unknown"
351
+ ip_address = _get_request_ip(request) or "unknown"
352
+
353
+ from nodes.models import NetMessage
354
+
355
+ NetMessage.broadcast(subject=f"login {username}", body=f"@ {ip_address}")
356
+
263
357
 
264
358
  @receiver(post_save, sender=settings.AUTH_USER_MODEL)
265
359
  def _on_user_created(sender, instance, created, **kwargs):
@@ -274,9 +368,7 @@ class UserDatumAdminMixin(admin.ModelAdmin):
274
368
  def render_change_form(
275
369
  self, request, context, add=False, change=False, form_url="", obj=None
276
370
  ):
277
- context["show_user_datum"] = issubclass(
278
- self.model, Entity
279
- ) and _user_allows_user_data(request.user)
371
+ context["show_user_datum"] = issubclass(self.model, Entity)
280
372
  context["show_seed_datum"] = issubclass(self.model, Entity)
281
373
  context["show_save_as_copy"] = issubclass(self.model, Entity) or hasattr(
282
374
  self.model, "clone"
@@ -317,6 +409,9 @@ class EntityModelAdmin(UserDatumAdminMixin, admin.ModelAdmin):
317
409
  )
318
410
  if copied:
319
411
  return
412
+ if getattr(self, "_skip_entity_user_datum", False):
413
+ return
414
+
320
415
  target_user = _resolve_fixture_user(obj, request.user)
321
416
  allow_user_data = _user_allows_user_data(target_user)
322
417
  if request.POST.get("_user_datum") == "on":