Resuelto conflicto en X

This commit is contained in:
jlimolina 2025-06-09 11:48:00 +02:00
parent 758da0ad4c
commit e9264bc6ce
9 changed files with 348 additions and 96 deletions

122
app.py
View file

@ -4,6 +4,7 @@ import sys
import hashlib import hashlib
import re import re
import csv import csv
import math
from io import StringIO from io import StringIO
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
@ -69,46 +70,71 @@ def home():
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) 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)
@app.route("/feeds") @app.route("/feeds")
def feeds(): def dashboard():
feeds_, categorias, continentes, paises = [], [], [], [] stats = {'feeds_totales': 0, 'noticias_totales': 0, 'feeds_caidos': 0}
try:
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, f.pais_id,
f.activo, f.fallos, c.nombre as cat_nom, p.nombre as pais_nom
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
""")
feeds_ = cursor.fetchall()
cursor.execute("SELECT id, nombre FROM categorias ORDER BY nombre")
categorias = cursor.fetchall()
cursor.execute("SELECT id, nombre FROM continentes ORDER BY nombre")
continentes = cursor.fetchall()
cursor.execute("SELECT id, nombre, continente_id FROM paises ORDER BY nombre")
paises = cursor.fetchall()
except psycopg2.Error as db_err:
app.logger.error(f"[DB ERROR] Al leer feeds: {db_err}", exc_info=True)
flash("Error de base de datos al cargar la gestión de feeds.", "error")
return render_template("index.html", feeds=feeds_, categorias=categorias, continentes=continentes, paises=paises)
@app.route("/add", methods=["POST"])
def add_feed():
nombre = request.form.get("nombre")
try: try:
with get_conn() as conn: with get_conn() as conn:
with conn.cursor() as cursor: with conn.cursor() as cursor:
cursor.execute( cursor.execute("SELECT COUNT(*) FROM feeds;")
"INSERT INTO feeds (nombre, descripcion, url, categoria_id, pais_id, idioma) VALUES (%s, %s, %s, %s, %s, %s)", stats['feeds_totales'] = cursor.fetchone()[0]
(nombre, request.form.get("descripcion"), request.form.get("url"), request.form.get("categoria_id"), request.form.get("pais_id"), (request.form.get("idioma", "").strip() or None)) cursor.execute("SELECT COUNT(*) FROM noticias;")
) stats['noticias_totales'] = cursor.fetchone()[0]
flash(f"Feed '{nombre}' añadido correctamente.", "success") cursor.execute("SELECT COUNT(*) FROM feeds WHERE activo = FALSE;")
stats['feeds_caidos'] = cursor.fetchone()[0]
except psycopg2.Error as db_err: except psycopg2.Error as db_err:
app.logger.error(f"[DB ERROR] Al agregar feed: {db_err}", exc_info=True) app.logger.error(f"[DB ERROR] Al calcular estadísticas del dashboard: {db_err}")
flash(f"Error al añadir el feed: {db_err}", "error") flash("Error al conectar con la base de datos para mostrar el resumen.", "error")
return redirect(url_for("feeds")) return render_template("dashboard.html", stats=stats)
@app.route("/feeds/manage")
def manage_feeds():
page = request.args.get('page', 1, type=int)
per_page = 10
offset = (page - 1) * per_page
feeds_list = []
total_feeds = 0
try:
with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
cursor.execute("SELECT COUNT(*) FROM feeds")
total_feeds = cursor.fetchone()[0]
cursor.execute("SELECT * FROM feeds ORDER BY nombre LIMIT %s OFFSET %s", (per_page, offset))
feeds_list = cursor.fetchall()
except psycopg2.Error as db_err:
app.logger.error(f"[DB ERROR] Al obtener lista de feeds: {db_err}")
flash("Error al obtener la lista de feeds.", "error")
total_pages = math.ceil(total_feeds / per_page)
return render_template("feeds_list.html", feeds=feeds_list, page=page, total_pages=total_pages, total_feeds=total_feeds)
@app.route("/feeds/add", methods=['GET', 'POST'])
def add_feed():
if request.method == 'POST':
nombre = request.form.get("nombre")
try:
with get_conn() as conn:
with conn.cursor() as cursor:
cursor.execute(
"INSERT INTO feeds (nombre, descripcion, url, categoria_id, pais_id, idioma) VALUES (%s, %s, %s, %s, %s, %s)",
(nombre, request.form.get("descripcion"), request.form.get("url"), request.form.get("categoria_id"), request.form.get("pais_id"), (request.form.get("idioma", "").strip() or None))
)
flash(f"Feed '{nombre}' añadido correctamente.", "success")
except psycopg2.Error as db_err:
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("dashboard"))
categorias, paises = [], []
try:
with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
cursor.execute("SELECT id, nombre FROM categorias ORDER BY nombre")
categorias = cursor.fetchall()
cursor.execute("SELECT id, nombre FROM paises ORDER BY nombre")
paises = cursor.fetchall()
except psycopg2.Error as db_err:
app.logger.error(f"[DB ERROR] Al cargar formulario para añadir feed: {db_err}")
flash("No se pudieron cargar las categorías o países.", "error")
return render_template("add_feed.html", categorias=categorias, paises=paises)
@app.route("/edit/<int:feed_id>", methods=["GET", "POST"]) @app.route("/edit/<int:feed_id>", methods=["GET", "POST"])
def edit_feed(feed_id): def edit_feed(feed_id):
@ -124,8 +150,7 @@ def edit_feed(feed_id):
(request.form.get("nombre"), request.form.get("descripcion"), request.form.get("url"), request.form.get("categoria_id"), request.form.get("pais_id"), idioma, activo, feed_id) (request.form.get("nombre"), request.form.get("descripcion"), request.form.get("url"), request.form.get("categoria_id"), request.form.get("pais_id"), idioma, activo, feed_id)
) )
flash("Feed actualizado correctamente.", "success") flash("Feed actualizado correctamente.", "success")
return redirect(url_for("feeds")) return redirect(url_for("manage_feeds"))
cursor.execute("SELECT * FROM feeds WHERE id = %s", (feed_id,)) cursor.execute("SELECT * FROM feeds WHERE id = %s", (feed_id,))
feed = cursor.fetchone() feed = cursor.fetchone()
cursor.execute("SELECT id, nombre FROM categorias ORDER BY nombre") cursor.execute("SELECT id, nombre FROM categorias ORDER BY nombre")
@ -135,12 +160,10 @@ def edit_feed(feed_id):
except psycopg2.Error as db_err: except psycopg2.Error as db_err:
app.logger.error(f"[DB ERROR] Al editar feed: {db_err}", exc_info=True) app.logger.error(f"[DB ERROR] Al editar feed: {db_err}", exc_info=True)
flash(f"Error al editar el feed: {db_err}", "error") flash(f"Error al editar el feed: {db_err}", "error")
return redirect(url_for("feeds")) return redirect(url_for("manage_feeds"))
if not feed: if not feed:
flash("No se encontró el feed solicitado.", "error") flash("No se encontró el feed solicitado.", "error")
return redirect(url_for("feeds")) return redirect(url_for("manage_feeds"))
return render_template("edit_feed.html", feed=feed, categorias=categorias, paises=paises) return render_template("edit_feed.html", feed=feed, categorias=categorias, paises=paises)
@app.route("/delete/<int:feed_id>") @app.route("/delete/<int:feed_id>")
@ -153,7 +176,7 @@ def delete_feed(feed_id):
except psycopg2.Error as db_err: except psycopg2.Error as db_err:
app.logger.error(f"[DB ERROR] Al eliminar feed: {db_err}", exc_info=True) app.logger.error(f"[DB ERROR] Al eliminar feed: {db_err}", exc_info=True)
flash(f"Error al eliminar el feed: {db_err}", "error") flash(f"Error al eliminar el feed: {db_err}", "error")
return redirect(url_for("feeds")) return redirect(url_for("manage_feeds"))
@app.route("/reactivar_feed/<int:feed_id>") @app.route("/reactivar_feed/<int:feed_id>")
def reactivar_feed(feed_id): def reactivar_feed(feed_id):
@ -165,7 +188,7 @@ def reactivar_feed(feed_id):
except psycopg2.Error as db_err: except psycopg2.Error as db_err:
app.logger.error(f"[DB ERROR] Al reactivar feed: {db_err}", exc_info=True) app.logger.error(f"[DB ERROR] Al reactivar feed: {db_err}", exc_info=True)
flash(f"Error al reactivar el feed: {db_err}", "error") flash(f"Error al reactivar el feed: {db_err}", "error")
return redirect(url_for("feeds")) return redirect(url_for("manage_feeds"))
@app.route("/backup_feeds") @app.route("/backup_feeds")
def backup_feeds(): def backup_feeds():
@ -183,21 +206,18 @@ def backup_feeds():
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("feeds")) return redirect(url_for("dashboard"))
si = StringIO() si = StringIO()
writer = csv.DictWriter(si, fieldnames=[desc[0] for desc in cursor.description]) writer = csv.DictWriter(si, fieldnames=[desc[0] for desc in cursor.description])
writer.writeheader() writer.writeheader()
writer.writerows([dict(row) for row in feeds_]) writer.writerows([dict(row) for row in feeds_])
output = si.getvalue() output = si.getvalue()
si.close() si.close()
return Response(output, mimetype="text/csv", headers={"Content-Disposition": "attachment;filename=feeds_backup.csv"}) return Response(output, mimetype="text/csv", headers={"Content-Disposition": "attachment;filename=feeds_backup.csv"})
except Exception as e: except Exception as e:
app.logger.error(f"[ERROR] Al hacer backup de feeds: {e}", exc_info=True) app.logger.error(f"[ERROR] Al hacer backup de feeds: {e}", exc_info=True)
flash("Error al generar el backup.", "error") flash("Error al generar el backup.", "error")
return redirect(url_for("feeds")) return redirect(url_for("dashboard"))
@app.route("/restore_feeds", methods=["GET", "POST"]) @app.route("/restore_feeds", methods=["GET", "POST"])
def restore_feeds(): def restore_feeds():
@ -211,7 +231,6 @@ def restore_feeds():
reader = csv.DictReader(file_stream) reader = csv.DictReader(file_stream)
rows = list(reader) rows = list(reader)
n_ok, n_err = 0, 0 n_ok, n_err = 0, 0
with get_conn() as conn: with get_conn() as conn:
with conn.cursor() as cursor: with conn.cursor() as cursor:
for row in rows: for row in rows:
@ -242,8 +261,7 @@ def restore_feeds():
except Exception as e: except Exception as e:
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("feeds")) return redirect(url_for("dashboard"))
return render_template("restore_feeds.html") return render_template("restore_feeds.html")
def sumar_fallo_feed(cursor, feed_id): def sumar_fallo_feed(cursor, feed_id):

57
templates/add_feed.html Normal file
View file

@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}Añadir Nuevo Feed{% endblock %}
{% block content %}
<header>
<h1>Añadir Nuevo Feed</h1>
<p class="subtitle">Introduce los detalles de la nueva fuente de noticias RSS.</p>
<a href="{{ url_for('dashboard') }}" class="top-link" style="margin-top:15px;">← Volver al Dashboard</a>
</header>
<div class="form-section">
<form action="{{ url_for('add_feed') }}" method="post" autocomplete="off">
<div>
<label for="nombre">Nombre del feed</label>
<input id="nombre" name="nombre" type="text" placeholder="Ej: Noticias de Tecnología" required>
</div>
<div style="margin-top:15px;">
<label for="url">URL del RSS</label>
<input id="url" name="url" type="url" placeholder="https://ejemplo.com/rss" required>
</div>
<div style="margin-top:15px;">
<label for="descripcion">Descripción</label>
<textarea id="descripcion" name="descripcion" rows="2" placeholder="Breve descripción del contenido del feed"></textarea>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 15px; margin-top: 15px;">
<div>
<label for="categoria_id">Categoría</label>
<select id="categoria_id" name="categoria_id" required>
<option value="">— Elige categoría —</option>
{% for cat in categorias %}
<option value="{{ cat.id }}">{{ cat.nombre }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="pais_id">País</label>
<select name="pais_id" id="pais_id">
<option value="">— Global / No aplica —</option>
{% for pais in paises %}
<option value="{{ pais.id }}">{{ pais.nombre }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="idioma">Idioma (código)</label>
<input id="idioma" name="idioma" type="text" maxlength="2" placeholder="ej: es, en">
</div>
</div>
<button type="submit" class="btn" style="margin-top: 25px; width: 100%;">Añadir Feed</button>
</form>
</div>
{% endblock %}

57
templates/add_feeds.html Normal file
View file

@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}Añadir Nuevo Feed{% endblock %}
{% block content %}
<header>
<h1>Añadir Nuevo Feed</h1>
<p class="subtitle">Introduce los detalles de la nueva fuente de noticias RSS.</p>
<a href="{{ url_for('dashboard') }}" class="top-link" style="margin-top:15px;">← Volver al Dashboard</a>
</header>
<div class="form-section">
<form action="{{ url_for('add_feed') }}" method="post" autocomplete="off">
<div>
<label for="nombre">Nombre del feed</label>
<input id="nombre" name="nombre" type="text" placeholder="Ej: Noticias de Tecnología" required>
</div>
<div style="margin-top:15px;">
<label for="url">URL del RSS</label>
<input id="url" name="url" type="url" placeholder="https://ejemplo.com/rss" required>
</div>
<div style="margin-top:15px;">
<label for="descripcion">Descripción</label>
<textarea id="descripcion" name="descripcion" rows="2" placeholder="Breve descripción del contenido del feed"></textarea>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 15px; margin-top: 15px;">
<div>
<label for="categoria_id">Categoría</label>
<select id="categoria_id" name="categoria_id" required>
<option value="">— Elige categoría —</option>
{% for cat in categorias %}
<option value="{{ cat.id }}">{{ cat.nombre }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="pais_id">País</label>
<select name="pais_id" id="pais_id">
<option value="">— Global / No aplica —</option>
{% for pais in paises %}
<option value="{{ pais.id }}">{{ pais.nombre }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="idioma">Idioma (código)</label>
<input id="idioma" name="idioma" type="text" maxlength="2" placeholder="ej: es, en">
</div>
</div>
<button type="submit" class="btn" style="margin-top: 25px; width: 100%;">Añadir Feed</button>
</form>
</div>
{% endblock %}

View file

@ -67,6 +67,7 @@
.btn, button { padding: 12px 25px; background: var(--gradiente-principal); color: white !important; border: none; border-radius: var(--border-radius-sm); font-size: 1rem; font-weight: 600; cursor: pointer; transition: all var(--transition-speed) ease; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); text-decoration: none; display: inline-block; text-align: center; } .btn, button { padding: 12px 25px; background: var(--gradiente-principal); color: white !important; border: none; border-radius: var(--border-radius-sm); font-size: 1rem; font-weight: 600; cursor: pointer; transition: all var(--transition-speed) ease; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); text-decoration: none; display: inline-block; text-align: center; }
.btn:hover, button:hover { transform: translateY(-3px); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2); text-decoration: none; } .btn:hover, button:hover { transform: translateY(-3px); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2); text-decoration: none; }
.btn-secondary { background: #34495e; } .btn-secondary:hover { background: #2c3e50; } .btn-secondary { background: #34495e; } .btn-secondary:hover { background: #2c3e50; }
.btn-small { padding: 6px 14px; font-size: 0.9rem; }
a { color: var(--secondary-color); text-decoration: none; font-weight: 500; } a:hover { text-decoration: underline; } a { color: var(--secondary-color); text-decoration: none; font-weight: 500; } a:hover { text-decoration: underline; }
.top-link { display: inline-block; margin-bottom: 25px; font-weight: 500; color: var(--primary-color); } .top-link { display: inline-block; margin-bottom: 25px; font-weight: 500; color: var(--primary-color); }
.top-link:hover { text-decoration: underline; } .top-link:hover { text-decoration: underline; }
@ -82,14 +83,6 @@
.noticia-texto h3 a:hover { color: var(--primary-color); } .noticia-texto h3 a:hover { color: var(--primary-color); }
.noticia-meta { font-size: 0.8rem; color: var(--text-color-light); margin-bottom: 8px; } .noticia-meta { font-size: 0.8rem; color: var(--text-color-light); margin-bottom: 8px; }
/* --- Tabla de Gestión de Feeds --- */
.table-wrapper { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { padding: 12px 15px; text-align: left; border-bottom: 1px solid var(--border-color); }
th { background-color: rgba(106, 27, 203, 0.05); font-weight: 600; }
tr:last-child td { border-bottom: none; }
td .actions a { margin-right: 10px; }
/* --- Alertas y Mensajes Flash --- */ /* --- Alertas y Mensajes Flash --- */
.flash-messages { list-style: none; padding: 0; margin-bottom: 20px; } .flash-messages { list-style: none; padding: 0; margin-bottom: 20px; }
.flash-messages li { padding: 15px 20px; border-radius: var(--border-radius-sm); border-left: 5px solid; } .flash-messages li { padding: 15px 20px; border-radius: var(--border-radius-sm); border-left: 5px solid; }
@ -97,11 +90,33 @@
.flash-messages .success { background-color: #e6fcf5; color: #00b894; border-color: #00b894; } .flash-messages .success { background-color: #e6fcf5; color: #00b894; border-color: #00b894; }
.flash-messages .warning { background-color: #fffbeb; color: #f39c12; border-color: #f39c12; } .flash-messages .warning { background-color: #fffbeb; color: #f39c12; border-color: #f39c12; }
/* --- INICIO DE ESTILOS AÑADIDOS PARA DASHBOARD Y PAGINACIÓN --- */
.dashboard-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 40px; }
.stat-card { background: rgba(255, 255, 255, 0.8); padding: 20px; border-radius: var(--border-radius-md); text-align: center; border: 1px solid var(--border-color); transition: all 0.3s ease; }
.stat-card:hover { transform: translateY(-5px); box-shadow: 0 4px 15px rgba(0,0,0,0.08); }
.stat-card .stat-number { font-size: 2.5rem; font-weight: 600; background: var(--gradiente-principal); -webkit-background-clip: text; -webkit-text-fill-color: transparent; line-height: 1.2; }
.stat-card .stat-label { font-size: 0.9rem; color: var(--text-color-light); font-weight: 500; margin-top: 5px; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 5px; margin: 30px 0; flex-wrap: wrap; }
.page-link { display: inline-block; padding: 8px 14px; background: rgba(255, 255, 255, 0.6); border: 1px solid var(--border-color); border-radius: var(--border-radius-sm); color: var(--primary-color); text-decoration: none; transition: all 0.2s ease; }
.page-link:hover { background: white; box-shadow: 0 2px 5px rgba(0,0,0,0.1); }
.page-link.active { background: var(--gradiente-principal); color: white; border-color: transparent; cursor: default; }
.feed-detail-card { padding: 0; }
.feed-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; background: rgba(233, 236, 239, 0.5); padding: 15px 25px; border-bottom: 1px solid var(--border-color); }
.feed-header h2 { margin: 0; font-size: 1.4rem; }
.feed-body { padding: 25px; }
.feed-body dl { display: grid; grid-template-columns: 120px 1fr; gap: 10px 20px; }
.feed-body dt { font-weight: 600; color: var(--text-color-light); }
.feed-body dd { margin: 0; word-break: break-all; }
/* --- FIN DE ESTILOS AÑADIDOS --- */
/* --- Responsividad --- */ /* --- Responsividad --- */
@media (max-width: 768px) { @media (max-width: 768px) {
.container { padding: 20px; margin: 15px; } .container { padding: 20px; margin: 15px; }
h1 { font-size: 2rem; } h1 { font-size: 2rem; }
.noticia-item { flex-direction: column; } .noticia-item { flex-direction: column; }
.feed-body dl { grid-template-columns: 100px 1fr; }
} }
</style> </style>
</head> </head>
@ -111,7 +126,7 @@
{% if messages %} {% if messages %}
<ul class="flash-messages"> <ul class="flash-messages">
{% for category, message in messages %} {% for category, message in messages %}
<li class="{{ category }}">{{ message }}</li> <li class="flash-{{ category }}">{{ message }}</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}

35
templates/dashboard.html Normal file
View file

@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Dashboard de Feeds{% endblock %}
{% block content %}
<header>
<h1>Dashboard de Feeds</h1>
<p class="subtitle">Un resumen del estado de tu agregador de noticias.</p>
<a href="{{ url_for('home') }}" class="top-link" style="margin-top:15px;">← Volver a las Noticias</a>
</header>
<div class="dashboard-grid">
<div class="stat-card">
<div class="stat-number">{{ stats.feeds_totales }}</div>
<div class="stat-label">Feeds Totales</div>
</div>
<div class="stat-card">
<div class="stat-number">{{ stats.noticias_totales }}</div>
<div class="stat-label">Noticias Recopiladas</div>
</div>
<div class="stat-card">
<div class="stat-number" style="color:#c0392b;">{{ stats.feeds_caidos }}</div>
<div class="stat-label">Feeds Caídos / Inactivos</div>
</div>
</div>
<div class="card" style="text-align: center; padding: 30px;">
<h2>Gestionar Feeds</h2>
<p style="color: var(--text-color-light);">Aquí puedes ver la lista completa, editar, añadir o eliminar tus feeds.</p>
<div style="margin-top: 20px; display: flex; justify-content: center; gap: 15px; flex-wrap: wrap;">
<a href="{{ url_for('manage_feeds') }}" class="btn">Ver Lista Detallada</a>
<a href="{{ url_for('add_feed') }}" class="btn">Añadir Nuevo Feed</a>
<a href="{{ url_for('restore_feeds') }}" class="btn btn-secondary">Importar / Restaurar</a>
</div>
</div>
{% endblock %}

View file

@ -9,7 +9,7 @@
</header> </header>
<div class="form-section"> <div class="form-section">
<form method="post" autocomplete="off"> <form method="post" action="{{ url_for('edit_feed', feed_id=feed.id) }}" autocomplete="off">
<div> <div>
<label for="nombre">Nombre del feed</label> <label for="nombre">Nombre del feed</label>
<input id="nombre" name="nombre" type="text" value="{{ feed.nombre }}" required> <input id="nombre" name="nombre" type="text" value="{{ feed.nombre }}" required>
@ -57,8 +57,10 @@
</div> </div>
<button class="btn" type="submit">Guardar Cambios</button> <button class="btn" type="submit">Guardar Cambios</button>
<a href="{{ url_for('feeds') }}" style="margin-left: 15px;">Cancelar</a> <a href="{{ url_for('manage_feeds') }}" style="margin-left: 15px;">Cancelar</a>
</form> </form>
</div> </div>
<a href="{{ url_for('feeds') }}" class="top-link">← Volver a la gestión de feeds</a>
<a href="{{ url_for('manage_feeds') }}" class="top-link">← Volver a la lista de feeds</a>
{% endblock %} {% endblock %}

64
templates/feeds_list.html Normal file
View file

@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}Lista Detallada de Feeds{% endblock %}
{% block content %}
<header>
<h1>Lista de Feeds</h1>
<p class="subtitle">Mostrando {{ feeds|length }} de {{ total_feeds }} feeds. Página {{ page }} de {{ total_pages }}.</p>
<a href="{{ url_for('dashboard') }}" class="top-link" style="margin-top:15px;">← Volver al Dashboard</a>
</header>
{% if total_pages > 1 %}
<nav class="pagination">
{% if page > 1 %}
<a href="{{ url_for('manage_feeds', page=page-1) }}" class="page-link">&laquo; Anterior</a>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<a href="#" class="page-link active">{{ p }}</a>
{% else %}
<a href="{{ url_for('manage_feeds', page=p) }}" class="page-link">{{ p }}</a>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="{{ url_for('manage_feeds', page=page+1) }}" class="page-link">Siguiente &raquo;</a>
{% endif %}
</nav>
{% endif %}
{% for feed in feeds %}
<div class="card feed-detail-card">
<div class="feed-header">
<h2>{{ feed.nombre }}</h2>
<div class="actions">
<a href="{{ url_for('edit_feed', feed_id=feed.id) }}" class="btn btn-small">Editar</a>
</div>
</div>
<div class="feed-body">
<dl>
<dt>ID:</dt><dd>{{ feed.id }}</dd>
<dt>URL:</dt><dd><a href="{{ feed.url }}" target="_blank" rel="noopener">{{ feed.url }}</a></dd>
<dt>Descripción:</dt><dd>{{ feed.descripcion or 'N/A' }}</dd>
<dt>Idioma:</dt><dd>{{ feed.idioma or 'N/D' }}</dd>
<dt>Estado:</dt><dd>
{% if feed.activo %}
<span style="color: #27ae60; font-weight:bold;">Activo</span>
{% else %}
<span style="color: #c0392b; font-weight: bold;">Inactivo</span>
{% endif %}
</dd>
<dt>Fallos:</dt><dd>{{ feed.fallos }}</dd>
</dl>
</div>
</div>
{% endfor %}
{% if not feeds %}
<div class="card" style="text-align:center;">
<p>No hay feeds para mostrar.</p>
</div>
{% endif %}
{% endblock %}

View file

@ -6,7 +6,7 @@
<header> <header>
<h1>Agregador de Noticias</h1> <h1>Agregador de Noticias</h1>
<p class="subtitle">Tus fuentes de información, en un solo lugar.</p> <p class="subtitle">Tus fuentes de información, en un solo lugar.</p>
<a href="{{ url_for('feeds') }}" class="top-link" style="margin-top:15px;">⚙️ Gestionar Feeds</a> <a href="{{ url_for('dashboard') }}" class="top-link" style="margin-top:15px;">⚙️ Gestionar Feeds</a>
</header> </header>
<div class="card"> <div class="card">

View file

@ -1,33 +1,37 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Restaurar Feeds RSS{% endblock %}
{% block content %}
<h1>Restaurar feeds desde backup CSV</h1>
<div class="card" style="max-width:500px;margin:auto;">
<form method="post" enctype="multipart/form-data" autocomplete="off">
<label for="file"><strong>Selecciona el archivo CSV exportado de feeds:</strong></label>
<input type="file" name="file" id="file" accept=".csv" required style="margin:10px 0;">
<button class="btn" type="submit">Restaurar</button>
</form>
{% if msg %}
<div style="margin:15px 0;">
{% if "Error" in msg or "Error en fila" in msg %}
<div style="color:#c00; font-weight:bold;">{{ msg|safe }}</div>
{% else %}
<div style="color:#198754; font-weight:bold;">{{ msg|safe }}</div>
{% endif %}
</div>
{% endif %}
<p style="font-size:0.92em;color:#64748b;">
El archivo debe contener las columnas:<br>
<code>id, nombre, [descripcion,] url, categoria_id, categoria, pais_id, pais, idioma, activo, fallos</code><br>
<small>
Las columnas <b>descripcion</b> e <b>idioma</b> son opcionales.<br>
<b>activo</b> puede ser: <code>True</code>, <code>False</code>, <code>1</code> o <code>0</code>.<br>
<b>idioma</b> debe ser el código ISO 639-1 de dos letras (<i>ej:</i> <code>es</code>, <code>en</code>, <code>fr</code>...).<br>
Si falta alguna columna, la restauración puede fallar o ignorar ese campo.
</small>
</p>
</div>
<a href="/feeds" class="top-link">← Volver a feeds</a>
{% endblock %}
{% block title %}Restaurar Feeds desde Backup{% endblock %}
{% block content %}
<header>
<h1>Restaurar Feeds</h1>
<p class="subtitle">Importa todos tus feeds desde un único archivo de backup en formato CSV.</p>
<a href="{{ url_for('dashboard') }}" class="top-link" style="margin-top:15px;">← Volver al Dashboard</a>
</header>
<div class="form-section">
<h2>Subir Archivo de Backup</h2>
<form method="post" enctype="multipart/form-data" action="{{ url_for('restore_feeds') }}" autocomplete="off">
<label for="file"><strong>Selecciona el archivo <code>feeds_backup.csv</code>:</strong></label>
<input type="file" name="file" id="file" accept=".csv" required style="margin-top:10px;">
<button class="btn" type="submit" style="margin-top:20px; width: 100%;">Iniciar Restauración</button>
</form>
</div>
<div class="card">
<h2>Formato del Archivo CSV</h2>
<p style="color: var(--text-color-light);">
El archivo debe ser de tipo CSV y contener las siguientes columnas para que la importación funcione correctamente:
</p>
<code>id,nombre,descripcion,url,categoria_id,categoria,pais_id,pais,idioma,activo,fallos</code>
<br><br>
<small>
<ul>
<li>La columna `id` debe ser única para cada feed.</li>
<li>La columna `url` también debe ser única.</li>
<li><b>activo</b> puede ser: <code>True</code>, <code>False</code>, <code>1</code> o <code>0</code>.</li>
<li><b>idioma</b> debe ser el código ISO 639-1 de dos letras (ej: <code>es</code>, <code>en</code>).</li>
</ul>
</small>
</div>
{% endblock %}