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

pages/admin.py CHANGED
@@ -6,12 +6,13 @@ from django.contrib.sites.admin import SiteAdmin as DjangoSiteAdmin
6
6
  from django.contrib.sites.models import Site
7
7
  from django import forms
8
8
  from django.shortcuts import redirect, render, get_object_or_404
9
- from django.urls import path, reverse
9
+ from django.urls import NoReverseMatch, path, reverse
10
10
  from django.utils.html import format_html
11
11
  from django.template.response import TemplateResponse
12
12
  from django.http import JsonResponse
13
13
  from django.utils import timezone
14
14
  from django.db.models import Count
15
+ from django.core.exceptions import FieldError
15
16
  from django.db.models.functions import TruncDate
16
17
  from datetime import datetime, time, timedelta
17
18
  import ipaddress
@@ -25,6 +26,7 @@ from nodes.utils import capture_screenshot, save_screenshot
25
26
 
26
27
  from .forms import UserManualAdminForm
27
28
  from .module_defaults import reload_default_modules as restore_default_modules
29
+ from .site_config import ensure_site_fields
28
30
  from .utils import landing_leads_supported
29
31
 
30
32
  from .models import (
@@ -41,6 +43,7 @@ from .models import (
41
43
  UserStory,
42
44
  )
43
45
  from django.contrib.contenttypes.models import ContentType
46
+ from core.models import ReleaseManager
44
47
  from core.user_data import EntityModelAdmin
45
48
 
46
49
 
@@ -73,12 +76,47 @@ class SiteForm(forms.ModelForm):
73
76
  fields = "__all__"
74
77
 
75
78
 
79
+ ensure_site_fields()
80
+
81
+
82
+ class _BooleanAttributeListFilter(admin.SimpleListFilter):
83
+ """Filter helper for boolean attributes on :class:`~django.contrib.sites.models.Site`."""
84
+
85
+ field_name: str
86
+
87
+ def lookups(self, request, model_admin): # pragma: no cover - admin UI
88
+ return (("1", _("Yes")), ("0", _("No")))
89
+
90
+ def queryset(self, request, queryset):
91
+ value = self.value()
92
+ if value not in {"0", "1"}:
93
+ return queryset
94
+ expected = value == "1"
95
+ try:
96
+ return queryset.filter(**{self.field_name: expected})
97
+ except FieldError: # pragma: no cover - defensive when fields missing
98
+ return queryset
99
+
100
+
101
+ class ManagedSiteListFilter(_BooleanAttributeListFilter):
102
+ title = _("Managed by local NGINX")
103
+ parameter_name = "managed"
104
+ field_name = "managed"
105
+
106
+
107
+ class RequireHttpsListFilter(_BooleanAttributeListFilter):
108
+ title = _("Require HTTPS")
109
+ parameter_name = "require_https"
110
+ field_name = "require_https"
111
+
112
+
76
113
  class SiteAdmin(DjangoSiteAdmin):
77
114
  form = SiteForm
78
115
  inlines = [SiteBadgeInline]
79
116
  change_list_template = "admin/sites/site/change_list.html"
80
- fields = ("domain", "name")
81
- list_display = ("domain", "name")
117
+ fields = ("domain", "name", "managed", "require_https")
118
+ list_display = ("domain", "name", "managed", "require_https")
119
+ list_filter = (ManagedSiteListFilter, RequireHttpsListFilter)
82
120
  actions = ["capture_screenshot"]
83
121
 
84
122
  @admin.action(description="Capture screenshot")
@@ -110,6 +148,27 @@ class SiteAdmin(DjangoSiteAdmin):
110
148
  messages.INFO,
111
149
  )
112
150
 
151
+ def save_model(self, request, obj, form, change):
152
+ super().save_model(request, obj, form, change)
153
+ if {"managed", "require_https"} & set(form.changed_data or []):
154
+ self.message_user(
155
+ request,
156
+ _(
157
+ "Managed NGINX configuration staged. Run network-setup.sh to apply changes."
158
+ ),
159
+ messages.INFO,
160
+ )
161
+
162
+ def delete_model(self, request, obj):
163
+ super().delete_model(request, obj)
164
+ self.message_user(
165
+ request,
166
+ _(
167
+ "Managed NGINX configuration staged. Run network-setup.sh to apply changes."
168
+ ),
169
+ messages.INFO,
170
+ )
171
+
113
172
  def _reload_site_fixtures(self, request):
114
173
  fixtures_dir = Path(settings.BASE_DIR) / "core" / "fixtures"
115
174
  fixture_paths = sorted(fixtures_dir.glob("references__00_site_*.json"))
@@ -708,10 +767,33 @@ class UserStoryAdmin(EntityModelAdmin):
708
767
  issue_url = story.create_github_issue()
709
768
  except Exception as exc: # pragma: no cover - network/runtime errors
710
769
  logger.exception("Failed to create GitHub issue for UserStory %s", story.pk)
770
+ message = _("Unable to create a GitHub issue for %(story)s: %(error)s") % {
771
+ "story": story,
772
+ "error": exc,
773
+ }
774
+
775
+ if (
776
+ isinstance(exc, RuntimeError)
777
+ and "GitHub token is not configured" in str(exc)
778
+ ):
779
+ try:
780
+ opts = ReleaseManager._meta
781
+ config_url = reverse(
782
+ f"{self.admin_site.name}:{opts.app_label}_{opts.model_name}_changelist"
783
+ )
784
+ except NoReverseMatch: # pragma: no cover - defensive guard
785
+ config_url = None
786
+ if config_url:
787
+ message = format_html(
788
+ "{} <a href=\"{}\">{}</a>",
789
+ message,
790
+ config_url,
791
+ _("Configure GitHub credentials."),
792
+ )
793
+
711
794
  self.message_user(
712
795
  request,
713
- _("Unable to create a GitHub issue for %(story)s: %(error)s")
714
- % {"story": story, "error": exc},
796
+ message,
715
797
  messages.ERROR,
716
798
  )
717
799
  continue
pages/apps.py CHANGED
@@ -8,3 +8,6 @@ class PagesConfig(AppConfig):
8
8
 
9
9
  def ready(self): # pragma: no cover - import for side effects
10
10
  from . import checks # noqa: F401
11
+ from . import site_config
12
+
13
+ site_config.ready()
pages/site_config.py ADDED
@@ -0,0 +1,137 @@
1
+ """Customizations for :mod:`django.contrib.sites`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from pathlib import Path
8
+
9
+ from django.apps import apps
10
+ from django.conf import settings
11
+ from django.contrib.sites.models import Site
12
+ from django.db import DatabaseError, models
13
+ from django.db.models.signals import post_delete, post_migrate, post_save
14
+ from django.dispatch import receiver
15
+ from django.utils.translation import gettext_lazy as _
16
+
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ _FIELD_DEFINITIONS: tuple[tuple[str, models.Field], ...] = (
22
+ (
23
+ "managed",
24
+ models.BooleanField(
25
+ default=False,
26
+ db_default=False,
27
+ verbose_name=_("Managed by local NGINX"),
28
+ help_text=_(
29
+ "Include this site when staging the local NGINX configuration."
30
+ ),
31
+ ),
32
+ ),
33
+ (
34
+ "require_https",
35
+ models.BooleanField(
36
+ default=False,
37
+ db_default=False,
38
+ verbose_name=_("Require HTTPS"),
39
+ help_text=_(
40
+ "Redirect HTTP traffic to HTTPS when the staged NGINX configuration is applied."
41
+ ),
42
+ ),
43
+ ),
44
+ )
45
+
46
+
47
+ def _sites_config_path() -> Path:
48
+ return Path(settings.BASE_DIR) / "scripts" / "generated" / "nginx-sites.json"
49
+
50
+
51
+ def _ensure_directories(path: Path) -> bool:
52
+ try:
53
+ path.parent.mkdir(parents=True, exist_ok=True)
54
+ except OSError as exc: # pragma: no cover - filesystem errors
55
+ logger.warning("Unable to create directory for %s: %s", path, exc)
56
+ return False
57
+ return True
58
+
59
+
60
+ def update_local_nginx_scripts() -> None:
61
+ """Serialize managed site configuration for the network setup script."""
62
+
63
+ SiteModel = apps.get_model("sites", "Site")
64
+ data: list[dict[str, object]] = []
65
+ seen_domains: set[str] = set()
66
+
67
+ try:
68
+ sites = list(
69
+ SiteModel.objects.filter(managed=True)
70
+ .only("domain", "require_https")
71
+ .order_by("domain")
72
+ )
73
+ except DatabaseError: # pragma: no cover - database not ready
74
+ return
75
+
76
+ for site in sites:
77
+ domain = (site.domain or "").strip()
78
+ if not domain:
79
+ continue
80
+ if domain.lower() in seen_domains:
81
+ continue
82
+ seen_domains.add(domain.lower())
83
+ data.append({"domain": domain, "require_https": bool(site.require_https)})
84
+
85
+ output_path = _sites_config_path()
86
+ if not _ensure_directories(output_path):
87
+ return
88
+
89
+ if data:
90
+ try:
91
+ output_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
92
+ except OSError as exc: # pragma: no cover - filesystem errors
93
+ logger.warning("Failed to write managed site configuration: %s", exc)
94
+ else:
95
+ try:
96
+ output_path.unlink()
97
+ except FileNotFoundError:
98
+ pass
99
+ except OSError as exc: # pragma: no cover - filesystem errors
100
+ logger.warning("Failed to remove managed site configuration: %s", exc)
101
+
102
+
103
+ def _install_fields() -> None:
104
+ for name, field in _FIELD_DEFINITIONS:
105
+ if hasattr(Site, name):
106
+ continue
107
+ Site.add_to_class(name, field.clone())
108
+
109
+
110
+ def ensure_site_fields() -> None:
111
+ """Ensure the custom ``Site`` fields are installed."""
112
+
113
+ _install_fields()
114
+
115
+
116
+ @receiver(post_save, sender=Site, dispatch_uid="pages_site_save_update_nginx")
117
+ def _site_saved(sender, **kwargs) -> None: # pragma: no cover - signal wrapper
118
+ update_local_nginx_scripts()
119
+
120
+
121
+ @receiver(post_delete, sender=Site, dispatch_uid="pages_site_delete_update_nginx")
122
+ def _site_deleted(sender, **kwargs) -> None: # pragma: no cover - signal wrapper
123
+ update_local_nginx_scripts()
124
+
125
+
126
+ def _run_post_migrate_update(**kwargs) -> None: # pragma: no cover - signal wrapper
127
+ update_local_nginx_scripts()
128
+
129
+
130
+ def ready() -> None:
131
+ """Apply customizations and connect signal handlers."""
132
+
133
+ ensure_site_fields()
134
+ post_migrate.connect(
135
+ _run_post_migrate_update,
136
+ dispatch_uid="pages_site_post_migrate_update",
137
+ )
pages/tests.py CHANGED
@@ -8,18 +8,19 @@ django.setup()
8
8
 
9
9
  from django.test import Client, RequestFactory, TestCase, SimpleTestCase, override_settings
10
10
  from django.test.utils import CaptureQueriesContext
11
- from django.urls import reverse
11
+ from django.urls import NoReverseMatch, reverse
12
12
  from django.templatetags.static import static
13
13
  from urllib.parse import quote
14
14
  from django.contrib.auth import get_user_model
15
15
  from django.contrib.sites.models import Site
16
- from django.contrib import admin
16
+ from django.contrib import admin, messages
17
17
  from django.contrib.messages.storage.fallback import FallbackStorage
18
18
  from django.core.exceptions import DisallowedHost
19
19
  from django.core.cache import cache
20
20
  from django.db import connection
21
21
  import socket
22
22
  from django.db import connection
23
+ from pages import site_config
23
24
  from pages.models import (
24
25
  Application,
25
26
  Landing,
@@ -47,6 +48,7 @@ from pages.screenshot_specs import (
47
48
  )
48
49
  from pages.context_processors import nav_links
49
50
  from django.apps import apps as django_apps
51
+ from config.middleware import SiteHttpsRedirectMiddleware
50
52
  from core import mailer
51
53
  from core.admin import ProfileAdminMixin
52
54
  from core.models import (
@@ -60,8 +62,10 @@ from core.models import (
60
62
  Todo,
61
63
  TOTPDeviceSettings,
62
64
  )
65
+ from ocpp.models import Charger
63
66
  from django.core.files.uploadedfile import SimpleUploadedFile
64
67
  import base64
68
+ import json
65
69
  import tempfile
66
70
  import shutil
67
71
  from io import StringIO
@@ -72,6 +76,7 @@ from types import SimpleNamespace
72
76
  from django.core.management import call_command
73
77
  import re
74
78
  from django.contrib.contenttypes.models import ContentType
79
+ from django.http import HttpResponse
75
80
  from datetime import (
76
81
  date,
77
82
  datetime,
@@ -685,6 +690,22 @@ class AdminDashboardAppListTests(TestCase):
685
690
  resp = self.client.get(reverse("admin:index"))
686
691
  self.assertContains(resp, "5. Horologia MODELS")
687
692
 
693
+ def test_dashboard_handles_missing_last_net_message_url(self):
694
+ from pages.templatetags import admin_extras
695
+
696
+ real_reverse = admin_extras.reverse
697
+
698
+ def fake_reverse(name, *args, **kwargs):
699
+ if name == "last-net-message":
700
+ raise NoReverseMatch("missing")
701
+ return real_reverse(name, *args, **kwargs)
702
+
703
+ with patch("pages.templatetags.admin_extras.reverse", side_effect=fake_reverse):
704
+ resp = self.client.get(reverse("admin:index"))
705
+
706
+ self.assertEqual(resp.status_code, 200)
707
+ self.assertNotIn(b"last-net-message", resp.content)
708
+
688
709
 
689
710
  class AdminSidebarTests(TestCase):
690
711
  def setUp(self):
@@ -1156,6 +1177,125 @@ class AdminModelStatusTests(TestCase):
1156
1177
  self.assertContains(resp, 'class="model-status missing"', count=1)
1157
1178
 
1158
1179
 
1180
+ class _FakeQuerySet(list):
1181
+ def only(self, *args, **kwargs):
1182
+ return self
1183
+
1184
+ def order_by(self, *args, **kwargs):
1185
+ return self
1186
+
1187
+
1188
+ class SiteConfigurationStagingTests(SimpleTestCase):
1189
+ def setUp(self):
1190
+ self.tmpdir = tempfile.mkdtemp()
1191
+ self.addCleanup(shutil.rmtree, self.tmpdir)
1192
+ self.config_path = Path(self.tmpdir) / "nginx-sites.json"
1193
+ self._path_patcher = patch(
1194
+ "pages.site_config._sites_config_path", side_effect=lambda: self.config_path
1195
+ )
1196
+ self._path_patcher.start()
1197
+ self.addCleanup(self._path_patcher.stop)
1198
+ self._model_patcher = patch("pages.site_config.apps.get_model")
1199
+ self.mock_get_model = self._model_patcher.start()
1200
+ self.addCleanup(self._model_patcher.stop)
1201
+
1202
+ def _read_config(self):
1203
+ if not self.config_path.exists():
1204
+ return None
1205
+ return json.loads(self.config_path.read_text(encoding="utf-8"))
1206
+
1207
+ def _set_sites(self, sites):
1208
+ queryset = _FakeQuerySet(sites)
1209
+
1210
+ class _Manager:
1211
+ @staticmethod
1212
+ def filter(**kwargs):
1213
+ return queryset
1214
+
1215
+ self.mock_get_model.return_value = SimpleNamespace(objects=_Manager())
1216
+
1217
+ def test_managed_site_persists_configuration(self):
1218
+ self._set_sites([SimpleNamespace(domain="example.com", require_https=True)])
1219
+ site_config.update_local_nginx_scripts()
1220
+ config = self._read_config()
1221
+ self.assertEqual(
1222
+ config,
1223
+ [
1224
+ {
1225
+ "domain": "example.com",
1226
+ "require_https": True,
1227
+ }
1228
+ ],
1229
+ )
1230
+
1231
+ def test_disabling_managed_site_removes_entry(self):
1232
+ primary = SimpleNamespace(domain="primary.test", require_https=False)
1233
+ secondary = SimpleNamespace(domain="secondary.test", require_https=False)
1234
+ self._set_sites([primary, secondary])
1235
+ site_config.update_local_nginx_scripts()
1236
+ config = self._read_config()
1237
+ self.assertEqual(
1238
+ [entry["domain"] for entry in config],
1239
+ ["primary.test", "secondary.test"],
1240
+ )
1241
+
1242
+ self._set_sites([secondary])
1243
+ site_config.update_local_nginx_scripts()
1244
+ config = self._read_config()
1245
+ self.assertEqual(config, [{"domain": "secondary.test", "require_https": False}])
1246
+
1247
+ self._set_sites([])
1248
+ site_config.update_local_nginx_scripts()
1249
+ self.assertIsNone(self._read_config())
1250
+
1251
+ def test_require_https_toggle_updates_configuration(self):
1252
+ site = SimpleNamespace(domain="secure.example", require_https=False)
1253
+ self._set_sites([site])
1254
+ site_config.update_local_nginx_scripts()
1255
+ config = self._read_config()
1256
+ self.assertEqual(config, [{"domain": "secure.example", "require_https": False}])
1257
+
1258
+ site.require_https = True
1259
+ self._set_sites([site])
1260
+ site_config.update_local_nginx_scripts()
1261
+ config = self._read_config()
1262
+ self.assertEqual(config, [{"domain": "secure.example", "require_https": True}])
1263
+
1264
+
1265
+ class SiteRequireHttpsMiddlewareTests(SimpleTestCase):
1266
+ def setUp(self):
1267
+ self.factory = RequestFactory()
1268
+ self.middleware = SiteHttpsRedirectMiddleware(lambda request: HttpResponse("ok"))
1269
+ self.secure_site = SimpleNamespace(domain="secure.test", require_https=True)
1270
+
1271
+ def test_http_request_redirects_to_https(self):
1272
+ request = self.factory.get("/", HTTP_HOST="secure.test")
1273
+ request.site = self.secure_site
1274
+ response = self.middleware(request)
1275
+ self.assertEqual(response.status_code, 301)
1276
+ self.assertTrue(response["Location"].startswith("https://secure.test"))
1277
+
1278
+ def test_secure_request_not_redirected(self):
1279
+ request = self.factory.get("/", HTTP_HOST="secure.test", secure=True)
1280
+ request.site = self.secure_site
1281
+ response = self.middleware(request)
1282
+ self.assertEqual(response.status_code, 200)
1283
+
1284
+ def test_forwarded_proto_respected(self):
1285
+ request = self.factory.get(
1286
+ "/", HTTP_HOST="secure.test", HTTP_X_FORWARDED_PROTO="https"
1287
+ )
1288
+ request.site = self.secure_site
1289
+ response = self.middleware(request)
1290
+ self.assertEqual(response.status_code, 200)
1291
+
1292
+ self.secure_site.require_https = False
1293
+ request = self.factory.get("/", HTTP_HOST="secure.test")
1294
+ request.site = self.secure_site
1295
+ response = self.middleware(request)
1296
+ self.assertEqual(response.status_code, 200)
1297
+
1298
+
1159
1299
  class SiteAdminRegisterCurrentTests(TestCase):
1160
1300
  def setUp(self):
1161
1301
  self.client = Client()
@@ -2406,6 +2546,27 @@ class FavoriteTests(TestCase):
2406
2546
  self.assertContains(resp, f'title="{badge_label}"')
2407
2547
  self.assertContains(resp, f'aria-label="{badge_label}"')
2408
2548
 
2549
+ def test_dashboard_shows_charge_point_availability_badge(self):
2550
+ Charger.objects.create(charger_id="CP-001", last_status="Available")
2551
+ Charger.objects.create(
2552
+ charger_id="CP-001", connector_id=1, last_status="Available"
2553
+ )
2554
+ Charger.objects.create(
2555
+ charger_id="CP-002", connector_id=1, last_status="Unavailable"
2556
+ )
2557
+
2558
+ resp = self.client.get(reverse("admin:index"))
2559
+
2560
+ expected = "2 / 1"
2561
+ badge_label = gettext(
2562
+ "%(total)s chargers reporting Available status, including %(with_cp)s with a CP number"
2563
+ ) % {"total": 2, "with_cp": 1}
2564
+
2565
+ self.assertContains(resp, expected)
2566
+ self.assertContains(resp, 'class="charger-availability-badge"')
2567
+ self.assertContains(resp, f'title="{badge_label}"')
2568
+ self.assertContains(resp, f'aria-label="{badge_label}"')
2569
+
2409
2570
  def test_nav_sidebar_hides_dashboard_badges(self):
2410
2571
  InviteLead.objects.create(email="open@example.com")
2411
2572
  RFID.objects.create(rfid="RFID0003", released=True, allowed=True)
@@ -2568,6 +2729,15 @@ class FavoriteTests(TestCase):
2568
2729
  resp, '<div class="todo-details">More info</div>', html=True
2569
2730
  )
2570
2731
 
2732
+ def test_dashboard_hides_completed_todos(self):
2733
+ todo = Todo.objects.create(request="Completed task")
2734
+ Todo.objects.filter(pk=todo.pk).update(done_on=timezone.now())
2735
+
2736
+ resp = self.client.get(reverse("admin:index"))
2737
+
2738
+ self.assertNotContains(resp, todo.request)
2739
+ self.assertNotContains(resp, "Completed")
2740
+
2571
2741
  def test_dashboard_shows_todos_when_node_unknown(self):
2572
2742
  Todo.objects.create(request="Check fallback")
2573
2743
  from nodes.models import Node
@@ -3134,6 +3304,40 @@ class UserStoryAdminActionTests(TestCase):
3134
3304
 
3135
3305
  mock_create_issue.assert_not_called()
3136
3306
 
3307
+ def test_create_github_issues_action_links_to_credentials_when_missing(self):
3308
+ request = self._build_request()
3309
+ queryset = UserStory.objects.filter(pk=self.story.pk)
3310
+
3311
+ mock_url = "/admin/core/releasemanager/"
3312
+ with (
3313
+ patch(
3314
+ "pages.admin.reverse", return_value=mock_url
3315
+ ) as mock_reverse,
3316
+ patch.object(
3317
+ UserStory,
3318
+ "create_github_issue",
3319
+ side_effect=RuntimeError("GitHub token is not configured"),
3320
+ ),
3321
+ ):
3322
+ self.admin.create_github_issues(request, queryset)
3323
+
3324
+ messages_list = list(request._messages)
3325
+ self.assertTrue(messages_list)
3326
+
3327
+ opts = ReleaseManager._meta
3328
+ mock_reverse.assert_called_once_with(
3329
+ f"{self.admin.admin_site.name}:{opts.app_label}_{opts.model_name}_changelist"
3330
+ )
3331
+ self.assertTrue(
3332
+ any(mock_url in message.message for message in messages_list),
3333
+ )
3334
+ self.assertTrue(
3335
+ any("Configure GitHub credentials" in message.message for message in messages_list),
3336
+ )
3337
+ self.assertTrue(
3338
+ any(message.level == messages.ERROR for message in messages_list),
3339
+ )
3340
+
3137
3341
 
3138
3342
  class ClientReportLiveUpdateTests(TestCase):
3139
3343
  def setUp(self):