arthexis 0.1.8__py3-none-any.whl → 0.1.10__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.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
- arthexis-0.1.10.dist-info/RECORD +95 -0
- arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
- config/__init__.py +0 -1
- config/auth_app.py +0 -1
- config/celery.py +1 -2
- config/context_processors.py +1 -1
- config/offline.py +2 -0
- config/settings.py +352 -37
- config/urls.py +71 -6
- core/admin.py +1601 -200
- core/admin_history.py +50 -0
- core/admindocs.py +108 -1
- core/apps.py +161 -3
- core/auto_upgrade.py +57 -0
- core/backends.py +123 -7
- core/entity.py +62 -48
- core/fields.py +98 -0
- core/github_helper.py +25 -0
- core/github_issues.py +172 -0
- core/lcd_screen.py +1 -0
- core/liveupdate.py +25 -0
- core/log_paths.py +100 -0
- core/mailer.py +83 -0
- core/middleware.py +57 -0
- core/models.py +1279 -267
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/reference_utils.py +97 -0
- core/release.py +27 -20
- core/sigil_builder.py +144 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +162 -29
- core/tasks.py +269 -27
- core/test_system_info.py +59 -1
- core/tests.py +644 -73
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +425 -168
- core/views.py +627 -59
- core/widgets.py +51 -0
- core/workgroup_urls.py +7 -3
- core/workgroup_views.py +43 -6
- nodes/actions.py +0 -2
- nodes/admin.py +168 -285
- nodes/apps.py +9 -15
- nodes/backends.py +145 -0
- nodes/lcd.py +24 -10
- nodes/models.py +579 -179
- nodes/tasks.py +1 -5
- nodes/tests.py +894 -130
- nodes/utils.py +13 -2
- nodes/views.py +204 -28
- ocpp/admin.py +212 -63
- ocpp/apps.py +1 -1
- ocpp/consumers.py +642 -68
- ocpp/evcs.py +30 -10
- ocpp/models.py +452 -70
- ocpp/simulator.py +75 -11
- ocpp/store.py +288 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1576 -137
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +701 -123
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +39 -6
- pages/forms.py +131 -0
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +1182 -42
- pages/urls.py +4 -0
- pages/utils.py +0 -1
- pages/views.py +844 -51
- arthexis-0.1.8.dist-info/RECORD +0 -80
- arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
- config/workgroup_app.py +0 -7
- core/checks.py +0 -29
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
core/admin.py
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
from django import forms
|
|
2
2
|
from django.contrib import admin
|
|
3
3
|
from django.contrib.admin.widgets import RelatedFieldWidgetWrapper
|
|
4
|
-
from django.urls import path, reverse
|
|
4
|
+
from django.urls import NoReverseMatch, path, reverse
|
|
5
|
+
from urllib.parse import urlencode
|
|
5
6
|
from django.shortcuts import redirect, render
|
|
6
7
|
from django.http import JsonResponse, HttpResponseBase, HttpResponseRedirect
|
|
7
8
|
from django.template.response import TemplateResponse
|
|
9
|
+
from django.conf import settings
|
|
8
10
|
from django.views.decorators.csrf import csrf_exempt
|
|
9
11
|
from django.core.exceptions import ValidationError
|
|
12
|
+
from django.core.validators import EmailValidator
|
|
10
13
|
from django.contrib import messages
|
|
11
14
|
from django.contrib.auth import get_user_model
|
|
12
15
|
from django.contrib.auth.admin import (
|
|
@@ -19,54 +22,98 @@ from import_export.widgets import ForeignKeyWidget
|
|
|
19
22
|
from django.contrib.auth.models import Group
|
|
20
23
|
from django.templatetags.static import static
|
|
21
24
|
from django.utils.html import format_html
|
|
25
|
+
from django.utils.translation import gettext_lazy as _
|
|
26
|
+
from django.forms.models import BaseInlineFormSet
|
|
22
27
|
import json
|
|
23
28
|
import uuid
|
|
24
29
|
import requests
|
|
30
|
+
import datetime
|
|
31
|
+
import calendar
|
|
32
|
+
import re
|
|
25
33
|
from django_object_actions import DjangoObjectActions
|
|
26
|
-
from .
|
|
34
|
+
from ocpp.models import Transaction
|
|
35
|
+
from nodes.models import EmailOutbox
|
|
27
36
|
from .models import (
|
|
28
37
|
User,
|
|
38
|
+
UserPhoneNumber,
|
|
29
39
|
EnergyAccount,
|
|
30
40
|
ElectricVehicle,
|
|
31
|
-
EnergyCredit,
|
|
32
|
-
Address,
|
|
33
|
-
Product,
|
|
34
|
-
Subscription,
|
|
35
41
|
Brand,
|
|
36
|
-
WMICode,
|
|
37
42
|
EVModel,
|
|
43
|
+
WMICode,
|
|
44
|
+
EnergyCredit,
|
|
45
|
+
ClientReport,
|
|
46
|
+
ClientReportSchedule,
|
|
47
|
+
Product,
|
|
38
48
|
RFID,
|
|
49
|
+
SigilRoot,
|
|
50
|
+
CustomSigil,
|
|
39
51
|
Reference,
|
|
40
52
|
OdooProfile,
|
|
41
|
-
|
|
42
|
-
|
|
53
|
+
EmailInbox,
|
|
54
|
+
EmailCollector,
|
|
43
55
|
Package,
|
|
44
56
|
PackageRelease,
|
|
45
57
|
ReleaseManager,
|
|
46
58
|
SecurityGroup,
|
|
47
59
|
InviteLead,
|
|
48
|
-
|
|
60
|
+
PublicWifiAccess,
|
|
61
|
+
AssistantProfile,
|
|
62
|
+
Todo,
|
|
63
|
+
hash_key,
|
|
49
64
|
)
|
|
50
|
-
from .user_data import
|
|
65
|
+
from .user_data import (
|
|
66
|
+
EntityModelAdmin,
|
|
67
|
+
UserDatumAdminMixin,
|
|
68
|
+
delete_user_fixture,
|
|
69
|
+
dump_user_fixture,
|
|
70
|
+
_fixture_path,
|
|
71
|
+
_resolve_fixture_user,
|
|
72
|
+
_user_allows_user_data,
|
|
73
|
+
)
|
|
74
|
+
from .widgets import OdooProductWidget
|
|
75
|
+
from .mcp import process as mcp_process
|
|
51
76
|
|
|
52
77
|
|
|
53
78
|
admin.site.unregister(Group)
|
|
54
79
|
|
|
55
80
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
81
|
+
def _append_operate_as(fieldsets):
|
|
82
|
+
updated = []
|
|
83
|
+
for name, options in fieldsets:
|
|
84
|
+
opts = options.copy()
|
|
85
|
+
fields = opts.get("fields")
|
|
86
|
+
if fields and "is_staff" in fields and "operate_as" not in fields:
|
|
87
|
+
if not isinstance(fields, (list, tuple)):
|
|
88
|
+
fields = list(fields)
|
|
89
|
+
else:
|
|
90
|
+
fields = list(fields)
|
|
91
|
+
fields.append("operate_as")
|
|
92
|
+
opts["fields"] = tuple(fields)
|
|
93
|
+
updated.append((name, opts))
|
|
94
|
+
return tuple(updated)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Add object links for small datasets in changelist view
|
|
98
|
+
original_changelist_view = admin.ModelAdmin.changelist_view
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def changelist_view_with_object_links(self, request, extra_context=None):
|
|
102
|
+
extra_context = extra_context or {}
|
|
103
|
+
count = self.model._default_manager.count()
|
|
104
|
+
if 1 <= count <= 4:
|
|
105
|
+
links = []
|
|
106
|
+
for obj in self.model._default_manager.all():
|
|
107
|
+
url = reverse(
|
|
108
|
+
f"admin:{self.model._meta.app_label}_{self.model._meta.model_name}_change",
|
|
109
|
+
args=[obj.pk],
|
|
110
|
+
)
|
|
111
|
+
links.append({"url": url, "label": str(obj)})
|
|
112
|
+
extra_context["global_object_links"] = links
|
|
113
|
+
return original_changelist_view(self, request, extra_context=extra_context)
|
|
62
114
|
|
|
63
115
|
|
|
64
|
-
|
|
65
|
-
class Meta:
|
|
66
|
-
proxy = True
|
|
67
|
-
app_label = "post_office"
|
|
68
|
-
verbose_name = SecurityGroup._meta.verbose_name
|
|
69
|
-
verbose_name_plural = SecurityGroup._meta.verbose_name_plural
|
|
116
|
+
admin.ModelAdmin.changelist_view = changelist_view_with_object_links
|
|
70
117
|
|
|
71
118
|
|
|
72
119
|
class ExperienceReference(Reference):
|
|
@@ -77,7 +124,40 @@ class ExperienceReference(Reference):
|
|
|
77
124
|
verbose_name_plural = Reference._meta.verbose_name_plural
|
|
78
125
|
|
|
79
126
|
|
|
127
|
+
class CustomSigilAdminForm(forms.ModelForm):
|
|
128
|
+
class Meta:
|
|
129
|
+
model = CustomSigil
|
|
130
|
+
fields = ["prefix", "content_type"]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@admin.register(CustomSigil)
|
|
134
|
+
class CustomSigilAdmin(EntityModelAdmin):
|
|
135
|
+
form = CustomSigilAdminForm
|
|
136
|
+
list_display = ("prefix", "content_type")
|
|
137
|
+
|
|
138
|
+
def get_queryset(self, request):
|
|
139
|
+
qs = super().get_queryset(request)
|
|
140
|
+
return qs.filter(context_type=SigilRoot.Context.ENTITY)
|
|
141
|
+
|
|
142
|
+
def save_model(self, request, obj, form, change):
|
|
143
|
+
obj.context_type = SigilRoot.Context.ENTITY
|
|
144
|
+
super().save_model(request, obj, form, change)
|
|
145
|
+
|
|
146
|
+
|
|
80
147
|
class SaveBeforeChangeAction(DjangoObjectActions):
|
|
148
|
+
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
|
149
|
+
extra_context = extra_context or {}
|
|
150
|
+
extra_context.update(
|
|
151
|
+
{
|
|
152
|
+
"objectactions": [
|
|
153
|
+
self._get_tool_dict(action)
|
|
154
|
+
for action in self.get_change_actions(request, object_id, form_url)
|
|
155
|
+
],
|
|
156
|
+
"tools_view_name": self.tools_view_name,
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
return super().changeform_view(request, object_id, form_url, extra_context)
|
|
160
|
+
|
|
81
161
|
def response_change(self, request, obj):
|
|
82
162
|
action = request.POST.get("_action")
|
|
83
163
|
if action:
|
|
@@ -90,13 +170,99 @@ class SaveBeforeChangeAction(DjangoObjectActions):
|
|
|
90
170
|
return super().response_change(request, obj)
|
|
91
171
|
|
|
92
172
|
|
|
173
|
+
class ProfileAdminMixin:
|
|
174
|
+
"""Reusable actions for profile-bound admin classes."""
|
|
175
|
+
|
|
176
|
+
def _resolve_my_profile_target(self, request):
|
|
177
|
+
opts = self.model._meta
|
|
178
|
+
changelist_url = reverse(
|
|
179
|
+
f"admin:{opts.app_label}_{opts.model_name}_changelist"
|
|
180
|
+
)
|
|
181
|
+
user = getattr(request, "user", None)
|
|
182
|
+
if not getattr(user, "is_authenticated", False):
|
|
183
|
+
return (
|
|
184
|
+
changelist_url,
|
|
185
|
+
_("You must be logged in to manage your profile."),
|
|
186
|
+
messages.ERROR,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
profile = self.model._default_manager.filter(user=user).first()
|
|
190
|
+
if profile is not None:
|
|
191
|
+
permission_check = getattr(self, "has_view_or_change_permission", None)
|
|
192
|
+
has_permission = (
|
|
193
|
+
permission_check(request, obj=profile)
|
|
194
|
+
if callable(permission_check)
|
|
195
|
+
else self.has_change_permission(request, obj=profile)
|
|
196
|
+
)
|
|
197
|
+
if has_permission:
|
|
198
|
+
change_url = reverse(
|
|
199
|
+
f"admin:{opts.app_label}_{opts.model_name}_change",
|
|
200
|
+
args=[profile.pk],
|
|
201
|
+
)
|
|
202
|
+
return change_url, None, None
|
|
203
|
+
return (
|
|
204
|
+
changelist_url,
|
|
205
|
+
_("You do not have permission to view this profile."),
|
|
206
|
+
messages.ERROR,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if self.has_add_permission(request):
|
|
210
|
+
add_url = reverse(f"admin:{opts.app_label}_{opts.model_name}_add")
|
|
211
|
+
params = {}
|
|
212
|
+
user_id = getattr(user, "pk", None)
|
|
213
|
+
if user_id:
|
|
214
|
+
params["user"] = user_id
|
|
215
|
+
if params:
|
|
216
|
+
add_url = f"{add_url}?{urlencode(params)}"
|
|
217
|
+
return add_url, None, None
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
changelist_url,
|
|
221
|
+
_("You do not have permission to create this profile."),
|
|
222
|
+
messages.ERROR,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def get_my_profile_url(self, request):
|
|
226
|
+
url, _message, _level = self._resolve_my_profile_target(request)
|
|
227
|
+
return url
|
|
228
|
+
|
|
229
|
+
def _redirect_to_my_profile(self, request):
|
|
230
|
+
target_url, message, level = self._resolve_my_profile_target(request)
|
|
231
|
+
if message:
|
|
232
|
+
self.message_user(request, message, level=level)
|
|
233
|
+
return HttpResponseRedirect(target_url)
|
|
234
|
+
|
|
235
|
+
@admin.action(description=_("Active Profile"))
|
|
236
|
+
def my_profile(self, request, queryset=None):
|
|
237
|
+
return self._redirect_to_my_profile(request)
|
|
238
|
+
|
|
239
|
+
def my_profile_action(self, request, obj=None):
|
|
240
|
+
return self._redirect_to_my_profile(request)
|
|
241
|
+
|
|
242
|
+
my_profile_action.label = _("Active Profile")
|
|
243
|
+
my_profile_action.short_description = _("Active Profile")
|
|
244
|
+
|
|
245
|
+
def get_actions(self, request):
|
|
246
|
+
actions = super().get_actions(request)
|
|
247
|
+
if "my_profile" not in actions:
|
|
248
|
+
action = getattr(self, "my_profile", None)
|
|
249
|
+
if action is not None:
|
|
250
|
+
actions["my_profile"] = (
|
|
251
|
+
action,
|
|
252
|
+
"my_profile",
|
|
253
|
+
getattr(action, "short_description", _("Active Profile")),
|
|
254
|
+
)
|
|
255
|
+
return actions
|
|
256
|
+
|
|
257
|
+
|
|
93
258
|
@admin.register(ExperienceReference)
|
|
94
|
-
class ReferenceAdmin(
|
|
259
|
+
class ReferenceAdmin(EntityModelAdmin):
|
|
95
260
|
list_display = (
|
|
96
261
|
"alt_text",
|
|
97
262
|
"content_type",
|
|
98
|
-
"
|
|
99
|
-
"
|
|
263
|
+
"header",
|
|
264
|
+
"footer",
|
|
265
|
+
"visibility",
|
|
100
266
|
"author",
|
|
101
267
|
"transaction_uuid",
|
|
102
268
|
)
|
|
@@ -107,13 +273,18 @@ class ReferenceAdmin(admin.ModelAdmin):
|
|
|
107
273
|
"value",
|
|
108
274
|
"file",
|
|
109
275
|
"method",
|
|
276
|
+
"roles",
|
|
277
|
+
"features",
|
|
278
|
+
"sites",
|
|
110
279
|
"include_in_footer",
|
|
280
|
+
"show_in_header",
|
|
111
281
|
"footer_visibility",
|
|
112
282
|
"transaction_uuid",
|
|
113
283
|
"author",
|
|
114
284
|
"uses",
|
|
115
285
|
"qr_code",
|
|
116
286
|
)
|
|
287
|
+
filter_horizontal = ("roles", "features", "sites")
|
|
117
288
|
|
|
118
289
|
def get_readonly_fields(self, request, obj=None):
|
|
119
290
|
ro = list(super().get_readonly_fields(request, obj))
|
|
@@ -121,6 +292,18 @@ class ReferenceAdmin(admin.ModelAdmin):
|
|
|
121
292
|
ro.append("transaction_uuid")
|
|
122
293
|
return ro
|
|
123
294
|
|
|
295
|
+
@admin.display(description="Footer", boolean=True, ordering="include_in_footer")
|
|
296
|
+
def footer(self, obj):
|
|
297
|
+
return obj.include_in_footer
|
|
298
|
+
|
|
299
|
+
@admin.display(description="Header", boolean=True, ordering="show_in_header")
|
|
300
|
+
def header(self, obj):
|
|
301
|
+
return obj.show_in_header
|
|
302
|
+
|
|
303
|
+
@admin.display(description="Visibility", ordering="footer_visibility")
|
|
304
|
+
def visibility(self, obj):
|
|
305
|
+
return obj.get_footer_visibility_display()
|
|
306
|
+
|
|
124
307
|
def get_urls(self):
|
|
125
308
|
urls = super().get_urls()
|
|
126
309
|
custom = [
|
|
@@ -166,13 +349,86 @@ class ReferenceAdmin(admin.ModelAdmin):
|
|
|
166
349
|
qr_code.short_description = "QR Code"
|
|
167
350
|
|
|
168
351
|
|
|
169
|
-
|
|
170
|
-
class
|
|
171
|
-
|
|
352
|
+
class ReleaseManagerAdminForm(forms.ModelForm):
|
|
353
|
+
class Meta:
|
|
354
|
+
model = ReleaseManager
|
|
355
|
+
fields = "__all__"
|
|
356
|
+
widgets = {
|
|
357
|
+
"pypi_token": forms.Textarea(attrs={"rows": 3, "style": "width: 40em;"}),
|
|
358
|
+
"github_token": forms.Textarea(attrs={"rows": 3, "style": "width: 40em;"}),
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@admin.register(ReleaseManager)
|
|
363
|
+
class ReleaseManagerAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin):
|
|
364
|
+
form = ReleaseManagerAdminForm
|
|
365
|
+
list_display = ("owner", "pypi_username", "pypi_url")
|
|
366
|
+
actions = ["test_credentials"]
|
|
367
|
+
change_actions = ["test_credentials_action", "my_profile_action"]
|
|
368
|
+
changelist_actions = ["my_profile"]
|
|
369
|
+
fieldsets = (
|
|
370
|
+
("Owner", {"fields": ("user", "group")}),
|
|
371
|
+
(
|
|
372
|
+
"Credentials",
|
|
373
|
+
{
|
|
374
|
+
"fields": (
|
|
375
|
+
"pypi_username",
|
|
376
|
+
"pypi_token",
|
|
377
|
+
"pypi_password",
|
|
378
|
+
"github_token",
|
|
379
|
+
"pypi_url",
|
|
380
|
+
)
|
|
381
|
+
},
|
|
382
|
+
),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
def owner(self, obj):
|
|
386
|
+
return obj.owner_display()
|
|
387
|
+
|
|
388
|
+
owner.short_description = "Owner"
|
|
389
|
+
|
|
390
|
+
@admin.action(description="Test credentials")
|
|
391
|
+
def test_credentials(self, request, queryset):
|
|
392
|
+
for manager in queryset:
|
|
393
|
+
self._test_credentials(request, manager)
|
|
394
|
+
|
|
395
|
+
def test_credentials_action(self, request, obj):
|
|
396
|
+
self._test_credentials(request, obj)
|
|
397
|
+
|
|
398
|
+
test_credentials_action.label = "Test credentials"
|
|
399
|
+
test_credentials_action.short_description = "Test credentials"
|
|
400
|
+
|
|
401
|
+
def _test_credentials(self, request, manager):
|
|
402
|
+
creds = manager.to_credentials()
|
|
403
|
+
if not creds:
|
|
404
|
+
self.message_user(request, f"{manager} has no credentials", messages.ERROR)
|
|
405
|
+
return
|
|
406
|
+
url = manager.pypi_url or "https://upload.pypi.org/legacy/"
|
|
407
|
+
auth = (
|
|
408
|
+
("__token__", creds.token)
|
|
409
|
+
if creds.token
|
|
410
|
+
else (creds.username, creds.password)
|
|
411
|
+
)
|
|
412
|
+
try:
|
|
413
|
+
resp = requests.get(url, auth=auth, timeout=10)
|
|
414
|
+
if resp.ok:
|
|
415
|
+
self.message_user(
|
|
416
|
+
request, f"{manager} credentials valid", messages.SUCCESS
|
|
417
|
+
)
|
|
418
|
+
else:
|
|
419
|
+
self.message_user(
|
|
420
|
+
request,
|
|
421
|
+
f"{manager} credentials invalid ({resp.status_code})",
|
|
422
|
+
messages.ERROR,
|
|
423
|
+
)
|
|
424
|
+
except Exception as exc: # pragma: no cover - admin feedback
|
|
425
|
+
self.message_user(
|
|
426
|
+
request, f"{manager} credentials check failed: {exc}", messages.ERROR
|
|
427
|
+
)
|
|
172
428
|
|
|
173
429
|
|
|
174
430
|
@admin.register(Package)
|
|
175
|
-
class PackageAdmin(SaveBeforeChangeAction,
|
|
431
|
+
class PackageAdmin(SaveBeforeChangeAction, EntityModelAdmin):
|
|
176
432
|
list_display = (
|
|
177
433
|
"name",
|
|
178
434
|
"description",
|
|
@@ -180,7 +436,6 @@ class PackageAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
|
|
|
180
436
|
"release_manager",
|
|
181
437
|
"is_active",
|
|
182
438
|
)
|
|
183
|
-
actions = ["prepare_next_release"]
|
|
184
439
|
change_actions = ["prepare_next_release_action"]
|
|
185
440
|
|
|
186
441
|
def _prepare(self, request, package):
|
|
@@ -188,25 +443,36 @@ class PackageAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
|
|
|
188
443
|
from packaging.version import Version
|
|
189
444
|
|
|
190
445
|
ver_file = Path("VERSION")
|
|
191
|
-
repo_version =
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
Version(
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
446
|
+
repo_version = (
|
|
447
|
+
Version(ver_file.read_text().strip())
|
|
448
|
+
if ver_file.exists()
|
|
449
|
+
else Version("0.0.0")
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
pypi_latest = Version("0.0.0")
|
|
453
|
+
try:
|
|
454
|
+
resp = requests.get(
|
|
455
|
+
f"https://pypi.org/pypi/{package.name}/json", timeout=10
|
|
456
|
+
)
|
|
457
|
+
if resp.ok:
|
|
458
|
+
releases = resp.json().get("releases", {})
|
|
459
|
+
if releases:
|
|
460
|
+
pypi_latest = max(Version(v) for v in releases)
|
|
461
|
+
except Exception:
|
|
462
|
+
pass
|
|
463
|
+
pypi_plus_one = Version(
|
|
464
|
+
f"{pypi_latest.major}.{pypi_latest.minor}.{pypi_latest.micro + 1}"
|
|
465
|
+
)
|
|
466
|
+
next_version = max(repo_version, pypi_plus_one)
|
|
199
467
|
release, _created = PackageRelease.all_objects.update_or_create(
|
|
200
468
|
package=package,
|
|
201
|
-
version=next_version,
|
|
469
|
+
version=str(next_version),
|
|
202
470
|
defaults={
|
|
203
471
|
"release_manager": package.release_manager,
|
|
204
472
|
"is_deleted": False,
|
|
205
473
|
},
|
|
206
474
|
)
|
|
207
|
-
return redirect(
|
|
208
|
-
reverse("admin:core_packagerelease_change", args=[release.pk])
|
|
209
|
-
)
|
|
475
|
+
return redirect(reverse("admin:core_packagerelease_change", args=[release.pk]))
|
|
210
476
|
|
|
211
477
|
def get_urls(self):
|
|
212
478
|
urls = super().get_urls()
|
|
@@ -226,15 +492,6 @@ class PackageAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
|
|
|
226
492
|
return redirect("admin:core_package_changelist")
|
|
227
493
|
return self._prepare(request, package)
|
|
228
494
|
|
|
229
|
-
@admin.action(description="Prepare next Release")
|
|
230
|
-
def prepare_next_release(self, request, queryset):
|
|
231
|
-
if queryset.count() != 1:
|
|
232
|
-
self.message_user(
|
|
233
|
-
request, "Select exactly one package", messages.ERROR
|
|
234
|
-
)
|
|
235
|
-
return
|
|
236
|
-
return self._prepare(request, queryset.first())
|
|
237
|
-
|
|
238
495
|
def prepare_next_release_action(self, request, obj):
|
|
239
496
|
return self._prepare(request, obj)
|
|
240
497
|
|
|
@@ -250,7 +507,7 @@ class SecurityGroupAdminForm(forms.ModelForm):
|
|
|
250
507
|
)
|
|
251
508
|
|
|
252
509
|
class Meta:
|
|
253
|
-
model =
|
|
510
|
+
model = SecurityGroup
|
|
254
511
|
fields = "__all__"
|
|
255
512
|
|
|
256
513
|
def __init__(self, *args, **kwargs):
|
|
@@ -268,16 +525,14 @@ class SecurityGroupAdminForm(forms.ModelForm):
|
|
|
268
525
|
return instance
|
|
269
526
|
|
|
270
527
|
|
|
271
|
-
@admin.register(WorkgroupSecurityGroup)
|
|
272
528
|
class SecurityGroupAdmin(DjangoGroupAdmin):
|
|
273
529
|
form = SecurityGroupAdminForm
|
|
274
530
|
fieldsets = ((None, {"fields": ("name", "parent", "users", "permissions")}),)
|
|
275
531
|
filter_horizontal = ("permissions",)
|
|
276
532
|
|
|
277
533
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
list_display = ("email", "created_on")
|
|
534
|
+
class InviteLeadAdmin(EntityModelAdmin):
|
|
535
|
+
list_display = ("email", "mac_address", "created_on", "sent_on", "short_error")
|
|
281
536
|
search_fields = ("email", "comment")
|
|
282
537
|
readonly_fields = (
|
|
283
538
|
"created_on",
|
|
@@ -286,8 +541,24 @@ class InviteLeadAdmin(admin.ModelAdmin):
|
|
|
286
541
|
"referer",
|
|
287
542
|
"user_agent",
|
|
288
543
|
"ip_address",
|
|
544
|
+
"mac_address",
|
|
545
|
+
"sent_on",
|
|
546
|
+
"error",
|
|
289
547
|
)
|
|
290
548
|
|
|
549
|
+
def short_error(self, obj):
|
|
550
|
+
return (obj.error[:40] + "…") if len(obj.error) > 40 else obj.error
|
|
551
|
+
|
|
552
|
+
short_error.short_description = "error"
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@admin.register(PublicWifiAccess)
|
|
556
|
+
class PublicWifiAccessAdmin(EntityModelAdmin):
|
|
557
|
+
list_display = ("user", "mac_address", "created_on", "revoked_on")
|
|
558
|
+
search_fields = ("user__username", "mac_address")
|
|
559
|
+
readonly_fields = ("user", "mac_address", "created_on", "updated_on", "revoked_on")
|
|
560
|
+
ordering = ("-created_on",)
|
|
561
|
+
|
|
291
562
|
|
|
292
563
|
class EnergyAccountRFIDForm(forms.ModelForm):
|
|
293
564
|
"""Form for assigning existing RFIDs to an energy account."""
|
|
@@ -299,7 +570,9 @@ class EnergyAccountRFIDForm(forms.ModelForm):
|
|
|
299
570
|
def clean_rfid(self):
|
|
300
571
|
rfid = self.cleaned_data["rfid"]
|
|
301
572
|
if rfid.energy_accounts.exclude(pk=self.instance.energyaccount_id).exists():
|
|
302
|
-
raise forms.ValidationError(
|
|
573
|
+
raise forms.ValidationError(
|
|
574
|
+
"RFID is already assigned to another energy account"
|
|
575
|
+
)
|
|
303
576
|
return rfid
|
|
304
577
|
|
|
305
578
|
|
|
@@ -312,27 +585,53 @@ class EnergyAccountRFIDInline(admin.TabularInline):
|
|
|
312
585
|
verbose_name_plural = "RFIDs"
|
|
313
586
|
|
|
314
587
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
("Contact", {"fields": ("phone_number", "address", "has_charger")}),
|
|
318
|
-
)
|
|
319
|
-
add_fieldsets = DjangoUserAdmin.add_fieldsets + (
|
|
320
|
-
("Contact", {"fields": ("phone_number", "address", "has_charger")}),
|
|
321
|
-
)
|
|
588
|
+
def _raw_instance_value(instance, field_name):
|
|
589
|
+
"""Return the stored value for ``field_name`` without resolving sigils."""
|
|
322
590
|
|
|
591
|
+
field = instance._meta.get_field(field_name)
|
|
592
|
+
if not instance.pk:
|
|
593
|
+
return field.value_from_object(instance)
|
|
594
|
+
manager = type(instance)._default_manager
|
|
595
|
+
try:
|
|
596
|
+
return (
|
|
597
|
+
manager.filter(pk=instance.pk).values_list(field.attname, flat=True).get()
|
|
598
|
+
)
|
|
599
|
+
except type(instance).DoesNotExist: # pragma: no cover - instance deleted
|
|
600
|
+
return field.value_from_object(instance)
|
|
323
601
|
|
|
324
|
-
@admin.register(Address)
|
|
325
|
-
class AddressAdmin(UserDatumAdminMixin, admin.ModelAdmin):
|
|
326
|
-
change_form_template = "admin/user_datum_change_form.html"
|
|
327
|
-
list_display = ("street", "number", "municipality", "state", "postal_code")
|
|
328
|
-
search_fields = ("street", "municipality", "postal_code")
|
|
329
602
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
603
|
+
class KeepExistingValue:
|
|
604
|
+
"""Sentinel indicating a field should retain its stored value."""
|
|
605
|
+
|
|
606
|
+
__slots__ = ("field",)
|
|
607
|
+
|
|
608
|
+
def __init__(self, field: str):
|
|
609
|
+
self.field = field
|
|
610
|
+
|
|
611
|
+
def __bool__(self) -> bool: # pragma: no cover - trivial
|
|
612
|
+
return False
|
|
613
|
+
|
|
614
|
+
def __repr__(self) -> str: # pragma: no cover - debugging helper
|
|
615
|
+
return f"<KeepExistingValue field={self.field!r}>"
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def keep_existing(field: str) -> KeepExistingValue:
|
|
619
|
+
return KeepExistingValue(field)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _restore_sigil_values(form, field_names):
|
|
623
|
+
"""Reset sigil fields on ``form.instance`` to their raw form values."""
|
|
624
|
+
|
|
625
|
+
for name in field_names:
|
|
626
|
+
if name not in form.fields:
|
|
627
|
+
continue
|
|
628
|
+
if name in form.cleaned_data:
|
|
629
|
+
raw = form.cleaned_data[name]
|
|
630
|
+
if isinstance(raw, KeepExistingValue):
|
|
631
|
+
raw = _raw_instance_value(form.instance, name)
|
|
334
632
|
else:
|
|
335
|
-
|
|
633
|
+
raw = _raw_instance_value(form.instance, name)
|
|
634
|
+
setattr(form.instance, name, raw)
|
|
336
635
|
|
|
337
636
|
|
|
338
637
|
class OdooProfileAdminForm(forms.ModelForm):
|
|
@@ -359,103 +658,201 @@ class OdooProfileAdminForm(forms.ModelForm):
|
|
|
359
658
|
def clean_password(self):
|
|
360
659
|
pwd = self.cleaned_data.get("password")
|
|
361
660
|
if not pwd and self.instance.pk:
|
|
362
|
-
return
|
|
661
|
+
return keep_existing("password")
|
|
363
662
|
return pwd
|
|
364
663
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
readonly_fields = ("verified_on", "odoo_uid", "name", "email")
|
|
372
|
-
actions = ["verify_credentials"]
|
|
373
|
-
fieldsets = (
|
|
374
|
-
(None, {"fields": ("user", "host", "database", "username", "password")}),
|
|
375
|
-
("Odoo", {"fields": ("verified_on", "odoo_uid", "name", "email")}),
|
|
376
|
-
)
|
|
377
|
-
|
|
378
|
-
@admin.action(description="Test selected credentials")
|
|
379
|
-
def verify_credentials(self, request, queryset):
|
|
380
|
-
for profile in queryset:
|
|
381
|
-
try:
|
|
382
|
-
profile.verify()
|
|
383
|
-
self.message_user(request, f"{profile.user} verified")
|
|
384
|
-
except Exception as exc: # pragma: no cover - admin feedback
|
|
385
|
-
self.message_user(
|
|
386
|
-
request, f"{profile.user}: {exc}", level=messages.ERROR
|
|
387
|
-
)
|
|
664
|
+
def _post_clean(self):
|
|
665
|
+
super()._post_clean()
|
|
666
|
+
_restore_sigil_values(
|
|
667
|
+
self,
|
|
668
|
+
["host", "database", "username", "password"],
|
|
669
|
+
)
|
|
388
670
|
|
|
389
671
|
|
|
390
|
-
class
|
|
391
|
-
"""Admin form for :class:`core.models.
|
|
672
|
+
class EmailInboxAdminForm(forms.ModelForm):
|
|
673
|
+
"""Admin form for :class:`core.models.EmailInbox` with hidden password."""
|
|
392
674
|
|
|
393
|
-
|
|
675
|
+
password = forms.CharField(
|
|
394
676
|
widget=forms.PasswordInput(render_value=True),
|
|
395
677
|
required=False,
|
|
396
|
-
help_text="Leave blank to keep the current
|
|
678
|
+
help_text="Leave blank to keep the current password.",
|
|
397
679
|
)
|
|
398
680
|
|
|
399
681
|
class Meta:
|
|
400
|
-
model =
|
|
682
|
+
model = EmailInbox
|
|
401
683
|
fields = "__all__"
|
|
402
684
|
|
|
403
685
|
def __init__(self, *args, **kwargs):
|
|
404
686
|
super().__init__(*args, **kwargs)
|
|
405
687
|
if self.instance.pk:
|
|
406
|
-
self.fields["
|
|
407
|
-
self.initial["
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
)
|
|
688
|
+
self.fields["password"].initial = ""
|
|
689
|
+
self.initial["password"] = ""
|
|
690
|
+
else:
|
|
691
|
+
self.fields["password"].required = True
|
|
692
|
+
|
|
693
|
+
def clean_password(self):
|
|
694
|
+
pwd = self.cleaned_data.get("password")
|
|
695
|
+
if not pwd and self.instance.pk:
|
|
696
|
+
return keep_existing("password")
|
|
697
|
+
return pwd
|
|
698
|
+
|
|
699
|
+
def _post_clean(self):
|
|
700
|
+
super()._post_clean()
|
|
701
|
+
_restore_sigil_values(
|
|
702
|
+
self,
|
|
703
|
+
["username", "host", "password", "protocol"],
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
class ProfileInlineFormSet(BaseInlineFormSet):
|
|
708
|
+
"""Hide deletion controls and allow implicit removal when empty."""
|
|
709
|
+
|
|
710
|
+
@classmethod
|
|
711
|
+
def get_default_prefix(cls):
|
|
712
|
+
prefix = super().get_default_prefix()
|
|
713
|
+
if prefix:
|
|
714
|
+
return prefix
|
|
715
|
+
model_name = cls.model._meta.model_name
|
|
716
|
+
remote_field = getattr(cls.fk, "remote_field", None)
|
|
717
|
+
if remote_field is not None and getattr(remote_field, "one_to_one", False):
|
|
718
|
+
return model_name
|
|
719
|
+
return f"{model_name}_set"
|
|
720
|
+
|
|
721
|
+
def add_fields(self, form, index):
|
|
722
|
+
super().add_fields(form, index)
|
|
723
|
+
if "DELETE" in form.fields:
|
|
724
|
+
form.fields["DELETE"].widget = forms.HiddenInput()
|
|
725
|
+
form.fields["DELETE"].required = False
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _title_case(value):
|
|
729
|
+
text = str(value or "")
|
|
730
|
+
return " ".join(
|
|
731
|
+
word[:1].upper() + word[1:] if word else word for word in text.split()
|
|
436
732
|
)
|
|
437
733
|
|
|
438
|
-
@admin.action(description="Test selected profiles")
|
|
439
|
-
def test_connection(self, request, queryset):
|
|
440
|
-
for profile in queryset:
|
|
441
|
-
try:
|
|
442
|
-
profile.test_connection()
|
|
443
|
-
self.message_user(request, f"{profile} connection successful")
|
|
444
|
-
except Exception as exc: # pragma: no cover - admin feedback
|
|
445
|
-
self.message_user(request, f"{profile}: {exc}", level=messages.ERROR)
|
|
446
734
|
|
|
735
|
+
class ProfileFormMixin(forms.ModelForm):
|
|
736
|
+
"""Mark profiles for deletion when no data is provided."""
|
|
447
737
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
738
|
+
profile_fields: tuple[str, ...] = ()
|
|
739
|
+
user_datum = forms.BooleanField(
|
|
740
|
+
required=False,
|
|
741
|
+
label=_("User Datum"),
|
|
742
|
+
help_text=_("Store this profile in the user's data directory."),
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
def __init__(self, *args, **kwargs):
|
|
746
|
+
super().__init__(*args, **kwargs)
|
|
747
|
+
model_fields = getattr(self._meta.model, "profile_fields", tuple())
|
|
748
|
+
explicit = getattr(self, "profile_fields", tuple())
|
|
749
|
+
self._profile_fields = tuple(explicit or model_fields)
|
|
750
|
+
for name in self._profile_fields:
|
|
751
|
+
field = self.fields.get(name)
|
|
752
|
+
if field is not None:
|
|
753
|
+
field.required = False
|
|
754
|
+
if "user_datum" in self.fields:
|
|
755
|
+
self.fields["user_datum"].initial = getattr(
|
|
756
|
+
self.instance, "is_user_data", False
|
|
757
|
+
)
|
|
454
758
|
|
|
759
|
+
@staticmethod
|
|
760
|
+
def _is_empty_value(value) -> bool:
|
|
761
|
+
if isinstance(value, KeepExistingValue):
|
|
762
|
+
return True
|
|
763
|
+
if isinstance(value, bool):
|
|
764
|
+
return not value
|
|
765
|
+
if value in (None, "", [], (), {}, set()):
|
|
766
|
+
return True
|
|
767
|
+
if isinstance(value, str):
|
|
768
|
+
return value.strip() == ""
|
|
769
|
+
return False
|
|
770
|
+
|
|
771
|
+
def _has_profile_data(self) -> bool:
|
|
772
|
+
for name in self._profile_fields:
|
|
773
|
+
field = self.fields.get(name)
|
|
774
|
+
raw_value = None
|
|
775
|
+
if field is not None and not isinstance(field, forms.BooleanField):
|
|
776
|
+
try:
|
|
777
|
+
if hasattr(self, "_raw_value"):
|
|
778
|
+
raw_value = self._raw_value(name)
|
|
779
|
+
elif self.is_bound:
|
|
780
|
+
bound = self[name]
|
|
781
|
+
raw_value = bound.field.widget.value_from_datadict(
|
|
782
|
+
self.data,
|
|
783
|
+
self.files,
|
|
784
|
+
bound.html_name,
|
|
785
|
+
)
|
|
786
|
+
except (AttributeError, KeyError):
|
|
787
|
+
raw_value = None
|
|
788
|
+
if raw_value is not None:
|
|
789
|
+
if not isinstance(raw_value, (list, tuple)):
|
|
790
|
+
values = [raw_value]
|
|
791
|
+
else:
|
|
792
|
+
values = raw_value
|
|
793
|
+
if any(not self._is_empty_value(value) for value in values):
|
|
794
|
+
return True
|
|
795
|
+
# When raw form data is present but empty (e.g. ""), skip the
|
|
796
|
+
# instance fallback so empty submissions mark the form deleted.
|
|
797
|
+
continue
|
|
455
798
|
|
|
456
|
-
|
|
457
|
-
|
|
799
|
+
if name in self.cleaned_data:
|
|
800
|
+
value = self.cleaned_data.get(name)
|
|
801
|
+
elif hasattr(self.instance, name):
|
|
802
|
+
value = getattr(self.instance, name)
|
|
803
|
+
else:
|
|
804
|
+
continue
|
|
805
|
+
if not self._is_empty_value(value):
|
|
806
|
+
return True
|
|
807
|
+
return False
|
|
808
|
+
|
|
809
|
+
def clean(self):
|
|
810
|
+
cleaned = super().clean()
|
|
811
|
+
if cleaned.get("DELETE") or not self._profile_fields:
|
|
812
|
+
return cleaned
|
|
813
|
+
if not self._has_profile_data():
|
|
814
|
+
cleaned["DELETE"] = True
|
|
815
|
+
return cleaned
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
class OdooProfileInlineForm(ProfileFormMixin, OdooProfileAdminForm):
|
|
819
|
+
profile_fields = OdooProfile.profile_fields
|
|
820
|
+
|
|
821
|
+
class Meta(OdooProfileAdminForm.Meta):
|
|
822
|
+
exclude = ("user", "group", "verified_on", "odoo_uid", "name", "email")
|
|
823
|
+
|
|
824
|
+
def clean(self):
|
|
825
|
+
cleaned = super().clean()
|
|
826
|
+
if cleaned.get("DELETE") or self.errors:
|
|
827
|
+
return cleaned
|
|
828
|
+
|
|
829
|
+
provided = [
|
|
830
|
+
name
|
|
831
|
+
for name in self._profile_fields
|
|
832
|
+
if not self._is_empty_value(cleaned.get(name))
|
|
833
|
+
]
|
|
834
|
+
missing = [
|
|
835
|
+
name
|
|
836
|
+
for name in self._profile_fields
|
|
837
|
+
if self._is_empty_value(cleaned.get(name))
|
|
838
|
+
]
|
|
839
|
+
if provided and missing:
|
|
840
|
+
raise forms.ValidationError(
|
|
841
|
+
"Provide host, database, username, and password to create an Odoo employee.",
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
return cleaned
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
class EmailInboxInlineForm(ProfileFormMixin, EmailInboxAdminForm):
|
|
848
|
+
profile_fields = EmailInbox.profile_fields
|
|
849
|
+
|
|
850
|
+
class Meta(EmailInboxAdminForm.Meta):
|
|
851
|
+
exclude = ("user", "group")
|
|
458
852
|
|
|
853
|
+
|
|
854
|
+
class EmailOutboxInlineForm(ProfileFormMixin, forms.ModelForm):
|
|
855
|
+
profile_fields = EmailOutbox.profile_fields
|
|
459
856
|
password = forms.CharField(
|
|
460
857
|
widget=forms.PasswordInput(render_value=True),
|
|
461
858
|
required=False,
|
|
@@ -463,8 +860,16 @@ class EmailInboxAdminForm(forms.ModelForm):
|
|
|
463
860
|
)
|
|
464
861
|
|
|
465
862
|
class Meta:
|
|
466
|
-
model =
|
|
467
|
-
fields =
|
|
863
|
+
model = EmailOutbox
|
|
864
|
+
fields = (
|
|
865
|
+
"password",
|
|
866
|
+
"host",
|
|
867
|
+
"port",
|
|
868
|
+
"username",
|
|
869
|
+
"use_tls",
|
|
870
|
+
"use_ssl",
|
|
871
|
+
"from_email",
|
|
872
|
+
)
|
|
468
873
|
|
|
469
874
|
def __init__(self, *args, **kwargs):
|
|
470
875
|
super().__init__(*args, **kwargs)
|
|
@@ -477,9 +882,376 @@ class EmailInboxAdminForm(forms.ModelForm):
|
|
|
477
882
|
def clean_password(self):
|
|
478
883
|
pwd = self.cleaned_data.get("password")
|
|
479
884
|
if not pwd and self.instance.pk:
|
|
480
|
-
return
|
|
885
|
+
return keep_existing("password")
|
|
481
886
|
return pwd
|
|
482
887
|
|
|
888
|
+
def _post_clean(self):
|
|
889
|
+
super()._post_clean()
|
|
890
|
+
_restore_sigil_values(
|
|
891
|
+
self,
|
|
892
|
+
["password", "host", "username", "from_email"],
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
class ReleaseManagerInlineForm(ProfileFormMixin, forms.ModelForm):
|
|
897
|
+
profile_fields = ReleaseManager.profile_fields
|
|
898
|
+
|
|
899
|
+
class Meta:
|
|
900
|
+
model = ReleaseManager
|
|
901
|
+
fields = (
|
|
902
|
+
"pypi_username",
|
|
903
|
+
"pypi_token",
|
|
904
|
+
"github_token",
|
|
905
|
+
"pypi_password",
|
|
906
|
+
"pypi_url",
|
|
907
|
+
)
|
|
908
|
+
widgets = {
|
|
909
|
+
"pypi_token": forms.Textarea(attrs={"rows": 3, "style": "width: 40em;"}),
|
|
910
|
+
"github_token": forms.Textarea(attrs={"rows": 3, "style": "width: 40em;"}),
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
class AssistantProfileInlineForm(ProfileFormMixin, forms.ModelForm):
|
|
915
|
+
user_key = forms.CharField(
|
|
916
|
+
required=False,
|
|
917
|
+
widget=forms.PasswordInput(render_value=True),
|
|
918
|
+
help_text="Provide a plain key to create or rotate credentials.",
|
|
919
|
+
)
|
|
920
|
+
profile_fields = ("user_key", "scopes", "is_active")
|
|
921
|
+
|
|
922
|
+
class Meta:
|
|
923
|
+
model = AssistantProfile
|
|
924
|
+
fields = ("scopes", "is_active")
|
|
925
|
+
|
|
926
|
+
def __init__(self, *args, **kwargs):
|
|
927
|
+
super().__init__(*args, **kwargs)
|
|
928
|
+
if not self.instance.pk and "is_active" in self.fields:
|
|
929
|
+
self.fields["is_active"].initial = False
|
|
930
|
+
|
|
931
|
+
def clean(self):
|
|
932
|
+
cleaned = super().clean()
|
|
933
|
+
if cleaned.get("DELETE"):
|
|
934
|
+
return cleaned
|
|
935
|
+
if not self.instance.pk and not cleaned.get("user_key"):
|
|
936
|
+
if cleaned.get("scopes") or cleaned.get("is_active"):
|
|
937
|
+
raise forms.ValidationError(
|
|
938
|
+
"Provide a user key to create an assistant profile."
|
|
939
|
+
)
|
|
940
|
+
return cleaned
|
|
941
|
+
|
|
942
|
+
def save(self, commit=True):
|
|
943
|
+
instance = super().save(commit=False)
|
|
944
|
+
user_key = self.cleaned_data.get("user_key")
|
|
945
|
+
if user_key:
|
|
946
|
+
instance.user_key_hash = hash_key(user_key)
|
|
947
|
+
instance.last_used_at = None
|
|
948
|
+
if commit:
|
|
949
|
+
instance.save()
|
|
950
|
+
self.save_m2m()
|
|
951
|
+
return instance
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
PROFILE_INLINE_CONFIG = {
|
|
955
|
+
OdooProfile: {
|
|
956
|
+
"form": OdooProfileInlineForm,
|
|
957
|
+
"fieldsets": (
|
|
958
|
+
(
|
|
959
|
+
None,
|
|
960
|
+
{
|
|
961
|
+
"fields": (
|
|
962
|
+
"host",
|
|
963
|
+
"database",
|
|
964
|
+
"username",
|
|
965
|
+
"password",
|
|
966
|
+
)
|
|
967
|
+
},
|
|
968
|
+
),
|
|
969
|
+
(
|
|
970
|
+
"Odoo Employee",
|
|
971
|
+
{
|
|
972
|
+
"fields": ("verified_on", "odoo_uid", "name", "email"),
|
|
973
|
+
},
|
|
974
|
+
),
|
|
975
|
+
),
|
|
976
|
+
"readonly_fields": ("verified_on", "odoo_uid", "name", "email"),
|
|
977
|
+
},
|
|
978
|
+
EmailInbox: {
|
|
979
|
+
"form": EmailInboxInlineForm,
|
|
980
|
+
"fields": (
|
|
981
|
+
"username",
|
|
982
|
+
"host",
|
|
983
|
+
"port",
|
|
984
|
+
"password",
|
|
985
|
+
"protocol",
|
|
986
|
+
"use_ssl",
|
|
987
|
+
),
|
|
988
|
+
},
|
|
989
|
+
EmailOutbox: {
|
|
990
|
+
"form": EmailOutboxInlineForm,
|
|
991
|
+
"fields": (
|
|
992
|
+
"password",
|
|
993
|
+
"host",
|
|
994
|
+
"port",
|
|
995
|
+
"username",
|
|
996
|
+
"use_tls",
|
|
997
|
+
"use_ssl",
|
|
998
|
+
"from_email",
|
|
999
|
+
),
|
|
1000
|
+
},
|
|
1001
|
+
ReleaseManager: {
|
|
1002
|
+
"form": ReleaseManagerInlineForm,
|
|
1003
|
+
"fields": (
|
|
1004
|
+
"pypi_username",
|
|
1005
|
+
"pypi_token",
|
|
1006
|
+
"github_token",
|
|
1007
|
+
"pypi_password",
|
|
1008
|
+
"pypi_url",
|
|
1009
|
+
),
|
|
1010
|
+
},
|
|
1011
|
+
AssistantProfile: {
|
|
1012
|
+
"form": AssistantProfileInlineForm,
|
|
1013
|
+
"fields": ("user_key", "scopes", "is_active"),
|
|
1014
|
+
"readonly_fields": ("user_key_hash", "created_at", "last_used_at"),
|
|
1015
|
+
"template": "admin/edit_inline/profile_stacked.html",
|
|
1016
|
+
},
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _build_profile_inline(model, owner_field):
|
|
1021
|
+
config = PROFILE_INLINE_CONFIG[model]
|
|
1022
|
+
verbose_name = config.get("verbose_name")
|
|
1023
|
+
if verbose_name is None:
|
|
1024
|
+
verbose_name = _title_case(model._meta.verbose_name)
|
|
1025
|
+
verbose_name_plural = config.get("verbose_name_plural")
|
|
1026
|
+
if verbose_name_plural is None:
|
|
1027
|
+
verbose_name_plural = _title_case(model._meta.verbose_name_plural)
|
|
1028
|
+
attrs = {
|
|
1029
|
+
"model": model,
|
|
1030
|
+
"fk_name": owner_field,
|
|
1031
|
+
"form": config["form"],
|
|
1032
|
+
"formset": ProfileInlineFormSet,
|
|
1033
|
+
"extra": 1,
|
|
1034
|
+
"max_num": 1,
|
|
1035
|
+
"can_delete": True,
|
|
1036
|
+
"verbose_name": verbose_name,
|
|
1037
|
+
"verbose_name_plural": verbose_name_plural,
|
|
1038
|
+
"template": "admin/edit_inline/profile_stacked.html",
|
|
1039
|
+
}
|
|
1040
|
+
if "fieldsets" in config:
|
|
1041
|
+
attrs["fieldsets"] = config["fieldsets"]
|
|
1042
|
+
if "fields" in config:
|
|
1043
|
+
attrs["fields"] = config["fields"]
|
|
1044
|
+
if "readonly_fields" in config:
|
|
1045
|
+
attrs["readonly_fields"] = config["readonly_fields"]
|
|
1046
|
+
if "template" in config:
|
|
1047
|
+
attrs["template"] = config["template"]
|
|
1048
|
+
return type(
|
|
1049
|
+
f"{model.__name__}{owner_field.title()}Inline",
|
|
1050
|
+
(admin.StackedInline,),
|
|
1051
|
+
attrs,
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
PROFILE_MODELS = (
|
|
1056
|
+
OdooProfile,
|
|
1057
|
+
EmailInbox,
|
|
1058
|
+
EmailOutbox,
|
|
1059
|
+
ReleaseManager,
|
|
1060
|
+
AssistantProfile,
|
|
1061
|
+
)
|
|
1062
|
+
USER_PROFILE_INLINES = [
|
|
1063
|
+
_build_profile_inline(model, "user") for model in PROFILE_MODELS
|
|
1064
|
+
]
|
|
1065
|
+
GROUP_PROFILE_INLINES = [
|
|
1066
|
+
_build_profile_inline(model, "group") for model in PROFILE_MODELS
|
|
1067
|
+
]
|
|
1068
|
+
|
|
1069
|
+
SecurityGroupAdmin.inlines = GROUP_PROFILE_INLINES
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
class UserPhoneNumberInline(admin.TabularInline):
|
|
1073
|
+
model = UserPhoneNumber
|
|
1074
|
+
extra = 0
|
|
1075
|
+
fields = ("number", "priority")
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
class UserAdmin(UserDatumAdminMixin, DjangoUserAdmin):
|
|
1079
|
+
fieldsets = _append_operate_as(DjangoUserAdmin.fieldsets)
|
|
1080
|
+
add_fieldsets = _append_operate_as(DjangoUserAdmin.add_fieldsets)
|
|
1081
|
+
inlines = USER_PROFILE_INLINES + [UserPhoneNumberInline]
|
|
1082
|
+
change_form_template = "admin/user_profile_change_form.html"
|
|
1083
|
+
_skip_entity_user_datum = True
|
|
1084
|
+
|
|
1085
|
+
def _get_operate_as_profile_template(self):
|
|
1086
|
+
opts = self.model._meta
|
|
1087
|
+
try:
|
|
1088
|
+
return reverse(
|
|
1089
|
+
f"{self.admin_site.name}:{opts.app_label}_{opts.model_name}_change",
|
|
1090
|
+
args=["__ID__"],
|
|
1091
|
+
)
|
|
1092
|
+
except NoReverseMatch:
|
|
1093
|
+
user_opts = User._meta
|
|
1094
|
+
try:
|
|
1095
|
+
return reverse(
|
|
1096
|
+
f"{self.admin_site.name}:{user_opts.app_label}_{user_opts.model_name}_change",
|
|
1097
|
+
args=["__ID__"],
|
|
1098
|
+
)
|
|
1099
|
+
except NoReverseMatch:
|
|
1100
|
+
return None
|
|
1101
|
+
|
|
1102
|
+
def render_change_form(
|
|
1103
|
+
self, request, context, add=False, change=False, form_url="", obj=None
|
|
1104
|
+
):
|
|
1105
|
+
response = super().render_change_form(
|
|
1106
|
+
request, context, add=add, change=change, form_url=form_url, obj=obj
|
|
1107
|
+
)
|
|
1108
|
+
if isinstance(response, dict):
|
|
1109
|
+
context_data = response
|
|
1110
|
+
else:
|
|
1111
|
+
context_data = getattr(response, "context_data", None)
|
|
1112
|
+
if context_data is not None:
|
|
1113
|
+
context_data["show_user_datum"] = False
|
|
1114
|
+
context_data["show_seed_datum"] = False
|
|
1115
|
+
context_data["show_save_as_copy"] = False
|
|
1116
|
+
operate_as_user = None
|
|
1117
|
+
operate_as_template = self._get_operate_as_profile_template()
|
|
1118
|
+
operate_as_url = None
|
|
1119
|
+
if obj and getattr(obj, "operate_as_id", None):
|
|
1120
|
+
try:
|
|
1121
|
+
operate_as_user = obj.operate_as
|
|
1122
|
+
except User.DoesNotExist:
|
|
1123
|
+
operate_as_user = None
|
|
1124
|
+
if operate_as_user and operate_as_template:
|
|
1125
|
+
operate_as_url = operate_as_template.replace(
|
|
1126
|
+
"__ID__", str(operate_as_user.pk)
|
|
1127
|
+
)
|
|
1128
|
+
if context_data is not None:
|
|
1129
|
+
context_data["operate_as_user"] = operate_as_user
|
|
1130
|
+
context_data["operate_as_profile_url_template"] = operate_as_template
|
|
1131
|
+
context_data["operate_as_profile_url"] = operate_as_url
|
|
1132
|
+
return response
|
|
1133
|
+
|
|
1134
|
+
def get_inline_instances(self, request, obj=None):
|
|
1135
|
+
inline_instances = super().get_inline_instances(request, obj)
|
|
1136
|
+
if obj and getattr(obj, "is_profile_restricted", False):
|
|
1137
|
+
profile_inline_classes = tuple(USER_PROFILE_INLINES)
|
|
1138
|
+
inline_instances = [
|
|
1139
|
+
inline
|
|
1140
|
+
for inline in inline_instances
|
|
1141
|
+
if inline.__class__ not in profile_inline_classes
|
|
1142
|
+
]
|
|
1143
|
+
return inline_instances
|
|
1144
|
+
|
|
1145
|
+
def _update_profile_fixture(self, instance, owner, *, store: bool) -> None:
|
|
1146
|
+
if not getattr(instance, "pk", None):
|
|
1147
|
+
return
|
|
1148
|
+
manager = getattr(type(instance), "all_objects", None)
|
|
1149
|
+
if manager is not None:
|
|
1150
|
+
manager.filter(pk=instance.pk).update(is_user_data=store)
|
|
1151
|
+
instance.is_user_data = store
|
|
1152
|
+
if owner is None:
|
|
1153
|
+
owner = getattr(instance, "user", None)
|
|
1154
|
+
if owner is None:
|
|
1155
|
+
return
|
|
1156
|
+
if store:
|
|
1157
|
+
dump_user_fixture(instance, owner)
|
|
1158
|
+
else:
|
|
1159
|
+
delete_user_fixture(instance, owner)
|
|
1160
|
+
|
|
1161
|
+
def save_formset(self, request, form, formset, change):
|
|
1162
|
+
super().save_formset(request, form, formset, change)
|
|
1163
|
+
owner = form.instance if isinstance(form.instance, User) else None
|
|
1164
|
+
for deleted in getattr(formset, "deleted_objects", []):
|
|
1165
|
+
owner_user = getattr(deleted, "user", None) or owner
|
|
1166
|
+
self._update_profile_fixture(deleted, owner_user, store=False)
|
|
1167
|
+
for inline_form in getattr(formset, "forms", []):
|
|
1168
|
+
if not hasattr(inline_form, "cleaned_data"):
|
|
1169
|
+
continue
|
|
1170
|
+
if inline_form.cleaned_data.get("DELETE"):
|
|
1171
|
+
continue
|
|
1172
|
+
if "user_datum" not in inline_form.cleaned_data:
|
|
1173
|
+
continue
|
|
1174
|
+
instance = inline_form.instance
|
|
1175
|
+
owner_user = getattr(instance, "user", None) or owner
|
|
1176
|
+
should_store = bool(inline_form.cleaned_data.get("user_datum"))
|
|
1177
|
+
self._update_profile_fixture(instance, owner_user, store=should_store)
|
|
1178
|
+
|
|
1179
|
+
def save_model(self, request, obj, form, change):
|
|
1180
|
+
super().save_model(request, obj, form, change)
|
|
1181
|
+
if not getattr(obj, "pk", None):
|
|
1182
|
+
return
|
|
1183
|
+
target_user = _resolve_fixture_user(obj, obj)
|
|
1184
|
+
allow_user_data = _user_allows_user_data(target_user)
|
|
1185
|
+
if request.POST.get("_user_datum") == "on":
|
|
1186
|
+
type(obj).all_objects.filter(pk=obj.pk).update(is_user_data=False)
|
|
1187
|
+
obj.is_user_data = False
|
|
1188
|
+
delete_user_fixture(obj, target_user)
|
|
1189
|
+
self.message_user(
|
|
1190
|
+
request,
|
|
1191
|
+
_("User data for user accounts is managed through the profile sections."),
|
|
1192
|
+
)
|
|
1193
|
+
elif obj.is_user_data:
|
|
1194
|
+
type(obj).all_objects.filter(pk=obj.pk).update(is_user_data=False)
|
|
1195
|
+
obj.is_user_data = False
|
|
1196
|
+
delete_user_fixture(obj, target_user)
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
class EmailCollectorInline(admin.TabularInline):
|
|
1200
|
+
model = EmailCollector
|
|
1201
|
+
extra = 0
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
class EmailCollectorAdmin(EntityModelAdmin):
|
|
1205
|
+
list_display = ("inbox", "subject", "sender", "body", "fragment")
|
|
1206
|
+
search_fields = ("subject", "sender", "body", "fragment")
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
@admin.register(OdooProfile)
|
|
1210
|
+
class OdooProfileAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin):
|
|
1211
|
+
change_form_template = "django_object_actions/change_form.html"
|
|
1212
|
+
form = OdooProfileAdminForm
|
|
1213
|
+
list_display = ("owner", "host", "database", "verified_on")
|
|
1214
|
+
readonly_fields = ("verified_on", "odoo_uid", "name", "email")
|
|
1215
|
+
actions = ["verify_credentials"]
|
|
1216
|
+
change_actions = ["verify_credentials_action", "my_profile_action"]
|
|
1217
|
+
changelist_actions = ["my_profile"]
|
|
1218
|
+
fieldsets = (
|
|
1219
|
+
("Owner", {"fields": ("user", "group")}),
|
|
1220
|
+
(
|
|
1221
|
+
"Configuration",
|
|
1222
|
+
{"fields": ("host", "database", "username", "password")},
|
|
1223
|
+
),
|
|
1224
|
+
(
|
|
1225
|
+
"Odoo Employee",
|
|
1226
|
+
{"fields": ("verified_on", "odoo_uid", "name", "email")},
|
|
1227
|
+
),
|
|
1228
|
+
)
|
|
1229
|
+
|
|
1230
|
+
def owner(self, obj):
|
|
1231
|
+
return obj.owner_display()
|
|
1232
|
+
|
|
1233
|
+
owner.short_description = "Owner"
|
|
1234
|
+
|
|
1235
|
+
def _verify_credentials(self, request, profile):
|
|
1236
|
+
try:
|
|
1237
|
+
profile.verify()
|
|
1238
|
+
self.message_user(request, f"{profile.owner_display()} verified")
|
|
1239
|
+
except Exception as exc: # pragma: no cover - admin feedback
|
|
1240
|
+
self.message_user(
|
|
1241
|
+
request, f"{profile.owner_display()}: {exc}", level=messages.ERROR
|
|
1242
|
+
)
|
|
1243
|
+
|
|
1244
|
+
@admin.action(description="Test credentials")
|
|
1245
|
+
def verify_credentials(self, request, queryset):
|
|
1246
|
+
for profile in queryset:
|
|
1247
|
+
self._verify_credentials(request, profile)
|
|
1248
|
+
|
|
1249
|
+
def verify_credentials_action(self, request, obj):
|
|
1250
|
+
self._verify_credentials(request, obj)
|
|
1251
|
+
|
|
1252
|
+
verify_credentials_action.label = "Test credentials"
|
|
1253
|
+
verify_credentials_action.short_description = "Test credentials"
|
|
1254
|
+
|
|
483
1255
|
|
|
484
1256
|
class EmailSearchForm(forms.Form):
|
|
485
1257
|
subject = forms.CharField(
|
|
@@ -496,17 +1268,52 @@ class EmailSearchForm(forms.Form):
|
|
|
496
1268
|
)
|
|
497
1269
|
|
|
498
1270
|
|
|
499
|
-
|
|
500
|
-
class EmailInboxAdmin(admin.ModelAdmin):
|
|
1271
|
+
class EmailInboxAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin):
|
|
501
1272
|
form = EmailInboxAdminForm
|
|
502
|
-
list_display = ("
|
|
503
|
-
actions = ["test_connection", "search_inbox"]
|
|
1273
|
+
list_display = ("owner_label", "username", "host", "protocol")
|
|
1274
|
+
actions = ["test_connection", "search_inbox", "test_collectors"]
|
|
1275
|
+
change_actions = ["test_collectors_action", "my_profile_action"]
|
|
1276
|
+
changelist_actions = ["my_profile"]
|
|
1277
|
+
change_form_template = "admin/core/emailinbox/change_form.html"
|
|
1278
|
+
inlines = [EmailCollectorInline]
|
|
1279
|
+
|
|
1280
|
+
def get_urls(self):
|
|
1281
|
+
urls = super().get_urls()
|
|
1282
|
+
custom = [
|
|
1283
|
+
path(
|
|
1284
|
+
"<path:object_id>/test/",
|
|
1285
|
+
self.admin_site.admin_view(self.test_inbox),
|
|
1286
|
+
name="core_emailinbox_test",
|
|
1287
|
+
)
|
|
1288
|
+
]
|
|
1289
|
+
return custom + urls
|
|
1290
|
+
|
|
1291
|
+
def test_inbox(self, request, object_id):
|
|
1292
|
+
inbox = self.get_object(request, object_id)
|
|
1293
|
+
if not inbox:
|
|
1294
|
+
self.message_user(request, "Unknown inbox", messages.ERROR)
|
|
1295
|
+
return redirect("..")
|
|
1296
|
+
try:
|
|
1297
|
+
inbox.test_connection()
|
|
1298
|
+
self.message_user(request, "Inbox connection successful", messages.SUCCESS)
|
|
1299
|
+
except Exception as exc: # pragma: no cover - admin feedback
|
|
1300
|
+
self.message_user(request, str(exc), messages.ERROR)
|
|
1301
|
+
return redirect("..")
|
|
1302
|
+
|
|
1303
|
+
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
|
1304
|
+
extra_context = extra_context or {}
|
|
1305
|
+
if object_id:
|
|
1306
|
+
extra_context["test_url"] = reverse(
|
|
1307
|
+
"admin:core_emailinbox_test", args=[object_id]
|
|
1308
|
+
)
|
|
1309
|
+
return super().changeform_view(request, object_id, form_url, extra_context)
|
|
1310
|
+
|
|
504
1311
|
fieldsets = (
|
|
1312
|
+
("Owner", {"fields": ("user", "group")}),
|
|
505
1313
|
(
|
|
506
1314
|
None,
|
|
507
1315
|
{
|
|
508
1316
|
"fields": (
|
|
509
|
-
"user",
|
|
510
1317
|
"username",
|
|
511
1318
|
"host",
|
|
512
1319
|
"port",
|
|
@@ -518,9 +1325,12 @@ class EmailInboxAdmin(admin.ModelAdmin):
|
|
|
518
1325
|
),
|
|
519
1326
|
)
|
|
520
1327
|
|
|
1328
|
+
@admin.display(description="Owner")
|
|
1329
|
+
def owner_label(self, obj):
|
|
1330
|
+
return obj.owner_display()
|
|
1331
|
+
|
|
521
1332
|
def save_model(self, request, obj, form, change):
|
|
522
1333
|
super().save_model(request, obj, form, change)
|
|
523
|
-
obj.__class__ = EmailInbox
|
|
524
1334
|
|
|
525
1335
|
@admin.action(description="Test selected inboxes")
|
|
526
1336
|
def test_connection(self, request, queryset):
|
|
@@ -531,6 +1341,33 @@ class EmailInboxAdmin(admin.ModelAdmin):
|
|
|
531
1341
|
except Exception as exc: # pragma: no cover - admin feedback
|
|
532
1342
|
self.message_user(request, f"{inbox}: {exc}", level=messages.ERROR)
|
|
533
1343
|
|
|
1344
|
+
def _test_collectors(self, request, inbox):
|
|
1345
|
+
for collector in inbox.collectors.all():
|
|
1346
|
+
before = collector.artifacts.count()
|
|
1347
|
+
try:
|
|
1348
|
+
collector.collect(limit=1)
|
|
1349
|
+
after = collector.artifacts.count()
|
|
1350
|
+
if after > before:
|
|
1351
|
+
msg = f"{collector} collected {after - before} email(s)"
|
|
1352
|
+
self.message_user(request, msg)
|
|
1353
|
+
else:
|
|
1354
|
+
self.message_user(
|
|
1355
|
+
request, f"{collector} found no emails", level=messages.WARNING
|
|
1356
|
+
)
|
|
1357
|
+
except Exception as exc: # pragma: no cover - admin feedback
|
|
1358
|
+
self.message_user(request, f"{collector}: {exc}", level=messages.ERROR)
|
|
1359
|
+
|
|
1360
|
+
@admin.action(description="Test collectors")
|
|
1361
|
+
def test_collectors(self, request, queryset):
|
|
1362
|
+
for inbox in queryset:
|
|
1363
|
+
self._test_collectors(request, inbox)
|
|
1364
|
+
|
|
1365
|
+
def test_collectors_action(self, request, obj):
|
|
1366
|
+
self._test_collectors(request, obj)
|
|
1367
|
+
|
|
1368
|
+
test_collectors_action.label = "Test collectors"
|
|
1369
|
+
test_collectors_action.short_description = "Test collectors"
|
|
1370
|
+
|
|
534
1371
|
@admin.action(description="Search selected inbox")
|
|
535
1372
|
def search_inbox(self, request, queryset):
|
|
536
1373
|
if queryset.count() != 1:
|
|
@@ -552,6 +1389,7 @@ class EmailInboxAdmin(admin.ModelAdmin):
|
|
|
552
1389
|
"results": results,
|
|
553
1390
|
"queryset": queryset,
|
|
554
1391
|
"action": "search_inbox",
|
|
1392
|
+
"opts": self.model._meta,
|
|
555
1393
|
}
|
|
556
1394
|
return TemplateResponse(
|
|
557
1395
|
request, "admin/core/emailinbox/search.html", context
|
|
@@ -562,48 +1400,177 @@ class EmailInboxAdmin(admin.ModelAdmin):
|
|
|
562
1400
|
"form": form,
|
|
563
1401
|
"queryset": queryset,
|
|
564
1402
|
"action": "search_inbox",
|
|
1403
|
+
"opts": self.model._meta,
|
|
565
1404
|
}
|
|
566
1405
|
return TemplateResponse(request, "admin/core/emailinbox/search.html", context)
|
|
567
1406
|
|
|
568
1407
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
1408
|
+
@admin.register(AssistantProfile)
|
|
1409
|
+
class AssistantProfileAdmin(
|
|
1410
|
+
ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin
|
|
1411
|
+
):
|
|
1412
|
+
list_display = ("owner", "created_at", "last_used_at", "is_active")
|
|
1413
|
+
readonly_fields = ("user_key_hash", "created_at", "last_used_at")
|
|
575
1414
|
|
|
1415
|
+
change_form_template = "admin/workgroupassistantprofile_change_form.html"
|
|
1416
|
+
change_list_template = "admin/assistantprofile_change_list.html"
|
|
1417
|
+
change_actions = ["my_profile_action"]
|
|
1418
|
+
changelist_actions = ["my_profile"]
|
|
1419
|
+
fieldsets = (
|
|
1420
|
+
("Owner", {"fields": ("user", "group")}),
|
|
1421
|
+
(
|
|
1422
|
+
None,
|
|
1423
|
+
{
|
|
1424
|
+
"fields": (
|
|
1425
|
+
"scopes",
|
|
1426
|
+
"is_active",
|
|
1427
|
+
"user_key_hash",
|
|
1428
|
+
"created_at",
|
|
1429
|
+
"last_used_at",
|
|
1430
|
+
)
|
|
1431
|
+
},
|
|
1432
|
+
),
|
|
1433
|
+
)
|
|
576
1434
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
list_display = ("user", "created_at", "last_used_at", "is_active")
|
|
580
|
-
readonly_fields = ("user_key_hash",)
|
|
1435
|
+
def owner(self, obj):
|
|
1436
|
+
return obj.owner_display()
|
|
581
1437
|
|
|
582
|
-
|
|
1438
|
+
owner.short_description = "Owner"
|
|
583
1439
|
|
|
584
1440
|
def get_urls(self):
|
|
585
1441
|
urls = super().get_urls()
|
|
1442
|
+
opts = self.model._meta
|
|
1443
|
+
app_label = opts.app_label
|
|
1444
|
+
model_name = opts.model_name
|
|
586
1445
|
custom = [
|
|
587
1446
|
path(
|
|
588
1447
|
"<path:object_id>/generate-key/",
|
|
589
1448
|
self.admin_site.admin_view(self.generate_key),
|
|
590
|
-
name="
|
|
1449
|
+
name=f"{app_label}_{model_name}_generate_key",
|
|
1450
|
+
),
|
|
1451
|
+
path(
|
|
1452
|
+
"server/start/",
|
|
1453
|
+
self.admin_site.admin_view(self.start_server),
|
|
1454
|
+
name=f"{app_label}_{model_name}_start_server",
|
|
1455
|
+
),
|
|
1456
|
+
path(
|
|
1457
|
+
"server/stop/",
|
|
1458
|
+
self.admin_site.admin_view(self.stop_server),
|
|
1459
|
+
name=f"{app_label}_{model_name}_stop_server",
|
|
1460
|
+
),
|
|
1461
|
+
path(
|
|
1462
|
+
"server/status/",
|
|
1463
|
+
self.admin_site.admin_view(self.server_status),
|
|
1464
|
+
name=f"{app_label}_{model_name}_status",
|
|
591
1465
|
),
|
|
592
1466
|
]
|
|
593
1467
|
return custom + urls
|
|
594
1468
|
|
|
1469
|
+
def changelist_view(self, request, extra_context=None):
|
|
1470
|
+
extra_context = extra_context or {}
|
|
1471
|
+
status = mcp_process.get_status()
|
|
1472
|
+
opts = self.model._meta
|
|
1473
|
+
app_label = opts.app_label
|
|
1474
|
+
model_name = opts.model_name
|
|
1475
|
+
extra_context.update(
|
|
1476
|
+
{
|
|
1477
|
+
"mcp_status": status,
|
|
1478
|
+
"mcp_server_actions": {
|
|
1479
|
+
"start": reverse(f"admin:{app_label}_{model_name}_start_server"),
|
|
1480
|
+
"stop": reverse(f"admin:{app_label}_{model_name}_stop_server"),
|
|
1481
|
+
"status": reverse(f"admin:{app_label}_{model_name}_status"),
|
|
1482
|
+
},
|
|
1483
|
+
}
|
|
1484
|
+
)
|
|
1485
|
+
return super().changelist_view(request, extra_context=extra_context)
|
|
1486
|
+
|
|
1487
|
+
def _redirect_to_changelist(self):
|
|
1488
|
+
opts = self.model._meta
|
|
1489
|
+
return HttpResponseRedirect(
|
|
1490
|
+
reverse(f"admin:{opts.app_label}_{opts.model_name}_changelist")
|
|
1491
|
+
)
|
|
1492
|
+
|
|
595
1493
|
def generate_key(self, request, object_id, *args, **kwargs):
|
|
596
1494
|
profile = self.get_object(request, object_id)
|
|
597
1495
|
if profile is None:
|
|
598
1496
|
return HttpResponseRedirect("../")
|
|
599
|
-
|
|
1497
|
+
if profile.user is None:
|
|
1498
|
+
self.message_user(
|
|
1499
|
+
request,
|
|
1500
|
+
"Assign a user before generating a key.",
|
|
1501
|
+
level=messages.ERROR,
|
|
1502
|
+
)
|
|
1503
|
+
return HttpResponseRedirect("../")
|
|
1504
|
+
profile, key = AssistantProfile.issue_key(profile.user)
|
|
600
1505
|
context = {
|
|
601
1506
|
**self.admin_site.each_context(request),
|
|
602
1507
|
"opts": self.model._meta,
|
|
603
1508
|
"original": profile,
|
|
604
1509
|
"user_key": key,
|
|
605
1510
|
}
|
|
606
|
-
return TemplateResponse(request, "admin/
|
|
1511
|
+
return TemplateResponse(request, "admin/assistantprofile_key.html", context)
|
|
1512
|
+
|
|
1513
|
+
def render_change_form(
|
|
1514
|
+
self, request, context, add=False, change=False, form_url="", obj=None
|
|
1515
|
+
):
|
|
1516
|
+
response = super().render_change_form(
|
|
1517
|
+
request, context, add=add, change=change, form_url=form_url, obj=obj
|
|
1518
|
+
)
|
|
1519
|
+
config = dict(getattr(settings, "MCP_SIGIL_SERVER", {}))
|
|
1520
|
+
host = config.get("host") or "127.0.0.1"
|
|
1521
|
+
port = config.get("port", 8800)
|
|
1522
|
+
if isinstance(response, dict):
|
|
1523
|
+
response.setdefault("mcp_server_host", host)
|
|
1524
|
+
response.setdefault("mcp_server_port", port)
|
|
1525
|
+
else:
|
|
1526
|
+
context_data = getattr(response, "context_data", None)
|
|
1527
|
+
if context_data is not None:
|
|
1528
|
+
context_data.setdefault("mcp_server_host", host)
|
|
1529
|
+
context_data.setdefault("mcp_server_port", port)
|
|
1530
|
+
return response
|
|
1531
|
+
|
|
1532
|
+
def start_server(self, request):
|
|
1533
|
+
try:
|
|
1534
|
+
pid = mcp_process.start_server()
|
|
1535
|
+
except mcp_process.ServerAlreadyRunningError as exc:
|
|
1536
|
+
self.message_user(request, str(exc), level=messages.WARNING)
|
|
1537
|
+
except mcp_process.ServerStartError as exc:
|
|
1538
|
+
self.message_user(request, str(exc), level=messages.ERROR)
|
|
1539
|
+
else:
|
|
1540
|
+
self.message_user(
|
|
1541
|
+
request,
|
|
1542
|
+
f"Started MCP server (PID {pid}).",
|
|
1543
|
+
level=messages.SUCCESS,
|
|
1544
|
+
)
|
|
1545
|
+
return self._redirect_to_changelist()
|
|
1546
|
+
|
|
1547
|
+
def stop_server(self, request):
|
|
1548
|
+
try:
|
|
1549
|
+
pid = mcp_process.stop_server()
|
|
1550
|
+
except mcp_process.ServerNotRunningError as exc:
|
|
1551
|
+
self.message_user(request, str(exc), level=messages.WARNING)
|
|
1552
|
+
except mcp_process.ServerStopError as exc:
|
|
1553
|
+
self.message_user(request, str(exc), level=messages.ERROR)
|
|
1554
|
+
else:
|
|
1555
|
+
self.message_user(
|
|
1556
|
+
request,
|
|
1557
|
+
f"Stopped MCP server (PID {pid}).",
|
|
1558
|
+
level=messages.SUCCESS,
|
|
1559
|
+
)
|
|
1560
|
+
return self._redirect_to_changelist()
|
|
1561
|
+
|
|
1562
|
+
def server_status(self, request):
|
|
1563
|
+
status = mcp_process.get_status()
|
|
1564
|
+
if status["running"]:
|
|
1565
|
+
msg = f"MCP server is running (PID {status['pid']})."
|
|
1566
|
+
level = messages.INFO
|
|
1567
|
+
else:
|
|
1568
|
+
msg = "MCP server is not running."
|
|
1569
|
+
level = messages.WARNING
|
|
1570
|
+
if status.get("last_error"):
|
|
1571
|
+
msg = f"{msg} {status['last_error']}"
|
|
1572
|
+
self.message_user(request, msg, level=level)
|
|
1573
|
+
return self._redirect_to_changelist()
|
|
607
1574
|
|
|
608
1575
|
|
|
609
1576
|
class EnergyCreditInline(admin.TabularInline):
|
|
@@ -614,8 +1581,9 @@ class EnergyCreditInline(admin.TabularInline):
|
|
|
614
1581
|
|
|
615
1582
|
|
|
616
1583
|
@admin.register(EnergyAccount)
|
|
617
|
-
class EnergyAccountAdmin(
|
|
1584
|
+
class EnergyAccountAdmin(EntityModelAdmin):
|
|
618
1585
|
change_list_template = "admin/core/energyaccount/change_list.html"
|
|
1586
|
+
change_form_template = "admin/user_datum_change_form.html"
|
|
619
1587
|
list_display = (
|
|
620
1588
|
"name",
|
|
621
1589
|
"user",
|
|
@@ -652,6 +1620,15 @@ class EnergyAccountAdmin(admin.ModelAdmin):
|
|
|
652
1620
|
)
|
|
653
1621
|
},
|
|
654
1622
|
),
|
|
1623
|
+
(
|
|
1624
|
+
"Live Subscription",
|
|
1625
|
+
{
|
|
1626
|
+
"fields": (
|
|
1627
|
+
"live_subscription_product",
|
|
1628
|
+
("live_subscription_start_date", "live_subscription_next_renewal"),
|
|
1629
|
+
)
|
|
1630
|
+
},
|
|
1631
|
+
),
|
|
655
1632
|
)
|
|
656
1633
|
|
|
657
1634
|
def authorized(self, obj):
|
|
@@ -732,29 +1709,25 @@ class EnergyAccountAdmin(admin.ModelAdmin):
|
|
|
732
1709
|
|
|
733
1710
|
|
|
734
1711
|
@admin.register(ElectricVehicle)
|
|
735
|
-
class ElectricVehicleAdmin(
|
|
1712
|
+
class ElectricVehicleAdmin(EntityModelAdmin):
|
|
736
1713
|
list_display = ("vin", "license_plate", "brand", "model", "account")
|
|
1714
|
+
search_fields = (
|
|
1715
|
+
"vin",
|
|
1716
|
+
"license_plate",
|
|
1717
|
+
"brand__name",
|
|
1718
|
+
"model__name",
|
|
1719
|
+
"account__name",
|
|
1720
|
+
)
|
|
737
1721
|
fields = ("account", "vin", "license_plate", "brand", "model")
|
|
738
1722
|
|
|
739
1723
|
|
|
740
|
-
@admin.register(EnergyCredit)
|
|
741
|
-
class EnergyCreditAdmin(admin.ModelAdmin):
|
|
742
|
-
list_display = ("account", "amount_kw", "created_by", "created_on")
|
|
743
|
-
readonly_fields = ("created_by", "created_on")
|
|
744
|
-
|
|
745
|
-
def save_model(self, request, obj, form, change):
|
|
746
|
-
if not obj.created_by:
|
|
747
|
-
obj.created_by = request.user
|
|
748
|
-
super().save_model(request, obj, form, change)
|
|
749
|
-
|
|
750
|
-
|
|
751
1724
|
class WMICodeInline(admin.TabularInline):
|
|
752
1725
|
model = WMICode
|
|
753
1726
|
extra = 0
|
|
754
1727
|
|
|
755
1728
|
|
|
756
1729
|
@admin.register(Brand)
|
|
757
|
-
class BrandAdmin(
|
|
1730
|
+
class BrandAdmin(EntityModelAdmin):
|
|
758
1731
|
fields = ("name",)
|
|
759
1732
|
list_display = ("name", "wmi_codes_display")
|
|
760
1733
|
inlines = [WMICodeInline]
|
|
@@ -766,14 +1739,230 @@ class BrandAdmin(admin.ModelAdmin):
|
|
|
766
1739
|
|
|
767
1740
|
|
|
768
1741
|
@admin.register(EVModel)
|
|
769
|
-
class EVModelAdmin(
|
|
1742
|
+
class EVModelAdmin(EntityModelAdmin):
|
|
770
1743
|
fields = ("brand", "name")
|
|
771
|
-
list_display = ("name", "brand")
|
|
772
|
-
|
|
1744
|
+
list_display = ("name", "brand", "brand_wmi_codes")
|
|
1745
|
+
|
|
1746
|
+
def get_queryset(self, request):
|
|
1747
|
+
queryset = super().get_queryset(request)
|
|
1748
|
+
return queryset.select_related("brand").prefetch_related("brand__wmi_codes")
|
|
1749
|
+
|
|
1750
|
+
def brand_wmi_codes(self, obj):
|
|
1751
|
+
if not obj.brand:
|
|
1752
|
+
return ""
|
|
1753
|
+
codes = [wmi.code for wmi in obj.brand.wmi_codes.all()]
|
|
1754
|
+
return ", ".join(codes)
|
|
1755
|
+
|
|
1756
|
+
brand_wmi_codes.short_description = "WMI codes"
|
|
1757
|
+
|
|
1758
|
+
|
|
1759
|
+
@admin.register(EnergyCredit)
|
|
1760
|
+
class EnergyCreditAdmin(EntityModelAdmin):
|
|
1761
|
+
list_display = ("account", "amount_kw", "created_by", "created_on")
|
|
1762
|
+
readonly_fields = ("created_by", "created_on")
|
|
1763
|
+
|
|
1764
|
+
def save_model(self, request, obj, form, change):
|
|
1765
|
+
if not obj.created_by:
|
|
1766
|
+
obj.created_by = request.user
|
|
1767
|
+
super().save_model(request, obj, form, change)
|
|
1768
|
+
|
|
1769
|
+
def get_model_perms(self, request):
|
|
1770
|
+
return {}
|
|
1771
|
+
|
|
1772
|
+
|
|
1773
|
+
class ProductAdminForm(forms.ModelForm):
|
|
1774
|
+
class Meta:
|
|
1775
|
+
model = Product
|
|
1776
|
+
fields = "__all__"
|
|
1777
|
+
widgets = {"odoo_product": OdooProductWidget}
|
|
1778
|
+
|
|
1779
|
+
|
|
1780
|
+
class ProductFetchWizardForm(forms.Form):
|
|
1781
|
+
name = forms.CharField(label="Name", required=False)
|
|
1782
|
+
default_code = forms.CharField(label="Internal reference", required=False)
|
|
1783
|
+
barcode = forms.CharField(label="Barcode", required=False)
|
|
1784
|
+
renewal_period = forms.IntegerField(
|
|
1785
|
+
label="Renewal period (days)", min_value=1, initial=30
|
|
1786
|
+
)
|
|
1787
|
+
|
|
1788
|
+
def __init__(self, *args, require_search_terms=True, **kwargs):
|
|
1789
|
+
self.require_search_terms = require_search_terms
|
|
1790
|
+
super().__init__(*args, **kwargs)
|
|
1791
|
+
|
|
1792
|
+
def clean(self):
|
|
1793
|
+
cleaned = super().clean()
|
|
1794
|
+
if self.require_search_terms:
|
|
1795
|
+
if not any(
|
|
1796
|
+
cleaned.get(field) for field in ("name", "default_code", "barcode")
|
|
1797
|
+
):
|
|
1798
|
+
raise forms.ValidationError(
|
|
1799
|
+
_("Enter at least one field to search for a product.")
|
|
1800
|
+
)
|
|
1801
|
+
return cleaned
|
|
1802
|
+
|
|
1803
|
+
def build_domain(self):
|
|
1804
|
+
domain = []
|
|
1805
|
+
if self.cleaned_data.get("name"):
|
|
1806
|
+
domain.append(("name", "ilike", self.cleaned_data["name"]))
|
|
1807
|
+
if self.cleaned_data.get("default_code"):
|
|
1808
|
+
domain.append(("default_code", "ilike", self.cleaned_data["default_code"]))
|
|
1809
|
+
if self.cleaned_data.get("barcode"):
|
|
1810
|
+
domain.append(("barcode", "ilike", self.cleaned_data["barcode"]))
|
|
1811
|
+
return domain
|
|
1812
|
+
|
|
1813
|
+
|
|
1814
|
+
@admin.register(Product)
|
|
1815
|
+
class ProductAdmin(EntityModelAdmin):
|
|
1816
|
+
form = ProductAdminForm
|
|
1817
|
+
actions = ["fetch_odoo_product"]
|
|
1818
|
+
|
|
1819
|
+
def _odoo_profile_admin(self):
|
|
1820
|
+
return self.admin_site._registry.get(OdooProfile)
|
|
1821
|
+
|
|
1822
|
+
def _search_odoo_products(self, profile, form):
|
|
1823
|
+
domain = form.build_domain()
|
|
1824
|
+
return profile.execute(
|
|
1825
|
+
"product.product",
|
|
1826
|
+
"search_read",
|
|
1827
|
+
domain,
|
|
1828
|
+
{
|
|
1829
|
+
"fields": [
|
|
1830
|
+
"name",
|
|
1831
|
+
"default_code",
|
|
1832
|
+
"barcode",
|
|
1833
|
+
"description_sale",
|
|
1834
|
+
],
|
|
1835
|
+
"limit": 50,
|
|
1836
|
+
},
|
|
1837
|
+
)
|
|
1838
|
+
|
|
1839
|
+
@admin.action(description="Fetch Odoo Product")
|
|
1840
|
+
def fetch_odoo_product(self, request, queryset):
|
|
1841
|
+
profile = getattr(request.user, "odoo_profile", None)
|
|
1842
|
+
has_credentials = bool(profile and profile.is_verified)
|
|
1843
|
+
profile_admin = self._odoo_profile_admin()
|
|
1844
|
+
profile_url = None
|
|
1845
|
+
if profile_admin is not None:
|
|
1846
|
+
profile_url = profile_admin.get_my_profile_url(request)
|
|
1847
|
+
|
|
1848
|
+
context = {
|
|
1849
|
+
"opts": self.model._meta,
|
|
1850
|
+
"queryset": queryset,
|
|
1851
|
+
"action": "fetch_odoo_product",
|
|
1852
|
+
"has_credentials": has_credentials,
|
|
1853
|
+
"profile_url": profile_url,
|
|
1854
|
+
}
|
|
773
1855
|
|
|
1856
|
+
if not has_credentials:
|
|
1857
|
+
context["credential_error"] = _(
|
|
1858
|
+
"Configure your Odoo employee credentials before fetching products."
|
|
1859
|
+
)
|
|
1860
|
+
return TemplateResponse(
|
|
1861
|
+
request, "admin/core/product/fetch_odoo.html", context
|
|
1862
|
+
)
|
|
774
1863
|
|
|
775
|
-
|
|
776
|
-
|
|
1864
|
+
is_import = "import" in request.POST
|
|
1865
|
+
form_kwargs = {"require_search_terms": not is_import}
|
|
1866
|
+
if request.method == "POST":
|
|
1867
|
+
form = ProductFetchWizardForm(request.POST, **form_kwargs)
|
|
1868
|
+
else:
|
|
1869
|
+
form = ProductFetchWizardForm()
|
|
1870
|
+
|
|
1871
|
+
results = None
|
|
1872
|
+
selected_product_id = request.POST.get("product_id", "")
|
|
1873
|
+
|
|
1874
|
+
if request.method == "POST" and form.is_valid():
|
|
1875
|
+
try:
|
|
1876
|
+
results = self._search_odoo_products(profile, form)
|
|
1877
|
+
except Exception:
|
|
1878
|
+
form.add_error(None, _("Unable to fetch products from Odoo."))
|
|
1879
|
+
results = []
|
|
1880
|
+
else:
|
|
1881
|
+
if is_import:
|
|
1882
|
+
if not self.has_add_permission(request):
|
|
1883
|
+
form.add_error(
|
|
1884
|
+
None, _("You do not have permission to add products.")
|
|
1885
|
+
)
|
|
1886
|
+
else:
|
|
1887
|
+
product_id = request.POST.get("product_id")
|
|
1888
|
+
if not product_id:
|
|
1889
|
+
form.add_error(None, _("Select a product to import."))
|
|
1890
|
+
else:
|
|
1891
|
+
try:
|
|
1892
|
+
odoo_id = int(product_id)
|
|
1893
|
+
except (TypeError, ValueError):
|
|
1894
|
+
form.add_error(None, _("Invalid product selection."))
|
|
1895
|
+
else:
|
|
1896
|
+
match = next(
|
|
1897
|
+
(item for item in results if item.get("id") == odoo_id),
|
|
1898
|
+
None,
|
|
1899
|
+
)
|
|
1900
|
+
if not match:
|
|
1901
|
+
form.add_error(
|
|
1902
|
+
None,
|
|
1903
|
+
_(
|
|
1904
|
+
"The selected product was not found. Run the search again."
|
|
1905
|
+
),
|
|
1906
|
+
)
|
|
1907
|
+
else:
|
|
1908
|
+
existing = self.model.objects.filter(
|
|
1909
|
+
odoo_product__id=odoo_id
|
|
1910
|
+
).first()
|
|
1911
|
+
if existing:
|
|
1912
|
+
self.message_user(
|
|
1913
|
+
request,
|
|
1914
|
+
_(
|
|
1915
|
+
"Product %(name)s already imported; opening existing record."
|
|
1916
|
+
)
|
|
1917
|
+
% {"name": existing.name},
|
|
1918
|
+
level=messages.WARNING,
|
|
1919
|
+
)
|
|
1920
|
+
return HttpResponseRedirect(
|
|
1921
|
+
reverse(
|
|
1922
|
+
"admin:%s_%s_change"
|
|
1923
|
+
% (
|
|
1924
|
+
existing._meta.app_label,
|
|
1925
|
+
existing._meta.model_name,
|
|
1926
|
+
),
|
|
1927
|
+
args=[existing.pk],
|
|
1928
|
+
)
|
|
1929
|
+
)
|
|
1930
|
+
product = self.model.objects.create(
|
|
1931
|
+
name=match.get("name") or f"Odoo Product {odoo_id}",
|
|
1932
|
+
description=match.get("description_sale", "") or "",
|
|
1933
|
+
renewal_period=form.cleaned_data["renewal_period"],
|
|
1934
|
+
odoo_product={
|
|
1935
|
+
"id": odoo_id,
|
|
1936
|
+
"name": match.get("name", ""),
|
|
1937
|
+
},
|
|
1938
|
+
)
|
|
1939
|
+
self.log_addition(
|
|
1940
|
+
request, product, "Imported product from Odoo"
|
|
1941
|
+
)
|
|
1942
|
+
self.message_user(
|
|
1943
|
+
request,
|
|
1944
|
+
_("Imported %(name)s from Odoo.")
|
|
1945
|
+
% {"name": product.name},
|
|
1946
|
+
)
|
|
1947
|
+
return HttpResponseRedirect(
|
|
1948
|
+
reverse(
|
|
1949
|
+
"admin:%s_%s_change"
|
|
1950
|
+
% (
|
|
1951
|
+
product._meta.app_label,
|
|
1952
|
+
product._meta.model_name,
|
|
1953
|
+
),
|
|
1954
|
+
args=[product.pk],
|
|
1955
|
+
)
|
|
1956
|
+
)
|
|
1957
|
+
context.update(
|
|
1958
|
+
{
|
|
1959
|
+
"form": form,
|
|
1960
|
+
"results": results,
|
|
1961
|
+
"selected_product_id": selected_product_id,
|
|
1962
|
+
}
|
|
1963
|
+
)
|
|
1964
|
+
context["media"] = self.media + form.media
|
|
1965
|
+
return TemplateResponse(request, "admin/core/product/fetch_odoo.html", context)
|
|
777
1966
|
|
|
778
1967
|
|
|
779
1968
|
class RFIDResource(resources.ModelResource):
|
|
@@ -788,6 +1977,7 @@ class RFIDResource(resources.ModelResource):
|
|
|
788
1977
|
fields = (
|
|
789
1978
|
"label_id",
|
|
790
1979
|
"rfid",
|
|
1980
|
+
"custom_label",
|
|
791
1981
|
"reference",
|
|
792
1982
|
"allowed",
|
|
793
1983
|
"color",
|
|
@@ -822,12 +2012,13 @@ class RFIDForm(forms.ModelForm):
|
|
|
822
2012
|
|
|
823
2013
|
|
|
824
2014
|
@admin.register(RFID)
|
|
825
|
-
class RFIDAdmin(ImportExportModelAdmin):
|
|
2015
|
+
class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
|
|
826
2016
|
change_list_template = "admin/core/rfid/change_list.html"
|
|
827
2017
|
resource_class = RFIDResource
|
|
828
2018
|
list_display = (
|
|
829
2019
|
"label_id",
|
|
830
2020
|
"rfid",
|
|
2021
|
+
"custom_label",
|
|
831
2022
|
"color",
|
|
832
2023
|
"kind",
|
|
833
2024
|
"released",
|
|
@@ -837,7 +2028,7 @@ class RFIDAdmin(ImportExportModelAdmin):
|
|
|
837
2028
|
"last_seen_on",
|
|
838
2029
|
)
|
|
839
2030
|
list_filter = ("color", "released", "allowed")
|
|
840
|
-
search_fields = ("label_id", "rfid")
|
|
2031
|
+
search_fields = ("label_id", "rfid", "custom_label")
|
|
841
2032
|
autocomplete_fields = ["energy_accounts"]
|
|
842
2033
|
raw_id_fields = ["reference"]
|
|
843
2034
|
actions = ["scan_rfids"]
|
|
@@ -852,11 +2043,16 @@ class RFIDAdmin(ImportExportModelAdmin):
|
|
|
852
2043
|
def scan_rfids(self, request, queryset):
|
|
853
2044
|
return redirect("admin:core_rfid_scan")
|
|
854
2045
|
|
|
855
|
-
scan_rfids.short_description = "Scan
|
|
2046
|
+
scan_rfids.short_description = "Scan RFIDs"
|
|
856
2047
|
|
|
857
2048
|
def get_urls(self):
|
|
858
2049
|
urls = super().get_urls()
|
|
859
2050
|
custom = [
|
|
2051
|
+
path(
|
|
2052
|
+
"report/",
|
|
2053
|
+
self.admin_site.admin_view(self.report_view),
|
|
2054
|
+
name="core_rfid_report",
|
|
2055
|
+
),
|
|
860
2056
|
path(
|
|
861
2057
|
"scan/",
|
|
862
2058
|
self.admin_site.admin_view(csrf_exempt(self.scan_view)),
|
|
@@ -870,6 +2066,11 @@ class RFIDAdmin(ImportExportModelAdmin):
|
|
|
870
2066
|
]
|
|
871
2067
|
return custom + urls
|
|
872
2068
|
|
|
2069
|
+
def report_view(self, request):
|
|
2070
|
+
context = self.admin_site.each_context(request)
|
|
2071
|
+
context["report"] = ClientReport.build_rows()
|
|
2072
|
+
return TemplateResponse(request, "admin/core/rfid/report.html", context)
|
|
2073
|
+
|
|
873
2074
|
def scan_view(self, request):
|
|
874
2075
|
context = self.admin_site.each_context(request)
|
|
875
2076
|
context["scan_url"] = reverse("admin:core_rfid_scan_next")
|
|
@@ -886,8 +2087,179 @@ class RFIDAdmin(ImportExportModelAdmin):
|
|
|
886
2087
|
return JsonResponse(result, status=status)
|
|
887
2088
|
|
|
888
2089
|
|
|
2090
|
+
@admin.register(ClientReport)
|
|
2091
|
+
class ClientReportAdmin(EntityModelAdmin):
|
|
2092
|
+
list_display = ("created_on", "start_date", "end_date")
|
|
2093
|
+
readonly_fields = ("created_on", "data")
|
|
2094
|
+
|
|
2095
|
+
change_list_template = "admin/core/clientreport/change_list.html"
|
|
2096
|
+
|
|
2097
|
+
class ClientReportForm(forms.Form):
|
|
2098
|
+
PERIOD_CHOICES = [
|
|
2099
|
+
("range", "Date range"),
|
|
2100
|
+
("week", "Week"),
|
|
2101
|
+
("month", "Month"),
|
|
2102
|
+
]
|
|
2103
|
+
RECURRENCE_CHOICES = ClientReportSchedule.PERIODICITY_CHOICES
|
|
2104
|
+
period = forms.ChoiceField(
|
|
2105
|
+
choices=PERIOD_CHOICES,
|
|
2106
|
+
widget=forms.RadioSelect,
|
|
2107
|
+
initial="range",
|
|
2108
|
+
help_text="Choose how the reporting window will be calculated.",
|
|
2109
|
+
)
|
|
2110
|
+
start = forms.DateField(
|
|
2111
|
+
label="Start date",
|
|
2112
|
+
required=False,
|
|
2113
|
+
widget=forms.DateInput(attrs={"type": "date"}),
|
|
2114
|
+
help_text="First day included when using a custom date range.",
|
|
2115
|
+
)
|
|
2116
|
+
end = forms.DateField(
|
|
2117
|
+
label="End date",
|
|
2118
|
+
required=False,
|
|
2119
|
+
widget=forms.DateInput(attrs={"type": "date"}),
|
|
2120
|
+
help_text="Last day included when using a custom date range.",
|
|
2121
|
+
)
|
|
2122
|
+
week = forms.CharField(
|
|
2123
|
+
label="Week",
|
|
2124
|
+
required=False,
|
|
2125
|
+
widget=forms.TextInput(attrs={"type": "week"}),
|
|
2126
|
+
help_text="Generates the report for the ISO week that you select.",
|
|
2127
|
+
)
|
|
2128
|
+
month = forms.DateField(
|
|
2129
|
+
label="Month",
|
|
2130
|
+
required=False,
|
|
2131
|
+
widget=forms.DateInput(attrs={"type": "month"}),
|
|
2132
|
+
help_text="Generates the report for the calendar month that you select.",
|
|
2133
|
+
)
|
|
2134
|
+
owner = forms.ModelChoiceField(
|
|
2135
|
+
queryset=get_user_model().objects.all(),
|
|
2136
|
+
required=False,
|
|
2137
|
+
help_text="Sets who owns the report schedule and is listed as the requestor.",
|
|
2138
|
+
)
|
|
2139
|
+
destinations = forms.CharField(
|
|
2140
|
+
label="Email destinations",
|
|
2141
|
+
required=False,
|
|
2142
|
+
widget=forms.Textarea(attrs={"rows": 2}),
|
|
2143
|
+
help_text="Separate addresses with commas or new lines.",
|
|
2144
|
+
)
|
|
2145
|
+
recurrence = forms.ChoiceField(
|
|
2146
|
+
label="Recurrency",
|
|
2147
|
+
choices=RECURRENCE_CHOICES,
|
|
2148
|
+
initial=ClientReportSchedule.PERIODICITY_NONE,
|
|
2149
|
+
help_text="Defines how often the report should be generated automatically.",
|
|
2150
|
+
)
|
|
2151
|
+
disable_emails = forms.BooleanField(
|
|
2152
|
+
label="Disable email delivery",
|
|
2153
|
+
required=False,
|
|
2154
|
+
help_text="Generate files without sending emails.",
|
|
2155
|
+
)
|
|
2156
|
+
|
|
2157
|
+
def __init__(self, *args, request=None, **kwargs):
|
|
2158
|
+
self.request = request
|
|
2159
|
+
super().__init__(*args, **kwargs)
|
|
2160
|
+
if (
|
|
2161
|
+
request
|
|
2162
|
+
and getattr(request, "user", None)
|
|
2163
|
+
and request.user.is_authenticated
|
|
2164
|
+
):
|
|
2165
|
+
self.fields["owner"].initial = request.user.pk
|
|
2166
|
+
|
|
2167
|
+
def clean(self):
|
|
2168
|
+
cleaned = super().clean()
|
|
2169
|
+
period = cleaned.get("period")
|
|
2170
|
+
if period == "range":
|
|
2171
|
+
if not cleaned.get("start") or not cleaned.get("end"):
|
|
2172
|
+
raise forms.ValidationError("Please provide start and end dates.")
|
|
2173
|
+
elif period == "week":
|
|
2174
|
+
week_str = cleaned.get("week")
|
|
2175
|
+
if not week_str:
|
|
2176
|
+
raise forms.ValidationError("Please select a week.")
|
|
2177
|
+
year, week_num = week_str.split("-W")
|
|
2178
|
+
start = datetime.date.fromisocalendar(int(year), int(week_num), 1)
|
|
2179
|
+
cleaned["start"] = start
|
|
2180
|
+
cleaned["end"] = start + datetime.timedelta(days=6)
|
|
2181
|
+
elif period == "month":
|
|
2182
|
+
month_dt = cleaned.get("month")
|
|
2183
|
+
if not month_dt:
|
|
2184
|
+
raise forms.ValidationError("Please select a month.")
|
|
2185
|
+
start = month_dt.replace(day=1)
|
|
2186
|
+
last_day = calendar.monthrange(month_dt.year, month_dt.month)[1]
|
|
2187
|
+
cleaned["start"] = start
|
|
2188
|
+
cleaned["end"] = month_dt.replace(day=last_day)
|
|
2189
|
+
return cleaned
|
|
2190
|
+
|
|
2191
|
+
def clean_destinations(self):
|
|
2192
|
+
raw = self.cleaned_data.get("destinations", "")
|
|
2193
|
+
if not raw:
|
|
2194
|
+
return []
|
|
2195
|
+
validator = EmailValidator()
|
|
2196
|
+
seen: set[str] = set()
|
|
2197
|
+
emails: list[str] = []
|
|
2198
|
+
for part in re.split(r"[\s,]+", raw):
|
|
2199
|
+
candidate = part.strip()
|
|
2200
|
+
if not candidate:
|
|
2201
|
+
continue
|
|
2202
|
+
validator(candidate)
|
|
2203
|
+
key = candidate.lower()
|
|
2204
|
+
if key in seen:
|
|
2205
|
+
continue
|
|
2206
|
+
seen.add(key)
|
|
2207
|
+
emails.append(candidate)
|
|
2208
|
+
return emails
|
|
2209
|
+
|
|
2210
|
+
def get_urls(self):
|
|
2211
|
+
urls = super().get_urls()
|
|
2212
|
+
custom = [
|
|
2213
|
+
path(
|
|
2214
|
+
"generate/",
|
|
2215
|
+
self.admin_site.admin_view(self.generate_view),
|
|
2216
|
+
name="core_clientreport_generate",
|
|
2217
|
+
),
|
|
2218
|
+
]
|
|
2219
|
+
return custom + urls
|
|
2220
|
+
|
|
2221
|
+
def generate_view(self, request):
|
|
2222
|
+
form = self.ClientReportForm(request.POST or None, request=request)
|
|
2223
|
+
report = None
|
|
2224
|
+
schedule = None
|
|
2225
|
+
if request.method == "POST" and form.is_valid():
|
|
2226
|
+
owner = form.cleaned_data.get("owner")
|
|
2227
|
+
if not owner and request.user.is_authenticated:
|
|
2228
|
+
owner = request.user
|
|
2229
|
+
report = ClientReport.generate(
|
|
2230
|
+
form.cleaned_data["start"],
|
|
2231
|
+
form.cleaned_data["end"],
|
|
2232
|
+
owner=owner,
|
|
2233
|
+
recipients=form.cleaned_data.get("destinations"),
|
|
2234
|
+
disable_emails=form.cleaned_data.get("disable_emails", False),
|
|
2235
|
+
)
|
|
2236
|
+
report.store_local_copy()
|
|
2237
|
+
recurrence = form.cleaned_data.get("recurrence")
|
|
2238
|
+
if recurrence and recurrence != ClientReportSchedule.PERIODICITY_NONE:
|
|
2239
|
+
schedule = ClientReportSchedule.objects.create(
|
|
2240
|
+
owner=owner,
|
|
2241
|
+
created_by=request.user if request.user.is_authenticated else None,
|
|
2242
|
+
periodicity=recurrence,
|
|
2243
|
+
email_recipients=form.cleaned_data.get("destinations", []),
|
|
2244
|
+
disable_emails=form.cleaned_data.get("disable_emails", False),
|
|
2245
|
+
)
|
|
2246
|
+
report.schedule = schedule
|
|
2247
|
+
report.save(update_fields=["schedule"])
|
|
2248
|
+
self.message_user(
|
|
2249
|
+
request,
|
|
2250
|
+
"Client report schedule created; future reports will be generated automatically.",
|
|
2251
|
+
messages.SUCCESS,
|
|
2252
|
+
)
|
|
2253
|
+
context = self.admin_site.each_context(request)
|
|
2254
|
+
context.update({"form": form, "report": report, "schedule": schedule})
|
|
2255
|
+
return TemplateResponse(
|
|
2256
|
+
request, "admin/core/clientreport/generate.html", context
|
|
2257
|
+
)
|
|
2258
|
+
|
|
2259
|
+
|
|
889
2260
|
@admin.register(PackageRelease)
|
|
890
|
-
class PackageReleaseAdmin(SaveBeforeChangeAction,
|
|
2261
|
+
class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
|
|
2262
|
+
change_list_template = "admin/core/packagerelease/change_list.html"
|
|
891
2263
|
list_display = (
|
|
892
2264
|
"version",
|
|
893
2265
|
"package_link",
|
|
@@ -899,7 +2271,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
|
|
|
899
2271
|
list_display_links = ("version",)
|
|
900
2272
|
actions = ["publish_release", "validate_releases"]
|
|
901
2273
|
change_actions = ["publish_release_action"]
|
|
902
|
-
changelist_actions = ["refresh_from_pypi"]
|
|
2274
|
+
changelist_actions = ["refresh_from_pypi", "prepare_next_release"]
|
|
903
2275
|
readonly_fields = ("pypi_url", "is_current", "revision")
|
|
904
2276
|
fields = (
|
|
905
2277
|
"package",
|
|
@@ -944,6 +2316,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
|
|
|
944
2316
|
package=package,
|
|
945
2317
|
release_manager=package.release_manager,
|
|
946
2318
|
version=version,
|
|
2319
|
+
revision="",
|
|
947
2320
|
pypi_url=f"https://pypi.org/project/{package.name}/{version}/",
|
|
948
2321
|
)
|
|
949
2322
|
created += 1
|
|
@@ -960,6 +2333,16 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
|
|
|
960
2333
|
refresh_from_pypi.label = "Refresh from PyPI"
|
|
961
2334
|
refresh_from_pypi.short_description = "Refresh from PyPI"
|
|
962
2335
|
|
|
2336
|
+
def prepare_next_release(self, request, queryset):
|
|
2337
|
+
package = Package.objects.filter(is_active=True).first()
|
|
2338
|
+
if not package:
|
|
2339
|
+
self.message_user(request, "No active package", messages.ERROR)
|
|
2340
|
+
return redirect("admin:core_packagerelease_changelist")
|
|
2341
|
+
return PackageAdmin._prepare(self, request, package)
|
|
2342
|
+
|
|
2343
|
+
prepare_next_release.label = "Prepare next Release"
|
|
2344
|
+
prepare_next_release.short_description = "Prepare next release"
|
|
2345
|
+
|
|
963
2346
|
def _publish_release(self, request, release):
|
|
964
2347
|
try:
|
|
965
2348
|
release.full_clean()
|
|
@@ -994,9 +2377,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
|
|
|
994
2377
|
messages.WARNING,
|
|
995
2378
|
)
|
|
996
2379
|
continue
|
|
997
|
-
url =
|
|
998
|
-
f"https://pypi.org/pypi/{release.package.name}/{release.version}/json"
|
|
999
|
-
)
|
|
2380
|
+
url = f"https://pypi.org/pypi/{release.package.name}/{release.version}/json"
|
|
1000
2381
|
try:
|
|
1001
2382
|
resp = requests.get(url, timeout=10)
|
|
1002
2383
|
except Exception as exc: # pragma: no cover - network failure
|
|
@@ -1029,3 +2410,23 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
|
|
|
1029
2410
|
return self._boolean_icon(obj.is_current)
|
|
1030
2411
|
|
|
1031
2412
|
|
|
2413
|
+
@admin.register(Todo)
|
|
2414
|
+
class TodoAdmin(EntityModelAdmin):
|
|
2415
|
+
list_display = ("request", "url")
|
|
2416
|
+
|
|
2417
|
+
def has_add_permission(self, request, obj=None):
|
|
2418
|
+
return False
|
|
2419
|
+
|
|
2420
|
+
def get_model_perms(self, request):
|
|
2421
|
+
return {}
|
|
2422
|
+
|
|
2423
|
+
def render_change_form(
|
|
2424
|
+
self, request, context, add=False, change=False, form_url="", obj=None
|
|
2425
|
+
):
|
|
2426
|
+
context = super().render_change_form(
|
|
2427
|
+
request, context, add=add, change=change, form_url=form_url, obj=obj
|
|
2428
|
+
)
|
|
2429
|
+
context["show_user_datum"] = False
|
|
2430
|
+
context["show_seed_datum"] = False
|
|
2431
|
+
context["show_save_as_copy"] = False
|
|
2432
|
+
return context
|