Spaces:
Sleeping
Sleeping
add unified project overview endpoint with caching and role-based response
Browse files- docs/api/projects/project-overview.md +237 -0
- src/app/api/v1/projects.py +57 -0
- src/app/schemas/project.py +51 -0
- src/app/services/dashboard_service.py +218 -1
- src/app/utils/cache.py +76 -1
docs/api/projects/project-overview.md
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Project Overview Endpoint
|
| 2 |
+
|
| 3 |
+
## Purpose
|
| 4 |
+
|
| 5 |
+
Single endpoint that replaces 4 separate calls to get complete project structure. Returns project details, regions, roles, subcontractors, and team info based on user permissions.
|
| 6 |
+
|
| 7 |
+
**Replaces:**
|
| 8 |
+
- `GET /projects/{id}`
|
| 9 |
+
- `GET /projects/{id}/regions`
|
| 10 |
+
- `GET /projects/{id}/project-roles`
|
| 11 |
+
- `GET /projects/{id}/subcontractors`
|
| 12 |
+
|
| 13 |
+
## Endpoint
|
| 14 |
+
|
| 15 |
+
```
|
| 16 |
+
GET /api/v1/projects/{project_id}/overview
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
**Query Params:**
|
| 20 |
+
- `refresh` (optional): `true` to force cache refresh
|
| 21 |
+
|
| 22 |
+
**Cache:** 12 hours
|
| 23 |
+
|
| 24 |
+
## Response Structure
|
| 25 |
+
|
| 26 |
+
### For Managers/Admins
|
| 27 |
+
|
| 28 |
+
```json
|
| 29 |
+
{
|
| 30 |
+
"project": {
|
| 31 |
+
"id": "uuid",
|
| 32 |
+
"title": "Project Name",
|
| 33 |
+
"project_type": "customer_service",
|
| 34 |
+
"status": "active",
|
| 35 |
+
"client_id": "uuid",
|
| 36 |
+
"client_name": "Client Name",
|
| 37 |
+
"contractor_id": "uuid",
|
| 38 |
+
"contractor_name": "Contractor Name",
|
| 39 |
+
"primary_manager_id": "uuid",
|
| 40 |
+
"primary_manager_name": "Manager Name",
|
| 41 |
+
"service_type": "ftth",
|
| 42 |
+
"planned_start_date": "2025-01-01",
|
| 43 |
+
"planned_end_date": "2025-12-31",
|
| 44 |
+
"activation_requirements": [...],
|
| 45 |
+
"photo_requirements": [...],
|
| 46 |
+
"budget": {...},
|
| 47 |
+
"is_closed": false,
|
| 48 |
+
"created_at": "2025-01-01T00:00:00Z"
|
| 49 |
+
},
|
| 50 |
+
"regions": [
|
| 51 |
+
{
|
| 52 |
+
"id": "uuid",
|
| 53 |
+
"region_name": "Nairobi West",
|
| 54 |
+
"region_code": "NRB-W",
|
| 55 |
+
"manager_id": "uuid",
|
| 56 |
+
"manager_name": "Regional Manager",
|
| 57 |
+
"is_active": true,
|
| 58 |
+
"city": "Nairobi",
|
| 59 |
+
"latitude": -1.2921,
|
| 60 |
+
"longitude": 36.8219
|
| 61 |
+
}
|
| 62 |
+
],
|
| 63 |
+
"roles": [
|
| 64 |
+
{
|
| 65 |
+
"id": "uuid",
|
| 66 |
+
"role_name": "Technician",
|
| 67 |
+
"compensation_type": "commission",
|
| 68 |
+
"commission_percentage": 15.0,
|
| 69 |
+
"base_amount": 500.0,
|
| 70 |
+
"is_active": true
|
| 71 |
+
}
|
| 72 |
+
],
|
| 73 |
+
"subcontractors": [
|
| 74 |
+
{
|
| 75 |
+
"id": "uuid",
|
| 76 |
+
"subcontractor_id": "uuid",
|
| 77 |
+
"subcontractor_name": "SubCo Ltd",
|
| 78 |
+
"scope_description": "Installation work",
|
| 79 |
+
"project_region_id": "uuid",
|
| 80 |
+
"region_name": "Nairobi West",
|
| 81 |
+
"contract_value": 50000.0,
|
| 82 |
+
"is_active": true
|
| 83 |
+
}
|
| 84 |
+
],
|
| 85 |
+
"team_summary": {
|
| 86 |
+
"total_members": 25,
|
| 87 |
+
"by_role": {
|
| 88 |
+
"field_agent": 15,
|
| 89 |
+
"dispatcher": 3,
|
| 90 |
+
"sales_agent": 5,
|
| 91 |
+
"project_manager": 2
|
| 92 |
+
},
|
| 93 |
+
"by_region": {
|
| 94 |
+
"Nairobi West": 10,
|
| 95 |
+
"Mombasa": 8,
|
| 96 |
+
"Project-wide": 7
|
| 97 |
+
}
|
| 98 |
+
},
|
| 99 |
+
"my_involvement": null,
|
| 100 |
+
"cached_at": "2025-12-02T10:00:00Z",
|
| 101 |
+
"cache_expires_in_seconds": 43200
|
| 102 |
+
}
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
### For Field Agents/Sales Agents
|
| 106 |
+
|
| 107 |
+
```json
|
| 108 |
+
{
|
| 109 |
+
"project": {
|
| 110 |
+
"id": "uuid",
|
| 111 |
+
"title": "Project Name",
|
| 112 |
+
"project_type": "customer_service",
|
| 113 |
+
"status": "active",
|
| 114 |
+
"client_name": "Client Name",
|
| 115 |
+
"contractor_name": "Contractor Name",
|
| 116 |
+
"service_type": "ftth",
|
| 117 |
+
"activation_requirements": [...],
|
| 118 |
+
"photo_requirements": [...]
|
| 119 |
+
},
|
| 120 |
+
"regions": [
|
| 121 |
+
{
|
| 122 |
+
"id": "uuid",
|
| 123 |
+
"region_name": "Nairobi West",
|
| 124 |
+
"is_active": true,
|
| 125 |
+
"city": "Nairobi"
|
| 126 |
+
}
|
| 127 |
+
],
|
| 128 |
+
"roles": null,
|
| 129 |
+
"subcontractors": null,
|
| 130 |
+
"team_summary": null,
|
| 131 |
+
"my_involvement": {
|
| 132 |
+
"user_id": "uuid",
|
| 133 |
+
"user_name": "John Doe",
|
| 134 |
+
"user_email": "john@example.com",
|
| 135 |
+
"user_role": "field_agent",
|
| 136 |
+
"team_role": "technician",
|
| 137 |
+
"project_role_id": "uuid",
|
| 138 |
+
"project_role_name": "Senior Technician",
|
| 139 |
+
"assigned_region_id": "uuid",
|
| 140 |
+
"assigned_region_name": "Nairobi West",
|
| 141 |
+
"is_lead": false,
|
| 142 |
+
"assigned_at": "2025-01-15T08:00:00Z",
|
| 143 |
+
"subcontractor_id": null,
|
| 144 |
+
"subcontractor_name": null
|
| 145 |
+
},
|
| 146 |
+
"cached_at": "2025-12-02T10:00:00Z",
|
| 147 |
+
"cache_expires_in_seconds": 43200
|
| 148 |
+
}
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
## Frontend Usage
|
| 152 |
+
|
| 153 |
+
### Display Project Overview Page
|
| 154 |
+
|
| 155 |
+
```typescript
|
| 156 |
+
// Fetch overview
|
| 157 |
+
const response = await fetch(`/api/v1/projects/${projectId}/overview`);
|
| 158 |
+
const data = await response.json();
|
| 159 |
+
|
| 160 |
+
// Show project header
|
| 161 |
+
showProjectHeader({
|
| 162 |
+
title: data.project.title,
|
| 163 |
+
status: data.project.status,
|
| 164 |
+
client: data.project.client_name,
|
| 165 |
+
contractor: data.project.contractor_name
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
// Show regions (all users see all regions)
|
| 169 |
+
data.regions.forEach(region => {
|
| 170 |
+
const isMyRegion = data.my_involvement?.assigned_region_id === region.id;
|
| 171 |
+
renderRegionCard(region, isMyRegion); // Highlight user's region
|
| 172 |
+
});
|
| 173 |
+
|
| 174 |
+
// Role-specific rendering
|
| 175 |
+
if (data.my_involvement) {
|
| 176 |
+
// Field agent/sales agent view
|
| 177 |
+
showMyInvolvement({
|
| 178 |
+
role: data.my_involvement.team_role,
|
| 179 |
+
region: data.my_involvement.assigned_region_name,
|
| 180 |
+
projectRole: data.my_involvement.project_role_name
|
| 181 |
+
});
|
| 182 |
+
} else {
|
| 183 |
+
// Manager/admin view
|
| 184 |
+
showRoles(data.roles);
|
| 185 |
+
showSubcontractors(data.subcontractors);
|
| 186 |
+
showTeamSummary(data.team_summary);
|
| 187 |
+
}
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+
### Check User's Region Assignment
|
| 191 |
+
|
| 192 |
+
```typescript
|
| 193 |
+
// Highlight user's assigned region in UI
|
| 194 |
+
const myRegionId = data.my_involvement?.assigned_region_id;
|
| 195 |
+
|
| 196 |
+
data.regions.forEach(region => {
|
| 197 |
+
const card = createRegionCard(region);
|
| 198 |
+
if (region.id === myRegionId) {
|
| 199 |
+
card.classList.add('my-region', 'highlighted');
|
| 200 |
+
}
|
| 201 |
+
});
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
### Display Requirements (Field Agents)
|
| 205 |
+
|
| 206 |
+
```typescript
|
| 207 |
+
// Show what photos are required
|
| 208 |
+
data.project.photo_requirements.forEach(req => {
|
| 209 |
+
console.log(`${req.type}: ${req.min_photos}-${req.max_photos} photos`);
|
| 210 |
+
});
|
| 211 |
+
|
| 212 |
+
// Show activation form fields
|
| 213 |
+
data.project.activation_requirements.forEach(field => {
|
| 214 |
+
renderFormField({
|
| 215 |
+
name: field.field,
|
| 216 |
+
label: field.label,
|
| 217 |
+
type: field.type,
|
| 218 |
+
required: field.required,
|
| 219 |
+
options: field.options
|
| 220 |
+
});
|
| 221 |
+
});
|
| 222 |
+
```
|
| 223 |
+
|
| 224 |
+
## Key Points
|
| 225 |
+
|
| 226 |
+
1. **Single call** - Replaces 4 separate API calls
|
| 227 |
+
2. **Role-based** - Response adapts to user permissions automatically
|
| 228 |
+
3. **Long cache** - 12 hours (structure rarely changes)
|
| 229 |
+
4. **All see regions** - Everyone sees all regions, UI highlights user's assigned region
|
| 230 |
+
5. **Field agents** - See their involvement only, not other team members
|
| 231 |
+
6. **Managers** - See complete project structure and team composition
|
| 232 |
+
|
| 233 |
+
## Error Responses
|
| 234 |
+
|
| 235 |
+
- `404` - Project not found
|
| 236 |
+
- `403` - User not authorized to access project
|
| 237 |
+
- `500` - Server error
|
src/app/api/v1/projects.py
CHANGED
|
@@ -475,6 +475,63 @@ async def get_project_activity_feed(
|
|
| 475 |
)
|
| 476 |
|
| 477 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
@router.put("/{project_id}", response_model=ProjectResponse)
|
| 479 |
@router.patch("/{project_id}", response_model=ProjectResponse)
|
| 480 |
@require_permission("manage_projects")
|
|
|
|
| 475 |
)
|
| 476 |
|
| 477 |
|
| 478 |
+
@router.get("/{project_id}/overview")
|
| 479 |
+
async def get_project_overview(
|
| 480 |
+
project_id: UUID,
|
| 481 |
+
refresh: bool = Query(False, description="Force refresh cache"),
|
| 482 |
+
current_user: User = Depends(get_current_active_user),
|
| 483 |
+
db: Session = Depends(get_db)
|
| 484 |
+
):
|
| 485 |
+
"""
|
| 486 |
+
Get comprehensive project overview (structure, not metrics).
|
| 487 |
+
|
| 488 |
+
Returns project details, regions, roles, subcontractors, and team information
|
| 489 |
+
tailored to the user's role and permissions.
|
| 490 |
+
|
| 491 |
+
**For Managers/Admins:**
|
| 492 |
+
- Full project details
|
| 493 |
+
- All regions with details
|
| 494 |
+
- All project roles with compensation structures
|
| 495 |
+
- All subcontractors
|
| 496 |
+
- Team composition summary
|
| 497 |
+
|
| 498 |
+
**For Field Agents/Sales Agents:**
|
| 499 |
+
- Basic project information
|
| 500 |
+
- All regions (UI highlights their assigned region)
|
| 501 |
+
- Their specific involvement (role, region, subcontractor)
|
| 502 |
+
- Project requirements (photo_requirements, activation_requirements)
|
| 503 |
+
- No access to other team members, all roles, or all subcontractors
|
| 504 |
+
|
| 505 |
+
**Performance:**
|
| 506 |
+
- Cached for 12 hours for optimal performance
|
| 507 |
+
- Use ?refresh=true to force cache refresh
|
| 508 |
+
- Structure changes rarely, so long cache is appropriate
|
| 509 |
+
|
| 510 |
+
**Authorization:** Any user with project access
|
| 511 |
+
"""
|
| 512 |
+
try:
|
| 513 |
+
from app.services.dashboard_service import DashboardService
|
| 514 |
+
from app.schemas.project import ProjectOverviewResponse
|
| 515 |
+
|
| 516 |
+
overview = DashboardService.get_project_overview(
|
| 517 |
+
db=db,
|
| 518 |
+
project_id=str(project_id),
|
| 519 |
+
current_user=current_user,
|
| 520 |
+
force_refresh=refresh
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
return overview
|
| 524 |
+
|
| 525 |
+
except HTTPException:
|
| 526 |
+
raise
|
| 527 |
+
except Exception as e:
|
| 528 |
+
logger.error(f"Failed to get project overview: {str(e)}", exc_info=True)
|
| 529 |
+
raise HTTPException(
|
| 530 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 531 |
+
detail=f"Failed to get project overview: {str(e)}"
|
| 532 |
+
)
|
| 533 |
+
|
| 534 |
+
|
| 535 |
@router.put("/{project_id}", response_model=ProjectResponse)
|
| 536 |
@router.patch("/{project_id}", response_model=ProjectResponse)
|
| 537 |
@require_permission("manage_projects")
|
src/app/schemas/project.py
CHANGED
|
@@ -534,3 +534,54 @@ class ProjectSetupResponse(BaseModel):
|
|
| 534 |
subcontractors_added: int
|
| 535 |
team_members_added: int
|
| 536 |
message: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
subcontractors_added: int
|
| 535 |
team_members_added: int
|
| 536 |
message: str
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
# ============================================
|
| 540 |
+
# PROJECT OVERVIEW SCHEMAS
|
| 541 |
+
# ============================================
|
| 542 |
+
|
| 543 |
+
class UserInvolvement(BaseModel):
|
| 544 |
+
"""User's involvement in the project (for field agents/sales agents)"""
|
| 545 |
+
user_id: UUID
|
| 546 |
+
user_name: str
|
| 547 |
+
user_email: Optional[str]
|
| 548 |
+
user_role: str # System role (field_agent, sales_agent, etc.)
|
| 549 |
+
team_role: Optional[str] # Project team role
|
| 550 |
+
project_role_id: Optional[UUID] # Custom project role with compensation
|
| 551 |
+
project_role_name: Optional[str]
|
| 552 |
+
assigned_region_id: Optional[UUID] # Their assigned region
|
| 553 |
+
assigned_region_name: Optional[str]
|
| 554 |
+
is_lead: bool
|
| 555 |
+
assigned_at: datetime
|
| 556 |
+
subcontractor_id: Optional[UUID] # If they work for a subcontractor
|
| 557 |
+
subcontractor_name: Optional[str]
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
class ProjectOverviewResponse(BaseModel):
|
| 561 |
+
"""
|
| 562 |
+
Comprehensive project overview response.
|
| 563 |
+
Adapts based on user role:
|
| 564 |
+
- Managers/Admins: Full project structure
|
| 565 |
+
- Field/Sales Agents: Basic info + their involvement
|
| 566 |
+
"""
|
| 567 |
+
# Basic project info (everyone sees this)
|
| 568 |
+
project: ProjectResponse
|
| 569 |
+
|
| 570 |
+
# Regions (everyone sees all regions)
|
| 571 |
+
regions: List[ProjectRegionResponse]
|
| 572 |
+
|
| 573 |
+
# Role-specific data
|
| 574 |
+
# For managers/admins: all roles, all subcontractors, team composition
|
| 575 |
+
roles: Optional[List[ProjectRoleResponse]] = None
|
| 576 |
+
subcontractors: Optional[List[ProjectSubcontractorResponse]] = None
|
| 577 |
+
team_summary: Optional[Dict[str, Any]] = None # Team composition stats
|
| 578 |
+
|
| 579 |
+
# For field/sales agents: their involvement only
|
| 580 |
+
my_involvement: Optional[UserInvolvement] = None
|
| 581 |
+
|
| 582 |
+
# Cache info
|
| 583 |
+
cached_at: str = Field(..., description="ISO timestamp when data was cached")
|
| 584 |
+
cache_expires_in_seconds: int = Field(43200, description="Cache TTL in seconds (12 hours)")
|
| 585 |
+
|
| 586 |
+
class Config:
|
| 587 |
+
from_attributes = True
|
src/app/services/dashboard_service.py
CHANGED
|
@@ -25,7 +25,7 @@ from app.models.enums import ProjectType, AppRole, TicketStatus, SalesOrderStatu
|
|
| 25 |
from app.services.ticket_service import TicketService
|
| 26 |
from app.services.sales_order_service import SalesOrderService
|
| 27 |
from app.services.notification_service import NotificationService
|
| 28 |
-
from app.utils.cache import get_cached_dashboard, set_cached_dashboard
|
| 29 |
|
| 30 |
logger = logging.getLogger(__name__)
|
| 31 |
|
|
@@ -1326,3 +1326,220 @@ class DashboardService:
|
|
| 1326 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 1327 |
detail=f"Failed to generate user overview: {str(e)}"
|
| 1328 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
from app.services.ticket_service import TicketService
|
| 26 |
from app.services.sales_order_service import SalesOrderService
|
| 27 |
from app.services.notification_service import NotificationService
|
| 28 |
+
from app.utils.cache import get_cached_dashboard, set_cached_dashboard, get_cached_overview, set_cached_overview
|
| 29 |
|
| 30 |
logger = logging.getLogger(__name__)
|
| 31 |
|
|
|
|
| 1326 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 1327 |
detail=f"Failed to generate user overview: {str(e)}"
|
| 1328 |
)
|
| 1329 |
+
|
| 1330 |
+
@staticmethod
|
| 1331 |
+
def get_project_overview(
|
| 1332 |
+
db: Session,
|
| 1333 |
+
project_id: str,
|
| 1334 |
+
current_user: User,
|
| 1335 |
+
force_refresh: bool = False
|
| 1336 |
+
) -> Dict:
|
| 1337 |
+
"""
|
| 1338 |
+
Get comprehensive project overview (structure, not metrics).
|
| 1339 |
+
|
| 1340 |
+
Returns project details, regions, roles, subcontractors, and team info
|
| 1341 |
+
based on user's role and permissions.
|
| 1342 |
+
|
| 1343 |
+
Uses caching with 12-hour TTL unless force_refresh=True.
|
| 1344 |
+
|
| 1345 |
+
Args:
|
| 1346 |
+
db: Database session
|
| 1347 |
+
project_id: Project UUID
|
| 1348 |
+
current_user: Current authenticated user
|
| 1349 |
+
force_refresh: Force cache refresh
|
| 1350 |
+
|
| 1351 |
+
Returns:
|
| 1352 |
+
Dict with project overview data tailored to user role
|
| 1353 |
+
"""
|
| 1354 |
+
# Check cache first (unless force refresh)
|
| 1355 |
+
if not force_refresh:
|
| 1356 |
+
cached = get_cached_overview(project_id, str(current_user.id))
|
| 1357 |
+
if cached:
|
| 1358 |
+
logger.info(f"Overview cache HIT for project {project_id}, user {current_user.id}")
|
| 1359 |
+
return cached
|
| 1360 |
+
|
| 1361 |
+
logger.info(f"Overview cache MISS for project {project_id}, user {current_user.id} - building fresh data")
|
| 1362 |
+
|
| 1363 |
+
# Get project with relationships
|
| 1364 |
+
project = db.query(Project).options(
|
| 1365 |
+
joinedload(Project.client),
|
| 1366 |
+
joinedload(Project.contractor),
|
| 1367 |
+
joinedload(Project.primary_manager)
|
| 1368 |
+
).filter(
|
| 1369 |
+
Project.id == project_id,
|
| 1370 |
+
Project.deleted_at.is_(None)
|
| 1371 |
+
).first()
|
| 1372 |
+
|
| 1373 |
+
if not project:
|
| 1374 |
+
raise HTTPException(
|
| 1375 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 1376 |
+
detail=f"Project not found: {project_id}"
|
| 1377 |
+
)
|
| 1378 |
+
|
| 1379 |
+
# Check authorization
|
| 1380 |
+
if not DashboardService.can_user_access_project(current_user, project_id, db):
|
| 1381 |
+
raise HTTPException(
|
| 1382 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 1383 |
+
detail="Not authorized to access this project"
|
| 1384 |
+
)
|
| 1385 |
+
|
| 1386 |
+
# Build project response
|
| 1387 |
+
from app.schemas.project import ProjectResponse
|
| 1388 |
+
project_response = ProjectResponse.model_validate(project)
|
| 1389 |
+
project_response.client_name = project.client.name if project.client else None
|
| 1390 |
+
project_response.contractor_name = project.contractor.name if project.contractor else None
|
| 1391 |
+
project_response.primary_manager_name = project.primary_manager.name if project.primary_manager else None
|
| 1392 |
+
project_response.is_overdue = project.is_overdue
|
| 1393 |
+
project_response.duration_days = project.duration_days
|
| 1394 |
+
|
| 1395 |
+
# Get all regions (everyone sees all regions)
|
| 1396 |
+
from app.models.project import ProjectRegion
|
| 1397 |
+
from app.schemas.project import ProjectRegionResponse
|
| 1398 |
+
|
| 1399 |
+
regions = db.query(ProjectRegion).options(
|
| 1400 |
+
joinedload(ProjectRegion.manager)
|
| 1401 |
+
).filter(
|
| 1402 |
+
ProjectRegion.project_id == project_id,
|
| 1403 |
+
ProjectRegion.deleted_at.is_(None)
|
| 1404 |
+
).all()
|
| 1405 |
+
|
| 1406 |
+
region_responses = []
|
| 1407 |
+
for region in regions:
|
| 1408 |
+
region_resp = ProjectRegionResponse.model_validate(region)
|
| 1409 |
+
region_resp.manager_name = region.manager.name if region.manager else None
|
| 1410 |
+
region_responses.append(region_resp)
|
| 1411 |
+
|
| 1412 |
+
# Build response based on user role
|
| 1413 |
+
is_manager_or_admin = current_user.role in [
|
| 1414 |
+
AppRole.PLATFORM_ADMIN.value,
|
| 1415 |
+
AppRole.CLIENT_ADMIN.value,
|
| 1416 |
+
AppRole.CONTRACTOR_ADMIN.value,
|
| 1417 |
+
AppRole.PROJECT_MANAGER.value,
|
| 1418 |
+
AppRole.DISPATCHER.value
|
| 1419 |
+
]
|
| 1420 |
+
|
| 1421 |
+
overview_data = {
|
| 1422 |
+
"project": project_response.model_dump(),
|
| 1423 |
+
"regions": [r.model_dump() for r in region_responses],
|
| 1424 |
+
"cached_at": datetime.utcnow().isoformat() + "Z",
|
| 1425 |
+
"cache_expires_in_seconds": 43200 # 12 hours
|
| 1426 |
+
}
|
| 1427 |
+
|
| 1428 |
+
if is_manager_or_admin:
|
| 1429 |
+
# Managers/Admins: Get full project structure
|
| 1430 |
+
|
| 1431 |
+
# Get all project roles
|
| 1432 |
+
from app.models.project import ProjectRole
|
| 1433 |
+
from app.schemas.project import ProjectRoleResponse
|
| 1434 |
+
|
| 1435 |
+
roles = db.query(ProjectRole).filter(
|
| 1436 |
+
ProjectRole.project_id == project_id,
|
| 1437 |
+
ProjectRole.deleted_at.is_(None)
|
| 1438 |
+
).all()
|
| 1439 |
+
|
| 1440 |
+
role_responses = [ProjectRoleResponse.model_validate(r) for r in roles]
|
| 1441 |
+
overview_data["roles"] = [r.model_dump() for r in role_responses]
|
| 1442 |
+
|
| 1443 |
+
# Get all subcontractors
|
| 1444 |
+
from app.models.project import ProjectSubcontractor
|
| 1445 |
+
from app.schemas.project import ProjectSubcontractorResponse
|
| 1446 |
+
|
| 1447 |
+
subcontractors = db.query(ProjectSubcontractor).options(
|
| 1448 |
+
joinedload(ProjectSubcontractor.subcontractor),
|
| 1449 |
+
joinedload(ProjectSubcontractor.region)
|
| 1450 |
+
).filter(
|
| 1451 |
+
ProjectSubcontractor.project_id == project_id,
|
| 1452 |
+
ProjectSubcontractor.deleted_at.is_(None)
|
| 1453 |
+
).all()
|
| 1454 |
+
|
| 1455 |
+
subcontractor_responses = []
|
| 1456 |
+
for sub in subcontractors:
|
| 1457 |
+
sub_resp = ProjectSubcontractorResponse.model_validate(sub)
|
| 1458 |
+
sub_resp.subcontractor_name = sub.subcontractor.name if sub.subcontractor else None
|
| 1459 |
+
sub_resp.region_name = sub.region.region_name if sub.region else None
|
| 1460 |
+
subcontractor_responses.append(sub_resp)
|
| 1461 |
+
|
| 1462 |
+
overview_data["subcontractors"] = [s.model_dump() for s in subcontractor_responses]
|
| 1463 |
+
|
| 1464 |
+
# Get team summary
|
| 1465 |
+
team_members = db.query(ProjectTeam).options(
|
| 1466 |
+
joinedload(ProjectTeam.user),
|
| 1467 |
+
joinedload(ProjectTeam.project_role),
|
| 1468 |
+
joinedload(ProjectTeam.region)
|
| 1469 |
+
).filter(
|
| 1470 |
+
ProjectTeam.project_id == project_id,
|
| 1471 |
+
ProjectTeam.deleted_at.is_(None),
|
| 1472 |
+
ProjectTeam.removed_at.is_(None)
|
| 1473 |
+
).all()
|
| 1474 |
+
|
| 1475 |
+
# Build team summary
|
| 1476 |
+
by_role = {}
|
| 1477 |
+
by_region = {}
|
| 1478 |
+
for member in team_members:
|
| 1479 |
+
# Count by system role
|
| 1480 |
+
user_role = member.user.role if member.user else "unknown"
|
| 1481 |
+
by_role[user_role] = by_role.get(user_role, 0) + 1
|
| 1482 |
+
|
| 1483 |
+
# Count by region
|
| 1484 |
+
if member.region:
|
| 1485 |
+
region_name = member.region.region_name
|
| 1486 |
+
by_region[region_name] = by_region.get(region_name, 0) + 1
|
| 1487 |
+
else:
|
| 1488 |
+
by_region["Project-wide"] = by_region.get("Project-wide", 0) + 1
|
| 1489 |
+
|
| 1490 |
+
overview_data["team_summary"] = {
|
| 1491 |
+
"total_members": len(team_members),
|
| 1492 |
+
"by_role": by_role,
|
| 1493 |
+
"by_region": by_region
|
| 1494 |
+
}
|
| 1495 |
+
|
| 1496 |
+
overview_data["my_involvement"] = None
|
| 1497 |
+
|
| 1498 |
+
else:
|
| 1499 |
+
# Field agents/Sales agents: Get their involvement only
|
| 1500 |
+
team_membership = db.query(ProjectTeam).options(
|
| 1501 |
+
joinedload(ProjectTeam.project_role),
|
| 1502 |
+
joinedload(ProjectTeam.region),
|
| 1503 |
+
joinedload(ProjectTeam.subcontractor).joinedload(ProjectSubcontractor.subcontractor)
|
| 1504 |
+
).filter(
|
| 1505 |
+
ProjectTeam.project_id == project_id,
|
| 1506 |
+
ProjectTeam.user_id == current_user.id,
|
| 1507 |
+
ProjectTeam.deleted_at.is_(None),
|
| 1508 |
+
ProjectTeam.removed_at.is_(None)
|
| 1509 |
+
).first()
|
| 1510 |
+
|
| 1511 |
+
if team_membership:
|
| 1512 |
+
involvement = {
|
| 1513 |
+
"user_id": str(current_user.id),
|
| 1514 |
+
"user_name": current_user.name,
|
| 1515 |
+
"user_email": current_user.email,
|
| 1516 |
+
"user_role": current_user.role,
|
| 1517 |
+
"team_role": team_membership.role,
|
| 1518 |
+
"project_role_id": str(team_membership.project_role_id) if team_membership.project_role_id else None,
|
| 1519 |
+
"project_role_name": team_membership.project_role.role_name if team_membership.project_role else None,
|
| 1520 |
+
"assigned_region_id": str(team_membership.project_region_id) if team_membership.project_region_id else None,
|
| 1521 |
+
"assigned_region_name": team_membership.region.region_name if team_membership.region else None,
|
| 1522 |
+
"is_lead": team_membership.is_lead,
|
| 1523 |
+
"assigned_at": team_membership.assigned_at.isoformat() if team_membership.assigned_at else None,
|
| 1524 |
+
"subcontractor_id": str(team_membership.project_subcontractor_id) if team_membership.project_subcontractor_id else None,
|
| 1525 |
+
"subcontractor_name": None
|
| 1526 |
+
}
|
| 1527 |
+
|
| 1528 |
+
# Get subcontractor name if applicable
|
| 1529 |
+
if team_membership.subcontractor and team_membership.subcontractor.subcontractor:
|
| 1530 |
+
involvement["subcontractor_name"] = team_membership.subcontractor.subcontractor.name
|
| 1531 |
+
|
| 1532 |
+
overview_data["my_involvement"] = involvement
|
| 1533 |
+
else:
|
| 1534 |
+
overview_data["my_involvement"] = None
|
| 1535 |
+
|
| 1536 |
+
# Don't include roles, subcontractors, or team_summary for field agents
|
| 1537 |
+
overview_data["roles"] = None
|
| 1538 |
+
overview_data["subcontractors"] = None
|
| 1539 |
+
overview_data["team_summary"] = None
|
| 1540 |
+
|
| 1541 |
+
# Cache the result
|
| 1542 |
+
set_cached_overview(project_id, str(current_user.id), overview_data)
|
| 1543 |
+
|
| 1544 |
+
logger.info(f"Built and cached overview for project {project_id}")
|
| 1545 |
+
return overview_data
|
src/app/utils/cache.py
CHANGED
|
@@ -20,6 +20,11 @@ dashboard_cache_lock = RLock()
|
|
| 20 |
trends_cache = TTLCache(maxsize=500, ttl=600)
|
| 21 |
trends_cache_lock = RLock()
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
def get_cached_dashboard(project_id: str, user_id: str) -> Optional[dict]:
|
| 25 |
"""
|
|
@@ -180,6 +185,71 @@ def clear_all_caches() -> None:
|
|
| 180 |
logger.error(f"Error clearing caches: {e}")
|
| 181 |
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
def get_cache_stats() -> dict:
|
| 184 |
"""
|
| 185 |
Get cache statistics for monitoring.
|
|
@@ -188,7 +258,7 @@ def get_cache_stats() -> dict:
|
|
| 188 |
Dict with cache size, hits, etc.
|
| 189 |
"""
|
| 190 |
try:
|
| 191 |
-
with dashboard_cache_lock, trends_cache_lock:
|
| 192 |
return {
|
| 193 |
"dashboard_cache": {
|
| 194 |
"size": len(dashboard_cache),
|
|
@@ -199,6 +269,11 @@ def get_cache_stats() -> dict:
|
|
| 199 |
"size": len(trends_cache),
|
| 200 |
"maxsize": trends_cache.maxsize,
|
| 201 |
"ttl": trends_cache.ttl
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
}
|
| 203 |
}
|
| 204 |
except Exception as e:
|
|
|
|
| 20 |
trends_cache = TTLCache(maxsize=500, ttl=600)
|
| 21 |
trends_cache_lock = RLock()
|
| 22 |
|
| 23 |
+
# Project overview cache with 12-hour TTL (structure changes rarely)
|
| 24 |
+
# Stores project structure: regions, roles, subcontractors, team info
|
| 25 |
+
overview_cache = TTLCache(maxsize=500, ttl=43200) # 12 hours = 43200 seconds
|
| 26 |
+
overview_cache_lock = RLock()
|
| 27 |
+
|
| 28 |
|
| 29 |
def get_cached_dashboard(project_id: str, user_id: str) -> Optional[dict]:
|
| 30 |
"""
|
|
|
|
| 185 |
logger.error(f"Error clearing caches: {e}")
|
| 186 |
|
| 187 |
|
| 188 |
+
def get_cached_overview(project_id: str, user_id: str) -> Optional[dict]:
|
| 189 |
+
"""
|
| 190 |
+
Get cached project overview data.
|
| 191 |
+
|
| 192 |
+
Args:
|
| 193 |
+
project_id: UUID of the project
|
| 194 |
+
user_id: UUID of the user
|
| 195 |
+
|
| 196 |
+
Returns:
|
| 197 |
+
Cached overview dict or None if not found/expired
|
| 198 |
+
"""
|
| 199 |
+
try:
|
| 200 |
+
with overview_cache_lock:
|
| 201 |
+
key = f"overview:{project_id}:{user_id}"
|
| 202 |
+
cached_data = overview_cache.get(key)
|
| 203 |
+
if cached_data:
|
| 204 |
+
logger.debug(f"Overview cache HIT: {key}")
|
| 205 |
+
return cached_data
|
| 206 |
+
except Exception as e:
|
| 207 |
+
logger.error(f"Error retrieving overview from cache: {e}")
|
| 208 |
+
return None
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def set_cached_overview(project_id: str, user_id: str, data: dict) -> None:
|
| 212 |
+
"""
|
| 213 |
+
Cache project overview data.
|
| 214 |
+
|
| 215 |
+
Args:
|
| 216 |
+
project_id: UUID of the project
|
| 217 |
+
user_id: UUID of the user
|
| 218 |
+
data: Overview data to cache
|
| 219 |
+
"""
|
| 220 |
+
try:
|
| 221 |
+
with overview_cache_lock:
|
| 222 |
+
key = f"overview:{project_id}:{user_id}"
|
| 223 |
+
overview_cache[key] = data
|
| 224 |
+
logger.debug(f"Overview cache SET: {key}")
|
| 225 |
+
except Exception as e:
|
| 226 |
+
logger.error(f"Error setting overview cache: {e}")
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def invalidate_overview_cache(project_id: str) -> None:
|
| 230 |
+
"""
|
| 231 |
+
Invalidate all cached overviews for a project.
|
| 232 |
+
Called when project structure changes (regions, roles, team, subcontractors).
|
| 233 |
+
|
| 234 |
+
Args:
|
| 235 |
+
project_id: UUID of the project
|
| 236 |
+
"""
|
| 237 |
+
try:
|
| 238 |
+
with overview_cache_lock:
|
| 239 |
+
keys_to_delete = [
|
| 240 |
+
k for k in overview_cache.keys()
|
| 241 |
+
if k.startswith(f"overview:{project_id}:")
|
| 242 |
+
]
|
| 243 |
+
|
| 244 |
+
for key in keys_to_delete:
|
| 245 |
+
overview_cache.pop(key, None)
|
| 246 |
+
|
| 247 |
+
if keys_to_delete:
|
| 248 |
+
logger.info(f"Invalidated {len(keys_to_delete)} overview cache entries for project {project_id}")
|
| 249 |
+
except Exception as e:
|
| 250 |
+
logger.error(f"Error invalidating overview cache: {e}")
|
| 251 |
+
|
| 252 |
+
|
| 253 |
def get_cache_stats() -> dict:
|
| 254 |
"""
|
| 255 |
Get cache statistics for monitoring.
|
|
|
|
| 258 |
Dict with cache size, hits, etc.
|
| 259 |
"""
|
| 260 |
try:
|
| 261 |
+
with dashboard_cache_lock, trends_cache_lock, overview_cache_lock:
|
| 262 |
return {
|
| 263 |
"dashboard_cache": {
|
| 264 |
"size": len(dashboard_cache),
|
|
|
|
| 269 |
"size": len(trends_cache),
|
| 270 |
"maxsize": trends_cache.maxsize,
|
| 271 |
"ttl": trends_cache.ttl
|
| 272 |
+
},
|
| 273 |
+
"overview_cache": {
|
| 274 |
+
"size": len(overview_cache),
|
| 275 |
+
"maxsize": overview_cache.maxsize,
|
| 276 |
+
"ttl": overview_cache.ttl
|
| 277 |
}
|
| 278 |
}
|
| 279 |
except Exception as e:
|