suprema-biostar-mcp 1.0.1__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.
- biostar_x_mcp_server/__init__.py +25 -0
- biostar_x_mcp_server/__main__.py +15 -0
- biostar_x_mcp_server/config.py +87 -0
- biostar_x_mcp_server/handlers/__init__.py +35 -0
- biostar_x_mcp_server/handlers/access_handler.py +2162 -0
- biostar_x_mcp_server/handlers/audit_handler.py +489 -0
- biostar_x_mcp_server/handlers/auth_handler.py +216 -0
- biostar_x_mcp_server/handlers/base_handler.py +228 -0
- biostar_x_mcp_server/handlers/card_handler.py +746 -0
- biostar_x_mcp_server/handlers/device_handler.py +4344 -0
- biostar_x_mcp_server/handlers/door_handler.py +3969 -0
- biostar_x_mcp_server/handlers/event_handler.py +1331 -0
- biostar_x_mcp_server/handlers/file_handler.py +212 -0
- biostar_x_mcp_server/handlers/help_web_handler.py +379 -0
- biostar_x_mcp_server/handlers/log_handler.py +1051 -0
- biostar_x_mcp_server/handlers/navigation_handler.py +109 -0
- biostar_x_mcp_server/handlers/occupancy_handler.py +541 -0
- biostar_x_mcp_server/handlers/user_handler.py +3568 -0
- biostar_x_mcp_server/schemas/__init__.py +21 -0
- biostar_x_mcp_server/schemas/access.py +158 -0
- biostar_x_mcp_server/schemas/audit.py +73 -0
- biostar_x_mcp_server/schemas/auth.py +24 -0
- biostar_x_mcp_server/schemas/cards.py +128 -0
- biostar_x_mcp_server/schemas/devices.py +496 -0
- biostar_x_mcp_server/schemas/doors.py +306 -0
- biostar_x_mcp_server/schemas/events.py +104 -0
- biostar_x_mcp_server/schemas/files.py +7 -0
- biostar_x_mcp_server/schemas/help.py +29 -0
- biostar_x_mcp_server/schemas/logs.py +33 -0
- biostar_x_mcp_server/schemas/occupancy.py +19 -0
- biostar_x_mcp_server/schemas/tool_response.py +29 -0
- biostar_x_mcp_server/schemas/users.py +166 -0
- biostar_x_mcp_server/server.py +335 -0
- biostar_x_mcp_server/session.py +221 -0
- biostar_x_mcp_server/tool_manager.py +172 -0
- biostar_x_mcp_server/tools/__init__.py +45 -0
- biostar_x_mcp_server/tools/access.py +510 -0
- biostar_x_mcp_server/tools/audit.py +227 -0
- biostar_x_mcp_server/tools/auth.py +59 -0
- biostar_x_mcp_server/tools/cards.py +269 -0
- biostar_x_mcp_server/tools/categories.py +197 -0
- biostar_x_mcp_server/tools/devices.py +1552 -0
- biostar_x_mcp_server/tools/doors.py +865 -0
- biostar_x_mcp_server/tools/events.py +305 -0
- biostar_x_mcp_server/tools/files.py +28 -0
- biostar_x_mcp_server/tools/help.py +80 -0
- biostar_x_mcp_server/tools/logs.py +123 -0
- biostar_x_mcp_server/tools/navigation.py +89 -0
- biostar_x_mcp_server/tools/occupancy.py +91 -0
- biostar_x_mcp_server/tools/users.py +1113 -0
- biostar_x_mcp_server/utils/__init__.py +31 -0
- biostar_x_mcp_server/utils/category_mapper.py +206 -0
- biostar_x_mcp_server/utils/decorators.py +101 -0
- biostar_x_mcp_server/utils/language_detector.py +51 -0
- biostar_x_mcp_server/utils/search.py +42 -0
- biostar_x_mcp_server/utils/timezone.py +122 -0
- suprema_biostar_mcp-1.0.1.dist-info/METADATA +163 -0
- suprema_biostar_mcp-1.0.1.dist-info/RECORD +61 -0
- suprema_biostar_mcp-1.0.1.dist-info/WHEEL +4 -0
- suprema_biostar_mcp-1.0.1.dist-info/entry_points.txt +2 -0
- suprema_biostar_mcp-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
# handlers/card_handler.py
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
from typing import Sequence, Dict, Any, Optional, List
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .base_handler import BaseHandler
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
# Fixed mapping by BioStar 2 spec (card_type.id)
|
|
14
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
CARD_KIND_TO_ID = {
|
|
16
|
+
"CSN": 0,
|
|
17
|
+
"WIEGAND": 1,
|
|
18
|
+
"SECURE_CREDENTIAL": 2,
|
|
19
|
+
"ACCESS_ON_CARD": 3,
|
|
20
|
+
"MOBILE_CSN": 4,
|
|
21
|
+
"WIEGAND_MOBILE": 5,
|
|
22
|
+
"QR_BARCODE": 6,
|
|
23
|
+
"BIOSTAR2_QR": 7,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# card_type.type fallback when GET /api/cards/types is missing or incomplete
|
|
27
|
+
TYPE_FALLBACK: Dict[int, int] = {
|
|
28
|
+
0: 1, # CSN
|
|
29
|
+
1: 10, # WIEGAND -> CSN_WIEGAND
|
|
30
|
+
2: 2, # SECURE CREDENTIAL
|
|
31
|
+
3: 3, # ACCESS ON CARD
|
|
32
|
+
4: 4, # MOBILE CSN
|
|
33
|
+
5: 5, # WIEGAND MOBILE
|
|
34
|
+
6: 6, # QR/BARCODE
|
|
35
|
+
7: 7, # BIOSTAR2 QR
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Common Wiegand format aliases
|
|
39
|
+
WIEGAND_FORMAT_ALIAS: Dict[str, int] = {
|
|
40
|
+
"26bit": 0, "26-bit": 0, "h10301": 0, "sia26": 0,
|
|
41
|
+
"hid37": 1, "h10302": 1, "37bit": 1, "37-bit": 1,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Hard constraints (fail if violated)
|
|
45
|
+
WIEGAND_CONSTRAINTS: Dict[int, Dict[str, Any]] = {
|
|
46
|
+
# 26bit SIA Standard (H10301)
|
|
47
|
+
0: {
|
|
48
|
+
"name": "26bit SIA Standard (H10301)",
|
|
49
|
+
"facility_min": 0, "facility_max": 255,
|
|
50
|
+
"card_min": 0, "card_max": 65535,
|
|
51
|
+
"example_display": "12-3456",
|
|
52
|
+
},
|
|
53
|
+
# HID 37bit (H10302)
|
|
54
|
+
1: {
|
|
55
|
+
"name": "HID 37bit (H10302)",
|
|
56
|
+
"facility_min": 0, "facility_max": 65535,
|
|
57
|
+
"card_min": 0, "card_max": 524287,
|
|
58
|
+
"example_display": "100-12345",
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Presets for UX (includes constraints)
|
|
63
|
+
WIEGAND_PRESETS: List[Dict[str, Any]] = [
|
|
64
|
+
{
|
|
65
|
+
"id": 0,
|
|
66
|
+
"name": WIEGAND_CONSTRAINTS[0]["name"],
|
|
67
|
+
"aliases": ["26bit", "h10301", "sia26"],
|
|
68
|
+
"hint": f"display_card_id like '{WIEGAND_CONSTRAINTS[0]['example_display']}'",
|
|
69
|
+
"constraints": {
|
|
70
|
+
"facility_code": [WIEGAND_CONSTRAINTS[0]["facility_min"], WIEGAND_CONSTRAINTS[0]["facility_max"]],
|
|
71
|
+
"card_number": [WIEGAND_CONSTRAINTS[0]["card_min"], WIEGAND_CONSTRAINTS[0]["card_max"]],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": 1,
|
|
76
|
+
"name": WIEGAND_CONSTRAINTS[1]["name"],
|
|
77
|
+
"aliases": ["hid37", "h10302", "37bit"],
|
|
78
|
+
"hint": f"display_card_id like '{WIEGAND_CONSTRAINTS[1]['example_display']}'",
|
|
79
|
+
"constraints": {
|
|
80
|
+
"facility_code": [WIEGAND_CONSTRAINTS[1]["facility_min"], WIEGAND_CONSTRAINTS[1]["facility_max"]],
|
|
81
|
+
"card_number": [WIEGAND_CONSTRAINTS[1]["card_min"], WIEGAND_CONSTRAINTS[1]["card_max"]],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
# CSN validation rules
|
|
87
|
+
CSN_RULES = {
|
|
88
|
+
"min_len": 1,
|
|
89
|
+
"max_len": 32,
|
|
90
|
+
"no_leading_zero": True,
|
|
91
|
+
# strictly '0-9' (no Unicode digits)
|
|
92
|
+
"ascii_digits_only": True,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def _ascii_digits(s: str) -> bool:
|
|
96
|
+
"""Return True if string contains ASCII digits only (0-9)."""
|
|
97
|
+
return bool(re.fullmatch(r"[0-9]+", s))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class CardsHandler(BaseHandler):
|
|
101
|
+
"""
|
|
102
|
+
Handle card-related operations:
|
|
103
|
+
- per-type create
|
|
104
|
+
- card types lookup
|
|
105
|
+
- availability check
|
|
106
|
+
- optional assignment to user (PUT preferred, POST legacy fallback)
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
# ──────────────────────────── helpers ────────────────────────────
|
|
110
|
+
def _headers(self) -> Dict[str, str]:
|
|
111
|
+
return {
|
|
112
|
+
"bs-session-id": self.get_session_id(),
|
|
113
|
+
"Content-Type": "application/json",
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async def _get_card_types_map(self) -> Dict[int, List[int]]:
|
|
117
|
+
"""
|
|
118
|
+
GET /api/cards/types -> { card_type_id(int): [allowed type ints] }
|
|
119
|
+
Adjust parsing if your server responds differently.
|
|
120
|
+
"""
|
|
121
|
+
self.check_auth()
|
|
122
|
+
url = f"{self.session.config.biostar_url}/api/cards/types"
|
|
123
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
124
|
+
resp = await client.get(url, headers=self._headers())
|
|
125
|
+
if resp.status_code != 200:
|
|
126
|
+
raise RuntimeError(f"Failed to get card types: {resp.status_code} - {resp.text}")
|
|
127
|
+
|
|
128
|
+
data = resp.json()
|
|
129
|
+
rows = (
|
|
130
|
+
data.get("CardTypes", {}).get("rows")
|
|
131
|
+
or data.get("rows")
|
|
132
|
+
or data.get("data")
|
|
133
|
+
or []
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
types_map: Dict[int, List[int]] = {}
|
|
137
|
+
for row in rows:
|
|
138
|
+
try:
|
|
139
|
+
cid = int(row.get("id"))
|
|
140
|
+
if "types" in row and isinstance(row["types"], list):
|
|
141
|
+
types_map[cid] = [int(x) for x in row["types"]]
|
|
142
|
+
elif "type" in row:
|
|
143
|
+
types_map[cid] = [int(row["type"])]
|
|
144
|
+
except Exception:
|
|
145
|
+
continue
|
|
146
|
+
return types_map
|
|
147
|
+
|
|
148
|
+
async def _resolve_type(self, card_type_id: int, explicit: Optional[int]) -> int:
|
|
149
|
+
"""Return explicit type if given; otherwise resolve from /api/cards/types with fallback."""
|
|
150
|
+
if explicit is not None:
|
|
151
|
+
return int(explicit)
|
|
152
|
+
try:
|
|
153
|
+
types_map = await self._get_card_types_map()
|
|
154
|
+
cand = types_map.get(int(card_type_id))
|
|
155
|
+
if cand and len(cand) > 0:
|
|
156
|
+
return int(cand[0])
|
|
157
|
+
except Exception as e:
|
|
158
|
+
logger.warning(f"_resolve_type: failed to fetch types, using fallback. reason={e}")
|
|
159
|
+
|
|
160
|
+
if int(card_type_id) in TYPE_FALLBACK:
|
|
161
|
+
return TYPE_FALLBACK[int(card_type_id)]
|
|
162
|
+
raise RuntimeError(f"No available 'type' for card_type.id={card_type_id}")
|
|
163
|
+
|
|
164
|
+
async def _check_availability(self, card_id: str) -> bool:
|
|
165
|
+
"""
|
|
166
|
+
Check via GET /api/v2/cards/registered/?card_id=...
|
|
167
|
+
If response contains "Card", it's already registered -> not available.
|
|
168
|
+
If only "Response":{"code":"0"} without "Card", it's available.
|
|
169
|
+
If endpoint not present (non-200), optimistically return True (server will enforce duplicates).
|
|
170
|
+
"""
|
|
171
|
+
self.check_auth()
|
|
172
|
+
url = f"{self.session.config.biostar_url}/api/v2/cards/registered/"
|
|
173
|
+
params = {"card_id": card_id}
|
|
174
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
175
|
+
resp = await client.get(url, headers=self._headers(), params=params)
|
|
176
|
+
|
|
177
|
+
if resp.status_code != 200:
|
|
178
|
+
logger.info(f"_check_availability: endpoint unavailable or error {resp.status_code}, optimistic True")
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
data = resp.json()
|
|
183
|
+
except Exception:
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
not_registered = "Card" not in data
|
|
187
|
+
return not_registered
|
|
188
|
+
|
|
189
|
+
# ── CSN validation ───────────────────────────────────────────────────────
|
|
190
|
+
def _validate_csn(self, card_id: str) -> Optional[str]:
|
|
191
|
+
"""
|
|
192
|
+
CSN must follow:
|
|
193
|
+
- ASCII digits only (0-9)
|
|
194
|
+
- cannot start with '0' (configurable)
|
|
195
|
+
- length 1..32
|
|
196
|
+
"""
|
|
197
|
+
s = (card_id or "").strip()
|
|
198
|
+
if not (CSN_RULES["min_len"] <= len(s) <= CSN_RULES["max_len"]):
|
|
199
|
+
return (
|
|
200
|
+
f"Invalid CSN card_id length. It must be {CSN_RULES['min_len']}–{CSN_RULES['max_len']} "
|
|
201
|
+
f"ASCII digits."
|
|
202
|
+
)
|
|
203
|
+
if CSN_RULES["ascii_digits_only"] and not _ascii_digits(s):
|
|
204
|
+
return "CSN card_id must contain ASCII digits only (0-9)."
|
|
205
|
+
if CSN_RULES["no_leading_zero"] and s.startswith("0"):
|
|
206
|
+
return "CSN card_id must not start with '0'."
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
# ── Wiegand-specific validation ──────────────────────────────────────────
|
|
210
|
+
def _get_wiegand_constraints(self, wf_id: int) -> Optional[Dict[str, Any]]:
|
|
211
|
+
return WIEGAND_CONSTRAINTS.get(int(wf_id))
|
|
212
|
+
|
|
213
|
+
def _validate_display_card_id(self, display_card_id: str) -> Optional[str]:
|
|
214
|
+
"""
|
|
215
|
+
Must be strictly numeric "FC-ID", e.g., "12-3456".
|
|
216
|
+
"""
|
|
217
|
+
if not isinstance(display_card_id, str):
|
|
218
|
+
return "display_card_id must be a string like '12-3456'."
|
|
219
|
+
if not re.fullmatch(r"\d+-\d+", display_card_id):
|
|
220
|
+
return "display_card_id must be numeric 'FC-ID' (e.g., '12-3456')."
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
def _validate_wiegand_values(
|
|
224
|
+
self,
|
|
225
|
+
*,
|
|
226
|
+
wf_id: int,
|
|
227
|
+
facility_code: Any,
|
|
228
|
+
card_number: Any,
|
|
229
|
+
display_card_id: Optional[str],
|
|
230
|
+
) -> Optional[Dict[str, Any]]:
|
|
231
|
+
"""
|
|
232
|
+
Return None if valid; otherwise return an error dict suitable for error_response details.
|
|
233
|
+
"""
|
|
234
|
+
c = self._get_wiegand_constraints(wf_id)
|
|
235
|
+
if not c:
|
|
236
|
+
return {
|
|
237
|
+
"reason": "Unsupported Wiegand format id",
|
|
238
|
+
"wiegand_format_id": wf_id,
|
|
239
|
+
"supported_format_ids": list(WIEGAND_CONSTRAINTS.keys()),
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
# numeric checks
|
|
243
|
+
try:
|
|
244
|
+
fc = int(facility_code)
|
|
245
|
+
cn = int(card_number)
|
|
246
|
+
except Exception:
|
|
247
|
+
return {
|
|
248
|
+
"reason": "facility_code and card_number must be integers",
|
|
249
|
+
"got": {"facility_code": facility_code, "card_number": card_number},
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
# range checks
|
|
253
|
+
fc_min, fc_max = c["facility_min"], c["facility_max"]
|
|
254
|
+
cn_min, cn_max = c["card_min"], c["card_max"]
|
|
255
|
+
fc_ok = (fc_min <= fc <= fc_max)
|
|
256
|
+
cn_ok = (cn_min <= cn <= cn_max)
|
|
257
|
+
if not fc_ok or not cn_ok:
|
|
258
|
+
return {
|
|
259
|
+
"reason": "Wiegand value out of range",
|
|
260
|
+
"format": c["name"],
|
|
261
|
+
"expected_ranges": {
|
|
262
|
+
"facility_code": [fc_min, fc_max],
|
|
263
|
+
"card_number": [cn_min, cn_max],
|
|
264
|
+
},
|
|
265
|
+
"got": {"facility_code": facility_code, "card_number": card_number},
|
|
266
|
+
"hint": f"Try display_card_id like '{c['example_display']}'",
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
# display_card_id pattern + consistency with fc/cn
|
|
270
|
+
if display_card_id is not None:
|
|
271
|
+
msg = self._validate_display_card_id(display_card_id)
|
|
272
|
+
if msg:
|
|
273
|
+
return {
|
|
274
|
+
"reason": msg,
|
|
275
|
+
"example": c["example_display"],
|
|
276
|
+
}
|
|
277
|
+
# match to fc-cn
|
|
278
|
+
m = display_card_id.split("-")
|
|
279
|
+
dfc, dcn = int(m[0]), int(m[1])
|
|
280
|
+
if dfc != fc or dcn != cn:
|
|
281
|
+
return {
|
|
282
|
+
"reason": "display_card_id must match facility_code and card_number",
|
|
283
|
+
"expected": f"{fc}-{cn}",
|
|
284
|
+
"got": display_card_id,
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
# all good
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
def _rows_payload(
|
|
291
|
+
self,
|
|
292
|
+
card_id: str,
|
|
293
|
+
card_type_id: int,
|
|
294
|
+
card_type_type: int,
|
|
295
|
+
*,
|
|
296
|
+
wiegand_format_id: Optional[int] = None,
|
|
297
|
+
mobile_opts: Optional[Dict[str, Any]] = None,
|
|
298
|
+
display_card_id: Optional[str] = None,
|
|
299
|
+
) -> Dict[str, Any]:
|
|
300
|
+
"""
|
|
301
|
+
Build POST /api/cards payload
|
|
302
|
+
"""
|
|
303
|
+
row: Dict[str, Any] = {
|
|
304
|
+
"card_id": str(card_id),
|
|
305
|
+
"card_type": {"id": str(int(card_type_id)), "type": str(int(card_type_type))},
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if wiegand_format_id is not None:
|
|
309
|
+
row["wiegand_format_id"] = {"id": str(int(wiegand_format_id))}
|
|
310
|
+
|
|
311
|
+
if display_card_id:
|
|
312
|
+
row["display_card_id"] = str(display_card_id)
|
|
313
|
+
|
|
314
|
+
if mobile_opts:
|
|
315
|
+
for k in ("isUserPhoto", "isDepartment", "isTitle", "start_datetime", "expiry_datetime"):
|
|
316
|
+
if k in mobile_opts and mobile_opts[k] is not None:
|
|
317
|
+
row[k] = mobile_opts[k]
|
|
318
|
+
if "display_card_id" in mobile_opts and mobile_opts["display_card_id"]:
|
|
319
|
+
row["display_card_id"] = mobile_opts["display_card_id"]
|
|
320
|
+
|
|
321
|
+
return {"CardCollection": {"rows": [row]}}
|
|
322
|
+
|
|
323
|
+
async def _post_create(self, payload: Dict[str, Any]) -> httpx.Response:
|
|
324
|
+
self.check_auth()
|
|
325
|
+
url = f"{self.session.config.biostar_url}/api/cards"
|
|
326
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
327
|
+
resp = await client.post(url, headers=self._headers(), json=payload)
|
|
328
|
+
return resp
|
|
329
|
+
|
|
330
|
+
# ── Assignment helpers ─────────────────────────────────────────────────
|
|
331
|
+
async def _assign_to_user_put(
|
|
332
|
+
self,
|
|
333
|
+
user_id: str,
|
|
334
|
+
create_json: Dict[str, Any],
|
|
335
|
+
*,
|
|
336
|
+
card_id: str,
|
|
337
|
+
card_type_id: int,
|
|
338
|
+
card_type_type: int,
|
|
339
|
+
) -> Dict[str, Any]:
|
|
340
|
+
"""
|
|
341
|
+
Preferred: PUT /api/users/{user_id} with {"User":{"cards":[{...}]}}
|
|
342
|
+
"""
|
|
343
|
+
created_rows = (create_json.get("CardCollection") or {}).get("rows") or []
|
|
344
|
+
created_row = created_rows[0] if created_rows else {}
|
|
345
|
+
|
|
346
|
+
card_payload = {
|
|
347
|
+
**({"id": str(created_row.get("id"))} if created_row.get("id") is not None else {}),
|
|
348
|
+
"card_id": str(card_id),
|
|
349
|
+
"display_card_id": str(created_row.get("display_card_id") or card_id),
|
|
350
|
+
"status": "1",
|
|
351
|
+
"is_blocked": "false",
|
|
352
|
+
"is_assigned": "true",
|
|
353
|
+
"card_type": {
|
|
354
|
+
"id": str(card_type_id),
|
|
355
|
+
"type": str(card_type_type),
|
|
356
|
+
},
|
|
357
|
+
"mobile_card": "false",
|
|
358
|
+
"issue_count": "1",
|
|
359
|
+
"card_slot": "1",
|
|
360
|
+
"card_mask": "0",
|
|
361
|
+
"wiegand_format_id": {"id": str((created_row.get("wiegand_format_id") or {}).get("id") or 0)},
|
|
362
|
+
"start_datetime": created_row.get("start_datetime") or "2001-01-01T00:00:00.00Z",
|
|
363
|
+
"expiry_datetime": created_row.get("expiry_datetime") or "2030-12-31T23:59:00.00Z",
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
payload = {"User": {"cards": [card_payload]}}
|
|
367
|
+
url = f"{self.session.config.biostar_url}/api/users/{user_id}"
|
|
368
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
369
|
+
resp = await client.put(url, headers=self._headers(), json=payload)
|
|
370
|
+
|
|
371
|
+
if resp.status_code != 200:
|
|
372
|
+
return {
|
|
373
|
+
"assigned": False,
|
|
374
|
+
"method": "PUT",
|
|
375
|
+
"status": resp.status_code,
|
|
376
|
+
"error": resp.text,
|
|
377
|
+
"request_body": payload,
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
"assigned": True,
|
|
381
|
+
"method": "PUT",
|
|
382
|
+
"response": resp.json(),
|
|
383
|
+
"request_body": payload,
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async def _assign_to_user_post_legacy(
|
|
387
|
+
self,
|
|
388
|
+
user_id: str,
|
|
389
|
+
*,
|
|
390
|
+
card_id: str,
|
|
391
|
+
card_type_id: int,
|
|
392
|
+
card_type_type: int,
|
|
393
|
+
) -> Dict[str, Any]:
|
|
394
|
+
"""
|
|
395
|
+
Legacy fallback: POST /api/users/{user_id}/cards with {"Card":{...}}
|
|
396
|
+
"""
|
|
397
|
+
payload = {
|
|
398
|
+
"Card": {
|
|
399
|
+
"card_id": str(card_id),
|
|
400
|
+
"card_type": {"id": str(card_type_id), "type": str(card_type_type)},
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
url = f"{self.session.config.biostar_url}/api/users/{user_id}/cards"
|
|
404
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
405
|
+
resp = await client.post(url, headers=self._headers(), json=payload)
|
|
406
|
+
|
|
407
|
+
if resp.status_code != 200:
|
|
408
|
+
return {
|
|
409
|
+
"assigned": False,
|
|
410
|
+
"method": "POST", # fixed typo from earlier
|
|
411
|
+
"status": resp.status_code,
|
|
412
|
+
"error": resp.text,
|
|
413
|
+
"request_body": payload,
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
"assigned": True,
|
|
417
|
+
"method": "POST",
|
|
418
|
+
"response": resp.json(),
|
|
419
|
+
"request_body": payload,
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async def _assign_to_user(
|
|
423
|
+
self,
|
|
424
|
+
user_id: str,
|
|
425
|
+
create_json: Dict[str, Any],
|
|
426
|
+
*,
|
|
427
|
+
card_id: str,
|
|
428
|
+
card_type_id: int,
|
|
429
|
+
card_type_type: int,
|
|
430
|
+
) -> Dict[str, Any]:
|
|
431
|
+
"""
|
|
432
|
+
Try PUT first; if it fails, fallback to POST legacy endpoint.
|
|
433
|
+
"""
|
|
434
|
+
put_res = await self._assign_to_user_put(
|
|
435
|
+
user_id,
|
|
436
|
+
create_json,
|
|
437
|
+
card_id=card_id,
|
|
438
|
+
card_type_id=card_type_id,
|
|
439
|
+
card_type_type=card_type_type,
|
|
440
|
+
)
|
|
441
|
+
if put_res.get("assigned"):
|
|
442
|
+
return put_res
|
|
443
|
+
post_res = await self._assign_to_user_post_legacy(
|
|
444
|
+
user_id,
|
|
445
|
+
card_id=card_id,
|
|
446
|
+
card_type_id=card_type_id,
|
|
447
|
+
card_type_type=card_type_type,
|
|
448
|
+
)
|
|
449
|
+
return {"put": put_res, "post": post_res, "assigned": bool(post_res.get("assigned"))}
|
|
450
|
+
|
|
451
|
+
# ── Creation flow ────────────────────────────────────────────────────────
|
|
452
|
+
async def _create_card_flow(
|
|
453
|
+
self,
|
|
454
|
+
*,
|
|
455
|
+
card_id_raw: Any,
|
|
456
|
+
card_type_id: int,
|
|
457
|
+
card_type_type_raw: Optional[Any],
|
|
458
|
+
assign_to_user_id: Optional[Any],
|
|
459
|
+
skip_availability_check: bool,
|
|
460
|
+
dry_run: bool,
|
|
461
|
+
validate_csn: bool = False,
|
|
462
|
+
extra_payload: Optional[Dict[str, Any]] = None,
|
|
463
|
+
) -> Sequence[Dict[str, Any]]:
|
|
464
|
+
"""Shared creation flow used by each per-type method."""
|
|
465
|
+
try:
|
|
466
|
+
card_id = str(card_id_raw).strip() if card_id_raw is not None else ""
|
|
467
|
+
if not card_id:
|
|
468
|
+
return self.error_response("card_id is required")
|
|
469
|
+
|
|
470
|
+
if validate_csn:
|
|
471
|
+
err = self._validate_csn(card_id)
|
|
472
|
+
if err:
|
|
473
|
+
return self.error_response(f"Invalid CSN card_id: {err}")
|
|
474
|
+
|
|
475
|
+
if not skip_availability_check:
|
|
476
|
+
available = await self._check_availability(card_id)
|
|
477
|
+
if not available:
|
|
478
|
+
return self.error_response(f"card_id '{card_id}' is not available (already exists).")
|
|
479
|
+
|
|
480
|
+
card_type_type = await self._resolve_type(
|
|
481
|
+
card_type_id,
|
|
482
|
+
int(card_type_type_raw) if card_type_type_raw is not None else None
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
extra_payload = extra_payload or {}
|
|
486
|
+
payload = self._rows_payload(
|
|
487
|
+
card_id,
|
|
488
|
+
card_type_id,
|
|
489
|
+
card_type_type,
|
|
490
|
+
wiegand_format_id=extra_payload.get("wiegand_format_id"),
|
|
491
|
+
mobile_opts=extra_payload.get("mobile_opts"),
|
|
492
|
+
display_card_id=extra_payload.get("display_card_id"),
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
if dry_run:
|
|
496
|
+
return self.success_response({"dry_run": True, "request_body": payload})
|
|
497
|
+
|
|
498
|
+
resp = await self._post_create(payload)
|
|
499
|
+
if resp.status_code != 200:
|
|
500
|
+
try:
|
|
501
|
+
ej = resp.json()
|
|
502
|
+
except Exception:
|
|
503
|
+
ej = None
|
|
504
|
+
if ej:
|
|
505
|
+
ec = ej.get("error_code") or ej.get("code")
|
|
506
|
+
msg = ej.get("message") or resp.text
|
|
507
|
+
return self.error_response(f"Failed to create card: {ec or resp.status_code} - {msg}")
|
|
508
|
+
return self.error_response(f"Failed to create card: {resp.status_code} - {resp.text}")
|
|
509
|
+
|
|
510
|
+
create_json = resp.json()
|
|
511
|
+
result: Dict[str, Any] = {
|
|
512
|
+
"message": "Card created",
|
|
513
|
+
"request_body": payload,
|
|
514
|
+
"response": create_json,
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if assign_to_user_id is not None:
|
|
518
|
+
assign_res = await self._assign_to_user(
|
|
519
|
+
user_id=str(assign_to_user_id),
|
|
520
|
+
create_json=create_json,
|
|
521
|
+
card_id=card_id,
|
|
522
|
+
card_type_id=card_type_id,
|
|
523
|
+
card_type_type=card_type_type,
|
|
524
|
+
)
|
|
525
|
+
result["assignment"] = assign_res
|
|
526
|
+
if not assign_res.get("assigned"):
|
|
527
|
+
result["message"] = "Card created, but assignment failed."
|
|
528
|
+
|
|
529
|
+
return self.success_response(result)
|
|
530
|
+
|
|
531
|
+
except Exception as e:
|
|
532
|
+
return await self.handle_api_error(e)
|
|
533
|
+
|
|
534
|
+
# ──────────────────────────── public tools ────────────────────────────
|
|
535
|
+
async def get_card_types(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
|
|
536
|
+
"""Tool: get-card-types"""
|
|
537
|
+
try:
|
|
538
|
+
self.check_auth()
|
|
539
|
+
types_map = await self._get_card_types_map()
|
|
540
|
+
return self.success_response({"types": types_map})
|
|
541
|
+
except Exception as e:
|
|
542
|
+
return await self.handle_api_error(e)
|
|
543
|
+
|
|
544
|
+
async def get_wiegand_format_presets(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
|
|
545
|
+
"""Tool: get-wiegand-format-presets"""
|
|
546
|
+
try:
|
|
547
|
+
self.check_auth()
|
|
548
|
+
return self.success_response({
|
|
549
|
+
"presets": WIEGAND_PRESETS,
|
|
550
|
+
"how_to_choose": (
|
|
551
|
+
"Choose a format used at your site. For 26bit (H10301), facility_code must be 0–255 and "
|
|
552
|
+
"card_number 0–65535. For HID37 (H10302), facility_code 0–65535 and card_number 0–524287. "
|
|
553
|
+
"display_card_id must be 'FC-ID' (e.g., '12-3456')."
|
|
554
|
+
),
|
|
555
|
+
"examples": [
|
|
556
|
+
{"wiegand_format_id": 0, "facility_code": 12, "card_number": 3456, "display_card_id": "12-3456"},
|
|
557
|
+
{"wiegand_format": "HID37", "facility_code": 100, "card_number": 12345, "display_card_id": "100-12345"},
|
|
558
|
+
],
|
|
559
|
+
})
|
|
560
|
+
except Exception as e:
|
|
561
|
+
return await self.handle_api_error(e)
|
|
562
|
+
|
|
563
|
+
async def check_card_availability(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
|
|
564
|
+
"""Tool: check-card-availability"""
|
|
565
|
+
try:
|
|
566
|
+
self.check_auth()
|
|
567
|
+
card_id = str(args.get("card_id", "")).strip()
|
|
568
|
+
if not card_id:
|
|
569
|
+
return self.error_response("card_id is required")
|
|
570
|
+
available = await self._check_availability(card_id)
|
|
571
|
+
return self.success_response({"card_id": card_id, "available": available})
|
|
572
|
+
except Exception as e:
|
|
573
|
+
return await self.handle_api_error(e)
|
|
574
|
+
|
|
575
|
+
# ── Per-type creators (names align with tool names) ───────────────────
|
|
576
|
+
async def create_card_csn(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
|
|
577
|
+
return await self._create_card_flow(
|
|
578
|
+
card_id_raw=args.get("card_id"),
|
|
579
|
+
card_type_id=CARD_KIND_TO_ID["CSN"],
|
|
580
|
+
card_type_type_raw=args.get("card_type_type"),
|
|
581
|
+
assign_to_user_id=args.get("assign_to_user_id"),
|
|
582
|
+
skip_availability_check=bool(args.get("skip_availability_check", False)),
|
|
583
|
+
dry_run=bool(args.get("dry_run", False)),
|
|
584
|
+
validate_csn=True,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
async def create_card_wiegand(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
|
|
588
|
+
# Resolve wiegand_format_id from alias if needed
|
|
589
|
+
wf_id = args.get("wiegand_format_id")
|
|
590
|
+
wf_alias = args.get("wiegand_format")
|
|
591
|
+
if wf_id is None and wf_alias:
|
|
592
|
+
wf_id = WIEGAND_FORMAT_ALIAS.get(str(wf_alias).lower())
|
|
593
|
+
|
|
594
|
+
if wf_id is None:
|
|
595
|
+
return self.error_response(
|
|
596
|
+
"wiegand_format_id is required for Wiegand cards.",
|
|
597
|
+
{"presets": WIEGAND_PRESETS}
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
# Required by tool schema; double-check & validate ranges
|
|
601
|
+
facility = args.get("facility_code")
|
|
602
|
+
number = args.get("card_number")
|
|
603
|
+
display_override = args.get("display_card_id")
|
|
604
|
+
|
|
605
|
+
# card_id must be numeric ASCII digits (no coercion)
|
|
606
|
+
cid_raw = args.get("card_id")
|
|
607
|
+
cid = str(cid_raw).strip() if cid_raw is not None else ""
|
|
608
|
+
if not cid:
|
|
609
|
+
return self.error_response("card_id is required")
|
|
610
|
+
if not _ascii_digits(cid):
|
|
611
|
+
return self.error_response("Wiegand card_id must be numeric (ASCII digits 0-9 only).")
|
|
612
|
+
|
|
613
|
+
# Validate facility/card_number + display_card_id consistency & ranges
|
|
614
|
+
err_details = self._validate_wiegand_values(
|
|
615
|
+
wf_id=int(wf_id),
|
|
616
|
+
facility_code=facility,
|
|
617
|
+
card_number=number,
|
|
618
|
+
display_card_id=display_override,
|
|
619
|
+
)
|
|
620
|
+
if err_details:
|
|
621
|
+
return self.error_response(
|
|
622
|
+
"Invalid Wiegand parameters for selected format.",
|
|
623
|
+
err_details
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
extra: Dict[str, Any] = {"wiegand_format_id": int(wf_id)}
|
|
627
|
+
if display_override:
|
|
628
|
+
extra["display_card_id"] = str(display_override)
|
|
629
|
+
else:
|
|
630
|
+
# Safe to set display as "FC-ID" derived from user-provided numeric inputs (no mutation)
|
|
631
|
+
extra["display_card_id"] = f"{int(facility)}-{int(number)}"
|
|
632
|
+
|
|
633
|
+
return await self._create_card_flow(
|
|
634
|
+
card_id_raw=cid,
|
|
635
|
+
card_type_id=CARD_KIND_TO_ID["WIEGAND"],
|
|
636
|
+
card_type_type_raw=args.get("card_type_type"),
|
|
637
|
+
assign_to_user_id=args.get("assign_to_user_id"),
|
|
638
|
+
skip_availability_check=bool(args.get("skip_availability_check", False)),
|
|
639
|
+
dry_run=bool(args.get("dry_run", False)),
|
|
640
|
+
extra_payload=extra,
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
async def create_card_secure_credential(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
|
|
644
|
+
return await self._create_card_flow(
|
|
645
|
+
card_id_raw=args.get("card_id"),
|
|
646
|
+
card_type_id=CARD_KIND_TO_ID["SECURE_CREDENTIAL"],
|
|
647
|
+
card_type_type_raw=args.get("card_type_type"),
|
|
648
|
+
assign_to_user_id=args.get("assign_to_user_id"),
|
|
649
|
+
skip_availability_check=bool(args.get("skip_availability_check", False)),
|
|
650
|
+
dry_run=bool(args.get("dry_run", False)),
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
async def create_card_access_on_card(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
|
|
654
|
+
return await self._create_card_flow(
|
|
655
|
+
card_id_raw=args.get("card_id"),
|
|
656
|
+
card_type_id=CARD_KIND_TO_ID["ACCESS_ON_CARD"],
|
|
657
|
+
card_type_type_raw=args.get("card_type_type"),
|
|
658
|
+
assign_to_user_id=args.get("assign_to_user_id"),
|
|
659
|
+
skip_availability_check=bool(args.get("skip_availability_check", False)),
|
|
660
|
+
dry_run=bool(args.get("dry_run", False)),
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
async def create_card_mobile_csn(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
|
|
664
|
+
mobile_opts = {k: args.get(k) for k in (
|
|
665
|
+
"isUserPhoto", "isDepartment", "isTitle", "start_datetime", "expiry_datetime", "display_card_id"
|
|
666
|
+
)}
|
|
667
|
+
return await self._create_card_flow(
|
|
668
|
+
card_id_raw=args.get("card_id"),
|
|
669
|
+
card_type_id=CARD_KIND_TO_ID["MOBILE_CSN"],
|
|
670
|
+
card_type_type_raw=args.get("card_type_type"),
|
|
671
|
+
assign_to_user_id=args.get("assign_to_user_id"),
|
|
672
|
+
skip_availability_check=bool(args.get("skip_availability_check", False)),
|
|
673
|
+
dry_run=bool(args.get("dry_run", False)),
|
|
674
|
+
extra_payload={"mobile_opts": mobile_opts},
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
async def create_card_wiegand_mobile(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
|
|
678
|
+
wf_id = args.get("wiegand_format_id")
|
|
679
|
+
wf_alias = args.get("wiegand_format")
|
|
680
|
+
if wf_id is None and wf_alias:
|
|
681
|
+
wf_id = WIEGAND_FORMAT_ALIAS.get(str(wf_alias).lower())
|
|
682
|
+
|
|
683
|
+
if wf_id is None:
|
|
684
|
+
return self.error_response(
|
|
685
|
+
"wiegand_format_id is required for Wiegand Mobile cards.",
|
|
686
|
+
{"presets": WIEGAND_PRESETS},
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
facility = args.get("facility_code")
|
|
690
|
+
number = args.get("card_number")
|
|
691
|
+
display_override = args.get("display_card_id")
|
|
692
|
+
|
|
693
|
+
cid_raw = args.get("card_id")
|
|
694
|
+
cid = str(cid_raw).strip() if cid_raw is not None else ""
|
|
695
|
+
if not cid:
|
|
696
|
+
return self.error_response("card_id is required")
|
|
697
|
+
if not _ascii_digits(cid):
|
|
698
|
+
return self.error_response("Wiegand Mobile card_id must be numeric (ASCII digits 0-9 only).")
|
|
699
|
+
|
|
700
|
+
err_details = self._validate_wiegand_values(
|
|
701
|
+
wf_id=int(wf_id),
|
|
702
|
+
facility_code=facility,
|
|
703
|
+
card_number=number,
|
|
704
|
+
display_card_id=display_override,
|
|
705
|
+
)
|
|
706
|
+
if err_details:
|
|
707
|
+
return self.error_response(
|
|
708
|
+
"Invalid Wiegand parameters for selected format.",
|
|
709
|
+
err_details
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
extra: Dict[str, Any] = {"wiegand_format_id": int(wf_id)}
|
|
713
|
+
if display_override:
|
|
714
|
+
extra["display_card_id"] = str(display_override)
|
|
715
|
+
else:
|
|
716
|
+
extra["display_card_id"] = f"{int(facility)}-{int(number)}"
|
|
717
|
+
|
|
718
|
+
return await self._create_card_flow(
|
|
719
|
+
card_id_raw=cid,
|
|
720
|
+
card_type_id=CARD_KIND_TO_ID["WIEGAND_MOBILE"],
|
|
721
|
+
card_type_type_raw=args.get("card_type_type"),
|
|
722
|
+
assign_to_user_id=args.get("assign_to_user_id"),
|
|
723
|
+
skip_availability_check=bool(args.get("skip_availability_check", False)),
|
|
724
|
+
dry_run=bool(args.get("dry_run", False)),
|
|
725
|
+
extra_payload=extra,
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
async def create_card_qr_barcode(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
|
|
729
|
+
return await self._create_card_flow(
|
|
730
|
+
card_id_raw=args.get("card_id"),
|
|
731
|
+
card_type_id=CARD_KIND_TO_ID["QR_BARCODE"],
|
|
732
|
+
card_type_type_raw=args.get("card_type_type"),
|
|
733
|
+
assign_to_user_id=args.get("assign_to_user_id"),
|
|
734
|
+
skip_availability_check=bool(args.get("skip_availability_check", False)),
|
|
735
|
+
dry_run=bool(args.get("dry_run", False)),
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
async def create_card_biostar2_qr(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
|
|
739
|
+
return await self._create_card_flow(
|
|
740
|
+
card_id_raw=args.get("card_id"),
|
|
741
|
+
card_type_id=CARD_KIND_TO_ID["BIOSTAR2_QR"],
|
|
742
|
+
card_type_type_raw=args.get("card_type_type"),
|
|
743
|
+
assign_to_user_id=args.get("assign_to_user_id"),
|
|
744
|
+
skip_availability_check=bool(args.get("skip_availability_check", False)),
|
|
745
|
+
dry_run=bool(args.get("dry_run", False)),
|
|
746
|
+
)
|