arthexis 0.1.20__py3-none-any.whl → 0.1.22__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.

core/admin.py CHANGED
@@ -83,6 +83,7 @@ from .models import (
83
83
  OdooProfile,
84
84
  OpenPayProfile,
85
85
  EmailInbox,
86
+ GoogleCalendarProfile,
86
87
  SocialProfile,
87
88
  EmailCollector,
88
89
  Package,
@@ -91,9 +92,7 @@ from .models import (
91
92
  SecurityGroup,
92
93
  InviteLead,
93
94
  PublicWifiAccess,
94
- AssistantProfile,
95
95
  Todo,
96
- hash_key,
97
96
  )
98
97
  from .user_data import (
99
98
  EntityModelAdmin,
@@ -110,8 +109,6 @@ from .rfid_import_export import (
110
109
  parse_accounts,
111
110
  serialize_accounts,
112
111
  )
113
- from .mcp import process as mcp_process
114
- from .mcp.server import resolve_base_urls
115
112
  from . import release as release_utils
116
113
 
117
114
  logger = logging.getLogger(__name__)
@@ -1009,6 +1006,41 @@ class OpenPayProfileAdminForm(forms.ModelForm):
1009
1006
  )
1010
1007
 
1011
1008
 
1009
+ class GoogleCalendarProfileAdminForm(forms.ModelForm):
1010
+ """Admin form for :class:`core.models.GoogleCalendarProfile`."""
1011
+
1012
+ api_key = forms.CharField(
1013
+ widget=forms.PasswordInput(render_value=True),
1014
+ required=False,
1015
+ help_text="Leave blank to keep the current key.",
1016
+ )
1017
+
1018
+ class Meta:
1019
+ model = GoogleCalendarProfile
1020
+ fields = "__all__"
1021
+
1022
+ def __init__(self, *args, **kwargs):
1023
+ super().__init__(*args, **kwargs)
1024
+ if self.instance.pk:
1025
+ self.fields["api_key"].initial = ""
1026
+ self.initial["api_key"] = ""
1027
+ else:
1028
+ self.fields["api_key"].required = True
1029
+
1030
+ def clean_api_key(self):
1031
+ key = self.cleaned_data.get("api_key")
1032
+ if not key and self.instance.pk:
1033
+ return keep_existing("api_key")
1034
+ return key
1035
+
1036
+ def _post_clean(self):
1037
+ super()._post_clean()
1038
+ _restore_sigil_values(
1039
+ self,
1040
+ ["calendar_id", "api_key", "display_name", "timezone"],
1041
+ )
1042
+
1043
+
1012
1044
  class MaskedPasswordFormMixin:
1013
1045
  """Mixin that hides stored passwords while allowing updates."""
1014
1046
 
@@ -1226,6 +1258,15 @@ class OpenPayProfileInlineForm(ProfileFormMixin, OpenPayProfileAdminForm):
1226
1258
  return cleaned
1227
1259
 
1228
1260
 
1261
+ class GoogleCalendarProfileInlineForm(
1262
+ ProfileFormMixin, GoogleCalendarProfileAdminForm
1263
+ ):
1264
+ profile_fields = GoogleCalendarProfile.profile_fields
1265
+
1266
+ class Meta(GoogleCalendarProfileAdminForm.Meta):
1267
+ exclude = ("user", "group")
1268
+
1269
+
1229
1270
  class EmailInboxInlineForm(ProfileFormMixin, EmailInboxAdminForm):
1230
1271
  profile_fields = EmailInbox.profile_fields
1231
1272
 
@@ -1304,46 +1345,6 @@ class ReleaseManagerInlineForm(ProfileFormMixin, forms.ModelForm):
1304
1345
  }
1305
1346
 
1306
1347
 
1307
- class AssistantProfileInlineForm(ProfileFormMixin, forms.ModelForm):
1308
- user_key = forms.CharField(
1309
- required=False,
1310
- widget=forms.PasswordInput(render_value=True),
1311
- help_text="Provide a plain key to create or rotate credentials.",
1312
- )
1313
- profile_fields = ("assistant_name", "user_key", "scopes", "is_active")
1314
-
1315
- class Meta:
1316
- model = AssistantProfile
1317
- fields = ("assistant_name", "scopes", "is_active")
1318
-
1319
- def __init__(self, *args, **kwargs):
1320
- super().__init__(*args, **kwargs)
1321
- if not self.instance.pk and "is_active" in self.fields:
1322
- self.fields["is_active"].initial = False
1323
-
1324
- def clean(self):
1325
- cleaned = super().clean()
1326
- if cleaned.get("DELETE"):
1327
- return cleaned
1328
- if not self.instance.pk and not cleaned.get("user_key"):
1329
- if cleaned.get("scopes") or cleaned.get("is_active"):
1330
- raise forms.ValidationError(
1331
- "Provide a user key to create an assistant profile."
1332
- )
1333
- return cleaned
1334
-
1335
- def save(self, commit=True):
1336
- instance = super().save(commit=False)
1337
- user_key = self.cleaned_data.get("user_key")
1338
- if user_key:
1339
- instance.user_key_hash = hash_key(user_key)
1340
- instance.last_used_at = None
1341
- if commit:
1342
- instance.save()
1343
- self.save_m2m()
1344
- return instance
1345
-
1346
-
1347
1348
  PROFILE_INLINE_CONFIG = {
1348
1349
  OdooProfile: {
1349
1350
  "form": OdooProfileInlineForm,
@@ -1390,6 +1391,16 @@ PROFILE_INLINE_CONFIG = {
1390
1391
  ),
1391
1392
  "readonly_fields": ("verified_on", "verification_reference"),
1392
1393
  },
1394
+ GoogleCalendarProfile: {
1395
+ "form": GoogleCalendarProfileInlineForm,
1396
+ "fields": (
1397
+ "display_name",
1398
+ "calendar_id",
1399
+ "api_key",
1400
+ "max_events",
1401
+ "timezone",
1402
+ ),
1403
+ },
1393
1404
  EmailInbox: {
1394
1405
  "form": EmailInboxInlineForm,
1395
1406
  "fields": (
@@ -1474,12 +1485,6 @@ PROFILE_INLINE_CONFIG = {
1474
1485
  "secondary_pypi_url",
1475
1486
  ),
1476
1487
  },
1477
- AssistantProfile: {
1478
- "form": AssistantProfileInlineForm,
1479
- "fields": ("assistant_name", "user_key", "scopes", "is_active"),
1480
- "readonly_fields": ("user_key_hash", "created_at", "last_used_at"),
1481
- "template": "admin/edit_inline/profile_stacked.html",
1482
- },
1483
1488
  }
1484
1489
 
1485
1490
 
@@ -1526,7 +1531,6 @@ PROFILE_MODELS = (
1526
1531
  EmailOutbox,
1527
1532
  SocialProfile,
1528
1533
  ReleaseManager,
1529
- AssistantProfile,
1530
1534
  )
1531
1535
  USER_PROFILE_INLINES = [
1532
1536
  _build_profile_inline(model, "user") for model in PROFILE_MODELS
@@ -1868,6 +1872,44 @@ class OpenPayProfileAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModel
1868
1872
  verify_credentials_action.short_description = _("Test credentials")
1869
1873
 
1870
1874
 
1875
+ class GoogleCalendarProfileAdmin(
1876
+ ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin
1877
+ ):
1878
+ form = GoogleCalendarProfileAdminForm
1879
+ list_display = ("owner", "calendar_identifier", "max_events")
1880
+ search_fields = (
1881
+ "display_name",
1882
+ "calendar_id",
1883
+ "user__username",
1884
+ "group__name",
1885
+ )
1886
+ changelist_actions = ["my_profile"]
1887
+ change_actions = ["my_profile_action"]
1888
+ fieldsets = (
1889
+ (_("Owner"), {"fields": ("user", "group")}),
1890
+ (
1891
+ _("Calendar"),
1892
+ {
1893
+ "fields": (
1894
+ "display_name",
1895
+ "calendar_id",
1896
+ "api_key",
1897
+ "max_events",
1898
+ "timezone",
1899
+ )
1900
+ },
1901
+ ),
1902
+ )
1903
+
1904
+ @admin.display(description=_("Owner"))
1905
+ def owner(self, obj):
1906
+ return obj.owner_display()
1907
+
1908
+ @admin.display(description=_("Calendar"))
1909
+ def calendar_identifier(self, obj):
1910
+ display = obj.get_display_name()
1911
+ return display or obj.resolved_calendar_id()
1912
+
1871
1913
  class EmailSearchForm(forms.Form):
1872
1914
  subject = forms.CharField(
1873
1915
  required=False, widget=forms.TextInput(attrs={"style": "width: 40em;"})
@@ -2013,188 +2055,6 @@ class EmailInboxAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmi
2013
2055
  return TemplateResponse(request, "admin/core/emailinbox/search.html", context)
2014
2056
 
2015
2057
 
2016
- @admin.register(AssistantProfile)
2017
- class AssistantProfileAdmin(
2018
- ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin
2019
- ):
2020
- list_display = ("assistant_name", "owner", "created_at", "last_used_at", "is_active")
2021
- readonly_fields = ("user_key_hash", "created_at", "last_used_at")
2022
-
2023
- change_form_template = "admin/workgroupassistantprofile_change_form.html"
2024
- change_list_template = "admin/assistantprofile_change_list.html"
2025
- change_actions = ["my_profile_action"]
2026
- changelist_actions = ["my_profile"]
2027
- fieldsets = (
2028
- ("Owner", {"fields": ("user", "group")}),
2029
- ("Credentials", {"fields": ("user_key_hash",)}),
2030
- (
2031
- "Configuration",
2032
- {
2033
- "fields": (
2034
- "assistant_name",
2035
- "scopes",
2036
- "is_active",
2037
- "created_at",
2038
- "last_used_at",
2039
- )
2040
- },
2041
- ),
2042
- )
2043
-
2044
- def owner(self, obj):
2045
- return obj.owner_display()
2046
-
2047
- owner.short_description = "Owner"
2048
-
2049
- def get_urls(self):
2050
- urls = super().get_urls()
2051
- opts = self.model._meta
2052
- app_label = opts.app_label
2053
- model_name = opts.model_name
2054
- custom = [
2055
- path(
2056
- "<path:object_id>/generate-key/",
2057
- self.admin_site.admin_view(self.generate_key),
2058
- name=f"{app_label}_{model_name}_generate_key",
2059
- ),
2060
- path(
2061
- "server/start/",
2062
- self.admin_site.admin_view(self.start_server),
2063
- name=f"{app_label}_{model_name}_start_server",
2064
- ),
2065
- path(
2066
- "server/stop/",
2067
- self.admin_site.admin_view(self.stop_server),
2068
- name=f"{app_label}_{model_name}_stop_server",
2069
- ),
2070
- path(
2071
- "server/status/",
2072
- self.admin_site.admin_view(self.server_status),
2073
- name=f"{app_label}_{model_name}_status",
2074
- ),
2075
- ]
2076
- return custom + urls
2077
-
2078
- def changelist_view(self, request, extra_context=None):
2079
- extra_context = extra_context or {}
2080
- status = mcp_process.get_status()
2081
- opts = self.model._meta
2082
- app_label = opts.app_label
2083
- model_name = opts.model_name
2084
- extra_context.update(
2085
- {
2086
- "mcp_status": status,
2087
- "mcp_server_actions": {
2088
- "start": reverse(f"admin:{app_label}_{model_name}_start_server"),
2089
- "stop": reverse(f"admin:{app_label}_{model_name}_stop_server"),
2090
- "status": reverse(f"admin:{app_label}_{model_name}_status"),
2091
- },
2092
- }
2093
- )
2094
- return super().changelist_view(request, extra_context=extra_context)
2095
-
2096
- def _redirect_to_changelist(self):
2097
- opts = self.model._meta
2098
- return HttpResponseRedirect(
2099
- reverse(f"admin:{opts.app_label}_{opts.model_name}_changelist")
2100
- )
2101
-
2102
- def generate_key(self, request, object_id, *args, **kwargs):
2103
- profile = self.get_object(request, object_id)
2104
- if profile is None:
2105
- return HttpResponseRedirect("../")
2106
- if profile.user is None:
2107
- self.message_user(
2108
- request,
2109
- "Assign a user before generating a key.",
2110
- level=messages.ERROR,
2111
- )
2112
- return HttpResponseRedirect("../")
2113
- profile, key = AssistantProfile.issue_key(profile.user)
2114
- context = {
2115
- **self.admin_site.each_context(request),
2116
- "opts": self.model._meta,
2117
- "original": profile,
2118
- "user_key": key,
2119
- }
2120
- return TemplateResponse(request, "admin/assistantprofile_key.html", context)
2121
-
2122
- def render_change_form(
2123
- self, request, context, add=False, change=False, form_url="", obj=None
2124
- ):
2125
- response = super().render_change_form(
2126
- request, context, add=add, change=change, form_url=form_url, obj=obj
2127
- )
2128
- config = dict(getattr(settings, "MCP_SIGIL_SERVER", {}))
2129
- host = config.get("host") or "127.0.0.1"
2130
- port = config.get("port", 8800)
2131
- base_url, issuer_url = resolve_base_urls(config)
2132
- mount_path = config.get("mount_path") or "/"
2133
- display_base_url = base_url or f"http://{host}:{port}"
2134
- display_issuer_url = issuer_url or display_base_url
2135
- chat_endpoint = f"{display_base_url.rstrip('/')}/api/chat/"
2136
- if isinstance(response, dict):
2137
- response.setdefault("mcp_server_host", host)
2138
- response.setdefault("mcp_server_port", port)
2139
- response.setdefault("mcp_server_base_url", display_base_url)
2140
- response.setdefault("mcp_server_issuer_url", display_issuer_url)
2141
- response.setdefault("mcp_server_mount_path", mount_path)
2142
- response.setdefault("mcp_server_chat_endpoint", chat_endpoint)
2143
- else:
2144
- context_data = getattr(response, "context_data", None)
2145
- if context_data is not None:
2146
- context_data.setdefault("mcp_server_host", host)
2147
- context_data.setdefault("mcp_server_port", port)
2148
- context_data.setdefault("mcp_server_base_url", display_base_url)
2149
- context_data.setdefault("mcp_server_issuer_url", display_issuer_url)
2150
- context_data.setdefault("mcp_server_mount_path", mount_path)
2151
- context_data.setdefault("mcp_server_chat_endpoint", chat_endpoint)
2152
- return response
2153
-
2154
- def start_server(self, request):
2155
- try:
2156
- pid = mcp_process.start_server()
2157
- except mcp_process.ServerAlreadyRunningError as exc:
2158
- self.message_user(request, str(exc), level=messages.WARNING)
2159
- except mcp_process.ServerStartError as exc:
2160
- self.message_user(request, str(exc), level=messages.ERROR)
2161
- else:
2162
- self.message_user(
2163
- request,
2164
- f"Started MCP server (PID {pid}).",
2165
- level=messages.SUCCESS,
2166
- )
2167
- return self._redirect_to_changelist()
2168
-
2169
- def stop_server(self, request):
2170
- try:
2171
- pid = mcp_process.stop_server()
2172
- except mcp_process.ServerNotRunningError as exc:
2173
- self.message_user(request, str(exc), level=messages.WARNING)
2174
- except mcp_process.ServerStopError as exc:
2175
- self.message_user(request, str(exc), level=messages.ERROR)
2176
- else:
2177
- self.message_user(
2178
- request,
2179
- f"Stopped MCP server (PID {pid}).",
2180
- level=messages.SUCCESS,
2181
- )
2182
- return self._redirect_to_changelist()
2183
-
2184
- def server_status(self, request):
2185
- status = mcp_process.get_status()
2186
- if status["running"]:
2187
- msg = f"MCP server is running (PID {status['pid']})."
2188
- level = messages.INFO
2189
- else:
2190
- msg = "MCP server is not running."
2191
- level = messages.WARNING
2192
- if status.get("last_error"):
2193
- msg = f"{msg} {status['last_error']}"
2194
- self.message_user(request, msg, level=level)
2195
- return self._redirect_to_changelist()
2196
-
2197
-
2198
2058
  class EnergyCreditInline(admin.TabularInline):
2199
2059
  model = EnergyCredit
2200
2060
  fields = ("amount_kw", "created_by", "created_on")
@@ -2649,8 +2509,30 @@ class ProductAdmin(EntityModelAdmin):
2649
2509
  ],
2650
2510
  limit=0,
2651
2511
  )
2652
- except Exception:
2512
+ except Exception as exc:
2513
+ logger.exception(
2514
+ "Failed to fetch Odoo products for user %s (profile_id=%s, host=%s, database=%s)",
2515
+ getattr(getattr(request, "user", None), "pk", None),
2516
+ getattr(profile, "pk", None),
2517
+ getattr(profile, "host", None),
2518
+ getattr(profile, "database", None),
2519
+ )
2653
2520
  context["error"] = _("Unable to fetch products from Odoo.")
2521
+ if getattr(request.user, "is_superuser", False):
2522
+ fault = getattr(exc, "faultString", "")
2523
+ message = str(exc)
2524
+ details = [
2525
+ f"Host: {getattr(profile, 'host', '')}",
2526
+ f"Database: {getattr(profile, 'database', '')}",
2527
+ f"User ID: {getattr(profile, 'odoo_uid', '')}",
2528
+ ]
2529
+ if fault and fault != message:
2530
+ details.append(f"Fault: {fault}")
2531
+ if message:
2532
+ details.append(f"Exception: {type(exc).__name__}: {message}")
2533
+ else:
2534
+ details.append(f"Exception type: {type(exc).__name__}")
2535
+ context["debug_error"] = "\n".join(details)
2654
2536
  return context, []
2655
2537
 
2656
2538
  context["has_credentials"] = True
@@ -2930,7 +2812,7 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
2930
2812
  "user_data_flag",
2931
2813
  "color",
2932
2814
  "kind",
2933
- "endianness",
2815
+ "endianness_short",
2934
2816
  "released",
2935
2817
  "allowed",
2936
2818
  "last_seen_on",
@@ -2995,6 +2877,14 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
2995
2877
  def user_data_flag(self, obj):
2996
2878
  return getattr(obj, "is_user_data", False)
2997
2879
 
2880
+ @admin.display(description=_("End"), ordering="endianness")
2881
+ def endianness_short(self, obj):
2882
+ labels = {
2883
+ RFID.BIG_ENDIAN: _("Big"),
2884
+ RFID.LITTLE_ENDIAN: _("Little"),
2885
+ }
2886
+ return labels.get(obj.endianness, obj.get_endianness_display())
2887
+
2998
2888
  def scan_rfids(self, request, queryset):
2999
2889
  return redirect("admin:core_rfid_scan")
3000
2890
 
@@ -3690,6 +3580,7 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
3690
3580
  "toggle_url": toggle_url,
3691
3581
  "toggle_label": toggle_label,
3692
3582
  "public_view_url": public_view_url,
3583
+ "deep_read_url": reverse("rfid-scan-deep"),
3693
3584
  }
3694
3585
  )
3695
3586
  context["title"] = _("Scan RFIDs")
@@ -3947,9 +3838,9 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
3947
3838
  self.message_user(request, str(exc), messages.ERROR)
3948
3839
  return
3949
3840
  releases = resp.json().get("releases", {})
3950
- created = 0
3951
3841
  updated = 0
3952
3842
  restored = 0
3843
+ missing: list[str] = []
3953
3844
 
3954
3845
  for version, files in releases.items():
3955
3846
  release_on = self._release_on_from_files(files)
@@ -3974,23 +3865,11 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
3974
3865
  if update_fields:
3975
3866
  release.save(update_fields=update_fields)
3976
3867
  continue
3977
- PackageRelease.objects.create(
3978
- package=package,
3979
- release_manager=package.release_manager,
3980
- version=version,
3981
- revision="",
3982
- pypi_url=f"https://pypi.org/project/{package.name}/{version}/",
3983
- release_on=release_on,
3984
- )
3985
- created += 1
3868
+ missing.append(version)
3986
3869
 
3987
- if created or updated or restored:
3870
+ if updated or restored:
3988
3871
  PackageRelease.dump_fixture()
3989
3872
  message_parts = []
3990
- if created:
3991
- message_parts.append(
3992
- f"Created {created} release{'s' if created != 1 else ''} from PyPI"
3993
- )
3994
3873
  if updated:
3995
3874
  message_parts.append(
3996
3875
  f"Updated release date for {updated} release"
@@ -4001,8 +3880,17 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
4001
3880
  f"Restored {restored} release{'s' if restored != 1 else ''}"
4002
3881
  )
4003
3882
  self.message_user(request, "; ".join(message_parts), messages.SUCCESS)
4004
- else:
4005
- self.message_user(request, "No new releases found", messages.INFO)
3883
+ elif not missing:
3884
+ self.message_user(request, "No matching releases found", messages.INFO)
3885
+
3886
+ if missing:
3887
+ versions = ", ".join(sorted(missing))
3888
+ count = len(missing)
3889
+ message = (
3890
+ "Manual creation required for "
3891
+ f"{count} release{'s' if count != 1 else ''}: {versions}"
3892
+ )
3893
+ self.message_user(request, message, messages.WARNING)
4006
3894
 
4007
3895
  refresh_from_pypi.label = "Refresh from PyPI"
4008
3896
  refresh_from_pypi.short_description = "Refresh from PyPI"
core/apps.py CHANGED
@@ -348,9 +348,3 @@ class CoreConfig(AppConfig):
348
348
  weak=False,
349
349
  )
350
350
 
351
- try:
352
- from .mcp.auto_start import schedule_auto_start
353
-
354
- schedule_auto_start(check_profiles_immediately=False)
355
- except Exception: # pragma: no cover - defensive
356
- logger.exception("Failed to schedule MCP auto-start")