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.
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/METADATA +2 -2
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/RECORD +50 -44
- config/asgi.py +15 -1
- config/celery.py +8 -1
- config/settings.py +49 -78
- config/settings_helpers.py +109 -0
- core/admin.py +293 -78
- core/apps.py +21 -0
- core/auto_upgrade.py +2 -2
- core/form_fields.py +75 -0
- core/models.py +203 -47
- core/reference_utils.py +1 -1
- core/release.py +42 -20
- core/system.py +6 -3
- core/tasks.py +92 -40
- core/tests.py +75 -1
- core/views.py +178 -29
- core/widgets.py +43 -0
- nodes/admin.py +583 -10
- nodes/apps.py +15 -0
- nodes/feature_checks.py +133 -0
- nodes/models.py +287 -49
- nodes/reports.py +411 -0
- nodes/tests.py +990 -42
- nodes/urls.py +1 -0
- nodes/utils.py +32 -0
- nodes/views.py +173 -5
- ocpp/admin.py +424 -17
- ocpp/consumers.py +630 -15
- ocpp/evcs.py +7 -94
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +236 -4
- ocpp/routing.py +4 -2
- ocpp/simulator.py +346 -26
- ocpp/status_display.py +26 -0
- ocpp/store.py +110 -2
- ocpp/tests.py +1425 -33
- ocpp/transactions_io.py +27 -3
- ocpp/views.py +344 -38
- pages/admin.py +138 -3
- pages/context_processors.py +15 -1
- pages/defaults.py +1 -2
- pages/forms.py +67 -0
- pages/models.py +136 -1
- pages/tests.py +379 -4
- pages/urls.py +1 -0
- pages/views.py +64 -7
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/WHEEL +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
694
|
-
"""
|
|
721
|
+
class MaskedPasswordFormMixin:
|
|
722
|
+
"""Mixin that hides stored passwords while allowing updates."""
|
|
695
723
|
|
|
696
|
-
|
|
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
|
-
|
|
736
|
+
field.initial = ""
|
|
710
737
|
self.initial["password"] = ""
|
|
711
738
|
else:
|
|
712
|
-
|
|
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
|
-
|
|
723
|
-
self,
|
|
724
|
-
|
|
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
|
|
884
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
2147
|
-
return
|
|
2333
|
+
def label(self, obj):
|
|
2334
|
+
return obj.label_id
|
|
2148
2335
|
|
|
2149
|
-
|
|
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
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
interval_minutes = 5
|
|
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
|