feat: paginación de noticias (app.py + templates)
This commit is contained in:
parent
db6fa3d2c3
commit
8109dbf274
3 changed files with 233 additions and 104 deletions
220
app.py
220
app.py
|
|
@ -38,6 +38,9 @@ MAX_WORKERS = int(os.environ.get("RSS_MAX_WORKERS", 20))
|
||||||
SINGLE_FEED_TIMEOUT = int(os.environ.get("RSS_FEED_TIMEOUT", 30))
|
SINGLE_FEED_TIMEOUT = int(os.environ.get("RSS_FEED_TIMEOUT", 30))
|
||||||
MAX_FALLOS = int(os.environ.get("RSS_MAX_FAILURES", 5))
|
MAX_FALLOS = int(os.environ.get("RSS_MAX_FAILURES", 5))
|
||||||
|
|
||||||
|
# Tamaño de página configurable (límite en 10–100 por seguridad)
|
||||||
|
NEWS_PER_PAGE = int(os.environ.get("NEWS_PER_PAGE", 20))
|
||||||
|
|
||||||
db_pool = None
|
db_pool = None
|
||||||
try:
|
try:
|
||||||
db_pool = psycopg2.pool.SimpleConnectionPool(minconn=1, maxconn=10, **DB_CONFIG)
|
db_pool = psycopg2.pool.SimpleConnectionPool(minconn=1, maxconn=10, **DB_CONFIG)
|
||||||
|
|
@ -47,17 +50,20 @@ except psycopg2.OperationalError as e:
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def get_conn():
|
def get_conn():
|
||||||
if not db_pool: raise ConnectionError("El pool de la base de datos no está disponible.")
|
if not db_pool:
|
||||||
|
raise ConnectionError("El pool de la base de datos no está disponible.")
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = db_pool.getconn()
|
conn = db_pool.getconn()
|
||||||
yield conn
|
yield conn
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if conn: conn.rollback()
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
raise e
|
raise e
|
||||||
finally:
|
finally:
|
||||||
if conn: db_pool.putconn(conn)
|
if conn:
|
||||||
|
db_pool.putconn(conn)
|
||||||
|
|
||||||
@atexit.register
|
@atexit.register
|
||||||
def shutdown_hooks():
|
def shutdown_hooks():
|
||||||
|
|
@ -67,7 +73,8 @@ def shutdown_hooks():
|
||||||
|
|
||||||
@app.template_filter('safe_html')
|
@app.template_filter('safe_html')
|
||||||
def safe_html(text):
|
def safe_html(text):
|
||||||
if not text: return ""
|
if not text:
|
||||||
|
return ""
|
||||||
return bleach.clean(text, tags={'a', 'b', 'strong', 'i', 'em', 'p', 'br'}, attributes={'a': ['href', 'title']}, strip=True)
|
return bleach.clean(text, tags={'a', 'b', 'strong', 'i', 'em', 'p', 'br'}, attributes={'a': ['href', 'title']}, strip=True)
|
||||||
|
|
||||||
def _get_form_dependencies(cursor):
|
def _get_form_dependencies(cursor):
|
||||||
|
|
@ -77,43 +84,45 @@ def _get_form_dependencies(cursor):
|
||||||
paises = cursor.fetchall()
|
paises = cursor.fetchall()
|
||||||
return categorias, paises
|
return categorias, paises
|
||||||
|
|
||||||
## CORRECCIÓN: Se extrae la lógica de construcción de la consulta de la ruta home() para mayor claridad.
|
def _build_news_query(args, *, count=False, limit=None, offset=None):
|
||||||
def _build_news_query(args):
|
"""
|
||||||
"""Construye la consulta SQL y los parámetros basados en los argumentos de la petición."""
|
Construye la consulta SQL y los parámetros basados en los argumentos de la petición.
|
||||||
|
Si count=True => SELECT COUNT(*)
|
||||||
|
Si count=False => SELECT columnas con ORDER + LIMIT/OFFSET
|
||||||
|
"""
|
||||||
sql_params = []
|
sql_params = []
|
||||||
conditions = []
|
conditions = []
|
||||||
|
|
||||||
q = args.get("q", "").strip()
|
q = args.get("q", "").strip()
|
||||||
cat_id = args.get("categoria_id")
|
cat_id = args.get("categoria_id")
|
||||||
cont_id = args.get("continente_id")
|
cont_id = args.get("continente_id")
|
||||||
pais_id = args.get("pais_id")
|
pais_id = args.get("pais_id")
|
||||||
fecha_filtro = args.get("fecha")
|
fecha_filtro = args.get("fecha")
|
||||||
|
|
||||||
sql_base = """
|
sql_from = """
|
||||||
SELECT n.fecha, n.titulo, n.resumen, n.url, n.imagen_url, n.fuente_nombre,
|
FROM noticias n
|
||||||
c.nombre AS categoria, p.nombre AS pais, co.nombre AS continente
|
LEFT JOIN categorias c ON n.categoria_id = c.id
|
||||||
FROM noticias n
|
LEFT JOIN paises p ON n.pais_id = p.id
|
||||||
LEFT JOIN categorias c ON n.categoria_id = c.id
|
|
||||||
LEFT JOIN paises p ON n.pais_id = p.id
|
|
||||||
LEFT JOIN continentes co ON p.continente_id = co.id
|
LEFT JOIN continentes co ON p.continente_id = co.id
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# WHERE dinámico
|
||||||
if q:
|
if q:
|
||||||
search_query = " & ".join(q.split())
|
# plainto_tsquery para tolerar espacios/acentos
|
||||||
conditions.append("n.tsv @@ to_tsquery('spanish', %s)")
|
conditions.append("n.tsv @@ plainto_tsquery('spanish', %s)")
|
||||||
sql_params.append(search_query)
|
sql_params.append(q)
|
||||||
|
|
||||||
if cat_id:
|
if cat_id:
|
||||||
conditions.append("n.categoria_id = %s")
|
conditions.append("n.categoria_id = %s")
|
||||||
sql_params.append(cat_id)
|
sql_params.append(cat_id)
|
||||||
|
|
||||||
if pais_id:
|
if pais_id:
|
||||||
conditions.append("n.pais_id = %s")
|
conditions.append("n.pais_id = %s")
|
||||||
sql_params.append(pais_id)
|
sql_params.append(pais_id)
|
||||||
elif cont_id:
|
elif cont_id:
|
||||||
conditions.append("p.continente_id = %s")
|
conditions.append("p.continente_id = %s")
|
||||||
sql_params.append(cont_id)
|
sql_params.append(cont_id)
|
||||||
|
|
||||||
if fecha_filtro:
|
if fecha_filtro:
|
||||||
try:
|
try:
|
||||||
fecha_obj = datetime.strptime(fecha_filtro, '%Y-%m-%d')
|
fecha_obj = datetime.strptime(fecha_filtro, '%Y-%m-%d')
|
||||||
|
|
@ -122,36 +131,70 @@ def _build_news_query(args):
|
||||||
except ValueError:
|
except ValueError:
|
||||||
flash("Formato de fecha no válido. Use AAAA-MM-DD.", "error")
|
flash("Formato de fecha no válido. Use AAAA-MM-DD.", "error")
|
||||||
|
|
||||||
if conditions:
|
where_clause = " WHERE " + " AND ".join(conditions) if conditions else ""
|
||||||
sql_base += " WHERE " + " AND ".join(conditions)
|
|
||||||
|
|
||||||
|
if count:
|
||||||
|
# Conteo total
|
||||||
|
sql_count = "SELECT COUNT(*) " + sql_from + where_clause
|
||||||
|
return sql_count, sql_params
|
||||||
|
|
||||||
|
# Selección de columnas para página
|
||||||
|
select_cols = """
|
||||||
|
SELECT n.fecha, n.titulo, n.resumen, n.url, n.imagen_url, n.fuente_nombre,
|
||||||
|
c.nombre AS categoria, p.nombre AS pais, co.nombre AS continente
|
||||||
|
"""
|
||||||
order_clause = " ORDER BY n.fecha DESC NULLS LAST"
|
order_clause = " ORDER BY n.fecha DESC NULLS LAST"
|
||||||
if q:
|
|
||||||
search_query_ts = " & ".join(q.split())
|
|
||||||
# Es importante añadir el parámetro de ordenación al final para que coincida con la consulta
|
|
||||||
order_params = sql_params + [search_query_ts]
|
|
||||||
order_clause = " ORDER BY ts_rank(n.tsv, to_tsquery('spanish', %s)) DESC, n.fecha DESC"
|
|
||||||
sql_final = sql_base + order_clause + " LIMIT 50"
|
|
||||||
return sql_final, order_params
|
|
||||||
|
|
||||||
sql_final = sql_base + order_clause + " LIMIT 50"
|
if q:
|
||||||
return sql_final, sql_params
|
# Ranking por relevancia cuando hay búsqueda
|
||||||
|
select_cols = select_cols.replace(
|
||||||
|
"SELECT",
|
||||||
|
"SELECT ts_rank(n.tsv, plainto_tsquery('spanish', %s)) AS rank,"
|
||||||
|
)
|
||||||
|
# El %s de rank va al principio de los params de SELECT
|
||||||
|
sql_params = [q] + sql_params
|
||||||
|
order_clause = " ORDER BY rank DESC, n.fecha DESC NULLS LAST"
|
||||||
|
|
||||||
|
# Paginación
|
||||||
|
if limit is not None:
|
||||||
|
order_clause += " LIMIT %s"
|
||||||
|
sql_params.append(limit)
|
||||||
|
if offset is not None:
|
||||||
|
order_clause += " OFFSET %s"
|
||||||
|
sql_params.append(offset)
|
||||||
|
|
||||||
|
sql_page = select_cols + sql_from + where_clause + order_clause
|
||||||
|
return sql_page, sql_params
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def home():
|
def home():
|
||||||
noticias, categorias, continentes, paises = [], [], [], []
|
noticias, categorias, continentes, paises = [], [], [], []
|
||||||
|
|
||||||
# Obtenemos los valores para mantener el estado de los filtros en la plantilla
|
# Estado de filtros (para mantenerlos en la UI)
|
||||||
q = request.args.get("q", "").strip()
|
q = request.args.get("q", "").strip()
|
||||||
cat_id = request.args.get("categoria_id")
|
cat_id = request.args.get("categoria_id")
|
||||||
cont_id = request.args.get("continente_id")
|
cont_id = request.args.get("continente_id")
|
||||||
pais_id = request.args.get("pais_id")
|
pais_id = request.args.get("pais_id")
|
||||||
fecha_filtro = request.args.get("fecha")
|
fecha_filtro = request.args.get("fecha")
|
||||||
|
|
||||||
|
# Paginación
|
||||||
|
page = request.args.get("page", default=1, type=int)
|
||||||
|
per_page = request.args.get("per_page", default=NEWS_PER_PAGE, type=int)
|
||||||
|
# límites de seguridad
|
||||||
|
if per_page is None or per_page <= 0:
|
||||||
|
per_page = NEWS_PER_PAGE
|
||||||
|
per_page = 100 if per_page > 100 else (10 if per_page < 10 else per_page)
|
||||||
|
if page is None or page <= 0:
|
||||||
|
page = 1
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
total_results = 0
|
||||||
|
total_pages = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||||
# Cargar dependencias de la UI
|
# Dependencias de UI
|
||||||
cursor.execute("SELECT id, nombre FROM categorias ORDER BY nombre")
|
cursor.execute("SELECT id, nombre FROM categorias ORDER BY nombre")
|
||||||
categorias = cursor.fetchall()
|
categorias = cursor.fetchall()
|
||||||
cursor.execute("SELECT id, nombre FROM continentes ORDER BY nombre")
|
cursor.execute("SELECT id, nombre FROM continentes ORDER BY nombre")
|
||||||
|
|
@ -159,22 +202,34 @@ def home():
|
||||||
cursor.execute("SELECT id, nombre, continente_id FROM paises ORDER BY nombre")
|
cursor.execute("SELECT id, nombre, continente_id FROM paises ORDER BY nombre")
|
||||||
paises = cursor.fetchall()
|
paises = cursor.fetchall()
|
||||||
|
|
||||||
# Construir y ejecutar la consulta de noticias
|
# 1) Conteo total
|
||||||
sql_final, sql_params = _build_news_query(request.args)
|
sql_count, params_count = _build_news_query(request.args, count=True)
|
||||||
cursor.execute(sql_final, tuple(sql_params))
|
cursor.execute(sql_count, tuple(params_count))
|
||||||
|
total_results = cursor.fetchone()[0] or 0
|
||||||
|
total_pages = math.ceil(total_results / per_page) if total_results else 0
|
||||||
|
|
||||||
|
# 2) Página actual
|
||||||
|
sql_page, params_page = _build_news_query(request.args, count=False, limit=per_page, offset=offset)
|
||||||
|
cursor.execute(sql_page, tuple(params_page))
|
||||||
noticias = cursor.fetchall()
|
noticias = cursor.fetchall()
|
||||||
|
|
||||||
except psycopg2.Error as db_err:
|
except psycopg2.Error as db_err:
|
||||||
app.logger.error(f"[DB ERROR] Al leer noticias: {db_err}", exc_info=True)
|
app.logger.error(f"[DB ERROR] Al leer noticias: {db_err}", exc_info=True)
|
||||||
flash("Error de base de datos al cargar las noticias.", "error")
|
flash("Error de base de datos al cargar las noticias.", "error")
|
||||||
|
|
||||||
|
ctx = dict(
|
||||||
|
noticias=noticias, categorias=categorias, continentes=continentes, paises=paises,
|
||||||
|
cat_id=int(cat_id) if cat_id else None, cont_id=int(cont_id) if cont_id else None,
|
||||||
|
pais_id=int(pais_id) if pais_id else None, fecha_filtro=fecha_filtro, q=q,
|
||||||
|
page=page, per_page=per_page, total_pages=total_pages, total_results=total_results
|
||||||
|
)
|
||||||
|
|
||||||
|
# Respuesta parcial para AJAX
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
return render_template('_noticias_list.html', noticias=noticias)
|
return render_template('_noticias_list.html', **ctx)
|
||||||
|
|
||||||
return render_template("noticias.html",
|
# Render completo
|
||||||
noticias=noticias, categorias=categorias, continentes=continentes, paises=paises,
|
return render_template("noticias.html", **ctx)
|
||||||
cat_id=int(cat_id) if cat_id else None, cont_id=int(cont_id) if cont_id else None,
|
|
||||||
pais_id=int(pais_id) if pais_id else None, fecha_filtro=fecha_filtro, q=q)
|
|
||||||
|
|
||||||
@app.route("/dashboard")
|
@app.route("/dashboard")
|
||||||
def dashboard():
|
def dashboard():
|
||||||
|
|
@ -205,10 +260,10 @@ def manage_feeds():
|
||||||
cursor.execute("SELECT COUNT(*) FROM feeds")
|
cursor.execute("SELECT COUNT(*) FROM feeds")
|
||||||
total_feeds = cursor.fetchone()[0]
|
total_feeds = cursor.fetchone()[0]
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT f.id, f.nombre, f.url, c.nombre as categoria, p.nombre as pais, f.idioma, f.activo
|
SELECT f.id, f.nombre, f.url, c.nombre as categoria, p.nombre as pais, f.idioma, f.activo, f.fallos
|
||||||
FROM feeds f
|
FROM feeds f
|
||||||
LEFT JOIN categorias c ON f.categoria_id = c.id
|
LEFT JOIN categorias c ON f.categoria_id = c.id
|
||||||
LEFT JOIN paises p ON f.pais_id = p.id
|
LEFT JOIN paises p ON f.pais_id = p.id
|
||||||
ORDER BY f.nombre LIMIT %s OFFSET %s
|
ORDER BY f.nombre LIMIT %s OFFSET %s
|
||||||
""", (per_page, offset))
|
""", (per_page, offset))
|
||||||
feeds_list = cursor.fetchall()
|
feeds_list = cursor.fetchall()
|
||||||
|
|
@ -236,7 +291,7 @@ def add_feed():
|
||||||
app.logger.error(f"[DB ERROR] Al agregar feed: {db_err}", exc_info=True)
|
app.logger.error(f"[DB ERROR] Al agregar feed: {db_err}", exc_info=True)
|
||||||
flash(f"Error al añadir el feed: {db_err}", "error")
|
flash(f"Error al añadir el feed: {db_err}", "error")
|
||||||
return redirect(url_for("manage_feeds"))
|
return redirect(url_for("manage_feeds"))
|
||||||
|
|
||||||
categorias, paises = [], []
|
categorias, paises = [], []
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|
@ -313,10 +368,10 @@ def manage_urls():
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT f.id, f.nombre, f.url, c.nombre as categoria, p.nombre as pais, f.idioma
|
SELECT f.id, f.nombre, f.url, c.nombre as categoria, p.nombre as pais, f.idioma
|
||||||
FROM fuentes_url f
|
FROM fuentes_url f
|
||||||
LEFT JOIN categorias c ON f.categoria_id = c.id
|
LEFT JOIN categorias c ON f.categoria_id = c.id
|
||||||
LEFT JOIN paises p ON f.pais_id = p.id
|
LEFT JOIN paises p ON f.pais_id = p.id
|
||||||
ORDER BY f.nombre
|
ORDER BY f.nombre
|
||||||
""")
|
""")
|
||||||
fuentes = cursor.fetchall()
|
fuentes = cursor.fetchall()
|
||||||
|
|
@ -344,7 +399,7 @@ def add_url_source():
|
||||||
app.logger.error(f"[DB ERROR] Al agregar fuente URL: {db_err}", exc_info=True)
|
app.logger.error(f"[DB ERROR] Al agregar fuente URL: {db_err}", exc_info=True)
|
||||||
flash(f"Error al añadir la fuente URL: {db_err}", "error")
|
flash(f"Error al añadir la fuente URL: {db_err}", "error")
|
||||||
return redirect(url_for("manage_urls"))
|
return redirect(url_for("manage_urls"))
|
||||||
|
|
||||||
categorias, paises = [], []
|
categorias, paises = [], []
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|
@ -407,7 +462,7 @@ def fetch_and_store_all():
|
||||||
feeds_fallidos = []
|
feeds_fallidos = []
|
||||||
feeds_exitosos = []
|
feeds_exitosos = []
|
||||||
feeds_para_actualizar_headers = []
|
feeds_para_actualizar_headers = []
|
||||||
|
|
||||||
# --- Parte 1: Procesando Feeds RSS ---
|
# --- Parte 1: Procesando Feeds RSS ---
|
||||||
logging.info("=> Parte 1: Procesando Feeds RSS...")
|
logging.info("=> Parte 1: Procesando Feeds RSS...")
|
||||||
feeds_to_process = []
|
feeds_to_process = []
|
||||||
|
|
@ -424,7 +479,7 @@ def fetch_and_store_all():
|
||||||
if feeds_to_process:
|
if feeds_to_process:
|
||||||
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
||||||
future_to_feed = {executor.submit(process_single_feed, dict(feed)): feed for feed in feeds_to_process}
|
future_to_feed = {executor.submit(process_single_feed, dict(feed)): feed for feed in feeds_to_process}
|
||||||
for future in tqdm(as_completed(future_to_feed), total=len(feeds_to_process), desc="Procesando Feeds RSS"):
|
for future in tqdm(as_completed(future_to_feed), total=len(feeds_to_process), desc="Procesando Fuentes RSS"):
|
||||||
original_feed_data = future_to_feed[future]
|
original_feed_data = future_to_feed[future]
|
||||||
feed_id = original_feed_data['id']
|
feed_id = original_feed_data['id']
|
||||||
try:
|
try:
|
||||||
|
|
@ -457,7 +512,7 @@ def fetch_and_store_all():
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error de BD al obtener fuentes URL: {e}")
|
logging.error(f"Error de BD al obtener fuentes URL: {e}")
|
||||||
|
|
||||||
## CORRECCIÓN: Se paraleliza la captura de noticias desde fuentes URL para mejorar el rendimiento.
|
# Paraleliza la captura desde newspaper3k
|
||||||
if urls_to_process:
|
if urls_to_process:
|
||||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||||
future_to_url = {
|
future_to_url = {
|
||||||
|
|
@ -475,7 +530,7 @@ def fetch_and_store_all():
|
||||||
todas_las_noticias.extend(noticias_encontradas)
|
todas_las_noticias.extend(noticias_encontradas)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logging.error(f"Fallo al procesar la fuente URL {source['nombre']}: {exc}")
|
logging.error(f"Fallo al procesar la fuente URL {source['nombre']}: {exc}")
|
||||||
|
|
||||||
noticias_desde_urls_count = len(todas_las_noticias) - noticias_desde_rss_count
|
noticias_desde_urls_count = len(todas_las_noticias) - noticias_desde_rss_count
|
||||||
logging.info(f"=> Parte 2 Finalizada. Noticias encontradas desde URLs: {noticias_desde_urls_count}.")
|
logging.info(f"=> Parte 2 Finalizada. Noticias encontradas desde URLs: {noticias_desde_urls_count}.")
|
||||||
|
|
||||||
|
|
@ -493,7 +548,7 @@ def fetch_and_store_all():
|
||||||
cursor.execute("UPDATE feeds SET fallos = fallos + 1 WHERE id IN %s", (tuple(feeds_fallidos),))
|
cursor.execute("UPDATE feeds SET fallos = fallos + 1 WHERE id IN %s", (tuple(feeds_fallidos),))
|
||||||
cursor.execute("UPDATE feeds SET activo = FALSE WHERE fallos >= %s AND id IN %s", (MAX_FALLOS, tuple(feeds_fallidos)))
|
cursor.execute("UPDATE feeds SET activo = FALSE WHERE fallos >= %s AND id IN %s", (MAX_FALLOS, tuple(feeds_fallidos)))
|
||||||
logging.info(f"Incrementado contador de fallos para {len(feeds_fallidos)} feeds.")
|
logging.info(f"Incrementado contador de fallos para {len(feeds_fallidos)} feeds.")
|
||||||
|
|
||||||
if feeds_exitosos:
|
if feeds_exitosos:
|
||||||
cursor.execute("UPDATE feeds SET fallos = 0 WHERE id IN %s", (tuple(feeds_exitosos),))
|
cursor.execute("UPDATE feeds SET fallos = 0 WHERE id IN %s", (tuple(feeds_exitosos),))
|
||||||
logging.info(f"Reseteado contador de fallos para {len(feeds_exitosos)} feeds.")
|
logging.info(f"Reseteado contador de fallos para {len(feeds_exitosos)} feeds.")
|
||||||
|
|
@ -519,7 +574,7 @@ def fetch_and_store_all():
|
||||||
logging.info("=> Parte 3 Finalizada. Base de datos actualizada correctamente.")
|
logging.info("=> Parte 3 Finalizada. Base de datos actualizada correctamente.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error de BD en la actualización masiva final: {e}", exc_info=True)
|
logging.error(f"Error de BD en la actualización masiva final: {e}", exc_info=True)
|
||||||
|
|
||||||
logging.info("--- CICLO DE CAPTURA GLOBAL FINALIZADO ---")
|
logging.info("--- CICLO DE CAPTURA GLOBAL FINALIZADO ---")
|
||||||
|
|
||||||
# --- Funciones de Backup y Restore (sin cambios) ---
|
# --- Funciones de Backup y Restore (sin cambios) ---
|
||||||
|
|
@ -530,18 +585,18 @@ def backup_feeds():
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT f.id, f.nombre, f.descripcion, f.url, f.categoria_id, c.nombre AS categoria,
|
SELECT f.id, f.nombre, f.descripcion, f.url, f.categoria_id, c.nombre AS categoria,
|
||||||
f.pais_id, p.nombre AS pais, f.idioma, f.activo, f.fallos
|
f.pais_id, p.nombre AS pais, f.idioma, f.activo, f.fallos
|
||||||
FROM feeds f
|
FROM feeds f
|
||||||
LEFT JOIN categorias c ON f.categoria_id = c.id
|
LEFT JOIN categorias c ON f.categoria_id = c.id
|
||||||
LEFT JOIN paises p ON f.pais_id = p.id
|
LEFT JOIN paises p ON f.pais_id = p.id
|
||||||
ORDER BY f.id
|
ORDER BY f.id
|
||||||
""")
|
""")
|
||||||
feeds_ = cursor.fetchall()
|
feeds_ = cursor.fetchall()
|
||||||
if not feeds_:
|
if not feeds_:
|
||||||
flash("No hay feeds para exportar.", "warning")
|
flash("No hay feeds para exportar.", "warning")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("dashboard"))
|
||||||
|
|
||||||
fieldnames = list(feeds_[0].keys())
|
fieldnames = list(feeds_[0].keys())
|
||||||
output = StringIO()
|
output = StringIO()
|
||||||
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||||
|
|
@ -559,10 +614,10 @@ def backup_urls():
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT f.id, f.nombre, f.url, f.categoria_id, c.nombre AS categoria, f.pais_id, p.nombre AS pais, f.idioma
|
SELECT f.id, f.nombre, f.url, f.categoria_id, c.nombre AS categoria, f.pais_id, p.nombre AS pais, f.idioma
|
||||||
FROM fuentes_url f
|
FROM fuentes_url f
|
||||||
LEFT JOIN categorias c ON f.categoria_id = c.id
|
LEFT JOIN categorias c ON f.categoria_id = c.id
|
||||||
LEFT JOIN paises p ON f.pais_id = p.id
|
LEFT JOIN paises p ON f.pais_id = p.id
|
||||||
ORDER BY f.id
|
ORDER BY f.id
|
||||||
""")
|
""")
|
||||||
fuentes = cursor.fetchall()
|
fuentes = cursor.fetchall()
|
||||||
|
|
@ -576,8 +631,8 @@ def backup_urls():
|
||||||
writer.writeheader()
|
writer.writeheader()
|
||||||
writer.writerows([dict(fuente) for fuente in fuentes])
|
writer.writerows([dict(fuente) for fuente in fuentes])
|
||||||
return Response(
|
return Response(
|
||||||
output.getvalue(),
|
output.getvalue(),
|
||||||
mimetype="text/csv",
|
mimetype="text/csv",
|
||||||
headers={"Content-Disposition": "attachment;filename=fuentes_url_backup.csv"}
|
headers={"Content-Disposition": "attachment;filename=fuentes_url_backup.csv"}
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -591,12 +646,12 @@ def backup_noticias():
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT n.id, n.titulo, n.resumen, n.url, n.fecha, n.imagen_url, n.fuente_nombre,
|
SELECT n.id, n.titulo, n.resumen, n.url, n.fecha, n.imagen_url, n.fuente_nombre,
|
||||||
c.nombre AS categoria, p.nombre AS pais, co.nombre AS continente
|
c.nombre AS categoria, p.nombre AS pais, co.nombre AS continente
|
||||||
FROM noticias n
|
FROM noticias n
|
||||||
LEFT JOIN categorias c ON n.categoria_id = c.id
|
LEFT JOIN categorias c ON n.categoria_id = c.id
|
||||||
LEFT JOIN paises p ON n.pais_id = p.id
|
LEFT JOIN paises p ON n.pais_id = p.id
|
||||||
LEFT JOIN continentes co ON p.continente_id = co.id
|
LEFT JOIN continentes co ON p.continente_id = co.id
|
||||||
ORDER BY n.fecha DESC
|
ORDER BY n.fecha DESC
|
||||||
""")
|
""")
|
||||||
noticias = cursor.fetchall()
|
noticias = cursor.fetchall()
|
||||||
|
|
@ -666,7 +721,7 @@ def restore_feeds():
|
||||||
if not file or not file.filename.endswith(".csv"):
|
if not file or not file.filename.endswith(".csv"):
|
||||||
flash("Archivo no válido. Sube un .csv.", "error")
|
flash("Archivo no válido. Sube un .csv.", "error")
|
||||||
return redirect(url_for("restore_feeds"))
|
return redirect(url_for("restore_feeds"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
file_stream = StringIO(file.read().decode("utf-8", errors='ignore'))
|
file_stream = StringIO(file.read().decode("utf-8", errors='ignore'))
|
||||||
reader = csv.DictReader(file_stream)
|
reader = csv.DictReader(file_stream)
|
||||||
|
|
@ -705,7 +760,7 @@ def restore_feeds():
|
||||||
app.logger.error(f"Error al restaurar feeds desde CSV: {e}", exc_info=True)
|
app.logger.error(f"Error al restaurar feeds desde CSV: {e}", exc_info=True)
|
||||||
flash(f"Ocurrió un error general al procesar el archivo: {e}", "error")
|
flash(f"Ocurrió un error general al procesar el archivo: {e}", "error")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("dashboard"))
|
||||||
|
|
||||||
return render_template("restore_feeds.html")
|
return render_template("restore_feeds.html")
|
||||||
|
|
||||||
@app.route("/restore_urls", methods=["GET", "POST"])
|
@app.route("/restore_urls", methods=["GET", "POST"])
|
||||||
|
|
@ -715,7 +770,7 @@ def restore_urls():
|
||||||
if not file or not file.filename.endswith(".csv"):
|
if not file or not file.filename.endswith(".csv"):
|
||||||
flash("Archivo no válido. Sube un .csv.", "error")
|
flash("Archivo no válido. Sube un .csv.", "error")
|
||||||
return redirect(url_for("restore_urls"))
|
return redirect(url_for("restore_urls"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
file_stream = StringIO(file.read().decode("utf-8", errors='ignore'))
|
file_stream = StringIO(file.read().decode("utf-8", errors='ignore'))
|
||||||
reader = csv.DictReader(file_stream)
|
reader = csv.DictReader(file_stream)
|
||||||
|
|
@ -756,7 +811,7 @@ def restore_urls():
|
||||||
app.logger.error(f"Error al restaurar fuentes URL desde CSV: {e}", exc_info=True)
|
app.logger.error(f"Error al restaurar fuentes URL desde CSV: {e}", exc_info=True)
|
||||||
flash(f"Ocurrió un error general al procesar el archivo: {e}", "error")
|
flash(f"Ocurrió un error general al procesar el archivo: {e}", "error")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("dashboard"))
|
||||||
|
|
||||||
return render_template("restore_urls.html")
|
return render_template("restore_urls.html")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -765,3 +820,4 @@ if __name__ == "__main__":
|
||||||
app.logger.error("La aplicación no puede arrancar sin una conexión a la base de datos.")
|
app.logger.error("La aplicación no puede arrancar sin una conexión a la base de datos.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
app.run(host="0.0.0.0", port=8001, debug=True)
|
app.run(host="0.0.0.0", port=8001, debug=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,27 +3,28 @@
|
||||||
<li class="noticia-item">
|
<li class="noticia-item">
|
||||||
{% if noticia.imagen_url %}
|
{% if noticia.imagen_url %}
|
||||||
<div class="noticia-imagen">
|
<div class="noticia-imagen">
|
||||||
<a href="{{ noticia.url }}" target="_blank" rel="noopener noreferrer"><img src="{{ noticia.imagen_url }}" alt="Imagen para {{ noticia.titulo }}" loading="lazy"></a>
|
<a href="{{ noticia.url }}" target="_blank" rel="noopener noreferrer">
|
||||||
|
<img src="{{ noticia.imagen_url }}" alt="Imagen para {{ noticia.titulo }}" loading="lazy">
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="noticia-texto">
|
<div class="noticia-texto">
|
||||||
<h3><a href="{{ noticia.url }}" target="_blank" rel="noopener noreferrer">{{ noticia.titulo }}</a></h3>
|
<h3><a href="{{ noticia.url }}" target="_blank" rel="noopener noreferrer">{{ noticia.titulo }}</a></h3>
|
||||||
<div class="noticia-meta">
|
<div class="noticia-meta">
|
||||||
<span><i class="far fa-calendar-alt"></i> {{ noticia.fecha.strftime('%d-%m-%Y %H:%M') if noticia.fecha else 'N/D' }}</span>
|
<span><i class="far fa-calendar-alt"></i>
|
||||||
|
{{ noticia.fecha.strftime('%d-%m-%Y %H:%M') if noticia.fecha else 'N/D' }}
|
||||||
|
</span>
|
||||||
{% if noticia.fuente_nombre %}
|
{% if noticia.fuente_nombre %}
|
||||||
| <span><i class="fas fa-newspaper"></i> <strong>{{ noticia.fuente_nombre }}</strong></span>
|
| <span><i class="fas fa-newspaper"></i> <strong>{{ noticia.fuente_nombre }}</strong></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if noticia.categoria %}
|
{% if noticia.categoria %}
|
||||||
| <span><i class="fas fa-tag"></i> {{ noticia.categoria }}</span>
|
| <span><i class="fas fa-tag"></i> {{ noticia.categoria }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if noticia.pais %}
|
{% if noticia.pais %}
|
||||||
| <span><i class="fas fa-globe-americas"></i> {{ noticia.pais }}</span>
|
| <span><i class="fas fa-globe-americas"></i> {{ noticia.pais }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="resumen-container">
|
<div class="resumen-container">
|
||||||
<div class="resumen-corto">
|
<div class="resumen-corto">
|
||||||
{{ noticia.resumen | safe_html | truncate(280, True) }}
|
{{ noticia.resumen | safe_html | truncate(280, True) }}
|
||||||
|
|
@ -31,11 +32,11 @@
|
||||||
<div class="resumen-completo" style="display: none;">
|
<div class="resumen-completo" style="display: none;">
|
||||||
{{ noticia.resumen | safe_html }}
|
{{ noticia.resumen | safe_html }}
|
||||||
</div>
|
</div>
|
||||||
{% if noticia.resumen|length > 280 %}
|
{% if noticia.resumen and noticia.resumen|length > 280 %}
|
||||||
<button class="ver-mas-btn">Ver más</button>
|
<button class="ver-mas-btn">Ver más</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="text-center p-4">
|
<li class="text-center p-4">
|
||||||
|
|
@ -43,3 +44,50 @@
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
{# Resumen y paginación #}
|
||||||
|
{% if total_results and total_results > 0 %}
|
||||||
|
<div style="text-align:center; margin-top: 10px; color: var(--text-color-light);">
|
||||||
|
{% set start_i = (page - 1) * per_page + 1 %}
|
||||||
|
{% set end_i = (page - 1) * per_page + (noticias|length) %}
|
||||||
|
Mostrando {{ start_i }}–{{ end_i }} de {{ total_results }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if total_pages and total_pages > 1 %}
|
||||||
|
<nav class="pagination" aria-label="Paginación de noticias" style="margin-top: 15px;">
|
||||||
|
{% set current = page %}
|
||||||
|
{# Anterior #}
|
||||||
|
{% if current > 1 %}
|
||||||
|
<a href="#" class="page-link" data-page="{{ current - 1 }}">« Anterior</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Ventana de páginas (máx 5 alrededor) #}
|
||||||
|
{% 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="#" class="page-link" data-page="1">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="#" class="page-link" data-page="{{ p }}">{{ p }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if end < total_pages %}
|
||||||
|
{% if end < total_pages - 1 %}<span class="page-link">…</span>{% endif %}
|
||||||
|
<a href="#" class="page-link" data-page="{{ total_pages }}">{{ total_pages }}</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Siguiente #}
|
||||||
|
{% if current < total_pages %}
|
||||||
|
<a href="#" class="page-link" data-page="{{ current + 1 }}">Siguiente »</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,27 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Últimas Noticias RSS{% endblock %}
|
{% block title %}Últimas Noticias RSS{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><i class="fas fa-filter" style="color: var(--secondary-color); margin-right: 10px;"></i>Filtrar Noticias</h2>
|
<h2><i class="fas fa-filter" style="color: var(--secondary-color); margin-right: 10px;"></i>Filtrar Noticias</h2>
|
||||||
|
|
||||||
<form method="get" action="{{ url_for('home') }}" id="filter-form">
|
<form method="get" action="{{ url_for('home') }}" id="filter-form">
|
||||||
|
<!-- Campos ocultos para paginación -->
|
||||||
|
<input type="hidden" name="page" id="page" value="{{ page or 1 }}">
|
||||||
|
<input type="hidden" name="per_page" id="per_page" value="{{ per_page or 20 }}">
|
||||||
|
|
||||||
<div class="filter-main-row">
|
<div class="filter-main-row">
|
||||||
|
|
||||||
<div class="filter-search-box">
|
<div class="filter-search-box">
|
||||||
<label for="q">Buscar por palabra clave</label>
|
<label for="q">Buscar por palabra clave</label>
|
||||||
<input type="search" name="q" id="q" placeholder="Ej: Trump, California, IA..." value="{{ q or '' }}">
|
<input type="search" name="q" id="q" placeholder="Ej: Trump, California, IA..." value="{{ q or '' }}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-actions">
|
<div class="filter-actions">
|
||||||
<button type="submit" class="btn"><i class="fas fa-search"></i> Filtrar</button>
|
<button type="submit" class="btn"><i class="fas fa-search"></i> Filtrar</button>
|
||||||
<a href="{{ url_for('home') }}" class="btn btn-secondary"><i class="fas fa-eraser"></i> Limpiar</a>
|
<a href="{{ url_for('home') }}" class="btn btn-secondary"><i class="fas fa-eraser"></i> Limpiar</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 15px; align-items: flex-end; border-top: 1px solid var(--border-color); padding-top: 20px;">
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 15px; align-items: flex-end; border-top: 1px solid var(--border-color); padding-top: 20px;">
|
||||||
<div>
|
<div>
|
||||||
<label for="categoria_id">Categoría</label>
|
<label for="categoria_id">Categoría</label>
|
||||||
|
|
@ -59,6 +61,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="noticias-container">
|
<div id="noticias-container">
|
||||||
|
{# El parcial incluye la lista + la paginación #}
|
||||||
{% include '_noticias_list.html' %}
|
{% include '_noticias_list.html' %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -67,8 +70,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
const form = document.getElementById('filter-form');
|
const form = document.getElementById('filter-form');
|
||||||
const continenteSelect = document.getElementById('continente_id');
|
const continenteSelect = document.getElementById('continente_id');
|
||||||
const paisSelect = document.getElementById('pais_id');
|
const paisSelect = document.getElementById('pais_id');
|
||||||
|
const pageInput = document.getElementById('page');
|
||||||
|
|
||||||
// Lógica para filtrar países al cambiar el continente
|
|
||||||
function filtrarPaises() {
|
function filtrarPaises() {
|
||||||
const continenteId = continenteSelect.value;
|
const continenteId = continenteSelect.value;
|
||||||
for (let i = 1; i < paisSelect.options.length; i++) {
|
for (let i = 1; i < paisSelect.options.length; i++) {
|
||||||
|
|
@ -82,19 +85,20 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lógica para enviar el formulario con AJAX
|
async function cargarNoticias(keepPage) {
|
||||||
async function actualizarNoticias(event) {
|
// Si cambiamos filtros manualmente, reiniciamos a página 1
|
||||||
if (event) event.preventDefault();
|
if (!keepPage) pageInput.value = 1;
|
||||||
|
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
const params = new URLSearchParams(formData);
|
const params = new URLSearchParams(formData);
|
||||||
const newUrl = `${form.action}?${params.toString()}`;
|
const newUrl = `${form.action}?${params.toString()}`;
|
||||||
|
|
||||||
const container = document.getElementById('noticias-container');
|
const container = document.getElementById('noticias-container');
|
||||||
container.style.opacity = '0.5';
|
container.style.opacity = '0.5';
|
||||||
container.innerHTML = '<div style="text-align:center; padding: 40px;"><i class="fas fa-spinner fa-spin fa-2x"></i></div>';
|
container.innerHTML = '<div style="text-align:center; padding: 40px;"><i class="fas fa-spinner fa-spin fa-2x"></i></div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(newUrl, {
|
const response = await fetch(newUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
||||||
});
|
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
window.history.pushState({path: newUrl}, '', newUrl);
|
window.history.pushState({path: newUrl}, '', newUrl);
|
||||||
|
|
@ -106,10 +110,31 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Asignar los eventos
|
// Envío del formulario (filtrar) -> page = 1
|
||||||
continenteSelect.addEventListener('change', filtrarPaises);
|
form.addEventListener('submit', function(e) {
|
||||||
form.addEventListener('submit', actualizarNoticias);
|
e.preventDefault();
|
||||||
|
cargarNoticias(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clic en enlaces de paginación (delegación)
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const link = e.target.closest('a.page-link');
|
||||||
|
if (link && link.dataset.page) {
|
||||||
|
e.preventDefault();
|
||||||
|
pageInput.value = link.dataset.page;
|
||||||
|
cargarNoticias(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inicializaciones
|
||||||
|
continenteSelect.addEventListener('change', function() {
|
||||||
|
filtrarPaises();
|
||||||
|
// al cambiar continente, forzamos recarga desde página 1
|
||||||
|
cargarNoticias(false);
|
||||||
|
});
|
||||||
|
|
||||||
filtrarPaises(); // Ejecutar al inicio
|
filtrarPaises(); // Ejecutar al inicio
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue