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/views.py CHANGED
@@ -3,19 +3,24 @@ import shutil
3
3
  from datetime import timedelta
4
4
 
5
5
  import requests
6
+ from django.conf import settings
6
7
  from django.contrib.admin.views.decorators import staff_member_required
7
8
  from django.contrib.auth import authenticate, login
9
+ from django.contrib import messages
10
+ from django.contrib.sites.models import Site
8
11
  from django.http import Http404, JsonResponse
9
- from django.shortcuts import get_object_or_404, render, redirect
10
- from django.views.decorators.csrf import csrf_exempt
11
- from django.views.decorators.http import require_POST
12
- from django.utils.translation import gettext as _
12
+ from django.shortcuts import get_object_or_404, redirect, render, resolve_url
13
13
  from django.utils import timezone
14
+ from django.utils.translation import gettext as _
14
15
  from django.urls import NoReverseMatch, reverse
16
+ from django.views.decorators.csrf import csrf_exempt
17
+ from django.views.decorators.http import require_GET, require_POST
18
+ from django.utils.http import url_has_allowed_host_and_scheme
15
19
  from pathlib import Path
20
+ from urllib.parse import urlsplit, urlunsplit
16
21
  import subprocess
17
- import json
18
22
 
23
+ from utils import revision
19
24
  from utils.api import api_login_required
20
25
 
21
26
  from .models import Product, EnergyAccount, PackageRelease, Todo
@@ -42,6 +47,22 @@ def odoo_products(request):
42
47
  return JsonResponse(items, safe=False)
43
48
 
44
49
 
50
+ @require_GET
51
+ def version_info(request):
52
+ """Return the running application version and Git revision."""
53
+
54
+ version = ""
55
+ version_path = Path(settings.BASE_DIR) / "VERSION"
56
+ if version_path.exists():
57
+ version = version_path.read_text(encoding="utf-8").strip()
58
+ return JsonResponse(
59
+ {
60
+ "version": version,
61
+ "revision": revision.get_revision(),
62
+ }
63
+ )
64
+
65
+
45
66
  from . import release as release_utils
46
67
 
47
68
 
@@ -60,6 +81,53 @@ def _clean_repo() -> None:
60
81
  subprocess.run(["git", "clean", "-fd"], check=False)
61
82
 
62
83
 
84
+ def _sync_release_with_revision(release: PackageRelease) -> tuple[bool, str]:
85
+ """Ensure ``release`` matches the repository revision and version.
86
+
87
+ Returns a tuple ``(updated, previous_version)`` where ``updated`` is
88
+ ``True`` when any field changed and ``previous_version`` is the version
89
+ before synchronization.
90
+ """
91
+
92
+ from packaging.version import InvalidVersion, Version
93
+
94
+ previous_version = release.version
95
+ updated_fields: set[str] = set()
96
+
97
+ repo_version: Version | None = None
98
+ version_path = Path("VERSION")
99
+ if version_path.exists():
100
+ try:
101
+ repo_version = Version(version_path.read_text(encoding="utf-8").strip())
102
+ except InvalidVersion:
103
+ repo_version = None
104
+
105
+ try:
106
+ release_version = Version(release.version)
107
+ except InvalidVersion:
108
+ release_version = None
109
+
110
+ if repo_version is not None:
111
+ bumped_repo_version = Version(
112
+ f"{repo_version.major}.{repo_version.minor}.{repo_version.micro + 1}"
113
+ )
114
+ if release_version is None or release_version < bumped_repo_version:
115
+ release.version = str(bumped_repo_version)
116
+ release_version = bumped_repo_version
117
+ updated_fields.add("version")
118
+
119
+ current_revision = revision.get_revision()
120
+ if current_revision and current_revision != release.revision:
121
+ release.revision = current_revision
122
+ updated_fields.add("revision")
123
+
124
+ if updated_fields:
125
+ release.save(update_fields=list(updated_fields))
126
+ PackageRelease.dump_fixture()
127
+
128
+ return bool(updated_fields), previous_version
129
+
130
+
63
131
  def _changelog_notes(version: str) -> str:
64
132
  path = Path("CHANGELOG.rst")
65
133
  if not path.exists():
@@ -85,6 +153,46 @@ class ApprovalRequired(Exception):
85
153
  """Raised when release manager approval is required before continuing."""
86
154
 
87
155
 
156
+ def _format_condition_failure(todo: Todo, result) -> str:
157
+ """Return a localized error message for a failed TODO condition."""
158
+
159
+ if result.error and result.resolved:
160
+ detail = _("%(condition)s (error: %(error)s)") % {
161
+ "condition": result.resolved,
162
+ "error": result.error,
163
+ }
164
+ elif result.error:
165
+ detail = _("Error: %(error)s") % {"error": result.error}
166
+ elif result.resolved:
167
+ detail = result.resolved
168
+ else:
169
+ detail = _("Condition evaluated to False")
170
+ return _("Condition failed for %(todo)s: %(detail)s") % {
171
+ "todo": todo.request,
172
+ "detail": detail,
173
+ }
174
+
175
+
176
+ def _get_return_url(request) -> str:
177
+ """Return a safe URL to redirect back to after completing a TODO."""
178
+
179
+ candidates = [request.GET.get("next"), request.POST.get("next")]
180
+ referer = request.META.get("HTTP_REFERER")
181
+ if referer:
182
+ candidates.append(referer)
183
+
184
+ for candidate in candidates:
185
+ if not candidate:
186
+ continue
187
+ if url_has_allowed_host_and_scheme(
188
+ candidate,
189
+ allowed_hosts={request.get_host()},
190
+ require_https=request.is_secure(),
191
+ ):
192
+ return candidate
193
+ return resolve_url("admin:index")
194
+
195
+
88
196
  def _step_check_todos(release, ctx, log_path: Path) -> None:
89
197
  pending_qs = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
90
198
  if pending_qs.exists():
@@ -312,10 +420,13 @@ def _step_publish(release, ctx, log_path: Path) -> None:
312
420
  _append_log(log_path, "Upload complete")
313
421
 
314
422
 
423
+ FIXTURE_REVIEW_STEP_NAME = "Freeze, squash and approve migrations"
424
+
425
+
315
426
  PUBLISH_STEPS = [
316
427
  ("Check version number availability", _step_check_version),
317
428
  ("Confirm release TODO completion", _step_check_todos),
318
- ("Freeze, squash and approve migrations", _step_handle_migrations),
429
+ (FIXTURE_REVIEW_STEP_NAME, _step_handle_migrations),
319
430
  ("Compose CHANGELOG and documentation", _step_changelog_docs),
320
431
  ("Execute pre-release actions", _step_pre_release_actions),
321
432
  ("Build release artifacts", _step_promote_build),
@@ -514,12 +625,26 @@ def release_progress(request, pk: int, action: str):
514
625
  release = get_object_or_404(PackageRelease, pk=pk)
515
626
  if action != "publish":
516
627
  raise Http404("Unknown action")
517
- if not release.is_current:
518
- raise Http404("Release is not current")
519
628
  session_key = f"release_publish_{pk}"
520
629
  lock_path = Path("locks") / f"release_publish_{pk}.json"
521
630
  restart_path = Path("locks") / f"release_publish_{pk}.restarts"
522
631
 
632
+ if not release.is_current:
633
+ if release.is_published:
634
+ raise Http404("Release is not current")
635
+ updated, previous_version = _sync_release_with_revision(release)
636
+ if updated:
637
+ request.session.pop(session_key, None)
638
+ if lock_path.exists():
639
+ lock_path.unlink()
640
+ if restart_path.exists():
641
+ restart_path.unlink()
642
+ log_dir = Path("logs")
643
+ for log_file in log_dir.glob(
644
+ f"{release.package.name}-{previous_version}*.log"
645
+ ):
646
+ log_file.unlink()
647
+
523
648
  if request.GET.get("restart"):
524
649
  count = 0
525
650
  if restart_path.exists():
@@ -555,11 +680,11 @@ def release_progress(request, pk: int, action: str):
555
680
  if credentials_ready and ctx.get("approval_credentials_missing"):
556
681
  ctx.pop("approval_credentials_missing", None)
557
682
 
683
+ ack_todos_requested = bool(request.GET.get("ack_todos"))
684
+
558
685
  if request.GET.get("start"):
559
686
  ctx["started"] = True
560
687
  ctx["paused"] = False
561
- if request.GET.get("ack_todos"):
562
- ctx["todos_ack"] = True
563
688
  if (
564
689
  ctx.get("awaiting_approval")
565
690
  and not ctx.get("approval_credentials_missing")
@@ -580,9 +705,34 @@ def release_progress(request, pk: int, action: str):
580
705
  step_count = ctx.get("step", 0)
581
706
  step_param = request.GET.get("step")
582
707
 
583
- pending = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
584
- if pending.exists() and not ctx.get("todos_ack"):
585
- ctx["todos"] = list(pending.values("id", "request", "url", "request_details"))
708
+ pending_qs = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
709
+ pending_items = list(pending_qs)
710
+ if ack_todos_requested:
711
+ if pending_items:
712
+ failures = []
713
+ for todo in pending_items:
714
+ result = todo.check_on_done_condition()
715
+ if not result.passed:
716
+ failures.append((todo, result))
717
+ if failures:
718
+ ctx.pop("todos_ack", None)
719
+ for todo, result in failures:
720
+ messages.error(request, _format_condition_failure(todo, result))
721
+ else:
722
+ ctx["todos_ack"] = True
723
+ else:
724
+ ctx["todos_ack"] = True
725
+
726
+ if pending_items and not ctx.get("todos_ack"):
727
+ ctx["todos"] = [
728
+ {
729
+ "id": todo.pk,
730
+ "request": todo.request,
731
+ "url": todo.url,
732
+ "request_details": todo.request_details,
733
+ }
734
+ for todo in pending_items
735
+ ]
586
736
  else:
587
737
  ctx.pop("todos", None)
588
738
 
@@ -608,6 +758,14 @@ def release_progress(request, pk: int, action: str):
608
758
  log_path.unlink()
609
759
 
610
760
  steps = PUBLISH_STEPS
761
+ fixtures_step_index = next(
762
+ (
763
+ index
764
+ for index, (name, _) in enumerate(steps)
765
+ if name == FIXTURE_REVIEW_STEP_NAME
766
+ ),
767
+ None,
768
+ )
611
769
  error = ctx.get("error")
612
770
 
613
771
  if (
@@ -742,6 +900,14 @@ def release_progress(request, pk: int, action: str):
742
900
  "admin:core_user_change", args=[request.user.pk]
743
901
  )
744
902
 
903
+ fixtures_summary = ctx.get("fixtures")
904
+ if (
905
+ fixtures_summary
906
+ and fixtures_step_index is not None
907
+ and step_count > fixtures_step_index
908
+ ):
909
+ fixtures_summary = None
910
+
745
911
  context = {
746
912
  "release": release,
747
913
  "action": "publish",
@@ -753,7 +919,7 @@ def release_progress(request, pk: int, action: str):
753
919
  "log_content": log_content,
754
920
  "log_path": str(log_path),
755
921
  "cert_log": ctx.get("cert_log"),
756
- "fixtures": ctx.get("fixtures"),
922
+ "fixtures": fixtures_summary,
757
923
  "todos": ctx.get("todos"),
758
924
  "restart_count": restart_count,
759
925
  "started": ctx.get("started", False),
@@ -780,10 +946,86 @@ def release_progress(request, pk: int, action: str):
780
946
  return render(request, "core/release_progress.html", context)
781
947
 
782
948
 
949
+ def _todo_iframe_url(request, todo: Todo) -> str:
950
+ """Return a safe iframe URL for ``todo`` scoped to the current host."""
951
+
952
+ fallback = reverse("admin:core_todo_change", args=[todo.pk])
953
+ raw_url = (todo.url or "").strip()
954
+ if not raw_url:
955
+ return fallback
956
+
957
+ parsed = urlsplit(raw_url)
958
+ if not parsed.scheme and not parsed.netloc:
959
+ return raw_url
960
+
961
+ if parsed.scheme and parsed.scheme.lower() not in {"http", "https"}:
962
+ return fallback
963
+
964
+ request_host = request.get_host().strip().lower()
965
+ host_without_port = request_host.split(":", 1)[0]
966
+ allowed_hosts = {
967
+ request_host,
968
+ host_without_port,
969
+ "localhost",
970
+ "127.0.0.1",
971
+ "0.0.0.0",
972
+ "::1",
973
+ }
974
+
975
+ site_domain = ""
976
+ try:
977
+ site_domain = Site.objects.get_current().domain.strip().lower()
978
+ except Site.DoesNotExist:
979
+ site_domain = ""
980
+ if site_domain:
981
+ allowed_hosts.add(site_domain)
982
+ allowed_hosts.add(site_domain.split(":", 1)[0])
983
+
984
+ for host in getattr(settings, "ALLOWED_HOSTS", []):
985
+ if not isinstance(host, str):
986
+ continue
987
+ normalized = host.strip().lower()
988
+ if not normalized or normalized.startswith("*"):
989
+ continue
990
+ allowed_hosts.add(normalized)
991
+ allowed_hosts.add(normalized.split(":", 1)[0])
992
+
993
+ hostname = (parsed.hostname or "").strip().lower()
994
+ netloc = parsed.netloc.strip().lower()
995
+ if hostname in allowed_hosts or netloc in allowed_hosts:
996
+ path = parsed.path or "/"
997
+ if not path.startswith("/"):
998
+ path = f"/{path}"
999
+ return urlunsplit(("", "", path, parsed.query, parsed.fragment)) or fallback
1000
+
1001
+ return fallback
1002
+
1003
+
1004
+ @staff_member_required
1005
+ def todo_focus(request, pk: int):
1006
+ todo = get_object_or_404(Todo, pk=pk, is_deleted=False)
1007
+ if todo.done_on:
1008
+ return redirect(_get_return_url(request))
1009
+
1010
+ iframe_url = _todo_iframe_url(request, todo)
1011
+ context = {
1012
+ "todo": todo,
1013
+ "iframe_url": iframe_url,
1014
+ "next_url": _get_return_url(request),
1015
+ "done_url": reverse("todo-done", args=[todo.pk]),
1016
+ }
1017
+ return render(request, "core/todo_focus.html", context)
1018
+
1019
+
783
1020
  @staff_member_required
784
1021
  @require_POST
785
1022
  def todo_done(request, pk: int):
786
1023
  todo = get_object_or_404(Todo, pk=pk, is_deleted=False, done_on__isnull=True)
1024
+ redirect_to = _get_return_url(request)
1025
+ result = todo.check_on_done_condition()
1026
+ if not result.passed:
1027
+ messages.error(request, _format_condition_failure(todo, result))
1028
+ return redirect(redirect_to)
787
1029
  todo.done_on = timezone.now()
788
1030
  todo.save(update_fields=["done_on"])
789
- return redirect("admin:index")
1031
+ return redirect(redirect_to)
nodes/admin.py CHANGED
@@ -9,6 +9,7 @@ from django.db.models import Count
9
9
  from django.conf import settings
10
10
  from pathlib import Path
11
11
  from django.http import HttpResponse
12
+ from django.utils.translation import gettext_lazy as _
12
13
  import base64
13
14
  import pyperclip
14
15
  from pyperclip import PyperclipException
@@ -114,6 +115,9 @@ class NodeAdmin(EntityModelAdmin):
114
115
 
115
116
  token = uuid.uuid4().hex
116
117
  context = {
118
+ **self.admin_site.each_context(request),
119
+ "opts": self.model._meta,
120
+ "title": _("Register Visitor Node"),
117
121
  "token": token,
118
122
  "info_url": reverse("node-info"),
119
123
  "register_url": reverse("register-node"),
@@ -280,9 +284,6 @@ class EmailOutboxAdmin(EntityModelAdmin):
280
284
  self.message_user(request, str(exc), messages.ERROR)
281
285
  return redirect("..")
282
286
 
283
- def get_model_perms(self, request): # pragma: no cover - hide from index
284
- return {}
285
-
286
287
  def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
287
288
  extra_context = extra_context or {}
288
289
  if object_id:
@@ -430,7 +431,14 @@ class ContentSampleAdmin(EntityModelAdmin):
430
431
 
431
432
  @admin.register(NetMessage)
432
433
  class NetMessageAdmin(EntityModelAdmin):
433
- list_display = ("subject", "body", "reach", "created", "complete")
434
+ list_display = (
435
+ "subject",
436
+ "body",
437
+ "reach",
438
+ "node_origin",
439
+ "created",
440
+ "complete",
441
+ )
434
442
  search_fields = ("subject", "body")
435
443
  list_filter = ("complete", "reach")
436
444
  ordering = ("-created",)
nodes/backends.py CHANGED
@@ -1,5 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import random
4
+ from collections import defaultdict
5
+
3
6
  from django.core.mail.backends.base import BaseEmailBackend
4
7
  from django.core.mail import get_connection
5
8
  from django.conf import settings
@@ -13,41 +16,130 @@ class OutboxEmailBackend(BaseEmailBackend):
13
16
 
14
17
  If a matching outbox exists for the message's ``from_email`` (matching
15
18
  either ``from_email`` or ``username``), that outbox's SMTP credentials are
16
- used. Otherwise, the first available outbox is used. When no outboxes are
17
- configured, the system falls back to Django's default SMTP settings.
19
+ used. ``EmailOutbox`` associations to ``node``, ``user`` and ``group`` are
20
+ also considered and preferred when multiple criteria match. When no
21
+ outboxes are configured, the system falls back to Django's default SMTP
22
+ settings.
18
23
  """
19
24
 
20
- def _select_outbox(self, from_email: str | None) -> EmailOutbox | None:
25
+ def _resolve_identifier(self, message, attr: str):
26
+ value = getattr(message, attr, None)
27
+ if value is None:
28
+ value = getattr(message, f"{attr}_id", None)
29
+ if value is None:
30
+ return None
31
+ return getattr(value, "pk", value)
32
+
33
+ def _select_outbox(
34
+ self, message
35
+ ) -> tuple[EmailOutbox | None, list[EmailOutbox]]:
36
+ from_email = getattr(message, "from_email", None)
37
+ node_id = self._resolve_identifier(message, "node")
38
+ user_id = self._resolve_identifier(message, "user")
39
+ group_id = self._resolve_identifier(message, "group")
40
+
41
+ match_sets: list[tuple[str, list[EmailOutbox]]] = []
42
+
21
43
  if from_email:
22
- return (
44
+ email_matches = list(
23
45
  EmailOutbox.objects.filter(
24
46
  Q(from_email__iexact=from_email) | Q(username__iexact=from_email)
25
- ).first()
26
- or EmailOutbox.objects.first()
47
+ )
27
48
  )
28
- return EmailOutbox.objects.first()
49
+ if email_matches:
50
+ match_sets.append(("from_email", email_matches))
51
+
52
+ if node_id:
53
+ node_matches = list(EmailOutbox.objects.filter(node_id=node_id))
54
+ if node_matches:
55
+ match_sets.append(("node", node_matches))
56
+
57
+ if user_id:
58
+ user_matches = list(EmailOutbox.objects.filter(user_id=user_id))
59
+ if user_matches:
60
+ match_sets.append(("user", user_matches))
61
+
62
+ if group_id:
63
+ group_matches = list(EmailOutbox.objects.filter(group_id=group_id))
64
+ if group_matches:
65
+ match_sets.append(("group", group_matches))
66
+
67
+ if not match_sets:
68
+ return EmailOutbox.objects.first(), []
69
+
70
+ candidates: dict[int, EmailOutbox] = {}
71
+ scores: defaultdict[int, int] = defaultdict(int)
72
+
73
+ for _, matches in match_sets:
74
+ for outbox in matches:
75
+ candidates[outbox.pk] = outbox
76
+ scores[outbox.pk] += 1
77
+
78
+ if not candidates:
79
+ return EmailOutbox.objects.first(), []
80
+
81
+ selected: EmailOutbox | None = None
82
+ fallbacks: list[EmailOutbox] = []
83
+
84
+ for score in sorted(set(scores.values()), reverse=True):
85
+ group = [candidates[pk] for pk, value in scores.items() if value == score]
86
+ if len(group) > 1:
87
+ random.shuffle(group)
88
+ if selected is None:
89
+ selected = group[0]
90
+ fallbacks.extend(group[1:])
91
+ else:
92
+ fallbacks.extend(group)
93
+
94
+ return selected, fallbacks
29
95
 
30
96
  def send_messages(self, email_messages):
31
97
  sent = 0
32
98
  for message in email_messages:
33
- outbox = self._select_outbox(message.from_email)
99
+ original_from_email = message.from_email
100
+ outbox, fallbacks = self._select_outbox(message)
101
+ tried_outboxes = []
34
102
  if outbox:
35
- connection = outbox.get_connection()
36
- if not message.from_email:
103
+ tried_outboxes.append(outbox)
104
+ tried_outboxes.extend(fallbacks)
105
+
106
+ last_error: Exception | None = None
107
+
108
+ if tried_outboxes:
109
+ for candidate in tried_outboxes:
110
+ connection = candidate.get_connection()
37
111
  message.from_email = (
38
- outbox.from_email or settings.DEFAULT_FROM_EMAIL
112
+ original_from_email
113
+ or candidate.from_email
114
+ or settings.DEFAULT_FROM_EMAIL
39
115
  )
116
+ try:
117
+ sent += connection.send_messages([message]) or 0
118
+ last_error = None
119
+ break
120
+ except Exception as exc: # pragma: no cover - retry on error
121
+ last_error = exc
122
+ finally:
123
+ try:
124
+ connection.close()
125
+ except Exception: # pragma: no cover - close errors shouldn't fail send
126
+ pass
127
+ if last_error is not None:
128
+ message.from_email = original_from_email
129
+ raise last_error
40
130
  else:
41
131
  connection = get_connection(
42
132
  "django.core.mail.backends.smtp.EmailBackend"
43
133
  )
44
134
  if not message.from_email:
45
135
  message.from_email = settings.DEFAULT_FROM_EMAIL
46
- try:
47
- sent += connection.send_messages([message]) or 0
48
- finally:
49
136
  try:
50
- connection.close()
51
- except Exception: # pragma: no cover - close errors shouldn't fail send
52
- pass
137
+ sent += connection.send_messages([message]) or 0
138
+ finally:
139
+ try:
140
+ connection.close()
141
+ except Exception: # pragma: no cover - close errors shouldn't fail send
142
+ pass
143
+
144
+ message.from_email = original_from_email
53
145
  return sent