simplyplural-cli 0.1.0__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.
- simplyplural/__init__.py +3 -0
- simplyplural/api_client.py +616 -0
- simplyplural/cache_manager.py +294 -0
- simplyplural/cli.py +1409 -0
- simplyplural/config_manager.py +687 -0
- simplyplural/daemon.py +1042 -0
- simplyplural/daemon_client.py +313 -0
- simplyplural/daemon_protocol.py +219 -0
- simplyplural/shell_integration.py +169 -0
- simplyplural_cli-0.1.0.dist-info/METADATA +599 -0
- simplyplural_cli-0.1.0.dist-info/RECORD +15 -0
- simplyplural_cli-0.1.0.dist-info/WHEEL +5 -0
- simplyplural_cli-0.1.0.dist-info/entry_points.txt +2 -0
- simplyplural_cli-0.1.0.dist-info/licenses/LICENSE +504 -0
- simplyplural_cli-0.1.0.dist-info/top_level.txt +1 -0
simplyplural/__init__.py
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Simply Plural API Client
|
|
3
|
+
|
|
4
|
+
Handles all communication with the Simply Plural API, including:
|
|
5
|
+
- Authentication
|
|
6
|
+
- Rate limiting
|
|
7
|
+
- Error handling
|
|
8
|
+
- Endpoint abstraction
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
import time
|
|
13
|
+
import json
|
|
14
|
+
from typing import List, Dict, Any, Optional
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class APIError(Exception):
|
|
20
|
+
"""Exception raised for API-related errors"""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SimplyPluralAPI:
|
|
25
|
+
"""Simply Plural API client"""
|
|
26
|
+
|
|
27
|
+
BASE_URL = "https://api.apparyllis.com/v1"
|
|
28
|
+
|
|
29
|
+
def __init__(self, api_token: str, config_manager=None, debug: bool = False, cache_manager=None):
|
|
30
|
+
self.api_token = api_token
|
|
31
|
+
self.config = config_manager
|
|
32
|
+
self.debug = debug
|
|
33
|
+
self.cache = cache_manager
|
|
34
|
+
|
|
35
|
+
# Get timeout and retry settings from config
|
|
36
|
+
self.timeout = config_manager.api_timeout if config_manager else 10
|
|
37
|
+
self.max_retries = config_manager.max_retries if config_manager else 3
|
|
38
|
+
|
|
39
|
+
self.session = requests.Session()
|
|
40
|
+
self.session.headers.update({
|
|
41
|
+
'Authorization': api_token,
|
|
42
|
+
'User-Agent': 'SimplyPlural-CLI/1.0',
|
|
43
|
+
'Content-Type': 'application/json'
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
def _filter_sensitive_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
|
|
47
|
+
"""Filter out any potentially sensitive headers from debug output"""
|
|
48
|
+
# Headers that should never be logged (case-insensitive)
|
|
49
|
+
sensitive_headers = {'authorization', 'x-api-key', 'x-auth-token', 'bearer'}
|
|
50
|
+
|
|
51
|
+
filtered = {}
|
|
52
|
+
for key, value in headers.items():
|
|
53
|
+
if key.lower() in sensitive_headers:
|
|
54
|
+
filtered[key] = "[REDACTED]"
|
|
55
|
+
else:
|
|
56
|
+
filtered[key] = value
|
|
57
|
+
return filtered
|
|
58
|
+
|
|
59
|
+
def _sanitize_debug_data(self, data: Any) -> Any:
|
|
60
|
+
"""Remove any potentially sensitive data from debug output"""
|
|
61
|
+
if isinstance(data, dict):
|
|
62
|
+
sanitized = {}
|
|
63
|
+
for key, value in data.items():
|
|
64
|
+
if any(sensitive in key.lower() for sensitive in ['token', 'auth', 'password', 'secret']):
|
|
65
|
+
sanitized[key] = "[REDACTED]"
|
|
66
|
+
else:
|
|
67
|
+
sanitized[key] = self._sanitize_debug_data(value)
|
|
68
|
+
return sanitized
|
|
69
|
+
elif isinstance(data, list):
|
|
70
|
+
return [self._sanitize_debug_data(item) for item in data]
|
|
71
|
+
else:
|
|
72
|
+
return data
|
|
73
|
+
|
|
74
|
+
def _sanitize_debug_text(self, text: str) -> str:
|
|
75
|
+
"""Remove tokens from debug text output"""
|
|
76
|
+
# Simple pattern to catch base64-like tokens (44+ chars of base64)
|
|
77
|
+
import re
|
|
78
|
+
# Replace what looks like long base64 tokens with [REDACTED]
|
|
79
|
+
sanitized = re.sub(r'[A-Za-z0-9+/]{44,}={0,2}', '[REDACTED_TOKEN]', text)
|
|
80
|
+
return sanitized
|
|
81
|
+
|
|
82
|
+
def _generate_fallback_name(self, member_id: str, fronter: Dict[str, Any]) -> str:
|
|
83
|
+
"""Generate a more useful fallback name when member details aren't available"""
|
|
84
|
+
# Extract potentially useful info from the fronter object
|
|
85
|
+
custom_status = fronter.get('content', {}).get('customStatus', '')
|
|
86
|
+
|
|
87
|
+
# Use middle section of ID for better uniqueness (where IDs actually differ)
|
|
88
|
+
if len(member_id) >= 16:
|
|
89
|
+
# Show chars 8-16 which tend to be more unique
|
|
90
|
+
unique_part = member_id[8:16]
|
|
91
|
+
short_id = f"ID-{unique_part}"
|
|
92
|
+
elif len(member_id) >= 8:
|
|
93
|
+
# Fallback to first 8 chars
|
|
94
|
+
short_id = f"ID-{member_id[:8]}"
|
|
95
|
+
else:
|
|
96
|
+
short_id = f"ID-{member_id}"
|
|
97
|
+
|
|
98
|
+
# If there's a custom status, include it
|
|
99
|
+
if custom_status and custom_status.strip():
|
|
100
|
+
return f"{short_id} ({custom_status.strip()})"
|
|
101
|
+
else:
|
|
102
|
+
return short_id
|
|
103
|
+
|
|
104
|
+
def _request(self, method: str, endpoint: str, **kwargs) -> Dict[Any, Any]:
|
|
105
|
+
"""Make an API request with retries"""
|
|
106
|
+
|
|
107
|
+
url = f"{self.BASE_URL}{endpoint}"
|
|
108
|
+
# Use configured timeout, allow override via kwargs
|
|
109
|
+
timeout = kwargs.pop('timeout', self.timeout)
|
|
110
|
+
|
|
111
|
+
if self.debug:
|
|
112
|
+
print(f"DEBUG: {method} {url}")
|
|
113
|
+
if 'json' in kwargs:
|
|
114
|
+
print(f"DEBUG: Request body: {json.dumps(kwargs['json'], indent=2)}")
|
|
115
|
+
if 'params' in kwargs:
|
|
116
|
+
print(f"DEBUG: Query params: {kwargs['params']}")
|
|
117
|
+
|
|
118
|
+
last_exception = None
|
|
119
|
+
|
|
120
|
+
# Retry loop
|
|
121
|
+
for attempt in range(self.max_retries):
|
|
122
|
+
try:
|
|
123
|
+
response = self.session.request(method, url, timeout=timeout, **kwargs)
|
|
124
|
+
|
|
125
|
+
if self.debug:
|
|
126
|
+
print(f"DEBUG: Response status: {response.status_code}")
|
|
127
|
+
# Safely show response headers (never contains our auth token)
|
|
128
|
+
safe_headers = self._filter_sensitive_headers(dict(response.headers))
|
|
129
|
+
print(f"DEBUG: Response headers: {safe_headers}")
|
|
130
|
+
# Sanitize response text to prevent token leaks
|
|
131
|
+
sanitized_response = self._sanitize_debug_text(response.text)
|
|
132
|
+
print(f"DEBUG: Response text: {sanitized_response[:500]}{'...' if len(sanitized_response) > 500 else ''}")
|
|
133
|
+
|
|
134
|
+
# Handle rate limiting
|
|
135
|
+
if response.status_code == 429:
|
|
136
|
+
retry_after = int(response.headers.get('Retry-After', 60))
|
|
137
|
+
if self.debug:
|
|
138
|
+
print(f"DEBUG: Server returned HTTP 429 - actual rate limit from API")
|
|
139
|
+
raise APIError(f"Rate limited (server). Retry after {retry_after} seconds.")
|
|
140
|
+
|
|
141
|
+
# Handle other HTTP errors
|
|
142
|
+
if response.status_code == 401:
|
|
143
|
+
raise APIError("HTTP 401 - Bad Request. Check if your token has the correct permissions (in particular, write permissions are needed to update fronters). If you are sure your token is not the problem, open a bug report at https://github.com/SiteRelEnby/simplyplural-cli/issues")
|
|
144
|
+
elif response.status_code == 403:
|
|
145
|
+
if self.debug:
|
|
146
|
+
print(f"DEBUG: HTTP 403 - Access denied. Check your token is entered correctly and not revoked.")
|
|
147
|
+
raise APIError("HTTP 403 - Access denied. Check your token is entered correctly and not revoked.")
|
|
148
|
+
elif response.status_code == 404:
|
|
149
|
+
if self.debug:
|
|
150
|
+
print(f"DEBUG: HTTP 404 - Endpoint {endpoint} not found")
|
|
151
|
+
raise APIError("HTTP 404 - Not found. This is probably a bug - open a report at https://github.com/SiteRelEnby/simplyplural-cli/issues")
|
|
152
|
+
elif response.status_code >= 400:
|
|
153
|
+
try:
|
|
154
|
+
error_data = response.json()
|
|
155
|
+
error_msg = error_data.get('message', f'HTTP {response.status_code}')
|
|
156
|
+
except:
|
|
157
|
+
error_msg = f'HTTP {response.status_code}: {response.text[:100]}'
|
|
158
|
+
if self.debug:
|
|
159
|
+
sanitized_error = self._sanitize_debug_text(error_msg)
|
|
160
|
+
print(f"DEBUG: HTTP {response.status_code} error: {sanitized_error}")
|
|
161
|
+
raise APIError(error_msg)
|
|
162
|
+
|
|
163
|
+
# Parse JSON response
|
|
164
|
+
try:
|
|
165
|
+
# Handle empty successful responses (like PATCH updates)
|
|
166
|
+
if response.status_code == 200 and not response.text.strip():
|
|
167
|
+
return {}
|
|
168
|
+
|
|
169
|
+
result = response.json()
|
|
170
|
+
if self.debug:
|
|
171
|
+
print(f"DEBUG: Parsed JSON: {json.dumps(self._sanitize_debug_data(result), indent=2) if result else 'None'}")
|
|
172
|
+
return result
|
|
173
|
+
except json.JSONDecodeError:
|
|
174
|
+
if response.status_code == 204: # No Content
|
|
175
|
+
return {}
|
|
176
|
+
# For successful responses with empty content, return empty dict
|
|
177
|
+
if response.status_code == 200 and not response.text.strip():
|
|
178
|
+
return {}
|
|
179
|
+
raise APIError("Invalid JSON response from API")
|
|
180
|
+
|
|
181
|
+
except (requests.Timeout, requests.ConnectionError, requests.RequestException) as e:
|
|
182
|
+
last_exception = e
|
|
183
|
+
if attempt < self.max_retries - 1:
|
|
184
|
+
if self.debug:
|
|
185
|
+
print(f"DEBUG: Request failed (attempt {attempt + 1}/{self.max_retries}): {e}")
|
|
186
|
+
# Exponential backoff for retries
|
|
187
|
+
time.sleep(2 ** attempt)
|
|
188
|
+
continue
|
|
189
|
+
else:
|
|
190
|
+
# Final attempt failed
|
|
191
|
+
if isinstance(e, requests.Timeout):
|
|
192
|
+
raise APIError("Request timed out. Check your connection.")
|
|
193
|
+
elif isinstance(e, requests.ConnectionError):
|
|
194
|
+
raise APIError("Connection failed. Check your internet connection.")
|
|
195
|
+
else:
|
|
196
|
+
raise APIError(f"Request failed: {e}")
|
|
197
|
+
|
|
198
|
+
# This shouldn't be reached, but just in case
|
|
199
|
+
raise APIError(f"Request failed after {self.max_retries} attempts: {last_exception}")
|
|
200
|
+
|
|
201
|
+
def get_fronters(self) -> Dict[str, Any]:
|
|
202
|
+
"""Get current fronters with resolved member/custom front names"""
|
|
203
|
+
fronters_response = self._request('GET', '/fronters')
|
|
204
|
+
|
|
205
|
+
# If it's a list of fronter objects, try to resolve names
|
|
206
|
+
if isinstance(fronters_response, list):
|
|
207
|
+
resolved_fronters = []
|
|
208
|
+
for fronter in fronters_response:
|
|
209
|
+
if 'content' in fronter and 'member' in fronter['content']:
|
|
210
|
+
entity_id = fronter['content']['member']
|
|
211
|
+
is_custom = fronter['content'].get('custom', False)
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
if is_custom:
|
|
215
|
+
# It's a custom front
|
|
216
|
+
custom_front = self.get_custom_front(entity_id)
|
|
217
|
+
fronter_with_name = fronter.copy()
|
|
218
|
+
fronter_with_name['name'] = custom_front.get('content', {}).get('name', self._generate_fallback_name(entity_id, fronter))
|
|
219
|
+
fronter_with_name['type'] = 'custom_front'
|
|
220
|
+
else:
|
|
221
|
+
# It's a regular member
|
|
222
|
+
member = self.get_member(entity_id)
|
|
223
|
+
fronter_with_name = fronter.copy()
|
|
224
|
+
fronter_with_name['name'] = member.get('content', {}).get('name', self._generate_fallback_name(entity_id, fronter))
|
|
225
|
+
fronter_with_name['type'] = 'member'
|
|
226
|
+
|
|
227
|
+
resolved_fronters.append(fronter_with_name)
|
|
228
|
+
except APIError:
|
|
229
|
+
# If we can't get details, use ID as fallback
|
|
230
|
+
fronter_with_name = fronter.copy()
|
|
231
|
+
fronter_with_name['name'] = self._generate_fallback_name(entity_id, fronter)
|
|
232
|
+
fronter_with_name['type'] = 'custom_front' if is_custom else 'member'
|
|
233
|
+
resolved_fronters.append(fronter_with_name)
|
|
234
|
+
else:
|
|
235
|
+
resolved_fronters.append(fronter)
|
|
236
|
+
return resolved_fronters
|
|
237
|
+
|
|
238
|
+
return fronters_response
|
|
239
|
+
|
|
240
|
+
def get_system_id(self) -> str:
|
|
241
|
+
"""Get the system ID from /me endpoint"""
|
|
242
|
+
# Check if we have it cached
|
|
243
|
+
if hasattr(self, '_system_id') and self._system_id:
|
|
244
|
+
return self._system_id
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
response = self._request('GET', '/me')
|
|
248
|
+
# The system ID might be in different fields
|
|
249
|
+
system_id = None
|
|
250
|
+
|
|
251
|
+
if isinstance(response, dict):
|
|
252
|
+
# Try common field names for system ID
|
|
253
|
+
for field in ['id', 'uid', 'system_id', 'systemId', 'user_id', 'userId']:
|
|
254
|
+
if field in response and response[field]:
|
|
255
|
+
system_id = response[field]
|
|
256
|
+
break
|
|
257
|
+
|
|
258
|
+
# Also check if it's nested
|
|
259
|
+
if not system_id and 'content' in response:
|
|
260
|
+
content = response['content']
|
|
261
|
+
for field in ['id', 'uid', 'system_id', 'systemId']:
|
|
262
|
+
if field in content and content[field]:
|
|
263
|
+
system_id = content[field]
|
|
264
|
+
break
|
|
265
|
+
|
|
266
|
+
if not system_id:
|
|
267
|
+
if self.debug:
|
|
268
|
+
print(f"DEBUG: Could not find system ID in /me response: {response}")
|
|
269
|
+
raise APIError("Could not extract system ID from /me endpoint")
|
|
270
|
+
|
|
271
|
+
# Cache it
|
|
272
|
+
self._system_id = system_id
|
|
273
|
+
if self.debug:
|
|
274
|
+
print(f"DEBUG: Found system ID: {system_id}")
|
|
275
|
+
|
|
276
|
+
return system_id
|
|
277
|
+
|
|
278
|
+
except APIError as e:
|
|
279
|
+
if self.debug:
|
|
280
|
+
print(f"DEBUG: Failed to get system ID from /me: {e}")
|
|
281
|
+
raise APIError(f"Could not get system ID: {e}")
|
|
282
|
+
|
|
283
|
+
def get_members(self) -> List[Dict[str, Any]]:
|
|
284
|
+
"""Get all members using the correct /members/{system_id} format"""
|
|
285
|
+
try:
|
|
286
|
+
# Get system ID first
|
|
287
|
+
system_id = self.get_system_id()
|
|
288
|
+
|
|
289
|
+
# Try the correct endpoint format
|
|
290
|
+
endpoint = f'/members/{system_id}'
|
|
291
|
+
|
|
292
|
+
if self.debug:
|
|
293
|
+
print(f"DEBUG: Trying members endpoint {endpoint}")
|
|
294
|
+
|
|
295
|
+
response = self._request('GET', endpoint)
|
|
296
|
+
|
|
297
|
+
# Handle different response formats
|
|
298
|
+
if isinstance(response, list):
|
|
299
|
+
return response
|
|
300
|
+
elif isinstance(response, dict):
|
|
301
|
+
# Try common keys for member lists
|
|
302
|
+
for key in ['members', 'profiles', 'system', 'data']:
|
|
303
|
+
if key in response and isinstance(response[key], list):
|
|
304
|
+
return response[key]
|
|
305
|
+
|
|
306
|
+
# If it's a single member object, wrap in list
|
|
307
|
+
if 'id' in response:
|
|
308
|
+
return [response]
|
|
309
|
+
|
|
310
|
+
if self.debug:
|
|
311
|
+
print(f"DEBUG: Unexpected response format from {endpoint}: {type(response)}")
|
|
312
|
+
print(f"DEBUG: Response: {response}")
|
|
313
|
+
|
|
314
|
+
# If we got here, try fallback endpoints
|
|
315
|
+
if self.debug:
|
|
316
|
+
print(f"DEBUG: Trying fallback endpoints...")
|
|
317
|
+
|
|
318
|
+
fallback_endpoints = [
|
|
319
|
+
'/members', # Maybe it works without system ID
|
|
320
|
+
'/profiles', # Maybe it's called profiles
|
|
321
|
+
'/system', # Maybe system info includes members
|
|
322
|
+
f'/system/{system_id}/members', # Alternative format
|
|
323
|
+
f'/user/{system_id}/members', # User-scoped format
|
|
324
|
+
]
|
|
325
|
+
|
|
326
|
+
for fallback_endpoint in fallback_endpoints:
|
|
327
|
+
try:
|
|
328
|
+
if self.debug:
|
|
329
|
+
print(f"DEBUG: Trying fallback endpoint {fallback_endpoint}")
|
|
330
|
+
response = self._request('GET', fallback_endpoint)
|
|
331
|
+
|
|
332
|
+
if isinstance(response, list):
|
|
333
|
+
return response
|
|
334
|
+
elif isinstance(response, dict):
|
|
335
|
+
for key in ['members', 'profiles', 'system', 'data']:
|
|
336
|
+
if key in response and isinstance(response[key], list):
|
|
337
|
+
return response[key]
|
|
338
|
+
|
|
339
|
+
except APIError as e:
|
|
340
|
+
if self.debug:
|
|
341
|
+
print(f"DEBUG: Fallback endpoint {fallback_endpoint} failed: {e}")
|
|
342
|
+
continue
|
|
343
|
+
|
|
344
|
+
raise APIError(f"No valid members endpoint found. Tried {endpoint} and fallbacks.")
|
|
345
|
+
|
|
346
|
+
except APIError as e:
|
|
347
|
+
if "Could not get system ID" in str(e):
|
|
348
|
+
# If we can't get system ID, the user might need to check their token permissions
|
|
349
|
+
raise APIError(f"Cannot get member list: {e}. Check if your API token has the required permissions.")
|
|
350
|
+
else:
|
|
351
|
+
raise e
|
|
352
|
+
|
|
353
|
+
def get_member(self, member_id: str) -> Dict[str, Any]:
|
|
354
|
+
"""Get a specific member with caching using the correct /member/{system_id}/{member_id} format"""
|
|
355
|
+
# Check cache first
|
|
356
|
+
if self.cache:
|
|
357
|
+
cached_member = self.cache.get_member(member_id)
|
|
358
|
+
if cached_member:
|
|
359
|
+
if self.debug:
|
|
360
|
+
print(f"DEBUG: Using cached member {member_id}: {cached_member.get('content', {}).get('name', 'Unknown')}")
|
|
361
|
+
return cached_member
|
|
362
|
+
|
|
363
|
+
if self.debug:
|
|
364
|
+
print(f"DEBUG: Cache miss for member {member_id}, fetching from API")
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
# Get system ID first
|
|
368
|
+
system_id = self.get_system_id()
|
|
369
|
+
|
|
370
|
+
# Use the correct endpoint format
|
|
371
|
+
endpoint = f'/member/{system_id}/{member_id}'
|
|
372
|
+
|
|
373
|
+
if self.debug:
|
|
374
|
+
print(f"DEBUG: Trying to get member {member_id} from {endpoint}")
|
|
375
|
+
|
|
376
|
+
member_data = self._request('GET', endpoint)
|
|
377
|
+
|
|
378
|
+
# Cache the result
|
|
379
|
+
if self.cache:
|
|
380
|
+
self.cache.set_member(member_id, member_data)
|
|
381
|
+
if self.debug:
|
|
382
|
+
print(f"DEBUG: Cached member {member_id}: {member_data.get('content', {}).get('name', 'Unknown')}")
|
|
383
|
+
|
|
384
|
+
return member_data
|
|
385
|
+
|
|
386
|
+
except APIError as e:
|
|
387
|
+
if self.debug:
|
|
388
|
+
print(f"DEBUG: Failed to get member {member_id}: {e}")
|
|
389
|
+
raise APIError(f"Could not fetch member {member_id}: {e}")
|
|
390
|
+
|
|
391
|
+
def get_custom_fronts(self) -> List[Dict[str, Any]]:
|
|
392
|
+
"""Get all custom fronts for this system"""
|
|
393
|
+
# Try cache first
|
|
394
|
+
if self.cache:
|
|
395
|
+
cached_custom_fronts = self.cache.get_custom_fronts()
|
|
396
|
+
if cached_custom_fronts is not None:
|
|
397
|
+
if self.debug:
|
|
398
|
+
print(f"DEBUG: Using cached custom fronts: {len(cached_custom_fronts)} custom fronts")
|
|
399
|
+
return cached_custom_fronts
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
system_id = self.get_system_id()
|
|
403
|
+
if self.debug:
|
|
404
|
+
print(f"DEBUG: Fetching custom fronts for system {system_id}")
|
|
405
|
+
|
|
406
|
+
response = self._request('GET', f'/customFronts/{system_id}')
|
|
407
|
+
|
|
408
|
+
# Return the list of custom fronts
|
|
409
|
+
custom_fronts = response if isinstance(response, list) else []
|
|
410
|
+
|
|
411
|
+
# Cache the results
|
|
412
|
+
if self.cache:
|
|
413
|
+
self.cache.set_custom_fronts(custom_fronts)
|
|
414
|
+
|
|
415
|
+
return custom_fronts
|
|
416
|
+
|
|
417
|
+
except APIError as e:
|
|
418
|
+
if self.debug:
|
|
419
|
+
print(f"DEBUG: Failed to get custom fronts: {e}")
|
|
420
|
+
raise APIError(f"Could not fetch custom fronts: {e}")
|
|
421
|
+
|
|
422
|
+
def get_custom_front(self, custom_front_id: str) -> Dict[str, Any]:
|
|
423
|
+
"""Get a specific custom front by ID"""
|
|
424
|
+
# Try cache first
|
|
425
|
+
if self.cache:
|
|
426
|
+
cached_custom_front = self.cache.get_custom_front(custom_front_id)
|
|
427
|
+
if cached_custom_front is not None:
|
|
428
|
+
if self.debug:
|
|
429
|
+
print(f"DEBUG: Using cached custom front {custom_front_id}")
|
|
430
|
+
return cached_custom_front
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
system_id = self.get_system_id()
|
|
434
|
+
if self.debug:
|
|
435
|
+
print(f"DEBUG: Fetching custom front {custom_front_id} for system {system_id}")
|
|
436
|
+
|
|
437
|
+
response = self._request('GET', f'/customFront/{system_id}/{custom_front_id}')
|
|
438
|
+
|
|
439
|
+
if 'content' in response:
|
|
440
|
+
# Cache the result
|
|
441
|
+
if self.cache:
|
|
442
|
+
self.cache.set_custom_front(custom_front_id, response)
|
|
443
|
+
return response
|
|
444
|
+
else:
|
|
445
|
+
raise APIError(f"Invalid custom front response format")
|
|
446
|
+
|
|
447
|
+
except APIError as e:
|
|
448
|
+
if self.debug:
|
|
449
|
+
print(f"DEBUG: Failed to get custom front {custom_front_id}: {e}")
|
|
450
|
+
raise APIError(f"Could not fetch custom front {custom_front_id}: {e}")
|
|
451
|
+
|
|
452
|
+
def register_switch(self, names: List[str], note: Optional[str] = None) -> Dict[str, Any]:
|
|
453
|
+
"""Register a switch to one or more members or custom fronts using the frontHistory API"""
|
|
454
|
+
|
|
455
|
+
# Get both members and custom fronts
|
|
456
|
+
members = self.get_members()
|
|
457
|
+
custom_fronts = self.get_custom_fronts()
|
|
458
|
+
|
|
459
|
+
# Create maps for name lookup
|
|
460
|
+
member_map = {m['content']['name'].lower(): {'id': m['id'], 'type': 'member'} for m in members}
|
|
461
|
+
custom_front_map = {cf['content']['name'].lower(): {'id': cf['id'], 'type': 'custom_front'} for cf in custom_fronts}
|
|
462
|
+
|
|
463
|
+
# Combine both maps for unified lookup
|
|
464
|
+
entity_map = {**member_map, **custom_front_map}
|
|
465
|
+
|
|
466
|
+
entities = []
|
|
467
|
+
for name in names:
|
|
468
|
+
entity = entity_map.get(name.lower())
|
|
469
|
+
if not entity:
|
|
470
|
+
# Try partial matching in both members and custom fronts
|
|
471
|
+
member_matches = [m for m in members if name.lower() in m['content']['name'].lower()]
|
|
472
|
+
custom_front_matches = [cf for cf in custom_fronts if name.lower() in cf['content']['name'].lower()]
|
|
473
|
+
|
|
474
|
+
all_matches = [
|
|
475
|
+
{'entity': m, 'type': 'member'} for m in member_matches
|
|
476
|
+
] + [
|
|
477
|
+
{'entity': cf, 'type': 'custom_front'} for cf in custom_front_matches
|
|
478
|
+
]
|
|
479
|
+
|
|
480
|
+
if len(all_matches) == 1:
|
|
481
|
+
match = all_matches[0]
|
|
482
|
+
entity = {'id': match['entity']['id'], 'type': match['type']}
|
|
483
|
+
elif len(all_matches) > 1:
|
|
484
|
+
names_list = [f"{match['entity']['content']['name']} ({match['type']})" for match in all_matches]
|
|
485
|
+
raise APIError(f"Ambiguous name '{name}'. Matches: {', '.join(names_list)}")
|
|
486
|
+
else:
|
|
487
|
+
available_members = [m['content']['name'] for m in members]
|
|
488
|
+
available_custom_fronts = [cf['content']['name'] for cf in custom_fronts]
|
|
489
|
+
all_available = [f"{name} (member)" for name in available_members] + [f"{name} (custom_front)" for name in available_custom_fronts]
|
|
490
|
+
raise APIError(f"Name '{name}' not found. Available: {', '.join(all_available)}")
|
|
491
|
+
entities.append(entity)
|
|
492
|
+
|
|
493
|
+
# Step 1: End all current live fronting sessions
|
|
494
|
+
current_fronters = self._request('GET', '/fronters')
|
|
495
|
+
current_time_ms = int(time.time() * 1000)
|
|
496
|
+
|
|
497
|
+
if self.debug:
|
|
498
|
+
print(f"DEBUG: Found {len(current_fronters)} current fronters to end")
|
|
499
|
+
|
|
500
|
+
for fronter in current_fronters:
|
|
501
|
+
if fronter.get('content', {}).get('live', False):
|
|
502
|
+
front_id = fronter['id']
|
|
503
|
+
if self.debug:
|
|
504
|
+
print(f"DEBUG: Ending front session {front_id}")
|
|
505
|
+
|
|
506
|
+
end_data = {
|
|
507
|
+
'live': False,
|
|
508
|
+
'endTime': current_time_ms
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
try:
|
|
512
|
+
self._request('PATCH', f'/frontHistory/{front_id}', json=end_data)
|
|
513
|
+
except APIError as e:
|
|
514
|
+
if self.debug:
|
|
515
|
+
print(f"DEBUG: Warning - failed to end front session {front_id}: {e}")
|
|
516
|
+
# Continue anyway - maybe it was already ended
|
|
517
|
+
|
|
518
|
+
# Step 2: Create new front sessions for the requested entities (members or custom fronts)
|
|
519
|
+
import random
|
|
520
|
+
results = []
|
|
521
|
+
|
|
522
|
+
for entity in entities:
|
|
523
|
+
# Generate a new ObjectId-style string (24 hex characters)
|
|
524
|
+
new_front_id = ''.join(random.choices('0123456789abcdef', k=24))
|
|
525
|
+
|
|
526
|
+
start_data = {
|
|
527
|
+
'member': entity['id'],
|
|
528
|
+
'startTime': current_time_ms + 1, # Slightly after end time
|
|
529
|
+
'live': True,
|
|
530
|
+
'custom': entity['type'] == 'custom_front'
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if note:
|
|
534
|
+
start_data['customStatus'] = note
|
|
535
|
+
|
|
536
|
+
if self.debug:
|
|
537
|
+
entity_type = 'custom front' if entity['type'] == 'custom_front' else 'member'
|
|
538
|
+
print(f"DEBUG: Creating front session {new_front_id} for {entity_type} {entity['id']}")
|
|
539
|
+
|
|
540
|
+
result = self._request('POST', f'/frontHistory/{new_front_id}', json=start_data)
|
|
541
|
+
results.append(result)
|
|
542
|
+
|
|
543
|
+
return results[0] if len(results) == 1 else results
|
|
544
|
+
|
|
545
|
+
def get_switches(self, period: str = "recent", count: int = 10) -> List[Dict[str, Any]]:
|
|
546
|
+
"""Get switch history using the correct frontHistory endpoint with required time parameters"""
|
|
547
|
+
try:
|
|
548
|
+
# Get system ID first
|
|
549
|
+
system_id = self.get_system_id()
|
|
550
|
+
|
|
551
|
+
# Calculate time range based on period
|
|
552
|
+
current_time_ms = int(time.time() * 1000)
|
|
553
|
+
|
|
554
|
+
if period == "today":
|
|
555
|
+
# Start of today
|
|
556
|
+
today = time.gmtime(time.time())
|
|
557
|
+
start_of_day = time.mktime((today.tm_year, today.tm_mon, today.tm_mday, 0, 0, 0, 0, 0, 0))
|
|
558
|
+
start_time_ms = int(start_of_day * 1000)
|
|
559
|
+
elif period == "week":
|
|
560
|
+
# Start of this week (7 days ago)
|
|
561
|
+
start_time_ms = current_time_ms - (7 * 24 * 60 * 60 * 1000)
|
|
562
|
+
else: # "recent" or any other value
|
|
563
|
+
# Default to last 30 days
|
|
564
|
+
start_time_ms = current_time_ms - (30 * 24 * 60 * 60 * 1000)
|
|
565
|
+
|
|
566
|
+
# Use the correct frontHistory endpoint with required parameters
|
|
567
|
+
endpoint = f'/frontHistory/{system_id}'
|
|
568
|
+
params = {
|
|
569
|
+
'startTime': start_time_ms,
|
|
570
|
+
'endTime': current_time_ms
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if self.debug:
|
|
574
|
+
print(f"DEBUG: Using frontHistory endpoint: {endpoint}")
|
|
575
|
+
print(f"DEBUG: Time range: {start_time_ms} to {current_time_ms} ({period})")
|
|
576
|
+
|
|
577
|
+
response = self._request('GET', endpoint, params=params)
|
|
578
|
+
|
|
579
|
+
# Handle the response - it should be a list of front history entries
|
|
580
|
+
if isinstance(response, list):
|
|
581
|
+
# Sort by timestamp (most recent first) and limit count
|
|
582
|
+
sorted_entries = sorted(response,
|
|
583
|
+
key=lambda x: x.get('content', {}).get('startTime', 0),
|
|
584
|
+
reverse=True)
|
|
585
|
+
return sorted_entries[:count]
|
|
586
|
+
else:
|
|
587
|
+
if self.debug:
|
|
588
|
+
print(f"DEBUG: Unexpected frontHistory response format: {type(response)}")
|
|
589
|
+
return []
|
|
590
|
+
|
|
591
|
+
except APIError as e:
|
|
592
|
+
if self.debug:
|
|
593
|
+
print(f"DEBUG: Failed to get switch history: {e}")
|
|
594
|
+
raise APIError(f"Could not fetch switch history: {e}")
|
|
595
|
+
|
|
596
|
+
def export_data(self) -> Dict[str, Any]:
|
|
597
|
+
"""Export all user data"""
|
|
598
|
+
data = {}
|
|
599
|
+
|
|
600
|
+
try:
|
|
601
|
+
data['members'] = self.get_members()
|
|
602
|
+
except APIError:
|
|
603
|
+
data['members'] = []
|
|
604
|
+
|
|
605
|
+
try:
|
|
606
|
+
data['fronters'] = self.get_fronters()
|
|
607
|
+
except APIError:
|
|
608
|
+
data['fronters'] = {}
|
|
609
|
+
|
|
610
|
+
try:
|
|
611
|
+
data['switches'] = self.get_switches(count=1000)
|
|
612
|
+
except APIError:
|
|
613
|
+
data['switches'] = []
|
|
614
|
+
|
|
615
|
+
data['exported_at'] = time.strftime('%Y-%m-%dT%H:%M:%S.000Z', time.gmtime())
|
|
616
|
+
return data
|