Mathias Claude Opus 4.5 commited on
Commit
d5e4667
·
1 Parent(s): 6a2acdf

Add February 2026 support with multi-month selection

Browse files

- Add multi-month configuration via months_config.json
- Implement per-month caching for Google Sheets data
- Add month selector in dashboard UI
- Support January 2026 and February 2026 data sources
- Add Excel fallback for local development testing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Files changed (5) hide show
  1. .gitignore +1 -0
  2. app.py +252 -58
  3. excel_parser.py +219 -0
  4. months_config.json +17 -0
  5. requirements.txt +1 -0
.gitignore CHANGED
@@ -2,6 +2,7 @@
2
  *.xlsx
3
  *.xls
4
  *.csv
 
5
 
6
  # Credentials (sensitive)
7
  credentials.json
 
2
  *.xlsx
3
  *.xls
4
  *.csv
5
+ data/
6
 
7
  # Credentials (sensitive)
8
  credentials.json
app.py CHANGED
@@ -1,6 +1,7 @@
1
  """
2
  SDR Status Tracker - FastAPI Backend
3
  Fetches data from Google Sheets and serves the dashboard
 
4
  """
5
  import os
6
  import json
@@ -14,14 +15,84 @@ from google.oauth2 import service_account
14
  from googleapiclient.discovery import build
15
  from googleapiclient.errors import HttpError
16
 
 
 
 
17
  app = FastAPI(title="SDR Status Tracker")
18
 
19
- # Configuration via environment variables
20
  SHEET_ID = os.environ.get("GOOGLE_SHEET_ID", "1af6-2KsRqeTQxdw5KVRp2WCrM6RT7HIcl70m-GgGZB4")
21
  SHEET_GID = os.environ.get("GOOGLE_SHEET_GID", "1864606926") # Tab ID (configured via env var for current month)
22
 
23
- # Cache - with webhook invalidation, we can use a long TTL
24
- _cache = {"data": None, "timestamp": None}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  CACHE_TTL = 3600 # 1 hour - webhook will invalidate on actual changes
26
 
27
  # Webhook secret for cache invalidation (optional security)
@@ -372,41 +443,114 @@ def extract_percentage(row, col):
372
 
373
 
374
  @app.get("/api/data")
375
- async def get_data():
376
- """Fetch data from Google Sheets and return as JSON."""
 
 
 
 
 
 
 
377
  global _cache
378
 
379
- # Check cache
380
- now = datetime.now()
381
- if _cache["data"] and _cache["timestamp"]:
382
- age = (now - _cache["timestamp"]).total_seconds()
383
- if age < CACHE_TTL:
384
- return JSONResponse(content={"cases": _cache["data"], "cached": True})
385
 
386
- try:
387
- service = get_sheets_service()
 
388
 
389
- # Fetch all data from the DAILY sheet tab
390
- # Sheet name includes emoji: "DAILY - for SDR to add data🌟"
391
- sheet_name = "DAILY - for SDR to add data🌟"
392
- result = service.spreadsheets().values().get(
393
- spreadsheetId=SHEET_ID,
394
- range=f"'{sheet_name}'!A:AI"
395
- ).execute()
396
 
397
- values = result.get("values", [])
398
- cases = parse_sheet_data(values)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
 
400
- # Update cache
401
- _cache["data"] = cases
402
- _cache["timestamp"] = now
403
 
404
- return JSONResponse(content={"cases": cases, "cached": False})
 
405
 
406
- except HttpError as e:
407
- raise HTTPException(status_code=500, detail=f"Google Sheets API error: {str(e)}")
408
- except Exception as e:
409
- raise HTTPException(status_code=500, detail=f"Error fetching data: {str(e)}")
 
 
 
410
 
411
 
412
  @app.get("/api/config")
@@ -419,11 +563,26 @@ async def get_config():
419
  })
420
 
421
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  @app.post("/api/invalidate-cache")
423
- async def invalidate_cache(request: Request):
424
  """
425
  Webhook endpoint to invalidate the cache when sheet data changes.
426
  Called by Google Apps Script onEdit trigger.
 
 
427
  """
428
  global _cache
429
 
@@ -433,9 +592,14 @@ async def invalidate_cache(request: Request):
433
  if not hmac.compare_digest(auth_header, WEBHOOK_SECRET):
434
  raise HTTPException(status_code=401, detail="Invalid webhook secret")
435
 
436
- # Clear the cache
437
- _cache["data"] = None
438
- _cache["timestamp"] = None
 
 
 
 
 
439
 
440
  # Notify all connected SSE clients to refresh
441
  for queue in _sse_clients.copy():
@@ -447,6 +611,7 @@ async def invalidate_cache(request: Request):
447
  return JSONResponse(content={
448
  "success": True,
449
  "message": "Cache invalidated",
 
450
  "clients_notified": len(_sse_clients),
451
  "timestamp": datetime.now().isoformat()
452
  })
@@ -495,24 +660,41 @@ async def sse_events():
495
 
496
 
497
  @app.get("/api/cache-status")
498
- async def cache_status():
499
- """Check current cache status."""
500
- if _cache["timestamp"]:
501
- age = (datetime.now() - _cache["timestamp"]).total_seconds()
502
- return JSONResponse(content={
503
- "cached": True,
504
- "age_seconds": age,
505
- "ttl_seconds": CACHE_TTL,
506
- "expires_in": max(0, CACHE_TTL - age)
507
- })
508
- return JSONResponse(content={"cached": False})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
509
 
510
 
511
  @app.post("/api/reload-config")
512
  async def reload_config(request: Request):
513
  """
514
- Reload column configuration from column_config.json.
515
- Also invalidates the data cache to force a fresh fetch with new config.
516
  """
517
  global _cache
518
 
@@ -522,18 +704,20 @@ async def reload_config(request: Request):
522
  if not hmac.compare_digest(auth_header, WEBHOOK_SECRET):
523
  raise HTTPException(status_code=401, detail="Invalid webhook secret")
524
 
525
- # Reload the column configuration
526
- config = load_column_config(force_reload=True)
 
527
 
528
- # Invalidate data cache to use new config
529
- _cache["data"] = None
530
- _cache["timestamp"] = None
531
 
532
  return JSONResponse(content={
533
  "success": True,
534
- "message": "Column config reloaded, cache invalidated",
535
- "config_loaded_at": config["loaded_at"],
536
- "weeks_count": len(config["weeks"]),
 
 
537
  "timestamp": datetime.now().isoformat()
538
  })
539
 
@@ -559,13 +743,23 @@ async def get_column_config():
559
 
560
 
561
  @app.get("/api/debug")
562
- async def debug_data():
563
  """Debug endpoint to see raw sheet data structure and parsed blocks."""
564
  try:
 
 
 
 
 
 
 
 
 
565
  service = get_sheets_service()
566
- sheet_name = "DAILY - for SDR to add data🌟"
 
567
  result = service.spreadsheets().values().get(
568
- spreadsheetId=SHEET_ID,
569
  range=f"'{sheet_name}'!A1:AI150"
570
  ).execute()
571
  values = result.get("values", [])
 
1
  """
2
  SDR Status Tracker - FastAPI Backend
3
  Fetches data from Google Sheets and serves the dashboard
4
+ Supports Excel file fallback when Google Sheet access is unavailable
5
  """
6
  import os
7
  import json
 
15
  from googleapiclient.discovery import build
16
  from googleapiclient.errors import HttpError
17
 
18
+ # Excel file support
19
+ from excel_parser import read_excel_file, get_excel_file_path
20
+
21
  app = FastAPI(title="SDR Status Tracker")
22
 
23
+ # Configuration via environment variables (fallback for backward compatibility)
24
  SHEET_ID = os.environ.get("GOOGLE_SHEET_ID", "1af6-2KsRqeTQxdw5KVRp2WCrM6RT7HIcl70m-GgGZB4")
25
  SHEET_GID = os.environ.get("GOOGLE_SHEET_GID", "1864606926") # Tab ID (configured via env var for current month)
26
 
27
+ # Month configuration file path
28
+ MONTHS_CONFIG_FILE = "months_config.json"
29
+
30
+ # Month configuration (loaded at startup)
31
+ _months_config = {"months": [], "default_month": None, "loaded_at": None}
32
+
33
+ def load_months_config(force_reload=False):
34
+ """
35
+ Load month configuration from JSON file.
36
+ Falls back to env var for backward compatibility if file not found.
37
+ """
38
+ global _months_config
39
+
40
+ if _months_config["months"] and not force_reload:
41
+ return _months_config
42
+
43
+ try:
44
+ with open(MONTHS_CONFIG_FILE, "r") as f:
45
+ config = json.load(f)
46
+
47
+ _months_config["months"] = config.get("months", [])
48
+ _months_config["default_month"] = config.get("default_month")
49
+ _months_config["loaded_at"] = datetime.now().isoformat()
50
+ print(f"Months config loaded from {MONTHS_CONFIG_FILE}: {len(_months_config['months'])} months")
51
+
52
+ except FileNotFoundError:
53
+ print(f"Months config file {MONTHS_CONFIG_FILE} not found, using env var fallback")
54
+ # Fallback: create single month from env var
55
+ _months_config["months"] = [{
56
+ "id": "default",
57
+ "label": "Current Month",
58
+ "sheet_id": SHEET_ID,
59
+ "tab_name": "DAILY - for SDR to add data🌟"
60
+ }]
61
+ _months_config["default_month"] = "default"
62
+ _months_config["loaded_at"] = datetime.now().isoformat()
63
+
64
+ except Exception as e:
65
+ print(f"Error loading months config: {e}, using env var fallback")
66
+ _months_config["months"] = [{
67
+ "id": "default",
68
+ "label": "Current Month",
69
+ "sheet_id": SHEET_ID,
70
+ "tab_name": "DAILY - for SDR to add data🌟"
71
+ }]
72
+ _months_config["default_month"] = "default"
73
+ _months_config["loaded_at"] = datetime.now().isoformat()
74
+
75
+ return _months_config
76
+
77
+
78
+ def get_month_config(month_id: str = None):
79
+ """Get configuration for a specific month. Returns None if not found."""
80
+ config = load_months_config()
81
+ if not month_id:
82
+ month_id = config["default_month"]
83
+
84
+ for month in config["months"]:
85
+ if month["id"] == month_id:
86
+ return month
87
+ return None
88
+
89
+
90
+ # Load months config at startup
91
+ load_months_config()
92
+
93
+ # Cache - per-month with webhook invalidation
94
+ # Structure: {"2026-01": {"data": [...], "timestamp": datetime}, ...}
95
+ _cache = {}
96
  CACHE_TTL = 3600 # 1 hour - webhook will invalidate on actual changes
97
 
98
  # Webhook secret for cache invalidation (optional security)
 
443
 
444
 
445
  @app.get("/api/data")
446
+ async def get_data(month: str = None, source: str = None):
447
+ """
448
+ Fetch data from Google Sheets or local Excel file.
449
+
450
+ Args:
451
+ month: Month ID (e.g., "2026-02"). Uses default if not specified.
452
+ source: Data source override. "file" forces local Excel file,
453
+ "api" forces Google Sheets API. Auto-detects if not specified.
454
+ """
455
  global _cache
456
 
457
+ # Get month configuration
458
+ months_config = load_months_config()
459
+ if not month:
460
+ month = months_config["default_month"]
 
 
461
 
462
+ month_config = get_month_config(month)
463
+ if not month_config:
464
+ raise HTTPException(status_code=400, detail=f"Unknown month: {month}")
465
 
466
+ # Check if month config specifies a file source
467
+ config_source = month_config.get("source", "api")
468
+ if source:
469
+ config_source = source # Override with query param
 
 
 
470
 
471
+ # Check per-month cache
472
+ now = datetime.now()
473
+ cache_key = f"{month}:{config_source}"
474
+ if cache_key in _cache:
475
+ cache_entry = _cache[cache_key]
476
+ if cache_entry["data"] and cache_entry["timestamp"]:
477
+ age = (now - cache_entry["timestamp"]).total_seconds()
478
+ if age < CACHE_TTL:
479
+ return JSONResponse(content={
480
+ "cases": cache_entry["data"],
481
+ "cached": True,
482
+ "month": month,
483
+ "month_label": month_config["label"],
484
+ "source": cache_entry.get("source", "api")
485
+ })
486
+
487
+ # Try to load data from the specified source
488
+ values = None
489
+ actual_source = None
490
+ error_messages = []
491
+
492
+ # If source is "file" or config specifies file, try file first
493
+ if config_source == "file":
494
+ file_path = month_config.get("file_path") or get_excel_file_path(month)
495
+ if file_path:
496
+ try:
497
+ tab_name = month_config.get("tab_name", "DAILY - for SDR to add data🌟")
498
+ values = read_excel_file(file_path, tab_name)
499
+ actual_source = "file"
500
+ print(f"Loaded {len(values)} rows from Excel file: {file_path}")
501
+ except Exception as e:
502
+ error_messages.append(f"Excel file error: {str(e)}")
503
+ else:
504
+ error_messages.append(f"No Excel file found for month {month}")
505
+
506
+ # If source is "api" or file failed, try Google Sheets API
507
+ if values is None and config_source != "file":
508
+ try:
509
+ service = get_sheets_service()
510
+ sheet_id = month_config["sheet_id"]
511
+ sheet_name = month_config.get("tab_name", "DAILY - for SDR to add data🌟")
512
+ result = service.spreadsheets().values().get(
513
+ spreadsheetId=sheet_id,
514
+ range=f"'{sheet_name}'!A:AI"
515
+ ).execute()
516
+ values = result.get("values", [])
517
+ actual_source = "api"
518
+ except HttpError as e:
519
+ error_messages.append(f"Google Sheets API error: {str(e)}")
520
+ # If API fails with permission error, try file fallback
521
+ if "403" in str(e) or "permission" in str(e).lower():
522
+ file_path = month_config.get("file_path") or get_excel_file_path(month)
523
+ if file_path:
524
+ try:
525
+ tab_name = month_config.get("tab_name", "DAILY - for SDR to add data🌟")
526
+ values = read_excel_file(file_path, tab_name)
527
+ actual_source = "file"
528
+ print(f"API permission denied, fallback to Excel: {file_path}")
529
+ except Exception as file_e:
530
+ error_messages.append(f"Fallback Excel error: {str(file_e)}")
531
+ except Exception as e:
532
+ error_messages.append(f"Error: {str(e)}")
533
+
534
+ # If we still have no data, raise an error
535
+ if values is None:
536
+ raise HTTPException(
537
+ status_code=500,
538
+ detail=f"Failed to load data. Errors: {'; '.join(error_messages)}"
539
+ )
540
 
541
+ # Parse the data
542
+ cases = parse_sheet_data(values)
 
543
 
544
+ # Update cache
545
+ _cache[cache_key] = {"data": cases, "timestamp": now, "source": actual_source}
546
 
547
+ return JSONResponse(content={
548
+ "cases": cases,
549
+ "cached": False,
550
+ "month": month,
551
+ "month_label": month_config["label"],
552
+ "source": actual_source
553
+ })
554
 
555
 
556
  @app.get("/api/config")
 
563
  })
564
 
565
 
566
+ @app.get("/api/months")
567
+ async def get_months():
568
+ """Return list of available months for the dropdown selector."""
569
+ config = load_months_config()
570
+ return JSONResponse(content={
571
+ "months": [
572
+ {"id": m["id"], "label": m["label"]}
573
+ for m in config["months"]
574
+ ],
575
+ "default_month": config["default_month"]
576
+ })
577
+
578
+
579
  @app.post("/api/invalidate-cache")
580
+ async def invalidate_cache(request: Request, month: str = None):
581
  """
582
  Webhook endpoint to invalidate the cache when sheet data changes.
583
  Called by Google Apps Script onEdit trigger.
584
+ If month is specified, only that month's cache is cleared.
585
+ If month is not specified, all months' caches are cleared.
586
  """
587
  global _cache
588
 
 
592
  if not hmac.compare_digest(auth_header, WEBHOOK_SECRET):
593
  raise HTTPException(status_code=401, detail="Invalid webhook secret")
594
 
595
+ # Clear the cache (specific month or all)
596
+ if month:
597
+ if month in _cache:
598
+ del _cache[month]
599
+ cleared = [month]
600
+ else:
601
+ cleared = list(_cache.keys())
602
+ _cache.clear()
603
 
604
  # Notify all connected SSE clients to refresh
605
  for queue in _sse_clients.copy():
 
611
  return JSONResponse(content={
612
  "success": True,
613
  "message": "Cache invalidated",
614
+ "months_cleared": cleared,
615
  "clients_notified": len(_sse_clients),
616
  "timestamp": datetime.now().isoformat()
617
  })
 
660
 
661
 
662
  @app.get("/api/cache-status")
663
+ async def cache_status(month: str = None):
664
+ """Check current cache status for a specific month or all months."""
665
+ now = datetime.now()
666
+
667
+ if month:
668
+ # Check specific month
669
+ if month in _cache and _cache[month].get("timestamp"):
670
+ age = (now - _cache[month]["timestamp"]).total_seconds()
671
+ return JSONResponse(content={
672
+ "cached": True,
673
+ "month": month,
674
+ "age_seconds": age,
675
+ "ttl_seconds": CACHE_TTL,
676
+ "expires_in": max(0, CACHE_TTL - age)
677
+ })
678
+ return JSONResponse(content={"cached": False, "month": month})
679
+
680
+ # Return status for all cached months
681
+ status = {}
682
+ for m, entry in _cache.items():
683
+ if entry.get("timestamp"):
684
+ age = (now - entry["timestamp"]).total_seconds()
685
+ status[m] = {
686
+ "cached": True,
687
+ "age_seconds": age,
688
+ "expires_in": max(0, CACHE_TTL - age)
689
+ }
690
+ return JSONResponse(content={"months": status, "ttl_seconds": CACHE_TTL})
691
 
692
 
693
  @app.post("/api/reload-config")
694
  async def reload_config(request: Request):
695
  """
696
+ Reload column configuration and months configuration.
697
+ Also invalidates all data caches to force a fresh fetch with new config.
698
  """
699
  global _cache
700
 
 
704
  if not hmac.compare_digest(auth_header, WEBHOOK_SECRET):
705
  raise HTTPException(status_code=401, detail="Invalid webhook secret")
706
 
707
+ # Reload configurations
708
+ column_config = load_column_config(force_reload=True)
709
+ months_config = load_months_config(force_reload=True)
710
 
711
+ # Invalidate all data caches
712
+ _cache.clear()
 
713
 
714
  return JSONResponse(content={
715
  "success": True,
716
+ "message": "Configs reloaded, all caches invalidated",
717
+ "column_config_loaded_at": column_config["loaded_at"],
718
+ "months_config_loaded_at": months_config["loaded_at"],
719
+ "weeks_count": len(column_config["weeks"]),
720
+ "months_count": len(months_config["months"]),
721
  "timestamp": datetime.now().isoformat()
722
  })
723
 
 
743
 
744
 
745
  @app.get("/api/debug")
746
+ async def debug_data(month: str = None):
747
  """Debug endpoint to see raw sheet data structure and parsed blocks."""
748
  try:
749
+ # Get month configuration
750
+ months_config = load_months_config()
751
+ if not month:
752
+ month = months_config["default_month"]
753
+
754
+ month_config = get_month_config(month)
755
+ if not month_config:
756
+ raise HTTPException(status_code=400, detail=f"Unknown month: {month}")
757
+
758
  service = get_sheets_service()
759
+ sheet_id = month_config["sheet_id"]
760
+ sheet_name = month_config.get("tab_name", "DAILY - for SDR to add data🌟")
761
  result = service.spreadsheets().values().get(
762
+ spreadsheetId=sheet_id,
763
  range=f"'{sheet_name}'!A1:AI150"
764
  ).execute()
765
  values = result.get("values", [])
excel_parser.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Excel File Parser for SDR Status Tracker
4
+
5
+ Reads Excel files (.xlsx) and converts them to the same row format
6
+ that the Google Sheets API returns, enabling local file fallback
7
+ when Google Sheet access is not available.
8
+
9
+ Usage:
10
+ # As a module
11
+ from excel_parser import read_excel_file
12
+ rows = read_excel_file("data/Case status_ February 2026.xlsx")
13
+
14
+ # CLI for testing
15
+ python excel_parser.py data/Case_status_February_2026.xlsx
16
+ """
17
+ import os
18
+ import sys
19
+ from typing import List, Optional
20
+ from openpyxl import load_workbook
21
+
22
+
23
+ # Default tab name to look for
24
+ DEFAULT_TAB_NAME = "DAILY - for SDR to add data🌟"
25
+
26
+
27
+ def read_excel_file(
28
+ file_path: str,
29
+ tab_name: str = DEFAULT_TAB_NAME,
30
+ max_col: str = "AI"
31
+ ) -> List[List]:
32
+ """
33
+ Read an Excel file and return rows as list of lists.
34
+
35
+ Matches the format returned by Google Sheets API:
36
+ - Returns list of rows, where each row is a list of cell values
37
+ - Empty cells are represented as empty strings ""
38
+ - Rows are trimmed to remove trailing empty cells (like Sheets API)
39
+
40
+ Args:
41
+ file_path: Path to the .xlsx file
42
+ tab_name: Name of the worksheet tab to read
43
+ max_col: Maximum column to read (e.g., "AI" = column 35)
44
+
45
+ Returns:
46
+ List of rows, each row is a list of cell values (strings)
47
+
48
+ Raises:
49
+ FileNotFoundError: If the Excel file doesn't exist
50
+ ValueError: If the specified tab is not found
51
+ """
52
+ if not os.path.exists(file_path):
53
+ raise FileNotFoundError(f"Excel file not found: {file_path}")
54
+
55
+ # Load workbook (data_only=True to get calculated values, not formulas)
56
+ wb = load_workbook(file_path, data_only=True)
57
+
58
+ # Find the tab
59
+ if tab_name not in wb.sheetnames:
60
+ # Try partial match
61
+ matching_tabs = [name for name in wb.sheetnames if tab_name.lower() in name.lower()]
62
+ if matching_tabs:
63
+ tab_name = matching_tabs[0]
64
+ print(f"Using matching tab: {tab_name}")
65
+ else:
66
+ available_tabs = ", ".join(wb.sheetnames)
67
+ raise ValueError(f"Tab '{tab_name}' not found. Available tabs: {available_tabs}")
68
+
69
+ ws = wb[tab_name]
70
+
71
+ # Convert column letter to number (e.g., "AI" -> 35)
72
+ max_col_num = column_letter_to_number(max_col)
73
+
74
+ # Read all rows
75
+ rows = []
76
+ for row in ws.iter_rows(min_row=1, max_col=max_col_num):
77
+ row_values = []
78
+ for cell in row:
79
+ # Convert cell value to string (matching Google Sheets behavior)
80
+ if cell.value is None:
81
+ row_values.append("")
82
+ elif isinstance(cell.value, (int, float)):
83
+ # Handle percentages (stored as decimals in Excel)
84
+ if cell.number_format and '%' in cell.number_format:
85
+ # Convert decimal to percentage integer (0.5 -> 50)
86
+ row_values.append(str(int(cell.value * 100)))
87
+ else:
88
+ # Keep numbers as-is
89
+ if cell.value == int(cell.value):
90
+ row_values.append(str(int(cell.value)))
91
+ else:
92
+ row_values.append(str(cell.value))
93
+ else:
94
+ row_values.append(str(cell.value))
95
+
96
+ # Trim trailing empty cells (like Sheets API does)
97
+ while row_values and row_values[-1] == "":
98
+ row_values.pop()
99
+
100
+ rows.append(row_values)
101
+
102
+ # Remove trailing empty rows
103
+ while rows and not any(rows[-1]):
104
+ rows.pop()
105
+
106
+ wb.close()
107
+ return rows
108
+
109
+
110
+ def column_letter_to_number(col_letter: str) -> int:
111
+ """Convert column letter(s) to 1-based number. E.g., 'A'->1, 'Z'->26, 'AI'->35"""
112
+ result = 0
113
+ for char in col_letter.upper():
114
+ result = result * 26 + (ord(char) - ord('A') + 1)
115
+ return result
116
+
117
+
118
+ def get_excel_file_path(month_id: str, data_dir: str = "data") -> Optional[str]:
119
+ """
120
+ Get the Excel file path for a given month.
121
+
122
+ Tries several naming patterns:
123
+ - data/Case status_ February 2026.xlsx
124
+ - data/Case_status_February_2026.xlsx
125
+ - data/2026-02.xlsx
126
+
127
+ Args:
128
+ month_id: Month ID in format "2026-02"
129
+ data_dir: Directory where Excel files are stored
130
+
131
+ Returns:
132
+ Path to Excel file if found, None otherwise
133
+ """
134
+ # Parse month_id to get month name and year
135
+ try:
136
+ year, month_num = month_id.split("-")
137
+ month_names = {
138
+ "01": "January", "02": "February", "03": "March",
139
+ "04": "April", "05": "May", "06": "June",
140
+ "07": "July", "08": "August", "09": "September",
141
+ "10": "October", "11": "November", "12": "December"
142
+ }
143
+ month_name = month_names.get(month_num, "")
144
+ except ValueError:
145
+ month_name = ""
146
+ year = ""
147
+
148
+ # List of patterns to try
149
+ patterns = [
150
+ f"Case status_ {month_name} {year}.xlsx",
151
+ f"Case_status_{month_name}_{year}.xlsx",
152
+ f"{month_id}.xlsx",
153
+ f"Case status {month_name} {year}.xlsx",
154
+ ]
155
+
156
+ for pattern in patterns:
157
+ path = os.path.join(data_dir, pattern)
158
+ if os.path.exists(path):
159
+ return path
160
+
161
+ return None
162
+
163
+
164
+ def list_available_excel_files(data_dir: str = "data") -> List[str]:
165
+ """List all .xlsx files in the data directory."""
166
+ if not os.path.exists(data_dir):
167
+ return []
168
+
169
+ return [f for f in os.listdir(data_dir) if f.endswith('.xlsx')]
170
+
171
+
172
+ def main():
173
+ """CLI for testing Excel parsing."""
174
+ if len(sys.argv) < 2:
175
+ print("Usage: python excel_parser.py <excel_file> [tab_name]")
176
+ print("\nExample: python excel_parser.py data/Case_status_February_2026.xlsx")
177
+
178
+ # List available files
179
+ files = list_available_excel_files()
180
+ if files:
181
+ print(f"\nAvailable Excel files in data/:")
182
+ for f in files:
183
+ print(f" - {f}")
184
+ return 1
185
+
186
+ file_path = sys.argv[1]
187
+ tab_name = sys.argv[2] if len(sys.argv) > 2 else DEFAULT_TAB_NAME
188
+
189
+ try:
190
+ print(f"Reading: {file_path}")
191
+ print(f"Tab: {tab_name}")
192
+ print("-" * 50)
193
+
194
+ rows = read_excel_file(file_path, tab_name)
195
+
196
+ print(f"Total rows: {len(rows)}")
197
+ print(f"Header row 1: {rows[0][:10] if rows else 'empty'}...")
198
+ print(f"Header row 4: {rows[3][:10] if len(rows) > 3 else 'empty'}...")
199
+
200
+ # Show a few data rows
201
+ print("\nFirst data row (row 5):")
202
+ if len(rows) > 4:
203
+ print(f" {rows[4][:15]}...")
204
+
205
+ # Count rows with data in column C (activity)
206
+ activity_rows = sum(1 for r in rows[4:] if len(r) > 2 and r[2])
207
+ print(f"\nRows with activity data: {activity_rows}")
208
+
209
+ return 0
210
+
211
+ except Exception as e:
212
+ print(f"Error: {e}")
213
+ import traceback
214
+ traceback.print_exc()
215
+ return 1
216
+
217
+
218
+ if __name__ == "__main__":
219
+ exit(main())
months_config.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "months": [
3
+ {
4
+ "id": "2026-01",
5
+ "label": "January 2026",
6
+ "sheet_id": "1af6-2KsRqeTQxdw5KVRp2WCrM6RT7HIcl70m-GgGZB4",
7
+ "tab_name": "DAILY - for SDR to add data🌟"
8
+ },
9
+ {
10
+ "id": "2026-02",
11
+ "label": "February 2026",
12
+ "sheet_id": "173_4EJ6pWjyP96d1zpZ8VasffsciCSiRLOGB_CNn7aI",
13
+ "tab_name": "DAILY - for SDR to add data🌟"
14
+ }
15
+ ],
16
+ "default_month": "2026-02"
17
+ }
requirements.txt CHANGED
@@ -3,3 +3,4 @@ uvicorn[standard]==0.27.0
3
  google-auth==2.27.0
4
  google-api-python-client==2.116.0
5
  python-multipart==0.0.6
 
 
3
  google-auth==2.27.0
4
  google-api-python-client==2.116.0
5
  python-multipart==0.0.6
6
+ openpyxl==3.1.2