arthexis 0.1.10__py3-none-any.whl → 0.1.12__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 (54) hide show
  1. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/METADATA +36 -26
  2. arthexis-0.1.12.dist-info/RECORD +102 -0
  3. config/context_processors.py +1 -0
  4. config/settings.py +31 -5
  5. config/urls.py +5 -4
  6. core/admin.py +430 -90
  7. core/apps.py +48 -2
  8. core/backends.py +38 -0
  9. core/environment.py +23 -5
  10. core/mailer.py +3 -1
  11. core/models.py +303 -31
  12. core/reference_utils.py +20 -9
  13. core/release.py +4 -0
  14. core/sigil_builder.py +7 -2
  15. core/sigil_resolver.py +35 -4
  16. core/system.py +250 -1
  17. core/tasks.py +92 -40
  18. core/temp_passwords.py +181 -0
  19. core/test_system_info.py +62 -2
  20. core/tests.py +169 -3
  21. core/user_data.py +51 -8
  22. core/views.py +371 -20
  23. nodes/admin.py +453 -8
  24. nodes/backends.py +21 -6
  25. nodes/dns.py +203 -0
  26. nodes/feature_checks.py +133 -0
  27. nodes/models.py +374 -31
  28. nodes/reports.py +411 -0
  29. nodes/tests.py +677 -38
  30. nodes/utils.py +32 -0
  31. nodes/views.py +14 -0
  32. ocpp/admin.py +278 -15
  33. ocpp/consumers.py +517 -16
  34. ocpp/evcs_discovery.py +158 -0
  35. ocpp/models.py +237 -4
  36. ocpp/reference_utils.py +42 -0
  37. ocpp/simulator.py +321 -22
  38. ocpp/store.py +110 -2
  39. ocpp/test_rfid.py +169 -7
  40. ocpp/tests.py +819 -6
  41. ocpp/transactions_io.py +17 -3
  42. ocpp/views.py +233 -19
  43. pages/admin.py +144 -4
  44. pages/context_processors.py +21 -7
  45. pages/defaults.py +13 -0
  46. pages/forms.py +38 -0
  47. pages/models.py +189 -15
  48. pages/tests.py +281 -8
  49. pages/urls.py +4 -0
  50. pages/views.py +137 -21
  51. arthexis-0.1.10.dist-info/RECORD +0 -95
  52. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
  53. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
  54. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/top_level.txt +0 -0
core/admin.py CHANGED
@@ -21,6 +21,8 @@ from import_export.admin import ImportExportModelAdmin
21
21
  from import_export.widgets import ForeignKeyWidget
22
22
  from django.contrib.auth.models import Group
23
23
  from django.templatetags.static import static
24
+ from django.utils import timezone
25
+ from django.utils.dateparse import parse_datetime
24
26
  from django.utils.html import format_html
25
27
  from django.utils.translation import gettext_lazy as _
26
28
  from django.forms.models import BaseInlineFormSet
@@ -51,6 +53,7 @@ from .models import (
51
53
  Reference,
52
54
  OdooProfile,
53
55
  EmailInbox,
56
+ SocialProfile,
54
57
  EmailCollector,
55
58
  Package,
56
59
  PackageRelease,
@@ -73,6 +76,7 @@ from .user_data import (
73
76
  )
74
77
  from .widgets import OdooProductWidget
75
78
  from .mcp import process as mcp_process
79
+ from .mcp.server import resolve_base_urls
76
80
 
77
81
 
78
82
  admin.site.unregister(Group)
@@ -260,6 +264,7 @@ class ReferenceAdmin(EntityModelAdmin):
260
264
  list_display = (
261
265
  "alt_text",
262
266
  "content_type",
267
+ "link",
263
268
  "header",
264
269
  "footer",
265
270
  "visibility",
@@ -304,6 +309,15 @@ class ReferenceAdmin(EntityModelAdmin):
304
309
  def visibility(self, obj):
305
310
  return obj.get_footer_visibility_display()
306
311
 
312
+ @admin.display(description="LINK")
313
+ def link(self, obj):
314
+ if obj.value:
315
+ return format_html(
316
+ '<a href="{}" target="_blank" rel="noopener noreferrer">open</a>',
317
+ obj.value,
318
+ )
319
+ return ""
320
+
307
321
  def get_urls(self):
308
322
  urls = super().get_urls()
309
323
  custom = [
@@ -358,6 +372,29 @@ class ReleaseManagerAdminForm(forms.ModelForm):
358
372
  "github_token": forms.Textarea(attrs={"rows": 3, "style": "width: 40em;"}),
359
373
  }
360
374
 
375
+ def __init__(self, *args, **kwargs):
376
+ super().__init__(*args, **kwargs)
377
+ self.fields["pypi_token"].help_text = format_html(
378
+ "{} <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\">{}</a>{}",
379
+ "Generate an API token from your PyPI account settings.",
380
+ "https://pypi.org/manage/account/token/",
381
+ "pypi.org/manage/account/token/",
382
+ (
383
+ " by clicking “Add API token”, optionally scoping it to the package, "
384
+ "and paste the full `pypi-***` value here."
385
+ ),
386
+ )
387
+ self.fields["github_token"].help_text = format_html(
388
+ "{} <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\">{}</a>{}",
389
+ "Create a personal access token at GitHub → Settings → Developer settings →",
390
+ "https://github.com/settings/tokens",
391
+ "github.com/settings/tokens",
392
+ (
393
+ " with the repository access needed for releases (repo scope for classic tokens "
394
+ "or an equivalent fine-grained token) and paste it here."
395
+ ),
396
+ )
397
+
361
398
 
362
399
  @admin.register(ReleaseManager)
363
400
  class ReleaseManagerAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin):
@@ -532,7 +569,14 @@ class SecurityGroupAdmin(DjangoGroupAdmin):
532
569
 
533
570
 
534
571
  class InviteLeadAdmin(EntityModelAdmin):
535
- list_display = ("email", "mac_address", "created_on", "sent_on", "short_error")
572
+ list_display = (
573
+ "email",
574
+ "mac_address",
575
+ "created_on",
576
+ "sent_on",
577
+ "sent_via_outbox",
578
+ "short_error",
579
+ )
536
580
  search_fields = ("email", "comment")
537
581
  readonly_fields = (
538
582
  "created_on",
@@ -543,6 +587,7 @@ class InviteLeadAdmin(EntityModelAdmin):
543
587
  "ip_address",
544
588
  "mac_address",
545
589
  "sent_on",
590
+ "sent_via_outbox",
546
591
  "error",
547
592
  )
548
593
 
@@ -669,28 +714,30 @@ class OdooProfileAdminForm(forms.ModelForm):
669
714
  )
670
715
 
671
716
 
672
- class EmailInboxAdminForm(forms.ModelForm):
673
- """Admin form for :class:`core.models.EmailInbox` with hidden password."""
674
-
675
- password = forms.CharField(
676
- widget=forms.PasswordInput(render_value=True),
677
- required=False,
678
- help_text="Leave blank to keep the current password.",
679
- )
717
+ class MaskedPasswordFormMixin:
718
+ """Mixin that hides stored passwords while allowing updates."""
680
719
 
681
- class Meta:
682
- model = EmailInbox
683
- fields = "__all__"
720
+ password_sigil_fields: tuple[str, ...] = ()
684
721
 
685
722
  def __init__(self, *args, **kwargs):
686
723
  super().__init__(*args, **kwargs)
724
+ field = self.fields.get("password")
725
+ if field is None:
726
+ return
727
+ if not isinstance(field.widget, forms.PasswordInput):
728
+ field.widget = forms.PasswordInput()
729
+ field.widget.attrs.setdefault("autocomplete", "new-password")
730
+ field.help_text = field.help_text or "Leave blank to keep the current password."
687
731
  if self.instance.pk:
688
- self.fields["password"].initial = ""
732
+ field.initial = ""
689
733
  self.initial["password"] = ""
690
734
  else:
691
- self.fields["password"].required = True
735
+ field.required = True
692
736
 
693
737
  def clean_password(self):
738
+ field = self.fields.get("password")
739
+ if field is None:
740
+ return self.cleaned_data.get("password")
694
741
  pwd = self.cleaned_data.get("password")
695
742
  if not pwd and self.instance.pk:
696
743
  return keep_existing("password")
@@ -698,10 +745,23 @@ class EmailInboxAdminForm(forms.ModelForm):
698
745
 
699
746
  def _post_clean(self):
700
747
  super()._post_clean()
701
- _restore_sigil_values(
702
- self,
703
- ["username", "host", "password", "protocol"],
704
- )
748
+ if self.password_sigil_fields:
749
+ _restore_sigil_values(self, self.password_sigil_fields)
750
+
751
+
752
+ class EmailInboxAdminForm(MaskedPasswordFormMixin, forms.ModelForm):
753
+ """Admin form for :class:`core.models.EmailInbox` with hidden password."""
754
+
755
+ password = forms.CharField(
756
+ widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
757
+ required=False,
758
+ help_text="Leave blank to keep the current password.",
759
+ )
760
+ password_sigil_fields = ("username", "host", "password", "protocol")
761
+
762
+ class Meta:
763
+ model = EmailInbox
764
+ fields = "__all__"
705
765
 
706
766
 
707
767
  class ProfileInlineFormSet(BaseInlineFormSet):
@@ -851,16 +911,33 @@ class EmailInboxInlineForm(ProfileFormMixin, EmailInboxAdminForm):
851
911
  exclude = ("user", "group")
852
912
 
853
913
 
854
- class EmailOutboxInlineForm(ProfileFormMixin, forms.ModelForm):
855
- profile_fields = EmailOutbox.profile_fields
914
+ class SocialProfileInlineForm(ProfileFormMixin, forms.ModelForm):
915
+ profile_fields = SocialProfile.profile_fields
916
+
917
+ class Meta:
918
+ model = SocialProfile
919
+ fields = ("network", "handle", "domain", "did")
920
+
921
+
922
+ class EmailOutboxAdminForm(MaskedPasswordFormMixin, forms.ModelForm):
923
+ """Admin form for :class:`nodes.models.EmailOutbox` with hidden password."""
924
+
856
925
  password = forms.CharField(
857
- widget=forms.PasswordInput(render_value=True),
926
+ widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
858
927
  required=False,
859
928
  help_text="Leave blank to keep the current password.",
860
929
  )
930
+ password_sigil_fields = ("password", "host", "username", "from_email")
861
931
 
862
932
  class Meta:
863
933
  model = EmailOutbox
934
+ fields = "__all__"
935
+
936
+
937
+ class EmailOutboxInlineForm(ProfileFormMixin, EmailOutboxAdminForm):
938
+ profile_fields = EmailOutbox.profile_fields
939
+
940
+ class Meta(EmailOutboxAdminForm.Meta):
864
941
  fields = (
865
942
  "password",
866
943
  "host",
@@ -869,27 +946,7 @@ class EmailOutboxInlineForm(ProfileFormMixin, forms.ModelForm):
869
946
  "use_tls",
870
947
  "use_ssl",
871
948
  "from_email",
872
- )
873
-
874
- def __init__(self, *args, **kwargs):
875
- super().__init__(*args, **kwargs)
876
- if self.instance.pk:
877
- self.fields["password"].initial = ""
878
- self.initial["password"] = ""
879
- else:
880
- self.fields["password"].required = True
881
-
882
- def clean_password(self):
883
- pwd = self.cleaned_data.get("password")
884
- if not pwd and self.instance.pk:
885
- return keep_existing("password")
886
- return pwd
887
-
888
- def _post_clean(self):
889
- super()._post_clean()
890
- _restore_sigil_values(
891
- self,
892
- ["password", "host", "username", "from_email"],
949
+ "is_enabled",
893
950
  )
894
951
 
895
952
 
@@ -998,6 +1055,22 @@ PROFILE_INLINE_CONFIG = {
998
1055
  "from_email",
999
1056
  ),
1000
1057
  },
1058
+ SocialProfile: {
1059
+ "form": SocialProfileInlineForm,
1060
+ "fieldsets": (
1061
+ (
1062
+ _("Configuration: Bluesky"),
1063
+ {
1064
+ "fields": ("network", "handle", "domain", "did"),
1065
+ "description": _(
1066
+ "1. Set your Bluesky handle to the domain managed by Arthexis. "
1067
+ "2. Publish a _atproto TXT record or /.well-known/atproto-did file pointing to the DID below. "
1068
+ "3. Save once Bluesky confirms the domain matches the DID."
1069
+ ),
1070
+ },
1071
+ ),
1072
+ ),
1073
+ },
1001
1074
  ReleaseManager: {
1002
1075
  "form": ReleaseManagerInlineForm,
1003
1076
  "fields": (
@@ -1056,6 +1129,7 @@ PROFILE_MODELS = (
1056
1129
  OdooProfile,
1057
1130
  EmailInbox,
1058
1131
  EmailOutbox,
1132
+ SocialProfile,
1059
1133
  ReleaseManager,
1060
1134
  AssistantProfile,
1061
1135
  )
@@ -1202,8 +1276,68 @@ class EmailCollectorInline(admin.TabularInline):
1202
1276
 
1203
1277
 
1204
1278
  class EmailCollectorAdmin(EntityModelAdmin):
1205
- list_display = ("inbox", "subject", "sender", "body", "fragment")
1206
- search_fields = ("subject", "sender", "body", "fragment")
1279
+ list_display = ("name", "inbox", "subject", "sender", "body", "fragment")
1280
+ search_fields = ("name", "subject", "sender", "body", "fragment")
1281
+ actions = ["preview_messages"]
1282
+
1283
+ @admin.action(description=_("Preview matches"))
1284
+ def preview_messages(self, request, queryset):
1285
+ results = []
1286
+ for collector in queryset.select_related("inbox"):
1287
+ try:
1288
+ messages = collector.search_messages(limit=5)
1289
+ error = None
1290
+ except ValidationError as exc:
1291
+ messages = []
1292
+ error = str(exc)
1293
+ except Exception as exc: # pragma: no cover - admin feedback
1294
+ messages = []
1295
+ error = str(exc)
1296
+ results.append(
1297
+ {
1298
+ "collector": collector,
1299
+ "messages": messages,
1300
+ "error": error,
1301
+ }
1302
+ )
1303
+ context = {
1304
+ "title": _("Preview Email Collectors"),
1305
+ "results": results,
1306
+ "opts": self.model._meta,
1307
+ "queryset": queryset,
1308
+ }
1309
+ return TemplateResponse(
1310
+ request, "admin/core/emailcollector/preview.html", context
1311
+ )
1312
+
1313
+
1314
+ @admin.register(SocialProfile)
1315
+ class SocialProfileAdmin(
1316
+ ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin
1317
+ ):
1318
+ list_display = ("owner", "network", "handle", "domain")
1319
+ list_filter = ("network",)
1320
+ search_fields = ("handle", "domain", "did")
1321
+ changelist_actions = ["my_profile"]
1322
+ change_actions = ["my_profile_action"]
1323
+ fieldsets = (
1324
+ (_("Owner"), {"fields": ("user", "group")}),
1325
+ (
1326
+ _("Configuration: Bluesky"),
1327
+ {
1328
+ "fields": ("network", "handle", "domain", "did"),
1329
+ "description": _(
1330
+ "Link Arthexis to Bluesky by using a verified domain handle. "
1331
+ "Publish a _atproto TXT record or /.well-known/atproto-did file "
1332
+ "that returns the DID stored here before saving."
1333
+ ),
1334
+ },
1335
+ ),
1336
+ )
1337
+
1338
+ @admin.display(description=_("Owner"))
1339
+ def owner(self, obj):
1340
+ return obj.owner_display()
1207
1341
 
1208
1342
 
1209
1343
  @admin.register(OdooProfile)
@@ -1217,10 +1351,8 @@ class OdooProfileAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdm
1217
1351
  changelist_actions = ["my_profile"]
1218
1352
  fieldsets = (
1219
1353
  ("Owner", {"fields": ("user", "group")}),
1220
- (
1221
- "Configuration",
1222
- {"fields": ("host", "database", "username", "password")},
1223
- ),
1354
+ ("Configuration", {"fields": ("host", "database")}),
1355
+ ("Credentials", {"fields": ("username", "password")}),
1224
1356
  (
1225
1357
  "Odoo Employee",
1226
1358
  {"fields": ("verified_on", "odoo_uid", "name", "email")},
@@ -1310,18 +1442,10 @@ class EmailInboxAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmi
1310
1442
 
1311
1443
  fieldsets = (
1312
1444
  ("Owner", {"fields": ("user", "group")}),
1445
+ ("Credentials", {"fields": ("username", "password")}),
1313
1446
  (
1314
- None,
1315
- {
1316
- "fields": (
1317
- "username",
1318
- "host",
1319
- "port",
1320
- "password",
1321
- "protocol",
1322
- "use_ssl",
1323
- )
1324
- },
1447
+ "Configuration",
1448
+ {"fields": ("host", "port", "protocol", "use_ssl")},
1325
1449
  ),
1326
1450
  )
1327
1451
 
@@ -1383,6 +1507,7 @@ class EmailInboxAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmi
1383
1507
  subject=form.cleaned_data["subject"],
1384
1508
  from_address=form.cleaned_data["from_address"],
1385
1509
  body=form.cleaned_data["body"],
1510
+ use_regular_expressions=False,
1386
1511
  )
1387
1512
  context = {
1388
1513
  "form": form,
@@ -1418,17 +1543,10 @@ class AssistantProfileAdmin(
1418
1543
  changelist_actions = ["my_profile"]
1419
1544
  fieldsets = (
1420
1545
  ("Owner", {"fields": ("user", "group")}),
1546
+ ("Credentials", {"fields": ("user_key_hash",)}),
1421
1547
  (
1422
- None,
1423
- {
1424
- "fields": (
1425
- "scopes",
1426
- "is_active",
1427
- "user_key_hash",
1428
- "created_at",
1429
- "last_used_at",
1430
- )
1431
- },
1548
+ "Configuration",
1549
+ {"fields": ("scopes", "is_active", "created_at", "last_used_at")},
1432
1550
  ),
1433
1551
  )
1434
1552
 
@@ -1519,14 +1637,19 @@ class AssistantProfileAdmin(
1519
1637
  config = dict(getattr(settings, "MCP_SIGIL_SERVER", {}))
1520
1638
  host = config.get("host") or "127.0.0.1"
1521
1639
  port = config.get("port", 8800)
1640
+ base_url, issuer_url = resolve_base_urls(config)
1522
1641
  if isinstance(response, dict):
1523
1642
  response.setdefault("mcp_server_host", host)
1524
1643
  response.setdefault("mcp_server_port", port)
1644
+ response.setdefault("mcp_server_base_url", base_url)
1645
+ response.setdefault("mcp_server_issuer_url", issuer_url)
1525
1646
  else:
1526
1647
  context_data = getattr(response, "context_data", None)
1527
1648
  if context_data is not None:
1528
1649
  context_data.setdefault("mcp_server_host", host)
1529
1650
  context_data.setdefault("mcp_server_port", port)
1651
+ context_data.setdefault("mcp_server_base_url", base_url)
1652
+ context_data.setdefault("mcp_server_issuer_url", issuer_url)
1530
1653
  return response
1531
1654
 
1532
1655
  def start_server(self, request):
@@ -1814,7 +1937,7 @@ class ProductFetchWizardForm(forms.Form):
1814
1937
  @admin.register(Product)
1815
1938
  class ProductAdmin(EntityModelAdmin):
1816
1939
  form = ProductAdminForm
1817
- actions = ["fetch_odoo_product"]
1940
+ actions = ["fetch_odoo_product", "register_from_odoo"]
1818
1941
 
1819
1942
  def _odoo_profile_admin(self):
1820
1943
  return self.admin_site._registry.get(OdooProfile)
@@ -1824,7 +1947,7 @@ class ProductAdmin(EntityModelAdmin):
1824
1947
  return profile.execute(
1825
1948
  "product.product",
1826
1949
  "search_read",
1827
- domain,
1950
+ [domain],
1828
1951
  {
1829
1952
  "fields": [
1830
1953
  "name",
@@ -1964,6 +2087,169 @@ class ProductAdmin(EntityModelAdmin):
1964
2087
  context["media"] = self.media + form.media
1965
2088
  return TemplateResponse(request, "admin/core/product/fetch_odoo.html", context)
1966
2089
 
2090
+ def get_urls(self):
2091
+ urls = super().get_urls()
2092
+ custom = [
2093
+ path(
2094
+ "register-from-odoo/",
2095
+ self.admin_site.admin_view(self.register_from_odoo_view),
2096
+ name=f"{self.opts.app_label}_{self.opts.model_name}_register_from_odoo",
2097
+ )
2098
+ ]
2099
+ return custom + urls
2100
+
2101
+ @admin.action(description="Register from Odoo")
2102
+ def register_from_odoo(self, request, queryset=None): # pragma: no cover - simple redirect
2103
+ return HttpResponseRedirect(
2104
+ reverse(
2105
+ f"admin:{self.opts.app_label}_{self.opts.model_name}_register_from_odoo"
2106
+ )
2107
+ )
2108
+
2109
+ def _build_register_context(self, request):
2110
+ opts = self.model._meta
2111
+ context = self.admin_site.each_context(request)
2112
+ context.update(
2113
+ {
2114
+ "opts": opts,
2115
+ "title": _("Register from Odoo"),
2116
+ "has_credentials": False,
2117
+ "profile_url": None,
2118
+ "products": [],
2119
+ "selected_product_id": request.POST.get("product_id", ""),
2120
+ }
2121
+ )
2122
+
2123
+ profile_admin = self._odoo_profile_admin()
2124
+ if profile_admin is not None:
2125
+ context["profile_url"] = profile_admin.get_my_profile_url(request)
2126
+
2127
+ profile = getattr(request.user, "odoo_profile", None)
2128
+ if not profile or not profile.is_verified:
2129
+ context["credential_error"] = _(
2130
+ "Configure your Odoo employee credentials before registering products."
2131
+ )
2132
+ return context, None
2133
+
2134
+ try:
2135
+ products = profile.execute(
2136
+ "product.product",
2137
+ "search_read",
2138
+ [[]],
2139
+ {
2140
+ "fields": [
2141
+ "name",
2142
+ "description_sale",
2143
+ "list_price",
2144
+ "standard_price",
2145
+ ],
2146
+ "limit": 0,
2147
+ },
2148
+ )
2149
+ except Exception:
2150
+ context["error"] = _("Unable to fetch products from Odoo.")
2151
+ return context, []
2152
+
2153
+ context["has_credentials"] = True
2154
+ simplified = []
2155
+ for product in products:
2156
+ simplified.append(
2157
+ {
2158
+ "id": product.get("id"),
2159
+ "name": product.get("name", ""),
2160
+ "description_sale": product.get("description_sale", ""),
2161
+ "list_price": product.get("list_price"),
2162
+ "standard_price": product.get("standard_price"),
2163
+ }
2164
+ )
2165
+ context["products"] = simplified
2166
+ return context, simplified
2167
+
2168
+ def register_from_odoo_view(self, request):
2169
+ context, products = self._build_register_context(request)
2170
+ if products is None:
2171
+ return TemplateResponse(
2172
+ request, "admin/core/product/register_from_odoo.html", context
2173
+ )
2174
+
2175
+ if request.method == "POST" and context.get("has_credentials"):
2176
+ if not self.has_add_permission(request):
2177
+ context["form_error"] = _(
2178
+ "You do not have permission to add products."
2179
+ )
2180
+ else:
2181
+ product_id = request.POST.get("product_id")
2182
+ if not product_id:
2183
+ context["form_error"] = _("Select a product to register.")
2184
+ else:
2185
+ try:
2186
+ odoo_id = int(product_id)
2187
+ except (TypeError, ValueError):
2188
+ context["form_error"] = _("Invalid product selection.")
2189
+ else:
2190
+ match = next(
2191
+ (item for item in products if item.get("id") == odoo_id),
2192
+ None,
2193
+ )
2194
+ if not match:
2195
+ context["form_error"] = _(
2196
+ "The selected product was not found. Reload the page and try again."
2197
+ )
2198
+ else:
2199
+ existing = self.model.objects.filter(
2200
+ odoo_product__id=odoo_id
2201
+ ).first()
2202
+ if existing:
2203
+ self.message_user(
2204
+ request,
2205
+ _(
2206
+ "Product %(name)s already imported; opening existing record."
2207
+ )
2208
+ % {"name": existing.name},
2209
+ level=messages.WARNING,
2210
+ )
2211
+ return HttpResponseRedirect(
2212
+ reverse(
2213
+ "admin:%s_%s_change"
2214
+ % (
2215
+ existing._meta.app_label,
2216
+ existing._meta.model_name,
2217
+ ),
2218
+ args=[existing.pk],
2219
+ )
2220
+ )
2221
+ product = self.model.objects.create(
2222
+ name=match.get("name") or f"Odoo Product {odoo_id}",
2223
+ description=match.get("description_sale", "") or "",
2224
+ renewal_period=30,
2225
+ odoo_product={
2226
+ "id": odoo_id,
2227
+ "name": match.get("name", ""),
2228
+ },
2229
+ )
2230
+ self.log_addition(
2231
+ request, product, "Registered product from Odoo"
2232
+ )
2233
+ self.message_user(
2234
+ request,
2235
+ _("Imported %(name)s from Odoo.")
2236
+ % {"name": product.name},
2237
+ )
2238
+ return HttpResponseRedirect(
2239
+ reverse(
2240
+ "admin:%s_%s_change"
2241
+ % (
2242
+ product._meta.app_label,
2243
+ product._meta.model_name,
2244
+ ),
2245
+ args=[product.pk],
2246
+ )
2247
+ )
2248
+
2249
+ return TemplateResponse(
2250
+ request, "admin/core/product/register_from_odoo.html", context
2251
+ )
2252
+
1967
2253
 
1968
2254
  class RFIDResource(resources.ModelResource):
1969
2255
  reference = fields.Field(
@@ -2265,6 +2551,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
2265
2551
  "package_link",
2266
2552
  "is_current",
2267
2553
  "pypi_url",
2554
+ "release_on",
2268
2555
  "revision_short",
2269
2556
  "published_status",
2270
2557
  )
@@ -2272,7 +2559,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
2272
2559
  actions = ["publish_release", "validate_releases"]
2273
2560
  change_actions = ["publish_release_action"]
2274
2561
  changelist_actions = ["refresh_from_pypi", "prepare_next_release"]
2275
- readonly_fields = ("pypi_url", "is_current", "revision")
2562
+ readonly_fields = ("pypi_url", "release_on", "is_current", "revision")
2276
2563
  fields = (
2277
2564
  "package",
2278
2565
  "release_manager",
@@ -2280,6 +2567,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
2280
2567
  "revision",
2281
2568
  "is_current",
2282
2569
  "pypi_url",
2570
+ "release_on",
2283
2571
  )
2284
2572
 
2285
2573
  @admin.display(description="package", ordering="package")
@@ -2307,32 +2595,84 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
2307
2595
  return
2308
2596
  releases = resp.json().get("releases", {})
2309
2597
  created = 0
2310
- for version in releases:
2311
- exists = PackageRelease.all_objects.filter(
2598
+ updated = 0
2599
+ restored = 0
2600
+
2601
+ for version, files in releases.items():
2602
+ release_on = self._release_on_from_files(files)
2603
+ release = PackageRelease.all_objects.filter(
2312
2604
  package=package, version=version
2313
- ).exists()
2314
- if not exists:
2315
- PackageRelease.objects.create(
2316
- package=package,
2317
- release_manager=package.release_manager,
2318
- version=version,
2319
- revision="",
2320
- pypi_url=f"https://pypi.org/project/{package.name}/{version}/",
2321
- )
2322
- created += 1
2323
- if created:
2324
- PackageRelease.dump_fixture()
2325
- self.message_user(
2326
- request,
2327
- f"Created {created} release{'s' if created != 1 else ''} from PyPI",
2328
- messages.SUCCESS,
2605
+ ).first()
2606
+ if release:
2607
+ update_fields = []
2608
+ if release.is_deleted:
2609
+ release.is_deleted = False
2610
+ update_fields.append("is_deleted")
2611
+ restored += 1
2612
+ if not release.pypi_url:
2613
+ release.pypi_url = (
2614
+ f"https://pypi.org/project/{package.name}/{version}/"
2615
+ )
2616
+ update_fields.append("pypi_url")
2617
+ if release_on and release.release_on != release_on:
2618
+ release.release_on = release_on
2619
+ update_fields.append("release_on")
2620
+ updated += 1
2621
+ if update_fields:
2622
+ release.save(update_fields=update_fields)
2623
+ continue
2624
+ PackageRelease.objects.create(
2625
+ package=package,
2626
+ release_manager=package.release_manager,
2627
+ version=version,
2628
+ revision="",
2629
+ pypi_url=f"https://pypi.org/project/{package.name}/{version}/",
2630
+ release_on=release_on,
2329
2631
  )
2632
+ created += 1
2633
+
2634
+ if created or updated or restored:
2635
+ PackageRelease.dump_fixture()
2636
+ message_parts = []
2637
+ if created:
2638
+ message_parts.append(
2639
+ f"Created {created} release{'s' if created != 1 else ''} from PyPI"
2640
+ )
2641
+ if updated:
2642
+ message_parts.append(
2643
+ f"Updated release date for {updated} release"
2644
+ f"{'s' if updated != 1 else ''}"
2645
+ )
2646
+ if restored:
2647
+ message_parts.append(
2648
+ f"Restored {restored} release{'s' if restored != 1 else ''}"
2649
+ )
2650
+ self.message_user(request, "; ".join(message_parts), messages.SUCCESS)
2330
2651
  else:
2331
2652
  self.message_user(request, "No new releases found", messages.INFO)
2332
2653
 
2333
2654
  refresh_from_pypi.label = "Refresh from PyPI"
2334
2655
  refresh_from_pypi.short_description = "Refresh from PyPI"
2335
2656
 
2657
+ @staticmethod
2658
+ def _release_on_from_files(files):
2659
+ if not files:
2660
+ return None
2661
+ candidates = []
2662
+ for item in files:
2663
+ stamp = item.get("upload_time_iso_8601") or item.get("upload_time")
2664
+ if not stamp:
2665
+ continue
2666
+ when = parse_datetime(stamp)
2667
+ if when is None:
2668
+ continue
2669
+ if timezone.is_naive(when):
2670
+ when = timezone.make_aware(when, datetime.timezone.utc)
2671
+ candidates.append(when.astimezone(datetime.timezone.utc))
2672
+ if not candidates:
2673
+ return None
2674
+ return min(candidates)
2675
+
2336
2676
  def prepare_next_release(self, request, queryset):
2337
2677
  package = Package.objects.filter(is_active=True).first()
2338
2678
  if not package: