Skip to content

Commit 41b413b

Browse files
authored
Merge pull request #54 from VectorInstitute/add_delete_participants_cmd
Add delete participants command, fix snapshots
2 parents 11f2521 + 9608833 commit 41b413b

File tree

7 files changed

+1383
-40
lines changed

7 files changed

+1383
-40
lines changed

scripts/collect_coder_analytics.py

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import os
1515
import subprocess
1616
import sys
17-
from datetime import UTC, datetime
17+
from datetime import datetime, timezone
1818
from typing import Any
1919

2020
import requests
@@ -216,15 +216,19 @@ def fetch_user_activity_insights(
216216
return {}
217217

218218

219-
def get_team_mappings() -> dict[str, str]:
220-
"""Get team mappings from Firestore.
219+
def get_participant_mappings() -> dict[str, dict[str, Any]]:
220+
"""Get participant data from Firestore including team and name info.
221221
222222
Returns
223223
-------
224-
dict[str, str]
225-
Mapping of github_handle (lowercase) -> team_name
224+
dict[str, dict[str, Any]]
225+
Mapping of github_handle (lowercase) -> {
226+
'team_name': str,
227+
'first_name': str | None,
228+
'last_name': str | None
229+
}
226230
"""
227-
print("Fetching team mappings from Firestore...")
231+
print("Fetching participant data from Firestore...")
228232

229233
project_id = "coderd"
230234
database_id = "onboarding"
@@ -238,22 +242,25 @@ def get_team_mappings() -> dict[str, str]:
238242
data = doc.to_dict()
239243
if data:
240244
github_handle = doc.id.lower()
241-
team_name = data.get("team_name", "Unassigned")
242-
mappings[github_handle] = team_name
245+
mappings[github_handle] = {
246+
"team_name": data.get("team_name", "Unassigned"),
247+
"first_name": data.get("first_name"),
248+
"last_name": data.get("last_name"),
249+
}
243250

244-
print(f"✓ Loaded {len(mappings)} participant team mappings")
251+
print(f"✓ Loaded {len(mappings)} participant mappings")
245252
return mappings
246253

247254

248255
def fetch_workspaces(
249-
team_mappings: dict[str, str], api_url: str, session_token: str
256+
participant_mappings: dict[str, dict[str, Any]], api_url: str, session_token: str
250257
) -> list[dict[str, Any]]:
251258
"""Fetch workspaces using Coder CLI and enrich with build data.
252259
253260
Parameters
254261
----------
255-
team_mappings : dict[str, str]
256-
Mapping of github_handle -> team_name
262+
participant_mappings : dict[str, dict[str, Any]]
263+
Mapping of github_handle -> participant data (team_name, first_name, last_name)
257264
api_url : str
258265
Coder API base URL
259266
session_token : str
@@ -262,7 +269,7 @@ def fetch_workspaces(
262269
Returns
263270
-------
264271
list[dict[str, Any]]
265-
List of workspace objects with builds, usage hours, and active hours
272+
List of workspace objects with builds, usage hours, active hours, and team data
266273
"""
267274
print("Fetching workspaces from Coder...")
268275
workspaces = run_command(["coder", "list", "-a", "-o", "json"])
@@ -276,7 +283,8 @@ def fetch_workspaces(
276283
filtered_workspaces = []
277284
for ws in workspaces:
278285
owner_name = ws.get("owner_name", "").lower()
279-
team_name = team_mappings.get(owner_name, "Unassigned")
286+
participant_data = participant_mappings.get(owner_name, {})
287+
team_name = participant_data.get("team_name", "Unassigned")
280288

281289
if team_name not in excluded_teams:
282290
filtered_workspaces.append(ws)
@@ -299,14 +307,14 @@ def fetch_workspaces(
299307
for ws in filtered_workspaces
300308
if ws.get("created_at")
301309
),
302-
default=datetime.now(UTC),
310+
default=datetime.now(timezone.utc),
303311
)
304312
# Normalize to midnight (00:00:00) as required by the API
305313
start_time = earliest_created.replace(
306314
hour=0, minute=0, second=0, microsecond=0
307315
).strftime("%Y-%m-%dT%H:%M:%SZ")
308316
# Normalize end time to the start of the current hour (required by API)
309-
now = datetime.now(UTC)
317+
now = datetime.now(timezone.utc)
310318
end_time = now.replace(minute=0, second=0, microsecond=0).strftime(
311319
"%Y-%m-%dT%H:%M:%SZ"
312320
)
@@ -337,6 +345,13 @@ def fetch_workspaces(
337345
owner_name = workspace.get("owner_name", "").lower()
338346
workspace["active_hours"] = activity_map.get(owner_name, 0.0)
339347

348+
# Enrich with participant data (team and name)
349+
# This ensures all data needed for dashboard is in the snapshot
350+
participant_data = participant_mappings.get(owner_name, {})
351+
workspace["team_name"] = participant_data.get("team_name", "Unassigned")
352+
workspace["owner_first_name"] = participant_data.get("first_name")
353+
workspace["owner_last_name"] = participant_data.get("last_name")
354+
340355
# Progress indicator
341356
if i % 10 == 0:
342357
print(f" Processed {i}/{len(filtered_workspaces)} workspaces...")
@@ -371,7 +386,7 @@ def create_snapshot(
371386
workspaces: list[dict[str, Any]], templates: list[dict[str, Any]]
372387
) -> dict[str, Any]:
373388
"""Create a snapshot object with timestamp."""
374-
timestamp = datetime.now(UTC).isoformat().replace("+00:00", "Z")
389+
timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
375390

376391
snapshot = {
377392
"timestamp": timestamp,
@@ -454,11 +469,11 @@ def main() -> None:
454469
api_url, session_token = get_coder_api_config()
455470
print(f"✓ Using Coder API: {api_url}")
456471

457-
# Fetch team mappings first
458-
team_mappings = get_team_mappings()
472+
# Fetch participant mappings first
473+
participant_mappings = get_participant_mappings()
459474

460475
# Fetch data (with filtering and build enrichment)
461-
workspaces = fetch_workspaces(team_mappings, api_url, session_token)
476+
workspaces = fetch_workspaces(participant_mappings, api_url, session_token)
462477
templates = fetch_templates()
463478

464479
# Create snapshot

services/analytics/app/api/snapshot/route.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { NextResponse } from 'next/server';
22
import { getLatestSnapshot } from '@/lib/gcs';
3-
import { getTeamMappings } from '@/lib/firestore';
43
import {
54
enrichWorkspaceData,
65
aggregateByTeam,
@@ -24,17 +23,13 @@ export async function GET() {
2423
try {
2524
console.log('Fetching latest Coder analytics snapshot...');
2625

27-
// Fetch data in parallel
28-
const [snapshot, teamMappings] = await Promise.all([
29-
getLatestSnapshot(),
30-
getTeamMappings(),
31-
]);
26+
// Fetch snapshot (team data is pre-enriched at collection time)
27+
const snapshot = await getLatestSnapshot();
3228

3329
console.log(`Snapshot contains ${snapshot.workspaces.length} workspaces and ${snapshot.templates.length} templates`);
34-
console.log(`Loaded ${teamMappings.size} team mappings`);
3530

36-
// Enrich workspace data with team information
37-
const workspaceMetrics = enrichWorkspaceData(snapshot.workspaces, teamMappings);
31+
// Enrich workspace data with calculated metrics (team data already in snapshot)
32+
const workspaceMetrics = enrichWorkspaceData(snapshot.workspaces);
3833
console.log(`Enriched ${workspaceMetrics.length} workspace metrics`);
3934

4035
// Calculate aggregated metrics

services/analytics/lib/metrics.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import type {
55
TeamMetrics,
66
PlatformMetrics,
77
TemplateMetrics,
8-
ParticipantData,
98
ActivityStatus,
109
WorkspaceStatus,
1110
HealthStatus,
@@ -240,36 +239,38 @@ export function getHealthStatus(workspace: CoderWorkspace): HealthStatus {
240239
// ===== Main Enrichment Functions =====
241240

242241
/**
243-
* Enrich workspace data with team information and calculated metrics
242+
* Enrich workspace data with calculated metrics
243+
* Team information is now pre-enriched in the snapshot at collection time
244244
*/
245245
export function enrichWorkspaceData(
246-
workspaces: CoderWorkspace[],
247-
teamMappings: Map<string, ParticipantData>
246+
workspaces: CoderWorkspace[]
248247
): WorkspaceMetrics[] {
249248
const now = new Date();
250249

251250
return workspaces.map((workspace) => {
252-
const ownerHandle = workspace.owner_name.toLowerCase();
253-
const participant = teamMappings.get(ownerHandle);
254-
255251
const lastActive = getLastActiveTimestamp(workspace);
256252
const daysSinceActive = daysBetween(new Date(lastActive), now);
257253
const daysSinceCreated = daysBetween(new Date(workspace.created_at), now);
258254
const workspaceHours = getWorkspaceUsageHours(workspace);
259255
const activeHours = getWorkspaceActiveHours(workspace);
260256

261-
// Determine full name
257+
// Use pre-enriched data from snapshot
258+
const teamName = workspace.team_name || 'Unassigned';
259+
const firstName = workspace.owner_first_name;
260+
const lastName = workspace.owner_last_name;
261+
262+
// Build owner name
262263
let ownerName = workspace.owner_name;
263-
if (participant?.first_name && participant?.last_name) {
264-
ownerName = `${participant.first_name} ${participant.last_name}`;
264+
if (firstName && lastName) {
265+
ownerName = `${firstName} ${lastName}`;
265266
}
266267

267268
return {
268269
workspace_id: workspace.id,
269270
workspace_name: workspace.name || `${workspace.owner_name}/workspace`,
270271
owner_github_handle: workspace.owner_name,
271272
owner_name: ownerName,
272-
team_name: participant?.team_name || 'Unassigned',
273+
team_name: teamName,
273274
template_id: workspace.template_id,
274275
template_name: workspace.template_name,
275276
template_display_name: workspace.template_display_name,

services/analytics/lib/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ export interface CoderWorkspace {
77
owner_id: string;
88
owner_name: string; // This is the GitHub username!
99
owner_avatar_url?: string;
10+
11+
// Pre-enriched participant data from snapshot (added at collection time)
12+
team_name: string; // Team assignment at snapshot time
13+
owner_first_name?: string; // First name at snapshot time
14+
owner_last_name?: string; // Last name at snapshot time
15+
1016
organization_id: string;
1117
organization_name: string;
1218
template_id: string;

src/aieng_platform_onboard/admin/cli.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import argparse
44
import sys
55

6+
from aieng_platform_onboard.admin.delete_participants import (
7+
delete_participants_from_csv,
8+
)
69
from aieng_platform_onboard.admin.setup_participants import (
710
setup_participants_from_csv,
811
)
@@ -46,11 +49,39 @@ def main() -> int:
4649
help="Validate and show what would be done without making changes",
4750
)
4851

52+
# delete-participants subcommand
53+
delete_participants_parser = subparsers.add_parser(
54+
"delete-participants",
55+
help="Delete participants from Firestore database",
56+
description="Remove participants and optionally empty teams from Firestore",
57+
)
58+
delete_participants_parser.add_argument(
59+
"csv_file",
60+
type=str,
61+
help="Path to CSV file with column: github_handle",
62+
)
63+
delete_participants_parser.add_argument(
64+
"--dry-run",
65+
action="store_true",
66+
help="Validate and show what would be done without making changes",
67+
)
68+
delete_participants_parser.add_argument(
69+
"--keep-empty-teams",
70+
action="store_true",
71+
help="Keep teams even if they become empty after removing participants",
72+
)
73+
4974
args = parser.parse_args()
5075

5176
# Route to appropriate command handler
5277
if args.command == "setup-participants":
5378
return setup_participants_from_csv(args.csv_file, dry_run=args.dry_run)
79+
if args.command == "delete-participants":
80+
return delete_participants_from_csv(
81+
args.csv_file,
82+
delete_empty_teams=not args.keep_empty_teams,
83+
dry_run=args.dry_run,
84+
)
5485

5586
# Should never reach here due to required=True
5687
parser.print_help()

0 commit comments

Comments
 (0)