arthexis 0.1.16__py3-none-any.whl → 0.1.26__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.26.dist-info}/METADATA +84 -35
- arthexis-0.1.26.dist-info/RECORD +111 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +15 -30
- config/urls.py +53 -1
- core/admin.py +540 -450
- core/apps.py +0 -6
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1566 -203
- 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 +268 -2
- core/tasks.py +174 -48
- core/tests.py +314 -16
- core/user_data.py +42 -2
- core/views.py +278 -183
- nodes/admin.py +557 -65
- nodes/apps.py +11 -0
- nodes/models.py +658 -113
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +97 -2
- nodes/tests.py +1212 -116
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1239 -154
- ocpp/admin.py +979 -152
- ocpp/consumers.py +268 -28
- ocpp/models.py +488 -3
- ocpp/network.py +398 -0
- ocpp/store.py +6 -4
- ocpp/tasks.py +296 -2
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +121 -4
- ocpp/tests.py +950 -11
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +596 -51
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +26 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +77 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +885 -109
- pages/urls.py +13 -2
- pages/utils.py +70 -0
- pages/views.py +558 -55
- 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.26.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.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
|
|
@@ -46,6 +50,7 @@ import uuid
|
|
|
46
50
|
import requests
|
|
47
51
|
import datetime
|
|
48
52
|
from django.db import IntegrityError, transaction
|
|
53
|
+
from django.db.models import Q
|
|
49
54
|
import calendar
|
|
50
55
|
import re
|
|
51
56
|
from django_object_actions import DjangoObjectActions
|
|
@@ -59,7 +64,7 @@ from reportlab.graphics.barcode import qr
|
|
|
59
64
|
from reportlab.graphics.shapes import Drawing
|
|
60
65
|
from reportlab.lib.styles import getSampleStyleSheet
|
|
61
66
|
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
|
62
|
-
from ocpp.models import Transaction
|
|
67
|
+
from ocpp.models import Charger, Transaction
|
|
63
68
|
from ocpp.rfid.utils import build_mode_toggle
|
|
64
69
|
from nodes.models import EmailOutbox
|
|
65
70
|
from .github_helper import GitHubRepositoryError, create_repository_for_package
|
|
@@ -82,6 +87,7 @@ from .models import (
|
|
|
82
87
|
OdooProfile,
|
|
83
88
|
OpenPayProfile,
|
|
84
89
|
EmailInbox,
|
|
90
|
+
GoogleCalendarProfile,
|
|
85
91
|
SocialProfile,
|
|
86
92
|
EmailCollector,
|
|
87
93
|
Package,
|
|
@@ -90,9 +96,7 @@ from .models import (
|
|
|
90
96
|
SecurityGroup,
|
|
91
97
|
InviteLead,
|
|
92
98
|
PublicWifiAccess,
|
|
93
|
-
AssistantProfile,
|
|
94
99
|
Todo,
|
|
95
|
-
hash_key,
|
|
96
100
|
)
|
|
97
101
|
from .user_data import (
|
|
98
102
|
EntityModelAdmin,
|
|
@@ -109,8 +113,6 @@ from .rfid_import_export import (
|
|
|
109
113
|
parse_accounts,
|
|
110
114
|
serialize_accounts,
|
|
111
115
|
)
|
|
112
|
-
from .mcp import process as mcp_process
|
|
113
|
-
from .mcp.server import resolve_base_urls
|
|
114
116
|
from . import release as release_utils
|
|
115
117
|
|
|
116
118
|
logger = logging.getLogger(__name__)
|
|
@@ -608,15 +610,18 @@ class PackageAdmin(SaveBeforeChangeAction, EntityModelAdmin):
|
|
|
608
610
|
change_actions = ["create_repository_action", "prepare_next_release_action"]
|
|
609
611
|
|
|
610
612
|
def _prepare(self, request, package):
|
|
613
|
+
if request.method not in {"POST", "GET"}:
|
|
614
|
+
return HttpResponseNotAllowed(["GET", "POST"])
|
|
611
615
|
from pathlib import Path
|
|
612
616
|
from packaging.version import Version
|
|
613
617
|
|
|
614
618
|
ver_file = Path("VERSION")
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
619
|
+
if ver_file.exists():
|
|
620
|
+
raw_version = ver_file.read_text().strip()
|
|
621
|
+
cleaned_version = raw_version.rstrip("+") or "0.0.0"
|
|
622
|
+
repo_version = Version(cleaned_version)
|
|
623
|
+
else:
|
|
624
|
+
repo_version = Version("0.0.0")
|
|
620
625
|
|
|
621
626
|
pypi_latest = Version("0.0.0")
|
|
622
627
|
try:
|
|
@@ -1008,6 +1013,41 @@ class OpenPayProfileAdminForm(forms.ModelForm):
|
|
|
1008
1013
|
)
|
|
1009
1014
|
|
|
1010
1015
|
|
|
1016
|
+
class GoogleCalendarProfileAdminForm(forms.ModelForm):
|
|
1017
|
+
"""Admin form for :class:`core.models.GoogleCalendarProfile`."""
|
|
1018
|
+
|
|
1019
|
+
api_key = forms.CharField(
|
|
1020
|
+
widget=forms.PasswordInput(render_value=True),
|
|
1021
|
+
required=False,
|
|
1022
|
+
help_text="Leave blank to keep the current key.",
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
class Meta:
|
|
1026
|
+
model = GoogleCalendarProfile
|
|
1027
|
+
fields = "__all__"
|
|
1028
|
+
|
|
1029
|
+
def __init__(self, *args, **kwargs):
|
|
1030
|
+
super().__init__(*args, **kwargs)
|
|
1031
|
+
if self.instance.pk:
|
|
1032
|
+
self.fields["api_key"].initial = ""
|
|
1033
|
+
self.initial["api_key"] = ""
|
|
1034
|
+
else:
|
|
1035
|
+
self.fields["api_key"].required = True
|
|
1036
|
+
|
|
1037
|
+
def clean_api_key(self):
|
|
1038
|
+
key = self.cleaned_data.get("api_key")
|
|
1039
|
+
if not key and self.instance.pk:
|
|
1040
|
+
return keep_existing("api_key")
|
|
1041
|
+
return key
|
|
1042
|
+
|
|
1043
|
+
def _post_clean(self):
|
|
1044
|
+
super()._post_clean()
|
|
1045
|
+
_restore_sigil_values(
|
|
1046
|
+
self,
|
|
1047
|
+
["calendar_id", "api_key", "display_name", "timezone"],
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
|
|
1011
1051
|
class MaskedPasswordFormMixin:
|
|
1012
1052
|
"""Mixin that hides stored passwords while allowing updates."""
|
|
1013
1053
|
|
|
@@ -1225,6 +1265,15 @@ class OpenPayProfileInlineForm(ProfileFormMixin, OpenPayProfileAdminForm):
|
|
|
1225
1265
|
return cleaned
|
|
1226
1266
|
|
|
1227
1267
|
|
|
1268
|
+
class GoogleCalendarProfileInlineForm(
|
|
1269
|
+
ProfileFormMixin, GoogleCalendarProfileAdminForm
|
|
1270
|
+
):
|
|
1271
|
+
profile_fields = GoogleCalendarProfile.profile_fields
|
|
1272
|
+
|
|
1273
|
+
class Meta(GoogleCalendarProfileAdminForm.Meta):
|
|
1274
|
+
exclude = ("user", "group")
|
|
1275
|
+
|
|
1276
|
+
|
|
1228
1277
|
class EmailInboxInlineForm(ProfileFormMixin, EmailInboxAdminForm):
|
|
1229
1278
|
profile_fields = EmailInbox.profile_fields
|
|
1230
1279
|
|
|
@@ -1303,46 +1352,6 @@ class ReleaseManagerInlineForm(ProfileFormMixin, forms.ModelForm):
|
|
|
1303
1352
|
}
|
|
1304
1353
|
|
|
1305
1354
|
|
|
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
1355
|
PROFILE_INLINE_CONFIG = {
|
|
1347
1356
|
OdooProfile: {
|
|
1348
1357
|
"form": OdooProfileInlineForm,
|
|
@@ -1389,6 +1398,16 @@ PROFILE_INLINE_CONFIG = {
|
|
|
1389
1398
|
),
|
|
1390
1399
|
"readonly_fields": ("verified_on", "verification_reference"),
|
|
1391
1400
|
},
|
|
1401
|
+
GoogleCalendarProfile: {
|
|
1402
|
+
"form": GoogleCalendarProfileInlineForm,
|
|
1403
|
+
"fields": (
|
|
1404
|
+
"display_name",
|
|
1405
|
+
"calendar_id",
|
|
1406
|
+
"api_key",
|
|
1407
|
+
"max_events",
|
|
1408
|
+
"timezone",
|
|
1409
|
+
),
|
|
1410
|
+
},
|
|
1392
1411
|
EmailInbox: {
|
|
1393
1412
|
"form": EmailInboxInlineForm,
|
|
1394
1413
|
"fields": (
|
|
@@ -1473,12 +1492,6 @@ PROFILE_INLINE_CONFIG = {
|
|
|
1473
1492
|
"secondary_pypi_url",
|
|
1474
1493
|
),
|
|
1475
1494
|
},
|
|
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
1495
|
}
|
|
1483
1496
|
|
|
1484
1497
|
|
|
@@ -1525,7 +1538,6 @@ PROFILE_MODELS = (
|
|
|
1525
1538
|
EmailOutbox,
|
|
1526
1539
|
SocialProfile,
|
|
1527
1540
|
ReleaseManager,
|
|
1528
|
-
AssistantProfile,
|
|
1529
1541
|
)
|
|
1530
1542
|
USER_PROFILE_INLINES = [
|
|
1531
1543
|
_build_profile_inline(model, "user") for model in PROFILE_MODELS
|
|
@@ -1867,6 +1879,44 @@ class OpenPayProfileAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModel
|
|
|
1867
1879
|
verify_credentials_action.short_description = _("Test credentials")
|
|
1868
1880
|
|
|
1869
1881
|
|
|
1882
|
+
class GoogleCalendarProfileAdmin(
|
|
1883
|
+
ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin
|
|
1884
|
+
):
|
|
1885
|
+
form = GoogleCalendarProfileAdminForm
|
|
1886
|
+
list_display = ("owner", "calendar_identifier", "max_events")
|
|
1887
|
+
search_fields = (
|
|
1888
|
+
"display_name",
|
|
1889
|
+
"calendar_id",
|
|
1890
|
+
"user__username",
|
|
1891
|
+
"group__name",
|
|
1892
|
+
)
|
|
1893
|
+
changelist_actions = ["my_profile"]
|
|
1894
|
+
change_actions = ["my_profile_action"]
|
|
1895
|
+
fieldsets = (
|
|
1896
|
+
(_("Owner"), {"fields": ("user", "group")}),
|
|
1897
|
+
(
|
|
1898
|
+
_("Calendar"),
|
|
1899
|
+
{
|
|
1900
|
+
"fields": (
|
|
1901
|
+
"display_name",
|
|
1902
|
+
"calendar_id",
|
|
1903
|
+
"api_key",
|
|
1904
|
+
"max_events",
|
|
1905
|
+
"timezone",
|
|
1906
|
+
)
|
|
1907
|
+
},
|
|
1908
|
+
),
|
|
1909
|
+
)
|
|
1910
|
+
|
|
1911
|
+
@admin.display(description=_("Owner"))
|
|
1912
|
+
def owner(self, obj):
|
|
1913
|
+
return obj.owner_display()
|
|
1914
|
+
|
|
1915
|
+
@admin.display(description=_("Calendar"))
|
|
1916
|
+
def calendar_identifier(self, obj):
|
|
1917
|
+
display = obj.get_display_name()
|
|
1918
|
+
return display or obj.resolved_calendar_id()
|
|
1919
|
+
|
|
1870
1920
|
class EmailSearchForm(forms.Form):
|
|
1871
1921
|
subject = forms.CharField(
|
|
1872
1922
|
required=False, widget=forms.TextInput(attrs={"style": "width: 40em;"})
|
|
@@ -2012,180 +2062,6 @@ class EmailInboxAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmi
|
|
|
2012
2062
|
return TemplateResponse(request, "admin/core/emailinbox/search.html", context)
|
|
2013
2063
|
|
|
2014
2064
|
|
|
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
2065
|
class EnergyCreditInline(admin.TabularInline):
|
|
2190
2066
|
model = EnergyCredit
|
|
2191
2067
|
fields = ("amount_kw", "created_by", "created_on")
|
|
@@ -2390,199 +2266,15 @@ class ProductAdminForm(forms.ModelForm):
|
|
|
2390
2266
|
widgets = {"odoo_product": OdooProductWidget}
|
|
2391
2267
|
|
|
2392
2268
|
|
|
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
2269
|
@admin.register(Product)
|
|
2428
2270
|
class ProductAdmin(EntityModelAdmin):
|
|
2429
2271
|
form = ProductAdminForm
|
|
2430
|
-
actions = ["
|
|
2272
|
+
actions = ["register_from_odoo"]
|
|
2431
2273
|
change_list_template = "admin/core/product/change_list.html"
|
|
2432
2274
|
|
|
2433
2275
|
def _odoo_profile_admin(self):
|
|
2434
2276
|
return self.admin_site._registry.get(OdooProfile)
|
|
2435
2277
|
|
|
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
2278
|
def get_urls(self):
|
|
2587
2279
|
urls = super().get_urls()
|
|
2588
2280
|
custom = [
|
|
@@ -2631,7 +2323,6 @@ class ProductAdmin(EntityModelAdmin):
|
|
|
2631
2323
|
products = profile.execute(
|
|
2632
2324
|
"product.product",
|
|
2633
2325
|
"search_read",
|
|
2634
|
-
[[]],
|
|
2635
2326
|
fields=[
|
|
2636
2327
|
"name",
|
|
2637
2328
|
"description_sale",
|
|
@@ -2640,8 +2331,30 @@ class ProductAdmin(EntityModelAdmin):
|
|
|
2640
2331
|
],
|
|
2641
2332
|
limit=0,
|
|
2642
2333
|
)
|
|
2643
|
-
except Exception:
|
|
2334
|
+
except Exception as exc:
|
|
2335
|
+
logger.exception(
|
|
2336
|
+
"Failed to fetch Odoo products for user %s (profile_id=%s, host=%s, database=%s)",
|
|
2337
|
+
getattr(getattr(request, "user", None), "pk", None),
|
|
2338
|
+
getattr(profile, "pk", None),
|
|
2339
|
+
getattr(profile, "host", None),
|
|
2340
|
+
getattr(profile, "database", None),
|
|
2341
|
+
)
|
|
2644
2342
|
context["error"] = _("Unable to fetch products from Odoo.")
|
|
2343
|
+
if getattr(request.user, "is_superuser", False):
|
|
2344
|
+
fault = getattr(exc, "faultString", "")
|
|
2345
|
+
message = str(exc)
|
|
2346
|
+
details = [
|
|
2347
|
+
f"Host: {getattr(profile, 'host', '')}",
|
|
2348
|
+
f"Database: {getattr(profile, 'database', '')}",
|
|
2349
|
+
f"User ID: {getattr(profile, 'odoo_uid', '')}",
|
|
2350
|
+
]
|
|
2351
|
+
if fault and fault != message:
|
|
2352
|
+
details.append(f"Fault: {fault}")
|
|
2353
|
+
if message:
|
|
2354
|
+
details.append(f"Exception: {type(exc).__name__}: {message}")
|
|
2355
|
+
else:
|
|
2356
|
+
details.append(f"Exception type: {type(exc).__name__}")
|
|
2357
|
+
context["debug_error"] = "\n".join(details)
|
|
2645
2358
|
return context, []
|
|
2646
2359
|
|
|
2647
2360
|
context["has_credentials"] = True
|
|
@@ -2835,6 +2548,7 @@ class RFIDResource(resources.ModelResource):
|
|
|
2835
2548
|
"post_auth_command",
|
|
2836
2549
|
"allowed",
|
|
2837
2550
|
"color",
|
|
2551
|
+
"endianness",
|
|
2838
2552
|
"kind",
|
|
2839
2553
|
"released",
|
|
2840
2554
|
"last_seen_on",
|
|
@@ -2849,6 +2563,7 @@ class RFIDResource(resources.ModelResource):
|
|
|
2849
2563
|
"post_auth_command",
|
|
2850
2564
|
"allowed",
|
|
2851
2565
|
"color",
|
|
2566
|
+
"endianness",
|
|
2852
2567
|
"kind",
|
|
2853
2568
|
"released",
|
|
2854
2569
|
"last_seen_on",
|
|
@@ -2899,7 +2614,7 @@ class CopyRFIDForm(forms.Form):
|
|
|
2899
2614
|
normalized = (cleaned or "").strip().upper()
|
|
2900
2615
|
if not normalized:
|
|
2901
2616
|
raise forms.ValidationError(_("RFID value is required."))
|
|
2902
|
-
if RFID.
|
|
2617
|
+
if RFID.matching_queryset(normalized).exists():
|
|
2903
2618
|
raise forms.ValidationError(
|
|
2904
2619
|
_("An RFID with this value already exists.")
|
|
2905
2620
|
)
|
|
@@ -2919,11 +2634,12 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
|
|
|
2919
2634
|
"user_data_flag",
|
|
2920
2635
|
"color",
|
|
2921
2636
|
"kind",
|
|
2637
|
+
"endianness_short",
|
|
2922
2638
|
"released",
|
|
2923
2639
|
"allowed",
|
|
2924
2640
|
"last_seen_on",
|
|
2925
2641
|
)
|
|
2926
|
-
list_filter = ("color", "released", "allowed")
|
|
2642
|
+
list_filter = ("color", "endianness", "released", "allowed")
|
|
2927
2643
|
search_fields = ("label_id", "rfid", "custom_label")
|
|
2928
2644
|
autocomplete_fields = ["energy_accounts"]
|
|
2929
2645
|
raw_id_fields = ["reference"]
|
|
@@ -2932,9 +2648,12 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
|
|
|
2932
2648
|
"print_card_labels",
|
|
2933
2649
|
"print_release_form",
|
|
2934
2650
|
"copy_rfids",
|
|
2651
|
+
"merge_rfids",
|
|
2935
2652
|
"toggle_selected_user_data",
|
|
2653
|
+
"toggle_selected_released",
|
|
2654
|
+
"toggle_selected_allowed",
|
|
2936
2655
|
]
|
|
2937
|
-
readonly_fields = ("added_on", "last_seen_on")
|
|
2656
|
+
readonly_fields = ("added_on", "last_seen_on", "reversed_uid")
|
|
2938
2657
|
form = RFIDForm
|
|
2939
2658
|
|
|
2940
2659
|
def get_import_resource_kwargs(self, request, form=None, **kwargs):
|
|
@@ -2980,6 +2699,14 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
|
|
|
2980
2699
|
def user_data_flag(self, obj):
|
|
2981
2700
|
return getattr(obj, "is_user_data", False)
|
|
2982
2701
|
|
|
2702
|
+
@admin.display(description=_("End"), ordering="endianness")
|
|
2703
|
+
def endianness_short(self, obj):
|
|
2704
|
+
labels = {
|
|
2705
|
+
RFID.BIG_ENDIAN: _("Big"),
|
|
2706
|
+
RFID.LITTLE_ENDIAN: _("Little"),
|
|
2707
|
+
}
|
|
2708
|
+
return labels.get(obj.endianness, obj.get_endianness_display())
|
|
2709
|
+
|
|
2983
2710
|
def scan_rfids(self, request, queryset):
|
|
2984
2711
|
return redirect("admin:core_rfid_scan")
|
|
2985
2712
|
|
|
@@ -3030,6 +2757,50 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
|
|
|
3030
2757
|
level=messages.WARNING,
|
|
3031
2758
|
)
|
|
3032
2759
|
|
|
2760
|
+
@admin.action(description=_("Toggle Released flag"))
|
|
2761
|
+
def toggle_selected_released(self, request, queryset):
|
|
2762
|
+
manager = getattr(self.model, "all_objects", self.model.objects)
|
|
2763
|
+
toggled = 0
|
|
2764
|
+
for tag in queryset:
|
|
2765
|
+
new_state = not tag.released
|
|
2766
|
+
manager.filter(pk=tag.pk).update(released=new_state)
|
|
2767
|
+
tag.released = new_state
|
|
2768
|
+
toggled += 1
|
|
2769
|
+
|
|
2770
|
+
if toggled:
|
|
2771
|
+
self.message_user(
|
|
2772
|
+
request,
|
|
2773
|
+
ngettext(
|
|
2774
|
+
"Toggled released flag for %(count)d RFID.",
|
|
2775
|
+
"Toggled released flag for %(count)d RFIDs.",
|
|
2776
|
+
toggled,
|
|
2777
|
+
)
|
|
2778
|
+
% {"count": toggled},
|
|
2779
|
+
level=messages.SUCCESS,
|
|
2780
|
+
)
|
|
2781
|
+
|
|
2782
|
+
@admin.action(description=_("Toggle Allowed flag"))
|
|
2783
|
+
def toggle_selected_allowed(self, request, queryset):
|
|
2784
|
+
manager = getattr(self.model, "all_objects", self.model.objects)
|
|
2785
|
+
toggled = 0
|
|
2786
|
+
for tag in queryset:
|
|
2787
|
+
new_state = not tag.allowed
|
|
2788
|
+
manager.filter(pk=tag.pk).update(allowed=new_state)
|
|
2789
|
+
tag.allowed = new_state
|
|
2790
|
+
toggled += 1
|
|
2791
|
+
|
|
2792
|
+
if toggled:
|
|
2793
|
+
self.message_user(
|
|
2794
|
+
request,
|
|
2795
|
+
ngettext(
|
|
2796
|
+
"Toggled allowed flag for %(count)d RFID.",
|
|
2797
|
+
"Toggled allowed flag for %(count)d RFIDs.",
|
|
2798
|
+
toggled,
|
|
2799
|
+
)
|
|
2800
|
+
% {"count": toggled},
|
|
2801
|
+
level=messages.SUCCESS,
|
|
2802
|
+
)
|
|
2803
|
+
|
|
3033
2804
|
@admin.action(description=_("Copy RFID"))
|
|
3034
2805
|
def copy_rfids(self, request, queryset):
|
|
3035
2806
|
if queryset.count() != 1:
|
|
@@ -3120,6 +2891,145 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
|
|
|
3120
2891
|
context["media"] = self.media + form.media
|
|
3121
2892
|
return TemplateResponse(request, "admin/core/rfid/copy.html", context)
|
|
3122
2893
|
|
|
2894
|
+
@admin.action(description=_("Merge RFID cards"))
|
|
2895
|
+
def merge_rfids(self, request, queryset):
|
|
2896
|
+
tags = list(queryset.prefetch_related("energy_accounts"))
|
|
2897
|
+
if len(tags) < 2:
|
|
2898
|
+
self.message_user(
|
|
2899
|
+
request,
|
|
2900
|
+
_("Select at least two RFIDs to merge."),
|
|
2901
|
+
level=messages.WARNING,
|
|
2902
|
+
)
|
|
2903
|
+
return None
|
|
2904
|
+
|
|
2905
|
+
normalized_map: dict[int, str] = {}
|
|
2906
|
+
groups: defaultdict[str, list[RFID]] = defaultdict(list)
|
|
2907
|
+
unmatched = 0
|
|
2908
|
+
for tag in tags:
|
|
2909
|
+
normalized = RFID.normalize_code(tag.rfid)
|
|
2910
|
+
normalized_map[tag.pk] = normalized
|
|
2911
|
+
if not normalized:
|
|
2912
|
+
unmatched += 1
|
|
2913
|
+
continue
|
|
2914
|
+
prefix = normalized[: RFID.MATCH_PREFIX_LENGTH]
|
|
2915
|
+
groups[prefix].append(tag)
|
|
2916
|
+
|
|
2917
|
+
merge_groups: list[list[RFID]] = []
|
|
2918
|
+
skipped = unmatched
|
|
2919
|
+
for prefix, group in groups.items():
|
|
2920
|
+
if len(group) < 2:
|
|
2921
|
+
skipped += len(group)
|
|
2922
|
+
continue
|
|
2923
|
+
group.sort(
|
|
2924
|
+
key=lambda item: (
|
|
2925
|
+
len(normalized_map.get(item.pk, "")),
|
|
2926
|
+
normalized_map.get(item.pk, ""),
|
|
2927
|
+
item.pk,
|
|
2928
|
+
)
|
|
2929
|
+
)
|
|
2930
|
+
merge_groups.append(group)
|
|
2931
|
+
|
|
2932
|
+
if not merge_groups:
|
|
2933
|
+
self.message_user(
|
|
2934
|
+
request,
|
|
2935
|
+
_("No matching RFIDs were found to merge."),
|
|
2936
|
+
level=messages.WARNING,
|
|
2937
|
+
)
|
|
2938
|
+
return None
|
|
2939
|
+
|
|
2940
|
+
merged_tags = 0
|
|
2941
|
+
merged_groups = 0
|
|
2942
|
+
conflicting_accounts = 0
|
|
2943
|
+
with transaction.atomic():
|
|
2944
|
+
for group in merge_groups:
|
|
2945
|
+
canonical = group[0]
|
|
2946
|
+
update_fields: set[str] = set()
|
|
2947
|
+
existing_account_ids = set(
|
|
2948
|
+
canonical.energy_accounts.values_list("pk", flat=True)
|
|
2949
|
+
)
|
|
2950
|
+
for tag in group[1:]:
|
|
2951
|
+
other_value = normalized_map.get(tag.pk, "")
|
|
2952
|
+
if canonical.adopt_rfid(other_value):
|
|
2953
|
+
update_fields.add("rfid")
|
|
2954
|
+
normalized_map[canonical.pk] = RFID.normalize_code(
|
|
2955
|
+
canonical.rfid
|
|
2956
|
+
)
|
|
2957
|
+
accounts = list(tag.energy_accounts.all())
|
|
2958
|
+
if accounts:
|
|
2959
|
+
transferable: list[EnergyAccount] = []
|
|
2960
|
+
for account in accounts:
|
|
2961
|
+
if existing_account_ids and account.pk not in existing_account_ids:
|
|
2962
|
+
conflicting_accounts += 1
|
|
2963
|
+
continue
|
|
2964
|
+
transferable.append(account)
|
|
2965
|
+
if transferable:
|
|
2966
|
+
canonical.energy_accounts.add(*transferable)
|
|
2967
|
+
existing_account_ids.update(
|
|
2968
|
+
account.pk for account in transferable
|
|
2969
|
+
)
|
|
2970
|
+
if tag.allowed and not canonical.allowed:
|
|
2971
|
+
canonical.allowed = True
|
|
2972
|
+
update_fields.add("allowed")
|
|
2973
|
+
if tag.released and not canonical.released:
|
|
2974
|
+
canonical.released = True
|
|
2975
|
+
update_fields.add("released")
|
|
2976
|
+
if tag.key_a_verified and not canonical.key_a_verified:
|
|
2977
|
+
canonical.key_a_verified = True
|
|
2978
|
+
update_fields.add("key_a_verified")
|
|
2979
|
+
if tag.key_b_verified and not canonical.key_b_verified:
|
|
2980
|
+
canonical.key_b_verified = True
|
|
2981
|
+
update_fields.add("key_b_verified")
|
|
2982
|
+
if tag.last_seen_on and (
|
|
2983
|
+
not canonical.last_seen_on
|
|
2984
|
+
or tag.last_seen_on > canonical.last_seen_on
|
|
2985
|
+
):
|
|
2986
|
+
canonical.last_seen_on = tag.last_seen_on
|
|
2987
|
+
update_fields.add("last_seen_on")
|
|
2988
|
+
if not canonical.origin_node and tag.origin_node_id:
|
|
2989
|
+
canonical.origin_node = tag.origin_node
|
|
2990
|
+
update_fields.add("origin_node")
|
|
2991
|
+
merged_tags += 1
|
|
2992
|
+
tag.delete()
|
|
2993
|
+
if update_fields:
|
|
2994
|
+
canonical.save(update_fields=sorted(update_fields))
|
|
2995
|
+
merged_groups += 1
|
|
2996
|
+
|
|
2997
|
+
if merged_tags:
|
|
2998
|
+
self.message_user(
|
|
2999
|
+
request,
|
|
3000
|
+
ngettext(
|
|
3001
|
+
"Merged %(removed)d RFID into %(groups)d canonical record.",
|
|
3002
|
+
"Merged %(removed)d RFIDs into %(groups)d canonical records.",
|
|
3003
|
+
merged_tags,
|
|
3004
|
+
)
|
|
3005
|
+
% {"removed": merged_tags, "groups": merged_groups},
|
|
3006
|
+
level=messages.SUCCESS,
|
|
3007
|
+
)
|
|
3008
|
+
|
|
3009
|
+
if skipped:
|
|
3010
|
+
self.message_user(
|
|
3011
|
+
request,
|
|
3012
|
+
ngettext(
|
|
3013
|
+
"Skipped %(count)d RFID because it did not share the first %(length)d characters with another selection.",
|
|
3014
|
+
"Skipped %(count)d RFIDs because they did not share the first %(length)d characters with another selection.",
|
|
3015
|
+
skipped,
|
|
3016
|
+
)
|
|
3017
|
+
% {"count": skipped, "length": RFID.MATCH_PREFIX_LENGTH},
|
|
3018
|
+
level=messages.WARNING,
|
|
3019
|
+
)
|
|
3020
|
+
|
|
3021
|
+
if conflicting_accounts:
|
|
3022
|
+
self.message_user(
|
|
3023
|
+
request,
|
|
3024
|
+
ngettext(
|
|
3025
|
+
"Skipped %(count)d energy account because the RFID was already linked to a different account.",
|
|
3026
|
+
"Skipped %(count)d energy accounts because the RFID was already linked to a different account.",
|
|
3027
|
+
conflicting_accounts,
|
|
3028
|
+
)
|
|
3029
|
+
% {"count": conflicting_accounts},
|
|
3030
|
+
level=messages.WARNING,
|
|
3031
|
+
)
|
|
3032
|
+
|
|
3123
3033
|
def _render_card_labels(
|
|
3124
3034
|
self,
|
|
3125
3035
|
request,
|
|
@@ -3492,11 +3402,13 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
|
|
|
3492
3402
|
"toggle_url": toggle_url,
|
|
3493
3403
|
"toggle_label": toggle_label,
|
|
3494
3404
|
"public_view_url": public_view_url,
|
|
3405
|
+
"deep_read_url": reverse("rfid-scan-deep"),
|
|
3495
3406
|
}
|
|
3496
3407
|
)
|
|
3497
3408
|
context["title"] = _("Scan RFIDs")
|
|
3498
3409
|
context["opts"] = self.model._meta
|
|
3499
3410
|
context["show_release_info"] = True
|
|
3411
|
+
context["default_endianness"] = RFID.BIG_ENDIAN
|
|
3500
3412
|
return render(request, "admin/core/rfid/scan.html", context)
|
|
3501
3413
|
|
|
3502
3414
|
def scan_next(self, request):
|
|
@@ -3510,20 +3422,71 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
|
|
|
3510
3422
|
return JsonResponse({"error": "Invalid JSON payload"}, status=400)
|
|
3511
3423
|
rfid = payload.get("rfid") or payload.get("value")
|
|
3512
3424
|
kind = payload.get("kind")
|
|
3513
|
-
|
|
3425
|
+
endianness = payload.get("endianness")
|
|
3426
|
+
result = validate_rfid_value(rfid, kind=kind, endianness=endianness)
|
|
3514
3427
|
else:
|
|
3515
|
-
|
|
3428
|
+
endianness = request.GET.get("endianness")
|
|
3429
|
+
result = scan_sources(request, endianness=endianness)
|
|
3516
3430
|
status = 500 if result.get("error") else 200
|
|
3517
3431
|
return JsonResponse(result, status=status)
|
|
3518
3432
|
|
|
3519
3433
|
|
|
3434
|
+
class ClientReportRecurrencyFilter(admin.SimpleListFilter):
|
|
3435
|
+
title = "Recurrency"
|
|
3436
|
+
parameter_name = "recurrency"
|
|
3437
|
+
|
|
3438
|
+
def lookups(self, request, model_admin):
|
|
3439
|
+
for value, label in ClientReportSchedule.PERIODICITY_CHOICES:
|
|
3440
|
+
yield (value, label)
|
|
3441
|
+
|
|
3442
|
+
def queryset(self, request, queryset):
|
|
3443
|
+
value = self.value()
|
|
3444
|
+
if not value:
|
|
3445
|
+
return queryset
|
|
3446
|
+
if value == ClientReportSchedule.PERIODICITY_NONE:
|
|
3447
|
+
return queryset.filter(
|
|
3448
|
+
Q(schedule__isnull=True) | Q(schedule__periodicity=value)
|
|
3449
|
+
)
|
|
3450
|
+
return queryset.filter(schedule__periodicity=value)
|
|
3451
|
+
|
|
3452
|
+
|
|
3520
3453
|
@admin.register(ClientReport)
|
|
3521
3454
|
class ClientReportAdmin(EntityModelAdmin):
|
|
3522
|
-
list_display = (
|
|
3455
|
+
list_display = (
|
|
3456
|
+
"created_on",
|
|
3457
|
+
"period_range",
|
|
3458
|
+
"owner",
|
|
3459
|
+
"recurrency_display",
|
|
3460
|
+
"total_kw_period_display",
|
|
3461
|
+
"download_link",
|
|
3462
|
+
)
|
|
3463
|
+
list_select_related = ("schedule", "owner")
|
|
3464
|
+
list_filter = ("owner", ClientReportRecurrencyFilter)
|
|
3523
3465
|
readonly_fields = ("created_on", "data")
|
|
3524
3466
|
|
|
3525
3467
|
change_list_template = "admin/core/clientreport/change_list.html"
|
|
3526
3468
|
|
|
3469
|
+
def period_range(self, obj):
|
|
3470
|
+
return str(obj)
|
|
3471
|
+
|
|
3472
|
+
period_range.short_description = "Period"
|
|
3473
|
+
|
|
3474
|
+
def recurrency_display(self, obj):
|
|
3475
|
+
return obj.periodicity_label
|
|
3476
|
+
|
|
3477
|
+
recurrency_display.short_description = "Recurrency"
|
|
3478
|
+
|
|
3479
|
+
def total_kw_period_display(self, obj):
|
|
3480
|
+
return f"{obj.total_kw_period:.2f}"
|
|
3481
|
+
|
|
3482
|
+
total_kw_period_display.short_description = "Total kW (period)"
|
|
3483
|
+
|
|
3484
|
+
def download_link(self, obj):
|
|
3485
|
+
url = reverse("admin:core_clientreport_download", args=[obj.pk])
|
|
3486
|
+
return format_html('<a href="{}">Download</a>', url)
|
|
3487
|
+
|
|
3488
|
+
download_link.short_description = "Download"
|
|
3489
|
+
|
|
3527
3490
|
class ClientReportForm(forms.Form):
|
|
3528
3491
|
PERIOD_CHOICES = [
|
|
3529
3492
|
("range", "Date range"),
|
|
@@ -3559,8 +3522,28 @@ class ClientReportAdmin(EntityModelAdmin):
|
|
|
3559
3522
|
label="Month",
|
|
3560
3523
|
required=False,
|
|
3561
3524
|
widget=forms.DateInput(attrs={"type": "month"}),
|
|
3525
|
+
input_formats=["%Y-%m"],
|
|
3562
3526
|
help_text="Generates the report for the calendar month that you select.",
|
|
3563
3527
|
)
|
|
3528
|
+
language = forms.ChoiceField(
|
|
3529
|
+
label="Report language",
|
|
3530
|
+
choices=settings.LANGUAGES,
|
|
3531
|
+
help_text="Choose the language used for the generated report.",
|
|
3532
|
+
)
|
|
3533
|
+
title = forms.CharField(
|
|
3534
|
+
label="Report title",
|
|
3535
|
+
required=False,
|
|
3536
|
+
max_length=200,
|
|
3537
|
+
help_text="Optional heading that replaces the default report title.",
|
|
3538
|
+
)
|
|
3539
|
+
chargers = forms.ModelMultipleChoiceField(
|
|
3540
|
+
label="Charge points",
|
|
3541
|
+
queryset=Charger.objects.filter(connector_id__isnull=True)
|
|
3542
|
+
.order_by("display_name", "charger_id"),
|
|
3543
|
+
required=False,
|
|
3544
|
+
widget=forms.CheckboxSelectMultiple,
|
|
3545
|
+
help_text="Choose which charge points are included in the report.",
|
|
3546
|
+
)
|
|
3564
3547
|
owner = forms.ModelChoiceField(
|
|
3565
3548
|
queryset=get_user_model().objects.all(),
|
|
3566
3549
|
required=False,
|
|
@@ -3578,10 +3561,10 @@ class ClientReportAdmin(EntityModelAdmin):
|
|
|
3578
3561
|
initial=ClientReportSchedule.PERIODICITY_NONE,
|
|
3579
3562
|
help_text="Defines how often the report should be generated automatically.",
|
|
3580
3563
|
)
|
|
3581
|
-
|
|
3582
|
-
label="
|
|
3564
|
+
enable_emails = forms.BooleanField(
|
|
3565
|
+
label="Enable email delivery",
|
|
3583
3566
|
required=False,
|
|
3584
|
-
help_text="
|
|
3567
|
+
help_text="Send the report via email to the recipients listed above.",
|
|
3585
3568
|
)
|
|
3586
3569
|
|
|
3587
3570
|
def __init__(self, *args, request=None, **kwargs):
|
|
@@ -3593,6 +3576,13 @@ class ClientReportAdmin(EntityModelAdmin):
|
|
|
3593
3576
|
and request.user.is_authenticated
|
|
3594
3577
|
):
|
|
3595
3578
|
self.fields["owner"].initial = request.user.pk
|
|
3579
|
+
self.fields["chargers"].widget.attrs["class"] = "charger-options"
|
|
3580
|
+
language_initial = ClientReport.default_language()
|
|
3581
|
+
if request:
|
|
3582
|
+
language_initial = ClientReport.normalize_language(
|
|
3583
|
+
getattr(request, "LANGUAGE_CODE", language_initial)
|
|
3584
|
+
)
|
|
3585
|
+
self.fields["language"].initial = language_initial
|
|
3596
3586
|
|
|
3597
3587
|
def clean(self):
|
|
3598
3588
|
cleaned = super().clean()
|
|
@@ -3637,6 +3627,10 @@ class ClientReportAdmin(EntityModelAdmin):
|
|
|
3637
3627
|
emails.append(candidate)
|
|
3638
3628
|
return emails
|
|
3639
3629
|
|
|
3630
|
+
def clean_title(self):
|
|
3631
|
+
title = self.cleaned_data.get("title")
|
|
3632
|
+
return ClientReport.normalize_title(title)
|
|
3633
|
+
|
|
3640
3634
|
def get_urls(self):
|
|
3641
3635
|
urls = super().get_urls()
|
|
3642
3636
|
custom = [
|
|
@@ -3645,6 +3639,16 @@ class ClientReportAdmin(EntityModelAdmin):
|
|
|
3645
3639
|
self.admin_site.admin_view(self.generate_view),
|
|
3646
3640
|
name="core_clientreport_generate",
|
|
3647
3641
|
),
|
|
3642
|
+
path(
|
|
3643
|
+
"generate/action/",
|
|
3644
|
+
self.admin_site.admin_view(self.generate_report),
|
|
3645
|
+
name="core_clientreport_generate_report",
|
|
3646
|
+
),
|
|
3647
|
+
path(
|
|
3648
|
+
"download/<int:report_id>/",
|
|
3649
|
+
self.admin_site.admin_view(self.download_view),
|
|
3650
|
+
name="core_clientreport_download",
|
|
3651
|
+
),
|
|
3648
3652
|
]
|
|
3649
3653
|
return custom + urls
|
|
3650
3654
|
|
|
@@ -3652,40 +3656,127 @@ class ClientReportAdmin(EntityModelAdmin):
|
|
|
3652
3656
|
form = self.ClientReportForm(request.POST or None, request=request)
|
|
3653
3657
|
report = None
|
|
3654
3658
|
schedule = None
|
|
3659
|
+
download_url = None
|
|
3655
3660
|
if request.method == "POST" and form.is_valid():
|
|
3656
3661
|
owner = form.cleaned_data.get("owner")
|
|
3657
3662
|
if not owner and request.user.is_authenticated:
|
|
3658
3663
|
owner = request.user
|
|
3664
|
+
enable_emails = form.cleaned_data.get("enable_emails", False)
|
|
3665
|
+
disable_emails = not enable_emails
|
|
3666
|
+
recipients = form.cleaned_data.get("destinations") if enable_emails else []
|
|
3667
|
+
chargers = list(form.cleaned_data.get("chargers") or [])
|
|
3668
|
+
language = form.cleaned_data.get("language")
|
|
3669
|
+
title = form.cleaned_data.get("title")
|
|
3659
3670
|
report = ClientReport.generate(
|
|
3660
3671
|
form.cleaned_data["start"],
|
|
3661
3672
|
form.cleaned_data["end"],
|
|
3662
3673
|
owner=owner,
|
|
3663
|
-
recipients=
|
|
3664
|
-
disable_emails=
|
|
3674
|
+
recipients=recipients,
|
|
3675
|
+
disable_emails=disable_emails,
|
|
3676
|
+
chargers=chargers,
|
|
3677
|
+
language=language,
|
|
3678
|
+
title=title,
|
|
3665
3679
|
)
|
|
3666
3680
|
report.store_local_copy()
|
|
3681
|
+
if chargers:
|
|
3682
|
+
report.chargers.set(chargers)
|
|
3683
|
+
if enable_emails and recipients:
|
|
3684
|
+
delivered = report.send_delivery(
|
|
3685
|
+
to=recipients,
|
|
3686
|
+
cc=[],
|
|
3687
|
+
outbox=ClientReport.resolve_outbox_for_owner(owner),
|
|
3688
|
+
reply_to=ClientReport.resolve_reply_to_for_owner(owner),
|
|
3689
|
+
)
|
|
3690
|
+
if delivered:
|
|
3691
|
+
report.recipients = delivered
|
|
3692
|
+
report.save(update_fields=["recipients"])
|
|
3693
|
+
self.message_user(
|
|
3694
|
+
request,
|
|
3695
|
+
"Consumer report emailed to the selected recipients.",
|
|
3696
|
+
messages.SUCCESS,
|
|
3697
|
+
)
|
|
3667
3698
|
recurrence = form.cleaned_data.get("recurrence")
|
|
3668
3699
|
if recurrence and recurrence != ClientReportSchedule.PERIODICITY_NONE:
|
|
3669
3700
|
schedule = ClientReportSchedule.objects.create(
|
|
3670
3701
|
owner=owner,
|
|
3671
3702
|
created_by=request.user if request.user.is_authenticated else None,
|
|
3672
3703
|
periodicity=recurrence,
|
|
3673
|
-
email_recipients=
|
|
3674
|
-
disable_emails=
|
|
3704
|
+
email_recipients=recipients,
|
|
3705
|
+
disable_emails=disable_emails,
|
|
3706
|
+
language=language,
|
|
3707
|
+
title=title,
|
|
3675
3708
|
)
|
|
3709
|
+
if chargers:
|
|
3710
|
+
schedule.chargers.set(chargers)
|
|
3676
3711
|
report.schedule = schedule
|
|
3677
3712
|
report.save(update_fields=["schedule"])
|
|
3678
3713
|
self.message_user(
|
|
3679
3714
|
request,
|
|
3680
|
-
"
|
|
3715
|
+
"Consumer report schedule created; future reports will be generated automatically.",
|
|
3681
3716
|
messages.SUCCESS,
|
|
3682
3717
|
)
|
|
3718
|
+
if disable_emails:
|
|
3719
|
+
self.message_user(
|
|
3720
|
+
request,
|
|
3721
|
+
"Consumer report generated. The download will begin automatically.",
|
|
3722
|
+
messages.SUCCESS,
|
|
3723
|
+
)
|
|
3724
|
+
redirect_url = f"{reverse('admin:core_clientreport_generate')}?download={report.pk}"
|
|
3725
|
+
return HttpResponseRedirect(redirect_url)
|
|
3726
|
+
download_param = request.GET.get("download")
|
|
3727
|
+
if download_param:
|
|
3728
|
+
try:
|
|
3729
|
+
download_report = ClientReport.objects.get(pk=download_param)
|
|
3730
|
+
except ClientReport.DoesNotExist:
|
|
3731
|
+
pass
|
|
3732
|
+
else:
|
|
3733
|
+
download_url = reverse(
|
|
3734
|
+
"admin:core_clientreport_download", args=[download_report.pk]
|
|
3735
|
+
)
|
|
3683
3736
|
context = self.admin_site.each_context(request)
|
|
3684
|
-
context.update(
|
|
3737
|
+
context.update(
|
|
3738
|
+
{
|
|
3739
|
+
"form": form,
|
|
3740
|
+
"report": report,
|
|
3741
|
+
"schedule": schedule,
|
|
3742
|
+
"download_url": download_url,
|
|
3743
|
+
"opts": self.model._meta,
|
|
3744
|
+
}
|
|
3745
|
+
)
|
|
3685
3746
|
return TemplateResponse(
|
|
3686
3747
|
request, "admin/core/clientreport/generate.html", context
|
|
3687
3748
|
)
|
|
3688
3749
|
|
|
3750
|
+
def get_changelist_actions(self, request):
|
|
3751
|
+
parent = getattr(super(), "get_changelist_actions", None)
|
|
3752
|
+
actions: list[str] = []
|
|
3753
|
+
if callable(parent):
|
|
3754
|
+
parent_actions = parent(request)
|
|
3755
|
+
if parent_actions:
|
|
3756
|
+
actions.extend(parent_actions)
|
|
3757
|
+
if "generate_report" not in actions:
|
|
3758
|
+
actions.append("generate_report")
|
|
3759
|
+
return actions
|
|
3760
|
+
|
|
3761
|
+
def generate_report(self, request):
|
|
3762
|
+
return HttpResponseRedirect(reverse("admin:core_clientreport_generate"))
|
|
3763
|
+
|
|
3764
|
+
generate_report.label = _("Generate report")
|
|
3765
|
+
|
|
3766
|
+
def download_view(self, request, report_id: int):
|
|
3767
|
+
report = get_object_or_404(ClientReport, pk=report_id)
|
|
3768
|
+
pdf_path = report.ensure_pdf()
|
|
3769
|
+
if not pdf_path.exists():
|
|
3770
|
+
raise Http404("Report file unavailable")
|
|
3771
|
+
end_date = report.end_date
|
|
3772
|
+
if hasattr(end_date, "isoformat"):
|
|
3773
|
+
end_date_str = end_date.isoformat()
|
|
3774
|
+
else: # pragma: no cover - fallback for unexpected values
|
|
3775
|
+
end_date_str = str(end_date)
|
|
3776
|
+
filename = f"consumer-report-{end_date_str}.pdf"
|
|
3777
|
+
response = FileResponse(pdf_path.open("rb"), content_type="application/pdf")
|
|
3778
|
+
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
|
3779
|
+
return response
|
|
3689
3780
|
|
|
3690
3781
|
@admin.register(PackageRelease)
|
|
3691
3782
|
class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
|
|
@@ -3693,6 +3784,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
|
|
|
3693
3784
|
list_display = (
|
|
3694
3785
|
"version",
|
|
3695
3786
|
"package_link",
|
|
3787
|
+
"severity",
|
|
3696
3788
|
"is_current",
|
|
3697
3789
|
"pypi_url",
|
|
3698
3790
|
"github_url",
|
|
@@ -3709,6 +3801,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
|
|
|
3709
3801
|
"package",
|
|
3710
3802
|
"release_manager",
|
|
3711
3803
|
"version",
|
|
3804
|
+
"severity",
|
|
3712
3805
|
"revision",
|
|
3713
3806
|
"is_current",
|
|
3714
3807
|
"pypi_url",
|
|
@@ -3746,9 +3839,9 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
|
|
|
3746
3839
|
self.message_user(request, str(exc), messages.ERROR)
|
|
3747
3840
|
return
|
|
3748
3841
|
releases = resp.json().get("releases", {})
|
|
3749
|
-
created = 0
|
|
3750
3842
|
updated = 0
|
|
3751
3843
|
restored = 0
|
|
3844
|
+
missing: list[str] = []
|
|
3752
3845
|
|
|
3753
3846
|
for version, files in releases.items():
|
|
3754
3847
|
release_on = self._release_on_from_files(files)
|
|
@@ -3773,23 +3866,11 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
|
|
|
3773
3866
|
if update_fields:
|
|
3774
3867
|
release.save(update_fields=update_fields)
|
|
3775
3868
|
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
|
|
3869
|
+
missing.append(version)
|
|
3785
3870
|
|
|
3786
|
-
if
|
|
3871
|
+
if updated or restored:
|
|
3787
3872
|
PackageRelease.dump_fixture()
|
|
3788
3873
|
message_parts = []
|
|
3789
|
-
if created:
|
|
3790
|
-
message_parts.append(
|
|
3791
|
-
f"Created {created} release{'s' if created != 1 else ''} from PyPI"
|
|
3792
|
-
)
|
|
3793
3874
|
if updated:
|
|
3794
3875
|
message_parts.append(
|
|
3795
3876
|
f"Updated release date for {updated} release"
|
|
@@ -3800,8 +3881,17 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
|
|
|
3800
3881
|
f"Restored {restored} release{'s' if restored != 1 else ''}"
|
|
3801
3882
|
)
|
|
3802
3883
|
self.message_user(request, "; ".join(message_parts), messages.SUCCESS)
|
|
3803
|
-
|
|
3804
|
-
self.message_user(request, "No
|
|
3884
|
+
elif not missing:
|
|
3885
|
+
self.message_user(request, "No matching releases found", messages.INFO)
|
|
3886
|
+
|
|
3887
|
+
if missing:
|
|
3888
|
+
versions = ", ".join(sorted(missing))
|
|
3889
|
+
count = len(missing)
|
|
3890
|
+
message = (
|
|
3891
|
+
"Manual creation required for "
|
|
3892
|
+
f"{count} release{'s' if count != 1 else ''}: {versions}"
|
|
3893
|
+
)
|
|
3894
|
+
self.message_user(request, message, messages.WARNING)
|
|
3805
3895
|
|
|
3806
3896
|
refresh_from_pypi.label = "Refresh from PyPI"
|
|
3807
3897
|
refresh_from_pypi.short_description = "Refresh from PyPI"
|