Compare commits
2 Commits
p2-notific
...
p4-fronten
| Author | SHA1 | Date | |
|---|---|---|---|
| 602a1a7d74 | |||
| e3f659cac8 |
BIN
.gitignore
vendored
BIN
.gitignore
vendored
Binary file not shown.
@@ -1,33 +0,0 @@
|
|||||||
# Notification Service — Contrato de API
|
|
||||||
Base URL: http://localhost:8001
|
|
||||||
|
|
||||||
## Para P1 (Backend)
|
|
||||||
### Avisar que el camión avanzó
|
|
||||||
POST /internal/position-update
|
|
||||||
Body: { "routeId": "RUTA-01", "positionId": 2 }
|
|
||||||
Respuesta: { "status": "ok", "routeId": "RUTA-01", "positionId": 2 }
|
|
||||||
|
|
||||||
## Para P3 (App Android)
|
|
||||||
### Registrar token FCM del usuario
|
|
||||||
POST /fcm-token
|
|
||||||
Body: { "colonia": "Zona Centro", "token": "TOKEN_FCM_DEL_CELULAR" }
|
|
||||||
Respuesta: { "status": "ok" }
|
|
||||||
|
|
||||||
### Consultar ETA de la ruta del usuario
|
|
||||||
GET /eta/RUTA-01
|
|
||||||
Respuesta:
|
|
||||||
{
|
|
||||||
"routeId": "RUTA-01",
|
|
||||||
"positionId": 3,
|
|
||||||
"eta_window": "06:38 – 07:00",
|
|
||||||
"message": "El camión llegará a tu zona entre las 06:38 y 07:00",
|
|
||||||
"minutes_approx": 13
|
|
||||||
}
|
|
||||||
|
|
||||||
## Para el demo
|
|
||||||
### Reiniciar todas las rutas
|
|
||||||
POST /internal/reset
|
|
||||||
|
|
||||||
### Forzar trigger en vivo
|
|
||||||
POST /internal/demo
|
|
||||||
Body: { "routeId": "RUTA-01", "positionId": 4 }
|
|
||||||
BIN
__pycache__/main.cpython-314.pyc
Normal file
BIN
__pycache__/main.cpython-314.pyc
Normal file
Binary file not shown.
135
alertas.html
Normal file
135
alertas.html
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Configurar alertas</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: Arial, sans-serif; background: #f5f5f5; color: #222; }
|
||||||
|
.header { background: #6a1b9a; color: white; padding: 20px 16px 16px; }
|
||||||
|
.header p { font-size: 13px; opacity: 0.85; margin-top: 4px; }
|
||||||
|
.seccion { background: white; margin: 16px 16px 0; border-radius: 12px; padding: 4px 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
|
||||||
|
.item { display: flex; align-items: center; justify-content: space-between; padding: 16px 0; border-bottom: 1px solid #f0f0f0; }
|
||||||
|
.item:last-child { border-bottom: none; }
|
||||||
|
.item-info { display: flex; align-items: center; gap: 14px; }
|
||||||
|
.icono { width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 18px; }
|
||||||
|
.item-texto strong { display: block; font-size: 14px; font-weight: 600; }
|
||||||
|
.item-texto span { font-size: 12px; color: #777; }
|
||||||
|
.toggle { position: relative; width: 50px; height: 28px; }
|
||||||
|
.toggle input { opacity: 0; width: 0; height: 0; }
|
||||||
|
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background: #ccc; border-radius: 28px; transition: 0.3s; }
|
||||||
|
.slider:before { position: absolute; content: ""; height: 22px; width: 22px; left: 3px; bottom: 3px; background: white; border-radius: 50%; transition: 0.3s; }
|
||||||
|
input:checked + .slider { background: #6a1b9a; }
|
||||||
|
input:checked + .slider:before { transform: translateX(22px); }
|
||||||
|
.guardado { display: none; margin: 12px 16px; background: #f3e5f5; border-radius: 10px; padding: 12px 16px; font-size: 13px; color: #6a1b9a; text-align: center; }
|
||||||
|
.btn { display: block; width: calc(100% - 32px); margin: 12px 16px 80px; padding: 14px; background: #6a1b9a; color: white; border: none; border-radius: 10px; font-size: 16px; font-weight: 600; cursor: pointer; }
|
||||||
|
.warning-bar { margin: 0 16px 16px; background: #fff8e1; border-radius: 10px; padding: 12px 14px; font-size: 13px; color: #e65100; }
|
||||||
|
.menu { position:fixed; bottom:0; left:0; right:0; background:white; border-top:1px solid #ddd; display:flex; justify-content:space-around; padding:10px 0; }
|
||||||
|
.menu a { text-decoration:none; text-align:center; }
|
||||||
|
.menu div { font-size:22px; }
|
||||||
|
.menu span { font-size:11px; display:block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1 style="font-size:20px; font-weight:600;">Configurar alertas</h1>
|
||||||
|
<p>Elige cuándo quieres recibir notificaciones</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warning-bar">
|
||||||
|
🔔 Las alertas te ayudan a sacar la basura a tiempo sin esperar afuera.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="seccion">
|
||||||
|
<div class="item">
|
||||||
|
<div class="item-info">
|
||||||
|
<div class="icono" style="background:#e8f5e9;">🚛</div>
|
||||||
|
<div class="item-texto">
|
||||||
|
<strong>Ruta por iniciar</strong>
|
||||||
|
<span>Aviso cuando el camión salga del depósito</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="a1" checked onchange="guardar()">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<div class="item-info">
|
||||||
|
<div class="icono" style="background:#e3f2fd;">📍</div>
|
||||||
|
<div class="item-texto">
|
||||||
|
<strong>Camión aproximándose</strong>
|
||||||
|
<span>Aviso 15 minutos antes de llegar</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="a2" checked onchange="guardar()">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<div class="item-info">
|
||||||
|
<div class="icono" style="background:#ffebee;">⚠️</div>
|
||||||
|
<div class="item-texto">
|
||||||
|
<strong>Retrasos o fallas</strong>
|
||||||
|
<span>Aviso si el camión tiene un problema</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="a3" checked onchange="guardar()">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<div class="item-info">
|
||||||
|
<div class="icono" style="background:#ede7f6;">🌙</div>
|
||||||
|
<div class="item-texto">
|
||||||
|
<strong>Ruta nocturna</strong>
|
||||||
|
<span>Servicio especial desde las 10:00 p.m.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="a4" onchange="guardar()">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="guardado" id="guardado">✅ Preferencias guardadas correctamente</div>
|
||||||
|
<button class="btn" onclick="guardar()">Guardar preferencias</button>
|
||||||
|
|
||||||
|
<nav class="menu">
|
||||||
|
<a href="/guia" style="color:#888;">
|
||||||
|
<div>♻️</div>
|
||||||
|
<span>Guía</span>
|
||||||
|
</a>
|
||||||
|
<a href="/reportes" style="color:#888;">
|
||||||
|
<div>📋</div>
|
||||||
|
<span>Reportes</span>
|
||||||
|
</a>
|
||||||
|
<a href="/alertas" style="color:#6a1b9a;">
|
||||||
|
<div>🔔</div>
|
||||||
|
<span>Alertas</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const ids = ["a1","a2","a3","a4"];
|
||||||
|
window.onload = function() {
|
||||||
|
ids.forEach(id => {const val = localStorage.getItem(id);
|
||||||
|
if (val !== null) document.getElementById(id).checked = val === "true";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
function guardar() {
|
||||||
|
ids.forEach(id => {
|
||||||
|
localStorage.setItem(id, document.getElementById(id).checked);
|
||||||
|
});
|
||||||
|
const msg = document.getElementById("guardado");
|
||||||
|
msg.style.display = "block";
|
||||||
|
setTimeout(() => msg.style.display = "none", 2000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
91
guia.html
Normal file
91
guia.html
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Guia de Separacion de Residuos</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: Arial, sans-serif; background: #f5f5f5; color: #222; padding-bottom: 70px; }
|
||||||
|
.header { background: #2e7d32; color: white; padding: 20px 16px 16px; }
|
||||||
|
.header p { font-size: 13px; opacity: 0.85; margin-top: 4px; }
|
||||||
|
.categories { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 16px; }
|
||||||
|
.cat-card { background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
|
||||||
|
.cat-icon { width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-bottom: 10px; font-size: 22px; }
|
||||||
|
.cat-title { font-size: 15px; font-weight: 600; margin-bottom: 3px; }
|
||||||
|
.cat-sub { font-size: 12px; color: #666; margin-bottom: 10px; }
|
||||||
|
.tag { display: inline-block; font-size: 11px; padding: 3px 8px; border-radius: 20px; margin: 2px 2px 2px 0; }
|
||||||
|
.green .cat-icon { background: #e8f5e9; }
|
||||||
|
.green .cat-title { color: #2e7d32; }
|
||||||
|
.green .tag { background: #e8f5e9; color: #2e7d32; }
|
||||||
|
.blue .cat-icon { background: #e3f2fd; }
|
||||||
|
.blue .cat-title { color: #1565c0; }
|
||||||
|
.blue .tag { background: #e3f2fd; color: #1565c0; }
|
||||||
|
.red .cat-icon { background: #ffebee; }
|
||||||
|
.red .cat-title { color: #c62828; }
|
||||||
|
.red .tag { background: #ffebee; color: #c62828; }
|
||||||
|
.amber .cat-icon { background: #fff8e1; }
|
||||||
|
.amber .cat-title { color: #e65100; }
|
||||||
|
.amber .tag { background: #fff8e1; color: #e65100; }
|
||||||
|
.warning-bar { margin: 0 16px 16px; background: #fff8e1; border-radius: 10px; padding: 12px 14px; font-size: 13px; color: #e65100; }
|
||||||
|
.menu { position: fixed; bottom: 0; left: 0; right: 0; background: white; border-top: 2px solid #ddd; display: flex; justify-content: space-around; padding: 12px 0; z-index: 9999; }
|
||||||
|
.menu a { text-decoration: none; text-align: center; font-size: 13px; font-weight: 600; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<h1 style="font-size:20px; font-weight:600;">Guia de separacion</h1>
|
||||||
|
<p>Aprende a clasificar tus residuos correctamente</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="categories">
|
||||||
|
<div class="cat-card green">
|
||||||
|
<div class="cat-icon" style="background:#e8f5e9;">🌿</div>
|
||||||
|
<div class="cat-title">Organicos</div>
|
||||||
|
<div class="cat-sub">Se descomponen naturalmente</div>
|
||||||
|
<span class="tag">Cascaras de fruta</span>
|
||||||
|
<span class="tag">Restos de comida</span>
|
||||||
|
<span class="tag">Cafe y te</span>
|
||||||
|
<span class="tag">Cascaras de huevo</span>
|
||||||
|
</div>
|
||||||
|
<div class="cat-card blue">
|
||||||
|
<div class="cat-icon" style="background:#e3f2fd;">♻</div>
|
||||||
|
<div class="cat-title">Reciclables</div>
|
||||||
|
<div class="cat-sub">Limpios y secos</div>
|
||||||
|
<span class="tag">Botellas PET</span>
|
||||||
|
<span class="tag">Latas de aluminio</span>
|
||||||
|
<span class="tag">Carton limpio</span>
|
||||||
|
<span class="tag">Vidrio</span>
|
||||||
|
</div>
|
||||||
|
<div class="cat-card red">
|
||||||
|
<div class="cat-icon" style="background:#ffebee;">✖</div>
|
||||||
|
<div class="cat-title">Sanitarios</div>
|
||||||
|
<div class="cat-sub">No se reciclan</div>
|
||||||
|
<span class="tag">Panales</span>
|
||||||
|
<span class="tag">Toallas sanitarias</span>
|
||||||
|
<span class="tag">Cubrebocas</span>
|
||||||
|
<span class="tag">Papel higienico</span>
|
||||||
|
</div>
|
||||||
|
<div class="cat-card amber">
|
||||||
|
<div class="cat-icon" style="background:#fff8e1;">!</div>
|
||||||
|
<div class="cat-title">Especiales</div>
|
||||||
|
<div class="cat-sub">Requieren manejo especial</div>
|
||||||
|
<span class="tag">Pilas y baterias</span>
|
||||||
|
<span class="tag">Medicamentos</span>
|
||||||
|
<span class="tag">Electronicos</span>
|
||||||
|
<span class="tag">Aceite usado</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="warning-bar">
|
||||||
|
Saca tu basura solo cuando recibas la alerta. No persigas al camion.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="menu">
|
||||||
|
<a href="/guia" style="color:#2e7d32;">Guia</a>
|
||||||
|
<a href="/reportes" style="color:#1565c0;">Reportes</a>
|
||||||
|
<a href="/alertas" style="color:#6a1b9a;">Alertas</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
main.py
Normal file
29
main.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
reportes = []
|
||||||
|
|
||||||
|
@app.get("/guia", response_class=HTMLResponse)
|
||||||
|
def guia_separacion():
|
||||||
|
with open("guia.html", "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
@app.get("/reportes", response_class=HTMLResponse)
|
||||||
|
def pagina_reportes():
|
||||||
|
with open("reportes.html", "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
@app.get("/alertas", response_class=HTMLResponse)
|
||||||
|
def pagina_alertas():
|
||||||
|
with open("alertas.html", "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
@app.post("/api/reportes")
|
||||||
|
async def recibir_reporte(request: Request):
|
||||||
|
datos = await request.json()
|
||||||
|
datos["fecha"] = datetime.now().strftime("%d/%m/%Y %H:%M")
|
||||||
|
reportes.append(datos)
|
||||||
|
return {"mensaje": "Reporte recibido correctamente", "total": len(reportes)}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
FIREBASE_CREDENTIALS=firebase-key.json
|
|
||||||
BACKEND_URL=http://localhost:3000
|
|
||||||
SECRET_KEY=192837465
|
|
||||||
BIN
notification-service/.gitignore
vendored
BIN
notification-service/.gitignore
vendored
Binary file not shown.
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"type": "service_account",
|
|
||||||
"project_id": "basura-celaya",
|
|
||||||
"private_key_id": "e43f2a4f10142b5275335a1bc244c4b315101e4e",
|
|
||||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC/9NfYMoFg88Vs\nOhagjaIRSAOvNjI3LUDoPQDeCOxjLTJdqcOXmNp4/H0S8f2VaurJTEgQNiNwzuga\n/EpKvc9FAaxRCy073wqpQ/c5bMMcqxRZPTMpXMW2d4kp6O+4oYycZMNiXhk547kQ\niyY4uFomgX0lUqdgt4IYCR4oBTwwDr73NCCTN+2b6SVQpnOmpc2CM76yYNtkWwxa\nBQ4p9TDdCgxdgtT13xs0xM+jm3DapN25FJYNW8SeSowSbuHnaGXVgBjBFSoxzZ3i\nFQ7Z2QIyV+TuUpAkiUshoEZC7IEl3hwb8DrSInkmz36WBhDO8ReUXZXadUFQJahD\n4+S9kQrnAgMBAAECggEAAgCgM82AaK4s/rkIaA+yLh34iTO2iGQndi9qD+bl7bmY\npJN9yoS6BWMU5vAGpjAMV6Hnv+Dgs8TkyktGzHV4cQ7YaQFbLRxhicHUwBDwuXQr\nbSw57rzcryTwwF2BYAVcvdDrR4kVwvMriLmljMKXkNPu+0cGdKLVb5drbMgeiEh1\nHRv0QyQl7ue3Xufa9ydrfLya1vords+o4BLcuE9yJZA09hte62BcAsQqXb/gzINj\nZUiJZkOCQt6RLPYYfWOJV+pi2roOboHUqWHW5LQziXTxmK409lvZnpCTFLb1ppz0\nL4MYi5D3dyBwCwJaE86XtJG98M0Kytz+LgWcR2nDyQKBgQD1+bacMm3azKTSlh9B\npmKXvik4bwNjLPeWn75WQV8GlYQITL4vBTOatGgEUspwoCnj9eIyu4C9aL59uHDR\nnaAJuXK9A8OfTBsaghDqFJMANoCVfXlhPxEZrMCZBzPrwOZ0eKoRLSmFVlenF2oB\n0QbulCrOwwH+ePBy696Ujjj6lQKBgQDHx4s7ku/dfiRkOEJYMQvs8FEUdimBVcws\nj1AIOee/zpFtayLgEyPONydSpjmEY1mu/sKLtd5TlJjlNrFByLY8nHcqJRMzEIlC\no9qZ5TDywl63jeEuL253MuXM9A+Rr2GzMjWKYSFBqk92WCSTfPoxzVrnF1T1PWN/\n2qh2HtoMiwKBgHtS2cVyWzWqCLE0ZzNpEmF2DACpWA9vSisQqENivxvz9qCaqXe1\nqevUq5oPUEQraRVMAD7jV2afj3JE+Pt/he+aNPajXn8Nj0E5GPXjntgqe0l4AVVK\nY251+JJA1D1NF74piUrXU8vwQD4cNR/4Bvuy+ct0ZhmJ1TQpIg1lSRgJAoGAOaQk\nUwsBNDn6DASDd+im1TU9X5b8QLndkBnFcKosaJYUNarMxDQhh5U4PkuBmuYDcU9G\nGINf42Ojfbb7C8z6b6CBbWKHGJuzzstx/ic3qUNVisZf6zB6QeAol6rvdwxQNyDM\ne+Gsc8LM7Itf+kH7+jSS/swnkh6lP7V6F6KtLSMCgYAD4IeJM8p1J/Utxe7sxKpj\nlIKVJ1XoibzPC4jm+rHiTdn3c8vlgT7IRn8QEI0HLrdMpHZ+fq98lLCa/rxTIpoD\nc8SoWEM901cEUGUP0u2PaIXIpwPMSsVkfhHmrU7jsMm9FibhELRhxsK1BJJ2Im/z\nocJZlfTWSyBV1HzCAwtOhg==\n-----END PRIVATE KEY-----\n",
|
|
||||||
"client_email": "firebase-adminsdk-fbsvc@basura-celaya.iam.gserviceaccount.com",
|
|
||||||
"client_id": "107422889923199906515",
|
|
||||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
||||||
"token_uri": "https://oauth2.googleapis.com/token",
|
|
||||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
|
||||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40basura-celaya.iam.gserviceaccount.com",
|
|
||||||
"universe_domain": "googleapis.com"
|
|
||||||
}
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
import os, json, time, threading, schedule
|
|
||||||
from datetime import datetime
|
|
||||||
import firebase_admin
|
|
||||||
from firebase_admin import credentials, messaging
|
|
||||||
from fastapi import FastAPI, Header, HTTPException
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
# ── Firebase init ──────────────────────────────────────────
|
|
||||||
cred = credentials.Certificate(os.getenv("FIREBASE_CREDENTIALS"))
|
|
||||||
firebase_admin.initialize_app(cred)
|
|
||||||
|
|
||||||
app = FastAPI(title="Notification Service")
|
|
||||||
|
|
||||||
# ── Carga los JSON del municipio ───────────────────────────
|
|
||||||
with open("../JSON/rutas.json") as f: RUTAS = {r["routeId"]: r for r in json.load(f)}
|
|
||||||
with open("../JSON/colonias.json") as f: COLONIAS = json.load(f)
|
|
||||||
with open("../JSON/notificaciones.json") as f: NOTIF_TEMPLATES = json.load(f)
|
|
||||||
|
|
||||||
# ── Estado en memoria (Redis en producción) ─────────────────
|
|
||||||
# { routeId: { positionId, notified: set() } }
|
|
||||||
route_state: dict = {}
|
|
||||||
|
|
||||||
# Simulación: tokens FCM de usuarios por colonia
|
|
||||||
# En producción esto viene de la BD de P1
|
|
||||||
fake_users = {
|
|
||||||
"Zona Centro": ["TOKEN_TEST_1"],
|
|
||||||
"Las Arboledas": ["TOKEN_TEST_1"],
|
|
||||||
"Trojes": ["TOKEN_TEST_2"],
|
|
||||||
"San Juanico": [],
|
|
||||||
"Los Olivos": [],
|
|
||||||
"Rancho Seco": [],
|
|
||||||
"Las Insurgentes":[],
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Helpers ─────────────────────────────────────────────────
|
|
||||||
def get_colonias_for_route(route_id: str) -> list:
|
|
||||||
return [c["colonia"] for c in COLONIAS if c["routeId"] == route_id]
|
|
||||||
|
|
||||||
def get_tokens_for_route(route_id: str) -> list:
|
|
||||||
tokens = []
|
|
||||||
for colonia in get_colonias_for_route(route_id):
|
|
||||||
tokens += fake_users.get(colonia, [])
|
|
||||||
return tokens
|
|
||||||
|
|
||||||
def get_template(trigger_event: str) -> dict:
|
|
||||||
for t in NOTIF_TEMPLATES:
|
|
||||||
if t["triggerEvent"] == trigger_event:
|
|
||||||
return t["pushPayload"]
|
|
||||||
return None
|
|
||||||
|
|
||||||
def send_push(tokens: list, title: str, body: str, data: dict = {}):
|
|
||||||
"""Envía push a una lista de FCM tokens."""
|
|
||||||
if not tokens:
|
|
||||||
print(f" [FCM] Sin tokens para notificar")
|
|
||||||
return
|
|
||||||
for token in tokens:
|
|
||||||
try:
|
|
||||||
message = messaging.Message(
|
|
||||||
notification=messaging.Notification(title=title, body=body),
|
|
||||||
data={k: str(v) for k, v in data.items()},
|
|
||||||
android=messaging.AndroidConfig(
|
|
||||||
priority="high",
|
|
||||||
notification=messaging.AndroidNotification(
|
|
||||||
channel_id="truck_alerts",
|
|
||||||
sound="default"
|
|
||||||
)
|
|
||||||
),
|
|
||||||
token=token,
|
|
||||||
)
|
|
||||||
messaging.send(message)
|
|
||||||
print(f" [FCM] ✓ Enviado a token ...{token[-6:]}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" [FCM] ✗ Error con token {token}: {e}")
|
|
||||||
|
|
||||||
# ── Lógica de ETA ───────────────────────────────────────────
|
|
||||||
def calculate_eta(route_id: str, current_position_id: int) -> dict:
|
|
||||||
"""
|
|
||||||
Devuelve ventana de llegada basada en timestamps del JSON.
|
|
||||||
positionId 4 = punto de proximidad (~15 min del domicilio)
|
|
||||||
"""
|
|
||||||
ruta = RUTAS.get(route_id)
|
|
||||||
if not ruta:
|
|
||||||
return {"eta": "No disponible"}
|
|
||||||
|
|
||||||
positions = ruta["positions"]
|
|
||||||
current = next((p for p in positions if p["positionId"] == current_position_id), None)
|
|
||||||
next_pos = next((p for p in positions if p["positionId"] == current_position_id + 1), None)
|
|
||||||
|
|
||||||
if not current:
|
|
||||||
return {"eta": "No disponible"}
|
|
||||||
|
|
||||||
# Convierte timestamps a hora local (ajusta timezone si es necesario)
|
|
||||||
fmt = "%Y-%m-%dT%H:%M:%SZ"
|
|
||||||
t_current = datetime.strptime(current["timestamp"], fmt)
|
|
||||||
|
|
||||||
if next_pos:
|
|
||||||
t_next = datetime.strptime(next_pos["timestamp"], fmt)
|
|
||||||
# Ventana: desde ahora hasta el siguiente punto + 15% buffer
|
|
||||||
delta = (t_next - t_current).seconds
|
|
||||||
buffer = int(delta * 0.15)
|
|
||||||
eta_min = t_current.strftime("%H:%M")
|
|
||||||
from datetime import timedelta
|
|
||||||
eta_max = (t_next + timedelta(seconds=buffer)).strftime("%H:%M")
|
|
||||||
return {
|
|
||||||
"eta_window": f"{eta_min} – {eta_max}",
|
|
||||||
"message": f"El camión llegará a tu zona entre las {eta_min} y {eta_max}",
|
|
||||||
"minutes_approx": delta // 60
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"eta_window": t_current.strftime("%H:%M"),
|
|
||||||
"message": f"El camión llega aproximadamente a las {t_current.strftime('%H:%M')}",
|
|
||||||
"minutes_approx": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Procesador de triggers ───────────────────────────────────
|
|
||||||
def process_position_update(route_id: str, position_id: int):
|
|
||||||
"""
|
|
||||||
Llamado por el simulador (P1) o por el cron job.
|
|
||||||
Decide si dispara una notificación según positionId.
|
|
||||||
"""
|
|
||||||
state = route_state.setdefault(route_id, {"positionId": 1, "notified": set()})
|
|
||||||
state["positionId"] = position_id
|
|
||||||
|
|
||||||
trigger = None
|
|
||||||
if position_id == 2: trigger = "ROUTE_START"
|
|
||||||
elif position_id == 4: trigger = "TRUCK_PROXIMITY"
|
|
||||||
elif position_id == 8: trigger = "ROUTE_COMPLETED"
|
|
||||||
|
|
||||||
if trigger and trigger not in state["notified"]:
|
|
||||||
template = get_template(trigger)
|
|
||||||
tokens = get_tokens_for_route(route_id)
|
|
||||||
eta_info = calculate_eta(route_id, position_id)
|
|
||||||
|
|
||||||
print(f"\n[TRIGGER] {route_id} → {trigger} | {len(tokens)} usuarios")
|
|
||||||
|
|
||||||
send_push(
|
|
||||||
tokens = tokens,
|
|
||||||
title = template["title"],
|
|
||||||
body = template["body"],
|
|
||||||
data = {"routeId": route_id, "trigger": trigger, **eta_info}
|
|
||||||
)
|
|
||||||
state["notified"].add(trigger)
|
|
||||||
|
|
||||||
# ── API Endpoints ────────────────────────────────────────────
|
|
||||||
|
|
||||||
@app.post("/internal/position-update")
|
|
||||||
def position_update(payload: dict):
|
|
||||||
"""
|
|
||||||
P1 llama este endpoint cuando el simulador avanza una posición.
|
|
||||||
Body: { "routeId": "RUTA-01", "positionId": 2 }
|
|
||||||
"""
|
|
||||||
route_id = payload.get("routeId")
|
|
||||||
position_id = payload.get("positionId")
|
|
||||||
|
|
||||||
if not route_id or not position_id:
|
|
||||||
raise HTTPException(400, "routeId y positionId son requeridos")
|
|
||||||
if route_id not in RUTAS:
|
|
||||||
raise HTTPException(404, f"Ruta {route_id} no encontrada")
|
|
||||||
|
|
||||||
process_position_update(route_id, position_id)
|
|
||||||
return {"status": "ok", "routeId": route_id, "positionId": position_id}
|
|
||||||
|
|
||||||
@app.get("/eta/{route_id}")
|
|
||||||
def get_eta(route_id: str):
|
|
||||||
"""
|
|
||||||
La app Android consulta esto para mostrar la ventana de llegada.
|
|
||||||
Requiere JWT (P1 valida en el gateway, aquí solo calculamos).
|
|
||||||
"""
|
|
||||||
state = route_state.get(route_id)
|
|
||||||
if not state:
|
|
||||||
return {"message": "Ruta aún no iniciada", "eta_window": None}
|
|
||||||
|
|
||||||
eta = calculate_eta(route_id, state["positionId"])
|
|
||||||
return {
|
|
||||||
"routeId": route_id,
|
|
||||||
"positionId": state["positionId"],
|
|
||||||
**eta
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.post("/fcm-token")
|
|
||||||
def register_fcm_token(payload: dict):
|
|
||||||
"""
|
|
||||||
La app Android registra su token FCM al iniciar sesión.
|
|
||||||
Body: { "colonia": "Zona Centro", "token": "FCM_TOKEN_REAL" }
|
|
||||||
"""
|
|
||||||
colonia = payload.get("colonia")
|
|
||||||
token = payload.get("token")
|
|
||||||
if not colonia or not token:
|
|
||||||
raise HTTPException(400, "colonia y token requeridos")
|
|
||||||
|
|
||||||
if colonia not in fake_users:
|
|
||||||
fake_users[colonia] = []
|
|
||||||
if token not in fake_users[colonia]:
|
|
||||||
fake_users[colonia].append(token)
|
|
||||||
|
|
||||||
print(f"[TOKEN] Registrado token para {colonia}")
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
def health():
|
|
||||||
return {"status": "ok", "routes_active": list(route_state.keys())}
|
|
||||||
|
|
||||||
# ── Cron job propio (para demo sin depender de P1) ──────────
|
|
||||||
def _simulate_all_routes():
|
|
||||||
"""Avanza cada ruta respetando su horario del JSON."""
|
|
||||||
now_utc = datetime.utcnow()
|
|
||||||
print(f"\n[CRON] Tick — {now_utc.strftime('%H:%M:%S')} UTC")
|
|
||||||
|
|
||||||
for route_id, ruta in RUTAS.items():
|
|
||||||
state = route_state.setdefault(route_id, {"positionId": 1, "notified": set()})
|
|
||||||
current_id = state["positionId"]
|
|
||||||
|
|
||||||
if current_id >= 8:
|
|
||||||
continue # esta ruta ya terminó
|
|
||||||
|
|
||||||
# Busca la siguiente posición
|
|
||||||
positions = ruta["positions"]
|
|
||||||
next_pos = next((p for p in positions if p["positionId"] == current_id + 1), None)
|
|
||||||
if not next_pos:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Solo avanza si ya pasó el timestamp de esa posición
|
|
||||||
fmt = "%Y-%m-%dT%H:%M:%SZ"
|
|
||||||
next_time = datetime.strptime(next_pos["timestamp"], fmt)
|
|
||||||
|
|
||||||
if now_utc >= next_time:
|
|
||||||
print(f" {route_id}: positionId {current_id} → {current_id + 1}")
|
|
||||||
process_position_update(route_id, current_id + 1)
|
|
||||||
# si no, espera silenciosamente
|
|
||||||
|
|
||||||
def start_cron():
|
|
||||||
"""Corre el simulador cada 2 minutos en un hilo separado."""
|
|
||||||
schedule.every(2).minutes.do(_simulate_all_routes)
|
|
||||||
while True:
|
|
||||||
schedule.run_pending()
|
|
||||||
time.sleep(10)
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
|
||||||
def startup():
|
|
||||||
# Inicializa todas las rutas en positionId 1
|
|
||||||
for route_id in RUTAS:
|
|
||||||
route_state[route_id] = {"positionId": 1, "notified": set()}
|
|
||||||
# Arranca el cron en background
|
|
||||||
t = threading.Thread(target=start_cron, daemon=True)
|
|
||||||
t.start()
|
|
||||||
print(f"[STARTUP] Notification Service listo. {len(RUTAS)} rutas cargadas.")
|
|
||||||
|
|
||||||
@app.post("/internal/reset")
|
|
||||||
def reset_routes():
|
|
||||||
"""Reinicia todas las rutas a positionId 1. Útil para el demo."""
|
|
||||||
for route_id in RUTAS:
|
|
||||||
route_state[route_id] = {"positionId": 1, "notified": set()}
|
|
||||||
print("[RESET] Todas las rutas reiniciadas a positionId 1")
|
|
||||||
return {"status": "ok", "message": f"{len(RUTAS)} rutas reiniciadas"}
|
|
||||||
|
|
||||||
@app.post("/internal/demo")
|
|
||||||
def demo_trigger(payload: dict):
|
|
||||||
"""
|
|
||||||
Fuerza una ruta a un positionId específico al instante.
|
|
||||||
Ideal para demos en vivo.
|
|
||||||
Body: { "routeId": "RUTA-01", "positionId": 4 }
|
|
||||||
"""
|
|
||||||
route_id = payload.get("routeId", "RUTA-01")
|
|
||||||
position_id = payload.get("positionId", 4)
|
|
||||||
|
|
||||||
# Limpia notificaciones previas para que dispare de nuevo
|
|
||||||
state = route_state.setdefault(route_id, {"positionId": 1, "notified": set()})
|
|
||||||
state["notified"].discard("ROUTE_START")
|
|
||||||
state["notified"].discard("TRUCK_PROXIMITY")
|
|
||||||
state["notified"].discard("ROUTE_COMPLETED")
|
|
||||||
|
|
||||||
process_position_update(route_id, position_id)
|
|
||||||
return {"status": "ok", "routeId": route_id, "positionId": position_id}
|
|
||||||
109
reportes.html
Normal file
109
reportes.html
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Reportar incidencia</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: Arial, sans-serif; background: #f5f5f5; color: #222; }
|
||||||
|
.header { background: #1565c0; color: white; padding: 20px 16px 16px; }
|
||||||
|
.header p { font-size: 13px; opacity: 0.85; margin-top: 4px; }
|
||||||
|
.form-card { background: white; margin: 16px 16px 80px; border-radius: 12px; padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
|
||||||
|
label { display: block; font-size: 13px; color: #555; margin-bottom: 6px; margin-top: 16px; }
|
||||||
|
select, textarea { width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; font-family: Arial, sans-serif; background: white; }
|
||||||
|
textarea { height: 100px; resize: none; }
|
||||||
|
.btn { display: block; width: 100%; margin-top: 20px; padding: 14px; background: #1565c0; color: white; border: none; border-radius: 10px; font-size: 16px; font-weight: 600; cursor: pointer; }
|
||||||
|
.exito { display: none; margin: 16px 16px 80px; background: #e8f5e9; border-radius: 12px; padding: 20px; text-align: center; }
|
||||||
|
.exito p { color: #2e7d32; font-size: 15px; margin-top: 8px; }
|
||||||
|
.warning-bar { margin: 0 16px 16px; background: #fff8e1; border-radius: 10px; padding: 12px 14px; font-size: 13px; color: #e65100; }
|
||||||
|
.menu { position:fixed; bottom:0; left:0; right:0; background:white; border-top:1px solid #ddd; display:flex; justify-content:space-around; padding:10px 0; }
|
||||||
|
.menu a { text-decoration:none; text-align:center; }
|
||||||
|
.menu div { font-size:22px; }
|
||||||
|
.menu span { font-size:11px; display:block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<h1 style="font-size:20px; font-weight:600;">Buzón de reportes</h1>
|
||||||
|
<p>Ayúdanos a mejorar el servicio</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warning-bar">
|
||||||
|
⏰ Recuerda: solo saca la basura cuando recibas la alerta de llegada.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-card" id="formulario">
|
||||||
|
<label>Tipo de reporte</label>
|
||||||
|
<select id="tipo">
|
||||||
|
<option value="">Selecciona una opción...</option>
|
||||||
|
<option value="no_paso">El camión no pasó</option>
|
||||||
|
<option value="muy_tarde">Pasó muy tarde</option>
|
||||||
|
<option value="muy_rapido">Pasó muy rápido</option>
|
||||||
|
<option value="mal_servicio">Mal servicio</option>
|
||||||
|
<option value="otro">Otro</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label>Descripción (opcional)</label>
|
||||||
|
<textarea id="descripcion" placeholder="Describe lo que pasó..."></textarea>
|
||||||
|
|
||||||
|
<label>Tu domicilio</label>
|
||||||
|
<select id="domicilio">
|
||||||
|
<option value="casa">Casa — Calle Reforma 45</option>
|
||||||
|
<option value="trabajo">Trabajo — Av. Juárez 120</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button class="btn" onclick="enviarReporte()">Enviar reporte</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="exito" id="exito">
|
||||||
|
<div style="font-size:40px;">✅</div>
|
||||||
|
<p><strong>¡Reporte enviado!</strong></p>
|
||||||
|
<p style="margin-top:8px; font-size:13px; color:#555;">Gracias por ayudarnos a mejorar el servicio de recolección.</p>
|
||||||
|
<button class="btn" style="background:#2e7d32;" onclick="nuevoReporte()">Enviar otro reporte</button>
|
||||||
|
</div>
|
||||||
|
<nav class="menu">
|
||||||
|
<a href="/guia" style="color:#888;">
|
||||||
|
<div>♻️</div>
|
||||||
|
<span>Guía</span>
|
||||||
|
</a>
|
||||||
|
<a href="/reportes" style="color:#1565c0;">
|
||||||
|
<div>📋</div>
|
||||||
|
<span>Reportes</span>
|
||||||
|
</a>
|
||||||
|
<a href="/alertas" style="color:#888;">
|
||||||
|
<div>🔔</div>
|
||||||
|
<span>Alertas</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function enviarReporte() {
|
||||||
|
const tipo = document.getElementById("tipo").value;
|
||||||
|
if (!tipo) { alert("Por favor selecciona el tipo de reporte."); return; }
|
||||||
|
const datos = {
|
||||||
|
tipo: tipo,
|
||||||
|
descripcion: document.getElementById("descripcion").value,
|
||||||
|
domicilio: document.getElementById("domicilio").value
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await fetch("/api/reportes", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(datos)
|
||||||
|
});
|
||||||
|
} catch(e) {}
|
||||||
|
document.getElementById("formulario").style.display = "none";
|
||||||
|
document.getElementById("exito").style.display = "block";
|
||||||
|
}
|
||||||
|
function nuevoReporte() {
|
||||||
|
document.getElementById("tipo").value = "";
|
||||||
|
document.getElementById("descripcion").value = "";
|
||||||
|
document.getElementById("formulario").style.display = "block";
|
||||||
|
document.getElementById("exito").style.display = "none";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user