267 lines
9.6 KiB
Python
267 lines
9.6 KiB
Python
"""
|
|
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
|
|
})
|