492 lines
14 KiB
Python
492 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Batch Operations Module for Team Manager (FUNC-005)
|
|
|
|
Provides CSV and JSON import/export functionality for bulk team operations.
|
|
"""
|
|
|
|
import csv
|
|
import json
|
|
import io
|
|
from pathlib import Path
|
|
from typing import List, Dict, Any, Optional, Tuple
|
|
from dataclasses import asdict
|
|
from datetime import datetime
|
|
|
|
|
|
def import_csv(manager, csv_path: Path, dry_run: bool = False) -> Dict[str, Any]:
|
|
"""
|
|
Import role assignments from a CSV file.
|
|
|
|
Args:
|
|
manager: TeamManager instance
|
|
csv_path: Path to CSV file
|
|
dry_run: If True, validate without making changes
|
|
|
|
Returns:
|
|
Dict with import results:
|
|
{
|
|
"success": bool,
|
|
"imported": int,
|
|
"skipped": int,
|
|
"errors": List[Dict],
|
|
"dry_run": bool
|
|
}
|
|
"""
|
|
result = {
|
|
"success": True,
|
|
"imported": 0,
|
|
"skipped": 0,
|
|
"errors": [],
|
|
"dry_run": dry_run
|
|
}
|
|
|
|
if not csv_path.exists():
|
|
result["success"] = False
|
|
result["errors"].append({
|
|
"row": 0,
|
|
"error": f"File not found: {csv_path}"
|
|
})
|
|
return result
|
|
|
|
# Validate file is UTF-8
|
|
try:
|
|
with open(csv_path, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
except UnicodeDecodeError as e:
|
|
result["success"] = False
|
|
result["errors"].append({
|
|
"row": 0,
|
|
"error": f"File is not valid UTF-8: {e}"
|
|
})
|
|
return result
|
|
|
|
# Parse CSV
|
|
reader = csv.DictReader(io.StringIO(content))
|
|
expected_columns = {'team_id', 'role_name', 'assignee'}
|
|
|
|
if not reader.fieldname:
|
|
result["success"] = False
|
|
result["errors"].append({
|
|
"row": 0,
|
|
"error": "CSV file is empty or has no header row"
|
|
})
|
|
return result
|
|
|
|
# Check for required columns
|
|
missing = expected_columns - set(reader.fieldname)
|
|
if missing:
|
|
result["success"] = False
|
|
result["errors"].append({
|
|
"row": 0,
|
|
"error": f"Missing required columns: {', '.join(missing)}"
|
|
})
|
|
return result
|
|
|
|
# Process rows
|
|
rows = list(reader)
|
|
|
|
# First pass: validate all rows
|
|
validated_rows = []
|
|
for i, row in enumerate(rows, start=2): # Start at 2 (1 for header, 1 for 0-index)
|
|
validation = _validate_csv_row(manager, row, i)
|
|
if validation["valid"]:
|
|
validated_rows.append((i, row, validation))
|
|
else:
|
|
result["errors"].append({
|
|
"row": i,
|
|
"data": row,
|
|
"error": validation["error"]
|
|
})
|
|
|
|
if result["errors"]:
|
|
result["success"] = False
|
|
if not dry_run:
|
|
return result
|
|
|
|
# Second pass: apply changes (unless dry run)
|
|
if not dry_run:
|
|
for row_num, row, validation in validated_rows:
|
|
try:
|
|
team_id = int(row['team_id'])
|
|
role_name = row['role_name'].strip()
|
|
assignee = row['assignee'].strip()
|
|
|
|
success = manager.assign_role(team_id, role_name, assignee)
|
|
if success:
|
|
result["imported"] += 1
|
|
else:
|
|
result["skipped"] += 1
|
|
result["errors"].append({
|
|
"row": row_num,
|
|
"data": row,
|
|
"error": "assign_role returned False"
|
|
})
|
|
except Exception as e:
|
|
result["errors"].append({
|
|
"row": row_num,
|
|
"data": row,
|
|
"error": str(e)
|
|
})
|
|
|
|
return result
|
|
|
|
|
|
def _validate_csv_row(manager, row: Dict[str, str], row_num: int) -> Dict[str, Any]:
|
|
"""Validate a single CSV row."""
|
|
result = {"valid": True, "error": None}
|
|
|
|
# Check required fields
|
|
if not row.get('team_id', '').strip():
|
|
result["valid"] = False
|
|
result["error"] = "team_id is required"
|
|
return result
|
|
|
|
if not row.get('role_name', '').strip():
|
|
result["valid"] = False
|
|
result["error"] = "role_name is required"
|
|
return result
|
|
|
|
if not row.get('assignee', '').strip():
|
|
result["valid"] = False
|
|
result["error"] = "assignee is required"
|
|
return result
|
|
|
|
# Validate team_id is integer
|
|
try:
|
|
team_id = int(row['team_id'].strip())
|
|
except ValueError:
|
|
result["valid"] = False
|
|
result["error"] = f"team_id must be an integer, got: {row['team_id']}"
|
|
return result
|
|
|
|
# Check team exists
|
|
if team_id not in manager.teams:
|
|
result["valid"] = False
|
|
result["error"] = f"Team {team_id} does not exist"
|
|
return result
|
|
|
|
# Check role exists in team
|
|
team = manager.teams[team_id]
|
|
role_name = row['role_name'].strip()
|
|
valid_roles = [r.name for r in team.roles]
|
|
if role_name not in valid_roles:
|
|
result["valid"] = False
|
|
result["error"] = f"Role '{role_name}' not found in team {team_id}. Valid roles: {', '.join(valid_roles)}"
|
|
return result
|
|
|
|
return result
|
|
|
|
|
|
def export_csv(manager, csv_path: Path) -> Dict[str, Any]:
|
|
"""
|
|
Export all role assignments to a CSV file.
|
|
|
|
Args:
|
|
manager: TeamManager instance
|
|
csv_path: Path for output CSV file
|
|
|
|
Returns:
|
|
Dict with export results
|
|
"""
|
|
result = {
|
|
"success": True,
|
|
"exported": 0,
|
|
"file_path": str(csv_path),
|
|
"errors": []
|
|
}
|
|
|
|
try:
|
|
with open(csv_path, 'w', newline='', encoding='utf-8') as f:
|
|
writer = csv.writer(f)
|
|
# Header
|
|
writer.writerow(['team_id', 'team_name', 'role_name', 'assigned_to', 'status'])
|
|
|
|
# Data rows
|
|
for team in sorted(manager.teams.values(), key=lambda t: t.id):
|
|
for role in team.roles:
|
|
writer.writerow([
|
|
team.id,
|
|
team.name,
|
|
role.name,
|
|
role.assigned_to or '',
|
|
team.status
|
|
])
|
|
result["exported"] += 1
|
|
|
|
except Exception as e:
|
|
result["success"] = False
|
|
result["errors"].append(str(e))
|
|
|
|
return result
|
|
|
|
|
|
def import_json(manager, json_path: Path, dry_run: bool = False) -> Dict[str, Any]:
|
|
"""
|
|
Import role assignments from a JSON file.
|
|
|
|
Args:
|
|
manager: TeamManager instance
|
|
json_path: Path to JSON file
|
|
dry_run: If True, validate without making changes
|
|
|
|
Returns:
|
|
Dict with import results
|
|
"""
|
|
result = {
|
|
"success": True,
|
|
"imported": 0,
|
|
"skipped": 0,
|
|
"errors": [],
|
|
"dry_run": dry_run
|
|
}
|
|
|
|
if not json_path.exists():
|
|
result["success"] = False
|
|
result["errors"].append(f"File not found: {json_path}")
|
|
return result
|
|
|
|
# Load JSON
|
|
try:
|
|
with open(json_path, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
except json.JSONDecodeError as e:
|
|
result["success"] = False
|
|
result["errors"].append(f"Invalid JSON: {e}")
|
|
return result
|
|
except Exception as e:
|
|
result["success"] = False
|
|
result["errors"].append(f"Error reading file: {e}")
|
|
return result
|
|
|
|
# Handle different JSON formats
|
|
if isinstance(data, dict):
|
|
if "assignments" in data:
|
|
assignments = data["assignments"]
|
|
else:
|
|
result["success"] = False
|
|
result["errors"].append("JSON must contain 'assignments' array or be an array")
|
|
return result
|
|
elif isinstance(data, list):
|
|
assignments = data
|
|
else:
|
|
result["success"] = False
|
|
result["errors"].append("JSON must be an array or object with 'assignments' key")
|
|
return result
|
|
|
|
# Validate all entries first (atomic operation)
|
|
validated = []
|
|
for i, entry in enumerate(assignments):
|
|
validation = _validate_json_entry(manager, entry, i)
|
|
if validation["valid"]:
|
|
validated.append((i, entry))
|
|
else:
|
|
result["errors"].append({
|
|
"index": i,
|
|
"entry": entry,
|
|
"error": validation["error"]
|
|
})
|
|
|
|
if result["errors"]:
|
|
result["success"] = False
|
|
if not dry_run:
|
|
return result
|
|
|
|
# Apply changes (unless dry run)
|
|
if not dry_run:
|
|
for index, entry in validated:
|
|
try:
|
|
team_id = int(entry['team_id'])
|
|
role_name = entry['role_name']
|
|
assignee = entry['assignee']
|
|
|
|
success = manager.assign_role(team_id, role_name, assignee)
|
|
if success:
|
|
result["imported"] += 1
|
|
else:
|
|
result["skipped"] += 1
|
|
except Exception as e:
|
|
result["errors"].append({
|
|
"index": index,
|
|
"entry": entry,
|
|
"error": str(e)
|
|
})
|
|
|
|
return result
|
|
|
|
|
|
def _validate_json_entry(manager, entry: Dict, index: int) -> Dict[str, Any]:
|
|
"""Validate a single JSON entry."""
|
|
result = {"valid": True, "error": None}
|
|
|
|
if not isinstance(entry, dict):
|
|
result["valid"] = False
|
|
result["error"] = f"Entry must be an object, got {type(entry).__name__}"
|
|
return result
|
|
|
|
# Check required fields
|
|
required = ['team_id', 'role_name', 'assignee']
|
|
missing = [f for f in required if f not in entry]
|
|
if missing:
|
|
result["valid"] = False
|
|
result["error"] = f"Missing required fields: {', '.join(missing)}"
|
|
return result
|
|
|
|
# Validate team_id
|
|
try:
|
|
team_id = int(entry['team_id'])
|
|
except (ValueError, TypeError):
|
|
result["valid"] = False
|
|
result["error"] = f"team_id must be an integer, got: {entry['team_id']}"
|
|
return result
|
|
|
|
# Check team exists
|
|
if team_id not in manager.teams:
|
|
result["valid"] = False
|
|
result["error"] = f"Team {team_id} does not exist"
|
|
return result
|
|
|
|
# Check role exists
|
|
team = manager.teams[team_id]
|
|
role_name = entry['role_name']
|
|
valid_roles = [r.name for r in team.roles]
|
|
if role_name not in valid_roles:
|
|
result["valid"] = False
|
|
result["error"] = f"Role '{role_name}' not found in team {team_id}"
|
|
return result
|
|
|
|
return result
|
|
|
|
|
|
def export_json(manager, json_path: Path, pretty: bool = True) -> Dict[str, Any]:
|
|
"""
|
|
Export full project state to a JSON file.
|
|
|
|
Args:
|
|
manager: TeamManager instance
|
|
json_path: Path for output JSON file
|
|
pretty: Whether to pretty-print JSON
|
|
|
|
Returns:
|
|
Dict with export results
|
|
"""
|
|
result = {
|
|
"success": True,
|
|
"file_path": str(json_path),
|
|
"team_count": len(manager.teams),
|
|
"errors": []
|
|
}
|
|
|
|
data = {
|
|
"project_name": manager.project_name,
|
|
"version": getattr(manager, '_data_version', '1.0.0'), # OPS-005: Include version
|
|
"exported_at": datetime.now().isoformat(),
|
|
"team_count": len(manager.teams),
|
|
"teams": []
|
|
}
|
|
|
|
for team in sorted(manager.teams.values(), key=lambda t: t.id):
|
|
team_data = {
|
|
"id": team.id,
|
|
"name": team.name,
|
|
"phase": team.phase,
|
|
"description": team.description,
|
|
"status": team.status,
|
|
"started_at": team.started_at,
|
|
"completed_at": team.completed_at,
|
|
"exit_criteria": team.exit_criteria,
|
|
"roles": [
|
|
{
|
|
"name": role.name,
|
|
"responsibility": role.responsibility,
|
|
"deliverables": role.deliverables,
|
|
"assigned_to": role.assigned_to
|
|
}
|
|
for role in team.roles
|
|
]
|
|
}
|
|
data["teams"].append(team_data)
|
|
|
|
try:
|
|
with open(json_path, 'w', encoding='utf-8') as f:
|
|
indent = 2 if pretty else None
|
|
json.dump(data, f, indent=indent, ensure_ascii=False)
|
|
except Exception as e:
|
|
result["success"] = False
|
|
result["errors"].append(str(e))
|
|
|
|
return result
|
|
|
|
|
|
# For CLI integration
|
|
def create_csv_template(csv_path: Path) -> Dict[str, Any]:
|
|
"""
|
|
Create a CSV template file for bulk assignments.
|
|
|
|
Args:
|
|
csv_path: Path for template file
|
|
|
|
Returns:
|
|
Dict with result
|
|
"""
|
|
result = {
|
|
"success": True,
|
|
"file_path": str(csv_path),
|
|
"errors": []
|
|
}
|
|
|
|
try:
|
|
with open(csv_path, 'w', newline='', encoding='utf-8') as f:
|
|
writer = csv.writer(f)
|
|
writer.writerow(['team_id', 'role_name', 'assignee'])
|
|
# Add example row
|
|
writer.writerow([1, 'Business Relationship Manager', 'John Doe'])
|
|
writer.writerow([7, 'Technical Lead', 'Jane Smith'])
|
|
except Exception as e:
|
|
result["success"] = False
|
|
result["errors"].append(str(e))
|
|
|
|
return result
|
|
|
|
|
|
def create_json_template(json_path: Path) -> Dict[str, Any]:
|
|
"""
|
|
Create a JSON template file for bulk assignments.
|
|
|
|
Args:
|
|
json_path: Path for template file
|
|
|
|
Returns:
|
|
Dict with result
|
|
"""
|
|
result = {
|
|
"success": True,
|
|
"file_path": str(json_path),
|
|
"errors": []
|
|
}
|
|
|
|
template = {
|
|
"description": "Bulk role assignments",
|
|
"assignments": [
|
|
{
|
|
"team_id": 1,
|
|
"role_name": "Business Relationship Manager",
|
|
"assignee": "John Doe"
|
|
},
|
|
{
|
|
"team_id": 7,
|
|
"role_name": "Technical Lead",
|
|
"assignee": "Jane Smith"
|
|
}
|
|
]
|
|
}
|
|
|
|
try:
|
|
with open(json_path, 'w', encoding='utf-8') as f:
|
|
json.dump(template, f, indent=2)
|
|
except Exception as e:
|
|
result["success"] = False
|
|
result["errors"].append(str(e))
|
|
|
|
return result
|