vxrachit commited on
Commit
0217894
·
1 Parent(s): 75e22f2

Added admin endpoints.

Browse files
Files changed (1) hide show
  1. Backend/api/routes/admin.py +1160 -0
Backend/api/routes/admin.py ADDED
@@ -0,0 +1,1160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional, List
2
+ from uuid import UUID
3
+ from datetime import datetime, timedelta
4
+ from fastapi import APIRouter, Depends, HTTPException, status, Query
5
+ from pydantic import BaseModel, EmailStr
6
+ from sqlalchemy import select, func, or_, desc, asc
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+ from sqlalchemy.orm import selectinload, aliased
9
+ import bcrypt
10
+ import jwt
11
+
12
+ from Backend.database.connection import get_db
13
+ from Backend.database.models import Department, Member, Issue, Escalation, Classification, IssueEvent, IssueImage
14
+ from Backend.core.config import settings
15
+ from Backend.core.logging import get_logger
16
+ from Backend.core.schemas import IssueResponse, IssueState
17
+ from Backend.utils.storage import get_upload_url
18
+
19
+ logger = get_logger(__name__)
20
+ router = APIRouter()
21
+
22
+ SECRET_KEY = settings.supabase_jwt_secret
23
+ ALGORITHM = "HS256"
24
+ ACCESS_TOKEN_EXPIRE_HOURS = 24
25
+
26
+
27
+ def hash_password(password: str) -> str:
28
+ return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
29
+
30
+
31
+ def verify_password(password: str, password_hash: str) -> bool:
32
+ return bcrypt.checkpw(password.encode(), password_hash.encode())
33
+
34
+
35
+ def create_access_token(member_id: UUID, role: str) -> str:
36
+ expire = datetime.utcnow() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
37
+ payload = {
38
+ "sub": str(member_id),
39
+ "role": role,
40
+ "exp": expire,
41
+ "iat": datetime.utcnow(),
42
+ }
43
+ return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
44
+
45
+
46
+ class LoginRequest(BaseModel):
47
+ email: str
48
+ password: str
49
+ expected_role: Optional[str] = None
50
+
51
+
52
+ class LoginResponse(BaseModel):
53
+ access_token: str
54
+ token_type: str = "bearer"
55
+ user: dict
56
+
57
+
58
+
59
+ from fastapi.security import OAuth2PasswordBearer
60
+ from jwt import PyJWTError
61
+
62
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/admin/login")
63
+
64
+ async def get_current_user(token: str = Depends(oauth2_scheme), db: AsyncSession = Depends(get_db)):
65
+ try:
66
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
67
+ member_id: str = payload.get("sub")
68
+ if member_id is None:
69
+ raise HTTPException(status_code=401, detail="Invalid authentication credentials")
70
+ except PyJWTError:
71
+ raise HTTPException(status_code=401, detail="Invalid authentication credentials")
72
+
73
+ member = await db.get(Member, UUID(member_id))
74
+ if member is None:
75
+ raise HTTPException(status_code=401, detail="User not found")
76
+ return member
77
+
78
+ async def get_current_active_user(current_user: Member = Depends(get_current_user)):
79
+ if not current_user.is_active:
80
+ raise HTTPException(status_code=400, detail="Inactive user")
81
+ return current_user
82
+
83
+ async def get_current_admin(current_user: Member = Depends(get_current_active_user)):
84
+ if current_user.role != "admin":
85
+ raise HTTPException(status_code=403, detail="Not authorized")
86
+ return current_user
87
+
88
+
89
+ @router.post("/login", response_model=LoginResponse)
90
+ async def staff_login(
91
+ data: LoginRequest,
92
+ db: AsyncSession = Depends(get_db),
93
+ ):
94
+ member = await db.execute(
95
+ select(Member).where(Member.email == data.email, Member.is_active == True)
96
+ )
97
+ member = member.scalar_one_or_none()
98
+
99
+ if not member or not member.password_hash:
100
+ raise HTTPException(status_code=401, detail="Invalid email or password")
101
+
102
+ if not verify_password(data.password, member.password_hash):
103
+ raise HTTPException(status_code=401, detail="Invalid email or password")
104
+
105
+ if data.expected_role:
106
+ if data.expected_role == "admin" and member.role != "admin":
107
+ raise HTTPException(status_code=403, detail="Access denied. You are not an admin.")
108
+ if data.expected_role == "worker" and member.role == "admin":
109
+ raise HTTPException(status_code=403, detail="Admins should login as Admin, not Worker.")
110
+
111
+ access_token = create_access_token(member.id, member.role)
112
+
113
+ return LoginResponse(
114
+ access_token=access_token,
115
+ user={
116
+ "id": str(member.id),
117
+ "name": member.name,
118
+ "email": member.email,
119
+ "role": member.role,
120
+ "department_id": str(member.department_id) if member.department_id else None,
121
+ },
122
+ )
123
+
124
+
125
+ class DepartmentCreate(BaseModel):
126
+ name: str
127
+ code: str
128
+ description: Optional[str] = None
129
+ categories: Optional[str] = None
130
+ default_sla_hours: int = 48
131
+ escalation_email: Optional[str] = None
132
+
133
+
134
+ class DepartmentUpdate(BaseModel):
135
+ name: Optional[str] = None
136
+ description: Optional[str] = None
137
+ categories: Optional[str] = None
138
+ default_sla_hours: Optional[int] = None
139
+ escalation_email: Optional[str] = None
140
+ is_active: Optional[bool] = None
141
+
142
+
143
+ class DepartmentResponse(BaseModel):
144
+ id: UUID
145
+ name: str
146
+ code: str
147
+ description: Optional[str]
148
+ categories: Optional[str]
149
+ default_sla_hours: int
150
+ escalation_email: Optional[str]
151
+ is_active: bool
152
+ member_count: int = 0
153
+
154
+ class Config:
155
+ from_attributes = True
156
+
157
+
158
+ class MemberInvite(BaseModel):
159
+ department_id: UUID
160
+ name: str
161
+ email: str
162
+ phone: Optional[str] = None
163
+ role: str = "officer"
164
+ city: Optional[str] = None
165
+ locality: Optional[str] = None
166
+ max_workload: int = 10
167
+ send_invite: bool = True
168
+
169
+
170
+ class MemberCreate(BaseModel):
171
+ department_id: UUID
172
+ name: str
173
+ email: str
174
+ phone: Optional[str] = None
175
+ role: str = "worker"
176
+ city: Optional[str] = None
177
+ locality: Optional[str] = None
178
+ max_workload: int = 10
179
+ password: str
180
+
181
+
182
+ class MemberUpdate(BaseModel):
183
+ name: Optional[str] = None
184
+ email: Optional[str] = None
185
+ phone: Optional[str] = None
186
+ role: Optional[str] = None
187
+ city: Optional[str] = None
188
+ locality: Optional[str] = None
189
+ max_workload: Optional[int] = None
190
+ is_active: Optional[bool] = None
191
+ password: Optional[str] = None
192
+
193
+
194
+ class MemberResponse(BaseModel):
195
+ id: UUID
196
+ department_id: Optional[UUID]
197
+ name: str
198
+ email: str
199
+ phone: Optional[str]
200
+ role: str
201
+ city: Optional[str]
202
+ locality: Optional[str]
203
+ is_active: bool
204
+ current_workload: int
205
+ max_workload: int
206
+ invite_status: Optional[str] = None
207
+
208
+ class Config:
209
+ from_attributes = True
210
+
211
+
212
+
213
+
214
+
215
+ @router.post("/departments", response_model=DepartmentResponse, status_code=status.HTTP_201_CREATED)
216
+ async def create_department(
217
+ data: DepartmentCreate,
218
+ db: AsyncSession = Depends(get_db),
219
+ current_user: Member = Depends(get_current_admin),
220
+ ):
221
+
222
+ existing = await db.execute(select(Department).where(Department.code == data.code))
223
+ if existing.scalar_one_or_none():
224
+ raise HTTPException(status_code=400, detail="Department code already exists")
225
+
226
+ department = Department(
227
+ name=data.name,
228
+ code=data.code.upper(),
229
+ description=data.description,
230
+ categories=data.categories,
231
+ default_sla_hours=data.default_sla_hours,
232
+ escalation_email=data.escalation_email,
233
+ )
234
+ db.add(department)
235
+ await db.flush()
236
+ await db.refresh(department)
237
+
238
+ return DepartmentResponse(
239
+ id=department.id,
240
+ name=department.name,
241
+ code=department.code,
242
+ description=department.description,
243
+ categories=department.categories,
244
+ default_sla_hours=department.default_sla_hours,
245
+ escalation_email=department.escalation_email,
246
+ is_active=department.is_active,
247
+ member_count=0,
248
+ )
249
+
250
+
251
+ @router.get("/departments", response_model=list[DepartmentResponse])
252
+ async def list_departments(
253
+ db: AsyncSession = Depends(get_db),
254
+ current_user: Member = Depends(get_current_active_user),
255
+ ):
256
+ query = select(Department).order_by(Department.name)
257
+ result = await db.execute(query)
258
+ departments = result.scalars().all()
259
+
260
+ response = []
261
+ for dept in departments:
262
+ member_count = await db.execute(
263
+ select(func.count(Member.id)).where(Member.department_id == dept.id)
264
+ )
265
+ count = member_count.scalar() or 0
266
+
267
+ response.append(DepartmentResponse(
268
+ id=dept.id,
269
+ name=dept.name,
270
+ code=dept.code,
271
+ description=dept.description,
272
+ categories=dept.categories,
273
+ default_sla_hours=dept.default_sla_hours,
274
+ escalation_email=dept.escalation_email,
275
+ is_active=dept.is_active,
276
+ member_count=count,
277
+ ))
278
+
279
+ return response
280
+
281
+
282
+ @router.get("/departments/{department_id}", response_model=DepartmentResponse)
283
+ async def get_department(
284
+ department_id: UUID,
285
+ db: AsyncSession = Depends(get_db),
286
+ current_user: Member = Depends(get_current_active_user),
287
+ ):
288
+ department = await db.get(Department, department_id)
289
+ if not department:
290
+ raise HTTPException(status_code=404, detail="Department not found")
291
+
292
+ member_count = await db.execute(
293
+ select(func.count(Member.id)).where(Member.department_id == department.id)
294
+ )
295
+ count = member_count.scalar() or 0
296
+
297
+ return DepartmentResponse(
298
+ id=department.id,
299
+ name=department.name,
300
+ code=department.code,
301
+ description=department.description,
302
+ categories=department.categories,
303
+ default_sla_hours=department.default_sla_hours,
304
+ escalation_email=department.escalation_email,
305
+ is_active=department.is_active,
306
+ member_count=count,
307
+ )
308
+
309
+
310
+ @router.patch("/departments/{department_id}", response_model=DepartmentResponse)
311
+ async def update_department(
312
+ department_id: UUID,
313
+ data: DepartmentUpdate,
314
+ db: AsyncSession = Depends(get_db),
315
+ current_user: Member = Depends(get_current_admin),
316
+ ):
317
+ department = await db.get(Department, department_id)
318
+ if not department:
319
+ raise HTTPException(status_code=404, detail="Department not found")
320
+
321
+ update_data = data.model_dump(exclude_unset=True)
322
+ for key, value in update_data.items():
323
+ setattr(department, key, value)
324
+
325
+ await db.flush()
326
+
327
+ member_count = await db.execute(
328
+ select(func.count(Member.id)).where(Member.department_id == department.id)
329
+ )
330
+ count = member_count.scalar() or 0
331
+
332
+ return DepartmentResponse(
333
+ id=department.id,
334
+ name=department.name,
335
+ code=department.code,
336
+ description=department.description,
337
+ categories=department.categories,
338
+ default_sla_hours=department.default_sla_hours,
339
+ escalation_email=department.escalation_email,
340
+ is_active=department.is_active,
341
+ member_count=count,
342
+ )
343
+
344
+
345
+ @router.delete("/departments/{department_id}", status_code=status.HTTP_204_NO_CONTENT)
346
+ async def delete_department(
347
+ department_id: UUID,
348
+ db: AsyncSession = Depends(get_db),
349
+ current_user: Member = Depends(get_current_admin),
350
+ ):
351
+ department = await db.get(Department, department_id)
352
+ if not department:
353
+ raise HTTPException(status_code=404, detail="Department not found")
354
+
355
+ await db.delete(department)
356
+ await db.flush()
357
+
358
+
359
+ @router.post("/members/invite", status_code=status.HTTP_201_CREATED)
360
+ async def invite_member(
361
+ data: MemberInvite,
362
+ db: AsyncSession = Depends(get_db),
363
+ current_user: Member = Depends(get_current_admin),
364
+ ):
365
+ department = await db.get(Department, data.department_id)
366
+ if not department:
367
+ raise HTTPException(status_code=404, detail="Department not found")
368
+
369
+ existing = await db.execute(select(Member).where(Member.email == data.email))
370
+ if existing.scalar_one_or_none():
371
+ raise HTTPException(status_code=400, detail="Email already exists")
372
+
373
+ invite_result = None
374
+ if data.send_invite:
375
+ invite_result = await supabase_auth.invite_user(
376
+ email=data.email,
377
+ redirect_to=f"{settings.frontend_url}/auth/callback"
378
+ )
379
+
380
+ member = Member(
381
+ department_id=data.department_id,
382
+ name=data.name,
383
+ email=data.email,
384
+ phone=data.phone,
385
+ role=data.role,
386
+ city=data.city,
387
+ locality=data.locality,
388
+ max_workload=data.max_workload,
389
+ )
390
+ db.add(member)
391
+ await db.flush()
392
+ await db.refresh(member)
393
+
394
+ return {
395
+ "member": MemberResponse(
396
+ id=member.id,
397
+ department_id=member.department_id,
398
+ name=member.name,
399
+ email=member.email,
400
+ phone=member.phone,
401
+ role=member.role,
402
+ city=member.city,
403
+ locality=member.locality,
404
+ is_active=member.is_active,
405
+ current_workload=member.current_workload,
406
+ max_workload=member.max_workload,
407
+ invite_status="sent" if invite_result and invite_result.get("success") else "not_sent",
408
+ ),
409
+ "invite": invite_result,
410
+ "message": f"Member created. {'Invite email sent!' if invite_result and invite_result.get('success') else 'No invite sent.'}",
411
+ }
412
+
413
+
414
+
415
+
416
+
417
+ @router.post("/members", response_model=MemberResponse, status_code=status.HTTP_201_CREATED)
418
+ async def create_member(
419
+ data: MemberCreate,
420
+ db: AsyncSession = Depends(get_db),
421
+ current_user: Member = Depends(get_current_admin),
422
+ ):
423
+
424
+ department = await db.get(Department, data.department_id)
425
+ if not department:
426
+ raise HTTPException(status_code=404, detail="Department not found")
427
+
428
+ existing = await db.execute(select(Member).where(Member.email == data.email))
429
+ if existing.scalar_one_or_none():
430
+ raise HTTPException(status_code=400, detail="Email already exists")
431
+
432
+ member = Member(
433
+ department_id=data.department_id,
434
+ name=data.name,
435
+ email=data.email,
436
+ phone=data.phone,
437
+ role=data.role,
438
+ city=data.city,
439
+ locality=data.locality,
440
+ max_workload=data.max_workload,
441
+ password_hash=hash_password(data.password),
442
+ )
443
+ db.add(member)
444
+ await db.flush()
445
+ await db.refresh(member)
446
+
447
+
448
+ return MemberResponse(
449
+ id=member.id,
450
+ department_id=member.department_id,
451
+ name=member.name,
452
+ email=member.email,
453
+ phone=member.phone,
454
+ role=member.role,
455
+ city=member.city,
456
+ locality=member.locality,
457
+ is_active=member.is_active,
458
+ current_workload=member.current_workload,
459
+ max_workload=member.max_workload,
460
+ )
461
+
462
+
463
+ @router.post("/members/{member_id}/send-invite")
464
+ async def send_member_invite(
465
+ member_id: UUID,
466
+ db: AsyncSession = Depends(get_db),
467
+ current_user: Member = Depends(get_current_admin),
468
+ ):
469
+ member = await db.get(Member, member_id)
470
+ if not member:
471
+ raise HTTPException(status_code=404, detail="Member not found")
472
+
473
+ if not settings.frontend_url:
474
+ raise HTTPException(status_code=500, detail="FRONTEND_URL not configured")
475
+
476
+ result = await supabase_auth.invite_user(
477
+ email=member.email,
478
+ redirect_to=f"{settings.frontend_url}/auth/callback"
479
+ )
480
+
481
+ if result.get("success"):
482
+ return {
483
+ "success": True,
484
+ "message": f"Invite sent to {member.email}",
485
+ "member_id": str(member.id),
486
+ }
487
+ else:
488
+ raise HTTPException(
489
+ status_code=400,
490
+ detail=result.get("message", "Failed to send invite")
491
+ )
492
+
493
+
494
+ @router.post("/members/{member_id}/magic-link")
495
+ async def send_magic_link(
496
+ member_id: UUID,
497
+ db: AsyncSession = Depends(get_db),
498
+ current_user: Member = Depends(get_current_admin),
499
+ ):
500
+ member = await db.get(Member, member_id)
501
+ if not member:
502
+ raise HTTPException(status_code=404, detail="Member not found")
503
+
504
+ if not settings.frontend_url:
505
+ raise HTTPException(status_code=500, detail="FRONTEND_URL not configured")
506
+
507
+ result = await supabase_auth.send_magic_link(
508
+ email=member.email,
509
+ redirect_to=f"{settings.frontend_url}/auth/callback"
510
+ )
511
+
512
+ if result.get("success"):
513
+ return {
514
+ "success": True,
515
+ "message": f"Magic link sent to {member.email}",
516
+ }
517
+ else:
518
+ raise HTTPException(
519
+ status_code=400,
520
+ detail=result.get("message", "Failed to send magic link")
521
+ )
522
+
523
+
524
+ @router.get("/members", response_model=list[MemberResponse])
525
+ async def list_members(
526
+ department_id: Optional[UUID] = None,
527
+ db: AsyncSession = Depends(get_db),
528
+ current_user: Member = Depends(get_current_active_user),
529
+ ):
530
+ query = select(Member).order_by(Member.name)
531
+ if department_id:
532
+ query = query.where(Member.department_id == department_id)
533
+
534
+ result = await db.execute(query)
535
+ members = result.scalars().all()
536
+
537
+ return [
538
+ MemberResponse(
539
+ id=m.id,
540
+ department_id=m.department_id,
541
+ name=m.name,
542
+ email=m.email,
543
+ phone=m.phone,
544
+ role=m.role,
545
+ city=m.city,
546
+ locality=m.locality,
547
+ is_active=m.is_active,
548
+ current_workload=m.current_workload,
549
+ max_workload=m.max_workload,
550
+ )
551
+ for m in members
552
+ ]
553
+
554
+
555
+ @router.get("/members/{member_id}", response_model=MemberResponse)
556
+ async def get_member(
557
+ member_id: UUID,
558
+ db: AsyncSession = Depends(get_db),
559
+ current_user: Member = Depends(get_current_active_user),
560
+ ):
561
+ member = await db.get(Member, member_id)
562
+ if not member:
563
+ raise HTTPException(status_code=404, detail="Member not found")
564
+
565
+ return MemberResponse(
566
+ id=member.id,
567
+ department_id=member.department_id,
568
+ name=member.name,
569
+ email=member.email,
570
+ phone=member.phone,
571
+ role=member.role,
572
+ city=member.city,
573
+ locality=member.locality,
574
+ is_active=member.is_active,
575
+ current_workload=member.current_workload,
576
+ max_workload=member.max_workload,
577
+ )
578
+
579
+
580
+ @router.patch("/members/{member_id}", response_model=MemberResponse)
581
+ async def update_member(
582
+ member_id: UUID,
583
+ data: MemberUpdate,
584
+ db: AsyncSession = Depends(get_db),
585
+ current_user: Member = Depends(get_current_admin),
586
+ ):
587
+ member = await db.get(Member, member_id)
588
+ if not member:
589
+ raise HTTPException(status_code=404, detail="Member not found")
590
+
591
+ update_data = data.model_dump(exclude_unset=True)
592
+ for key, value in update_data.items():
593
+ setattr(member, key, value)
594
+
595
+ await db.flush()
596
+
597
+ return MemberResponse(
598
+ id=member.id,
599
+ department_id=member.department_id,
600
+ name=member.name,
601
+ email=member.email,
602
+ phone=member.phone,
603
+ role=member.role,
604
+ city=member.city,
605
+ locality=member.locality,
606
+ is_active=member.is_active,
607
+ current_workload=member.current_workload,
608
+ max_workload=member.max_workload,
609
+ )
610
+
611
+
612
+ @router.delete("/members/{member_id}", status_code=status.HTTP_204_NO_CONTENT)
613
+ async def delete_member(
614
+ member_id: UUID,
615
+ db: AsyncSession = Depends(get_db),
616
+ current_user: Member = Depends(get_current_admin),
617
+ ):
618
+ member = await db.get(Member, member_id)
619
+ if not member:
620
+ raise HTTPException(status_code=404, detail="Member not found")
621
+
622
+ await db.delete(member)
623
+ await db.flush()
624
+
625
+
626
+ @router.get("/stats")
627
+ async def get_admin_stats(
628
+ db: AsyncSession = Depends(get_db),
629
+ current_user: Member = Depends(get_current_active_user),
630
+ ):
631
+ from Backend.database.models import Issue, Classification
632
+ from datetime import datetime, timedelta
633
+
634
+ dept_count = await db.execute(select(func.count(Department.id)))
635
+ member_count = await db.execute(select(func.count(Member.id)))
636
+ issue_count = await db.execute(select(func.count(Issue.id)))
637
+ pending_count = await db.execute(
638
+ select(func.count(Issue.id)).where(Issue.state.in_(["reported", "validated", "assigned"]))
639
+ )
640
+ resolved_count = await db.execute(
641
+ select(func.count(Issue.id)).where(Issue.state.in_(["resolved", "closed", "verified"]))
642
+ )
643
+ verification_count = await db.execute(
644
+ select(func.count(Issue.id)).where(Issue.state == "pending_verification")
645
+ )
646
+
647
+ category_query = (
648
+ select(
649
+ Classification.primary_category,
650
+ func.count(Classification.id).label("count")
651
+ )
652
+ .group_by(Classification.primary_category)
653
+ .order_by(func.count(Classification.id).desc())
654
+ .limit(6)
655
+ )
656
+ category_result = await db.execute(category_query)
657
+ categories = category_result.all()
658
+ issues_by_category = [{"name": cat or "Unknown", "value": cnt} for cat, cnt in categories]
659
+
660
+ today = datetime.utcnow().date()
661
+ day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
662
+ issues_activity = []
663
+
664
+ for i in range(6, -1, -1):
665
+ day = today - timedelta(days=i)
666
+ day_start = datetime.combine(day, datetime.min.time())
667
+ day_end = datetime.combine(day, datetime.max.time())
668
+
669
+ reported_q = await db.execute(
670
+ select(func.count(Issue.id)).where(
671
+ Issue.created_at >= day_start,
672
+ Issue.created_at <= day_end
673
+ )
674
+ )
675
+ resolved_q = await db.execute(
676
+ select(func.count(Issue.id)).where(
677
+ Issue.resolved_at >= day_start,
678
+ Issue.resolved_at <= day_end
679
+ )
680
+ )
681
+
682
+ issues_activity.append({
683
+ "name": day_names[day.weekday()],
684
+ "reported": reported_q.scalar() or 0,
685
+ "resolved": resolved_q.scalar() or 0
686
+ })
687
+
688
+ return {
689
+ "departments": dept_count.scalar() or 0,
690
+ "members": member_count.scalar() or 0,
691
+ "total_issues": issue_count.scalar() or 0,
692
+ "pending_issues": pending_count.scalar() or 0,
693
+ "resolved_issues": resolved_count.scalar() or 0,
694
+ "verification_needed": verification_count.scalar() or 0,
695
+ "issues_by_category": issues_by_category,
696
+ "issues_activity": issues_activity,
697
+ }
698
+
699
+
700
+ @router.get("/stats/heatmap")
701
+ async def get_issue_heatmap(
702
+ db: AsyncSession = Depends(get_db),
703
+ current_user: Member = Depends(get_current_active_user),
704
+ ):
705
+ """
706
+ Returns city-aggregated issue counts for heatmap visualization.
707
+ """
708
+ query = (
709
+ select(
710
+ Issue.city,
711
+ func.count(Issue.id).label("count"),
712
+ func.avg(Issue.priority).label("priority_avg")
713
+ )
714
+ .where(Issue.state.notin_(["closed", "resolved", "verified"]))
715
+ .where(Issue.city.isnot(None))
716
+ .group_by(Issue.city)
717
+ .order_by(func.count(Issue.id).desc())
718
+ )
719
+ result = await db.execute(query)
720
+ rows = result.all()
721
+
722
+ heatmap_data = []
723
+ for city, count, priority_avg in rows:
724
+ heatmap_data.append({
725
+ "city": city or "Unknown",
726
+ "count": count,
727
+ "priority_avg": round(float(priority_avg or 3), 1)
728
+ })
729
+
730
+ return heatmap_data
731
+
732
+
733
+ @router.get("/stats/escalations", response_model=list[dict])
734
+ async def get_escalation_alerts(
735
+ db: AsyncSession = Depends(get_db),
736
+ current_user: Member = Depends(get_current_active_user),
737
+ ):
738
+ """
739
+ Returns a list of currently escalated issues with details.
740
+ """
741
+ query = (
742
+ select(Issue, Escalation)
743
+ .join(Escalation, Issue.id == Escalation.issue_id)
744
+ .where(Issue.state == "escalated")
745
+ .order_by(Escalation.created_at.desc())
746
+ )
747
+ result = await db.execute(query)
748
+ rows = result.all()
749
+
750
+ alerts = []
751
+ for issue, esc in rows:
752
+ alerts.append({
753
+ "issue_id": issue.id,
754
+ "category": issue.classification.primary_category if issue.classification else "Unknown",
755
+ "priority": issue.priority,
756
+ "escalated_at": esc.created_at,
757
+ "level": esc.to_level,
758
+ "reason": esc.reason,
759
+ "city": issue.city,
760
+ "locality": issue.locality
761
+ })
762
+
763
+
764
+ class ManualReviewRequest(BaseModel):
765
+ status: str
766
+ reason: Optional[str] = None
767
+
768
+
769
+
770
+ class AdminIssueListItem(BaseModel):
771
+ id: UUID
772
+ description: Optional[str]
773
+ state: str
774
+ priority: Optional[int]
775
+ city: Optional[str]
776
+ created_at: datetime
777
+ updated_at: datetime
778
+ department: Optional[str]
779
+ assigned_to: Optional[str]
780
+ category: Optional[str]
781
+ sla_deadline: Optional[datetime]
782
+ thumbnail: Optional[str]
783
+
784
+ class Config:
785
+ from_attributes = True
786
+
787
+ def issue_to_response(issue: Issue) -> IssueResponse:
788
+ image_urls = []
789
+ annotated_urls = []
790
+ for img in issue.images:
791
+ image_urls.append(get_upload_url(img.file_path))
792
+ if img.annotated_path:
793
+ annotated_urls.append(get_upload_url(img.annotated_path))
794
+
795
+ proof_image_url = None
796
+ if issue.proof_image_path:
797
+ proof_image_url = get_upload_url(issue.proof_image_path)
798
+
799
+ return IssueResponse(
800
+ id=issue.id,
801
+ description=issue.description,
802
+ latitude=issue.latitude,
803
+ longitude=issue.longitude,
804
+ state=IssueState(issue.state),
805
+ priority=issue.priority,
806
+ category=issue.classification.primary_category if issue.classification else None,
807
+ confidence=issue.classification.primary_confidence if issue.classification else None,
808
+ image_urls=image_urls,
809
+ annotated_urls=annotated_urls,
810
+ proof_image_url=proof_image_url,
811
+ validation_source=issue.validation_source,
812
+ is_duplicate=issue.is_duplicate,
813
+ parent_issue_id=issue.parent_issue_id,
814
+ city=issue.city,
815
+ locality=issue.locality,
816
+ full_address=issue.full_address,
817
+ sla_hours=issue.sla_hours,
818
+ sla_deadline=issue.sla_deadline,
819
+ created_at=issue.created_at,
820
+ updated_at=issue.updated_at,
821
+ )
822
+
823
+ @router.get("/issues", response_model=dict)
824
+ async def list_admin_issues(
825
+ page: int = Query(1, ge=1),
826
+ limit: int = Query(20, ge=1, le=100),
827
+ status: Optional[str] = None,
828
+ priority: Optional[int] = None,
829
+ department_id: Optional[UUID] = None,
830
+ worker_id: Optional[UUID] = None,
831
+ search: Optional[str] = None,
832
+ sort_by: str = "created_at",
833
+ sort_order: str = "desc",
834
+ db: AsyncSession = Depends(get_db),
835
+ current_user: Member = Depends(get_current_active_user),
836
+ ):
837
+ query = (
838
+ select(Issue)
839
+ .options(
840
+ selectinload(Issue.department),
841
+ selectinload(Issue.assigned_member),
842
+ selectinload(Issue.classification),
843
+ selectinload(Issue.images)
844
+ )
845
+ )
846
+
847
+
848
+ if status:
849
+ statuses = status.split(",")
850
+ query = query.where(Issue.state.in_(statuses))
851
+
852
+ if priority is not None:
853
+ query = query.where(Issue.priority == priority)
854
+
855
+ if department_id:
856
+ query = query.where(Issue.department_id == department_id)
857
+
858
+ if worker_id:
859
+ query = query.where(Issue.assigned_member_id == worker_id)
860
+
861
+ if search:
862
+ search_filter = or_(
863
+ Issue.description.ilike(f"%{search}%"),
864
+ Issue.city.ilike(f"%{search}%"),
865
+ Issue.locality.ilike(f"%{search}%"),
866
+ Issue.id.cast(String).ilike(f"%{search}%")
867
+ )
868
+ query = query.where(search_filter)
869
+
870
+
871
+ sort_column = getattr(Issue, sort_by, Issue.created_at)
872
+ if sort_order == "asc":
873
+ query = query.order_by(asc(sort_column))
874
+ else:
875
+ query = query.order_by(desc(sort_column))
876
+
877
+
878
+ total_query = select(func.count()).select_from(query.subquery())
879
+ total_result = await db.execute(total_query)
880
+ total = total_result.scalar_one()
881
+
882
+ query = query.offset((page - 1) * limit).limit(limit)
883
+ result = await db.execute(query)
884
+ issues = result.scalars().all()
885
+
886
+
887
+
888
+
889
+ items = []
890
+ for issue in issues:
891
+ thumb = None
892
+ if issue.images and len(issue.images) > 0:
893
+ thumb = get_upload_url(issue.images[0].file_path)
894
+
895
+ items.append(AdminIssueListItem(
896
+ id=issue.id,
897
+ description=issue.description,
898
+ state=issue.state,
899
+ priority=issue.priority,
900
+ city=issue.city,
901
+ created_at=issue.created_at,
902
+ updated_at=issue.updated_at,
903
+ department=issue.department.name if issue.department else None,
904
+ assigned_to=issue.assigned_member.name if issue.assigned_member else None,
905
+ category=issue.classification.primary_category if issue.classification else None,
906
+ sla_deadline=issue.sla_deadline,
907
+ thumbnail=thumb
908
+ ))
909
+
910
+ return {
911
+ "items": items,
912
+ "total": total,
913
+ "page": page,
914
+ "limit": limit,
915
+ "pages": (total + limit - 1) // limit
916
+ }
917
+
918
+ @router.get("/issues/{issue_id}/details")
919
+ async def get_admin_issue_details(
920
+ issue_id: UUID,
921
+ db: AsyncSession = Depends(get_db),
922
+ current_user: Member = Depends(get_current_active_user),
923
+ ):
924
+ query = (
925
+ select(Issue)
926
+ .options(
927
+ selectinload(Issue.department),
928
+ selectinload(Issue.classification),
929
+ selectinload(Issue.images),
930
+ selectinload(Issue.events),
931
+ selectinload(Issue.duplicates)
932
+ )
933
+ .where(Issue.id == issue_id)
934
+ )
935
+ result = await db.execute(query)
936
+ issue = result.scalar_one_or_none()
937
+
938
+ if not issue:
939
+ raise HTTPException(status_code=404, detail="Issue not found")
940
+
941
+
942
+ worker = None
943
+ if issue.assigned_member_id:
944
+ worker = await db.get(Member, issue.assigned_member_id)
945
+
946
+ return {
947
+ "issue": issue_to_response(issue),
948
+ "department": {
949
+ "id": issue.department.id,
950
+ "name": issue.department.name
951
+ } if issue.department else None,
952
+ "worker": {
953
+ "id": worker.id,
954
+ "name": worker.name,
955
+ "email": worker.email,
956
+ "workload": worker.current_workload
957
+ } if worker else None,
958
+ "events": [
959
+ {
960
+ "id": e.id,
961
+ "type": e.event_type,
962
+ "agent": e.agent_name,
963
+ "data": e.event_data,
964
+ "created_at": e.created_at
965
+ } for e in sorted(issue.events, key=lambda x: x.created_at, reverse=True)
966
+ ],
967
+ "duplicates": [
968
+ {
969
+ "id": d.id,
970
+ "created_at": d.created_at,
971
+ "status": d.state
972
+ } for d in issue.duplicates
973
+ ]
974
+ }
975
+
976
+ @router.get("/workers/performance")
977
+ async def get_worker_performance(
978
+ department_id: Optional[UUID] = None,
979
+ db: AsyncSession = Depends(get_db),
980
+ current_user: Member = Depends(get_current_active_user),
981
+ ):
982
+
983
+ q = select(Member).where(Member.role == "worker")
984
+ if department_id:
985
+ q = q.where(Member.department_id == department_id)
986
+
987
+ res = await db.execute(q)
988
+ workers = res.scalars().all()
989
+
990
+ performance_data = []
991
+
992
+ for w in workers:
993
+
994
+
995
+ resolved_count = await db.execute(
996
+ select(func.count(Issue.id)).where(
997
+ Issue.assigned_member_id == w.id,
998
+ Issue.state.in_(["resolved", "closed"])
999
+ )
1000
+ )
1001
+ resolved = resolved_count.scalar() or 0
1002
+
1003
+
1004
+
1005
+
1006
+
1007
+ performance_data.append({
1008
+ "id": w.id,
1009
+ "name": w.name,
1010
+ "active": w.is_active,
1011
+ "current_load": w.current_workload,
1012
+ "max_load": w.max_workload,
1013
+ "resolved_total": resolved,
1014
+ "efficiency": round(resolved / (max(1, (datetime.utcnow() - w.created_at).days / 7)), 1)
1015
+ })
1016
+
1017
+ return performance_data
1018
+
1019
+ @router.patch("/issues/{issue_id}", response_model=IssueResponse)
1020
+ async def update_issue_details(
1021
+ issue_id: UUID,
1022
+ data: dict,
1023
+ db: AsyncSession = Depends(get_db),
1024
+ current_user: Member = Depends(get_current_admin),
1025
+ ):
1026
+ issue = await db.get(Issue, issue_id)
1027
+ if not issue:
1028
+ raise HTTPException(status_code=404, detail="Issue not found")
1029
+
1030
+ if "priority" in data:
1031
+ issue.priority = data["priority"]
1032
+
1033
+
1034
+ if "assigned_member_id" in data:
1035
+ new_worker_id = data["assigned_member_id"]
1036
+ if new_worker_id:
1037
+ worker = await db.get(Member, UUID(new_worker_id))
1038
+ if not worker:
1039
+ raise HTTPException(status_code=400, detail="Worker not found")
1040
+ issue.assigned_member_id = worker.id
1041
+ issue.state = "assigned"
1042
+ worker.current_workload += 1
1043
+
1044
+
1045
+ else:
1046
+ issue.assigned_member_id = None
1047
+
1048
+ await db.commit()
1049
+ await db.refresh(issue)
1050
+
1051
+
1052
+
1053
+
1054
+ return issue_to_response(issue)
1055
+
1056
+ class ResolutionReviewRequest(BaseModel):
1057
+ action: str
1058
+ comment: Optional[str] = None
1059
+
1060
+ @router.post("/issues/{issue_id}/approve_resolution")
1061
+ async def approve_resolution(
1062
+ issue_id: UUID,
1063
+ data: ResolutionReviewRequest,
1064
+ db: AsyncSession = Depends(get_db),
1065
+ current_user: Member = Depends(get_current_admin),
1066
+ ):
1067
+ issue = await db.get(Issue, issue_id)
1068
+ if not issue:
1069
+ raise HTTPException(status_code=404, detail="Issue not found")
1070
+
1071
+ if issue.state != "pending_verification":
1072
+ raise HTTPException(status_code=400, detail="Issue is not pending verification.")
1073
+
1074
+ if data.action == "approve":
1075
+ issue.state = "resolved"
1076
+ issue.completed_at = datetime.utcnow()
1077
+ if data.comment:
1078
+ issue.resolution_notes = (issue.resolution_notes or "") + f"\nAdmin Note: {data.comment}"
1079
+
1080
+
1081
+ if issue.assigned_member_id:
1082
+ worker = await db.get(Member, issue.assigned_member_id)
1083
+ if worker and worker.current_workload > 0:
1084
+ worker.current_workload -= 1
1085
+
1086
+ await db.commit()
1087
+ return {"message": "Issue resolution approved and marked as resolved."}
1088
+
1089
+ elif data.action == "reject":
1090
+ issue.state = "in_progress"
1091
+
1092
+ if data.comment:
1093
+ issue.resolution_notes = (issue.resolution_notes or "") + f"\n[REJECTED]: {data.comment}"
1094
+
1095
+
1096
+
1097
+ await db.commit()
1098
+ return {"message": "Issue resolution rejected. Sent back to worker."}
1099
+
1100
+ else:
1101
+ raise HTTPException(status_code=400, detail="Invalid action.")
1102
+
1103
+ @router.post("/issues/{issue_id}/review")
1104
+ async def review_issue(
1105
+ issue_id: UUID,
1106
+ data: ManualReviewRequest,
1107
+ db: AsyncSession = Depends(get_db),
1108
+ current_user: Member = Depends(get_current_admin),
1109
+ ):
1110
+ """
1111
+ Manually review an issue.
1112
+ - If REJECTED: Mark as rejected.
1113
+ - If APPROVED: Mark as assigned and auto-assign to a worker.
1114
+ """
1115
+ issue = await db.get(Issue, issue_id)
1116
+ if not issue:
1117
+ raise HTTPException(status_code=404, detail="Issue not found")
1118
+
1119
+ if data.status == "rejected":
1120
+ issue.state = "rejected"
1121
+ issue.resolution_notes = data.reason or "Rejected during manual review."
1122
+ await db.commit()
1123
+ return {"message": "Issue rejected successfully"}
1124
+
1125
+ elif data.status == "approved":
1126
+
1127
+
1128
+
1129
+ query = select(Member).where(Member.role == "worker", Member.is_active == True).order_by(Member.current_workload.asc())
1130
+
1131
+
1132
+ if issue.department_id:
1133
+ query = query.where(Member.department_id == issue.department_id)
1134
+
1135
+ result = await db.execute(query)
1136
+ Workers = result.scalars().all()
1137
+
1138
+ selected_worker = None
1139
+
1140
+ if not Workers:
1141
+
1142
+
1143
+ issue.state = "verified"
1144
+ issue.resolution_notes = "Verified but no workers available for auto-assignment."
1145
+ else:
1146
+ selected_worker = Workers[0]
1147
+ issue.assigned_member_id = selected_worker.id
1148
+ issue.state = "assigned"
1149
+ selected_worker.current_workload += 1
1150
+ db.add(selected_worker)
1151
+
1152
+ await db.commit()
1153
+
1154
+ return {
1155
+ "message": f"Issue approved. {'Assigned to ' + selected_worker.name if selected_worker else 'No worker available, queued as verified.'}",
1156
+ "assigned_to": str(selected_worker.id) if selected_worker else None
1157
+ }
1158
+
1159
+ else:
1160
+ raise HTTPException(status_code=400, detail="Invalid status. Use 'approved' or 'rejected'.")