Initial commit

This commit is contained in:
marianesaldana
2026-05-23 08:59:34 -06:00
commit 80dbd947e5
36446 changed files with 3729147 additions and 0 deletions

BIN
backend/.DS_Store vendored Normal file

Binary file not shown.

5
backend/.env.example Normal file
View File

@@ -0,0 +1,5 @@
SECRET_KEY=cambia-esta-clave-super-secreta-en-produccion
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=10080
DATABASE_URL=sqlite:///./mi_ruta_limpia.db
FRONTEND_URL=http://localhost:8081

155
backend/DATABASE_README.md Normal file
View File

@@ -0,0 +1,155 @@
# 📦 Restaurar la base de datos en otra computadora
Este directorio contiene un **dump completo de la BD** para que cualquier persona pueda replicar el estado actual de la base de datos en su propia máquina.
---
## 📁 Archivos clave
| Archivo | Tamaño | Descripción |
|---------|--------|-------------|
| `database_dump.sql` | ~209 KB | Dump SQL completo (schema + 1,216 INSERTs) |
| `restore_db.py` | — | Script Python que aplica el dump |
| `mi_ruta_limpia.db` | ~266 KB | (Opcional) Base de datos SQLite ya hidratada |
---
## 🚀 Restaurar en otra máquina (paso a paso)
### Opción 1 (Recomendada): con Python
```bash
# 1. Clona o copia el proyecto en la otra máquina
cd backend
# 2. Activa el venv si no lo has hecho
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# 3. Restaura la BD desde el dump
python restore_db.py
```
Verás:
```
✅ BASE DE DATOS RESTAURADA EXITOSAMENTE
✓ users 202/202
✓ addresses 302/302
✓ reports 280/280
✓ operational_reports 150/150
✓ service_ratings 220/220
✓ trucks 62/62
```
### Opción 2: con `sqlite3` (sin Python)
Si solo tienes `sqlite3` instalado (sin Python):
```bash
cd backend
rm -f mi_ruta_limpia.db
sqlite3 mi_ruta_limpia.db < database_dump.sql
```
### Opción 3 (más rápida): copiar el `.db` directamente
Si simplemente quieres llevarte la BD ya hecha:
```bash
# Solo asegúrate de incluir mi_ruta_limpia.db al copiar/clonar
# SQLite es un solo archivo, portable a cualquier OS
```
---
## ✅ Verificar después de restaurar
Una vez restaurado, arranca el backend:
```bash
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
Y prueba el login con cualquiera de los 3 usuarios demo:
| Rol | Email | Contraseña |
|-----|-------|-----------|
| 🧑 Ciudadano | `demo@celaya.gob.mx` | `Celaya2026` |
| 👮 Empleado | `empleado@celaya.gob.mx` | `Empleado2026` |
| 🛡️ Admin | `admin@celaya.gob.mx` | `Admin2026` |
Test rápido con curl:
```bash
curl -X POST http://localhost:8000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@celaya.gob.mx","password":"Admin2026"}'
```
Debe responder con un token JWT y `"role":"ADMIN"`.
---
## 📊 Contenido de la BD restaurada
| Tabla | Registros |
|-------|----------:|
| `users` | **202** (185 ciudadanos · 14 empleados · 3 admins) |
| `addresses` | **302** (con `route_id` ya asignado por geo-routing) |
| `reports` | **280** reportes ciudadanos con folios reales |
| `operational_reports` | **150** reportes operativos de empleados |
| `service_ratings` | **220** calificaciones 1-5 estrellas |
| `trucks` | **62** camiones (CEL-001 a CEL-062) |
| **TOTAL** | **1,216 registros** |
Los datos cubren ~60 días hacia atrás con timestamps realistas.
---
## 🔄 Regenerar el dump (solo si modificaste la BD original)
Si haces cambios en tu BD local y quieres actualizar el dump:
```bash
cd backend
sqlite3 mi_ruta_limpia.db .dump > database_dump.sql
```
Eso reemplaza el archivo. Después haz commit del nuevo `database_dump.sql`.
---
## 🆘 Troubleshooting
### "no such table" al restaurar
Borra la BD existente y reintenta:
```bash
rm mi_ruta_limpia.db
python restore_db.py
```
### Las contraseñas no funcionan después de restaurar
Las contraseñas están hasheadas con bcrypt. Verifica que tienes la versión correcta:
```bash
pip install 'bcrypt==4.0.1'
```
### El archivo `database_dump.sql` está vacío o corrupto
Pide a tu equipo el original o, si tienes acceso a la BD original:
```bash
sqlite3 mi_ruta_limpia.db .dump > database_dump.sql
wc -l database_dump.sql # Debe ser >1000 líneas
```
---
## 💡 Alternativa: generar datos nuevos (no restaurar)
Si prefieres datos generados aleatoriamente en vez de los exactos del original:
```bash
python seed_massive.py
```
Esto crea cantidades similares pero con seed determinístico (mismo resultado siempre, pero diferente al dump).
> Las credenciales demo son las mismas en ambos métodos.

Binary file not shown.

0
backend/app/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

15
backend/app/config.py Normal file
View File

@@ -0,0 +1,15 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
SECRET_KEY: str = "dev-secret-key-change-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 days
DATABASE_URL: str = "sqlite:///./mi_ruta_limpia.db"
FRONTEND_URL: str = "http://localhost:8081"
class Config:
env_file = ".env"
settings = Settings()

View File

@@ -0,0 +1,17 @@
[
{"routeId":"RUTA-01","name":"Zona Centro - Las Arboledas","truckId":101,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:00:00Z"},{"positionId":2,"lat":20.5185,"lng":-100.8450,"speed":45,"timestamp":"2026-05-22T06:12:00Z"},{"positionId":3,"lat":20.5215,"lng":-100.8142,"speed":22,"timestamp":"2026-05-22T06:25:00Z"},{"positionId":4,"lat":20.5212,"lng":-100.8175,"speed":15,"timestamp":"2026-05-22T06:38:00Z"},{"positionId":5,"lat":20.5210,"lng":-100.8210,"speed":0,"timestamp":"2026-05-22T06:50:00Z"},{"positionId":6,"lat":20.5235,"lng":-100.8212,"speed":18,"timestamp":"2026-05-22T07:05:00Z"},{"positionId":7,"lat":20.5260,"lng":-100.8215,"speed":20,"timestamp":"2026-05-22T07:18:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":40,"timestamp":"2026-05-22T07:40:00Z"}]},
{"routeId":"RUTA-02","name":"Sector Norte - Av. Tecnológico","truckId":102,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:05:00Z"},{"positionId":2,"lat":20.5280,"lng":-100.8135,"speed":38,"timestamp":"2026-05-22T06:18:00Z"},{"positionId":3,"lat":20.5410,"lng":-100.8130,"speed":25,"timestamp":"2026-05-22T06:30:00Z"},{"positionId":4,"lat":20.5445,"lng":-100.8132,"speed":12,"timestamp":"2026-05-22T06:45:00Z"},{"positionId":5,"lat":20.5480,"lng":-100.8135,"speed":0,"timestamp":"2026-05-22T06:58:00Z"},{"positionId":6,"lat":20.5515,"lng":-100.8138,"speed":15,"timestamp":"2026-05-22T07:10:00Z"},{"positionId":7,"lat":20.5540,"lng":-100.8110,"speed":22,"timestamp":"2026-05-22T07:25:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":45,"timestamp":"2026-05-22T07:50:00Z"}]},
{"routeId":"RUTA-03","name":"Sector Poniente - San Juanico","truckId":103,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:10:00Z"},{"positionId":2,"lat":20.5250,"lng":-100.8510,"speed":42,"timestamp":"2026-05-22T06:20:00Z"},{"positionId":3,"lat":20.5290,"lng":-100.8320,"speed":20,"timestamp":"2026-05-22T06:35:00Z"},{"positionId":4,"lat":20.5315,"lng":-100.8355,"speed":15,"timestamp":"2026-05-22T06:48:00Z"},{"positionId":5,"lat":20.5340,"lng":-100.8390,"speed":0,"timestamp":"2026-05-22T07:00:00Z"},{"positionId":6,"lat":20.5362,"lng":-100.8425,"speed":10,"timestamp":"2026-05-22T07:15:00Z"},{"positionId":7,"lat":20.5330,"lng":-100.8430,"speed":18,"timestamp":"2026-05-22T07:28:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":35,"timestamp":"2026-05-22T07:45:00Z"}]},
{"routeId":"RUTA-04","name":"Oriente - Los Olivos","truckId":104,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:15:00Z"},{"positionId":2,"lat":20.5260,"lng":-100.8010,"speed":45,"timestamp":"2026-05-22T06:30:00Z"},{"positionId":3,"lat":20.5295,"lng":-100.7890,"speed":24,"timestamp":"2026-05-22T06:45:00Z"},{"positionId":4,"lat":20.5320,"lng":-100.7850,"speed":12,"timestamp":"2026-05-22T06:58:00Z"},{"positionId":5,"lat":20.5350,"lng":-100.7790,"speed":0,"timestamp":"2026-05-22T07:12:00Z"},{"positionId":6,"lat":20.5310,"lng":-100.7760,"speed":15,"timestamp":"2026-05-22T07:25:00Z"},{"positionId":7,"lat":20.5270,"lng":-100.7820,"speed":26,"timestamp":"2026-05-22T07:38:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":48,"timestamp":"2026-05-22T07:58:00Z"}]},
{"routeId":"RUTA-05","name":"Sector Sur - Rancho Seco","truckId":105,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:20:00Z"},{"positionId":2,"lat":20.5050,"lng":-100.8620,"speed":35,"timestamp":"2026-05-22T06:32:00Z"},{"positionId":3,"lat":20.5020,"lng":-100.8350,"speed":22,"timestamp":"2026-05-22T06:45:00Z"},{"positionId":4,"lat":20.4995,"lng":-100.8210,"speed":14,"timestamp":"2026-05-22T06:58:00Z"},{"positionId":5,"lat":20.4970,"lng":-100.8150,"speed":0,"timestamp":"2026-05-22T07:10:00Z"},{"positionId":6,"lat":20.5010,"lng":-100.8120,"speed":16,"timestamp":"2026-05-22T07:22:00Z"},{"positionId":7,"lat":20.5060,"lng":-100.8160,"speed":25,"timestamp":"2026-05-22T07:35:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":40,"timestamp":"2026-05-22T07:55:00Z"}]},
{"routeId":"RUTA-06","name":"Norte Extremo - Rumbos de Roque","truckId":106,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:00:00Z"},{"positionId":2,"lat":20.5380,"lng":-100.8380,"speed":40,"timestamp":"2026-05-22T06:15:00Z"},{"positionId":3,"lat":20.5610,"lng":-100.8370,"speed":30,"timestamp":"2026-05-22T06:30:00Z"},{"positionId":4,"lat":20.5750,"lng":-100.8360,"speed":15,"timestamp":"2026-05-22T06:45:00Z"},{"positionId":5,"lat":20.5820,"lng":-100.8350,"speed":0,"timestamp":"2026-05-22T07:00:00Z"},{"positionId":6,"lat":20.5780,"lng":-100.8310,"speed":20,"timestamp":"2026-05-22T07:15:00Z"},{"positionId":7,"lat":20.5650,"lng":-100.8320,"speed":28,"timestamp":"2026-05-22T07:30:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":45,"timestamp":"2026-05-22T07:55:00Z"}]},
{"routeId":"RUTA-07","name":"Nororiente - Ciudad Industrial","truckId":107,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:10:00Z"},{"positionId":2,"lat":20.5350,"lng":-100.8050,"speed":44,"timestamp":"2026-05-22T06:24:00Z"},{"positionId":3,"lat":20.5450,"lng":-100.7950,"speed":25,"timestamp":"2026-05-22T06:38:00Z"},{"positionId":4,"lat":20.5480,"lng":-100.7850,"speed":18,"timestamp":"2026-05-22T06:52:00Z"},{"positionId":5,"lat":20.5510,"lng":-100.7750,"speed":0,"timestamp":"2026-05-22T07:05:00Z"},{"positionId":6,"lat":20.5460,"lng":-100.7720,"speed":12,"timestamp":"2026-05-22T07:18:00Z"},{"positionId":7,"lat":20.5390,"lng":-100.7820,"speed":30,"timestamp":"2026-05-22T07:30:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":42,"timestamp":"2026-05-22T07:52:00Z"}]},
{"routeId":"RUTA-08","name":"Suroriente - Universidad Latina","truckId":108,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:15:00Z"},{"positionId":2,"lat":20.5180,"lng":-100.8310,"speed":38,"timestamp":"2026-05-22T06:28:00Z"},{"positionId":3,"lat":20.5245,"lng":-100.7980,"speed":30,"timestamp":"2026-05-22T06:42:00Z"},{"positionId":4,"lat":20.5210,"lng":-100.7995,"speed":14,"timestamp":"2026-05-22T06:55:00Z"},{"positionId":5,"lat":20.5175,"lng":-100.8010,"speed":0,"timestamp":"2026-05-22T07:08:00Z"},{"positionId":6,"lat":20.5140,"lng":-100.8030,"speed":18,"timestamp":"2026-05-22T07:20:00Z"},{"positionId":7,"lat":20.5110,"lng":-100.8055,"speed":22,"timestamp":"2026-05-22T07:32:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":40,"timestamp":"2026-05-22T07:54:00Z"}]},
{"routeId":"RUTA-09","name":"Poniente - Hospital General","truckId":109,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:02:00Z"},{"positionId":2,"lat":20.5210,"lng":-100.8650,"speed":45,"timestamp":"2026-05-22T06:12:00Z"},{"positionId":3,"lat":20.5260,"lng":-100.8520,"speed":26,"timestamp":"2026-05-22T06:24:00Z"},{"positionId":4,"lat":20.5275,"lng":-100.8490,"speed":12,"timestamp":"2026-05-22T06:36:00Z"},{"positionId":5,"lat":20.5285,"lng":-100.8460,"speed":0,"timestamp":"2026-05-22T06:48:00Z"},{"positionId":6,"lat":20.5250,"lng":-100.8470,"speed":15,"timestamp":"2026-05-22T07:00:00Z"},{"positionId":7,"lat":20.5220,"lng":-100.8550,"speed":32,"timestamp":"2026-05-22T07:12:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":44,"timestamp":"2026-05-22T07:30:00Z"}]},
{"routeId":"RUTA-10","name":"Eje Juan Pablo II - Sede UG Sur","truckId":110,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:22:00Z"},{"positionId":2,"lat":20.5015,"lng":-100.8520,"speed":40,"timestamp":"2026-05-22T06:34:00Z"},{"positionId":3,"lat":20.4990,"lng":-100.8390,"speed":28,"timestamp":"2026-05-22T06:46:00Z"},{"positionId":4,"lat":20.4950,"lng":-100.8320,"speed":18,"timestamp":"2026-05-22T06:58:00Z"},{"positionId":5,"lat":20.4920,"lng":-100.8280,"speed":0,"timestamp":"2026-05-22T07:10:00Z"},{"positionId":6,"lat":20.4945,"lng":-100.8240,"speed":14,"timestamp":"2026-05-22T07:22:00Z"},{"positionId":7,"lat":20.4980,"lng":-100.8300,"speed":30,"timestamp":"2026-05-22T07:34:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":38,"timestamp":"2026-05-22T07:52:00Z"}]},
{"routeId":"RUTA-11","name":"Zona de Oro - Torres Landa","truckId":111,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:04:00Z"},{"positionId":2,"lat":20.5240,"lng":-100.8350,"speed":36,"timestamp":"2026-05-22T06:16:00Z"},{"positionId":3,"lat":20.5280,"lng":-100.8250,"speed":22,"timestamp":"2026-05-22T06:29:00Z"},{"positionId":4,"lat":20.5295,"lng":-100.8210,"speed":10,"timestamp":"2026-05-22T06:42:00Z"},{"positionId":5,"lat":20.5310,"lng":-100.8170,"speed":0,"timestamp":"2026-05-22T06:55:00Z"},{"positionId":6,"lat":20.5290,"lng":-100.8140,"speed":16,"timestamp":"2026-05-22T07:08:00Z"},{"positionId":7,"lat":20.5260,"lng":-100.8220,"speed":28,"timestamp":"2026-05-22T07:21:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":42,"timestamp":"2026-05-22T07:42:00Z"}]},
{"routeId":"RUTA-12","name":"Nororiente - Las Insurgentes","truckId":112,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:08:00Z"},{"positionId":2,"lat":20.5280,"lng":-100.8080,"speed":40,"timestamp":"2026-05-22T06:22:00Z"},{"positionId":3,"lat":20.5320,"lng":-100.7980,"speed":24,"timestamp":"2026-05-22T06:35:00Z"},{"positionId":4,"lat":20.5340,"lng":-100.7940,"speed":15,"timestamp":"2026-05-22T06:48:00Z"},{"positionId":5,"lat":20.5360,"lng":-100.7900,"speed":0,"timestamp":"2026-05-22T07:00:00Z"},{"positionId":6,"lat":20.5310,"lng":-100.7920,"speed":12,"timestamp":"2026-05-22T07:12:00Z"},{"positionId":7,"lat":20.5270,"lng":-100.8020,"speed":26,"timestamp":"2026-05-22T07:25:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":44,"timestamp":"2026-05-22T07:48:00Z"}]},
{"routeId":"RUTA-13","name":"Sector Norte - Trojes e Irrigación","truckId":113,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:12:00Z"},{"positionId":2,"lat":20.5360,"lng":-100.8190,"speed":35,"timestamp":"2026-05-22T06:26:00Z"},{"positionId":3,"lat":20.5420,"lng":-100.8080,"speed":28,"timestamp":"2026-05-22T06:40:00Z"},{"positionId":4,"lat":20.5440,"lng":-100.8040,"speed":14,"timestamp":"2026-05-22T06:54:00Z"},{"positionId":5,"lat":20.5460,"lng":-100.8000,"speed":0,"timestamp":"2026-05-22T07:06:00Z"},{"positionId":6,"lat":20.5410,"lng":-100.8020,"speed":18,"timestamp":"2026-05-22T07:18:00Z"},{"positionId":7,"lat":20.5370,"lng":-100.8120,"speed":25,"timestamp":"2026-05-22T07:30:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":39,"timestamp":"2026-05-22T07:54:00Z"}]},
{"routeId":"RUTA-14","name":"Sur Poniente - La Toscana","truckId":114,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:16:00Z"},{"positionId":2,"lat":20.5150,"lng":-100.8580,"speed":42,"timestamp":"2026-05-22T06:28:00Z"},{"positionId":3,"lat":20.5140,"lng":-100.8390,"speed":26,"timestamp":"2026-05-22T06:41:00Z"},{"positionId":4,"lat":20.5125,"lng":-100.8310,"speed":16,"timestamp":"2026-05-22T06:54:00Z"},{"positionId":5,"lat":20.5110,"lng":-100.8250,"speed":0,"timestamp":"2026-05-22T07:06:00Z"},{"positionId":6,"lat":20.5135,"lng":-100.8280,"speed":12,"timestamp":"2026-05-22T07:18:00Z"},{"positionId":7,"lat":20.5160,"lng":-100.8420,"speed":32,"timestamp":"2026-05-22T07:30:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":45,"timestamp":"2026-05-22T07:51:00Z"}]},
{"routeId":"RUTA-15","name":"Norponiente - Camino a San José de Celaya","truckId":115,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:18:00Z"},{"positionId":2,"lat":20.5320,"lng":-100.8590,"speed":38,"timestamp":"2026-05-22T06:31:00Z"},{"positionId":3,"lat":20.5390,"lng":-100.8480,"speed":24,"timestamp":"2026-05-22T06:44:00Z"},{"positionId":4,"lat":20.5420,"lng":-100.8440,"speed":15,"timestamp":"2026-05-22T06:57:00Z"},{"positionId":5,"lat":20.5450,"lng":-100.8410,"speed":0,"timestamp":"2026-05-22T07:09:00Z"},{"positionId":6,"lat":20.5410,"lng":-100.8430,"speed":14,"timestamp":"2026-05-22T07:21:00Z"},{"positionId":7,"lat":20.5360,"lng":-100.8520,"speed":28,"timestamp":"2026-05-22T07:33:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":41,"timestamp":"2026-05-22T07:54:00Z"}]}
]

19
backend/app/database.py Normal file
View File

@@ -0,0 +1,19 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from .config import settings
engine = create_engine(
settings.DATABASE_URL,
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,4 @@
from .user import User
from .address import Address
from .report import Report, ServiceRating, OperationalReport, ReportType, ReportStatus
from .truck import Truck

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,24 @@
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime
from ..database import Base
class Address(Base):
__tablename__ = "addresses"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
label = Column(String, nullable=False, default="Casa") # e.g. "Casa", "Trabajo"
street = Column(String, nullable=False)
colony = Column(String, nullable=True)
city = Column(String, nullable=False, default="Celaya")
lat = Column(Float, nullable=True)
lng = Column(Float, nullable=True)
route_id = Column(String, nullable=True) # assigned route from geo-matching
is_default = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="addresses")
reports = relationship("Report", back_populates="address")
ratings = relationship("ServiceRating", back_populates="address")

View File

@@ -0,0 +1,69 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from ..database import Base
class ReportType(str, enum.Enum):
NO_PASO = "NO_PASO"
RETRASO = "RETRASO"
ACUMULACION = "ACUMULACION"
OTRO = "OTRO"
class ReportStatus(str, enum.Enum):
PENDIENTE = "PENDIENTE"
EN_PROCESO = "EN_PROCESO"
RESUELTO = "RESUELTO"
CERRADO = "CERRADO"
class Report(Base):
__tablename__ = "reports"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
address_id = Column(Integer, ForeignKey("addresses.id"), nullable=False)
folio = Column(String, unique=True, index=True, nullable=False)
report_type = Column(String, nullable=False)
description = Column(Text, nullable=True)
status = Column(String, default=ReportStatus.PENDIENTE)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="reports")
address = relationship("Address", back_populates="reports")
class ServiceRating(Base):
__tablename__ = "service_ratings"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
address_id = Column(Integer, ForeignKey("addresses.id"), nullable=False)
rating = Column(Integer, nullable=False) # 1-5
comment = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="ratings")
address = relationship("Address", back_populates="ratings")
class OperationalReport(Base):
"""Reportes generados por el personal operativo (empleados)."""
__tablename__ = "operational_reports"
id = Column(Integer, primary_key=True, index=True)
employee_id = Column(Integer, ForeignKey("users.id"), nullable=False)
folio = Column(String, unique=True, index=True, nullable=False)
category = Column(String, nullable=False)
# NO_ARRANQUE | FALLA_MECANICA | ACCIDENTE | OBSTACULO | TRAFICO |
# COMBUSTIBLE | CLIMA | OTRO
description = Column(Text, nullable=True)
severity = Column(String, default="MEDIA") # BAJA | MEDIA | ALTA
route_id = Column(String, nullable=True) # RUTA-XX (opcional)
truck_id = Column(Integer, nullable=True)
status = Column(String, default="REPORTADO") # REPORTADO | EN_ATENCION | RESUELTO
created_at = Column(DateTime, default=datetime.utcnow)
resolved_at = Column(DateTime, nullable=True)

View File

@@ -0,0 +1,25 @@
from sqlalchemy import Column, Integer, String, DateTime, Float
from datetime import datetime
from ..database import Base
class Truck(Base):
"""Flotilla de recolección de basura del Gobierno de Celaya."""
__tablename__ = "trucks"
id = Column(Integer, primary_key=True, index=True)
unit_number = Column(String, unique=True, index=True, nullable=False) # CEL-001
plate = Column(String, unique=True, nullable=True) # GTO-123-A
model = Column(String, nullable=True)
year = Column(Integer, nullable=True)
capacity_kg = Column(Integer, nullable=True)
fuel_type = Column(String, default="DIESEL")
status = Column(String, default="OPERATIVO")
# OPERATIVO | EN_RUTA | TALLER | MANTENIMIENTO | FUERA_SERVICIO | RESERVA
route_id = Column(String, nullable=True, index=True)
base = Column(String, nullable=True) # Patio Norte | Patio Sur | etc.
odometer_km = Column(Integer, default=0)
fuel_level_pct = Column(Integer, default=80)
last_maintenance = Column(DateTime, nullable=True)
next_maintenance_km = Column(Integer, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)

View File

@@ -0,0 +1,31 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from ..database import Base
class UserRole(str, enum.Enum):
CIUDADANO = "CIUDADANO"
EMPLEADO = "EMPLEADO"
ADMIN = "ADMIN"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=True)
phone = Column(String, unique=True, index=True, nullable=True)
full_name = Column(String, nullable=False)
hashed_password = Column(String, nullable=True)
oauth_provider = Column(String, nullable=True) # google, facebook, apple
oauth_id = Column(String, nullable=True)
push_token = Column(String, nullable=True)
role = Column(String, nullable=False, default=UserRole.CIUDADANO.value, index=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
addresses = relationship("Address", back_populates="user", cascade="all, delete-orphan")
reports = relationship("Report", back_populates="user", cascade="all, delete-orphan")
ratings = relationship("ServiceRating", back_populates="user", cascade="all, delete-orphan")

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,74 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.address import Address
from ..schemas.address import AddressCreate, AddressUpdate, AddressOut
from ..services import eta_service
from .deps import get_current_user
router = APIRouter(prefix="/addresses", tags=["addresses"])
@router.get("/", response_model=list[AddressOut])
def list_addresses(db: Session = Depends(get_db), user=Depends(get_current_user)):
return db.query(Address).filter(Address.user_id == user.id).all()
@router.post("/", response_model=AddressOut, status_code=201)
def create_address(data: AddressCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
# Assign the closest route based on coordinates
route_id = None
if data.lat and data.lng:
route_id = eta_service.assign_route(data.lat, data.lng)
# If this is default, unset others
if data.is_default:
db.query(Address).filter(Address.user_id == user.id).update({"is_default": False})
# First address is automatically default
existing_count = db.query(Address).filter(Address.user_id == user.id).count()
is_default = data.is_default or existing_count == 0
address = Address(
user_id=user.id,
label=data.label,
street=data.street,
colony=data.colony,
city=data.city,
lat=data.lat,
lng=data.lng,
route_id=route_id,
is_default=is_default,
)
db.add(address)
db.commit()
db.refresh(address)
return address
@router.patch("/{address_id}", response_model=AddressOut)
def update_address(address_id: int, data: AddressUpdate, db: Session = Depends(get_db), user=Depends(get_current_user)):
address = db.query(Address).filter(Address.id == address_id, Address.user_id == user.id).first()
if not address:
raise HTTPException(status_code=404, detail="Domicilio no encontrado")
if data.label is not None:
address.label = data.label
if data.street is not None:
address.street = data.street
if data.colony is not None:
address.colony = data.colony
if data.is_default is True:
db.query(Address).filter(Address.user_id == user.id).update({"is_default": False})
address.is_default = True
db.commit()
db.refresh(address)
return address
@router.delete("/{address_id}", status_code=204)
def delete_address(address_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)):
address = db.query(Address).filter(Address.id == address_id, Address.user_id == user.id).first()
if not address:
raise HTTPException(status_code=404, detail="Domicilio no encontrado")
db.delete(address)
db.commit()

View File

@@ -0,0 +1,179 @@
"""
Endpoints exclusivos para personal del gobierno (EMPLEADO / ADMIN).
Cierra el loop: ciudadano reporta → personal recibe y resuelve.
"""
from typing import Optional
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, desc
from ..database import get_db
from ..models.user import User
from ..models.address import Address
from ..models.report import Report, ServiceRating
from ..services import eta_service
from .deps import require_staff, require_admin
router = APIRouter(prefix="/admin", tags=["admin"])
# ─── 1. DASHBOARD: estadísticas globales ─────────────────────────────────────
@router.get("/stats")
def get_dashboard_stats(db: Session = Depends(get_db), _=Depends(require_admin)):
total_users = db.query(func.count(User.id)).filter(User.role == "CIUDADANO").scalar()
total_addresses = db.query(func.count(Address.id)).scalar()
total_reports = db.query(func.count(Report.id)).scalar()
by_status = dict(
db.query(Report.status, func.count(Report.id)).group_by(Report.status).all()
)
by_type = dict(
db.query(Report.report_type, func.count(Report.id)).group_by(Report.report_type).all()
)
# Reportes en últimas 24h
yesterday = datetime.utcnow() - timedelta(days=1)
recent_count = db.query(func.count(Report.id)).filter(Report.created_at >= yesterday).scalar()
# Promedio de calificaciones
avg_rating = db.query(func.avg(ServiceRating.rating)).scalar() or 0
return {
"total_ciudadanos": total_users,
"total_domicilios": total_addresses,
"total_reportes": total_reports,
"reportes_24h": recent_count,
"promedio_calificacion": round(float(avg_rating), 2),
"reportes_por_estado": by_status,
"reportes_por_tipo": by_type,
"rutas_activas": len(eta_service.get_all_routes_summary()),
}
# ─── 2. REPORTES: ver todos / cambiar estado ─────────────────────────────────
@router.get("/reports")
def list_all_reports(
status: Optional[str] = Query(None, description="Filtrar por estado"),
report_type: Optional[str] = Query(None),
db: Session = Depends(get_db),
_=Depends(require_admin),
):
q = db.query(Report).order_by(desc(Report.created_at))
if status:
q = q.filter(Report.status == status)
if report_type:
q = q.filter(Report.report_type == report_type)
results = []
for r in q.all():
addr = db.query(Address).filter(Address.id == r.address_id).first()
user = db.query(User).filter(User.id == r.user_id).first()
results.append({
"id": r.id,
"folio": r.folio,
"report_type": r.report_type,
"description": r.description,
"status": r.status,
"created_at": r.created_at.isoformat(),
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
"user_name": user.full_name if user else "?",
"user_email": user.email if user else None,
"address_label": addr.label if addr else "?",
"address_street": addr.street if addr else "?",
"address_colony": addr.colony if addr else None,
"route_id": addr.route_id if addr else None,
})
return results
@router.patch("/reports/{report_id}/status")
def update_report_status(
report_id: int,
status: str = Query(..., description="PENDIENTE | EN_PROCESO | RESUELTO | CERRADO"),
db: Session = Depends(get_db),
_=Depends(require_admin),
):
if status not in ("PENDIENTE", "EN_PROCESO", "RESUELTO", "CERRADO"):
raise HTTPException(status_code=400, detail="Estado inválido")
report = db.query(Report).filter(Report.id == report_id).first()
if not report:
raise HTTPException(status_code=404, detail="Reporte no encontrado")
report.status = status
report.updated_at = datetime.utcnow()
db.commit()
db.refresh(report)
return {"id": report.id, "folio": report.folio, "status": report.status}
# ─── 3. RUTAS: estado operativo de la flotilla ───────────────────────────────
@router.get("/routes")
def list_routes_status(_=Depends(require_admin)):
"""Lista todas las rutas con su estado actual (cálculo desde el simulador)."""
return eta_service.get_all_routes_summary()
# ─── 4. USUARIOS: gestionar ciudadanos / empleados ───────────────────────────
@router.get("/users")
def list_users(
role: Optional[str] = Query(None),
db: Session = Depends(get_db),
_=Depends(require_admin), # Solo ADMIN puede ver usuarios
):
q = db.query(User).order_by(desc(User.created_at))
if role:
q = q.filter(User.role == role)
results = []
for u in q.all():
n_addrs = db.query(func.count(Address.id)).filter(Address.user_id == u.id).scalar()
n_reports = db.query(func.count(Report.id)).filter(Report.user_id == u.id).scalar()
results.append({
"id": u.id,
"full_name": u.full_name,
"email": u.email,
"phone": u.phone,
"role": u.role,
"is_active": u.is_active,
"created_at": u.created_at.isoformat(),
"total_domicilios": n_addrs,
"total_reportes": n_reports,
})
return results
@router.patch("/users/{user_id}/role")
def update_user_role(
user_id: int,
role: str = Query(..., description="CIUDADANO | EMPLEADO | ADMIN"),
db: Session = Depends(get_db),
_=Depends(require_admin),
):
if role not in ("CIUDADANO", "EMPLEADO", "ADMIN"):
raise HTTPException(status_code=400, detail="Rol inválido")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="Usuario no encontrado")
user.role = role
db.commit()
return {"id": user.id, "role": user.role}
# ─── 5. ANUNCIOS / COMUNICACIÓN ──────────────────────────────────────────────
@router.get("/feedback")
def list_recent_feedback(db: Session = Depends(get_db), _=Depends(require_admin)):
"""Últimas calificaciones del servicio para ver feedback ciudadano."""
ratings = db.query(ServiceRating).order_by(desc(ServiceRating.created_at)).limit(50).all()
results = []
for r in ratings:
user = db.query(User).filter(User.id == r.user_id).first()
addr = db.query(Address).filter(Address.id == r.address_id).first()
results.append({
"id": r.id,
"rating": r.rating,
"comment": r.comment,
"created_at": r.created_at.isoformat(),
"user_name": user.full_name if user else "?",
"address_label": addr.label if addr else "?",
"route_id": addr.route_id if addr else None,
})
return results

View File

@@ -0,0 +1,66 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..schemas.auth import UserRegister, UserLogin, OAuthLogin, Token, UserOut
from ..services import auth_service
from .deps import get_current_user
router = APIRouter(prefix="/auth", tags=["auth"])
def _make_token(user) -> Token:
"""user puede ser un User o un int (id)."""
if isinstance(user, int):
# Fallback antiguo
token = auth_service.create_access_token({"sub": str(user)})
return Token(access_token=token, role="CIUDADANO")
token = auth_service.create_access_token({"sub": str(user.id), "role": user.role})
return Token(access_token=token, role=user.role)
@router.post("/register", response_model=Token, status_code=201)
def register(data: UserRegister, db: Session = Depends(get_db)):
if data.email and auth_service.get_user_by_email(db, data.email):
raise HTTPException(status_code=400, detail="El correo ya está registrado")
user = auth_service.create_user(db, data.full_name, data.email, data.phone, data.password)
return _make_token(user)
@router.post("/login", response_model=Token)
def login(data: UserLogin, db: Session = Depends(get_db)):
# Normalizar entrada: trim espacios, lowercase email
email = (data.email or "").strip().lower()
password = (data.password or "").strip()
# Log de debug — muestra qué llega exactamente (sin la contraseña completa)
print(f"[LOGIN] email_recibido={email!r} pw_len={len(password)}")
if not email or not password:
raise HTTPException(status_code=401, detail="Credenciales inválidas — campos vacíos")
# Buscar usuario (case-insensitive con LOWER en SQL)
from sqlalchemy import func
from ..models.user import User as UserModel
user = db.query(UserModel).filter(func.lower(UserModel.email) == email).first()
if not user:
print(f"[LOGIN] ✗ usuario no encontrado")
raise HTTPException(status_code=401, detail="Credenciales inválidas")
if not user.hashed_password:
print(f"[LOGIN] ✗ usuario sin password (cuenta OAuth)")
raise HTTPException(status_code=401, detail="Esta cuenta usa OAuth, no contraseña")
if not auth_service.verify_password(password, user.hashed_password):
print(f"[LOGIN] ✗ password incorrecta")
raise HTTPException(status_code=401, detail="Credenciales inválidas")
print(f"[LOGIN] ✓ login OK para id={user.id}")
return _make_token(user)
@router.post("/oauth", response_model=Token)
def oauth_login(data: OAuthLogin, db: Session = Depends(get_db)):
user = auth_service.get_or_create_oauth_user(db, data.provider, data.oauth_id, data.email, data.full_name)
if data.push_token:
user.push_token = data.push_token
db.commit()
return _make_token(user)

View File

@@ -0,0 +1,35 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from ..database import get_db
from ..services.auth_service import decode_token, get_user_by_id
bearer_scheme = HTTPBearer()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: Session = Depends(get_db),
):
payload = decode_token(credentials.credentials)
if not payload:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token inválido o expirado")
user_id = int(payload.get("sub", 0))
user = get_user_by_id(db, user_id)
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Usuario no encontrado")
return user
def require_admin(user=Depends(get_current_user)):
"""Solo permite acceso a usuarios con rol ADMIN."""
if user.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Acceso denegado: requiere rol ADMIN")
return user
def require_staff(user=Depends(get_current_user)):
"""Permite acceso a EMPLEADO o ADMIN (personal del gobierno)."""
if user.role not in ("EMPLEADO", "ADMIN"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Acceso denegado: requiere rol EMPLEADO o ADMIN")
return user

View File

@@ -0,0 +1,57 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.address import Address
from ..models.report import ServiceRating
from ..schemas.eta import ETAResponse, RouteScheduleResponse, ServiceRatingCreate
from ..services import eta_service
from .deps import get_current_user
router = APIRouter(prefix="/eta", tags=["eta"])
def _get_owned_address(address_id: int, user, db: Session) -> Address:
address = db.query(Address).filter(Address.id == address_id, Address.user_id == user.id).first()
if not address:
raise HTTPException(status_code=404, detail="Domicilio no encontrado")
if not address.route_id:
raise HTTPException(status_code=422, detail="Este domicilio no tiene una ruta asignada todavía")
return address
@router.get("/address/{address_id}", response_model=ETAResponse)
def get_eta_for_address(address_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""
Returns the ETA for the garbage truck to reach the user's registered address.
Privacy-by-design: never exposes truck coordinates or other users' routes.
"""
address = _get_owned_address(address_id, user, db)
result = eta_service.get_eta(address.route_id, address.lat, address.lng)
return result
@router.get("/schedule/{address_id}", response_model=RouteScheduleResponse)
def get_schedule_for_address(address_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)):
address = _get_owned_address(address_id, user, db)
schedule = eta_service.get_route_schedule(address.route_id)
if not schedule:
raise HTTPException(status_code=404, detail="Horario no disponible")
return schedule
@router.post("/rate", status_code=201)
def rate_service(data: ServiceRatingCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
address = db.query(Address).filter(Address.id == data.address_id, Address.user_id == user.id).first()
if not address:
raise HTTPException(status_code=404, detail="Domicilio no encontrado")
if not 1 <= data.rating <= 5:
raise HTTPException(status_code=400, detail="La calificación debe ser entre 1 y 5")
rating = ServiceRating(
user_id=user.id,
address_id=data.address_id,
rating=data.rating,
comment=data.comment,
)
db.add(rating)
db.commit()
return {"message": "Gracias por tu calificación"}

View File

@@ -0,0 +1,77 @@
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.report import Report
from ..models.address import Address
from ..schemas.report import ReportCreate, ReportOut, ReportListItem
from .deps import get_current_user
router = APIRouter(prefix="/reports", tags=["reports"])
REPORT_TYPE_LABELS = {
"NO_PASO": "Camión no pasó",
"RETRASO": "Retraso en la ruta",
"ACUMULACION": "Acumulación de basura",
"OTRO": "Otro",
}
def _generate_folio() -> str:
date_str = datetime.utcnow().strftime("%Y%m%d")
short = str(uuid.uuid4())[:6].upper()
return f"MRL-{date_str}-{short}"
@router.post("/", response_model=ReportOut, status_code=201)
def create_report(data: ReportCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
address = db.query(Address).filter(Address.id == data.address_id, Address.user_id == user.id).first()
if not address:
raise HTTPException(status_code=404, detail="Domicilio no encontrado")
if data.report_type not in REPORT_TYPE_LABELS:
raise HTTPException(status_code=400, detail="Tipo de reporte inválido")
report = Report(
user_id=user.id,
address_id=data.address_id,
folio=_generate_folio(),
report_type=data.report_type,
description=data.description,
)
db.add(report)
db.commit()
db.refresh(report)
return ReportOut(
id=report.id,
folio=report.folio,
report_type=report.report_type,
description=report.description,
status=report.status,
created_at=report.created_at,
address_label=address.label,
)
@router.get("/", response_model=list[ReportListItem])
def list_reports(db: Session = Depends(get_db), user=Depends(get_current_user)):
reports = db.query(Report).filter(Report.user_id == user.id).order_by(Report.created_at.desc()).all()
return reports
@router.get("/{report_id}", response_model=ReportOut)
def get_report(report_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)):
report = db.query(Report).filter(Report.id == report_id, Report.user_id == user.id).first()
if not report:
raise HTTPException(status_code=404, detail="Reporte no encontrado")
address = db.query(Address).filter(Address.id == report.address_id).first()
return ReportOut(
id=report.id,
folio=report.folio,
report_type=report.report_type,
description=report.description,
status=report.status,
created_at=report.created_at,
address_label=address.label if address else None,
)

View File

@@ -0,0 +1,175 @@
"""
Endpoints para EMPLEADOS operativos.
- NO pueden gestionar reportes ciudadanos (eso es solo de ADMIN).
- SÍ pueden levantar reportes operativos (problemas con el camión, ruta, etc.)
- Reciben info de su horario, puntualidad y bonos para motivar el servicio.
"""
import uuid
import hashlib
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import desc, func
from pydantic import BaseModel
from ..database import get_db
from ..models.user import User
from ..models.report import OperationalReport
from .deps import get_current_user
router = APIRouter(prefix="/staff", tags=["staff"])
# ─── Helpers ─────────────────────────────────────────────────────────────────
def _require_employee_or_admin(user: User):
"""Asegura que solo empleados o admins acceden. Ciudadanos: 403."""
if user.role not in ("EMPLEADO", "ADMIN"):
raise HTTPException(status_code=403, detail="Acceso solo para personal")
return user
def _generate_folio() -> str:
date = datetime.utcnow().strftime("%Y%m%d")
short = str(uuid.uuid4())[:6].upper()
return f"OP-{date}-{short}"
# ─── Schemas ────────────────────────────────────────────────────────────────
class OperationalReportCreate(BaseModel):
category: str
description: Optional[str] = None
severity: str = "MEDIA"
route_id: Optional[str] = None
truck_id: Optional[int] = None
class OperationalReportOut(BaseModel):
id: int
folio: str
category: str
description: Optional[str]
severity: str
route_id: Optional[str]
truck_id: Optional[int]
status: str
created_at: datetime
class Config:
from_attributes = True
CATEGORY_LABELS = {
"NO_ARRANQUE": "El camión no arrancó",
"FALLA_MECANICA": "Falla mecánica en ruta",
"ACCIDENTE": "Accidente vial",
"OBSTACULO": "Obstáculo bloqueando la ruta",
"TRAFICO": "Tráfico intenso / retraso",
"COMBUSTIBLE": "Nivel bajo de combustible",
"CLIMA": "Clima adverso",
"OTRO": "Otro incidente",
}
# ─── 1. CATEGORÍAS DISPONIBLES ────────────────────────────────────────────────
@router.get("/categories")
def list_categories(user=Depends(get_current_user)):
_require_employee_or_admin(user)
return [{"key": k, "label": v} for k, v in CATEGORY_LABELS.items()]
# ─── 2. CREAR REPORTE OPERATIVO ──────────────────────────────────────────────
@router.post("/operational-reports", response_model=OperationalReportOut, status_code=201)
def create_op_report(data: OperationalReportCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
_require_employee_or_admin(user)
if data.category not in CATEGORY_LABELS:
raise HTTPException(status_code=400, detail="Categoría inválida")
if data.severity not in ("BAJA", "MEDIA", "ALTA"):
raise HTTPException(status_code=400, detail="Severidad inválida")
rep = OperationalReport(
employee_id=user.id,
folio=_generate_folio(),
category=data.category,
description=data.description,
severity=data.severity,
route_id=data.route_id,
truck_id=data.truck_id,
)
db.add(rep)
db.commit()
db.refresh(rep)
return rep
# ─── 3. LISTAR MIS REPORTES OPERATIVOS ───────────────────────────────────────
@router.get("/operational-reports", response_model=list[OperationalReportOut])
def list_my_op_reports(db: Session = Depends(get_db), user=Depends(get_current_user)):
_require_employee_or_admin(user)
return db.query(OperationalReport).filter(
OperationalReport.employee_id == user.id
).order_by(desc(OperationalReport.created_at)).all()
# ─── 4. HORARIO DE TRABAJO ───────────────────────────────────────────────────
@router.get("/schedule")
def get_my_schedule(user=Depends(get_current_user)):
_require_employee_or_admin(user)
# Horario preestablecido (en producción vendría de una tabla)
return {
"shift_name": "Turno Matutino",
"shift_start": "05:30",
"shift_end": "09:00",
"route_block": "06:00 - 08:00",
"breaks": [
{"name": "Descanso técnico", "time": "07:00", "duration_min": 10, "icon": "coffee"},
{"name": "Pausa estiramiento", "time": "08:00", "duration_min": 5, "icon": "yoga"},
],
"days_per_week": "Lunes a sábado",
"rest_day": "Domingo",
"notes": "Llegada puntual a las 05:30 garantiza salida del camión a las 06:00 en tiempo.",
}
# ─── 5. PUNTUALIDAD Y BONOS (datos mock pero coherentes) ─────────────────────
@router.get("/dashboard")
def get_employee_dashboard(db: Session = Depends(get_db), user=Depends(get_current_user)):
_require_employee_or_admin(user)
# Generar valores determinísticos por employee_id (sin random, así no cambia entre llamadas)
seed = int(hashlib.md5(str(user.id).encode()).hexdigest(), 16)
streak_days = 7 + (seed % 18) # 7-24 días
punctuality_pct = 88 + (seed % 12) # 88-99%
bonus_mxn = streak_days * 50 # $50 por día puntual
next_milestone_days = 30 - streak_days if streak_days < 30 else 60 - streak_days
next_milestone_mxn = 500
# Total de reportes operativos generados por este empleado
my_reports_count = db.query(func.count(OperationalReport.id)).filter(
OperationalReport.employee_id == user.id
).scalar() or 0
# Mensaje motivacional rotativo (basado en día del año + user id)
motivations = [
"¡Tu puntualidad permite que miles de familias planifiquen su día!",
"Cada salida a tiempo es un acto de servicio que transforma a Celaya.",
"Hoy también cuentan contigo. Manejen seguro, equipo.",
"Una ruta puntual = vecinos contentos y ciudad más limpia.",
"Tu trabajo refleja el orgullo del Gobierno de Celaya. ¡Gracias!",
]
motivation = motivations[(datetime.utcnow().timetuple().tm_yday + user.id) % len(motivations)]
return {
"employee_name": user.full_name,
"streak_days": streak_days,
"punctuality_pct": punctuality_pct,
"bonus_accumulated_mxn": bonus_mxn,
"next_milestone_days": max(1, next_milestone_days),
"next_milestone_mxn": next_milestone_mxn,
"reports_generated": my_reports_count,
"motivation_quote": motivation,
"rating_label": (
"EXCELENTE" if punctuality_pct >= 95
else "MUY BUENO" if punctuality_pct >= 90
else "BUENO"
),
}

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,34 @@
from pydantic import BaseModel
from typing import Optional
class AddressCreate(BaseModel):
label: str = "Casa"
street: str
colony: Optional[str] = None
city: str = "Celaya"
lat: Optional[float] = None
lng: Optional[float] = None
is_default: bool = False
class AddressUpdate(BaseModel):
label: Optional[str] = None
street: Optional[str] = None
colony: Optional[str] = None
is_default: Optional[bool] = None
class AddressOut(BaseModel):
id: int
label: str
street: str
colony: Optional[str] = None
city: str
lat: Optional[float] = None
lng: Optional[float] = None
route_id: Optional[str] = None
is_default: bool
class Config:
from_attributes = True

View File

@@ -0,0 +1,45 @@
from pydantic import BaseModel, EmailStr
from typing import Optional
class UserRegister(BaseModel):
full_name: str
email: Optional[EmailStr] = None
phone: Optional[str] = None
password: str
class UserLogin(BaseModel):
email: Optional[str] = None
phone: Optional[str] = None
password: str
class OAuthLogin(BaseModel):
provider: str # google | facebook | apple
oauth_id: str
email: Optional[str] = None
full_name: Optional[str] = None
push_token: Optional[str] = None
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
role: str = "CIUDADANO"
class UserOut(BaseModel):
id: int
full_name: str
email: Optional[str] = None
phone: Optional[str] = None
oauth_provider: Optional[str] = None
role: str = "CIUDADANO"
class Config:
from_attributes = True
class TokenData(BaseModel):
user_id: Optional[int] = None

View File

@@ -0,0 +1,28 @@
from pydantic import BaseModel
from typing import Optional
class ETAResponse(BaseModel):
status: str # PROGRAMADO | EN_CAMINO | LLEGANDO | PASO | NO_SERVICIO
message: str
eta_minutes: Optional[int] = None
window_start: Optional[str] = None # "07:20"
window_end: Optional[str] = None # "07:35"
progress: float = 0.0 # 0-100 route completion %
route_name: Optional[str] = None
passes_today: bool = True
next_service: Optional[str] = None # ISO date when it passes next
class RouteScheduleResponse(BaseModel):
route_id: str
route_name: str
days_of_week: list[str] # ["Lunes", "Miércoles", "Viernes"]
approximate_time: str # "6:00 - 8:00 AM"
truck_id: int
class ServiceRatingCreate(BaseModel):
address_id: int
rating: int # 1-5
comment: Optional[str] = None

View File

@@ -0,0 +1,33 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class ReportCreate(BaseModel):
address_id: int
report_type: str # NO_PASO | RETRASO | ACUMULACION | OTRO
description: Optional[str] = None
class ReportOut(BaseModel):
id: int
folio: str
report_type: str
description: Optional[str] = None
status: str
created_at: datetime
address_label: Optional[str] = None
class Config:
from_attributes = True
class ReportListItem(BaseModel):
id: int
folio: str
report_type: str
status: str
created_at: datetime
class Config:
from_attributes = True

View File

View File

@@ -0,0 +1,76 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from ..models.user import User
from ..config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode["exp"] = expire
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_token(token: str) -> Optional[dict]:
try:
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
except JWTError:
return None
def get_user_by_email(db: Session, email: str) -> Optional[User]:
return db.query(User).filter(User.email == email).first()
def get_user_by_id(db: Session, user_id: int) -> Optional[User]:
return db.query(User).filter(User.id == user_id).first()
def create_user(db: Session, full_name: str, email: Optional[str], phone: Optional[str], password: str) -> User:
user = User(
full_name=full_name,
email=email,
phone=phone,
hashed_password=hash_password(password),
)
db.add(user)
db.commit()
db.refresh(user)
return user
def get_or_create_oauth_user(db: Session, provider: str, oauth_id: str, email: Optional[str], full_name: Optional[str]) -> User:
user = db.query(User).filter(User.oauth_provider == provider, User.oauth_id == oauth_id).first()
if user:
return user
if email:
user = db.query(User).filter(User.email == email).first()
if user:
user.oauth_provider = provider
user.oauth_id = oauth_id
db.commit()
db.refresh(user)
return user
user = User(
full_name=full_name or "Usuario",
email=email,
oauth_provider=provider,
oauth_id=oauth_id,
)
db.add(user)
db.commit()
db.refresh(user)
return user

View File

@@ -0,0 +1,192 @@
import json
import math
import os
from datetime import datetime, timedelta
from typing import Optional
import pytz
CELAYA_TZ = pytz.timezone("America/Mexico_City")
_routes_cache: Optional[list] = None
def _load_routes() -> list:
global _routes_cache
if _routes_cache is None:
path = os.path.join(os.path.dirname(__file__), "../data/routes.json")
with open(path, encoding="utf-8") as f:
_routes_cache = json.load(f)
return _routes_cache
def _haversine(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
"""Distance in meters between two lat/lng points."""
R = 6_371_000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lng2 - lng1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def assign_route(lat: float, lng: float) -> Optional[str]:
"""Return the route_id whose waypoints are closest to the given address."""
routes = _load_routes()
best_id = None
best_dist = float("inf")
for route in routes:
for pos in route["positions"]:
d = _haversine(lat, lng, pos["lat"], pos["lng"])
if d < best_dist:
best_dist = d
best_id = route["routeId"]
return best_id
def _parse_time_as_local(ts_str: str) -> datetime:
"""
The mock timestamps carry Z but represent local Celaya (CST/CDT) times.
We strip the Z and treat them as naive local time, then localize to Celaya TZ.
"""
naive = datetime.fromisoformat(ts_str.replace("Z", ""))
return CELAYA_TZ.localize(naive)
def _today_at(template: datetime) -> datetime:
"""Move a template datetime to today keeping hour/minute/second."""
now = datetime.now(CELAYA_TZ)
return now.replace(
hour=template.hour,
minute=template.minute,
second=template.second,
microsecond=0,
)
def get_eta(route_id: str, user_lat: float, user_lng: float) -> dict:
routes = _load_routes()
route = next((r for r in routes if r["routeId"] == route_id), None)
if not route:
return {
"status": "NO_SERVICIO",
"message": "No se encontró una ruta asignada a este domicilio.",
"passes_today": False,
"progress": 0,
}
positions = route["positions"]
now = datetime.now(CELAYA_TZ)
# Schedule for today
first_t = _today_at(_parse_time_as_local(positions[0]["timestamp"]))
last_t = _today_at(_parse_time_as_local(positions[-1]["timestamp"]))
# Find the waypoint whose scheduled arrival is closest to user_lat/lng
closest_idx = min(
range(len(positions)),
key=lambda i: _haversine(user_lat, user_lng, positions[i]["lat"], positions[i]["lng"]),
)
user_scheduled_t = _today_at(_parse_time_as_local(positions[closest_idx]["timestamp"]))
# Determine progress along the route (0-100)
total_seconds = (last_t - first_t).total_seconds()
elapsed_seconds = (now - first_t).total_seconds()
progress = max(0.0, min(100.0, (elapsed_seconds / total_seconds) * 100)) if total_seconds > 0 else 0.0
# Route hasn't started yet today
if now < first_t:
eta_min = int((user_scheduled_t - now).total_seconds() / 60)
w_start = user_scheduled_t - timedelta(minutes=8)
w_end = user_scheduled_t + timedelta(minutes=8)
return {
"status": "PROGRAMADO",
"message": f"El camión llegará a tu zona hoy",
"eta_minutes": eta_min,
"window_start": w_start.strftime("%H:%M"),
"window_end": w_end.strftime("%H:%M"),
"progress": 0.0,
"route_name": route["name"],
"passes_today": True,
}
# Route finished for the day
if now > last_t:
return {
"status": "PASO",
"message": "El camión ya pasó por tu zona hoy. ¡Hasta mañana!",
"progress": 100.0,
"route_name": route["name"],
"passes_today": True,
}
# Route in progress — simulate current waypoint index via interpolation
current_idx = 0
for i in range(len(positions) - 1):
t_a = _today_at(_parse_time_as_local(positions[i]["timestamp"]))
t_b = _today_at(_parse_time_as_local(positions[i + 1]["timestamp"]))
if t_a <= now <= t_b:
current_idx = i
break
if current_idx > closest_idx:
return {
"status": "PASO",
"message": "El camión ya pasó por tu zona hace un momento.",
"progress": progress,
"route_name": route["name"],
"passes_today": True,
}
delta = user_scheduled_t - now
eta_min = max(1, int(delta.total_seconds() / 60))
if eta_min <= 10:
return {
"status": "LLEGANDO",
"message": "¡El camión está llegando a tu zona! Prepara tu basura.",
"eta_minutes": eta_min,
"progress": progress,
"route_name": route["name"],
"passes_today": True,
}
w_start = (user_scheduled_t - timedelta(minutes=8)).strftime("%H:%M")
w_end = (user_scheduled_t + timedelta(minutes=8)).strftime("%H:%M")
return {
"status": "EN_CAMINO",
"message": f"El camión llegará a tu zona entre las {w_start} y {w_end}",
"eta_minutes": eta_min,
"window_start": w_start,
"window_end": w_end,
"progress": progress,
"route_name": route["name"],
"passes_today": True,
}
def get_route_schedule(route_id: str) -> Optional[dict]:
routes = _load_routes()
route = next((r for r in routes if r["routeId"] == route_id), None)
if not route:
return None
positions = route["positions"]
first_t = _parse_time_as_local(positions[0]["timestamp"])
last_t = _parse_time_as_local(positions[-1]["timestamp"])
return {
"route_id": route["routeId"],
"route_name": route["name"],
"days_of_week": ["Lunes", "Miércoles", "Viernes"],
"approximate_time": f"{first_t.strftime('%H:%M')} - {last_t.strftime('%H:%M')}",
"truck_id": route["truckId"],
}
def get_all_routes_summary() -> list:
return [
{
"route_id": r["routeId"],
"name": r["name"],
"truck_id": r["truckId"],
"status": r["status"],
}
for r in _load_routes()
]

1318
backend/database_dump.sql Normal file

File diff suppressed because it is too large Load Diff

48
backend/main.py Normal file
View File

@@ -0,0 +1,48 @@
from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from app.database import Base, engine, get_db
from app.models import User, Address, Report, ServiceRating
from app.routers import auth, addresses, eta, reports, admin, staff
from app.routers.deps import get_current_user
from app.schemas.auth import UserOut
# Create all tables on startup
Base.metadata.create_all(bind=engine)
app = FastAPI(
title="Mi Ruta Limpia API",
description="Sistema de notificación inteligente de recolección de residuos — Gobierno de Celaya",
version="1.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api/v1")
app.include_router(addresses.router, prefix="/api/v1")
app.include_router(eta.router, prefix="/api/v1")
app.include_router(reports.router, prefix="/api/v1")
app.include_router(admin.router, prefix="/api/v1")
app.include_router(staff.router, prefix="/api/v1")
@app.get("/api/v1/me", response_model=UserOut, tags=["auth"])
def get_me(user=Depends(get_current_user)):
return user
@app.get("/health", tags=["system"])
def health():
return {"status": "ok", "service": "Mi Ruta Limpia API"}
@app.get("/", tags=["system"])
def root():
return {"message": "Mi Ruta Limpia API — Gobierno de Celaya", "docs": "/docs"}

BIN
backend/mi_ruta_limpia.db Normal file

Binary file not shown.

10
backend/requirements.txt Normal file
View File

@@ -0,0 +1,10 @@
fastapi==0.115.5
uvicorn[standard]==0.32.1
sqlalchemy==2.0.36
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.12
pydantic[email]==2.10.3
pydantic-settings==2.6.1
httpx==0.27.2
pytz==2024.1

111
backend/restore_db.py Normal file
View File

@@ -0,0 +1,111 @@
"""
Restaura la base de datos desde database_dump.sql.
Uso (en cualquier máquina):
cd backend
python restore_db.py
Esto:
1. Borra mi_ruta_limpia.db si existe
2. Lee database_dump.sql
3. Crea una BD SQLite nueva idéntica a la original
4. Verifica conteos de registros
Resultado:
- 202 usuarios (185 ciudadanos, 14 empleados, 3 admins)
- 302 domicilios con rutas asignadas
- 280 reportes ciudadanos
- 150 reportes operativos
- 220 calificaciones de servicio
- 62 camiones de la flotilla
Credenciales demo:
- demo@celaya.gob.mx / Celaya2026 (CIUDADANO)
- empleado@celaya.gob.mx / Empleado2026 (EMPLEADO)
- admin@celaya.gob.mx / Admin2026 (ADMIN)
"""
import os
import sqlite3
import sys
DB_FILE = "mi_ruta_limpia.db"
DUMP_FILE = "database_dump.sql"
def main():
# Verificar que existe el dump
if not os.path.exists(DUMP_FILE):
print(f"✗ ERROR: no se encontró '{DUMP_FILE}' en este directorio.")
print(f" Asegúrate de estar parado en backend/ y que el archivo esté ahí.")
sys.exit(1)
# Borrar BD existente si la hay
if os.path.exists(DB_FILE):
print(f"⚠ La base de datos '{DB_FILE}' ya existe. Borrándola para restaurar limpio...")
os.remove(DB_FILE)
# Crear nueva BD ejecutando el dump
print(f"📥 Restaurando desde '{DUMP_FILE}'...")
conn = sqlite3.connect(DB_FILE)
with open(DUMP_FILE, "r", encoding="utf-8") as f:
sql = f.read()
conn.executescript(sql)
conn.commit()
# Verificar conteos
cursor = conn.cursor()
expected = {
"users": 202,
"addresses": 302,
"reports": 280,
"operational_reports": 150,
"service_ratings": 220,
"trucks": 62,
}
print("\n📊 Verificación de tablas:")
all_ok = True
for table, exp_count in expected.items():
try:
cursor.execute(f"SELECT COUNT(*) FROM {table}")
n = cursor.fetchone()[0]
ok = "" if n == exp_count else ""
print(f" {ok} {table:25} {n:>4}/{exp_count}")
if n != exp_count:
all_ok = False
except sqlite3.OperationalError as e:
print(f"{table:25} ERROR: {e}")
all_ok = False
conn.close()
# Resumen
print()
print("" * 60)
if all_ok:
print(" ✅ BASE DE DATOS RESTAURADA EXITOSAMENTE")
else:
print(" ⚠ RESTAURACIÓN COMPLETADA CON DIFERENCIAS")
print("" * 60)
print()
print(" Credenciales demo para probar la app:")
print()
print(" 🧑 CIUDADANO")
print(" Email: demo@celaya.gob.mx")
print(" Contraseña: Celaya2026")
print()
print(" 👮 EMPLEADO")
print(" Email: empleado@celaya.gob.mx")
print(" Contraseña: Empleado2026")
print()
print(" 🛡️ ADMINISTRADOR")
print(" Email: admin@celaya.gob.mx")
print(" Contraseña: Admin2026")
print()
print(" Ya puedes arrancar el backend con:")
print(" uvicorn main:app --reload --host 0.0.0.0 --port 8000")
print()
if __name__ == "__main__":
main()

161
backend/seed.py Normal file
View File

@@ -0,0 +1,161 @@
"""
Script para poblar la base de datos con usuarios demo (ciudadano + empleado + admin).
Uso: python seed.py
"""
from sqlalchemy import text
from app.database import Base, engine, SessionLocal
from app.models import User, Address, Report, ServiceRating
from app.models.user import UserRole
from app.services.auth_service import hash_password
from app.services.eta_service import assign_route
import uuid
from datetime import datetime, timedelta
# Asegurar que todas las tablas existen (creará las nuevas columnas si no existen)
Base.metadata.create_all(bind=engine)
# Migración manual: si la BD ya existía sin la columna `role`, añadirla
with engine.connect() as conn:
try:
conn.execute(text("ALTER TABLE users ADD COLUMN role VARCHAR DEFAULT 'CIUDADANO'"))
conn.commit()
print("✓ Columna 'role' añadida a tabla users")
except Exception:
pass # Ya existía
db = SessionLocal()
# ─── Limpiar TODO para empezar fresco ────────────────────────────────────
print("Limpiando datos previos...")
db.query(ServiceRating).delete()
db.query(Report).delete()
db.query(Address).delete()
db.query(User).delete()
db.commit()
# ─── Definir usuarios demo ───────────────────────────────────────────────
USERS = [
{
"full_name": "María González Demo",
"email": "demo@celaya.gob.mx",
"phone": "461-123-4567",
"password": "Celaya2026",
"role": UserRole.CIUDADANO.value,
"addresses": [
{"label": "Casa", "street": "Calle Hidalgo 245", "colony": "Centro", "lat": 20.5215, "lng": -100.8142, "is_default": True},
{"label": "Trabajo", "street": "Av. Tecnológico 1500", "colony": "Ciudad Industrial", "lat": 20.5450, "lng": -100.7950, "is_default": False},
],
},
{
"full_name": "Carlos Hernández (Empleado)",
"email": "empleado@celaya.gob.mx",
"phone": "461-200-1000",
"password": "Empleado2026",
"role": UserRole.EMPLEADO.value,
"addresses": [],
},
{
"full_name": "Lic. Patricia Ramírez (Admin)",
"email": "admin@celaya.gob.mx",
"phone": "461-100-0001",
"password": "Admin2026",
"role": UserRole.ADMIN.value,
"addresses": [],
},
]
created_users = {}
for u in USERS:
user = User(
full_name=u["full_name"],
email=u["email"],
phone=u["phone"],
hashed_password=hash_password(u["password"]),
role=u["role"],
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
created_users[u["role"]] = user
print(f"{u['role']}: {u['email']} (id={user.id})")
for addr in u["addresses"]:
route_id = assign_route(addr["lat"], addr["lng"])
a = Address(
user_id=user.id,
label=addr["label"],
street=addr["street"],
colony=addr["colony"],
city="Celaya",
lat=addr["lat"],
lng=addr["lng"],
route_id=route_id,
is_default=addr["is_default"],
)
db.add(a)
db.commit()
print(f" 📍 {addr['label']} en {addr['street']}{route_id}")
# ─── Reportes demo del ciudadano ─────────────────────────────────────────
ciudadano = created_users[UserRole.CIUDADANO.value]
addresses = db.query(Address).filter(Address.user_id == ciudadano.id).all()
reportes_demo = [
{"addr": 0, "type": "RETRASO", "desc": "El camión pasó 40 min tarde el viernes", "status": "RESUELTO", "days": 5},
{"addr": 0, "type": "NO_PASO", "desc": "Hoy el camión no pasó por la cuadra", "status": "EN_PROCESO", "days": 2},
{"addr": 1, "type": "ACUMULACION", "desc": "Basura acumulada en la esquina", "status": "PENDIENTE", "days": 1},
{"addr": 0, "type": "OTRO", "desc": "Solicito información sobre día festivo", "status": "PENDIENTE", "days": 0},
]
for r in reportes_demo:
folio = f"MRL-{datetime.utcnow().strftime('%Y%m%d')}-{str(uuid.uuid4())[:6].upper()}"
rep = Report(
user_id=ciudadano.id,
address_id=addresses[r["addr"]].id,
folio=folio,
report_type=r["type"],
description=r["desc"],
status=r["status"],
)
db.add(rep)
db.commit()
db.refresh(rep)
rep.created_at = datetime.utcnow() - timedelta(days=r["days"])
db.commit()
print(f" 📋 {folio} | {r['type']} | {r['status']}")
# Calificación
rating = ServiceRating(
user_id=ciudadano.id,
address_id=addresses[0].id,
rating=4,
comment="El servicio mejoró este mes, gracias",
)
db.add(rating)
db.commit()
print(f" ⭐ Calificación 4★")
db.close()
# ─── Resumen ─────────────────────────────────────────────────────────────
print()
print("" * 65)
print(" ✅ BASE DE DATOS POBLADA")
print("" * 65)
print()
print(" CREDENCIALES DE DEMO:")
print()
print(" 🧑 CIUDADANO")
print(" 📧 demo@celaya.gob.mx")
print(" 🔑 Celaya2026")
print()
print(" 👮 EMPLEADO (ve y resuelve reportes)")
print(" 📧 empleado@celaya.gob.mx")
print(" 🔑 Empleado2026")
print()
print(" 🛡️ ADMIN (acceso total + gestión de usuarios)")
print(" 📧 admin@celaya.gob.mx")
print(" 🔑 Admin2026")
print()

356
backend/seed_massive.py Normal file
View File

@@ -0,0 +1,356 @@
"""
Inserción masiva en la base de datos con datos realistas de Celaya.
Cantidades:
- 200 usuarios (185 ciudadanos + 13 empleados + 2 admins)
- 62 camiones (CEL-001 ... CEL-062)
- ~340 domicilios (1-3 por ciudadano)
- ~280 reportes ciudadanos con varios estados y fechas
- ~150 reportes operativos de empleados
- ~220 calificaciones de servicio
Conserva las credenciales demo:
- demo@celaya.gob.mx / Celaya2026 (CIUDADANO)
- empleado@celaya.gob.mx / Empleado2026 (EMPLEADO)
- admin@celaya.gob.mx / Admin2026 (ADMIN)
"""
import random
import uuid
from datetime import datetime, timedelta
from sqlalchemy import text
from app.database import Base, engine, SessionLocal
from app.models import User, Address, Report, ServiceRating, OperationalReport, Truck
from app.models.user import UserRole
from app.services.auth_service import hash_password
from app.services.eta_service import assign_route
random.seed(42) # Reproducible
# ─── Crear tablas y migrar columnas faltantes ────────────────────────────────
Base.metadata.create_all(bind=engine)
with engine.connect() as conn:
for sql in [
"ALTER TABLE users ADD COLUMN role VARCHAR DEFAULT 'CIUDADANO'",
]:
try: conn.execute(text(sql)); conn.commit()
except Exception: pass
db = SessionLocal()
# ─── Datos realistas ─────────────────────────────────────────────────────────
NOMBRES_M = ['María','Guadalupe','Ana','Patricia','Carmen','Rosa','Laura','Sandra','Verónica','Adriana','Claudia','Mónica','Cecilia','Gabriela','Lourdes','Isabel','Yolanda','Alejandra','Leticia','Norma','Diana','Karla','Fabiola','Brenda','Lucía','Elena','Beatriz','Andrea','Sofía','Valeria','Daniela','Paola','Karina','Marisol','Magdalena']
NOMBRES_H = ['José','Juan','Pedro','Carlos','Luis','Miguel','Jorge','Francisco','Alejandro','Antonio','Ricardo','Roberto','Daniel','Fernando','Mario','Eduardo','Javier','Sergio','Raúl','Manuel','Rafael','Arturo','Gerardo','Héctor','Óscar','Ignacio','Salvador','Octavio','Rubén','Pablo','Andrés','Diego','Iván','Alberto','Enrique']
APELLIDOS = ['García','Hernández','González','Martínez','López','Rodríguez','Pérez','Sánchez','Ramírez','Cruz','Flores','Gómez','Morales','Vázquez','Reyes','Jiménez','Torres','Díaz','Ruiz','Mendoza','Aguilar','Castro','Romero','Vargas','Herrera','Castillo','Ortiz','Moreno','Rivera','Chávez','Ramos','Guzmán','Mendez','Estrada','Salazar','Núñez','Cervantes','Domínguez','Solís','Avila']
COLONIAS = ['Centro','Las Arboledas','San Juanico','Los Olivos','Rancho Seco','Rumbos de Roque','Ciudad Industrial','Hospital General','Sede UG Sur','Torres Landa','Las Insurgentes','Trojes','Irrigación','La Toscana','San José de Celaya','Tecnológico','Las Américas','Las Hadas','Real del Cid','Pueblitos','Villas del Mineral','Casa Blanca','La Aurora','Insurgentes Norte','Las Plazas','San Isidro','La Esperanza','Villas de la Hacienda','Camino Real','Los Sauces','Jardines','Praderas','Vista Hermosa']
CALLES = ['Hidalgo','Juárez','Morelos','Madero','Allende','Zaragoza','Independencia','5 de Mayo','16 de Septiembre','Reforma','Constituyentes','Insurgentes','Av. del Trabajo','Av. Tecnológico','Av. Torres Landa','Av. Irrigación','Av. Las Torres','Av. Juan Pablo II','Av. Universidad','Calzada de los Olivos','Mariano Escobedo','Vicente Guerrero','Benito Juárez','Cuauhtémoc','Galeana','Aldama','Pino Suárez','Niños Héroes','Emiliano Zapata','Lázaro Cárdenas']
LABELS_DOMICILIO = ['Casa','Trabajo','Casa de mis papás','Negocio','Casa de campo','Departamento','Casa de la abuela','Oficina','Local','Familia']
# Coordenadas reales aproximadas de zonas de Celaya
ZONAS_CELAYA = [
(20.5215, -100.8142), # Centro
(20.5410, -100.8130), # Av. Tecnológico
(20.5290, -100.8320), # San Juanico
(20.5295, -100.7890), # Los Olivos
(20.5020, -100.8350), # Sur
(20.5610, -100.8370), # Roque
(20.5450, -100.7950), # Ciudad Industrial
(20.5245, -100.7980), # Universidad Latina
(20.5260, -100.8520), # Hospital
(20.4990, -100.8390), # UG Sur
(20.5280, -100.8250), # Torres Landa
(20.5320, -100.7980), # Las Insurgentes
(20.5420, -100.8080), # Trojes
(20.5140, -100.8390), # La Toscana
(20.5390, -100.8480), # San José
]
MODELOS_CAMION = [
{'model': 'International 4300', 'capacity': 8000, 'year_min': 2018},
{'model': 'International 4400', 'capacity': 10000, 'year_min': 2019},
{'model': 'Mercedes-Benz Axor 1725', 'capacity': 12000, 'year_min': 2020},
{'model': 'Freightliner M2 106', 'capacity': 9000, 'year_min': 2017},
{'model': 'Ford F-750', 'capacity': 7500, 'year_min': 2018},
{'model': 'Hino 268', 'capacity': 8500, 'year_min': 2019},
{'model': 'Isuzu FTR', 'capacity': 7000, 'year_min': 2020},
{'model': 'Kenworth T270', 'capacity': 9500, 'year_min': 2019},
]
PATIOS = ['Patio Norte','Patio Sur','Patio Centro','Patio Industrial']
STATUS_CAMION = ['OPERATIVO','EN_RUTA','TALLER','MANTENIMIENTO','RESERVA']
STATUS_WEIGHTS = [0.45, 0.30, 0.08, 0.07, 0.10]
DEMO_USERS = [
{'full_name':'María González Demo','email':'demo@celaya.gob.mx','phone':'461-123-4567','password':'Celaya2026','role':UserRole.CIUDADANO.value},
{'full_name':'Carlos Hernández (Empleado)','email':'empleado@celaya.gob.mx','phone':'461-200-1000','password':'Empleado2026','role':UserRole.EMPLEADO.value},
{'full_name':'Lic. Patricia Ramírez (Admin)','email':'admin@celaya.gob.mx','phone':'461-100-0001','password':'Admin2026','role':UserRole.ADMIN.value},
]
# ─── Helpers ─────────────────────────────────────────────────────────────────
def random_name():
nombre = random.choice(NOMBRES_H if random.random() < 0.5 else NOMBRES_M)
return f"{nombre} {random.choice(APELLIDOS)} {random.choice(APELLIDOS)}"
def random_email(name, i):
parts = name.lower().replace('á','a').replace('é','e').replace('í','i').replace('ó','o').replace('ú','u').replace('ñ','n').split()
return f"{parts[0]}.{parts[-1]}{i:03d}@celaya.gob.mx"
def random_phone():
return f"461-{random.randint(100,999)}-{random.randint(1000,9999)}"
def random_coords():
lat0, lng0 = random.choice(ZONAS_CELAYA)
return (lat0 + random.uniform(-0.005, 0.005), lng0 + random.uniform(-0.005, 0.005))
def random_password_hash():
return hash_password(f"Pwd{random.randint(1000,9999)}!")
def random_date_in_last_days(days):
return datetime.utcnow() - timedelta(days=random.randint(0, days), hours=random.randint(0,23), minutes=random.randint(0,59))
# ─── Limpiar ─────────────────────────────────────────────────────────────────
print("🧹 Limpiando datos previos...")
db.query(ServiceRating).delete()
db.query(OperationalReport).delete()
db.query(Report).delete()
db.query(Address).delete()
db.query(User).delete()
db.query(Truck).delete()
db.commit()
# ─── 1) Usuarios DEMO + masivos (200 total) ──────────────────────────────────
print("\n👥 Creando usuarios...")
demo_password_hash = hash_password("xyz") # se sobreescribe
created_users = []
for u in DEMO_USERS:
user = User(
full_name=u['full_name'], email=u['email'], phone=u['phone'],
hashed_password=hash_password(u['password']),
role=u['role'], is_active=True,
)
db.add(user); db.commit(); db.refresh(user)
created_users.append(user)
print(f" ✓ Demo: {u['role']:9} {u['email']}")
# 13 empleados adicionales
for i in range(13):
name = random_name()
user = User(
full_name=name, email=random_email(name, 100+i),
phone=random_phone(), hashed_password=random_password_hash(),
role=UserRole.EMPLEADO.value, is_active=True,
)
db.add(user); db.commit(); db.refresh(user); created_users.append(user)
# 2 admins adicionales
for i in range(2):
name = random_name()
user = User(
full_name=f"Lic. {name}", email=random_email(name, 200+i),
phone=random_phone(), hashed_password=random_password_hash(),
role=UserRole.ADMIN.value, is_active=True,
)
db.add(user); db.commit(); db.refresh(user); created_users.append(user)
# 184 ciudadanos adicionales (total 185 con el demo)
for i in range(184):
name = random_name()
user = User(
full_name=name, email=random_email(name, 300+i),
phone=random_phone(), hashed_password=random_password_hash(),
role=UserRole.CIUDADANO.value, is_active=True,
)
db.add(user); db.commit(); db.refresh(user); created_users.append(user)
n_citizen = sum(1 for u in created_users if u.role == 'CIUDADANO')
n_employee = sum(1 for u in created_users if u.role == 'EMPLEADO')
n_admin = sum(1 for u in created_users if u.role == 'ADMIN')
print(f" ✓ Total: {len(created_users)} ({n_citizen} ciudadanos, {n_employee} empleados, {n_admin} admins)")
# ─── 2) Camiones (62) ────────────────────────────────────────────────────────
print("\n🚛 Creando 62 camiones...")
trucks = []
for i in range(1, 63):
spec = random.choice(MODELOS_CAMION)
status = random.choices(STATUS_CAMION, weights=STATUS_WEIGHTS)[0]
# Asignar ruta solo si está OPERATIVO o EN_RUTA
route_id = f"RUTA-{random.randint(1,15):02d}" if status in ('OPERATIVO','EN_RUTA') and random.random() < 0.7 else None
truck = Truck(
unit_number=f"CEL-{i:03d}",
plate=f"GTO-{random.randint(100,999)}-{random.choice('ABCDEFGHJKLMNP')}",
model=spec['model'],
year=random.randint(spec['year_min'], 2025),
capacity_kg=spec['capacity'] + random.randint(-500, 500),
fuel_type='DIESEL' if random.random() < 0.9 else 'GAS_LP',
status=status,
route_id=route_id,
base=random.choice(PATIOS),
odometer_km=random.randint(15000, 280000),
fuel_level_pct=random.randint(20, 100),
last_maintenance=datetime.utcnow() - timedelta(days=random.randint(5, 180)),
next_maintenance_km=random.randint(5000, 15000) * 1,
)
db.add(truck); trucks.append(truck)
db.commit()
print(f"{len(trucks)} camiones (status: {dict((s, sum(1 for t in trucks if t.status==s)) for s in STATUS_CAMION)})")
# ─── 3) Domicilios (1-3 por ciudadano) ───────────────────────────────────────
print("\n🏠 Creando domicilios...")
ciudadanos = [u for u in created_users if u.role == 'CIUDADANO']
total_addresses = 0
created_addresses = []
for citizen in ciudadanos:
n = random.choices([1,1,1,2,2,3], k=1)[0] # mayoría 1, algunos 2-3
for j in range(n):
lat, lng = random_coords()
route_id = assign_route(lat, lng)
addr = Address(
user_id=citizen.id,
label=random.choice(LABELS_DOMICILIO),
street=f"{random.choice(CALLES)} {random.randint(1,1500)}",
colony=random.choice(COLONIAS),
city="Celaya",
lat=lat, lng=lng,
route_id=route_id,
is_default=(j == 0),
)
db.add(addr); created_addresses.append(addr)
total_addresses += 1
db.commit()
for a in created_addresses: db.refresh(a)
print(f"{total_addresses} domicilios creados")
# ─── 4) Reportes ciudadanos (~280) ───────────────────────────────────────────
print("\n📋 Creando reportes ciudadanos...")
TYPES = ['NO_PASO','RETRASO','ACUMULACION','OTRO']
TYPE_WEIGHTS = [0.35, 0.30, 0.25, 0.10]
STATUSES = ['PENDIENTE','EN_PROCESO','RESUELTO','CERRADO']
STATUS_W = [0.30, 0.20, 0.40, 0.10]
DESCRIPCIONES = {
'NO_PASO': ['El camión no pasó por mi calle.', 'Hoy no escuché el camión.', 'No vi pasar el camión en su horario.', 'El camión saltó nuestra cuadra.', 'Llevamos 2 días sin recolección.'],
'RETRASO': ['Pasó más de una hora tarde.', 'Llegó cuando ya nos íbamos a trabajar.', 'El retraso es de cada vez peor en la semana.', 'Llegó casi a mediodía.', 'Estuvo retrasado más de 90 minutos.'],
'ACUMULACION': ['Hay basura acumulada en la esquina.', 'Bolsas regadas en la calle desde hace días.', 'Acumulación que atrae perros.', 'Mal olor por basura acumulada.', 'Bote desbordado en parada de camión.'],
'OTRO': ['Solicito información sobre días festivos.', 'Pregunta sobre separación de residuos.', 'Comentario general sobre el servicio.', '¿Cuándo van a pasar por electrónicos?', 'Felicitación al equipo del camión.'],
}
n_reports = 0
for _ in range(280):
if not created_addresses: break
addr = random.choice(created_addresses)
rtype = random.choices(TYPES, weights=TYPE_WEIGHTS)[0]
status = random.choices(STATUSES, weights=STATUS_W)[0]
folio = f"MRL-{datetime.utcnow().strftime('%Y%m%d')}-{str(uuid.uuid4())[:6].upper()}"
rep = Report(
user_id=addr.user_id,
address_id=addr.id,
folio=folio,
report_type=rtype,
description=random.choice(DESCRIPCIONES[rtype]),
status=status,
)
db.add(rep); db.flush()
rep.created_at = random_date_in_last_days(60)
n_reports += 1
db.commit()
print(f"{n_reports} reportes ciudadanos")
# ─── 5) Reportes operativos de empleados (~150) ──────────────────────────────
print("\n🚧 Creando reportes operativos...")
empleados = [u for u in created_users if u.role == 'EMPLEADO']
CATEGORIES = ['NO_ARRANQUE','FALLA_MECANICA','ACCIDENTE','OBSTACULO','TRAFICO','COMBUSTIBLE','CLIMA','OTRO']
CAT_W = [0.10, 0.20, 0.05, 0.15, 0.20, 0.10, 0.15, 0.05]
SEVERIDADES = ['BAJA','MEDIA','ALTA']
SEV_W = [0.45, 0.40, 0.15]
OP_STATUSES = ['REPORTADO','EN_ATENCION','RESUELTO']
OP_STATUS_W = [0.25, 0.20, 0.55]
DESC_OP = {
'NO_ARRANQUE': ['Batería descargada esta mañana.', 'No prendió, parece arranque.', 'Marcha fallada, intentos múltiples.'],
'FALLA_MECANICA': ['Frenos haciendo ruido.', 'Pérdida de aceite visible.', 'Falla en la transmisión.', 'Sobrecalentamiento del motor.', 'Ruido extraño en la dirección.'],
'ACCIDENTE': ['Golpe leve con vehículo particular en Av. Tecnológico.', 'Raspón con poste, sin lesiones.', 'Choque sin víctimas, esperando ajustador.'],
'OBSTACULO': ['Vehículo bloqueando paso en colonia.', 'Calle inundada, no se puede acceder.', 'Árbol caído atravesando la calle.', 'Obra municipal cerrando la ruta.'],
'TRAFICO': ['Tráfico inusual por evento religioso.', 'Manifestación cerrando avenida principal.', 'Embotellamiento por accidente ajeno.'],
'COMBUSTIBLE': ['Tanque bajo, requiere recarga.', 'Bomba de combustible reportando fallas.'],
'CLIMA': ['Lluvia intensa, suspendido temporalmente.', 'Granizada en la zona alta.', 'Viento fuerte que dificulta operación.'],
'OTRO': ['Comentario al supervisor.', 'Solicitud de mantenimiento preventivo.'],
}
n_op = 0
for _ in range(150):
if not empleados: break
employee = random.choice(empleados)
cat = random.choices(CATEGORIES, weights=CAT_W)[0]
sev = random.choices(SEVERIDADES, weights=SEV_W)[0]
st = random.choices(OP_STATUSES, weights=OP_STATUS_W)[0]
folio = f"OP-{datetime.utcnow().strftime('%Y%m%d')}-{str(uuid.uuid4())[:6].upper()}"
truck = random.choice(trucks)
rep = OperationalReport(
employee_id=employee.id,
folio=folio,
category=cat,
description=random.choice(DESC_OP[cat]),
severity=sev,
route_id=truck.route_id or f"RUTA-{random.randint(1,15):02d}",
truck_id=truck.id,
status=st,
)
db.add(rep); db.flush()
rep.created_at = random_date_in_last_days(45)
if st == 'RESUELTO':
rep.resolved_at = rep.created_at + timedelta(hours=random.randint(2, 72))
n_op += 1
db.commit()
print(f"{n_op} reportes operativos")
# ─── 6) Calificaciones de servicio (~220) ────────────────────────────────────
print("\n⭐ Creando calificaciones...")
COMENTARIOS_POS = ['Servicio puntual, gracias.', 'Buen trabajo del equipo.', 'Mejoró mucho este mes.', 'Excelente atención.', None, None, None]
COMENTARIOS_NEG = ['Llegaron tarde varias veces.', 'Falta limpieza después del paso.', 'Hicieron mucho ruido.', None, None]
n_ratings = 0
for _ in range(220):
if not created_addresses: break
addr = random.choice(created_addresses)
rating_val = random.choices([5,4,3,2,1], weights=[0.45, 0.30, 0.15, 0.07, 0.03])[0]
if rating_val >= 4:
comment = random.choice(COMENTARIOS_POS)
elif rating_val == 3:
comment = None
else:
comment = random.choice(COMENTARIOS_NEG)
rating = ServiceRating(
user_id=addr.user_id,
address_id=addr.id,
rating=rating_val,
comment=comment,
)
db.add(rating); db.flush()
rating.created_at = random_date_in_last_days(45)
n_ratings += 1
db.commit()
print(f"{n_ratings} calificaciones")
db.close()
# ─── Resumen final ───────────────────────────────────────────────────────────
print()
print("" * 65)
print(" ✅ INSERCIÓN MASIVA COMPLETADA")
print("" * 65)
print(f" 👥 Usuarios: {len(created_users):>5}")
print(f" - Ciudadanos: {n_citizen:>5}")
print(f" - Empleados: {n_employee:>5}")
print(f" - Admins: {n_admin:>5}")
print(f" 🏠 Domicilios: {total_addresses:>5}")
print(f" 🚛 Camiones: {len(trucks):>5}")
print(f" 📋 Reportes ciudadanos: {n_reports:>5}")
print(f" 🚧 Reportes operativos: {n_op:>5}")
print(f" ⭐ Calificaciones servicio: {n_ratings:>5}")
print()
print(" CREDENCIALES DEMO (sin cambios):")
print(" 🧑 demo@celaya.gob.mx / Celaya2026")
print(" 👮 empleado@celaya.gob.mx / Empleado2026")
print(" 🛡️ admin@celaya.gob.mx / Admin2026")
print()