arthexis 0.1.7__py3-none-any.whl → 0.1.9__py3-none-any.whl

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

Potentially problematic release.


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

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