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))
|
||||
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
|
||||
try:
|
||||
db_pool = psycopg2.pool.SimpleConnectionPool(minconn=1, maxconn=10, **DB_CONFIG)
|
||||
|
|
@ -47,17 +50,20 @@ except psycopg2.OperationalError as e:
|
|||
|
||||
@contextmanager
|
||||
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
|
||||
try:
|
||||
conn = db_pool.getconn()
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
if conn: conn.rollback()
|
||||
if conn:
|
||||
conn.rollback()
|
||||
raise e
|
||||
finally:
|
||||
if conn: db_pool.putconn(conn)
|
||||
if conn:
|
||||
db_pool.putconn(conn)
|
||||
|
||||
@atexit.register
|
||||
def shutdown_hooks():
|
||||
|
|
@ -67,7 +73,8 @@ def shutdown_hooks():
|
|||
|
||||
@app.template_filter('safe_html')
|
||||
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)
|
||||
|
||||
def _get_form_dependencies(cursor):
|
||||
|
|
@ -77,43 +84,45 @@ def _get_form_dependencies(cursor):
|
|||
paises = cursor.fetchall()
|
||||
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):
|
||||
"""Construye la consulta SQL y los parámetros basados en los argumentos de la petición."""
|
||||
def _build_news_query(args, *, count=False, limit=None, offset=None):
|
||||
"""
|
||||
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 = []
|
||||
conditions = []
|
||||
|
||||
|
||||
q = args.get("q", "").strip()
|
||||
cat_id = args.get("categoria_id")
|
||||
cont_id = args.get("continente_id")
|
||||
pais_id = args.get("pais_id")
|
||||
fecha_filtro = args.get("fecha")
|
||||
|
||||
sql_base = """
|
||||
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
|
||||
FROM noticias n
|
||||
LEFT JOIN categorias c ON n.categoria_id = c.id
|
||||
LEFT JOIN paises p ON n.pais_id = p.id
|
||||
sql_from = """
|
||||
FROM noticias n
|
||||
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
|
||||
"""
|
||||
|
||||
# WHERE dinámico
|
||||
if q:
|
||||
search_query = " & ".join(q.split())
|
||||
conditions.append("n.tsv @@ to_tsquery('spanish', %s)")
|
||||
sql_params.append(search_query)
|
||||
|
||||
# plainto_tsquery para tolerar espacios/acentos
|
||||
conditions.append("n.tsv @@ plainto_tsquery('spanish', %s)")
|
||||
sql_params.append(q)
|
||||
|
||||
if cat_id:
|
||||
conditions.append("n.categoria_id = %s")
|
||||
sql_params.append(cat_id)
|
||||
|
||||
|
||||
if pais_id:
|
||||
conditions.append("n.pais_id = %s")
|
||||
sql_params.append(pais_id)
|
||||
elif cont_id:
|
||||
conditions.append("p.continente_id = %s")
|
||||
sql_params.append(cont_id)
|
||||
|
||||
|
||||
if fecha_filtro:
|
||||
try:
|
||||
fecha_obj = datetime.strptime(fecha_filtro, '%Y-%m-%d')
|
||||
|
|
@ -122,36 +131,70 @@ def _build_news_query(args):
|
|||
except ValueError:
|
||||
flash("Formato de fecha no válido. Use AAAA-MM-DD.", "error")
|
||||
|
||||
if conditions:
|
||||
sql_base += " WHERE " + " AND ".join(conditions)
|
||||
where_clause = " WHERE " + " AND ".join(conditions) if conditions else ""
|
||||
|
||||
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"
|
||||
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"
|
||||
return sql_final, sql_params
|
||||
if q:
|
||||
# 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("/")
|
||||
def home():
|
||||
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()
|
||||
cat_id = request.args.get("categoria_id")
|
||||
cont_id = request.args.get("continente_id")
|
||||
pais_id = request.args.get("pais_id")
|
||||
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:
|
||||
with get_conn() as conn:
|
||||
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")
|
||||
categorias = cursor.fetchall()
|
||||
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")
|
||||
paises = cursor.fetchall()
|
||||
|
||||
# Construir y ejecutar la consulta de noticias
|
||||
sql_final, sql_params = _build_news_query(request.args)
|
||||
cursor.execute(sql_final, tuple(sql_params))
|
||||
# 1) Conteo total
|
||||
sql_count, params_count = _build_news_query(request.args, count=True)
|
||||
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()
|
||||
|
||||
except psycopg2.Error as db_err:
|
||||
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")
|
||||
|
||||
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':
|
||||
return render_template('_noticias_list.html', noticias=noticias)
|
||||
|
||||
return render_template("noticias.html",
|
||||
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)
|
||||
return render_template('_noticias_list.html', **ctx)
|
||||
|
||||
# Render completo
|
||||
return render_template("noticias.html", **ctx)
|
||||
|
||||
@app.route("/dashboard")
|
||||
def dashboard():
|
||||
|
|
@ -205,10 +260,10 @@ def manage_feeds():
|
|||
cursor.execute("SELECT COUNT(*) FROM feeds")
|
||||
total_feeds = cursor.fetchone()[0]
|
||||
cursor.execute("""
|
||||
SELECT f.id, f.nombre, f.url, c.nombre as categoria, p.nombre as pais, f.idioma, f.activo
|
||||
FROM feeds f
|
||||
LEFT JOIN categorias c ON f.categoria_id = c.id
|
||||
LEFT JOIN paises p ON f.pais_id = p.id
|
||||
SELECT f.id, f.nombre, f.url, c.nombre as categoria, p.nombre as pais, f.idioma, f.activo, f.fallos
|
||||
FROM feeds f
|
||||
LEFT JOIN categorias c ON f.categoria_id = c.id
|
||||
LEFT JOIN paises p ON f.pais_id = p.id
|
||||
ORDER BY f.nombre LIMIT %s OFFSET %s
|
||||
""", (per_page, offset))
|
||||
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)
|
||||
flash(f"Error al añadir el feed: {db_err}", "error")
|
||||
return redirect(url_for("manage_feeds"))
|
||||
|
||||
|
||||
categorias, paises = [], []
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
|
|
@ -313,10 +368,10 @@ def manage_urls():
|
|||
with get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||
cursor.execute("""
|
||||
SELECT f.id, f.nombre, f.url, c.nombre as categoria, p.nombre as pais, f.idioma
|
||||
FROM fuentes_url f
|
||||
LEFT JOIN categorias c ON f.categoria_id = c.id
|
||||
LEFT JOIN paises p ON f.pais_id = p.id
|
||||
SELECT f.id, f.nombre, f.url, c.nombre as categoria, p.nombre as pais, f.idioma
|
||||
FROM fuentes_url f
|
||||
LEFT JOIN categorias c ON f.categoria_id = c.id
|
||||
LEFT JOIN paises p ON f.pais_id = p.id
|
||||
ORDER BY f.nombre
|
||||
""")
|
||||
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)
|
||||
flash(f"Error al añadir la fuente URL: {db_err}", "error")
|
||||
return redirect(url_for("manage_urls"))
|
||||
|
||||
|
||||
categorias, paises = [], []
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
|
|
@ -407,7 +462,7 @@ def fetch_and_store_all():
|
|||
feeds_fallidos = []
|
||||
feeds_exitosos = []
|
||||
feeds_para_actualizar_headers = []
|
||||
|
||||
|
||||
# --- Parte 1: Procesando Feeds RSS ---
|
||||
logging.info("=> Parte 1: Procesando Feeds RSS...")
|
||||
feeds_to_process = []
|
||||
|
|
@ -424,7 +479,7 @@ def fetch_and_store_all():
|
|||
if feeds_to_process:
|
||||
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}
|
||||
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]
|
||||
feed_id = original_feed_data['id']
|
||||
try:
|
||||
|
|
@ -457,7 +512,7 @@ def fetch_and_store_all():
|
|||
except Exception as 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:
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
future_to_url = {
|
||||
|
|
@ -475,7 +530,7 @@ def fetch_and_store_all():
|
|||
todas_las_noticias.extend(noticias_encontradas)
|
||||
except Exception as 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
|
||||
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 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.")
|
||||
|
||||
|
||||
if 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.")
|
||||
|
|
@ -519,7 +574,7 @@ def fetch_and_store_all():
|
|||
logging.info("=> Parte 3 Finalizada. Base de datos actualizada correctamente.")
|
||||
except Exception as e:
|
||||
logging.error(f"Error de BD en la actualización masiva final: {e}", exc_info=True)
|
||||
|
||||
|
||||
logging.info("--- CICLO DE CAPTURA GLOBAL FINALIZADO ---")
|
||||
|
||||
# --- Funciones de Backup y Restore (sin cambios) ---
|
||||
|
|
@ -530,18 +585,18 @@ def backup_feeds():
|
|||
with get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||
cursor.execute("""
|
||||
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
|
||||
FROM feeds f
|
||||
LEFT JOIN categorias c ON f.categoria_id = c.id
|
||||
LEFT JOIN paises p ON f.pais_id = p.id
|
||||
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
|
||||
FROM feeds f
|
||||
LEFT JOIN categorias c ON f.categoria_id = c.id
|
||||
LEFT JOIN paises p ON f.pais_id = p.id
|
||||
ORDER BY f.id
|
||||
""")
|
||||
feeds_ = cursor.fetchall()
|
||||
if not feeds_:
|
||||
flash("No hay feeds para exportar.", "warning")
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
|
||||
fieldnames = list(feeds_[0].keys())
|
||||
output = StringIO()
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||
|
|
@ -559,10 +614,10 @@ def backup_urls():
|
|||
with get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||
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
|
||||
FROM fuentes_url f
|
||||
LEFT JOIN categorias c ON f.categoria_id = c.id
|
||||
LEFT JOIN paises p ON f.pais_id = p.id
|
||||
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
|
||||
LEFT JOIN categorias c ON f.categoria_id = c.id
|
||||
LEFT JOIN paises p ON f.pais_id = p.id
|
||||
ORDER BY f.id
|
||||
""")
|
||||
fuentes = cursor.fetchall()
|
||||
|
|
@ -576,8 +631,8 @@ def backup_urls():
|
|||
writer.writeheader()
|
||||
writer.writerows([dict(fuente) for fuente in fuentes])
|
||||
return Response(
|
||||
output.getvalue(),
|
||||
mimetype="text/csv",
|
||||
output.getvalue(),
|
||||
mimetype="text/csv",
|
||||
headers={"Content-Disposition": "attachment;filename=fuentes_url_backup.csv"}
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
@ -591,12 +646,12 @@ def backup_noticias():
|
|||
with get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||
cursor.execute("""
|
||||
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
|
||||
FROM noticias n
|
||||
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
|
||||
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
|
||||
FROM noticias n
|
||||
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
|
||||
ORDER BY n.fecha DESC
|
||||
""")
|
||||
noticias = cursor.fetchall()
|
||||
|
|
@ -666,7 +721,7 @@ def restore_feeds():
|
|||
if not file or not file.filename.endswith(".csv"):
|
||||
flash("Archivo no válido. Sube un .csv.", "error")
|
||||
return redirect(url_for("restore_feeds"))
|
||||
|
||||
|
||||
try:
|
||||
file_stream = StringIO(file.read().decode("utf-8", errors='ignore'))
|
||||
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)
|
||||
flash(f"Ocurrió un error general al procesar el archivo: {e}", "error")
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
|
||||
return render_template("restore_feeds.html")
|
||||
|
||||
@app.route("/restore_urls", methods=["GET", "POST"])
|
||||
|
|
@ -715,7 +770,7 @@ def restore_urls():
|
|||
if not file or not file.filename.endswith(".csv"):
|
||||
flash("Archivo no válido. Sube un .csv.", "error")
|
||||
return redirect(url_for("restore_urls"))
|
||||
|
||||
|
||||
try:
|
||||
file_stream = StringIO(file.read().decode("utf-8", errors='ignore'))
|
||||
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)
|
||||
flash(f"Ocurrió un error general al procesar el archivo: {e}", "error")
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
|
||||
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.")
|
||||
sys.exit(1)
|
||||
app.run(host="0.0.0.0", port=8001, debug=True)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue