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

Implemented worker endpoints

Browse files
Backend/api/routes/worker.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+ from uuid import UUID
3
+ from datetime import datetime
4
+ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
5
+ from fastapi.security import OAuth2PasswordBearer
6
+ from pydantic import BaseModel
7
+ from sqlalchemy import select
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+ from sqlalchemy.orm import selectinload
10
+ import jwt
11
+ from jwt import PyJWTError
12
+
13
+ from Backend.database.connection import get_db
14
+ from Backend.database.models import Issue, Member
15
+ from Backend.core.logging import get_logger
16
+ from Backend.core.config import settings
17
+ from Backend.utils.storage import save_upload, get_upload_url
18
+
19
+ logger = get_logger(__name__)
20
+ router = APIRouter()
21
+
22
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/admin/login")
23
+
24
+ async def get_current_worker(
25
+ token: str = Depends(oauth2_scheme),
26
+ db: AsyncSession = Depends(get_db)
27
+ ) -> Member:
28
+ try:
29
+ payload = jwt.decode(token, settings.supabase_jwt_secret, algorithms=["HS256"])
30
+ member_id = payload.get("sub")
31
+ if not member_id:
32
+ raise HTTPException(status_code=401, detail="Invalid token")
33
+
34
+ member = await db.get(Member, UUID(member_id))
35
+ if not member or not member.is_active:
36
+ raise HTTPException(status_code=401, detail="User not found or inactive")
37
+
38
+ if member.role not in ["worker", "admin"]:
39
+ raise HTTPException(status_code=403, detail="Not a worker")
40
+
41
+ return member
42
+ except PyJWTError:
43
+ raise HTTPException(status_code=401, detail="Invalid token")
44
+
45
+
46
+ class TaskResponse(BaseModel):
47
+ id: UUID
48
+ description: Optional[str]
49
+ priority: Optional[int]
50
+ state: str
51
+ city: Optional[str]
52
+ locality: Optional[str]
53
+ full_address: Optional[str]
54
+ latitude: float
55
+ longitude: float
56
+ image_url: Optional[str]
57
+ annotated_url: Optional[str]
58
+ created_at: datetime
59
+ sla_deadline: Optional[datetime]
60
+ category: Optional[str] = None
61
+
62
+
63
+ @router.get("/tasks", response_model=list[TaskResponse])
64
+ async def get_worker_tasks(
65
+ db: AsyncSession = Depends(get_db),
66
+ current_worker: Member = Depends(get_current_worker),
67
+ ):
68
+ result = await db.execute(
69
+ select(Issue)
70
+ .options(selectinload(Issue.images), selectinload(Issue.classification))
71
+ .where(Issue.assigned_member_id == current_worker.id)
72
+ .where(Issue.state.in_(["assigned", "in_progress", "pending_verification", "resolved"]))
73
+ .order_by(Issue.priority.asc().nullslast(), Issue.created_at.asc())
74
+ )
75
+ issues = result.scalars().all()
76
+
77
+ tasks = []
78
+ for issue in issues:
79
+ image_url = None
80
+ annotated_url = None
81
+ if issue.images:
82
+ image_url = get_upload_url(issue.images[0].file_path)
83
+ if issue.images[0].annotated_path:
84
+ annotated_url = get_upload_url(issue.images[0].annotated_path)
85
+
86
+ tasks.append(TaskResponse(
87
+ id=issue.id,
88
+ description=issue.description,
89
+ priority=issue.priority,
90
+ state=issue.state,
91
+ city=issue.city,
92
+ locality=issue.locality,
93
+ full_address=issue.full_address,
94
+ latitude=issue.latitude,
95
+ longitude=issue.longitude,
96
+ image_url=image_url,
97
+ annotated_url=annotated_url,
98
+ created_at=issue.created_at,
99
+ sla_deadline=issue.sla_deadline,
100
+ category=issue.classification.primary_category if issue.classification else None,
101
+ ))
102
+
103
+ return tasks
104
+
105
+
106
+ @router.post("/tasks/{task_id}/start")
107
+ async def start_task(
108
+ task_id: UUID,
109
+ db: AsyncSession = Depends(get_db),
110
+ current_worker: Member = Depends(get_current_worker),
111
+ ):
112
+ issue = await db.get(Issue, task_id)
113
+ if not issue:
114
+ raise HTTPException(status_code=404, detail="Task not found")
115
+
116
+ if issue.assigned_member_id != current_worker.id:
117
+ raise HTTPException(status_code=403, detail="Not assigned to this task")
118
+
119
+ issue.state = "in_progress"
120
+ await db.commit()
121
+
122
+ logger.info(f"Worker {current_worker.id} started task {task_id}")
123
+ return {"status": "started"}
124
+
125
+
126
+ @router.post("/tasks/{task_id}/complete")
127
+ async def complete_task(
128
+ task_id: UUID,
129
+ notes: Optional[str] = Form(None),
130
+ proof_image: UploadFile = File(...),
131
+ db: AsyncSession = Depends(get_db),
132
+ current_worker: Member = Depends(get_current_worker),
133
+ ):
134
+ issue = await db.get(Issue, task_id)
135
+ if not issue:
136
+ raise HTTPException(status_code=404, detail="Task not found")
137
+
138
+ if issue.assigned_member_id != current_worker.id:
139
+ raise HTTPException(status_code=403, detail="Not assigned to this task")
140
+
141
+ proof_path = await save_upload(proof_image, f"proofs/{task_id}")
142
+
143
+ issue.state = "pending_verification"
144
+ issue.proof_image_path = proof_path
145
+ issue.resolution_notes = notes
146
+ issue.resolved_at = datetime.utcnow()
147
+
148
+
149
+
150
+ await db.commit()
151
+
152
+ logger.info(f"Worker {current_worker.id} completed task {task_id}")
153
+
154
+ return {
155
+ "status": "completed",
156
+ "proof_url": get_upload_url(proof_path),
157
+ }
158
+
159
+
160
+ @router.get("/tasks/{task_id}")
161
+ async def get_task_detail(
162
+ task_id: UUID,
163
+ db: AsyncSession = Depends(get_db),
164
+ current_worker: Member = Depends(get_current_worker),
165
+ ):
166
+ result = await db.execute(
167
+ select(Issue)
168
+ .options(selectinload(Issue.images), selectinload(Issue.classification))
169
+ .where(Issue.id == task_id)
170
+ )
171
+ issue = result.scalar_one_or_none()
172
+
173
+ if not issue:
174
+ raise HTTPException(status_code=404, detail="Task not found")
175
+
176
+ if issue.assigned_member_id != current_worker.id:
177
+ raise HTTPException(status_code=403, detail="Not assigned to this task")
178
+
179
+ image_url = None
180
+ annotated_url = None
181
+ if issue.images:
182
+ image_url = get_upload_url(issue.images[0].file_path)
183
+ if issue.images[0].annotated_path:
184
+ annotated_url = get_upload_url(issue.images[0].annotated_path)
185
+
186
+ return {
187
+ "id": str(issue.id),
188
+ "description": issue.description,
189
+ "priority": issue.priority,
190
+ "state": issue.state,
191
+ "city": issue.city,
192
+ "locality": issue.locality,
193
+ "full_address": issue.full_address,
194
+ "latitude": issue.latitude,
195
+ "longitude": issue.longitude,
196
+ "image_url": image_url,
197
+ "annotated_url": annotated_url,
198
+ "created_at": issue.created_at,
199
+ "sla_deadline": issue.sla_deadline,
200
+ "category": issue.classification.primary_category if issue.classification else None,
201
+ "proof_image_url": get_upload_url(issue.proof_image_path) if issue.proof_image_path else None,
202
+ "resolution_notes": issue.resolution_notes,
203
+ "resolved_at": issue.resolved_at,
204
+ }
Backend/utils/storage.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import aiofiles
2
+ import aiohttp
3
+ from pathlib import Path
4
+ from uuid import uuid4
5
+ from typing import Optional
6
+ from fastapi import UploadFile
7
+
8
+ from Backend.core.config import settings
9
+ from Backend.core.logging import get_logger
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ def generate_filename(original_filename: str) -> str:
15
+ ext = Path(original_filename).suffix.lower()
16
+ if not ext:
17
+ ext = ".jpg"
18
+ return f"{uuid4().hex}{ext}"
19
+
20
+
21
+ def get_supabase_public_url(file_path: str) -> str:
22
+ return f"{settings.supabase_url}/storage/v1/object/public/{settings.supabase_bucket}/{file_path}"
23
+
24
+
25
+ async def upload_to_supabase(file_data: bytes, remote_path: str, content_type: str = "image/jpeg") -> str:
26
+ url = f"{settings.supabase_url}/storage/v1/object/{settings.supabase_bucket}/{remote_path}"
27
+
28
+ headers = {
29
+ "Authorization": f"Bearer {settings.supabase_key}",
30
+ "Content-Type": content_type,
31
+ "x-upsert": "true",
32
+ }
33
+
34
+ async with aiohttp.ClientSession() as session:
35
+ async with session.post(url, data=file_data, headers=headers) as response:
36
+ if response.status not in (200, 201):
37
+ error_text = await response.text()
38
+ logger.error(f"Supabase upload failed: {response.status} - {error_text}")
39
+ raise Exception(f"Failed to upload to Supabase: {error_text}")
40
+
41
+ logger.info(f"Uploaded to Supabase: {remote_path}")
42
+ return get_supabase_public_url(remote_path)
43
+
44
+
45
+ async def save_upload(file: UploadFile, subfolder: str = "") -> str:
46
+ filename = generate_filename(file.filename or "image.jpg")
47
+
48
+ if subfolder:
49
+ remote_path = f"{subfolder}/{filename}"
50
+ else:
51
+ remote_path = filename
52
+
53
+ content = await file.read()
54
+ await file.seek(0)
55
+
56
+ content_type = file.content_type or "image/jpeg"
57
+
58
+ public_url = await upload_to_supabase(content, remote_path, content_type)
59
+
60
+ return remote_path
61
+
62
+
63
+ async def save_bytes(data: bytes, filename: str, subfolder: str = "", content_type: str = "image/jpeg") -> str:
64
+ if subfolder:
65
+ remote_path = f"{subfolder}/{filename}"
66
+ else:
67
+ remote_path = filename
68
+
69
+ public_url = await upload_to_supabase(data, remote_path, content_type)
70
+
71
+ return remote_path
72
+
73
+
74
+ async def save_local_temp(data: bytes, filename: str) -> str:
75
+ temp_dir = settings.local_temp_dir
76
+ temp_dir.mkdir(parents=True, exist_ok=True)
77
+
78
+ file_path = temp_dir / filename
79
+ async with aiofiles.open(file_path, "wb") as f:
80
+ await f.write(data)
81
+
82
+ return str(file_path)
83
+
84
+
85
+ async def download_from_supabase(remote_path: str) -> bytes:
86
+ url = get_supabase_public_url(remote_path)
87
+
88
+ async with aiohttp.ClientSession() as session:
89
+ async with session.get(url) as response:
90
+ if response.status != 200:
91
+ raise Exception(f"Failed to download from Supabase: {response.status}")
92
+ return await response.read()
93
+
94
+
95
+ def get_upload_url(file_path: str) -> str:
96
+ if file_path.startswith("http"):
97
+ return file_path
98
+ return get_supabase_public_url(file_path)
99
+
100
+
101
+ def validate_file_extension(filename: str) -> bool:
102
+ ext = Path(filename).suffix.lower().lstrip(".")
103
+ return ext in settings.allowed_extensions
104
+
105
+
106
+ def validate_file_size(size: int) -> bool:
107
+ max_bytes = settings.max_upload_size_mb * 1024 * 1024
108
+ return size <= max_bytes