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.

Files changed (81) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/METADATA +42 -4
  2. arthexis-0.1.9.dist-info/RECORD +92 -0
  3. arthexis-0.1.9.dist-info/licenses/LICENSE +674 -0
  4. config/__init__.py +0 -1
  5. config/auth_app.py +0 -1
  6. config/celery.py +1 -2
  7. config/context_processors.py +1 -1
  8. config/offline.py +2 -0
  9. config/settings.py +133 -16
  10. config/urls.py +65 -6
  11. core/admin.py +1226 -191
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +158 -3
  15. core/backends.py +46 -4
  16. core/entity.py +62 -48
  17. core/fields.py +6 -1
  18. core/github_helper.py +25 -0
  19. core/github_issues.py +172 -0
  20. core/lcd_screen.py +1 -0
  21. core/liveupdate.py +25 -0
  22. core/log_paths.py +100 -0
  23. core/mailer.py +83 -0
  24. core/middleware.py +57 -0
  25. core/models.py +1071 -264
  26. core/notifications.py +11 -1
  27. core/public_wifi.py +227 -0
  28. core/release.py +27 -20
  29. core/sigil_builder.py +131 -0
  30. core/sigil_context.py +20 -0
  31. core/sigil_resolver.py +284 -0
  32. core/system.py +129 -10
  33. core/tasks.py +118 -19
  34. core/test_system_info.py +22 -0
  35. core/tests.py +358 -63
  36. core/tests_liveupdate.py +17 -0
  37. core/urls.py +2 -2
  38. core/user_data.py +329 -167
  39. core/views.py +383 -57
  40. core/widgets.py +51 -0
  41. core/workgroup_urls.py +7 -3
  42. core/workgroup_views.py +43 -6
  43. nodes/actions.py +0 -2
  44. nodes/admin.py +159 -284
  45. nodes/apps.py +9 -15
  46. nodes/backends.py +53 -0
  47. nodes/lcd.py +24 -10
  48. nodes/models.py +375 -178
  49. nodes/tasks.py +1 -5
  50. nodes/tests.py +524 -129
  51. nodes/utils.py +13 -2
  52. nodes/views.py +66 -23
  53. ocpp/admin.py +150 -61
  54. ocpp/apps.py +1 -1
  55. ocpp/consumers.py +432 -69
  56. ocpp/evcs.py +25 -8
  57. ocpp/models.py +408 -68
  58. ocpp/simulator.py +13 -6
  59. ocpp/store.py +258 -30
  60. ocpp/tasks.py +11 -7
  61. ocpp/test_export_import.py +8 -7
  62. ocpp/test_rfid.py +211 -16
  63. ocpp/tests.py +1198 -135
  64. ocpp/transactions_io.py +68 -22
  65. ocpp/urls.py +35 -2
  66. ocpp/views.py +654 -101
  67. pages/admin.py +173 -13
  68. pages/checks.py +0 -1
  69. pages/context_processors.py +19 -6
  70. pages/middleware.py +153 -0
  71. pages/models.py +37 -9
  72. pages/tests.py +759 -40
  73. pages/urls.py +3 -0
  74. pages/utils.py +0 -1
  75. pages/views.py +576 -25
  76. arthexis-0.1.8.dist-info/RECORD +0 -80
  77. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  78. config/workgroup_app.py +0 -7
  79. core/checks.py +0 -29
  80. {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
  81. {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 .user_data import UserDatumAdminMixin
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
- FediverseProfile,
42
- EmailInbox as CoreEmailInbox,
52
+ EmailInbox,
53
+ EmailCollector,
43
54
  Package,
44
55
  PackageRelease,
45
56
  ReleaseManager,
46
57
  SecurityGroup,
47
58
  InviteLead,
48
- ChatProfile,
59
+ PublicWifiAccess,
60
+ AssistantProfile,
61
+ Todo,
62
+ hash_key,
49
63
  )
50
- from .user_data import UserDatumAdminMixin
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
- class WorkgroupReleaseManager(ReleaseManager):
57
- class Meta:
58
- proxy = True
59
- app_label = "post_office"
60
- verbose_name = ReleaseManager._meta.verbose_name
61
- verbose_name_plural = ReleaseManager._meta.verbose_name_plural
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
- class WorkgroupSecurityGroup(SecurityGroup):
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(admin.ModelAdmin):
165
+ class ReferenceAdmin(EntityModelAdmin):
95
166
  list_display = (
96
167
  "alt_text",
97
168
  "content_type",
98
- "include_in_footer",
99
- "footer_visibility",
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
- @admin.register(WorkgroupReleaseManager)
170
- class ReleaseManagerAdmin(admin.ModelAdmin):
171
- list_display = ("user", "pypi_username", "pypi_url")
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, admin.ModelAdmin):
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 = ver_file.read_text().strip() if ver_file.exists() else "0.0.0"
192
- versions = [Version(repo_version)]
193
- versions += [
194
- Version(r.version)
195
- for r in PackageRelease.all_objects.filter(package=package)
196
- ]
197
- highest = max(versions)
198
- next_version = f"{highest.major}.{highest.minor}.{highest.micro + 1}"
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 = WorkgroupSecurityGroup
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
- @admin.register(InviteLead)
279
- class InviteLeadAdmin(admin.ModelAdmin):
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("RFID is already assigned to another energy account")
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
- class UserAdmin(DjangoUserAdmin):
316
- fieldsets = DjangoUserAdmin.fieldsets + (
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
- def save_model(self, request, obj, form, change):
331
- if "_saveacopy" in request.POST:
332
- obj.pk = None
333
- super().save_model(request, obj, form, False)
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
- super().save_model(request, obj, form, change)
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 self.instance.password
571
+ return keep_existing("password")
363
572
  return pwd
364
573
 
365
-
366
- @admin.register(OdooProfile)
367
- class OdooProfileAdmin(UserDatumAdminMixin, admin.ModelAdmin):
368
- change_form_template = "admin/user_datum_change_form.html"
369
- form = OdooProfileAdminForm
370
- list_display = ("user", "host", "database", "verified_on")
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 FediverseProfileAdminForm(forms.ModelForm):
391
- """Admin form for :class:`core.models.FediverseProfile` with hidden token."""
582
+ class EmailInboxAdminForm(forms.ModelForm):
583
+ """Admin form for :class:`core.models.EmailInbox` with hidden password."""
392
584
 
393
- access_token = forms.CharField(
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 token.",
588
+ help_text="Leave blank to keep the current password.",
397
589
  )
398
590
 
399
591
  class Meta:
400
- model = FediverseProfile
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["access_token"].initial = ""
407
- self.initial["access_token"] = ""
408
-
409
- def clean_access_token(self):
410
- token = self.cleaned_data.get("access_token")
411
- if not token and self.instance.pk:
412
- return self.instance.access_token
413
- return token
414
-
415
-
416
- @admin.register(FediverseProfile)
417
- class FediverseProfileAdmin(admin.ModelAdmin):
418
- form = FediverseProfileAdminForm
419
- list_display = ("user", "service", "host", "handle", "verified_on")
420
- readonly_fields = ("verified_on",)
421
- actions = ["test_connection"]
422
- fieldsets = (
423
- (
424
- None,
425
- {
426
- "fields": (
427
- "user",
428
- "service",
429
- "host",
430
- "handle",
431
- "access_token",
432
- "verified_on",
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
- class EmailInbox(CoreEmailInbox):
449
- class Meta:
450
- proxy = True
451
- app_label = "post_office"
452
- verbose_name = CoreEmailInbox._meta.verbose_name
453
- verbose_name_plural = CoreEmailInbox._meta.verbose_name_plural
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
- class EmailInboxAdminForm(forms.ModelForm):
457
- """Admin form for :class:`core.models.EmailInbox` with hidden password."""
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 = CoreEmailInbox
467
- fields = "__all__"
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 self.instance.password
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
- @admin.register(EmailInbox)
500
- class EmailInboxAdmin(admin.ModelAdmin):
1110
+ class EmailInboxAdmin(SaveBeforeChangeAction, EntityModelAdmin):
501
1111
  form = EmailInboxAdminForm
502
- list_display = ("user", "username", "host", "protocol")
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
- class WorkgroupChatProfile(ChatProfile):
570
- class Meta:
571
- proxy = True
572
- app_label = "post_office"
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
- @admin.register(WorkgroupChatProfile)
578
- class ChatProfileAdmin(admin.ModelAdmin):
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
- change_form_template = "admin/workgroupchatprofile_change_form.html"
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="post_office_workgroupchatprofile_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",
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
- profile, key = ChatProfile.issue_key(profile.user)
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/chatprofile_key.html", context)
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(admin.ModelAdmin):
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(admin.ModelAdmin):
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(admin.ModelAdmin):
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(admin.ModelAdmin):
1579
+ class EVModelAdmin(EntityModelAdmin):
770
1580
  fields = ("brand", "name")
771
- list_display = ("name", "brand")
772
- list_filter = ("brand",)
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
- admin.site.register(Product)
776
- admin.site.register(Subscription)
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 new RFIDs"
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, admin.ModelAdmin):
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