arthexis 0.1.9__py3-none-any.whl → 0.1.11__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 (51) hide show
  1. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/METADATA +76 -23
  2. arthexis-0.1.11.dist-info/RECORD +99 -0
  3. config/context_processors.py +1 -0
  4. config/settings.py +245 -26
  5. config/urls.py +11 -4
  6. core/admin.py +585 -57
  7. core/apps.py +29 -1
  8. core/auto_upgrade.py +57 -0
  9. core/backends.py +115 -3
  10. core/environment.py +23 -5
  11. core/fields.py +93 -0
  12. core/mailer.py +3 -1
  13. core/models.py +482 -38
  14. core/reference_utils.py +108 -0
  15. core/sigil_builder.py +23 -5
  16. core/sigil_resolver.py +35 -4
  17. core/system.py +400 -140
  18. core/tasks.py +151 -8
  19. core/temp_passwords.py +181 -0
  20. core/test_system_info.py +97 -1
  21. core/tests.py +393 -15
  22. core/user_data.py +154 -16
  23. core/views.py +499 -20
  24. nodes/admin.py +149 -6
  25. nodes/backends.py +125 -18
  26. nodes/dns.py +203 -0
  27. nodes/models.py +498 -9
  28. nodes/tests.py +682 -3
  29. nodes/views.py +154 -7
  30. ocpp/admin.py +63 -3
  31. ocpp/consumers.py +255 -41
  32. ocpp/evcs.py +6 -3
  33. ocpp/models.py +52 -7
  34. ocpp/reference_utils.py +42 -0
  35. ocpp/simulator.py +62 -5
  36. ocpp/store.py +30 -0
  37. ocpp/test_rfid.py +169 -7
  38. ocpp/tests.py +414 -8
  39. ocpp/views.py +109 -76
  40. pages/admin.py +9 -1
  41. pages/context_processors.py +24 -4
  42. pages/defaults.py +14 -0
  43. pages/forms.py +131 -0
  44. pages/models.py +53 -14
  45. pages/tests.py +450 -14
  46. pages/urls.py +4 -0
  47. pages/views.py +419 -110
  48. arthexis-0.1.9.dist-info/RECORD +0 -92
  49. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
  50. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
  51. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/top_level.txt +0 -0
core/tests.py CHANGED
@@ -15,11 +15,13 @@ from unittest.mock import patch
15
15
  from pathlib import Path
16
16
  import subprocess
17
17
  from glob import glob
18
- from datetime import timedelta
18
+ from datetime import datetime, timedelta, timezone as datetime_timezone
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
 
@@ -828,15 +831,21 @@ class ReleaseProcessTests(TestCase):
828
831
  )
829
832
  version_path.write_text(original, encoding="utf-8")
830
833
 
834
+ @mock.patch("core.views.timezone.now")
831
835
  @mock.patch("core.views.PackageRelease.dump_fixture")
832
836
  @mock.patch("core.views.release_utils.publish")
833
- def test_publish_sets_pypi_url(self, publish, dump_fixture):
837
+ def test_publish_sets_pypi_url(self, publish, dump_fixture, now):
838
+ now.return_value = datetime(2025, 3, 4, 5, 6, tzinfo=datetime_timezone.utc)
834
839
  _step_publish(self.release, {}, Path("rel.log"))
835
840
  self.release.refresh_from_db()
836
841
  self.assertEqual(
837
842
  self.release.pypi_url,
838
843
  f"https://pypi.org/project/{self.package.name}/{self.release.version}/",
839
844
  )
845
+ self.assertEqual(
846
+ self.release.release_on,
847
+ datetime(2025, 3, 4, 5, 6, tzinfo=datetime_timezone.utc),
848
+ )
840
849
  dump_fixture.assert_called_once()
841
850
 
842
851
  @mock.patch("core.views.PackageRelease.dump_fixture")
@@ -846,8 +855,33 @@ class ReleaseProcessTests(TestCase):
846
855
  _step_publish(self.release, {}, Path("rel.log"))
847
856
  self.release.refresh_from_db()
848
857
  self.assertEqual(self.release.pypi_url, "")
858
+ self.assertIsNone(self.release.release_on)
849
859
  dump_fixture.assert_not_called()
850
860
 
861
+ def test_new_todo_does_not_reset_pending_flow(self):
862
+ user = User.objects.create_superuser("admin", "admin@example.com", "pw")
863
+ url = reverse("release-progress", args=[self.release.pk, "publish"])
864
+ Todo.objects.create(request="Initial checklist item")
865
+ steps = [("Confirm release TODO completion", core_views._step_check_todos)]
866
+ with mock.patch("core.views.PUBLISH_STEPS", steps):
867
+ self.client.force_login(user)
868
+ response = self.client.get(url)
869
+ self.assertTrue(response.context["has_pending_todos"])
870
+ self.client.get(f"{url}?ack_todos=1")
871
+ self.client.get(f"{url}?start=1")
872
+ self.client.get(f"{url}?step=0")
873
+ Todo.objects.create(request="Follow-up checklist item")
874
+ response = self.client.get(url)
875
+ self.assertEqual(
876
+ Todo.objects.filter(is_deleted=False, done_on__isnull=True).count(),
877
+ 1,
878
+ )
879
+ self.assertIsNone(response.context["todos"])
880
+ self.assertFalse(response.context["has_pending_todos"])
881
+ session = self.client.session
882
+ ctx = session.get(f"release_publish_{self.release.pk}")
883
+ self.assertTrue(ctx.get("todos_ack"))
884
+
851
885
  def test_release_progress_uses_lockfile(self):
852
886
  run = []
853
887
 
@@ -901,6 +935,139 @@ class ReleaseProcessTests(TestCase):
901
935
  self.assertEqual(count_file.read_text(), "1")
902
936
 
903
937
 
938
+ class ReleaseProgressSyncTests(TestCase):
939
+ def setUp(self):
940
+ self.client = Client()
941
+ self.user = User.objects.create_superuser("admin", "admin@example.com", "pw")
942
+ self.client.force_login(self.user)
943
+ self.package = Package.objects.get(name="arthexis")
944
+ self.version_path = Path("VERSION")
945
+ self.original_version = self.version_path.read_text(encoding="utf-8")
946
+ self.version_path.write_text("1.2.3", encoding="utf-8")
947
+
948
+ def tearDown(self):
949
+ self.version_path.write_text(self.original_version, encoding="utf-8")
950
+
951
+ @mock.patch("core.views.PackageRelease.dump_fixture")
952
+ @mock.patch("core.views.revision.get_revision", return_value="abc123")
953
+ def test_unpublished_release_syncs_version_and_revision(
954
+ self, get_revision, dump_fixture
955
+ ):
956
+ release = PackageRelease.objects.create(
957
+ package=self.package,
958
+ version="1.0.0",
959
+ )
960
+ release.revision = "oldrev"
961
+ release.save(update_fields=["revision"])
962
+
963
+ url = reverse("release-progress", args=[release.pk, "publish"])
964
+ response = self.client.get(url)
965
+
966
+ self.assertEqual(response.status_code, 200)
967
+ release.refresh_from_db()
968
+ self.assertEqual(release.version, "1.2.4")
969
+ self.assertEqual(release.revision, "abc123")
970
+ dump_fixture.assert_called_once()
971
+
972
+ def test_published_release_not_current_returns_404(self):
973
+ release = PackageRelease.objects.create(
974
+ package=self.package,
975
+ version="1.2.4",
976
+ pypi_url="https://example.com",
977
+ )
978
+
979
+ url = reverse("release-progress", args=[release.pk, "publish"])
980
+ response = self.client.get(url)
981
+
982
+ self.assertEqual(response.status_code, 404)
983
+
984
+
985
+ class ReleaseProgressFixtureVisibilityTests(TestCase):
986
+ def setUp(self):
987
+ self.client = Client()
988
+ self.user = User.objects.create_superuser(
989
+ "fixture-check", "fixture@example.com", "pw"
990
+ )
991
+ self.client.force_login(self.user)
992
+ current_version = Path("VERSION").read_text(encoding="utf-8").strip()
993
+ package = Package.objects.filter(is_active=True).first()
994
+ if package is None:
995
+ package = Package.objects.create(name="fixturepkg", is_active=True)
996
+ try:
997
+ self.release = PackageRelease.objects.get(
998
+ package=package, version=current_version
999
+ )
1000
+ except PackageRelease.DoesNotExist:
1001
+ self.release = PackageRelease.objects.create(
1002
+ package=package, version=current_version
1003
+ )
1004
+ self.session_key = f"release_publish_{self.release.pk}"
1005
+ self.log_name = f"{self.release.package.name}-{self.release.version}.log"
1006
+ self.lock_path = Path("locks") / f"{self.session_key}.json"
1007
+ self.restart_path = Path("locks") / f"{self.session_key}.restarts"
1008
+ self.log_path = Path("logs") / self.log_name
1009
+ for path in (self.lock_path, self.restart_path, self.log_path):
1010
+ if path.exists():
1011
+ path.unlink()
1012
+ try:
1013
+ self.fixture_step_index = next(
1014
+ idx
1015
+ for idx, (name, _) in enumerate(core_views.PUBLISH_STEPS)
1016
+ if name == core_views.FIXTURE_REVIEW_STEP_NAME
1017
+ )
1018
+ except StopIteration: # pragma: no cover - defensive guard
1019
+ self.fail("Fixture review step not configured in publish steps")
1020
+ self.url = reverse("release-progress", args=[self.release.pk, "publish"])
1021
+
1022
+ def tearDown(self):
1023
+ session = self.client.session
1024
+ if self.session_key in session:
1025
+ session.pop(self.session_key)
1026
+ session.save()
1027
+ for path in (self.lock_path, self.restart_path, self.log_path):
1028
+ if path.exists():
1029
+ path.unlink()
1030
+ super().tearDown()
1031
+
1032
+ def _set_session(self, step: int, fixtures: list[dict]):
1033
+ session = self.client.session
1034
+ session[self.session_key] = {
1035
+ "step": step,
1036
+ "fixtures": fixtures,
1037
+ "log": self.log_name,
1038
+ "started": True,
1039
+ }
1040
+ session.save()
1041
+
1042
+ def test_fixture_summary_visible_until_migration_step(self):
1043
+ fixtures = [
1044
+ {
1045
+ "path": "core/fixtures/example.json",
1046
+ "count": 2,
1047
+ "models": ["core.Model"],
1048
+ }
1049
+ ]
1050
+ self._set_session(self.fixture_step_index, fixtures)
1051
+ response = self.client.get(self.url)
1052
+ self.assertEqual(response.status_code, 200)
1053
+ self.assertEqual(response.context["fixtures"], fixtures)
1054
+ self.assertContains(response, "Fixture changes")
1055
+
1056
+ def test_fixture_summary_hidden_after_migration_step(self):
1057
+ fixtures = [
1058
+ {
1059
+ "path": "core/fixtures/example.json",
1060
+ "count": 2,
1061
+ "models": ["core.Model"],
1062
+ }
1063
+ ]
1064
+ self._set_session(self.fixture_step_index + 1, fixtures)
1065
+ response = self.client.get(self.url)
1066
+ self.assertEqual(response.status_code, 200)
1067
+ self.assertIsNone(response.context["fixtures"])
1068
+ self.assertNotContains(response, "Fixture changes")
1069
+
1070
+
904
1071
  class PackageReleaseAdminActionTests(TestCase):
905
1072
  def setUp(self):
906
1073
  self.factory = RequestFactory()
@@ -908,6 +1075,8 @@ class PackageReleaseAdminActionTests(TestCase):
908
1075
  self.admin = PackageReleaseAdmin(PackageRelease, self.site)
909
1076
  self.admin.message_user = lambda *args, **kwargs: None
910
1077
  self.package = Package.objects.create(name="pkg")
1078
+ self.package.is_active = True
1079
+ self.package.save(update_fields=["is_active"])
911
1080
  self.release = PackageRelease.objects.create(
912
1081
  package=self.package,
913
1082
  version="1.0.0",
@@ -936,11 +1105,64 @@ class PackageReleaseAdminActionTests(TestCase):
936
1105
  def test_refresh_from_pypi_creates_releases(self, mock_get, dump):
937
1106
  mock_get.return_value.raise_for_status.return_value = None
938
1107
  mock_get.return_value.json.return_value = {
939
- "releases": {"1.0.0": [], "1.1.0": []}
1108
+ "releases": {
1109
+ "1.0.0": [
1110
+ {"upload_time_iso_8601": "2024-01-01T12:30:00.000000Z"}
1111
+ ],
1112
+ "1.1.0": [
1113
+ {"upload_time_iso_8601": "2024-02-02T15:45:00.000000Z"}
1114
+ ],
1115
+ }
940
1116
  }
941
1117
  self.admin.refresh_from_pypi(self.request, PackageRelease.objects.none())
942
1118
  new_release = PackageRelease.objects.get(version="1.1.0")
943
1119
  self.assertEqual(new_release.revision, "")
1120
+ self.assertEqual(
1121
+ new_release.release_on,
1122
+ datetime(2024, 2, 2, 15, 45, tzinfo=datetime_timezone.utc),
1123
+ )
1124
+ dump.assert_called_once()
1125
+
1126
+ @mock.patch("core.admin.PackageRelease.dump_fixture")
1127
+ @mock.patch("core.admin.requests.get")
1128
+ def test_refresh_from_pypi_updates_release_date(self, mock_get, dump):
1129
+ self.release.release_on = None
1130
+ self.release.save(update_fields=["release_on"])
1131
+ mock_get.return_value.raise_for_status.return_value = None
1132
+ mock_get.return_value.json.return_value = {
1133
+ "releases": {
1134
+ "1.0.0": [
1135
+ {"upload_time_iso_8601": "2024-01-01T12:30:00.000000Z"}
1136
+ ]
1137
+ }
1138
+ }
1139
+ self.admin.refresh_from_pypi(self.request, PackageRelease.objects.none())
1140
+ self.release.refresh_from_db()
1141
+ self.assertEqual(
1142
+ self.release.release_on,
1143
+ datetime(2024, 1, 1, 12, 30, tzinfo=datetime_timezone.utc),
1144
+ )
1145
+ dump.assert_called_once()
1146
+
1147
+ @mock.patch("core.admin.PackageRelease.dump_fixture")
1148
+ @mock.patch("core.admin.requests.get")
1149
+ def test_refresh_from_pypi_restores_deleted_release(self, mock_get, dump):
1150
+ self.release.is_deleted = True
1151
+ self.release.save(update_fields=["is_deleted"])
1152
+ mock_get.return_value.raise_for_status.return_value = None
1153
+ mock_get.return_value.json.return_value = {
1154
+ "releases": {
1155
+ "1.0.0": [
1156
+ {"upload_time_iso_8601": "2024-01-01T12:30:00.000000Z"}
1157
+ ]
1158
+ }
1159
+ }
1160
+
1161
+ self.admin.refresh_from_pypi(self.request, PackageRelease.objects.none())
1162
+
1163
+ self.assertTrue(
1164
+ PackageRelease.objects.filter(version="1.0.0").exists()
1165
+ )
944
1166
  dump.assert_called_once()
945
1167
 
946
1168
 
@@ -982,6 +1204,40 @@ class PackageReleaseCurrentTests(TestCase):
982
1204
  self.assertFalse(self.release.is_current)
983
1205
 
984
1206
 
1207
+ class PackageReleaseChangelistTests(TestCase):
1208
+ def setUp(self):
1209
+ self.client = Client()
1210
+ User.objects.create_superuser("admin", "admin@example.com", "pw")
1211
+ self.client.force_login(User.objects.get(username="admin"))
1212
+
1213
+ def test_prepare_next_release_button_present(self):
1214
+ response = self.client.get(reverse("admin:core_packagerelease_changelist"))
1215
+ prepare_url = reverse(
1216
+ "admin:core_packagerelease_actions", args=["prepare_next_release"]
1217
+ )
1218
+ self.assertContains(response, prepare_url, html=False)
1219
+
1220
+ def test_refresh_from_pypi_button_present(self):
1221
+ response = self.client.get(reverse("admin:core_packagerelease_changelist"))
1222
+ refresh_url = reverse(
1223
+ "admin:core_packagerelease_actions", args=["refresh_from_pypi"]
1224
+ )
1225
+ self.assertContains(response, refresh_url, html=False)
1226
+
1227
+ def test_prepare_next_release_action_creates_release(self):
1228
+ package = Package.objects.get(name="arthexis")
1229
+ PackageRelease.all_objects.filter(package=package).delete()
1230
+ response = self.client.post(
1231
+ reverse(
1232
+ "admin:core_packagerelease_actions", args=["prepare_next_release"]
1233
+ )
1234
+ )
1235
+ self.assertEqual(response.status_code, 302)
1236
+ self.assertTrue(
1237
+ PackageRelease.all_objects.filter(package=package).exists()
1238
+ )
1239
+
1240
+
985
1241
  class PackageAdminPrepareNextReleaseTests(TestCase):
986
1242
  def setUp(self):
987
1243
  self.factory = RequestFactory()
@@ -1000,24 +1256,18 @@ class PackageAdminPrepareNextReleaseTests(TestCase):
1000
1256
  )
1001
1257
 
1002
1258
 
1003
- class PackageReleaseChangelistTests(TestCase):
1259
+ class PackageAdminChangeViewTests(TestCase):
1004
1260
  def setUp(self):
1005
1261
  self.client = Client()
1006
1262
  User.objects.create_superuser("admin", "admin@example.com", "pw")
1007
1263
  self.client.force_login(User.objects.get(username="admin"))
1264
+ self.package = Package.objects.get(name="arthexis")
1008
1265
 
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
1013
- )
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"]
1266
+ def test_prepare_next_release_button_visible_on_change_view(self):
1267
+ response = self.client.get(
1268
+ reverse("admin:core_package_change", args=[self.package.pk])
1019
1269
  )
1020
- self.assertContains(response, refresh_url, html=False)
1270
+ self.assertContains(response, "Prepare next Release")
1021
1271
 
1022
1272
 
1023
1273
  class TodoDoneTests(TestCase):
@@ -1034,6 +1284,134 @@ class TodoDoneTests(TestCase):
1034
1284
  self.assertIsNotNone(todo.done_on)
1035
1285
  self.assertFalse(todo.is_deleted)
1036
1286
 
1287
+ def test_mark_done_condition_failure_shows_message(self):
1288
+ todo = Todo.objects.create(
1289
+ request="Task",
1290
+ on_done_condition="1 = 0",
1291
+ )
1292
+ resp = self.client.post(reverse("todo-done", args=[todo.pk]))
1293
+ self.assertRedirects(resp, reverse("admin:index"))
1294
+ messages = [m.message for m in get_messages(resp.wsgi_request)]
1295
+ self.assertTrue(messages)
1296
+ self.assertIn("1 = 0", messages[0])
1297
+ todo.refresh_from_db()
1298
+ self.assertIsNone(todo.done_on)
1299
+
1300
+ def test_mark_done_condition_invalid_expression(self):
1301
+ todo = Todo.objects.create(
1302
+ request="Task",
1303
+ on_done_condition="1; SELECT 1",
1304
+ )
1305
+ resp = self.client.post(reverse("todo-done", args=[todo.pk]))
1306
+ self.assertRedirects(resp, reverse("admin:index"))
1307
+ messages = [m.message for m in get_messages(resp.wsgi_request)]
1308
+ self.assertTrue(messages)
1309
+ self.assertIn("Semicolons", messages[0])
1310
+ todo.refresh_from_db()
1311
+ self.assertIsNone(todo.done_on)
1312
+
1313
+ def test_mark_done_condition_resolves_sigils(self):
1314
+ todo = Todo.objects.create(
1315
+ request="Task",
1316
+ on_done_condition="[TEST]",
1317
+ )
1318
+ with mock.patch.object(Todo, "resolve_sigils", return_value="1 = 1") as resolver:
1319
+ resp = self.client.post(reverse("todo-done", args=[todo.pk]))
1320
+ self.assertRedirects(resp, reverse("admin:index"))
1321
+ resolver.assert_called_once_with("on_done_condition")
1322
+ todo.refresh_from_db()
1323
+ self.assertIsNotNone(todo.done_on)
1324
+
1325
+ def test_mark_done_respects_next_parameter(self):
1326
+ todo = Todo.objects.create(request="Task")
1327
+ next_url = reverse("admin:index") + "?section=todos"
1328
+ resp = self.client.post(
1329
+ reverse("todo-done", args=[todo.pk]),
1330
+ {"next": next_url},
1331
+ )
1332
+ self.assertRedirects(resp, next_url, target_status_code=200)
1333
+ todo.refresh_from_db()
1334
+ self.assertIsNotNone(todo.done_on)
1335
+
1336
+ def test_mark_done_rejects_external_next(self):
1337
+ todo = Todo.objects.create(request="Task")
1338
+ resp = self.client.post(
1339
+ reverse("todo-done", args=[todo.pk]),
1340
+ {"next": "https://example.com/"},
1341
+ )
1342
+ self.assertRedirects(resp, reverse("admin:index"))
1343
+ todo.refresh_from_db()
1344
+ self.assertIsNotNone(todo.done_on)
1345
+
1346
+
1347
+ class TodoFocusViewTests(TestCase):
1348
+ def setUp(self):
1349
+ self.client = Client()
1350
+ User.objects.create_superuser("admin", "admin@example.com", "pw")
1351
+ self.client.force_login(User.objects.get(username="admin"))
1352
+
1353
+ def test_focus_view_renders_requested_page(self):
1354
+ todo = Todo.objects.create(request="Task", url="/docs/")
1355
+ next_url = reverse("admin:index")
1356
+ resp = self.client.get(
1357
+ f"{reverse('todo-focus', args=[todo.pk])}?next={quote(next_url)}"
1358
+ )
1359
+ self.assertEqual(resp.status_code, 200)
1360
+ self.assertContains(resp, todo.request)
1361
+ self.assertEqual(resp["X-Frame-Options"], "SAMEORIGIN")
1362
+ self.assertContains(resp, f'src="{todo.url}"')
1363
+ self.assertContains(resp, "Done")
1364
+ self.assertContains(resp, "Back")
1365
+
1366
+ def test_focus_view_uses_admin_change_when_no_url(self):
1367
+ todo = Todo.objects.create(request="Task")
1368
+ resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
1369
+ change_url = reverse("admin:core_todo_change", args=[todo.pk])
1370
+ self.assertContains(resp, f'src="{change_url}"')
1371
+
1372
+ def test_focus_view_sanitizes_loopback_absolute_url(self):
1373
+ todo = Todo.objects.create(
1374
+ request="Task",
1375
+ url="http://127.0.0.1:8000/docs/?section=chart",
1376
+ )
1377
+ resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
1378
+ self.assertContains(resp, 'src="/docs/?section=chart"')
1379
+
1380
+ def test_focus_view_rejects_external_absolute_url(self):
1381
+ todo = Todo.objects.create(
1382
+ request="Task",
1383
+ url="https://outside.invalid/external/",
1384
+ )
1385
+ resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
1386
+ change_url = reverse("admin:core_todo_change", args=[todo.pk])
1387
+ self.assertContains(resp, f'src="{change_url}"')
1388
+
1389
+ def test_focus_view_avoids_recursive_focus_url(self):
1390
+ todo = Todo.objects.create(request="Task")
1391
+ focus_url = reverse("todo-focus", args=[todo.pk])
1392
+ Todo.objects.filter(pk=todo.pk).update(url=focus_url)
1393
+ resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
1394
+ change_url = reverse("admin:core_todo_change", args=[todo.pk])
1395
+ self.assertContains(resp, f'src="{change_url}"')
1396
+
1397
+ def test_focus_view_avoids_recursive_focus_absolute_url(self):
1398
+ todo = Todo.objects.create(request="Task")
1399
+ focus_url = reverse("todo-focus", args=[todo.pk])
1400
+ Todo.objects.filter(pk=todo.pk).update(url=f"http://testserver{focus_url}")
1401
+ resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
1402
+ change_url = reverse("admin:core_todo_change", args=[todo.pk])
1403
+ self.assertContains(resp, f'src="{change_url}"')
1404
+
1405
+ def test_focus_view_redirects_if_todo_completed(self):
1406
+ todo = Todo.objects.create(request="Task")
1407
+ todo.done_on = timezone.now()
1408
+ todo.save(update_fields=["done_on"])
1409
+ next_url = reverse("admin:index")
1410
+ resp = self.client.get(
1411
+ f"{reverse('todo-focus', args=[todo.pk])}?next={quote(next_url)}"
1412
+ )
1413
+ self.assertRedirects(resp, next_url, target_status_code=200)
1414
+
1037
1415
 
1038
1416
  class TodoUrlValidationTests(TestCase):
1039
1417
  def test_relative_url_valid(self):