kamau1 commited on
Commit
19dd95f
·
1 Parent(s): eb78e1e

add unified project overview endpoint with caching and role-based response

Browse files
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: