feat: panel admin, perfil de usuario y config IMAP por usuario

This commit is contained in:
Tatiana Villa Ema 2026-04-25 19:18:24 +02:00
parent 69fc1011d8
commit b4b201323a
6 changed files with 430 additions and 10 deletions

146
app.py
View File

@ -160,7 +160,8 @@ def inicializar_admin():
users = {
"admin": {
"password_hash": generate_password_hash(pwd),
"nombre": "Admin"
"nombre": "Admin",
"admin": True
}
}
guardar_usuarios(users)
@ -168,7 +169,7 @@ def inicializar_admin():
print(f"[init] Cambialo en users.json o con ADMIN_PASSWORD en el entorno.")
# -----------------------------------------------------------------------
# Decorador de autenticacion
# Decoradores de autenticacion
# -----------------------------------------------------------------------
def login_required(f):
@wraps(f)
@ -178,6 +179,17 @@ def login_required(f):
return f(*args, **kwargs)
return decorated
def admin_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not session.get("usuario"):
return redirect(url_for("login", next=request.path))
users = cargar_usuarios()
if not users.get(session["usuario"], {}).get("admin"):
abort(403)
return f(*args, **kwargs)
return decorated
# -----------------------------------------------------------------------
# Rutas de autenticacion
# -----------------------------------------------------------------------
@ -204,6 +216,136 @@ def logout():
session.clear()
return redirect(url_for("login"))
# -----------------------------------------------------------------------
# Perfil de usuario
# -----------------------------------------------------------------------
@app.route("/perfil", methods=["GET", "POST"])
@login_required
def perfil():
usuario = session["usuario"]
users = cargar_usuarios()
user = users[usuario]
error = None
ok = None
if request.method == "POST":
accion = request.form.get("accion", "")
if accion == "nombre":
nombre = request.form.get("nombre", "").strip()
if not nombre:
error = "El nombre no puede estar vacio"
else:
user["nombre"] = nombre
session["nombre"] = nombre
guardar_usuarios(users)
ok = "Nombre actualizado"
elif accion == "password":
pwd_actual = request.form.get("pwd_actual", "")
pwd_nueva = request.form.get("pwd_nueva", "")
pwd_nueva2 = request.form.get("pwd_nueva2", "")
if not check_password_hash(user["password_hash"], pwd_actual):
error = "La contrasena actual no es correcta"
elif len(pwd_nueva) < 6:
error = "La nueva contrasena debe tener al menos 6 caracteres"
elif pwd_nueva != pwd_nueva2:
error = "Las contrasenas no coinciden"
else:
user["password_hash"] = generate_password_hash(pwd_nueva)
guardar_usuarios(users)
ok = "Contrasena actualizada"
elif accion == "email":
user["email_config"] = {
"imap_host": request.form.get("imap_host", "").strip(),
"imap_port": request.form.get("imap_port", "993").strip(),
"correo": request.form.get("correo", "").strip(),
"password": request.form.get("email_pwd", "").strip(),
"remitente": request.form.get("remitente", "noreply@mercadona.es").strip(),
"ssl_verify": request.form.get("ssl_verify", "false"),
}
guardar_usuarios(users)
ok = "Configuracion de email guardada"
email_cfg = user.get("email_config", {})
return render_template("perfil.html",
nombre=session.get("nombre", ""),
usuario=usuario,
email_cfg=email_cfg,
error=error, ok=ok)
# -----------------------------------------------------------------------
# Panel de administracion
# -----------------------------------------------------------------------
@app.route("/admin")
@admin_required
def admin_panel():
users = cargar_usuarios()
lista = []
for uname, udata in users.items():
tickets_dir = BASE_DIR / "tickets" / uname
n_tickets = sum(1 for f in tickets_dir.iterdir() if f.is_file()) if tickets_dir.exists() else 0
lista.append({
"usuario": uname,
"nombre": udata.get("nombre", uname),
"admin": udata.get("admin", False),
"n_tickets": n_tickets,
})
lista.sort(key=lambda x: x["usuario"])
return render_template("admin.html", nombre=session.get("nombre", ""), usuarios=lista)
@app.route("/admin/crear", methods=["POST"])
@admin_required
def admin_crear_usuario():
usuario = request.form.get("usuario", "").strip().lower()
nombre = request.form.get("nombre", "").strip()
password = request.form.get("password", "").strip()
es_admin = request.form.get("es_admin") == "on"
users = cargar_usuarios()
error = None
if not usuario or not nombre or not password:
error = "Todos los campos son obligatorios"
elif len(usuario) < 3 or not usuario.isalnum():
error = "Usuario: min. 3 caracteres, solo letras y numeros"
elif len(password) < 6:
error = "Contrasena: min. 6 caracteres"
elif usuario in users:
error = "Ese nombre de usuario ya existe"
if error:
# Redirigir con el error como query param (sencillo)
return redirect(url_for("admin_panel") + f"?error={error}")
users[usuario] = {
"password_hash": generate_password_hash(password),
"nombre": nombre,
}
if es_admin:
users[usuario]["admin"] = True
guardar_usuarios(users)
(BASE_DIR / "tickets" / usuario).mkdir(parents=True, exist_ok=True)
(BASE_DIR / "datos" / usuario).mkdir(parents=True, exist_ok=True)
return redirect(url_for("admin_panel"))
@app.route("/admin/eliminar", methods=["POST"])
@admin_required
def admin_eliminar_usuario():
import shutil
objetivo = request.form.get("usuario", "").strip().lower()
if not objetivo or objetivo == session["usuario"]:
return redirect(url_for("admin_panel")) # no se puede autoeliminar
users = cargar_usuarios()
users.pop(objetivo, None)
guardar_usuarios(users)
# Borrar datos del usuario
for carpeta in ["tickets", "datos"]:
d = BASE_DIR / carpeta / objetivo
if d.exists():
shutil.rmtree(d)
return redirect(url_for("admin_panel"))
@app.route("/registro", methods=["GET", "POST"])
def registro():
error = None

View File

@ -83,14 +83,33 @@ def descargar_tickets(usuario: str) -> int:
cfg = leer_config()
sec = cfg["email"]
# Variables de entorno sobreescriben config.ini
host = os.environ.get("EMAIL_IMAP_HOST", sec["imap_host"]).strip()
port = int(os.environ.get("EMAIL_IMAP_PORT", sec["imap_port"]))
correo = os.environ.get("EMAIL_CORREO", sec["correo"]).strip()
password = os.environ.get("EMAIL_PASSWORD", sec["password"]).strip()
remitente = sec["remitente"]
# Config por usuario desde users.json (tiene prioridad sobre config.ini)
users_file = BASE_DIR / "users.json"
user_email_cfg = {}
if users_file.exists():
try:
import json as _json
with open(users_file, encoding="utf-8") as f:
users = _json.load(f)
user_email_cfg = users.get(usuario, {}).get("email_config", {})
except Exception:
pass
def _get(key, default=""):
return user_email_cfg.get(key) or os.environ.get(
{"imap_host": "EMAIL_IMAP_HOST", "imap_port": "EMAIL_IMAP_PORT",
"correo": "EMAIL_CORREO", "password": "EMAIL_PASSWORD"}.get(key, ""),
""
) or sec.get(key, default)
host = _get("imap_host", "localhost").strip()
port = int(_get("imap_port", "993"))
correo = _get("correo").strip()
password = _get("password").strip()
remitente = _get("remitente", "noreply@mercadona.es")
solo_nuevos = _get("ssl_verify", "true") != "false" # reutilizamos lógica abajo
ssl_verify = _get("ssl_verify", "true").lower() == "true"
solo_nuevos = sec["solo_nuevos"].lower() == "true"
ssl_verify = sec["ssl_verify"].lower() == "true"
ejecutar_pipeline = sec["ejecutar_pipeline"].lower() == "true"
if not correo or not password:

126
templates/admin.html Normal file
View File

@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Administración — Lista de la Compra</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<style>
.admin-wrap { max-width: 800px; margin: 0 auto; }
.admin-section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: .7rem;
padding: 1.25rem 1.5rem;
margin-bottom: 1.5rem;
}
.admin-section h2 { margin: 0 0 1rem; font-size: 1rem;
border-bottom: 1px solid var(--border); padding-bottom: .6rem; }
table { width: 100%; border-collapse: collapse; font-size: .88rem; }
th { text-align: left; padding: .5rem .75rem;
font-size: .75rem; text-transform: uppercase; letter-spacing: .04em;
color: var(--text-muted); border-bottom: 1px solid var(--border); }
td { padding: .6rem .75rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
.badge-admin { background:#1a2e42; color:#58a6ff;
font-size:.7rem; padding:.15rem .5rem; border-radius:1rem; }
label { display:block; font-size:.82rem; color:var(--text-muted); margin:.65rem 0 .2rem; }
input[type=text], input[type=password] {
width:100%; padding:.5rem .7rem;
background:var(--bg-input); border:1px solid var(--border);
border-radius:.45rem; color:var(--text); font-size:.88rem;
}
input:focus { outline:none; border-color:var(--primary); }
.form-row { display:flex; gap:.75rem; }
.form-row > * { flex:1; }
.check-row { display:flex; align-items:center; gap:.5rem; margin-top:.6rem; font-size:.85rem; }
.check-row input[type=checkbox] { width:auto; }
</style>
</head>
<body>
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.25rem;">
<h1 style="margin:0;">⚙️ Administración</h1>
<div style="display:flex; align-items:center; gap:.75rem;">
<a href="/" class="btn btn-secondary btn-sm">← Inicio</a>
<span style="font-size:.85rem; color:var(--text-muted);">{{ nombre }}</span>
<a href="/logout" class="btn btn-secondary btn-sm">Salir</a>
</div>
</div>
<div class="admin-wrap">
{% if request.args.get('error') %}
<div class="alert" style="margin-bottom:1rem;">{{ request.args.get('error') }}</div>
{% endif %}
<!-- Lista de usuarios -->
<div class="admin-section">
<h2>Usuarios registrados ({{ usuarios|length }})</h2>
<table>
<thead>
<tr>
<th>Usuario</th>
<th>Nombre</th>
<th>Tickets</th>
<th></th>
</tr>
</thead>
<tbody>
{% for u in usuarios %}
<tr>
<td>
{{ u.usuario }}
{% if u.admin %}<span class="badge-admin">admin</span>{% endif %}
</td>
<td>{{ u.nombre }}</td>
<td>{{ u.n_tickets }}</td>
<td style="text-align:right;">
{% if u.usuario != session['usuario'] %}
<form method="post" action="/admin/eliminar"
onsubmit="return confirm('¿Eliminar a {{ u.usuario }} y todos sus datos?')">
<input type="hidden" name="usuario" value="{{ u.usuario }}">
<button type="submit" class="btn btn-secondary btn-sm"
style="color:#f85149; border-color:#f85149;">
Eliminar
</button>
</form>
{% else %}
<span style="font-size:.75rem; color:var(--text-muted);">(tú)</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Crear usuario -->
<div class="admin-section">
<h2>Crear nuevo usuario</h2>
<form method="post" action="/admin/crear">
<div class="form-row">
<div>
<label>Usuario <span style="font-size:.75rem;">(letras/números, min. 3)</span></label>
<input name="usuario" type="text" placeholder="usuario123" required>
</div>
<div>
<label>Nombre visible</label>
<input name="nombre" type="text" placeholder="Nombre Apellido" required>
</div>
</div>
<label>Contraseña <span style="font-size:.75rem;">(min. 6 caracteres)</span></label>
<input name="password" type="password" placeholder="••••••••" required>
<div class="check-row" style="margin-top:.75rem;">
<input type="checkbox" id="es_admin" name="es_admin">
<label for="es_admin" style="margin:0; color:var(--text);">Dar permisos de administrador</label>
</div>
<button type="submit" class="btn btn-primary btn-sm" style="margin-top:1rem;">
Crear usuario
</button>
</form>
</div>
</div><!-- /admin-wrap -->
</body>
</html>

View File

@ -169,6 +169,7 @@
<h1>Estadísticas</h1>
<div style="display:flex; align-items:center; gap:.75rem;">
<a href="/" class="btn btn-secondary btn-sm">← Lista de la compra</a>
<a href="/perfil" class="btn btn-secondary btn-sm">👤 Perfil</a>
<span style="font-size:.85rem; color:var(--text-muted);">{{ nombre }}</span>
<a href="/logout" class="btn btn-secondary btn-sm">Salir</a>
</div>

View File

@ -12,9 +12,10 @@
<h1>Lista de la Compra</h1>
<div style="display:flex; align-items:center; gap:.75rem;">
{% if session.get('usuario') == 'admin' %}
<a href="/admin/usuarios" class="btn btn-secondary btn-sm">Usuarios</a>
<a href="/admin" class="btn btn-secondary btn-sm">⚙️ Admin</a>
{% endif %}
<a href="/estadisticas" class="btn btn-secondary btn-sm">📊 Estadísticas</a>
<a href="/perfil" class="btn btn-secondary btn-sm">👤 Perfil</a>
<span style="font-size:.85rem; color:var(--text-muted);">{{ nombre }}</span>
<a href="/logout" class="btn btn-secondary btn-sm">Salir</a>
</div>

131
templates/perfil.html Normal file
View File

@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mi perfil — Lista de la Compra</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<style>
.perfil-wrap { max-width: 560px; margin: 0 auto; }
.perfil-section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: .7rem;
padding: 1.25rem 1.5rem;
margin-bottom: 1.25rem;
}
.perfil-section h2 {
margin: 0 0 1rem;
font-size: 1rem;
border-bottom: 1px solid var(--border);
padding-bottom: .6rem;
}
label { display: block; font-size: .82rem; color: var(--text-muted); margin-bottom: .25rem; margin-top: .75rem; }
label:first-of-type { margin-top: 0; }
input[type=text], input[type=password], input[type=number], select {
width: 100%; padding: .55rem .75rem;
background: var(--bg-input); border: 1px solid var(--border);
border-radius: .45rem; color: var(--text); font-size: .9rem;
}
input:focus, select:focus { outline: none; border-color: var(--primary); }
.form-row { display: flex; gap: .75rem; }
.form-row > * { flex: 1; }
.check-row { display: flex; align-items: center; gap: .5rem; margin-top: .75rem; font-size: .88rem; }
.check-row input[type=checkbox] { width: auto; }
</style>
</head>
<body>
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.25rem;">
<h1 style="margin:0;">Mi perfil</h1>
<div style="display:flex; align-items:center; gap:.75rem;">
<a href="/" class="btn btn-secondary btn-sm">← Inicio</a>
<span style="font-size:.85rem; color:var(--text-muted);">{{ nombre }}</span>
<a href="/logout" class="btn btn-secondary btn-sm">Salir</a>
</div>
</div>
<div class="perfil-wrap">
{% if error %}
<div class="alert" style="margin-bottom:1rem;">{{ error }}</div>
{% endif %}
{% if ok %}
<div class="alert alert-ok" style="margin-bottom:1rem;">✅ {{ ok }}</div>
{% endif %}
<!-- Nombre -->
<div class="perfil-section">
<h2>Datos personales</h2>
<form method="post">
<input type="hidden" name="accion" value="nombre">
<label for="nombre">Nombre visible</label>
<input id="nombre" name="nombre" type="text" value="{{ nombre }}" required>
<button type="submit" class="btn btn-primary btn-sm" style="margin-top:.85rem;">Guardar nombre</button>
</form>
</div>
<!-- Contraseña -->
<div class="perfil-section">
<h2>Cambiar contraseña</h2>
<form method="post">
<input type="hidden" name="accion" value="password">
<label for="pwd_actual">Contraseña actual</label>
<input id="pwd_actual" name="pwd_actual" type="password" autocomplete="current-password" required>
<label for="pwd_nueva">Nueva contraseña</label>
<input id="pwd_nueva" name="pwd_nueva" type="password" autocomplete="new-password" placeholder="min. 6 caracteres" required>
<label for="pwd_nueva2">Repite la nueva contraseña</label>
<input id="pwd_nueva2" name="pwd_nueva2" type="password" autocomplete="new-password" placeholder="••••••••" required>
<button type="submit" class="btn btn-primary btn-sm" style="margin-top:.85rem;">Cambiar contraseña</button>
</form>
</div>
<!-- Email / IMAP -->
<div class="perfil-section">
<h2>📧 Importación automática de tickets</h2>
<p style="font-size:.82rem; color:var(--text-muted); margin:0 0 1rem;">
Configura tu cuenta de correo para que la app descargue automáticamente
los tickets PDF de Mercadona. Reenvía o configura
<strong>noreply@mercadona.es</strong> como remitente.
</p>
<form method="post">
<input type="hidden" name="accion" value="email">
<div class="form-row">
<div>
<label for="imap_host">Servidor IMAP</label>
<input id="imap_host" name="imap_host" type="text"
placeholder="imap.gmail.com"
value="{{ email_cfg.get('imap_host', '') }}">
</div>
<div style="max-width:100px;">
<label for="imap_port">Puerto</label>
<input id="imap_port" name="imap_port" type="number"
value="{{ email_cfg.get('imap_port', '993') }}">
</div>
</div>
<label for="correo">Correo electrónico</label>
<input id="correo" name="correo" type="text"
placeholder="tu@correo.com"
value="{{ email_cfg.get('correo', '') }}" autocomplete="off">
<label for="email_pwd">Contraseña del correo</label>
<input id="email_pwd" name="email_pwd" type="password"
placeholder="contraseña o clave de aplicación"
value="{{ email_cfg.get('password', '') }}" autocomplete="off">
<label for="remitente">Remitente de tickets (no cambiar normalmente)</label>
<input id="remitente" name="remitente" type="text"
value="{{ email_cfg.get('remitente', 'noreply@mercadona.es') }}">
<div class="check-row">
<input type="checkbox" id="ssl_verify" name="ssl_verify"
value="false"
{% if email_cfg.get('ssl_verify', 'true') == 'false' %}checked{% endif %}>
<label for="ssl_verify" style="margin:0; color:var(--text);">
Desactivar verificación SSL (útil con servidores locales)
</label>
</div>
<button type="submit" class="btn btn-primary btn-sm" style="margin-top:.85rem;">Guardar configuración de email</button>
</form>
</div>
</div><!-- /perfil-wrap -->
</body>
</html>