Preparar repositorio para despliegue: código fuente limpio

This commit is contained in:
jlimolina 2026-01-23 02:00:40 +01:00
parent 866f5c432d
commit 3eca832c1a
76 changed files with 5434 additions and 3496 deletions

View file

@ -1,494 +0,0 @@
routers/
├── __init__.py # Paquete Python (vacío)
├── home.py # Página principal y búsqueda de noticias
├── feeds.py # Gestión de feeds RSS
├── urls.py # Gestión de fuentes de URL
├── noticia.py # Página de detalle de noticia
├── eventos.py # Visualización de eventos por país
└── backup.py # Importación/exportación de feeds
init.py
Propósito: Archivo necesario para que Python reconozca este directorio como un paquete.
Contenido: Vacío o comentario explicativo.
Uso: Permite importar blueprints desde routers:
python
from routers.home import home_bp
home.py
Propósito: Blueprint para la página principal y búsqueda de noticias.
Ruta base: / y /home
Blueprints definidos:
home_bp = Blueprint("home", __name__)
Rutas:
@home_bp.route("/") y @home_bp.route("/home")
Método: GET
Descripción: Página principal con sistema de búsqueda avanzada.
Parámetros de consulta soportados:
page: Número de página (default: 1)
per_page: Resultados por página (default: 20, range: 10-100)
q: Término de búsqueda
categoria_id: Filtrar por categoría
continente_id: Filtrar por continente
pais_id: Filtrar por país
fecha: Filtrar por fecha (YYYY-MM-DD)
lang: Idioma para mostrar (default: "es")
orig: Si está presente, mostrar sólo originales sin traducciones
Funcionalidades:
Paginación: Sistema robusto con límites
Búsqueda avanzada: Usa models.noticias.buscar_noticias()
Soporte AJAX: Si X-Requested-With: XMLHttpRequest, retorna solo _noticias_list.html
Filtros combinados: Todos los filtros pueden usarse simultáneamente
Manejo de fechas: Conversión segura de strings a date
Variables de contexto para template:
noticias: Lista de noticias con datos completos
total_results: Total de resultados
total_pages: Total de páginas
categorias, paises: Para dropdowns de filtros
tags_por_tr: Diccionario de tags por traducción
Templates utilizados:
noticias.html: Página completa (HTML)
_noticias_list.html: Fragmento para AJAX (solo lista de noticias)
Características especiales:
use_tr = not bool(request.args.get("orig")): Controla si mostrar traducciones
lang = (request.args.get("lang") or DEFAULT_TRANSLATION_LANG or DEFAULT_LANG).lower()[:5]: Manejo seguro de idioma
feeds.py
Propósito: Blueprint para la gestión completa de feeds RSS.
Ruta base: /feeds
Blueprints definidos:
feeds_bp = Blueprint("feeds", __name__, url_prefix="/feeds")
Rutas:
@feeds_bp.route("/") - list_feeds()
Método: GET
Descripción: Listado paginado de feeds con filtros avanzados.
Parámetros de filtro:
pais_id: Filtrar por país
categoria_id: Filtrar por categoría
estado: "activos", "inactivos", "errores" o vacío para todos
Características:
Paginación (50 feeds por página)
Contador de totales
Ordenamiento: país → categoría → nombre
@feeds_bp.route("/add", methods=["GET", "POST"]) - add_feed()
Método: GET y POST
Descripción: Formulario para añadir nuevo feed.
Campos del formulario:
nombre: Nombre del feed (requerido)
descripcion: Descripción opcional
url: URL del feed RSS (requerido)
categoria_id: Categoría (select dropdown)
pais_id: País (select dropdown)
idioma: Código de idioma (2 letras, opcional)
Validaciones:
idioma se normaliza a minúsculas y máximo 2 caracteres
Campos opcionales convertidos a None si vacíos
@feeds_bp.route("/<int:feed_id>/edit", methods=["GET", "POST"]) - edit_feed(feed_id)
Método: GET y POST
Descripción: Editar feed existente.
Funcionalidades:
Pre-carga datos actuales del feed
Mismo formulario que add_feed pero con datos existentes
Campo adicional: activo (checkbox)
@feeds_bp.route("/<int:feed_id>/delete") - delete_feed(feed_id)
Método: GET
Descripción: Eliminar feed por ID.
Nota: DELETE simple sin confirmación en frontend (depende de template).
@feeds_bp.route("/<int:feed_id>/reactivar") - reactivar_feed(feed_id)
Método: GET
Descripción: Reactivar feed que tiene fallos.
Acción: Establece activo=TRUE y fallos=0.
Templates utilizados:
feeds_list.html: Listado principal
add_feed.html: Formulario de añadir
edit_feed.html: Formulario de editar
urls.py
Propósito: Blueprint para gestión de fuentes de URL (no feeds RSS).
Ruta base: /urls
Blueprints definidos:
urls_bp = Blueprint("urls", __name__, url_prefix="/urls")
Rutas:
@urls_bp.route("/") - manage_urls()
Método: GET
Descripción: Lista todas las fuentes de URL registradas.
Datos mostrados: ID, nombre, URL, categoría, país, idioma.
@urls_bp.route("/add_source", methods=["GET", "POST"]) - add_url_source()
Método: GET y POST
Descripción: Añadir/actualizar fuente de URL.
Características únicas:
Usa ON CONFLICT (url) DO UPDATE: Si la URL ya existe, actualiza
idioma default: "es" si no se especifica
Mismos campos que feeds pero para URLs individuales
Templates utilizados:
urls_list.html: Listado
add_url_source.html: Formulario
noticia.py
Propósito: Blueprint para página de detalle de noticia individual.
Ruta base: /noticia
Blueprints definidos:
noticia_bp = Blueprint("noticia", __name__)
Rutas:
@noticia_bp.route("/noticia") - noticia()
Método: GET
Descripción: Muestra detalle completo de una noticia.
Parámetros de consulta:
tr_id: ID de traducción (prioritario)
id: ID de noticia original (si no hay tr_id)
Flujo de datos:
Si hay tr_id: Obtiene datos combinados de traducción y noticia original
Si solo hay id: Obtiene solo datos originales
Si no hay ninguno: Redirige a home con mensaje de error
Datos obtenidos:
Información básica: título, resumen, URL, fecha, imagen, fuente
Datos de traducción (si aplica): idiomas, títulos/resúmenes traducidos
Metadatos: categoría, país
Tags: Etiquetas asociadas a la traducción
Noticias relacionadas: Hasta 8, ordenadas por score de similitud
Consultas adicionales (solo si hay traducción):
Tags: SELECT tg.valor, tg.tipo FROM tags_noticia...
Noticias relacionadas: SELECT n2.url, n2.titulo... FROM related_noticias...
Templates utilizados:
noticia.html: Página de detalle completa
eventos.py
Propósito: Blueprint para visualización de eventos agrupados por país.
Ruta base: /eventos_pais
Blueprints definidos:
eventos_bp = Blueprint("eventos", __name__, url_prefix="/eventos_pais")
Rutas:
@eventos_bp.route("/") - eventos_pais()
Método: GET
Descripción: Lista eventos (clusters de noticias) filtrados por país.
Parámetros de consulta:
pais_id: ID del país (obligatorio para ver eventos)
page: Número de página (default: 1)
lang: Idioma para traducciones (default: "es")
Funcionalidades:
Lista de países: Siempre visible para selección
Eventos paginados: 30 por página
Noticias por evento: Agrupadas bajo cada evento
Datos completos: Cada noticia con originales y traducidos
Estructura de datos:
Países: Lista completa para dropdown
Eventos: Paginados, con título, fechas, conteo de noticias
Noticias por evento: Diccionario {evento_id: [noticias...]}
Consultas complejas:
Agrupación con GROUP BY y MAX(p.nombre)
JOIN múltiple: eventos ↔ traducciones ↔ noticias ↔ países
Subconsulta para noticias por evento usando ANY(%s)
Variables de contexto:
paises, eventos, noticias_por_evento
pais_nombre: Nombre del país seleccionado
total_eventos, total_pages, page, lang
Templates utilizados:
eventos_pais.html: Página principal
backup.py
Propósito: Blueprint para importación y exportación de feeds en CSV.
Ruta base: /backup_feeds y /restore_feeds
Blueprints definidos:
backup_bp = Blueprint("backup", __name__)
Rutas:
@backup_bp.route("/backup_feeds") - backup_feeds()
Método: GET
Descripción: Exporta todos los feeds a CSV.
Características:
Incluye joins con categorías y países para nombres legibles
Codificación UTF-8 con BOM
Nombre de archivo: feeds_backup.csv
Usa io.StringIO y io.BytesIO para evitar archivos temporales
Campos exportados:
Todos los campos de feeds más nombres de categoría y país
@backup_bp.route("/restore_feeds", methods=["GET", "POST"]) - restore_feeds()
Método: GET y POST
Descripción: Restaura feeds desde CSV (reemplazo completo).
Flujo de restauración:
GET: Muestra formulario de subida
POST:
Valida archivo y encabezados CSV
TRUNCATE feeds RESTART IDENTITY CASCADE: Borra todo antes de importar
Procesa cada fila con validación
Estadísticas: importados, saltados, fallidos
Validaciones:
Encabezados exactos esperados
URL y nombre no vacíos
Conversión segura de tipos (int, bool)
Normalización de idioma (2 caracteres minúsculas)
Limpieza de datos:
python
row = {k: (v.strip().rstrip("ç") if v else "") for k, v in row.items()}
Manejo de booleanos:
python
activo = str(row["activo"]).lower() in ("true", "1", "t", "yes", "y")
Templates utilizados:
restore_feeds.html: Formulario de subida
Patrones de Diseño Comunes
1. Estructura de Blueprints
python
# Definición estándar
bp = Blueprint("nombre", __name__, url_prefix="/ruta")
# Registro en app.py
app.register_blueprint(bp)
2. Manejo de Conexiones a BD
python
with get_conn() as conn:
# Usar conn para múltiples operaciones
# conn.autocommit = True si es necesario
3. Paginación Consistente
python
page = max(int(request.args.get("page", 1)), 1)
per_page = 50 # o variable
offset = (page - 1) * per_page
4. Manejo de Parámetros de Filtro
python
where = []
params = []
if pais_id:
where.append("f.pais_id = %s")
params.append(int(pais_id))
where_sql = "WHERE " + " AND ".join(where) if where else ""
5. Flash Messages
python
flash("Operación exitosa", "success")
flash("Error: algo salió mal", "error")
6. Redirecciones
python
return redirect(url_for("blueprint.funcion"))
7. Manejo de Formularios
python
if request.method == "POST":
# Procesar datos
return redirect(...)
# GET: mostrar formulario
return render_template("form.html", datos=...)
Seguridad y Validaciones
1. SQL Injection
Todos los parámetros usan %s con psycopg2
No hay concatenación de strings en SQL
2. Validación de Entrada
Conversión segura a int: int(valor) if valor else None
Limpieza de strings: .strip(), normalización
Rangos: min(max(per_page, 10), 100)
3. Manejo de Archivos
Validación de tipo de contenido
Decodificación UTF-8 con manejo de BOM
Uso de io para evitar archivos temporales
Optimizaciones
1. JOINs Eficientes
LEFT JOIN para datos opcionales
GROUP BY cuando es necesario
Uso de índices implícitos en ORDER BY
2. Batch Operations
TRUNCATE ... RESTART IDENTITY más rápido que DELETE
Inserción fila por fila con validación
3. Manejo de Memoria
io.StringIO para CSV en memoria
Cursors con DictCursor para acceso por nombre
Dependencias entre Blueprints
text
home.py
└── usa: models.noticias.buscar_noticias()
└── usa: _extraer_tags_por_traduccion()
feeds.py
└── usa: models.categorias.get_categorias()
└── usa: models.paises.get_paises()
urls.py
└── usa: models.categorias.get_categorias()
└── usa: models.paises.get_paises()
noticia.py
└── consultas directas (no usa models/)
eventos.py
└── consultas directas (no usa models/)
backup.py
└── consultas directas (no usa models/)

View file

@ -4,12 +4,14 @@ from psycopg2 import extras
from models.categorias import get_categorias
from models.paises import get_paises
from utils.feed_discovery import discover_feeds, validate_feed, get_feed_metadata
from cache import cached
# Blueprint correcto
feeds_bp = Blueprint("feeds", __name__, url_prefix="/feeds")
@feeds_bp.route("/")
@cached(ttl_seconds=300, prefix="feeds") # 5 minutos para listados
def list_feeds():
"""Listado con filtros"""
page = max(int(request.args.get("page", 1)), 1)

View file

@ -6,14 +6,14 @@ from utils.auth import get_current_user
from config import DEFAULT_TRANSLATION_LANG, DEFAULT_LANG, NEWS_PER_PAGE_DEFAULT
from models.categorias import get_categorias
from models.paises import get_paises
from models.noticias import buscar_noticias, buscar_noticias_semantica
from cache import cached
from models.noticias import buscar_noticias
home_bp = Blueprint("home", __name__)
@home_bp.route("/")
@home_bp.route("/home")
def home():
"""Simplified home page to avoid timeouts."""
page = max(int(request.args.get("page", 1)), 1)
per_page = int(request.args.get("per_page", NEWS_PER_PAGE_DEFAULT))
per_page = min(max(per_page, 10), 100)
@ -27,7 +27,6 @@ def home():
lang = (request.args.get("lang") or DEFAULT_TRANSLATION_LANG or DEFAULT_LANG).lower()[:5]
use_tr = not bool(request.args.get("orig"))
fecha_str = request.args.get("fecha") or ""
fecha_filtro = None
if fecha_str:
try:
@ -35,129 +34,28 @@ def home():
except ValueError:
fecha_filtro = None
from utils.qdrant_search import semantic_search
# Búsqueda semántica solo si se solicita explícitamente y hay query
use_semantic = bool(request.args.get("semantic")) and bool(q)
# Logic for semantic search enabled by default if query exists, unless explicitly disabled
# If the user passed 'semantic=' explicitly as empty string, it might mean False, but for UX speed default to True is better.
# However, let's respect the flag if it's explicitly 'false' or '0'.
# If key is missing, default to True. If key is present but empty, treat as False (standard HTML form behavior unfortunately).
# But wait, the previous log showed 'semantic='. HTML checkboxes send nothing if unchecked, 'on' if checked.
# So if it appears as empty string, it might be a hidden input or unassigned var.
# Let's check 'semantic' param presence.
raw_semantic = request.args.get("semantic")
if raw_semantic is None:
use_semantic = True # Default to semantic if not specified
elif raw_semantic == "" or raw_semantic.lower() in ["false", "0", "off"]:
use_semantic = False
else:
use_semantic = True
with get_read_conn() as conn:
conn.autocommit = True
categorias = get_categorias(conn)
paises = get_paises(conn)
noticias = []
total_results = 0
total_pages = 0
tags_por_tr = {}
# 1. Intentar búsqueda semántica si hay query y está habilitado
semantic_success = False
if use_semantic and q:
try:
# Obtener más resultados para 'llenar' la página si hay IDs no encontrados
limit_fetch = per_page * 2
sem_results = semantic_search(
query=q,
limit=limit_fetch, # Pedimos más para asegurar
score_threshold=0.30
)
if sem_results:
# Extraer IDs
news_ids = [r['news_id'] for r in sem_results]
# Traer datos completos de PostgreSQL (igual que en search.py)
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
query_sql = """
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,
-- traducciones
t.id AS traduccion_id,
t.titulo_trad AS titulo_traducido,
t.resumen_trad AS resumen_traducido,
CASE WHEN t.id IS NOT NULL THEN TRUE ELSE FALSE END AS tiene_traduccion,
-- originales
n.titulo AS titulo_original,
n.resumen AS resumen_original
FROM noticias n
LEFT JOIN categorias c ON c.id = n.categoria_id
LEFT JOIN paises p ON p.id = n.pais_id
LEFT JOIN traducciones t
ON t.noticia_id = n.id
AND t.lang_to = %s
AND t.status = 'done'
WHERE n.id = ANY(%s)
"""
cur.execute(query_sql, (lang, news_ids))
rows = cur.fetchall()
# Convertimos a lista para poder ordenar por fecha
rows_list = list(rows)
# Ordenar cronológicamente (más reciente primero)
sorted_rows = sorted(
rows_list,
key=lambda x: x['fecha'] if x['fecha'] else datetime.min,
reverse=True
)
# Aplicar paginación manual sobre los resultados ordenados
# Nota: semantic_search ya devolvió los "top" globales (aproximadamente).
# Para paginación real profunda con Qdrant se necesita scroll/offset,
# aquí asumimos que page request mapea al limit/offset enviado a Qdrant.
# Pero `semantic_search` simple en utils no tiene offset.
# Arreglo temporal: Solo mostramos la primera "tanda" de resultados semánticos.
# Si el usuario quiere paginar profundo, Qdrant search debe soportar offset.
# utils/qdrant_search.py NO tiene offset.
# ASÍ QUE: Solo funcionará bien para la página 1.
# Si page > 1, semantic_search simple no sirve sin offset.
# Fallback: Si page > 1, usamos búsqueda tradicional O implementamos offset en Qdrant (mejor).
# Por ahora: Usamos lo que devolvió semantic_search y cortamos localmente
# si page=1.
if len(sorted_rows) > 0:
noticias = sorted_rows
total_results = len(noticias) # Aproximado
total_pages = 1 # Qdrant simple no pagina bien aun
# Extraer tags
tr_ids = [n["traduccion_id"] for n in noticias if n["traduccion_id"]]
from models.noticias import _extraer_tags_por_traduccion
tags_por_tr = _extraer_tags_por_traduccion(cur, tr_ids)
semantic_success = True
except Exception as e:
print(f"⚠️ Error en semántica home, fallback: {e}")
semantic_success = False
# 2. Si no hubo búsqueda semántica (o falló, o no había query, o usuario la desactivó), usar la tradicional
if not semantic_success:
if use_semantic:
from models.noticias import buscar_noticias_semantica
noticias, total_results, total_pages, tags_por_tr = buscar_noticias_semantica(
conn=conn,
page=page,
per_page=per_page,
q=q,
categoria_id=categoria_id,
continente_id=continente_id,
pais_id=pais_id,
fecha=fecha_filtro,
lang=lang,
)
else:
noticias, total_results, total_pages, tags_por_tr = buscar_noticias(
conn=conn,
page=page,
@ -171,82 +69,22 @@ def home():
use_tr=use_tr,
)
# Record search history for logged-in users (only on first page to avoid dupes)
if (q or categoria_id or pais_id) and page == 1:
# Historial de búsqueda (solo para usuarios logueados y en primera página)
recent_searches_with_results = []
user = get_current_user()
if user:
try:
with get_write_conn() as w_conn:
with w_conn.cursor() as w_cur:
# Check if it's the same as the last search to avoid immediate duplicates
w_cur.execute("""
SELECT query, pais_id, categoria_id
FROM search_history
WHERE user_id = %s
ORDER BY searched_at DESC LIMIT 1
""", (user['id'],))
last_search = w_cur.fetchone()
current_search = (q or None, int(pais_id) if pais_id else None, int(categoria_id) if categoria_id else None)
if not last_search or (last_search[0], last_search[1], last_search[2]) != current_search:
w_cur.execute("""
INSERT INTO search_history (user_id, query, pais_id, categoria_id, results_count)
VALUES (%s, %s, %s, %s, %s)
""", (user['id'], current_search[0], current_search[1], current_search[2], total_results))
w_conn.commit()
except Exception as e:
# Log error but don't break the page load
print(f"Error saving search history: {e}")
pass
user = get_current_user()
recent_searches_with_results = []
if user and not q and not categoria_id and not pais_id and page == 1:
with get_read_conn() as conn:
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
# Fetch unique latest searches using DISTINCT ON
cur.execute("""
SELECT sub.id, query, pais_id, categoria_id, results_count, searched_at,
p.nombre as pais_nombre, c.nombre as categoria_nombre
FROM (
SELECT DISTINCT ON (COALESCE(query, ''), COALESCE(pais_id, 0), COALESCE(categoria_id, 0))
id, query, pais_id, categoria_id, results_count, searched_at
FROM search_history
WHERE user_id = %s
ORDER BY COALESCE(query, ''), COALESCE(pais_id, 0), COALESCE(categoria_id, 0), searched_at DESC
) sub
LEFT JOIN paises p ON p.id = sub.pais_id
LEFT JOIN categorias c ON c.id = sub.categoria_id
ORDER BY searched_at DESC
LIMIT 6
""", (user['id'],))
recent_searches = cur.fetchall()
for s in recent_searches:
# Fetch top 6 news for this search
news_items, _, _, _ = buscar_noticias(
conn=conn,
page=1,
per_page=6,
q=s['query'] or "",
pais_id=s['pais_id'],
categoria_id=s['categoria_id'],
lang=lang,
use_tr=use_tr,
skip_count=True
)
recent_searches_with_results.append({
'id': s['id'],
'query': s['query'],
'pais_id': s['pais_id'],
'pais_nombre': s['pais_nombre'],
'categoria_id': s['categoria_id'],
'categoria_nombre': s['categoria_nombre'],
'results_count': s['results_count'],
'searched_at': s['searched_at'],
'noticias': news_items
})
if user and page == 1 and not q:
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
cur.execute("""
SELECT sh.id, sh.query, sh.searched_at, sh.results_count,
p.nombre as pais_nombre, c.nombre as categoria_nombre
FROM search_history sh
LEFT JOIN paises p ON p.id = sh.pais_id
LEFT JOIN categorias c ON c.id = sh.categoria_id
WHERE sh.user_id = %s
ORDER BY sh.searched_at DESC
LIMIT 10
""", (user['id'],))
recent_searches_with_results = cur.fetchall()
context = dict(
noticias=noticias,
@ -259,6 +97,7 @@ def home():
q=q,
cat_id=int(categoria_id) if categoria_id else None,
pais_id=int(pais_id) if pais_id else None,
cont_id=int(continente_id) if continente_id else None,
fecha_filtro=fecha_str,
lang=lang,
use_tr=use_tr,
@ -282,7 +121,6 @@ def delete_search(search_id):
try:
with get_write_conn() as conn:
with conn.cursor() as cur:
# Direct deletion ensuring ownership
cur.execute(
"DELETE FROM search_history WHERE id = %s AND user_id = %s",
(search_id, user["id"])

View file

@ -112,5 +112,14 @@ def noticia():
)
relacionadas = cur.fetchall()
return render_template("noticia.html", dato=dato, tags=tags, relacionadas=relacionadas)
# Preparar datos para el template clásico
context = {
'dato': dato,
'etiquetas': ', '.join([tag['valor'] for tag in tags]) if tags else '',
'related_news': relacionadas,
'categorias': [], # Podríamos añadir categorías populares si quisiéramos
'idioma_orig': dato['lang_from'] if dato else None
}
return render_template("noticia_classic.html", **context)

View file

@ -1,76 +0,0 @@
"""
Resumen router - Daily summary of news.
"""
from flask import Blueprint, render_template, request
from psycopg2 import extras
from db import get_conn
from datetime import datetime, timedelta
resumen_bp = Blueprint("resumen", __name__, url_prefix="/resumen")
@resumen_bp.route("/")
def diario():
"""Daily summary page."""
# Default to today
date_str = request.args.get("date")
if date_str:
try:
target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
target_date = datetime.utcnow().date()
else:
target_date = datetime.utcnow().date()
prev_date = target_date - timedelta(days=1)
next_date = target_date + timedelta(days=1)
if next_date > datetime.utcnow().date():
next_date = None
with get_conn() as conn:
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
# Fetch top news for the day grouped by category
# We'll limit to 5 per category to keep it concise
cur.execute("""
WITH ranked_news AS (
SELECT
n.id, n.titulo, n.resumen, n.url, n.fecha, n.imagen_url, n.fuente_nombre,
c.id as cat_id, c.nombre as categoria,
t.titulo_trad, t.resumen_trad,
ROW_NUMBER() OVER (PARTITION BY n.categoria_id ORDER BY n.fecha DESC) as rn
FROM noticias n
LEFT JOIN categorias c ON c.id = n.categoria_id
LEFT JOIN traducciones t ON t.noticia_id = n.id
AND t.lang_to = 'es' AND t.status = 'done'
WHERE n.fecha >= %s AND n.fecha < %s + INTERVAL '1 day'
)
SELECT * FROM ranked_news WHERE rn <= 5 ORDER BY categoria, rn
""", (target_date, target_date))
rows = cur.fetchall()
# Group by category
noticias_by_cat = {}
for r in rows:
cat = r["categoria"] or "Sin Categoría"
if cat not in noticias_by_cat:
noticias_by_cat[cat] = []
noticias_by_cat[cat].append({
"id": r["id"],
"titulo": r["titulo_trad"] or r["titulo"],
"resumen": r["resumen_trad"] or r["resumen"],
"url": r["url"],
"fecha": r["fecha"],
"imagen_url": r["imagen_url"],
"fuente": r["fuente_nombre"]
})
return render_template(
"resumen.html",
noticias_by_cat=noticias_by_cat,
current_date=target_date,
prev_date=prev_date,
next_date=next_date
)

View file

@ -511,6 +511,7 @@ def get_cpu_info():
return None
@stats_bp.route("/api/system/info")
@cached(ttl_seconds=40, prefix="system_info")
def system_info_api():
"""Endpoint for real-time system monitoring."""
return jsonify({

View file

@ -1,59 +0,0 @@
from flask import Blueprint, render_template, request
from db import get_read_conn
traducciones_bp = Blueprint("traducciones", __name__)
@traducciones_bp.route("/traducciones")
def ultimas_traducciones():
"""Muestra las últimas noticias traducidas."""
page = max(int(request.args.get("page", 1)), 1)
per_page = min(max(int(request.args.get("per_page", 20)), 10), 100)
offset = (page - 1) * per_page
with get_read_conn() as conn:
conn.autocommit = True
with conn.cursor() as cur:
# Total count
cur.execute("""
SELECT COUNT(*) FROM traducciones WHERE status = 'done'
""")
total = cur.fetchone()[0]
# Fetch latest translations
cur.execute("""
SELECT
t.id,
t.noticia_id,
t.titulo_trad,
t.resumen_trad,
t.lang_from,
t.lang_to,
t.created_at AS updated_at,
n.url AS link,
n.imagen_url AS imagen,
n.fuente_nombre AS feed_nombre,
c.nombre AS categoria_nombre,
p.nombre AS pais_nombre
FROM traducciones t
JOIN noticias n ON n.id = t.noticia_id
LEFT JOIN categorias c ON c.id = n.categoria_id
LEFT JOIN paises p ON p.id = n.pais_id
WHERE t.status = 'done'
ORDER BY t.created_at DESC
LIMIT %s OFFSET %s
""", (per_page, offset))
columns = [desc[0] for desc in cur.description]
traducciones = [dict(zip(columns, row)) for row in cur.fetchall()]
total_pages = (total + per_page - 1) // per_page
return render_template(
"traducciones.html",
traducciones=traducciones,
page=page,
per_page=per_page,
total=total,
total_pages=total_pages,
)