feat: Versión final estable con nuevo diseño
Aplicación completamente funcional con servidor Waitress. Solucionados todos los problemas de arranque y recolección de noticias. Se ha implementado un nuevo diseño visual y el script de instalación está finalizado.
This commit is contained in:
parent
e355003f65
commit
758da0ad4c
3 changed files with 104 additions and 159 deletions
155
app.py
155
app.py
|
|
@ -1,69 +1,43 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Flask RSS aggregator — versión PostgreSQL
|
||||
|
||||
(Copyright tuyo 😉, con mejoras de estabilidad, seguridad y gestión de DB)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from flask import Flask, render_template, request, redirect, url_for, Response, flash
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from datetime import datetime
|
||||
import feedparser
|
||||
import hashlib
|
||||
import re
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
import csv
|
||||
from io import StringIO
|
||||
import bleach # Para la seguridad (prevenir XSS)
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import atexit
|
||||
|
||||
from flask import Flask, render_template, request, redirect, url_for, Response, flash
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
import feedparser
|
||||
import bleach
|
||||
|
||||
# Configuración del logging
|
||||
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='[%(asctime)s] %(levelname)s in %(module)s: %(message)s')
|
||||
|
||||
app = Flask(__name__)
|
||||
# Es necesaria para usar los mensajes flash de forma segura.
|
||||
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', os.urandom(24))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuración de la base de datos PostgreSQL
|
||||
# ---------------------------------------------------------------------------
|
||||
DB_CONFIG = {
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"dbname": "rss",
|
||||
"user": "rss",
|
||||
"password": "x",
|
||||
}
|
||||
|
||||
def get_conn():
|
||||
"""Devuelve una conexión nueva usando psycopg2 y el diccionario DB_CONFIG."""
|
||||
return psycopg2.connect(**DB_CONFIG)
|
||||
|
||||
DB_CONFIG = {"host": "localhost", "port": 5432, "dbname": "rss", "user": "rss", "password": "x"}
|
||||
MAX_FALLOS = 5
|
||||
|
||||
# ======================================
|
||||
# Filtro de Plantilla para HTML Seguro
|
||||
# ======================================
|
||||
def get_conn():
|
||||
return psycopg2.connect(**DB_CONFIG)
|
||||
|
||||
@app.template_filter('safe_html')
|
||||
def safe_html(text):
|
||||
if not text:
|
||||
return ""
|
||||
allowed_tags = {'a', 'abbr', 'b', 'strong', 'i', 'em', 'p', 'br', 'img'}
|
||||
if not text: return ""
|
||||
allowed_tags = {'a', 'b', 'strong', 'i', 'em', 'p', 'br', 'img'}
|
||||
allowed_attrs = {'a': ['href', 'title'], 'img': ['src', 'alt']}
|
||||
return bleach.clean(text, tags=allowed_tags, attributes=allowed_attrs, strip=True)
|
||||
|
||||
# ======================================
|
||||
# Rutas de la Aplicación
|
||||
# ======================================
|
||||
@app.route("/")
|
||||
def home():
|
||||
noticias, categorias, continentes, paises = [], [], [], []
|
||||
cat_id = request.args.get("categoria_id")
|
||||
cont_id = request.args.get("continente_id")
|
||||
pais_id = request.args.get("pais_id")
|
||||
|
||||
cat_id, cont_id, pais_id = request.args.get("categoria_id"), request.args.get("continente_id"), request.args.get("pais_id")
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||
|
|
@ -71,41 +45,22 @@ def home():
|
|||
categorias = cursor.fetchall()
|
||||
cursor.execute("SELECT id, nombre FROM continentes ORDER BY nombre")
|
||||
continentes = cursor.fetchall()
|
||||
|
||||
if cont_id:
|
||||
cursor.execute("SELECT id, nombre, continente_id FROM paises WHERE continente_id = %s ORDER BY nombre", (cont_id,))
|
||||
else:
|
||||
cursor.execute("SELECT id, nombre, continente_id FROM paises ORDER BY nombre")
|
||||
paises = cursor.fetchall()
|
||||
|
||||
sql_params = []
|
||||
sql_base = """
|
||||
SELECT n.fecha, n.titulo, n.resumen, n.url, n.imagen_url,
|
||||
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
|
||||
"""
|
||||
|
||||
conditions = []
|
||||
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 conditions:
|
||||
sql_base += " WHERE " + " AND ".join(conditions)
|
||||
sql_params, conditions = [], []
|
||||
sql_base = "SELECT n.fecha, n.titulo, n.resumen, n.url, n.imagen_url, 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"
|
||||
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 conditions: sql_base += " WHERE " + " AND ".join(conditions)
|
||||
|
||||
sql_final = sql_base + " ORDER BY n.fecha DESC NULLS LAST LIMIT 50"
|
||||
cursor.execute(sql_final, tuple(sql_params))
|
||||
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")
|
||||
|
|
@ -147,7 +102,7 @@ def add_feed():
|
|||
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") or None)
|
||||
(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:
|
||||
|
|
@ -162,13 +117,15 @@ def edit_feed(feed_id):
|
|||
with get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||
if request.method == "POST":
|
||||
idioma = request.form.get("idioma", "").strip() or None
|
||||
activo = "activo" in request.form
|
||||
cursor.execute(
|
||||
"""UPDATE feeds SET nombre=%s, descripcion=%s, url=%s, categoria_id=%s, pais_id=%s, idioma=%s, activo=%s WHERE id=%s""",
|
||||
(request.form.get("nombre"), request.form.get("descripcion"), request.form.get("url"), request.form.get("categoria_id"), request.form.get("pais_id"), request.form.get("idioma") or None, 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")
|
||||
return redirect(url_for("feeds"))
|
||||
|
||||
cursor.execute("SELECT * FROM feeds WHERE id = %s", (feed_id,))
|
||||
feed = cursor.fetchone()
|
||||
cursor.execute("SELECT id, nombre FROM categorias ORDER BY nombre")
|
||||
|
|
@ -179,9 +136,11 @@ def edit_feed(feed_id):
|
|||
app.logger.error(f"[DB ERROR] Al editar feed: {db_err}", exc_info=True)
|
||||
flash(f"Error al editar el feed: {db_err}", "error")
|
||||
return redirect(url_for("feeds"))
|
||||
|
||||
if not feed:
|
||||
flash("No se encontró el feed solicitado.", "error")
|
||||
return redirect(url_for("feeds"))
|
||||
|
||||
return render_template("edit_feed.html", feed=feed, categorias=categorias, paises=paises)
|
||||
|
||||
@app.route("/delete/<int:feed_id>")
|
||||
|
|
@ -208,12 +167,8 @@ def reactivar_feed(feed_id):
|
|||
flash(f"Error al reactivar el feed: {db_err}", "error")
|
||||
return redirect(url_for("feeds"))
|
||||
|
||||
# ===================================================================
|
||||
# [INICIO DEL CÓDIGO AÑADIDO] Backup y Restauración de Feeds
|
||||
# ===================================================================
|
||||
@app.route("/backup_feeds")
|
||||
def backup_feeds():
|
||||
"""Exporta todos los feeds a un archivo CSV."""
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||
|
|
@ -238,11 +193,7 @@ def backup_feeds():
|
|||
output = si.getvalue()
|
||||
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:
|
||||
app.logger.error(f"[ERROR] Al hacer backup de feeds: {e}", exc_info=True)
|
||||
flash("Error al generar el backup.", "error")
|
||||
|
|
@ -250,13 +201,11 @@ def backup_feeds():
|
|||
|
||||
@app.route("/restore_feeds", methods=["GET", "POST"])
|
||||
def restore_feeds():
|
||||
"""Muestra el formulario de restauración y procesa el archivo CSV subido."""
|
||||
if request.method == "POST":
|
||||
file = request.files.get("file")
|
||||
if not file or not file.filename.endswith(".csv"):
|
||||
flash("Archivo no válido. Por favor, sube un archivo .csv.", "error")
|
||||
return redirect(url_for("restore_feeds"))
|
||||
|
||||
try:
|
||||
file_stream = StringIO(file.read().decode("utf-8"))
|
||||
reader = csv.DictReader(file_stream)
|
||||
|
|
@ -289,23 +238,14 @@ def restore_feeds():
|
|||
except Exception as e:
|
||||
n_err += 1
|
||||
app.logger.error(f"Error procesando fila del CSV: {row} - Error: {e}")
|
||||
|
||||
flash(f"Restauración completada. Feeds procesados: {n_ok}. Errores: {n_err}.", "success" if n_err == 0 else "warning")
|
||||
|
||||
except Exception as e:
|
||||
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("feeds"))
|
||||
|
||||
return render_template("restore_feeds.html")
|
||||
# ===================================================================
|
||||
# [FIN DEL CÓDIGO AÑADIDO]
|
||||
# ===================================================================
|
||||
|
||||
# ================================
|
||||
# Lógica de procesado de feeds
|
||||
# ================================
|
||||
def sumar_fallo_feed(cursor, feed_id):
|
||||
cursor.execute("UPDATE feeds SET fallos = fallos + 1 WHERE id = %s RETURNING fallos", (feed_id,))
|
||||
fallos = cursor.fetchone()[0]
|
||||
|
|
@ -324,12 +264,15 @@ def fetch_and_store():
|
|||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||
cursor.execute("SELECT id, url, categoria_id, pais_id FROM feeds WHERE activo = TRUE")
|
||||
feeds_to_process = cursor.fetchall()
|
||||
if not feeds_to_process:
|
||||
app.logger.info("No hay feeds activos para procesar.")
|
||||
return
|
||||
for feed in feeds_to_process:
|
||||
try:
|
||||
app.logger.info(f"Procesando feed: {feed['url']}")
|
||||
parsed = feedparser.parse(feed['url'])
|
||||
if getattr(parsed, "bozo", False):
|
||||
app.logger.warning(f"[BOZO] Feed mal formado: {feed['url']}")
|
||||
app.logger.warning(f"[BOZO] Feed mal formado: {feed['url']} - Excepción: {parsed.bozo_exception}")
|
||||
sumar_fallo_feed(cursor, feed['id'])
|
||||
continue
|
||||
resetear_fallos_feed(cursor, feed['id'])
|
||||
|
|
@ -345,35 +288,29 @@ def fetch_and_store():
|
|||
imagen_url = entry.media_content[0].get("url", "")
|
||||
elif "<img" in resumen:
|
||||
img_search = re.search(r'src="([^"]+)"', resumen)
|
||||
if img_search:
|
||||
imagen_url = img_search.group(1)
|
||||
if img_search: imagen_url = img_search.group(1)
|
||||
fecha_publicacion = None
|
||||
if "published_parsed" in entry:
|
||||
fecha_publicacion = datetime(*entry.published_parsed[:6])
|
||||
elif "updated_parsed" in entry:
|
||||
fecha_publicacion = datetime(*entry.updated_parsed[:6])
|
||||
if "published_parsed" in entry and entry.published_parsed: fecha_publicacion = datetime(*entry.published_parsed[:6])
|
||||
elif "updated_parsed" in entry and entry.updated_parsed: fecha_publicacion = datetime(*entry.updated_parsed[:6])
|
||||
cursor.execute(
|
||||
"INSERT INTO noticias (id, titulo, resumen, url, fecha, imagen_url, categoria_id, pais_id) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (id) DO NOTHING",
|
||||
(noticia_id, titulo, resumen, link, fecha_publicacion, imagen_url, feed['categoria_id'], feed['pais_id'])
|
||||
)
|
||||
except Exception as entry_err:
|
||||
app.logger.error(f"Error procesando entrada de feed {feed['url']}: {entry_err}", exc_info=True)
|
||||
app.logger.error(f"Error en entrada de feed {feed['url']}: {entry_err}")
|
||||
except Exception as e:
|
||||
app.logger.error(f"[PARSE ERROR] En feed {feed['url']}: {e}", exc_info=True)
|
||||
app.logger.error(f"[PARSE ERROR] En feed {feed['url']}: {e}")
|
||||
sumar_fallo_feed(cursor, feed['id'])
|
||||
app.logger.info(f"Ciclo de feeds completado.")
|
||||
app.logger.info("Ciclo de feeds completado.")
|
||||
except psycopg2.Error as db_err:
|
||||
app.logger.error(f"[DB ERROR] Fallo en el ciclo de actualización de feeds: {db_err}", exc_info=True)
|
||||
app.logger.error(f"[DB ERROR] Fallo en ciclo de actualización: {db_err}")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lanzador de la aplicación + scheduler
|
||||
# ---------------------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
if os.environ.get('WERKZEUG_RUN_MAIN') != 'true':
|
||||
scheduler = BackgroundScheduler(daemon=True)
|
||||
scheduler.add_job(fetch_and_store, "interval", minutes=2, id="rss_job", misfire_grace_time=60)
|
||||
run_time = datetime.now() + timedelta(seconds=20)
|
||||
scheduler.add_job(fetch_and_store, "interval", minutes=15, id="rss_job", next_run_time=run_time)
|
||||
scheduler.start()
|
||||
app.logger.info("Scheduler iniciado correctamente.")
|
||||
import atexit
|
||||
atexit.register(lambda: scheduler.shutdown())
|
||||
app.run(host="0.0.0.0", port=5000, debug=True, use_reloader=True)
|
||||
app.logger.info("Scheduler configurado. Primera ejecución en 20 segundos.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5000, debug=True, use_reloader=False)
|
||||
|
|
|
|||
14
n
14
n
|
|
@ -1,14 +0,0 @@
|
|||
fecha | titulo | url
|
||||
---------------------+-----------------------------------------------------------------------------------------+--------------------------------------------------------------------------------------------------------------------------------
|
||||
2025-06-08 21:55:11 | Deportee’s Lawyers Push for Contempt Proceedings Despite His Return | https://www.nytimes.com/2025/06/08/us/politics/abrego-garcia-justice-department-contempt-proceedings.html
|
||||
2025-06-08 21:51:57 | Степашин рассказал о стратегической ошибке в отношении Украины в 1990-х годах | https://rg.ru/2025/06/09/stepashin-rasskazal-o-strategicheskoj-oshibke-v-otnoshenii-ukrainy-v-1990-h-godah.html
|
||||
2025-06-08 21:50:46 | Lavrov, Rubio in touch, keen to maintain ties — MFA | https://tass.com/politics/1970525
|
||||
2025-06-08 21:45:00 | Минобороны РФ сообщило об уничтожении 24 украинских дронов | https://www.interfax.ru/russia/1030308
|
||||
2025-06-08 21:39:46 | Cuts to UK’s global vaccination funding would risk avoidable child deaths, experts warn | https://www.theguardian.com/society/2025/jun/08/global-vaccination-funding-cuts-threaten-uk-soft-power-and-pandemic-resilience
|
||||
2025-06-08 21:32:32 | Крупный пожар в Подмосковье остановил движение на двух шоссе | https://rg.ru/2025/06/09/reg-cfo/dymovaia-zavesa.html
|
||||
2025-06-08 21:31:06 | Zelensky declines 6,000 bodies to conceal army losses — envoy | https://tass.com/politics/1970521
|
||||
2025-06-08 21:26:37 | Котяков и Ракова посетили геронтопсихологический центр в Москве | https://rg.ru/2025/06/09/reg-cfo/dom-s-teplom.html
|
||||
2025-06-08 21:25:19 | Рогов: В оккупированном ВСУ городе Запорожье слышны взрывы и стрельба | https://rg.ru/2025/06/09/reg-zaporozhskaya/rogov-v-okkupirovannom-vsu-gorode-zaporozhe-slyshny-vzryvy-i-strelba.html
|
||||
2025-06-08 21:23:00 | РФ готова поставлять Индонезии подлодки, корабли, истребители и системы ПВО | https://www.interfax.ru/world/1030307
|
||||
(10 filas)
|
||||
|
||||
54
templates/edit_feed.html
Executable file → Normal file
54
templates/edit_feed.html
Executable file → Normal file
|
|
@ -1,42 +1,64 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Editar Feed RSS{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header>
|
||||
<h1>Editar Feed</h1>
|
||||
<div class="card">
|
||||
<p class="subtitle">Modifica los detalles de tu fuente de noticias: <strong>{{ feed.nombre }}</strong></p>
|
||||
</header>
|
||||
|
||||
<div class="form-section">
|
||||
<form method="post" autocomplete="off">
|
||||
<div>
|
||||
<label for="nombre">Nombre del feed</label>
|
||||
<input id="nombre" name="nombre" type="text" placeholder="Nombre" value="{{ feed['nombre'] }}" required>
|
||||
<input id="nombre" name="nombre" type="text" value="{{ feed.nombre }}" 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 feed">{{ feed['descripcion'] or '' }}</textarea>
|
||||
<textarea id="descripcion" name="descripcion" rows="2">{{ feed.descripcion or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:15px;">
|
||||
<label for="url">URL del RSS</label>
|
||||
<input id="url" name="url" type="url" placeholder="URL" value="{{ feed['url'] }}" required>
|
||||
<input id="url" name="url" type="url" value="{{ feed.url }}" required>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 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 }}" {% if cat.id == feed['categoria_id'] %}selected{% endif %}>{{ cat.nombre }}</option>
|
||||
<option value="{{ cat.id }}" {% if cat.id == feed.categoria_id %}selected{% endif %}>{{ cat.nombre }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
</div>
|
||||
<div>
|
||||
<label for="pais_id">País</label>
|
||||
<select id="pais_id" name="pais_id" required>
|
||||
<option value="">— Elige país —</option>
|
||||
<select id="pais_id" name="pais_id">
|
||||
<option value="">— Global / No aplica —</option>
|
||||
{% for p in paises %}
|
||||
<option value="{{ p.id }}" {% if p.id == feed['pais_id'] %}selected{% endif %}>{{ p.nombre }}</option>
|
||||
<option value="{{ p.id }}" {% if p.id == feed.pais_id %}selected{% endif %}>{{ p.nombre }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<div style="margin: 12px 0;">
|
||||
<input type="checkbox" id="activo" name="activo" {% if feed['activo'] %}checked{% endif %}>
|
||||
<label for="activo" style="display:inline;">Activo</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn" type="submit">Guardar cambios</button>
|
||||
<a href="{{ url_for('feeds') }}">Cancelar</a>
|
||||
<div style="margin-top:15px;">
|
||||
<label for="idioma">Idioma (código de 2 letras)</label>
|
||||
<input id="idioma" name="idioma" type="text" value="{{ feed.idioma or '' }}" maxlength="2" placeholder="ej: es, en, fr">
|
||||
</div>
|
||||
|
||||
<div style="margin: 20px 0;">
|
||||
<input type="checkbox" id="activo" name="activo" {% if feed.activo %}checked{% endif %} style="width: auto; vertical-align: middle; margin-right: 5px;">
|
||||
<label for="activo" style="display:inline; font-weight:normal;">Feed activo</label>
|
||||
</div>
|
||||
|
||||
<button class="btn" type="submit">Guardar Cambios</button>
|
||||
<a href="{{ url_for('feeds') }}" style="margin-left: 15px;">Cancelar</a>
|
||||
</form>
|
||||
</div>
|
||||
<a href="{{ url_for('feeds') }}" class="top-link">← Volver a la gestión de feeds</a>
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue