arthexis 0.1.11__py3-none-any.whl → 0.1.13__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 (50) hide show
  1. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/METADATA +2 -2
  2. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/RECORD +50 -44
  3. config/asgi.py +15 -1
  4. config/celery.py +8 -1
  5. config/settings.py +49 -78
  6. config/settings_helpers.py +109 -0
  7. core/admin.py +293 -78
  8. core/apps.py +21 -0
  9. core/auto_upgrade.py +2 -2
  10. core/form_fields.py +75 -0
  11. core/models.py +203 -47
  12. core/reference_utils.py +1 -1
  13. core/release.py +42 -20
  14. core/system.py +6 -3
  15. core/tasks.py +92 -40
  16. core/tests.py +75 -1
  17. core/views.py +178 -29
  18. core/widgets.py +43 -0
  19. nodes/admin.py +583 -10
  20. nodes/apps.py +15 -0
  21. nodes/feature_checks.py +133 -0
  22. nodes/models.py +287 -49
  23. nodes/reports.py +411 -0
  24. nodes/tests.py +990 -42
  25. nodes/urls.py +1 -0
  26. nodes/utils.py +32 -0
  27. nodes/views.py +173 -5
  28. ocpp/admin.py +424 -17
  29. ocpp/consumers.py +630 -15
  30. ocpp/evcs.py +7 -94
  31. ocpp/evcs_discovery.py +158 -0
  32. ocpp/models.py +236 -4
  33. ocpp/routing.py +4 -2
  34. ocpp/simulator.py +346 -26
  35. ocpp/status_display.py +26 -0
  36. ocpp/store.py +110 -2
  37. ocpp/tests.py +1425 -33
  38. ocpp/transactions_io.py +27 -3
  39. ocpp/views.py +344 -38
  40. pages/admin.py +138 -3
  41. pages/context_processors.py +15 -1
  42. pages/defaults.py +1 -2
  43. pages/forms.py +67 -0
  44. pages/models.py +136 -1
  45. pages/tests.py +379 -4
  46. pages/urls.py +1 -0
  47. pages/views.py +64 -7
  48. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/WHEEL +0 -0
  49. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/licenses/LICENSE +0 -0
  50. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/top_level.txt +0 -0
nodes/admin.py CHANGED
@@ -1,7 +1,8 @@
1
1
  from django.contrib import admin, messages
2
- from django.urls import path, reverse
2
+ from django.urls import NoReverseMatch, path, reverse
3
3
  from django.shortcuts import redirect, render
4
- from django.utils.html import format_html
4
+ from django.template.response import TemplateResponse
5
+ from django.utils.html import format_html, format_html_join
5
6
  from django import forms
6
7
  from django.contrib.admin.widgets import FilteredSelectMultiple
7
8
  from core.widgets import CopyColorWidget
@@ -9,15 +10,32 @@ from django.db.models import Count
9
10
  from django.conf import settings
10
11
  from pathlib import Path
11
12
  from django.http import HttpResponse
13
+ from django.utils import timezone
12
14
  from django.utils.translation import gettext_lazy as _
15
+ from urllib.parse import urlsplit, urlunsplit
16
+ from django.core.exceptions import PermissionDenied
17
+ from django.utils.dateparse import parse_datetime
13
18
  import base64
19
+ import json
14
20
  import pyperclip
15
21
  from pyperclip import PyperclipException
16
22
  import uuid
17
23
  import subprocess
18
- from .utils import capture_screenshot, save_screenshot
24
+
25
+ import requests
26
+ from requests import RequestException
27
+ from cryptography.hazmat.primitives import hashes, serialization
28
+ from cryptography.hazmat.primitives.asymmetric import padding
29
+ from .utils import capture_rpi_snapshot, capture_screenshot, save_screenshot
19
30
  from .actions import NodeAction
31
+ from .reports import (
32
+ collect_celery_log_entries,
33
+ collect_scheduled_tasks,
34
+ iter_report_periods,
35
+ resolve_period,
36
+ )
20
37
 
38
+ from core.admin import EmailOutboxAdminForm
21
39
  from .models import (
22
40
  Node,
23
41
  EmailOutbox,
@@ -30,6 +48,7 @@ from .models import (
30
48
  DNSRecord,
31
49
  )
32
50
  from . import dns as dns_utils
51
+ from core.models import RFID
33
52
  from core.user_data import EntityModelAdmin
34
53
 
35
54
 
@@ -69,6 +88,24 @@ class NodeManagerAdmin(EntityModelAdmin):
69
88
  "user__username",
70
89
  "group__name",
71
90
  )
91
+ fieldsets = (
92
+ (_("Owner"), {"fields": ("user", "group")}),
93
+ (
94
+ _("Credentials"),
95
+ {"fields": ("api_key", "api_secret", "customer_id")},
96
+ ),
97
+ (
98
+ _("Configuration"),
99
+ {
100
+ "fields": (
101
+ "provider",
102
+ "default_domain",
103
+ "use_sandbox",
104
+ "is_enabled",
105
+ )
106
+ },
107
+ ),
108
+ )
72
109
 
73
110
 
74
111
  @admin.register(DNSRecord)
@@ -183,7 +220,12 @@ class NodeAdmin(EntityModelAdmin):
183
220
  change_list_template = "admin/nodes/node/change_list.html"
184
221
  change_form_template = "admin/nodes/node/change_form.html"
185
222
  form = NodeAdminForm
186
- actions = ["register_visitor", "run_task", "take_screenshots"]
223
+ actions = [
224
+ "register_visitor",
225
+ "run_task",
226
+ "take_screenshots",
227
+ "fetch_rfids",
228
+ ]
187
229
  inlines = [NodeFeatureAssignmentInline]
188
230
 
189
231
  def get_urls(self):
@@ -214,6 +256,8 @@ class NodeAdmin(EntityModelAdmin):
214
256
 
215
257
  def register_current(self, request):
216
258
  """Create or update this host and offer browser node registration."""
259
+ if not request.user.is_superuser:
260
+ raise PermissionDenied
217
261
  node, created = Node.register_current()
218
262
  if created:
219
263
  self.message_user(
@@ -319,6 +363,151 @@ class NodeAdmin(EntityModelAdmin):
319
363
  count += 1
320
364
  self.message_user(request, f"{count} screenshots captured", messages.SUCCESS)
321
365
 
366
+ @admin.action(description="Fetch RFIDs from selected")
367
+ def fetch_rfids(self, request, queryset):
368
+ local_node = Node.get_local()
369
+ if not local_node:
370
+ self.message_user(
371
+ request,
372
+ "Local node is not registered.",
373
+ messages.ERROR,
374
+ )
375
+ return None
376
+
377
+ security_dir = Path(local_node.base_path or settings.BASE_DIR) / "security"
378
+ priv_path = security_dir / f"{local_node.public_endpoint}"
379
+ if not priv_path.exists():
380
+ self.message_user(
381
+ request,
382
+ "Local node private key not found.",
383
+ messages.ERROR,
384
+ )
385
+ return None
386
+
387
+ try:
388
+ private_key = serialization.load_pem_private_key(
389
+ priv_path.read_bytes(), password=None
390
+ )
391
+ except Exception as exc: # pragma: no cover - unexpected key errors
392
+ self.message_user(
393
+ request,
394
+ f"Failed to load private key: {exc}",
395
+ messages.ERROR,
396
+ )
397
+ return None
398
+
399
+ payload = json.dumps(
400
+ {"requester": str(local_node.uuid)},
401
+ separators=(",", ":"),
402
+ sort_keys=True,
403
+ )
404
+ signature = base64.b64encode(
405
+ private_key.sign(
406
+ payload.encode(),
407
+ padding.PKCS1v15(),
408
+ hashes.SHA256(),
409
+ )
410
+ ).decode()
411
+ headers = {
412
+ "Content-Type": "application/json",
413
+ "X-Signature": signature,
414
+ }
415
+
416
+ processed = 0
417
+ total_created = 0
418
+ total_updated = 0
419
+ errors = 0
420
+
421
+ for node in queryset:
422
+ if local_node.pk and node.pk == local_node.pk:
423
+ continue
424
+ url = f"http://{node.address}:{node.port}/nodes/rfid/export/"
425
+ try:
426
+ response = requests.post(
427
+ url,
428
+ data=payload,
429
+ headers=headers,
430
+ timeout=5,
431
+ )
432
+ except RequestException as exc:
433
+ self.message_user(request, f"{node}: {exc}", messages.ERROR)
434
+ errors += 1
435
+ continue
436
+
437
+ if response.status_code != 200:
438
+ self.message_user(
439
+ request,
440
+ f"{node}: {response.status_code} {response.text}",
441
+ messages.ERROR,
442
+ )
443
+ errors += 1
444
+ continue
445
+
446
+ try:
447
+ data = response.json()
448
+ except ValueError:
449
+ self.message_user(
450
+ request,
451
+ f"{node}: invalid JSON response",
452
+ messages.ERROR,
453
+ )
454
+ errors += 1
455
+ continue
456
+
457
+ created = 0
458
+ updated = 0
459
+ rfids = data.get("rfids", []) or []
460
+ for entry in rfids:
461
+ rfid_value = entry.get("rfid")
462
+ if not rfid_value:
463
+ continue
464
+ defaults = {
465
+ "custom_label": entry.get("custom_label", ""),
466
+ "key_a": entry.get(
467
+ "key_a", RFID._meta.get_field("key_a").default
468
+ ),
469
+ "key_b": entry.get(
470
+ "key_b", RFID._meta.get_field("key_b").default
471
+ ),
472
+ "data": entry.get("data", []),
473
+ "key_a_verified": bool(entry.get("key_a_verified", False)),
474
+ "key_b_verified": bool(entry.get("key_b_verified", False)),
475
+ "allowed": bool(entry.get("allowed", True)),
476
+ "color": entry.get("color", RFID.BLACK),
477
+ "kind": entry.get("kind", RFID.CLASSIC),
478
+ "released": bool(entry.get("released", False)),
479
+ "origin_node": node,
480
+ }
481
+ if "last_seen_on" in entry:
482
+ last_seen_raw = entry.get("last_seen_on")
483
+ if last_seen_raw:
484
+ defaults["last_seen_on"] = parse_datetime(last_seen_raw)
485
+ else:
486
+ defaults["last_seen_on"] = None
487
+
488
+ obj, created_flag = RFID.objects.update_or_create(
489
+ rfid=rfid_value,
490
+ defaults=defaults,
491
+ )
492
+ if created_flag:
493
+ created += 1
494
+ else:
495
+ updated += 1
496
+
497
+ processed += 1
498
+ total_created += created
499
+ total_updated += updated
500
+
501
+ if processed:
502
+ message = (
503
+ f"Fetched RFIDs from {processed} node(s); "
504
+ f"{total_created} created, {total_updated} updated."
505
+ )
506
+ level = messages.SUCCESS if not errors else messages.WARNING
507
+ self.message_user(request, message, level)
508
+ elif not errors:
509
+ self.message_user(request, "No remote nodes selected.", messages.INFO)
510
+
322
511
  def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
323
512
  extra_context = extra_context or {}
324
513
  extra_context["node_actions"] = NodeAction.get_actions()
@@ -358,6 +547,7 @@ class NodeAdmin(EntityModelAdmin):
358
547
 
359
548
  @admin.register(EmailOutbox)
360
549
  class EmailOutboxAdmin(EntityModelAdmin):
550
+ form = EmailOutboxAdminForm
361
551
  list_display = (
362
552
  "owner_label",
363
553
  "host",
@@ -369,15 +559,15 @@ class EmailOutboxAdmin(EntityModelAdmin):
369
559
  )
370
560
  change_form_template = "admin/nodes/emailoutbox/change_form.html"
371
561
  fieldsets = (
372
- ("Owner", {"fields": ("user", "group", "node")}),
562
+ ("Owner", {"fields": ("user", "group")}),
563
+ ("Credentials", {"fields": ("username", "password")}),
373
564
  (
374
565
  "Configuration",
375
566
  {
376
567
  "fields": (
568
+ "node",
377
569
  "host",
378
570
  "port",
379
- "username",
380
- "password",
381
571
  "use_tls",
382
572
  "use_ssl",
383
573
  "from_email",
@@ -472,7 +662,14 @@ class NodeRoleAdmin(EntityModelAdmin):
472
662
  @admin.register(NodeFeature)
473
663
  class NodeFeatureAdmin(EntityModelAdmin):
474
664
  filter_horizontal = ("roles",)
475
- list_display = ("display", "slug", "default_roles", "is_enabled")
665
+ list_display = (
666
+ "display",
667
+ "slug",
668
+ "default_roles",
669
+ "is_enabled_display",
670
+ "available_actions",
671
+ )
672
+ actions = ["check_features_for_eligibility", "enable_selected_features"]
476
673
  readonly_fields = ("is_enabled",)
477
674
  search_fields = ("display", "slug")
478
675
 
@@ -485,6 +682,321 @@ class NodeFeatureAdmin(EntityModelAdmin):
485
682
  roles = [role.name for role in obj.roles.all()]
486
683
  return ", ".join(roles) if roles else "—"
487
684
 
685
+ @admin.display(description="Is Enabled", boolean=True, ordering="is_enabled")
686
+ def is_enabled_display(self, obj):
687
+ return obj.is_enabled
688
+
689
+ @admin.display(description="Actions")
690
+ def available_actions(self, obj):
691
+ if not obj.is_enabled:
692
+ return "—"
693
+ actions = obj.get_default_actions()
694
+ if not actions:
695
+ return "—"
696
+
697
+ links = []
698
+ for action in actions:
699
+ try:
700
+ url = reverse(action.url_name)
701
+ except NoReverseMatch:
702
+ links.append(action.label)
703
+ else:
704
+ links.append(format_html('<a href="{}">{}</a>', url, action.label))
705
+
706
+ if not links:
707
+ return "—"
708
+ return format_html_join(" | ", "{}", ((link,) for link in links))
709
+
710
+ def _manual_enablement_message(self, feature, node):
711
+ if node is None:
712
+ return (
713
+ "Manual enablement is unavailable without a registered local node."
714
+ )
715
+ if feature.slug in Node.MANUAL_FEATURE_SLUGS:
716
+ return "This feature can be enabled manually."
717
+ return "This feature cannot be enabled manually."
718
+
719
+ @admin.action(description="Check features for eligibility")
720
+ def check_features_for_eligibility(self, request, queryset):
721
+ from .feature_checks import feature_checks
722
+
723
+ features = list(queryset)
724
+ total = len(features)
725
+ successes = 0
726
+ node = Node.get_local()
727
+ for feature in features:
728
+ enablement_message = self._manual_enablement_message(feature, node)
729
+ try:
730
+ result = feature_checks.run(feature, node=node)
731
+ except Exception as exc: # pragma: no cover - defensive
732
+ self.message_user(
733
+ request,
734
+ f"{feature.display}: {exc} {enablement_message}",
735
+ level=messages.ERROR,
736
+ )
737
+ continue
738
+ if result is None:
739
+ self.message_user(
740
+ request,
741
+ f"No check is configured for {feature.display}. {enablement_message}",
742
+ level=messages.WARNING,
743
+ )
744
+ continue
745
+ message = result.message or (
746
+ f"{feature.display} check {'passed' if result.success else 'failed'}."
747
+ )
748
+ self.message_user(
749
+ request, f"{message} {enablement_message}", level=result.level
750
+ )
751
+ if result.success:
752
+ successes += 1
753
+ if total:
754
+ self.message_user(
755
+ request,
756
+ f"Completed {successes} of {total} feature check(s) successfully.",
757
+ level=messages.INFO,
758
+ )
759
+
760
+ @admin.action(description="Enable selected action")
761
+ def enable_selected_features(self, request, queryset):
762
+ node = Node.get_local()
763
+ if node is None:
764
+ self.message_user(
765
+ request,
766
+ "No local node is registered; unable to enable features manually.",
767
+ level=messages.ERROR,
768
+ )
769
+ return
770
+
771
+ manual_features = [
772
+ feature
773
+ for feature in queryset
774
+ if feature.slug in Node.MANUAL_FEATURE_SLUGS
775
+ ]
776
+ non_manual_features = [
777
+ feature
778
+ for feature in queryset
779
+ if feature.slug not in Node.MANUAL_FEATURE_SLUGS
780
+ ]
781
+ for feature in non_manual_features:
782
+ self.message_user(
783
+ request,
784
+ f"{feature.display} cannot be enabled manually.",
785
+ level=messages.WARNING,
786
+ )
787
+
788
+ if not manual_features:
789
+ self.message_user(
790
+ request,
791
+ "None of the selected features can be enabled manually.",
792
+ level=messages.WARNING,
793
+ )
794
+ return
795
+
796
+ current_manual = set(
797
+ node.features.filter(slug__in=Node.MANUAL_FEATURE_SLUGS).values_list(
798
+ "slug", flat=True
799
+ )
800
+ )
801
+ desired_manual = current_manual | {feature.slug for feature in manual_features}
802
+ newly_enabled = desired_manual - current_manual
803
+ if not newly_enabled:
804
+ self.message_user(
805
+ request,
806
+ "Selected manual features are already enabled.",
807
+ level=messages.INFO,
808
+ )
809
+ return
810
+
811
+ node.update_manual_features(desired_manual)
812
+ display_map = {feature.slug: feature.display for feature in manual_features}
813
+ newly_enabled_names = [display_map[slug] for slug in sorted(newly_enabled)]
814
+ self.message_user(
815
+ request,
816
+ "Enabled {} feature(s): {}".format(
817
+ len(newly_enabled), ", ".join(newly_enabled_names)
818
+ ),
819
+ level=messages.SUCCESS,
820
+ )
821
+
822
+ def get_urls(self):
823
+ urls = super().get_urls()
824
+ custom = [
825
+ path(
826
+ "celery-report/",
827
+ self.admin_site.admin_view(self.celery_report),
828
+ name="nodes_nodefeature_celery_report",
829
+ ),
830
+ path(
831
+ "take-screenshot/",
832
+ self.admin_site.admin_view(self.take_screenshot),
833
+ name="nodes_nodefeature_take_screenshot",
834
+ ),
835
+ path(
836
+ "take-snapshot/",
837
+ self.admin_site.admin_view(self.take_snapshot),
838
+ name="nodes_nodefeature_take_snapshot",
839
+ ),
840
+ path(
841
+ "view-stream/",
842
+ self.admin_site.admin_view(self.view_stream),
843
+ name="nodes_nodefeature_view_stream",
844
+ ),
845
+ ]
846
+ return custom + urls
847
+
848
+ def celery_report(self, request):
849
+ period = resolve_period(request.GET.get("period"))
850
+ now = timezone.now()
851
+ window_end = now + period.delta
852
+ log_window_start = now - period.delta
853
+
854
+ scheduled_tasks = collect_scheduled_tasks(now, window_end)
855
+ log_collection = collect_celery_log_entries(log_window_start, now)
856
+
857
+ period_options = [
858
+ {
859
+ "key": candidate.key,
860
+ "label": candidate.label,
861
+ "selected": candidate.key == period.key,
862
+ "url": f"?period={candidate.key}",
863
+ }
864
+ for candidate in iter_report_periods()
865
+ ]
866
+
867
+ context = {
868
+ **self.admin_site.each_context(request),
869
+ "title": _("Celery Report"),
870
+ "period": period,
871
+ "period_options": period_options,
872
+ "current_time": now,
873
+ "window_end": window_end,
874
+ "log_window_start": log_window_start,
875
+ "scheduled_tasks": scheduled_tasks,
876
+ "log_entries": log_collection.entries,
877
+ "log_sources": log_collection.checked_sources,
878
+ }
879
+ return TemplateResponse(
880
+ request,
881
+ "admin/nodes/nodefeature/celery_report.html",
882
+ context,
883
+ )
884
+
885
+ def _ensure_feature_enabled(self, request, slug: str, action_label: str):
886
+ try:
887
+ feature = NodeFeature.objects.get(slug=slug)
888
+ except NodeFeature.DoesNotExist:
889
+ self.message_user(
890
+ request,
891
+ f"{action_label} is unavailable because the feature is not configured.",
892
+ level=messages.ERROR,
893
+ )
894
+ return None
895
+ if not feature.is_enabled:
896
+ self.message_user(
897
+ request,
898
+ f"{feature.display} feature is not enabled on this node.",
899
+ level=messages.WARNING,
900
+ )
901
+ return None
902
+ return feature
903
+
904
+ def take_screenshot(self, request):
905
+ feature = self._ensure_feature_enabled(
906
+ request, "screenshot-poll", "Take Screenshot"
907
+ )
908
+ if not feature:
909
+ return redirect("..")
910
+ url = request.build_absolute_uri("/")
911
+ try:
912
+ path = capture_screenshot(url)
913
+ except Exception as exc: # pragma: no cover - depends on selenium setup
914
+ self.message_user(request, str(exc), level=messages.ERROR)
915
+ return redirect("..")
916
+ node = Node.get_local()
917
+ sample = save_screenshot(path, node=node, method="DEFAULT_ACTION")
918
+ if not sample:
919
+ self.message_user(
920
+ request, "Duplicate screenshot; not saved", level=messages.INFO
921
+ )
922
+ return redirect("..")
923
+ self.message_user(
924
+ request, f"Screenshot saved to {sample.path}", level=messages.SUCCESS
925
+ )
926
+ try:
927
+ change_url = reverse(
928
+ "admin:nodes_contentsample_change", args=[sample.pk]
929
+ )
930
+ except NoReverseMatch: # pragma: no cover - admin URL always registered
931
+ self.message_user(
932
+ request,
933
+ "Screenshot saved but the admin page could not be resolved.",
934
+ level=messages.WARNING,
935
+ )
936
+ return redirect("..")
937
+ return redirect(change_url)
938
+
939
+ def take_snapshot(self, request):
940
+ feature = self._ensure_feature_enabled(
941
+ request, "rpi-camera", "Take a Snapshot"
942
+ )
943
+ if not feature:
944
+ return redirect("..")
945
+ try:
946
+ path = capture_rpi_snapshot()
947
+ except Exception as exc: # pragma: no cover - depends on camera stack
948
+ self.message_user(request, str(exc), level=messages.ERROR)
949
+ return redirect("..")
950
+ node = Node.get_local()
951
+ sample = save_screenshot(path, node=node, method="RPI_CAMERA")
952
+ if not sample:
953
+ self.message_user(
954
+ request, "Duplicate snapshot; not saved", level=messages.INFO
955
+ )
956
+ return redirect("..")
957
+ self.message_user(
958
+ request, f"Snapshot saved to {sample.path}", level=messages.SUCCESS
959
+ )
960
+ try:
961
+ change_url = reverse(
962
+ "admin:nodes_contentsample_change", args=[sample.pk]
963
+ )
964
+ except NoReverseMatch: # pragma: no cover - admin URL always registered
965
+ self.message_user(
966
+ request,
967
+ "Snapshot saved but the admin page could not be resolved.",
968
+ level=messages.WARNING,
969
+ )
970
+ return redirect("..")
971
+ return redirect(change_url)
972
+
973
+ def view_stream(self, request):
974
+ feature = self._ensure_feature_enabled(request, "rpi-camera", "View stream")
975
+ if not feature:
976
+ return redirect("..")
977
+
978
+ configured_stream = getattr(settings, "RPI_CAMERA_STREAM_URL", "").strip()
979
+ if configured_stream:
980
+ stream_url = configured_stream
981
+ else:
982
+ base_uri = request.build_absolute_uri("/")
983
+ parsed = urlsplit(base_uri)
984
+ hostname = parsed.hostname or "127.0.0.1"
985
+ port = getattr(settings, "RPI_CAMERA_STREAM_PORT", 8554)
986
+ scheme = getattr(settings, "RPI_CAMERA_STREAM_SCHEME", "http")
987
+ netloc = f"{hostname}:{port}" if port else hostname
988
+ stream_url = urlunsplit((scheme, netloc, "/", "", ""))
989
+ context = {
990
+ **self.admin_site.each_context(request),
991
+ "title": _("Raspberry Pi Camera Stream"),
992
+ "stream_url": stream_url,
993
+ }
994
+ return TemplateResponse(
995
+ request,
996
+ "admin/nodes/nodefeature/view_stream.html",
997
+ context,
998
+ )
999
+
488
1000
 
489
1001
  @admin.register(ContentSample)
490
1002
  class ContentSampleAdmin(EntityModelAdmin):
@@ -566,19 +1078,80 @@ class ContentSampleAdmin(EntityModelAdmin):
566
1078
 
567
1079
  @admin.register(NetMessage)
568
1080
  class NetMessageAdmin(EntityModelAdmin):
1081
+ class NetMessageAdminForm(forms.ModelForm):
1082
+ class Meta:
1083
+ model = NetMessage
1084
+ fields = "__all__"
1085
+ widgets = {"body": forms.Textarea(attrs={"rows": 4})}
1086
+
1087
+ form = NetMessageAdminForm
1088
+ change_form_template = "admin/nodes/netmessage/change_form.html"
569
1089
  list_display = (
570
1090
  "subject",
571
1091
  "body",
572
- "reach",
1092
+ "filter_node",
1093
+ "filter_node_role",
573
1094
  "node_origin",
574
1095
  "created",
1096
+ "target_limit",
575
1097
  "complete",
576
1098
  )
577
1099
  search_fields = ("subject", "body")
578
- list_filter = ("complete", "reach")
1100
+ list_filter = ("complete", "filter_node_role", "filter_current_relation")
579
1101
  ordering = ("-created",)
580
1102
  readonly_fields = ("complete",)
581
1103
  actions = ["send_messages"]
1104
+ fieldsets = (
1105
+ (None, {"fields": ("subject", "body")}),
1106
+ (
1107
+ "Filters",
1108
+ {
1109
+ "fields": (
1110
+ "filter_node",
1111
+ "filter_node_feature",
1112
+ "filter_node_role",
1113
+ "filter_current_relation",
1114
+ "filter_installed_version",
1115
+ "filter_installed_revision",
1116
+ )
1117
+ },
1118
+ ),
1119
+ (
1120
+ "Propagation",
1121
+ {
1122
+ "fields": (
1123
+ "node_origin",
1124
+ "target_limit",
1125
+ "propagated_to",
1126
+ "complete",
1127
+ )
1128
+ },
1129
+ ),
1130
+ )
1131
+
1132
+ def get_changeform_initial_data(self, request):
1133
+ initial = super().get_changeform_initial_data(request)
1134
+ initial = dict(initial) if initial else {}
1135
+ reply_to = request.GET.get("reply_to")
1136
+ if reply_to:
1137
+ try:
1138
+ message = (
1139
+ NetMessage.objects.select_related("node_origin__role")
1140
+ .get(pk=reply_to)
1141
+ )
1142
+ except (NetMessage.DoesNotExist, ValueError, TypeError):
1143
+ message = None
1144
+ if message:
1145
+ subject = (message.subject or "").strip()
1146
+ if subject:
1147
+ if not subject.lower().startswith("re:"):
1148
+ subject = f"Re: {subject}"
1149
+ else:
1150
+ subject = "Re:"
1151
+ initial.setdefault("subject", subject[:64])
1152
+ if message.node_origin and "filter_node" not in initial:
1153
+ initial["filter_node"] = message.node_origin.pk
1154
+ return initial
582
1155
 
583
1156
  def send_messages(self, request, queryset):
584
1157
  for msg in queryset: