Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |
| # ============================================ | |
| 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, | |
| ) | |
| 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 | |
| ) | |
| 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 | |
| ] | |
| 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 | |
| ) | |
| 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 | |
| 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)}" | |
| ) | |
| 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)}" | |
| ) | |
| 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)}" | |
| ) | |
| 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" | |
| ) | |
| 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" | |
| ) | |
| 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" | |
| ) | |
| 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" | |
| ) | |
| 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 | |
| # ============================================ | |
| 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 | |
| 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) | |
| 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 | |
| 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) | |
| 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) | |
| 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] | |
| 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 | |
| # ============================================ | |
| 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" | |
| ) | |
| 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" | |
| ) | |
| 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 | |
| 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" | |
| ) | |
| 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 | |
| # ============================================ | |
| 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" | |
| ) | |
| 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" | |
| ) | |
| 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" | |
| ) | |
| 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 | |
| # ============================================ | |
| 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" | |
| ) | |
| 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" | |
| ) | |
| 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" | |
| ) | |
| 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 | |
| # ============================================ | |
| 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 | |
| # ============================================ | |
| 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" | |
| ) | |
| 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" | |
| ) | |
| 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" | |
| ) | |
| 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" | |
| ) | |