arthexis 0.1.10__py3-none-any.whl → 0.1.12__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 (54) hide show
  1. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/METADATA +36 -26
  2. arthexis-0.1.12.dist-info/RECORD +102 -0
  3. config/context_processors.py +1 -0
  4. config/settings.py +31 -5
  5. config/urls.py +5 -4
  6. core/admin.py +430 -90
  7. core/apps.py +48 -2
  8. core/backends.py +38 -0
  9. core/environment.py +23 -5
  10. core/mailer.py +3 -1
  11. core/models.py +303 -31
  12. core/reference_utils.py +20 -9
  13. core/release.py +4 -0
  14. core/sigil_builder.py +7 -2
  15. core/sigil_resolver.py +35 -4
  16. core/system.py +250 -1
  17. core/tasks.py +92 -40
  18. core/temp_passwords.py +181 -0
  19. core/test_system_info.py +62 -2
  20. core/tests.py +169 -3
  21. core/user_data.py +51 -8
  22. core/views.py +371 -20
  23. nodes/admin.py +453 -8
  24. nodes/backends.py +21 -6
  25. nodes/dns.py +203 -0
  26. nodes/feature_checks.py +133 -0
  27. nodes/models.py +374 -31
  28. nodes/reports.py +411 -0
  29. nodes/tests.py +677 -38
  30. nodes/utils.py +32 -0
  31. nodes/views.py +14 -0
  32. ocpp/admin.py +278 -15
  33. ocpp/consumers.py +517 -16
  34. ocpp/evcs_discovery.py +158 -0
  35. ocpp/models.py +237 -4
  36. ocpp/reference_utils.py +42 -0
  37. ocpp/simulator.py +321 -22
  38. ocpp/store.py +110 -2
  39. ocpp/test_rfid.py +169 -7
  40. ocpp/tests.py +819 -6
  41. ocpp/transactions_io.py +17 -3
  42. ocpp/views.py +233 -19
  43. pages/admin.py +144 -4
  44. pages/context_processors.py +21 -7
  45. pages/defaults.py +13 -0
  46. pages/forms.py +38 -0
  47. pages/models.py +189 -15
  48. pages/tests.py +281 -8
  49. pages/urls.py +4 -0
  50. pages/views.py +137 -21
  51. arthexis-0.1.10.dist-info/RECORD +0 -95
  52. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
  53. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
  54. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/top_level.txt +0 -0
core/views.py CHANGED
@@ -11,13 +11,15 @@ from django.contrib.sites.models import Site
11
11
  from django.http import Http404, JsonResponse
12
12
  from django.shortcuts import get_object_or_404, redirect, render, resolve_url
13
13
  from django.utils import timezone
14
+ from django.utils.text import slugify
14
15
  from django.utils.translation import gettext as _
15
16
  from django.urls import NoReverseMatch, reverse
16
17
  from django.views.decorators.csrf import csrf_exempt
17
18
  from django.views.decorators.http import require_GET, require_POST
18
19
  from django.utils.http import url_has_allowed_host_and_scheme
19
20
  from pathlib import Path
20
- from urllib.parse import urlsplit, urlunsplit
21
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
22
+ import errno
21
23
  import subprocess
22
24
 
23
25
  from utils import revision
@@ -38,7 +40,7 @@ def odoo_products(request):
38
40
  products = profile.execute(
39
41
  "product.product",
40
42
  "search_read",
41
- [],
43
+ [[]],
42
44
  {"fields": ["name"], "limit": 50},
43
45
  )
44
46
  except Exception:
@@ -81,6 +83,126 @@ def _clean_repo() -> None:
81
83
  subprocess.run(["git", "clean", "-fd"], check=False)
82
84
 
83
85
 
86
+ def _format_path(path: Path) -> str:
87
+ try:
88
+ return str(path.resolve().relative_to(Path.cwd()))
89
+ except ValueError:
90
+ return str(path)
91
+
92
+
93
+ def _next_patch_version(version: str) -> str:
94
+ from packaging.version import InvalidVersion, Version
95
+
96
+ try:
97
+ parsed = Version(version)
98
+ except InvalidVersion:
99
+ parts = version.split(".")
100
+ for index in range(len(parts) - 1, -1, -1):
101
+ segment = parts[index]
102
+ if segment.isdigit():
103
+ parts[index] = str(int(segment) + 1)
104
+ return ".".join(parts)
105
+ return version
106
+ return f"{parsed.major}.{parsed.minor}.{parsed.micro + 1}"
107
+
108
+
109
+ def _write_todo_fixture(todo: Todo) -> Path:
110
+ safe_request = todo.request.replace(".", " ")
111
+ slug = slugify(safe_request).replace("-", "_")
112
+ if not slug:
113
+ slug = "todo"
114
+ path = TODO_FIXTURE_DIR / f"todos__{slug}.json"
115
+ path.parent.mkdir(parents=True, exist_ok=True)
116
+ data = [
117
+ {
118
+ "model": "core.todo",
119
+ "fields": {
120
+ "request": todo.request,
121
+ "url": todo.url,
122
+ "request_details": todo.request_details,
123
+ },
124
+ }
125
+ ]
126
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
127
+ return path
128
+
129
+
130
+ def _should_use_python_changelog(exc: OSError) -> bool:
131
+ winerror = getattr(exc, "winerror", None)
132
+ if winerror in {193}:
133
+ return True
134
+ return exc.errno in {errno.ENOEXEC, errno.EACCES, errno.ENOENT}
135
+
136
+
137
+ def _generate_changelog_with_python(log_path: Path) -> None:
138
+ _append_log(log_path, "Falling back to Python changelog generator")
139
+ describe = subprocess.run(
140
+ ["git", "describe", "--tags", "--abbrev=0"],
141
+ capture_output=True,
142
+ text=True,
143
+ check=False,
144
+ )
145
+ start_tag = describe.stdout.strip() if describe.returncode == 0 else ""
146
+ range_spec = f"{start_tag}..HEAD" if start_tag else "HEAD"
147
+ log_proc = subprocess.run(
148
+ ["git", "log", range_spec, "--no-merges", "--pretty=format:- %h %s"],
149
+ capture_output=True,
150
+ text=True,
151
+ check=True,
152
+ )
153
+ entries = [line for line in log_proc.stdout.splitlines() if line]
154
+ changelog_path = Path("CHANGELOG.rst")
155
+ previous_lines: list[str] = []
156
+ if changelog_path.exists():
157
+ previous_lines = changelog_path.read_text(encoding="utf-8").splitlines()
158
+ if len(previous_lines) > 6:
159
+ previous_lines = previous_lines[6:]
160
+ else:
161
+ previous_lines = []
162
+ lines = [
163
+ "Changelog",
164
+ "=========",
165
+ "",
166
+ "Unreleased",
167
+ "----------",
168
+ "",
169
+ ]
170
+ if entries:
171
+ lines.extend(entries)
172
+ if previous_lines:
173
+ lines.append("")
174
+ lines.extend(previous_lines)
175
+ content = "\n".join(lines)
176
+ if not content.endswith("\n"):
177
+ content += "\n"
178
+ changelog_path.write_text(content, encoding="utf-8")
179
+ _append_log(log_path, "Regenerated CHANGELOG.rst using Python fallback")
180
+
181
+
182
+ def _ensure_release_todo(release) -> tuple[Todo, Path]:
183
+ target_version = _next_patch_version(release.version)
184
+ request = f"Create release {release.package.name} {target_version}"
185
+ try:
186
+ url = reverse("admin:core_packagerelease_changelist")
187
+ except NoReverseMatch:
188
+ url = ""
189
+ todo, _ = Todo.all_objects.update_or_create(
190
+ request__iexact=request,
191
+ defaults={
192
+ "request": request,
193
+ "url": url,
194
+ "request_details": "",
195
+ "is_seed_data": True,
196
+ "is_deleted": False,
197
+ "is_user_data": False,
198
+ "done_on": None,
199
+ "on_done_condition": "",
200
+ },
201
+ )
202
+ fixture_path = _write_todo_fixture(todo)
203
+ return todo, fixture_path
204
+
205
+
84
206
  def _sync_release_with_revision(release: PackageRelease) -> tuple[bool, str]:
85
207
  """Ensure ``release`` matches the repository revision and version.
86
208
 
@@ -125,7 +247,22 @@ def _sync_release_with_revision(release: PackageRelease) -> tuple[bool, str]:
125
247
  release.save(update_fields=list(updated_fields))
126
248
  PackageRelease.dump_fixture()
127
249
 
128
- return bool(updated_fields), previous_version
250
+ package_updated = False
251
+ if release.package_id and not release.package.is_active:
252
+ release.package.is_active = True
253
+ release.package.save(update_fields=["is_active"])
254
+ package_updated = True
255
+
256
+ version_updated = False
257
+ if release.version:
258
+ current = ""
259
+ if version_path.exists():
260
+ current = version_path.read_text(encoding="utf-8").strip()
261
+ if current != release.version:
262
+ version_path.write_text(f"{release.version}\n", encoding="utf-8")
263
+ version_updated = True
264
+
265
+ return bool(updated_fields or version_updated or package_updated), previous_version
129
266
 
130
267
 
131
268
  def _changelog_notes(version: str) -> str:
@@ -215,12 +352,12 @@ def _step_check_todos(release, ctx, log_path: Path) -> None:
215
352
  check=False,
216
353
  )
217
354
  ctx.pop("todos", None)
218
- ctx.pop("todos_ack", None)
355
+ ctx["todos_ack"] = True
219
356
 
220
357
 
221
358
  def _step_check_version(release, ctx, log_path: Path) -> None:
222
359
  from . import release as release_utils
223
- from packaging.version import Version
360
+ from packaging.version import InvalidVersion, Version
224
361
 
225
362
  if not release_utils._git_clean():
226
363
  proc = subprocess.run(
@@ -266,6 +403,7 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
266
403
  )
267
404
  subprocess.run(["git", "add", *fixture_files], check=True)
268
405
  subprocess.run(["git", "commit", "-m", "chore: update fixtures"], check=True)
406
+ _append_log(log_path, "Fixture changes committed")
269
407
 
270
408
  version_path = Path("VERSION")
271
409
  if version_path.exists():
@@ -279,32 +417,91 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
279
417
  if release_utils.network_available():
280
418
  try:
281
419
  resp = requests.get(f"https://pypi.org/pypi/{release.package.name}/json")
282
- if resp.ok and release.version in resp.json().get("releases", {}):
283
- raise Exception(f"Version {release.version} already on PyPI")
420
+ if resp.ok:
421
+ data = resp.json()
422
+ releases = data.get("releases", {})
423
+ try:
424
+ target_version = Version(release.version)
425
+ except InvalidVersion:
426
+ target_version = None
427
+
428
+ for candidate, files in releases.items():
429
+ same_version = candidate == release.version
430
+ if target_version is not None and not same_version:
431
+ try:
432
+ same_version = Version(candidate) == target_version
433
+ except InvalidVersion:
434
+ same_version = False
435
+ if not same_version:
436
+ continue
437
+
438
+ has_available_files = any(
439
+ isinstance(file_data, dict)
440
+ and not file_data.get("yanked", False)
441
+ for file_data in files or []
442
+ )
443
+ if has_available_files:
444
+ raise Exception(
445
+ f"Version {release.version} already on PyPI"
446
+ )
284
447
  except Exception as exc:
285
448
  # network errors should be logged but not crash
286
449
  if "already on PyPI" in str(exc):
287
450
  raise
288
451
  _append_log(log_path, f"PyPI check failed: {exc}")
452
+ else:
453
+ _append_log(
454
+ log_path,
455
+ f"Version {release.version} not published on PyPI",
456
+ )
289
457
  else:
290
458
  _append_log(log_path, "Network unavailable, skipping PyPI check")
291
459
 
292
460
 
293
461
  def _step_handle_migrations(release, ctx, log_path: Path) -> None:
294
462
  _append_log(log_path, "Freeze, squash and approve migrations")
463
+ _append_log(log_path, "Migration review acknowledged (manual step)")
295
464
 
296
465
 
297
466
  def _step_changelog_docs(release, ctx, log_path: Path) -> None:
298
467
  _append_log(log_path, "Compose CHANGELOG and documentation")
468
+ _append_log(log_path, "CHANGELOG and documentation review recorded")
299
469
 
300
470
 
301
471
  def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
302
472
  _append_log(log_path, "Execute pre-release actions")
473
+ try:
474
+ subprocess.run(["scripts/generate-changelog.sh"], check=True)
475
+ except OSError as exc:
476
+ if _should_use_python_changelog(exc):
477
+ _append_log(
478
+ log_path,
479
+ f"scripts/generate-changelog.sh failed: {exc}",
480
+ )
481
+ _generate_changelog_with_python(log_path)
482
+ else: # pragma: no cover - unexpected OSError
483
+ raise
484
+ else:
485
+ _append_log(
486
+ log_path, "Regenerated CHANGELOG.rst using scripts/generate-changelog.sh"
487
+ )
488
+ subprocess.run(["git", "add", "CHANGELOG.rst"], check=True)
489
+ _append_log(log_path, "Staged CHANGELOG.rst for commit")
303
490
  version_path = Path("VERSION")
304
491
  version_path.write_text(f"{release.version}\n", encoding="utf-8")
492
+ _append_log(log_path, f"Updated VERSION file to {release.version}")
305
493
  subprocess.run(["git", "add", "VERSION"], check=True)
494
+ _append_log(log_path, "Staged VERSION for commit")
306
495
  diff = subprocess.run(
307
- ["git", "diff", "--cached", "--quiet", "--", "VERSION"],
496
+ [
497
+ "git",
498
+ "diff",
499
+ "--cached",
500
+ "--quiet",
501
+ "--",
502
+ "CHANGELOG.rst",
503
+ "VERSION",
504
+ ],
308
505
  check=False,
309
506
  )
310
507
  if diff.returncode != 0:
@@ -312,13 +509,40 @@ def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
312
509
  ["git", "commit", "-m", f"pre-release commit {release.version}"],
313
510
  check=True,
314
511
  )
512
+ _append_log(log_path, f"Committed VERSION update for {release.version}")
315
513
  else:
316
- _append_log(log_path, "No changes detected for VERSION; skipping commit")
514
+ _append_log(
515
+ log_path, "No changes detected for VERSION or CHANGELOG; skipping commit"
516
+ )
517
+ subprocess.run(["git", "reset", "HEAD", "CHANGELOG.rst"], check=False)
518
+ _append_log(log_path, "Unstaged CHANGELOG.rst")
317
519
  subprocess.run(["git", "reset", "HEAD", "VERSION"], check=False)
520
+ _append_log(log_path, "Unstaged VERSION file")
521
+ todo, fixture_path = _ensure_release_todo(release)
522
+ fixture_display = _format_path(fixture_path)
523
+ _append_log(log_path, f"Added TODO: {todo.request}")
524
+ _append_log(log_path, f"Wrote TODO fixture {fixture_display}")
525
+ subprocess.run(["git", "add", str(fixture_path)], check=True)
526
+ _append_log(log_path, f"Staged TODO fixture {fixture_display}")
527
+ fixture_diff = subprocess.run(
528
+ ["git", "diff", "--cached", "--quiet", "--", str(fixture_path)],
529
+ check=False,
530
+ )
531
+ if fixture_diff.returncode != 0:
532
+ commit_message = f"chore: add release TODO for {release.package.name}"
533
+ subprocess.run(["git", "commit", "-m", commit_message], check=True)
534
+ _append_log(log_path, f"Committed TODO fixture {fixture_display}")
535
+ else:
536
+ _append_log(
537
+ log_path,
538
+ f"No changes detected for TODO fixture {fixture_display}; skipping commit",
539
+ )
540
+ _append_log(log_path, "Pre-release actions complete")
318
541
 
319
542
 
320
543
  def _step_run_tests(release, ctx, log_path: Path) -> None:
321
544
  _append_log(log_path, "Complete test suite with --all flag")
545
+ _append_log(log_path, "Test suite completion acknowledged")
322
546
 
323
547
 
324
548
  def _step_promote_build(release, ctx, log_path: Path) -> None:
@@ -328,15 +552,22 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
328
552
  try:
329
553
  try:
330
554
  subprocess.run(["git", "fetch", "origin", "main"], check=True)
555
+ _append_log(log_path, "Fetched latest changes from origin/main")
331
556
  subprocess.run(["git", "rebase", "origin/main"], check=True)
557
+ _append_log(log_path, "Rebased current branch onto origin/main")
332
558
  except subprocess.CalledProcessError as exc:
333
559
  subprocess.run(["git", "rebase", "--abort"], check=False)
560
+ _append_log(log_path, "Rebase onto origin/main failed; aborted rebase")
334
561
  raise Exception("Rebase onto main failed") from exc
335
562
  release_utils.promote(
336
563
  package=release.to_package(),
337
564
  version=release.version,
338
565
  creds=release.to_credentials(),
339
566
  )
567
+ _append_log(
568
+ log_path,
569
+ f"Generated release artifacts for v{release.version}",
570
+ )
340
571
  from glob import glob
341
572
 
342
573
  paths = ["VERSION", *glob("core/fixtures/releases__*.json")]
@@ -347,6 +578,7 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
347
578
  )
348
579
  if diff.stdout.strip():
349
580
  subprocess.run(["git", "add", *paths], check=True)
581
+ _append_log(log_path, "Staged release metadata updates")
350
582
  subprocess.run(
351
583
  [
352
584
  "git",
@@ -356,8 +588,14 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
356
588
  ],
357
589
  check=True,
358
590
  )
591
+ _append_log(
592
+ log_path,
593
+ f"Committed release metadata for v{release.version}",
594
+ )
359
595
  subprocess.run(["git", "push"], check=True)
596
+ _append_log(log_path, "Pushed release changes to origin")
360
597
  PackageRelease.dump_fixture()
598
+ _append_log(log_path, "Updated release fixtures")
361
599
  except Exception:
362
600
  _clean_repo()
363
601
  raise
@@ -415,8 +653,10 @@ def _step_publish(release, ctx, log_path: Path) -> None:
415
653
  release.pypi_url = (
416
654
  f"https://pypi.org/project/{release.package.name}/{release.version}/"
417
655
  )
418
- release.save(update_fields=["pypi_url"])
656
+ release.release_on = timezone.now()
657
+ release.save(update_fields=["pypi_url", "release_on"])
419
658
  PackageRelease.dump_fixture()
659
+ _append_log(log_path, f"Recorded PyPI URL: {release.pypi_url}")
420
660
  _append_log(log_path, "Upload complete")
421
661
 
422
662
 
@@ -644,6 +884,8 @@ def release_progress(request, pk: int, action: str):
644
884
  f"{release.package.name}-{previous_version}*.log"
645
885
  ):
646
886
  log_file.unlink()
887
+ if not release.is_current:
888
+ raise Http404("Release is not current")
647
889
 
648
890
  if request.GET.get("restart"):
649
891
  count = 0
@@ -656,7 +898,8 @@ def release_progress(request, pk: int, action: str):
656
898
  restart_path.write_text(str(count + 1), encoding="utf-8")
657
899
  _clean_repo()
658
900
  release.pypi_url = ""
659
- release.save(update_fields=["pypi_url"])
901
+ release.release_on = None
902
+ release.save(update_fields=["pypi_url", "release_on"])
660
903
  request.session.pop(session_key, None)
661
904
  if lock_path.exists():
662
905
  lock_path.unlink()
@@ -946,20 +1189,121 @@ def release_progress(request, pk: int, action: str):
946
1189
  return render(request, "core/release_progress.html", context)
947
1190
 
948
1191
 
949
- def _todo_iframe_url(request, todo: Todo) -> str:
950
- """Return a safe iframe URL for ``todo`` scoped to the current host."""
1192
+ def _dedupe_preserve_order(values):
1193
+ seen = set()
1194
+ result = []
1195
+ for value in values:
1196
+ if value in seen:
1197
+ continue
1198
+ seen.add(value)
1199
+ result.append(value)
1200
+ return result
1201
+
1202
+
1203
+ def _parse_todo_auth_directives(query: str):
1204
+ directives = {
1205
+ "require_logout": False,
1206
+ "users": [],
1207
+ "permissions": [],
1208
+ "notes": [],
1209
+ }
1210
+ if not query:
1211
+ return "", directives
1212
+
1213
+ remaining = []
1214
+ for key, value in parse_qsl(query, keep_blank_values=True):
1215
+ if key != "_todo_auth":
1216
+ remaining.append((key, value))
1217
+ continue
1218
+ token = (value or "").strip()
1219
+ if not token:
1220
+ continue
1221
+ kind, _, payload = token.partition(":")
1222
+ kind = kind.strip().lower()
1223
+ payload = payload.strip()
1224
+ if kind in {"logout", "anonymous", "anon"}:
1225
+ directives["require_logout"] = True
1226
+ elif kind in {"user", "username"} and payload:
1227
+ directives["users"].append(payload)
1228
+ elif kind in {"perm", "permission"} and payload:
1229
+ directives["permissions"].append(payload)
1230
+ else:
1231
+ directives["notes"].append(token)
1232
+
1233
+ sanitized_query = urlencode(remaining, doseq=True)
1234
+ return sanitized_query, directives
1235
+
1236
+
1237
+ def _todo_iframe_url(request, todo: Todo):
1238
+ """Return a safe iframe URL and auth context for ``todo``."""
951
1239
 
952
1240
  fallback = reverse("admin:core_todo_change", args=[todo.pk])
953
1241
  raw_url = (todo.url or "").strip()
1242
+
1243
+ auth_context = {
1244
+ "require_logout": False,
1245
+ "users": [],
1246
+ "permissions": [],
1247
+ "notes": [],
1248
+ }
1249
+
1250
+ def _final_context(target_url: str):
1251
+ return {
1252
+ "target_url": target_url or fallback,
1253
+ "require_logout": auth_context["require_logout"],
1254
+ "users": _dedupe_preserve_order(auth_context["users"]),
1255
+ "permissions": _dedupe_preserve_order(auth_context["permissions"]),
1256
+ "notes": _dedupe_preserve_order(auth_context["notes"]),
1257
+ "has_requirements": bool(
1258
+ auth_context["require_logout"]
1259
+ or auth_context["users"]
1260
+ or auth_context["permissions"]
1261
+ or auth_context["notes"]
1262
+ ),
1263
+ }
1264
+
954
1265
  if not raw_url:
955
- return fallback
1266
+ return fallback, _final_context(fallback)
1267
+
1268
+ focus_path = reverse("todo-focus", args=[todo.pk])
1269
+ focus_norm = focus_path.strip("/").lower()
1270
+
1271
+ def _is_focus_target(target: str) -> bool:
1272
+ if not target:
1273
+ return False
1274
+ parsed_target = urlsplit(target)
1275
+ path = parsed_target.path
1276
+ if not path and not parsed_target.scheme and not parsed_target.netloc:
1277
+ path = target.split("?", 1)[0].split("#", 1)[0]
1278
+ normalized = path.strip("/").lower()
1279
+ return normalized == focus_norm if normalized else False
1280
+
1281
+ if _is_focus_target(raw_url):
1282
+ return fallback, _final_context(fallback)
956
1283
 
957
1284
  parsed = urlsplit(raw_url)
1285
+
1286
+ def _merge_directives(parsed_result):
1287
+ sanitized_query, directives = _parse_todo_auth_directives(parsed_result.query)
1288
+ if directives["require_logout"]:
1289
+ auth_context["require_logout"] = True
1290
+ auth_context["users"].extend(directives["users"])
1291
+ auth_context["permissions"].extend(directives["permissions"])
1292
+ auth_context["notes"].extend(directives["notes"])
1293
+ return parsed_result._replace(query=sanitized_query)
1294
+
958
1295
  if not parsed.scheme and not parsed.netloc:
959
- return raw_url
1296
+ sanitized = _merge_directives(parsed)
1297
+ path = sanitized.path or "/"
1298
+ if not path.startswith("/"):
1299
+ path = f"/{path}"
1300
+ relative_url = urlunsplit(("", "", path, sanitized.query, sanitized.fragment))
1301
+ if _is_focus_target(relative_url):
1302
+ return fallback, _final_context(fallback)
1303
+ return relative_url or fallback, _final_context(relative_url)
960
1304
 
961
1305
  if parsed.scheme and parsed.scheme.lower() not in {"http", "https"}:
962
- return fallback
1306
+ return fallback, _final_context(fallback)
963
1307
 
964
1308
  request_host = request.get_host().strip().lower()
965
1309
  host_without_port = request_host.split(":", 1)[0]
@@ -993,12 +1337,16 @@ def _todo_iframe_url(request, todo: Todo) -> str:
993
1337
  hostname = (parsed.hostname or "").strip().lower()
994
1338
  netloc = parsed.netloc.strip().lower()
995
1339
  if hostname in allowed_hosts or netloc in allowed_hosts:
996
- path = parsed.path or "/"
1340
+ sanitized = _merge_directives(parsed)
1341
+ path = sanitized.path or "/"
997
1342
  if not path.startswith("/"):
998
1343
  path = f"/{path}"
999
- return urlunsplit(("", "", path, parsed.query, parsed.fragment)) or fallback
1344
+ relative_url = urlunsplit(("", "", path, sanitized.query, sanitized.fragment))
1345
+ if _is_focus_target(relative_url):
1346
+ return fallback, _final_context(fallback)
1347
+ return relative_url or fallback, _final_context(relative_url)
1000
1348
 
1001
- return fallback
1349
+ return fallback, _final_context(fallback)
1002
1350
 
1003
1351
 
1004
1352
  @staff_member_required
@@ -1007,10 +1355,13 @@ def todo_focus(request, pk: int):
1007
1355
  if todo.done_on:
1008
1356
  return redirect(_get_return_url(request))
1009
1357
 
1010
- iframe_url = _todo_iframe_url(request, todo)
1358
+ iframe_url, focus_auth = _todo_iframe_url(request, todo)
1359
+ focus_target_url = focus_auth.get("target_url", iframe_url) if focus_auth else iframe_url
1011
1360
  context = {
1012
1361
  "todo": todo,
1013
1362
  "iframe_url": iframe_url,
1363
+ "focus_target_url": focus_target_url,
1364
+ "focus_auth": focus_auth,
1014
1365
  "next_url": _get_return_url(request),
1015
1366
  "done_url": reverse("todo-done", args=[todo.pk]),
1016
1367
  }