solucion de eventos por pais
This commit is contained in:
parent
fc06566928
commit
68a5528f2f
7 changed files with 796 additions and 14 deletions
115
app.py
115
app.py
|
|
@ -1291,6 +1291,121 @@ def restore_completo():
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("dashboard"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/eventos_pais")
|
||||||
|
def eventos_pais():
|
||||||
|
pais_id = request.args.get("pais_id") or None
|
||||||
|
page = max(int(request.args.get("page", 1) or 1), 1)
|
||||||
|
per_page = 30
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
lang = (request.args.get("lang") or DEFAULT_TRANSLATION_LANG or DEFAULT_LANG).lower()[:5]
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.autocommit = True
|
||||||
|
paises = get_paises(conn)
|
||||||
|
|
||||||
|
eventos = []
|
||||||
|
total_eventos = 0
|
||||||
|
noticias_por_evento = {}
|
||||||
|
pais_nombre = None
|
||||||
|
|
||||||
|
if pais_id:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
# 1) Eventos que tienen al menos una traducción cuya noticia es de ese país
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
e.id,
|
||||||
|
e.titulo,
|
||||||
|
e.fecha_inicio,
|
||||||
|
e.fecha_fin,
|
||||||
|
e.n_noticias,
|
||||||
|
MAX(p.nombre) AS pais_nombre
|
||||||
|
FROM eventos e
|
||||||
|
JOIN traducciones t ON t.evento_id = e.id
|
||||||
|
JOIN noticias n ON n.id = t.noticia_id
|
||||||
|
JOIN paises p ON p.id = n.pais_id
|
||||||
|
WHERE n.pais_id = %s
|
||||||
|
GROUP BY e.id, e.titulo, e.fecha_inicio, e.fecha_fin, e.n_noticias
|
||||||
|
ORDER BY e.fecha_inicio DESC NULLS LAST, e.id DESC
|
||||||
|
LIMIT %s OFFSET %s;
|
||||||
|
""",
|
||||||
|
(int(pais_id), per_page, offset),
|
||||||
|
)
|
||||||
|
eventos = cur.fetchall()
|
||||||
|
|
||||||
|
# 2) Total de eventos distintos para ese país
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(DISTINCT e.id)
|
||||||
|
FROM eventos e
|
||||||
|
JOIN traducciones t ON t.evento_id = e.id
|
||||||
|
JOIN noticias n ON n.id = t.noticia_id
|
||||||
|
WHERE n.pais_id = %s;
|
||||||
|
""",
|
||||||
|
(int(pais_id),),
|
||||||
|
)
|
||||||
|
total_eventos = cur.fetchone()[0] if cur.rowcount else 0
|
||||||
|
|
||||||
|
# 3) Cargar noticias asociadas a esos eventos (desde traducciones + noticias)
|
||||||
|
if eventos:
|
||||||
|
evento_ids = [e["id"] for e in eventos]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
t.evento_id,
|
||||||
|
n.id AS noticia_id,
|
||||||
|
n.url,
|
||||||
|
n.fecha,
|
||||||
|
n.imagen_url,
|
||||||
|
n.fuente_nombre,
|
||||||
|
n.titulo AS titulo_orig,
|
||||||
|
n.resumen AS resumen_orig,
|
||||||
|
t.id AS traduccion_id,
|
||||||
|
t.titulo_trad AS titulo_trad,
|
||||||
|
t.resumen_trad AS resumen_trad,
|
||||||
|
p.nombre AS pais_nombre
|
||||||
|
FROM traducciones t
|
||||||
|
JOIN noticias n ON n.id = t.noticia_id
|
||||||
|
LEFT JOIN paises p ON p.id = n.pais_id
|
||||||
|
WHERE t.evento_id = ANY(%s)
|
||||||
|
AND t.status = 'done'
|
||||||
|
AND t.lang_to = %s
|
||||||
|
ORDER BY t.evento_id, n.fecha DESC;
|
||||||
|
""",
|
||||||
|
(evento_ids, lang),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
noticias_por_evento = {e["id"]: [] for e in eventos}
|
||||||
|
for r in rows:
|
||||||
|
noticias_por_evento.setdefault(r["evento_id"], []).append(r)
|
||||||
|
|
||||||
|
# Nombre del país (todos los eventos en esta vista son del mismo país filtrado)
|
||||||
|
pais_nombre = eventos[0]["pais_nombre"]
|
||||||
|
else:
|
||||||
|
# Si no hay eventos, al menos sacamos el nombre del país desde la lista
|
||||||
|
for p in paises:
|
||||||
|
if p["id"] == int(pais_id):
|
||||||
|
pais_nombre = p["nombre"]
|
||||||
|
break
|
||||||
|
|
||||||
|
total_pages = (total_eventos // per_page) + (1 if total_eventos % per_page else 0)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"eventos_pais.html",
|
||||||
|
paises=paises,
|
||||||
|
eventos=eventos,
|
||||||
|
noticias_por_evento=noticias_por_evento,
|
||||||
|
pais_id=int(pais_id) if pais_id else None,
|
||||||
|
pais_nombre=pais_nombre,
|
||||||
|
total_eventos=total_eventos,
|
||||||
|
total_pages=total_pages,
|
||||||
|
page=page,
|
||||||
|
lang=lang,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(host="0.0.0.0", port=8001, debug=True)
|
app.run(host="0.0.0.0", port=8001, debug=True)
|
||||||
|
|
||||||
|
|
|
||||||
309
cluster_worker.py
Normal file
309
cluster_worker.py
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='[eventos] %(asctime)s %(levelname)s: %(message)s'
|
||||||
|
)
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DB = dict(
|
||||||
|
host=os.environ.get("DB_HOST", "localhost"),
|
||||||
|
port=int(os.environ.get("DB_PORT", 5432)),
|
||||||
|
dbname=os.environ.get("DB_NAME", "rss"),
|
||||||
|
user=os.environ.get("DB_USER", "rss"),
|
||||||
|
password=os.environ.get("DB_PASS", "x"),
|
||||||
|
)
|
||||||
|
|
||||||
|
EVENT_LANGS = [
|
||||||
|
s.strip().lower()
|
||||||
|
for s in os.environ.get("EVENT_LANGS", "es").split(",")
|
||||||
|
if s.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
EVENT_BATCH_IDS = int(os.environ.get("EVENT_BATCH_IDS", "200"))
|
||||||
|
EVENT_SLEEP_IDLE = float(os.environ.get("EVENT_SLEEP_IDLE", "5.0"))
|
||||||
|
EVENT_DIST_THRESHOLD = float(os.environ.get("EVENT_DIST_THRESHOLD", "0.25"))
|
||||||
|
|
||||||
|
|
||||||
|
def get_conn():
|
||||||
|
return psycopg2.connect(**DB)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_schema(conn):
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS eventos (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
creado_en TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
actualizado_en TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
centroid JSONB NOT NULL,
|
||||||
|
total_traducciones INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE traducciones
|
||||||
|
ADD COLUMN IF NOT EXISTS evento_id INTEGER REFERENCES eventos(id);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_traducciones_evento
|
||||||
|
ON traducciones(evento_id);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_traducciones_evento_fecha
|
||||||
|
ON traducciones(evento_id, noticia_id);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE OR REPLACE FUNCTION actualizar_evento_modificado()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.actualizado_en = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute("DROP TRIGGER IF EXISTS trg_evento_modificado ON eventos;")
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER trg_evento_modificado
|
||||||
|
BEFORE UPDATE ON eventos
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION actualizar_evento_modificado();
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_pending_traducciones(conn) -> List[int]:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT t.id
|
||||||
|
FROM traducciones t
|
||||||
|
JOIN embeddings e ON e.traduccion_id = t.id
|
||||||
|
WHERE t.status = 'done'
|
||||||
|
AND t.evento_id IS NULL
|
||||||
|
AND t.lang_to = ANY(%s)
|
||||||
|
ORDER BY t.id DESC
|
||||||
|
LIMIT %s;
|
||||||
|
""",
|
||||||
|
(EVENT_LANGS, EVENT_BATCH_IDS),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return [r[0] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_embeddings_for(conn, tr_ids: List[int]) -> Dict[int, np.ndarray]:
|
||||||
|
if not tr_ids:
|
||||||
|
return {}
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT traduccion_id, vec
|
||||||
|
FROM embeddings
|
||||||
|
WHERE traduccion_id = ANY(%s);
|
||||||
|
""",
|
||||||
|
(tr_ids,),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
out: Dict[int, np.ndarray] = {}
|
||||||
|
for tr_id, vec in rows:
|
||||||
|
if not vec:
|
||||||
|
continue
|
||||||
|
arr = np.array([float(x or 0.0) for x in vec], dtype="float32")
|
||||||
|
if arr.size == 0:
|
||||||
|
continue
|
||||||
|
out[int(tr_id)] = arr
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_centroids(conn) -> List[Dict[str, Any]]:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, centroid, total_traducciones
|
||||||
|
FROM eventos
|
||||||
|
ORDER BY id;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
centroids: List[Dict[str, Any]] = []
|
||||||
|
for r in rows:
|
||||||
|
cid = int(r["id"])
|
||||||
|
raw = r["centroid"]
|
||||||
|
cnt = int(r["total_traducciones"] or 1)
|
||||||
|
if not isinstance(raw, (list, tuple)):
|
||||||
|
continue
|
||||||
|
arr = np.array([float(x or 0.0) for x in raw], dtype="float32")
|
||||||
|
if arr.size == 0:
|
||||||
|
continue
|
||||||
|
centroids.append({"id": cid, "vec": arr, "n": cnt})
|
||||||
|
return centroids
|
||||||
|
|
||||||
|
|
||||||
|
def cosine_distance(a: np.ndarray, b: np.ndarray) -> float:
|
||||||
|
num = float(np.dot(a, b))
|
||||||
|
da = float(np.linalg.norm(a))
|
||||||
|
db = float(np.linalg.norm(b))
|
||||||
|
denom = da * db
|
||||||
|
if denom <= 0.0:
|
||||||
|
return 1.0
|
||||||
|
cos = num / denom
|
||||||
|
if cos > 1.0:
|
||||||
|
cos = 1.0
|
||||||
|
if cos < -1.0:
|
||||||
|
cos = -1.0
|
||||||
|
return 1.0 - cos
|
||||||
|
|
||||||
|
|
||||||
|
def assign_to_event(
|
||||||
|
conn,
|
||||||
|
tr_id: int,
|
||||||
|
vec: np.ndarray,
|
||||||
|
centroids: List[Dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
from psycopg2.extras import Json
|
||||||
|
|
||||||
|
if vec is None or vec.size == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not centroids:
|
||||||
|
centroid_list = [float(x) for x in vec.tolist()]
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO eventos (centroid, total_traducciones)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
RETURNING id;
|
||||||
|
""",
|
||||||
|
(Json(centroid_list), 1),
|
||||||
|
)
|
||||||
|
new_id = cur.fetchone()[0]
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE traducciones SET evento_id = %s WHERE id = %s;",
|
||||||
|
(new_id, tr_id),
|
||||||
|
)
|
||||||
|
centroids.append({"id": new_id, "vec": vec.copy(), "n": 1})
|
||||||
|
return
|
||||||
|
|
||||||
|
best_idx: Optional[int] = None
|
||||||
|
best_dist: float = 1.0
|
||||||
|
|
||||||
|
for i, c in enumerate(centroids):
|
||||||
|
d = cosine_distance(vec, c["vec"])
|
||||||
|
if d < best_dist:
|
||||||
|
best_dist = d
|
||||||
|
best_idx = i
|
||||||
|
|
||||||
|
if best_idx is not None and best_dist <= EVENT_DIST_THRESHOLD:
|
||||||
|
c = centroids[best_idx]
|
||||||
|
n_old = c["n"]
|
||||||
|
new_n = n_old + 1
|
||||||
|
new_vec = (c["vec"] * n_old + vec) / float(new_n)
|
||||||
|
|
||||||
|
c["vec"] = new_vec
|
||||||
|
c["n"] = new_n
|
||||||
|
|
||||||
|
centroid_list = [float(x) for x in new_vec.tolist()]
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE eventos
|
||||||
|
SET centroid = %s,
|
||||||
|
total_traducciones = total_traducciones + 1
|
||||||
|
WHERE id = %s;
|
||||||
|
""",
|
||||||
|
(Json(centroid_list), c["id"]),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE traducciones SET evento_id = %s WHERE id = %s;",
|
||||||
|
(c["id"], tr_id),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
centroid_list = [float(x) for x in vec.tolist()]
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO eventos (centroid, total_traducciones)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
RETURNING id;
|
||||||
|
""",
|
||||||
|
(Json(centroid_list), 1),
|
||||||
|
)
|
||||||
|
new_id = cur.fetchone()[0]
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE traducciones SET evento_id = %s WHERE id = %s;",
|
||||||
|
(new_id, tr_id),
|
||||||
|
)
|
||||||
|
centroids.append({"id": new_id, "vec": vec.copy(), "n": 1})
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log.info(
|
||||||
|
"Iniciando cluster_worker eventos "
|
||||||
|
"(EVENT_LANGS=%s, BATCH_IDS=%s, DIST_THRESHOLD=%.3f, SLEEP=%.1fs)",
|
||||||
|
",".join(EVENT_LANGS),
|
||||||
|
EVENT_BATCH_IDS,
|
||||||
|
EVENT_DIST_THRESHOLD,
|
||||||
|
EVENT_SLEEP_IDLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
ensure_schema(conn)
|
||||||
|
|
||||||
|
pending_ids = fetch_pending_traducciones(conn)
|
||||||
|
if not pending_ids:
|
||||||
|
time.sleep(EVENT_SLEEP_IDLE)
|
||||||
|
continue
|
||||||
|
|
||||||
|
log.info("Traducciones pendientes de asignar evento: %d", len(pending_ids))
|
||||||
|
|
||||||
|
emb_by_tr = fetch_embeddings_for(conn, pending_ids)
|
||||||
|
if not emb_by_tr:
|
||||||
|
log.warning("No se encontraron embeddings para las traducciones pendientes.")
|
||||||
|
time.sleep(EVENT_SLEEP_IDLE)
|
||||||
|
continue
|
||||||
|
|
||||||
|
centroids = fetch_centroids(conn)
|
||||||
|
log.info("Centroides cargados: %d", len(centroids))
|
||||||
|
|
||||||
|
processed = 0
|
||||||
|
for tr_id in pending_ids:
|
||||||
|
vec = emb_by_tr.get(tr_id)
|
||||||
|
if vec is None:
|
||||||
|
continue
|
||||||
|
assign_to_event(conn, tr_id, vec, centroids)
|
||||||
|
processed += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
log.info("Asignación de eventos completada. Traducciones procesadas: %d", processed)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Error en cluster_worker: %s", e)
|
||||||
|
time.sleep(EVENT_SLEEP_IDLE)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
|
|
@ -182,6 +182,24 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
|
cluster:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
TORCH_CUDA: cu121
|
||||||
|
container_name: rss_cluster
|
||||||
|
command: bash -lc "python cluster_worker.py"
|
||||||
|
environment:
|
||||||
|
- DB_HOST=db
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_NAME=${DB_NAME}
|
||||||
|
- DB_USER=${DB_USER}
|
||||||
|
- DB_PASS=${DB_PASS}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: always
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: rss_default
|
name: rss_default
|
||||||
|
|
|
||||||
89
init-db/09-eventos.sql
Normal file
89
init-db/09-eventos.sql
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================
|
||||||
|
-- Sistema de eventos (clustering incremental)
|
||||||
|
-- =============================================
|
||||||
|
|
||||||
|
-- ---------------------------------------------
|
||||||
|
-- 1. TABLA DE EVENTOS (CLUSTERS)
|
||||||
|
-- ---------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS eventos (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
creado_en TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
actualizado_en TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Datos "semánticos" del evento (para la web)
|
||||||
|
titulo TEXT,
|
||||||
|
fecha_inicio TIMESTAMPTZ,
|
||||||
|
fecha_fin TIMESTAMPTZ,
|
||||||
|
n_noticias INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Datos de clustering
|
||||||
|
centroid JSONB NOT NULL,
|
||||||
|
total_traducciones INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------
|
||||||
|
-- 2. COLUMNA evento_id EN TRADUCCIONES
|
||||||
|
-- ---------------------------------------------
|
||||||
|
ALTER TABLE traducciones
|
||||||
|
ADD COLUMN IF NOT EXISTS evento_id BIGINT REFERENCES eventos(id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------
|
||||||
|
-- 3. TABLA RELACIÓN EVENTO <-> NOTICIA <-> TRADUCCIÓN
|
||||||
|
-- ---------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS eventos_noticias (
|
||||||
|
evento_id BIGINT NOT NULL REFERENCES eventos(id) ON DELETE CASCADE,
|
||||||
|
noticia_id CHAR(32) NOT NULL REFERENCES noticias(id) ON DELETE CASCADE,
|
||||||
|
traduccion_id BIGINT NOT NULL REFERENCES traducciones(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (evento_id, traduccion_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------
|
||||||
|
-- 4. ÍNDICES ÚTILES
|
||||||
|
-- ---------------------------------------------
|
||||||
|
|
||||||
|
-- Consultar traducciones por evento
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_traducciones_evento
|
||||||
|
ON traducciones(evento_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_traducciones_evento_fecha
|
||||||
|
ON traducciones(evento_id, noticia_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trad_id
|
||||||
|
ON traducciones(id);
|
||||||
|
|
||||||
|
-- Ordenar eventos por fecha de inicio
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_eventos_fecha_inicio
|
||||||
|
ON eventos (fecha_inicio DESC NULLS LAST);
|
||||||
|
|
||||||
|
-- Relación evento <-> noticia / traducción
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_eventos_noticias_evento
|
||||||
|
ON eventos_noticias (evento_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_eventos_noticias_noticia
|
||||||
|
ON eventos_noticias (noticia_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_eventos_noticias_traduccion
|
||||||
|
ON eventos_noticias (traduccion_id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------
|
||||||
|
-- 5. TRIGGER PARA actualizar "actualizado_en"
|
||||||
|
-- ---------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION actualizar_evento_modificado()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.actualizado_en = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_evento_modificado ON eventos;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_evento_modificado
|
||||||
|
BEFORE UPDATE ON eventos
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION actualizar_evento_modificado();
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
|
@ -12,27 +12,58 @@
|
||||||
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
|
||||||
<!-- Estilos mínimos para chapa "Traducido" y (opcional) switch -->
|
|
||||||
<style>
|
<style>
|
||||||
.badge{
|
.badge{
|
||||||
display:inline-block; font-size:.75rem; line-height:1;
|
display:inline-block;
|
||||||
padding:.35rem .5rem; border-radius:.5rem;
|
font-size:.75rem;
|
||||||
background: var(--secondary-color, #6c63ff); color:#fff; vertical-align: middle;
|
line-height:1;
|
||||||
|
padding:.35rem .5rem;
|
||||||
|
border-radius:.5rem;
|
||||||
|
background: var(--secondary-color, #6c63ff);
|
||||||
|
color:#fff;
|
||||||
|
vertical-align: middle;
|
||||||
margin-left:.4rem;
|
margin-left:.4rem;
|
||||||
}
|
}
|
||||||
/* Toggle switch opcional (si lo usas en alguna vista) */
|
.switch {
|
||||||
.switch { position: relative; display: inline-block; width: 42px; height: 22px; vertical-align: middle; }
|
position: relative;
|
||||||
.switch input { opacity: 0; width: 0; height: 0; }
|
display: inline-block;
|
||||||
|
width: 42px;
|
||||||
|
height: 22px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
.slider {
|
.slider {
|
||||||
position: absolute; cursor: pointer; top:0; left:0; right:0; bottom:0;
|
position: absolute;
|
||||||
background:#ccc; transition:.2s; border-radius:999px;
|
cursor: pointer;
|
||||||
|
top:0;
|
||||||
|
left:0;
|
||||||
|
right:0;
|
||||||
|
bottom:0;
|
||||||
|
background:#ccc;
|
||||||
|
transition:.2s;
|
||||||
|
border-radius:999px;
|
||||||
}
|
}
|
||||||
.slider:before {
|
.slider:before {
|
||||||
position: absolute; content:""; height:16px; width:16px; left:3px; bottom:3px;
|
position: absolute;
|
||||||
background:#fff; transition:.2s; border-radius:50%;
|
content:"";
|
||||||
|
height:16px;
|
||||||
|
width:16px;
|
||||||
|
left:3px;
|
||||||
|
bottom:3px;
|
||||||
|
background:#fff;
|
||||||
|
transition:.2s;
|
||||||
|
border-radius:50%;
|
||||||
|
}
|
||||||
|
.switch input:checked + .slider {
|
||||||
|
background: var(--secondary-color, #6c63ff);
|
||||||
|
}
|
||||||
|
.switch input:checked + .slider:before {
|
||||||
|
transform: translateX(20px);
|
||||||
}
|
}
|
||||||
.switch input:checked + .slider { background: var(--secondary-color, #6c63ff); }
|
|
||||||
.switch input:checked + .slider:before { transform: translateX(20px); }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -44,6 +75,7 @@
|
||||||
|
|
||||||
<nav class="main-nav">
|
<nav class="main-nav">
|
||||||
<a href="{{ url_for('home') }}" class="nav-link">Noticias</a>
|
<a href="{{ url_for('home') }}" class="nav-link">Noticias</a>
|
||||||
|
<a href="{{ url_for('eventos_pais') }}" class="nav-link">Eventos por país</a>
|
||||||
<a href="{{ url_for('dashboard') }}" class="nav-link">Dashboard</a>
|
<a href="{{ url_for('dashboard') }}" class="nav-link">Dashboard</a>
|
||||||
<a href="{{ url_for('manage_feeds') }}" class="nav-link">Gestionar Feeds</a>
|
<a href="{{ url_for('manage_feeds') }}" class="nav-link">Gestionar Feeds</a>
|
||||||
<a href="{{ url_for('manage_urls') }}" class="nav-link">Gestionar URLs</a>
|
<a href="{{ url_for('manage_urls') }}" class="nav-link">Gestionar URLs</a>
|
||||||
|
|
@ -64,7 +96,6 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Toggle "Ver más / Ver menos" en resúmenes
|
|
||||||
document.addEventListener('click', function(event) {
|
document.addEventListener('click', function(event) {
|
||||||
if (event.target.classList.contains('ver-mas-btn')) {
|
if (event.target.classList.contains('ver-mas-btn')) {
|
||||||
const container = event.target.closest('.resumen-container');
|
const container = event.target.closest('.resumen-container');
|
||||||
|
|
|
||||||
220
templates/eventos_pais.html
Normal file
220
templates/eventos_pais.html
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}
|
||||||
|
{% if pais_nombre %}
|
||||||
|
Eventos en {{ pais_nombre }}
|
||||||
|
{% else %}
|
||||||
|
Eventos por país
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card">
|
||||||
|
<h2><i class="fas fa-globe-europe" style="color: var(--secondary-color); margin-right: 8px;"></i>Eventos por país</h2>
|
||||||
|
|
||||||
|
<form method="get" action="{{ url_for('eventos_pais') }}" id="form-eventos-pais">
|
||||||
|
<div style="display: flex; gap: 15px; align-items: flex-end; flex-wrap: wrap;">
|
||||||
|
<div style="min-width: 220px;">
|
||||||
|
<label for="pais_id">Selecciona un país</label>
|
||||||
|
<select name="pais_id" id="pais_id">
|
||||||
|
<option value="">— Elegir país —</option>
|
||||||
|
{% for p in paises %}
|
||||||
|
<option value="{{ p.id }}" {% if pais_id == p.id %}selected{% endif %}>
|
||||||
|
{{ p.nombre }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="btn"><i class="fas fa-filter"></i> Ver eventos</button>
|
||||||
|
{% if pais_id %}
|
||||||
|
<a href="{{ url_for('eventos_pais') }}" class="btn btn-secondary btn-small">
|
||||||
|
<i class="fas fa-eraser"></i> Limpiar
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not pais_id %}
|
||||||
|
<div class="card">
|
||||||
|
<p>Selecciona un país para ver los eventos agregados de sus noticias.</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<div class="card" style="margin-top: 16px;">
|
||||||
|
<div class="noticia-meta" style="margin-bottom: 0;">
|
||||||
|
<i class="fas fa-flag"></i>
|
||||||
|
País: <strong>{{ pais_nombre or 'N/D' }}</strong>
|
||||||
|
{% if total_eventos %}
|
||||||
|
| <i class="fas fa-stream"></i> Eventos detectados: <strong>{{ total_eventos }}</strong>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if eventos and eventos|length %}
|
||||||
|
<ul class="noticias-list" style="margin-top: 16px;">
|
||||||
|
{% for e in eventos %}
|
||||||
|
{% set lista = noticias_por_evento.get(e.id) or [] %}
|
||||||
|
{% set primera = lista[0] if lista else None %}
|
||||||
|
|
||||||
|
<li class="noticia-item">
|
||||||
|
{% if primera and primera.imagen_url %}
|
||||||
|
<div class="noticia-imagen">
|
||||||
|
<a href="{{ primera.url }}" target="_blank" rel="noopener noreferrer">
|
||||||
|
<img src="{{ primera.imagen_url }}" alt="Imagen del evento" loading="lazy">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="noticia-texto">
|
||||||
|
<h3 class="m0">
|
||||||
|
{{ e.titulo or (primera.titulo_trad or primera.titulo_orig if primera else 'Evento') }}
|
||||||
|
{% if e.n_noticias %}
|
||||||
|
<span class="badge badge-secondary" title="Número de noticias agrupadas">
|
||||||
|
{{ e.n_noticias }} noticias
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="noticia-meta">
|
||||||
|
{% if e.fecha_inicio %}
|
||||||
|
<i class="far fa-calendar-alt"></i>
|
||||||
|
{% if e.fecha_inicio is string %}
|
||||||
|
{{ e.fecha_inicio }}
|
||||||
|
{% else %}
|
||||||
|
{{ e.fecha_inicio.strftime('%d-%m-%Y') }}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if e.fecha_fin and e.fecha_fin != e.fecha_inicio %}
|
||||||
|
–
|
||||||
|
{% if e.fecha_fin is string %}
|
||||||
|
{{ e.fecha_fin }}
|
||||||
|
{% else %}
|
||||||
|
{{ e.fecha_fin.strftime('%d-%m-%Y') }}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if e.pais_nombre %}
|
||||||
|
| <i class="fas fa-globe-americas"></i> {{ e.pais_nombre }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if primera %}
|
||||||
|
{% set titulo_tr = primera.titulo_trad %}
|
||||||
|
{% set resumen_tr = primera.resumen_trad %}
|
||||||
|
{% set titulo_orig = primera.titulo_orig %}
|
||||||
|
{% set resumen_orig = primera.resumen_orig %}
|
||||||
|
|
||||||
|
<div class="resumen-container">
|
||||||
|
{% if titulo_tr or resumen_tr %}
|
||||||
|
<div class="m0" style="margin-bottom:6px;">
|
||||||
|
<a href="{{ primera.url }}" target="_blank" rel="noopener noreferrer">
|
||||||
|
{{ titulo_tr or titulo_orig }}
|
||||||
|
</a>
|
||||||
|
<span class="badge" title="Traducción">{{ (lang or 'es')|upper }}</span>
|
||||||
|
</div>
|
||||||
|
{% if resumen_tr %}
|
||||||
|
<div class="resumen-corto">
|
||||||
|
{{ resumen_tr|safe_html|truncate(280, True) }}
|
||||||
|
</div>
|
||||||
|
<div class="resumen-completo" style="display:none;">
|
||||||
|
{{ resumen_tr|safe_html }}
|
||||||
|
</div>
|
||||||
|
{% if resumen_tr|length > 280 %}
|
||||||
|
<button class="ver-mas-btn" type="button">Ver más</button>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="resumen-corto">
|
||||||
|
{{ (resumen_orig or '')|safe_html|truncate(280, True) }}
|
||||||
|
</div>
|
||||||
|
<div class="resumen-completo" style="display:none;">
|
||||||
|
{{ (resumen_orig or '')|safe_html }}
|
||||||
|
</div>
|
||||||
|
{% if (resumen_orig or '')|length > 280 %}
|
||||||
|
<button class="ver-mas-btn" type="button">Ver más</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="m0" style="margin-bottom:6px;">
|
||||||
|
<a href="{{ primera.url }}" target="_blank" rel="noopener noreferrer">
|
||||||
|
{{ titulo_orig }}
|
||||||
|
</a>
|
||||||
|
<span class="badge badge-secondary">ORIG</span>
|
||||||
|
</div>
|
||||||
|
{% if resumen_orig %}
|
||||||
|
<div class="resumen-corto">
|
||||||
|
{{ resumen_orig|safe_html|truncate(280, True) }}
|
||||||
|
</div>
|
||||||
|
<div class="resumen-completo" style="display:none;">
|
||||||
|
{{ resumen_orig|safe_html }}
|
||||||
|
</div>
|
||||||
|
{% if resumen_orig|length > 280 %}
|
||||||
|
<button class="ver-mas-btn" type="button">Ver más</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if lista|length > 1 %}
|
||||||
|
<div style="margin-top:8px; font-size:0.85rem; color:var(--text-color-light);">
|
||||||
|
Otras fuentes de este evento:
|
||||||
|
<ul style="padding-left:18px; margin:4px 0 0 0;">
|
||||||
|
{% for n in lista[1:4] %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ n.url }}" target="_blank" rel="noopener noreferrer">
|
||||||
|
{{ n.titulo_trad or n.titulo_orig }}
|
||||||
|
</a>
|
||||||
|
{% if n.fuente_nombre %}
|
||||||
|
<span class="mini-link">({{ n.fuente_nombre }})</span>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% if lista|length > 4 %}
|
||||||
|
<li>… y otras {{ lista|length - 4 }} noticias</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% if total_pages and total_pages > 1 %}
|
||||||
|
<nav class="pagination" aria-label="Paginación de eventos" style="margin-top:15px;">
|
||||||
|
{% set current = page %}
|
||||||
|
{% if current > 1 %}
|
||||||
|
<a href="{{ url_for('eventos_pais', pais_id=pais_id, page=current-1) }}" class="page-link">« Anterior</a>
|
||||||
|
{% endif %}
|
||||||
|
{% set start = 1 if current - 2 < 1 else current - 2 %}
|
||||||
|
{% set end = total_pages if current + 2 > total_pages else current + 2 %}
|
||||||
|
{% if start > 1 %}
|
||||||
|
<a href="{{ url_for('eventos_pais', pais_id=pais_id, page=1) }}" class="page-link">1</a>
|
||||||
|
{% if start > 2 %}<span class="page-link">…</span>{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% for p in range(start, end + 1) %}
|
||||||
|
{% if p == current %}
|
||||||
|
<span class="page-link active">{{ p }}</span>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('eventos_pais', pais_id=pais_id, page=p) }}" class="page-link">{{ p }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if end < total_pages %}
|
||||||
|
{% if end < total_pages - 1 %}<span class="page-link">…</span>{% endif %}
|
||||||
|
<a href="{{ url_for('eventos_pais', pais_id=pais_id, page=total_pages) }}" class="page-link">{{ total_pages }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if current < total_pages %}
|
||||||
|
<a href="{{ url_for('eventos_pais', pais_id=pais_id, page=current+1) }}" class="page-link">Siguiente »</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="card" style="margin-top:16px;">
|
||||||
|
<p>No se han encontrado eventos para este país.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
0
total_pages
Normal file
0
total_pages
Normal file
Loading…
Add table
Add a link
Reference in a new issue