Initial clean commit

This commit is contained in:
jlimolina 2026-01-13 13:39:51 +01:00
commit 6784d81c2c
141 changed files with 25219 additions and 0 deletions

267
routers/account.py Normal file
View file

@ -0,0 +1,267 @@
"""
Account management router - User profile and account settings.
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from psycopg2 import extras
from db import get_conn
from utils.auth import get_current_user, login_required, hash_password, verify_password, validate_password
from datetime import datetime
account_bp = Blueprint("account", __name__, url_prefix="/account")
@account_bp.route("/")
@login_required
def index():
"""User account dashboard."""
user = get_current_user()
if not user:
return redirect(url_for('auth.login'))
with get_conn() as conn:
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
# Get favorites count
cur.execute("""
SELECT COUNT(*) as count
FROM favoritos
WHERE user_id = %s
""", (user['id'],))
favorites_count = cur.fetchone()['count']
# Get search history count
cur.execute("""
SELECT COUNT(*) as count
FROM search_history
WHERE user_id = %s
""", (user['id'],))
searches_count = cur.fetchone()['count']
# Get recent searches (last 10)
cur.execute("""
SELECT query, results_count, searched_at
FROM search_history
WHERE user_id = %s
ORDER BY searched_at DESC
LIMIT 10
""", (user['id'],))
recent_searches = cur.fetchall()
# Get recent favorites (last 5)
cur.execute("""
SELECT n.id, n.titulo, n.imagen_url, f.created_at,
t.titulo_trad, t.id AS traduccion_id
FROM favoritos f
JOIN noticias n ON n.id = f.noticia_id
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = 'es' AND t.status = 'done'
WHERE f.user_id = %s
ORDER BY f.created_at DESC
LIMIT 5
""", (user['id'],))
recent_favorites = cur.fetchall()
return render_template("account.html",
user=user,
favorites_count=favorites_count,
searches_count=searches_count,
recent_searches=recent_searches,
recent_favorites=recent_favorites)
@account_bp.route("/search-history")
@login_required
def search_history():
"""Full search history page."""
user = get_current_user()
if not user:
return redirect(url_for('auth.login'))
page = max(1, int(request.args.get('page', 1)))
per_page = 50
offset = (page - 1) * per_page
with get_conn() as conn:
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
# Get total count
cur.execute("""
SELECT COUNT(*) as count
FROM search_history
WHERE user_id = %s
""", (user['id'],))
total = cur.fetchone()['count']
# Get paginated results
cur.execute("""
SELECT query, results_count, searched_at
FROM search_history
WHERE user_id = %s
ORDER BY searched_at DESC
LIMIT %s OFFSET %s
""", (user['id'], per_page, offset))
searches = cur.fetchall()
total_pages = (total + per_page - 1) // per_page
return render_template("search_history.html",
user=user,
searches=searches,
page=page,
total_pages=total_pages,
total=total)
@account_bp.route("/change-password", methods=["POST"])
@login_required
def change_password():
"""Change user password."""
user = get_current_user()
if not user:
return redirect(url_for('auth.login'))
current_password = request.form.get("current_password", "")
new_password = request.form.get("new_password", "")
new_password_confirm = request.form.get("new_password_confirm", "")
# Validation
if not current_password or not new_password:
flash("Por favor completa todos los campos", "danger")
return redirect(url_for('account.index'))
valid_password, password_error = validate_password(new_password)
if not valid_password:
flash(password_error, "danger")
return redirect(url_for('account.index'))
if new_password != new_password_confirm:
flash("Las contraseñas nuevas no coinciden", "danger")
return redirect(url_for('account.index'))
try:
with get_conn() as conn:
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
# Verify current password
cur.execute("""
SELECT password_hash
FROM usuarios
WHERE id = %s
""", (user['id'],))
result = cur.fetchone()
if not result or not verify_password(current_password, result['password_hash']):
flash("La contraseña actual es incorrecta", "danger")
return redirect(url_for('account.index'))
# Update password
new_hash = hash_password(new_password)
cur.execute("""
UPDATE usuarios
SET password_hash = %s, updated_at = NOW()
WHERE id = %s
""", (new_hash, user['id']))
conn.commit()
flash("Contraseña actualizada exitosamente", "success")
except Exception as e:
flash("Error al actualizar la contraseña", "danger")
return redirect(url_for('account.index'))
@account_bp.route("/upload-avatar", methods=["POST"])
@login_required
def upload_avatar():
"""Upload user avatar."""
import os
import secrets
from werkzeug.utils import secure_filename
from flask import current_app
user = get_current_user()
if not user:
return redirect(url_for('auth.login'))
if 'avatar' not in request.files:
flash("No se seleccionó ningún archivo", "danger")
return redirect(url_for('account.index'))
file = request.files['avatar']
if file.filename == '':
flash("No se seleccionó ningún archivo", "danger")
return redirect(url_for('account.index'))
if file:
# Check extension
allowed_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
_, ext = os.path.splitext(file.filename)
if ext.lower() not in allowed_extensions:
flash("Formato de imagen no permitido. Usa JPG, PNG, GIF o WEBP.", "danger")
return redirect(url_for('account.index'))
# Save file
try:
# Create filename using user ID and random partial to avoid caching issues
random_hex = secrets.token_hex(4)
filename = f"user_{user['id']}_{random_hex}{ext.lower()}"
# Ensure upload folder exists
upload_folder = os.path.join(current_app.root_path, 'static/uploads/avatars')
os.makedirs(upload_folder, exist_ok=True)
# Delete old avatar if exists
if user.get('avatar_url'):
old_path = os.path.join(current_app.root_path, user['avatar_url'].lstrip('/'))
if os.path.exists(old_path) and 'user_' in old_path: # Safety check
try:
os.remove(old_path)
except:
pass
file_path = os.path.join(upload_folder, filename)
file.save(file_path)
# Update DB
relative_path = f"/static/uploads/avatars/{filename}"
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("""
UPDATE usuarios
SET avatar_url = %s, updated_at = NOW()
WHERE id = %s
""", (relative_path, user['id']))
conn.commit()
# Update session
from flask import session
session['avatar_url'] = relative_path
flash("Foto de perfil actualizada", "success")
except Exception as e:
print(f"Error uploading avatar: {e}")
flash("Error al subir la imagen", "danger")
return redirect(url_for('account.index'))
@account_bp.route("/stats")
@login_required
def stats():
"""Get user statistics as JSON."""
user = get_current_user()
if not user:
return jsonify({"error": "Not authenticated"}), 401
with get_conn() as conn:
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
cur.execute("""
SELECT
(SELECT COUNT(*) FROM favoritos WHERE user_id = %s) as favorites_count,
(SELECT COUNT(*) FROM search_history WHERE user_id = %s) as searches_count,
(SELECT MAX(searched_at) FROM search_history WHERE user_id = %s) as last_search
""", (user['id'], user['id'], user['id']))
stats = cur.fetchone()
return jsonify({
"favorites_count": stats['favorites_count'],
"searches_count": stats['searches_count'],
"last_search": stats['last_search'].isoformat() if stats['last_search'] else None
})