arthexis 0.1.15__py3-none-any.whl → 0.1.17__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/tests.py CHANGED
@@ -8,7 +8,7 @@ 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
@@ -45,6 +45,7 @@ from pages.screenshot_specs import (
45
45
  ScreenshotUnavailable,
46
46
  registry,
47
47
  )
48
+ from pages.context_processors import nav_links
48
49
  from django.apps import apps as django_apps
49
50
  from core import mailer
50
51
  from core.admin import ProfileAdminMixin
@@ -96,7 +97,7 @@ from nodes.models import (
96
97
  NodeFeature,
97
98
  NodeFeatureAssignment,
98
99
  )
99
-
100
+ from django.contrib.auth.models import AnonymousUser
100
101
 
101
102
  class LoginViewTests(TestCase):
102
103
  def setUp(self):
@@ -684,6 +685,22 @@ class AdminDashboardAppListTests(TestCase):
684
685
  resp = self.client.get(reverse("admin:index"))
685
686
  self.assertContains(resp, "5. Horologia MODELS")
686
687
 
688
+ def test_dashboard_handles_missing_last_net_message_url(self):
689
+ from pages.templatetags import admin_extras
690
+
691
+ real_reverse = admin_extras.reverse
692
+
693
+ def fake_reverse(name, *args, **kwargs):
694
+ if name == "last-net-message":
695
+ raise NoReverseMatch("missing")
696
+ return real_reverse(name, *args, **kwargs)
697
+
698
+ with patch("pages.templatetags.admin_extras.reverse", side_effect=fake_reverse):
699
+ resp = self.client.get(reverse("admin:index"))
700
+
701
+ self.assertEqual(resp.status_code, 200)
702
+ self.assertNotIn(b"last-net-message", resp.content)
703
+
687
704
 
688
705
  class AdminSidebarTests(TestCase):
689
706
  def setUp(self):
@@ -1520,6 +1537,74 @@ class ConstellationNavTests(TestCase):
1520
1537
  resp = self.client.get(reverse("pages:index"))
1521
1538
  self.assertContains(resp, 'href="/ocpp/"')
1522
1539
 
1540
+
1541
+ class ReleaseModuleNavTests(TestCase):
1542
+ def setUp(self):
1543
+ self.client = Client()
1544
+ self.user_model = get_user_model()
1545
+ role, _ = NodeRole.objects.get_or_create(name="Terminal")
1546
+ Node.objects.update_or_create(
1547
+ mac_address=Node.get_current_mac(),
1548
+ defaults={
1549
+ "hostname": "localhost",
1550
+ "address": "127.0.0.1",
1551
+ "role": role,
1552
+ },
1553
+ )
1554
+ Site.objects.update_or_create(
1555
+ id=1, defaults={"domain": "testserver", "name": "Terminal"}
1556
+ )
1557
+ application, _ = Application.objects.get_or_create(name="core")
1558
+ module, _ = Module.objects.get_or_create(
1559
+ node_role=role,
1560
+ application=application,
1561
+ path="/release/",
1562
+ defaults={"menu": "Release", "is_default": False},
1563
+ )
1564
+ module_updates = []
1565
+ if module.menu != "Release":
1566
+ module.menu = "Release"
1567
+ module_updates.append("menu")
1568
+ if getattr(module, "is_deleted", False):
1569
+ module.is_deleted = False
1570
+ module_updates.append("is_deleted")
1571
+ if module_updates:
1572
+ module.save(update_fields=module_updates)
1573
+ Landing.objects.update_or_create(
1574
+ module=module,
1575
+ path="/release/",
1576
+ defaults={
1577
+ "label": "Package Releases",
1578
+ "enabled": True,
1579
+ "description": "",
1580
+ },
1581
+ )
1582
+ self.release_group, _ = SecurityGroup.objects.get_or_create(
1583
+ name="Release Managers"
1584
+ )
1585
+
1586
+ def test_release_module_hidden_for_anonymous(self):
1587
+ response = self.client.get(reverse("pages:index"))
1588
+ self.assertNotContains(response, 'badge rounded-pill text-bg-secondary">RELEASE')
1589
+
1590
+ def test_release_module_visible_to_release_manager(self):
1591
+ user = self.user_model.objects.create_user(
1592
+ "release-admin", password="test", is_staff=True
1593
+ )
1594
+ user.groups.add(self.release_group)
1595
+ self.client.force_login(user)
1596
+ response = self.client.get(reverse("pages:index"))
1597
+ self.assertContains(response, 'badge rounded-pill text-bg-secondary">RELEASE')
1598
+
1599
+ def test_release_module_hidden_for_non_member_staff(self):
1600
+ user = self.user_model.objects.create_user(
1601
+ "staff-user", password="test", is_staff=True
1602
+ )
1603
+ self.client.force_login(user)
1604
+ response = self.client.get(reverse("pages:index"))
1605
+ self.assertNotContains(response, 'badge rounded-pill text-bg-secondary">RELEASE')
1606
+
1607
+
1523
1608
  class ControlNavTests(TestCase):
1524
1609
  def setUp(self):
1525
1610
  self.client = Client()
@@ -1793,6 +1878,83 @@ class StaffNavVisibilityTests(TestCase):
1793
1878
  self.assertContains(resp, 'href="/ocpp/"')
1794
1879
 
1795
1880
 
1881
+ class ModuleAdminReloadActionTests(TestCase):
1882
+ def setUp(self):
1883
+ self.client = Client()
1884
+ User = get_user_model()
1885
+ self.superuser = User.objects.create_superuser(
1886
+ username="admin",
1887
+ email="admin@example.com",
1888
+ password="pw",
1889
+ )
1890
+ self.client.force_login(self.superuser)
1891
+ self.role, _ = NodeRole.objects.get_or_create(name="Constellation")
1892
+ Application.objects.get_or_create(name="ocpp")
1893
+ Application.objects.get_or_create(name="awg")
1894
+ Site.objects.update_or_create(
1895
+ id=1, defaults={"domain": "testserver", "name": ""}
1896
+ )
1897
+
1898
+ def _post_reload(self):
1899
+ changelist_url = reverse("admin:pages_module_changelist")
1900
+ self.client.get(changelist_url)
1901
+ csrf_cookie = self.client.cookies.get("csrftoken")
1902
+ token = csrf_cookie.value if csrf_cookie else ""
1903
+ return self.client.post(
1904
+ reverse("admin:pages_module_reload_default_modules"),
1905
+ {"csrfmiddlewaretoken": token},
1906
+ follow=True,
1907
+ )
1908
+
1909
+ def test_reload_restores_missing_modules_and_landings(self):
1910
+ Module.objects.filter(node_role=self.role).delete()
1911
+ Landing.objects.filter(module__node_role=self.role).delete()
1912
+
1913
+ response = self._post_reload()
1914
+ self.assertEqual(response.status_code, 200)
1915
+
1916
+ chargers = Module.objects.get(node_role=self.role, path="/ocpp/")
1917
+ calculators = Module.objects.get(node_role=self.role, path="/awg/")
1918
+
1919
+ self.assertEqual(chargers.menu, "Chargers")
1920
+ self.assertEqual(calculators.menu, "")
1921
+ self.assertFalse(getattr(chargers, "is_deleted", False))
1922
+ self.assertFalse(getattr(calculators, "is_deleted", False))
1923
+
1924
+ charger_landings = set(
1925
+ Landing.objects.filter(module=chargers).values_list("path", flat=True)
1926
+ )
1927
+ self.assertSetEqual(
1928
+ charger_landings,
1929
+ {"/ocpp/", "/ocpp/simulator/", "/ocpp/rfid/"},
1930
+ )
1931
+
1932
+ calculator_landings = set(
1933
+ Landing.objects.filter(module=calculators).values_list(
1934
+ "path", flat=True
1935
+ )
1936
+ )
1937
+ self.assertSetEqual(
1938
+ calculator_landings,
1939
+ {"/awg/", "/awg/energy-tariff/"},
1940
+ )
1941
+
1942
+ def test_reload_is_idempotent(self):
1943
+ self._post_reload()
1944
+ module_count = Module.objects.filter(node_role=self.role).count()
1945
+ landing_count = Landing.objects.filter(module__node_role=self.role).count()
1946
+
1947
+ self._post_reload()
1948
+
1949
+ self.assertEqual(
1950
+ Module.objects.filter(node_role=self.role).count(), module_count
1951
+ )
1952
+ self.assertEqual(
1953
+ Landing.objects.filter(module__node_role=self.role).count(),
1954
+ landing_count,
1955
+ )
1956
+
1957
+
1796
1958
  class ApplicationModelTests(TestCase):
1797
1959
  def test_path_defaults_to_slugified_name(self):
1798
1960
  role, _ = NodeRole.objects.get_or_create(name="Terminal")
@@ -2422,6 +2584,33 @@ class FavoriteTests(TestCase):
2422
2584
  resp, '<div class="todo-details">More info</div>', html=True
2423
2585
  )
2424
2586
 
2587
+ def test_dashboard_hides_completed_todos(self):
2588
+ todo = Todo.objects.create(request="Completed task")
2589
+ Todo.objects.filter(pk=todo.pk).update(done_on=timezone.now())
2590
+
2591
+ resp = self.client.get(reverse("admin:index"))
2592
+
2593
+ self.assertNotContains(resp, todo.request)
2594
+ self.assertNotContains(resp, "Completed")
2595
+
2596
+ def test_dashboard_shows_todos_when_node_unknown(self):
2597
+ Todo.objects.create(request="Check fallback")
2598
+ from nodes.models import Node
2599
+
2600
+ Node.objects.all().delete()
2601
+
2602
+ resp = self.client.get(reverse("admin:index"))
2603
+ self.assertContains(resp, "Release manager tasks")
2604
+ self.assertContains(resp, "Check fallback")
2605
+
2606
+ def test_dashboard_shows_todos_without_release_manager_profile(self):
2607
+ Todo.objects.create(request="Unrestricted task")
2608
+ ReleaseManager.objects.filter(user=self.user).delete()
2609
+
2610
+ resp = self.client.get(reverse("admin:index"))
2611
+ self.assertContains(resp, "Release manager tasks")
2612
+ self.assertContains(resp, "Unrestricted task")
2613
+
2425
2614
  def test_dashboard_excludes_todo_changelist_link(self):
2426
2615
  ct = ContentType.objects.get_for_model(Todo)
2427
2616
  Favorite.objects.create(user=self.user, content_type=ct)
@@ -2435,7 +2624,7 @@ class FavoriteTests(TestCase):
2435
2624
  changelist = reverse("admin:core_todo_changelist")
2436
2625
  self.assertNotContains(resp, f'href="{changelist}"')
2437
2626
 
2438
- def test_dashboard_hides_todos_without_release_manager(self):
2627
+ def test_dashboard_shows_todos_for_admin_without_release_manager(self):
2439
2628
  todo = Todo.objects.create(request="Only Release Manager")
2440
2629
  User = get_user_model()
2441
2630
  other_user = User.objects.create_superuser(
@@ -2443,10 +2632,10 @@ class FavoriteTests(TestCase):
2443
2632
  )
2444
2633
  self.client.force_login(other_user)
2445
2634
  resp = self.client.get(reverse("admin:index"))
2446
- self.assertNotContains(resp, "Release manager tasks")
2447
- self.assertNotContains(resp, todo.request)
2635
+ self.assertContains(resp, "Release manager tasks")
2636
+ self.assertContains(resp, todo.request)
2448
2637
 
2449
- def test_dashboard_hides_todos_for_non_terminal_node(self):
2638
+ def test_dashboard_shows_todos_for_non_terminal_node(self):
2450
2639
  todo = Todo.objects.create(request="Terminal Tasks")
2451
2640
  from nodes.models import NodeRole
2452
2641
 
@@ -2454,8 +2643,8 @@ class FavoriteTests(TestCase):
2454
2643
  self.node.role = control_role
2455
2644
  self.node.save(update_fields=["role"])
2456
2645
  resp = self.client.get(reverse("admin:index"))
2457
- self.assertNotContains(resp, "Release manager tasks")
2458
- self.assertNotContains(resp, todo.request)
2646
+ self.assertContains(resp, "Release manager tasks")
2647
+ self.assertContains(resp, todo.request)
2459
2648
 
2460
2649
  def test_dashboard_shows_todos_for_delegate_release_manager(self):
2461
2650
  todo = Todo.objects.create(request="Delegate Task")
@@ -2716,6 +2905,7 @@ class DatasetteTests(TestCase):
2716
2905
 
2717
2906
  class UserStorySubmissionTests(TestCase):
2718
2907
  def setUp(self):
2908
+ cache.clear()
2719
2909
  self.client = Client()
2720
2910
  self.url = reverse("pages:user-story-submit")
2721
2911
  User = get_user_model()
@@ -2731,6 +2921,8 @@ class UserStorySubmissionTests(TestCase):
2731
2921
  "path": "/wizard/step-1/",
2732
2922
  "take_screenshot": "1",
2733
2923
  },
2924
+ HTTP_REFERER="https://example.test/wizard/step-1/",
2925
+ HTTP_USER_AGENT="FeedbackBot/1.0",
2734
2926
  )
2735
2927
  self.assertEqual(response.status_code, 200)
2736
2928
  self.assertEqual(response.json(), {"success": True})
@@ -2742,6 +2934,10 @@ class UserStorySubmissionTests(TestCase):
2742
2934
  self.assertEqual(story.owner, self.user)
2743
2935
  self.assertTrue(story.is_user_data)
2744
2936
  self.assertTrue(story.take_screenshot)
2937
+ self.assertEqual(story.status, UserStory.Status.OPEN)
2938
+ self.assertEqual(story.referer, "https://example.test/wizard/step-1/")
2939
+ self.assertEqual(story.user_agent, "FeedbackBot/1.0")
2940
+ self.assertEqual(story.ip_address, "127.0.0.1")
2745
2941
 
2746
2942
  def test_anonymous_submission_uses_provided_name(self):
2747
2943
  response = self.client.post(
@@ -2762,6 +2958,7 @@ class UserStorySubmissionTests(TestCase):
2762
2958
  self.assertIsNone(story.owner)
2763
2959
  self.assertEqual(story.comments, "It was fine.")
2764
2960
  self.assertTrue(story.take_screenshot)
2961
+ self.assertEqual(story.status, UserStory.Status.OPEN)
2765
2962
 
2766
2963
  def test_invalid_rating_returns_errors(self):
2767
2964
  response = self.client.post(
@@ -2794,6 +2991,7 @@ class UserStorySubmissionTests(TestCase):
2794
2991
  self.assertIsNone(story.user)
2795
2992
  self.assertIsNone(story.owner)
2796
2993
  self.assertTrue(story.take_screenshot)
2994
+ self.assertEqual(story.status, UserStory.Status.OPEN)
2797
2995
 
2798
2996
  def test_submission_without_screenshot_request(self):
2799
2997
  response = self.client.post(
@@ -2809,12 +3007,32 @@ class UserStorySubmissionTests(TestCase):
2809
3007
  self.assertFalse(story.take_screenshot)
2810
3008
  self.assertIsNone(story.owner)
2811
3009
 
3010
+ def test_rate_limit_blocks_repeated_submissions(self):
3011
+ payload = {
3012
+ "rating": 4,
3013
+ "comments": "Pretty good",
3014
+ "path": "/feedback/",
3015
+ "take_screenshot": "1",
3016
+ }
3017
+ first = self.client.post(self.url, payload)
3018
+ self.assertEqual(first.status_code, 200)
3019
+ second = self.client.post(self.url, payload)
3020
+ self.assertEqual(second.status_code, 429)
3021
+ data = second.json()
3022
+ self.assertFalse(data["success"])
3023
+ self.assertIn("__all__", data.get("errors", {}))
3024
+ self.assertIn("5", data["errors"]["__all__"][0])
3025
+
2812
3026
 
2813
3027
  class UserStoryIssueAutomationTests(TestCase):
2814
3028
  def setUp(self):
2815
3029
  self.lock_dir = Path(settings.BASE_DIR) / "locks"
2816
3030
  self.lock_dir.mkdir(parents=True, exist_ok=True)
2817
3031
  self.lock_file = self.lock_dir / "celery.lck"
3032
+ User = get_user_model()
3033
+ self.user = User.objects.create_user(
3034
+ username="feedback_user", password="pwd"
3035
+ )
2818
3036
 
2819
3037
  def tearDown(self):
2820
3038
  self.lock_file.unlink(missing_ok=True)
@@ -2828,10 +3046,24 @@ class UserStoryIssueAutomationTests(TestCase):
2828
3046
  rating=2,
2829
3047
  comments="Needs work",
2830
3048
  take_screenshot=False,
3049
+ user=self.user,
2831
3050
  )
2832
3051
 
2833
3052
  mock_delay.assert_called_once_with(story.pk)
2834
3053
 
3054
+ def test_low_rating_story_without_user_does_not_enqueue_issue(self):
3055
+ self.lock_file.write_text("")
3056
+
3057
+ with patch("pages.models.create_user_story_github_issue.delay") as mock_delay:
3058
+ UserStory.objects.create(
3059
+ path="/feedback/",
3060
+ rating=2,
3061
+ comments="Needs work",
3062
+ take_screenshot=False,
3063
+ )
3064
+
3065
+ mock_delay.assert_not_called()
3066
+
2835
3067
  def test_five_star_story_does_not_enqueue_issue(self):
2836
3068
  self.lock_file.write_text("")
2837
3069
 
@@ -2854,6 +3086,7 @@ class UserStoryIssueAutomationTests(TestCase):
2854
3086
  rating=1,
2855
3087
  comments="Not good",
2856
3088
  take_screenshot=False,
3089
+ user=self.user,
2857
3090
  )
2858
3091
 
2859
3092
  mock_delay.assert_not_called()
pages/urls.py CHANGED
@@ -8,6 +8,7 @@ urlpatterns = [
8
8
  path("", views.index, name="index"),
9
9
  path("readme/", views.readme, name="readme"),
10
10
  path("sitemap.xml", views.sitemap, name="pages-sitemap"),
11
+ path("release/", views.release_admin_redirect, name="release-admin"),
11
12
  path("client-report/", views.client_report, name="client-report"),
12
13
  path("release-checklist", views.release_checklist, name="release-checklist"),
13
14
  path("login/", views.login_view, name="login"),
pages/views.py CHANGED
@@ -19,12 +19,14 @@ from django.contrib.auth.tokens import default_token_generator
19
19
  from django.contrib.auth.views import LoginView
20
20
  from django import forms
21
21
  from django.apps import apps as django_apps
22
+ from utils.decorators import security_group_required
22
23
  from utils.sites import get_site
23
24
  from django.http import Http404, HttpResponse, JsonResponse
24
25
  from django.shortcuts import get_object_or_404, redirect, render
25
26
  from nodes.models import Node
27
+ from django.template import loader
26
28
  from django.template.response import TemplateResponse
27
- from django.test import RequestFactory
29
+ from django.test import RequestFactory, signals as test_signals
28
30
  from django.urls import NoReverseMatch, reverse
29
31
  from django.utils import timezone
30
32
  from django.utils.encoding import force_bytes, force_str
@@ -105,6 +107,18 @@ def _get_registered_models(app_label: str):
105
107
  return sorted(registered, key=lambda model: str(model._meta.verbose_name))
106
108
 
107
109
 
110
+ def _get_client_ip(request) -> str:
111
+ """Return the client IP from the request headers."""
112
+
113
+ forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "")
114
+ if forwarded_for:
115
+ for value in forwarded_for.split(","):
116
+ candidate = value.strip()
117
+ if candidate:
118
+ return candidate
119
+ return request.META.get("REMOTE_ADDR", "")
120
+
121
+
108
122
  def _filter_models_for_request(models, request):
109
123
  """Filter ``models`` to only those viewable by ``request.user``."""
110
124
 
@@ -408,7 +422,21 @@ def admin_model_graph(request, app_label: str):
408
422
  }
409
423
  )
410
424
 
411
- return TemplateResponse(request, "admin/model_graph.html", context)
425
+ template_name = "admin/model_graph.html"
426
+ response = render(request, template_name, context)
427
+ if getattr(response, "context", None) is None:
428
+ response.context = context
429
+ if test_signals.template_rendered.receivers:
430
+ template = loader.get_template(template_name)
431
+ signal_context = context
432
+ if request is not None and "request" not in signal_context:
433
+ signal_context = {**context, "request": request}
434
+ test_signals.template_rendered.send(
435
+ sender=template.__class__,
436
+ template=template,
437
+ context=signal_context,
438
+ )
439
+ return response
412
440
 
413
441
 
414
442
  def _render_readme(request, role):
@@ -523,6 +551,12 @@ def sitemap(request):
523
551
  return HttpResponse("\n".join(lines), content_type="application/xml")
524
552
 
525
553
 
554
+ @landing("Package Releases")
555
+ @security_group_required("Release Managers")
556
+ def release_admin_redirect(request):
557
+ return redirect("admin:core_packagerelease_changelist")
558
+
559
+
526
560
  def release_checklist(request):
527
561
  file_path = Path(settings.BASE_DIR) / "releases" / "release-checklist.md"
528
562
  if not file_path.exists():
@@ -554,7 +588,7 @@ class CustomLoginView(LoginView):
554
588
  return super().dispatch(request, *args, **kwargs)
555
589
 
556
590
  def get_context_data(self, **kwargs):
557
- context = super(LoginView, self).get_context_data(**kwargs)
591
+ context = super().get_context_data(**kwargs)
558
592
  current_site = get_site(self.request)
559
593
  redirect_target = self.request.GET.get(self.redirect_field_name)
560
594
  restricted_notice = None
@@ -569,11 +603,13 @@ class CustomLoginView(LoginView):
569
603
  restricted_notice = _(
570
604
  "This page is reserved for members only. Please log in to continue."
571
605
  )
606
+ redirect_value = context.get(self.redirect_field_name) or self.get_success_url()
607
+ context[self.redirect_field_name] = redirect_value
608
+ context["next"] = redirect_value
572
609
  context.update(
573
610
  {
574
611
  "site": current_site,
575
612
  "site_name": getattr(current_site, "name", ""),
576
- "next": self.get_success_url(),
577
613
  "can_request_invite": mailer.can_send_email(),
578
614
  "restricted_notice": restricted_notice,
579
615
  }
@@ -1093,6 +1129,24 @@ def client_report(request):
1093
1129
 
1094
1130
  @require_POST
1095
1131
  def submit_user_story(request):
1132
+ throttle_seconds = getattr(settings, "USER_STORY_THROTTLE_SECONDS", 300)
1133
+ client_ip = _get_client_ip(request)
1134
+ cache_key = None
1135
+
1136
+ if throttle_seconds:
1137
+ cache_key = f"user-story:ip:{client_ip or 'unknown'}"
1138
+ if not cache.add(cache_key, timezone.now(), throttle_seconds):
1139
+ minutes = throttle_seconds // 60
1140
+ if throttle_seconds % 60:
1141
+ minutes += 1
1142
+ error_message = _(
1143
+ "You can only submit feedback once every %(minutes)s minutes."
1144
+ ) % {"minutes": minutes or 1}
1145
+ return JsonResponse(
1146
+ {"success": False, "errors": {"__all__": [error_message]}},
1147
+ status=429,
1148
+ )
1149
+
1096
1150
  data = request.POST.copy()
1097
1151
  if request.user.is_authenticated and not data.get("name"):
1098
1152
  data["name"] = request.user.get_username()[:40]
@@ -1113,6 +1167,9 @@ def submit_user_story(request):
1113
1167
  if not story.name:
1114
1168
  story.name = str(_("Anonymous"))[:40]
1115
1169
  story.path = (story.path or request.get_full_path())[:500]
1170
+ story.referer = request.META.get("HTTP_REFERER", "")
1171
+ story.user_agent = request.META.get("HTTP_USER_AGENT", "")
1172
+ story.ip_address = client_ip or None
1116
1173
  story.is_user_data = True
1117
1174
  story.save()
1118
1175
  return JsonResponse({"success": True})