Co-authored-by: eddgranados12 <eddgranados12@users.noreply.github.com>
Co-authored-by: MENDOZA BALLARDO GAEL RICARDO <gael-meb123@users.noreply.github.com> Co-authored-by: Azareth-Tr <Azareth-Tr@users.noreply.github.com> modificacion de las vistas principales para el usuario ciudadano, primer avance para el panel admin
This commit is contained in:
@@ -29,18 +29,22 @@ def create_address(
|
||||
"""Alta de domicilio. El routeId se deriva automáticamente de la colonia elegida."""
|
||||
route_id = _resolve_route_id(body.colonia)
|
||||
|
||||
insert_data: dict = {
|
||||
"user_id": current_user["user_id"],
|
||||
"label": body.label,
|
||||
"calle": body.calle,
|
||||
"colonia": body.colonia,
|
||||
"route_id": route_id,
|
||||
"verified": False,
|
||||
}
|
||||
if body.lat is not None:
|
||||
insert_data["lat"] = body.lat
|
||||
if body.lng is not None:
|
||||
insert_data["lng"] = body.lng
|
||||
|
||||
result = (
|
||||
supabase_admin.table("addresses")
|
||||
.insert(
|
||||
{
|
||||
"user_id": current_user["user_id"],
|
||||
"label": body.label,
|
||||
"calle": body.calle,
|
||||
"colonia": body.colonia,
|
||||
"route_id": route_id,
|
||||
"verified": False,
|
||||
}
|
||||
)
|
||||
.insert(insert_data)
|
||||
.execute()
|
||||
)
|
||||
|
||||
|
||||
@@ -16,12 +16,34 @@ def _fetch_role(user_id: str) -> str:
|
||||
return result.data["role"] if result.data else "citizen"
|
||||
|
||||
|
||||
def _fetch_route_for_citizen(user_id: str) -> str | None:
|
||||
"""
|
||||
Busca la primera dirección verificada del ciudadano y devuelve su `route_id`.
|
||||
Devuelve None si no hay dirección verificada.
|
||||
"""
|
||||
try:
|
||||
res = (
|
||||
supabase_admin.table("addresses")
|
||||
.select("route_id")
|
||||
.eq("user_id", user_id)
|
||||
.eq("verified", True)
|
||||
.limit(1)
|
||||
.maybe_single()
|
||||
.execute()
|
||||
)
|
||||
if res.data and isinstance(res.data, dict):
|
||||
return res.data.get("route_id")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
def register(body: RegisterRequest):
|
||||
"""
|
||||
Registro por email o teléfono.
|
||||
- Email: flujo estándar Supabase email+password.
|
||||
- Teléfono: requiere que Supabase tenga configurado un proveedor SMS (Twilio).
|
||||
Registro por email o teléfono. Usa el admin client para confirmar automáticamente
|
||||
sin requerir que el usuario verifique su correo.
|
||||
"""
|
||||
if not body.email and not body.phone:
|
||||
raise HTTPException(status_code=400, detail="Se requiere email o teléfono")
|
||||
@@ -29,47 +51,83 @@ def register(body: RegisterRequest):
|
||||
if len(body.password) < 6:
|
||||
raise HTTPException(status_code=400, detail="La contraseña debe tener al menos 6 caracteres.")
|
||||
|
||||
# Crear usuario con confirmación automática vía service_role (bypasea email confirmation)
|
||||
try:
|
||||
create_attrs: dict = {"password": body.password}
|
||||
if body.email:
|
||||
resp = supabase.auth.sign_up({"email": body.email, "password": body.password})
|
||||
create_attrs["email"] = body.email
|
||||
create_attrs["email_confirm"] = True
|
||||
else:
|
||||
resp = supabase.auth.sign_up({"phone": body.phone, "password": body.password})
|
||||
create_attrs["phone"] = body.phone
|
||||
create_attrs["phone_confirm"] = True
|
||||
|
||||
admin_resp = supabase_admin.auth.admin.create_user(create_attrs)
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "already registered" in error_msg.lower() or "user already exists" in error_msg.lower():
|
||||
raise HTTPException(status_code=400, detail="El usuario ya está registrado en el sistema de autenticación.")
|
||||
if "already registered" in error_msg.lower() or "user already exists" in error_msg.lower() or "already been registered" in error_msg.lower():
|
||||
raise HTTPException(status_code=400, detail="El usuario ya está registrado.")
|
||||
if "signups are disabled" in error_msg.lower():
|
||||
raise HTTPException(status_code=400, detail="El registro de nuevos usuarios está deshabilitado temporalmente.")
|
||||
if "rate limit" in error_msg.lower():
|
||||
raise HTTPException(status_code=400, detail="Límite de registros excedido por seguridad. Desactiva la confirmación de correos en Supabase o intenta más tarde.")
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
|
||||
auth_user = resp.user
|
||||
auth_user = admin_resp.user
|
||||
if not auth_user:
|
||||
raise HTTPException(status_code=400, detail="No se pudo crear el usuario en Supabase Auth")
|
||||
|
||||
# Crear entrada en public.users con el rol elegido
|
||||
try:
|
||||
supabase_admin.table("users").upsert(
|
||||
{
|
||||
"id": str(auth_user.id),
|
||||
"role": body.role,
|
||||
}
|
||||
{"id": str(auth_user.id), "role": body.role}
|
||||
).execute()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error al guardar el usuario: {e}")
|
||||
|
||||
# Si no hubo sesión (email confirmation pendiente)
|
||||
if not resp.session:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cuenta creada. Revisa tu correo para confirmar tu email antes de iniciar sesión.",
|
||||
)
|
||||
# Iniciar sesión para obtener el JWT
|
||||
try:
|
||||
if body.email:
|
||||
session_resp = supabase.auth.sign_in_with_password(
|
||||
{"email": body.email, "password": body.password}
|
||||
)
|
||||
else:
|
||||
session_resp = supabase.auth.sign_in_with_password(
|
||||
{"phone": body.phone, "password": body.password}
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Usuario creado pero no se pudo iniciar sesión: {e}")
|
||||
|
||||
# Guardar dirección inicial si viene en el payload (evita un segundo HTTP call desde Flutter)
|
||||
saved_route_id: str | None = None
|
||||
if body.address_calle and body.address_colonia:
|
||||
try:
|
||||
from app.services.simulation import get_colonias
|
||||
mapping = get_colonias()
|
||||
match = next(
|
||||
(c for c in mapping if c.get("colonia", "").lower() == body.address_colonia.lower()),
|
||||
None,
|
||||
)
|
||||
if match:
|
||||
addr_data: dict = {
|
||||
"user_id": str(auth_user.id),
|
||||
"label": body.address_label or "Mi Casa",
|
||||
"calle": body.address_calle,
|
||||
"colonia": body.address_colonia,
|
||||
"route_id": match["routeId"],
|
||||
"verified": False,
|
||||
}
|
||||
if body.address_lat is not None:
|
||||
addr_data["lat"] = body.address_lat
|
||||
if body.address_lng is not None:
|
||||
addr_data["lng"] = body.address_lng
|
||||
supabase_admin.table("addresses").insert(addr_data).execute()
|
||||
saved_route_id = match["routeId"]
|
||||
except Exception as e:
|
||||
print(f"[register] No se pudo guardar la dirección inicial: {e}")
|
||||
|
||||
return TokenResponse(
|
||||
access_token=resp.session.access_token,
|
||||
access_token=session_resp.session.access_token,
|
||||
user_id=str(auth_user.id),
|
||||
role=body.role,
|
||||
route_id=saved_route_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -92,10 +150,16 @@ def login(body: LoginRequest):
|
||||
raise HTTPException(status_code=401, detail="Credenciales inválidas")
|
||||
|
||||
auth_user = resp.user
|
||||
role = _fetch_role(str(auth_user.id))
|
||||
user_id = str(auth_user.id)
|
||||
role = _fetch_role(user_id)
|
||||
|
||||
route_id = None
|
||||
if role == 'citizen':
|
||||
route_id = _fetch_route_for_citizen(user_id)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=resp.session.access_token,
|
||||
user_id=str(auth_user.id),
|
||||
user_id=user_id,
|
||||
role=role,
|
||||
route_id=route_id,
|
||||
)
|
||||
|
||||
43
backend/app/api/users.py
Normal file
43
backend/app/api/users.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from app.core.deps import get_current_user
|
||||
from app.core.supabase_client import supabase_admin
|
||||
from gotrue.types import User
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
email: EmailStr | None = None
|
||||
|
||||
@router.patch("/me", status_code=204)
|
||||
def update_user_profile(
|
||||
update_data: UserUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Actualiza el perfil del usuario autenticado (nombre, email).
|
||||
"""
|
||||
user_id = current_user.id
|
||||
update_payload = {}
|
||||
|
||||
if update_data.name:
|
||||
# El nombre no está en Supabase Auth, sino en nuestra tabla `users`
|
||||
try:
|
||||
supabase_admin.table("users").update({"name": update_data.name}).eq(
|
||||
"id", user_id
|
||||
).execute()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error al actualizar el nombre: {e}")
|
||||
|
||||
if update_data.email:
|
||||
# El email sí está en Supabase Auth
|
||||
try:
|
||||
supabase_admin.auth.admin.update_user_by_id(
|
||||
user_id, {"email": update_data.email}
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error al actualizar el email: {e}")
|
||||
|
||||
return
|
||||
|
||||
@@ -6,6 +6,8 @@ class AddressCreate(BaseModel):
|
||||
label: str
|
||||
calle: str
|
||||
colonia: str # el backend deriva route_id a partir de colonias-rutas.json
|
||||
lat: Optional[float] = None
|
||||
lng: Optional[float] = None
|
||||
|
||||
|
||||
class AddressResponse(BaseModel):
|
||||
@@ -19,3 +21,5 @@ class AddressResponse(BaseModel):
|
||||
verified_method: Optional[str] = None
|
||||
verified_at: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
lat: Optional[float] = None
|
||||
lng: Optional[float] = None
|
||||
|
||||
@@ -7,6 +7,12 @@ class RegisterRequest(BaseModel):
|
||||
phone: Optional[str] = None
|
||||
password: str
|
||||
role: Literal["citizen", "driver", "admin"] = "citizen"
|
||||
# Dirección inicial (opcional, se guarda en el mismo request para evitar un segundo HTTP call)
|
||||
address_label: Optional[str] = None
|
||||
address_calle: Optional[str] = None
|
||||
address_colonia: Optional[str] = None
|
||||
address_lat: Optional[float] = None
|
||||
address_lng: Optional[float] = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -20,3 +26,6 @@ class TokenResponse(BaseModel):
|
||||
token_type: str = "bearer"
|
||||
user_id: str
|
||||
role: str
|
||||
# route_id se incluye opcionalmente para ciudadanos; permite al cliente
|
||||
# suscribirse al topic correcto inmediatamente después del login.
|
||||
route_id: Optional[str] = None
|
||||
|
||||
@@ -8,7 +8,7 @@ from app.api.eta import router as eta_router
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.addresses import router as addresses_router
|
||||
from app.api.colonias import router as colonias_router
|
||||
from app.api.simulation import router as simulation_router
|
||||
from app.api.users import router as users_router
|
||||
from app.services import simulation, notifications
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
@@ -48,8 +48,8 @@ app = FastAPI(
|
||||
# CORS — necesario para el simulador web y el cliente Flutter
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # En producción limitar a dominios reales
|
||||
allow_credentials=True,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False, # Usamos Bearer tokens, no cookies — wildcard compatible
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
@@ -58,17 +58,4 @@ app.include_router(auth_router)
|
||||
app.include_router(addresses_router)
|
||||
app.include_router(eta_router)
|
||||
app.include_router(colonias_router)
|
||||
app.include_router(simulation_router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Backend operativo. Regla innegociable #1: NUNCA se devuelven coordenadas al ciudadano.",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "healthy"}
|
||||
app.include_router(users_router)
|
||||
|
||||
Reference in New Issue
Block a user