arthexis 0.1.7__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.9.dist-info/METADATA +168 -0
- 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 +134 -16
- config/urls.py +71 -3
- core/admin.py +1331 -165
- core/admin_history.py +50 -0
- core/admindocs.py +151 -0
- 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 +1136 -259
- 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 +445 -58
- 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 +17 -0
- core/workgroup_views.py +94 -0
- 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 +4 -3
- 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.7.dist-info/METADATA +0 -126
- arthexis-0.1.7.dist-info/RECORD +0 -77
- arthexis-0.1.7.dist-info/licenses/LICENSE +0 -21
- config/workgroup_app.py +0 -7
- core/checks.py +0 -29
- {arthexis-0.1.7.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
- {arthexis-0.1.7.dist-info → arthexis-0.1.9.dist-info}/top_level.txt +0 -0
core/admin.py
CHANGED
|
@@ -3,10 +3,12 @@ from django.contrib import admin
|
|
|
3
3
|
from django.contrib.admin.widgets import RelatedFieldWidgetWrapper
|
|
4
4
|
from django.urls import path, reverse
|
|
5
5
|
from django.shortcuts import redirect, render
|
|
6
|
-
from django.http import JsonResponse, HttpResponseBase
|
|
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,40 +21,134 @@ 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,
|
|
59
|
+
PublicWifiAccess,
|
|
60
|
+
AssistantProfile,
|
|
61
|
+
Todo,
|
|
62
|
+
hash_key,
|
|
48
63
|
)
|
|
49
|
-
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
|
|
50
67
|
|
|
51
68
|
|
|
52
69
|
admin.site.unregister(Group)
|
|
53
70
|
|
|
54
71
|
|
|
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)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
admin.ModelAdmin.changelist_view = changelist_view_with_object_links
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ExperienceReference(Reference):
|
|
111
|
+
class Meta:
|
|
112
|
+
proxy = True
|
|
113
|
+
app_label = "pages"
|
|
114
|
+
verbose_name = Reference._meta.verbose_name
|
|
115
|
+
verbose_name_plural = Reference._meta.verbose_name_plural
|
|
116
|
+
|
|
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
|
+
|
|
55
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
|
+
|
|
56
152
|
def response_change(self, request, obj):
|
|
57
153
|
action = request.POST.get("_action")
|
|
58
154
|
if action:
|
|
@@ -65,13 +161,13 @@ class SaveBeforeChangeAction(DjangoObjectActions):
|
|
|
65
161
|
return super().response_change(request, obj)
|
|
66
162
|
|
|
67
163
|
|
|
68
|
-
@admin.register(
|
|
69
|
-
class ReferenceAdmin(
|
|
164
|
+
@admin.register(ExperienceReference)
|
|
165
|
+
class ReferenceAdmin(EntityModelAdmin):
|
|
70
166
|
list_display = (
|
|
71
167
|
"alt_text",
|
|
72
168
|
"content_type",
|
|
73
|
-
"
|
|
74
|
-
"
|
|
169
|
+
"footer",
|
|
170
|
+
"visibility",
|
|
75
171
|
"author",
|
|
76
172
|
"transaction_uuid",
|
|
77
173
|
)
|
|
@@ -82,6 +178,9 @@ class ReferenceAdmin(admin.ModelAdmin):
|
|
|
82
178
|
"value",
|
|
83
179
|
"file",
|
|
84
180
|
"method",
|
|
181
|
+
"roles",
|
|
182
|
+
"features",
|
|
183
|
+
"sites",
|
|
85
184
|
"include_in_footer",
|
|
86
185
|
"footer_visibility",
|
|
87
186
|
"transaction_uuid",
|
|
@@ -89,6 +188,7 @@ class ReferenceAdmin(admin.ModelAdmin):
|
|
|
89
188
|
"uses",
|
|
90
189
|
"qr_code",
|
|
91
190
|
)
|
|
191
|
+
filter_horizontal = ("roles", "features", "sites")
|
|
92
192
|
|
|
93
193
|
def get_readonly_fields(self, request, obj=None):
|
|
94
194
|
ro = list(super().get_readonly_fields(request, obj))
|
|
@@ -96,6 +196,14 @@ class ReferenceAdmin(admin.ModelAdmin):
|
|
|
96
196
|
ro.append("transaction_uuid")
|
|
97
197
|
return ro
|
|
98
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
|
+
|
|
99
207
|
def get_urls(self):
|
|
100
208
|
urls = super().get_urls()
|
|
101
209
|
custom = [
|
|
@@ -141,14 +249,95 @@ class ReferenceAdmin(admin.ModelAdmin):
|
|
|
141
249
|
qr_code.short_description = "QR Code"
|
|
142
250
|
|
|
143
251
|
|
|
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
|
+
|
|
144
262
|
@admin.register(ReleaseManager)
|
|
145
|
-
class ReleaseManagerAdmin(
|
|
146
|
-
|
|
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 {}
|
|
147
330
|
|
|
148
331
|
|
|
149
332
|
@admin.register(Package)
|
|
150
|
-
class PackageAdmin(SaveBeforeChangeAction,
|
|
151
|
-
list_display = (
|
|
333
|
+
class PackageAdmin(SaveBeforeChangeAction, EntityModelAdmin):
|
|
334
|
+
list_display = (
|
|
335
|
+
"name",
|
|
336
|
+
"description",
|
|
337
|
+
"homepage_url",
|
|
338
|
+
"release_manager",
|
|
339
|
+
"is_active",
|
|
340
|
+
)
|
|
152
341
|
actions = ["prepare_next_release"]
|
|
153
342
|
change_actions = ["prepare_next_release_action"]
|
|
154
343
|
|
|
@@ -157,32 +346,59 @@ class PackageAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
|
|
|
157
346
|
from packaging.version import Version
|
|
158
347
|
|
|
159
348
|
ver_file = Path("VERSION")
|
|
160
|
-
repo_version =
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
Version(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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)
|
|
168
370
|
release, _created = PackageRelease.all_objects.update_or_create(
|
|
169
371
|
package=package,
|
|
170
|
-
version=next_version,
|
|
372
|
+
version=str(next_version),
|
|
171
373
|
defaults={
|
|
172
374
|
"release_manager": package.release_manager,
|
|
173
375
|
"is_deleted": False,
|
|
174
376
|
},
|
|
175
377
|
)
|
|
176
|
-
return redirect(
|
|
177
|
-
|
|
178
|
-
|
|
378
|
+
return redirect(reverse("admin:core_packagerelease_change", args=[release.pk]))
|
|
379
|
+
|
|
380
|
+
def get_urls(self):
|
|
381
|
+
urls = super().get_urls()
|
|
382
|
+
custom = [
|
|
383
|
+
path(
|
|
384
|
+
"prepare-next-release/",
|
|
385
|
+
self.admin_site.admin_view(self.prepare_next_release_active),
|
|
386
|
+
name="core_package_prepare_next_release",
|
|
387
|
+
)
|
|
388
|
+
]
|
|
389
|
+
return custom + urls
|
|
390
|
+
|
|
391
|
+
def prepare_next_release_active(self, request):
|
|
392
|
+
package = Package.objects.filter(is_active=True).first()
|
|
393
|
+
if not package:
|
|
394
|
+
self.message_user(request, "No active package", messages.ERROR)
|
|
395
|
+
return redirect("admin:core_package_changelist")
|
|
396
|
+
return self._prepare(request, package)
|
|
179
397
|
|
|
180
398
|
@admin.action(description="Prepare next Release")
|
|
181
399
|
def prepare_next_release(self, request, queryset):
|
|
182
400
|
if queryset.count() != 1:
|
|
183
|
-
self.message_user(
|
|
184
|
-
request, "Select exactly one package", messages.ERROR
|
|
185
|
-
)
|
|
401
|
+
self.message_user(request, "Select exactly one package", messages.ERROR)
|
|
186
402
|
return
|
|
187
403
|
return self._prepare(request, queryset.first())
|
|
188
404
|
|
|
@@ -219,16 +435,14 @@ class SecurityGroupAdminForm(forms.ModelForm):
|
|
|
219
435
|
return instance
|
|
220
436
|
|
|
221
437
|
|
|
222
|
-
@admin.register(SecurityGroup)
|
|
223
438
|
class SecurityGroupAdmin(DjangoGroupAdmin):
|
|
224
439
|
form = SecurityGroupAdminForm
|
|
225
440
|
fieldsets = ((None, {"fields": ("name", "parent", "users", "permissions")}),)
|
|
226
441
|
filter_horizontal = ("permissions",)
|
|
227
442
|
|
|
228
443
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
list_display = ("email", "created_on")
|
|
444
|
+
class InviteLeadAdmin(EntityModelAdmin):
|
|
445
|
+
list_display = ("email", "mac_address", "created_on", "sent_on", "short_error")
|
|
232
446
|
search_fields = ("email", "comment")
|
|
233
447
|
readonly_fields = (
|
|
234
448
|
"created_on",
|
|
@@ -237,8 +451,24 @@ class InviteLeadAdmin(admin.ModelAdmin):
|
|
|
237
451
|
"referer",
|
|
238
452
|
"user_agent",
|
|
239
453
|
"ip_address",
|
|
454
|
+
"mac_address",
|
|
455
|
+
"sent_on",
|
|
456
|
+
"error",
|
|
240
457
|
)
|
|
241
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
|
+
|
|
242
472
|
|
|
243
473
|
class EnergyAccountRFIDForm(forms.ModelForm):
|
|
244
474
|
"""Form for assigning existing RFIDs to an energy account."""
|
|
@@ -250,7 +480,9 @@ class EnergyAccountRFIDForm(forms.ModelForm):
|
|
|
250
480
|
def clean_rfid(self):
|
|
251
481
|
rfid = self.cleaned_data["rfid"]
|
|
252
482
|
if rfid.energy_accounts.exclude(pk=self.instance.energyaccount_id).exists():
|
|
253
|
-
raise forms.ValidationError(
|
|
483
|
+
raise forms.ValidationError(
|
|
484
|
+
"RFID is already assigned to another energy account"
|
|
485
|
+
)
|
|
254
486
|
return rfid
|
|
255
487
|
|
|
256
488
|
|
|
@@ -263,27 +495,53 @@ class EnergyAccountRFIDInline(admin.TabularInline):
|
|
|
263
495
|
verbose_name_plural = "RFIDs"
|
|
264
496
|
|
|
265
497
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
)
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
)
|
|
498
|
+
def _raw_instance_value(instance, field_name):
|
|
499
|
+
"""Return the stored value for ``field_name`` without resolving sigils."""
|
|
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)
|
|
273
511
|
|
|
274
512
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
change_form_template = "admin/user_datum_change_form.html"
|
|
278
|
-
list_display = ("street", "number", "municipality", "state", "postal_code")
|
|
279
|
-
search_fields = ("street", "municipality", "postal_code")
|
|
513
|
+
class KeepExistingValue:
|
|
514
|
+
"""Sentinel indicating a field should retain its stored value."""
|
|
280
515
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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)
|
|
285
542
|
else:
|
|
286
|
-
|
|
543
|
+
raw = _raw_instance_value(form.instance, name)
|
|
544
|
+
setattr(form.instance, name, raw)
|
|
287
545
|
|
|
288
546
|
|
|
289
547
|
class OdooProfileAdminForm(forms.ModelForm):
|
|
@@ -310,103 +568,190 @@ class OdooProfileAdminForm(forms.ModelForm):
|
|
|
310
568
|
def clean_password(self):
|
|
311
569
|
pwd = self.cleaned_data.get("password")
|
|
312
570
|
if not pwd and self.instance.pk:
|
|
313
|
-
return
|
|
571
|
+
return keep_existing("password")
|
|
314
572
|
return pwd
|
|
315
573
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
readonly_fields = ("verified_on", "odoo_uid", "name", "email")
|
|
323
|
-
actions = ["verify_credentials"]
|
|
324
|
-
fieldsets = (
|
|
325
|
-
(None, {"fields": ("user", "host", "database", "username", "password")}),
|
|
326
|
-
("Odoo", {"fields": ("verified_on", "odoo_uid", "name", "email")}),
|
|
327
|
-
)
|
|
328
|
-
|
|
329
|
-
@admin.action(description="Test selected credentials")
|
|
330
|
-
def verify_credentials(self, request, queryset):
|
|
331
|
-
for profile in queryset:
|
|
332
|
-
try:
|
|
333
|
-
profile.verify()
|
|
334
|
-
self.message_user(request, f"{profile.user} verified")
|
|
335
|
-
except Exception as exc: # pragma: no cover - admin feedback
|
|
336
|
-
self.message_user(
|
|
337
|
-
request, f"{profile.user}: {exc}", level=messages.ERROR
|
|
338
|
-
)
|
|
574
|
+
def _post_clean(self):
|
|
575
|
+
super()._post_clean()
|
|
576
|
+
_restore_sigil_values(
|
|
577
|
+
self,
|
|
578
|
+
["host", "database", "username", "password"],
|
|
579
|
+
)
|
|
339
580
|
|
|
340
581
|
|
|
341
|
-
class
|
|
342
|
-
"""Admin form for :class:`core.models.
|
|
582
|
+
class EmailInboxAdminForm(forms.ModelForm):
|
|
583
|
+
"""Admin form for :class:`core.models.EmailInbox` with hidden password."""
|
|
343
584
|
|
|
344
|
-
|
|
585
|
+
password = forms.CharField(
|
|
345
586
|
widget=forms.PasswordInput(render_value=True),
|
|
346
587
|
required=False,
|
|
347
|
-
help_text="Leave blank to keep the current
|
|
588
|
+
help_text="Leave blank to keep the current password.",
|
|
348
589
|
)
|
|
349
590
|
|
|
350
591
|
class Meta:
|
|
351
|
-
model =
|
|
592
|
+
model = EmailInbox
|
|
352
593
|
fields = "__all__"
|
|
353
594
|
|
|
354
595
|
def __init__(self, *args, **kwargs):
|
|
355
596
|
super().__init__(*args, **kwargs)
|
|
356
597
|
if self.instance.pk:
|
|
357
|
-
self.fields["
|
|
358
|
-
self.initial["
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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()
|
|
387
631
|
)
|
|
388
632
|
|
|
389
|
-
@admin.action(description="Test selected profiles")
|
|
390
|
-
def test_connection(self, request, queryset):
|
|
391
|
-
for profile in queryset:
|
|
392
|
-
try:
|
|
393
|
-
profile.test_connection()
|
|
394
|
-
self.message_user(request, f"{profile} connection successful")
|
|
395
|
-
except Exception as exc: # pragma: no cover - admin feedback
|
|
396
|
-
self.message_user(request, f"{profile}: {exc}", level=messages.ERROR)
|
|
397
633
|
|
|
634
|
+
class ProfileFormMixin(forms.ModelForm):
|
|
635
|
+
"""Mark profiles for deletion when no data is provided."""
|
|
398
636
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
+
)
|
|
405
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
|
+
)
|
|
406
657
|
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
697
|
+
|
|
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
|
|
409
748
|
|
|
749
|
+
class Meta(EmailInboxAdminForm.Meta):
|
|
750
|
+
exclude = ("user", "group")
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
class EmailOutboxInlineForm(ProfileFormMixin, forms.ModelForm):
|
|
754
|
+
profile_fields = EmailOutbox.profile_fields
|
|
410
755
|
password = forms.CharField(
|
|
411
756
|
widget=forms.PasswordInput(render_value=True),
|
|
412
757
|
required=False,
|
|
@@ -414,8 +759,16 @@ class EmailInboxAdminForm(forms.ModelForm):
|
|
|
414
759
|
)
|
|
415
760
|
|
|
416
761
|
class Meta:
|
|
417
|
-
model =
|
|
418
|
-
fields =
|
|
762
|
+
model = EmailOutbox
|
|
763
|
+
fields = (
|
|
764
|
+
"password",
|
|
765
|
+
"host",
|
|
766
|
+
"port",
|
|
767
|
+
"username",
|
|
768
|
+
"use_tls",
|
|
769
|
+
"use_ssl",
|
|
770
|
+
"from_email",
|
|
771
|
+
)
|
|
419
772
|
|
|
420
773
|
def __init__(self, *args, **kwargs):
|
|
421
774
|
super().__init__(*args, **kwargs)
|
|
@@ -428,9 +781,316 @@ class EmailInboxAdminForm(forms.ModelForm):
|
|
|
428
781
|
def clean_password(self):
|
|
429
782
|
pwd = self.cleaned_data.get("password")
|
|
430
783
|
if not pwd and self.instance.pk:
|
|
431
|
-
return
|
|
784
|
+
return keep_existing("password")
|
|
432
785
|
return pwd
|
|
433
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
|
+
|
|
434
1094
|
|
|
435
1095
|
class EmailSearchForm(forms.Form):
|
|
436
1096
|
subject = forms.CharField(
|
|
@@ -447,17 +1107,51 @@ class EmailSearchForm(forms.Form):
|
|
|
447
1107
|
)
|
|
448
1108
|
|
|
449
1109
|
|
|
450
|
-
|
|
451
|
-
class EmailInboxAdmin(admin.ModelAdmin):
|
|
1110
|
+
class EmailInboxAdmin(SaveBeforeChangeAction, EntityModelAdmin):
|
|
452
1111
|
form = EmailInboxAdminForm
|
|
453
|
-
list_display = ("
|
|
454
|
-
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
|
+
|
|
455
1149
|
fieldsets = (
|
|
1150
|
+
("Owner", {"fields": ("user", "group")}),
|
|
456
1151
|
(
|
|
457
1152
|
None,
|
|
458
1153
|
{
|
|
459
1154
|
"fields": (
|
|
460
|
-
"user",
|
|
461
1155
|
"username",
|
|
462
1156
|
"host",
|
|
463
1157
|
"port",
|
|
@@ -469,9 +1163,12 @@ class EmailInboxAdmin(admin.ModelAdmin):
|
|
|
469
1163
|
),
|
|
470
1164
|
)
|
|
471
1165
|
|
|
1166
|
+
@admin.display(description="Owner")
|
|
1167
|
+
def owner_label(self, obj):
|
|
1168
|
+
return obj.owner_display()
|
|
1169
|
+
|
|
472
1170
|
def save_model(self, request, obj, form, change):
|
|
473
1171
|
super().save_model(request, obj, form, change)
|
|
474
|
-
obj.__class__ = EmailInbox
|
|
475
1172
|
|
|
476
1173
|
@admin.action(description="Test selected inboxes")
|
|
477
1174
|
def test_connection(self, request, queryset):
|
|
@@ -482,6 +1179,33 @@ class EmailInboxAdmin(admin.ModelAdmin):
|
|
|
482
1179
|
except Exception as exc: # pragma: no cover - admin feedback
|
|
483
1180
|
self.message_user(request, f"{inbox}: {exc}", level=messages.ERROR)
|
|
484
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
|
+
|
|
485
1209
|
@admin.action(description="Search selected inbox")
|
|
486
1210
|
def search_inbox(self, request, queryset):
|
|
487
1211
|
if queryset.count() != 1:
|
|
@@ -503,6 +1227,7 @@ class EmailInboxAdmin(admin.ModelAdmin):
|
|
|
503
1227
|
"results": results,
|
|
504
1228
|
"queryset": queryset,
|
|
505
1229
|
"action": "search_inbox",
|
|
1230
|
+
"opts": self.model._meta,
|
|
506
1231
|
}
|
|
507
1232
|
return TemplateResponse(
|
|
508
1233
|
request, "admin/core/emailinbox/search.html", context
|
|
@@ -513,10 +1238,178 @@ class EmailInboxAdmin(admin.ModelAdmin):
|
|
|
513
1238
|
"form": form,
|
|
514
1239
|
"queryset": queryset,
|
|
515
1240
|
"action": "search_inbox",
|
|
1241
|
+
"opts": self.model._meta,
|
|
516
1242
|
}
|
|
517
1243
|
return TemplateResponse(request, "admin/core/emailinbox/search.html", context)
|
|
518
1244
|
|
|
519
1245
|
|
|
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")
|
|
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
|
+
)
|
|
1268
|
+
|
|
1269
|
+
def owner(self, obj):
|
|
1270
|
+
return obj.owner_display()
|
|
1271
|
+
|
|
1272
|
+
owner.short_description = "Owner"
|
|
1273
|
+
|
|
1274
|
+
def get_urls(self):
|
|
1275
|
+
urls = super().get_urls()
|
|
1276
|
+
opts = self.model._meta
|
|
1277
|
+
app_label = opts.app_label
|
|
1278
|
+
model_name = opts.model_name
|
|
1279
|
+
custom = [
|
|
1280
|
+
path(
|
|
1281
|
+
"<path:object_id>/generate-key/",
|
|
1282
|
+
self.admin_site.admin_view(self.generate_key),
|
|
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",
|
|
1299
|
+
),
|
|
1300
|
+
]
|
|
1301
|
+
return custom + urls
|
|
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
|
+
|
|
1327
|
+
def generate_key(self, request, object_id, *args, **kwargs):
|
|
1328
|
+
profile = self.get_object(request, object_id)
|
|
1329
|
+
if profile is None:
|
|
1330
|
+
return HttpResponseRedirect("../")
|
|
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)
|
|
1339
|
+
context = {
|
|
1340
|
+
**self.admin_site.each_context(request),
|
|
1341
|
+
"opts": self.model._meta,
|
|
1342
|
+
"original": profile,
|
|
1343
|
+
"user_key": key,
|
|
1344
|
+
}
|
|
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 {}
|
|
1411
|
+
|
|
1412
|
+
|
|
520
1413
|
class EnergyCreditInline(admin.TabularInline):
|
|
521
1414
|
model = EnergyCredit
|
|
522
1415
|
fields = ("amount_kw", "created_by", "created_on")
|
|
@@ -525,8 +1418,9 @@ class EnergyCreditInline(admin.TabularInline):
|
|
|
525
1418
|
|
|
526
1419
|
|
|
527
1420
|
@admin.register(EnergyAccount)
|
|
528
|
-
class EnergyAccountAdmin(
|
|
1421
|
+
class EnergyAccountAdmin(EntityModelAdmin):
|
|
529
1422
|
change_list_template = "admin/core/energyaccount/change_list.html"
|
|
1423
|
+
change_form_template = "admin/user_datum_change_form.html"
|
|
530
1424
|
list_display = (
|
|
531
1425
|
"name",
|
|
532
1426
|
"user",
|
|
@@ -563,6 +1457,15 @@ class EnergyAccountAdmin(admin.ModelAdmin):
|
|
|
563
1457
|
)
|
|
564
1458
|
},
|
|
565
1459
|
),
|
|
1460
|
+
(
|
|
1461
|
+
"Live Subscription",
|
|
1462
|
+
{
|
|
1463
|
+
"fields": (
|
|
1464
|
+
"live_subscription_product",
|
|
1465
|
+
("live_subscription_start_date", "live_subscription_next_renewal"),
|
|
1466
|
+
)
|
|
1467
|
+
},
|
|
1468
|
+
),
|
|
566
1469
|
)
|
|
567
1470
|
|
|
568
1471
|
def authorized(self, obj):
|
|
@@ -643,29 +1546,25 @@ class EnergyAccountAdmin(admin.ModelAdmin):
|
|
|
643
1546
|
|
|
644
1547
|
|
|
645
1548
|
@admin.register(ElectricVehicle)
|
|
646
|
-
class ElectricVehicleAdmin(
|
|
1549
|
+
class ElectricVehicleAdmin(EntityModelAdmin):
|
|
647
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
|
+
)
|
|
648
1558
|
fields = ("account", "vin", "license_plate", "brand", "model")
|
|
649
1559
|
|
|
650
1560
|
|
|
651
|
-
@admin.register(EnergyCredit)
|
|
652
|
-
class EnergyCreditAdmin(admin.ModelAdmin):
|
|
653
|
-
list_display = ("account", "amount_kw", "created_by", "created_on")
|
|
654
|
-
readonly_fields = ("created_by", "created_on")
|
|
655
|
-
|
|
656
|
-
def save_model(self, request, obj, form, change):
|
|
657
|
-
if not obj.created_by:
|
|
658
|
-
obj.created_by = request.user
|
|
659
|
-
super().save_model(request, obj, form, change)
|
|
660
|
-
|
|
661
|
-
|
|
662
1561
|
class WMICodeInline(admin.TabularInline):
|
|
663
1562
|
model = WMICode
|
|
664
1563
|
extra = 0
|
|
665
1564
|
|
|
666
1565
|
|
|
667
1566
|
@admin.register(Brand)
|
|
668
|
-
class BrandAdmin(
|
|
1567
|
+
class BrandAdmin(EntityModelAdmin):
|
|
669
1568
|
fields = ("name",)
|
|
670
1569
|
list_display = ("name", "wmi_codes_display")
|
|
671
1570
|
inlines = [WMICodeInline]
|
|
@@ -677,14 +1576,47 @@ class BrandAdmin(admin.ModelAdmin):
|
|
|
677
1576
|
|
|
678
1577
|
|
|
679
1578
|
@admin.register(EVModel)
|
|
680
|
-
class EVModelAdmin(
|
|
1579
|
+
class EVModelAdmin(EntityModelAdmin):
|
|
681
1580
|
fields = ("brand", "name")
|
|
682
|
-
list_display = ("name", "brand")
|
|
683
|
-
|
|
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")
|
|
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)
|
|
1592
|
+
|
|
1593
|
+
brand_wmi_codes.short_description = "WMI codes"
|
|
1594
|
+
|
|
684
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}
|
|
685
1615
|
|
|
686
|
-
|
|
687
|
-
admin.
|
|
1616
|
+
|
|
1617
|
+
@admin.register(Product)
|
|
1618
|
+
class ProductAdmin(EntityModelAdmin):
|
|
1619
|
+
form = ProductAdminForm
|
|
688
1620
|
|
|
689
1621
|
|
|
690
1622
|
class RFIDResource(resources.ModelResource):
|
|
@@ -699,6 +1631,7 @@ class RFIDResource(resources.ModelResource):
|
|
|
699
1631
|
fields = (
|
|
700
1632
|
"label_id",
|
|
701
1633
|
"rfid",
|
|
1634
|
+
"custom_label",
|
|
702
1635
|
"reference",
|
|
703
1636
|
"allowed",
|
|
704
1637
|
"color",
|
|
@@ -720,6 +1653,7 @@ class RFIDForm(forms.ModelForm):
|
|
|
720
1653
|
super().__init__(*args, **kwargs)
|
|
721
1654
|
self.fields["reference"].required = False
|
|
722
1655
|
rel = RFID._meta.get_field("reference").remote_field
|
|
1656
|
+
rel.model = ExperienceReference
|
|
723
1657
|
widget = self.fields["reference"].widget
|
|
724
1658
|
self.fields["reference"].widget = RelatedFieldWidgetWrapper(
|
|
725
1659
|
widget,
|
|
@@ -732,12 +1666,13 @@ class RFIDForm(forms.ModelForm):
|
|
|
732
1666
|
|
|
733
1667
|
|
|
734
1668
|
@admin.register(RFID)
|
|
735
|
-
class RFIDAdmin(ImportExportModelAdmin):
|
|
1669
|
+
class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
|
|
736
1670
|
change_list_template = "admin/core/rfid/change_list.html"
|
|
737
1671
|
resource_class = RFIDResource
|
|
738
1672
|
list_display = (
|
|
739
1673
|
"label_id",
|
|
740
1674
|
"rfid",
|
|
1675
|
+
"custom_label",
|
|
741
1676
|
"color",
|
|
742
1677
|
"kind",
|
|
743
1678
|
"released",
|
|
@@ -747,7 +1682,7 @@ class RFIDAdmin(ImportExportModelAdmin):
|
|
|
747
1682
|
"last_seen_on",
|
|
748
1683
|
)
|
|
749
1684
|
list_filter = ("color", "released", "allowed")
|
|
750
|
-
search_fields = ("label_id", "rfid")
|
|
1685
|
+
search_fields = ("label_id", "rfid", "custom_label")
|
|
751
1686
|
autocomplete_fields = ["energy_accounts"]
|
|
752
1687
|
raw_id_fields = ["reference"]
|
|
753
1688
|
actions = ["scan_rfids"]
|
|
@@ -762,11 +1697,16 @@ class RFIDAdmin(ImportExportModelAdmin):
|
|
|
762
1697
|
def scan_rfids(self, request, queryset):
|
|
763
1698
|
return redirect("admin:core_rfid_scan")
|
|
764
1699
|
|
|
765
|
-
scan_rfids.short_description = "Scan
|
|
1700
|
+
scan_rfids.short_description = "Scan RFIDs"
|
|
766
1701
|
|
|
767
1702
|
def get_urls(self):
|
|
768
1703
|
urls = super().get_urls()
|
|
769
1704
|
custom = [
|
|
1705
|
+
path(
|
|
1706
|
+
"report/",
|
|
1707
|
+
self.admin_site.admin_view(self.report_view),
|
|
1708
|
+
name="core_rfid_report",
|
|
1709
|
+
),
|
|
770
1710
|
path(
|
|
771
1711
|
"scan/",
|
|
772
1712
|
self.admin_site.admin_view(csrf_exempt(self.scan_view)),
|
|
@@ -780,6 +1720,11 @@ class RFIDAdmin(ImportExportModelAdmin):
|
|
|
780
1720
|
]
|
|
781
1721
|
return custom + urls
|
|
782
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
|
+
|
|
783
1728
|
def scan_view(self, request):
|
|
784
1729
|
context = self.admin_site.each_context(request)
|
|
785
1730
|
context["scan_url"] = reverse("admin:core_rfid_scan_next")
|
|
@@ -796,8 +1741,169 @@ class RFIDAdmin(ImportExportModelAdmin):
|
|
|
796
1741
|
return JsonResponse(result, status=status)
|
|
797
1742
|
|
|
798
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
|
+
|
|
799
1904
|
@admin.register(PackageRelease)
|
|
800
|
-
class PackageReleaseAdmin(SaveBeforeChangeAction,
|
|
1905
|
+
class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
|
|
1906
|
+
change_list_template = "admin/core/packagerelease/change_list.html"
|
|
801
1907
|
list_display = (
|
|
802
1908
|
"version",
|
|
803
1909
|
"package_link",
|
|
@@ -809,6 +1915,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
|
|
|
809
1915
|
list_display_links = ("version",)
|
|
810
1916
|
actions = ["publish_release", "validate_releases"]
|
|
811
1917
|
change_actions = ["publish_release_action"]
|
|
1918
|
+
changelist_actions = ["refresh_from_pypi"]
|
|
812
1919
|
readonly_fields = ("pypi_url", "is_current", "revision")
|
|
813
1920
|
fields = (
|
|
814
1921
|
"package",
|
|
@@ -829,6 +1936,47 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
|
|
|
829
1936
|
|
|
830
1937
|
revision_short.short_description = "revision"
|
|
831
1938
|
|
|
1939
|
+
def refresh_from_pypi(self, request, queryset):
|
|
1940
|
+
package = Package.objects.filter(is_active=True).first()
|
|
1941
|
+
if not package:
|
|
1942
|
+
self.message_user(request, "No active package", messages.ERROR)
|
|
1943
|
+
return
|
|
1944
|
+
try:
|
|
1945
|
+
resp = requests.get(
|
|
1946
|
+
f"https://pypi.org/pypi/{package.name}/json", timeout=10
|
|
1947
|
+
)
|
|
1948
|
+
resp.raise_for_status()
|
|
1949
|
+
except Exception as exc: # pragma: no cover - network failure
|
|
1950
|
+
self.message_user(request, str(exc), messages.ERROR)
|
|
1951
|
+
return
|
|
1952
|
+
releases = resp.json().get("releases", {})
|
|
1953
|
+
created = 0
|
|
1954
|
+
for version in releases:
|
|
1955
|
+
exists = PackageRelease.all_objects.filter(
|
|
1956
|
+
package=package, version=version
|
|
1957
|
+
).exists()
|
|
1958
|
+
if not exists:
|
|
1959
|
+
PackageRelease.objects.create(
|
|
1960
|
+
package=package,
|
|
1961
|
+
release_manager=package.release_manager,
|
|
1962
|
+
version=version,
|
|
1963
|
+
revision="",
|
|
1964
|
+
pypi_url=f"https://pypi.org/project/{package.name}/{version}/",
|
|
1965
|
+
)
|
|
1966
|
+
created += 1
|
|
1967
|
+
if created:
|
|
1968
|
+
PackageRelease.dump_fixture()
|
|
1969
|
+
self.message_user(
|
|
1970
|
+
request,
|
|
1971
|
+
f"Created {created} release{'s' if created != 1 else ''} from PyPI",
|
|
1972
|
+
messages.SUCCESS,
|
|
1973
|
+
)
|
|
1974
|
+
else:
|
|
1975
|
+
self.message_user(request, "No new releases found", messages.INFO)
|
|
1976
|
+
|
|
1977
|
+
refresh_from_pypi.label = "Refresh from PyPI"
|
|
1978
|
+
refresh_from_pypi.short_description = "Refresh from PyPI"
|
|
1979
|
+
|
|
832
1980
|
def _publish_release(self, request, release):
|
|
833
1981
|
try:
|
|
834
1982
|
release.full_clean()
|
|
@@ -863,9 +2011,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
|
|
|
863
2011
|
messages.WARNING,
|
|
864
2012
|
)
|
|
865
2013
|
continue
|
|
866
|
-
url =
|
|
867
|
-
f"https://pypi.org/pypi/{release.package.name}/{release.version}/json"
|
|
868
|
-
)
|
|
2014
|
+
url = f"https://pypi.org/pypi/{release.package.name}/{release.version}/json"
|
|
869
2015
|
try:
|
|
870
2016
|
resp = requests.get(url, timeout=10)
|
|
871
2017
|
except Exception as exc: # pragma: no cover - network failure
|
|
@@ -898,3 +2044,23 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
|
|
|
898
2044
|
return self._boolean_icon(obj.is_current)
|
|
899
2045
|
|
|
900
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
|