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:
jlimolina 2025-06-08 23:07:37 +00:00
parent e355003f65
commit 758da0ad4c
3 changed files with 104 additions and 159 deletions

161
app.py
View file

@ -1,69 +1,43 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Flask RSS aggregator — versión PostgreSQL
(Copyright tuyo 😉, con mejoras de estabilidad, seguridad y gestión de DB)
"""
import os import os
import sys 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 hashlib
import re import re
import psycopg2
import psycopg2.extras
import csv import csv
from io import StringIO from io import StringIO
import bleach # Para la seguridad (prevenir XSS) from datetime import datetime, timedelta
import logging 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') logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='[%(asctime)s] %(levelname)s in %(module)s: %(message)s')
app = Flask(__name__) 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)) app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', os.urandom(24))
# --------------------------------------------------------------------------- DB_CONFIG = {"host": "localhost", "port": 5432, "dbname": "rss", "user": "rss", "password": "x"}
# 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)
MAX_FALLOS = 5 MAX_FALLOS = 5
# ====================================== def get_conn():
# Filtro de Plantilla para HTML Seguro return psycopg2.connect(**DB_CONFIG)
# ======================================
@app.template_filter('safe_html') @app.template_filter('safe_html')
def safe_html(text): def safe_html(text):
if not text: if not text: return ""
return "" allowed_tags = {'a', 'b', 'strong', 'i', 'em', 'p', 'br', 'img'}
allowed_tags = {'a', 'abbr', 'b', 'strong', 'i', 'em', 'p', 'br', 'img'}
allowed_attrs = {'a': ['href', 'title'], 'img': ['src', 'alt']} allowed_attrs = {'a': ['href', 'title'], 'img': ['src', 'alt']}
return bleach.clean(text, tags=allowed_tags, attributes=allowed_attrs, strip=True) return bleach.clean(text, tags=allowed_tags, attributes=allowed_attrs, strip=True)
# ======================================
# Rutas de la Aplicación
# ======================================
@app.route("/") @app.route("/")
def home(): def home():
noticias, categorias, continentes, paises = [], [], [], [] noticias, categorias, continentes, paises = [], [], [], []
cat_id = request.args.get("categoria_id") cat_id, cont_id, pais_id = request.args.get("categoria_id"), request.args.get("continente_id"), request.args.get("pais_id")
cont_id = request.args.get("continente_id")
pais_id = request.args.get("pais_id")
try: try:
with get_conn() as conn: with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor: with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
@ -71,45 +45,26 @@ def home():
categorias = cursor.fetchall() categorias = cursor.fetchall()
cursor.execute("SELECT id, nombre FROM continentes ORDER BY nombre") cursor.execute("SELECT id, nombre FROM continentes ORDER BY nombre")
continentes = cursor.fetchall() continentes = cursor.fetchall()
if cont_id: if cont_id:
cursor.execute("SELECT id, nombre, continente_id FROM paises WHERE continente_id = %s ORDER BY nombre", (cont_id,)) cursor.execute("SELECT id, nombre, continente_id FROM paises WHERE continente_id = %s ORDER BY nombre", (cont_id,))
else: else:
cursor.execute("SELECT id, nombre, continente_id FROM paises ORDER BY nombre") cursor.execute("SELECT id, nombre, continente_id FROM paises ORDER BY nombre")
paises = cursor.fetchall() 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 = [] sql_params, conditions = [], []
if cat_id: 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.append("n.categoria_id = %s") if cat_id: conditions.append("n.categoria_id = %s"); sql_params.append(cat_id)
sql_params.append(cat_id) if pais_id: conditions.append("n.pais_id = %s"); sql_params.append(pais_id)
if pais_id: elif cont_id: conditions.append("p.continente_id = %s"); sql_params.append(cont_id)
conditions.append("n.pais_id = %s") if conditions: sql_base += " WHERE " + " AND ".join(conditions)
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" sql_final = sql_base + " ORDER BY n.fecha DESC NULLS LAST LIMIT 50"
cursor.execute(sql_final, tuple(sql_params)) cursor.execute(sql_final, tuple(sql_params))
noticias = cursor.fetchall() noticias = cursor.fetchall()
except psycopg2.Error as db_err: except psycopg2.Error as db_err:
app.logger.error(f"[DB ERROR] Al leer noticias: {db_err}", exc_info=True) 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") flash("Error de base de datos al cargar las noticias.", "error")
return render_template("noticias.html", noticias=noticias, categorias=categorias, continentes=continentes, paises=paises, return render_template("noticias.html", noticias=noticias, categorias=categorias, continentes=continentes, paises=paises,
cat_id=int(cat_id) if cat_id else None, cont_id=int(cont_id) if cont_id else None, pais_id=int(pais_id) if pais_id else None) 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)
@ -147,7 +102,7 @@ def add_feed():
with conn.cursor() as cursor: with conn.cursor() as cursor:
cursor.execute( cursor.execute(
"INSERT INTO feeds (nombre, descripcion, url, categoria_id, pais_id, idioma) VALUES (%s, %s, %s, %s, %s, %s)", "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") flash(f"Feed '{nombre}' añadido correctamente.", "success")
except psycopg2.Error as db_err: except psycopg2.Error as db_err:
@ -162,13 +117,15 @@ def edit_feed(feed_id):
with get_conn() as conn: with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor: with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
if request.method == "POST": if request.method == "POST":
idioma = request.form.get("idioma", "").strip() or None
activo = "activo" in request.form activo = "activo" in request.form
cursor.execute( cursor.execute(
"""UPDATE feeds SET nombre=%s, descripcion=%s, url=%s, categoria_id=%s, pais_id=%s, idioma=%s, activo=%s WHERE id=%s""", """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") flash("Feed actualizado correctamente.", "success")
return redirect(url_for("feeds")) return redirect(url_for("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")
@ -179,9 +136,11 @@ def edit_feed(feed_id):
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("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("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>")
@ -208,12 +167,8 @@ def reactivar_feed(feed_id):
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("feeds"))
# ===================================================================
# [INICIO DEL CÓDIGO AÑADIDO] Backup y Restauración de Feeds
# ===================================================================
@app.route("/backup_feeds") @app.route("/backup_feeds")
def backup_feeds(): def backup_feeds():
"""Exporta todos los feeds a un archivo CSV."""
try: try:
with get_conn() as conn: with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor: with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
@ -238,11 +193,7 @@ def backup_feeds():
output = si.getvalue() output = si.getvalue()
si.close() si.close()
return Response( return Response(output, mimetype="text/csv", headers={"Content-Disposition": "attachment;filename=feeds_backup.csv"})
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")
@ -250,13 +201,11 @@ def backup_feeds():
@app.route("/restore_feeds", methods=["GET", "POST"]) @app.route("/restore_feeds", methods=["GET", "POST"])
def restore_feeds(): def restore_feeds():
"""Muestra el formulario de restauración y procesa el archivo CSV subido."""
if request.method == "POST": if request.method == "POST":
file = request.files.get("file") file = request.files.get("file")
if not file or not file.filename.endswith(".csv"): if not file or not file.filename.endswith(".csv"):
flash("Archivo no válido. Por favor, sube un archivo .csv.", "error") flash("Archivo no válido. Por favor, sube un archivo .csv.", "error")
return redirect(url_for("restore_feeds")) return redirect(url_for("restore_feeds"))
try: try:
file_stream = StringIO(file.read().decode("utf-8")) file_stream = StringIO(file.read().decode("utf-8"))
reader = csv.DictReader(file_stream) reader = csv.DictReader(file_stream)
@ -289,23 +238,14 @@ def restore_feeds():
except Exception as e: except Exception as e:
n_err += 1 n_err += 1
app.logger.error(f"Error procesando fila del CSV: {row} - Error: {e}") 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") flash(f"Restauración completada. Feeds procesados: {n_ok}. Errores: {n_err}.", "success" if n_err == 0 else "warning")
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("feeds"))
return render_template("restore_feeds.html") 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): def sumar_fallo_feed(cursor, feed_id):
cursor.execute("UPDATE feeds SET fallos = fallos + 1 WHERE id = %s RETURNING fallos", (feed_id,)) cursor.execute("UPDATE feeds SET fallos = fallos + 1 WHERE id = %s RETURNING fallos", (feed_id,))
fallos = cursor.fetchone()[0] fallos = cursor.fetchone()[0]
@ -324,12 +264,15 @@ def fetch_and_store():
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor: with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
cursor.execute("SELECT id, url, categoria_id, pais_id FROM feeds WHERE activo = TRUE") cursor.execute("SELECT id, url, categoria_id, pais_id FROM feeds WHERE activo = TRUE")
feeds_to_process = cursor.fetchall() 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: for feed in feeds_to_process:
try: try:
app.logger.info(f"Procesando feed: {feed['url']}") app.logger.info(f"Procesando feed: {feed['url']}")
parsed = feedparser.parse(feed['url']) parsed = feedparser.parse(feed['url'])
if getattr(parsed, "bozo", False): 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']) sumar_fallo_feed(cursor, feed['id'])
continue continue
resetear_fallos_feed(cursor, feed['id']) resetear_fallos_feed(cursor, feed['id'])
@ -345,35 +288,29 @@ def fetch_and_store():
imagen_url = entry.media_content[0].get("url", "") imagen_url = entry.media_content[0].get("url", "")
elif "<img" in resumen: elif "<img" in resumen:
img_search = re.search(r'src="([^"]+)"', resumen) img_search = re.search(r'src="([^"]+)"', resumen)
if img_search: if img_search: imagen_url = img_search.group(1)
imagen_url = img_search.group(1)
fecha_publicacion = None fecha_publicacion = None
if "published_parsed" in entry: if "published_parsed" in entry and entry.published_parsed: fecha_publicacion = datetime(*entry.published_parsed[:6])
fecha_publicacion = datetime(*entry.published_parsed[:6]) elif "updated_parsed" in entry and entry.updated_parsed: fecha_publicacion = datetime(*entry.updated_parsed[:6])
elif "updated_parsed" in entry:
fecha_publicacion = datetime(*entry.updated_parsed[:6])
cursor.execute( 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", "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']) (noticia_id, titulo, resumen, link, fecha_publicacion, imagen_url, feed['categoria_id'], feed['pais_id'])
) )
except Exception as entry_err: 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: 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']) 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: 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}")
scheduler = BackgroundScheduler(daemon=True)
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()
atexit.register(lambda: scheduler.shutdown())
app.logger.info("Scheduler configurado. Primera ejecución en 20 segundos.")
# ---------------------------------------------------------------------------
# Lanzador de la aplicación + scheduler
# ---------------------------------------------------------------------------
if __name__ == "__main__": if __name__ == "__main__":
if os.environ.get('WERKZEUG_RUN_MAIN') != 'true': app.run(host="0.0.0.0", port=5000, debug=True, use_reloader=False)
scheduler = BackgroundScheduler(daemon=True)
scheduler.add_job(fetch_and_store, "interval", minutes=2, id="rss_job", misfire_grace_time=60)
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)

14
n
View file

@ -1,14 +0,0 @@
fecha | titulo | url
---------------------+-----------------------------------------------------------------------------------------+--------------------------------------------------------------------------------------------------------------------------------
2025-06-08 21:55:11 | Deportees 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 UKs 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)

88
templates/edit_feed.html Executable file → Normal file
View file

@ -1,42 +1,64 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Editar Feed RSS{% endblock %} {% block title %}Editar Feed RSS{% endblock %}
{% block content %} {% block content %}
<h1>Editar Feed</h1> <header>
<div class="card"> <h1>Editar Feed</h1>
<form method="post" autocomplete="off"> <p class="subtitle">Modifica los detalles de tu fuente de noticias: <strong>{{ feed.nombre }}</strong></p>
<label for="nombre">Nombre del feed</label> </header>
<input id="nombre" name="nombre" type="text" placeholder="Nombre" value="{{ feed['nombre'] }}" required>
<label for="descripcion">Descripción</label> <div class="form-section">
<textarea id="descripcion" name="descripcion" rows="2" placeholder="Breve descripción del feed">{{ feed['descripcion'] or '' }}</textarea> <form method="post" autocomplete="off">
<div>
<label for="nombre">Nombre del feed</label>
<input id="nombre" name="nombre" type="text" value="{{ feed.nombre }}" required>
</div>
<label for="url">URL del RSS</label> <div style="margin-top:15px;">
<input id="url" name="url" type="url" placeholder="URL" value="{{ feed['url'] }}" required> <label for="descripcion">Descripción</label>
<textarea id="descripcion" name="descripcion" rows="2">{{ feed.descripcion or '' }}</textarea>
</div>
<label for="categoria_id">Categoría</label> <div style="margin-top:15px;">
<select id="categoria_id" name="categoria_id" required> <label for="url">URL del RSS</label>
<option value="">— Elige categoría —</option> <input id="url" name="url" type="url" value="{{ feed.url }}" required>
{% for cat in categorias %} </div>
<option value="{{ cat.id }}" {% if cat.id == feed['categoria_id'] %}selected{% endif %}>{{ cat.nombre }}</option>
{% endfor %}
</select>
<label for="pais_id">País</label> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-top: 15px;">
<select id="pais_id" name="pais_id" required> <div>
<option value="">— Elige país —</option> <label for="categoria_id">Categoría</label>
{% for p in paises %} <select id="categoria_id" name="categoria_id" required>
<option value="{{ p.id }}" {% if p.id == feed['pais_id'] %}selected{% endif %}>{{ p.nombre }}</option> <option value="">— Elige categoría —</option>
{% endfor %} {% for cat in categorias %}
</select> <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">
<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>
{% endfor %}
</select>
</div>
</div>
<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>
<div style="margin: 12px 0;"> <button class="btn" type="submit">Guardar Cambios</button>
<input type="checkbox" id="activo" name="activo" {% if feed['activo'] %}checked{% endif %}> <a href="{{ url_for('feeds') }}" style="margin-left: 15px;">Cancelar</a>
<label for="activo" style="display:inline;">Activo</label> </form>
</div> </div>
<a href="{{ url_for('feeds') }}" class="top-link">← Volver a la gestión de feeds</a>
<button class="btn" type="submit">Guardar cambios</button>
<a href="{{ url_for('feeds') }}">Cancelar</a>
</form>
</div>
{% endblock %} {% endblock %}