Spaces:
Running
Running
deploy: initial backend API to Tataaicoltd/api
Browse files- Dockerfile +15 -0
- backend/config.py +18 -0
- backend/main.py +505 -0
- backend/models.py +167 -0
- backend/requirements.txt +7 -0
- backend/services/__init__.py +1 -0
- backend/services/category_service.py +62 -0
- backend/services/intent_service.py +121 -0
- backend/services/maps_service.py +758 -0
- backend/table1.sql +58 -0
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 |
+
);
|