diff --git a/__pycache__/app.cpython-311.pyc b/__pycache__/app.cpython-311.pyc index bd4bd87..e918aa6 100644 Binary files a/__pycache__/app.cpython-311.pyc and b/__pycache__/app.cpython-311.pyc differ diff --git a/app.py b/app.py index cdebc0e..2a10f30 100644 --- a/app.py +++ b/app.py @@ -1,50 +1,44 @@ -import eventlet -eventlet.monkey_patch() - - -from util import socketio -import firebase_admin -from dotenv import load_dotenv -from components.contactos.routes import contactos_bp -from components.categorias.routes import categorias_bp -from components.funcionesAdmin.routes import admin_bp -from components.refugios.routes import overpass_bp -from components.roles.routes import roles_bp -from components.etiquetas.routes import etiquetas_bp -from components.ubicacion.routes import ubicacion_bp -from components.pdf.routes import pdf_bp -from components.qr.routes import qr_bp -from components.reportes.routes import reportes_bp -from components.notificaciones.routes import notificaciones_bp -from components.imagenes.routes import imagenes_bp -from components.comentarios.routes import comentarios_bp -from components.usuarios.routes import usuarios_bp -from components.publicaciones.routes import publicaciones_bp -from auth.routes import auth_bp -from core.models import db, Usuario -from flask_migrate import Migrate -from datetime import datetime -from functools import wraps -from flask_cors import CORS -from flask_sqlalchemy import SQLAlchemy -from flask import Flask, request, jsonify -from firebase_admin import credentials, auth as firebase_auth -import psycopg2 -import os -import json import builtins - - +# Mantenemos esto por compatibilidad de algunas libs viejas de Python 2/3 if not hasattr(builtins, "unicode"): builtins.unicode = str +import os +import psycopg2 +from firebase_admin import credentials, auth as firebase_auth +from flask import Flask, request, jsonify +from flask_sqlalchemy import SQLAlchemy +from flask_cors import CORS +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 +from components.usuarios.routes import usuarios_bp +from components.comentarios.routes import comentarios_bp +from components.imagenes.routes import imagenes_bp +from components.notificaciones.routes import notificaciones_bp +from components.reportes.routes import reportes_bp +from components.qr.routes import qr_bp +from components.pdf.routes import pdf_bp +from components.ubicacion.routes import ubicacion_bp +from components.etiquetas.routes import etiquetas_bp +from components.roles.routes import roles_bp +from components.refugios.routes import overpass_bp +from components.funcionesAdmin.routes import admin_bp +from components.categorias.routes import categorias_bp +from components.contactos.routes import contactos_bp + +from dotenv import load_dotenv +import firebase_admin -# load_dotenv() app = Flask(__name__) - def cerrar_sesion(): """ Cierra la sesión de base de datos de forma segura. @@ -54,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"), @@ -80,21 +73,17 @@ def shutdown_session(exception=None): cred = credentials.Certificate(json.loads(os.environ["FIREBASE_CREDENTIALS"])) 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'] = { - # Mantiene máximo 5 conexiones abiertas permanentemente por instancia - "pool_size": 5, - "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 - # Recicla conexiones cada 30 mins para evitar que mueran silenciosamente - "pool_recycle": 1800, - # Verifica que la conexión sirva antes de usarla (VITAL) - "pool_pre_ping": True + "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 @@ -106,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) @@ -131,7 +119,6 @@ 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": @@ -141,7 +128,6 @@ def handle_options(): headers["Access-Control-Allow-Origin"] = frontend_url headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS" - return resp @@ -155,4 +141,4 @@ def health_check(): socketio.init_app(app) 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/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/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/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/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/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 5298e84..8f9b2ab 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,181 @@ 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)) + ) + + # 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) + + # Filtro Etiquetas + 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) + ) + + # --- CORRECCIÓN FILTRO GEOESPACIAL (ARRAY) --- + if lat is not None and lon is not None and radio_km is not None: + # Acceso directo al ARRAY(Float). Indices SQL empiezan en 1. + 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_km) + # --------------------------------------------- + + query = query.options( + joinedload(Publicacion.imagenes), + joinedload(Publicacion.localidad), + joinedload(Publicacion.categoria_obj), + joinedload(Publicacion.etiquetas) + ) + + 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}") + traceback.print_exc() + 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 optimizada para el mapa (versión Array corregida).""" + try: + query = db.session.query(Publicacion).filter( + (Publicacion.estado == 0) | (Publicacion.estado.is_(None)) + ) + + 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']) + + # --- 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 = 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) + # --------------------------------------------- + + query = query.options( + joinedload(Publicacion.categoria_obj), + joinedload(Publicacion.imagenes) + ) + + 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 + }) + + return mapa_data + + except Exception as e: + print(f"Error en mapa backend: {e}") + traceback.print_exc() + return [] + + 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 +204,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 +214,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 +225,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 +261,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 +274,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,135 +310,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") - for comentario in Comentario.query.filter_by(id_publicacion=publicacion.id).all(): eliminar_comentario(comentario.id) - for img in Imagen.query.filter_by(id_publicacion=publicacion.id).all(): eliminar_imagen(img.id) - db.session.delete(publicacion) db.session.commit() - - -# 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 - + return {"mensaje": "Publicación eliminada correctamente"} 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 archivada"}), 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 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.''' diff --git a/components/usuarios/routes.py b/components/usuarios/routes.py index c4394a7..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, @@ -8,26 +8,26 @@ obtener_usuario_por_slug, ) from firebase_admin import auth -from auth.services import require_auth -from flask_socketio import SocketIO, disconnect -from util import socketio +from core.auth_middleware import require_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) @@ -42,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.''' @@ -56,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(): @@ -88,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.''' @@ -116,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.''' @@ -202,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.''' @@ -218,24 +145,63 @@ 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. +@usuarios_bp.get("/usuario/is_admin") +@require_auth +def is_admin(): + decoded = request.user + return jsonify({"admin": decoded.get("admin", False)}) - 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() +@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() - if not usuario: - return jsonify({'error': 'Usuario no encontrado'}), 404 + # 2) Obtener usuarios admin + cursor.execute("SELECT firebase_uid FROM usuarios WHERE role_id = 2") + admins = cursor.fetchall() - # `role_id` almacena el id del rol; 2 == admin - is_admin = (getattr(usuario, 'role_id', None) == 2) + 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 - return jsonify({'is_admin': bool(is_admin)}), 200 - + 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 \ No newline at end of file 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/core/models.py b/core/models.py index 9ddb00a..6c87466 100644 --- a/core/models.py +++ b/core/models.py @@ -448,7 +448,8 @@ class SolicitudContacto(db.Model): # Relaciones solicitante = db.relationship('Usuario', foreign_keys=[id_solicitante], backref='solicitudes_enviadas') receptor = db.relationship('Usuario', foreign_keys=[id_receptor], backref='solicitudes_recibidas') - publicacion = db.relationship('Publicacion', backref='solicitudes') + + publicacion = db.relationship('Publicacion', backref=db.backref('solicitudes', cascade='all, delete-orphan')) def to_dict(self): return { 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") 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