arthexis 0.1.19__py3-none-any.whl → 0.1.21__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.
core/admin.py CHANGED
@@ -1,3 +1,4 @@
1
+ from collections import defaultdict
1
2
  from io import BytesIO
2
3
  import os
3
4
 
@@ -90,9 +91,7 @@ from .models import (
90
91
  SecurityGroup,
91
92
  InviteLead,
92
93
  PublicWifiAccess,
93
- AssistantProfile,
94
94
  Todo,
95
- hash_key,
96
95
  )
97
96
  from .user_data import (
98
97
  EntityModelAdmin,
@@ -109,8 +108,6 @@ from .rfid_import_export import (
109
108
  parse_accounts,
110
109
  serialize_accounts,
111
110
  )
112
- from .mcp import process as mcp_process
113
- from .mcp.server import resolve_base_urls
114
111
  from . import release as release_utils
115
112
 
116
113
  logger = logging.getLogger(__name__)
@@ -1303,46 +1300,6 @@ class ReleaseManagerInlineForm(ProfileFormMixin, forms.ModelForm):
1303
1300
  }
1304
1301
 
1305
1302
 
1306
- class AssistantProfileInlineForm(ProfileFormMixin, forms.ModelForm):
1307
- user_key = forms.CharField(
1308
- required=False,
1309
- widget=forms.PasswordInput(render_value=True),
1310
- help_text="Provide a plain key to create or rotate credentials.",
1311
- )
1312
- profile_fields = ("assistant_name", "user_key", "scopes", "is_active")
1313
-
1314
- class Meta:
1315
- model = AssistantProfile
1316
- fields = ("assistant_name", "scopes", "is_active")
1317
-
1318
- def __init__(self, *args, **kwargs):
1319
- super().__init__(*args, **kwargs)
1320
- if not self.instance.pk and "is_active" in self.fields:
1321
- self.fields["is_active"].initial = False
1322
-
1323
- def clean(self):
1324
- cleaned = super().clean()
1325
- if cleaned.get("DELETE"):
1326
- return cleaned
1327
- if not self.instance.pk and not cleaned.get("user_key"):
1328
- if cleaned.get("scopes") or cleaned.get("is_active"):
1329
- raise forms.ValidationError(
1330
- "Provide a user key to create an assistant profile."
1331
- )
1332
- return cleaned
1333
-
1334
- def save(self, commit=True):
1335
- instance = super().save(commit=False)
1336
- user_key = self.cleaned_data.get("user_key")
1337
- if user_key:
1338
- instance.user_key_hash = hash_key(user_key)
1339
- instance.last_used_at = None
1340
- if commit:
1341
- instance.save()
1342
- self.save_m2m()
1343
- return instance
1344
-
1345
-
1346
1303
  PROFILE_INLINE_CONFIG = {
1347
1304
  OdooProfile: {
1348
1305
  "form": OdooProfileInlineForm,
@@ -1473,12 +1430,6 @@ PROFILE_INLINE_CONFIG = {
1473
1430
  "secondary_pypi_url",
1474
1431
  ),
1475
1432
  },
1476
- AssistantProfile: {
1477
- "form": AssistantProfileInlineForm,
1478
- "fields": ("assistant_name", "user_key", "scopes", "is_active"),
1479
- "readonly_fields": ("user_key_hash", "created_at", "last_used_at"),
1480
- "template": "admin/edit_inline/profile_stacked.html",
1481
- },
1482
1433
  }
1483
1434
 
1484
1435
 
@@ -1525,7 +1476,6 @@ PROFILE_MODELS = (
1525
1476
  EmailOutbox,
1526
1477
  SocialProfile,
1527
1478
  ReleaseManager,
1528
- AssistantProfile,
1529
1479
  )
1530
1480
  USER_PROFILE_INLINES = [
1531
1481
  _build_profile_inline(model, "user") for model in PROFILE_MODELS
@@ -2012,188 +1962,6 @@ class EmailInboxAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmi
2012
1962
  return TemplateResponse(request, "admin/core/emailinbox/search.html", context)
2013
1963
 
2014
1964
 
2015
- @admin.register(AssistantProfile)
2016
- class AssistantProfileAdmin(
2017
- ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin
2018
- ):
2019
- list_display = ("assistant_name", "owner", "created_at", "last_used_at", "is_active")
2020
- readonly_fields = ("user_key_hash", "created_at", "last_used_at")
2021
-
2022
- change_form_template = "admin/workgroupassistantprofile_change_form.html"
2023
- change_list_template = "admin/assistantprofile_change_list.html"
2024
- change_actions = ["my_profile_action"]
2025
- changelist_actions = ["my_profile"]
2026
- fieldsets = (
2027
- ("Owner", {"fields": ("user", "group")}),
2028
- ("Credentials", {"fields": ("user_key_hash",)}),
2029
- (
2030
- "Configuration",
2031
- {
2032
- "fields": (
2033
- "assistant_name",
2034
- "scopes",
2035
- "is_active",
2036
- "created_at",
2037
- "last_used_at",
2038
- )
2039
- },
2040
- ),
2041
- )
2042
-
2043
- def owner(self, obj):
2044
- return obj.owner_display()
2045
-
2046
- owner.short_description = "Owner"
2047
-
2048
- def get_urls(self):
2049
- urls = super().get_urls()
2050
- opts = self.model._meta
2051
- app_label = opts.app_label
2052
- model_name = opts.model_name
2053
- custom = [
2054
- path(
2055
- "<path:object_id>/generate-key/",
2056
- self.admin_site.admin_view(self.generate_key),
2057
- name=f"{app_label}_{model_name}_generate_key",
2058
- ),
2059
- path(
2060
- "server/start/",
2061
- self.admin_site.admin_view(self.start_server),
2062
- name=f"{app_label}_{model_name}_start_server",
2063
- ),
2064
- path(
2065
- "server/stop/",
2066
- self.admin_site.admin_view(self.stop_server),
2067
- name=f"{app_label}_{model_name}_stop_server",
2068
- ),
2069
- path(
2070
- "server/status/",
2071
- self.admin_site.admin_view(self.server_status),
2072
- name=f"{app_label}_{model_name}_status",
2073
- ),
2074
- ]
2075
- return custom + urls
2076
-
2077
- def changelist_view(self, request, extra_context=None):
2078
- extra_context = extra_context or {}
2079
- status = mcp_process.get_status()
2080
- opts = self.model._meta
2081
- app_label = opts.app_label
2082
- model_name = opts.model_name
2083
- extra_context.update(
2084
- {
2085
- "mcp_status": status,
2086
- "mcp_server_actions": {
2087
- "start": reverse(f"admin:{app_label}_{model_name}_start_server"),
2088
- "stop": reverse(f"admin:{app_label}_{model_name}_stop_server"),
2089
- "status": reverse(f"admin:{app_label}_{model_name}_status"),
2090
- },
2091
- }
2092
- )
2093
- return super().changelist_view(request, extra_context=extra_context)
2094
-
2095
- def _redirect_to_changelist(self):
2096
- opts = self.model._meta
2097
- return HttpResponseRedirect(
2098
- reverse(f"admin:{opts.app_label}_{opts.model_name}_changelist")
2099
- )
2100
-
2101
- def generate_key(self, request, object_id, *args, **kwargs):
2102
- profile = self.get_object(request, object_id)
2103
- if profile is None:
2104
- return HttpResponseRedirect("../")
2105
- if profile.user is None:
2106
- self.message_user(
2107
- request,
2108
- "Assign a user before generating a key.",
2109
- level=messages.ERROR,
2110
- )
2111
- return HttpResponseRedirect("../")
2112
- profile, key = AssistantProfile.issue_key(profile.user)
2113
- context = {
2114
- **self.admin_site.each_context(request),
2115
- "opts": self.model._meta,
2116
- "original": profile,
2117
- "user_key": key,
2118
- }
2119
- return TemplateResponse(request, "admin/assistantprofile_key.html", context)
2120
-
2121
- def render_change_form(
2122
- self, request, context, add=False, change=False, form_url="", obj=None
2123
- ):
2124
- response = super().render_change_form(
2125
- request, context, add=add, change=change, form_url=form_url, obj=obj
2126
- )
2127
- config = dict(getattr(settings, "MCP_SIGIL_SERVER", {}))
2128
- host = config.get("host") or "127.0.0.1"
2129
- port = config.get("port", 8800)
2130
- base_url, issuer_url = resolve_base_urls(config)
2131
- mount_path = config.get("mount_path") or "/"
2132
- display_base_url = base_url or f"http://{host}:{port}"
2133
- display_issuer_url = issuer_url or display_base_url
2134
- chat_endpoint = f"{display_base_url.rstrip('/')}/api/chat/"
2135
- if isinstance(response, dict):
2136
- response.setdefault("mcp_server_host", host)
2137
- response.setdefault("mcp_server_port", port)
2138
- response.setdefault("mcp_server_base_url", display_base_url)
2139
- response.setdefault("mcp_server_issuer_url", display_issuer_url)
2140
- response.setdefault("mcp_server_mount_path", mount_path)
2141
- response.setdefault("mcp_server_chat_endpoint", chat_endpoint)
2142
- else:
2143
- context_data = getattr(response, "context_data", None)
2144
- if context_data is not None:
2145
- context_data.setdefault("mcp_server_host", host)
2146
- context_data.setdefault("mcp_server_port", port)
2147
- context_data.setdefault("mcp_server_base_url", display_base_url)
2148
- context_data.setdefault("mcp_server_issuer_url", display_issuer_url)
2149
- context_data.setdefault("mcp_server_mount_path", mount_path)
2150
- context_data.setdefault("mcp_server_chat_endpoint", chat_endpoint)
2151
- return response
2152
-
2153
- def start_server(self, request):
2154
- try:
2155
- pid = mcp_process.start_server()
2156
- except mcp_process.ServerAlreadyRunningError as exc:
2157
- self.message_user(request, str(exc), level=messages.WARNING)
2158
- except mcp_process.ServerStartError as exc:
2159
- self.message_user(request, str(exc), level=messages.ERROR)
2160
- else:
2161
- self.message_user(
2162
- request,
2163
- f"Started MCP server (PID {pid}).",
2164
- level=messages.SUCCESS,
2165
- )
2166
- return self._redirect_to_changelist()
2167
-
2168
- def stop_server(self, request):
2169
- try:
2170
- pid = mcp_process.stop_server()
2171
- except mcp_process.ServerNotRunningError as exc:
2172
- self.message_user(request, str(exc), level=messages.WARNING)
2173
- except mcp_process.ServerStopError as exc:
2174
- self.message_user(request, str(exc), level=messages.ERROR)
2175
- else:
2176
- self.message_user(
2177
- request,
2178
- f"Stopped MCP server (PID {pid}).",
2179
- level=messages.SUCCESS,
2180
- )
2181
- return self._redirect_to_changelist()
2182
-
2183
- def server_status(self, request):
2184
- status = mcp_process.get_status()
2185
- if status["running"]:
2186
- msg = f"MCP server is running (PID {status['pid']})."
2187
- level = messages.INFO
2188
- else:
2189
- msg = "MCP server is not running."
2190
- level = messages.WARNING
2191
- if status.get("last_error"):
2192
- msg = f"{msg} {status['last_error']}"
2193
- self.message_user(request, msg, level=level)
2194
- return self._redirect_to_changelist()
2195
-
2196
-
2197
1965
  class EnergyCreditInline(admin.TabularInline):
2198
1966
  model = EnergyCredit
2199
1967
  fields = ("amount_kw", "created_by", "created_on")
@@ -2909,7 +2677,7 @@ class CopyRFIDForm(forms.Form):
2909
2677
  normalized = (cleaned or "").strip().upper()
2910
2678
  if not normalized:
2911
2679
  raise forms.ValidationError(_("RFID value is required."))
2912
- if RFID.objects.filter(rfid=normalized).exists():
2680
+ if RFID.matching_queryset(normalized).exists():
2913
2681
  raise forms.ValidationError(
2914
2682
  _("An RFID with this value already exists.")
2915
2683
  )
@@ -2943,6 +2711,7 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
2943
2711
  "print_card_labels",
2944
2712
  "print_release_form",
2945
2713
  "copy_rfids",
2714
+ "merge_rfids",
2946
2715
  "toggle_selected_user_data",
2947
2716
  "toggle_selected_released",
2948
2717
  "toggle_selected_allowed",
@@ -3177,6 +2946,145 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
3177
2946
  context["media"] = self.media + form.media
3178
2947
  return TemplateResponse(request, "admin/core/rfid/copy.html", context)
3179
2948
 
2949
+ @admin.action(description=_("Merge RFID cards"))
2950
+ def merge_rfids(self, request, queryset):
2951
+ tags = list(queryset.prefetch_related("energy_accounts"))
2952
+ if len(tags) < 2:
2953
+ self.message_user(
2954
+ request,
2955
+ _("Select at least two RFIDs to merge."),
2956
+ level=messages.WARNING,
2957
+ )
2958
+ return None
2959
+
2960
+ normalized_map: dict[int, str] = {}
2961
+ groups: defaultdict[str, list[RFID]] = defaultdict(list)
2962
+ unmatched = 0
2963
+ for tag in tags:
2964
+ normalized = RFID.normalize_code(tag.rfid)
2965
+ normalized_map[tag.pk] = normalized
2966
+ if not normalized:
2967
+ unmatched += 1
2968
+ continue
2969
+ prefix = normalized[: RFID.MATCH_PREFIX_LENGTH]
2970
+ groups[prefix].append(tag)
2971
+
2972
+ merge_groups: list[list[RFID]] = []
2973
+ skipped = unmatched
2974
+ for prefix, group in groups.items():
2975
+ if len(group) < 2:
2976
+ skipped += len(group)
2977
+ continue
2978
+ group.sort(
2979
+ key=lambda item: (
2980
+ len(normalized_map.get(item.pk, "")),
2981
+ normalized_map.get(item.pk, ""),
2982
+ item.pk,
2983
+ )
2984
+ )
2985
+ merge_groups.append(group)
2986
+
2987
+ if not merge_groups:
2988
+ self.message_user(
2989
+ request,
2990
+ _("No matching RFIDs were found to merge."),
2991
+ level=messages.WARNING,
2992
+ )
2993
+ return None
2994
+
2995
+ merged_tags = 0
2996
+ merged_groups = 0
2997
+ conflicting_accounts = 0
2998
+ with transaction.atomic():
2999
+ for group in merge_groups:
3000
+ canonical = group[0]
3001
+ update_fields: set[str] = set()
3002
+ existing_account_ids = set(
3003
+ canonical.energy_accounts.values_list("pk", flat=True)
3004
+ )
3005
+ for tag in group[1:]:
3006
+ other_value = normalized_map.get(tag.pk, "")
3007
+ if canonical.adopt_rfid(other_value):
3008
+ update_fields.add("rfid")
3009
+ normalized_map[canonical.pk] = RFID.normalize_code(
3010
+ canonical.rfid
3011
+ )
3012
+ accounts = list(tag.energy_accounts.all())
3013
+ if accounts:
3014
+ transferable: list[EnergyAccount] = []
3015
+ for account in accounts:
3016
+ if existing_account_ids and account.pk not in existing_account_ids:
3017
+ conflicting_accounts += 1
3018
+ continue
3019
+ transferable.append(account)
3020
+ if transferable:
3021
+ canonical.energy_accounts.add(*transferable)
3022
+ existing_account_ids.update(
3023
+ account.pk for account in transferable
3024
+ )
3025
+ if tag.allowed and not canonical.allowed:
3026
+ canonical.allowed = True
3027
+ update_fields.add("allowed")
3028
+ if tag.released and not canonical.released:
3029
+ canonical.released = True
3030
+ update_fields.add("released")
3031
+ if tag.key_a_verified and not canonical.key_a_verified:
3032
+ canonical.key_a_verified = True
3033
+ update_fields.add("key_a_verified")
3034
+ if tag.key_b_verified and not canonical.key_b_verified:
3035
+ canonical.key_b_verified = True
3036
+ update_fields.add("key_b_verified")
3037
+ if tag.last_seen_on and (
3038
+ not canonical.last_seen_on
3039
+ or tag.last_seen_on > canonical.last_seen_on
3040
+ ):
3041
+ canonical.last_seen_on = tag.last_seen_on
3042
+ update_fields.add("last_seen_on")
3043
+ if not canonical.origin_node and tag.origin_node_id:
3044
+ canonical.origin_node = tag.origin_node
3045
+ update_fields.add("origin_node")
3046
+ merged_tags += 1
3047
+ tag.delete()
3048
+ if update_fields:
3049
+ canonical.save(update_fields=sorted(update_fields))
3050
+ merged_groups += 1
3051
+
3052
+ if merged_tags:
3053
+ self.message_user(
3054
+ request,
3055
+ ngettext(
3056
+ "Merged %(removed)d RFID into %(groups)d canonical record.",
3057
+ "Merged %(removed)d RFIDs into %(groups)d canonical records.",
3058
+ merged_tags,
3059
+ )
3060
+ % {"removed": merged_tags, "groups": merged_groups},
3061
+ level=messages.SUCCESS,
3062
+ )
3063
+
3064
+ if skipped:
3065
+ self.message_user(
3066
+ request,
3067
+ ngettext(
3068
+ "Skipped %(count)d RFID because it did not share the first %(length)d characters with another selection.",
3069
+ "Skipped %(count)d RFIDs because they did not share the first %(length)d characters with another selection.",
3070
+ skipped,
3071
+ )
3072
+ % {"count": skipped, "length": RFID.MATCH_PREFIX_LENGTH},
3073
+ level=messages.WARNING,
3074
+ )
3075
+
3076
+ if conflicting_accounts:
3077
+ self.message_user(
3078
+ request,
3079
+ ngettext(
3080
+ "Skipped %(count)d energy account because the RFID was already linked to a different account.",
3081
+ "Skipped %(count)d energy accounts because the RFID was already linked to a different account.",
3082
+ conflicting_accounts,
3083
+ )
3084
+ % {"count": conflicting_accounts},
3085
+ level=messages.WARNING,
3086
+ )
3087
+
3180
3088
  def _render_card_labels(
3181
3089
  self,
3182
3090
  request,
@@ -3549,6 +3457,7 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
3549
3457
  "toggle_url": toggle_url,
3550
3458
  "toggle_label": toggle_label,
3551
3459
  "public_view_url": public_view_url,
3460
+ "deep_read_url": reverse("rfid-scan-deep"),
3552
3461
  }
3553
3462
  )
3554
3463
  context["title"] = _("Scan RFIDs")
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")
core/backends.py CHANGED
@@ -81,10 +81,16 @@ class RFIDBackend:
81
81
  if not rfid_value:
82
82
  return None
83
83
 
84
- tag = RFID.objects.filter(rfid=rfid_value).first()
85
- if not tag or not tag.allowed:
84
+ tag = RFID.matching_queryset(rfid_value).filter(allowed=True).first()
85
+ if not tag:
86
86
  return None
87
87
 
88
+ update_fields: list[str] = []
89
+ if tag.adopt_rfid(rfid_value):
90
+ update_fields.append("rfid")
91
+ if update_fields:
92
+ tag.save(update_fields=update_fields)
93
+
88
94
  command = (tag.external_command or "").strip()
89
95
  if command:
90
96
  env = os.environ.copy()