arthexis 0.1.11__py3-none-any.whl → 0.1.13__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 (50) hide show
  1. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/METADATA +2 -2
  2. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/RECORD +50 -44
  3. config/asgi.py +15 -1
  4. config/celery.py +8 -1
  5. config/settings.py +49 -78
  6. config/settings_helpers.py +109 -0
  7. core/admin.py +293 -78
  8. core/apps.py +21 -0
  9. core/auto_upgrade.py +2 -2
  10. core/form_fields.py +75 -0
  11. core/models.py +203 -47
  12. core/reference_utils.py +1 -1
  13. core/release.py +42 -20
  14. core/system.py +6 -3
  15. core/tasks.py +92 -40
  16. core/tests.py +75 -1
  17. core/views.py +178 -29
  18. core/widgets.py +43 -0
  19. nodes/admin.py +583 -10
  20. nodes/apps.py +15 -0
  21. nodes/feature_checks.py +133 -0
  22. nodes/models.py +287 -49
  23. nodes/reports.py +411 -0
  24. nodes/tests.py +990 -42
  25. nodes/urls.py +1 -0
  26. nodes/utils.py +32 -0
  27. nodes/views.py +173 -5
  28. ocpp/admin.py +424 -17
  29. ocpp/consumers.py +630 -15
  30. ocpp/evcs.py +7 -94
  31. ocpp/evcs_discovery.py +158 -0
  32. ocpp/models.py +236 -4
  33. ocpp/routing.py +4 -2
  34. ocpp/simulator.py +346 -26
  35. ocpp/status_display.py +26 -0
  36. ocpp/store.py +110 -2
  37. ocpp/tests.py +1425 -33
  38. ocpp/transactions_io.py +27 -3
  39. ocpp/views.py +344 -38
  40. pages/admin.py +138 -3
  41. pages/context_processors.py +15 -1
  42. pages/defaults.py +1 -2
  43. pages/forms.py +67 -0
  44. pages/models.py +136 -1
  45. pages/tests.py +379 -4
  46. pages/urls.py +1 -0
  47. pages/views.py +64 -7
  48. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/WHEEL +0 -0
  49. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/licenses/LICENSE +0 -0
  50. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/top_level.txt +0 -0
core/admin.py CHANGED
@@ -16,6 +16,7 @@ from django.contrib.auth.admin import (
16
16
  GroupAdmin as DjangoGroupAdmin,
17
17
  UserAdmin as DjangoUserAdmin,
18
18
  )
19
+ import logging
19
20
  from import_export import resources, fields
20
21
  from import_export.admin import ImportExportModelAdmin
21
22
  from import_export.widgets import ForeignKeyWidget
@@ -34,6 +35,7 @@ import calendar
34
35
  import re
35
36
  from django_object_actions import DjangoObjectActions
36
37
  from ocpp.models import Transaction
38
+ from ocpp.rfid.utils import build_mode_toggle
37
39
  from nodes.models import EmailOutbox
38
40
  from .models import (
39
41
  User,
@@ -76,6 +78,9 @@ from .user_data import (
76
78
  )
77
79
  from .widgets import OdooProductWidget
78
80
  from .mcp import process as mcp_process
81
+ from .mcp.server import resolve_base_urls
82
+
83
+ logger = logging.getLogger(__name__)
79
84
 
80
85
 
81
86
  admin.site.unregister(Group)
@@ -371,6 +376,29 @@ class ReleaseManagerAdminForm(forms.ModelForm):
371
376
  "github_token": forms.Textarea(attrs={"rows": 3, "style": "width: 40em;"}),
372
377
  }
373
378
 
379
+ def __init__(self, *args, **kwargs):
380
+ super().__init__(*args, **kwargs)
381
+ self.fields["pypi_token"].help_text = format_html(
382
+ "{} <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\">{}</a>{}",
383
+ "Generate an API token from your PyPI account settings.",
384
+ "https://pypi.org/manage/account/token/",
385
+ "pypi.org/manage/account/token/",
386
+ (
387
+ " by clicking “Add API token”, optionally scoping it to the package, "
388
+ "and paste the full `pypi-***` value here."
389
+ ),
390
+ )
391
+ self.fields["github_token"].help_text = format_html(
392
+ "{} <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\">{}</a>{}",
393
+ "Create a personal access token at GitHub → Settings → Developer settings →",
394
+ "https://github.com/settings/tokens",
395
+ "github.com/settings/tokens",
396
+ (
397
+ " with the repository access needed for releases (repo scope for classic tokens "
398
+ "or an equivalent fine-grained token) and paste it here."
399
+ ),
400
+ )
401
+
374
402
 
375
403
  @admin.register(ReleaseManager)
376
404
  class ReleaseManagerAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin):
@@ -690,28 +718,30 @@ class OdooProfileAdminForm(forms.ModelForm):
690
718
  )
691
719
 
692
720
 
693
- class EmailInboxAdminForm(forms.ModelForm):
694
- """Admin form for :class:`core.models.EmailInbox` with hidden password."""
721
+ class MaskedPasswordFormMixin:
722
+ """Mixin that hides stored passwords while allowing updates."""
695
723
 
696
- password = forms.CharField(
697
- widget=forms.PasswordInput(render_value=True),
698
- required=False,
699
- help_text="Leave blank to keep the current password.",
700
- )
701
-
702
- class Meta:
703
- model = EmailInbox
704
- fields = "__all__"
724
+ password_sigil_fields: tuple[str, ...] = ()
705
725
 
706
726
  def __init__(self, *args, **kwargs):
707
727
  super().__init__(*args, **kwargs)
728
+ field = self.fields.get("password")
729
+ if field is None:
730
+ return
731
+ if not isinstance(field.widget, forms.PasswordInput):
732
+ field.widget = forms.PasswordInput()
733
+ field.widget.attrs.setdefault("autocomplete", "new-password")
734
+ field.help_text = field.help_text or "Leave blank to keep the current password."
708
735
  if self.instance.pk:
709
- self.fields["password"].initial = ""
736
+ field.initial = ""
710
737
  self.initial["password"] = ""
711
738
  else:
712
- self.fields["password"].required = True
739
+ field.required = True
713
740
 
714
741
  def clean_password(self):
742
+ field = self.fields.get("password")
743
+ if field is None:
744
+ return self.cleaned_data.get("password")
715
745
  pwd = self.cleaned_data.get("password")
716
746
  if not pwd and self.instance.pk:
717
747
  return keep_existing("password")
@@ -719,10 +749,23 @@ class EmailInboxAdminForm(forms.ModelForm):
719
749
 
720
750
  def _post_clean(self):
721
751
  super()._post_clean()
722
- _restore_sigil_values(
723
- self,
724
- ["username", "host", "password", "protocol"],
725
- )
752
+ if self.password_sigil_fields:
753
+ _restore_sigil_values(self, self.password_sigil_fields)
754
+
755
+
756
+ class EmailInboxAdminForm(MaskedPasswordFormMixin, forms.ModelForm):
757
+ """Admin form for :class:`core.models.EmailInbox` with hidden password."""
758
+
759
+ password = forms.CharField(
760
+ widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
761
+ required=False,
762
+ help_text="Leave blank to keep the current password.",
763
+ )
764
+ password_sigil_fields = ("username", "host", "password", "protocol")
765
+
766
+ class Meta:
767
+ model = EmailInbox
768
+ fields = "__all__"
726
769
 
727
770
 
728
771
  class ProfileInlineFormSet(BaseInlineFormSet):
@@ -880,16 +923,25 @@ class SocialProfileInlineForm(ProfileFormMixin, forms.ModelForm):
880
923
  fields = ("network", "handle", "domain", "did")
881
924
 
882
925
 
883
- class EmailOutboxInlineForm(ProfileFormMixin, forms.ModelForm):
884
- profile_fields = EmailOutbox.profile_fields
926
+ class EmailOutboxAdminForm(MaskedPasswordFormMixin, forms.ModelForm):
927
+ """Admin form for :class:`nodes.models.EmailOutbox` with hidden password."""
928
+
885
929
  password = forms.CharField(
886
- widget=forms.PasswordInput(render_value=True),
930
+ widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
887
931
  required=False,
888
932
  help_text="Leave blank to keep the current password.",
889
933
  )
934
+ password_sigil_fields = ("password", "host", "username", "from_email")
890
935
 
891
936
  class Meta:
892
937
  model = EmailOutbox
938
+ fields = "__all__"
939
+
940
+
941
+ class EmailOutboxInlineForm(ProfileFormMixin, EmailOutboxAdminForm):
942
+ profile_fields = EmailOutbox.profile_fields
943
+
944
+ class Meta(EmailOutboxAdminForm.Meta):
893
945
  fields = (
894
946
  "password",
895
947
  "host",
@@ -901,27 +953,6 @@ class EmailOutboxInlineForm(ProfileFormMixin, forms.ModelForm):
901
953
  "is_enabled",
902
954
  )
903
955
 
904
- def __init__(self, *args, **kwargs):
905
- super().__init__(*args, **kwargs)
906
- if self.instance.pk:
907
- self.fields["password"].initial = ""
908
- self.initial["password"] = ""
909
- else:
910
- self.fields["password"].required = True
911
-
912
- def clean_password(self):
913
- pwd = self.cleaned_data.get("password")
914
- if not pwd and self.instance.pk:
915
- return keep_existing("password")
916
- return pwd
917
-
918
- def _post_clean(self):
919
- super()._post_clean()
920
- _restore_sigil_values(
921
- self,
922
- ["password", "host", "username", "from_email"],
923
- )
924
-
925
956
 
926
957
  class ReleaseManagerInlineForm(ProfileFormMixin, forms.ModelForm):
927
958
  profile_fields = ReleaseManager.profile_fields
@@ -1324,10 +1355,8 @@ class OdooProfileAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdm
1324
1355
  changelist_actions = ["my_profile"]
1325
1356
  fieldsets = (
1326
1357
  ("Owner", {"fields": ("user", "group")}),
1327
- (
1328
- "Configuration",
1329
- {"fields": ("host", "database", "username", "password")},
1330
- ),
1358
+ ("Configuration", {"fields": ("host", "database")}),
1359
+ ("Credentials", {"fields": ("username", "password")}),
1331
1360
  (
1332
1361
  "Odoo Employee",
1333
1362
  {"fields": ("verified_on", "odoo_uid", "name", "email")},
@@ -1417,18 +1446,10 @@ class EmailInboxAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmi
1417
1446
 
1418
1447
  fieldsets = (
1419
1448
  ("Owner", {"fields": ("user", "group")}),
1449
+ ("Credentials", {"fields": ("username", "password")}),
1420
1450
  (
1421
- None,
1422
- {
1423
- "fields": (
1424
- "username",
1425
- "host",
1426
- "port",
1427
- "password",
1428
- "protocol",
1429
- "use_ssl",
1430
- )
1431
- },
1451
+ "Configuration",
1452
+ {"fields": ("host", "port", "protocol", "use_ssl")},
1432
1453
  ),
1433
1454
  )
1434
1455
 
@@ -1526,17 +1547,10 @@ class AssistantProfileAdmin(
1526
1547
  changelist_actions = ["my_profile"]
1527
1548
  fieldsets = (
1528
1549
  ("Owner", {"fields": ("user", "group")}),
1550
+ ("Credentials", {"fields": ("user_key_hash",)}),
1529
1551
  (
1530
- None,
1531
- {
1532
- "fields": (
1533
- "scopes",
1534
- "is_active",
1535
- "user_key_hash",
1536
- "created_at",
1537
- "last_used_at",
1538
- )
1539
- },
1552
+ "Configuration",
1553
+ {"fields": ("scopes", "is_active", "created_at", "last_used_at")},
1540
1554
  ),
1541
1555
  )
1542
1556
 
@@ -1627,14 +1641,19 @@ class AssistantProfileAdmin(
1627
1641
  config = dict(getattr(settings, "MCP_SIGIL_SERVER", {}))
1628
1642
  host = config.get("host") or "127.0.0.1"
1629
1643
  port = config.get("port", 8800)
1644
+ base_url, issuer_url = resolve_base_urls(config)
1630
1645
  if isinstance(response, dict):
1631
1646
  response.setdefault("mcp_server_host", host)
1632
1647
  response.setdefault("mcp_server_port", port)
1648
+ response.setdefault("mcp_server_base_url", base_url)
1649
+ response.setdefault("mcp_server_issuer_url", issuer_url)
1633
1650
  else:
1634
1651
  context_data = getattr(response, "context_data", None)
1635
1652
  if context_data is not None:
1636
1653
  context_data.setdefault("mcp_server_host", host)
1637
1654
  context_data.setdefault("mcp_server_port", port)
1655
+ context_data.setdefault("mcp_server_base_url", base_url)
1656
+ context_data.setdefault("mcp_server_issuer_url", issuer_url)
1638
1657
  return response
1639
1658
 
1640
1659
  def start_server(self, request):
@@ -1922,7 +1941,7 @@ class ProductFetchWizardForm(forms.Form):
1922
1941
  @admin.register(Product)
1923
1942
  class ProductAdmin(EntityModelAdmin):
1924
1943
  form = ProductAdminForm
1925
- actions = ["fetch_odoo_product"]
1944
+ actions = ["fetch_odoo_product", "register_from_odoo"]
1926
1945
 
1927
1946
  def _odoo_profile_admin(self):
1928
1947
  return self.admin_site._registry.get(OdooProfile)
@@ -1932,7 +1951,7 @@ class ProductAdmin(EntityModelAdmin):
1932
1951
  return profile.execute(
1933
1952
  "product.product",
1934
1953
  "search_read",
1935
- domain,
1954
+ [domain],
1936
1955
  {
1937
1956
  "fields": [
1938
1957
  "name",
@@ -1983,6 +2002,13 @@ class ProductAdmin(EntityModelAdmin):
1983
2002
  try:
1984
2003
  results = self._search_odoo_products(profile, form)
1985
2004
  except Exception:
2005
+ logger.exception(
2006
+ "Failed to fetch Odoo products for user %s (profile_id=%s, host=%s, database=%s)",
2007
+ getattr(getattr(request, "user", None), "pk", None),
2008
+ getattr(profile, "pk", None),
2009
+ getattr(profile, "host", None),
2010
+ getattr(profile, "database", None),
2011
+ )
1986
2012
  form.add_error(None, _("Unable to fetch products from Odoo."))
1987
2013
  results = []
1988
2014
  else:
@@ -2072,6 +2098,169 @@ class ProductAdmin(EntityModelAdmin):
2072
2098
  context["media"] = self.media + form.media
2073
2099
  return TemplateResponse(request, "admin/core/product/fetch_odoo.html", context)
2074
2100
 
2101
+ def get_urls(self):
2102
+ urls = super().get_urls()
2103
+ custom = [
2104
+ path(
2105
+ "register-from-odoo/",
2106
+ self.admin_site.admin_view(self.register_from_odoo_view),
2107
+ name=f"{self.opts.app_label}_{self.opts.model_name}_register_from_odoo",
2108
+ )
2109
+ ]
2110
+ return custom + urls
2111
+
2112
+ @admin.action(description="Register from Odoo")
2113
+ def register_from_odoo(self, request, queryset=None): # pragma: no cover - simple redirect
2114
+ return HttpResponseRedirect(
2115
+ reverse(
2116
+ f"admin:{self.opts.app_label}_{self.opts.model_name}_register_from_odoo"
2117
+ )
2118
+ )
2119
+
2120
+ def _build_register_context(self, request):
2121
+ opts = self.model._meta
2122
+ context = self.admin_site.each_context(request)
2123
+ context.update(
2124
+ {
2125
+ "opts": opts,
2126
+ "title": _("Register from Odoo"),
2127
+ "has_credentials": False,
2128
+ "profile_url": None,
2129
+ "products": [],
2130
+ "selected_product_id": request.POST.get("product_id", ""),
2131
+ }
2132
+ )
2133
+
2134
+ profile_admin = self._odoo_profile_admin()
2135
+ if profile_admin is not None:
2136
+ context["profile_url"] = profile_admin.get_my_profile_url(request)
2137
+
2138
+ profile = getattr(request.user, "odoo_profile", None)
2139
+ if not profile or not profile.is_verified:
2140
+ context["credential_error"] = _(
2141
+ "Configure your Odoo employee credentials before registering products."
2142
+ )
2143
+ return context, None
2144
+
2145
+ try:
2146
+ products = profile.execute(
2147
+ "product.product",
2148
+ "search_read",
2149
+ [[]],
2150
+ {
2151
+ "fields": [
2152
+ "name",
2153
+ "description_sale",
2154
+ "list_price",
2155
+ "standard_price",
2156
+ ],
2157
+ "limit": 0,
2158
+ },
2159
+ )
2160
+ except Exception:
2161
+ context["error"] = _("Unable to fetch products from Odoo.")
2162
+ return context, []
2163
+
2164
+ context["has_credentials"] = True
2165
+ simplified = []
2166
+ for product in products:
2167
+ simplified.append(
2168
+ {
2169
+ "id": product.get("id"),
2170
+ "name": product.get("name", ""),
2171
+ "description_sale": product.get("description_sale", ""),
2172
+ "list_price": product.get("list_price"),
2173
+ "standard_price": product.get("standard_price"),
2174
+ }
2175
+ )
2176
+ context["products"] = simplified
2177
+ return context, simplified
2178
+
2179
+ def register_from_odoo_view(self, request):
2180
+ context, products = self._build_register_context(request)
2181
+ if products is None:
2182
+ return TemplateResponse(
2183
+ request, "admin/core/product/register_from_odoo.html", context
2184
+ )
2185
+
2186
+ if request.method == "POST" and context.get("has_credentials"):
2187
+ if not self.has_add_permission(request):
2188
+ context["form_error"] = _(
2189
+ "You do not have permission to add products."
2190
+ )
2191
+ else:
2192
+ product_id = request.POST.get("product_id")
2193
+ if not product_id:
2194
+ context["form_error"] = _("Select a product to register.")
2195
+ else:
2196
+ try:
2197
+ odoo_id = int(product_id)
2198
+ except (TypeError, ValueError):
2199
+ context["form_error"] = _("Invalid product selection.")
2200
+ else:
2201
+ match = next(
2202
+ (item for item in products if item.get("id") == odoo_id),
2203
+ None,
2204
+ )
2205
+ if not match:
2206
+ context["form_error"] = _(
2207
+ "The selected product was not found. Reload the page and try again."
2208
+ )
2209
+ else:
2210
+ existing = self.model.objects.filter(
2211
+ odoo_product__id=odoo_id
2212
+ ).first()
2213
+ if existing:
2214
+ self.message_user(
2215
+ request,
2216
+ _(
2217
+ "Product %(name)s already imported; opening existing record."
2218
+ )
2219
+ % {"name": existing.name},
2220
+ level=messages.WARNING,
2221
+ )
2222
+ return HttpResponseRedirect(
2223
+ reverse(
2224
+ "admin:%s_%s_change"
2225
+ % (
2226
+ existing._meta.app_label,
2227
+ existing._meta.model_name,
2228
+ ),
2229
+ args=[existing.pk],
2230
+ )
2231
+ )
2232
+ product = self.model.objects.create(
2233
+ name=match.get("name") or f"Odoo Product {odoo_id}",
2234
+ description=match.get("description_sale", "") or "",
2235
+ renewal_period=30,
2236
+ odoo_product={
2237
+ "id": odoo_id,
2238
+ "name": match.get("name", ""),
2239
+ },
2240
+ )
2241
+ self.log_addition(
2242
+ request, product, "Registered product from Odoo"
2243
+ )
2244
+ self.message_user(
2245
+ request,
2246
+ _("Imported %(name)s from Odoo.")
2247
+ % {"name": product.name},
2248
+ )
2249
+ return HttpResponseRedirect(
2250
+ reverse(
2251
+ "admin:%s_%s_change"
2252
+ % (
2253
+ product._meta.app_label,
2254
+ product._meta.model_name,
2255
+ ),
2256
+ args=[product.pk],
2257
+ )
2258
+ )
2259
+
2260
+ return TemplateResponse(
2261
+ request, "admin/core/product/register_from_odoo.html", context
2262
+ )
2263
+
2075
2264
 
2076
2265
  class RFIDResource(resources.ModelResource):
2077
2266
  reference = fields.Field(
@@ -2124,15 +2313,13 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
2124
2313
  change_list_template = "admin/core/rfid/change_list.html"
2125
2314
  resource_class = RFIDResource
2126
2315
  list_display = (
2127
- "label_id",
2316
+ "label",
2128
2317
  "rfid",
2129
2318
  "custom_label",
2130
2319
  "color",
2131
2320
  "kind",
2132
2321
  "released",
2133
- "energy_accounts_display",
2134
2322
  "allowed",
2135
- "added_on",
2136
2323
  "last_seen_on",
2137
2324
  )
2138
2325
  list_filter = ("color", "released", "allowed")
@@ -2143,10 +2330,11 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
2143
2330
  readonly_fields = ("added_on", "last_seen_on")
2144
2331
  form = RFIDForm
2145
2332
 
2146
- def energy_accounts_display(self, obj):
2147
- return ", ".join(str(a) for a in obj.energy_accounts.all())
2333
+ def label(self, obj):
2334
+ return obj.label_id
2148
2335
 
2149
- energy_accounts_display.short_description = "Energy Accounts"
2336
+ label.admin_order_field = "label_id"
2337
+ label.short_description = "Label"
2150
2338
 
2151
2339
  def scan_rfids(self, request, queryset):
2152
2340
  return redirect("admin:core_rfid_scan")
@@ -2181,16 +2369,43 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
2181
2369
 
2182
2370
  def scan_view(self, request):
2183
2371
  context = self.admin_site.each_context(request)
2184
- context["scan_url"] = reverse("admin:core_rfid_scan_next")
2185
- context["admin_change_url_template"] = reverse(
2186
- "admin:core_rfid_change", args=[0]
2372
+ table_mode, toggle_url, toggle_label = build_mode_toggle(request)
2373
+ public_view_url = reverse("rfid-reader")
2374
+ if table_mode:
2375
+ public_view_url = f"{public_view_url}?mode=table"
2376
+ context.update(
2377
+ {
2378
+ "scan_url": reverse("admin:core_rfid_scan_next"),
2379
+ "admin_change_url_template": reverse(
2380
+ "admin:core_rfid_change", args=[0]
2381
+ ),
2382
+ "title": _("Scan RFIDs"),
2383
+ "opts": self.model._meta,
2384
+ "table_mode": table_mode,
2385
+ "toggle_url": toggle_url,
2386
+ "toggle_label": toggle_label,
2387
+ "public_view_url": public_view_url,
2388
+ }
2187
2389
  )
2390
+ context["title"] = _("Scan RFIDs")
2391
+ context["opts"] = self.model._meta
2392
+ context["show_release_info"] = True
2188
2393
  return render(request, "admin/core/rfid/scan.html", context)
2189
2394
 
2190
2395
  def scan_next(self, request):
2191
2396
  from ocpp.rfid.scanner import scan_sources
2397
+ from ocpp.rfid.reader import validate_rfid_value
2192
2398
 
2193
- result = scan_sources(request)
2399
+ if request.method == "POST":
2400
+ try:
2401
+ payload = json.loads(request.body.decode("utf-8") or "{}")
2402
+ except (json.JSONDecodeError, UnicodeDecodeError):
2403
+ return JsonResponse({"error": "Invalid JSON payload"}, status=400)
2404
+ rfid = payload.get("rfid") or payload.get("value")
2405
+ kind = payload.get("kind")
2406
+ result = validate_rfid_value(rfid, kind=kind)
2407
+ else:
2408
+ result = scan_sources(request)
2194
2409
  status = 500 if result.get("error") else 200
2195
2410
  return JsonResponse(result, status=status)
2196
2411
 
core/apps.py CHANGED
@@ -21,6 +21,7 @@ class CoreConfig(AppConfig):
21
21
  from pathlib import Path
22
22
 
23
23
  from django.conf import settings
24
+ from django.core.exceptions import ObjectDoesNotExist
24
25
  from django.contrib.auth import get_user_model
25
26
  from django.db.models.signals import post_migrate
26
27
  from django.core.signals import got_request_exception
@@ -39,6 +40,26 @@ class CoreConfig(AppConfig):
39
40
  )
40
41
  from .admin_history import patch_admin_history
41
42
 
43
+ from django_otp.plugins.otp_totp.models import TOTPDevice as OTP_TOTPDevice
44
+
45
+ if not hasattr(
46
+ OTP_TOTPDevice._read_str_from_settings, "_core_totp_issuer_patch"
47
+ ):
48
+ original_read_str = OTP_TOTPDevice._read_str_from_settings
49
+
50
+ def _core_totp_read_str(self, key):
51
+ if key == "OTP_TOTP_ISSUER":
52
+ try:
53
+ settings_obj = self.custom_settings
54
+ except ObjectDoesNotExist:
55
+ settings_obj = None
56
+ if settings_obj and settings_obj.issuer:
57
+ return settings_obj.issuer
58
+ return original_read_str(self, key)
59
+
60
+ _core_totp_read_str._core_totp_issuer_patch = True
61
+ OTP_TOTPDevice._read_str_from_settings = _core_totp_read_str
62
+
42
63
  def create_default_arthexis(**kwargs):
43
64
  User = get_user_model()
44
65
  if not User.all_objects.exists():
core/auto_upgrade.py CHANGED
@@ -39,8 +39,8 @@ def ensure_auto_upgrade_periodic_task(
39
39
  except Exception:
40
40
  return
41
41
 
42
- mode = mode_file.read_text().strip() or "version"
43
- interval_minutes = 5 if mode == "latest" else 10
42
+ _mode = mode_file.read_text().strip() or "version"
43
+ interval_minutes = 5
44
44
 
45
45
  try:
46
46
  schedule, _ = IntervalSchedule.objects.get_or_create(
core/form_fields.py ADDED
@@ -0,0 +1,75 @@
1
+ """Custom form fields for the Arthexis admin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from typing import Any
7
+
8
+ from django.core.exceptions import ValidationError
9
+ from django.forms.fields import FileField
10
+ from django.forms.widgets import FILE_INPUT_CONTRADICTION
11
+ from django.utils.translation import gettext_lazy as _
12
+
13
+ from .widgets import AdminBase64FileWidget
14
+
15
+
16
+ class Base64FileField(FileField):
17
+ """Form field storing uploaded files as base64 encoded strings.
18
+
19
+ The field behaves like :class:`~django.forms.FileField` from the user's
20
+ perspective. Uploaded files are converted to base64 and returned as text so
21
+ they can be stored in ``TextField`` columns. When no new file is uploaded the
22
+ initial base64 value is preserved, while clearing the field stores an empty
23
+ string.
24
+ """
25
+
26
+ widget = AdminBase64FileWidget
27
+ default_error_messages = {
28
+ **FileField.default_error_messages,
29
+ "contradiction": _(
30
+ "Please either submit a file or check the clear checkbox, not both."
31
+ ),
32
+ }
33
+
34
+ def __init__(
35
+ self,
36
+ *,
37
+ download_name: str | None = None,
38
+ content_type: str = "application/octet-stream",
39
+ **kwargs: Any,
40
+ ) -> None:
41
+ widget = kwargs.pop("widget", None) or self.widget()
42
+ if download_name:
43
+ widget.download_name = download_name
44
+ if content_type:
45
+ widget.content_type = content_type
46
+ super().__init__(widget=widget, **kwargs)
47
+
48
+ def to_python(self, data: Any) -> str | None:
49
+ """Convert uploaded data to a base64 string."""
50
+
51
+ if isinstance(data, str):
52
+ return data
53
+ uploaded = super().to_python(data)
54
+ if uploaded is None:
55
+ return None
56
+ content = uploaded.read()
57
+ if hasattr(uploaded, "seek"):
58
+ uploaded.seek(0)
59
+ return base64.b64encode(content).decode("ascii")
60
+
61
+ def clean(self, data: Any, initial: str | None = None) -> str:
62
+ if data is FILE_INPUT_CONTRADICTION:
63
+ raise ValidationError(
64
+ self.error_messages["contradiction"], code="contradiction"
65
+ )
66
+ cleaned = super().clean(data, initial)
67
+ if cleaned in {None, False}:
68
+ return ""
69
+ return cleaned
70
+
71
+ def bound_data(self, data: Any, initial: str | None) -> str | None:
72
+ return initial
73
+
74
+ def has_changed(self, initial: str | None, data: Any) -> bool:
75
+ return not self.disabled and data is not None