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.
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/METADATA +95 -41
- arthexis-0.1.28.dist-info/RECORD +112 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +21 -30
- config/settings_helpers.py +176 -1
- config/urls.py +69 -1
- core/admin.py +805 -473
- core/apps.py +6 -8
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/celery_utils.py +73 -0
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1825 -218
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/release.py +55 -7
- core/sigil_builder.py +2 -2
- core/sigil_resolver.py +1 -66
- core/system.py +285 -4
- core/tasks.py +439 -138
- core/test_system_info.py +43 -5
- core/tests.py +516 -18
- core/user_data.py +94 -21
- core/views.py +348 -186
- nodes/admin.py +904 -67
- nodes/apps.py +12 -1
- nodes/feature_checks.py +30 -0
- nodes/models.py +800 -127
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +98 -3
- nodes/tests.py +1381 -152
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1382 -152
- ocpp/admin.py +1970 -152
- ocpp/consumers.py +839 -34
- ocpp/models.py +968 -17
- ocpp/network.py +398 -0
- ocpp/store.py +411 -43
- ocpp/tasks.py +261 -3
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +194 -6
- ocpp/tests.py +1918 -87
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +8 -3
- ocpp/views.py +700 -53
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +28 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +86 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +1050 -126
- pages/urls.py +14 -2
- pages/utils.py +70 -0
- pages/views.py +622 -56
- arthexis-0.1.16.dist-info/RECORD +0 -111
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
[
|
|
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
|
|
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
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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", "
|
|
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
|
-
(
|
|
1819
|
-
|
|
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
|
-
|
|
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 =
|
|
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 = ["
|
|
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
|
|
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.
|
|
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
|
-
|
|
3667
|
+
endianness = payload.get("endianness")
|
|
3668
|
+
result = validate_rfid_value(rfid, kind=kind, endianness=endianness)
|
|
3514
3669
|
else:
|
|
3515
|
-
|
|
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 = (
|
|
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
|
-
|
|
3582
|
-
label="
|
|
3806
|
+
enable_emails = forms.BooleanField(
|
|
3807
|
+
label="Enable email delivery",
|
|
3583
3808
|
required=False,
|
|
3584
|
-
help_text="
|
|
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=
|
|
3664
|
-
disable_emails=
|
|
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=
|
|
3674
|
-
disable_emails=
|
|
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
|
-
"
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3804
|
-
self.message_user(request, "No
|
|
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"
|