hpde-analytics-cli 1.0.0__tar.gz → 6.0.0__tar.gz
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.
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/PKG-INFO +9 -5
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/README.md +2 -2
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli/api/client.py +192 -91
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli/auth/oauth.py +14 -15
- hpde_analytics_cli-6.0.0/hpde_analytics_cli/integrations/__init__.py +1 -0
- hpde_analytics_cli-6.0.0/hpde_analytics_cli/integrations/email_populator.py +247 -0
- hpde_analytics_cli-6.0.0/hpde_analytics_cli/integrations/google_sheets.py +150 -0
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli/main.py +137 -5
- hpde_analytics_cli-6.0.0/hpde_analytics_cli/utils/data_export.py +394 -0
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli/utils/field_discovery.py +26 -23
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli/utils/report_generator.py +262 -222
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli.egg-info/PKG-INFO +9 -5
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli.egg-info/SOURCES.txt +5 -0
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli.egg-info/requires.txt +3 -0
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/pyproject.toml +8 -4
- hpde_analytics_cli-6.0.0/tests/test_email_populator.py +388 -0
- hpde_analytics_cli-6.0.0/tests/test_google_sheets.py +225 -0
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/tests/test_main.py +100 -0
- hpde_analytics_cli-1.0.0/hpde_analytics_cli/utils/data_export.py +0 -366
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/LICENSE +0 -0
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli/__init__.py +0 -0
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli/api/__init__.py +0 -0
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli/auth/__init__.py +0 -0
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli/auth/credentials.py +0 -0
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli/utils/__init__.py +0 -0
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli.egg-info/dependency_links.txt +0 -0
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli.egg-info/entry_points.txt +0 -0
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli.egg-info/top_level.txt +0 -0
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/setup.cfg +0 -0
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/tests/test_client.py +0 -0
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/tests/test_credentials.py +0 -0
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/tests/test_data_export.py +0 -0
- {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/tests/test_report_generator.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hpde-analytics-cli
|
|
3
|
-
Version:
|
|
3
|
+
Version: 6.0.0
|
|
4
4
|
Summary: Data analysis and reporting tool for HPDE and Time Trials programs
|
|
5
5
|
Author-email: Kevin Homan <homan13@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -15,13 +15,14 @@ Classifier: Intended Audience :: End Users/Desktop
|
|
|
15
15
|
Classifier: License :: OSI Approved :: MIT License
|
|
16
16
|
Classifier: Operating System :: OS Independent
|
|
17
17
|
Classifier: Programming Language :: Python :: 3
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
19
18
|
Classifier: Programming Language :: Python :: 3.9
|
|
20
19
|
Classifier: Programming Language :: Python :: 3.10
|
|
21
20
|
Classifier: Programming Language :: Python :: 3.11
|
|
22
21
|
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
24
|
Classifier: Topic :: Utilities
|
|
24
|
-
Requires-Python: >=3.
|
|
25
|
+
Requires-Python: >=3.9
|
|
25
26
|
Description-Content-Type: text/markdown
|
|
26
27
|
License-File: LICENSE
|
|
27
28
|
Requires-Dist: requests>=2.28.0
|
|
@@ -29,6 +30,8 @@ Requires-Dist: requests-oauthlib>=1.3.0
|
|
|
29
30
|
Requires-Dist: python-dotenv>=1.0.0
|
|
30
31
|
Requires-Dist: openpyxl>=3.1.0
|
|
31
32
|
Requires-Dist: keyring>=24.0.0
|
|
33
|
+
Requires-Dist: gspread>=6.0.0
|
|
34
|
+
Requires-Dist: google-auth>=2.22.0
|
|
32
35
|
Provides-Extra: dev
|
|
33
36
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
34
37
|
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
@@ -38,6 +41,7 @@ Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
|
38
41
|
Requires-Dist: isort>=5.12.0; extra == "dev"
|
|
39
42
|
Requires-Dist: bandit>=1.7.0; extra == "dev"
|
|
40
43
|
Requires-Dist: safety>=2.3.0; extra == "dev"
|
|
44
|
+
Requires-Dist: pre-commit>=3.5.0; extra == "dev"
|
|
41
45
|
Dynamic: license-file
|
|
42
46
|
|
|
43
47
|
# HPDE Analytics CLI
|
|
@@ -278,11 +282,11 @@ This project uses [Semantic Versioning (SemVer)](https://semver.org/):
|
|
|
278
282
|
- **MINOR** version for new functionality in a backward compatible manner
|
|
279
283
|
- **PATCH** version for backward compatible bug fixes
|
|
280
284
|
|
|
281
|
-
**Current Version:**
|
|
285
|
+
**Current Version:** 2.0.0
|
|
282
286
|
|
|
283
287
|
## Authors
|
|
284
288
|
|
|
285
|
-
* **Kevin Homan**
|
|
289
|
+
* **Kevin Homan**
|
|
286
290
|
|
|
287
291
|
## License
|
|
288
292
|
|
|
@@ -236,11 +236,11 @@ This project uses [Semantic Versioning (SemVer)](https://semver.org/):
|
|
|
236
236
|
- **MINOR** version for new functionality in a backward compatible manner
|
|
237
237
|
- **PATCH** version for backward compatible bug fixes
|
|
238
238
|
|
|
239
|
-
**Current Version:**
|
|
239
|
+
**Current Version:** 2.0.0
|
|
240
240
|
|
|
241
241
|
## Authors
|
|
242
242
|
|
|
243
|
-
* **Kevin Homan**
|
|
243
|
+
* **Kevin Homan**
|
|
244
244
|
|
|
245
245
|
## License
|
|
246
246
|
|
|
@@ -11,6 +11,10 @@ from requests_oauthlib import OAuth1Session
|
|
|
11
11
|
|
|
12
12
|
from hpde_analytics_cli.auth.oauth import MSROAuth
|
|
13
13
|
|
|
14
|
+
# Error message constants
|
|
15
|
+
ERR_EVENT_ID_REQUIRED = "Event ID is required"
|
|
16
|
+
ERR_ORG_ID_REQUIRED = "Organization ID is required"
|
|
17
|
+
|
|
14
18
|
|
|
15
19
|
class APIError(Exception):
|
|
16
20
|
"""Exception raised for API errors."""
|
|
@@ -46,6 +50,121 @@ class MSRClient:
|
|
|
46
50
|
"""Get an authenticated OAuth session."""
|
|
47
51
|
return self.oauth.get_oauth_session()
|
|
48
52
|
|
|
53
|
+
def _execute_http_request(
|
|
54
|
+
self,
|
|
55
|
+
method: str,
|
|
56
|
+
session: OAuth1Session,
|
|
57
|
+
url: str,
|
|
58
|
+
params: Optional[Dict[str, Any]],
|
|
59
|
+
headers: Dict[str, str],
|
|
60
|
+
):
|
|
61
|
+
"""Execute HTTP request based on method type."""
|
|
62
|
+
if method.upper() == "GET":
|
|
63
|
+
return session.get(url, params=params, headers=headers)
|
|
64
|
+
elif method.upper() == "POST":
|
|
65
|
+
return session.post(url, data=params, headers=headers)
|
|
66
|
+
else:
|
|
67
|
+
raise APIError(f"Unsupported HTTP method: {method}")
|
|
68
|
+
|
|
69
|
+
def _handle_response_status(self, response, endpoint: str) -> Optional[Dict[str, Any]]:
|
|
70
|
+
"""
|
|
71
|
+
Handle HTTP response status codes.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Parsed response data if successful, None if should retry
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
APIError: For non-retryable errors
|
|
78
|
+
"""
|
|
79
|
+
if response.status_code == 200:
|
|
80
|
+
data = response.json()
|
|
81
|
+
# MSR wraps responses in {"response": {...}}
|
|
82
|
+
if isinstance(data, dict) and "response" in data:
|
|
83
|
+
data = data["response"]
|
|
84
|
+
return data
|
|
85
|
+
|
|
86
|
+
if response.status_code == 401:
|
|
87
|
+
raise APIError(
|
|
88
|
+
"Authentication failed - tokens may be invalid or expired",
|
|
89
|
+
status_code=401,
|
|
90
|
+
response_body=response.text,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if response.status_code == 403:
|
|
94
|
+
raise APIError(
|
|
95
|
+
"Access forbidden - insufficient permissions",
|
|
96
|
+
status_code=403,
|
|
97
|
+
response_body=response.text,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if response.status_code == 404:
|
|
101
|
+
raise APIError(
|
|
102
|
+
f"Resource not found: {endpoint}",
|
|
103
|
+
status_code=404,
|
|
104
|
+
response_body=response.text,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if response.status_code >= 500:
|
|
108
|
+
# Server error - signal retry
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
raise APIError(
|
|
112
|
+
f"Request failed with status {response.status_code}",
|
|
113
|
+
status_code=response.status_code,
|
|
114
|
+
response_body=response.text,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _should_retry_on_server_error(
|
|
118
|
+
self, response, endpoint: str, attempt: int, retries: int
|
|
119
|
+
) -> APIError:
|
|
120
|
+
"""
|
|
121
|
+
Handle server error response and determine if retry should occur.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
response: HTTP response object
|
|
125
|
+
endpoint: API endpoint path
|
|
126
|
+
attempt: Current attempt number
|
|
127
|
+
retries: Maximum number of retries
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
APIError to raise if no more retries
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
APIError: If no more retries remain
|
|
134
|
+
"""
|
|
135
|
+
error = APIError(
|
|
136
|
+
f"Server error: {response.status_code}",
|
|
137
|
+
status_code=response.status_code,
|
|
138
|
+
response_body=response.text,
|
|
139
|
+
)
|
|
140
|
+
if attempt < retries:
|
|
141
|
+
time.sleep(self.retry_delay * (attempt + 1))
|
|
142
|
+
else:
|
|
143
|
+
raise error
|
|
144
|
+
return error
|
|
145
|
+
|
|
146
|
+
def _should_retry_on_exception(self, e: Exception, attempt: int, retries: int) -> APIError:
|
|
147
|
+
"""
|
|
148
|
+
Handle request exception and determine if retry should occur.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
e: Exception that occurred
|
|
152
|
+
attempt: Current attempt number
|
|
153
|
+
retries: Maximum number of retries
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
APIError to raise if no more retries
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
APIError: If no more retries remain
|
|
160
|
+
"""
|
|
161
|
+
error = APIError(f"Request failed: {str(e)}")
|
|
162
|
+
if attempt < retries:
|
|
163
|
+
time.sleep(self.retry_delay * (attempt + 1))
|
|
164
|
+
else:
|
|
165
|
+
raise error
|
|
166
|
+
return error
|
|
167
|
+
|
|
49
168
|
def _request(
|
|
50
169
|
self,
|
|
51
170
|
method: str,
|
|
@@ -89,64 +208,21 @@ class MSRClient:
|
|
|
89
208
|
|
|
90
209
|
for attempt in range(retries + 1):
|
|
91
210
|
try:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
# MSR wraps responses in {"response": {...}}
|
|
103
|
-
if isinstance(data, dict) and "response" in data:
|
|
104
|
-
data = data["response"]
|
|
105
|
-
return data
|
|
106
|
-
elif response.status_code == 401:
|
|
107
|
-
raise APIError(
|
|
108
|
-
"Authentication failed - tokens may be invalid or expired",
|
|
109
|
-
status_code=401,
|
|
110
|
-
response_body=response.text,
|
|
111
|
-
)
|
|
112
|
-
elif response.status_code == 403:
|
|
113
|
-
raise APIError(
|
|
114
|
-
"Access forbidden - insufficient permissions",
|
|
115
|
-
status_code=403,
|
|
116
|
-
response_body=response.text,
|
|
117
|
-
)
|
|
118
|
-
elif response.status_code == 404:
|
|
119
|
-
raise APIError(
|
|
120
|
-
f"Resource not found: {endpoint}",
|
|
121
|
-
status_code=404,
|
|
122
|
-
response_body=response.text,
|
|
123
|
-
)
|
|
124
|
-
elif response.status_code >= 500:
|
|
125
|
-
# Server error - retry
|
|
126
|
-
last_error = APIError(
|
|
127
|
-
f"Server error: {response.status_code}",
|
|
128
|
-
status_code=response.status_code,
|
|
129
|
-
response_body=response.text,
|
|
130
|
-
)
|
|
131
|
-
if attempt < retries:
|
|
132
|
-
time.sleep(self.retry_delay * (attempt + 1))
|
|
133
|
-
continue
|
|
134
|
-
raise last_error
|
|
135
|
-
else:
|
|
136
|
-
raise APIError(
|
|
137
|
-
f"Request failed with status {response.status_code}",
|
|
138
|
-
status_code=response.status_code,
|
|
139
|
-
response_body=response.text,
|
|
140
|
-
)
|
|
211
|
+
response = self._execute_http_request(method, session, url, params, headers)
|
|
212
|
+
result = self._handle_response_status(response, endpoint)
|
|
213
|
+
|
|
214
|
+
if result is not None:
|
|
215
|
+
return result
|
|
216
|
+
|
|
217
|
+
# Server error - retry
|
|
218
|
+
last_error = self._should_retry_on_server_error(
|
|
219
|
+
response, endpoint, attempt, retries
|
|
220
|
+
)
|
|
141
221
|
|
|
142
222
|
except APIError:
|
|
143
223
|
raise
|
|
144
224
|
except Exception as e:
|
|
145
|
-
last_error =
|
|
146
|
-
if attempt < retries:
|
|
147
|
-
time.sleep(self.retry_delay * (attempt + 1))
|
|
148
|
-
continue
|
|
149
|
-
raise last_error
|
|
225
|
+
last_error = self._should_retry_on_exception(e, attempt, retries)
|
|
150
226
|
|
|
151
227
|
raise last_error
|
|
152
228
|
|
|
@@ -173,7 +249,7 @@ class MSRClient:
|
|
|
173
249
|
"""
|
|
174
250
|
org_id = organization_id or self.organization_id
|
|
175
251
|
if not org_id:
|
|
176
|
-
raise ValueError(
|
|
252
|
+
raise ValueError(ERR_ORG_ID_REQUIRED)
|
|
177
253
|
|
|
178
254
|
return self._request("GET", f"/rest/calendars/organization/{org_id}")
|
|
179
255
|
|
|
@@ -190,7 +266,7 @@ class MSRClient:
|
|
|
190
266
|
Endpoint: GET /rest/events/{event_id}/entrylist
|
|
191
267
|
"""
|
|
192
268
|
if not event_id:
|
|
193
|
-
raise ValueError(
|
|
269
|
+
raise ValueError(ERR_EVENT_ID_REQUIRED)
|
|
194
270
|
|
|
195
271
|
return self._request("GET", f"/rest/events/{event_id}/entrylist")
|
|
196
272
|
|
|
@@ -207,7 +283,7 @@ class MSRClient:
|
|
|
207
283
|
Endpoint: GET /rest/events/{event_id}/attendees
|
|
208
284
|
"""
|
|
209
285
|
if not event_id:
|
|
210
|
-
raise ValueError(
|
|
286
|
+
raise ValueError(ERR_EVENT_ID_REQUIRED)
|
|
211
287
|
|
|
212
288
|
return self._request("GET", f"/rest/events/{event_id}/attendees")
|
|
213
289
|
|
|
@@ -224,7 +300,7 @@ class MSRClient:
|
|
|
224
300
|
Endpoint: GET /rest/events/{event_id}/assignments
|
|
225
301
|
"""
|
|
226
302
|
if not event_id:
|
|
227
|
-
raise ValueError(
|
|
303
|
+
raise ValueError(ERR_EVENT_ID_REQUIRED)
|
|
228
304
|
|
|
229
305
|
return self._request("GET", f"/rest/events/{event_id}/assignments")
|
|
230
306
|
|
|
@@ -241,10 +317,61 @@ class MSRClient:
|
|
|
241
317
|
Endpoint: GET /rest/events/{event_id}/feeds/timing
|
|
242
318
|
"""
|
|
243
319
|
if not event_id:
|
|
244
|
-
raise ValueError(
|
|
320
|
+
raise ValueError(ERR_EVENT_ID_REQUIRED)
|
|
245
321
|
|
|
246
322
|
return self._request("GET", f"/rest/events/{event_id}/feeds/timing")
|
|
247
323
|
|
|
324
|
+
def _fetch_user_profile(self, results: Dict[str, Any]) -> None:
|
|
325
|
+
"""Fetch and store user profile data."""
|
|
326
|
+
print(" Fetching /rest/me...")
|
|
327
|
+
try:
|
|
328
|
+
results["me"] = self.get_me()
|
|
329
|
+
print(" [OK] User profile retrieved")
|
|
330
|
+
except APIError as e:
|
|
331
|
+
print(f" [ERROR] {e}")
|
|
332
|
+
results["me"] = {"error": str(e)}
|
|
333
|
+
|
|
334
|
+
def _fetch_organization_calendar(
|
|
335
|
+
self, results: Dict[str, Any], event_id: Optional[str]
|
|
336
|
+
) -> Optional[str]:
|
|
337
|
+
"""
|
|
338
|
+
Fetch and store organization calendar data.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
Event ID if found in calendar, otherwise the input event_id
|
|
342
|
+
"""
|
|
343
|
+
if not self.organization_id:
|
|
344
|
+
return event_id
|
|
345
|
+
|
|
346
|
+
print(f" Fetching organization calendar (org: {self.organization_id})...")
|
|
347
|
+
try:
|
|
348
|
+
results["calendar"] = self.get_organization_calendar()
|
|
349
|
+
print(" [OK] Calendar retrieved")
|
|
350
|
+
|
|
351
|
+
# Get first event ID if not provided
|
|
352
|
+
if not event_id:
|
|
353
|
+
events = results["calendar"].get("events", [])
|
|
354
|
+
if events:
|
|
355
|
+
event_id = events[0].get("id")
|
|
356
|
+
print(f" Using first event from calendar: {event_id}")
|
|
357
|
+
except APIError as e:
|
|
358
|
+
print(f" [ERROR] {e}")
|
|
359
|
+
results["calendar"] = {"error": str(e)}
|
|
360
|
+
|
|
361
|
+
return event_id
|
|
362
|
+
|
|
363
|
+
def _fetch_event_endpoint(
|
|
364
|
+
self, results: Dict[str, Any], key: str, method, description: str, event_id: str
|
|
365
|
+
) -> None:
|
|
366
|
+
"""Fetch data from a single event endpoint."""
|
|
367
|
+
print(f" Fetching {description} (event: {event_id})...")
|
|
368
|
+
try:
|
|
369
|
+
results[key] = method(event_id)
|
|
370
|
+
print(f" [OK] {description} retrieved")
|
|
371
|
+
except APIError as e:
|
|
372
|
+
print(f" [ERROR] {e}")
|
|
373
|
+
results[key] = {"error": str(e)}
|
|
374
|
+
|
|
248
375
|
def get_all_endpoint_data(self, event_id: Optional[str] = None) -> Dict[str, Any]:
|
|
249
376
|
"""
|
|
250
377
|
Fetch data from all available endpoints for field discovery.
|
|
@@ -255,33 +382,13 @@ class MSRClient:
|
|
|
255
382
|
Returns:
|
|
256
383
|
Dict with data from each endpoint, keyed by endpoint name
|
|
257
384
|
"""
|
|
258
|
-
results = {}
|
|
385
|
+
results: Dict[str, Any] = {}
|
|
259
386
|
|
|
260
|
-
#
|
|
261
|
-
|
|
262
|
-
try:
|
|
263
|
-
results["me"] = self.get_me()
|
|
264
|
-
print(" [OK] User profile retrieved")
|
|
265
|
-
except APIError as e:
|
|
266
|
-
print(f" [ERROR] {e}")
|
|
267
|
-
results["me"] = {"error": str(e)}
|
|
387
|
+
# Fetch user profile
|
|
388
|
+
self._fetch_user_profile(results)
|
|
268
389
|
|
|
269
|
-
# Fetch organization calendar
|
|
270
|
-
|
|
271
|
-
print(f" Fetching organization calendar (org: {self.organization_id})...")
|
|
272
|
-
try:
|
|
273
|
-
results["calendar"] = self.get_organization_calendar()
|
|
274
|
-
print(" [OK] Calendar retrieved")
|
|
275
|
-
|
|
276
|
-
# Get first event ID if not provided
|
|
277
|
-
if not event_id:
|
|
278
|
-
events = results["calendar"].get("events", [])
|
|
279
|
-
if events:
|
|
280
|
-
event_id = events[0].get("id")
|
|
281
|
-
print(f" Using first event from calendar: {event_id}")
|
|
282
|
-
except APIError as e:
|
|
283
|
-
print(f" [ERROR] {e}")
|
|
284
|
-
results["calendar"] = {"error": str(e)}
|
|
390
|
+
# Fetch organization calendar and potentially get event_id
|
|
391
|
+
event_id = self._fetch_organization_calendar(results, event_id)
|
|
285
392
|
|
|
286
393
|
# Event-specific endpoints (require event_id)
|
|
287
394
|
if event_id:
|
|
@@ -293,13 +400,7 @@ class MSRClient:
|
|
|
293
400
|
]
|
|
294
401
|
|
|
295
402
|
for key, method, description in event_endpoints:
|
|
296
|
-
|
|
297
|
-
try:
|
|
298
|
-
results[key] = method(event_id)
|
|
299
|
-
print(f" [OK] {description} retrieved")
|
|
300
|
-
except APIError as e:
|
|
301
|
-
print(f" [ERROR] {e}")
|
|
302
|
-
results[key] = {"error": str(e)}
|
|
403
|
+
self._fetch_event_endpoint(results, key, method, description, event_id)
|
|
303
404
|
else:
|
|
304
405
|
print(" [SKIP] No event ID available - skipping event-specific endpoints")
|
|
305
406
|
print(" Use --event-id to specify an event, or ensure calendar has events")
|
|
@@ -35,8 +35,8 @@ class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
|
|
35
35
|
parsed = urlparse(self.path)
|
|
36
36
|
params = parse_qs(parsed.query)
|
|
37
37
|
|
|
38
|
-
# Log
|
|
39
|
-
print(
|
|
38
|
+
# Log callback received (without sensitive path details)
|
|
39
|
+
print("\n [Callback] Received OAuth callback request")
|
|
40
40
|
|
|
41
41
|
# Ignore non-callback requests (favicon, root path, etc.)
|
|
42
42
|
if not parsed.path.startswith("/callback"):
|
|
@@ -55,7 +55,7 @@ class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
|
|
55
55
|
self.wfile.write(response.encode())
|
|
56
56
|
return
|
|
57
57
|
|
|
58
|
-
print(
|
|
58
|
+
print(" [Callback] OAuth parameters received")
|
|
59
59
|
|
|
60
60
|
# Extract OAuth parameters
|
|
61
61
|
OAuthCallbackHandler.oauth_verifier = params.get("oauth_verifier", [None])[0]
|
|
@@ -236,7 +236,7 @@ class MSROAuth:
|
|
|
236
236
|
if not self.request_token:
|
|
237
237
|
raise Exception("Failed to obtain request token")
|
|
238
238
|
|
|
239
|
-
print(
|
|
239
|
+
print(" Request token obtained: [REDACTED]")
|
|
240
240
|
return self.request_token, self.request_token_secret
|
|
241
241
|
|
|
242
242
|
def get_authorization_url(self) -> str:
|
|
@@ -255,8 +255,7 @@ class MSROAuth:
|
|
|
255
255
|
|
|
256
256
|
print("\n[Step 2/3] User authorization required")
|
|
257
257
|
print("-" * 60)
|
|
258
|
-
print("
|
|
259
|
-
print(f"\n {auth_url}\n")
|
|
258
|
+
print("Opening authorization URL in browser...")
|
|
260
259
|
print("-" * 60)
|
|
261
260
|
|
|
262
261
|
return auth_url
|
|
@@ -294,7 +293,7 @@ class MSROAuth:
|
|
|
294
293
|
if not self.access_token:
|
|
295
294
|
raise Exception("Failed to obtain access token")
|
|
296
295
|
|
|
297
|
-
print(
|
|
296
|
+
print(" Access token obtained: [REDACTED]")
|
|
298
297
|
if self.profile_id:
|
|
299
298
|
print(f" Profile ID: {self.profile_id}")
|
|
300
299
|
|
|
@@ -449,24 +448,24 @@ class MSROAuth:
|
|
|
449
448
|
# Step 2: Get authorization URL
|
|
450
449
|
auth_url = self.get_authorization_url()
|
|
451
450
|
|
|
451
|
+
# Always print the URL so the user can copy/paste it if needed
|
|
452
|
+
print(f"\nAuthorization URL:\n\n {auth_url}\n")
|
|
453
|
+
|
|
452
454
|
# Open browser automatically if requested
|
|
453
455
|
if auto_open_browser:
|
|
454
|
-
print("
|
|
455
|
-
print("IMPORTANT: Copy and paste this URL into your browser:")
|
|
456
|
-
print(f"\n {auth_url}\n")
|
|
457
|
-
print("!" * 60)
|
|
458
|
-
print("\nAttempting to open browser automatically...")
|
|
456
|
+
print("Attempting to open browser automatically...")
|
|
459
457
|
try:
|
|
460
458
|
webbrowser.open(auth_url)
|
|
459
|
+
print(" Browser opened. If nothing appeared, copy the URL above.")
|
|
461
460
|
except Exception as e:
|
|
462
461
|
print(f" Could not open browser automatically: {e}")
|
|
463
|
-
|
|
462
|
+
print(" Copy the URL above and open it in your browser manually.")
|
|
464
463
|
else:
|
|
465
|
-
print("
|
|
464
|
+
print("Copy the URL above and open it in your browser.")
|
|
466
465
|
|
|
467
466
|
# Step 3: Wait for callback with verifier
|
|
468
467
|
verifier = self._wait_for_callback(server)
|
|
469
|
-
print(
|
|
468
|
+
print("\n Received verifier: [REDACTED]")
|
|
470
469
|
|
|
471
470
|
# Step 4: Exchange for access token
|
|
472
471
|
self.get_access_token(verifier)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Google Sheets integration module."""
|