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

Files changed (67) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/METADATA +95 -41
  2. arthexis-0.1.28.dist-info/RECORD +112 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +21 -30
  6. config/settings_helpers.py +176 -1
  7. config/urls.py +69 -1
  8. core/admin.py +805 -473
  9. core/apps.py +6 -8
  10. core/auto_upgrade.py +19 -4
  11. core/backends.py +13 -3
  12. core/celery_utils.py +73 -0
  13. core/changelog.py +66 -5
  14. core/environment.py +4 -5
  15. core/models.py +1825 -218
  16. core/notifications.py +1 -1
  17. core/reference_utils.py +10 -11
  18. core/release.py +55 -7
  19. core/sigil_builder.py +2 -2
  20. core/sigil_resolver.py +1 -66
  21. core/system.py +285 -4
  22. core/tasks.py +439 -138
  23. core/test_system_info.py +43 -5
  24. core/tests.py +516 -18
  25. core/user_data.py +94 -21
  26. core/views.py +348 -186
  27. nodes/admin.py +904 -67
  28. nodes/apps.py +12 -1
  29. nodes/feature_checks.py +30 -0
  30. nodes/models.py +800 -127
  31. nodes/rfid_sync.py +1 -1
  32. nodes/tasks.py +98 -3
  33. nodes/tests.py +1381 -152
  34. nodes/urls.py +15 -1
  35. nodes/utils.py +51 -3
  36. nodes/views.py +1382 -152
  37. ocpp/admin.py +1970 -152
  38. ocpp/consumers.py +839 -34
  39. ocpp/models.py +968 -17
  40. ocpp/network.py +398 -0
  41. ocpp/store.py +411 -43
  42. ocpp/tasks.py +261 -3
  43. ocpp/test_export_import.py +1 -0
  44. ocpp/test_rfid.py +194 -6
  45. ocpp/tests.py +1918 -87
  46. ocpp/transactions_io.py +9 -1
  47. ocpp/urls.py +8 -3
  48. ocpp/views.py +700 -53
  49. pages/admin.py +262 -30
  50. pages/apps.py +35 -0
  51. pages/context_processors.py +28 -21
  52. pages/defaults.py +1 -1
  53. pages/forms.py +31 -8
  54. pages/middleware.py +6 -2
  55. pages/models.py +86 -2
  56. pages/module_defaults.py +5 -5
  57. pages/site_config.py +137 -0
  58. pages/tests.py +1050 -126
  59. pages/urls.py +14 -2
  60. pages/utils.py +70 -0
  61. pages/views.py +622 -56
  62. arthexis-0.1.16.dist-info/RECORD +0 -111
  63. core/workgroup_urls.py +0 -17
  64. core/workgroup_views.py +0 -94
  65. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
  66. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
  67. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
core/tests.py CHANGED
@@ -5,9 +5,11 @@ import django
5
5
 
6
6
  django.setup()
7
7
 
8
+ from django import forms
8
9
  from django.test import Client, TestCase, RequestFactory, override_settings
9
10
  from django.urls import reverse
10
11
  from django.http import HttpRequest
12
+ from django.contrib import messages
11
13
  import csv
12
14
  import json
13
15
  import importlib.util
@@ -20,6 +22,7 @@ import types
20
22
  from glob import glob
21
23
  from datetime import datetime, timedelta, timezone as datetime_timezone
22
24
  import tempfile
25
+ from io import StringIO
23
26
  from urllib.parse import quote
24
27
 
25
28
  from django.utils import timezone
@@ -49,6 +52,7 @@ from core.admin import (
49
52
  PackageReleaseAdmin,
50
53
  PackageAdmin,
51
54
  RFIDResource,
55
+ SecurityGroupAdmin,
52
56
  UserAdmin,
53
57
  USER_PROFILE_INLINES,
54
58
  )
@@ -57,6 +61,7 @@ from nodes.models import ContentSample
57
61
 
58
62
  from django.core.exceptions import ValidationError
59
63
  from django.core.management import call_command
64
+ from django.core.management.base import CommandError
60
65
  from django.db import IntegrityError
61
66
  from .backends import LocalhostAdminBackend
62
67
  from core.views import (
@@ -306,6 +311,16 @@ class UserAdminInlineTests(TestCase):
306
311
  self.assertEqual(len(other_profiles), len(USER_PROFILE_INLINES))
307
312
 
308
313
 
314
+ class SecurityGroupAdminTests(TestCase):
315
+ def setUp(self):
316
+ self.site = AdminSite()
317
+ self.admin = SecurityGroupAdmin(SecurityGroup, self.site)
318
+
319
+ def test_search_fields_include_name_and_parent(self):
320
+ self.assertIn("name", self.admin.search_fields)
321
+ self.assertIn("parent__name", self.admin.search_fields)
322
+
323
+
309
324
  class RFIDLoginTests(TestCase):
310
325
  def setUp(self):
311
326
  self.client = Client()
@@ -321,7 +336,9 @@ class RFIDLoginTests(TestCase):
321
336
  content_type="application/json",
322
337
  )
323
338
  self.assertEqual(response.status_code, 200)
324
- self.assertEqual(response.json()["username"], "alice")
339
+ payload = response.json()
340
+ self.assertEqual(payload["username"], "alice")
341
+ self.assertEqual(payload["redirect"], "/")
325
342
 
326
343
  def test_rfid_login_invalid(self):
327
344
  response = self.client.post(
@@ -345,6 +362,7 @@ class RFIDLoginTests(TestCase):
345
362
  )
346
363
 
347
364
  self.assertEqual(response.status_code, 200)
365
+ self.assertEqual(response.json().get("redirect"), "/")
348
366
  run_args, run_kwargs = mock_run.call_args
349
367
  self.assertEqual(run_args[0], "echo ok")
350
368
  self.assertTrue(run_kwargs.get("shell"))
@@ -381,6 +399,7 @@ class RFIDLoginTests(TestCase):
381
399
  )
382
400
 
383
401
  self.assertEqual(response.status_code, 200)
402
+ self.assertEqual(response.json().get("redirect"), "/")
384
403
  mock_popen.assert_called_once()
385
404
  args, kwargs = mock_popen.call_args
386
405
  self.assertEqual(args[0], "echo welcome")
@@ -407,6 +426,24 @@ class RFIDLoginTests(TestCase):
407
426
  self.assertEqual(response.status_code, 401)
408
427
  mock_popen.assert_not_called()
409
428
 
429
+ def test_rfid_login_uses_next_redirect(self):
430
+ response = self.client.post(
431
+ reverse("rfid-login"),
432
+ data={"rfid": "CARD123", "next": "/dashboard/"},
433
+ content_type="application/json",
434
+ )
435
+ self.assertEqual(response.status_code, 200)
436
+ self.assertEqual(response.json().get("redirect"), "/dashboard/")
437
+
438
+ def test_rfid_login_ignores_unsafe_redirect(self):
439
+ response = self.client.post(
440
+ reverse("rfid-login"),
441
+ data={"rfid": "CARD123", "next": "https://example.com/"},
442
+ content_type="application/json",
443
+ )
444
+ self.assertEqual(response.status_code, 200)
445
+ self.assertEqual(response.json().get("redirect"), "/")
446
+
410
447
 
411
448
  class RFIDBatchApiTests(TestCase):
412
449
  def setUp(self):
@@ -439,6 +476,140 @@ class RFIDBatchApiTests(TestCase):
439
476
  },
440
477
  )
441
478
 
479
+
480
+ class UserAdminPasswordChangeTests(TestCase):
481
+ def setUp(self):
482
+ self.client = Client()
483
+ self.admin = User.objects.create_superuser(
484
+ username="admin", email="admin@example.com", password="adminpass"
485
+ )
486
+ self.user = User.objects.create_user(username="target", password="oldpass")
487
+ self.client.force_login(self.admin)
488
+
489
+ def test_change_password_form_excludes_rfid_field(self):
490
+ url = reverse("admin:core_user_password_change", args=[self.user.pk])
491
+ response = self.client.get(url)
492
+ self.assertEqual(response.status_code, 200)
493
+ self.assertNotContains(response, 'name="rfid"')
494
+ self.assertNotContains(response, "Login RFID")
495
+
496
+ def test_change_password_does_not_alter_existing_rfid_assignment(self):
497
+ account = EnergyAccount.objects.create(user=self.user, name="TARGET")
498
+ tag = RFID.objects.create(rfid="CARD778")
499
+ account.rfids.add(tag)
500
+ url = reverse("admin:core_user_password_change", args=[self.user.pk])
501
+ response = self.client.post(
502
+ url,
503
+ {
504
+ "new_password1": "NewStrongPass123",
505
+ "new_password2": "NewStrongPass123",
506
+ },
507
+ )
508
+ self.assertRedirects(
509
+ response, reverse("admin:core_user_change", args=[self.user.pk])
510
+ )
511
+ account.refresh_from_db()
512
+ self.assertTrue(account.rfids.filter(pk=tag.pk).exists())
513
+ self.user.refresh_from_db()
514
+ self.assertTrue(self.user.check_password("NewStrongPass123"))
515
+
516
+
517
+ class UserAdminLoginRFIDTests(TestCase):
518
+ def setUp(self):
519
+ self.client = Client()
520
+ self.admin = User.objects.create_superuser(
521
+ username="admin", email="admin@example.com", password="adminpass"
522
+ )
523
+ self.user = User.objects.create_user(username="target", password="oldpass")
524
+ self.client.force_login(self.admin)
525
+
526
+ def _build_change_form_data(self, response, **overrides):
527
+ form = response.context["adminform"].form
528
+ data = {}
529
+ for name, field in form.fields.items():
530
+ if isinstance(field, forms.ModelMultipleChoiceField):
531
+ initial = form.initial.get(name, field.initial) or []
532
+ data[name] = [str(value.pk) for value in initial]
533
+ elif isinstance(field, forms.BooleanField):
534
+ initial = form.initial.get(name, field.initial)
535
+ if initial:
536
+ data[name] = "on"
537
+ elif name != "login_rfid":
538
+ value = form.initial.get(name, field.initial)
539
+ data[name] = "" if value is None else value
540
+ data["_save"] = "Save"
541
+ data.update({key: value for key, value in overrides.items() if value is not None})
542
+ return data
543
+
544
+ def test_change_form_includes_login_rfid_field(self):
545
+ tag = RFID.objects.create(rfid="CARD777")
546
+ EnergyAccount.objects.create(user=self.user, name="TARGET")
547
+ url = reverse("admin:core_user_change", args=[self.user.pk])
548
+ response = self.client.get(url)
549
+ self.assertEqual(response.status_code, 200)
550
+ self.assertContains(response, 'name="login_rfid"')
551
+ self.assertContains(response, str(tag.pk))
552
+
553
+ def test_change_form_assigns_login_rfid(self):
554
+ account = EnergyAccount.objects.create(user=self.user, name="TARGET")
555
+ tag = RFID.objects.create(rfid="CARD778")
556
+ url = reverse("admin:core_user_change", args=[self.user.pk])
557
+ response = self.client.get(url)
558
+ form_data = self._build_change_form_data(response, login_rfid=str(tag.pk))
559
+ post_response = self.client.post(url, form_data)
560
+ self.assertRedirects(
561
+ post_response, reverse("admin:core_user_change", args=[self.user.pk])
562
+ )
563
+ account.refresh_from_db()
564
+ self.assertTrue(account.rfids.filter(pk=tag.pk).exists())
565
+
566
+ def test_change_form_creates_energy_account_when_missing(self):
567
+ tag = RFID.objects.create(rfid="CARD779")
568
+ url = reverse("admin:core_user_change", args=[self.user.pk])
569
+ response = self.client.get(url)
570
+ form_data = self._build_change_form_data(response, login_rfid=str(tag.pk))
571
+ post_response = self.client.post(url, form_data)
572
+ self.assertRedirects(
573
+ post_response, reverse("admin:core_user_change", args=[self.user.pk])
574
+ )
575
+ account = EnergyAccount.objects.get(user=self.user)
576
+ self.assertTrue(account.rfids.filter(pk=tag.pk).exists())
577
+
578
+
579
+ class AdminSitePasswordChangeTests(TestCase):
580
+ def setUp(self):
581
+ self.client = Client()
582
+ self.admin = User.objects.create_superuser(
583
+ username="admin", email="admin@example.com", password="adminpass"
584
+ )
585
+ self.client.force_login(self.admin)
586
+
587
+ def test_admin_password_change_form_excludes_rfid_field(self):
588
+ response = self.client.get(reverse("admin:password_change"))
589
+ self.assertEqual(response.status_code, 200)
590
+ self.assertNotContains(response, 'name="rfid"')
591
+ self.assertNotContains(response, "Login RFID")
592
+
593
+ def test_admin_password_change_keeps_existing_rfid_assignment(self):
594
+ account = EnergyAccount.objects.create(user=self.admin, name="ADMIN")
595
+ tag = RFID.objects.create(rfid="CARD881")
596
+ account.rfids.add(tag)
597
+ response = self.client.post(
598
+ reverse("admin:password_change"),
599
+ {
600
+ "old_password": "adminpass",
601
+ "new_password1": "UltraSecurePass123",
602
+ "new_password2": "UltraSecurePass123",
603
+ "password1": "UltraSecurePass123",
604
+ "password2": "UltraSecurePass123",
605
+ },
606
+ )
607
+ self.assertRedirects(response, reverse("admin:password_change_done"))
608
+ self.admin.refresh_from_db()
609
+ self.assertTrue(self.admin.check_password("UltraSecurePass123"))
610
+ account.refresh_from_db()
611
+ self.assertTrue(account.rfids.filter(pk=tag.pk).exists())
612
+
442
613
  def test_export_rfids_color_filter(self):
443
614
  RFID.objects.create(rfid="CARD111", color=RFID.WHITE)
444
615
  response = self.client.get(reverse("rfid-batch"), {"color": "W"})
@@ -548,6 +719,15 @@ class RFIDValidationTests(TestCase):
548
719
  tag = RFID.objects.create(rfid="DEADBEEF10")
549
720
  self.assertEqual(tag.rfid, "DEADBEEF10")
550
721
 
722
+ def test_reversed_uid_updates_with_rfid(self):
723
+ tag = RFID.objects.create(rfid="A1B2C3D4")
724
+ self.assertEqual(tag.reversed_uid, "D4C3B2A1")
725
+
726
+ tag.rfid = "112233"
727
+ tag.save(update_fields=["rfid"])
728
+ tag.refresh_from_db()
729
+ self.assertEqual(tag.reversed_uid, "332211")
730
+
551
731
  def test_find_user_by_rfid(self):
552
732
  user = User.objects.create_user(username="finder", password="pwd")
553
733
  acc = EnergyAccount.objects.create(user=user, name="FINDER")
@@ -978,11 +1158,77 @@ class RFIDImportExportCommandTests(TestCase):
978
1158
  result = resource.import_data(dataset, dry_run=False)
979
1159
  self.assertFalse(result.has_errors())
980
1160
 
1161
+ def test_admin_import_restores_soft_deleted_label(self):
1162
+ tag = RFID.objects.create(rfid="DELETEME01")
1163
+ label = tag.label_id
1164
+ tag.delete()
1165
+ self.assertFalse(RFID.objects.filter(label_id=label).exists())
1166
+ self.assertTrue(
1167
+ RFID.all_objects.filter(label_id=label, is_deleted=True).exists()
1168
+ )
1169
+
1170
+ resource = RFIDResource()
1171
+ headers = resource.get_export_headers()
1172
+ dataset = Dataset()
1173
+ dataset.headers = headers
1174
+ row = {header: "" for header in headers}
1175
+ row.update(
1176
+ {
1177
+ "label_id": str(label),
1178
+ "rfid": "DELETEME02",
1179
+ "allowed": "true",
1180
+ "color": RFID.BLACK,
1181
+ "kind": RFID.CLASSIC,
1182
+ }
1183
+ )
1184
+ dataset.append([row[h] for h in headers])
1185
+
1186
+ result = resource.import_data(dataset, dry_run=False)
1187
+
1188
+ self.assertFalse(result.has_errors())
1189
+ restored = RFID.all_objects.get(label_id=label)
1190
+ self.assertEqual(restored.rfid, "DELETEME02")
1191
+ self.assertFalse(restored.is_deleted)
1192
+
981
1193
  account = EnergyAccount.objects.get(name="IMPORTED ADMIN ACCOUNT")
982
1194
  tag = RFID.objects.get(rfid="NAMETAG002")
983
1195
  self.assertTrue(tag.energy_accounts.filter(pk=account.pk).exists())
984
1196
 
985
1197
 
1198
+ class CheckRFIDCommandTests(TestCase):
1199
+ def test_successful_validation_outputs_json(self):
1200
+ out = StringIO()
1201
+
1202
+ call_command("check_rfid", "abcd1234", stdout=out)
1203
+
1204
+ payload = json.loads(out.getvalue())
1205
+ self.assertEqual(payload["rfid"], "ABCD1234")
1206
+ self.assertTrue(payload["created"])
1207
+ self.assertTrue(RFID.objects.filter(rfid="ABCD1234").exists())
1208
+
1209
+ def test_invalid_value_raises_error(self):
1210
+ with self.assertRaises(CommandError):
1211
+ call_command("check_rfid", "invalid!")
1212
+
1213
+ def test_kind_option_updates_existing_tag(self):
1214
+ tag = RFID.objects.create(rfid="EXISTING", allowed=False, kind=RFID.CLASSIC)
1215
+ out = StringIO()
1216
+
1217
+ call_command(
1218
+ "check_rfid",
1219
+ "existing",
1220
+ "--kind",
1221
+ RFID.NTAG215,
1222
+ stdout=out,
1223
+ )
1224
+
1225
+ payload = json.loads(out.getvalue())
1226
+ tag.refresh_from_db()
1227
+ self.assertFalse(payload["created"])
1228
+ self.assertEqual(payload["kind"], RFID.NTAG215)
1229
+ self.assertEqual(tag.kind, RFID.NTAG215)
1230
+
1231
+
986
1232
  class RFIDKeyVerificationFlagTests(TestCase):
987
1233
  def test_flags_reset_on_key_change(self):
988
1234
  tag = RFID.objects.create(
@@ -1037,6 +1283,44 @@ class ReleaseProcessTests(TestCase):
1037
1283
  )
1038
1284
  sync_main.assert_called_once_with(Path("rel.log"))
1039
1285
 
1286
+ def test_step_check_todos_logs_instruction_when_pending(self):
1287
+ log_path = Path("rel.log")
1288
+ log_path.unlink(missing_ok=True)
1289
+ Todo.objects.create(request="Review checklist")
1290
+ ctx: dict[str, object] = {}
1291
+
1292
+ try:
1293
+ with self.assertRaises(core_views.PendingTodos):
1294
+ core_views._step_check_todos(self.release, ctx, log_path)
1295
+
1296
+ contents = log_path.read_text(encoding="utf-8")
1297
+ message = "Release checklist requires acknowledgment before continuing."
1298
+ self.assertIn(message, contents)
1299
+ self.assertIn("Review outstanding TODO items", contents)
1300
+
1301
+ with self.assertRaises(core_views.PendingTodos):
1302
+ core_views._step_check_todos(self.release, ctx, log_path)
1303
+
1304
+ contents = log_path.read_text(encoding="utf-8")
1305
+ self.assertEqual(contents.count(message), 1)
1306
+ finally:
1307
+ log_path.unlink(missing_ok=True)
1308
+
1309
+ def test_step_check_todos_auto_ack_when_no_pending(self):
1310
+ log_path = Path("rel.log")
1311
+ log_path.unlink(missing_ok=True)
1312
+ ctx: dict[str, object] = {}
1313
+
1314
+ try:
1315
+ with mock.patch("core.views._refresh_changelog_once"):
1316
+ core_views._step_check_todos(self.release, ctx, log_path)
1317
+ finally:
1318
+ log_path.unlink(missing_ok=True)
1319
+
1320
+ self.assertTrue(ctx.get("todos_ack"))
1321
+ self.assertNotIn("todos_required", ctx)
1322
+ self.assertIsNone(ctx.get("todos"))
1323
+
1040
1324
  @mock.patch("core.views._sync_with_origin_main")
1041
1325
  @mock.patch("core.views.release_utils._git_clean", return_value=True)
1042
1326
  @mock.patch("core.views.release_utils.network_available", return_value=False)
@@ -1206,11 +1490,10 @@ class ReleaseProcessTests(TestCase):
1206
1490
  run.assert_any_call(["git", "clean", "-fd"], check=False)
1207
1491
 
1208
1492
  @mock.patch("core.views.PackageRelease.dump_fixture")
1209
- @mock.patch("core.views._ensure_release_todo")
1210
1493
  @mock.patch("core.views._sync_with_origin_main")
1211
1494
  @mock.patch("core.views.subprocess.run")
1212
1495
  def test_pre_release_syncs_with_main(
1213
- self, run, sync_main, ensure_todo, dump_fixture
1496
+ self, run, sync_main, dump_fixture
1214
1497
  ):
1215
1498
  import subprocess as sp
1216
1499
 
@@ -1222,11 +1505,6 @@ class ReleaseProcessTests(TestCase):
1222
1505
  return sp.CompletedProcess(cmd, 0)
1223
1506
 
1224
1507
  run.side_effect = fake_run
1225
- ensure_todo.return_value = (
1226
- mock.Mock(request="Create release pkg 1.0.1", url="", request_details=""),
1227
- Path("core/fixtures/todos__next_release.json"),
1228
- )
1229
-
1230
1508
  version_path = Path("VERSION")
1231
1509
  original_version = version_path.read_text(encoding="utf-8")
1232
1510
 
@@ -1527,6 +1805,54 @@ class ReleaseProcessTests(TestCase):
1527
1805
  ctx = session.get(f"release_publish_{self.release.pk}")
1528
1806
  self.assertTrue(ctx.get("dry_run"))
1529
1807
 
1808
+ def test_resume_button_shown_when_credentials_missing(self):
1809
+ user = User.objects.create_superuser("admin", "admin@example.com", "pw")
1810
+ url = reverse("release-progress", args=[self.release.pk, "publish"])
1811
+ self.client.force_login(user)
1812
+
1813
+ self.client.get(f"{url}?start=1")
1814
+
1815
+ session = self.client.session
1816
+ ctx = session.get(f"release_publish_{self.release.pk}") or {}
1817
+ ctx.update({"step": 7, "started": True, "paused": False})
1818
+ session[f"release_publish_{self.release.pk}"] = ctx
1819
+ session.save()
1820
+
1821
+ response = self.client.get(f"{url}?step=7")
1822
+ self.assertEqual(response.status_code, 200)
1823
+ context = response.context
1824
+ if isinstance(context, list):
1825
+ context = context[-1]
1826
+ self.assertTrue(context["resume_available"])
1827
+ self.assertIn(b"Resume Publish", response.content)
1828
+
1829
+ def test_resume_without_step_parameter_defaults_to_current_progress(self):
1830
+ run: list[str] = []
1831
+
1832
+ def step_fn(release, ctx, log_path):
1833
+ run.append("step")
1834
+
1835
+ steps = [("Only step", step_fn)]
1836
+ user = User.objects.create_superuser("admin", "admin@example.com", "pw")
1837
+ url = reverse("release-progress", args=[self.release.pk, "publish"])
1838
+ with mock.patch("core.views.PUBLISH_STEPS", steps):
1839
+ self.client.force_login(user)
1840
+ session = self.client.session
1841
+ session[f"release_publish_{self.release.pk}"] = {
1842
+ "step": 0,
1843
+ "started": True,
1844
+ "paused": False,
1845
+ }
1846
+ session.save()
1847
+
1848
+ response = self.client.get(f"{url}?resume=1")
1849
+ self.assertEqual(response.status_code, 200)
1850
+ self.assertEqual(run, ["step"])
1851
+
1852
+ session = self.client.session
1853
+ ctx = session.get(f"release_publish_{self.release.pk}")
1854
+ self.assertEqual(ctx.get("step"), 1)
1855
+
1530
1856
  def test_new_todo_does_not_reset_pending_flow(self):
1531
1857
  user = User.objects.create_superuser("admin", "admin@example.com", "pw")
1532
1858
  url = reverse("release-progress", args=[self.release.pk, "publish"])
@@ -1843,7 +2169,7 @@ class PackageReleaseAdminActionTests(TestCase):
1843
2169
 
1844
2170
  @mock.patch("core.admin.PackageRelease.dump_fixture")
1845
2171
  @mock.patch("core.admin.requests.get")
1846
- def test_refresh_from_pypi_creates_releases(self, mock_get, dump):
2172
+ def test_refresh_from_pypi_reports_missing_releases(self, mock_get, dump):
1847
2173
  mock_get.return_value.raise_for_status.return_value = None
1848
2174
  mock_get.return_value.json.return_value = {
1849
2175
  "releases": {
@@ -1856,13 +2182,17 @@ class PackageReleaseAdminActionTests(TestCase):
1856
2182
  }
1857
2183
  }
1858
2184
  self.admin.refresh_from_pypi(self.request, PackageRelease.objects.none())
1859
- new_release = PackageRelease.objects.get(version="1.1.0")
1860
- self.assertEqual(new_release.revision, "")
1861
- self.assertEqual(
1862
- new_release.release_on,
1863
- datetime(2024, 2, 2, 15, 45, tzinfo=datetime_timezone.utc),
2185
+ self.assertFalse(
2186
+ PackageRelease.objects.filter(version="1.1.0").exists()
2187
+ )
2188
+ dump.assert_not_called()
2189
+ self.assertIn(
2190
+ (
2191
+ "Manual creation required for 1 release: 1.1.0",
2192
+ messages.WARNING,
2193
+ ),
2194
+ self.messages,
1864
2195
  )
1865
- dump.assert_called_once()
1866
2196
 
1867
2197
  @mock.patch("core.admin.PackageRelease.dump_fixture")
1868
2198
  @mock.patch("core.admin.requests.get")
@@ -2008,6 +2338,25 @@ class PackageReleaseCurrentTests(TestCase):
2008
2338
  self.package.save()
2009
2339
  self.assertFalse(self.release.is_current)
2010
2340
 
2341
+ def test_is_current_false_when_version_has_plus(self):
2342
+ self.version_path.write_text("1.0.0+")
2343
+ self.assertFalse(self.release.is_current)
2344
+
2345
+
2346
+ class PackageReleaseRevisionTests(TestCase):
2347
+ def setUp(self):
2348
+ self.package = Package.objects.get(name="arthexis")
2349
+ self.release = PackageRelease.objects.create(
2350
+ package=self.package,
2351
+ version="1.0.0",
2352
+ revision="abcdef123456",
2353
+ )
2354
+
2355
+ def test_matches_revision_ignores_plus_suffix(self):
2356
+ self.assertTrue(
2357
+ PackageRelease.matches_revision("1.0.0+", "abcdef123456")
2358
+ )
2359
+
2011
2360
  def test_is_current_false_when_version_differs(self):
2012
2361
  self.release.version = "2.0.0"
2013
2362
  self.release.save()
@@ -2058,13 +2407,22 @@ class PackageAdminPrepareNextReleaseTests(TestCase):
2058
2407
 
2059
2408
  def test_prepare_next_release_active_creates_release(self):
2060
2409
  PackageRelease.all_objects.filter(package=self.package).delete()
2061
- request = self.factory.get("/admin/core/package/prepare-next-release/")
2410
+ request = self.factory.post("/admin/core/package/prepare-next-release/")
2062
2411
  response = self.admin.prepare_next_release_active(request)
2063
2412
  self.assertEqual(response.status_code, 302)
2064
2413
  self.assertEqual(
2065
2414
  PackageRelease.all_objects.filter(package=self.package).count(), 1
2066
2415
  )
2067
2416
 
2417
+ def test_prepare_next_release_active_get_creates_release(self):
2418
+ PackageRelease.all_objects.filter(package=self.package).delete()
2419
+ request = self.factory.get("/admin/core/package/prepare-next-release/")
2420
+ response = self.admin.prepare_next_release_active(request)
2421
+ self.assertEqual(response.status_code, 302)
2422
+ self.assertTrue(
2423
+ PackageRelease.all_objects.filter(package=self.package).exists()
2424
+ )
2425
+
2068
2426
 
2069
2427
  class PackageAdminChangeViewTests(TestCase):
2070
2428
  def setUp(self):
@@ -2086,13 +2444,97 @@ class TodoDoneTests(TestCase):
2086
2444
  User.objects.create_superuser("admin", "admin@example.com", "pw")
2087
2445
  self.client.force_login(User.objects.get(username="admin"))
2088
2446
 
2089
- def test_mark_done_sets_timestamp(self):
2447
+ @mock.patch("core.models.revision_utils.get_revision", return_value="rev123")
2448
+ def test_mark_done_sets_timestamp(self, _get_revision):
2090
2449
  todo = Todo.objects.create(request="Task", is_seed_data=True)
2091
2450
  resp = self.client.post(reverse("todo-done", args=[todo.pk]))
2092
2451
  self.assertRedirects(resp, reverse("admin:index"))
2093
2452
  todo.refresh_from_db()
2094
2453
  self.assertIsNotNone(todo.done_on)
2095
2454
  self.assertFalse(todo.is_deleted)
2455
+ self.assertIsNone(todo.done_node)
2456
+ version_path = Path(settings.BASE_DIR) / "VERSION"
2457
+ expected_version = ""
2458
+ if version_path.exists():
2459
+ expected_version = version_path.read_text(encoding="utf-8").strip()
2460
+ self.assertEqual(todo.done_version, expected_version)
2461
+ self.assertEqual(todo.done_revision, "rev123")
2462
+ self.assertEqual(todo.done_username, "admin")
2463
+
2464
+ def test_mark_done_updates_seed_fixture(self):
2465
+ todo = Todo.objects.create(request="Task", is_seed_data=True)
2466
+ with tempfile.TemporaryDirectory() as tmp:
2467
+ base = Path(tmp)
2468
+ fixture_dir = base / "core" / "fixtures"
2469
+ fixture_dir.mkdir(parents=True)
2470
+ fixture_path = fixture_dir / "todo__task.json"
2471
+ fixture_path.write_text(
2472
+ json.dumps(
2473
+ [
2474
+ {
2475
+ "model": "core.todo",
2476
+ "fields": {
2477
+ "request": "Task",
2478
+ "url": "",
2479
+ "request_details": "",
2480
+ },
2481
+ }
2482
+ ],
2483
+ indent=2,
2484
+ )
2485
+ + "\n",
2486
+ encoding="utf-8",
2487
+ )
2488
+
2489
+ with override_settings(BASE_DIR=base):
2490
+ with mock.patch(
2491
+ "core.models.revision_utils.get_revision", return_value="rev456"
2492
+ ):
2493
+ resp = self.client.post(reverse("todo-done", args=[todo.pk]))
2494
+
2495
+ self.assertRedirects(resp, reverse("admin:index"))
2496
+ data = json.loads(fixture_path.read_text(encoding="utf-8"))
2497
+ self.assertEqual(len(data), 1)
2498
+ fields = data[0]["fields"]
2499
+ self.assertIn("done_on", fields)
2500
+ self.assertTrue(fields["done_on"])
2501
+ self.assertFalse(fields.get("is_deleted", False))
2502
+ self.assertIn("done_version", fields)
2503
+ self.assertEqual(fields.get("done_revision"), "rev456")
2504
+ self.assertEqual(fields.get("done_username"), "admin")
2505
+
2506
+ def test_soft_delete_updates_seed_fixture(self):
2507
+ todo = Todo.objects.create(request="Task", is_seed_data=True)
2508
+ with tempfile.TemporaryDirectory() as tmp:
2509
+ base = Path(tmp)
2510
+ fixture_dir = base / "core" / "fixtures"
2511
+ fixture_dir.mkdir(parents=True)
2512
+ fixture_path = fixture_dir / "todo__task.json"
2513
+ fixture_path.write_text(
2514
+ json.dumps(
2515
+ [
2516
+ {
2517
+ "model": "core.todo",
2518
+ "fields": {
2519
+ "request": "Task",
2520
+ "url": "",
2521
+ "request_details": "",
2522
+ },
2523
+ }
2524
+ ],
2525
+ indent=2,
2526
+ )
2527
+ + "\n",
2528
+ encoding="utf-8",
2529
+ )
2530
+
2531
+ with override_settings(BASE_DIR=base):
2532
+ todo.delete()
2533
+
2534
+ data = json.loads(fixture_path.read_text(encoding="utf-8"))
2535
+ self.assertEqual(len(data), 1)
2536
+ fields = data[0]["fields"]
2537
+ self.assertTrue(fields.get("is_deleted"))
2096
2538
 
2097
2539
  def test_mark_done_missing_task_refreshes(self):
2098
2540
  todo = Todo.objects.create(request="Task", is_seed_data=True)
@@ -2198,6 +2640,61 @@ class TodoDoneTests(TestCase):
2198
2640
  self.assertTrue(todo.is_seed_data)
2199
2641
 
2200
2642
 
2643
+ class TodoDeleteTests(TestCase):
2644
+ def setUp(self):
2645
+ self.client = Client()
2646
+ User.objects.create_superuser("admin", "admin@example.com", "pw")
2647
+ self.client.force_login(User.objects.get(username="admin"))
2648
+
2649
+ def test_delete_marks_task_deleted(self):
2650
+ todo = Todo.objects.create(request="Task", is_seed_data=True)
2651
+ resp = self.client.post(reverse("todo-delete", args=[todo.pk]))
2652
+ self.assertRedirects(resp, reverse("admin:index"))
2653
+ todo.refresh_from_db()
2654
+ self.assertTrue(todo.is_deleted)
2655
+ self.assertIsNone(todo.done_on)
2656
+
2657
+ def test_delete_updates_seed_fixture(self):
2658
+ todo = Todo.objects.create(request="Task", is_seed_data=True)
2659
+ with tempfile.TemporaryDirectory() as tmp:
2660
+ base = Path(tmp)
2661
+ fixture_dir = base / "core" / "fixtures"
2662
+ fixture_dir.mkdir(parents=True)
2663
+ fixture_path = fixture_dir / "todo__task.json"
2664
+ fixture_path.write_text(
2665
+ json.dumps(
2666
+ [
2667
+ {
2668
+ "model": "core.todo",
2669
+ "fields": {
2670
+ "request": "Task",
2671
+ "url": "",
2672
+ "request_details": "",
2673
+ },
2674
+ }
2675
+ ],
2676
+ indent=2,
2677
+ )
2678
+ + "\n",
2679
+ encoding="utf-8",
2680
+ )
2681
+
2682
+ with override_settings(BASE_DIR=base):
2683
+ resp = self.client.post(reverse("todo-delete", args=[todo.pk]))
2684
+ self.assertRedirects(resp, reverse("admin:index"))
2685
+ data = json.loads(fixture_path.read_text(encoding="utf-8"))
2686
+ self.assertEqual(len(data), 1)
2687
+ fields = data[0]["fields"]
2688
+ self.assertTrue(fields.get("is_deleted"))
2689
+
2690
+ def test_delete_missing_task_redirects(self):
2691
+ todo = Todo.objects.create(request="Task")
2692
+ todo.is_deleted = True
2693
+ todo.save(update_fields=["is_deleted"])
2694
+ resp = self.client.post(reverse("todo-delete", args=[todo.pk]))
2695
+ self.assertRedirects(resp, reverse("admin:index"))
2696
+
2697
+
2201
2698
  class TodoFocusViewTests(TestCase):
2202
2699
  def setUp(self):
2203
2700
  self.client = Client()
@@ -2215,6 +2712,7 @@ class TodoFocusViewTests(TestCase):
2215
2712
  self.assertEqual(resp["X-Frame-Options"], "SAMEORIGIN")
2216
2713
  self.assertContains(resp, f'src="{todo.url}"')
2217
2714
  self.assertContains(resp, "Done")
2715
+ self.assertContains(resp, "Delete")
2218
2716
  self.assertContains(resp, "Back")
2219
2717
  self.assertContains(resp, "Take Snapshot")
2220
2718
  snapshot_url = reverse("todo-snapshot", args=[todo.pk])
@@ -2236,7 +2734,7 @@ class TodoFocusViewTests(TestCase):
2236
2734
  def test_focus_view_sanitizes_loopback_absolute_url(self):
2237
2735
  todo = Todo.objects.create(
2238
2736
  request="Task",
2239
- url="http://127.0.0.1:8000/docs/?section=chart",
2737
+ url="http://127.0.0.1:8888/docs/?section=chart",
2240
2738
  )
2241
2739
  resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
2242
2740
  self.assertContains(resp, 'src="/docs/?section=chart"')