arthexis 0.1.15__py3-none-any.whl → 0.1.16__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
@@ -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):
@@ -1520,6 +1521,74 @@ class ConstellationNavTests(TestCase):
1520
1521
  resp = self.client.get(reverse("pages:index"))
1521
1522
  self.assertContains(resp, 'href="/ocpp/"')
1522
1523
 
1524
+
1525
+ class ReleaseModuleNavTests(TestCase):
1526
+ def setUp(self):
1527
+ self.client = Client()
1528
+ self.user_model = get_user_model()
1529
+ role, _ = NodeRole.objects.get_or_create(name="Terminal")
1530
+ Node.objects.update_or_create(
1531
+ mac_address=Node.get_current_mac(),
1532
+ defaults={
1533
+ "hostname": "localhost",
1534
+ "address": "127.0.0.1",
1535
+ "role": role,
1536
+ },
1537
+ )
1538
+ Site.objects.update_or_create(
1539
+ id=1, defaults={"domain": "testserver", "name": "Terminal"}
1540
+ )
1541
+ application, _ = Application.objects.get_or_create(name="core")
1542
+ module, _ = Module.objects.get_or_create(
1543
+ node_role=role,
1544
+ application=application,
1545
+ path="/release/",
1546
+ defaults={"menu": "Release", "is_default": False},
1547
+ )
1548
+ module_updates = []
1549
+ if module.menu != "Release":
1550
+ module.menu = "Release"
1551
+ module_updates.append("menu")
1552
+ if getattr(module, "is_deleted", False):
1553
+ module.is_deleted = False
1554
+ module_updates.append("is_deleted")
1555
+ if module_updates:
1556
+ module.save(update_fields=module_updates)
1557
+ Landing.objects.update_or_create(
1558
+ module=module,
1559
+ path="/release/",
1560
+ defaults={
1561
+ "label": "Package Releases",
1562
+ "enabled": True,
1563
+ "description": "",
1564
+ },
1565
+ )
1566
+ self.release_group, _ = SecurityGroup.objects.get_or_create(
1567
+ name="Release Managers"
1568
+ )
1569
+
1570
+ def test_release_module_hidden_for_anonymous(self):
1571
+ response = self.client.get(reverse("pages:index"))
1572
+ self.assertNotContains(response, 'badge rounded-pill text-bg-secondary">RELEASE')
1573
+
1574
+ def test_release_module_visible_to_release_manager(self):
1575
+ user = self.user_model.objects.create_user(
1576
+ "release-admin", password="test", is_staff=True
1577
+ )
1578
+ user.groups.add(self.release_group)
1579
+ self.client.force_login(user)
1580
+ response = self.client.get(reverse("pages:index"))
1581
+ self.assertContains(response, 'badge rounded-pill text-bg-secondary">RELEASE')
1582
+
1583
+ def test_release_module_hidden_for_non_member_staff(self):
1584
+ user = self.user_model.objects.create_user(
1585
+ "staff-user", password="test", is_staff=True
1586
+ )
1587
+ self.client.force_login(user)
1588
+ response = self.client.get(reverse("pages:index"))
1589
+ self.assertNotContains(response, 'badge rounded-pill text-bg-secondary">RELEASE')
1590
+
1591
+
1523
1592
  class ControlNavTests(TestCase):
1524
1593
  def setUp(self):
1525
1594
  self.client = Client()
@@ -1793,6 +1862,83 @@ class StaffNavVisibilityTests(TestCase):
1793
1862
  self.assertContains(resp, 'href="/ocpp/"')
1794
1863
 
1795
1864
 
1865
+ class ModuleAdminReloadActionTests(TestCase):
1866
+ def setUp(self):
1867
+ self.client = Client()
1868
+ User = get_user_model()
1869
+ self.superuser = User.objects.create_superuser(
1870
+ username="admin",
1871
+ email="admin@example.com",
1872
+ password="pw",
1873
+ )
1874
+ self.client.force_login(self.superuser)
1875
+ self.role, _ = NodeRole.objects.get_or_create(name="Constellation")
1876
+ Application.objects.get_or_create(name="ocpp")
1877
+ Application.objects.get_or_create(name="awg")
1878
+ Site.objects.update_or_create(
1879
+ id=1, defaults={"domain": "testserver", "name": ""}
1880
+ )
1881
+
1882
+ def _post_reload(self):
1883
+ changelist_url = reverse("admin:pages_module_changelist")
1884
+ self.client.get(changelist_url)
1885
+ csrf_cookie = self.client.cookies.get("csrftoken")
1886
+ token = csrf_cookie.value if csrf_cookie else ""
1887
+ return self.client.post(
1888
+ reverse("admin:pages_module_reload_default_modules"),
1889
+ {"csrfmiddlewaretoken": token},
1890
+ follow=True,
1891
+ )
1892
+
1893
+ def test_reload_restores_missing_modules_and_landings(self):
1894
+ Module.objects.filter(node_role=self.role).delete()
1895
+ Landing.objects.filter(module__node_role=self.role).delete()
1896
+
1897
+ response = self._post_reload()
1898
+ self.assertEqual(response.status_code, 200)
1899
+
1900
+ chargers = Module.objects.get(node_role=self.role, path="/ocpp/")
1901
+ calculators = Module.objects.get(node_role=self.role, path="/awg/")
1902
+
1903
+ self.assertEqual(chargers.menu, "Chargers")
1904
+ self.assertEqual(calculators.menu, "")
1905
+ self.assertFalse(getattr(chargers, "is_deleted", False))
1906
+ self.assertFalse(getattr(calculators, "is_deleted", False))
1907
+
1908
+ charger_landings = set(
1909
+ Landing.objects.filter(module=chargers).values_list("path", flat=True)
1910
+ )
1911
+ self.assertSetEqual(
1912
+ charger_landings,
1913
+ {"/ocpp/", "/ocpp/simulator/", "/ocpp/rfid/"},
1914
+ )
1915
+
1916
+ calculator_landings = set(
1917
+ Landing.objects.filter(module=calculators).values_list(
1918
+ "path", flat=True
1919
+ )
1920
+ )
1921
+ self.assertSetEqual(
1922
+ calculator_landings,
1923
+ {"/awg/", "/awg/energy-tariff/"},
1924
+ )
1925
+
1926
+ def test_reload_is_idempotent(self):
1927
+ self._post_reload()
1928
+ module_count = Module.objects.filter(node_role=self.role).count()
1929
+ landing_count = Landing.objects.filter(module__node_role=self.role).count()
1930
+
1931
+ self._post_reload()
1932
+
1933
+ self.assertEqual(
1934
+ Module.objects.filter(node_role=self.role).count(), module_count
1935
+ )
1936
+ self.assertEqual(
1937
+ Landing.objects.filter(module__node_role=self.role).count(),
1938
+ landing_count,
1939
+ )
1940
+
1941
+
1796
1942
  class ApplicationModelTests(TestCase):
1797
1943
  def test_path_defaults_to_slugified_name(self):
1798
1944
  role, _ = NodeRole.objects.get_or_create(name="Terminal")
@@ -2422,6 +2568,24 @@ class FavoriteTests(TestCase):
2422
2568
  resp, '<div class="todo-details">More info</div>', html=True
2423
2569
  )
2424
2570
 
2571
+ def test_dashboard_shows_todos_when_node_unknown(self):
2572
+ Todo.objects.create(request="Check fallback")
2573
+ from nodes.models import Node
2574
+
2575
+ Node.objects.all().delete()
2576
+
2577
+ resp = self.client.get(reverse("admin:index"))
2578
+ self.assertContains(resp, "Release manager tasks")
2579
+ self.assertContains(resp, "Check fallback")
2580
+
2581
+ def test_dashboard_shows_todos_without_release_manager_profile(self):
2582
+ Todo.objects.create(request="Unrestricted task")
2583
+ ReleaseManager.objects.filter(user=self.user).delete()
2584
+
2585
+ resp = self.client.get(reverse("admin:index"))
2586
+ self.assertContains(resp, "Release manager tasks")
2587
+ self.assertContains(resp, "Unrestricted task")
2588
+
2425
2589
  def test_dashboard_excludes_todo_changelist_link(self):
2426
2590
  ct = ContentType.objects.get_for_model(Todo)
2427
2591
  Favorite.objects.create(user=self.user, content_type=ct)
@@ -2435,7 +2599,7 @@ class FavoriteTests(TestCase):
2435
2599
  changelist = reverse("admin:core_todo_changelist")
2436
2600
  self.assertNotContains(resp, f'href="{changelist}"')
2437
2601
 
2438
- def test_dashboard_hides_todos_without_release_manager(self):
2602
+ def test_dashboard_shows_todos_for_admin_without_release_manager(self):
2439
2603
  todo = Todo.objects.create(request="Only Release Manager")
2440
2604
  User = get_user_model()
2441
2605
  other_user = User.objects.create_superuser(
@@ -2443,10 +2607,10 @@ class FavoriteTests(TestCase):
2443
2607
  )
2444
2608
  self.client.force_login(other_user)
2445
2609
  resp = self.client.get(reverse("admin:index"))
2446
- self.assertNotContains(resp, "Release manager tasks")
2447
- self.assertNotContains(resp, todo.request)
2610
+ self.assertContains(resp, "Release manager tasks")
2611
+ self.assertContains(resp, todo.request)
2448
2612
 
2449
- def test_dashboard_hides_todos_for_non_terminal_node(self):
2613
+ def test_dashboard_shows_todos_for_non_terminal_node(self):
2450
2614
  todo = Todo.objects.create(request="Terminal Tasks")
2451
2615
  from nodes.models import NodeRole
2452
2616
 
@@ -2454,8 +2618,8 @@ class FavoriteTests(TestCase):
2454
2618
  self.node.role = control_role
2455
2619
  self.node.save(update_fields=["role"])
2456
2620
  resp = self.client.get(reverse("admin:index"))
2457
- self.assertNotContains(resp, "Release manager tasks")
2458
- self.assertNotContains(resp, todo.request)
2621
+ self.assertContains(resp, "Release manager tasks")
2622
+ self.assertContains(resp, todo.request)
2459
2623
 
2460
2624
  def test_dashboard_shows_todos_for_delegate_release_manager(self):
2461
2625
  todo = Todo.objects.create(request="Delegate Task")
@@ -2716,6 +2880,7 @@ class DatasetteTests(TestCase):
2716
2880
 
2717
2881
  class UserStorySubmissionTests(TestCase):
2718
2882
  def setUp(self):
2883
+ cache.clear()
2719
2884
  self.client = Client()
2720
2885
  self.url = reverse("pages:user-story-submit")
2721
2886
  User = get_user_model()
@@ -2731,6 +2896,8 @@ class UserStorySubmissionTests(TestCase):
2731
2896
  "path": "/wizard/step-1/",
2732
2897
  "take_screenshot": "1",
2733
2898
  },
2899
+ HTTP_REFERER="https://example.test/wizard/step-1/",
2900
+ HTTP_USER_AGENT="FeedbackBot/1.0",
2734
2901
  )
2735
2902
  self.assertEqual(response.status_code, 200)
2736
2903
  self.assertEqual(response.json(), {"success": True})
@@ -2742,6 +2909,10 @@ class UserStorySubmissionTests(TestCase):
2742
2909
  self.assertEqual(story.owner, self.user)
2743
2910
  self.assertTrue(story.is_user_data)
2744
2911
  self.assertTrue(story.take_screenshot)
2912
+ self.assertEqual(story.status, UserStory.Status.OPEN)
2913
+ self.assertEqual(story.referer, "https://example.test/wizard/step-1/")
2914
+ self.assertEqual(story.user_agent, "FeedbackBot/1.0")
2915
+ self.assertEqual(story.ip_address, "127.0.0.1")
2745
2916
 
2746
2917
  def test_anonymous_submission_uses_provided_name(self):
2747
2918
  response = self.client.post(
@@ -2762,6 +2933,7 @@ class UserStorySubmissionTests(TestCase):
2762
2933
  self.assertIsNone(story.owner)
2763
2934
  self.assertEqual(story.comments, "It was fine.")
2764
2935
  self.assertTrue(story.take_screenshot)
2936
+ self.assertEqual(story.status, UserStory.Status.OPEN)
2765
2937
 
2766
2938
  def test_invalid_rating_returns_errors(self):
2767
2939
  response = self.client.post(
@@ -2794,6 +2966,7 @@ class UserStorySubmissionTests(TestCase):
2794
2966
  self.assertIsNone(story.user)
2795
2967
  self.assertIsNone(story.owner)
2796
2968
  self.assertTrue(story.take_screenshot)
2969
+ self.assertEqual(story.status, UserStory.Status.OPEN)
2797
2970
 
2798
2971
  def test_submission_without_screenshot_request(self):
2799
2972
  response = self.client.post(
@@ -2809,12 +2982,32 @@ class UserStorySubmissionTests(TestCase):
2809
2982
  self.assertFalse(story.take_screenshot)
2810
2983
  self.assertIsNone(story.owner)
2811
2984
 
2985
+ def test_rate_limit_blocks_repeated_submissions(self):
2986
+ payload = {
2987
+ "rating": 4,
2988
+ "comments": "Pretty good",
2989
+ "path": "/feedback/",
2990
+ "take_screenshot": "1",
2991
+ }
2992
+ first = self.client.post(self.url, payload)
2993
+ self.assertEqual(first.status_code, 200)
2994
+ second = self.client.post(self.url, payload)
2995
+ self.assertEqual(second.status_code, 429)
2996
+ data = second.json()
2997
+ self.assertFalse(data["success"])
2998
+ self.assertIn("__all__", data.get("errors", {}))
2999
+ self.assertIn("5", data["errors"]["__all__"][0])
3000
+
2812
3001
 
2813
3002
  class UserStoryIssueAutomationTests(TestCase):
2814
3003
  def setUp(self):
2815
3004
  self.lock_dir = Path(settings.BASE_DIR) / "locks"
2816
3005
  self.lock_dir.mkdir(parents=True, exist_ok=True)
2817
3006
  self.lock_file = self.lock_dir / "celery.lck"
3007
+ User = get_user_model()
3008
+ self.user = User.objects.create_user(
3009
+ username="feedback_user", password="pwd"
3010
+ )
2818
3011
 
2819
3012
  def tearDown(self):
2820
3013
  self.lock_file.unlink(missing_ok=True)
@@ -2828,10 +3021,24 @@ class UserStoryIssueAutomationTests(TestCase):
2828
3021
  rating=2,
2829
3022
  comments="Needs work",
2830
3023
  take_screenshot=False,
3024
+ user=self.user,
2831
3025
  )
2832
3026
 
2833
3027
  mock_delay.assert_called_once_with(story.pk)
2834
3028
 
3029
+ def test_low_rating_story_without_user_does_not_enqueue_issue(self):
3030
+ self.lock_file.write_text("")
3031
+
3032
+ with patch("pages.models.create_user_story_github_issue.delay") as mock_delay:
3033
+ UserStory.objects.create(
3034
+ path="/feedback/",
3035
+ rating=2,
3036
+ comments="Needs work",
3037
+ take_screenshot=False,
3038
+ )
3039
+
3040
+ mock_delay.assert_not_called()
3041
+
2835
3042
  def test_five_star_story_does_not_enqueue_issue(self):
2836
3043
  self.lock_file.write_text("")
2837
3044
 
@@ -2854,6 +3061,7 @@ class UserStoryIssueAutomationTests(TestCase):
2854
3061
  rating=1,
2855
3062
  comments="Not good",
2856
3063
  take_screenshot=False,
3064
+ user=self.user,
2857
3065
  )
2858
3066
 
2859
3067
  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})