arthexis 0.1.16__py3-none-any.whl → 0.1.28__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 (67) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/METADATA +95 -41
  2. arthexis-0.1.28.dist-info/RECORD +112 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +21 -30
  6. config/settings_helpers.py +176 -1
  7. config/urls.py +69 -1
  8. core/admin.py +805 -473
  9. core/apps.py +6 -8
  10. core/auto_upgrade.py +19 -4
  11. core/backends.py +13 -3
  12. core/celery_utils.py +73 -0
  13. core/changelog.py +66 -5
  14. core/environment.py +4 -5
  15. core/models.py +1825 -218
  16. core/notifications.py +1 -1
  17. core/reference_utils.py +10 -11
  18. core/release.py +55 -7
  19. core/sigil_builder.py +2 -2
  20. core/sigil_resolver.py +1 -66
  21. core/system.py +285 -4
  22. core/tasks.py +439 -138
  23. core/test_system_info.py +43 -5
  24. core/tests.py +516 -18
  25. core/user_data.py +94 -21
  26. core/views.py +348 -186
  27. nodes/admin.py +904 -67
  28. nodes/apps.py +12 -1
  29. nodes/feature_checks.py +30 -0
  30. nodes/models.py +800 -127
  31. nodes/rfid_sync.py +1 -1
  32. nodes/tasks.py +98 -3
  33. nodes/tests.py +1381 -152
  34. nodes/urls.py +15 -1
  35. nodes/utils.py +51 -3
  36. nodes/views.py +1382 -152
  37. ocpp/admin.py +1970 -152
  38. ocpp/consumers.py +839 -34
  39. ocpp/models.py +968 -17
  40. ocpp/network.py +398 -0
  41. ocpp/store.py +411 -43
  42. ocpp/tasks.py +261 -3
  43. ocpp/test_export_import.py +1 -0
  44. ocpp/test_rfid.py +194 -6
  45. ocpp/tests.py +1918 -87
  46. ocpp/transactions_io.py +9 -1
  47. ocpp/urls.py +8 -3
  48. ocpp/views.py +700 -53
  49. pages/admin.py +262 -30
  50. pages/apps.py +35 -0
  51. pages/context_processors.py +28 -21
  52. pages/defaults.py +1 -1
  53. pages/forms.py +31 -8
  54. pages/middleware.py +6 -2
  55. pages/models.py +86 -2
  56. pages/module_defaults.py +5 -5
  57. pages/site_config.py +137 -0
  58. pages/tests.py +1050 -126
  59. pages/urls.py +14 -2
  60. pages/utils.py +70 -0
  61. pages/views.py +622 -56
  62. arthexis-0.1.16.dist-info/RECORD +0 -111
  63. core/workgroup_urls.py +0 -17
  64. core/workgroup_views.py +0 -94
  65. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
  66. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
  67. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
core/admin.py CHANGED
@@ -1,3 +1,4 @@
1
+ from collections import defaultdict
1
2
  from io import BytesIO
2
3
  import os
3
4
 
@@ -8,10 +9,13 @@ from django.urls import NoReverseMatch, path, reverse
8
9
  from urllib.parse import urlencode, urlparse
9
10
  from django.shortcuts import get_object_or_404, redirect, render
10
11
  from django.http import (
12
+ FileResponse,
13
+ Http404,
11
14
  HttpResponse,
12
15
  JsonResponse,
13
16
  HttpResponseBase,
14
17
  HttpResponseRedirect,
18
+ HttpResponseNotAllowed,
15
19
  )
16
20
  from django.template.response import TemplateResponse
17
21
  from django.conf import settings
@@ -39,6 +43,7 @@ from django.utils import timezone, translation
39
43
  from django.utils.formats import date_format
40
44
  from django.utils.dateparse import parse_datetime
41
45
  from django.utils.html import format_html
46
+ from django.utils.text import slugify
42
47
  from django.utils.translation import gettext_lazy as _, ngettext
43
48
  from django.forms.models import BaseInlineFormSet
44
49
  import json
@@ -46,6 +51,7 @@ import uuid
46
51
  import requests
47
52
  import datetime
48
53
  from django.db import IntegrityError, transaction
54
+ from django.db.models import Q
49
55
  import calendar
50
56
  import re
51
57
  from django_object_actions import DjangoObjectActions
@@ -59,7 +65,7 @@ from reportlab.graphics.barcode import qr
59
65
  from reportlab.graphics.shapes import Drawing
60
66
  from reportlab.lib.styles import getSampleStyleSheet
61
67
  from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
62
- from ocpp.models import Transaction
68
+ from ocpp.models import Charger, Transaction
63
69
  from ocpp.rfid.utils import build_mode_toggle
64
70
  from nodes.models import EmailOutbox
65
71
  from .github_helper import GitHubRepositoryError, create_repository_for_package
@@ -82,6 +88,7 @@ from .models import (
82
88
  OdooProfile,
83
89
  OpenPayProfile,
84
90
  EmailInbox,
91
+ GoogleCalendarProfile,
85
92
  SocialProfile,
86
93
  EmailCollector,
87
94
  Package,
@@ -90,9 +97,7 @@ from .models import (
90
97
  SecurityGroup,
91
98
  InviteLead,
92
99
  PublicWifiAccess,
93
- AssistantProfile,
94
100
  Todo,
95
- hash_key,
96
101
  )
97
102
  from .user_data import (
98
103
  EntityModelAdmin,
@@ -109,8 +114,6 @@ from .rfid_import_export import (
109
114
  parse_accounts,
110
115
  serialize_accounts,
111
116
  )
112
- from .mcp import process as mcp_process
113
- from .mcp.server import resolve_base_urls
114
117
  from . import release as release_utils
115
118
 
116
119
  logger = logging.getLogger(__name__)
@@ -608,15 +611,18 @@ class PackageAdmin(SaveBeforeChangeAction, EntityModelAdmin):
608
611
  change_actions = ["create_repository_action", "prepare_next_release_action"]
609
612
 
610
613
  def _prepare(self, request, package):
614
+ if request.method not in {"POST", "GET"}:
615
+ return HttpResponseNotAllowed(["GET", "POST"])
611
616
  from pathlib import Path
612
617
  from packaging.version import Version
613
618
 
614
619
  ver_file = Path("VERSION")
615
- repo_version = (
616
- Version(ver_file.read_text().strip())
617
- if ver_file.exists()
618
- else Version("0.0.0")
619
- )
620
+ if ver_file.exists():
621
+ raw_version = ver_file.read_text().strip()
622
+ cleaned_version = raw_version.rstrip("+") or "0.0.0"
623
+ repo_version = Version(cleaned_version)
624
+ else:
625
+ repo_version = Version("0.0.0")
620
626
 
621
627
  pypi_latest = Version("0.0.0")
622
628
  try:
@@ -808,6 +814,7 @@ class SecurityGroupAdmin(DjangoGroupAdmin):
808
814
  form = SecurityGroupAdminForm
809
815
  fieldsets = ((None, {"fields": ("name", "parent", "users", "permissions")}),)
810
816
  filter_horizontal = ("permissions",)
817
+ search_fields = ("name", "parent__name")
811
818
 
812
819
 
813
820
  class InviteLeadAdmin(EntityModelAdmin):
@@ -876,6 +883,82 @@ class EnergyAccountRFIDInline(admin.TabularInline):
876
883
  verbose_name_plural = "RFIDs"
877
884
 
878
885
 
886
+ class UserChangeRFIDForm(forms.ModelForm):
887
+ """Admin change form exposing login RFID assignment."""
888
+
889
+ login_rfid = forms.ModelChoiceField(
890
+ label=_("Login RFID"),
891
+ queryset=RFID.objects.none(),
892
+ required=False,
893
+ help_text=_("Assign an RFID card to this user for RFID logins."),
894
+ )
895
+
896
+ class Meta:
897
+ model = User
898
+ fields = "__all__"
899
+
900
+ def __init__(self, *args, **kwargs):
901
+ super().__init__(*args, **kwargs)
902
+ user = self.instance
903
+ field = self.fields["login_rfid"]
904
+ account = getattr(user, "energy_account", None)
905
+ if account is not None:
906
+ queryset = RFID.objects.filter(
907
+ Q(energy_accounts__isnull=True) | Q(energy_accounts=account)
908
+ )
909
+ current = account.rfids.order_by("label_id").first()
910
+ if current:
911
+ field.initial = current.pk
912
+ else:
913
+ queryset = RFID.objects.filter(energy_accounts__isnull=True)
914
+ field.queryset = queryset.order_by("label_id")
915
+ field.empty_label = _("Keep current assignment")
916
+
917
+ def _ensure_energy_account(self, user):
918
+ account = getattr(user, "energy_account", None)
919
+ if account is not None:
920
+ if account.user_id != user.pk:
921
+ account.user = user
922
+ account.save(update_fields=["user"])
923
+ return account
924
+ account = EnergyAccount.objects.filter(user=user).first()
925
+ if account is not None:
926
+ if account.user_id != user.pk:
927
+ account.user = user
928
+ account.save(update_fields=["user"])
929
+ return account
930
+ base_slug = slugify(
931
+ user.username
932
+ or user.get_full_name()
933
+ or user.email
934
+ or (str(user.pk) if user.pk is not None else "")
935
+ )
936
+ if not base_slug:
937
+ base_slug = f"user-{uuid.uuid4().hex[:8]}"
938
+ base_name = base_slug.upper()
939
+ candidate = base_name
940
+ suffix = 1
941
+ while EnergyAccount.objects.filter(name=candidate).exists():
942
+ suffix += 1
943
+ candidate = f"{base_name}-{suffix}"
944
+ return EnergyAccount.objects.create(user=user, name=candidate)
945
+
946
+ def save(self, commit=True):
947
+ user = super().save(commit)
948
+ rfid = self.cleaned_data.get("login_rfid")
949
+ if not rfid:
950
+ return user
951
+ account = self._ensure_energy_account(user)
952
+ if account.pk is None:
953
+ account.save()
954
+ other_accounts = list(rfid.energy_accounts.exclude(pk=account.pk))
955
+ if other_accounts:
956
+ rfid.energy_accounts.remove(*other_accounts)
957
+ if not account.rfids.filter(pk=rfid.pk).exists():
958
+ account.rfids.add(rfid)
959
+ return user
960
+
961
+
879
962
  def _raw_instance_value(instance, field_name):
880
963
  """Return the stored value for ``field_name`` without resolving sigils."""
881
964
 
@@ -973,6 +1056,15 @@ class OpenPayProfileAdminForm(forms.ModelForm):
973
1056
  required=False,
974
1057
  help_text="Leave blank to keep the current secret.",
975
1058
  )
1059
+ paypal_client_secret = forms.CharField(
1060
+ widget=forms.PasswordInput(render_value=True),
1061
+ required=False,
1062
+ help_text="Leave blank to keep the current secret.",
1063
+ )
1064
+ paypal_webhook_id = forms.CharField(
1065
+ required=False,
1066
+ help_text="Leave blank to keep the current webhook identifier.",
1067
+ )
976
1068
 
977
1069
  class Meta:
978
1070
  model = OpenPayProfile
@@ -980,13 +1072,43 @@ class OpenPayProfileAdminForm(forms.ModelForm):
980
1072
 
981
1073
  def __init__(self, *args, **kwargs):
982
1074
  super().__init__(*args, **kwargs)
1075
+ openpay_help = _(
1076
+ "Provide merchant ID, public and private keys, and webhook secret from OpenPay."
1077
+ )
1078
+ self.fields["merchant_id"].help_text = openpay_help
1079
+ self.fields["public_key"].help_text = _(
1080
+ "OpenPay public key used for browser integrations."
1081
+ )
1082
+ self.fields["private_key"].help_text = _(
1083
+ "OpenPay private key used for server-side requests. Leave blank to keep the current key."
1084
+ )
1085
+ self.fields["webhook_secret"].help_text = _(
1086
+ "Secret used to sign OpenPay webhooks. Leave blank to keep the current secret."
1087
+ )
1088
+ self.fields["is_production"].help_text = _(
1089
+ "Enable to send requests to OpenPay's live environment."
1090
+ )
1091
+ self.fields["default_processor"].help_text = _(
1092
+ "Select which configured processor to try first when charging."
1093
+ )
1094
+ self.fields["paypal_client_id"].help_text = _(
1095
+ "PayPal REST client ID for your application."
1096
+ )
1097
+ self.fields["paypal_client_secret"].help_text = _(
1098
+ "PayPal REST client secret. Leave blank to keep the current secret."
1099
+ )
1100
+ self.fields["paypal_webhook_id"].help_text = _(
1101
+ "PayPal webhook ID used to validate notifications. Leave blank to keep the current webhook identifier."
1102
+ )
1103
+ self.fields["paypal_is_production"].help_text = _(
1104
+ "Enable to send requests to PayPal's live environment."
1105
+ )
1106
+
983
1107
  if self.instance.pk:
984
- for field in ("private_key", "webhook_secret"):
1108
+ for field in ("private_key", "webhook_secret", "paypal_client_secret", "paypal_webhook_id"):
985
1109
  if field in self.fields:
986
1110
  self.fields[field].initial = ""
987
1111
  self.initial[field] = ""
988
- else:
989
- self.fields["private_key"].required = True
990
1112
 
991
1113
  def clean_private_key(self):
992
1114
  key = self.cleaned_data.get("private_key")
@@ -1000,11 +1122,66 @@ class OpenPayProfileAdminForm(forms.ModelForm):
1000
1122
  return keep_existing("webhook_secret")
1001
1123
  return secret
1002
1124
 
1125
+ def clean_paypal_client_secret(self):
1126
+ secret = self.cleaned_data.get("paypal_client_secret")
1127
+ if not secret and self.instance.pk:
1128
+ return keep_existing("paypal_client_secret")
1129
+ return secret
1130
+
1131
+ def clean_paypal_webhook_id(self):
1132
+ identifier = self.cleaned_data.get("paypal_webhook_id")
1133
+ if identifier == "" and self.instance.pk:
1134
+ return keep_existing("paypal_webhook_id")
1135
+ return identifier
1136
+
1003
1137
  def _post_clean(self):
1004
1138
  super()._post_clean()
1005
1139
  _restore_sigil_values(
1006
1140
  self,
1007
- ["merchant_id", "private_key", "public_key", "webhook_secret"],
1141
+ [
1142
+ "merchant_id",
1143
+ "private_key",
1144
+ "public_key",
1145
+ "webhook_secret",
1146
+ "paypal_client_id",
1147
+ "paypal_client_secret",
1148
+ "paypal_webhook_id",
1149
+ ],
1150
+ )
1151
+
1152
+
1153
+ class GoogleCalendarProfileAdminForm(forms.ModelForm):
1154
+ """Admin form for :class:`core.models.GoogleCalendarProfile`."""
1155
+
1156
+ api_key = forms.CharField(
1157
+ widget=forms.PasswordInput(render_value=True),
1158
+ required=False,
1159
+ help_text="Leave blank to keep the current key.",
1160
+ )
1161
+
1162
+ class Meta:
1163
+ model = GoogleCalendarProfile
1164
+ fields = "__all__"
1165
+
1166
+ def __init__(self, *args, **kwargs):
1167
+ super().__init__(*args, **kwargs)
1168
+ if self.instance.pk:
1169
+ self.fields["api_key"].initial = ""
1170
+ self.initial["api_key"] = ""
1171
+ else:
1172
+ self.fields["api_key"].required = True
1173
+
1174
+ def clean_api_key(self):
1175
+ key = self.cleaned_data.get("api_key")
1176
+ if not key and self.instance.pk:
1177
+ return keep_existing("api_key")
1178
+ return key
1179
+
1180
+ def _post_clean(self):
1181
+ super()._post_clean()
1182
+ _restore_sigil_values(
1183
+ self,
1184
+ ["calendar_id", "api_key", "display_name", "timezone"],
1008
1185
  )
1009
1186
 
1010
1187
 
@@ -1192,7 +1369,7 @@ class OdooProfileInlineForm(ProfileFormMixin, OdooProfileAdminForm):
1192
1369
  ]
1193
1370
  if provided and missing:
1194
1371
  raise forms.ValidationError(
1195
- "Provide host, database, username, and password to create an Odoo employee.",
1372
+ "Provide host, database, username, and password to create a CRM employee.",
1196
1373
  )
1197
1374
 
1198
1375
  return cleaned
@@ -1209,22 +1386,63 @@ class OpenPayProfileInlineForm(ProfileFormMixin, OpenPayProfileAdminForm):
1209
1386
  if cleaned.get("DELETE") or self.errors:
1210
1387
  return cleaned
1211
1388
 
1212
- required = ("merchant_id", "private_key", "public_key")
1213
- provided = [
1214
- name for name in required if not self._is_empty_value(cleaned.get(name))
1215
- ]
1216
- missing = [
1217
- name for name in required if self._is_empty_value(cleaned.get(name))
1218
- ]
1219
- if provided and missing:
1389
+ def _has_value(name: str) -> bool:
1390
+ value = cleaned.get(name)
1391
+ if isinstance(value, KeepExistingValue):
1392
+ return bool(getattr(self.instance, name))
1393
+ return not self._is_empty_value(value)
1394
+
1395
+ openpay_fields = ("merchant_id", "private_key", "public_key")
1396
+ openpay_provided = [name for name in openpay_fields if _has_value(name)]
1397
+ openpay_missing = [name for name in openpay_fields if not _has_value(name)]
1398
+ if openpay_provided and openpay_missing:
1220
1399
  raise forms.ValidationError(
1221
1400
  _(
1222
1401
  "Provide merchant ID, private key, and public key to configure OpenPay."
1223
1402
  )
1224
1403
  )
1404
+
1405
+ paypal_fields = ("paypal_client_id", "paypal_client_secret")
1406
+ paypal_provided = [name for name in paypal_fields if _has_value(name)]
1407
+ paypal_missing = [name for name in paypal_fields if not _has_value(name)]
1408
+ if paypal_provided and paypal_missing:
1409
+ raise forms.ValidationError(
1410
+ _("Provide PayPal client ID and client secret to configure PayPal.")
1411
+ )
1412
+
1413
+ has_openpay = len(openpay_provided) == len(openpay_fields)
1414
+ has_paypal = len(paypal_provided) == len(paypal_fields)
1415
+
1416
+ if not has_openpay and not has_paypal:
1417
+ raise forms.ValidationError(
1418
+ _("Provide OpenPay or PayPal credentials to configure a payment processor.")
1419
+ )
1420
+
1421
+ default_processor = cleaned.get("default_processor") or OpenPayProfile.PROCESSOR_OPENPAY
1422
+ if default_processor == OpenPayProfile.PROCESSOR_OPENPAY and not has_openpay:
1423
+ raise forms.ValidationError(
1424
+ _(
1425
+ "OpenPay must be fully configured or select PayPal as the default processor."
1426
+ )
1427
+ )
1428
+ if default_processor == OpenPayProfile.PROCESSOR_PAYPAL and not has_paypal:
1429
+ raise forms.ValidationError(
1430
+ _(
1431
+ "PayPal must be fully configured or select OpenPay as the default processor."
1432
+ )
1433
+ )
1225
1434
  return cleaned
1226
1435
 
1227
1436
 
1437
+ class GoogleCalendarProfileInlineForm(
1438
+ ProfileFormMixin, GoogleCalendarProfileAdminForm
1439
+ ):
1440
+ profile_fields = GoogleCalendarProfile.profile_fields
1441
+
1442
+ class Meta(GoogleCalendarProfileAdminForm.Meta):
1443
+ exclude = ("user", "group")
1444
+
1445
+
1228
1446
  class EmailInboxInlineForm(ProfileFormMixin, EmailInboxAdminForm):
1229
1447
  profile_fields = EmailInbox.profile_fields
1230
1448
 
@@ -1303,46 +1521,6 @@ class ReleaseManagerInlineForm(ProfileFormMixin, forms.ModelForm):
1303
1521
  }
1304
1522
 
1305
1523
 
1306
- class AssistantProfileInlineForm(ProfileFormMixin, forms.ModelForm):
1307
- user_key = forms.CharField(
1308
- required=False,
1309
- widget=forms.PasswordInput(render_value=True),
1310
- help_text="Provide a plain key to create or rotate credentials.",
1311
- )
1312
- profile_fields = ("user_key", "scopes", "is_active")
1313
-
1314
- class Meta:
1315
- model = AssistantProfile
1316
- fields = ("scopes", "is_active")
1317
-
1318
- def __init__(self, *args, **kwargs):
1319
- super().__init__(*args, **kwargs)
1320
- if not self.instance.pk and "is_active" in self.fields:
1321
- self.fields["is_active"].initial = False
1322
-
1323
- def clean(self):
1324
- cleaned = super().clean()
1325
- if cleaned.get("DELETE"):
1326
- return cleaned
1327
- if not self.instance.pk and not cleaned.get("user_key"):
1328
- if cleaned.get("scopes") or cleaned.get("is_active"):
1329
- raise forms.ValidationError(
1330
- "Provide a user key to create an assistant profile."
1331
- )
1332
- return cleaned
1333
-
1334
- def save(self, commit=True):
1335
- instance = super().save(commit=False)
1336
- user_key = self.cleaned_data.get("user_key")
1337
- if user_key:
1338
- instance.user_key_hash = hash_key(user_key)
1339
- instance.last_used_at = None
1340
- if commit:
1341
- instance.save()
1342
- self.save_m2m()
1343
- return instance
1344
-
1345
-
1346
1524
  PROFILE_INLINE_CONFIG = {
1347
1525
  OdooProfile: {
1348
1526
  "form": OdooProfileInlineForm,
@@ -1351,6 +1529,7 @@ PROFILE_INLINE_CONFIG = {
1351
1529
  None,
1352
1530
  {
1353
1531
  "fields": (
1532
+ "crm",
1354
1533
  "host",
1355
1534
  "database",
1356
1535
  "username",
@@ -1359,7 +1538,7 @@ PROFILE_INLINE_CONFIG = {
1359
1538
  },
1360
1539
  ),
1361
1540
  (
1362
- "Odoo Employee",
1541
+ "CRM Employee",
1363
1542
  {
1364
1543
  "fields": ("verified_on", "odoo_uid", "name", "email"),
1365
1544
  },
@@ -1389,6 +1568,16 @@ PROFILE_INLINE_CONFIG = {
1389
1568
  ),
1390
1569
  "readonly_fields": ("verified_on", "verification_reference"),
1391
1570
  },
1571
+ GoogleCalendarProfile: {
1572
+ "form": GoogleCalendarProfileInlineForm,
1573
+ "fields": (
1574
+ "display_name",
1575
+ "calendar_id",
1576
+ "api_key",
1577
+ "max_events",
1578
+ "timezone",
1579
+ ),
1580
+ },
1392
1581
  EmailInbox: {
1393
1582
  "form": EmailInboxInlineForm,
1394
1583
  "fields": (
@@ -1473,12 +1662,6 @@ PROFILE_INLINE_CONFIG = {
1473
1662
  "secondary_pypi_url",
1474
1663
  ),
1475
1664
  },
1476
- AssistantProfile: {
1477
- "form": AssistantProfileInlineForm,
1478
- "fields": ("user_key", "scopes", "is_active"),
1479
- "readonly_fields": ("user_key_hash", "created_at", "last_used_at"),
1480
- "template": "admin/edit_inline/profile_stacked.html",
1481
- },
1482
1665
  }
1483
1666
 
1484
1667
 
@@ -1525,7 +1708,6 @@ PROFILE_MODELS = (
1525
1708
  EmailOutbox,
1526
1709
  SocialProfile,
1527
1710
  ReleaseManager,
1528
- AssistantProfile,
1529
1711
  )
1530
1712
  USER_PROFILE_INLINES = [
1531
1713
  _build_profile_inline(model, "user") for model in PROFILE_MODELS
@@ -1544,12 +1726,25 @@ class UserPhoneNumberInline(admin.TabularInline):
1544
1726
 
1545
1727
 
1546
1728
  class UserAdmin(UserDatumAdminMixin, DjangoUserAdmin):
1729
+ form = UserChangeRFIDForm
1547
1730
  fieldsets = _append_operate_as(DjangoUserAdmin.fieldsets)
1548
1731
  add_fieldsets = _append_operate_as(DjangoUserAdmin.add_fieldsets)
1549
1732
  inlines = USER_PROFILE_INLINES + [UserPhoneNumberInline]
1550
1733
  change_form_template = "admin/user_profile_change_form.html"
1551
1734
  _skip_entity_user_datum = True
1552
1735
 
1736
+ def get_fieldsets(self, request, obj=None):
1737
+ fieldsets = list(super().get_fieldsets(request, obj))
1738
+ if obj is not None and fieldsets:
1739
+ name, options = fieldsets[0]
1740
+ fields = list(options.get("fields", ()))
1741
+ if "login_rfid" not in fields:
1742
+ fields.append("login_rfid")
1743
+ options = options.copy()
1744
+ options["fields"] = tuple(fields)
1745
+ fieldsets[0] = (name, options)
1746
+ return fieldsets
1747
+
1553
1748
  def _get_operate_as_profile_template(self):
1554
1749
  opts = self.model._meta
1555
1750
  try:
@@ -1757,17 +1952,18 @@ class SocialProfileAdmin(
1757
1952
  class OdooProfileAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin):
1758
1953
  change_form_template = "django_object_actions/change_form.html"
1759
1954
  form = OdooProfileAdminForm
1760
- list_display = ("owner", "host", "database", "verified_on")
1955
+ list_display = ("owner", "host", "database", "credentials_ok", "verified_on")
1956
+ list_filter = ("crm",)
1761
1957
  readonly_fields = ("verified_on", "odoo_uid", "name", "email")
1762
1958
  actions = ["verify_credentials"]
1763
1959
  change_actions = ["verify_credentials_action", "my_profile_action"]
1764
1960
  changelist_actions = ["my_profile", "generate_quote_report"]
1765
1961
  fieldsets = (
1766
1962
  ("Owner", {"fields": ("user", "group")}),
1767
- ("Configuration", {"fields": ("host", "database")}),
1963
+ ("Configuration", {"fields": ("crm", "host", "database")}),
1768
1964
  ("Credentials", {"fields": ("username", "password")}),
1769
1965
  (
1770
- "Odoo Employee",
1966
+ "CRM Employee",
1771
1967
  {"fields": ("verified_on", "odoo_uid", "name", "email")},
1772
1968
  ),
1773
1969
  )
@@ -1777,6 +1973,10 @@ class OdooProfileAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdm
1777
1973
 
1778
1974
  owner.short_description = "Owner"
1779
1975
 
1976
+ @admin.display(description=_("Credentials OK"), boolean=True)
1977
+ def credentials_ok(self, obj):
1978
+ return bool(obj.password) and obj.is_verified
1979
+
1780
1980
  def _verify_credentials(self, request, profile):
1781
1981
  try:
1782
1982
  profile.verify()
@@ -1808,15 +2008,47 @@ class OdooProfileAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdm
1808
2008
  class OpenPayProfileAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin):
1809
2009
  change_form_template = "django_object_actions/change_form.html"
1810
2010
  form = OpenPayProfileAdminForm
1811
- list_display = ("owner", "merchant_id", "environment", "verified_on")
2011
+ list_display = ("owner", "default_processor_display", "environment", "verified_on")
1812
2012
  readonly_fields = ("verified_on", "verification_reference")
1813
2013
  actions = ["verify_credentials"]
1814
2014
  change_actions = ["verify_credentials_action", "my_profile_action"]
1815
2015
  changelist_actions = ["my_profile"]
1816
2016
  fieldsets = (
1817
2017
  (_("Owner"), {"fields": ("user", "group")}),
1818
- (_("Gateway"), {"fields": ("merchant_id", "public_key", "is_production")}),
1819
- (_("Credentials"), {"fields": ("private_key", "webhook_secret")}),
2018
+ (
2019
+ _("Default Processor"),
2020
+ {
2021
+ "fields": ("default_processor",),
2022
+ "description": _(
2023
+ "Choose which configured processor to contact first when processing payments."
2024
+ ),
2025
+ },
2026
+ ),
2027
+ (
2028
+ _("OpenPay"),
2029
+ {
2030
+ "fields": (
2031
+ "merchant_id",
2032
+ "public_key",
2033
+ "private_key",
2034
+ "webhook_secret",
2035
+ "is_production",
2036
+ ),
2037
+ "description": _("Configure OpenPay merchant access."),
2038
+ },
2039
+ ),
2040
+ (
2041
+ _("PayPal"),
2042
+ {
2043
+ "fields": (
2044
+ "paypal_client_id",
2045
+ "paypal_client_secret",
2046
+ "paypal_webhook_id",
2047
+ "paypal_is_production",
2048
+ ),
2049
+ "description": _("Configure PayPal REST API access."),
2050
+ },
2051
+ ),
1820
2052
  (
1821
2053
  _("Verification"),
1822
2054
  {"fields": ("verified_on", "verification_reference")},
@@ -1827,12 +2059,23 @@ class OpenPayProfileAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModel
1827
2059
  def owner(self, obj):
1828
2060
  return obj.owner_display()
1829
2061
 
2062
+ @admin.display(description=_("Default Processor"))
2063
+ def default_processor_display(self, obj):
2064
+ return obj.get_default_processor_display()
2065
+
1830
2066
  @admin.display(description=_("Environment"))
1831
2067
  def environment(self, obj):
1832
- return _("Production") if obj.is_production else _("Sandbox")
2068
+ if obj.default_processor == obj.PROCESSOR_PAYPAL:
2069
+ return _("PayPal Production") if obj.paypal_is_production else _("PayPal Sandbox")
2070
+ return _("OpenPay Production") if obj.is_production else _("OpenPay Sandbox")
1833
2071
 
1834
2072
  def _verify_credentials(self, request, profile):
1835
- owner = profile.owner_display() or profile.merchant_id
2073
+ owner = (
2074
+ profile.owner_display()
2075
+ or profile.merchant_id
2076
+ or profile.paypal_client_id
2077
+ or _("Payment Processor")
2078
+ )
1836
2079
  try:
1837
2080
  profile.verify()
1838
2081
  except ValidationError as exc:
@@ -1867,6 +2110,44 @@ class OpenPayProfileAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModel
1867
2110
  verify_credentials_action.short_description = _("Test credentials")
1868
2111
 
1869
2112
 
2113
+ class GoogleCalendarProfileAdmin(
2114
+ ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin
2115
+ ):
2116
+ form = GoogleCalendarProfileAdminForm
2117
+ list_display = ("owner", "calendar_identifier", "max_events")
2118
+ search_fields = (
2119
+ "display_name",
2120
+ "calendar_id",
2121
+ "user__username",
2122
+ "group__name",
2123
+ )
2124
+ changelist_actions = ["my_profile"]
2125
+ change_actions = ["my_profile_action"]
2126
+ fieldsets = (
2127
+ (_("Owner"), {"fields": ("user", "group")}),
2128
+ (
2129
+ _("Calendar"),
2130
+ {
2131
+ "fields": (
2132
+ "display_name",
2133
+ "calendar_id",
2134
+ "api_key",
2135
+ "max_events",
2136
+ "timezone",
2137
+ )
2138
+ },
2139
+ ),
2140
+ )
2141
+
2142
+ @admin.display(description=_("Owner"))
2143
+ def owner(self, obj):
2144
+ return obj.owner_display()
2145
+
2146
+ @admin.display(description=_("Calendar"))
2147
+ def calendar_identifier(self, obj):
2148
+ display = obj.get_display_name()
2149
+ return display or obj.resolved_calendar_id()
2150
+
1870
2151
  class EmailSearchForm(forms.Form):
1871
2152
  subject = forms.CharField(
1872
2153
  required=False, widget=forms.TextInput(attrs={"style": "width: 40em;"})
@@ -2012,180 +2293,6 @@ class EmailInboxAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmi
2012
2293
  return TemplateResponse(request, "admin/core/emailinbox/search.html", context)
2013
2294
 
2014
2295
 
2015
- @admin.register(AssistantProfile)
2016
- class AssistantProfileAdmin(
2017
- ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin
2018
- ):
2019
- list_display = ("owner", "created_at", "last_used_at", "is_active")
2020
- readonly_fields = ("user_key_hash", "created_at", "last_used_at")
2021
-
2022
- change_form_template = "admin/workgroupassistantprofile_change_form.html"
2023
- change_list_template = "admin/assistantprofile_change_list.html"
2024
- change_actions = ["my_profile_action"]
2025
- changelist_actions = ["my_profile"]
2026
- fieldsets = (
2027
- ("Owner", {"fields": ("user", "group")}),
2028
- ("Credentials", {"fields": ("user_key_hash",)}),
2029
- (
2030
- "Configuration",
2031
- {"fields": ("scopes", "is_active", "created_at", "last_used_at")},
2032
- ),
2033
- )
2034
-
2035
- def owner(self, obj):
2036
- return obj.owner_display()
2037
-
2038
- owner.short_description = "Owner"
2039
-
2040
- def get_urls(self):
2041
- urls = super().get_urls()
2042
- opts = self.model._meta
2043
- app_label = opts.app_label
2044
- model_name = opts.model_name
2045
- custom = [
2046
- path(
2047
- "<path:object_id>/generate-key/",
2048
- self.admin_site.admin_view(self.generate_key),
2049
- name=f"{app_label}_{model_name}_generate_key",
2050
- ),
2051
- path(
2052
- "server/start/",
2053
- self.admin_site.admin_view(self.start_server),
2054
- name=f"{app_label}_{model_name}_start_server",
2055
- ),
2056
- path(
2057
- "server/stop/",
2058
- self.admin_site.admin_view(self.stop_server),
2059
- name=f"{app_label}_{model_name}_stop_server",
2060
- ),
2061
- path(
2062
- "server/status/",
2063
- self.admin_site.admin_view(self.server_status),
2064
- name=f"{app_label}_{model_name}_status",
2065
- ),
2066
- ]
2067
- return custom + urls
2068
-
2069
- def changelist_view(self, request, extra_context=None):
2070
- extra_context = extra_context or {}
2071
- status = mcp_process.get_status()
2072
- opts = self.model._meta
2073
- app_label = opts.app_label
2074
- model_name = opts.model_name
2075
- extra_context.update(
2076
- {
2077
- "mcp_status": status,
2078
- "mcp_server_actions": {
2079
- "start": reverse(f"admin:{app_label}_{model_name}_start_server"),
2080
- "stop": reverse(f"admin:{app_label}_{model_name}_stop_server"),
2081
- "status": reverse(f"admin:{app_label}_{model_name}_status"),
2082
- },
2083
- }
2084
- )
2085
- return super().changelist_view(request, extra_context=extra_context)
2086
-
2087
- def _redirect_to_changelist(self):
2088
- opts = self.model._meta
2089
- return HttpResponseRedirect(
2090
- reverse(f"admin:{opts.app_label}_{opts.model_name}_changelist")
2091
- )
2092
-
2093
- def generate_key(self, request, object_id, *args, **kwargs):
2094
- profile = self.get_object(request, object_id)
2095
- if profile is None:
2096
- return HttpResponseRedirect("../")
2097
- if profile.user is None:
2098
- self.message_user(
2099
- request,
2100
- "Assign a user before generating a key.",
2101
- level=messages.ERROR,
2102
- )
2103
- return HttpResponseRedirect("../")
2104
- profile, key = AssistantProfile.issue_key(profile.user)
2105
- context = {
2106
- **self.admin_site.each_context(request),
2107
- "opts": self.model._meta,
2108
- "original": profile,
2109
- "user_key": key,
2110
- }
2111
- return TemplateResponse(request, "admin/assistantprofile_key.html", context)
2112
-
2113
- def render_change_form(
2114
- self, request, context, add=False, change=False, form_url="", obj=None
2115
- ):
2116
- response = super().render_change_form(
2117
- request, context, add=add, change=change, form_url=form_url, obj=obj
2118
- )
2119
- config = dict(getattr(settings, "MCP_SIGIL_SERVER", {}))
2120
- host = config.get("host") or "127.0.0.1"
2121
- port = config.get("port", 8800)
2122
- base_url, issuer_url = resolve_base_urls(config)
2123
- mount_path = config.get("mount_path") or "/"
2124
- display_base_url = base_url or f"http://{host}:{port}"
2125
- display_issuer_url = issuer_url or display_base_url
2126
- chat_endpoint = f"{display_base_url.rstrip('/')}/api/chat/"
2127
- if isinstance(response, dict):
2128
- response.setdefault("mcp_server_host", host)
2129
- response.setdefault("mcp_server_port", port)
2130
- response.setdefault("mcp_server_base_url", display_base_url)
2131
- response.setdefault("mcp_server_issuer_url", display_issuer_url)
2132
- response.setdefault("mcp_server_mount_path", mount_path)
2133
- response.setdefault("mcp_server_chat_endpoint", chat_endpoint)
2134
- else:
2135
- context_data = getattr(response, "context_data", None)
2136
- if context_data is not None:
2137
- context_data.setdefault("mcp_server_host", host)
2138
- context_data.setdefault("mcp_server_port", port)
2139
- context_data.setdefault("mcp_server_base_url", display_base_url)
2140
- context_data.setdefault("mcp_server_issuer_url", display_issuer_url)
2141
- context_data.setdefault("mcp_server_mount_path", mount_path)
2142
- context_data.setdefault("mcp_server_chat_endpoint", chat_endpoint)
2143
- return response
2144
-
2145
- def start_server(self, request):
2146
- try:
2147
- pid = mcp_process.start_server()
2148
- except mcp_process.ServerAlreadyRunningError as exc:
2149
- self.message_user(request, str(exc), level=messages.WARNING)
2150
- except mcp_process.ServerStartError as exc:
2151
- self.message_user(request, str(exc), level=messages.ERROR)
2152
- else:
2153
- self.message_user(
2154
- request,
2155
- f"Started MCP server (PID {pid}).",
2156
- level=messages.SUCCESS,
2157
- )
2158
- return self._redirect_to_changelist()
2159
-
2160
- def stop_server(self, request):
2161
- try:
2162
- pid = mcp_process.stop_server()
2163
- except mcp_process.ServerNotRunningError as exc:
2164
- self.message_user(request, str(exc), level=messages.WARNING)
2165
- except mcp_process.ServerStopError as exc:
2166
- self.message_user(request, str(exc), level=messages.ERROR)
2167
- else:
2168
- self.message_user(
2169
- request,
2170
- f"Stopped MCP server (PID {pid}).",
2171
- level=messages.SUCCESS,
2172
- )
2173
- return self._redirect_to_changelist()
2174
-
2175
- def server_status(self, request):
2176
- status = mcp_process.get_status()
2177
- if status["running"]:
2178
- msg = f"MCP server is running (PID {status['pid']})."
2179
- level = messages.INFO
2180
- else:
2181
- msg = "MCP server is not running."
2182
- level = messages.WARNING
2183
- if status.get("last_error"):
2184
- msg = f"{msg} {status['last_error']}"
2185
- self.message_user(request, msg, level=level)
2186
- return self._redirect_to_changelist()
2187
-
2188
-
2189
2296
  class EnergyCreditInline(admin.TabularInline):
2190
2297
  model = EnergyCredit
2191
2298
  fields = ("amount_kw", "created_by", "created_on")
@@ -2390,199 +2497,15 @@ class ProductAdminForm(forms.ModelForm):
2390
2497
  widgets = {"odoo_product": OdooProductWidget}
2391
2498
 
2392
2499
 
2393
- class ProductFetchWizardForm(forms.Form):
2394
- name = forms.CharField(label="Name", required=False)
2395
- default_code = forms.CharField(label="Internal reference", required=False)
2396
- barcode = forms.CharField(label="Barcode", required=False)
2397
- renewal_period = forms.IntegerField(
2398
- label="Renewal period (days)", min_value=1, initial=30
2399
- )
2400
-
2401
- def __init__(self, *args, require_search_terms=True, **kwargs):
2402
- self.require_search_terms = require_search_terms
2403
- super().__init__(*args, **kwargs)
2404
-
2405
- def clean(self):
2406
- cleaned = super().clean()
2407
- if self.require_search_terms:
2408
- if not any(
2409
- cleaned.get(field) for field in ("name", "default_code", "barcode")
2410
- ):
2411
- raise forms.ValidationError(
2412
- _("Enter at least one field to search for a product.")
2413
- )
2414
- return cleaned
2415
-
2416
- def build_domain(self):
2417
- domain = []
2418
- if self.cleaned_data.get("name"):
2419
- domain.append(("name", "ilike", self.cleaned_data["name"]))
2420
- if self.cleaned_data.get("default_code"):
2421
- domain.append(("default_code", "ilike", self.cleaned_data["default_code"]))
2422
- if self.cleaned_data.get("barcode"):
2423
- domain.append(("barcode", "ilike", self.cleaned_data["barcode"]))
2424
- return domain
2425
-
2426
-
2427
2500
  @admin.register(Product)
2428
2501
  class ProductAdmin(EntityModelAdmin):
2429
2502
  form = ProductAdminForm
2430
- actions = ["fetch_odoo_product", "register_from_odoo"]
2503
+ actions = ["register_from_odoo"]
2431
2504
  change_list_template = "admin/core/product/change_list.html"
2432
2505
 
2433
2506
  def _odoo_profile_admin(self):
2434
2507
  return self.admin_site._registry.get(OdooProfile)
2435
2508
 
2436
- def _search_odoo_products(self, profile, form):
2437
- domain = form.build_domain()
2438
- return profile.execute(
2439
- "product.product",
2440
- "search_read",
2441
- [domain],
2442
- fields=[
2443
- "name",
2444
- "default_code",
2445
- "barcode",
2446
- "description_sale",
2447
- ],
2448
- limit=50,
2449
- )
2450
-
2451
- @admin.action(description="Fetch Odoo Product")
2452
- def fetch_odoo_product(self, request, queryset):
2453
- profile = getattr(request.user, "odoo_profile", None)
2454
- has_credentials = bool(profile and profile.is_verified)
2455
- profile_admin = self._odoo_profile_admin()
2456
- profile_url = None
2457
- if profile_admin is not None:
2458
- profile_url = profile_admin.get_my_profile_url(request)
2459
-
2460
- context = {
2461
- "opts": self.model._meta,
2462
- "queryset": queryset,
2463
- "action": "fetch_odoo_product",
2464
- "has_credentials": has_credentials,
2465
- "profile_url": profile_url,
2466
- }
2467
-
2468
- if not has_credentials:
2469
- context["credential_error"] = _(
2470
- "Configure your Odoo employee credentials before fetching products."
2471
- )
2472
- return TemplateResponse(
2473
- request, "admin/core/product/fetch_odoo.html", context
2474
- )
2475
-
2476
- is_import = "import" in request.POST
2477
- form_kwargs = {"require_search_terms": not is_import}
2478
- if request.method == "POST":
2479
- form = ProductFetchWizardForm(request.POST, **form_kwargs)
2480
- else:
2481
- form = ProductFetchWizardForm()
2482
-
2483
- results = None
2484
- selected_product_id = request.POST.get("product_id", "")
2485
-
2486
- if request.method == "POST" and form.is_valid():
2487
- try:
2488
- results = self._search_odoo_products(profile, form)
2489
- except Exception:
2490
- logger.exception(
2491
- "Failed to fetch Odoo products for user %s (profile_id=%s, host=%s, database=%s)",
2492
- getattr(getattr(request, "user", None), "pk", None),
2493
- getattr(profile, "pk", None),
2494
- getattr(profile, "host", None),
2495
- getattr(profile, "database", None),
2496
- )
2497
- form.add_error(None, _("Unable to fetch products from Odoo."))
2498
- results = []
2499
- else:
2500
- if is_import:
2501
- if not self.has_add_permission(request):
2502
- form.add_error(
2503
- None, _("You do not have permission to add products.")
2504
- )
2505
- else:
2506
- product_id = request.POST.get("product_id")
2507
- if not product_id:
2508
- form.add_error(None, _("Select a product to import."))
2509
- else:
2510
- try:
2511
- odoo_id = int(product_id)
2512
- except (TypeError, ValueError):
2513
- form.add_error(None, _("Invalid product selection."))
2514
- else:
2515
- match = next(
2516
- (item for item in results if item.get("id") == odoo_id),
2517
- None,
2518
- )
2519
- if not match:
2520
- form.add_error(
2521
- None,
2522
- _(
2523
- "The selected product was not found. Run the search again."
2524
- ),
2525
- )
2526
- else:
2527
- existing = self.model.objects.filter(
2528
- odoo_product__id=odoo_id
2529
- ).first()
2530
- if existing:
2531
- self.message_user(
2532
- request,
2533
- _(
2534
- "Product %(name)s already imported; opening existing record."
2535
- )
2536
- % {"name": existing.name},
2537
- level=messages.WARNING,
2538
- )
2539
- return HttpResponseRedirect(
2540
- reverse(
2541
- "admin:%s_%s_change"
2542
- % (
2543
- existing._meta.app_label,
2544
- existing._meta.model_name,
2545
- ),
2546
- args=[existing.pk],
2547
- )
2548
- )
2549
- product = self.model.objects.create(
2550
- name=match.get("name") or f"Odoo Product {odoo_id}",
2551
- description=match.get("description_sale", "") or "",
2552
- renewal_period=form.cleaned_data["renewal_period"],
2553
- odoo_product={
2554
- "id": odoo_id,
2555
- "name": match.get("name", ""),
2556
- },
2557
- )
2558
- self.log_addition(
2559
- request, product, "Imported product from Odoo"
2560
- )
2561
- self.message_user(
2562
- request,
2563
- _("Imported %(name)s from Odoo.")
2564
- % {"name": product.name},
2565
- )
2566
- return HttpResponseRedirect(
2567
- reverse(
2568
- "admin:%s_%s_change"
2569
- % (
2570
- product._meta.app_label,
2571
- product._meta.model_name,
2572
- ),
2573
- args=[product.pk],
2574
- )
2575
- )
2576
- context.update(
2577
- {
2578
- "form": form,
2579
- "results": results,
2580
- "selected_product_id": selected_product_id,
2581
- }
2582
- )
2583
- context["media"] = self.media + form.media
2584
- return TemplateResponse(request, "admin/core/product/fetch_odoo.html", context)
2585
-
2586
2509
  def get_urls(self):
2587
2510
  urls = super().get_urls()
2588
2511
  custom = [
@@ -2623,7 +2546,7 @@ class ProductAdmin(EntityModelAdmin):
2623
2546
  profile = getattr(request.user, "odoo_profile", None)
2624
2547
  if not profile or not profile.is_verified:
2625
2548
  context["credential_error"] = _(
2626
- "Configure your Odoo employee credentials before registering products."
2549
+ "Configure your CRM employee credentials before registering products."
2627
2550
  )
2628
2551
  return context, None
2629
2552
 
@@ -2631,7 +2554,6 @@ class ProductAdmin(EntityModelAdmin):
2631
2554
  products = profile.execute(
2632
2555
  "product.product",
2633
2556
  "search_read",
2634
- [[]],
2635
2557
  fields=[
2636
2558
  "name",
2637
2559
  "description_sale",
@@ -2640,8 +2562,30 @@ class ProductAdmin(EntityModelAdmin):
2640
2562
  ],
2641
2563
  limit=0,
2642
2564
  )
2643
- except Exception:
2565
+ except Exception as exc:
2566
+ logger.exception(
2567
+ "Failed to fetch Odoo products for user %s (profile_id=%s, host=%s, database=%s)",
2568
+ getattr(getattr(request, "user", None), "pk", None),
2569
+ getattr(profile, "pk", None),
2570
+ getattr(profile, "host", None),
2571
+ getattr(profile, "database", None),
2572
+ )
2644
2573
  context["error"] = _("Unable to fetch products from Odoo.")
2574
+ if getattr(request.user, "is_superuser", False):
2575
+ fault = getattr(exc, "faultString", "")
2576
+ message = str(exc)
2577
+ details = [
2578
+ f"Host: {getattr(profile, 'host', '')}",
2579
+ f"Database: {getattr(profile, 'database', '')}",
2580
+ f"User ID: {getattr(profile, 'odoo_uid', '')}",
2581
+ ]
2582
+ if fault and fault != message:
2583
+ details.append(f"Fault: {fault}")
2584
+ if message:
2585
+ details.append(f"Exception: {type(exc).__name__}: {message}")
2586
+ else:
2587
+ details.append(f"Exception type: {type(exc).__name__}")
2588
+ context["debug_error"] = "\n".join(details)
2645
2589
  return context, []
2646
2590
 
2647
2591
  context["has_credentials"] = True
@@ -2810,6 +2754,12 @@ class RFIDResource(resources.ModelResource):
2810
2754
  account_column = account_column_for_field(account_field)
2811
2755
  self.fields["energy_accounts"].column_name = account_column
2812
2756
 
2757
+ def get_queryset(self):
2758
+ manager = getattr(self._meta.model, "all_objects", None)
2759
+ if manager is not None:
2760
+ return manager.all()
2761
+ return super().get_queryset()
2762
+
2813
2763
  def dehydrate_energy_accounts(self, obj):
2814
2764
  return serialize_accounts(obj, self.account_field)
2815
2765
 
@@ -2823,6 +2773,11 @@ class RFIDResource(resources.ModelResource):
2823
2773
  else:
2824
2774
  instance.energy_accounts.clear()
2825
2775
 
2776
+ def before_save_instance(self, instance, row, **kwargs):
2777
+ if getattr(instance, "is_deleted", False):
2778
+ instance.is_deleted = False
2779
+ super().before_save_instance(instance, row, **kwargs)
2780
+
2826
2781
  class Meta:
2827
2782
  model = RFID
2828
2783
  fields = (
@@ -2835,6 +2790,7 @@ class RFIDResource(resources.ModelResource):
2835
2790
  "post_auth_command",
2836
2791
  "allowed",
2837
2792
  "color",
2793
+ "endianness",
2838
2794
  "kind",
2839
2795
  "released",
2840
2796
  "last_seen_on",
@@ -2849,6 +2805,7 @@ class RFIDResource(resources.ModelResource):
2849
2805
  "post_auth_command",
2850
2806
  "allowed",
2851
2807
  "color",
2808
+ "endianness",
2852
2809
  "kind",
2853
2810
  "released",
2854
2811
  "last_seen_on",
@@ -2899,7 +2856,7 @@ class CopyRFIDForm(forms.Form):
2899
2856
  normalized = (cleaned or "").strip().upper()
2900
2857
  if not normalized:
2901
2858
  raise forms.ValidationError(_("RFID value is required."))
2902
- if RFID.objects.filter(rfid=normalized).exists():
2859
+ if RFID.matching_queryset(normalized).exists():
2903
2860
  raise forms.ValidationError(
2904
2861
  _("An RFID with this value already exists.")
2905
2862
  )
@@ -2919,11 +2876,12 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
2919
2876
  "user_data_flag",
2920
2877
  "color",
2921
2878
  "kind",
2879
+ "endianness_short",
2922
2880
  "released",
2923
2881
  "allowed",
2924
2882
  "last_seen_on",
2925
2883
  )
2926
- list_filter = ("color", "released", "allowed")
2884
+ list_filter = ("color", "endianness", "released", "allowed")
2927
2885
  search_fields = ("label_id", "rfid", "custom_label")
2928
2886
  autocomplete_fields = ["energy_accounts"]
2929
2887
  raw_id_fields = ["reference"]
@@ -2932,9 +2890,12 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
2932
2890
  "print_card_labels",
2933
2891
  "print_release_form",
2934
2892
  "copy_rfids",
2893
+ "merge_rfids",
2935
2894
  "toggle_selected_user_data",
2895
+ "toggle_selected_released",
2896
+ "toggle_selected_allowed",
2936
2897
  ]
2937
- readonly_fields = ("added_on", "last_seen_on")
2898
+ readonly_fields = ("added_on", "last_seen_on", "reversed_uid")
2938
2899
  form = RFIDForm
2939
2900
 
2940
2901
  def get_import_resource_kwargs(self, request, form=None, **kwargs):
@@ -2980,6 +2941,14 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
2980
2941
  def user_data_flag(self, obj):
2981
2942
  return getattr(obj, "is_user_data", False)
2982
2943
 
2944
+ @admin.display(description=_("End"), ordering="endianness")
2945
+ def endianness_short(self, obj):
2946
+ labels = {
2947
+ RFID.BIG_ENDIAN: _("Big"),
2948
+ RFID.LITTLE_ENDIAN: _("Little"),
2949
+ }
2950
+ return labels.get(obj.endianness, obj.get_endianness_display())
2951
+
2983
2952
  def scan_rfids(self, request, queryset):
2984
2953
  return redirect("admin:core_rfid_scan")
2985
2954
 
@@ -3030,6 +2999,50 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
3030
2999
  level=messages.WARNING,
3031
3000
  )
3032
3001
 
3002
+ @admin.action(description=_("Toggle Released flag"))
3003
+ def toggle_selected_released(self, request, queryset):
3004
+ manager = getattr(self.model, "all_objects", self.model.objects)
3005
+ toggled = 0
3006
+ for tag in queryset:
3007
+ new_state = not tag.released
3008
+ manager.filter(pk=tag.pk).update(released=new_state)
3009
+ tag.released = new_state
3010
+ toggled += 1
3011
+
3012
+ if toggled:
3013
+ self.message_user(
3014
+ request,
3015
+ ngettext(
3016
+ "Toggled released flag for %(count)d RFID.",
3017
+ "Toggled released flag for %(count)d RFIDs.",
3018
+ toggled,
3019
+ )
3020
+ % {"count": toggled},
3021
+ level=messages.SUCCESS,
3022
+ )
3023
+
3024
+ @admin.action(description=_("Toggle Allowed flag"))
3025
+ def toggle_selected_allowed(self, request, queryset):
3026
+ manager = getattr(self.model, "all_objects", self.model.objects)
3027
+ toggled = 0
3028
+ for tag in queryset:
3029
+ new_state = not tag.allowed
3030
+ manager.filter(pk=tag.pk).update(allowed=new_state)
3031
+ tag.allowed = new_state
3032
+ toggled += 1
3033
+
3034
+ if toggled:
3035
+ self.message_user(
3036
+ request,
3037
+ ngettext(
3038
+ "Toggled allowed flag for %(count)d RFID.",
3039
+ "Toggled allowed flag for %(count)d RFIDs.",
3040
+ toggled,
3041
+ )
3042
+ % {"count": toggled},
3043
+ level=messages.SUCCESS,
3044
+ )
3045
+
3033
3046
  @admin.action(description=_("Copy RFID"))
3034
3047
  def copy_rfids(self, request, queryset):
3035
3048
  if queryset.count() != 1:
@@ -3120,6 +3133,145 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
3120
3133
  context["media"] = self.media + form.media
3121
3134
  return TemplateResponse(request, "admin/core/rfid/copy.html", context)
3122
3135
 
3136
+ @admin.action(description=_("Merge RFID cards"))
3137
+ def merge_rfids(self, request, queryset):
3138
+ tags = list(queryset.prefetch_related("energy_accounts"))
3139
+ if len(tags) < 2:
3140
+ self.message_user(
3141
+ request,
3142
+ _("Select at least two RFIDs to merge."),
3143
+ level=messages.WARNING,
3144
+ )
3145
+ return None
3146
+
3147
+ normalized_map: dict[int, str] = {}
3148
+ groups: defaultdict[str, list[RFID]] = defaultdict(list)
3149
+ unmatched = 0
3150
+ for tag in tags:
3151
+ normalized = RFID.normalize_code(tag.rfid)
3152
+ normalized_map[tag.pk] = normalized
3153
+ if not normalized:
3154
+ unmatched += 1
3155
+ continue
3156
+ prefix = normalized[: RFID.MATCH_PREFIX_LENGTH]
3157
+ groups[prefix].append(tag)
3158
+
3159
+ merge_groups: list[list[RFID]] = []
3160
+ skipped = unmatched
3161
+ for prefix, group in groups.items():
3162
+ if len(group) < 2:
3163
+ skipped += len(group)
3164
+ continue
3165
+ group.sort(
3166
+ key=lambda item: (
3167
+ len(normalized_map.get(item.pk, "")),
3168
+ normalized_map.get(item.pk, ""),
3169
+ item.pk,
3170
+ )
3171
+ )
3172
+ merge_groups.append(group)
3173
+
3174
+ if not merge_groups:
3175
+ self.message_user(
3176
+ request,
3177
+ _("No matching RFIDs were found to merge."),
3178
+ level=messages.WARNING,
3179
+ )
3180
+ return None
3181
+
3182
+ merged_tags = 0
3183
+ merged_groups = 0
3184
+ conflicting_accounts = 0
3185
+ with transaction.atomic():
3186
+ for group in merge_groups:
3187
+ canonical = group[0]
3188
+ update_fields: set[str] = set()
3189
+ existing_account_ids = set(
3190
+ canonical.energy_accounts.values_list("pk", flat=True)
3191
+ )
3192
+ for tag in group[1:]:
3193
+ other_value = normalized_map.get(tag.pk, "")
3194
+ if canonical.adopt_rfid(other_value):
3195
+ update_fields.add("rfid")
3196
+ normalized_map[canonical.pk] = RFID.normalize_code(
3197
+ canonical.rfid
3198
+ )
3199
+ accounts = list(tag.energy_accounts.all())
3200
+ if accounts:
3201
+ transferable: list[EnergyAccount] = []
3202
+ for account in accounts:
3203
+ if existing_account_ids and account.pk not in existing_account_ids:
3204
+ conflicting_accounts += 1
3205
+ continue
3206
+ transferable.append(account)
3207
+ if transferable:
3208
+ canonical.energy_accounts.add(*transferable)
3209
+ existing_account_ids.update(
3210
+ account.pk for account in transferable
3211
+ )
3212
+ if tag.allowed and not canonical.allowed:
3213
+ canonical.allowed = True
3214
+ update_fields.add("allowed")
3215
+ if tag.released and not canonical.released:
3216
+ canonical.released = True
3217
+ update_fields.add("released")
3218
+ if tag.key_a_verified and not canonical.key_a_verified:
3219
+ canonical.key_a_verified = True
3220
+ update_fields.add("key_a_verified")
3221
+ if tag.key_b_verified and not canonical.key_b_verified:
3222
+ canonical.key_b_verified = True
3223
+ update_fields.add("key_b_verified")
3224
+ if tag.last_seen_on and (
3225
+ not canonical.last_seen_on
3226
+ or tag.last_seen_on > canonical.last_seen_on
3227
+ ):
3228
+ canonical.last_seen_on = tag.last_seen_on
3229
+ update_fields.add("last_seen_on")
3230
+ if not canonical.origin_node and tag.origin_node_id:
3231
+ canonical.origin_node = tag.origin_node
3232
+ update_fields.add("origin_node")
3233
+ merged_tags += 1
3234
+ tag.delete()
3235
+ if update_fields:
3236
+ canonical.save(update_fields=sorted(update_fields))
3237
+ merged_groups += 1
3238
+
3239
+ if merged_tags:
3240
+ self.message_user(
3241
+ request,
3242
+ ngettext(
3243
+ "Merged %(removed)d RFID into %(groups)d canonical record.",
3244
+ "Merged %(removed)d RFIDs into %(groups)d canonical records.",
3245
+ merged_tags,
3246
+ )
3247
+ % {"removed": merged_tags, "groups": merged_groups},
3248
+ level=messages.SUCCESS,
3249
+ )
3250
+
3251
+ if skipped:
3252
+ self.message_user(
3253
+ request,
3254
+ ngettext(
3255
+ "Skipped %(count)d RFID because it did not share the first %(length)d characters with another selection.",
3256
+ "Skipped %(count)d RFIDs because they did not share the first %(length)d characters with another selection.",
3257
+ skipped,
3258
+ )
3259
+ % {"count": skipped, "length": RFID.MATCH_PREFIX_LENGTH},
3260
+ level=messages.WARNING,
3261
+ )
3262
+
3263
+ if conflicting_accounts:
3264
+ self.message_user(
3265
+ request,
3266
+ ngettext(
3267
+ "Skipped %(count)d energy account because the RFID was already linked to a different account.",
3268
+ "Skipped %(count)d energy accounts because the RFID was already linked to a different account.",
3269
+ conflicting_accounts,
3270
+ )
3271
+ % {"count": conflicting_accounts},
3272
+ level=messages.WARNING,
3273
+ )
3274
+
3123
3275
  def _render_card_labels(
3124
3276
  self,
3125
3277
  request,
@@ -3492,11 +3644,13 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
3492
3644
  "toggle_url": toggle_url,
3493
3645
  "toggle_label": toggle_label,
3494
3646
  "public_view_url": public_view_url,
3647
+ "deep_read_url": reverse("rfid-scan-deep"),
3495
3648
  }
3496
3649
  )
3497
3650
  context["title"] = _("Scan RFIDs")
3498
3651
  context["opts"] = self.model._meta
3499
3652
  context["show_release_info"] = True
3653
+ context["default_endianness"] = RFID.BIG_ENDIAN
3500
3654
  return render(request, "admin/core/rfid/scan.html", context)
3501
3655
 
3502
3656
  def scan_next(self, request):
@@ -3510,20 +3664,71 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
3510
3664
  return JsonResponse({"error": "Invalid JSON payload"}, status=400)
3511
3665
  rfid = payload.get("rfid") or payload.get("value")
3512
3666
  kind = payload.get("kind")
3513
- result = validate_rfid_value(rfid, kind=kind)
3667
+ endianness = payload.get("endianness")
3668
+ result = validate_rfid_value(rfid, kind=kind, endianness=endianness)
3514
3669
  else:
3515
- result = scan_sources(request)
3670
+ endianness = request.GET.get("endianness")
3671
+ result = scan_sources(request, endianness=endianness)
3516
3672
  status = 500 if result.get("error") else 200
3517
3673
  return JsonResponse(result, status=status)
3518
3674
 
3519
3675
 
3676
+ class ClientReportRecurrencyFilter(admin.SimpleListFilter):
3677
+ title = "Recurrency"
3678
+ parameter_name = "recurrency"
3679
+
3680
+ def lookups(self, request, model_admin):
3681
+ for value, label in ClientReportSchedule.PERIODICITY_CHOICES:
3682
+ yield (value, label)
3683
+
3684
+ def queryset(self, request, queryset):
3685
+ value = self.value()
3686
+ if not value:
3687
+ return queryset
3688
+ if value == ClientReportSchedule.PERIODICITY_NONE:
3689
+ return queryset.filter(
3690
+ Q(schedule__isnull=True) | Q(schedule__periodicity=value)
3691
+ )
3692
+ return queryset.filter(schedule__periodicity=value)
3693
+
3694
+
3520
3695
  @admin.register(ClientReport)
3521
3696
  class ClientReportAdmin(EntityModelAdmin):
3522
- list_display = ("created_on", "start_date", "end_date")
3697
+ list_display = (
3698
+ "created_on",
3699
+ "period_range",
3700
+ "owner",
3701
+ "recurrency_display",
3702
+ "total_kw_period_display",
3703
+ "download_link",
3704
+ )
3705
+ list_select_related = ("schedule", "owner")
3706
+ list_filter = ("owner", ClientReportRecurrencyFilter)
3523
3707
  readonly_fields = ("created_on", "data")
3524
3708
 
3525
3709
  change_list_template = "admin/core/clientreport/change_list.html"
3526
3710
 
3711
+ def period_range(self, obj):
3712
+ return str(obj)
3713
+
3714
+ period_range.short_description = "Period"
3715
+
3716
+ def recurrency_display(self, obj):
3717
+ return obj.periodicity_label
3718
+
3719
+ recurrency_display.short_description = "Recurrency"
3720
+
3721
+ def total_kw_period_display(self, obj):
3722
+ return f"{obj.total_kw_period:.2f}"
3723
+
3724
+ total_kw_period_display.short_description = "Total kW (period)"
3725
+
3726
+ def download_link(self, obj):
3727
+ url = reverse("admin:core_clientreport_download", args=[obj.pk])
3728
+ return format_html('<a href="{}">Download</a>', url)
3729
+
3730
+ download_link.short_description = "Download"
3731
+
3527
3732
  class ClientReportForm(forms.Form):
3528
3733
  PERIOD_CHOICES = [
3529
3734
  ("range", "Date range"),
@@ -3559,8 +3764,28 @@ class ClientReportAdmin(EntityModelAdmin):
3559
3764
  label="Month",
3560
3765
  required=False,
3561
3766
  widget=forms.DateInput(attrs={"type": "month"}),
3767
+ input_formats=["%Y-%m"],
3562
3768
  help_text="Generates the report for the calendar month that you select.",
3563
3769
  )
3770
+ language = forms.ChoiceField(
3771
+ label="Report language",
3772
+ choices=settings.LANGUAGES,
3773
+ help_text="Choose the language used for the generated report.",
3774
+ )
3775
+ title = forms.CharField(
3776
+ label="Report title",
3777
+ required=False,
3778
+ max_length=200,
3779
+ help_text="Optional heading that replaces the default report title.",
3780
+ )
3781
+ chargers = forms.ModelMultipleChoiceField(
3782
+ label="Charge points",
3783
+ queryset=Charger.objects.filter(connector_id__isnull=True)
3784
+ .order_by("display_name", "charger_id"),
3785
+ required=False,
3786
+ widget=forms.CheckboxSelectMultiple,
3787
+ help_text="Choose which charge points are included in the report.",
3788
+ )
3564
3789
  owner = forms.ModelChoiceField(
3565
3790
  queryset=get_user_model().objects.all(),
3566
3791
  required=False,
@@ -3578,10 +3803,10 @@ class ClientReportAdmin(EntityModelAdmin):
3578
3803
  initial=ClientReportSchedule.PERIODICITY_NONE,
3579
3804
  help_text="Defines how often the report should be generated automatically.",
3580
3805
  )
3581
- disable_emails = forms.BooleanField(
3582
- label="Disable email delivery",
3806
+ enable_emails = forms.BooleanField(
3807
+ label="Enable email delivery",
3583
3808
  required=False,
3584
- help_text="Generate files without sending emails.",
3809
+ help_text="Send the report via email to the recipients listed above.",
3585
3810
  )
3586
3811
 
3587
3812
  def __init__(self, *args, request=None, **kwargs):
@@ -3593,6 +3818,13 @@ class ClientReportAdmin(EntityModelAdmin):
3593
3818
  and request.user.is_authenticated
3594
3819
  ):
3595
3820
  self.fields["owner"].initial = request.user.pk
3821
+ self.fields["chargers"].widget.attrs["class"] = "charger-options"
3822
+ language_initial = ClientReport.default_language()
3823
+ if request:
3824
+ language_initial = ClientReport.normalize_language(
3825
+ getattr(request, "LANGUAGE_CODE", language_initial)
3826
+ )
3827
+ self.fields["language"].initial = language_initial
3596
3828
 
3597
3829
  def clean(self):
3598
3830
  cleaned = super().clean()
@@ -3637,6 +3869,10 @@ class ClientReportAdmin(EntityModelAdmin):
3637
3869
  emails.append(candidate)
3638
3870
  return emails
3639
3871
 
3872
+ def clean_title(self):
3873
+ title = self.cleaned_data.get("title")
3874
+ return ClientReport.normalize_title(title)
3875
+
3640
3876
  def get_urls(self):
3641
3877
  urls = super().get_urls()
3642
3878
  custom = [
@@ -3645,6 +3881,16 @@ class ClientReportAdmin(EntityModelAdmin):
3645
3881
  self.admin_site.admin_view(self.generate_view),
3646
3882
  name="core_clientreport_generate",
3647
3883
  ),
3884
+ path(
3885
+ "generate/action/",
3886
+ self.admin_site.admin_view(self.generate_report),
3887
+ name="core_clientreport_generate_report",
3888
+ ),
3889
+ path(
3890
+ "download/<int:report_id>/",
3891
+ self.admin_site.admin_view(self.download_view),
3892
+ name="core_clientreport_download",
3893
+ ),
3648
3894
  ]
3649
3895
  return custom + urls
3650
3896
 
@@ -3652,40 +3898,127 @@ class ClientReportAdmin(EntityModelAdmin):
3652
3898
  form = self.ClientReportForm(request.POST or None, request=request)
3653
3899
  report = None
3654
3900
  schedule = None
3901
+ download_url = None
3655
3902
  if request.method == "POST" and form.is_valid():
3656
3903
  owner = form.cleaned_data.get("owner")
3657
3904
  if not owner and request.user.is_authenticated:
3658
3905
  owner = request.user
3906
+ enable_emails = form.cleaned_data.get("enable_emails", False)
3907
+ disable_emails = not enable_emails
3908
+ recipients = form.cleaned_data.get("destinations") if enable_emails else []
3909
+ chargers = list(form.cleaned_data.get("chargers") or [])
3910
+ language = form.cleaned_data.get("language")
3911
+ title = form.cleaned_data.get("title")
3659
3912
  report = ClientReport.generate(
3660
3913
  form.cleaned_data["start"],
3661
3914
  form.cleaned_data["end"],
3662
3915
  owner=owner,
3663
- recipients=form.cleaned_data.get("destinations"),
3664
- disable_emails=form.cleaned_data.get("disable_emails", False),
3916
+ recipients=recipients,
3917
+ disable_emails=disable_emails,
3918
+ chargers=chargers,
3919
+ language=language,
3920
+ title=title,
3665
3921
  )
3666
3922
  report.store_local_copy()
3923
+ if chargers:
3924
+ report.chargers.set(chargers)
3925
+ if enable_emails and recipients:
3926
+ delivered = report.send_delivery(
3927
+ to=recipients,
3928
+ cc=[],
3929
+ outbox=ClientReport.resolve_outbox_for_owner(owner),
3930
+ reply_to=ClientReport.resolve_reply_to_for_owner(owner),
3931
+ )
3932
+ if delivered:
3933
+ report.recipients = delivered
3934
+ report.save(update_fields=["recipients"])
3935
+ self.message_user(
3936
+ request,
3937
+ "Consumer report emailed to the selected recipients.",
3938
+ messages.SUCCESS,
3939
+ )
3667
3940
  recurrence = form.cleaned_data.get("recurrence")
3668
3941
  if recurrence and recurrence != ClientReportSchedule.PERIODICITY_NONE:
3669
3942
  schedule = ClientReportSchedule.objects.create(
3670
3943
  owner=owner,
3671
3944
  created_by=request.user if request.user.is_authenticated else None,
3672
3945
  periodicity=recurrence,
3673
- email_recipients=form.cleaned_data.get("destinations", []),
3674
- disable_emails=form.cleaned_data.get("disable_emails", False),
3946
+ email_recipients=recipients,
3947
+ disable_emails=disable_emails,
3948
+ language=language,
3949
+ title=title,
3675
3950
  )
3951
+ if chargers:
3952
+ schedule.chargers.set(chargers)
3676
3953
  report.schedule = schedule
3677
3954
  report.save(update_fields=["schedule"])
3678
3955
  self.message_user(
3679
3956
  request,
3680
- "Client report schedule created; future reports will be generated automatically.",
3957
+ "Consumer report schedule created; future reports will be generated automatically.",
3681
3958
  messages.SUCCESS,
3682
3959
  )
3960
+ if disable_emails:
3961
+ self.message_user(
3962
+ request,
3963
+ "Consumer report generated. The download will begin automatically.",
3964
+ messages.SUCCESS,
3965
+ )
3966
+ redirect_url = f"{reverse('admin:core_clientreport_generate')}?download={report.pk}"
3967
+ return HttpResponseRedirect(redirect_url)
3968
+ download_param = request.GET.get("download")
3969
+ if download_param:
3970
+ try:
3971
+ download_report = ClientReport.objects.get(pk=download_param)
3972
+ except ClientReport.DoesNotExist:
3973
+ pass
3974
+ else:
3975
+ download_url = reverse(
3976
+ "admin:core_clientreport_download", args=[download_report.pk]
3977
+ )
3683
3978
  context = self.admin_site.each_context(request)
3684
- context.update({"form": form, "report": report, "schedule": schedule})
3979
+ context.update(
3980
+ {
3981
+ "form": form,
3982
+ "report": report,
3983
+ "schedule": schedule,
3984
+ "download_url": download_url,
3985
+ "opts": self.model._meta,
3986
+ }
3987
+ )
3685
3988
  return TemplateResponse(
3686
3989
  request, "admin/core/clientreport/generate.html", context
3687
3990
  )
3688
3991
 
3992
+ def get_changelist_actions(self, request):
3993
+ parent = getattr(super(), "get_changelist_actions", None)
3994
+ actions: list[str] = []
3995
+ if callable(parent):
3996
+ parent_actions = parent(request)
3997
+ if parent_actions:
3998
+ actions.extend(parent_actions)
3999
+ if "generate_report" not in actions:
4000
+ actions.append("generate_report")
4001
+ return actions
4002
+
4003
+ def generate_report(self, request):
4004
+ return HttpResponseRedirect(reverse("admin:core_clientreport_generate"))
4005
+
4006
+ generate_report.label = _("Generate report")
4007
+
4008
+ def download_view(self, request, report_id: int):
4009
+ report = get_object_or_404(ClientReport, pk=report_id)
4010
+ pdf_path = report.ensure_pdf()
4011
+ if not pdf_path.exists():
4012
+ raise Http404("Report file unavailable")
4013
+ end_date = report.end_date
4014
+ if hasattr(end_date, "isoformat"):
4015
+ end_date_str = end_date.isoformat()
4016
+ else: # pragma: no cover - fallback for unexpected values
4017
+ end_date_str = str(end_date)
4018
+ filename = f"consumer-report-{end_date_str}.pdf"
4019
+ response = FileResponse(pdf_path.open("rb"), content_type="application/pdf")
4020
+ response["Content-Disposition"] = f'attachment; filename="{filename}"'
4021
+ return response
3689
4022
 
3690
4023
  @admin.register(PackageRelease)
3691
4024
  class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
@@ -3693,6 +4026,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
3693
4026
  list_display = (
3694
4027
  "version",
3695
4028
  "package_link",
4029
+ "severity",
3696
4030
  "is_current",
3697
4031
  "pypi_url",
3698
4032
  "github_url",
@@ -3709,6 +4043,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
3709
4043
  "package",
3710
4044
  "release_manager",
3711
4045
  "version",
4046
+ "severity",
3712
4047
  "revision",
3713
4048
  "is_current",
3714
4049
  "pypi_url",
@@ -3746,9 +4081,9 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
3746
4081
  self.message_user(request, str(exc), messages.ERROR)
3747
4082
  return
3748
4083
  releases = resp.json().get("releases", {})
3749
- created = 0
3750
4084
  updated = 0
3751
4085
  restored = 0
4086
+ missing: list[str] = []
3752
4087
 
3753
4088
  for version, files in releases.items():
3754
4089
  release_on = self._release_on_from_files(files)
@@ -3773,23 +4108,11 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
3773
4108
  if update_fields:
3774
4109
  release.save(update_fields=update_fields)
3775
4110
  continue
3776
- PackageRelease.objects.create(
3777
- package=package,
3778
- release_manager=package.release_manager,
3779
- version=version,
3780
- revision="",
3781
- pypi_url=f"https://pypi.org/project/{package.name}/{version}/",
3782
- release_on=release_on,
3783
- )
3784
- created += 1
4111
+ missing.append(version)
3785
4112
 
3786
- if created or updated or restored:
4113
+ if updated or restored:
3787
4114
  PackageRelease.dump_fixture()
3788
4115
  message_parts = []
3789
- if created:
3790
- message_parts.append(
3791
- f"Created {created} release{'s' if created != 1 else ''} from PyPI"
3792
- )
3793
4116
  if updated:
3794
4117
  message_parts.append(
3795
4118
  f"Updated release date for {updated} release"
@@ -3800,8 +4123,17 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
3800
4123
  f"Restored {restored} release{'s' if restored != 1 else ''}"
3801
4124
  )
3802
4125
  self.message_user(request, "; ".join(message_parts), messages.SUCCESS)
3803
- else:
3804
- self.message_user(request, "No new releases found", messages.INFO)
4126
+ elif not missing:
4127
+ self.message_user(request, "No matching releases found", messages.INFO)
4128
+
4129
+ if missing:
4130
+ versions = ", ".join(sorted(missing))
4131
+ count = len(missing)
4132
+ message = (
4133
+ "Manual creation required for "
4134
+ f"{count} release{'s' if count != 1 else ''}: {versions}"
4135
+ )
4136
+ self.message_user(request, message, messages.WARNING)
3805
4137
 
3806
4138
  refresh_from_pypi.label = "Refresh from PyPI"
3807
4139
  refresh_from_pypi.short_description = "Refresh from PyPI"