kamau1's picture
feat: unified sync notification system, realtime timesheets and payroll, simplified project role compensation
95005e1
"""
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"
)