hpde-analytics-cli 2.0.0__tar.gz → 6.0.1__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-2.0.0 → hpde_analytics_cli-6.0.1}/PKG-INFO +3 -1
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli/api/client.py +192 -91
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli/auth/oauth.py +7 -3
- hpde_analytics_cli-6.0.1/hpde_analytics_cli/integrations/__init__.py +1 -0
- hpde_analytics_cli-6.0.1/hpde_analytics_cli/integrations/email_populator.py +247 -0
- hpde_analytics_cli-6.0.1/hpde_analytics_cli/integrations/google_sheets.py +150 -0
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli/main.py +137 -5
- hpde_analytics_cli-6.0.1/hpde_analytics_cli/utils/data_export.py +394 -0
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli/utils/field_discovery.py +26 -23
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli/utils/report_generator.py +262 -222
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli.egg-info/PKG-INFO +3 -1
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli.egg-info/SOURCES.txt +5 -0
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli.egg-info/requires.txt +2 -0
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/pyproject.toml +3 -1
- hpde_analytics_cli-6.0.1/tests/test_email_populator.py +388 -0
- hpde_analytics_cli-6.0.1/tests/test_google_sheets.py +225 -0
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/tests/test_main.py +100 -0
- hpde_analytics_cli-2.0.0/hpde_analytics_cli/utils/data_export.py +0 -366
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/LICENSE +0 -0
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/README.md +0 -0
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli/__init__.py +0 -0
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli/api/__init__.py +0 -0
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli/auth/__init__.py +0 -0
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli/auth/credentials.py +0 -0
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli/utils/__init__.py +0 -0
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli.egg-info/dependency_links.txt +0 -0
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli.egg-info/entry_points.txt +0 -0
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli.egg-info/top_level.txt +0 -0
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/setup.cfg +0 -0
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/tests/test_client.py +0 -0
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/tests/test_credentials.py +0 -0
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/tests/test_data_export.py +0 -0
- {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/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.1
|
|
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
|
|
@@ -30,6 +30,8 @@ Requires-Dist: requests-oauthlib>=1.3.0
|
|
|
30
30
|
Requires-Dist: python-dotenv>=1.0.0
|
|
31
31
|
Requires-Dist: openpyxl>=3.1.0
|
|
32
32
|
Requires-Dist: keyring>=24.0.0
|
|
33
|
+
Requires-Dist: gspread>=6.0.0
|
|
34
|
+
Requires-Dist: google-auth>=2.22.0
|
|
33
35
|
Provides-Extra: dev
|
|
34
36
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
35
37
|
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
@@ -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")
|
|
@@ -448,16 +448,20 @@ class MSROAuth:
|
|
|
448
448
|
# Step 2: Get authorization URL
|
|
449
449
|
auth_url = self.get_authorization_url()
|
|
450
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
|
+
|
|
451
454
|
# Open browser automatically if requested
|
|
452
455
|
if auto_open_browser:
|
|
453
|
-
print("
|
|
456
|
+
print("Attempting to open browser automatically...")
|
|
454
457
|
try:
|
|
455
458
|
webbrowser.open(auth_url)
|
|
459
|
+
print(" Browser opened. If nothing appeared, copy the URL above.")
|
|
456
460
|
except Exception as e:
|
|
457
461
|
print(f" Could not open browser automatically: {e}")
|
|
458
|
-
|
|
462
|
+
print(" Copy the URL above and open it in your browser manually.")
|
|
459
463
|
else:
|
|
460
|
-
print("
|
|
464
|
+
print("Copy the URL above and open it in your browser.")
|
|
461
465
|
|
|
462
466
|
# Step 3: Wait for callback with verifier
|
|
463
467
|
verifier = self._wait_for_callback(server)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Google Sheets integration module."""
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Email Populator Module
|
|
3
|
+
|
|
4
|
+
Matches student names from Google Form responses to MSR event data
|
|
5
|
+
and populates email addresses in the Google Sheet.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import csv
|
|
9
|
+
import os
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NameMatcher:
|
|
14
|
+
"""Handles name normalization and matching between MSR and Sheet data."""
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def normalize(name: str) -> str:
|
|
18
|
+
"""Normalize a name for comparison: lowercase, strip, collapse spaces."""
|
|
19
|
+
return " ".join(name.lower().split())
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def build_email_lookup(
|
|
23
|
+
entrylist: List[Dict],
|
|
24
|
+
attendees: List[Dict],
|
|
25
|
+
group_filter: str,
|
|
26
|
+
) -> Dict[str, str]:
|
|
27
|
+
"""
|
|
28
|
+
Build a lookup dict: normalized full name -> email.
|
|
29
|
+
|
|
30
|
+
Filters entrylist by group substring, then enriches with email from attendees.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
entrylist: List of entrylist records (with firstName, lastName, group)
|
|
34
|
+
attendees: List of attendee records (with firstName, lastName, email)
|
|
35
|
+
group_filter: Case-insensitive substring to match against group field
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Dict mapping normalized "firstname lastname" -> email
|
|
39
|
+
"""
|
|
40
|
+
# Build attendee email lookup by driver key (first|last)
|
|
41
|
+
attendee_emails: Dict[str, str] = {}
|
|
42
|
+
for att in attendees:
|
|
43
|
+
first = (att.get("firstName") or "").strip()
|
|
44
|
+
last = (att.get("lastName") or "").strip()
|
|
45
|
+
email = (att.get("email") or "").strip()
|
|
46
|
+
if first and last and email:
|
|
47
|
+
key = f"{first.lower()}|{last.lower()}"
|
|
48
|
+
attendee_emails[key] = email
|
|
49
|
+
|
|
50
|
+
# Filter entrylist by group and build name -> email lookup
|
|
51
|
+
email_lookup: Dict[str, str] = {}
|
|
52
|
+
group_filter_lower = group_filter.lower()
|
|
53
|
+
|
|
54
|
+
for entry in entrylist:
|
|
55
|
+
group = (entry.get("group") or "").lower()
|
|
56
|
+
if group_filter_lower not in group:
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
first = (entry.get("firstName") or "").strip()
|
|
60
|
+
last = (entry.get("lastName") or "").strip()
|
|
61
|
+
if not first or not last:
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
driver_key = f"{first.lower()}|{last.lower()}"
|
|
65
|
+
email = attendee_emails.get(driver_key, "")
|
|
66
|
+
|
|
67
|
+
if email:
|
|
68
|
+
full_name = NameMatcher.normalize(f"{first} {last}")
|
|
69
|
+
email_lookup[full_name] = email
|
|
70
|
+
|
|
71
|
+
return email_lookup
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def match_name(sheet_name: str, email_lookup: Dict[str, str]) -> Optional[str]:
|
|
75
|
+
"""
|
|
76
|
+
Match a name from the Sheet against the MSR email lookup.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
sheet_name: Name string from the Google Sheet
|
|
80
|
+
email_lookup: Dict of normalized name -> email
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Email address if matched, None otherwise
|
|
84
|
+
"""
|
|
85
|
+
normalized = NameMatcher.normalize(sheet_name)
|
|
86
|
+
return email_lookup.get(normalized)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class EmailPopulator:
|
|
90
|
+
"""Orchestrates the email population workflow."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, verbose: bool = False):
|
|
93
|
+
self.verbose = verbose
|
|
94
|
+
|
|
95
|
+
def _log(self, message: str) -> None:
|
|
96
|
+
"""Print message if verbose mode is on."""
|
|
97
|
+
if self.verbose:
|
|
98
|
+
print(message)
|
|
99
|
+
|
|
100
|
+
def _read_csv(self, filepath: str) -> List[Dict]:
|
|
101
|
+
"""Read a CSV file into a list of dictionaries."""
|
|
102
|
+
rows = []
|
|
103
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
104
|
+
reader = csv.DictReader(f)
|
|
105
|
+
for row in reader:
|
|
106
|
+
rows.append(row)
|
|
107
|
+
return rows
|
|
108
|
+
|
|
109
|
+
def load_msr_data_from_export(self, export_dir: str) -> Tuple[List[Dict], List[Dict]]:
|
|
110
|
+
"""
|
|
111
|
+
Load entrylist and attendees from export CSV files.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
export_dir: Directory containing entrylist.csv and attendees.csv
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Tuple of (entrylist records, attendee records)
|
|
118
|
+
"""
|
|
119
|
+
entrylist_path = os.path.join(export_dir, "entrylist.csv")
|
|
120
|
+
attendees_path = os.path.join(export_dir, "attendees.csv")
|
|
121
|
+
|
|
122
|
+
if not os.path.exists(entrylist_path):
|
|
123
|
+
raise FileNotFoundError(f"entrylist.csv not found in {export_dir}")
|
|
124
|
+
if not os.path.exists(attendees_path):
|
|
125
|
+
raise FileNotFoundError(f"attendees.csv not found in {export_dir}")
|
|
126
|
+
|
|
127
|
+
entrylist = self._read_csv(entrylist_path)
|
|
128
|
+
attendees = self._read_csv(attendees_path)
|
|
129
|
+
|
|
130
|
+
self._log(
|
|
131
|
+
f" Loaded {len(entrylist)} entrylist records " f"and {len(attendees)} attendee records"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return entrylist, attendees
|
|
135
|
+
|
|
136
|
+
def load_msr_data_from_api(self, client: Any, event_id: str) -> Tuple[List[Dict], List[Dict]]:
|
|
137
|
+
"""
|
|
138
|
+
Fetch entrylist and attendees from MSR API.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
client: Authenticated MSRClient instance
|
|
142
|
+
event_id: MSR event ID
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Tuple of (entrylist records, attendee records)
|
|
146
|
+
"""
|
|
147
|
+
self._log(" Fetching entrylist from MSR API...")
|
|
148
|
+
entrylist_data = client.get_event_entrylist(event_id)
|
|
149
|
+
entrylist = entrylist_data.get("assignments", [])
|
|
150
|
+
|
|
151
|
+
self._log(" Fetching attendees from MSR API...")
|
|
152
|
+
attendees_data = client.get_event_attendees(event_id)
|
|
153
|
+
attendees = attendees_data.get("attendees", [])
|
|
154
|
+
|
|
155
|
+
self._log(
|
|
156
|
+
f" Fetched {len(entrylist)} entrylist records "
|
|
157
|
+
f"and {len(attendees)} attendee records"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return entrylist, attendees
|
|
161
|
+
|
|
162
|
+
def populate_emails(
|
|
163
|
+
self,
|
|
164
|
+
sheets_client: Any,
|
|
165
|
+
sheet_id: str,
|
|
166
|
+
worksheet_name: Optional[str],
|
|
167
|
+
name_column: str,
|
|
168
|
+
email_column: str,
|
|
169
|
+
email_lookup: Dict[str, str],
|
|
170
|
+
) -> Dict[str, Any]:
|
|
171
|
+
"""
|
|
172
|
+
Read names from Sheet, match to emails, write results.
|
|
173
|
+
|
|
174
|
+
Skips rows where the email column is already populated.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
sheets_client: Authenticated GoogleSheetsClient instance
|
|
178
|
+
sheet_id: Google Sheet ID
|
|
179
|
+
worksheet_name: Optional worksheet/tab name (default: first sheet)
|
|
180
|
+
name_column: Column letter or header text for student names
|
|
181
|
+
email_column: Column letter or header text for email addresses
|
|
182
|
+
email_lookup: Dict of normalized name -> email from MSR data
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Dict with keys: matched, unmatched, skipped, already_filled, total_rows
|
|
186
|
+
"""
|
|
187
|
+
self._log("\n Connecting to Google Sheets...")
|
|
188
|
+
sheets_client.connect()
|
|
189
|
+
|
|
190
|
+
worksheet = sheets_client.open_sheet(sheet_id, worksheet_name)
|
|
191
|
+
self._log(f" Opened worksheet: {worksheet.title}")
|
|
192
|
+
|
|
193
|
+
name_col_idx = sheets_client.find_column_index(worksheet, name_column)
|
|
194
|
+
email_col_idx = sheets_client.find_column_index(worksheet, email_column)
|
|
195
|
+
self._log(f" Name column index: {name_col_idx}, Email column index: {email_col_idx}")
|
|
196
|
+
|
|
197
|
+
# Read all data at once to minimize API calls
|
|
198
|
+
all_records = worksheet.get_all_values()
|
|
199
|
+
if len(all_records) <= 1:
|
|
200
|
+
self._log(" Sheet has no data rows (only header or empty).")
|
|
201
|
+
return {
|
|
202
|
+
"matched": 0,
|
|
203
|
+
"unmatched": [],
|
|
204
|
+
"skipped": 0,
|
|
205
|
+
"already_filled": 0,
|
|
206
|
+
"total_rows": 0,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
# Process rows (skip header row at index 0; Sheet rows start at 2)
|
|
210
|
+
updates = []
|
|
211
|
+
matched = 0
|
|
212
|
+
unmatched = []
|
|
213
|
+
skipped = 0
|
|
214
|
+
already_filled = 0
|
|
215
|
+
|
|
216
|
+
for row_idx, row in enumerate(all_records[1:], start=2):
|
|
217
|
+
name_value = row[name_col_idx - 1].strip() if len(row) >= name_col_idx else ""
|
|
218
|
+
email_value = row[email_col_idx - 1].strip() if len(row) >= email_col_idx else ""
|
|
219
|
+
|
|
220
|
+
if not name_value:
|
|
221
|
+
skipped += 1
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
if email_value:
|
|
225
|
+
already_filled += 1
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
email = NameMatcher.match_name(name_value, email_lookup)
|
|
229
|
+
if email:
|
|
230
|
+
updates.append((row_idx, email_col_idx, email))
|
|
231
|
+
matched += 1
|
|
232
|
+
self._log(f" [MATCH] Row {row_idx}: {name_value} -> {email}")
|
|
233
|
+
else:
|
|
234
|
+
unmatched.append({"row": row_idx, "name": name_value})
|
|
235
|
+
self._log(f" [NO MATCH] Row {row_idx}: {name_value}")
|
|
236
|
+
|
|
237
|
+
if updates:
|
|
238
|
+
self._log(f"\n Writing {len(updates)} email addresses to Sheet...")
|
|
239
|
+
sheets_client.batch_update_cells(worksheet, updates)
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
"matched": matched,
|
|
243
|
+
"unmatched": unmatched,
|
|
244
|
+
"skipped": skipped,
|
|
245
|
+
"already_filled": already_filled,
|
|
246
|
+
"total_rows": len(all_records) - 1,
|
|
247
|
+
}
|