vistas de ciudadano, escalar animaciones de mascota, implementacion de chatbot para concientizacion, modificacion de datos de ciudadano, modificacion de vista principal

This commit is contained in:
shinra32
2026-05-23 05:03:05 -06:00
parent 89dcc6250b
commit ca076607c7
39 changed files with 2909 additions and 560 deletions

73
backend/app/api/chat.py Normal file
View File

@@ -0,0 +1,73 @@
"""Chatbot de ayuda y FAQ — usa OpenAI desde el backend."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from app.core.config import settings
from app.core.deps import get_current_user
router = APIRouter(prefix="/chat", tags=["chat"])
SYSTEM_PROMPT = (
"Eres Eco, el asistente oficial de la app Recolecta, dedicada a la recolección "
"inteligente y privada de residuos sólidos urbanos. Tu objetivo es responder "
"preguntas frecuentes de los ciudadanos sobre: horarios y rutas de los camiones, "
"separación correcta de residuos (orgánico, inorgánico reciclable, sanitario, "
"RAEE, manejo especial), qué hacer si el camión no pasó, cómo reportar un "
"problema y cómo usar la app. "
"Reglas: 1) responde siempre en español neutro y de manera breve (máximo 4 "
"oraciones); 2) si te preguntan algo fuera de este dominio, redirige amablemente "
"al tema; 3) nunca inventes horarios ni rutas específicas — si el usuario pide "
"datos concretos de su colonia, indícale que los puede ver en la pantalla de mapa "
"o ETA dentro de la app; 4) jamás compartas información personal de choferes."
)
class ChatMessage(BaseModel):
role: str = Field(pattern=r"^(user|assistant)$")
content: str = Field(min_length=1, max_length=2000)
class ChatHelpIn(BaseModel):
messages: list[ChatMessage] = Field(min_length=1, max_length=20)
class ChatHelpOut(BaseModel):
reply: str
@router.post("/help", response_model=ChatHelpOut)
def chat_help(
body: ChatHelpIn,
_user: dict = Depends(get_current_user),
):
if not settings.OPENAI_API_KEY:
raise HTTPException(
status_code=503,
detail="El asistente no está configurado (falta OPENAI_API_KEY).",
)
try:
from openai import OpenAI
except ImportError:
raise HTTPException(
status_code=500, detail="Librería openai no instalada en el servidor."
)
client = OpenAI(api_key=settings.OPENAI_API_KEY)
payload = [{"role": "system", "content": SYSTEM_PROMPT}] + [
m.model_dump() for m in body.messages
]
try:
completion = client.chat.completions.create(
model=settings.OPENAI_MODEL,
messages=payload,
temperature=0.4,
max_tokens=400,
)
reply = completion.choices[0].message.content or ""
except Exception as e:
raise HTTPException(status_code=502, detail=f"Error con OpenAI: {e}")
return ChatHelpOut(reply=reply.strip())

View File

@@ -0,0 +1,132 @@
"""Reportes ciudadanos sobre unidades (incidents)."""
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from app.core.deps import get_current_user
from app.core.supabase_client import supabase_admin
from app.schemas.incidents import (
IncidentOut,
UnitPublic,
)
router = APIRouter(prefix="/incidents", tags=["incidents"])
BUCKET = "incident-photos"
VALID_CATEGORIES = {"derrame", "dano_propiedad", "conducta", "no_recoleccion", "otro"}
@router.get("/units", response_model=list[UnitPublic])
def list_units(_user: dict = Depends(get_current_user)):
"""Lista unidades activas para que el ciudadano elija al reportar."""
try:
res = (
supabase_admin.table("units")
.select("id, plate, status")
.eq("status", "active")
.order("id")
.execute()
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error al listar unidades: {e}")
return [UnitPublic(**r) for r in (res.data or [])]
def _upload_photo(user_id: str, photo: UploadFile) -> str:
ext = (photo.filename or "").rsplit(".", 1)[-1].lower() or "jpg"
if ext not in {"jpg", "jpeg", "png", "webp"}:
ext = "jpg"
path = f"{user_id}/{uuid.uuid4().hex}.{ext}"
content = photo.file.read()
content_type = photo.content_type or f"image/{ext}"
try:
supabase_admin.storage.from_(BUCKET).upload(
path=path,
file=content,
file_options={"content-type": content_type, "upsert": "false"},
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error al subir foto: {e}")
try:
public = supabase_admin.storage.from_(BUCKET).get_public_url(path)
return public if isinstance(public, str) else str(public)
except Exception:
return path
@router.post("", response_model=IncidentOut, status_code=201)
async def create_incident(
category: str = Form(...),
description: str = Form(...),
unit_id: Optional[int] = Form(None),
photo: Optional[UploadFile] = File(None),
current_user: dict = Depends(get_current_user),
):
if category not in VALID_CATEGORIES:
raise HTTPException(status_code=400, detail="Categoría inválida")
if len(description.strip()) < 3:
raise HTTPException(status_code=400, detail="Descripción demasiado corta")
user_id = current_user["user_id"]
photo_url: Optional[str] = None
if photo is not None and photo.filename:
photo_url = _upload_photo(user_id, photo)
payload = {
"user_id": user_id,
"unit_id": unit_id,
"category": category,
"description": description.strip(),
"photo_url": photo_url,
}
try:
res = supabase_admin.table("incidents").insert(payload).execute()
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error al guardar reporte: {e}")
row = (res.data or [{}])[0]
return IncidentOut(
id=row.get("id"),
user_id=row.get("user_id"),
unit_id=row.get("unit_id"),
category=row.get("category"),
description=row.get("description"),
photo_url=row.get("photo_url"),
status=row.get("status") or "open",
created_at=row.get("created_at") and str(row["created_at"]),
)
@router.get("/me", response_model=list[IncidentOut])
def my_incidents(current_user: dict = Depends(get_current_user)):
try:
res = (
supabase_admin.table("incidents")
.select("*")
.eq("user_id", current_user["user_id"])
.order("created_at", desc=True)
.execute()
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error al listar reportes: {e}")
out: list[IncidentOut] = []
for row in res.data or []:
out.append(
IncidentOut(
id=row.get("id"),
user_id=row.get("user_id"),
unit_id=row.get("unit_id"),
category=row.get("category"),
description=row.get("description"),
photo_url=row.get("photo_url"),
status=row.get("status") or "open",
created_at=row.get("created_at") and str(row["created_at"]),
)
)
return out

View File

@@ -1,43 +1,126 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, EmailStr
from fastapi import APIRouter, Depends, HTTPException, status
from app.core.deps import get_current_user
from app.core.supabase_client import supabase_admin
from gotrue.types import User
from app.core.supabase_client import supabase, supabase_admin
from app.schemas.users import UserMe, UserUpdateMe, ChangePasswordIn
router = APIRouter(prefix="/users", tags=["users"])
class UserUpdate(BaseModel):
name: str | None = None
email: EmailStr | None = None
@router.get("/me", response_model=UserMe)
def get_me(current_user: dict = Depends(get_current_user)):
"""Devuelve el perfil del usuario autenticado (datos públicos + email/phone de auth)."""
user_id = current_user["user_id"]
try:
public_row = (
supabase_admin.table("users")
.select("id, name, role, created_at")
.eq("id", user_id)
.maybe_single()
.execute()
)
public_data = public_row.data or {}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error al leer perfil: {e}")
email = current_user.get("email")
phone = None
try:
resp = supabase_admin.auth.admin.get_user_by_id(user_id)
auth_user = getattr(resp, "user", None) or resp
email = getattr(auth_user, "email", email)
phone = getattr(auth_user, "phone", None)
except Exception:
pass
return UserMe(
id=user_id,
email=email,
phone=phone,
name=public_data.get("name"),
role=public_data.get("role") or current_user["role"],
created_at=(public_data.get("created_at") and str(public_data["created_at"]))
or None,
)
@router.patch("/me", status_code=204)
def update_user_profile(
update_data: UserUpdate,
current_user: User = Depends(get_current_user),
def update_me(
body: UserUpdateMe,
current_user: dict = Depends(get_current_user),
):
"""
Actualiza el perfil del usuario autenticado (nombre, email).
"""
user_id = current_user.id
update_payload = {}
"""Actualiza nombre / email / teléfono del usuario autenticado."""
user_id = current_user["user_id"]
if update_data.name:
# El nombre no está en Supabase Auth, sino en nuestra tabla `users`
if body.name is not None:
try:
supabase_admin.table("users").update({"name": update_data.name}).eq(
supabase_admin.table("users").update({"name": body.name}).eq(
"id", user_id
).execute()
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error al actualizar el nombre: {e}")
raise HTTPException(status_code=500, detail=f"Error al actualizar nombre: {e}")
if update_data.email:
# El email sí está en Supabase Auth
auth_payload: dict = {}
if body.email is not None:
auth_payload["email"] = str(body.email)
if body.phone is not None:
auth_payload["phone"] = body.phone
if auth_payload:
try:
supabase_admin.auth.admin.update_user_by_id(
user_id, {"email": update_data.email}
)
supabase_admin.auth.admin.update_user_by_id(user_id, auth_payload)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error al actualizar el email: {e}")
raise HTTPException(
status_code=500, detail=f"Error al actualizar credenciales: {e}"
)
mirror: dict = {}
if "email" in auth_payload:
mirror["email"] = auth_payload["email"]
if "phone" in auth_payload:
mirror["phone"] = auth_payload["phone"]
if mirror:
try:
supabase_admin.table("users").update(mirror).eq("id", user_id).execute()
except Exception:
pass
return
@router.post("/me/change-password", status_code=204)
def change_password(
body: ChangePasswordIn,
current_user: dict = Depends(get_current_user),
):
"""Cambia la contraseña verificando primero la actual con signInWithPassword."""
if body.current_password == body.new_password:
raise HTTPException(
status_code=400, detail="La nueva contraseña debe ser distinta de la actual"
)
email = current_user.get("email")
if not email:
raise HTTPException(status_code=400, detail="Usuario sin email asociado")
try:
supabase.auth.sign_in_with_password(
{"email": email, "password": body.current_password}
)
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Contraseña actual incorrecta",
)
try:
supabase_admin.auth.admin.update_user_by_id(
current_user["user_id"], {"password": body.new_password}
)
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Error al actualizar contraseña: {e}"
)
return

View File

@@ -12,6 +12,9 @@ class Settings(BaseSettings):
FIREBASE_CREDENTIALS_PATH: str = "app/data/secrets/recoleccion-app-firebase-adminsdk-fbsvc-3da79d2a4c.json"
SIMULATION_TICK_SECONDS: int = 10
OPENAI_API_KEY: str | None = None
OPENAI_MODEL: str = "gpt-4o-mini"
@lru_cache
def get_settings() -> Settings:

View File

@@ -137,6 +137,29 @@ CREATE POLICY "feedback_insert" ON public.feedback
FOR INSERT WITH CHECK (auth.uid() = user_id);
-- ------------------------------------------------------------
-- 5b. Tabla public.incidents (reportes ciudadanos)
-- ------------------------------------------------------------
ALTER TABLE public.incidents ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "incidents_select" ON public.incidents;
CREATE POLICY "incidents_select" ON public.incidents
FOR SELECT USING (
auth.uid() = user_id
OR (SELECT role FROM public.users WHERE id = auth.uid()) = 'admin'
);
DROP POLICY IF EXISTS "incidents_insert" ON public.incidents;
CREATE POLICY "incidents_insert" ON public.incidents
FOR INSERT WITH CHECK (auth.uid() = user_id);
DROP POLICY IF EXISTS "incidents_update_admin" ON public.incidents;
CREATE POLICY "incidents_update_admin" ON public.incidents
FOR UPDATE USING (
(SELECT role FROM public.users WHERE id = auth.uid()) = 'admin'
);
-- ------------------------------------------------------------
-- 6. Índices útiles para rendimiento
-- ------------------------------------------------------------

View File

@@ -91,3 +91,22 @@ CREATE INDEX IF NOT EXISTS idx_route_positions_route ON public.route_positions(r
CREATE INDEX IF NOT EXISTS idx_colonias_route ON public.colonias(route_id);
CREATE INDEX IF NOT EXISTS idx_drivers_user ON public.drivers(user_id);
CREATE INDEX IF NOT EXISTS idx_collection_route ON public.collection_events(route_id);
-- ------------------------------------------------------------
-- 8. INCIDENTS (reportes de ciudadanos sobre unidades)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.incidents (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
unit_id INT REFERENCES public.units(id),
category TEXT NOT NULL
CHECK (category IN ('derrame','dano_propiedad','conducta','no_recoleccion','otro')),
description TEXT NOT NULL,
photo_url TEXT,
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open','in_review','resolved')),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_incidents_user ON public.incidents(user_id);
CREATE INDEX IF NOT EXISTS idx_incidents_unit ON public.incidents(unit_id);

View File

@@ -0,0 +1,32 @@
from typing import Literal, Optional
from pydantic import BaseModel, Field
IncidentCategory = Literal[
"derrame", "dano_propiedad", "conducta", "no_recoleccion", "otro"
]
IncidentStatus = Literal["open", "in_review", "resolved"]
class UnitPublic(BaseModel):
id: int
plate: Optional[str] = None
status: Optional[str] = None
class IncidentOut(BaseModel):
id: int
user_id: str
unit_id: Optional[int] = None
category: IncidentCategory
description: str
photo_url: Optional[str] = None
status: IncidentStatus
created_at: Optional[str] = None
class IncidentCreate(BaseModel):
"""Payload usado cuando NO se sube foto (JSON)."""
unit_id: Optional[int] = None
category: IncidentCategory
description: str = Field(min_length=3, max_length=1000)

View File

@@ -0,0 +1,21 @@
from pydantic import BaseModel, EmailStr, Field
class UserMe(BaseModel):
id: str
email: str | None = None
phone: str | None = None
name: str | None = None
role: str
created_at: str | None = None
class UserUpdateMe(BaseModel):
name: str | None = None
email: EmailStr | None = None
phone: str | None = None
class ChangePasswordIn(BaseModel):
current_password: str = Field(min_length=6)
new_password: str = Field(min_length=6)