hpde-analytics-cli 1.0.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.
- hpde_analytics_cli/__init__.py +4 -0
- hpde_analytics_cli/api/__init__.py +1 -0
- hpde_analytics_cli/api/client.py +325 -0
- hpde_analytics_cli/auth/__init__.py +1 -0
- hpde_analytics_cli/auth/credentials.py +256 -0
- hpde_analytics_cli/auth/oauth.py +529 -0
- hpde_analytics_cli/main.py +360 -0
- hpde_analytics_cli/utils/__init__.py +1 -0
- hpde_analytics_cli/utils/data_export.py +366 -0
- hpde_analytics_cli/utils/field_discovery.py +351 -0
- hpde_analytics_cli/utils/report_generator.py +459 -0
- hpde_analytics_cli-1.0.0.dist-info/METADATA +309 -0
- hpde_analytics_cli-1.0.0.dist-info/RECORD +17 -0
- hpde_analytics_cli-1.0.0.dist-info/WHEEL +5 -0
- hpde_analytics_cli-1.0.0.dist-info/entry_points.txt +2 -0
- hpde_analytics_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- hpde_analytics_cli-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Report Generator Module
|
|
3
|
+
|
|
4
|
+
Merges and processes event data into a consolidated Time Trials report.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import csv
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from openpyxl import Workbook
|
|
15
|
+
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
|
16
|
+
from openpyxl.utils import get_column_letter
|
|
17
|
+
|
|
18
|
+
OPENPYXL_AVAILABLE = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
OPENPYXL_AVAILABLE = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ReportGenerator:
|
|
24
|
+
"""Generates consolidated Time Trials reports from MSR data."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, export_dir: str):
|
|
27
|
+
"""
|
|
28
|
+
Initialize the report generator.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
export_dir: Directory containing exported CSV files
|
|
32
|
+
"""
|
|
33
|
+
self.export_dir = export_dir
|
|
34
|
+
self.entrylist_file = os.path.join(export_dir, "entrylist.csv")
|
|
35
|
+
self.attendees_file = os.path.join(export_dir, "attendees.csv")
|
|
36
|
+
self.assignments_file = os.path.join(export_dir, "assignments.csv")
|
|
37
|
+
self.assignments_json_file = os.path.join(export_dir, "assignments.json")
|
|
38
|
+
|
|
39
|
+
def _read_csv(self, filepath: str) -> List[Dict]:
|
|
40
|
+
"""Read a CSV file into a list of dictionaries."""
|
|
41
|
+
rows = []
|
|
42
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
43
|
+
reader = csv.DictReader(f)
|
|
44
|
+
for row in reader:
|
|
45
|
+
rows.append(row)
|
|
46
|
+
return rows
|
|
47
|
+
|
|
48
|
+
def _read_json(self, filepath: str) -> Any:
|
|
49
|
+
"""Read a JSON file."""
|
|
50
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
51
|
+
return json.load(f)
|
|
52
|
+
|
|
53
|
+
def _get_driver_key(self, row: Dict) -> str:
|
|
54
|
+
"""Create a unique key for a driver based on first and last name."""
|
|
55
|
+
first = (row.get("firstName") or "").strip().lower()
|
|
56
|
+
last = (row.get("lastName") or "").strip().lower()
|
|
57
|
+
return f"{first}|{last}"
|
|
58
|
+
|
|
59
|
+
def _parse_segment(self, segment: str) -> Optional[str]:
|
|
60
|
+
"""Extract the day from segment (Friday/Saturday/Sunday)."""
|
|
61
|
+
if not segment:
|
|
62
|
+
return None
|
|
63
|
+
segment_lower = segment.lower()
|
|
64
|
+
if "friday" in segment_lower:
|
|
65
|
+
return "Friday"
|
|
66
|
+
elif "saturday" in segment_lower:
|
|
67
|
+
return "Saturday"
|
|
68
|
+
elif "sunday" in segment_lower:
|
|
69
|
+
return "Sunday"
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def _is_time_trials(self, group: str) -> bool:
|
|
73
|
+
"""Check if this is a Time Trials entry."""
|
|
74
|
+
if not group:
|
|
75
|
+
return False
|
|
76
|
+
return "time trials" in group.lower()
|
|
77
|
+
|
|
78
|
+
def _is_instructor(self, group: str) -> bool:
|
|
79
|
+
"""Check if this is an Instructing entry."""
|
|
80
|
+
if not group:
|
|
81
|
+
return False
|
|
82
|
+
return "instructing" in group.lower()
|
|
83
|
+
|
|
84
|
+
def _is_advanced_hpde(self, group: str) -> bool:
|
|
85
|
+
"""Check if this is an Advanced HPDE entry."""
|
|
86
|
+
if not group:
|
|
87
|
+
return False
|
|
88
|
+
return "advanced hpde" in group.lower()
|
|
89
|
+
|
|
90
|
+
def _is_worker_only(self, segment: str) -> bool:
|
|
91
|
+
"""Check if this is a worker-only entry (not a track event)."""
|
|
92
|
+
if not segment:
|
|
93
|
+
return True
|
|
94
|
+
return "workers" in segment.lower()
|
|
95
|
+
|
|
96
|
+
def _get_participation_type(self, is_instructor: bool, is_ayce: bool) -> str:
|
|
97
|
+
"""
|
|
98
|
+
Determine participation type for pivot table analysis.
|
|
99
|
+
|
|
100
|
+
Returns one of:
|
|
101
|
+
- TT + Instructor + AYCE
|
|
102
|
+
- TT + Instructor
|
|
103
|
+
- TT + AYCE
|
|
104
|
+
- TT Only
|
|
105
|
+
"""
|
|
106
|
+
if is_instructor and is_ayce:
|
|
107
|
+
return "TT + Instructor + AYCE"
|
|
108
|
+
elif is_instructor:
|
|
109
|
+
return "TT + Instructor"
|
|
110
|
+
elif is_ayce:
|
|
111
|
+
return "TT + AYCE"
|
|
112
|
+
else:
|
|
113
|
+
return "TT Only"
|
|
114
|
+
|
|
115
|
+
def _get_day_count(self, day_count: int) -> str:
|
|
116
|
+
"""
|
|
117
|
+
Determine day count category for pivot table analysis.
|
|
118
|
+
|
|
119
|
+
Returns: '1 Day', '2 Days', or '3 Days'
|
|
120
|
+
"""
|
|
121
|
+
if day_count == 1:
|
|
122
|
+
return "1 Day"
|
|
123
|
+
elif day_count == 2:
|
|
124
|
+
return "2 Days"
|
|
125
|
+
elif day_count >= 3:
|
|
126
|
+
return "3 Days"
|
|
127
|
+
else:
|
|
128
|
+
return ""
|
|
129
|
+
|
|
130
|
+
def _get_class_group(self, tt_class: str) -> str:
|
|
131
|
+
"""
|
|
132
|
+
Determine class grouping for pivot table analysis.
|
|
133
|
+
|
|
134
|
+
Groups:
|
|
135
|
+
- Max (Max 1, Max 2, Max 5, Max 6, etc.)
|
|
136
|
+
- Sport (Sport 1, Sport 2, Sport 3, Sport 4, etc.)
|
|
137
|
+
- Tuner (Tuner 1, Tuner 2, Tuner 3, Tuner 4, etc.)
|
|
138
|
+
- Unlimited (Unlimited 1, Unlimited 2, etc.)
|
|
139
|
+
- Other (anything else or empty)
|
|
140
|
+
"""
|
|
141
|
+
if not tt_class:
|
|
142
|
+
return "Other"
|
|
143
|
+
|
|
144
|
+
class_lower = tt_class.lower().strip()
|
|
145
|
+
|
|
146
|
+
if class_lower.startswith("max"):
|
|
147
|
+
return "Max"
|
|
148
|
+
elif class_lower.startswith("sport"):
|
|
149
|
+
return "Sport"
|
|
150
|
+
elif class_lower.startswith("tuner"):
|
|
151
|
+
return "Tuner"
|
|
152
|
+
elif class_lower.startswith("unlimited"):
|
|
153
|
+
return "Unlimited"
|
|
154
|
+
else:
|
|
155
|
+
return "Other"
|
|
156
|
+
|
|
157
|
+
def generate_tt_report(self, output_path: Optional[str] = None) -> Tuple[str, int]:
|
|
158
|
+
"""
|
|
159
|
+
Generate consolidated Time Trials report.
|
|
160
|
+
|
|
161
|
+
Only includes drivers who participated in Time Trials.
|
|
162
|
+
Tracks if they also instructed and/or did Advanced HPDE (AYCE).
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
output_path: Path for output file (default: same dir as input)
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Tuple of (path to generated report file, number of drivers)
|
|
169
|
+
"""
|
|
170
|
+
if not OPENPYXL_AVAILABLE:
|
|
171
|
+
raise ImportError(
|
|
172
|
+
"openpyxl is required for Excel export. Install with: pip install openpyxl"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Read entry list data
|
|
176
|
+
entrylist = self._read_csv(self.entrylist_file)
|
|
177
|
+
attendees = self._read_csv(self.attendees_file)
|
|
178
|
+
|
|
179
|
+
# Read assignments JSON for tire data (not in CSV)
|
|
180
|
+
tire_lookup = {}
|
|
181
|
+
if os.path.exists(self.assignments_json_file):
|
|
182
|
+
assignments_data = self._read_json(self.assignments_json_file)
|
|
183
|
+
for assignment in assignments_data.get("assignments", []):
|
|
184
|
+
key = self._get_driver_key(assignment)
|
|
185
|
+
group = assignment.get("group", "")
|
|
186
|
+
tire = assignment.get("tireBrand", "")
|
|
187
|
+
# Only capture tire from Time Trials entries
|
|
188
|
+
if key and key != "|" and self._is_time_trials(group) and tire:
|
|
189
|
+
tire_lookup[key] = tire
|
|
190
|
+
|
|
191
|
+
# Build attendee lookup for email and member ID
|
|
192
|
+
attendee_lookup = {}
|
|
193
|
+
for att in attendees:
|
|
194
|
+
key = self._get_driver_key(att)
|
|
195
|
+
if key and key != "|":
|
|
196
|
+
attendee_lookup[key] = att
|
|
197
|
+
|
|
198
|
+
# Process entries - group by driver
|
|
199
|
+
drivers: Dict[str, Dict] = {}
|
|
200
|
+
|
|
201
|
+
for entry in entrylist:
|
|
202
|
+
# Skip worker-only entries
|
|
203
|
+
if self._is_worker_only(entry.get("segment", "")):
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
driver_key = self._get_driver_key(entry)
|
|
207
|
+
if not driver_key or driver_key == "|":
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
group = entry.get("group", "")
|
|
211
|
+
segment = entry.get("segment", "")
|
|
212
|
+
day = self._parse_segment(segment)
|
|
213
|
+
|
|
214
|
+
# Initialize driver record if new
|
|
215
|
+
if driver_key not in drivers:
|
|
216
|
+
drivers[driver_key] = {
|
|
217
|
+
"firstName": entry.get("firstName", "").strip(),
|
|
218
|
+
"lastName": entry.get("lastName", "").strip(),
|
|
219
|
+
"class": "",
|
|
220
|
+
"make": "",
|
|
221
|
+
"model": "",
|
|
222
|
+
"year": "",
|
|
223
|
+
"vehicleNumber": "",
|
|
224
|
+
"color": "",
|
|
225
|
+
"sponsor": "",
|
|
226
|
+
"tireBrand": "",
|
|
227
|
+
"is_tt": False,
|
|
228
|
+
"is_instructor": False,
|
|
229
|
+
"is_advanced_hpde": False,
|
|
230
|
+
"days_tt": set(),
|
|
231
|
+
"days_instructor": set(),
|
|
232
|
+
"days_advanced": set(),
|
|
233
|
+
"email": "",
|
|
234
|
+
"memberId": "",
|
|
235
|
+
"status": "",
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
driver = drivers[driver_key]
|
|
239
|
+
|
|
240
|
+
# Track Time Trials participation
|
|
241
|
+
if self._is_time_trials(group):
|
|
242
|
+
driver["is_tt"] = True
|
|
243
|
+
if day:
|
|
244
|
+
driver["days_tt"].add(day)
|
|
245
|
+
# Capture vehicle info from TT entry (most relevant)
|
|
246
|
+
if entry.get("class"):
|
|
247
|
+
driver["class"] = entry.get("class", "")
|
|
248
|
+
if entry.get("make"):
|
|
249
|
+
driver["make"] = entry.get("make", "")
|
|
250
|
+
if entry.get("model"):
|
|
251
|
+
driver["model"] = entry.get("model", "")
|
|
252
|
+
if entry.get("year"):
|
|
253
|
+
driver["year"] = entry.get("year", "")
|
|
254
|
+
if entry.get("vehicleNumber"):
|
|
255
|
+
driver["vehicleNumber"] = entry.get("vehicleNumber", "")
|
|
256
|
+
if entry.get("color"):
|
|
257
|
+
driver["color"] = entry.get("color", "")
|
|
258
|
+
if entry.get("sponsor"):
|
|
259
|
+
driver["sponsor"] = entry.get("sponsor", "")
|
|
260
|
+
|
|
261
|
+
# Track Instructor participation
|
|
262
|
+
if self._is_instructor(group):
|
|
263
|
+
driver["is_instructor"] = True
|
|
264
|
+
if day:
|
|
265
|
+
driver["days_instructor"].add(day)
|
|
266
|
+
|
|
267
|
+
# Track Advanced HPDE participation
|
|
268
|
+
if self._is_advanced_hpde(group):
|
|
269
|
+
driver["is_advanced_hpde"] = True
|
|
270
|
+
if day:
|
|
271
|
+
driver["days_advanced"].add(day)
|
|
272
|
+
|
|
273
|
+
# Filter to only Time Trials participants
|
|
274
|
+
tt_drivers = {k: v for k, v in drivers.items() if v["is_tt"]}
|
|
275
|
+
|
|
276
|
+
# Add attendee info (email, memberId, status) and tire data
|
|
277
|
+
for driver_key, driver in tt_drivers.items():
|
|
278
|
+
if driver_key in attendee_lookup:
|
|
279
|
+
att = attendee_lookup[driver_key]
|
|
280
|
+
driver["email"] = att.get("email", "")
|
|
281
|
+
driver["memberId"] = att.get("memberId", "")
|
|
282
|
+
driver["status"] = att.get("status", "")
|
|
283
|
+
if driver_key in tire_lookup:
|
|
284
|
+
driver["tireBrand"] = tire_lookup[driver_key]
|
|
285
|
+
|
|
286
|
+
# Create Excel workbook
|
|
287
|
+
wb = Workbook()
|
|
288
|
+
ws = wb.active
|
|
289
|
+
ws.title = "Time Trials Report"
|
|
290
|
+
|
|
291
|
+
# Define headers
|
|
292
|
+
headers = [
|
|
293
|
+
"First Name",
|
|
294
|
+
"Last Name",
|
|
295
|
+
"Email",
|
|
296
|
+
"Member ID",
|
|
297
|
+
"Class",
|
|
298
|
+
"Class Group",
|
|
299
|
+
"Vehicle #",
|
|
300
|
+
"Vehicle",
|
|
301
|
+
"Color",
|
|
302
|
+
"Tire",
|
|
303
|
+
"Sponsor",
|
|
304
|
+
"Days (TT)",
|
|
305
|
+
"Day Count",
|
|
306
|
+
"Instructor",
|
|
307
|
+
"AYCE",
|
|
308
|
+
"Participation Type",
|
|
309
|
+
"Status",
|
|
310
|
+
]
|
|
311
|
+
|
|
312
|
+
# Style settings
|
|
313
|
+
header_font = Font(bold=True, color="FFFFFF")
|
|
314
|
+
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
|
315
|
+
header_alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
|
316
|
+
thin_border = Border(
|
|
317
|
+
left=Side(style="thin"),
|
|
318
|
+
right=Side(style="thin"),
|
|
319
|
+
top=Side(style="thin"),
|
|
320
|
+
bottom=Side(style="thin"),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
# Write headers
|
|
324
|
+
for col, header in enumerate(headers, 1):
|
|
325
|
+
cell = ws.cell(row=1, column=col, value=header)
|
|
326
|
+
cell.font = header_font
|
|
327
|
+
cell.fill = header_fill
|
|
328
|
+
cell.alignment = header_alignment
|
|
329
|
+
cell.border = thin_border
|
|
330
|
+
|
|
331
|
+
# Sort drivers by last name, then first name
|
|
332
|
+
sorted_drivers = sorted(
|
|
333
|
+
tt_drivers.values(), key=lambda d: (d["lastName"].lower(), d["firstName"].lower())
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# Write data rows
|
|
337
|
+
for row_num, driver in enumerate(sorted_drivers, 2):
|
|
338
|
+
# Determine days string
|
|
339
|
+
days_tt = driver["days_tt"]
|
|
340
|
+
has_fri = "Friday" in days_tt
|
|
341
|
+
has_sat = "Saturday" in days_tt
|
|
342
|
+
has_sun = "Sunday" in days_tt
|
|
343
|
+
day_count = sum([has_fri, has_sat, has_sun])
|
|
344
|
+
|
|
345
|
+
if day_count == 3:
|
|
346
|
+
days_str = "All 3"
|
|
347
|
+
elif day_count == 2:
|
|
348
|
+
if has_fri and has_sat:
|
|
349
|
+
days_str = "Fri/Sat"
|
|
350
|
+
elif has_fri and has_sun:
|
|
351
|
+
days_str = "Fri/Sun"
|
|
352
|
+
else:
|
|
353
|
+
days_str = "Sat/Sun"
|
|
354
|
+
elif day_count == 1:
|
|
355
|
+
if has_fri:
|
|
356
|
+
days_str = "Friday"
|
|
357
|
+
elif has_sat:
|
|
358
|
+
days_str = "Saturday"
|
|
359
|
+
else:
|
|
360
|
+
days_str = "Sunday"
|
|
361
|
+
else:
|
|
362
|
+
days_str = ""
|
|
363
|
+
|
|
364
|
+
# AYCE = Time Trials + Advanced HPDE
|
|
365
|
+
is_ayce = driver["is_tt"] and driver["is_advanced_hpde"]
|
|
366
|
+
|
|
367
|
+
# Derived columns for pivot tables
|
|
368
|
+
class_group = self._get_class_group(driver["class"])
|
|
369
|
+
day_count_str = self._get_day_count(day_count)
|
|
370
|
+
participation_type = self._get_participation_type(driver["is_instructor"], is_ayce)
|
|
371
|
+
|
|
372
|
+
# Combine year, make, model into single Vehicle column
|
|
373
|
+
vehicle_parts = []
|
|
374
|
+
if driver["year"]:
|
|
375
|
+
vehicle_parts.append(str(driver["year"]))
|
|
376
|
+
if driver["make"]:
|
|
377
|
+
vehicle_parts.append(driver["make"])
|
|
378
|
+
if driver["model"]:
|
|
379
|
+
vehicle_parts.append(driver["model"])
|
|
380
|
+
vehicle = " ".join(vehicle_parts)
|
|
381
|
+
|
|
382
|
+
row_data = [
|
|
383
|
+
driver["firstName"],
|
|
384
|
+
driver["lastName"],
|
|
385
|
+
driver["email"],
|
|
386
|
+
driver["memberId"],
|
|
387
|
+
driver["class"],
|
|
388
|
+
class_group,
|
|
389
|
+
driver["vehicleNumber"],
|
|
390
|
+
vehicle,
|
|
391
|
+
driver["color"],
|
|
392
|
+
driver["tireBrand"],
|
|
393
|
+
driver["sponsor"],
|
|
394
|
+
days_str,
|
|
395
|
+
day_count_str,
|
|
396
|
+
"Yes" if driver["is_instructor"] else "No",
|
|
397
|
+
"Yes" if is_ayce else "No",
|
|
398
|
+
participation_type,
|
|
399
|
+
driver["status"],
|
|
400
|
+
]
|
|
401
|
+
|
|
402
|
+
# Columns to center: Class Group (6), Days (12), Day Count (13), Instructor (14), AYCE (15), Participation Type (16)
|
|
403
|
+
center_cols = [6, 12, 13, 14, 15, 16]
|
|
404
|
+
|
|
405
|
+
for col, value in enumerate(row_data, 1):
|
|
406
|
+
cell = ws.cell(row=row_num, column=col, value=value)
|
|
407
|
+
cell.border = thin_border
|
|
408
|
+
if col in center_cols:
|
|
409
|
+
cell.alignment = Alignment(horizontal="center")
|
|
410
|
+
|
|
411
|
+
# Auto-adjust column widths
|
|
412
|
+
for col in range(1, len(headers) + 1):
|
|
413
|
+
max_length = len(headers[col - 1])
|
|
414
|
+
for row in range(2, len(sorted_drivers) + 2):
|
|
415
|
+
cell_value = ws.cell(row=row, column=col).value
|
|
416
|
+
if cell_value:
|
|
417
|
+
max_length = max(max_length, len(str(cell_value)))
|
|
418
|
+
adjusted_width = min(max_length + 2, 50)
|
|
419
|
+
ws.column_dimensions[get_column_letter(col)].width = adjusted_width
|
|
420
|
+
|
|
421
|
+
# Freeze header row
|
|
422
|
+
ws.freeze_panes = "A2"
|
|
423
|
+
|
|
424
|
+
# Set output path
|
|
425
|
+
if output_path is None:
|
|
426
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
427
|
+
output_path = os.path.join(self.export_dir, f"tt_report_{timestamp}.xlsx")
|
|
428
|
+
|
|
429
|
+
wb.save(output_path)
|
|
430
|
+
|
|
431
|
+
return output_path, len(sorted_drivers)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def generate_report(
|
|
435
|
+
export_dir: str, output_path: Optional[str] = None, verbose: bool = False
|
|
436
|
+
) -> str:
|
|
437
|
+
"""
|
|
438
|
+
Generate Time Trials report from exported data.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
export_dir: Directory containing exported CSV files
|
|
442
|
+
output_path: Optional output file path
|
|
443
|
+
verbose: Print progress messages
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
Path to generated report
|
|
447
|
+
"""
|
|
448
|
+
generator = ReportGenerator(export_dir)
|
|
449
|
+
|
|
450
|
+
if verbose:
|
|
451
|
+
print("Generating Time Trials report...")
|
|
452
|
+
|
|
453
|
+
report_path, driver_count = generator.generate_tt_report(output_path)
|
|
454
|
+
|
|
455
|
+
if verbose:
|
|
456
|
+
print(f" [OK] Report generated: {report_path}")
|
|
457
|
+
print(f" {driver_count} Time Trials drivers included")
|
|
458
|
+
|
|
459
|
+
return report_path
|