arthexis 0.1.8__py3-none-any.whl → 0.1.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

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