Pushpendra Sharma commited on
Commit
6263792
·
1 Parent(s): a3c0d78

initial-db-setup: Setup database connection and configuration

Browse files
Backend/.env.example ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ DATABASE_URL=
2
+ SUPABASE_URL=
3
+ SUPABASE_KEY=
4
+ SUPABASE_JWT_SECRET=
5
+ SUPABASE_BUCKET=
6
+ GEMINI_API_KEY=
7
+ FRONTEND_URL=
Backend/core/config.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import lru_cache
2
+ from pathlib import Path
3
+ from typing import Optional
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+ from pydantic import field_validator
6
+
7
+
8
+ class Settings(BaseSettings):
9
+ model_config = SettingsConfigDict(
10
+ env_file=".env",
11
+ env_file_encoding="utf-8",
12
+ extra="ignore"
13
+ )
14
+
15
+ database_url: str
16
+
17
+ supabase_url: str
18
+ supabase_key: str
19
+ supabase_jwt_secret: str
20
+ supabase_bucket: str = "city-issues"
21
+
22
+ supabase_s3_endpoint: Optional[str] = None
23
+ supabase_s3_region: str = "ap-southeast-1"
24
+ supabase_s3_access_key: Optional[str] = None
25
+ supabase_s3_secret_key: Optional[str] = None
26
+
27
+ model_path: Path = Path("Backend/agents/vision/model.pt")
28
+ model_confidence_threshold: float = 0.25
29
+ model_input_size: int = 512
30
+
31
+ local_temp_dir: Path = Path("static/temp")
32
+
33
+ sla_critical_hours: int = 4
34
+ sla_high_hours: int = 12
35
+ sla_medium_hours: int = 48
36
+ sla_low_hours: int = 168
37
+
38
+ api_host: str = "0.0.0.0"
39
+ api_port: int = 8000
40
+ api_workers: int = 4
41
+
42
+ max_upload_size_mb: int = 10
43
+ allowed_extensions: set[str] = {"jpg", "jpeg", "png", "webp"}
44
+
45
+ duplicate_radius_meters: float = 50.0
46
+
47
+ debug: bool = False
48
+
49
+ resend_api_key: Optional[str] = None
50
+ google_client_id: Optional[str] = None
51
+ gemini_api_key: Optional[str] = None
52
+ google_client_secret: Optional[str] = None
53
+ project_id: Optional[str] = None
54
+ sender_email: str = "noreply@urbanlens.city"
55
+ admin_email: str = "admin@urbanlens.city"
56
+
57
+ frontend_url: Optional[str] = None
58
+
59
+ cors_origins: list[str] = []
60
+ jwt_algorithm: str = "HS256"
61
+ jwt_expire_hours: int = 24
62
+
63
+ @field_validator("database_url")
64
+ @classmethod
65
+ def validate_database_url(cls, v: str) -> str:
66
+ if not v.startswith("postgresql"):
67
+ raise ValueError("DATABASE_URL must be a PostgreSQL connection string")
68
+ return v
69
+
70
+ @field_validator("supabase_jwt_secret")
71
+ @classmethod
72
+ def validate_jwt_secret(cls, v: str) -> str:
73
+ if len(v) < 32:
74
+ raise ValueError("SUPABASE_JWT_SECRET must be at least 32 characters")
75
+ return v
76
+
77
+
78
+ @lru_cache
79
+ def get_settings() -> Settings:
80
+ return Settings()
81
+
82
+
83
+ settings = get_settings()
Backend/core/schemas.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from enum import IntEnum, StrEnum
3
+ from typing import Optional
4
+ from uuid import UUID, uuid4
5
+ from pydantic import BaseModel, Field, field_validator
6
+
7
+
8
+ class IssueState(StrEnum):
9
+ REPORTED = "reported"
10
+ PENDING_CONFIRMATION = "pending_confirmation"
11
+ VALIDATED = "validated"
12
+ ASSIGNED = "assigned"
13
+ IN_PROGRESS = "in_progress"
14
+ PENDING_VERIFICATION = "pending_verification"
15
+ RESOLVED = "resolved"
16
+ VERIFIED = "verified"
17
+ CLOSED = "closed"
18
+ ESCALATED = "escalated"
19
+ REJECTED = "rejected"
20
+
21
+
22
+ class PriorityLevel(IntEnum):
23
+ CRITICAL = 1
24
+ HIGH = 2
25
+ MEDIUM = 3
26
+ LOW = 4
27
+
28
+
29
+ class IssueCategory(StrEnum):
30
+ DAMAGED_ROAD = "Damaged Road Issues"
31
+ POTHOLE = "Pothole Issues"
32
+ ILLEGAL_PARKING = "Illegal Parking Issues"
33
+ BROKEN_SIGN = "Broken Road Sign Issues"
34
+ FALLEN_TREE = "Fallen Trees"
35
+ GARBAGE = "Littering/Garbage on Public Places"
36
+ VANDALISM = "Vandalism Issues"
37
+ DEAD_ANIMAL = "Dead Animal Pollution"
38
+ DAMAGED_CONCRETE = "Damaged Concrete Structures"
39
+ DAMAGED_ELECTRIC = "Damaged Electric Wires and Poles"
40
+
41
+
42
+ CLASS_ID_TO_CATEGORY = {
43
+ 0: IssueCategory.DAMAGED_ROAD,
44
+ 1: IssueCategory.POTHOLE,
45
+ 2: IssueCategory.ILLEGAL_PARKING,
46
+ 3: IssueCategory.BROKEN_SIGN,
47
+ 4: IssueCategory.FALLEN_TREE,
48
+ 5: IssueCategory.GARBAGE,
49
+ 6: IssueCategory.VANDALISM,
50
+ 7: IssueCategory.DEAD_ANIMAL,
51
+ 8: IssueCategory.DAMAGED_CONCRETE,
52
+ 9: IssueCategory.DAMAGED_ELECTRIC,
53
+ }
54
+
55
+
56
+ class Coordinates(BaseModel):
57
+ latitude: float = Field(..., ge=-90, le=90)
58
+ longitude: float = Field(..., ge=-180, le=180)
59
+ accuracy_meters: Optional[float] = Field(None, ge=0)
60
+
61
+
62
+ class DeviceMetadata(BaseModel):
63
+ platform: str = Field(..., max_length=50)
64
+ device_model: Optional[str] = Field(None, max_length=100)
65
+ os_version: Optional[str] = Field(None, max_length=50)
66
+ app_version: Optional[str] = Field(None, max_length=20)
67
+
68
+
69
+ class IssuePacket(BaseModel):
70
+ description: Optional[str] = Field(None, max_length=2000)
71
+ coordinates: Coordinates
72
+ device_metadata: DeviceMetadata
73
+ timestamp: datetime = Field(default_factory=datetime.utcnow)
74
+
75
+ @field_validator("description")
76
+ @classmethod
77
+ def clean_description(cls, v: Optional[str]) -> Optional[str]:
78
+ if v:
79
+ return v.strip()
80
+ return v
81
+
82
+
83
+ class DetectionBox(BaseModel):
84
+ class_id: int
85
+ class_name: str
86
+ confidence: float = Field(..., ge=0, le=1)
87
+ bbox: tuple[float, float, float, float]
88
+
89
+
90
+ class ClassificationResult(BaseModel):
91
+ issue_id: UUID
92
+ detections: list[DetectionBox]
93
+ primary_category: Optional[IssueCategory] = None
94
+ primary_confidence: float = 0.0
95
+ annotated_urls: list[str] = []
96
+ inference_time_ms: float
97
+ model_version: str = "1.0"
98
+
99
+ def model_post_init(self, __context) -> None:
100
+ if self.detections and not self.primary_category:
101
+ best = max(self.detections, key=lambda d: d.confidence)
102
+ self.primary_category = CLASS_ID_TO_CATEGORY.get(best.class_id)
103
+ self.primary_confidence = best.confidence
104
+
105
+
106
+ class IssueCreate(BaseModel):
107
+ description: Optional[str] = Field(None, max_length=2000)
108
+ latitude: float = Field(..., ge=-90, le=90)
109
+ longitude: float = Field(..., ge=-180, le=180)
110
+ accuracy_meters: Optional[float] = Field(None, ge=0)
111
+ platform: str = Field(..., max_length=50)
112
+ device_model: Optional[str] = Field(None, max_length=100)
113
+
114
+ @field_validator("description")
115
+ @classmethod
116
+ def clean_description(cls, v: Optional[str]) -> Optional[str]:
117
+ if v is None:
118
+ return None
119
+ cleaned = v.strip()
120
+ return cleaned or None
121
+
122
+
123
+ class AgentOutput(BaseModel):
124
+ agent: str
125
+ decision: str
126
+ reasoning: Optional[str] = None
127
+ duration_ms: Optional[float] = None
128
+
129
+
130
+ class IssueResponse(BaseModel):
131
+ id: UUID
132
+ description: Optional[str]
133
+ latitude: float
134
+ longitude: float
135
+ state: IssueState
136
+ priority: Optional[PriorityLevel]
137
+ priority_reason: Optional[str] = None
138
+ category: Optional[str]
139
+ confidence: Optional[float]
140
+ detections_count: Optional[int] = None
141
+ image_urls: list[str]
142
+ annotated_urls: list[str] = []
143
+ proof_image_url: Optional[str] = None
144
+ validation_source: Optional[str] = None
145
+ is_duplicate: bool = False
146
+ parent_issue_id: Optional[UUID] = None
147
+ nearby_count: Optional[int] = None
148
+ city: Optional[str] = None
149
+ locality: Optional[str] = None
150
+ full_address: Optional[str] = None
151
+ geo_status: Optional[str] = None
152
+ department: Optional[str] = None
153
+ assigned_member: Optional[str] = None
154
+ sla_hours: Optional[int] = None
155
+ sla_deadline: Optional[datetime] = None
156
+ agent_flow: list[AgentOutput] = []
157
+ created_at: datetime
158
+ updated_at: datetime
159
+
160
+ class Config:
161
+ from_attributes = True
162
+
163
+
164
+ class IssueListResponse(BaseModel):
165
+ items: list[IssueResponse]
166
+ total: int
167
+ page: int
168
+ page_size: int
169
+
Backend/database/connection.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from contextlib import asynccontextmanager
2
+ from typing import AsyncGenerator
3
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
4
+ from sqlalchemy.pool import NullPool
5
+
6
+ from Backend.core.config import settings
7
+
8
+ database_url = settings.database_url.replace("postgresql://", "postgresql+asyncpg://")
9
+
10
+ engine = create_async_engine(
11
+ database_url,
12
+ poolclass=NullPool,
13
+ echo=False,
14
+ connect_args={
15
+ "statement_cache_size": 0,
16
+ "prepared_statement_cache_size": 0,
17
+ },
18
+ )
19
+
20
+ async_session_factory = async_sessionmaker(
21
+ engine,
22
+ class_=AsyncSession,
23
+ expire_on_commit=False,
24
+ autocommit=False,
25
+ autoflush=False,
26
+ )
27
+
28
+
29
+ async def get_db() -> AsyncGenerator[AsyncSession, None]:
30
+ async with async_session_factory() as session:
31
+ try:
32
+ yield session
33
+ await session.commit()
34
+ except Exception:
35
+ await session.rollback()
36
+ raise
37
+
38
+
39
+ @asynccontextmanager
40
+ async def get_db_context() -> AsyncGenerator[AsyncSession, None]:
41
+ async with async_session_factory() as session:
42
+ try:
43
+ yield session
44
+ await session.commit()
45
+ except Exception:
46
+ await session.rollback()
47
+ raise
48
+
49
+
50
+ async def init_db() -> None:
51
+ from Backend.database.models import Base
52
+ async with engine.begin() as conn:
53
+ await conn.run_sync(Base.metadata.create_all)
54
+
55
+
56
+ async def close_db() -> None:
57
+ await engine.dispose()