arthexis 0.1.8__py3-none-any.whl → 0.1.9__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.9.dist-info}/METADATA +42 -4
- arthexis-0.1.9.dist-info/RECORD +92 -0
- arthexis-0.1.9.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 +133 -16
- config/urls.py +65 -6
- core/admin.py +1226 -191
- core/admin_history.py +50 -0
- core/admindocs.py +108 -1
- core/apps.py +158 -3
- core/backends.py +46 -4
- core/entity.py +62 -48
- core/fields.py +6 -1
- 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 +1071 -264
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/release.py +27 -20
- core/sigil_builder.py +131 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +129 -10
- core/tasks.py +118 -19
- core/test_system_info.py +22 -0
- core/tests.py +358 -63
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +329 -167
- core/views.py +383 -57
- 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 +159 -284
- nodes/apps.py +9 -15
- nodes/backends.py +53 -0
- nodes/lcd.py +24 -10
- nodes/models.py +375 -178
- nodes/tasks.py +1 -5
- nodes/tests.py +524 -129
- nodes/utils.py +13 -2
- nodes/views.py +66 -23
- ocpp/admin.py +150 -61
- ocpp/apps.py +1 -1
- ocpp/consumers.py +432 -69
- ocpp/evcs.py +25 -8
- ocpp/models.py +408 -68
- ocpp/simulator.py +13 -6
- ocpp/store.py +258 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1198 -135
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +654 -101
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +19 -6
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +759 -40
- pages/urls.py +3 -0
- pages/utils.py +0 -1
- pages/views.py +576 -25
- 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.9.dist-info}/WHEEL +0 -0
- {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/top_level.txt +0 -0
core/admin.py
CHANGED
|
@@ -5,8 +5,10 @@ from django.urls import path, reverse
|
|
|
5
5
|
from django.shortcuts import redirect, render
|
|
6
6
|
from django.http import JsonResponse, HttpResponseBase, HttpResponseRedirect
|
|
7
7
|
from django.template.response import TemplateResponse
|
|
8
|
+
from django.conf import settings
|
|
8
9
|
from django.views.decorators.csrf import csrf_exempt
|
|
9
10
|
from django.core.exceptions import ValidationError
|
|
11
|
+
from django.core.validators import EmailValidator
|
|
10
12
|
from django.contrib import messages
|
|
11
13
|
from django.contrib.auth import get_user_model
|
|
12
14
|
from django.contrib.auth.admin import (
|
|
@@ -19,54 +21,90 @@ from import_export.widgets import ForeignKeyWidget
|
|
|
19
21
|
from django.contrib.auth.models import Group
|
|
20
22
|
from django.templatetags.static import static
|
|
21
23
|
from django.utils.html import format_html
|
|
24
|
+
from django.utils.translation import gettext_lazy as _
|
|
25
|
+
from django.forms.models import BaseInlineFormSet
|
|
22
26
|
import json
|
|
23
27
|
import uuid
|
|
24
28
|
import requests
|
|
29
|
+
import datetime
|
|
30
|
+
import calendar
|
|
31
|
+
import re
|
|
25
32
|
from django_object_actions import DjangoObjectActions
|
|
26
|
-
from .
|
|
33
|
+
from ocpp.models import Transaction
|
|
34
|
+
from nodes.models import EmailOutbox
|
|
27
35
|
from .models import (
|
|
28
36
|
User,
|
|
37
|
+
UserPhoneNumber,
|
|
29
38
|
EnergyAccount,
|
|
30
39
|
ElectricVehicle,
|
|
31
|
-
EnergyCredit,
|
|
32
|
-
Address,
|
|
33
|
-
Product,
|
|
34
|
-
Subscription,
|
|
35
40
|
Brand,
|
|
36
|
-
WMICode,
|
|
37
41
|
EVModel,
|
|
42
|
+
WMICode,
|
|
43
|
+
EnergyCredit,
|
|
44
|
+
ClientReport,
|
|
45
|
+
ClientReportSchedule,
|
|
46
|
+
Product,
|
|
38
47
|
RFID,
|
|
48
|
+
SigilRoot,
|
|
49
|
+
CustomSigil,
|
|
39
50
|
Reference,
|
|
40
51
|
OdooProfile,
|
|
41
|
-
|
|
42
|
-
|
|
52
|
+
EmailInbox,
|
|
53
|
+
EmailCollector,
|
|
43
54
|
Package,
|
|
44
55
|
PackageRelease,
|
|
45
56
|
ReleaseManager,
|
|
46
57
|
SecurityGroup,
|
|
47
58
|
InviteLead,
|
|
48
|
-
|
|
59
|
+
PublicWifiAccess,
|
|
60
|
+
AssistantProfile,
|
|
61
|
+
Todo,
|
|
62
|
+
hash_key,
|
|
49
63
|
)
|
|
50
|
-
from .user_data import
|
|
64
|
+
from .user_data import EntityModelAdmin, delete_user_fixture, dump_user_fixture
|
|
65
|
+
from .widgets import OdooProductWidget
|
|
66
|
+
from .mcp import process as mcp_process
|
|
51
67
|
|
|
52
68
|
|
|
53
69
|
admin.site.unregister(Group)
|
|
54
70
|
|
|
55
71
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
def _append_operate_as(fieldsets):
|
|
73
|
+
updated = []
|
|
74
|
+
for name, options in fieldsets:
|
|
75
|
+
opts = options.copy()
|
|
76
|
+
fields = opts.get("fields")
|
|
77
|
+
if fields and "is_staff" in fields and "operate_as" not in fields:
|
|
78
|
+
if not isinstance(fields, (list, tuple)):
|
|
79
|
+
fields = list(fields)
|
|
80
|
+
else:
|
|
81
|
+
fields = list(fields)
|
|
82
|
+
fields.append("operate_as")
|
|
83
|
+
opts["fields"] = tuple(fields)
|
|
84
|
+
updated.append((name, opts))
|
|
85
|
+
return tuple(updated)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Add object links for small datasets in changelist view
|
|
89
|
+
original_changelist_view = admin.ModelAdmin.changelist_view
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def changelist_view_with_object_links(self, request, extra_context=None):
|
|
93
|
+
extra_context = extra_context or {}
|
|
94
|
+
count = self.model._default_manager.count()
|
|
95
|
+
if 1 <= count <= 4:
|
|
96
|
+
links = []
|
|
97
|
+
for obj in self.model._default_manager.all():
|
|
98
|
+
url = reverse(
|
|
99
|
+
f"admin:{self.model._meta.app_label}_{self.model._meta.model_name}_change",
|
|
100
|
+
args=[obj.pk],
|
|
101
|
+
)
|
|
102
|
+
links.append({"url": url, "label": str(obj)})
|
|
103
|
+
extra_context["global_object_links"] = links
|
|
104
|
+
return original_changelist_view(self, request, extra_context=extra_context)
|
|
62
105
|
|
|
63
106
|
|
|
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
|
|
107
|
+
admin.ModelAdmin.changelist_view = changelist_view_with_object_links
|
|
70
108
|
|
|
71
109
|
|
|
72
110
|
class ExperienceReference(Reference):
|
|
@@ -77,7 +115,40 @@ class ExperienceReference(Reference):
|
|
|
77
115
|
verbose_name_plural = Reference._meta.verbose_name_plural
|
|
78
116
|
|
|
79
117
|
|
|
118
|
+
class CustomSigilAdminForm(forms.ModelForm):
|
|
119
|
+
class Meta:
|
|
120
|
+
model = CustomSigil
|
|
121
|
+
fields = ["prefix", "content_type"]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@admin.register(CustomSigil)
|
|
125
|
+
class CustomSigilAdmin(EntityModelAdmin):
|
|
126
|
+
form = CustomSigilAdminForm
|
|
127
|
+
list_display = ("prefix", "content_type")
|
|
128
|
+
|
|
129
|
+
def get_queryset(self, request):
|
|
130
|
+
qs = super().get_queryset(request)
|
|
131
|
+
return qs.filter(context_type=SigilRoot.Context.ENTITY)
|
|
132
|
+
|
|
133
|
+
def save_model(self, request, obj, form, change):
|
|
134
|
+
obj.context_type = SigilRoot.Context.ENTITY
|
|
135
|
+
super().save_model(request, obj, form, change)
|
|
136
|
+
|
|
137
|
+
|
|
80
138
|
class SaveBeforeChangeAction(DjangoObjectActions):
|
|
139
|
+
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
|
140
|
+
extra_context = extra_context or {}
|
|
141
|
+
extra_context.update(
|
|
142
|
+
{
|
|
143
|
+
"objectactions": [
|
|
144
|
+
self._get_tool_dict(action)
|
|
145
|
+
for action in self.get_change_actions(request, object_id, form_url)
|
|
146
|
+
],
|
|
147
|
+
"tools_view_name": self.tools_view_name,
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
return super().changeform_view(request, object_id, form_url, extra_context)
|
|
151
|
+
|
|
81
152
|
def response_change(self, request, obj):
|
|
82
153
|
action = request.POST.get("_action")
|
|
83
154
|
if action:
|
|
@@ -91,12 +162,12 @@ class SaveBeforeChangeAction(DjangoObjectActions):
|
|
|
91
162
|
|
|
92
163
|
|
|
93
164
|
@admin.register(ExperienceReference)
|
|
94
|
-
class ReferenceAdmin(
|
|
165
|
+
class ReferenceAdmin(EntityModelAdmin):
|
|
95
166
|
list_display = (
|
|
96
167
|
"alt_text",
|
|
97
168
|
"content_type",
|
|
98
|
-
"
|
|
99
|
-
"
|
|
169
|
+
"footer",
|
|
170
|
+
"visibility",
|
|
100
171
|
"author",
|
|
101
172
|
"transaction_uuid",
|
|
102
173
|
)
|
|
@@ -107,6 +178,9 @@ class ReferenceAdmin(admin.ModelAdmin):
|
|
|
107
178
|
"value",
|
|
108
179
|
"file",
|
|
109
180
|
"method",
|
|
181
|
+
"roles",
|
|
182
|
+
"features",
|
|
183
|
+
"sites",
|
|
110
184
|
"include_in_footer",
|
|
111
185
|
"footer_visibility",
|
|
112
186
|
"transaction_uuid",
|
|
@@ -114,6 +188,7 @@ class ReferenceAdmin(admin.ModelAdmin):
|
|
|
114
188
|
"uses",
|
|
115
189
|
"qr_code",
|
|
116
190
|
)
|
|
191
|
+
filter_horizontal = ("roles", "features", "sites")
|
|
117
192
|
|
|
118
193
|
def get_readonly_fields(self, request, obj=None):
|
|
119
194
|
ro = list(super().get_readonly_fields(request, obj))
|
|
@@ -121,6 +196,14 @@ class ReferenceAdmin(admin.ModelAdmin):
|
|
|
121
196
|
ro.append("transaction_uuid")
|
|
122
197
|
return ro
|
|
123
198
|
|
|
199
|
+
@admin.display(description="Footer", boolean=True, ordering="include_in_footer")
|
|
200
|
+
def footer(self, obj):
|
|
201
|
+
return obj.include_in_footer
|
|
202
|
+
|
|
203
|
+
@admin.display(description="Visibility", ordering="footer_visibility")
|
|
204
|
+
def visibility(self, obj):
|
|
205
|
+
return obj.get_footer_visibility_display()
|
|
206
|
+
|
|
124
207
|
def get_urls(self):
|
|
125
208
|
urls = super().get_urls()
|
|
126
209
|
custom = [
|
|
@@ -166,13 +249,88 @@ class ReferenceAdmin(admin.ModelAdmin):
|
|
|
166
249
|
qr_code.short_description = "QR Code"
|
|
167
250
|
|
|
168
251
|
|
|
169
|
-
|
|
170
|
-
class
|
|
171
|
-
|
|
252
|
+
class ReleaseManagerAdminForm(forms.ModelForm):
|
|
253
|
+
class Meta:
|
|
254
|
+
model = ReleaseManager
|
|
255
|
+
fields = "__all__"
|
|
256
|
+
widgets = {
|
|
257
|
+
"pypi_token": forms.Textarea(attrs={"rows": 3, "style": "width: 40em;"}),
|
|
258
|
+
"github_token": forms.Textarea(attrs={"rows": 3, "style": "width: 40em;"}),
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@admin.register(ReleaseManager)
|
|
263
|
+
class ReleaseManagerAdmin(SaveBeforeChangeAction, EntityModelAdmin):
|
|
264
|
+
form = ReleaseManagerAdminForm
|
|
265
|
+
list_display = ("owner", "pypi_username", "pypi_url")
|
|
266
|
+
actions = ["test_credentials"]
|
|
267
|
+
change_actions = ["test_credentials_action"]
|
|
268
|
+
fieldsets = (
|
|
269
|
+
("Owner", {"fields": ("user", "group")}),
|
|
270
|
+
(
|
|
271
|
+
"Credentials",
|
|
272
|
+
{
|
|
273
|
+
"fields": (
|
|
274
|
+
"pypi_username",
|
|
275
|
+
"pypi_token",
|
|
276
|
+
"pypi_password",
|
|
277
|
+
"github_token",
|
|
278
|
+
"pypi_url",
|
|
279
|
+
)
|
|
280
|
+
},
|
|
281
|
+
),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def owner(self, obj):
|
|
285
|
+
return obj.owner_display()
|
|
286
|
+
|
|
287
|
+
owner.short_description = "Owner"
|
|
288
|
+
|
|
289
|
+
@admin.action(description="Test credentials")
|
|
290
|
+
def test_credentials(self, request, queryset):
|
|
291
|
+
for manager in queryset:
|
|
292
|
+
self._test_credentials(request, manager)
|
|
293
|
+
|
|
294
|
+
def test_credentials_action(self, request, obj):
|
|
295
|
+
self._test_credentials(request, obj)
|
|
296
|
+
|
|
297
|
+
test_credentials_action.label = "Test credentials"
|
|
298
|
+
test_credentials_action.short_description = "Test credentials"
|
|
299
|
+
|
|
300
|
+
def _test_credentials(self, request, manager):
|
|
301
|
+
creds = manager.to_credentials()
|
|
302
|
+
if not creds:
|
|
303
|
+
self.message_user(request, f"{manager} has no credentials", messages.ERROR)
|
|
304
|
+
return
|
|
305
|
+
url = manager.pypi_url or "https://upload.pypi.org/legacy/"
|
|
306
|
+
auth = (
|
|
307
|
+
("__token__", creds.token)
|
|
308
|
+
if creds.token
|
|
309
|
+
else (creds.username, creds.password)
|
|
310
|
+
)
|
|
311
|
+
try:
|
|
312
|
+
resp = requests.get(url, auth=auth, timeout=10)
|
|
313
|
+
if resp.ok:
|
|
314
|
+
self.message_user(
|
|
315
|
+
request, f"{manager} credentials valid", messages.SUCCESS
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
self.message_user(
|
|
319
|
+
request,
|
|
320
|
+
f"{manager} credentials invalid ({resp.status_code})",
|
|
321
|
+
messages.ERROR,
|
|
322
|
+
)
|
|
323
|
+
except Exception as exc: # pragma: no cover - admin feedback
|
|
324
|
+
self.message_user(
|
|
325
|
+
request, f"{manager} credentials check failed: {exc}", messages.ERROR
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def get_model_perms(self, request):
|
|
329
|
+
return {}
|
|
172
330
|
|
|
173
331
|
|
|
174
332
|
@admin.register(Package)
|
|
175
|
-
class PackageAdmin(SaveBeforeChangeAction,
|
|
333
|
+
class PackageAdmin(SaveBeforeChangeAction, EntityModelAdmin):
|
|
176
334
|
list_display = (
|
|
177
335
|
"name",
|
|
178
336
|
"description",
|
|
@@ -188,25 +346,36 @@ class PackageAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
|
|
|
188
346
|
from packaging.version import Version
|
|
189
347
|
|
|
190
348
|
ver_file = Path("VERSION")
|
|
191
|
-
repo_version =
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
Version(
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
349
|
+
repo_version = (
|
|
350
|
+
Version(ver_file.read_text().strip())
|
|
351
|
+
if ver_file.exists()
|
|
352
|
+
else Version("0.0.0")
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
pypi_latest = Version("0.0.0")
|
|
356
|
+
try:
|
|
357
|
+
resp = requests.get(
|
|
358
|
+
f"https://pypi.org/pypi/{package.name}/json", timeout=10
|
|
359
|
+
)
|
|
360
|
+
if resp.ok:
|
|
361
|
+
releases = resp.json().get("releases", {})
|
|
362
|
+
if releases:
|
|
363
|
+
pypi_latest = max(Version(v) for v in releases)
|
|
364
|
+
except Exception:
|
|
365
|
+
pass
|
|
366
|
+
pypi_plus_one = Version(
|
|
367
|
+
f"{pypi_latest.major}.{pypi_latest.minor}.{pypi_latest.micro + 1}"
|
|
368
|
+
)
|
|
369
|
+
next_version = max(repo_version, pypi_plus_one)
|
|
199
370
|
release, _created = PackageRelease.all_objects.update_or_create(
|
|
200
371
|
package=package,
|
|
201
|
-
version=next_version,
|
|
372
|
+
version=str(next_version),
|
|
202
373
|
defaults={
|
|
203
374
|
"release_manager": package.release_manager,
|
|
204
375
|
"is_deleted": False,
|
|
205
376
|
},
|
|
206
377
|
)
|
|
207
|
-
return redirect(
|
|
208
|
-
reverse("admin:core_packagerelease_change", args=[release.pk])
|
|
209
|
-
)
|
|
378
|
+
return redirect(reverse("admin:core_packagerelease_change", args=[release.pk]))
|
|
210
379
|
|
|
211
380
|
def get_urls(self):
|
|
212
381
|
urls = super().get_urls()
|
|
@@ -229,9 +398,7 @@ class PackageAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
|
|
|
229
398
|
@admin.action(description="Prepare next Release")
|
|
230
399
|
def prepare_next_release(self, request, queryset):
|
|
231
400
|
if queryset.count() != 1:
|
|
232
|
-
self.message_user(
|
|
233
|
-
request, "Select exactly one package", messages.ERROR
|
|
234
|
-
)
|
|
401
|
+
self.message_user(request, "Select exactly one package", messages.ERROR)
|
|
235
402
|
return
|
|
236
403
|
return self._prepare(request, queryset.first())
|
|
237
404
|
|
|
@@ -250,7 +417,7 @@ class SecurityGroupAdminForm(forms.ModelForm):
|
|
|
250
417
|
)
|
|
251
418
|
|
|
252
419
|
class Meta:
|
|
253
|
-
model =
|
|
420
|
+
model = SecurityGroup
|
|
254
421
|
fields = "__all__"
|
|
255
422
|
|
|
256
423
|
def __init__(self, *args, **kwargs):
|
|
@@ -268,16 +435,14 @@ class SecurityGroupAdminForm(forms.ModelForm):
|
|
|
268
435
|
return instance
|
|
269
436
|
|
|
270
437
|
|
|
271
|
-
@admin.register(WorkgroupSecurityGroup)
|
|
272
438
|
class SecurityGroupAdmin(DjangoGroupAdmin):
|
|
273
439
|
form = SecurityGroupAdminForm
|
|
274
440
|
fieldsets = ((None, {"fields": ("name", "parent", "users", "permissions")}),)
|
|
275
441
|
filter_horizontal = ("permissions",)
|
|
276
442
|
|
|
277
443
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
list_display = ("email", "created_on")
|
|
444
|
+
class InviteLeadAdmin(EntityModelAdmin):
|
|
445
|
+
list_display = ("email", "mac_address", "created_on", "sent_on", "short_error")
|
|
281
446
|
search_fields = ("email", "comment")
|
|
282
447
|
readonly_fields = (
|
|
283
448
|
"created_on",
|
|
@@ -286,8 +451,24 @@ class InviteLeadAdmin(admin.ModelAdmin):
|
|
|
286
451
|
"referer",
|
|
287
452
|
"user_agent",
|
|
288
453
|
"ip_address",
|
|
454
|
+
"mac_address",
|
|
455
|
+
"sent_on",
|
|
456
|
+
"error",
|
|
289
457
|
)
|
|
290
458
|
|
|
459
|
+
def short_error(self, obj):
|
|
460
|
+
return (obj.error[:40] + "…") if len(obj.error) > 40 else obj.error
|
|
461
|
+
|
|
462
|
+
short_error.short_description = "error"
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@admin.register(PublicWifiAccess)
|
|
466
|
+
class PublicWifiAccessAdmin(EntityModelAdmin):
|
|
467
|
+
list_display = ("user", "mac_address", "created_on", "revoked_on")
|
|
468
|
+
search_fields = ("user__username", "mac_address")
|
|
469
|
+
readonly_fields = ("user", "mac_address", "created_on", "updated_on", "revoked_on")
|
|
470
|
+
ordering = ("-created_on",)
|
|
471
|
+
|
|
291
472
|
|
|
292
473
|
class EnergyAccountRFIDForm(forms.ModelForm):
|
|
293
474
|
"""Form for assigning existing RFIDs to an energy account."""
|
|
@@ -299,7 +480,9 @@ class EnergyAccountRFIDForm(forms.ModelForm):
|
|
|
299
480
|
def clean_rfid(self):
|
|
300
481
|
rfid = self.cleaned_data["rfid"]
|
|
301
482
|
if rfid.energy_accounts.exclude(pk=self.instance.energyaccount_id).exists():
|
|
302
|
-
raise forms.ValidationError(
|
|
483
|
+
raise forms.ValidationError(
|
|
484
|
+
"RFID is already assigned to another energy account"
|
|
485
|
+
)
|
|
303
486
|
return rfid
|
|
304
487
|
|
|
305
488
|
|
|
@@ -312,27 +495,53 @@ class EnergyAccountRFIDInline(admin.TabularInline):
|
|
|
312
495
|
verbose_name_plural = "RFIDs"
|
|
313
496
|
|
|
314
497
|
|
|
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
|
-
)
|
|
498
|
+
def _raw_instance_value(instance, field_name):
|
|
499
|
+
"""Return the stored value for ``field_name`` without resolving sigils."""
|
|
322
500
|
|
|
501
|
+
field = instance._meta.get_field(field_name)
|
|
502
|
+
if not instance.pk:
|
|
503
|
+
return field.value_from_object(instance)
|
|
504
|
+
manager = type(instance)._default_manager
|
|
505
|
+
try:
|
|
506
|
+
return (
|
|
507
|
+
manager.filter(pk=instance.pk).values_list(field.attname, flat=True).get()
|
|
508
|
+
)
|
|
509
|
+
except type(instance).DoesNotExist: # pragma: no cover - instance deleted
|
|
510
|
+
return field.value_from_object(instance)
|
|
323
511
|
|
|
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
512
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
513
|
+
class KeepExistingValue:
|
|
514
|
+
"""Sentinel indicating a field should retain its stored value."""
|
|
515
|
+
|
|
516
|
+
__slots__ = ("field",)
|
|
517
|
+
|
|
518
|
+
def __init__(self, field: str):
|
|
519
|
+
self.field = field
|
|
520
|
+
|
|
521
|
+
def __bool__(self) -> bool: # pragma: no cover - trivial
|
|
522
|
+
return False
|
|
523
|
+
|
|
524
|
+
def __repr__(self) -> str: # pragma: no cover - debugging helper
|
|
525
|
+
return f"<KeepExistingValue field={self.field!r}>"
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def keep_existing(field: str) -> KeepExistingValue:
|
|
529
|
+
return KeepExistingValue(field)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _restore_sigil_values(form, field_names):
|
|
533
|
+
"""Reset sigil fields on ``form.instance`` to their raw form values."""
|
|
534
|
+
|
|
535
|
+
for name in field_names:
|
|
536
|
+
if name not in form.fields:
|
|
537
|
+
continue
|
|
538
|
+
if name in form.cleaned_data:
|
|
539
|
+
raw = form.cleaned_data[name]
|
|
540
|
+
if isinstance(raw, KeepExistingValue):
|
|
541
|
+
raw = _raw_instance_value(form.instance, name)
|
|
334
542
|
else:
|
|
335
|
-
|
|
543
|
+
raw = _raw_instance_value(form.instance, name)
|
|
544
|
+
setattr(form.instance, name, raw)
|
|
336
545
|
|
|
337
546
|
|
|
338
547
|
class OdooProfileAdminForm(forms.ModelForm):
|
|
@@ -359,103 +568,190 @@ class OdooProfileAdminForm(forms.ModelForm):
|
|
|
359
568
|
def clean_password(self):
|
|
360
569
|
pwd = self.cleaned_data.get("password")
|
|
361
570
|
if not pwd and self.instance.pk:
|
|
362
|
-
return
|
|
571
|
+
return keep_existing("password")
|
|
363
572
|
return pwd
|
|
364
573
|
|
|
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
|
-
)
|
|
574
|
+
def _post_clean(self):
|
|
575
|
+
super()._post_clean()
|
|
576
|
+
_restore_sigil_values(
|
|
577
|
+
self,
|
|
578
|
+
["host", "database", "username", "password"],
|
|
579
|
+
)
|
|
388
580
|
|
|
389
581
|
|
|
390
|
-
class
|
|
391
|
-
"""Admin form for :class:`core.models.
|
|
582
|
+
class EmailInboxAdminForm(forms.ModelForm):
|
|
583
|
+
"""Admin form for :class:`core.models.EmailInbox` with hidden password."""
|
|
392
584
|
|
|
393
|
-
|
|
585
|
+
password = forms.CharField(
|
|
394
586
|
widget=forms.PasswordInput(render_value=True),
|
|
395
587
|
required=False,
|
|
396
|
-
help_text="Leave blank to keep the current
|
|
588
|
+
help_text="Leave blank to keep the current password.",
|
|
397
589
|
)
|
|
398
590
|
|
|
399
591
|
class Meta:
|
|
400
|
-
model =
|
|
592
|
+
model = EmailInbox
|
|
401
593
|
fields = "__all__"
|
|
402
594
|
|
|
403
595
|
def __init__(self, *args, **kwargs):
|
|
404
596
|
super().__init__(*args, **kwargs)
|
|
405
597
|
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
|
-
|
|
598
|
+
self.fields["password"].initial = ""
|
|
599
|
+
self.initial["password"] = ""
|
|
600
|
+
else:
|
|
601
|
+
self.fields["password"].required = True
|
|
602
|
+
|
|
603
|
+
def clean_password(self):
|
|
604
|
+
pwd = self.cleaned_data.get("password")
|
|
605
|
+
if not pwd and self.instance.pk:
|
|
606
|
+
return keep_existing("password")
|
|
607
|
+
return pwd
|
|
608
|
+
|
|
609
|
+
def _post_clean(self):
|
|
610
|
+
super()._post_clean()
|
|
611
|
+
_restore_sigil_values(
|
|
612
|
+
self,
|
|
613
|
+
["username", "host", "password", "protocol"],
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
class ProfileInlineFormSet(BaseInlineFormSet):
|
|
618
|
+
"""Hide deletion controls and allow implicit removal when empty."""
|
|
619
|
+
|
|
620
|
+
def add_fields(self, form, index):
|
|
621
|
+
super().add_fields(form, index)
|
|
622
|
+
if "DELETE" in form.fields:
|
|
623
|
+
form.fields["DELETE"].widget = forms.HiddenInput()
|
|
624
|
+
form.fields["DELETE"].required = False
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _title_case(value):
|
|
628
|
+
text = str(value or "")
|
|
629
|
+
return " ".join(
|
|
630
|
+
word[:1].upper() + word[1:] if word else word for word in text.split()
|
|
436
631
|
)
|
|
437
632
|
|
|
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
633
|
|
|
634
|
+
class ProfileFormMixin(forms.ModelForm):
|
|
635
|
+
"""Mark profiles for deletion when no data is provided."""
|
|
447
636
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
637
|
+
profile_fields: tuple[str, ...] = ()
|
|
638
|
+
user_datum = forms.BooleanField(
|
|
639
|
+
required=False,
|
|
640
|
+
label=_("User Datum"),
|
|
641
|
+
help_text=_("Store this profile in the user's data directory."),
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
def __init__(self, *args, **kwargs):
|
|
645
|
+
super().__init__(*args, **kwargs)
|
|
646
|
+
model_fields = getattr(self._meta.model, "profile_fields", tuple())
|
|
647
|
+
explicit = getattr(self, "profile_fields", tuple())
|
|
648
|
+
self._profile_fields = tuple(explicit or model_fields)
|
|
649
|
+
for name in self._profile_fields:
|
|
650
|
+
field = self.fields.get(name)
|
|
651
|
+
if field is not None:
|
|
652
|
+
field.required = False
|
|
653
|
+
if "user_datum" in self.fields:
|
|
654
|
+
self.fields["user_datum"].initial = getattr(
|
|
655
|
+
self.instance, "is_user_data", False
|
|
656
|
+
)
|
|
454
657
|
|
|
658
|
+
@staticmethod
|
|
659
|
+
def _is_empty_value(value) -> bool:
|
|
660
|
+
if isinstance(value, KeepExistingValue):
|
|
661
|
+
return True
|
|
662
|
+
if isinstance(value, bool):
|
|
663
|
+
return not value
|
|
664
|
+
if value in (None, "", [], (), {}, set()):
|
|
665
|
+
return True
|
|
666
|
+
if isinstance(value, str):
|
|
667
|
+
return value.strip() == ""
|
|
668
|
+
return False
|
|
669
|
+
|
|
670
|
+
def _has_profile_data(self) -> bool:
|
|
671
|
+
for name in self._profile_fields:
|
|
672
|
+
field = self.fields.get(name)
|
|
673
|
+
raw_value = None
|
|
674
|
+
if field is not None and not isinstance(field, forms.BooleanField):
|
|
675
|
+
try:
|
|
676
|
+
if hasattr(self, "_raw_value"):
|
|
677
|
+
raw_value = self._raw_value(name)
|
|
678
|
+
elif self.is_bound:
|
|
679
|
+
bound = self[name]
|
|
680
|
+
raw_value = bound.field.widget.value_from_datadict(
|
|
681
|
+
self.data,
|
|
682
|
+
self.files,
|
|
683
|
+
bound.html_name,
|
|
684
|
+
)
|
|
685
|
+
except (AttributeError, KeyError):
|
|
686
|
+
raw_value = None
|
|
687
|
+
if raw_value is not None:
|
|
688
|
+
if not isinstance(raw_value, (list, tuple)):
|
|
689
|
+
values = [raw_value]
|
|
690
|
+
else:
|
|
691
|
+
values = raw_value
|
|
692
|
+
if any(not self._is_empty_value(value) for value in values):
|
|
693
|
+
return True
|
|
694
|
+
# When raw form data is present but empty (e.g. ""), skip the
|
|
695
|
+
# instance fallback so empty submissions mark the form deleted.
|
|
696
|
+
continue
|
|
455
697
|
|
|
456
|
-
|
|
457
|
-
|
|
698
|
+
if name in self.cleaned_data:
|
|
699
|
+
value = self.cleaned_data.get(name)
|
|
700
|
+
elif hasattr(self.instance, name):
|
|
701
|
+
value = getattr(self.instance, name)
|
|
702
|
+
else:
|
|
703
|
+
continue
|
|
704
|
+
if not self._is_empty_value(value):
|
|
705
|
+
return True
|
|
706
|
+
return False
|
|
707
|
+
|
|
708
|
+
def clean(self):
|
|
709
|
+
cleaned = super().clean()
|
|
710
|
+
if cleaned.get("DELETE") or not self._profile_fields:
|
|
711
|
+
return cleaned
|
|
712
|
+
if not self._has_profile_data():
|
|
713
|
+
cleaned["DELETE"] = True
|
|
714
|
+
return cleaned
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
class OdooProfileInlineForm(ProfileFormMixin, OdooProfileAdminForm):
|
|
718
|
+
profile_fields = OdooProfile.profile_fields
|
|
719
|
+
|
|
720
|
+
class Meta(OdooProfileAdminForm.Meta):
|
|
721
|
+
exclude = ("user", "group", "verified_on", "odoo_uid", "name", "email")
|
|
722
|
+
|
|
723
|
+
def clean(self):
|
|
724
|
+
cleaned = super().clean()
|
|
725
|
+
if cleaned.get("DELETE") or self.errors:
|
|
726
|
+
return cleaned
|
|
727
|
+
|
|
728
|
+
provided = [
|
|
729
|
+
name
|
|
730
|
+
for name in self._profile_fields
|
|
731
|
+
if not self._is_empty_value(cleaned.get(name))
|
|
732
|
+
]
|
|
733
|
+
missing = [
|
|
734
|
+
name
|
|
735
|
+
for name in self._profile_fields
|
|
736
|
+
if self._is_empty_value(cleaned.get(name))
|
|
737
|
+
]
|
|
738
|
+
if provided and missing:
|
|
739
|
+
raise forms.ValidationError(
|
|
740
|
+
"Provide host, database, username, and password to create an Odoo employee.",
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
return cleaned
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
class EmailInboxInlineForm(ProfileFormMixin, EmailInboxAdminForm):
|
|
747
|
+
profile_fields = EmailInbox.profile_fields
|
|
458
748
|
|
|
749
|
+
class Meta(EmailInboxAdminForm.Meta):
|
|
750
|
+
exclude = ("user", "group")
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
class EmailOutboxInlineForm(ProfileFormMixin, forms.ModelForm):
|
|
754
|
+
profile_fields = EmailOutbox.profile_fields
|
|
459
755
|
password = forms.CharField(
|
|
460
756
|
widget=forms.PasswordInput(render_value=True),
|
|
461
757
|
required=False,
|
|
@@ -463,8 +759,16 @@ class EmailInboxAdminForm(forms.ModelForm):
|
|
|
463
759
|
)
|
|
464
760
|
|
|
465
761
|
class Meta:
|
|
466
|
-
model =
|
|
467
|
-
fields =
|
|
762
|
+
model = EmailOutbox
|
|
763
|
+
fields = (
|
|
764
|
+
"password",
|
|
765
|
+
"host",
|
|
766
|
+
"port",
|
|
767
|
+
"username",
|
|
768
|
+
"use_tls",
|
|
769
|
+
"use_ssl",
|
|
770
|
+
"from_email",
|
|
771
|
+
)
|
|
468
772
|
|
|
469
773
|
def __init__(self, *args, **kwargs):
|
|
470
774
|
super().__init__(*args, **kwargs)
|
|
@@ -477,9 +781,316 @@ class EmailInboxAdminForm(forms.ModelForm):
|
|
|
477
781
|
def clean_password(self):
|
|
478
782
|
pwd = self.cleaned_data.get("password")
|
|
479
783
|
if not pwd and self.instance.pk:
|
|
480
|
-
return
|
|
784
|
+
return keep_existing("password")
|
|
481
785
|
return pwd
|
|
482
786
|
|
|
787
|
+
def _post_clean(self):
|
|
788
|
+
super()._post_clean()
|
|
789
|
+
_restore_sigil_values(
|
|
790
|
+
self,
|
|
791
|
+
["password", "host", "username", "from_email"],
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
class ReleaseManagerInlineForm(ProfileFormMixin, forms.ModelForm):
|
|
796
|
+
profile_fields = ReleaseManager.profile_fields
|
|
797
|
+
|
|
798
|
+
class Meta:
|
|
799
|
+
model = ReleaseManager
|
|
800
|
+
fields = (
|
|
801
|
+
"pypi_username",
|
|
802
|
+
"pypi_token",
|
|
803
|
+
"github_token",
|
|
804
|
+
"pypi_password",
|
|
805
|
+
"pypi_url",
|
|
806
|
+
)
|
|
807
|
+
widgets = {
|
|
808
|
+
"pypi_token": forms.Textarea(attrs={"rows": 3, "style": "width: 40em;"}),
|
|
809
|
+
"github_token": forms.Textarea(attrs={"rows": 3, "style": "width: 40em;"}),
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
class AssistantProfileInlineForm(ProfileFormMixin, forms.ModelForm):
|
|
814
|
+
user_key = forms.CharField(
|
|
815
|
+
required=False,
|
|
816
|
+
widget=forms.PasswordInput(render_value=True),
|
|
817
|
+
help_text="Provide a plain key to create or rotate credentials.",
|
|
818
|
+
)
|
|
819
|
+
profile_fields = ("user_key", "scopes", "is_active")
|
|
820
|
+
|
|
821
|
+
class Meta:
|
|
822
|
+
model = AssistantProfile
|
|
823
|
+
fields = ("scopes", "is_active")
|
|
824
|
+
|
|
825
|
+
def __init__(self, *args, **kwargs):
|
|
826
|
+
super().__init__(*args, **kwargs)
|
|
827
|
+
if not self.instance.pk and "is_active" in self.fields:
|
|
828
|
+
self.fields["is_active"].initial = False
|
|
829
|
+
|
|
830
|
+
def clean(self):
|
|
831
|
+
cleaned = super().clean()
|
|
832
|
+
if cleaned.get("DELETE"):
|
|
833
|
+
return cleaned
|
|
834
|
+
if not self.instance.pk and not cleaned.get("user_key"):
|
|
835
|
+
if cleaned.get("scopes") or cleaned.get("is_active"):
|
|
836
|
+
raise forms.ValidationError(
|
|
837
|
+
"Provide a user key to create an assistant profile."
|
|
838
|
+
)
|
|
839
|
+
return cleaned
|
|
840
|
+
|
|
841
|
+
def save(self, commit=True):
|
|
842
|
+
instance = super().save(commit=False)
|
|
843
|
+
user_key = self.cleaned_data.get("user_key")
|
|
844
|
+
if user_key:
|
|
845
|
+
instance.user_key_hash = hash_key(user_key)
|
|
846
|
+
instance.last_used_at = None
|
|
847
|
+
if commit:
|
|
848
|
+
instance.save()
|
|
849
|
+
self.save_m2m()
|
|
850
|
+
return instance
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
PROFILE_INLINE_CONFIG = {
|
|
854
|
+
OdooProfile: {
|
|
855
|
+
"form": OdooProfileInlineForm,
|
|
856
|
+
"fieldsets": (
|
|
857
|
+
(
|
|
858
|
+
None,
|
|
859
|
+
{
|
|
860
|
+
"fields": (
|
|
861
|
+
"host",
|
|
862
|
+
"database",
|
|
863
|
+
"username",
|
|
864
|
+
"password",
|
|
865
|
+
)
|
|
866
|
+
},
|
|
867
|
+
),
|
|
868
|
+
(
|
|
869
|
+
"Odoo Employee",
|
|
870
|
+
{
|
|
871
|
+
"fields": ("verified_on", "odoo_uid", "name", "email"),
|
|
872
|
+
},
|
|
873
|
+
),
|
|
874
|
+
),
|
|
875
|
+
"readonly_fields": ("verified_on", "odoo_uid", "name", "email"),
|
|
876
|
+
},
|
|
877
|
+
EmailInbox: {
|
|
878
|
+
"form": EmailInboxInlineForm,
|
|
879
|
+
"fields": (
|
|
880
|
+
"username",
|
|
881
|
+
"host",
|
|
882
|
+
"port",
|
|
883
|
+
"password",
|
|
884
|
+
"protocol",
|
|
885
|
+
"use_ssl",
|
|
886
|
+
),
|
|
887
|
+
},
|
|
888
|
+
EmailOutbox: {
|
|
889
|
+
"form": EmailOutboxInlineForm,
|
|
890
|
+
"fields": (
|
|
891
|
+
"password",
|
|
892
|
+
"host",
|
|
893
|
+
"port",
|
|
894
|
+
"username",
|
|
895
|
+
"use_tls",
|
|
896
|
+
"use_ssl",
|
|
897
|
+
"from_email",
|
|
898
|
+
),
|
|
899
|
+
},
|
|
900
|
+
ReleaseManager: {
|
|
901
|
+
"form": ReleaseManagerInlineForm,
|
|
902
|
+
"fields": (
|
|
903
|
+
"pypi_username",
|
|
904
|
+
"pypi_token",
|
|
905
|
+
"github_token",
|
|
906
|
+
"pypi_password",
|
|
907
|
+
"pypi_url",
|
|
908
|
+
),
|
|
909
|
+
},
|
|
910
|
+
AssistantProfile: {
|
|
911
|
+
"form": AssistantProfileInlineForm,
|
|
912
|
+
"fields": ("user_key", "scopes", "is_active"),
|
|
913
|
+
"readonly_fields": ("user_key_hash", "created_at", "last_used_at"),
|
|
914
|
+
"template": "admin/edit_inline/profile_stacked.html",
|
|
915
|
+
},
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _build_profile_inline(model, owner_field):
|
|
920
|
+
config = PROFILE_INLINE_CONFIG[model]
|
|
921
|
+
verbose_name = config.get("verbose_name")
|
|
922
|
+
if verbose_name is None:
|
|
923
|
+
verbose_name = _title_case(model._meta.verbose_name)
|
|
924
|
+
verbose_name_plural = config.get("verbose_name_plural")
|
|
925
|
+
if verbose_name_plural is None:
|
|
926
|
+
verbose_name_plural = _title_case(model._meta.verbose_name_plural)
|
|
927
|
+
attrs = {
|
|
928
|
+
"model": model,
|
|
929
|
+
"fk_name": owner_field,
|
|
930
|
+
"form": config["form"],
|
|
931
|
+
"formset": ProfileInlineFormSet,
|
|
932
|
+
"extra": 1,
|
|
933
|
+
"max_num": 1,
|
|
934
|
+
"can_delete": True,
|
|
935
|
+
"verbose_name": verbose_name,
|
|
936
|
+
"verbose_name_plural": verbose_name_plural,
|
|
937
|
+
"template": "admin/edit_inline/profile_stacked.html",
|
|
938
|
+
}
|
|
939
|
+
if "fieldsets" in config:
|
|
940
|
+
attrs["fieldsets"] = config["fieldsets"]
|
|
941
|
+
if "fields" in config:
|
|
942
|
+
attrs["fields"] = config["fields"]
|
|
943
|
+
if "readonly_fields" in config:
|
|
944
|
+
attrs["readonly_fields"] = config["readonly_fields"]
|
|
945
|
+
if "template" in config:
|
|
946
|
+
attrs["template"] = config["template"]
|
|
947
|
+
return type(
|
|
948
|
+
f"{model.__name__}{owner_field.title()}Inline",
|
|
949
|
+
(admin.StackedInline,),
|
|
950
|
+
attrs,
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
PROFILE_MODELS = (
|
|
955
|
+
OdooProfile,
|
|
956
|
+
EmailInbox,
|
|
957
|
+
EmailOutbox,
|
|
958
|
+
ReleaseManager,
|
|
959
|
+
AssistantProfile,
|
|
960
|
+
)
|
|
961
|
+
USER_PROFILE_INLINES = [
|
|
962
|
+
_build_profile_inline(model, "user") for model in PROFILE_MODELS
|
|
963
|
+
]
|
|
964
|
+
GROUP_PROFILE_INLINES = [
|
|
965
|
+
_build_profile_inline(model, "group") for model in PROFILE_MODELS
|
|
966
|
+
]
|
|
967
|
+
|
|
968
|
+
SecurityGroupAdmin.inlines = GROUP_PROFILE_INLINES
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
class UserPhoneNumberInline(admin.TabularInline):
|
|
972
|
+
model = UserPhoneNumber
|
|
973
|
+
extra = 0
|
|
974
|
+
fields = ("number", "priority")
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
class UserAdmin(DjangoUserAdmin):
|
|
978
|
+
fieldsets = _append_operate_as(DjangoUserAdmin.fieldsets)
|
|
979
|
+
add_fieldsets = _append_operate_as(DjangoUserAdmin.add_fieldsets)
|
|
980
|
+
inlines = USER_PROFILE_INLINES + [UserPhoneNumberInline]
|
|
981
|
+
change_form_template = "admin/user_profile_change_form.html"
|
|
982
|
+
|
|
983
|
+
def render_change_form(
|
|
984
|
+
self, request, context, add=False, change=False, form_url="", obj=None
|
|
985
|
+
):
|
|
986
|
+
context = super().render_change_form(
|
|
987
|
+
request, context, add=add, change=change, form_url=form_url, obj=obj
|
|
988
|
+
)
|
|
989
|
+
context["show_user_datum"] = False
|
|
990
|
+
context["show_seed_datum"] = False
|
|
991
|
+
return context
|
|
992
|
+
|
|
993
|
+
def get_inline_instances(self, request, obj=None):
|
|
994
|
+
inline_instances = super().get_inline_instances(request, obj)
|
|
995
|
+
if obj and getattr(obj, "is_profile_restricted", False):
|
|
996
|
+
profile_inline_classes = tuple(USER_PROFILE_INLINES)
|
|
997
|
+
inline_instances = [
|
|
998
|
+
inline
|
|
999
|
+
for inline in inline_instances
|
|
1000
|
+
if inline.__class__ not in profile_inline_classes
|
|
1001
|
+
]
|
|
1002
|
+
return inline_instances
|
|
1003
|
+
|
|
1004
|
+
def _update_profile_fixture(self, instance, owner, *, store: bool) -> None:
|
|
1005
|
+
if not getattr(instance, "pk", None):
|
|
1006
|
+
return
|
|
1007
|
+
manager = getattr(type(instance), "all_objects", None)
|
|
1008
|
+
if manager is not None:
|
|
1009
|
+
manager.filter(pk=instance.pk).update(is_user_data=store)
|
|
1010
|
+
instance.is_user_data = store
|
|
1011
|
+
if owner is None:
|
|
1012
|
+
owner = getattr(instance, "user", None)
|
|
1013
|
+
if owner is None:
|
|
1014
|
+
return
|
|
1015
|
+
if store:
|
|
1016
|
+
dump_user_fixture(instance, owner)
|
|
1017
|
+
else:
|
|
1018
|
+
delete_user_fixture(instance, owner)
|
|
1019
|
+
|
|
1020
|
+
def save_formset(self, request, form, formset, change):
|
|
1021
|
+
super().save_formset(request, form, formset, change)
|
|
1022
|
+
owner = form.instance if isinstance(form.instance, User) else None
|
|
1023
|
+
for deleted in getattr(formset, "deleted_objects", []):
|
|
1024
|
+
owner_user = getattr(deleted, "user", None) or owner
|
|
1025
|
+
self._update_profile_fixture(deleted, owner_user, store=False)
|
|
1026
|
+
for inline_form in getattr(formset, "forms", []):
|
|
1027
|
+
if not hasattr(inline_form, "cleaned_data"):
|
|
1028
|
+
continue
|
|
1029
|
+
if inline_form.cleaned_data.get("DELETE"):
|
|
1030
|
+
continue
|
|
1031
|
+
if "user_datum" not in inline_form.cleaned_data:
|
|
1032
|
+
continue
|
|
1033
|
+
instance = inline_form.instance
|
|
1034
|
+
owner_user = getattr(instance, "user", None) or owner
|
|
1035
|
+
should_store = bool(inline_form.cleaned_data.get("user_datum"))
|
|
1036
|
+
self._update_profile_fixture(instance, owner_user, store=should_store)
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
class EmailCollectorInline(admin.TabularInline):
|
|
1040
|
+
model = EmailCollector
|
|
1041
|
+
extra = 0
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
class EmailCollectorAdmin(EntityModelAdmin):
|
|
1045
|
+
list_display = ("inbox", "subject", "sender", "body", "fragment")
|
|
1046
|
+
search_fields = ("subject", "sender", "body", "fragment")
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
@admin.register(OdooProfile)
|
|
1050
|
+
class OdooProfileAdmin(SaveBeforeChangeAction, EntityModelAdmin):
|
|
1051
|
+
change_form_template = "django_object_actions/change_form.html"
|
|
1052
|
+
form = OdooProfileAdminForm
|
|
1053
|
+
list_display = ("owner", "host", "database", "verified_on")
|
|
1054
|
+
readonly_fields = ("verified_on", "odoo_uid", "name", "email")
|
|
1055
|
+
actions = ["verify_credentials"]
|
|
1056
|
+
change_actions = ["verify_credentials_action"]
|
|
1057
|
+
fieldsets = (
|
|
1058
|
+
("Owner", {"fields": ("user", "group")}),
|
|
1059
|
+
(None, {"fields": ("host", "database", "username", "password")}),
|
|
1060
|
+
(
|
|
1061
|
+
"Odoo Employee",
|
|
1062
|
+
{"fields": ("verified_on", "odoo_uid", "name", "email")},
|
|
1063
|
+
),
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
def owner(self, obj):
|
|
1067
|
+
return obj.owner_display()
|
|
1068
|
+
|
|
1069
|
+
owner.short_description = "Owner"
|
|
1070
|
+
|
|
1071
|
+
def _verify_credentials(self, request, profile):
|
|
1072
|
+
try:
|
|
1073
|
+
profile.verify()
|
|
1074
|
+
self.message_user(request, f"{profile.owner_display()} verified")
|
|
1075
|
+
except Exception as exc: # pragma: no cover - admin feedback
|
|
1076
|
+
self.message_user(
|
|
1077
|
+
request, f"{profile.owner_display()}: {exc}", level=messages.ERROR
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
@admin.action(description="Test credentials")
|
|
1081
|
+
def verify_credentials(self, request, queryset):
|
|
1082
|
+
for profile in queryset:
|
|
1083
|
+
self._verify_credentials(request, profile)
|
|
1084
|
+
|
|
1085
|
+
def verify_credentials_action(self, request, obj):
|
|
1086
|
+
self._verify_credentials(request, obj)
|
|
1087
|
+
|
|
1088
|
+
verify_credentials_action.label = "Test credentials"
|
|
1089
|
+
verify_credentials_action.short_description = "Test credentials"
|
|
1090
|
+
|
|
1091
|
+
def get_model_perms(self, request):
|
|
1092
|
+
return {}
|
|
1093
|
+
|
|
483
1094
|
|
|
484
1095
|
class EmailSearchForm(forms.Form):
|
|
485
1096
|
subject = forms.CharField(
|
|
@@ -496,17 +1107,51 @@ class EmailSearchForm(forms.Form):
|
|
|
496
1107
|
)
|
|
497
1108
|
|
|
498
1109
|
|
|
499
|
-
|
|
500
|
-
class EmailInboxAdmin(admin.ModelAdmin):
|
|
1110
|
+
class EmailInboxAdmin(SaveBeforeChangeAction, EntityModelAdmin):
|
|
501
1111
|
form = EmailInboxAdminForm
|
|
502
|
-
list_display = ("
|
|
503
|
-
actions = ["test_connection", "search_inbox"]
|
|
1112
|
+
list_display = ("owner_label", "username", "host", "protocol")
|
|
1113
|
+
actions = ["test_connection", "search_inbox", "test_collectors"]
|
|
1114
|
+
change_actions = ["test_collectors_action"]
|
|
1115
|
+
change_form_template = "admin/core/emailinbox/change_form.html"
|
|
1116
|
+
inlines = [EmailCollectorInline]
|
|
1117
|
+
|
|
1118
|
+
def get_urls(self):
|
|
1119
|
+
urls = super().get_urls()
|
|
1120
|
+
custom = [
|
|
1121
|
+
path(
|
|
1122
|
+
"<path:object_id>/test/",
|
|
1123
|
+
self.admin_site.admin_view(self.test_inbox),
|
|
1124
|
+
name="core_emailinbox_test",
|
|
1125
|
+
)
|
|
1126
|
+
]
|
|
1127
|
+
return custom + urls
|
|
1128
|
+
|
|
1129
|
+
def test_inbox(self, request, object_id):
|
|
1130
|
+
inbox = self.get_object(request, object_id)
|
|
1131
|
+
if not inbox:
|
|
1132
|
+
self.message_user(request, "Unknown inbox", messages.ERROR)
|
|
1133
|
+
return redirect("..")
|
|
1134
|
+
try:
|
|
1135
|
+
inbox.test_connection()
|
|
1136
|
+
self.message_user(request, "Inbox connection successful", messages.SUCCESS)
|
|
1137
|
+
except Exception as exc: # pragma: no cover - admin feedback
|
|
1138
|
+
self.message_user(request, str(exc), messages.ERROR)
|
|
1139
|
+
return redirect("..")
|
|
1140
|
+
|
|
1141
|
+
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
|
1142
|
+
extra_context = extra_context or {}
|
|
1143
|
+
if object_id:
|
|
1144
|
+
extra_context["test_url"] = reverse(
|
|
1145
|
+
"admin:core_emailinbox_test", args=[object_id]
|
|
1146
|
+
)
|
|
1147
|
+
return super().changeform_view(request, object_id, form_url, extra_context)
|
|
1148
|
+
|
|
504
1149
|
fieldsets = (
|
|
1150
|
+
("Owner", {"fields": ("user", "group")}),
|
|
505
1151
|
(
|
|
506
1152
|
None,
|
|
507
1153
|
{
|
|
508
1154
|
"fields": (
|
|
509
|
-
"user",
|
|
510
1155
|
"username",
|
|
511
1156
|
"host",
|
|
512
1157
|
"port",
|
|
@@ -518,9 +1163,12 @@ class EmailInboxAdmin(admin.ModelAdmin):
|
|
|
518
1163
|
),
|
|
519
1164
|
)
|
|
520
1165
|
|
|
1166
|
+
@admin.display(description="Owner")
|
|
1167
|
+
def owner_label(self, obj):
|
|
1168
|
+
return obj.owner_display()
|
|
1169
|
+
|
|
521
1170
|
def save_model(self, request, obj, form, change):
|
|
522
1171
|
super().save_model(request, obj, form, change)
|
|
523
|
-
obj.__class__ = EmailInbox
|
|
524
1172
|
|
|
525
1173
|
@admin.action(description="Test selected inboxes")
|
|
526
1174
|
def test_connection(self, request, queryset):
|
|
@@ -531,6 +1179,33 @@ class EmailInboxAdmin(admin.ModelAdmin):
|
|
|
531
1179
|
except Exception as exc: # pragma: no cover - admin feedback
|
|
532
1180
|
self.message_user(request, f"{inbox}: {exc}", level=messages.ERROR)
|
|
533
1181
|
|
|
1182
|
+
def _test_collectors(self, request, inbox):
|
|
1183
|
+
for collector in inbox.collectors.all():
|
|
1184
|
+
before = collector.artifacts.count()
|
|
1185
|
+
try:
|
|
1186
|
+
collector.collect(limit=1)
|
|
1187
|
+
after = collector.artifacts.count()
|
|
1188
|
+
if after > before:
|
|
1189
|
+
msg = f"{collector} collected {after - before} email(s)"
|
|
1190
|
+
self.message_user(request, msg)
|
|
1191
|
+
else:
|
|
1192
|
+
self.message_user(
|
|
1193
|
+
request, f"{collector} found no emails", level=messages.WARNING
|
|
1194
|
+
)
|
|
1195
|
+
except Exception as exc: # pragma: no cover - admin feedback
|
|
1196
|
+
self.message_user(request, f"{collector}: {exc}", level=messages.ERROR)
|
|
1197
|
+
|
|
1198
|
+
@admin.action(description="Test collectors")
|
|
1199
|
+
def test_collectors(self, request, queryset):
|
|
1200
|
+
for inbox in queryset:
|
|
1201
|
+
self._test_collectors(request, inbox)
|
|
1202
|
+
|
|
1203
|
+
def test_collectors_action(self, request, obj):
|
|
1204
|
+
self._test_collectors(request, obj)
|
|
1205
|
+
|
|
1206
|
+
test_collectors_action.label = "Test collectors"
|
|
1207
|
+
test_collectors_action.short_description = "Test collectors"
|
|
1208
|
+
|
|
534
1209
|
@admin.action(description="Search selected inbox")
|
|
535
1210
|
def search_inbox(self, request, queryset):
|
|
536
1211
|
if queryset.count() != 1:
|
|
@@ -552,6 +1227,7 @@ class EmailInboxAdmin(admin.ModelAdmin):
|
|
|
552
1227
|
"results": results,
|
|
553
1228
|
"queryset": queryset,
|
|
554
1229
|
"action": "search_inbox",
|
|
1230
|
+
"opts": self.model._meta,
|
|
555
1231
|
}
|
|
556
1232
|
return TemplateResponse(
|
|
557
1233
|
request, "admin/core/emailinbox/search.html", context
|
|
@@ -562,48 +1238,176 @@ class EmailInboxAdmin(admin.ModelAdmin):
|
|
|
562
1238
|
"form": form,
|
|
563
1239
|
"queryset": queryset,
|
|
564
1240
|
"action": "search_inbox",
|
|
1241
|
+
"opts": self.model._meta,
|
|
565
1242
|
}
|
|
566
1243
|
return TemplateResponse(request, "admin/core/emailinbox/search.html", context)
|
|
567
1244
|
|
|
568
1245
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
verbose_name = ChatProfile._meta.verbose_name
|
|
574
|
-
verbose_name_plural = ChatProfile._meta.verbose_name_plural
|
|
1246
|
+
@admin.register(AssistantProfile)
|
|
1247
|
+
class AssistantProfileAdmin(EntityModelAdmin):
|
|
1248
|
+
list_display = ("owner", "created_at", "last_used_at", "is_active")
|
|
1249
|
+
readonly_fields = ("user_key_hash", "created_at", "last_used_at")
|
|
575
1250
|
|
|
1251
|
+
change_form_template = "admin/workgroupassistantprofile_change_form.html"
|
|
1252
|
+
change_list_template = "admin/assistantprofile_change_list.html"
|
|
1253
|
+
fieldsets = (
|
|
1254
|
+
("Owner", {"fields": ("user", "group")}),
|
|
1255
|
+
(
|
|
1256
|
+
None,
|
|
1257
|
+
{
|
|
1258
|
+
"fields": (
|
|
1259
|
+
"scopes",
|
|
1260
|
+
"is_active",
|
|
1261
|
+
"user_key_hash",
|
|
1262
|
+
"created_at",
|
|
1263
|
+
"last_used_at",
|
|
1264
|
+
)
|
|
1265
|
+
},
|
|
1266
|
+
),
|
|
1267
|
+
)
|
|
576
1268
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
list_display = ("user", "created_at", "last_used_at", "is_active")
|
|
580
|
-
readonly_fields = ("user_key_hash",)
|
|
1269
|
+
def owner(self, obj):
|
|
1270
|
+
return obj.owner_display()
|
|
581
1271
|
|
|
582
|
-
|
|
1272
|
+
owner.short_description = "Owner"
|
|
583
1273
|
|
|
584
1274
|
def get_urls(self):
|
|
585
1275
|
urls = super().get_urls()
|
|
1276
|
+
opts = self.model._meta
|
|
1277
|
+
app_label = opts.app_label
|
|
1278
|
+
model_name = opts.model_name
|
|
586
1279
|
custom = [
|
|
587
1280
|
path(
|
|
588
1281
|
"<path:object_id>/generate-key/",
|
|
589
1282
|
self.admin_site.admin_view(self.generate_key),
|
|
590
|
-
name="
|
|
1283
|
+
name=f"{app_label}_{model_name}_generate_key",
|
|
1284
|
+
),
|
|
1285
|
+
path(
|
|
1286
|
+
"server/start/",
|
|
1287
|
+
self.admin_site.admin_view(self.start_server),
|
|
1288
|
+
name=f"{app_label}_{model_name}_start_server",
|
|
1289
|
+
),
|
|
1290
|
+
path(
|
|
1291
|
+
"server/stop/",
|
|
1292
|
+
self.admin_site.admin_view(self.stop_server),
|
|
1293
|
+
name=f"{app_label}_{model_name}_stop_server",
|
|
1294
|
+
),
|
|
1295
|
+
path(
|
|
1296
|
+
"server/status/",
|
|
1297
|
+
self.admin_site.admin_view(self.server_status),
|
|
1298
|
+
name=f"{app_label}_{model_name}_status",
|
|
591
1299
|
),
|
|
592
1300
|
]
|
|
593
1301
|
return custom + urls
|
|
594
1302
|
|
|
1303
|
+
def changelist_view(self, request, extra_context=None):
|
|
1304
|
+
extra_context = extra_context or {}
|
|
1305
|
+
status = mcp_process.get_status()
|
|
1306
|
+
opts = self.model._meta
|
|
1307
|
+
app_label = opts.app_label
|
|
1308
|
+
model_name = opts.model_name
|
|
1309
|
+
extra_context.update(
|
|
1310
|
+
{
|
|
1311
|
+
"mcp_status": status,
|
|
1312
|
+
"mcp_server_actions": {
|
|
1313
|
+
"start": reverse(f"admin:{app_label}_{model_name}_start_server"),
|
|
1314
|
+
"stop": reverse(f"admin:{app_label}_{model_name}_stop_server"),
|
|
1315
|
+
"status": reverse(f"admin:{app_label}_{model_name}_status"),
|
|
1316
|
+
},
|
|
1317
|
+
}
|
|
1318
|
+
)
|
|
1319
|
+
return super().changelist_view(request, extra_context=extra_context)
|
|
1320
|
+
|
|
1321
|
+
def _redirect_to_changelist(self):
|
|
1322
|
+
opts = self.model._meta
|
|
1323
|
+
return HttpResponseRedirect(
|
|
1324
|
+
reverse(f"admin:{opts.app_label}_{opts.model_name}_changelist")
|
|
1325
|
+
)
|
|
1326
|
+
|
|
595
1327
|
def generate_key(self, request, object_id, *args, **kwargs):
|
|
596
1328
|
profile = self.get_object(request, object_id)
|
|
597
1329
|
if profile is None:
|
|
598
1330
|
return HttpResponseRedirect("../")
|
|
599
|
-
|
|
1331
|
+
if profile.user is None:
|
|
1332
|
+
self.message_user(
|
|
1333
|
+
request,
|
|
1334
|
+
"Assign a user before generating a key.",
|
|
1335
|
+
level=messages.ERROR,
|
|
1336
|
+
)
|
|
1337
|
+
return HttpResponseRedirect("../")
|
|
1338
|
+
profile, key = AssistantProfile.issue_key(profile.user)
|
|
600
1339
|
context = {
|
|
601
1340
|
**self.admin_site.each_context(request),
|
|
602
1341
|
"opts": self.model._meta,
|
|
603
1342
|
"original": profile,
|
|
604
1343
|
"user_key": key,
|
|
605
1344
|
}
|
|
606
|
-
return TemplateResponse(request, "admin/
|
|
1345
|
+
return TemplateResponse(request, "admin/assistantprofile_key.html", context)
|
|
1346
|
+
|
|
1347
|
+
def render_change_form(
|
|
1348
|
+
self, request, context, add=False, change=False, form_url="", obj=None
|
|
1349
|
+
):
|
|
1350
|
+
response = super().render_change_form(
|
|
1351
|
+
request, context, add=add, change=change, form_url=form_url, obj=obj
|
|
1352
|
+
)
|
|
1353
|
+
config = dict(getattr(settings, "MCP_SIGIL_SERVER", {}))
|
|
1354
|
+
host = config.get("host") or "127.0.0.1"
|
|
1355
|
+
port = config.get("port", 8800)
|
|
1356
|
+
if isinstance(response, dict):
|
|
1357
|
+
response.setdefault("mcp_server_host", host)
|
|
1358
|
+
response.setdefault("mcp_server_port", port)
|
|
1359
|
+
else:
|
|
1360
|
+
context_data = getattr(response, "context_data", None)
|
|
1361
|
+
if context_data is not None:
|
|
1362
|
+
context_data.setdefault("mcp_server_host", host)
|
|
1363
|
+
context_data.setdefault("mcp_server_port", port)
|
|
1364
|
+
return response
|
|
1365
|
+
|
|
1366
|
+
def start_server(self, request):
|
|
1367
|
+
try:
|
|
1368
|
+
pid = mcp_process.start_server()
|
|
1369
|
+
except mcp_process.ServerAlreadyRunningError as exc:
|
|
1370
|
+
self.message_user(request, str(exc), level=messages.WARNING)
|
|
1371
|
+
except mcp_process.ServerStartError as exc:
|
|
1372
|
+
self.message_user(request, str(exc), level=messages.ERROR)
|
|
1373
|
+
else:
|
|
1374
|
+
self.message_user(
|
|
1375
|
+
request,
|
|
1376
|
+
f"Started MCP server (PID {pid}).",
|
|
1377
|
+
level=messages.SUCCESS,
|
|
1378
|
+
)
|
|
1379
|
+
return self._redirect_to_changelist()
|
|
1380
|
+
|
|
1381
|
+
def stop_server(self, request):
|
|
1382
|
+
try:
|
|
1383
|
+
pid = mcp_process.stop_server()
|
|
1384
|
+
except mcp_process.ServerNotRunningError as exc:
|
|
1385
|
+
self.message_user(request, str(exc), level=messages.WARNING)
|
|
1386
|
+
except mcp_process.ServerStopError as exc:
|
|
1387
|
+
self.message_user(request, str(exc), level=messages.ERROR)
|
|
1388
|
+
else:
|
|
1389
|
+
self.message_user(
|
|
1390
|
+
request,
|
|
1391
|
+
f"Stopped MCP server (PID {pid}).",
|
|
1392
|
+
level=messages.SUCCESS,
|
|
1393
|
+
)
|
|
1394
|
+
return self._redirect_to_changelist()
|
|
1395
|
+
|
|
1396
|
+
def server_status(self, request):
|
|
1397
|
+
status = mcp_process.get_status()
|
|
1398
|
+
if status["running"]:
|
|
1399
|
+
msg = f"MCP server is running (PID {status['pid']})."
|
|
1400
|
+
level = messages.INFO
|
|
1401
|
+
else:
|
|
1402
|
+
msg = "MCP server is not running."
|
|
1403
|
+
level = messages.WARNING
|
|
1404
|
+
if status.get("last_error"):
|
|
1405
|
+
msg = f"{msg} {status['last_error']}"
|
|
1406
|
+
self.message_user(request, msg, level=level)
|
|
1407
|
+
return self._redirect_to_changelist()
|
|
1408
|
+
|
|
1409
|
+
def get_model_perms(self, request):
|
|
1410
|
+
return {}
|
|
607
1411
|
|
|
608
1412
|
|
|
609
1413
|
class EnergyCreditInline(admin.TabularInline):
|
|
@@ -614,8 +1418,9 @@ class EnergyCreditInline(admin.TabularInline):
|
|
|
614
1418
|
|
|
615
1419
|
|
|
616
1420
|
@admin.register(EnergyAccount)
|
|
617
|
-
class EnergyAccountAdmin(
|
|
1421
|
+
class EnergyAccountAdmin(EntityModelAdmin):
|
|
618
1422
|
change_list_template = "admin/core/energyaccount/change_list.html"
|
|
1423
|
+
change_form_template = "admin/user_datum_change_form.html"
|
|
619
1424
|
list_display = (
|
|
620
1425
|
"name",
|
|
621
1426
|
"user",
|
|
@@ -652,6 +1457,15 @@ class EnergyAccountAdmin(admin.ModelAdmin):
|
|
|
652
1457
|
)
|
|
653
1458
|
},
|
|
654
1459
|
),
|
|
1460
|
+
(
|
|
1461
|
+
"Live Subscription",
|
|
1462
|
+
{
|
|
1463
|
+
"fields": (
|
|
1464
|
+
"live_subscription_product",
|
|
1465
|
+
("live_subscription_start_date", "live_subscription_next_renewal"),
|
|
1466
|
+
)
|
|
1467
|
+
},
|
|
1468
|
+
),
|
|
655
1469
|
)
|
|
656
1470
|
|
|
657
1471
|
def authorized(self, obj):
|
|
@@ -732,29 +1546,25 @@ class EnergyAccountAdmin(admin.ModelAdmin):
|
|
|
732
1546
|
|
|
733
1547
|
|
|
734
1548
|
@admin.register(ElectricVehicle)
|
|
735
|
-
class ElectricVehicleAdmin(
|
|
1549
|
+
class ElectricVehicleAdmin(EntityModelAdmin):
|
|
736
1550
|
list_display = ("vin", "license_plate", "brand", "model", "account")
|
|
1551
|
+
search_fields = (
|
|
1552
|
+
"vin",
|
|
1553
|
+
"license_plate",
|
|
1554
|
+
"brand__name",
|
|
1555
|
+
"model__name",
|
|
1556
|
+
"account__name",
|
|
1557
|
+
)
|
|
737
1558
|
fields = ("account", "vin", "license_plate", "brand", "model")
|
|
738
1559
|
|
|
739
1560
|
|
|
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
1561
|
class WMICodeInline(admin.TabularInline):
|
|
752
1562
|
model = WMICode
|
|
753
1563
|
extra = 0
|
|
754
1564
|
|
|
755
1565
|
|
|
756
1566
|
@admin.register(Brand)
|
|
757
|
-
class BrandAdmin(
|
|
1567
|
+
class BrandAdmin(EntityModelAdmin):
|
|
758
1568
|
fields = ("name",)
|
|
759
1569
|
list_display = ("name", "wmi_codes_display")
|
|
760
1570
|
inlines = [WMICodeInline]
|
|
@@ -766,14 +1576,47 @@ class BrandAdmin(admin.ModelAdmin):
|
|
|
766
1576
|
|
|
767
1577
|
|
|
768
1578
|
@admin.register(EVModel)
|
|
769
|
-
class EVModelAdmin(
|
|
1579
|
+
class EVModelAdmin(EntityModelAdmin):
|
|
770
1580
|
fields = ("brand", "name")
|
|
771
|
-
list_display = ("name", "brand")
|
|
772
|
-
|
|
1581
|
+
list_display = ("name", "brand", "brand_wmi_codes")
|
|
1582
|
+
|
|
1583
|
+
def get_queryset(self, request):
|
|
1584
|
+
queryset = super().get_queryset(request)
|
|
1585
|
+
return queryset.select_related("brand").prefetch_related("brand__wmi_codes")
|
|
773
1586
|
|
|
1587
|
+
def brand_wmi_codes(self, obj):
|
|
1588
|
+
if not obj.brand:
|
|
1589
|
+
return ""
|
|
1590
|
+
codes = [wmi.code for wmi in obj.brand.wmi_codes.all()]
|
|
1591
|
+
return ", ".join(codes)
|
|
774
1592
|
|
|
775
|
-
|
|
776
|
-
|
|
1593
|
+
brand_wmi_codes.short_description = "WMI codes"
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
@admin.register(EnergyCredit)
|
|
1597
|
+
class EnergyCreditAdmin(EntityModelAdmin):
|
|
1598
|
+
list_display = ("account", "amount_kw", "created_by", "created_on")
|
|
1599
|
+
readonly_fields = ("created_by", "created_on")
|
|
1600
|
+
|
|
1601
|
+
def save_model(self, request, obj, form, change):
|
|
1602
|
+
if not obj.created_by:
|
|
1603
|
+
obj.created_by = request.user
|
|
1604
|
+
super().save_model(request, obj, form, change)
|
|
1605
|
+
|
|
1606
|
+
def get_model_perms(self, request):
|
|
1607
|
+
return {}
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
class ProductAdminForm(forms.ModelForm):
|
|
1611
|
+
class Meta:
|
|
1612
|
+
model = Product
|
|
1613
|
+
fields = "__all__"
|
|
1614
|
+
widgets = {"odoo_product": OdooProductWidget}
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
@admin.register(Product)
|
|
1618
|
+
class ProductAdmin(EntityModelAdmin):
|
|
1619
|
+
form = ProductAdminForm
|
|
777
1620
|
|
|
778
1621
|
|
|
779
1622
|
class RFIDResource(resources.ModelResource):
|
|
@@ -788,6 +1631,7 @@ class RFIDResource(resources.ModelResource):
|
|
|
788
1631
|
fields = (
|
|
789
1632
|
"label_id",
|
|
790
1633
|
"rfid",
|
|
1634
|
+
"custom_label",
|
|
791
1635
|
"reference",
|
|
792
1636
|
"allowed",
|
|
793
1637
|
"color",
|
|
@@ -822,12 +1666,13 @@ class RFIDForm(forms.ModelForm):
|
|
|
822
1666
|
|
|
823
1667
|
|
|
824
1668
|
@admin.register(RFID)
|
|
825
|
-
class RFIDAdmin(ImportExportModelAdmin):
|
|
1669
|
+
class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
|
|
826
1670
|
change_list_template = "admin/core/rfid/change_list.html"
|
|
827
1671
|
resource_class = RFIDResource
|
|
828
1672
|
list_display = (
|
|
829
1673
|
"label_id",
|
|
830
1674
|
"rfid",
|
|
1675
|
+
"custom_label",
|
|
831
1676
|
"color",
|
|
832
1677
|
"kind",
|
|
833
1678
|
"released",
|
|
@@ -837,7 +1682,7 @@ class RFIDAdmin(ImportExportModelAdmin):
|
|
|
837
1682
|
"last_seen_on",
|
|
838
1683
|
)
|
|
839
1684
|
list_filter = ("color", "released", "allowed")
|
|
840
|
-
search_fields = ("label_id", "rfid")
|
|
1685
|
+
search_fields = ("label_id", "rfid", "custom_label")
|
|
841
1686
|
autocomplete_fields = ["energy_accounts"]
|
|
842
1687
|
raw_id_fields = ["reference"]
|
|
843
1688
|
actions = ["scan_rfids"]
|
|
@@ -852,11 +1697,16 @@ class RFIDAdmin(ImportExportModelAdmin):
|
|
|
852
1697
|
def scan_rfids(self, request, queryset):
|
|
853
1698
|
return redirect("admin:core_rfid_scan")
|
|
854
1699
|
|
|
855
|
-
scan_rfids.short_description = "Scan
|
|
1700
|
+
scan_rfids.short_description = "Scan RFIDs"
|
|
856
1701
|
|
|
857
1702
|
def get_urls(self):
|
|
858
1703
|
urls = super().get_urls()
|
|
859
1704
|
custom = [
|
|
1705
|
+
path(
|
|
1706
|
+
"report/",
|
|
1707
|
+
self.admin_site.admin_view(self.report_view),
|
|
1708
|
+
name="core_rfid_report",
|
|
1709
|
+
),
|
|
860
1710
|
path(
|
|
861
1711
|
"scan/",
|
|
862
1712
|
self.admin_site.admin_view(csrf_exempt(self.scan_view)),
|
|
@@ -870,6 +1720,11 @@ class RFIDAdmin(ImportExportModelAdmin):
|
|
|
870
1720
|
]
|
|
871
1721
|
return custom + urls
|
|
872
1722
|
|
|
1723
|
+
def report_view(self, request):
|
|
1724
|
+
context = self.admin_site.each_context(request)
|
|
1725
|
+
context["report"] = ClientReport.build_rows()
|
|
1726
|
+
return TemplateResponse(request, "admin/core/rfid/report.html", context)
|
|
1727
|
+
|
|
873
1728
|
def scan_view(self, request):
|
|
874
1729
|
context = self.admin_site.each_context(request)
|
|
875
1730
|
context["scan_url"] = reverse("admin:core_rfid_scan_next")
|
|
@@ -886,8 +1741,169 @@ class RFIDAdmin(ImportExportModelAdmin):
|
|
|
886
1741
|
return JsonResponse(result, status=status)
|
|
887
1742
|
|
|
888
1743
|
|
|
1744
|
+
@admin.register(ClientReport)
|
|
1745
|
+
class ClientReportAdmin(EntityModelAdmin):
|
|
1746
|
+
list_display = ("created_on", "start_date", "end_date")
|
|
1747
|
+
readonly_fields = ("created_on", "data")
|
|
1748
|
+
|
|
1749
|
+
change_list_template = "admin/core/clientreport/change_list.html"
|
|
1750
|
+
|
|
1751
|
+
class ClientReportForm(forms.Form):
|
|
1752
|
+
PERIOD_CHOICES = [
|
|
1753
|
+
("range", "Date range"),
|
|
1754
|
+
("week", "Week"),
|
|
1755
|
+
("month", "Month"),
|
|
1756
|
+
]
|
|
1757
|
+
RECURRENCE_CHOICES = ClientReportSchedule.PERIODICITY_CHOICES
|
|
1758
|
+
period = forms.ChoiceField(
|
|
1759
|
+
choices=PERIOD_CHOICES, widget=forms.RadioSelect, initial="range"
|
|
1760
|
+
)
|
|
1761
|
+
start = forms.DateField(
|
|
1762
|
+
label="Start date",
|
|
1763
|
+
required=False,
|
|
1764
|
+
widget=forms.DateInput(attrs={"type": "date"}),
|
|
1765
|
+
)
|
|
1766
|
+
end = forms.DateField(
|
|
1767
|
+
label="End date",
|
|
1768
|
+
required=False,
|
|
1769
|
+
widget=forms.DateInput(attrs={"type": "date"}),
|
|
1770
|
+
)
|
|
1771
|
+
week = forms.CharField(
|
|
1772
|
+
label="Week",
|
|
1773
|
+
required=False,
|
|
1774
|
+
widget=forms.TextInput(attrs={"type": "week"}),
|
|
1775
|
+
)
|
|
1776
|
+
month = forms.DateField(
|
|
1777
|
+
label="Month",
|
|
1778
|
+
required=False,
|
|
1779
|
+
widget=forms.DateInput(attrs={"type": "month"}),
|
|
1780
|
+
)
|
|
1781
|
+
owner = forms.ModelChoiceField(
|
|
1782
|
+
queryset=get_user_model().objects.all(), required=False
|
|
1783
|
+
)
|
|
1784
|
+
destinations = forms.CharField(
|
|
1785
|
+
label="Email destinations",
|
|
1786
|
+
required=False,
|
|
1787
|
+
widget=forms.Textarea(attrs={"rows": 2}),
|
|
1788
|
+
help_text="Separate addresses with commas or new lines.",
|
|
1789
|
+
)
|
|
1790
|
+
recurrence = forms.ChoiceField(
|
|
1791
|
+
label="Recurrency",
|
|
1792
|
+
choices=RECURRENCE_CHOICES,
|
|
1793
|
+
initial=ClientReportSchedule.PERIODICITY_NONE,
|
|
1794
|
+
)
|
|
1795
|
+
disable_emails = forms.BooleanField(
|
|
1796
|
+
label="Disable email delivery",
|
|
1797
|
+
required=False,
|
|
1798
|
+
help_text="Generate files without sending emails.",
|
|
1799
|
+
)
|
|
1800
|
+
|
|
1801
|
+
def __init__(self, *args, request=None, **kwargs):
|
|
1802
|
+
self.request = request
|
|
1803
|
+
super().__init__(*args, **kwargs)
|
|
1804
|
+
if (
|
|
1805
|
+
request
|
|
1806
|
+
and getattr(request, "user", None)
|
|
1807
|
+
and request.user.is_authenticated
|
|
1808
|
+
):
|
|
1809
|
+
self.fields["owner"].initial = request.user.pk
|
|
1810
|
+
|
|
1811
|
+
def clean(self):
|
|
1812
|
+
cleaned = super().clean()
|
|
1813
|
+
period = cleaned.get("period")
|
|
1814
|
+
if period == "range":
|
|
1815
|
+
if not cleaned.get("start") or not cleaned.get("end"):
|
|
1816
|
+
raise forms.ValidationError("Please provide start and end dates.")
|
|
1817
|
+
elif period == "week":
|
|
1818
|
+
week_str = cleaned.get("week")
|
|
1819
|
+
if not week_str:
|
|
1820
|
+
raise forms.ValidationError("Please select a week.")
|
|
1821
|
+
year, week_num = week_str.split("-W")
|
|
1822
|
+
start = datetime.date.fromisocalendar(int(year), int(week_num), 1)
|
|
1823
|
+
cleaned["start"] = start
|
|
1824
|
+
cleaned["end"] = start + datetime.timedelta(days=6)
|
|
1825
|
+
elif period == "month":
|
|
1826
|
+
month_dt = cleaned.get("month")
|
|
1827
|
+
if not month_dt:
|
|
1828
|
+
raise forms.ValidationError("Please select a month.")
|
|
1829
|
+
start = month_dt.replace(day=1)
|
|
1830
|
+
last_day = calendar.monthrange(month_dt.year, month_dt.month)[1]
|
|
1831
|
+
cleaned["start"] = start
|
|
1832
|
+
cleaned["end"] = month_dt.replace(day=last_day)
|
|
1833
|
+
return cleaned
|
|
1834
|
+
|
|
1835
|
+
def clean_destinations(self):
|
|
1836
|
+
raw = self.cleaned_data.get("destinations", "")
|
|
1837
|
+
if not raw:
|
|
1838
|
+
return []
|
|
1839
|
+
validator = EmailValidator()
|
|
1840
|
+
seen: set[str] = set()
|
|
1841
|
+
emails: list[str] = []
|
|
1842
|
+
for part in re.split(r"[\s,]+", raw):
|
|
1843
|
+
candidate = part.strip()
|
|
1844
|
+
if not candidate:
|
|
1845
|
+
continue
|
|
1846
|
+
validator(candidate)
|
|
1847
|
+
key = candidate.lower()
|
|
1848
|
+
if key in seen:
|
|
1849
|
+
continue
|
|
1850
|
+
seen.add(key)
|
|
1851
|
+
emails.append(candidate)
|
|
1852
|
+
return emails
|
|
1853
|
+
|
|
1854
|
+
def get_urls(self):
|
|
1855
|
+
urls = super().get_urls()
|
|
1856
|
+
custom = [
|
|
1857
|
+
path(
|
|
1858
|
+
"generate/",
|
|
1859
|
+
self.admin_site.admin_view(self.generate_view),
|
|
1860
|
+
name="core_clientreport_generate",
|
|
1861
|
+
),
|
|
1862
|
+
]
|
|
1863
|
+
return custom + urls
|
|
1864
|
+
|
|
1865
|
+
def generate_view(self, request):
|
|
1866
|
+
form = self.ClientReportForm(request.POST or None, request=request)
|
|
1867
|
+
report = None
|
|
1868
|
+
schedule = None
|
|
1869
|
+
if request.method == "POST" and form.is_valid():
|
|
1870
|
+
owner = form.cleaned_data.get("owner")
|
|
1871
|
+
if not owner and request.user.is_authenticated:
|
|
1872
|
+
owner = request.user
|
|
1873
|
+
report = ClientReport.generate(
|
|
1874
|
+
form.cleaned_data["start"],
|
|
1875
|
+
form.cleaned_data["end"],
|
|
1876
|
+
owner=owner,
|
|
1877
|
+
recipients=form.cleaned_data.get("destinations"),
|
|
1878
|
+
disable_emails=form.cleaned_data.get("disable_emails", False),
|
|
1879
|
+
)
|
|
1880
|
+
report.store_local_copy()
|
|
1881
|
+
recurrence = form.cleaned_data.get("recurrence")
|
|
1882
|
+
if recurrence and recurrence != ClientReportSchedule.PERIODICITY_NONE:
|
|
1883
|
+
schedule = ClientReportSchedule.objects.create(
|
|
1884
|
+
owner=owner,
|
|
1885
|
+
created_by=request.user if request.user.is_authenticated else None,
|
|
1886
|
+
periodicity=recurrence,
|
|
1887
|
+
email_recipients=form.cleaned_data.get("destinations", []),
|
|
1888
|
+
disable_emails=form.cleaned_data.get("disable_emails", False),
|
|
1889
|
+
)
|
|
1890
|
+
report.schedule = schedule
|
|
1891
|
+
report.save(update_fields=["schedule"])
|
|
1892
|
+
self.message_user(
|
|
1893
|
+
request,
|
|
1894
|
+
"Client report schedule created; future reports will be generated automatically.",
|
|
1895
|
+
messages.SUCCESS,
|
|
1896
|
+
)
|
|
1897
|
+
context = self.admin_site.each_context(request)
|
|
1898
|
+
context.update({"form": form, "report": report, "schedule": schedule})
|
|
1899
|
+
return TemplateResponse(
|
|
1900
|
+
request, "admin/core/clientreport/generate.html", context
|
|
1901
|
+
)
|
|
1902
|
+
|
|
1903
|
+
|
|
889
1904
|
@admin.register(PackageRelease)
|
|
890
|
-
class PackageReleaseAdmin(SaveBeforeChangeAction,
|
|
1905
|
+
class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
|
|
1906
|
+
change_list_template = "admin/core/packagerelease/change_list.html"
|
|
891
1907
|
list_display = (
|
|
892
1908
|
"version",
|
|
893
1909
|
"package_link",
|
|
@@ -944,6 +1960,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
|
|
|
944
1960
|
package=package,
|
|
945
1961
|
release_manager=package.release_manager,
|
|
946
1962
|
version=version,
|
|
1963
|
+
revision="",
|
|
947
1964
|
pypi_url=f"https://pypi.org/project/{package.name}/{version}/",
|
|
948
1965
|
)
|
|
949
1966
|
created += 1
|
|
@@ -994,9 +2011,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
|
|
|
994
2011
|
messages.WARNING,
|
|
995
2012
|
)
|
|
996
2013
|
continue
|
|
997
|
-
url =
|
|
998
|
-
f"https://pypi.org/pypi/{release.package.name}/{release.version}/json"
|
|
999
|
-
)
|
|
2014
|
+
url = f"https://pypi.org/pypi/{release.package.name}/{release.version}/json"
|
|
1000
2015
|
try:
|
|
1001
2016
|
resp = requests.get(url, timeout=10)
|
|
1002
2017
|
except Exception as exc: # pragma: no cover - network failure
|
|
@@ -1029,3 +2044,23 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
|
|
|
1029
2044
|
return self._boolean_icon(obj.is_current)
|
|
1030
2045
|
|
|
1031
2046
|
|
|
2047
|
+
@admin.register(Todo)
|
|
2048
|
+
class TodoAdmin(EntityModelAdmin):
|
|
2049
|
+
list_display = ("request", "url")
|
|
2050
|
+
|
|
2051
|
+
def has_add_permission(self, request, obj=None):
|
|
2052
|
+
return False
|
|
2053
|
+
|
|
2054
|
+
def get_model_perms(self, request):
|
|
2055
|
+
return {}
|
|
2056
|
+
|
|
2057
|
+
def render_change_form(
|
|
2058
|
+
self, request, context, add=False, change=False, form_url="", obj=None
|
|
2059
|
+
):
|
|
2060
|
+
context = super().render_change_form(
|
|
2061
|
+
request, context, add=add, change=change, form_url=form_url, obj=obj
|
|
2062
|
+
)
|
|
2063
|
+
context["show_user_datum"] = False
|
|
2064
|
+
context["show_seed_datum"] = False
|
|
2065
|
+
context["show_save_as_copy"] = False
|
|
2066
|
+
return context
|