""" Projects API Endpoints - Complete CRUD with nested resources """ from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, BackgroundTasks from sqlalchemy.orm import Session from sqlalchemy import or_ from typing import Optional, List from uuid import UUID from datetime import date import math import logging from app.api.deps import get_db, get_current_active_user, get_current_user from app.models.user import User from app.models.project import Project from app.models.project_team import ProjectTeam from app.models.notification import Notification from app.models.enums import AppRole from app.schemas.project import ( ProjectCreate, ProjectUpdate, ProjectResponse, ProjectListResponse, ProjectStatusUpdate, ProjectClose, ProjectSetup, ProjectSetupResponse, ProjectTeamCreate, ProjectTeamUpdate, ProjectTeamResponse, ProjectRegionCreate, ProjectRegionUpdate, ProjectRegionResponse, ProjectRoleCreate, ProjectRoleUpdate, ProjectRoleResponse, ProjectSubcontractorCreate, ProjectSubcontractorUpdate, ProjectSubcontractorResponse ) from app.schemas.invitation import ProjectInvitationCreate, InvitationCreate, InvitationResponse, InvitationStats from app.schemas.filters import ProjectFilters from app.services.project_service import ProjectService from app.services.invitation_service import InvitationService from app.services.audit_service import AuditService from app.core.permissions import require_permission logger = logging.getLogger(__name__) router = APIRouter(prefix="/projects", tags=["Projects"]) # ============================================ # CORE PROJECT CRUD # ============================================ @router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED) @require_permission("manage_projects") async def create_project( data: ProjectCreate, request: Request, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Create a new project **Authorization:** - platform_admin: Can create any project - client_admin: Can create projects for their client - contractor_admin: Can create projects for their contractor **Required Fields:** - title: Project name (3-500 chars) - client_id: Client organization UUID - contractor_id: Contractor organization UUID **Optional Configuration:** - activation_requirements: Dynamic form fields for subscription activation - photo_requirements: Photo evidence requirements - budget: Budget breakdown by category - inventory_requirements: Equipment/materials needed """ try: project = ProjectService.create_project(db, data, current_user) # Audit log AuditService.log_action( db=db, user=current_user, action="create", entity_type="project", entity_id=str(project.id), description=f"Created project '{project.title}'", changes={"status": "created"}, request=request ) # Prepare response with nested data response = ProjectResponse.model_validate(project) response.client_name = project.client.name if project.client else None response.contractor_name = project.contractor.name if project.contractor else None response.primary_manager_name = project.primary_manager.name if project.primary_manager else None response.is_overdue = project.is_overdue response.duration_days = project.duration_days return response except HTTPException: raise except Exception as e: logger.error(f"Error creating project: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while creating the project" ) def parse_project_filters( client_id: Optional[UUID] = Query(None), contractor_id: Optional[UUID] = Query(None), primary_manager_id: Optional[UUID] = Query(None), status: Optional[str] = Query(None), project_type: Optional[str] = Query(None), service_type: Optional[str] = Query(None), is_closed: Optional[bool] = Query(None), is_billable: Optional[bool] = Query(None), planned_start_from: Optional[date] = Query(None), planned_start_to: Optional[date] = Query(None), actual_start_from: Optional[date] = Query(None), actual_start_to: Optional[date] = Query(None), search: Optional[str] = Query(None), sort_by: Optional[str] = Query(None), sort_order: str = Query("desc"), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), from_date: Optional[date] = Query(None), to_date: Optional[date] = Query(None), ) -> ProjectFilters: """Parse and convert query parameters to ProjectFilters""" def parse_csv(value: Optional[str]) -> Optional[List[str]]: if value is None: return None return [item.strip() for item in value.split(',') if item.strip()] return ProjectFilters( client_id=client_id, contractor_id=contractor_id, primary_manager_id=primary_manager_id, status=parse_csv(status), project_type=parse_csv(project_type), service_type=parse_csv(service_type), is_closed=is_closed, is_billable=is_billable, planned_start_from=planned_start_from, planned_start_to=planned_start_to, actual_start_from=actual_start_from, actual_start_to=actual_start_to, search=search, sort_by=sort_by, sort_order=sort_order, page=page, page_size=page_size, from_date=from_date, to_date=to_date, ) @router.get("", response_model=ProjectListResponse) async def list_projects( # New pattern (preferred) filters: ProjectFilters = Depends(parse_project_filters), # Legacy support for backward compatibility skip: Optional[int] = Query(None, ge=0, description="DEPRECATED: Use page instead"), limit: Optional[int] = Query(None, ge=1, le=100, description="DEPRECATED: Use page_size instead"), status_filter: Optional[str] = Query(None, alias="status", description="DEPRECATED: Use filters.status"), current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ List projects with comprehensive filtering support. **NEW FEATURES:** - Multi-value filters: `?status=active,planning&project_type=customer_service` - Date ranges: `?planned_start_from=2024-01-01&planned_start_to=2024-12-31` - Boolean filters: `?is_closed=false&is_billable=true` - Text search: `?search=fiber network` - Flexible sorting: `?sort_by=title&sort_order=asc` - Standardized pagination: `?page=1&page_size=50` **Authorization:** User sees only projects they have access to - platform_admin: Sees all projects - client_admin: Sees their client's projects - contractor_admin: Sees their contractor's projects - project team members: See projects they're assigned to (including draft projects where they're primary_manager) **All Filters:** - `client_id`: Filter by client UUID - `contractor_id`: Filter by contractor UUID - `primary_manager_id`: Filter by primary manager UUID - `status`: Multi-value (draft,planning,active,on_hold,completed,cancelled) - `project_type`: Multi-value (customer_service,infrastructure) - `service_type`: Multi-value (ftth,fttb,fixed_wireless,dsl,etc.) - `is_closed`: Boolean - `is_billable`: Boolean - `planned_start_from/to`: Date range - `actual_start_from/to`: Date range - `from_date/to_date`: Created date range - `search`: Text search across title, description - `sort_by`: Field to sort by - `sort_order`: asc or desc - `page`: Page number (1-based) - `page_size`: Items per page (1-100) **Examples:** ``` # Multi-value filters GET /projects?status=active,planning&project_type=customer_service # Date range GET /projects?planned_start_from=2024-01-01&planned_start_to=2024-12-31 # Boolean filters GET /projects?is_closed=false&is_billable=true # Search GET /projects?search=fiber network # Combined GET /projects?status=active&is_closed=false&search=network&page=1&page_size=20 ``` **DEPRECATION NOTICE:** `skip`, `limit`, and `status` query parameters are deprecated. Use the new filter parameters instead. """ # Handle backward compatibility if skip is not None and filters.page == 1: filters.page = (skip // filters.page_size) + 1 if limit is not None: filters.page_size = limit if status_filter and not filters.status: filters.status = [status_filter] # Use new filtering method projects, total = ProjectService.list_projects_with_filters( db=db, filters=filters, current_user=current_user ) # Convert to response models with nested data project_responses = [] for project in projects: response = ProjectResponse.model_validate(project) response.client_name = project.client.name if project.client else None response.contractor_name = project.contractor.name if project.contractor else None response.primary_manager_name = project.primary_manager.name if project.primary_manager else None response.is_overdue = project.is_overdue response.duration_days = project.duration_days project_responses.append(response) # Calculate pagination total_pages = math.ceil(total / filters.page_size) if total > 0 else 0 return ProjectListResponse( projects=project_responses, total=total, page=filters.page, page_size=filters.page_size, total_pages=total_pages ) @router.get("/available-managers", response_model=List[dict]) async def get_available_project_managers( client_id: Optional[UUID] = Query(None, description="Client organization ID"), contractor_id: Optional[UUID] = Query(None, description="Contractor organization ID"), current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Get users who can be assigned as primary_manager for a project **Use Case:** - Frontend dropdown/picker when creating a project - Shows eligible users (PMs, Sales Managers) from relevant organizations **Returns users with roles:** - project_manager - sales_manager **From organizations:** - If client_id provided: users from that client org - If contractor_id provided: users from that contractor org - If both provided: users from BOTH organizations - If neither: uses current user's organization **Authorization:** - Org admins can query their org + partner orgs - Platform admin can query any org """ from sqlalchemy import or_ # Build organization filter org_filters = [] if client_id: org_filters.append(User.client_id == client_id) if contractor_id: org_filters.append(User.contractor_id == contractor_id) # If no orgs specified, use current user's org if not org_filters: if current_user.client_id: org_filters.append(User.client_id == current_user.client_id) elif current_user.contractor_id: org_filters.append(User.contractor_id == current_user.contractor_id) if not org_filters: return [] # Query users query = db.query(User).filter( User.deleted_at == None, User.status == 'active', User.role.in_(['project_manager', 'sales_manager']), or_(*org_filters) ).order_by(User.name) users = query.all() # Format response return [ { "id": str(user.id), "name": user.name, "email": user.email, "role": user.role, "organization_type": "client" if user.client_id else "contractor", "organization_id": str(user.client_id or user.contractor_id), "phone": user.phone } for user in users ] @router.get("/awaiting-setup/mine", response_model=ProjectListResponse) async def get_my_draft_projects( current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Get projects in 'draft' status where current user is primary_manager **Use Case:** - Shows projects awaiting setup by the primary manager - Displayed in PM dashboard as "Projects Awaiting Setup" **Authorization:** - Returns only projects where user is the primary_manager - Filtered to 'draft' status only """ # Get draft projects where user is primary manager projects, total = ProjectService.list_projects( db=db, current_user=current_user, skip=0, limit=100, status='draft' ) # Further filter to only projects where user is primary_manager my_draft_projects = [ p for p in projects if p.primary_manager_id == current_user.id ] # Convert to response models project_responses = [] for project in my_draft_projects: response = ProjectResponse.model_validate(project) response.client_name = project.client.name if project.client else None response.contractor_name = project.contractor.name if project.contractor else None response.primary_manager_name = project.primary_manager.name if project.primary_manager else None response.is_overdue = project.is_overdue response.duration_days = project.duration_days project_responses.append(response) return ProjectListResponse( projects=project_responses, total=len(my_draft_projects), page=1, page_size=len(my_draft_projects), total_pages=1 ) @router.get("/{project_id}", response_model=ProjectResponse) async def get_project( project_id: UUID, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Get a specific project by ID **Authorization:** User must have access to this project """ project = ProjectService.get_project_by_id(db, project_id, current_user) # Prepare response with nested data response = ProjectResponse.model_validate(project) response.client_name = project.client.name if project.client else None response.contractor_name = project.contractor.name if project.contractor else None response.primary_manager_name = project.primary_manager.name if project.primary_manager else None response.is_overdue = project.is_overdue response.duration_days = project.duration_days return response @router.get("/{project_id}/dashboard") async def get_project_dashboard( project_id: UUID, refresh: bool = Query(False, description="Force refresh cache"), current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Get comprehensive project dashboard with all key metrics. Returns consolidated statistics for: - Tickets (total, open, in_progress, closed, today) - Sales Orders (total, pending, processed, cancelled, today) - Customers (customer_service projects) or Tasks (infrastructure projects) - Expenses (count, amount, approvals, monthly) - Team (members by role) - Finances (revenue, expenses, profit, pending) - Regions (total, active) - Notifications (unread, total) **Performance:** - Cached for 5 minutes for optimal performance - Use ?refresh=true to force cache refresh - Automatically adapts based on project_type (customer_service vs infrastructure) **Authorization:** Any user with project access """ try: from app.services.dashboard_service import DashboardService from app.schemas.dashboard import ProjectDashboardResponse dashboard = DashboardService.get_project_dashboard( db=db, project_id=str(project_id), current_user=current_user, force_refresh=refresh ) return dashboard except HTTPException: raise except Exception as e: logger.error(f"Failed to get project dashboard: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get project dashboard: {str(e)}" ) @router.get("/{project_id}/activity-feed") async def get_project_activity_feed( project_id: UUID, limit: int = Query(20, ge=1, le=50, description="Number of items per section"), current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Get project activity feed: notifications, pending actions, recent activity. Returns: - **unread_notifications**: Unread notifications for current user in this project - **pending_actions**: Items requiring action (expense approvals, ticket reviews, etc.) - **recent_activity**: Recent project activity from audit logs **Use Cases:** - Dashboard notification panel - Activity timeline widget - Quick action items list **Authorization:** Any user with project access """ try: from app.services.dashboard_service import DashboardService from app.services.notification_service import NotificationService from app.services.audit_service import AuditService from app.models.ticket_expense import TicketExpense # Check authorization if not DashboardService.can_user_access_project(current_user, str(project_id), db): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to access this project" ) # Get unread notifications (project-related) # Note: Notification doesn't have deleted_at, priority, or action_url columns notifications_query = db.query(Notification).filter( Notification.user_id == current_user.id, Notification.read_at.is_(None) # Unread means read_at is NULL ).order_by(Notification.created_at.desc()).limit(limit) notifications = notifications_query.all() notification_items = [ { "id": str(n.id), "type": n.notification_type, "title": n.title, "message": n.message, "status": n.status.value if hasattr(n.status, 'value') else str(n.status), "created_at": n.created_at.isoformat() + "Z" if n.created_at else None } for n in notifications ] # Get pending actions (expenses pending approval) pending_actions = [] if current_user.role in ['project_manager', 'platform_admin', 'client_admin', 'contractor_admin']: # Join through Ticket to get project_id from app.models.ticket import Ticket pending_expenses = db.query(TicketExpense).join( Ticket, TicketExpense.ticket_id == Ticket.id ).filter( Ticket.project_id == project_id, TicketExpense.is_approved == False, TicketExpense.deleted_at.is_(None) ).order_by(TicketExpense.created_at.desc()).limit(limit).all() for exp in pending_expenses: pending_actions.append({ "type": "expense_approval", "id": str(exp.id), "description": f"Expense: {exp.category} - ${exp.total_cost:.2f}", "submitted_by": exp.incurred_by_user.name if exp.incurred_by_user else "Unknown", "submitted_at": exp.created_at.isoformat() + "Z" if exp.created_at else None, "action_required": "approve_or_reject" }) # Get recent activity from audit logs # Note: For now, return empty array as AuditService doesn't have get_project_audit_logs method # This can be implemented later with proper query filtering by project-related entities recent_activity = [] return { "unread_notifications": { "count": len(notification_items), "items": notification_items }, "pending_actions": { "count": len(pending_actions), "items": pending_actions }, "recent_activity": { "count": len(recent_activity), "items": recent_activity } } except HTTPException: raise except Exception as e: logger.error(f"Failed to get activity feed: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get activity feed: {str(e)}" ) @router.get("/{project_id}/overview") async def get_project_overview( project_id: UUID, refresh: bool = Query(False, description="Force refresh cache"), current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Get comprehensive project overview (structure, not metrics). Returns project details, regions, roles, subcontractors, and team information tailored to the user's role and permissions. **For Managers/Admins:** - Full project details - All regions with details - All project roles with compensation structures - All subcontractors - Team composition summary **For Field Agents/Sales Agents:** - Basic project information - All regions (UI highlights their assigned region) - Their specific involvement (role, region, subcontractor) - Project requirements (photo_requirements, activation_requirements) - No access to other team members, all roles, or all subcontractors **Performance:** - Cached for 12 hours for optimal performance - Use ?refresh=true to force cache refresh - Structure changes rarely, so long cache is appropriate **Authorization:** Any user with project access """ try: from app.services.dashboard_service import DashboardService from app.schemas.project import ProjectOverviewResponse overview = DashboardService.get_project_overview( db=db, project_id=str(project_id), current_user=current_user, force_refresh=refresh ) return overview except HTTPException: raise except Exception as e: logger.error(f"Failed to get project overview: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get project overview: {str(e)}" ) @router.put("/{project_id}", response_model=ProjectResponse) @router.patch("/{project_id}", response_model=ProjectResponse) @require_permission("manage_projects") async def update_project( project_id: UUID, data: ProjectUpdate, request: Request, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Update a project (supports both PUT and PATCH) **Authorization:** - platform_admin: Can update any project - client_admin: Can update their client's projects - contractor_admin: Can update their contractor's projects - project_manager: Can update projects they manage **Note:** Cannot change client_id or contractor_id after creation """ try: # Get old state for audit old_project = ProjectService.get_project_by_id(db, project_id, current_user) old_data = { "title": old_project.title, "status": old_project.status, "service_type": old_project.service_type } # Update project project = ProjectService.update_project(db, project_id, data, current_user) # Invalidate requirements cache if photo/activation requirements changed if data.photo_requirements is not None or data.activation_requirements is not None: from app.services.project_requirements_cache import ProjectRequirementsCache ProjectRequirementsCache.invalidate(project_id) # Audit log AuditService.log_action( db=db, user=current_user, action="update", entity_type="project", entity_id=str(project.id), description=f"Updated project '{project.title}'", changes={ "old": old_data, "new": { "title": project.title, "status": project.status, "service_type": project.service_type } }, request=request ) # Prepare response response = ProjectResponse.model_validate(project) response.client_name = project.client.name if project.client else None response.contractor_name = project.contractor.name if project.contractor else None response.primary_manager_name = project.primary_manager.name if project.primary_manager else None response.is_overdue = project.is_overdue response.duration_days = project.duration_days return response except HTTPException: raise except Exception as e: logger.error(f"Error updating project: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while updating the project" ) @router.post("/{project_id}/setup", response_model=ProjectSetupResponse) async def complete_project_setup( project_id: UUID, setup_data: ProjectSetup, background_tasks: BackgroundTasks, request: Request, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Complete project setup (draft → planning) **Authorization:** - Only the primary_manager or platform_admin can complete setup **What this does:** 1. Creates project regions/hubs 2. Creates project roles with compensation structures 3. Adds subcontractors 4. Adds core team members (PMs, dispatchers, sales managers) 5. Transitions project from 'draft' to 'planning' status **Required:** - At least 1 team member must be added **Use Case:** - Org admin creates project shell with primary_manager_id - Primary manager logs in, sees "Awaiting Setup" projects - Primary manager completes this setup wizard - Project becomes ready for operations """ try: result = ProjectService.complete_project_setup(db, project_id, setup_data, current_user, background_tasks) # Audit log AuditService.log_action( db=db, user=current_user, action="update", entity_type="project", entity_id=str(project_id), description=f"Completed project setup: {result['team_members_added']} team members, {result['regions_created']} regions", changes={ "status": "draft → planning", "regions_created": result['regions_created'], "roles_created": result['roles_created'], "subcontractors_added": result['subcontractors_added'], "team_members_added": result['team_members_added'] }, request=request ) return ProjectSetupResponse(**result) except HTTPException: raise except Exception as e: logger.error(f"Error completing project setup: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while completing project setup" ) @router.patch("/{project_id}/status", response_model=ProjectResponse) @require_permission("manage_projects") async def update_project_status( project_id: UUID, data: ProjectStatusUpdate, request: Request, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Update project status **Status Values:** - draft: Awaiting setup by primary manager - planning: Initial state, project setup - active: Project is running - on_hold: Temporarily paused - completed: Work finished - cancelled: Project terminated **Business Rules:** - Cannot change status of closed projects - Cannot revert from completed status - Auto-sets actual_start_date when activating - Auto-sets actual_end_date when completing """ try: old_status = ProjectService.get_project_by_id(db, project_id, current_user).status project = ProjectService.update_project_status(db, project_id, data, current_user) # Audit log AuditService.log_action( db=db, user=current_user, action="update", entity_type="project", entity_id=str(project.id), description=f"Changed project '{project.title}' status from {old_status} to {data.status}", changes={ "field": "status", "old": old_status, "new": data.status, "reason": data.reason }, request=request ) # Prepare response response = ProjectResponse.model_validate(project) response.client_name = project.client.name if project.client else None response.contractor_name = project.contractor.name if project.contractor else None response.primary_manager_name = project.primary_manager.name if project.primary_manager else None response.is_overdue = project.is_overdue response.duration_days = project.duration_days return response except HTTPException: raise except Exception as e: logger.error(f"Error updating project status: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while updating project status" ) @router.post("/{project_id}/close", response_model=ProjectResponse) @require_permission("manage_projects") async def close_project( project_id: UUID, data: ProjectClose, request: Request, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Close a project (final action) **Effect:** - Sets is_closed = true - Sets closed_at timestamp - Records closed_by_user_id - Sets status to completed if not already - Sets actual_end_date if not already set **Note:** Closed projects cannot be reopened """ try: project = ProjectService.close_project( db=db, project_id=project_id, current_user=current_user, reason=data.reason ) # Audit log AuditService.log_action( db=db, user=current_user, action="archive", entity_type="project", entity_id=str(project.id), description=f"Closed project '{project.title}'", changes={ "action": "closed", "reason": data.reason }, request=request ) # Prepare response response = ProjectResponse.model_validate(project) response.client_name = project.client.name if project.client else None response.contractor_name = project.contractor.name if project.contractor else None response.primary_manager_name = project.primary_manager.name if project.primary_manager else None response.is_overdue = project.is_overdue response.duration_days = project.duration_days return response except HTTPException: raise except Exception as e: logger.error(f"Error closing project: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while closing the project" ) @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_project( project_id: UUID, request: Request, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Soft delete a project **Authorization:** platform_admin only **Effect:** - Sets deleted_at timestamp - Cascades to child entities (regions, roles, team, subcontractors) - Cannot be undone via API (requires database intervention) """ try: ProjectService.delete_project(db, project_id, current_user) # Audit log AuditService.log_action( db=db, user=current_user, action="delete", entity_type="project", entity_id=str(project_id), description=f"Deleted project (ID: {project_id})", changes={"action": "soft_deleted"}, request=request ) return None except HTTPException: raise except Exception as e: logger.error(f"Error deleting project: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while deleting the project" ) # ============================================ # PROJECT TEAM MANAGEMENT # ============================================ @router.get("/{project_id}/team") async def get_project_team( project_id: UUID, role: Optional[str] = Query(None, description="Filter by system role"), project_role_id: Optional[UUID] = Query(None, description="Filter by project role"), project_region_id: Optional[UUID] = Query(None, description="Filter by region"), is_active: Optional[bool] = Query(None, description="Filter by user active status"), search: Optional[str] = Query(None, description="Search by name or email"), current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Get all team members for a project with optional filters **Authorization:** - platform_admin: Can view any project team - project_manager: Can view their own project teams - sales_manager: Can view their organization's project teams - dispatcher: Can view their contractor's project teams - field_agent: Can view team members for projects they're assigned to (needed for inventory transfers) **Query Parameters:** - role: Filter by system role (field_agent, dispatcher, etc.) - project_role_id: Filter by project role - project_region_id: Filter by region - is_active: Filter by user active status (true/false) - search: Search by user name or email **Returns:** - List of team members with user details, roles, and regions """ # Get project project = db.query(Project).filter( Project.id == project_id, Project.deleted_at == None ).first() if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) # Authorization check if current_user.role == 'platform_admin': pass # Can view any project elif current_user.role == 'project_manager': if str(project.primary_manager_id) != str(current_user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You can only view team members for your own projects" ) elif current_user.role == 'sales_manager': # Can view if project belongs to their client organization if str(current_user.client_id) != str(project.client_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You can only view team members for your organization's projects" ) elif current_user.role == 'dispatcher': # Can view if project belongs to their contractor if str(current_user.contractor_id) != str(project.contractor_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You can only view team members for your contractor's projects" ) elif current_user.role == 'field_agent': # Field agents can view team members of projects they're assigned to # This is needed for inventory transfers (selecting recipient agent) team_member = db.query(ProjectTeam).filter( ProjectTeam.project_id == project_id, ProjectTeam.user_id == current_user.id, ProjectTeam.deleted_at == None, ProjectTeam.removed_at == None ).first() if not team_member: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You can only view team members for projects you're assigned to" ) else: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions to view project team" ) # Build query with filters query = db.query(ProjectTeam).filter( ProjectTeam.project_id == project_id, ProjectTeam.deleted_at == None, ProjectTeam.removed_at == None ) # Apply filters if role: query = query.join(User).filter(User.role == role) if project_role_id: query = query.filter(ProjectTeam.project_role_id == project_role_id) if project_region_id: query = query.filter(ProjectTeam.project_region_id == project_region_id) if is_active is not None: query = query.join(User).filter(User.is_active == is_active) if search: query = query.join(User).filter( or_( User.name.ilike(f"%{search}%"), User.email.ilike(f"%{search}%") ) ) team_members = query.all() # Build response with nested data response_list = [] for member in team_members: member_data = { **member.__dict__, "user_name": member.user.name if member.user else None, "user_email": member.user.email if member.user else None, "user_role": member.user.role if member.user else None, } # Add role name if project_role is assigned if member.project_role_id and member.project_role: member_data["role_name"] = member.project_role.role_name # Add region name if region is assigned if member.project_region_id and member.region: member_data["region_name"] = member.region.region_name response_list.append(ProjectTeamResponse.model_validate(member_data)) return response_list @router.patch("/{project_id}/team/{member_id}", response_model=ProjectTeamResponse) async def update_project_team_member( project_id: UUID, member_id: UUID, update_data: ProjectTeamUpdate, request: Request, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Update a project team member's role, region, or other attributes **Authorization:** - platform_admin: Can update any team member - project_manager: Can update their own project's team members - dispatcher: Can update team members in their contractor's projects **Use Case:** Change someone's role, region assignment, or lead status """ # Verify project exists project = db.query(Project).filter( Project.id == project_id, Project.deleted_at == None ).first() if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Project {project_id} not found" ) # Check permission if current_user.role != 'platform_admin': if current_user.role == 'project_manager' and str(project.primary_manager_id) != str(current_user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You can only update team members in your own projects" ) elif current_user.role == 'dispatcher' and str(current_user.contractor_id) != str(project.contractor_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You can only update team members from your contractor" ) elif current_user.role not in ['project_manager', 'dispatcher']: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to update team members" ) # Get team member team_member = db.query(ProjectTeam).filter( ProjectTeam.id == member_id, ProjectTeam.project_id == project_id, ProjectTeam.deleted_at == None ).first() if not team_member: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Team member {member_id} not found in this project" ) # Update fields update_dict = update_data.model_dump(exclude_unset=True) for field, value in update_dict.items(): setattr(team_member, field, value) db.commit() db.refresh(team_member) # Audit log AuditService.log_action( db=db, user=current_user, action="update", entity_type="project_team", entity_id=str(team_member.id), description=f"Updated team member {team_member.user.name if team_member.user else member_id} in project {project.title}", changes=update_dict, request=request ) # Build response with nested data response_data = { **team_member.__dict__, "user_name": team_member.user.name if team_member.user else None, "user_email": team_member.user.email if team_member.user else None, "user_role": team_member.user.role if team_member.user else None } if team_member.project_role_id and team_member.project_role: response_data["role_name"] = team_member.project_role.role_name if team_member.project_region_id and team_member.region: response_data["region_name"] = team_member.region.region_name return ProjectTeamResponse.model_validate(response_data) @router.delete("/{project_id}/team/{member_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_project_team_member( project_id: UUID, member_id: UUID, request: Request, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Remove a team member from the project (soft delete) **Authorization:** - platform_admin: Can remove any team member - project_manager: Can remove team members from their own projects - dispatcher: Can remove team members from their contractor's projects **Effect:** - Sets removed_at timestamp (soft removal) - User account remains active, just removed from this project - Can be re-added later if needed """ # Verify project exists project = db.query(Project).filter( Project.id == project_id, Project.deleted_at == None ).first() if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Project {project_id} not found" ) # Check permission if current_user.role != 'platform_admin': if current_user.role == 'project_manager' and str(project.primary_manager_id) != str(current_user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You can only remove team members from your own projects" ) elif current_user.role == 'dispatcher' and str(current_user.contractor_id) != str(project.contractor_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You can only remove team members from your contractor" ) elif current_user.role not in ['project_manager', 'dispatcher']: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to remove team members" ) # Get team member team_member = db.query(ProjectTeam).filter( ProjectTeam.id == member_id, ProjectTeam.project_id == project_id, ProjectTeam.deleted_at == None ).first() if not team_member: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Team member {member_id} not found in this project" ) # Soft remove by setting removed_at from datetime import datetime team_member.removed_at = datetime.utcnow() db.commit() # Audit log AuditService.log_action( db=db, user=current_user, action="delete", entity_type="project_team", entity_id=str(team_member.id), description=f"Removed {team_member.user.name if team_member.user else member_id} from project {project.title}", changes={"removed_at": team_member.removed_at.isoformat()}, request=request ) return None @router.post("/{project_id}/team/invite", status_code=status.HTTP_201_CREATED) async def invite_to_project_team( project_id: UUID, invitation_data: ProjectInvitationCreate, request: Request, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Invite a NEW user to join project team **Use Case:** PM/Dispatcher inviting external technicians who don't have accounts yet **Authorization:** - platform_admin: Can invite to any project - project_manager: Can invite to their own projects - dispatcher: Can invite field_agent/driver to projects in their contractor - sales_manager: Can invite sales_agent to projects in their contractor **Note:** If user already exists, use POST /projects/{id}/team instead """ # Verify project exists project = db.query(Project).filter( Project.id == project_id, Project.deleted_at == None ).first() if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Project {project_id} not found" ) # Determine which organization the invited user should belong to based on role # Contractor roles: dispatcher, field_agent, driver # Client roles: client_admin # Can be either: sales_manager, sales_agent, project_manager contractor_roles = ['dispatcher', 'field_agent', 'driver'] client_roles = ['client_admin'] # Determine org assignment based on invited role if invitation_data.invited_role in contractor_roles: # Contractor-specific roles always belong to project's contractor org_client_id = None org_contractor_id = project.contractor_id elif invitation_data.invited_role in client_roles: # Client-specific roles always belong to project's client org_client_id = project.client_id org_contractor_id = None else: # For ambiguous roles (sales_manager, sales_agent, project_manager), # default to contractor (most common case for project invitations) org_client_id = None org_contractor_id = project.contractor_id # Build full invitation with project context full_invitation = InvitationCreate( email=invitation_data.email, phone=invitation_data.phone, invited_name=invitation_data.invited_name, invited_role=invitation_data.invited_role, client_id=org_client_id, contractor_id=org_contractor_id, invitation_method=invitation_data.invitation_method, project_id=project_id, project_role_id=invitation_data.project_role_id, project_region_id=invitation_data.project_region_id, project_subcontractor_id=invitation_data.project_subcontractor_id ) invitation_service = InvitationService() invitation = await invitation_service.create_invitation( invitation_data=full_invitation, invited_by_user=current_user, db=db ) # Audit log AuditService.log_action( db=db, user=current_user, action="create", entity_type="invitation", entity_id=str(invitation.id), description=f"Invited {invitation.email} to project {project.title}", changes={"project_id": str(project_id), "role": invitation.invited_role}, request=request ) return InvitationResponse.model_validate(invitation) @router.post("/{project_id}/team") async def add_existing_user_to_project( project_id: UUID, team_data: ProjectTeamCreate, request: Request, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Add an EXISTING user directly to project team (no invitation) **Use Case:** Adding users who already work for the contractor to this project **Authorization:** - platform_admin: Can add to any project - project_manager: Can add to their own projects - dispatcher: Can add users from their contractor **Note:** For new users who need accounts, use POST /projects/{id}/team/invite """ from app.models.project_team import ProjectTeam # Verify project exists project = db.query(Project).filter( Project.id == project_id, Project.deleted_at == None ).first() if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Project {project_id} not found" ) # Check permission if current_user.role != 'platform_admin': if current_user.role == 'project_manager' and str(project.primary_manager_id) != str(current_user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You can only add team members to your own projects" ) elif current_user.role == 'dispatcher' and str(current_user.contractor_id) != str(project.contractor_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You can only add team members from your contractor" ) elif current_user.role not in ['project_manager', 'dispatcher']: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to add team members" ) # Verify user exists user = db.query(User).filter( User.id == team_data.user_id, User.deleted_at == None ).first() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"User {team_data.user_id} not found. Use the invite endpoint for new users." ) # Verify user belongs to correct contractor if str(user.contractor_id) != str(project.contractor_id): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"User must belong to project's contractor organization" ) # Check if already on team existing = db.query(ProjectTeam).filter( ProjectTeam.project_id == project_id, ProjectTeam.user_id == team_data.user_id, ProjectTeam.deleted_at == None ).first() if existing: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="User is already on this project team" ) # Create team member from datetime import datetime team_member = ProjectTeam( **team_data.model_dump(), project_id=project_id, assigned_at=datetime.utcnow() ) db.add(team_member) db.commit() db.refresh(team_member) # Audit log AuditService.log_action( db=db, user=current_user, action="create", entity_type="project_team", entity_id=str(team_member.id), description=f"Added {user.name} to project {project.title}", changes={"user_id": str(team_data.user_id), "project_id": str(project_id)}, request=request ) # Build response with nested data response_data = { **team_member.__dict__, "user_name": user.name, "user_email": user.email, "user_role": user.role } # Add role name if project_role is assigned if team_member.project_role_id and team_member.project_role: response_data["role_name"] = team_member.project_role.role_name # Add region name if region is assigned if team_member.project_region_id and team_member.region: response_data["region_name"] = team_member.region.region_name return ProjectTeamResponse.model_validate(response_data) @router.get("/{project_id}/team/invitations") async def list_project_invitations( project_id: UUID, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db), status: Optional[str] = Query(None, description="Filter by status: pending, accepted, expired, cancelled") ): """ List invitations for a specific project **Authorization:** - platform_admin: Can view any project's invitations - project_manager: Can view their project's invitations - dispatcher: Can view invitations for projects in their contractor """ from app.models.invitation import UserInvitation # Verify project exists project = db.query(Project).filter( Project.id == project_id, Project.deleted_at == None ).first() if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Project {project_id} not found" ) # Check permission if current_user.role != 'platform_admin': if current_user.role == 'project_manager' and str(project.primary_manager_id) != str(current_user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You can only view invitations for your own projects" ) elif current_user.role == 'dispatcher' and str(current_user.contractor_id) != str(project.contractor_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You can only view invitations for projects in your contractor" ) elif current_user.role not in ['project_manager', 'dispatcher', 'sales_manager']: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to view project invitations" ) # Build query query = db.query(UserInvitation).filter( UserInvitation.project_id == project_id, UserInvitation.deleted_at == None ) if status: query = query.filter(UserInvitation.status == status) invitations = query.order_by(UserInvitation.created_at.desc()).all() return [InvitationResponse.model_validate(inv) for inv in invitations] @router.get("/{project_id}/team/invitations/stats", response_model=InvitationStats) async def get_project_invitation_stats( project_id: UUID, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Get invitation statistics for a project **Returns:** - Total invitations sent - Pending invitations (not yet accepted) - Accepted invitations (user created) - Expired invitations (past expiry date) - Cancelled invitations (manually cancelled) **Authorization:** - platform_admin: Can view any project's stats - project_manager: Can view their project's stats - dispatcher: Can view their contractor's project stats **Use Case:** Dashboard metrics for invitation management page """ from app.models.invitation import UserInvitation from sqlalchemy import func # Verify project exists project = db.query(Project).filter( Project.id == project_id, Project.deleted_at == None ).first() if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Project {project_id} not found" ) # Check permission if current_user.role != 'platform_admin': if current_user.role == 'project_manager' and str(project.primary_manager_id) != str(current_user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You can only view invitation stats for your own projects" ) elif current_user.role == 'dispatcher' and str(current_user.contractor_id) != str(project.contractor_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You can only view invitation stats for your contractor's projects" ) elif current_user.role not in ['project_manager', 'dispatcher', 'sales_manager']: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to view invitation stats" ) # Base query for project invitations base_query = db.query(UserInvitation).filter( UserInvitation.project_id == project_id, UserInvitation.deleted_at == None ) # Get counts by status total = base_query.count() pending = base_query.filter(UserInvitation.status == 'pending').count() accepted = base_query.filter(UserInvitation.status == 'accepted').count() expired = base_query.filter(UserInvitation.status == 'expired').count() cancelled = base_query.filter(UserInvitation.status == 'cancelled').count() return InvitationStats( total=total, pending=pending, accepted=accepted, expired=expired, cancelled=cancelled ) # ============================================ # PROJECT REGIONS MANAGEMENT # ============================================ @router.post("/{project_id}/regions", response_model=ProjectRegionResponse, status_code=status.HTTP_201_CREATED) async def create_project_region( project_id: UUID, region_data: ProjectRegionCreate, request: Request, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Create a new project region/hub **Authorization:** - platform_admin: Can create for any project - project_manager: Can create for their own projects **Use Case:** - PM defines regional hubs/areas for the project - Regions can have managers and are used for team/subcontractor assignment **Required Fields:** - region_name: Name of the region (must be unique within project) **Optional Fields:** - region_code: Short code for the region - description: Region description - manager_id: Regional manager user ID - hub_contact_persons: Array of contact persons for public tracking """ try: region = ProjectService.create_region(db, project_id, region_data, current_user) # Audit log AuditService.log_action( db=db, user=current_user, action="create", entity_type="project_region", entity_id=str(region.id), description=f"Created region '{region.region_name}' for project {project_id}", changes={"region_name": region.region_name, "region_code": region.region_code}, request=request ) response = ProjectRegionResponse.model_validate(region) if region.manager: response.manager_name = region.manager.name return response except HTTPException: raise except Exception as e: logger.error(f"Error creating region: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while creating the region" ) @router.get("/{project_id}/regions", response_model=List[ProjectRegionResponse]) async def list_project_regions( project_id: UUID, is_active: Optional[bool] = Query(None, description="Filter by active status"), current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ List all regions for a project **Authorization:** - platform_admin: Can view any project's regions - project_manager: Can view their own project's regions **Query Parameters:** - is_active: Filter by active status (true/false) """ try: regions = ProjectService.get_regions(db, project_id, current_user, is_active) response_list = [] for region in regions: region_response = ProjectRegionResponse.model_validate(region) if region.manager: region_response.manager_name = region.manager.name response_list.append(region_response) return response_list except HTTPException: raise except Exception as e: logger.error(f"Error listing regions: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while listing regions" ) @router.get("/{project_id}/regions/{region_id}", response_model=ProjectRegionResponse) async def get_project_region( project_id: UUID, region_id: UUID, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """ Get a specific project region by ID. **Authorization:** - PM, Dispatcher, Field Agent: Can view regions in their projects - Platform Admin: Can view any region """ from app.models.project import ProjectRegion # Get region region = db.query(ProjectRegion).filter( ProjectRegion.id == region_id, ProjectRegion.project_id == project_id, ProjectRegion.deleted_at.is_(None) ).first() if not region: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Region not found" ) # Authorization check if current_user.role != AppRole.PLATFORM_ADMIN.value: # Check if user has access to this project from app.models.project_team import ProjectTeam has_access = db.query(ProjectTeam).filter( ProjectTeam.project_id == project_id, ProjectTeam.user_id == current_user.id, ProjectTeam.deleted_at.is_(None) ).first() is not None if not has_access: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to view this region" ) return region @router.patch("/{project_id}/regions/{region_id}", response_model=ProjectRegionResponse) async def update_project_region( project_id: UUID, region_id: UUID, region_data: ProjectRegionUpdate, request: Request, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Update a project region **Authorization:** - platform_admin: Can update any region - project_manager: Can update their own project's regions **All fields are optional** - only provided fields will be updated """ try: region = ProjectService.update_region(db, region_id, region_data, current_user) # Audit log AuditService.log_action( db=db, user=current_user, action="update", entity_type="project_region", entity_id=str(region.id), description=f"Updated region '{region.region_name}'", changes=region_data.model_dump(exclude_unset=True), request=request ) response = ProjectRegionResponse.model_validate(region) if region.manager: response.manager_name = region.manager.name return response except HTTPException: raise except Exception as e: logger.error(f"Error updating region: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while updating the region" ) @router.delete("/{project_id}/regions/{region_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_project_region( project_id: UUID, region_id: UUID, request: Request, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Delete a project region (soft delete) **Authorization:** - platform_admin: Can delete any region - project_manager: Can delete their own project's regions **Restrictions:** - Cannot delete if team members are assigned to this region """ try: ProjectService.delete_region(db, region_id, current_user) # Audit log AuditService.log_action( db=db, user=current_user, action="delete", entity_type="project_region", entity_id=str(region_id), description=f"Deleted region {region_id}", changes={}, request=request ) return None except HTTPException: raise except Exception as e: logger.error(f"Error deleting region: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while deleting the region" ) # ============================================ # PROJECT ROLES MANAGEMENT # ============================================ @router.post("/{project_id}/project-roles", response_model=ProjectRoleResponse, status_code=status.HTTP_201_CREATED) async def create_project_role( project_id: UUID, role_data: ProjectRoleCreate, request: Request, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Create a new project role with compensation structure **Authorization:** - platform_admin: Can create for any project - project_manager: Can create for their own projects **Use Case:** - PM defines roles for field workers/casual workers - Roles include compensation structures for payroll calculation **Required Fields:** - role_name: Name of the role (must be unique within project) - compensation_type: flat_rate, commission, hourly, commission_plus_bonus, or custom **Compensation Requirements by Type:** - FIXED_RATE: Requires base_rate + rate_period (HOUR, DAY, WEEK, MONTH) - PER_UNIT: Requires per_unit_rate - COMMISSION: Requires commission_percentage - FIXED_PLUS_COMMISSION: Requires base_rate + rate_period + commission_percentage **Examples:** - Kenya daily worker: FIXED_RATE, base_rate=1000, rate_period=DAY - USA hourly worker: FIXED_RATE, base_rate=25, rate_period=HOUR - Per-ticket tech: PER_UNIT, per_unit_rate=500 - Sales agent: COMMISSION, commission_percentage=10 - Hybrid: FIXED_PLUS_COMMISSION, base_rate=500, rate_period=DAY, commission_percentage=5 """ try: role = ProjectService.create_project_role(db, project_id, role_data, current_user) # Audit log AuditService.log_action( db=db, user=current_user, action="create", entity_type="project_role", entity_id=str(role.id), description=f"Created role '{role.role_name}' for project {project_id}", changes={"role_name": role.role_name, "compensation_type": role.compensation_type}, request=request ) return ProjectRoleResponse.model_validate(role) except HTTPException: raise except Exception as e: logger.error(f"Error creating role: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while creating the role" ) @router.get("/{project_id}/project-roles", response_model=List[ProjectRoleResponse]) async def list_project_roles( project_id: UUID, is_active: Optional[bool] = Query(None, description="Filter by active status"), current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ List all roles for a project **Authorization:** - platform_admin: Can view any project's roles - project_manager: Can view their own project's roles **Query Parameters:** - is_active: Filter by active status (true/false) """ try: roles = ProjectService.get_project_roles(db, project_id, current_user, is_active) return [ProjectRoleResponse.model_validate(role) for role in roles] except HTTPException: raise except Exception as e: logger.error(f"Error listing roles: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while listing roles" ) @router.patch("/{project_id}/project-roles/{role_id}", response_model=ProjectRoleResponse) async def update_project_role( project_id: UUID, role_id: UUID, role_data: ProjectRoleUpdate, request: Request, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Update a project role **Authorization:** - platform_admin: Can update any role - project_manager: Can update their own project's roles **All fields are optional** - only provided fields will be updated **Note:** When updating compensation_type, ensure required compensation fields are provided """ try: role = ProjectService.update_project_role(db, role_id, role_data, current_user) # Audit log AuditService.log_action( db=db, user=current_user, action="update", entity_type="project_role", entity_id=str(role.id), description=f"Updated role '{role.role_name}'", changes=role_data.model_dump(exclude_unset=True), request=request ) return ProjectRoleResponse.model_validate(role) except HTTPException: raise except Exception as e: logger.error(f"Error updating role: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while updating the role" ) @router.delete("/{project_id}/project-roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_project_role( project_id: UUID, role_id: UUID, request: Request, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Delete a project role (soft delete) **Authorization:** - platform_admin: Can delete any role - project_manager: Can delete their own project's roles **Restrictions:** - Cannot delete if team members are using this role """ try: ProjectService.delete_project_role(db, role_id, current_user) # Audit log AuditService.log_action( db=db, user=current_user, action="delete", entity_type="project_role", entity_id=str(role_id), description=f"Deleted role {role_id}", changes={}, request=request ) return None except HTTPException: raise except Exception as e: logger.error(f"Error deleting role: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while deleting the role" ) # ============================================ # PROJECT SUBCONTRACTORS MANAGEMENT # ============================================ @router.post("/{project_id}/subcontractors", response_model=ProjectSubcontractorResponse, status_code=status.HTTP_201_CREATED) async def add_project_subcontractor( project_id: UUID, subcontractor_data: ProjectSubcontractorCreate, request: Request, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Add a subcontractor to a project **Authorization:** - platform_admin: Can add to any project - project_manager: Can add to their own projects **Use Case:** - PM adds subcontractors who will work on the project - Subcontractors can be assigned to specific regions - When inviting team members, PM can specify which subcontractor they work for **Required Fields:** - subcontractor_id: ID of the contractor organization **Optional Fields:** - scope_description: What work is this subcontractor responsible for - project_region_id: Limit subcontractor to specific region - contract_start_date, contract_end_date: Contract period - contract_value: Contract value **Restrictions:** - Cannot add the main contractor as a subcontractor - Subcontractor must be an active contractor organization """ try: project_subcon = ProjectService.create_subcontractor(db, project_id, subcontractor_data, current_user) # Audit log AuditService.log_action( db=db, user=current_user, action="create", entity_type="project_subcontractor", entity_id=str(project_subcon.id), description=f"Added subcontractor {subcontractor_data.subcontractor_id} to project {project_id}", changes={"subcontractor_id": str(subcontractor_data.subcontractor_id)}, request=request ) response = ProjectSubcontractorResponse.model_validate(project_subcon) if project_subcon.subcontractor: response.subcontractor_name = project_subcon.subcontractor.name if project_subcon.region: response.region_name = project_subcon.region.region_name return response except HTTPException: raise except Exception as e: logger.error(f"Error adding subcontractor: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while adding the subcontractor" ) @router.get("/{project_id}/subcontractors", response_model=List[ProjectSubcontractorResponse]) async def list_project_subcontractors( project_id: UUID, is_active: Optional[bool] = Query(None, description="Filter by active status"), current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ List all subcontractors for a project **Authorization:** - platform_admin: Can view any project's subcontractors - project_manager: Can view their own project's subcontractors **Query Parameters:** - is_active: Filter by active status (true/false) """ try: subcontractors = ProjectService.get_subcontractors(db, project_id, current_user, is_active) response_list = [] for subcon in subcontractors: subcon_response = ProjectSubcontractorResponse.model_validate(subcon) if subcon.subcontractor: subcon_response.subcontractor_name = subcon.subcontractor.name if subcon.region: subcon_response.region_name = subcon.region.region_name response_list.append(subcon_response) return response_list except HTTPException: raise except Exception as e: logger.error(f"Error listing subcontractors: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while listing subcontractors" ) @router.patch("/{project_id}/subcontractors/{subcontractor_id}", response_model=ProjectSubcontractorResponse) async def update_project_subcontractor( project_id: UUID, subcontractor_id: UUID, subcontractor_data: ProjectSubcontractorUpdate, request: Request, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Update a project subcontractor **Authorization:** - platform_admin: Can update any subcontractor - project_manager: Can update their own project's subcontractors **All fields are optional** - only provided fields will be updated **Deactivation:** - Set is_active=false to deactivate a subcontractor - Provide deactivation_reason when deactivating """ try: project_subcon = ProjectService.update_subcontractor(db, subcontractor_id, subcontractor_data, current_user) # Audit log AuditService.log_action( db=db, user=current_user, action="update", entity_type="project_subcontractor", entity_id=str(project_subcon.id), description=f"Updated subcontractor relationship {subcontractor_id}", changes=subcontractor_data.model_dump(exclude_unset=True), request=request ) response = ProjectSubcontractorResponse.model_validate(project_subcon) if project_subcon.subcontractor: response.subcontractor_name = project_subcon.subcontractor.name if project_subcon.region: response.region_name = project_subcon.region.region_name return response except HTTPException: raise except Exception as e: logger.error(f"Error updating subcontractor: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while updating the subcontractor" ) @router.delete("/{project_id}/subcontractors/{subcontractor_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_project_subcontractor( project_id: UUID, subcontractor_id: UUID, request: Request, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Delete a project subcontractor (soft delete) **Authorization:** - platform_admin: Can delete any subcontractor - project_manager: Can delete their own project's subcontractors **Restrictions:** - Cannot delete if team members are assigned to this subcontractor """ try: ProjectService.delete_subcontractor(db, subcontractor_id, current_user) # Audit log AuditService.log_action( db=db, user=current_user, action="delete", entity_type="project_subcontractor", entity_id=str(subcontractor_id), description=f"Deleted subcontractor relationship {subcontractor_id}", changes={}, request=request ) return None except HTTPException: raise except Exception as e: logger.error(f"Error deleting subcontractor: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while deleting the subcontractor" ) # ============================================ # PROJECT FINALIZATION # ============================================ @router.post("/{project_id}/finalize-setup") async def finalize_project_setup( project_id: UUID, setup_notes: Optional[str] = None, request: Request = None, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Finalize project setup and activate the project (draft/planning → active) **Authorization:** - platform_admin: Can finalize any project - project_manager: Can finalize their own projects **Use Case:** - PM completes the setup wizard and activates the project - Project moves from 'draft' or 'planning' to 'active' status - Returns summary of all configured items - Safe to call on both draft and planning projects **Validation:** - Project must be in 'draft' or 'planning' status - Cannot finalize projects that are already 'active', 'on_hold', 'completed', or 'cancelled' - At least 1 team member must exist **Optional:** - setup_notes: Any final notes about the setup **Returns:** - Project status (will be 'active') - Summary counts: regions, roles, subcontractors, team members - Configuration flags: budget, activation requirements, photo requirements, inventory """ try: result = ProjectService.finalize_project_setup(db, project_id, setup_notes, current_user) # Audit log AuditService.log_action( db=db, user=current_user, action="update", entity_type="project", entity_id=str(project_id), description=f"Finalized project setup: draft → planning", changes={"status": "draft → planning", "summary": result['summary']}, request=request ) return result except HTTPException: raise except Exception as e: logger.error(f"Error finalizing project setup: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while finalizing project setup" ) # ============================================ # INVENTORY REQUIREMENTS # ============================================ @router.get("/{project_id}/inventory-requirements") async def get_project_inventory_requirements( project_id: UUID, usage_type: Optional[str] = Query(None, description="Filter by usage type: 'installed' or 'consumed'"), category: Optional[str] = Query(None, description="Filter by category"), current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Get inventory requirements for a project Returns the inventory types that can be received and tracked for this project. Used when receiving inventory batches to populate dropdown options. **Authorization:** - Any user on the project team can view **Query Parameters:** - usage_type: Filter by 'installed' (equipment installed at customer sites) or 'consumed' (materials used up) - category: Filter by category (e.g., 'Equipment', 'Cable', 'Tools') **Response:** - Dictionary of inventory requirements keyed by code - Each requirement includes: code, name, description, usage_type, unit, requires_serial_number, category """ from app.services.inventory_requirements_service import InventoryRequirementsService try: # Get inventory requirements inventory_reqs = InventoryRequirementsService.get_project_inventory_requirements( project_id=project_id, db=db, usage_type=usage_type, category=category ) return { "project_id": str(project_id), "inventory_requirements": inventory_reqs, "total_count": len(inventory_reqs) } except HTTPException: raise except Exception as e: logger.error(f"Error getting inventory requirements: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve inventory requirements" ) @router.get("/{project_id}/inventory-requirements/dropdown") async def get_inventory_dropdown_options( project_id: UUID, usage_type: Optional[str] = Query(None, description="Filter by usage type: 'installed' or 'consumed'"), current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Get inventory options formatted for dropdown selection Used when receiving inventory batches - provides a clean list of options with code, name, description, and metadata. **Authorization:** - Any user on the project team can view **Response:** - Array of dropdown options sorted by category and name - Each option includes: code, name, description, category, unit, requires_serial_number, usage_type """ from app.services.inventory_requirements_service import InventoryRequirementsService try: options = InventoryRequirementsService.get_inventory_dropdown_options( project_id=project_id, db=db, usage_type=usage_type ) return { "project_id": str(project_id), "options": options, "total_count": len(options) } except HTTPException: raise except Exception as e: logger.error(f"Error getting inventory dropdown options: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve inventory dropdown options" ) @router.get("/{project_id}/inventory-requirements/completion-items") async def get_inventory_completion_items( project_id: UUID, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Get inventory items that appear in ticket completion forms Returns only 'installed' items with include_in_completion=True. These items will be dynamically added to ticket completion checklists. **Authorization:** - Any user on the project team can view **Response:** - Array of inventory items that require completion data - Each item includes: code, name, description, requires_serial_number, completion_field_label, completion_required """ from app.services.inventory_requirements_service import InventoryRequirementsService try: completion_items = InventoryRequirementsService.get_installed_inventory_for_completion( project_id=project_id, db=db ) return { "project_id": str(project_id), "completion_items": completion_items, "total_count": len(completion_items) } except HTTPException: raise except Exception as e: logger.error(f"Error getting inventory completion items: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve inventory completion items" ) @router.get("/{project_id}/inventory-requirements/categories") async def get_inventory_categories( project_id: UUID, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db) ): """ Get list of unique inventory categories in project Useful for filtering and grouping inventory items. **Authorization:** - Any user on the project team can view **Response:** - Array of category names (sorted alphabetically) """ from app.services.inventory_requirements_service import InventoryRequirementsService try: categories = InventoryRequirementsService.get_inventory_categories( project_id=project_id, db=db ) return { "project_id": str(project_id), "categories": categories, "total_count": len(categories) } except HTTPException: raise except Exception as e: logger.error(f"Error getting inventory categories: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve inventory categories" )