Tataaicoltd commited on
Commit
05f47b6
·
verified ·
1 Parent(s): b544a3d

deploy: initial backend API to Tataaicoltd/api

Browse files
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Copy backend only (no admin frontend)
6
+ COPY backend/ ./backend/
7
+
8
+ # Install dependencies
9
+ RUN pip install --no-cache-dir -r backend/requirements.txt
10
+
11
+ EXPOSE 7860
12
+
13
+ # Run from backend dir so relative imports resolve correctly
14
+ WORKDIR /app/backend
15
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
backend/config.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pydantic_settings import BaseSettings
3
+ from supabase import create_client, Client
4
+
5
+
6
+ class Settings(BaseSettings):
7
+ supabase_url: str
8
+ supabase_key: str
9
+ admin_api_key: str
10
+
11
+ class Config:
12
+ env_file = "../.env"
13
+
14
+
15
+ settings = Settings()
16
+
17
+ # Initialize Supabase client
18
+ supabase: Client = create_client(settings.supabase_url, settings.supabase_key)
backend/main.py ADDED
@@ -0,0 +1,505 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+
4
+ # Add backend directory to path to import modules
5
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
6
+
7
+ from fastapi import FastAPI, HTTPException, Query, Body
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from fastapi.staticfiles import StaticFiles
10
+ from starlette.middleware.base import BaseHTTPMiddleware
11
+ from starlette.responses import JSONResponse
12
+ from typing import Optional, List
13
+ import logging
14
+
15
+ from config import supabase, settings
16
+ from models import (
17
+ Category, CategoryCreate, CategoryUpdate,
18
+ Intent, IntentCreate, IntentUpdate, IntentWithCategory,
19
+ City, CityCreate, CityUpdate,
20
+ District, DistrictCreate, DistrictUpdate,
21
+ Street, StreetCreate, StreetUpdate,
22
+ Position, PositionCreate, PositionUpdate,
23
+ )
24
+ from services.category_service import CategoryService
25
+ from services.intent_service import IntentService
26
+ from services.maps_service import MapsService
27
+
28
+ # Setup logging
29
+ logging.basicConfig(level=logging.INFO)
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Create FastAPI app
33
+ app = FastAPI(
34
+ title="Admin Dashboard API",
35
+ description="CRUD API for managing categories and intents",
36
+ version="1.0.0"
37
+ )
38
+
39
+ # API Key authentication middleware
40
+ class APIKeyMiddleware(BaseHTTPMiddleware):
41
+ async def dispatch(self, request, call_next):
42
+ path = request.url.path
43
+ if path.startswith('/api/') and path != '/api/auth/verify':
44
+ api_key = request.headers.get('X-API-Key', '')
45
+ if api_key != settings.admin_api_key:
46
+ return JSONResponse({"detail": "Unauthorized"}, status_code=401)
47
+ return await call_next(request)
48
+
49
+ app.add_middleware(APIKeyMiddleware)
50
+
51
+ # Add CORS middleware
52
+ app.add_middleware(
53
+ CORSMiddleware,
54
+ allow_origins=["*"],
55
+ allow_credentials=True,
56
+ allow_methods=["*"],
57
+ allow_headers=["*"],
58
+ )
59
+
60
+
61
+ # Auth verify endpoint (no key required)
62
+ @app.post("/api/auth/verify")
63
+ async def verify_api_key(body: dict = Body(...)):
64
+ key = body.get("key", "")
65
+ return {"valid": key == settings.admin_api_key}
66
+
67
+
68
+ # Map images listing
69
+ @app.get("/api/map-images")
70
+ async def get_map_images():
71
+ images_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'admin', 'images')
72
+ try:
73
+ files = [f for f in os.listdir(images_dir) if f.lower().endswith(('.jpg', '.jpeg', '.png', '.webp'))]
74
+ files.sort(key=lambda x: int(x.rsplit('.', 1)[0].split('_')[-1]) if x.rsplit('.', 1)[0].split('_')[-1].isdigit() else 0)
75
+ return {"images": [f"images/{f}" for f in files]}
76
+ except FileNotFoundError:
77
+ raise HTTPException(status_code=404, detail="Images directory not found")
78
+
79
+
80
+ # Health check
81
+ @app.get("/api/health")
82
+ async def health_check():
83
+ """Health check endpoint"""
84
+ try:
85
+ # Test Supabase connection by fetching one record
86
+ result = supabase.table("Categories").select("id").limit(1).execute()
87
+ return {"status": "ok", "database": "connected"}
88
+ except Exception as e:
89
+ logger.error(f"Health check failed: {str(e)}")
90
+ raise HTTPException(status_code=500, detail="Database connection failed")
91
+
92
+
93
+ # ==================== CATEGORIES ENDPOINTS ====================
94
+
95
+ @app.get("/api/categories", response_model=List[Category])
96
+ async def list_categories(search: Optional[str] = Query(None)):
97
+ """Get all categories with optional search"""
98
+ try:
99
+ categories = await CategoryService.get_all_categories(search)
100
+ return categories
101
+ except Exception as e:
102
+ logger.error(f"Error fetching categories: {str(e)}")
103
+ raise HTTPException(status_code=500, detail=str(e))
104
+
105
+
106
+ @app.get("/api/categories/{category_id}", response_model=Category)
107
+ async def get_category(category_id: int):
108
+ """Get a single category by id"""
109
+ try:
110
+ category = await CategoryService.get_category_by_id(category_id)
111
+ if not category:
112
+ raise HTTPException(status_code=404, detail="Category not found")
113
+ return category
114
+ except HTTPException:
115
+ raise
116
+ except Exception as e:
117
+ logger.error(f"Error fetching category: {str(e)}")
118
+ raise HTTPException(status_code=500, detail=str(e))
119
+
120
+
121
+ @app.post("/api/categories", response_model=Category)
122
+ async def create_category(category: CategoryCreate):
123
+ """Create a new category"""
124
+ try:
125
+ new_category = await CategoryService.create_category(category)
126
+ return new_category
127
+ except Exception as e:
128
+ logger.error(f"Error creating category: {str(e)}")
129
+ raise HTTPException(status_code=500, detail=str(e))
130
+
131
+
132
+ @app.put("/api/categories/{category_id}", response_model=Category)
133
+ async def update_category(category_id: int, category: CategoryUpdate):
134
+ """Update an existing category"""
135
+ try:
136
+ updated = await CategoryService.update_category(category_id, category)
137
+ return updated
138
+ except Exception as e:
139
+ logger.error(f"Error updating category: {str(e)}")
140
+ raise HTTPException(status_code=500, detail=str(e))
141
+
142
+
143
+ @app.delete("/api/categories/{category_id}")
144
+ async def delete_category(category_id: int):
145
+ """Delete a category"""
146
+ try:
147
+ await CategoryService.delete_category(category_id)
148
+ return {"message": "Category deleted successfully"}
149
+ except Exception as e:
150
+ logger.error(f"Error deleting category: {str(e)}")
151
+ raise HTTPException(status_code=500, detail=str(e))
152
+
153
+
154
+ # ==================== INTENTS ENDPOINTS ====================
155
+
156
+ @app.get("/api/intents", response_model=List[IntentWithCategory])
157
+ async def list_intents(
158
+ search: Optional[str] = Query(None),
159
+ category_id: Optional[int] = Query(None),
160
+ priority: Optional[str] = Query(None)
161
+ ):
162
+ """Get all intents with optional filters"""
163
+ try:
164
+ intents = await IntentService.get_all_intents(search, category_id, priority)
165
+ return intents
166
+ except Exception as e:
167
+ logger.error(f"Error fetching intents: {str(e)}")
168
+ raise HTTPException(status_code=500, detail=str(e))
169
+
170
+
171
+ @app.get("/api/intents/{intent_id}", response_model=IntentWithCategory)
172
+ async def get_intent(intent_id: int):
173
+ """Get a single intent by ID"""
174
+ try:
175
+ intent = await IntentService.get_intent_by_id(intent_id)
176
+ if not intent:
177
+ raise HTTPException(status_code=404, detail="Intent not found")
178
+ return intent
179
+ except HTTPException:
180
+ raise
181
+ except Exception as e:
182
+ logger.error(f"Error fetching intent: {str(e)}")
183
+ raise HTTPException(status_code=500, detail=str(e))
184
+
185
+
186
+ @app.get("/api/categories/{category_id}/intents", response_model=List[IntentWithCategory])
187
+ async def get_intents_by_category(category_id: int):
188
+ """Get all intents for a specific category"""
189
+ try:
190
+ intents = await IntentService.get_intents_by_category(category_id)
191
+ return intents
192
+ except Exception as e:
193
+ logger.error(f"Error fetching intents for category: {str(e)}")
194
+ raise HTTPException(status_code=500, detail=str(e))
195
+
196
+
197
+ @app.post("/api/intents", response_model=IntentWithCategory)
198
+ async def create_intent(intent: IntentCreate):
199
+ """Create a new intent"""
200
+ try:
201
+ new_intent = await IntentService.create_intent(intent)
202
+ return new_intent
203
+ except Exception as e:
204
+ logger.error(f"Error creating intent: {str(e)}")
205
+ raise HTTPException(status_code=500, detail=str(e))
206
+
207
+
208
+ @app.put("/api/intents/{intent_id}", response_model=IntentWithCategory)
209
+ async def update_intent(intent_id: int, intent: IntentUpdate):
210
+ """Update an existing intent"""
211
+ try:
212
+ updated = await IntentService.update_intent(intent_id, intent)
213
+ return updated
214
+ except Exception as e:
215
+ logger.error(f"Error updating intent: {str(e)}")
216
+ raise HTTPException(status_code=500, detail=str(e))
217
+
218
+
219
+ @app.delete("/api/intents/{intent_id}")
220
+ async def delete_intent(intent_id: int):
221
+ """Delete an intent"""
222
+ try:
223
+ await IntentService.delete_intent(intent_id)
224
+ return {"message": "Intent deleted successfully"}
225
+ except Exception as e:
226
+ logger.error(f"Error deleting intent: {str(e)}")
227
+ raise HTTPException(status_code=500, detail=str(e))
228
+
229
+
230
+ # ==================== MAPS ENDPOINTS ====================
231
+
232
+ @app.get("/api/cities", response_model=List[City])
233
+ async def list_cities():
234
+ """Get all cities"""
235
+ try:
236
+ return await MapsService.get_all_cities()
237
+ except Exception as e:
238
+ logger.error(f"Error fetching cities: {str(e)}")
239
+ raise HTTPException(status_code=500, detail=str(e))
240
+
241
+
242
+ @app.post("/api/cities", response_model=City)
243
+ async def create_city(city: CityCreate):
244
+ """Create a new city"""
245
+ try:
246
+ return await MapsService.create_city(city)
247
+ except Exception as e:
248
+ logger.error(f"Error creating city: {str(e)}")
249
+ raise HTTPException(status_code=500, detail=str(e))
250
+
251
+
252
+ @app.get("/api/cities/{city_id}", response_model=City)
253
+ async def get_city(city_id: int):
254
+ try:
255
+ city = await MapsService.get_city_by_id(city_id)
256
+ if not city:
257
+ raise HTTPException(status_code=404, detail="City not found")
258
+ return city
259
+ except HTTPException:
260
+ raise
261
+ except Exception as e:
262
+ raise HTTPException(status_code=500, detail=str(e))
263
+
264
+
265
+ @app.put("/api/cities/{city_id}", response_model=City)
266
+ async def update_city(city_id: int, city: CityUpdate):
267
+ try:
268
+ return await MapsService.update_city(city_id, city)
269
+ except Exception as e:
270
+ raise HTTPException(status_code=500, detail=str(e))
271
+
272
+
273
+ @app.delete("/api/cities/{city_id}")
274
+ async def delete_city(city_id: int):
275
+ try:
276
+ await MapsService.delete_city(city_id)
277
+ return {"message": "City deleted"}
278
+ except Exception as e:
279
+ raise HTTPException(status_code=500, detail=str(e))
280
+
281
+
282
+ @app.get("/api/districts", response_model=List[District])
283
+ async def list_districts(city_id: Optional[int] = Query(None)):
284
+ """Get districts, optionally filtered by city_id"""
285
+ try:
286
+ return await MapsService.get_districts(city_id)
287
+ except Exception as e:
288
+ logger.error(f"Error fetching districts: {str(e)}")
289
+ raise HTTPException(status_code=500, detail=str(e))
290
+
291
+
292
+ @app.post("/api/districts", response_model=District)
293
+ async def create_district(district: DistrictCreate):
294
+ """Create a new district"""
295
+ try:
296
+ return await MapsService.create_district(district)
297
+ except Exception as e:
298
+ logger.error(f"Error creating district: {str(e)}")
299
+ raise HTTPException(status_code=500, detail=str(e))
300
+
301
+
302
+ @app.get("/api/districts/{district_id}", response_model=District)
303
+ async def get_district(district_id: int):
304
+ try:
305
+ district = await MapsService.get_district_by_id(district_id)
306
+ if not district:
307
+ raise HTTPException(status_code=404, detail="District not found")
308
+ return district
309
+ except HTTPException:
310
+ raise
311
+ except Exception as e:
312
+ raise HTTPException(status_code=500, detail=str(e))
313
+
314
+
315
+ @app.put("/api/districts/{district_id}", response_model=District)
316
+ async def update_district(district_id: int, district: DistrictUpdate):
317
+ try:
318
+ return await MapsService.update_district(district_id, district)
319
+ except Exception as e:
320
+ raise HTTPException(status_code=500, detail=str(e))
321
+
322
+
323
+ @app.delete("/api/districts/{district_id}")
324
+ async def delete_district(district_id: int):
325
+ try:
326
+ await MapsService.delete_district(district_id)
327
+ return {"message": "District deleted"}
328
+ except Exception as e:
329
+ raise HTTPException(status_code=500, detail=str(e))
330
+
331
+
332
+ @app.get("/api/streets", response_model=List[Street])
333
+ async def list_streets(
334
+ district_id: Optional[int] = Query(None),
335
+ city_id: Optional[int] = Query(None)
336
+ ):
337
+ """Get streets, optionally filtered by district_id or city_id"""
338
+ try:
339
+ return await MapsService.get_streets(district_id, city_id)
340
+ except Exception as e:
341
+ logger.error(f"Error fetching streets: {str(e)}")
342
+ raise HTTPException(status_code=500, detail=str(e))
343
+
344
+
345
+ @app.post("/api/streets/search")
346
+ async def search_streets(payload: dict = Body(...)):
347
+ """Search streets by text.
348
+ Payload: { text: str, district_id?: int, city_id?: int, limit?: int }
349
+ Returns list of matches with score.
350
+ """
351
+ try:
352
+ text = payload.get('text')
353
+ if not text:
354
+ raise HTTPException(status_code=422, detail='text is required')
355
+ district_id = payload.get('district_id')
356
+ city_id = payload.get('city_id')
357
+ limit = int(payload.get('limit') or 10)
358
+ results = await MapsService.search_streets_by_text(text, district_id=district_id, city_id=city_id, limit=limit)
359
+ return { 'results': results }
360
+ except HTTPException:
361
+ raise
362
+ except Exception as e:
363
+ logger.error(f"Error searching streets: {str(e)}")
364
+ raise HTTPException(status_code=500, detail=str(e))
365
+
366
+
367
+ @app.post("/api/streets", response_model=Street)
368
+ async def create_street(street: StreetCreate):
369
+ """Create a new street"""
370
+ try:
371
+ return await MapsService.create_street(street)
372
+ except Exception as e:
373
+ logger.error(f"Error creating street: {str(e)}")
374
+ raise HTTPException(status_code=500, detail=str(e))
375
+
376
+
377
+ @app.get("/api/streets/{street_id}", response_model=Street)
378
+ async def get_street(street_id: int):
379
+ try:
380
+ street = await MapsService.get_street_by_id(street_id)
381
+ if not street:
382
+ raise HTTPException(status_code=404, detail="Street not found")
383
+ return street
384
+ except HTTPException:
385
+ raise
386
+ except Exception as e:
387
+ raise HTTPException(status_code=500, detail=str(e))
388
+
389
+
390
+ @app.put("/api/streets/{street_id}", response_model=Street)
391
+ async def update_street(street_id: int, street: StreetUpdate):
392
+ try:
393
+ return await MapsService.update_street(street_id, street)
394
+ except Exception as e:
395
+ raise HTTPException(status_code=500, detail=str(e))
396
+
397
+
398
+ @app.delete("/api/streets/{street_id}")
399
+ async def delete_street(street_id: int):
400
+ try:
401
+ await MapsService.delete_street(street_id)
402
+ return {"message": "Street deleted"}
403
+ except Exception as e:
404
+ raise HTTPException(status_code=500, detail=str(e))
405
+
406
+
407
+ # ==================== POSITIONS ENDPOINTS ====================
408
+
409
+ @app.get("/api/positions", response_model=List[Position])
410
+ async def list_positions(street_id: Optional[int] = Query(None)):
411
+ try:
412
+ return await MapsService.get_positions(street_id)
413
+ except Exception as e:
414
+ raise HTTPException(status_code=500, detail=str(e))
415
+
416
+
417
+ @app.get("/api/positions/{position_id}", response_model=Position)
418
+ async def get_position(position_id: int):
419
+ try:
420
+ pos = await MapsService.get_position_by_id(position_id)
421
+ if not pos:
422
+ raise HTTPException(status_code=404, detail="Position not found")
423
+ return pos
424
+ except HTTPException:
425
+ raise
426
+ except Exception as e:
427
+ raise HTTPException(status_code=500, detail=str(e))
428
+
429
+
430
+ @app.post("/api/positions", response_model=Position)
431
+ async def create_position(position: PositionCreate):
432
+ try:
433
+ return await MapsService.create_position(position)
434
+ except Exception as e:
435
+ raise HTTPException(status_code=500, detail=str(e))
436
+
437
+
438
+ @app.post("/api/positions/bulk")
439
+ async def create_positions_bulk(payload: List[dict] = Body(...)):
440
+ """Accepts a flat array of unique positions and upserts them.
441
+ Expected payload: [{lat, lon, streets: [street_id, ...], pos_id?}, ...]
442
+ pos_id present → UPDATE; absent → INSERT.
443
+ """
444
+ try:
445
+ created = await MapsService.create_positions_bulk(payload)
446
+ return {"created": created}
447
+ except Exception as e:
448
+ logger.error(f"Error creating positions bulk: {str(e)}")
449
+ raise HTTPException(status_code=500, detail=str(e))
450
+
451
+
452
+ @app.put("/api/positions/{position_id}", response_model=Position)
453
+ async def update_position(position_id: int, position: PositionUpdate):
454
+ try:
455
+ return await MapsService.update_position(position_id, position)
456
+ except Exception as e:
457
+ raise HTTPException(status_code=500, detail=str(e))
458
+
459
+
460
+ @app.delete("/api/positions/{position_id}")
461
+ async def delete_position(position_id: int):
462
+ try:
463
+ await MapsService.delete_position(position_id)
464
+ return {"message": "Position deleted"}
465
+ except Exception as e:
466
+ raise HTTPException(status_code=500, detail=str(e))
467
+
468
+
469
+ @app.delete("/api/positions/{position_id}/cascade")
470
+ async def delete_position_cascade(position_id: int):
471
+ try:
472
+ await MapsService.delete_position_cascade(position_id)
473
+ return {"message": "Position deleted and Streets updated"}
474
+ except Exception as e:
475
+ logger.error(f"Error deleting position cascade: {str(e)}")
476
+ raise HTTPException(status_code=500, detail=str(e))
477
+
478
+
479
+ @app.post("/api/search")
480
+ async def search_nearest(payload: dict = Body(...)):
481
+ """Find the nearest DB position for each query point.
482
+ Expected: {points: [{lat, lon}, ...]} (1 or 2 points)
483
+ Returns: {data, label, warning, message}
484
+ """
485
+ try:
486
+ points = payload.get("points") or []
487
+ if not points:
488
+ raise HTTPException(status_code=422, detail="points array is required")
489
+ return await MapsService.search_nearest(points)
490
+ except HTTPException:
491
+ raise
492
+ except Exception as e:
493
+ logger.error(f"Error searching nearest: {str(e)}")
494
+ raise HTTPException(status_code=500, detail=str(e))
495
+
496
+
497
+ # Serve admin static files (must be mounted last, after all API routes)
498
+ _admin_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'admin')
499
+ if os.path.isdir(_admin_dir):
500
+ app.mount("/", StaticFiles(directory=_admin_dir, html=True), name="admin")
501
+
502
+
503
+ if __name__ == '__main__':
504
+ import uvicorn
505
+ uvicorn.run(app, host="0.0.0.0", port=4000, log_level="info")
backend/models.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional, List, Union
3
+
4
+
5
+ class CategoryBase(BaseModel):
6
+ label: str
7
+
8
+
9
+ class Category(CategoryBase):
10
+ id: Optional[int] = None
11
+ embedding: Optional[str] = None
12
+ created_at: Optional[str] = None
13
+
14
+ class Config:
15
+ from_attributes = True
16
+
17
+
18
+ class CategoryCreate(CategoryBase):
19
+ pass
20
+
21
+
22
+ class CategoryUpdate(BaseModel):
23
+ label: Optional[str] = None
24
+ embedding: Optional[str] = None
25
+
26
+
27
+ class IntentBase(BaseModel):
28
+ name: str
29
+ priority: Union[int, str]
30
+ category_id: int
31
+
32
+
33
+ class Intent(IntentBase):
34
+ id: Optional[int] = None
35
+ embedding: Optional[str] = None
36
+ created_at: Optional[str] = None
37
+
38
+ class Config:
39
+ from_attributes = True
40
+
41
+
42
+ class IntentCreate(IntentBase):
43
+ pass
44
+
45
+
46
+ class IntentUpdate(BaseModel):
47
+ name: Optional[str] = None
48
+ priority: Optional[Union[int, str]] = None
49
+ category_id: Optional[int] = None
50
+ embedding: Optional[str] = None
51
+
52
+
53
+ class IntentWithCategory(Intent):
54
+ category_label: Optional[str] = None
55
+
56
+
57
+ # ==================== MAPS MODELS ====================
58
+
59
+ class City(BaseModel):
60
+ id: Optional[int] = None
61
+ name: Optional[str] = None
62
+ created_at: Optional[str] = None
63
+
64
+ class Config:
65
+ from_attributes = True
66
+
67
+
68
+ class CityCreate(BaseModel):
69
+ name: str
70
+
71
+
72
+ class District(BaseModel):
73
+ id: Optional[int] = None
74
+ name: Optional[str] = None
75
+ lname: Optional[str] = None
76
+ city: Optional[int] = None
77
+ created_at: Optional[str] = None
78
+
79
+ class Config:
80
+ from_attributes = True
81
+
82
+
83
+ class DistrictCreate(BaseModel):
84
+ name: str
85
+ lname: Optional[str] = None
86
+ city: int
87
+
88
+
89
+ class Street(BaseModel):
90
+ id: Optional[int] = None
91
+ name: Optional[str] = None
92
+ type: Optional[str] = None
93
+ district_id: Optional[int] = None
94
+ city_id: Optional[int] = None
95
+ positions: Optional[list] = None
96
+ created_at: Optional[str] = None
97
+
98
+ class Config:
99
+ from_attributes = True
100
+
101
+
102
+ class StreetCreate(BaseModel):
103
+ name: str
104
+ type: Optional[str] = None
105
+ district_id: int
106
+ city_id: int
107
+
108
+
109
+ class CityUpdate(BaseModel):
110
+ name: Optional[str] = None
111
+
112
+
113
+ class DistrictUpdate(BaseModel):
114
+ name: Optional[str] = None
115
+ lname: Optional[str] = None
116
+ city: Optional[int] = None
117
+
118
+
119
+ class StreetUpdate(BaseModel):
120
+ name: Optional[str] = None
121
+ type: Optional[str] = None
122
+ district_id: Optional[int] = None
123
+ city_id: Optional[int] = None
124
+
125
+
126
+ # ==================== POSITIONS MODEL ====================
127
+
128
+ class Position(BaseModel):
129
+ id: Optional[int] = None
130
+ street_id: Optional[int] = None
131
+ x: Optional[float] = None
132
+ y: Optional[float] = None
133
+ streets: Optional[list] = None
134
+ ban: Optional[list] = None
135
+ speed: Optional[dict] = None
136
+ park: Optional[bool] = None
137
+ lane: Optional[int] = None
138
+ tool: Optional[int] = None
139
+ flooding: Optional[bool] = None
140
+ created_at: Optional[str] = None
141
+
142
+ class Config:
143
+ from_attributes = True
144
+
145
+
146
+ class PositionCreate(BaseModel):
147
+ street_id: int
148
+ x: float
149
+ y: float
150
+ ban: Optional[list] = None
151
+ speed: Optional[dict] = None
152
+ park: Optional[bool] = None
153
+ lane: Optional[int] = None
154
+ tool: Optional[int] = None
155
+ flooding: Optional[bool] = None
156
+
157
+
158
+ class PositionUpdate(BaseModel):
159
+ street_id: Optional[int] = None
160
+ x: Optional[float] = None
161
+ y: Optional[float] = None
162
+ ban: Optional[list] = None
163
+ speed: Optional[dict] = None
164
+ park: Optional[bool] = None
165
+ lane: Optional[int] = None
166
+ tool: Optional[int] = None
167
+ flooding: Optional[bool] = None
backend/requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ fastapi==0.104.1
2
+ uvicorn[standard]==0.24.0
3
+ python-dotenv==1.0.0
4
+ supabase==2.9.1
5
+ pydantic==2.5.0
6
+ pydantic-settings==2.1.0
7
+ httpx==0.27.2
backend/services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Services init
backend/services/category_service.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from config import supabase
2
+ from models import Category, CategoryCreate, CategoryUpdate
3
+ from typing import List, Optional
4
+
5
+
6
+ class CategoryService:
7
+ @staticmethod
8
+ async def get_all_categories(search: Optional[str] = None) -> List[Category]:
9
+ """Get all categories, optionally filtered by search term"""
10
+ try:
11
+ query = supabase.table("Categories").select("*")
12
+
13
+ if search:
14
+ query = query.ilike("label", f"%{search}%")
15
+
16
+ result = query.execute()
17
+ return [Category(**item) for item in result.data]
18
+ except Exception as e:
19
+ raise Exception(f"Failed to fetch categories: {str(e)}")
20
+
21
+ @staticmethod
22
+ async def get_category_by_id(category_id: int) -> Optional[Category]:
23
+ """Get a single category by id"""
24
+ try:
25
+ result = supabase.table("Categories").select("*").eq("id", category_id).execute()
26
+ if result.data:
27
+ return Category(**result.data[0])
28
+ return None
29
+ except Exception as e:
30
+ raise Exception(f"Failed to fetch category: {str(e)}")
31
+
32
+ @staticmethod
33
+ async def create_category(category: CategoryCreate) -> Category:
34
+ """Create a new category"""
35
+ try:
36
+ result = supabase.table("Categories").insert(category.dict()).execute()
37
+ if result.data:
38
+ return Category(**result.data[0])
39
+ raise Exception("Failed to create category")
40
+ except Exception as e:
41
+ raise Exception(f"Failed to create category: {str(e)}")
42
+
43
+ @staticmethod
44
+ async def update_category(category_id: int, category: CategoryUpdate) -> Category:
45
+ """Update an existing category"""
46
+ try:
47
+ update_data = category.dict(exclude_unset=True)
48
+ result = supabase.table("Categories").update(update_data).eq("id", category_id).execute()
49
+ if result.data:
50
+ return Category(**result.data[0])
51
+ raise Exception("Category not found")
52
+ except Exception as e:
53
+ raise Exception(f"Failed to update category: {str(e)}")
54
+
55
+ @staticmethod
56
+ async def delete_category(category_id: int) -> bool:
57
+ """Delete a category"""
58
+ try:
59
+ result = supabase.table("Categories").delete().eq("id", category_id).execute()
60
+ return True
61
+ except Exception as e:
62
+ raise Exception(f"Failed to delete category: {str(e)}")
backend/services/intent_service.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from config import supabase
2
+ from models import Intent, IntentCreate, IntentUpdate, IntentWithCategory
3
+ from typing import List, Optional
4
+
5
+
6
+ class IntentService:
7
+ @staticmethod
8
+ async def get_all_intents(
9
+ search: Optional[str] = None,
10
+ category_id: Optional[int] = None,
11
+ priority: Optional[str] = None
12
+ ) -> List[IntentWithCategory]:
13
+ """Get all intents with optional filters"""
14
+ try:
15
+ query = supabase.table("Intents").select("*, Categories(label)")
16
+
17
+ if search:
18
+ query = query.ilike("name", f"%{search}%")
19
+
20
+ if category_id:
21
+ query = query.eq("category_id", category_id)
22
+
23
+ if priority:
24
+ query = query.eq("priority", priority)
25
+
26
+ result = query.execute()
27
+ intents = []
28
+ for item in result.data:
29
+ # Flatten nested category data
30
+ if "Categories" in item and item["Categories"]:
31
+ item["category_label"] = item["Categories"].get("label")
32
+ else:
33
+ item["category_label"] = None
34
+
35
+ # Remove nested Categories key before creating model
36
+ item.pop("Categories", None)
37
+
38
+ intent = IntentWithCategory(**item)
39
+ intents.append(intent)
40
+ return intents
41
+ except Exception as e:
42
+ raise Exception(f"Failed to fetch intents: {str(e)}")
43
+
44
+ @staticmethod
45
+ async def get_intent_by_id(intent_id: int) -> Optional[IntentWithCategory]:
46
+ """Get a single intent by ID"""
47
+ try:
48
+ result = supabase.table("Intents").select("*, Categories(label)").eq("id", intent_id).execute()
49
+ if result.data:
50
+ item = result.data[0]
51
+
52
+ # Flatten nested category data
53
+ if "Categories" in item and item["Categories"]:
54
+ item["category_label"] = item["Categories"].get("label")
55
+ else:
56
+ item["category_label"] = None
57
+
58
+ # Remove nested Categories key before creating model
59
+ item.pop("Categories", None)
60
+
61
+ intent = IntentWithCategory(**item)
62
+ return intent
63
+ return None
64
+ except Exception as e:
65
+ raise Exception(f"Failed to fetch intent: {str(e)}")
66
+
67
+ @staticmethod
68
+ async def get_intents_by_category(category_id: int) -> List[IntentWithCategory]:
69
+ """Get all intents for a specific category"""
70
+ try:
71
+ result = supabase.table("Intents").select("*, Categories(label)").eq("category_id", category_id).execute()
72
+ intents = []
73
+ for item in result.data:
74
+ # Flatten nested category data
75
+ if "Categories" in item and item["Categories"]:
76
+ item["category_label"] = item["Categories"].get("label")
77
+ else:
78
+ item["category_label"] = None
79
+
80
+ # Remove nested Categories key before creating model
81
+ item.pop("Categories", None)
82
+
83
+ intent = IntentWithCategory(**item)
84
+ intents.append(intent)
85
+ return intents
86
+ except Exception as e:
87
+ raise Exception(f"Failed to fetch intents for category: {str(e)}")
88
+
89
+ @staticmethod
90
+ async def create_intent(intent: IntentCreate) -> IntentWithCategory:
91
+ """Create a new intent"""
92
+ try:
93
+ result = supabase.table("Intents").insert(intent.dict()).execute()
94
+ if result.data:
95
+ item = result.data[0]
96
+ return IntentWithCategory(**item)
97
+ raise Exception("Failed to create intent")
98
+ except Exception as e:
99
+ raise Exception(f"Failed to create intent: {str(e)}")
100
+
101
+ @staticmethod
102
+ async def update_intent(intent_id: int, intent: IntentUpdate) -> IntentWithCategory:
103
+ """Update an existing intent"""
104
+ try:
105
+ update_data = intent.dict(exclude_unset=True)
106
+ result = supabase.table("Intents").update(update_data).eq("id", intent_id).execute()
107
+ if result.data:
108
+ item = result.data[0]
109
+ return IntentWithCategory(**item)
110
+ raise Exception("Intent not found")
111
+ except Exception as e:
112
+ raise Exception(f"Failed to update intent: {str(e)}")
113
+
114
+ @staticmethod
115
+ async def delete_intent(intent_id: int) -> bool:
116
+ """Delete an intent"""
117
+ try:
118
+ result = supabase.table("Intents").delete().eq("id", intent_id).execute()
119
+ return True
120
+ except Exception as e:
121
+ raise Exception(f"Failed to delete intent: {str(e)}")
backend/services/maps_service.py ADDED
@@ -0,0 +1,758 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from config import supabase
2
+ from models import (
3
+ City, CityCreate, CityUpdate,
4
+ District, DistrictCreate, DistrictUpdate,
5
+ Street, StreetCreate, StreetUpdate,
6
+ Position, PositionCreate, PositionUpdate,
7
+ )
8
+ from typing import List, Optional
9
+ import unicodedata
10
+
11
+
12
+ def _next_id(table: str, id_col: str = "id") -> int:
13
+ """Get max(id) + 1 from a table to avoid identity sequence conflicts."""
14
+ result = supabase.table(table).select(id_col).order(id_col, desc=True).limit(1).execute()
15
+ max_id = result.data[0][id_col] if result.data else 0
16
+ return max_id + 1
17
+
18
+
19
+ class MapsService:
20
+ @staticmethod
21
+ def _db_pos_to_model(item: dict) -> dict:
22
+ if not item:
23
+ return {}
24
+ return {
25
+ 'id': item.get('id'),
26
+ 'x': item.get('long'),
27
+ 'y': item.get('lat'),
28
+ 'ban': item.get('ban'),
29
+ 'speed': item.get('speed'),
30
+ 'park': item.get('park'),
31
+ 'lane': item.get('lane'),
32
+ 'tool': item.get('tool'),
33
+ 'flooding': item.get('flooding'),
34
+ 'streets': item.get('streets'),
35
+ 'created_at': item.get('created_at')
36
+ }
37
+ @staticmethod
38
+ async def get_all_cities() -> List[City]:
39
+ try:
40
+ result = supabase.table("Cities").select("id, name").order("name").execute()
41
+ return [City(**item) for item in result.data]
42
+ except Exception as e:
43
+ raise Exception(f"Failed to fetch cities: {str(e)}")
44
+
45
+ @staticmethod
46
+ async def create_city(data: CityCreate) -> City:
47
+ try:
48
+ next_id = _next_id("Cities")
49
+ result = supabase.table("Cities").insert({"id": next_id, "name": data.name}).execute()
50
+ return City(**result.data[0])
51
+ except Exception as e:
52
+ raise Exception(f"Failed to create city: {str(e)}")
53
+
54
+ @staticmethod
55
+ async def get_city_by_id(city_id: int) -> Optional[City]:
56
+ try:
57
+ result = supabase.table("Cities").select("id, name").eq("id", city_id).execute()
58
+ return City(**result.data[0]) if result.data else None
59
+ except Exception as e:
60
+ raise Exception(f"Failed to fetch city: {str(e)}")
61
+
62
+ @staticmethod
63
+ async def update_city(city_id: int, data: CityUpdate) -> City:
64
+ try:
65
+ payload = {k: v for k, v in data.model_dump().items() if v is not None}
66
+ result = supabase.table("Cities").update(payload).eq("id", city_id).execute()
67
+ return City(**result.data[0])
68
+ except Exception as e:
69
+ raise Exception(f"Failed to update city: {str(e)}")
70
+
71
+ @staticmethod
72
+ async def delete_city(city_id: int) -> bool:
73
+ try:
74
+ supabase.table("Cities").delete().eq("id", city_id).execute()
75
+ return True
76
+ except Exception as e:
77
+ raise Exception(f"Failed to delete city: {str(e)}")
78
+
79
+ @staticmethod
80
+ async def get_districts(city_id: Optional[int] = None) -> List[District]:
81
+ try:
82
+ query = supabase.table("Districts").select("id, name, lname, city").order("name")
83
+ if city_id is not None:
84
+ query = query.eq("city", city_id)
85
+ result = query.execute()
86
+ return [District(**item) for item in result.data]
87
+ except Exception as e:
88
+ raise Exception(f"Failed to fetch districts: {str(e)}")
89
+
90
+ @staticmethod
91
+ async def create_district(data: DistrictCreate) -> District:
92
+ try:
93
+ next_id = _next_id("Districts")
94
+ payload = {"id": next_id, "name": data.name, "city": data.city}
95
+ if data.lname:
96
+ payload["lname"] = data.lname
97
+ result = supabase.table("Districts").insert(payload).execute()
98
+ return District(**result.data[0])
99
+ except Exception as e:
100
+ raise Exception(f"Failed to create district: {str(e)}")
101
+
102
+ @staticmethod
103
+ async def get_district_by_id(district_id: int) -> Optional[District]:
104
+ try:
105
+ result = supabase.table("Districts").select("id, name, lname, city").eq("id", district_id).execute()
106
+ return District(**result.data[0]) if result.data else None
107
+ except Exception as e:
108
+ raise Exception(f"Failed to fetch district: {str(e)}")
109
+
110
+ @staticmethod
111
+ async def update_district(district_id: int, data: DistrictUpdate) -> District:
112
+ try:
113
+ payload = {k: v for k, v in data.model_dump().items() if v is not None}
114
+ result = supabase.table("Districts").update(payload).eq("id", district_id).execute()
115
+ return District(**result.data[0])
116
+ except Exception as e:
117
+ raise Exception(f"Failed to update district: {str(e)}")
118
+
119
+ @staticmethod
120
+ async def delete_district(district_id: int) -> bool:
121
+ try:
122
+ supabase.table("Districts").delete().eq("id", district_id).execute()
123
+ return True
124
+ except Exception as e:
125
+ raise Exception(f"Failed to delete district: {str(e)}")
126
+
127
+ @staticmethod
128
+ async def get_streets(district_id: Optional[int] = None, city_id: Optional[int] = None) -> List[Street]:
129
+ try:
130
+ query = supabase.table("Streets").select("id, name, type, district_id, city_id").order("name")
131
+ if district_id is not None:
132
+ query = query.eq("district_id", district_id)
133
+ elif city_id is not None:
134
+ query = query.eq("city_id", city_id)
135
+ result = query.execute()
136
+ return [Street(**item) for item in result.data]
137
+ except Exception as e:
138
+ raise Exception(f"Failed to fetch streets: {str(e)}")
139
+
140
+ @staticmethod
141
+ async def create_street(data: StreetCreate) -> Street:
142
+ try:
143
+ # Normalize helper (remove diacritics, lower, collapse whitespace)
144
+ import re
145
+
146
+ def _normalize(s: str) -> str:
147
+ if not s:
148
+ return ''
149
+ s = str(s).strip().lower()
150
+ # NFKD to decompose accents, then remove combining marks
151
+ s = unicodedata.normalize('NFKD', s)
152
+ s = ''.join(ch for ch in s if not unicodedata.combining(ch))
153
+ # remove punctuation, keep letters/numbers and spaces
154
+ s = re.sub(r'[^0-9a-z\s]', ' ', s)
155
+ s = ' '.join(s.split())
156
+ return s
157
+
158
+ def _levenshtein(a: str, b: str) -> int:
159
+ # simple iterative DP
160
+ if a == b:
161
+ return 0
162
+ if len(a) == 0:
163
+ return len(b)
164
+ if len(b) == 0:
165
+ return len(a)
166
+ prev = list(range(len(b) + 1))
167
+ for i, ca in enumerate(a, start=1):
168
+ curr = [i]
169
+ for j, cb in enumerate(b, start=1):
170
+ insert_cost = curr[j-1] + 1
171
+ delete_cost = prev[j] + 1
172
+ replace_cost = prev[j-1] + (0 if ca == cb else 1)
173
+ curr.append(min(insert_cost, delete_cost, replace_cost))
174
+ prev = curr
175
+ return prev[-1]
176
+
177
+ name_norm = _normalize(data.name)
178
+ city_id = data.city_id
179
+ district_id = data.district_id
180
+
181
+ # Fetch candidate existing streets in same district or city
182
+ query = supabase.table("Streets").select("id, name, district_id, city_id").order("name")
183
+ if district_id is not None:
184
+ query = query.eq("district_id", district_id)
185
+ elif city_id is not None:
186
+ query = query.eq("city_id", city_id)
187
+ existing_res = query.execute()
188
+ existing_list = existing_res.data or []
189
+ for item in existing_list:
190
+ existing_name = item.get('name') or ''
191
+ en_norm = _normalize(existing_name)
192
+ # exact or containment
193
+ if en_norm == name_norm or (name_norm and en_norm and (name_norm in en_norm or en_norm in name_norm)):
194
+ raise Exception(f"Similar street exists: {existing_name} (id={item.get('id')})")
195
+ # small edit distance -> likely duplicate; threshold scales with length
196
+ max_dist = max(2, int(max(len(en_norm), len(name_norm)) * 0.2))
197
+ dist = _levenshtein(en_norm, name_norm)
198
+ # debug log
199
+ try:
200
+ import logging
201
+ logging.getLogger(__name__).info(f"create_street: compare '{name_norm}' vs '{en_norm}' -> dist={dist} threshold={max_dist}")
202
+ except Exception:
203
+ pass
204
+ if dist <= max_dist:
205
+ raise Exception(f"Similar street exists: {existing_name} (id={item.get('id')})")
206
+
207
+ # Also perform a global fuzzy search to catch similar names in other districts/cities
208
+ try:
209
+ global_results = await MapsService.search_streets_by_text(data.name, district_id=None, city_id=None, limit=10)
210
+ # Accept if any match has a low score (<= 0.15)
211
+ for r in (global_results or []):
212
+ if float(r.get('score', 1.0)) <= 0.15:
213
+ raise Exception(f"Similar street exists elsewhere: {r.get('name')} (id={r.get('id')})")
214
+ except Exception as e:
215
+ # If search_streets_by_text raised an exception because of DB, re-raise as generic
216
+ if str(e).startswith('Failed to create street'):
217
+ raise
218
+ # If we raised above due to similarity, pass it up
219
+ if 'Similar street exists' in str(e) or 'Similar street exists elsewhere' in str(e):
220
+ raise
221
+ # otherwise ignore search errors and proceed with insert
222
+ pass
223
+
224
+ # No similar found -> proceed to insert
225
+ next_id = _next_id("Streets")
226
+ payload = {"id": next_id, "name": data.name, "district_id": data.district_id, "city_id": data.city_id}
227
+ if data.type:
228
+ payload["type"] = data.type
229
+ result = supabase.table("Streets").insert(payload).execute()
230
+ return Street(**result.data[0])
231
+ except Exception as e:
232
+ raise Exception(f"Failed to create street: {str(e)}")
233
+
234
+ @staticmethod
235
+ async def get_street_by_id(street_id: int) -> Optional[Street]:
236
+ try:
237
+ result = supabase.table("Streets").select("id, name, type, district_id, city_id").eq("id", street_id).execute()
238
+ return Street(**result.data[0]) if result.data else None
239
+ except Exception as e:
240
+ raise Exception(f"Failed to fetch street: {str(e)}")
241
+
242
+ @staticmethod
243
+ async def update_street(street_id: int, data: StreetUpdate) -> Street:
244
+ try:
245
+ payload = {k: v for k, v in data.model_dump().items() if v is not None}
246
+ result = supabase.table("Streets").update(payload).eq("id", street_id).execute()
247
+ return Street(**result.data[0])
248
+ except Exception as e:
249
+ raise Exception(f"Failed to update street: {str(e)}")
250
+
251
+ @staticmethod
252
+ async def delete_street(street_id: int) -> bool:
253
+ try:
254
+ supabase.table("Streets").delete().eq("id", street_id).execute()
255
+ return True
256
+ except Exception as e:
257
+ raise Exception(f"Failed to delete street: {str(e)}")
258
+
259
+ @staticmethod
260
+ async def search_streets_by_text(text: str, district_id: Optional[int] = None, city_id: Optional[int] = None, limit: int = 10) -> list:
261
+ """Search streets by text with normalization and fuzzy matching.
262
+ Returns list of dicts: {id, name, type, district_id, city_id, score}
263
+ Lower score == better match.
264
+ """
265
+ try:
266
+ if not text or not str(text).strip():
267
+ return []
268
+ import re
269
+ def _normalize(s: str) -> str:
270
+ s = str(s or '').strip().lower()
271
+ s = unicodedata.normalize('NFKD', s)
272
+ s = ''.join(ch for ch in s if not unicodedata.combining(ch))
273
+ s = re.sub(r'[^0-9a-z\s]', ' ', s)
274
+ s = ' '.join(s.split())
275
+ return s
276
+
277
+ def _lev(a: str, b: str) -> int:
278
+ if a == b:
279
+ return 0
280
+ if len(a) == 0:
281
+ return len(b)
282
+ if len(b) == 0:
283
+ return len(a)
284
+ prev = list(range(len(b) + 1))
285
+ for i, ca in enumerate(a, start=1):
286
+ curr = [i]
287
+ for j, cb in enumerate(b, start=1):
288
+ insert_cost = curr[j-1] + 1
289
+ delete_cost = prev[j] + 1
290
+ replace_cost = prev[j-1] + (0 if ca == cb else 1)
291
+ curr.append(min(insert_cost, delete_cost, replace_cost))
292
+ prev = curr
293
+ return prev[-1]
294
+
295
+ q = _normalize(text)
296
+ # Fetch candidate streets filtered by district or city
297
+ query = supabase.table("Streets").select("id, name, type, district_id, city_id").order("name")
298
+ if district_id is not None:
299
+ query = query.eq("district_id", district_id)
300
+ elif city_id is not None:
301
+ query = query.eq("city_id", city_id)
302
+ res = query.execute()
303
+ candidates = res.data or []
304
+
305
+ scored = []
306
+ for item in candidates:
307
+ name = item.get('name') or ''
308
+ norm = _normalize(name)
309
+ score = None
310
+ if norm == q:
311
+ score = 0.0
312
+ elif q in norm or norm in q:
313
+ score = 0.1
314
+ else:
315
+ dist = _lev(q, norm)
316
+ max_len = max(len(q), len(norm), 1)
317
+ # normalized distance
318
+ score = dist / max_len
319
+ scored.append({
320
+ 'id': item.get('id'),
321
+ 'name': name,
322
+ 'type': item.get('type'),
323
+ 'district_id': item.get('district_id'),
324
+ 'city_id': item.get('city_id'),
325
+ 'score': round(float(score), 3)
326
+ })
327
+
328
+ # sort by score asc, then by name
329
+ scored.sort(key=lambda x: (x['score'], x['name']))
330
+ return scored[:limit]
331
+ except Exception as e:
332
+ raise Exception(f"Failed to search streets: {str(e)}")
333
+
334
+ # ==================== POSITIONS ====================
335
+
336
+ @staticmethod
337
+ async def get_positions(street_id: Optional[int] = None) -> List[Position]:
338
+ try:
339
+ if street_id is not None:
340
+ # Filter directly on Positions.streets JSON array
341
+ result = supabase.table("Positions").select("*").contains("streets", [street_id]).order("id").execute()
342
+ else:
343
+ result = supabase.table("Positions").select("*").order("id").execute()
344
+ return [Position(**MapsService._db_pos_to_model(item)) for item in (result.data or [])]
345
+ except Exception as e:
346
+ raise Exception(f"Failed to fetch positions: {str(e)}")
347
+
348
+ @staticmethod
349
+ async def get_position_by_id(position_id: int) -> Optional[Position]:
350
+ try:
351
+ result = supabase.table("Positions").select("*").eq("id", position_id).execute()
352
+ if result.data:
353
+ return Position(**MapsService._db_pos_to_model(result.data[0]))
354
+ return None
355
+ except Exception as e:
356
+ raise Exception(f"Failed to fetch position: {str(e)}")
357
+
358
+ @staticmethod
359
+ async def create_position(data: PositionCreate) -> Position:
360
+ try:
361
+ next_id = _next_id("Positions")
362
+ # Map internal x/y to DB columns long/lat
363
+ payload = {"id": next_id, "long": data.x, "lat": data.y}
364
+ # include streets JSON if provided on model
365
+ if getattr(data, 'street_id', None):
366
+ payload['streets'] = [data.street_id]
367
+ for field in ("ban", "speed", "park", "lane", "tool", "flooding"):
368
+ val = getattr(data, field)
369
+ if val is not None:
370
+ payload[field] = val
371
+ result = supabase.table("Positions").insert(payload).execute()
372
+ if not result.data:
373
+ raise Exception("Failed to insert position")
374
+ pos = result.data[0]
375
+ # append to Streets.positions JSON column if street_id provided
376
+ if getattr(data, 'street_id', None):
377
+ try:
378
+ street_row = supabase.table("Streets").select("positions").eq("id", data.street_id).limit(1).execute()
379
+ existing = []
380
+ if street_row.data and 'positions' in street_row.data[0] and street_row.data[0]['positions']:
381
+ existing = list(street_row.data[0]['positions'])
382
+ existing.append(pos['id'])
383
+ supabase.table("Streets").update({"positions": existing}).eq("id", data.street_id).execute()
384
+ except Exception:
385
+ pass
386
+ return Position(**MapsService._db_pos_to_model(pos))
387
+ except Exception as e:
388
+ raise Exception(f"Failed to create position: {str(e)}")
389
+
390
+ @staticmethod
391
+ async def update_position(position_id: int, data: PositionUpdate) -> Position:
392
+ try:
393
+ payload = {k: v for k, v in data.model_dump().items() if v is not None}
394
+ result = supabase.table("Positions").update(payload).eq("id", position_id).execute()
395
+ return Position(**result.data[0])
396
+ except Exception as e:
397
+ raise Exception(f"Failed to update position: {str(e)}")
398
+
399
+ @staticmethod
400
+ async def delete_position(position_id: int) -> bool:
401
+ try:
402
+ supabase.table("Positions").delete().eq("id", position_id).execute()
403
+ return True
404
+ except Exception as e:
405
+ raise Exception(f"Failed to delete position: {str(e)}")
406
+
407
+ @staticmethod
408
+ async def create_positions_bulk(positions_payload: list) -> list:
409
+ """positions_payload: [{lat, lon, streets: [street_id, ...], pos_id?}, ...]
410
+ Each item is ONE unique position carrying its complete streets array.
411
+ pos_id present → UPDATE existing Positions row and sync Streets.positions arrays.
412
+ pos_id absent → INSERT new row, then add its id to all relevant Streets.positions arrays.
413
+ Returns list of affected position records.
414
+ """
415
+ created = []
416
+ try:
417
+ for pt in positions_payload:
418
+ lat = float(pt.get('lat'))
419
+ lon = float(pt.get('lon'))
420
+ streets = [int(s) for s in (pt.get('streets') or [])]
421
+ pos_id = pt.get('pos_id')
422
+
423
+ if pos_id:
424
+ # ── UPDATE existing position ──────────────────────────────────
425
+ pos_id = int(pos_id)
426
+ # Fetch old streets so we can diff
427
+ existing_pos = supabase.table("Positions").select("streets").eq("id", pos_id).limit(1).execute()
428
+ old_streets = list((existing_pos.data[0].get('streets') or []) if existing_pos.data else [])
429
+
430
+ result = supabase.table("Positions").update({"long": lon, "lat": lat, "streets": streets}).eq("id", pos_id).execute()
431
+ if result.data:
432
+ created.append(result.data[0])
433
+
434
+ # Remove pos_id from Streets that are no longer associated
435
+ for sid in old_streets:
436
+ if sid not in streets:
437
+ try:
438
+ sr = supabase.table("Streets").select("positions").eq("id", sid).limit(1).execute()
439
+ if sr.data:
440
+ existing = [i for i in (sr.data[0].get('positions') or []) if i != pos_id]
441
+ supabase.table("Streets").update({"positions": existing}).eq("id", sid).execute()
442
+ except Exception:
443
+ pass
444
+ # Add pos_id to Streets newly associated
445
+ for sid in streets:
446
+ if sid not in old_streets:
447
+ try:
448
+ sr = supabase.table("Streets").select("positions").eq("id", sid).limit(1).execute()
449
+ if sr.data:
450
+ existing = list(sr.data[0].get('positions') or [])
451
+ if pos_id not in existing:
452
+ existing.append(pos_id)
453
+ supabase.table("Streets").update({"positions": existing}).eq("id", sid).execute()
454
+ except Exception:
455
+ pass
456
+ else:
457
+ # ── INSERT new position ───────────────────────────────────────
458
+ next_id = _next_id("Positions")
459
+ payload = {"id": next_id, "long": lon, "lat": lat, "streets": streets}
460
+ result = supabase.table("Positions").insert(payload).execute()
461
+ if not result.data:
462
+ raise Exception("Failed to insert position")
463
+ pos = result.data[0]
464
+ created.append(pos)
465
+ # Append new position id to every associated Street.positions array
466
+ for sid in streets:
467
+ try:
468
+ sr = supabase.table("Streets").select("positions").eq("id", sid).limit(1).execute()
469
+ if sr.data:
470
+ existing = list(sr.data[0].get('positions') or [])
471
+ if pos['id'] not in existing:
472
+ existing.append(pos['id'])
473
+ supabase.table("Streets").update({"positions": existing}).eq("id", sid).execute()
474
+ except Exception:
475
+ pass
476
+
477
+ return created
478
+ except Exception as e:
479
+ raise Exception(f"Failed to create/update positions bulk: {str(e)}")
480
+
481
+ @staticmethod
482
+ async def search_nearest(points: list) -> dict:
483
+ """
484
+ 1 query point → return nearest DB position.
485
+ 2 query points → find nearest DB node to each, then run Dijkstra on the
486
+ street-adjacency graph to return the shortest path array.
487
+ Graph: positions are nodes; two positions are adjacent iff they share ≥1 street.
488
+ Edge weight = Haversine distance (metres).
489
+ Returns `tts` field: natural Vietnamese navigation text with street names,
490
+ turn directions, and congestion warnings (speed.normal < 20 km/h).
491
+ """
492
+ import math
493
+ import heapq
494
+
495
+ def haversine_m(lat1, lon1, lat2, lon2):
496
+ R = 6_371_000
497
+ phi1, phi2 = math.radians(lat1), math.radians(lat2)
498
+ dphi = math.radians(lat2 - lat1)
499
+ dlam = math.radians(lon2 - lon1)
500
+ a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
501
+ return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
502
+
503
+ def bearing_deg(lat1, lon1, lat2, lon2):
504
+ phi1 = math.radians(lat1)
505
+ phi2 = math.radians(lat2)
506
+ dl = math.radians(lon2 - lon1)
507
+ x = math.sin(dl) * math.cos(phi2)
508
+ y = math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(dl)
509
+ return (math.degrees(math.atan2(x, y)) + 360) % 360
510
+
511
+ def turn_vi(b_in, b_out):
512
+ diff = (b_out - b_in + 360) % 360
513
+ if diff <= 30 or diff >= 330:
514
+ return "đi thẳng"
515
+ elif diff < 150:
516
+ return "rẽ phải"
517
+ elif diff < 210:
518
+ return "quay đầu"
519
+ else:
520
+ return "rẽ trái"
521
+
522
+ def is_congested(pos_row):
523
+ speed = pos_row.get("speed")
524
+ if isinstance(speed, dict):
525
+ normal = speed.get("normal")
526
+ try:
527
+ return normal is not None and float(normal) < 20
528
+ except (ValueError, TypeError):
529
+ pass
530
+ return False
531
+
532
+ def build_tts(path_pts, pos_by_id_map, s_map, total_m):
533
+ if not path_pts:
534
+ return ""
535
+ if len(path_pts) == 1:
536
+ snames = path_pts[0]["street_names"]
537
+ loc = snames[0] if snames else "vị trí không xác định"
538
+ return f"Vị trí gần nhất nằm trên {loc}."
539
+
540
+ # Street shared between each consecutive pair of path nodes
541
+ segment_streets = []
542
+ for i in range(len(path_pts) - 1):
543
+ shared = set(path_pts[i]["streets"]) & set(path_pts[i + 1]["streets"])
544
+ segment_streets.append(list(shared)[0] if shared else None)
545
+
546
+ instructions = []
547
+ current_sid = segment_streets[0]
548
+ start_name = s_map.get(current_sid, "đường không xác định")
549
+ instructions.append(f"Xuất phát từ {start_name}")
550
+
551
+ for i in range(1, len(path_pts)):
552
+ pt = path_pts[i]
553
+ pos_row = pos_by_id_map.get(pt["id"], {})
554
+
555
+ # Congestion warning
556
+ if is_congested(pos_row):
557
+ loc = " - ".join(pt["street_names"]) if pt["street_names"] else "điểm này"
558
+ instructions.append(f"Lưu ý kẹt xe tại {loc}")
559
+
560
+ # Detect street change at this node
561
+ new_sid = segment_streets[i] if i < len(segment_streets) else None
562
+ if new_sid is not None and new_sid != current_sid:
563
+ # Bearings: incoming (i-1 → i) and outgoing (i → i+1)
564
+ p_prev = path_pts[i - 1]
565
+ p_curr = pt
566
+ p_next = path_pts[i + 1] # always valid: i < len(segment_streets) ensures i+1 exists
567
+ b_in = bearing_deg(float(p_prev["lat"]), float(p_prev["lon"]),
568
+ float(p_curr["lat"]), float(p_curr["lon"]))
569
+ b_out = bearing_deg(float(p_curr["lat"]), float(p_curr["lon"]),
570
+ float(p_next["lat"]), float(p_next["lon"]))
571
+ turn = turn_vi(b_in, b_out)
572
+ new_name = s_map.get(new_sid, "đường không xác định")
573
+ inter = " - ".join(pt["street_names"]) if len(pt["street_names"]) > 1 else ""
574
+ if inter:
575
+ instructions.append(f"Tại ngã tư {inter}, {turn} vào {new_name}")
576
+ else:
577
+ instructions.append(f"{turn.capitalize()} vào {new_name}")
578
+ current_sid = new_sid
579
+
580
+ dist_text = (
581
+ f"khoảng {total_m} mét" if total_m < 1000
582
+ else f"khoảng {round(total_m / 1000, 1)} kilômét"
583
+ )
584
+ instructions.append(f"Đã đến nơi. Tổng quãng đường {dist_text}")
585
+ return ". ".join(instructions) + "."
586
+
587
+ try:
588
+ result = supabase.table("Positions").select("*").execute()
589
+ all_positions = result.data or []
590
+ if not all_positions:
591
+ return {"data": [], "label": "Không có điểm nào trong CSDL", "tts": "", "warning": "", "message": ""}
592
+
593
+ streets_result = supabase.table("Streets").select("id, name").execute()
594
+ street_map = {s["id"]: s["name"] for s in (streets_result.data or [])}
595
+
596
+ pos_by_id = {}
597
+ for p in all_positions:
598
+ if p.get("lat") is not None and p.get("long") is not None:
599
+ pos_by_id[p["id"]] = p
600
+
601
+ def find_nearest_id(qlat, qlon):
602
+ best_id, best_d = None, float("inf")
603
+ for pid, pos in pos_by_id.items():
604
+ d = haversine_m(qlat, qlon, float(pos["lat"]), float(pos["long"]))
605
+ if d < best_d:
606
+ best_d, best_id = d, pid
607
+ return best_id, best_d
608
+
609
+ def make_result_point(pos):
610
+ street_ids = pos.get("streets") or []
611
+ return {
612
+ "id": pos["id"],
613
+ "lat": pos["lat"],
614
+ "lon": pos["long"],
615
+ "streets": street_ids,
616
+ "street_names": [street_map.get(sid, str(sid)) for sid in street_ids],
617
+ }
618
+
619
+ def dist_label(m):
620
+ return f"~{m}m" if m < 1000 else f"~{round(m / 1000, 1)}km"
621
+
622
+ # ── Single-point mode: return nearest ────────────────────────────
623
+ if len(points) < 2:
624
+ qlat, qlon = float(points[0]["lat"]), float(points[0]["lon"])
625
+ nid, dist_m = find_nearest_id(qlat, qlon)
626
+ if nid is None:
627
+ return {"data": [], "label": "Không tìm thấy", "tts": "", "warning": "", "message": ""}
628
+ pt = make_result_point(pos_by_id[nid])
629
+ sname = pt["street_names"][0] if pt["street_names"] else "vị trí không xác định"
630
+ return {
631
+ "data": [pt],
632
+ "label": f"Điểm gần nhất: {sname} ({dist_label(round(dist_m))})",
633
+ "tts": f"Vị trí gần nhất nằm trên {sname}, cách bạn khoảng {round(dist_m)} mét.",
634
+ "warning": "",
635
+ "message": f"Khoảng cách: ~{round(dist_m)}m",
636
+ }
637
+
638
+ # ── Two-point mode: shortest path via Dijkstra ───────────────────
639
+ q1, q2 = points[0], points[1]
640
+ start_id, d_start = find_nearest_id(float(q1["lat"]), float(q1["lon"]))
641
+ end_id, d_end = find_nearest_id(float(q2["lat"]), float(q2["lon"]))
642
+
643
+ if start_id is None or end_id is None:
644
+ return {"data": [], "label": "Không tìm thấy", "tts": "", "warning": "Không có điểm nào gần query", "message": ""}
645
+
646
+ if start_id == end_id:
647
+ pt = make_result_point(pos_by_id[start_id])
648
+ sname = pt["street_names"][0] if pt["street_names"] else "vị trí này"
649
+ return {
650
+ "data": [pt],
651
+ "label": f"Điểm xuất phát và đích trùng nhau tại {sname}",
652
+ "tts": f"Điểm xuất phát và điểm đích đều nằm tại {sname}.",
653
+ "warning": "",
654
+ "message": "",
655
+ }
656
+
657
+ # Build adjacency: positions adjacent if they share a street
658
+ street_to_pids: dict = {}
659
+ for pid, pos in pos_by_id.items():
660
+ for sid in (pos.get("streets") or []):
661
+ street_to_pids.setdefault(sid, set()).add(pid)
662
+
663
+ adjacency: dict = {pid: {} for pid in pos_by_id}
664
+ for sid, pids in street_to_pids.items():
665
+ pid_list = list(pids)
666
+ for i, pid1 in enumerate(pid_list):
667
+ p1 = pos_by_id[pid1]
668
+ for pid2 in pid_list[i + 1:]:
669
+ p2 = pos_by_id[pid2]
670
+ d = haversine_m(float(p1["lat"]), float(p1["long"]),
671
+ float(p2["lat"]), float(p2["long"]))
672
+ if pid2 not in adjacency[pid1] or adjacency[pid1][pid2] > d:
673
+ adjacency[pid1][pid2] = d
674
+ adjacency[pid2][pid1] = d
675
+
676
+ # Dijkstra
677
+ dist = {pid: float("inf") for pid in pos_by_id}
678
+ prev = {pid: None for pid in pos_by_id}
679
+ dist[start_id] = 0
680
+ heap = [(0.0, start_id)]
681
+
682
+ while heap:
683
+ d, u = heapq.heappop(heap)
684
+ if d > dist[u]:
685
+ continue
686
+ if u == end_id:
687
+ break
688
+ for v, w in adjacency[u].items():
689
+ nd = dist[u] + w
690
+ if nd < dist[v]:
691
+ dist[v] = nd
692
+ prev[v] = u
693
+ heapq.heappush(heap, (nd, v))
694
+
695
+ if dist[end_id] == float("inf"):
696
+ return {
697
+ "data": [],
698
+ "label": "Không tìm được đường",
699
+ "tts": "Không tìm được đường đi giữa hai điểm này.",
700
+ "warning": "Hai điểm không có đường nối trong đồ thị",
701
+ "message": "",
702
+ }
703
+
704
+ # Reconstruct path
705
+ path_ids = []
706
+ cur = end_id
707
+ while cur is not None:
708
+ path_ids.append(cur)
709
+ cur = prev[cur]
710
+ path_ids.reverse()
711
+
712
+ total_m = round(dist[end_id])
713
+ data = [make_result_point(pos_by_id[pid]) for pid in path_ids]
714
+
715
+ start_name = data[0]["street_names"][0] if data[0]["street_names"] else "?"
716
+ end_name = data[-1]["street_names"][0] if data[-1]["street_names"] else "?"
717
+ label = f"Từ {start_name} đến {end_name}, {dist_label(total_m)}"
718
+ tts = build_tts(data, pos_by_id, street_map, total_m)
719
+
720
+ return {
721
+ "data": data,
722
+ "label": label,
723
+ "tts": tts,
724
+ "tts_url": "",
725
+ "warning": "",
726
+ "message": f"Tổng: {dist_label(total_m)} | Snap start: ~{round(d_start)}m | Snap end: ~{round(d_end)}m",
727
+ }
728
+
729
+ except Exception as e:
730
+ raise Exception(f"Search nearest failed: {str(e)}")
731
+
732
+ @staticmethod
733
+ async def delete_position_cascade(position_id: int) -> bool:
734
+ """Delete a position and remove its id from any Streets.positions arrays listed in position.streets"""
735
+ try:
736
+ # fetch position to know related streets
737
+ pos = None
738
+ result = supabase.table("Positions").select("*").eq("id", position_id).limit(1).execute()
739
+ if result.data:
740
+ pos = result.data[0]
741
+ # remove this position from Streets.positions JSON arrays if present
742
+ if pos and 'streets' in pos and pos['streets']:
743
+ for sid in pos['streets']:
744
+ try:
745
+ street_row = supabase.table("Streets").select("positions").eq("id", sid).limit(1).execute()
746
+ if street_row.data:
747
+ existing = list(street_row.data[0].get('positions') or [])
748
+ if position_id in existing:
749
+ existing = [i for i in existing if i != position_id]
750
+ supabase.table("Streets").update({"positions": existing}).eq("id", sid).execute()
751
+ except Exception:
752
+ pass
753
+ # delete the position
754
+ supabase.table("Positions").delete().eq("id", position_id).execute()
755
+
756
+ return True
757
+ except Exception as e:
758
+ raise Exception(f"Failed to delete position cascade: {str(e)}")
backend/table1.sql ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- WARNING: This schema is for context only and is not meant to be run.
2
+ -- Table order and constraints may not be valid for execution.
3
+
4
+
5
+ CREATE TABLE public.Cities (
6
+ id smallint GENERATED ALWAYS AS IDENTITY NOT NULL,
7
+ name character varying,
8
+ created_at timestamp with time zone NOT NULL DEFAULT now(),
9
+ CONSTRAINT Cities_pkey PRIMARY KEY (id)
10
+ );
11
+ CREATE TABLE public.Districts (
12
+ id integer GENERATED ALWAYS AS IDENTITY NOT NULL,
13
+ name character varying,
14
+ lname character varying,
15
+ city smallint,
16
+ created_at timestamp with time zone NOT NULL DEFAULT now(),
17
+ CONSTRAINT Districts_pkey PRIMARY KEY (id),
18
+ CONSTRAINT Districts_city_fkey FOREIGN KEY (city) REFERENCES public.Cities(id)
19
+ );
20
+
21
+ CREATE TABLE public.Positions (
22
+ id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
23
+ long real,
24
+ lat real,
25
+ type character varying,
26
+ speed json,
27
+ light smallint,
28
+ created_at timestamp with time zone NOT NULL DEFAULT now(),
29
+ CONSTRAINT Positions_pkey PRIMARY KEY (id)
30
+ );
31
+ CREATE TABLE public.StreetPosition (
32
+ id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
33
+ pos_id bigint,
34
+ street_id bigint,
35
+ created_at timestamp with time zone NOT NULL DEFAULT now(),
36
+ CONSTRAINT StreetPosition_pkey PRIMARY KEY (id)
37
+ );
38
+ CREATE TABLE public.Streets (
39
+ id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
40
+ name character varying,
41
+ type character varying,
42
+ district_id integer,
43
+ city_id smallint,
44
+ ban_id smallint,
45
+ parking_id smallint,
46
+ length smallint,
47
+ direction smallint,
48
+ speed smallint,
49
+ lane_1 character varying,
50
+ lane_2 character varying,
51
+ lane_n smallint,
52
+ toll smallint,
53
+ flooding smallint,
54
+ created_at timestamp with time zone NOT NULL DEFAULT now(),
55
+ CONSTRAINT Streets_pkey PRIMARY KEY (id),
56
+ CONSTRAINT Streets_city_id_fkey FOREIGN KEY (city_id) REFERENCES public.Cities(id),
57
+ CONSTRAINT Streets_district_id_fkey FOREIGN KEY (district_id) REFERENCES public.Districts(id)
58
+ );