Preparar repositorio para despliegue: código fuente limpio
This commit is contained in:
parent
866f5c432d
commit
3eca832c1a
76 changed files with 5434 additions and 3496 deletions
|
|
@ -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/)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
230
routers/home.py
230
routers/home.py
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue