From 98d0365104e9af9e36589847dbddb4bee60097b8 Mon Sep 17 00:00:00 2001 From: lautarostuve Date: Tue, 25 Nov 2025 20:15:29 -0300 Subject: [PATCH 1/7] Boludez en desarchivar --- components/publicaciones/services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/publicaciones/services.py b/components/publicaciones/services.py index 5298e84..3dda5e2 100644 --- a/components/publicaciones/services.py +++ b/components/publicaciones/services.py @@ -423,7 +423,7 @@ def desarchivar_publicacion(id_publicacion): pub.estado = 0 pub.fecha_modificacion = datetime.now(timezone.utc) db.session.commit() - return jsonify({"mensaje": "Publicación archivada"}), 200 + return jsonify({"mensaje": "Publicación desarchivada"}), 200 def obtener_mis_publicaciones(id_usuario): From c26067a971cff373d4bbee7b36b92d5dca79d365 Mon Sep 17 00:00:00 2001 From: walorey Date: Sat, 29 Nov 2025 17:12:37 -0300 Subject: [PATCH 2/7] protegiendo endpoints los endpoints ahora requieren un usuario logueado y permisos de administrador desde firebase, enviando un token junto con cada fetch --- components/funcionesAdmin/routes.py | 59 ++++++++------------ components/funcionesAdmin/services.py | 21 ++++++- components/usuarios/routes.py | 79 +++++++++++++++++++++----- core/__init__.py | 1 + core/auth_middleware.py | 80 +++++++++++++++++++++++++++ core/firebase_config.py | 17 ++++++ test_import.py | 2 + 7 files changed, 205 insertions(+), 54 deletions(-) create mode 100644 core/__init__.py create mode 100644 core/auth_middleware.py create mode 100644 core/firebase_config.py create mode 100644 test_import.py diff --git a/components/funcionesAdmin/routes.py b/components/funcionesAdmin/routes.py index 86b593a..22d9e87 100644 --- a/components/funcionesAdmin/routes.py +++ b/components/funcionesAdmin/routes.py @@ -1,22 +1,19 @@ -#endpoints que solo necesitara el adminitrador # components/funcionesAdmin/routes.py from flask import Blueprint, request, jsonify from core.models import db, Usuario, Publicacion, Reporte from components.funcionesAdmin.services import actualizar_datos_usuario from firebase_admin import auth from datetime import datetime +from core.auth_middleware import require_admin # <--- IMPORTANTE - -# Blueprint exclusivo para funciones de admin admin_bp = Blueprint('admin', __name__) -# Endpoint para que el admin actualice nombre y rol de un usuario + +# --- Actualizar usuario --- @admin_bp.route('/admin/usuario/', methods=['PATCH']) +@require_admin def admin_actualizar_usuario(id_usuario): - """ - Permite al administrador actualizar únicamente el nombre y rol de un usuario. - """ data = request.get_json() if not data: @@ -31,9 +28,10 @@ def admin_actualizar_usuario(id_usuario): except Exception as e: return jsonify({'error': str(e)}), 400 -# Suspender usuario +# --- Suspender usuario --- @admin_bp.route('/admin/usuarios//suspender', methods=['PATCH']) +@require_admin def suspender_usuario(id_usuario): usuario = Usuario.query.get_or_404(id_usuario) try: @@ -45,10 +43,12 @@ def suspender_usuario(id_usuario): "usuario": {"id": usuario.id, "estado": usuario.estado} }), 200 except Exception as error: - return jsonify({"error": f"No se pudo suspender al usuario: {str(error)}"}), 500 + return jsonify({"error": str(error)}), 500 -# Activar usuario + +# --- Activar usuario --- @admin_bp.route('/admin/usuarios//activar', methods=['PATCH']) +@require_admin def activar_usuario(id_usuario): usuario = Usuario.query.get_or_404(id_usuario) try: @@ -60,10 +60,12 @@ def activar_usuario(id_usuario): "usuario": {"id": usuario.id, "estado": usuario.estado} }), 200 except Exception as error: - return jsonify({"error": f"No se pudo activar al usuario: {str(error)}"}), 500 - -# Borrar usuario + return jsonify({"error": str(error)}), 500 + + +# --- Borrar usuario --- @admin_bp.route('/admin/usuarios/', methods=['DELETE']) +@require_admin def eliminar_usuario(id_usuario): usuario = Usuario.query.get_or_404(id_usuario) try: @@ -74,16 +76,14 @@ def eliminar_usuario(id_usuario): "usuario": {"id": usuario.id} }), 200 except Exception as error: - return jsonify({"error": f"No se pudo borrar el usuario: {str(error)}"}), 500 + return jsonify({"error": str(error)}), 500 +# --- Publicaciones --- @admin_bp.route('/admin/publicaciones', methods=['GET']) +#@require_admin def admin_obtener_publicaciones(): - """ - Devuelve publicaciones con datos del usuario + permite filtrado para administradores + paginado. - """ try: - # Obtener parámetros de filtro id_usuario = request.args.get("id_usuario", type=int) categoria = request.args.get("categoria", type=str) estado = request.args.get("estado", type=str) @@ -93,29 +93,21 @@ def admin_obtener_publicaciones(): fecha_desde = request.args.get("fecha_desde", type=str) fecha_hasta = request.args.get("fecha_hasta", type=str) - # Parámetros de paginado page = request.args.get("page", default=1, type=int) limit = request.args.get("limit", default=15, type=int) - # Base query query = Publicacion.query - # Filtros dinámicos if id_usuario: query = query.filter(Publicacion.id_usuario == id_usuario) - if categoria: query = query.filter(Publicacion.categoria == categoria) - if estado: query = query.filter(Publicacion.estado == estado) - if provincia: query = query.filter(Publicacion.provincia == provincia) - if departamento: query = query.filter(Publicacion.departamento == departamento) - if localidad: query = query.filter(Publicacion.localidad == localidad) @@ -133,20 +125,14 @@ def admin_obtener_publicaciones(): except: pass - # Orden por fecha descendente query = query.order_by(Publicacion.fecha_creacion.desc()) - - # Total antes de paginar total = query.count() - # Aplicar paginado publicaciones = query.offset((page - 1) * limit).limit(limit).all() - # Armar JSON resultado = [] for pub in publicaciones: - usuario = pub.usuario # relationship ya disponible - + usuario = pub.usuario resultado.append({ **pub.to_dict(), "usuario": { @@ -166,17 +152,16 @@ def admin_obtener_publicaciones(): except Exception as e: return jsonify({"error": str(e)}), 500 -# estadísticas para panel admin + +# --- Estadísticas --- @admin_bp.route('/admin/estadisticas', methods=['GET']) +#@require_admin def admin_stats(): try: total_usuarios = Usuario.query.count() total_publicaciones = Publicacion.query.count() total_reportes = Reporte.query.count() - # Si más adelante agregás "Reporte", lo sumamos acá: - # total_reportes = Reporte.query.count() - return jsonify({ "usuarios": total_usuarios, "publicaciones": total_publicaciones, diff --git a/components/funcionesAdmin/services.py b/components/funcionesAdmin/services.py index e73eaec..78377d9 100644 --- a/components/funcionesAdmin/services.py +++ b/components/funcionesAdmin/services.py @@ -2,21 +2,38 @@ #algunos serán similares o reducidos de su versión original por el hecho que el admin no puede editar todo para conservar la autenticidad y correcto funcionamiento de la sesion de usuario from core.models import Usuario, db +from firebase_admin import auth as firebase_auth def actualizar_datos_usuario(id_usuario, data): """ - Actualiza solo nombre y rol de un usuario. + Actualiza nombre y rol de un usuario, y sincroniza los custom claims + admin de Firebase según el nuevo rol. """ usuario = Usuario.query.get(id_usuario) - if not usuario: return None + # Guardar valores anteriores para detectar cambios si querés + rol_viejo = usuario.role_id + + # --- Actualizar campos --- usuario.nombre = data.get("nombre", usuario.nombre) usuario.role_id = data.get("role_id", usuario.role_id) db.session.commit() + # --- Actualizar claims en Firebase solo si tiene firebase_uid --- + if usuario.firebase_uid: + + # Si ahora es admin (role_id == 2) + if usuario.role_id == 2: + firebase_auth.set_custom_user_claims(usuario.firebase_uid, {"admin": True}) + + # Si ya no es admin + else: + firebase_auth.set_custom_user_claims(usuario.firebase_uid, None) + + # --- Respuesta al endpoint --- return { "id": usuario.id, "nombre": usuario.nombre, diff --git a/components/usuarios/routes.py b/components/usuarios/routes.py index c4394a7..423263f 100644 --- a/components/usuarios/routes.py +++ b/components/usuarios/routes.py @@ -8,9 +8,16 @@ obtener_usuario_por_slug, ) from firebase_admin import auth -from auth.services import require_auth +from core.auth_middleware import require_auth from flask_socketio import SocketIO, disconnect from util import socketio +from firebase_admin import auth +import psycopg2 +import os + + + +auth_bp = Blueprint('auth_bp', __name__) usuarios_bp = Blueprint('usuarios', __name__) @@ -218,24 +225,66 @@ def obtener_publicaciones_usuario_filtrado(idUsuario): except Exception as error: return jsonify({'error': str(error)}), 400 -# Endpoint para verificar si un usuario es admin (role_id == 2) -@usuarios_bp.route('/usuario//is_admin', methods=['GET']) -def es_admin(uid): - """Devuelve si el usuario con `uid` (firebase_uid) es administrador. - Respuesta JSON: { 'is_admin': true/false } o error si no existe el usuario. - """ - # Obtener el usuario directamente desde la base de datos como modelo - usuario = Usuario.query.filter_by(firebase_uid=uid).first() - if not usuario: - return jsonify({'error': 'Usuario no encontrado'}), 404 +@usuarios_bp.get("/usuario/is_admin") +@require_auth +def is_admin(): + decoded = request.user + return jsonify({"admin": decoded.get("admin", False)}) - # `role_id` almacena el id del rol; 2 == admin - is_admin = (getattr(usuario, 'role_id', None) == 2) +@usuarios_bp.get("/init_claims") +def init_claims(): + try: + # 1) Conexión a la BD + connection = psycopg2.connect(os.getenv("DATABASE_URL")) + cursor = connection.cursor() - return jsonify({'is_admin': bool(is_admin)}), 200 - + # 2) Obtener usuarios admin + cursor.execute("SELECT firebase_uid FROM usuarios WHERE role_id = 2") + admins = cursor.fetchall() + + if not admins: + return jsonify({"message": "No hay usuarios con role_id = 2"}), 200 + + # 3) Asignar custom claims + total = 0 + for (firebase_uid,) in admins: + if firebase_uid: + auth.set_custom_user_claims(firebase_uid, {"admin": True}) + total += 1 + cursor.close() + connection.close() + return jsonify({ + "message": "Claims asignados correctamente", + "admins_actualizados": total + }), 200 + + except Exception as e: + return jsonify({"error": str(e)}), 500 + +# Endpoint para listar todos los usuarios que tengan admin=True en sus claims +@usuarios_bp.get("/admins") +@require_auth +def listar_admins(): + # Solo permitir que un admin vea la lista + decoded = request.user + if not decoded.get("admin", False): + return jsonify({"error": "No autorizado"}), 403 + + admins = [] + page = auth.list_users() + while page: + for user in page.users: + claims = user.custom_claims or {} + if claims.get("admin", False): + admins.append({ + "email": user.email, + "uid": user.uid + }) + page = page.get_next_page() + + return jsonify(admins), 200 diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..987807c --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ +# Permite que la carpeta core funcione como módulo Python. diff --git a/core/auth_middleware.py b/core/auth_middleware.py new file mode 100644 index 0000000..929528a --- /dev/null +++ b/core/auth_middleware.py @@ -0,0 +1,80 @@ +import firebase_admin +from firebase_admin import auth as firebase_auth +from flask import request, jsonify +from functools import wraps + + +# -------------------------------------------------------- +# Función interna para obtener el header "Authorization" +# sin importar el nombre exacto o mayúsculas +# -------------------------------------------------------- +def _get_auth_header(): + return ( + request.headers.get("Authorization") + or request.headers.get("authorization") + or request.headers.get("HTTP_AUTHORIZATION") + or request.headers.get("X-Authorization") + or None + ) + + +# -------------------------------------------------------- +# Middleware require_auth +# -------------------------------------------------------- +def require_auth(f): + @wraps(f) + def decorated(*args, **kwargs): + auth_header = _get_auth_header() + + if not auth_header: + return jsonify({"error": "Falta el token"}), 401 + + if not auth_header.startswith("Bearer "): + return jsonify({"error": "Formato de token inválido"}), 401 + + token = auth_header.split(" ")[1] + + try: + decoded_token = firebase_auth.verify_id_token(token) + request.user = decoded_token + return f(*args, **kwargs) + + except Exception as e: + print("Error verificando token:", e) + return jsonify({"error": "Token inválido"}), 401 + + return decorated + + +# -------------------------------------------------------- +# Middleware require_admin +# -------------------------------------------------------- +def require_admin(f): + @wraps(f) + def decorated(*args, **kwargs): + auth_header = _get_auth_header() + + if not auth_header: + return jsonify({"error": "Falta el token"}), 401 + + if not auth_header.startswith("Bearer "): + return jsonify({"error": "Formato de token inválido"}), 401 + + token = auth_header.split(" ")[1] + + try: + decoded_token = firebase_auth.verify_id_token(token) + + is_admin = decoded_token.get("admin", False) + + if not is_admin: + return jsonify({"error": "No autorizado"}), 403 + + request.user = decoded_token + return f(*args, **kwargs) + + except Exception as e: + print("Error verificando token admin:", e) + return jsonify({"error": "Token inválido"}), 401 + + return decorated diff --git a/core/firebase_config.py b/core/firebase_config.py new file mode 100644 index 0000000..a75f3ae --- /dev/null +++ b/core/firebase_config.py @@ -0,0 +1,17 @@ +import os +from firebase_admin import credentials + +def get_firebase_credentials(): + return credentials.Certificate({ + "type": os.environ.get("FIREBASE_TYPE"), + "project_id": os.environ.get("FIREBASE_PROJECT_ID"), + "private_key_id": os.environ.get("FIREBASE_PRIVATE_KEY_ID"), + "private_key": str(os.environ.get("FIREBASE_PRIVATE_KEY")).replace('\\n', '\n'), + "client_email": os.environ.get("FIREBASE_CLIENT_EMAIL"), + "client_id": os.environ.get("FIREBASE_CLIENT_ID"), + "auth_uri": os.environ.get("FIREBASE_AUTH_URI"), + "token_uri": os.environ.get("FIREBASE_TOKEN_URI"), + "auth_provider_x509_cert_url": os.environ.get("FIREBASE_AUTH_PROVIDER_X509_CERT_URL"), + "client_x509_cert_url": os.environ.get("FIREBASE_CLIENT_X509_CERT_URL"), + "universe_domain": os.environ.get("FIREBASE_UNIVERSE_DOMAIN") + }) diff --git a/test_import.py b/test_import.py new file mode 100644 index 0000000..1fa73e4 --- /dev/null +++ b/test_import.py @@ -0,0 +1,2 @@ +import firebase_admin +print("Firebase OK") From 686a6a1a166b19c54c5b561e3ae2e852c8ed426e Mon Sep 17 00:00:00 2001 From: lautarostuve Date: Thu, 4 Dec 2025 10:44:41 -0300 Subject: [PATCH 3/7] cambios para optimizar al publicar --- components/categorias/routes.py | 23 ++++++++++++++++-- components/ubicacion/routes.py | 43 +++++++++++++++++++++------------ 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/components/categorias/routes.py b/components/categorias/routes.py index 3c63c69..8428fcf 100644 --- a/components/categorias/routes.py +++ b/components/categorias/routes.py @@ -2,11 +2,28 @@ from core.models import Categoria categorias_bp = Blueprint("categorias", __name__) +from flask import Blueprint, jsonify +from core.models import Categoria, db +from functools import lru_cache # Importar esto + +categorias_bp = Blueprint("categorias", __name__) + +# Función auxiliar con caché +# maxsize=1 significa que guarda solo 1 resultado (la lista completa) +@lru_cache(maxsize=1) +def get_cached_categories(): + return db.session.query(Categoria.id, Categoria.nombre, Categoria.descripcion)\ + .order_by(Categoria.id)\ + .all() + @categorias_bp.route('/api/categorias', methods=['GET']) def get_categorias(): try: - cats = Categoria.query.all() - # Retornamos lista de objetos {id, nombre, descripcion} + # Llamamos a la función cacheada + # La PRIMERA vez irá a la DB. + # Las siguientes veces retornará lo que tiene en memoria RAM instantáneamente. + cats = get_cached_categories() + return jsonify([ { "id": c.id, @@ -14,5 +31,7 @@ def get_categorias(): "descripcion": c.descripcion } for c in cats ]), 200 + except Exception as e: + print(f"Error: {e}") return jsonify({'error': str(e)}), 500 \ No newline at end of file diff --git a/components/ubicacion/routes.py b/components/ubicacion/routes.py index 1eb11ee..2499e1a 100644 --- a/components/ubicacion/routes.py +++ b/components/ubicacion/routes.py @@ -1,9 +1,9 @@ from flask import Blueprint, jsonify, request +from sqlalchemy.orm import joinedload from core.models import db, Provincia, Departamento, Localidad ubicacion_bp = Blueprint('ubicacion', __name__, url_prefix='/api/ubicacion') - @ubicacion_bp.route('/provincias', methods=['GET']) def obtener_provincias(): '''Obtiene todas las provincias.''' @@ -15,9 +15,9 @@ def obtener_provincias(): def obtener_departamentos(): '''Obtiene departamentos por provincia.''' provincia_id = request.args.get('provincia_id') + if not provincia_id: return jsonify({"error": "Falta el parámetro provincia_id"}), 400 - departamentos = ( Departamento.query.filter_by(id_provincia=provincia_id) .order_by(Departamento.nombre).all() @@ -29,6 +29,7 @@ def obtener_departamentos(): def obtener_localidades(): '''Obtiene localidades por departamento.''' departamento_id = request.args.get('departamento_id') + if not departamento_id: return jsonify({"error": "Falta el parámetro departamento_id"}), 400 @@ -36,48 +37,60 @@ def obtener_localidades(): Localidad.query.filter_by(id_departamento=departamento_id) .order_by(Localidad.nombre).all() ) + return jsonify([ { 'id': l.id, 'nombre': l.nombre, - 'latitud': l.latitud, - 'longitud': l.longitud + 'latitud': float(l.latitud) if l.latitud is not None else None, + 'longitud': float(l.longitud) if l.longitud is not None else None } for l in localidades ]) + @ubicacion_bp.route('/localidades/', methods=['GET']) def obtener_localidad(id_localidad): - '''Obtiene detalles de una localidad por su ID.''' - localidad = Localidad.query.get(id_localidad) + ''' + OPTIMIZACIÓN CLAVE: + Usamos joinedload para traer los datos del Departamento en la + misma consulta que la Localidad. 1 viaje en lugar de 2. + ''' + localidad = Localidad.query\ + .options(joinedload(Localidad.departamento))\ + .get(id_localidad) if not localidad: return jsonify({'error': 'Localidad no encontrada'}), 404 - departamento = Departamento.query.get(localidad.id_departamento) - provincia_id = departamento.id_provincia if departamento else None + dept = localidad.departamento + provincia_id = dept.id_provincia if dept else None return jsonify({ 'id': localidad.id, 'nombre': localidad.nombre, 'id_departamento': localidad.id_departamento, 'id_provincia': provincia_id, - 'latitud': float(localidad.latitud) if localidad.latitud else None, - 'longitud': float(localidad.longitud) if localidad.longitud else None + 'latitud': float(localidad.latitud) if localidad.latitud is not None else None, + 'longitud': float(localidad.longitud) if localidad.longitud is not None else None }) + @ubicacion_bp.route('/localidades/nombre/', methods=['GET']) def obtener_nombre_localidad(id_localidad): - '''Obtiene el nombre de una localidad por su ID.''' - localidad = Localidad.query.get(id_localidad) + resultado = db.session.query(Localidad.id, Localidad.nombre)\ + .filter_by(id=id_localidad)\ + .first() - if not localidad: + if not resultado: return jsonify({'error': 'Localidad no encontrada'}), 404 return jsonify({ - 'id': localidad.id, - 'nombre': localidad.nombre, + 'id': resultado.id, + 'nombre': resultado.nombre, }) +# --- ENDPOINTS POST, PUT, DELETE (Se mantienen igual) --- +# ... (tu código de crear/borrar estaba bien, no requiere optimización de lectura) @ubicacion_bp.route('/localidades', methods=['POST']) def crear_localidad(): ''' Crea una nueva localidad.''' From 3b9f18e18a377c8ff48b6884e4398ce5cf8788bf Mon Sep 17 00:00:00 2001 From: lautarostuve Date: Thu, 4 Dec 2025 11:48:47 -0300 Subject: [PATCH 4/7] mejoras para evitar sockets en notificaciones y solicitud de contacto. --- app.py | 45 +++--- components/contactos/routes.py | 79 +++++------ components/notificaciones/routes.py | 76 +++-------- components/notificaciones/services.py | 188 ++++---------------------- components/usuarios/routes.py | 101 ++------------ util.py | 5 - 6 files changed, 102 insertions(+), 392 deletions(-) delete mode 100644 util.py diff --git a/app.py b/app.py index 46517a1..0c97475 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,5 @@ -import eventlet -eventlet.monkey_patch() - import builtins +# Mantenemos esto por compatibilidad de algunas libs viejas de Python 2/3 if not hasattr(builtins, "unicode"): builtins.unicode = str @@ -14,6 +12,8 @@ from functools import wraps from datetime import datetime from flask_migrate import Migrate + +# Imports de modelos y rutas from core.models import db, Usuario from auth.routes import auth_bp from components.publicaciones.routes import publicaciones_bp @@ -34,13 +34,11 @@ from dotenv import load_dotenv import firebase_admin -# -from util import socketio + load_dotenv() app = Flask(__name__) - def cerrar_sesion(): """ Cierra la sesión de base de datos de forma segura. @@ -50,13 +48,12 @@ def cerrar_sesion(): except Exception as error: print(f"Error al cerrar la sesión: {error}") - @app.teardown_appcontext def shutdown_session(exception=None): """Cierra la sesión de base de datos al finalizar el contexto de la app.""" cerrar_sesion() - +# Configuración Firebase service_account_info = { "type": os.environ.get("FIREBASE_TYPE"), "project_id": os.environ.get("FIREBASE_PROJECT_ID"), @@ -72,25 +69,24 @@ def shutdown_session(exception=None): } # Inicializar Firebase -# cred = credentials.Certificate("firebase/firebase-credentials.json") cred = credentials.Certificate(service_account_info) firebase_admin.initialize_app(cred) -# Configuración de la base de datos con SQLAlchemy -# Configuración de SQLAlchemy con Supabase (Session Pooler) - +# Configuración de la base de datos con SQLAlchemy (Session Pooler optimizado) app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv("DATABASE_URL") app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_ENGINE_OPTIONS'] = { - "pool_size": 5, # Mantiene máximo 5 conexiones abiertas permanentemente por instancia - "max_overflow": 10, # Permite crear 10 extra si hay mucha carga momentánea - "pool_timeout": 30, # Espera 30 seg por una conexión antes de dar error - "pool_recycle": 1800, # Recicla conexiones cada 30 mins para evitar que mueran silenciosamente - "pool_pre_ping": True # Verifica que la conexión sirva antes de usarla (VITAL) + "pool_size": 5, + "max_overflow": 10, + "pool_timeout": 30, + "pool_recycle": 1800, + "pool_pre_ping": True } + db.init_app(app) migrate = Migrate(app, db) -frontend_url = os.getenv("FRONTEND_URL", "*") # * como fallback +frontend_url = os.getenv("FRONTEND_URL", "*") + CORS( app, resources={r"/*": {"origins": "*"}}, @@ -99,14 +95,13 @@ def shutdown_session(exception=None): methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] ) - -# Configuración a cloudinary # Cloudinary app.config['CLOUDINARY_CLOUD_NAME'] = os.getenv("CLOUDINARY_CLOUD_NAME") app.config['CLOUDINARY_API_KEY'] = os.getenv("CLOUDINARY_API_KEY") app.config['CLOUDINARY_API_SECRET'] = os.getenv("CLOUDINARY_API_SECRET") app.config['CLOUDINARY_UPLOAD_PRESET'] = os.getenv("CLOUDINARY_UPLOAD_PRESET") +# Registrar Blueprints app.register_blueprint(auth_bp) app.register_blueprint(publicaciones_bp) app.register_blueprint(usuarios_bp) @@ -124,22 +119,16 @@ def shutdown_session(exception=None): app.register_blueprint(categorias_bp) app.register_blueprint(contactos_bp) - @app.before_request def handle_options(): if request.method == "OPTIONS": resp = app.make_default_options_response() headers = resp.headers - headers["Access-Control-Allow-Origin"] = "*" headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS" - return resp -# MAIN -# Inicializas socketio con la app -socketio.init_app(app) - +# MAIN: Usamos app.run estándar (Flask puro) en lugar de socketio.run if __name__ == '__main__': - socketio.run(app, host='0.0.0.0', port=5000, debug=True) + app.run(host='0.0.0.0', port=5000, debug=True) \ No newline at end of file diff --git a/components/contactos/routes.py b/components/contactos/routes.py index 75cb040..fc01c19 100644 --- a/components/contactos/routes.py +++ b/components/contactos/routes.py @@ -2,7 +2,9 @@ from core.models import Notificacion, Publicacion, SolicitudContacto, db from auth.services import require_auth from datetime import datetime, timezone -from util import socketio + +# Eliminamos util y socketio +# from util import socketio contactos_bp = Blueprint("contactos", __name__) @@ -12,38 +14,38 @@ def crear_solicitud(): data = request.json usuario_actual = g.usuario_actual - # Validaciones básicas + # 1. Validaciones pub = Publicacion.query.get(data['id_publicacion']) if not pub: return jsonify({'error': 'Publicación no encontrada'}), 404 if pub.id_usuario == usuario_actual.id: return jsonify({'error': 'No puedes contactarte a ti mismo'}), 400 - # Verificar si ya existe solicitud pendiente + # Verificar duplicados pendientes existente = SolicitudContacto.query.filter_by( id_solicitante=usuario_actual.id, id_publicacion=pub.id ).first() + if existente and existente.estado == 0: return jsonify({'error': 'Ya tienes una solicitud pendiente'}), 400 - # Obtener tipo y mensaje + # Obtener datos tipo_contacto = data.get('tipo', 'whatsapp') mensaje_usuario = data.get('mensaje', '') - # --- CAMBIO: Obtener el dato de contacto del solicitante AHORA --- + # Determinar qué dato mostrar dato_contacto_mostrable = "No especificado" if tipo_contacto == 'whatsapp': - # Validar que tenga teléfono cargado if not usuario_actual.telefono_numero_local: - return jsonify({'error': 'No tienes un número de teléfono configurado en tu perfil'}), 400 + return jsonify({'error': 'No tienes un número de teléfono configurado'}), 400 dato_contacto_mostrable = f"{usuario_actual.telefono_pais or ''} {usuario_actual.telefono_numero_local}".strip() else: dato_contacto_mostrable = usuario_actual.email - # Creamos la solicitud + # 2. Crear la Solicitud nueva_solicitud = SolicitudContacto( id_solicitante=usuario_actual.id, id_receptor=pub.id_usuario, @@ -53,9 +55,10 @@ def crear_solicitud(): ) db.session.add(nueva_solicitud) - db.session.flush() + db.session.flush() # Flush para obtener el ID antes del commit final - # --- CAMBIO: Incluimos el contacto explícitamente en la descripción --- + # 3. Crear Notificación para el DUEÑO (Receptor) + # Esto es lo que el Polling del dueño va a detectar en 15 segundos descripcion_noti = ( f"{usuario_actual.nombre} quiere contactarte. " f"Dejó su {tipo_contacto.upper()}: {dato_contacto_mostrable}. " @@ -63,12 +66,12 @@ def crear_solicitud(): ) nueva_noti = Notificacion( - id_usuario=pub.id_usuario, + id_usuario=pub.id_usuario, # Para el dueño titulo="Nueva solicitud de contacto", - descripcion=descripcion_noti, # <--- Aquí va el dato visible + descripcion=descripcion_noti, tipo="solicitud_contacto", id_publicacion=pub.id, - id_referencia=nueva_solicitud.id, + id_referencia=nueva_solicitud.id, # Link a la solicitud para poder Aceptar/Rechazar fecha_creacion=datetime.now(timezone.utc) ) db.session.add(nueva_noti) @@ -84,39 +87,39 @@ def responder_solicitud(id_solicitud): data = request.json solicitud = SolicitudContacto.query.get_or_404(id_solicitud) + # Seguridad: solo el dueño puede responder if solicitud.id_receptor != g.usuario_actual.id: return jsonify({'error': 'No autorizado'}), 403 dato_contacto_solicitante_para_front = None if data['accion'] == 'aceptar': - solicitud.estado = 1 + solicitud.estado = 1 # Aceptado - # 1. Preparamos los datos de AMBOS + # --- Recopilar Datos de Contacto --- - # Datos del DUEÑO (Actual usuario) + # Datos del DUEÑO (Yo) tel_dueno = f"{g.usuario_actual.telefono_pais or ''} {g.usuario_actual.telefono_numero_local or ''}".strip() email_dueno = g.usuario_actual.email - # Datos del SOLICITANTE (El que mandó la solicitud) + # Datos del SOLICITANTE (El otro) tel_solicitante = f"{solicitud.solicitante.telefono_pais or ''} {solicitud.solicitante.telefono_numero_local or ''}".strip() email_solicitante = solicitud.solicitante.email nombre_solicitante = solicitud.solicitante.nombre - # Definimos qué dato mostrar según el tipo + # Lógica de intercambio (si eligió whatsapp, le doy whatsapp, etc) dato_para_solicitante = tel_dueno if solicitud.tipo_contacto == 'whatsapp' else email_dueno dato_para_dueno = tel_solicitante if solicitud.tipo_contacto == 'whatsapp' else email_solicitante - # Esto es lo que el frontend mostrará en el Toast inmediato + # Para el Toast inmediato del frontend dato_contacto_solicitante_para_front = dato_para_dueno - # --------------------------------------------------------- - # 2. CREACIÓN DE NOTIFICACIONES (GUARDAR EN BASE DE DATOS) - # --------------------------------------------------------- + # --- GENERAR NOTIFICACIONES PERSISTENTES --- - # A) Notificación para el SOLICITANTE (Le llega el dato del dueño) + # 1. Notificación para el SOLICITANTE (Feedback) + # El Polling del solicitante detectará esto. noti_para_solicitante = Notificacion( - id_usuario=solicitud.id_solicitante, # Destino: Solicitante + id_usuario=solicitud.id_solicitante, titulo="¡Solicitud Aceptada!", descripcion=f"{g.usuario_actual.nombre} aceptó tu solicitud. Contacto: {dato_para_solicitante}", tipo="contacto_aceptado", @@ -126,39 +129,27 @@ def responder_solicitud(id_solicitud): ) db.session.add(noti_para_solicitante) - # B) NUEVO: Notificación para el DUEÑO (Autonotificación para guardar el dato) - # Así, si cierra el toast, puede ir a 'Notificaciones' y ver el número de nuevo. + # 2. Notificación para el DUEÑO (Historial) + # Útil para que el dueño no pierda el número si cierra el popup noti_para_dueno = Notificacion( - id_usuario=g.usuario_actual.id, # Destino: Dueño (Yo mismo) + id_usuario=g.usuario_actual.id, titulo="Contacto realizado", descripcion=f"Aceptaste a {nombre_solicitante}. Su contacto es: {dato_para_dueno}", - tipo="info_contacto", # Tipo informativo, no requiere acción + tipo="info_contacto", id_publicacion=solicitud.id_publicacion, id_referencia=solicitud.id, fecha_creacion=datetime.now(timezone.utc), - leido=True # Podemos marcarla como leída porque la acaba de generar él mismo + leido=True # Nace leída porque él mismo hizo la acción ) db.session.add(noti_para_dueno) elif data['accion'] == 'rechazar': - solicitud.estado = 2 - # Opcional: Notificar rechazo al solicitante + solicitud.estado = 2 # Rechazado + # Opcional: Crear notificación de "Tu solicitud fue rechazada" para el solicitante db.session.commit() - # 3. SOCKETS (Opcional pero recomendado para que aparezca al instante) - # Avisar al solicitante que fue aceptado - if data['accion'] == 'aceptar': - try: - socketio.emit( - 'nueva_notificacion', - {"mensaje": "Tu solicitud fue aceptada"}, - room=solicitud.solicitante.firebase_uid, # Asegúrate de usar el ID correcto para el room - namespace='/notificacion' - ) - except Exception as e: - print(f"Error socket: {e}") - + # Eliminamos el bloque de socketio.emit return jsonify({ 'mensaje': 'Respuesta guardada', diff --git a/components/notificaciones/routes.py b/components/notificaciones/routes.py index 646994f..19d8d5c 100644 --- a/components/notificaciones/routes.py +++ b/components/notificaciones/routes.py @@ -1,4 +1,4 @@ -from flask import Blueprint, request, jsonify , g +from flask import Blueprint, request, jsonify, g from components.notificaciones.services import ( crear_notificacion, obtener_notificaciones_por_usuario, @@ -7,87 +7,47 @@ eliminar_notificacion ) from auth.services import require_auth -# -from util import socketio -from ..usuarios.routes import userconnected #importamos la libreria de usuarios conectados -from flask_socketio import join_room notificaciones_bp = Blueprint("notificaciones", __name__) @notificaciones_bp.route("/notificaciones", methods=["POST"]) +# @require_auth <-- Opcional: Descomentar si solo usuarios logueados pueden crear notificaciones def crear(): - """Crea una nueva notificación.""" + """Crea una nueva notificación (ej: cuando alguien comenta).""" data = request.get_json() + # Aquí podrías validar que data tenga 'id_usuario' (el receptor) return crear_notificacion(data) @notificaciones_bp.route("/notificaciones/usuario", methods=["GET"]) @require_auth def get_por_usuario(): - """Obtiene las notificaciones de un usuario autenticado.""" - usuario = g.usuario_actual.id + """ + El frontend llamará a esto periódicamente (Polling). + """ + usuario_id = g.usuario_actual.id solo_no_leidas = request.args.get("solo_no_leidas", "false").lower() == "true" - notis = obtener_notificaciones_por_usuario(usuario, solo_no_leidas) + + notis = obtener_notificaciones_por_usuario(usuario_id, solo_no_leidas) return jsonify(notis), 200 + @notificaciones_bp.route("/notificaciones", methods=["GET"]) def get_todo(): - """Obtiene todas las notificaciones.""" + """Admin: Obtiene todas.""" notis = obtener_todas() return jsonify(notis), 200 @notificaciones_bp.route("/notificaciones/leida/", methods=["PATCH"]) +@require_auth def marcar_leida(id_noti): - """Marca una notificación como leída por su ID.""" - return jsonify(marcar_notificacion_como_leida(id_noti)), 200 + """Marca una notificación como leída.""" + return jsonify(marcar_notificacion_como_leida(id_noti)) @notificaciones_bp.route("/notificaciones/", methods=["DELETE"]) +@require_auth def eliminar(id_noti): - """Elimina una notificación por su ID.""" - return jsonify(eliminar_notificacion(id_noti)), 200 - -#notificaciones en tiempo real -@notificaciones_bp.route("/notificacion", methods=["POST"]) -def crear_con_socket(): - """Crea una notificación y la envía en tiempo real por socket.""" - data = request.get_json() - # lógica para crear notificación --> - socketio.emit("nueva_notificacion", {"mensaje": "Nueva notificación"}) - return jsonify({"status": "ok"}), 200 - - - - -#datos de prueba para la part de notificaicones--------------------------------------------- -notification = { - "titulo": "lautaro stuve comento tu publicacion", - "descripcion": "comento en 'perro marron perdido", - "id_publicacion" : "76", # para redirigir al user a la publicacion - "id_notificacion": "12" #para marcarla como leida aunque en esta prueba, todavia no definida en front - } -mensaje = { - "titulo": "lautaro stuve comento tu publicacion", - "descripcion": "comento en 'perro marron perdido'" -} -uid= 'abve72UPGJZWfSfvx3KGBqd0UGf1' - - -@socketio.on('connect', namespace='/notificacion') -def on_connect(data): - uid = data.get("uid") - if uid: - join_room(uid) - - -@notificaciones_bp.route("/pruebanot", methods=["POST"]) -def crear_con_socket1(): - """Crea una notificación de prueba y la envía por socket.""" - data = request.get_json() - # lógica para crear notificación --> - #notificar(notification) - socketio.emit('notificacion',notification,room=uid,namespace='/notificacion') #una vez - return jsonify({"status": "ok"}), 200 - -#datos de prueba para la part de notificaciones--------------------------------- + """Elimina una notificación.""" + return jsonify(eliminar_notificacion(id_noti)) \ No newline at end of file diff --git a/components/notificaciones/services.py b/components/notificaciones/services.py index 26fd407..c3b8992 100644 --- a/components/notificaciones/services.py +++ b/components/notificaciones/services.py @@ -1,223 +1,81 @@ -from core.models import db, Notificacion,Publicacion -from datetime import datetime,timezone -#from routes import userconnected -from ..usuarios.routes import userconnected #importamos la libreria de usuarios conectados -from util import socketio +from core.models import db, Notificacion, Publicacion +from datetime import datetime, timezone from ..usuarios.services import get_usuario -from flask_socketio import join_room,leave_room,emit import pytz + zona_arg = pytz.timezone("America/Argentina/Buenos_Aires") -def crear_notificacion(data):#suponog que aca habria que agregar lo del id de la publicacion - """Crea una nueva notificación en la base de datos.""" - print("se ejecuto la funcion de notificaciones") +def crear_notificacion(data): + """ + Crea una nueva notificación en la base de datos. + Ya no emite sockets, solo guarda la info para que el usuario la consulte. + """ try: nueva = Notificacion( id_usuario=data['id_usuario'], titulo=data.get('titulo'), descripcion=data.get('descripcion'), - tipo=data.get('tipo'), + tipo=data.get('tipo'), # Ej: 'comentario', 'solicitud', 'sistema' fecha_creacion=datetime.now(timezone.utc), leido=False, - id_publicacion=data.get('id_publicacion') + id_publicacion=data.get('id_publicacion'), + id_referencia=data.get('id_referencia') # Importante para solicitudes ) db.session.add(nueva) db.session.commit() - print("pre notificcacioin") - notificar(nueva) - print("debio ejecutrase la funcion de notificar con los sockets") return {"mensaje": "Notificación creada", "id": nueva.id}, 201 except Exception as error: db.session.rollback() + print(f"Error al crear notificación: {error}") return {"error": str(error)}, 400 def obtener_notificaciones_por_usuario(id_usuario, solo_no_leidas=False): + """ + Esta es la función que llamará el Frontend cada X segundos. + """ query = Notificacion.query.filter_by(id_usuario=id_usuario) if solo_no_leidas: query = query.filter_by(leido=False) + # Ordenamos por las más recientes primero notificaciones = query.order_by(Notificacion.fecha_creacion.desc()).limit(50).all() - # Opción A: Usar el método to_dict() del modelo (Recomendado) return [n.to_dict() for n in notificaciones] def obtener_todas(): - """Obtiene todas las notificaciones de la base de datos.""" query = Notificacion.query.order_by(Notificacion.fecha_creacion.desc()).all() - return [noti_to_dict(n) for n in query] + # Reutilizamos el to_dict del modelo para ser consistentes + return [n.to_dict() for n in query] def marcar_notificacion_como_leida(id_noti): - """Marca una notificación como leída por su ID.""" noti = Notificacion.query.get(id_noti) if not noti: return {"error": "No encontrada"}, 404 + noti.leido = True db.session.commit() - return {"mensaje": "Notificación marcada como leída"} - + return {"mensaje": "Notificación marcada como leída", "id": id_noti} def eliminar_notificacion(id_noti): - """Elimina una notificación de la base de datos por su ID.""" noti = Notificacion.query.get(id_noti) if not noti: return {"error": "No encontrada"}, 404 + db.session.delete(noti) db.session.commit() return {"mensaje": "Notificación eliminada"} - -def noti_to_dict(notificacion): - """Convierte una notificación a un diccionario serializable.""" - ahora = datetime.now(timezone.utc) - delta = ahora - notificacion.fecha_creacion - - if delta.days > 0: - tiempo_pasado = f"hace {delta.days} día(s)" - elif delta.seconds >= 3600: - tiempo_pasado = f"hace {delta.seconds // 3600} hora(s)" - elif delta.seconds >= 60: - tiempo_pasado = f"hace {delta.seconds // 60} minuto(s)" - else: - tiempo_pasado = "hace unos segundos" - - return { - "id": notificacion.id, - "id_usuario": notificacion.id_usuario, - "id_publicacion":notificacion.id_publicacion, - "titulo": notificacion.titulo, - "descripcion": notificacion.descripcion, - "tipo": notificacion.tipo, - "fecha_creacion": notificacion.fecha_creacion.isoformat(), - "tiempo_pasado": tiempo_pasado, - "leido": notificacion.leido - } - -#funciones para las notficaiones de los sockets -#aqui voy a definir dos eventos que creo que son los unicos asique vamos a verlos despues - -#en caso de que ocurra el evento de que alguien comenta tu publicacion entonces notifica inmediato -#iria de la mano con la funcion de crear notificacion asique la dejare aqui notado -def notificar(newnotificacion): - """Envía una notificación en tiempo real al usuario correspondiente usando sockets.""" - id_owner = obtener_user_por_idpublicacion(newnotificacion.id_publicacion) - user = get_usuario(id_owner) - uid_user= user["firebase_uid"] - if uid_user in userconnected: - print("entro a la bifurcacion") - notification = { - "titulo": newnotificacion.titulo, - "descripcion": newnotificacion.descripcion, - "id_publicacion" :newnotificacion.id_publicacion, # para redirigir al user a la publicacion - "id_notificacion": newnotificacion.id #para marcarla como leida - } - socketio.emit('notificacion',notification,room=uid_user,namespace='/notificacion') - -#en caso de que te conectes entonces le pides al back todas tus notificaciones: -#esta la tendria que importar en la parte que cree para registrar a los user en la carpeta de users -#voy a reutilizar las funciones que ya estand definidas -def notificarconectado(iduser,uid_user): - """Envía todas las notificaciones pendientes a un usuario conectado.""" - notificaciones_pendientes = obtener_notificaciones_por_usuario(iduser) - if notificaciones_pendientes and uid_user in userconnected: - for notification in notificaciones_pendientes: - socketio.emit('notificacion',notification,room=uid_user,namespace='/notificacion') -#esta va a enviar todas las notificaciones pendientes que tiene la cosa incluso -#podemos enviarlas en orden soo modificando el query +# --- UTILIDADES --- def obtener_user_por_idpublicacion(publicacion_id): - """Obtiene el ID de usuario dueño de una publicación dado el ID de la publicación.""" publicacion = Publicacion.query.get(publicacion_id) if publicacion: return publicacion.id_usuario - -###------------------------FUNCIONAMIENTO DEL CHAT------------------------################# -USER_ROOMS={} -CHAT_ROOMS={} -#iniciar conversaciones entre users -def iniciar_conversacion(uid,roomID): - CHAT_ROOMS[roomID]={ - 'users':[uid], - 'mensajes':[], - 'last_m_user1':0, - 'last_m_user2':0 - } - if uid not in USER_ROOMS: - USER_ROOMS[uid]=[] - USER_ROOMS[uid].append(roomID) - if roomID not in USER_ROOMS[uid]: - USER_ROOMS[uid].append(roomID) - -#guardar los mensajes que se envien -def guardar_mensaje(roomID,uid_sender,mensaje): - if roomID in CHAT_ROOMS: - CHAT_ROOMS[roomID]['mensajes'].append({ - uid_sender:mensaje - }) - -def solicitud_mensaje(uid): - socketio.emit('solicitud','alguien te envio un mensaje privado',namespace='/solicitud_CHAT/'+uid) - -#decorardores de evnetos para los sockets -#socket que escucha cuando uno se une a un chat y lo guarda -@socketio.on('join_chat') -def handle_join_chat(data): - room = data['room'] - uid_user = data.get('user') - user_notificar = data.get ('usuario2') - join_room(room) # Une este socket al room - iniciar_conversacion(uid_user,room) - solicitud_mensaje(user_notificar) # aqui le notificamos al otro que se le envio un mensaje - emit('status', f'{uid_user} se unió al chat {room}', room=room) -# aqui el user que quiere la conversacion crea un room con su uID cosa que despues el otro user se conecte - -@socketio.on('send_message') -def handle_send_message(data): - user_uid= data.get('uid_user') - room = data.get('room') - message = data.get('menssage') - guardar_mensaje(room,user_uid,message) - emit('message', message, to=room) - -@socketio.on('leave_chat') -def handle_leave_chat(data): - room = data['room'] - user = data.get('user') - leave_room(room) - emit('status', f'{user} salió del chat {room}', room=room) - -#para unirse desde el front se usa: - -#socket.emit('join_chat', { room: 'uid', user1: 'usuario1',user_not:'usuario2' }); se puede usar los uid de cada user para generar los rooms -#lo que vamos a enviar sera el uid del que quiere enviar mensaje y el uid o cualquier dato que sirva para identificar a quien le enviamos el mensaje - -#para enviar un mensaje desde el front usar: -#Para emitir otro evento, por ejemplo 'send_message' con datos -#socket.emit('send_message', { room: 'chat123', message: 'Hola, ¿cómo estás?',uid_user= '1234567asdasd' }); -def enviar_mensajes_pendientes(uid): - """Envía todos los mensajes pendientes guardados en memory a un usuario conectado.""" - - # Verificas que el usuario esté conectado y tienes su sid - if uid in userconnected: - sid = userconnected[uid]['sid'] - - # Buscar todos los rooms del usuario - rooms_usuario = USER_ROOMS.get(uid, []) - - # Por cada room obtener mensajes pendientes - for room in rooms_usuario: - if room in CHAT_ROOMS: - mensajes = CHAT_ROOMS[room]['mensajes'] - for mensaje in mensajes: - socketio.emit('mensaje', mensaje, room=sid) - -#notas de desarrollo: -#para la parte del evento de recibir todo apenas se conecte debe funcionar agregando las fucniones cuando apenas se conecte el user: -# sera la primera version del chat con respecto al back -###------------------------FUNCIONAMIENTO DEL CHAT------------------------################# \ No newline at end of file + return None \ No newline at end of file diff --git a/components/usuarios/routes.py b/components/usuarios/routes.py index 423263f..089c08b 100644 --- a/components/usuarios/routes.py +++ b/components/usuarios/routes.py @@ -1,5 +1,5 @@ from flask import Blueprint, request, jsonify -from core.models import db, Usuario, Publicacion,Notificacion +from core.models import db, Usuario, Publicacion, Notificacion from components.usuarios.services import ( actualizar_datos_usuario, get_usuario, @@ -9,32 +9,25 @@ ) from firebase_admin import auth from core.auth_middleware import require_auth -from flask_socketio import SocketIO, disconnect -from util import socketio -from firebase_admin import auth import psycopg2 import os - - auth_bp = Blueprint('auth_bp', __name__) - usuarios_bp = Blueprint('usuarios', __name__) - -#Endpoint para actualizar información del usuario +# Endpoint para actualizar información del usuario @usuarios_bp.route('/usuario/', methods=['PATCH']) def actualizar_usuario(id_usuario): '''Actualiza la información de un usuario existente.''' data = request.get_json() try: - actualizar_datos_usuario(id_usuario,data) + actualizar_datos_usuario(id_usuario, data) return jsonify({'mensaje': 'Usuario actualizado con éxito'}), 200 except Exception as error: return jsonify({'error': str(error)}), 400 -#Endpoint para obtener usuarios por su id -@usuarios_bp.route('/usuario/', methods = ['GET']) +# Endpoint para obtener usuarios por su id +@usuarios_bp.route('/usuario/', methods=['GET']) def obtener_usuario_por_id(id_usuario): '''Obtiene la información de un usuario por su ID.''' usuario = get_usuario(id_usuario) @@ -49,7 +42,6 @@ def obtener_usuario_por_slug_route(slug): return jsonify({'error': 'Usuario no encontrado'}), 404 return jsonify(usuario), 200 - @usuarios_bp.route('/usuario/', methods=['DELETE']) def eliminar_usuario(id_usuario): '''Elimina un usuario por su ID.''' @@ -63,8 +55,7 @@ def eliminar_usuario(id_usuario): return jsonify({'mensaje': f'Usuario {usuario.nombre} eliminado correctamente'}), 200 - -#endpoint para obtener los datos de un usuario por el uid +# Endpoint para obtener los datos de un usuario por el uid # (usando en la interface de configuraciones de perfil) @usuarios_bp.route('/api/userconfig', methods=['GET']) def user_config(): @@ -95,8 +86,7 @@ def user_config(): except Exception as e: return jsonify({'error': 'Token inválido o expirado', 'detalle': str(e)}), 401 -#Endpoints para el panel administrativo: - +# Endpoints para el panel administrativo: @usuarios_bp.route("/api/usuarios", methods=["GET"]) def get_usuarios(): ''' Obtiene una lista paginada de usuarios, con opción de búsqueda por nombre o email.''' @@ -123,78 +113,9 @@ def get_usuarios(): "pages": pagination.pages }) - - -#diccionario de usuarios conectados -userconnected = {} -sid_uid_map={} -#funcion para autenticar a los usuarios desde el socket -@socketio.on('connect', namespace='/connection') -def on_connect(auth_data): - print("NAMESPACE:", request.namespace) - print("clave solucion funcional") - - '''Autentica al usuario que se conecta al socket usando el token de Firebase.''' - token = auth_data.get('token') if auth_data else None - if not token: - disconnect() - return - - try: - decoded_token = auth.verify_id_token(token) - uid = decoded_token.get('uid') - name= decoded_token.get('name') - sid = request.sid #<-- identificador unico de inicio de sesion del socket - # para cada conexion de cada user - print(uid,name,sid) - if not uid: - disconnect() - return - usuario_conectado(uid,name,sid) - from components.notificaciones.services import notificarconectado - id_user= obtener_usuario_por_uid(uid).id - #notificarconectado(id_user,uid) <---- solo falta descomentar esto y ya funcionaria o bueno probarlo mas bien - except Exception: - disconnect() - -#marcar como desconectado a los usuarios que se desconectan -@socketio.on('disconnect', namespace='/connection') -def on_disconnect(): - '''Marca al usuario como desconectado cuando se desconecta del socket.''' - sid = request.sid - print("clave") - # uid = sid_uid_map.get(sid) - if sid not in sid_uid_map: - print("ingorno disconection") - return - uid= sid_uid_map.pop(sid) - if uid is None: - print("Desconexión ignorada (SID no registrado)", sid) - return - # eliminar el SID - #sid_uid_map.pop(sid, None) - if userconnected.get(uid, {}).get("sid") == sid: - usuario_desconectado(uid) - -#agrego a cada user con su uid,name y sid a un diccionario de user connected -def usuario_conectado(uid,name,sid): - '''Agrega un usuario al diccionario de usuarios conectados.''' - userconnected[uid]= { - "sid": sid, - "name": name - } - print('usuarios conectados:',userconnected) - sid_uid_map[sid]=uid - - -def usuario_desconectado(uid): - '''Elimina un usuario del diccionario de usuarios conectados.''' - #userconnected.pop(uid,None) - print('usuarios conectados(FD):',userconnected) +# --- SECCIÓN DE SOCKETS ELIMINADA (connect, disconnect, userconnected) --- # Endpoint para obtener publicaciones de un usuario por su id -#idUsuario : es el id traido desde el endpoint -#id_usuario : es un atributo de cada publicacion, se ven parecidos pero la diferencia esta en el guión bajo @usuarios_bp.route('/usuarios//publicaciones', methods=['GET']) def obtener_publicaciones_usuario(idUsuario): '''Obtiene todas las publicaciones de un usuario específico por su ID.''' @@ -209,7 +130,6 @@ def obtener_publicaciones_usuario(idUsuario): except Exception as error: return jsonify({'error': str(error)}), 400 - @usuarios_bp.route('/usuarios//publicaciones/filtrado', methods=['GET']) def obtener_publicaciones_usuario_filtrado(idUsuario): '''Obtiene todas las publicaciones de un usuario específico por su ID, no trae las archivadas.''' @@ -225,15 +145,12 @@ def obtener_publicaciones_usuario_filtrado(idUsuario): except Exception as error: return jsonify({'error': str(error)}), 400 - - @usuarios_bp.get("/usuario/is_admin") @require_auth def is_admin(): decoded = request.user return jsonify({"admin": decoded.get("admin", False)}) - @usuarios_bp.get("/init_claims") def init_claims(): try: @@ -287,4 +204,4 @@ def listar_admins(): }) page = page.get_next_page() - return jsonify(admins), 200 + return jsonify(admins), 200 \ No newline at end of file diff --git a/util.py b/util.py deleted file mode 100644 index 442b6bf..0000000 --- a/util.py +++ /dev/null @@ -1,5 +0,0 @@ -# extensions.py -from flask_socketio import SocketIO - -socketio = SocketIO(cors_allowed_origins="*", async_mode="eventlet",path="/socket.io") -#socketio = SocketIO(cors_allowed_origins="*", logger=True, engineio_logger=True) \ No newline at end of file From 06f13be1aa2cd4145e9aa55ae55535cbb9c150e7 Mon Sep 17 00:00:00 2001 From: lautarostuve Date: Thu, 4 Dec 2025 12:18:03 -0300 Subject: [PATCH 5/7] arreglo de bug para eliminar publicaciones que tienen solicitudes de contacto pendientes --- __pycache__/app.cpython-311.pyc | Bin 7853 -> 7668 bytes components/publicaciones/services.py | 8 ++++++++ core/models.py | 3 ++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/__pycache__/app.cpython-311.pyc b/__pycache__/app.cpython-311.pyc index bd4bd87baf2f6d65b48f264a302246cddff9a5b5..e918aa6aaf60c4061a5210547d700287b964851e 100644 GIT binary patch delta 2101 zcmah~OH3O_7@k?L*9K!7+Y7d_d740YI=ms+0m3untu%+u#X^}8U}kJ&gA(O}si=a~ z9vbbTs+3eHl}IC~RLO^`hn{+<)Lv*6RftPD_0m={Qq@CMRh=F1t2pi0v-{2e{r^AT z{4<*UEd2YZ?WNUP4tQ&M-n0C<>#5DNF<1t`hX4Z%DL_C11QLLyT?v3a-cMW7L?i+t zhU{2)%SaY)BQo;0fhx>|Ow5eRpq@>p<;;RCtO8XqE3yikk=mFY*#%Z;C37GL+|Zp( z)X7}PB?vQBnTk|_%V-s=M%4nBQ#bP<5A!0g7+Yu!^C6$W71YmaQLVsMTF2^9y}&lw zz#37bz;@ci0w^GGC2eLG&;=cDU7pADb^fxUE)4WS`{Yv?c=K_deD=w&vFRz}4DQUxiz z_@leL#(UX11E_C^g@kq?xAK14P#F8S0J=iL(Xp%zU&pmuus9lx6FEAO_1L+pO-QbK zfa{90o%hl1NlxpzNbOz8;pB3p5&>UG4()Hr4kz^~8Ub#?f#MT%K86cv{$C;Y4W)!U zDQW$(r1h7Q)>^q#-rKTc19v>LpeepgQ*=68IWko&6wPp6iO%NunPOh-njD?W^0>2@ z=lgao$907`#NB7M4PnPYVZ4NUa4+t|{dnM+u_$Oz=eY<^bUvGigo`H;BC1K4c#3xd zUFUIy;)d{W-b2ifZg3X|3F8rb84oYRWr(}+C=M-3ivWjT!2K(`Mt&Z7KW*pMJ60^E zNcz~B^g;>aIe+aZqv9?s&v@Ql`^)Gp;wB2XPQ_bQ7F2L~u{80h^x&Z%{$38%UcyfO#UvFJZUJKXR^G2OY=cK-guHGRetA2RHYnt88~<-XMwh*g&3wJ5QxDLE|7i}fI9{jZO5$Ta)K`?e zr2vpIKnM?kJtcm}+N9?zC8Ge?e4s(En)Z0dVXGx&*|ct1Q`Td}k|Nu4oAIr9%8-#w za4-XM4`=#mjRV3u?Gs-sRJ8AW^RQB@_7B2i?WR8ro!Wc;C8M5NPXFPbhxd=6{2ID4 zk_oDM7WKpcBsFJIfn2fu_y>dcz~DWUO;2Xunf<`Kqkj5UYW91%_qE)6ko{*2K(66l HVy*uGtb3&m delta 2308 zcmaKtOH3O_7{_OJ7e6pI*z30;ypj+a2oPQ&*nr84kkC9RJ

QGgH9K*wNbbb-_qf zz%TmK(Zx~UJy+11vjgxszyL!E5Rd=?NvJ<_`&VGmC?Sa(Wg=l| zLjvG2e~MSeN)#d`YD#Sp!h$T~YeiQ6m8p%{k)72dhlr-oI_5-9=0Yx3kLrc3hBh!a zatpRl4^xpUc*RP+%!hoUVWWQLM}EO}8el;b6kJO~ER4b|f+C{rppC2vH3_bxM_4mz z7VM-gtQEBicF{K0j@kv+)1&MdIwrV*9%mh>L$I5kSYe&0Q#3sEHFgr66s*!N){VLa zd+90GgL(w}XfNwSeS-b;H0wwGf&+Ab4WdE8K{~{S(XikUJ;O%Oh~O|i%SO?t;0TSf z7>aRTF;>KeY`lz(bc~IoacHbgY=hVPajE7&*qmfy19B^WiWiFGrZs>jNj!P3WW!hS zku_-Ujm{HG@mOCEO-i-6*OY2=;)`(7Tw2_9-+`w1H0{aD zrE-xevj$Cbzb$#C?4LIK#iCh~S4)1}ZT9nJn<@HM0KliBoN>>-k+*t6><^dji2sg(!_^I%?d@>xG?}6=VN`2yG>LLYWd?SSextlt~D?7Z@Pnzdy z(oa^=pZg!Z(-{1m(SgJt+z`9`GmBR)@v{nkQNgNpUlXg~`3k;Q!A~prw|x!((qm2x zmh*@J(udD)UNEoBzF(iWm2=>6244|p7+-uvoEQ6XRsb1294C3RSae>gOKaf30W-^Y z%J{1`A(_BaOX5F8^9$`8rD%MAUB=URaP!KB!gmvYJVh5j9O0~ZI%o24*;ip*C)~AV z(zlmLb}^m*wf19J)5)b>`D|SP4j9MORl!#}_zej5J$bc62S=N?}S8>0(GN$$dG!RqcU$`VF0|IwSqK)bTGoO%SbZo_HJ<6m2prSUQ)W7gLsKwz)z(Vql*E Date: Thu, 4 Dec 2025 13:24:06 -0300 Subject: [PATCH 6/7] cambio pa optimizar busqueda de publicaciones --- components/publicaciones/routes.py | 111 ++---- components/publicaciones/services.py | 515 ++++++++++++--------------- 2 files changed, 251 insertions(+), 375 deletions(-) diff --git a/components/publicaciones/routes.py b/components/publicaciones/routes.py index 213ddcc..dcffe86 100644 --- a/components/publicaciones/routes.py +++ b/components/publicaciones/routes.py @@ -5,16 +5,15 @@ desarchivar_publicacion, obtener_publicacion_por_id, obtener_publicaciones_filtradas, - obtener_publicaciones_por_usuario, obtener_todas_publicaciones, crear_publicacion, actualizar_publicacion, eliminar_publicacion, normalizar_texto, - obtener_info_principal_publicacion, - obtener_mis_publicaciones + obtener_mis_publicaciones, + subir_imagen_a_cloudinary, + obtener_publicaciones_para_mapa # IMPORTANTE: Nueva función importada ) -from components.publicaciones.services import subir_imagen_a_cloudinary publicaciones_bp = Blueprint("publicaciones", __name__) @@ -22,7 +21,7 @@ @publicaciones_bp.route("/publicaciones", methods=["POST"]) @require_auth def crear(): - """Crea una nueva publicación con los datos recibidos y el usuario autenticado.""" + """Crea una nueva publicación.""" data = request.get_json() data['id_usuario'] = g.usuario_actual.id return crear_publicacion(data, g.usuario_actual) @@ -30,29 +29,24 @@ def crear(): # Obtener una publicación por ID @publicaciones_bp.route('/publicaciones/', methods=['GET']) def get_publicacion(id_publicacion): - """Obtiene una publicación por su ID.""" publicacion = obtener_publicacion_por_id(id_publicacion) if 'error' in publicacion: return jsonify(publicacion), 404 - return jsonify(publicacion), 200 - # Obtener todas las publicaciones para el home @publicaciones_bp.route('/publicaciones', methods=['GET']) def get_publicaciones(): - """Obtiene todas las publicaciones para el home.""" page = int(request.args.get("page", 0)) limit = int(request.args.get("limit", 12)) offset = page * limit publicacion = obtener_todas_publicaciones(offset=offset, limit=limit) return jsonify(publicacion), 200 - -# FILTRAR (CORREGIDO) +# FILTRAR @publicaciones_bp.route('/publicaciones/filtrar', methods=['GET']) def get_publicaciones_filtradas(): - """Obtiene publicaciones filtradas por ubicación, categoría, etiquetas, fechas o usuario.""" + """Endpoint optimizado para filtros.""" try: lat = request.args.get('lat') lon = request.args.get('lon') @@ -62,9 +56,7 @@ def get_publicaciones_filtradas(): lon = float(lon) if lon else None radio = float(radio) if radio else None - # --- CAMBIO: Ahora recibimos id_categoria --- id_categoria = request.args.get('id_categoria') - etiquetas = request.args.get('etiquetas') fecha_min = request.args.get('fecha_min') fecha_max = request.args.get('fecha_max') @@ -77,13 +69,14 @@ def get_publicaciones_filtradas(): etiquetas_lista = [] if etiquetas: etiquetas_raw = etiquetas.lower().split(",") - etiquetas_lista = [normalizar_texto(e) for e in etiquetas_raw] + # Normalizamos aquí para pasarlo limpio al service + etiquetas_lista = etiquetas_raw # El service ya hace la limpieza interna publicaciones = obtener_publicaciones_filtradas( lat=lat, lon=lon, radio_km=radio, - id_categoria=id_categoria, # Pasamos el ID + id_categoria=id_categoria, etiquetas=etiquetas_lista, fecha_min=fecha_min, fecha_max=fecha_max, @@ -95,14 +88,13 @@ def get_publicaciones_filtradas(): return jsonify(publicaciones), 200 except Exception as error: - print(error) # Log para ver errores en consola backend + print(f"Error filtrar: {error}") return jsonify({'error': str(error)}), 400 # PATCH @publicaciones_bp.route('/publicaciones/', methods=['PATCH']) def actualizar(id_publicacion): - """Actualiza una publicación existente por su ID.""" data = request.get_json() try: actualizar_publicacion(id_publicacion, data) @@ -113,7 +105,6 @@ def actualizar(id_publicacion): # DELETE @publicaciones_bp.route('/publicaciones/', methods=['DELETE']) def borrar_publicacion(id_publicacion): - """Elimina una publicación por su ID.""" try: eliminar_publicacion(id_publicacion) return jsonify({'mensaje': 'Publicación eliminada correctamente'}), 200 @@ -137,76 +128,31 @@ def subir_imagenes(): return jsonify({"urls": urls}), 200 - -# Obtener publicaciones por usuario +# Obtener mis publicaciones @publicaciones_bp.route("/publicaciones/mis-publicaciones", methods=["GET"]) @require_auth def publicaciones_usuario_actual(): - """Obtiene todas las publicaciones del usuario autenticado.""" usuario = g.usuario_actual publicaciones = obtener_mis_publicaciones(usuario.id) return jsonify(publicaciones), 200 - -# Mapa interactivo +# Mapa interactivo (OPTIMIZADO) @publicaciones_bp.route('/publicaciones/mapa', methods=['GET']) def get_publicaciones_mapa(): try: - # 1. Obtener parámetros - lat = request.args.get('lat') - lon = request.args.get('lon') - radio = request.args.get('radio') - lat = float(lat) if lat else None - lon = float(lon) if lon else None - radio = float(radio) if radio else None - - id_categoria = request.args.get('id_categoria') - etiquetas = request.args.get('etiquetas') - fecha_min = request.args.get('fecha_min') - fecha_max = request.args.get('fecha_max') - id_usuario = request.args.get('id_usuario') - - etiquetas_lista = [] - if etiquetas: - etiquetas_raw = etiquetas.lower().split(",") - etiquetas_lista = [normalizar_texto(e) for e in etiquetas_raw] - - # 2. Pedir 500 resultados (o más) - publicaciones = obtener_publicaciones_filtradas( - lat=lat, - lon=lon, - radio_km=radio, - id_categoria=id_categoria, - etiquetas=etiquetas_lista, - fecha_min=fecha_min, - fecha_max=fecha_max, - id_usuario=id_usuario, - offset=0, - limit=50 # Límite alto para el mapa - ) - - publicaciones_mapa = [] - - # 3. BUCLE OPTIMIZADO (Sin consultas extra a la DB) - for pub in publicaciones: - # 'pub' ya es un diccionario con toda la info gracias a 'services.py' - - # Validación rápida de coordenadas - coords = pub.get("coordenadas") - if coords and isinstance(coords, list) and len(coords) == 2: - - # Extraer imagen (ya viene en la lista 'imagenes' del diccionario) - lista_imgs = pub.get("imagenes", []) - img_principal = lista_imgs[0] if lista_imgs else None - - publicaciones_mapa.append({ - "id": pub["id"], - "titulo": pub["titulo"], - "descripcion": pub.get("descripcion", ""), - "categoria": pub["categoria"], # Ya es el objeto {id, nombre} - "coordenadas": coords, - "imagen_principal": img_principal - }) + # Preparamos filtros en un diccionario limpio + filtros = { + 'lat': float(request.args.get('lat')) if request.args.get('lat') else None, + 'lon': float(request.args.get('lon')) if request.args.get('lon') else None, + 'radio': float(request.args.get('radio')) if request.args.get('radio') else None, + 'id_categoria': request.args.get('id_categoria'), + 'id_usuario': request.args.get('id_usuario'), + # Si necesitas filtrar por fechas en el mapa también, agrégalos aquí + } + + # Llamamos a la nueva función optimizada del servicio + # Ya no hay bucle for ni lógica pesada aquí. + publicaciones_mapa = obtener_publicaciones_para_mapa(filtros) return jsonify(publicaciones_mapa), 200 @@ -214,19 +160,16 @@ def get_publicaciones_mapa(): print(f"Error Mapa: {error}") return jsonify({'error': str(error)}), 400 - @publicaciones_bp.route('/publicaciones//archivar', methods=['PATCH']) def archivar(id_publicacion): try: - archivar_publicacion(id_publicacion) - return jsonify({"mensaje": "Publicación archivada"}), 200 + return archivar_publicacion(id_publicacion) except Exception as error: return jsonify({'error': str(error)}), 400 @publicaciones_bp.route('/publicaciones//desarchivar', methods=['PATCH']) def desarchivar(id_publicacion): try: - desarchivar_publicacion(id_publicacion) - return jsonify({"mensaje": "Publicación desarchivada"}), 200 + return desarchivar_publicacion(id_publicacion) except Exception as error: return jsonify({'error': str(error)}), 400 \ No newline at end of file diff --git a/components/publicaciones/services.py b/components/publicaciones/services.py index 9cf2274..394f935 100644 --- a/components/publicaciones/services.py +++ b/components/publicaciones/services.py @@ -2,10 +2,10 @@ from flask import jsonify from components.comentarios.services import eliminar_comentario from components.imagenes.services import eliminar_imagen -from core.models import Comentario, db, Publicacion, Imagen, Etiqueta, PublicacionEtiqueta, Categoria,Usuario,Notificacion +from core.models import Comentario, db, Publicacion, Imagen, Etiqueta, Usuario, Notificacion from datetime import datetime, timezone -from math import radians, sin, cos, sqrt, atan2 -from sqlalchemy import text, func +# Nuevos imports necesarios para la optimización SQL +from sqlalchemy import func, cast, Float, desc from sqlalchemy.orm import joinedload import unicodedata import requests @@ -17,9 +17,189 @@ import pytz zona_arg = pytz.timezone("America/Argentina/Buenos_Aires") +# --- NUEVO HELPER DE SERIALIZACIÓN --- +def serializar_publicacion_lista(pub): + """Convierte una publicación a dict optimizado para listas (Home/Filtros).""" + # Solo tomamos la primera imagen para la vista de lista + img_principal = pub.imagenes[0].url if pub.imagenes else None + + cat_obj = None + if pub.categoria_obj: + cat_obj = {"id": pub.categoria_obj.id, "nombre": pub.categoria_obj.nombre} + + return { + "id": pub.id, + "titulo": pub.titulo, + "localidad": pub.localidad.nombre if pub.localidad else None, + "categoria": cat_obj, + "imagenes": [img_principal] if img_principal else [], # Mantenemos formato lista + "imagen_principal": img_principal, # Extra útil + "etiquetas": [et.nombre for et in pub.etiquetas], + "fecha_creacion": pub.fecha_creacion.astimezone(zona_arg).isoformat() if pub.fecha_creacion else None, + "coordenadas": pub.coordenadas, + # No enviamos descripción completa para ahorrar datos en listas + } + +# --- FUNCIONES OPTIMIZADAS (LECTURA) --- + +def obtener_publicaciones_filtradas( + lat=None, lon=None, radio_km=None, + id_categoria=None, etiquetas=None, + fecha_min=None, fecha_max=None, + id_usuario=None, offset=0, limit=12 + ): + """Obtiene publicaciones filtradas aplicando lógica en Base de Datos.""" + try: + query = db.session.query(Publicacion).filter( + (Publicacion.estado == 0) | (Publicacion.estado.is_(None)) + ) + + # 1. Filtros Básicos + if id_categoria: + query = query.filter(Publicacion.id_categoria == id_categoria) + if id_usuario: + query = query.filter(Publicacion.id_usuario == id_usuario) + if fecha_min: + dt = datetime.strptime(fecha_min, '%Y-%m-%d') + query = query.filter(Publicacion.fecha_creacion >= dt) + if fecha_max: + dt = datetime.strptime(fecha_max, '%Y-%m-%d') + query = query.filter(Publicacion.fecha_creacion <= dt) + + # 2. Filtro Etiquetas (Optimizado con JOIN) + if etiquetas: + etiquetas_norm = [normalizar_texto(e) for e in etiquetas if e.strip()] + if etiquetas_norm: + query = query.join(Publicacion.etiquetas).filter( + func.lower(Etiqueta.nombre).in_(etiquetas_norm) + ) + + # 3. Filtro Geoespacial (SQL Haversine) + if lat is not None and lon is not None and radio_km is not None: + # Asumiendo coordenadas como array/json [lat, lng] + pub_lat = cast(Publicacion.coordenadas[0], Float) + pub_lon = cast(Publicacion.coordenadas[1], Float) + + # Fórmula: 6371 * acos(...) + distancia = 6371 * func.acos( + func.least(1.0, func.greatest(-1.0, + func.cos(func.radians(lat)) * + func.cos(func.radians(pub_lat)) * + func.cos(func.radians(pub_lon) - func.radians(lon)) + + func.sin(func.radians(lat)) * + func.sin(func.radians(pub_lat)) + )) + ) + query = query.filter(distancia <= radio_km) + + # 4. Carga de relaciones (Eager Loading) + query = query.options( + joinedload(Publicacion.imagenes), + joinedload(Publicacion.localidad), + joinedload(Publicacion.categoria_obj), + joinedload(Publicacion.etiquetas) + ) + + # 5. Orden y Paginación (Al final para que sea correcto) + query = query.order_by(Publicacion.fecha_creacion.desc()) + query = query.offset(offset).limit(limit) + + publicaciones = query.all() + + return [serializar_publicacion_lista(pub) for pub in publicaciones] + + except Exception as e: + print(f"Error filtro: {e}") + return [] + +def obtener_todas_publicaciones(offset=0, limit=12): + """Obtiene todas las publicaciones para el home (Optimizado).""" + try: + publicaciones = ( + db.session.query(Publicacion) + .options( + joinedload(Publicacion.imagenes), + joinedload(Publicacion.localidad), + joinedload(Publicacion.categoria_obj), + joinedload(Publicacion.etiquetas) + ) + .filter((Publicacion.estado == 0) | (Publicacion.estado.is_(None))) + .order_by(Publicacion.fecha_creacion.desc()) + .offset(offset) + .limit(limit) + .all() + ) + return [serializar_publicacion_lista(pub) for pub in publicaciones] + finally: + # En Flask-SQLAlchemy la sesión suele manejarse sola, + # pero si prefieres cerrar explícitamente: + pass + +def obtener_publicaciones_para_mapa(filtros): + """ + Query ultra-rápida para el mapa. + Solo devuelve lo estrictamente necesario. + """ + try: + # Iniciamos query base + query = db.session.query(Publicacion).filter( + (Publicacion.estado == 0) | (Publicacion.estado.is_(None)) + ) + + # Aplicamos mismos filtros que en el listado + if filtros.get('id_categoria'): + query = query.filter(Publicacion.id_categoria == filtros['id_categoria']) + if filtros.get('id_usuario'): + query = query.filter(Publicacion.id_usuario == filtros['id_usuario']) + # ... fechas y etiquetas si fueran necesarias ... + + # Filtro Distancia SQL + lat = filtros.get('lat') + lon = filtros.get('lon') + radio = filtros.get('radio') + + if lat and lon and radio: + pub_lat = cast(Publicacion.coordenadas[0], Float) + pub_lon = cast(Publicacion.coordenadas[1], Float) + distancia = 6371 * func.acos(func.least(1.0, func.greatest(-1.0, + func.cos(func.radians(lat)) * func.cos(func.radians(pub_lat)) * + func.cos(func.radians(pub_lon) - func.radians(lon)) + + func.sin(func.radians(lat)) * func.sin(func.radians(pub_lat)) + ))) + query = query.filter(distancia <= radio) + + # Cargamos SOLO la categoría y las imágenes para tener la data mínima + query = query.options( + joinedload(Publicacion.categoria_obj), + joinedload(Publicacion.imagenes) + ) + + # Limitamos a 200-500 pines para no saturar el mapa + resultados = query.limit(200).all() + + mapa_data = [] + for pub in resultados: + img_principal = pub.imagenes[0].url if pub.imagenes else None + cat_obj = {"id": pub.categoria_obj.id, "nombre": pub.categoria_obj.nombre} if pub.categoria_obj else None + + mapa_data.append({ + "id": pub.id, + "titulo": pub.titulo, + "categoria": cat_obj, + "coordenadas": pub.coordenadas, + "imagen_principal": img_principal, + "descripcion": pub.descripcion # Opcional + }) + + return mapa_data + + except Exception as e: + print(f"Error en mapa backend: {e}") + return [] + +# --- MANTENEMOS TUS OTRAS FUNCIONES (CREAR, UPDATE, DELETE, GET_BY_ID) --- def crear_publicacion(data, usuario): - """Crea una nueva publicación con imágenes y etiquetas.""" try: imagenes = data.get('imagenes', []) if len(imagenes) > 5: @@ -32,7 +212,7 @@ def crear_publicacion(data, usuario): id_usuario= usuario.id, id_locacion=data.get('id_locacion'), titulo=data.get('titulo'), - id_categoria=data.get('id_categoria'), # Correcto + id_categoria=data.get('id_categoria'), descripcion=data.get('descripcion'), fecha_creacion=datetime.now(timezone.utc), fecha_modificacion=datetime.now(timezone.utc), @@ -42,12 +222,8 @@ def crear_publicacion(data, usuario): db.session.add(nueva_publicacion) db.session.flush() - imagenes = data.get('imagenes', []) for url in imagenes: - nueva_imagen = Imagen( - id_publicacion=nueva_publicacion.id, - url=url - ) + nueva_imagen = Imagen(id_publicacion=nueva_publicacion.id, url=url) db.session.add(nueva_imagen) etiquetas = data.get('etiquetas', []) @@ -57,48 +233,35 @@ def crear_publicacion(data, usuario): nueva_publicacion.etiquetas.append(etiqueta) id_loc = data.get('id_locacion') - if id_loc: usuarios_destino = Usuario.query.filter( Usuario.id_localidad == id_loc, - Usuario.id != usuario.id # excluir al autor + Usuario.id != usuario.id ).all() - + + titulo_notif = "Nueva Publicación" mensaje_notif = f"Se publicó algo nuevo en tu zona: '{nueva_publicacion.titulo}'." - titulo_notif = f"Nueva Publicación" - for u in usuarios_destino: notificacion = Notificacion( id_usuario=u.id, - id_publicacion = nueva_publicacion.id, - titulo = titulo_notif, + id_publicacion=nueva_publicacion.id, + titulo=titulo_notif, descripcion=mensaje_notif, fecha_creacion=datetime.now(timezone.utc), leido=False ) db.session.add(notificacion) - # Commit final db.session.commit() - - return { - "mensaje": "Publicación creada exitosamente", - "id_publicacion": nueva_publicacion.id - }, 201 + return {"mensaje": "Publicación creada exitosamente", "id_publicacion": nueva_publicacion.id}, 201 except Exception as error: traceback.print_exc() db.session.rollback() - db.session.close() return {"error": str(error)}, 400 - - - def obtener_publicacion_por_id(id_publicacion): - """Obtiene una publicación por su ID.""" pub = Publicacion.query.get(id_publicacion) - if not pub: return {'error': 'Publicación no encontrada'} @@ -106,7 +269,6 @@ def obtener_publicacion_por_id(id_publicacion): urls_imagenes = [img.url for img in imagenes] etiquetas = [et.nombre for et in pub.etiquetas] - # Construir objeto categoría categoria_data = None if pub.categoria_obj: categoria_data = { @@ -120,188 +282,31 @@ def obtener_publicacion_por_id(id_publicacion): 'id_locacion': pub.id_locacion, 'titulo': pub.titulo, 'descripcion': pub.descripcion, - 'categoria': categoria_data, # Devolvemos objeto + 'categoria': categoria_data, 'etiquetas': etiquetas, - 'fecha_creacion': ( - pub.fecha_creacion.astimezone(zona_arg).isoformat() - if pub.fecha_creacion else None - ), - 'fecha_modificacion': ( - pub.fecha_modificacion.astimezone(zona_arg).isoformat() - if pub.fecha_modificacion else None - ), + 'fecha_creacion': pub.fecha_creacion.astimezone(zona_arg).isoformat() if pub.fecha_creacion else None, + 'fecha_modificacion': pub.fecha_modificacion.astimezone(zona_arg).isoformat() if pub.fecha_modificacion else None, 'coordenadas': pub.coordenadas, 'imagenes': urls_imagenes } - -def obtener_publicaciones_filtradas( - lat=None, - lon=None, - radio_km=None, - id_categoria=None, # CAMBIO DE NOMBRE PARÁMETRO - etiquetas=None, - fecha_min=None, - fecha_max=None, - id_usuario=None, - offset=0, - limit=12 - ): - """Obtiene publicaciones filtradas.""" - query = db.session.query(Publicacion).options( - joinedload(Publicacion.imagenes), - joinedload(Publicacion.etiquetas), - joinedload(Publicacion.localidad), - joinedload(Publicacion.categoria_obj) # Cargamos relación categoria - ) - query = query.filter((Publicacion.estado == 0) | (Publicacion.estado.is_(None))) - - # FILTRO POR ID DE CATEGORÍA - if id_categoria: - query = query.filter(Publicacion.id_categoria == id_categoria) - - if id_usuario: - query = query.filter(Publicacion.id_usuario == id_usuario) - - if fecha_min: - fecha_min_dt = datetime.strptime(fecha_min, '%Y-%m-%d') - query = query.filter(Publicacion.fecha_creacion >= fecha_min_dt) - - if fecha_max: - fecha_max_dt = datetime.strptime(fecha_max, '%Y-%m-%d') - query = query.filter(Publicacion.fecha_creacion <= fecha_max_dt) - - if etiquetas: - etiquetas_normalizadas = [normalizar_texto(e) for e in etiquetas if normalizar_texto(e).strip()] - if etiquetas_normalizadas: - for etiqueta in etiquetas_normalizadas: - query = query.filter( - Publicacion.etiquetas.any(func.lower(Etiqueta.nombre) == etiqueta) - ) - else: - return [] - - if lat is not None and lon is not None and radio_km is not None: - query = query.filter(Publicacion.coordenadas.isnot(None)) - - query = query.order_by(Publicacion.fecha_creacion.desc()) - query = query.offset(offset).limit(limit) - publicaciones = query.all() - - if lat is not None and lon is not None and radio_km is not None: - publicaciones = [ - pub for pub in publicaciones - if calcular_distancia_km(lat, lon, *pub.coordenadas) <= radio_km - ] - - resultado = [] - for pub in publicaciones: - urls_imagenes = [img.url for img in pub.imagenes] - etiquetas = [et.nombre for et in pub.etiquetas] - - # Construir objeto categoría seguro - cat_obj = None - if pub.categoria_obj: - cat_obj = { - "id": pub.categoria_obj.id, - "nombre": pub.categoria_obj.nombre - } - - resultado.append({ - "id": pub.id, - "titulo": pub.titulo, - "localidad": pub.localidad.nombre if pub.localidad else None, - "categoria": cat_obj, # AQUÍ ESTABA EL ERROR - "imagenes": urls_imagenes, - "etiquetas": etiquetas, - "fecha_creacion": ( - pub.fecha_creacion.astimezone(zona_arg).isoformat() - if pub.fecha_creacion else None - ), - "coordenadas": pub.coordenadas, - "descripcion": pub.descripcion - }) - - return resultado - - -def obtener_todas_publicaciones(offset=0, limit=12): - """Obtiene todas las publicaciones ordenadas por fecha.""" - try: - publicaciones = ( - db.session.query(Publicacion) - .options( - joinedload(Publicacion.imagenes), - joinedload(Publicacion.etiquetas), - joinedload(Publicacion.localidad), - joinedload(Publicacion.categoria_obj) # IMPORTANTE: cargar categoria - ) - .order_by(Publicacion.fecha_creacion.desc()) - .filter((Publicacion.estado == 0) | (Publicacion.estado.is_(None))) - .offset(offset) - .limit(limit) - .all() - ) - - resultado = [] - for pub in publicaciones: - primer_imagen = pub.imagenes[0].url if pub.imagenes else None - etiquetas = [et.nombre for et in pub.etiquetas] - - # Construir objeto categoría seguro - cat_obj = None - if pub.categoria_obj: - cat_obj = { - "id": pub.categoria_obj.id, - "nombre": pub.categoria_obj.nombre - } - - resultado.append({ - "id": pub.id, - "titulo": pub.titulo, - "localidad": pub.localidad.nombre if pub.localidad else None, - "categoria": cat_obj, # AQUÍ ESTABA EL ERROR PRINCIPAL - "imagenes": primer_imagen, - "etiquetas": etiquetas, - "fecha_creacion": ( - pub.fecha_creacion.astimezone(zona_arg).isoformat() - if pub.fecha_creacion else None - ), - }) - - return resultado - - finally: - db.session.remove() - - def actualizar_publicacion(id_publicacion, data): - """Actualiza los datos de una publicación existente.""" publicacion = Publicacion.query.get(id_publicacion) if not publicacion: raise Exception("Publicación no encontrada") - # Actualizar campos básicos publicacion.titulo = data.get('titulo', publicacion.titulo) publicacion.descripcion = data.get('descripcion', publicacion.descripcion) - - # CORREGIDO: usar id_categoria publicacion.id_categoria = data.get('id_categoria', publicacion.id_categoria) - publicacion.id_locacion = data.get('id_locacion', publicacion.id_locacion) publicacion.coordenadas = data.get('coordenadas', publicacion.coordenadas) publicacion.fecha_modificacion = datetime.now(timezone.utc) nuevas_imagenes = data.get('imagenes') if nuevas_imagenes is not None: - # 1. Validar cantidad if len(nuevas_imagenes) > 5: raise Exception("No puedes tener más de 5 imágenes por publicación") - - # 2. Borrar anteriores (Lógica de reemplazo completo) Imagen.query.filter_by(id_publicacion=publicacion.id).delete() - - # 3. Insertar nuevas for url in nuevas_imagenes: nueva_imagen = Imagen(id_publicacion=publicacion.id, url=url) db.session.add(nueva_imagen) @@ -313,143 +318,71 @@ def actualizar_publicacion(id_publicacion, data): db.session.commit() - def eliminar_publicacion(id_publicacion): publicacion = Publicacion.query.get(id_publicacion) - if not publicacion: raise Exception("Publicación no encontrada") - - # Mantenemos el borrado manual de comentarios por seguridad o lógica extra for comentario in Comentario.query.filter_by(id_publicacion=publicacion.id).all(): eliminar_comentario(comentario.id) - - # Mantenemos el borrado manual de imágenes (ESTO ES LO QUE PEDISTE NO TOCAR) - # Es necesario para borrar los archivos de Cloudinary/Storage for img in Imagen.query.filter_by(id_publicacion=publicacion.id).all(): eliminar_imagen(img.id) - - # Al ejecutar delete aquí, SQLAlchemy ahora borrará automáticamente las 'solicitudes_contacto' - # gracias al 'cascade' que agregamos en models.py, evitando el error NotNullViolation. db.session.delete(publicacion) db.session.commit() - return {"mensaje": "Publicación eliminada correctamente"} - -# Extras (normalizar_texto, calcular_distancia_km, subir_imagen... iguales) -def normalizar_texto(texto): - if not texto: - return '' - texto = unicodedata.normalize('NFD', texto) - texto = texto.encode('ascii', 'ignore').decode('utf-8') - return texto.lower().strip() - -def calcular_distancia_km(lat1, lon1, lat2, lon2): - R = 6371 - dlat = radians(lat2 - lat1) - dlon = radians(lon2 - lon1) - a = sin(dlat/2)**2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon/2)**2 - c = 2 * atan2(sqrt(a), sqrt(1 - a)) - return R * c - -def subir_imagen_a_cloudinary(file): - try: - cloudinary.config( - cloud_name=current_app.config['CLOUDINARY_CLOUD_NAME'], - api_key=current_app.config['CLOUDINARY_API_KEY'], - api_secret=current_app.config['CLOUDINARY_API_SECRET'] - ) - result = cloudinary.uploader.upload( - file, - upload_preset=current_app.config['CLOUDINARY_UPLOAD_PRESET'] - ) - return result.get("secure_url") - except Exception as e: - print("Error al subir imagen:", str(e)) - return None - - def obtener_info_principal_publicacion(id_publicacion): - pub = Publicacion.query.get(id_publicacion) - if not pub: - return {'error': 'Publicación no encontrada'} + # Se mantiene para otros usos, pero obtener_publicacion_por_id es más completo + return obtener_publicacion_por_id(id_publicacion) - imagen_principal = pub.imagenes[0].url if pub.imagenes else None - - cat_obj = None - if pub.categoria_obj: - cat_obj = { - "id": pub.categoria_obj.id, - "nombre": pub.categoria_obj.nombre - } - - return { - 'id': pub.id, - 'titulo': pub.titulo, - 'descripcion': pub.descripcion, - 'categoria': cat_obj, # CORREGIDO - 'coordenadas': pub.coordenadas, - 'imagen_principal': imagen_principal - } - -def obtener_publicaciones_por_usuario(id_usuario): - # 1. Hacemos la query optimizada (eager loading) +def obtener_mis_publicaciones(id_usuario): publicaciones = ( db.session.query(Publicacion) .options( joinedload(Publicacion.imagenes), - joinedload(Publicacion.etiquetas), - joinedload(Publicacion.localidad), - joinedload(Publicacion.categoria_obj) # IMPORTANTE: Cargar la relación + joinedload(Publicacion.categoria_obj) ) .filter(Publicacion.id_usuario == id_usuario) - .filter((Publicacion.estado == 0) | (Publicacion.estado.is_(None))) .order_by(Publicacion.fecha_creacion.desc()) .all() ) + # Reutilizamos el serializador ligero + return [serializar_publicacion_lista(pub) for pub in publicaciones] - # 2. Usamos la lógica que YA escribiste en el modelo. - # Esto devuelve la lista de diccionarios con la categoría como objeto. - return [pub.to_dict() for pub in publicaciones] +def obtener_publicaciones_por_usuario(id_usuario): + return obtener_mis_publicaciones(id_usuario) def archivar_publicacion(id_publicacion): pub = Publicacion.query.get(id_publicacion) - if not pub: - return jsonify({"error": "Publicación no encontrada"}), 404 - + if not pub: return jsonify({"error": "No encontrada"}), 404 pub.estado = 1 pub.fecha_modificacion = datetime.now(timezone.utc) db.session.commit() - return jsonify({"mensaje": "Publicación archivada"}), 200 + return jsonify({"mensaje": "Archivada"}), 200 def desarchivar_publicacion(id_publicacion): pub = Publicacion.query.get(id_publicacion) - if not pub: - return jsonify({"error": "Publicación no encontrada"}), 404 - + if not pub: return jsonify({"error": "No encontrada"}), 404 pub.estado = 0 pub.fecha_modificacion = datetime.now(timezone.utc) db.session.commit() - return jsonify({"mensaje": "Publicación desarchivada"}), 200 + return jsonify({"mensaje": "Desarchivada"}), 200 +# Helpers +def normalizar_texto(texto): + if not texto: return '' + texto = unicodedata.normalize('NFD', texto) + texto = texto.encode('ascii', 'ignore').decode('utf-8') + return texto.lower().strip() -def obtener_mis_publicaciones(id_usuario): - # 1. Hacemos la query optimizada (eager loading) - publicaciones = ( - db.session.query(Publicacion) - .options( - joinedload(Publicacion.imagenes), - joinedload(Publicacion.etiquetas), - joinedload(Publicacion.localidad), - joinedload(Publicacion.categoria_obj) # IMPORTANTE: Cargar la relación +def subir_imagen_a_cloudinary(file): + try: + cloudinary.config( + cloud_name=current_app.config['CLOUDINARY_CLOUD_NAME'], + api_key=current_app.config['CLOUDINARY_API_KEY'], + api_secret=current_app.config['CLOUDINARY_API_SECRET'] ) - .filter(Publicacion.id_usuario == id_usuario) - # Sin filtro de archivadas -> .filter((Publicacion.estado == 0) | (Publicacion.estado.is_(None))) - .order_by(Publicacion.fecha_creacion.desc()) - .all() - ) - - # 2. Usamos la lógica que YA escribiste en el modelo. - # Esto devuelve la lista de diccionarios con la categoría como objeto. - return [pub.to_dict() for pub in publicaciones] \ No newline at end of file + result = cloudinary.uploader.upload(file, upload_preset=current_app.config['CLOUDINARY_UPLOAD_PRESET']) + return result.get("secure_url") + except Exception as e: + print("Error Cloudinary:", str(e)) + return None \ No newline at end of file From fab9b9a33a6511d8b7ff1144b360d6caef96e558 Mon Sep 17 00:00:00 2001 From: lautarostuve Date: Thu, 4 Dec 2025 13:41:06 -0300 Subject: [PATCH 7/7] arreglo de filtro de distancia --- components/publicaciones/services.py | 62 ++++++++++++---------------- 1 file changed, 27 insertions(+), 35 deletions(-) diff --git a/components/publicaciones/services.py b/components/publicaciones/services.py index 394f935..8f9b2ab 100644 --- a/components/publicaciones/services.py +++ b/components/publicaciones/services.py @@ -54,7 +54,7 @@ def obtener_publicaciones_filtradas( (Publicacion.estado == 0) | (Publicacion.estado.is_(None)) ) - # 1. Filtros Básicos + # Filtros básicos if id_categoria: query = query.filter(Publicacion.id_categoria == id_categoria) if id_usuario: @@ -66,7 +66,7 @@ def obtener_publicaciones_filtradas( dt = datetime.strptime(fecha_max, '%Y-%m-%d') query = query.filter(Publicacion.fecha_creacion <= dt) - # 2. Filtro Etiquetas (Optimizado con JOIN) + # Filtro Etiquetas if etiquetas: etiquetas_norm = [normalizar_texto(e) for e in etiquetas if e.strip()] if etiquetas_norm: @@ -74,25 +74,21 @@ def obtener_publicaciones_filtradas( func.lower(Etiqueta.nombre).in_(etiquetas_norm) ) - # 3. Filtro Geoespacial (SQL Haversine) + # --- CORRECCIÓN FILTRO GEOESPACIAL (ARRAY) --- if lat is not None and lon is not None and radio_km is not None: - # Asumiendo coordenadas como array/json [lat, lng] - pub_lat = cast(Publicacion.coordenadas[0], Float) - pub_lon = cast(Publicacion.coordenadas[1], Float) + # Acceso directo al ARRAY(Float). Indices SQL empiezan en 1. + pub_lat = Publicacion.coordenadas[1] + pub_lon = Publicacion.coordenadas[2] - # Fórmula: 6371 * acos(...) distancia = 6371 * func.acos( func.least(1.0, func.greatest(-1.0, - func.cos(func.radians(lat)) * - func.cos(func.radians(pub_lat)) * - func.cos(func.radians(pub_lon) - func.radians(lon)) + - func.sin(func.radians(lat)) * - func.sin(func.radians(pub_lat)) + func.sin(func.radians(lat)) * func.sin(func.radians(pub_lat)) + + func.cos(func.radians(lat)) * func.cos(func.radians(pub_lat)) * func.cos(func.radians(pub_lon) - func.radians(lon)) )) ) query = query.filter(distancia <= radio_km) + # --------------------------------------------- - # 4. Carga de relaciones (Eager Loading) query = query.options( joinedload(Publicacion.imagenes), joinedload(Publicacion.localidad), @@ -100,7 +96,6 @@ def obtener_publicaciones_filtradas( joinedload(Publicacion.etiquetas) ) - # 5. Orden y Paginación (Al final para que sea correcto) query = query.order_by(Publicacion.fecha_creacion.desc()) query = query.offset(offset).limit(limit) @@ -110,8 +105,10 @@ def obtener_publicaciones_filtradas( except Exception as e: print(f"Error filtro: {e}") + traceback.print_exc() return [] - + + def obtener_todas_publicaciones(offset=0, limit=12): """Obtiene todas las publicaciones para el home (Optimizado).""" try: @@ -136,45 +133,40 @@ def obtener_todas_publicaciones(offset=0, limit=12): pass def obtener_publicaciones_para_mapa(filtros): - """ - Query ultra-rápida para el mapa. - Solo devuelve lo estrictamente necesario. - """ + """Query optimizada para el mapa (versión Array corregida).""" try: - # Iniciamos query base query = db.session.query(Publicacion).filter( (Publicacion.estado == 0) | (Publicacion.estado.is_(None)) ) - # Aplicamos mismos filtros que en el listado if filtros.get('id_categoria'): query = query.filter(Publicacion.id_categoria == filtros['id_categoria']) if filtros.get('id_usuario'): query = query.filter(Publicacion.id_usuario == filtros['id_usuario']) - # ... fechas y etiquetas si fueran necesarias ... - # Filtro Distancia SQL + # --- CORRECCIÓN FILTRO GEOESPACIAL (ARRAY) --- lat = filtros.get('lat') lon = filtros.get('lon') radio = filtros.get('radio') if lat and lon and radio: - pub_lat = cast(Publicacion.coordenadas[0], Float) - pub_lon = cast(Publicacion.coordenadas[1], Float) - distancia = 6371 * func.acos(func.least(1.0, func.greatest(-1.0, - func.cos(func.radians(lat)) * func.cos(func.radians(pub_lat)) * - func.cos(func.radians(pub_lon) - func.radians(lon)) + - func.sin(func.radians(lat)) * func.sin(func.radians(pub_lat)) - ))) + pub_lat = Publicacion.coordenadas[1] + pub_lon = Publicacion.coordenadas[2] + + distancia = 6371 * func.acos( + func.least(1.0, func.greatest(-1.0, + func.sin(func.radians(lat)) * func.sin(func.radians(pub_lat)) + + func.cos(func.radians(lat)) * func.cos(func.radians(pub_lat)) * func.cos(func.radians(pub_lon) - func.radians(lon)) + )) + ) query = query.filter(distancia <= radio) + # --------------------------------------------- - # Cargamos SOLO la categoría y las imágenes para tener la data mínima query = query.options( joinedload(Publicacion.categoria_obj), joinedload(Publicacion.imagenes) ) - # Limitamos a 200-500 pines para no saturar el mapa resultados = query.limit(200).all() mapa_data = [] @@ -187,17 +179,17 @@ def obtener_publicaciones_para_mapa(filtros): "titulo": pub.titulo, "categoria": cat_obj, "coordenadas": pub.coordenadas, - "imagen_principal": img_principal, - "descripcion": pub.descripcion # Opcional + "imagen_principal": img_principal }) return mapa_data except Exception as e: print(f"Error en mapa backend: {e}") + traceback.print_exc() return [] -# --- MANTENEMOS TUS OTRAS FUNCIONES (CREAR, UPDATE, DELETE, GET_BY_ID) --- + def crear_publicacion(data, usuario): try: