diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..0bfc867d09f912f8f41220e38e212aa2a3464dc2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Ignorar entorno virtual +env/ +ENV/ +.venv/ +.ENV/ +*.env + +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.sqlite3 + +# Archivos del sistema +.DS_Store +Thumbs.db + +# Archivos de configuración de editores +.vscode/ +.idea/ + +# Archivos de log +*.log + +# Variables de entorno +.env + +# Compilados o binarios temporales +*.egg-info/ +build/ +dist/ + +# Ignorar archivos compilados por Python +__pycache__/ +*.py[cod] + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..95d8f5d82ae1527e653d3d0dd90b8e39a315c01f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --upgrade pip +RUN pip install --no-cache-dir --upgrade -r requirements.txt + +COPY . . + +EXPOSE 7860 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/main.py b/main.py new file mode 100644 index 0000000000000000000000000000000000000000..75c7b36324983659c81f01bc1c53e734b7521ff0 --- /dev/null +++ b/main.py @@ -0,0 +1,44 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware # 👈 nuevo + +# routers +from src.expon.iam.interfaces.rest.controllers.auth_controller import router as auth_router +from src.expon.profile.interfaces.rest.controllers.profile_controller import router as profile_router +from src.expon.presentation.interfaces.rest.controllers.presentation_controller import router as presentation_router +from src.expon.feedback.interfaces.rest.feedback_controller import router as feedback_router +from src.expon.subscription.interfaces.rest.controllers.subscription_controller import router as subscription_router +from src.expon.feedback.infrastructure.persistence.jpa.feedback_orm import FeedbackORM +from src.expon.shared.infrastructure.database import Base, engine + +app = FastAPI( + title="Expon Backend API", + version="1.0.0", + description="Backend estructurado por bounded contexts con FastAPI" +) + +# 👇 middleware CORS +origins = [ + "https://expon-frontend.netlify.app", + "http://localhost:4200", +] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# routers +app.include_router(auth_router, prefix="/api/v1/auth", tags=["Authentication"]) +app.include_router(profile_router, prefix="/api/v1/profile", tags=["Profile"]) +app.include_router(presentation_router, prefix="/api/v1/presentation", tags=["Presentations"]) +app.include_router(feedback_router, prefix="/api/v1/feedback", tags=["Feedback"]) +app.include_router(subscription_router, prefix="/api/v1/subscription", tags=["Subscriptions"]) + +Base.metadata.create_all(bind=engine) + +@app.get("/") +def read_root(): + return {"mensaje": "¡Expon backend funcionando con estructura profesional!"} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..c8b5445a4f8ad976732c23e5cc293c85b8a0b5b5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,111 @@ +# ======================================== +# DEPENDENCIAS PRINCIPALES UTILIZADAS +# ======================================== + +# Framework Principal +fastapi==0.115.12 +starlette==0.46.2 +uvicorn==0.34.3 + +# Validación de Datos +pydantic==2.11.6 +pydantic_core==2.33.2 +pydantic[email] + +# Base de Datos +SQLAlchemy==2.0.41 +psycopg2-binary==2.9.10 + +# Autenticación y Seguridad +passlib==1.7.4 +bcrypt==4.3.0 +PyJWT==2.10.1 + +# Configuración +python-dotenv==1.1.0 +python-decouple==3.8 + +# Manejo de Archivos +python-multipart==0.0.20 + +# Audio y Multimedia +vosk==0.3.45 +ffmpeg-python==0.2.0 +pydub==0.25.1 + +# IA y Machine Learning +transformers==4.52.4 +torch==2.7.1 +tokenizers==0.21.1 +safetensors==0.5.3 + +# Google AI Services +google-generativeai==0.8.5 +google-ai-generativelanguage==0.6.15 +google-api-core==2.25.1 +google-api-python-client==2.174.0 +google-auth==2.40.3 +google-auth-httplib2==0.2.0 +googleapis-common-protos==1.70.0 + +# HTTP Requests +requests==2.32.4 + +# Dependencias de Sistema +proto-plus==1.26.1 +protobuf==5.29.5 +grpcio==1.73.1 +grpcio-status==1.71.0 +pyasn1==0.6.1 +pyasn1_modules==0.4.2 +rsa==4.9.1 +uritemplate==4.2.0 +cachetools==5.5.2 + +# Servidor de Producción (opcional) +gunicorn==23.0.0 + +# ======================================== +# DEPENDENCIAS A REMOVER (no utilizadas) +# ======================================== +# annotated-types==0.7.0 +# anyio==4.9.0 +# certifi==2025.6.15 +# cffi==1.17.1 +# charset-normalizer==3.4.2 +# click==8.2.1 +# colorama==0.4.6 +# distro==1.9.0 +# dnspython==2.7.0 +# email_validator==2.2.0 +# filelock==3.18.0 +# fsspec==2025.5.1 +# future==1.0.0 +# greenlet==3.2.3 +# h11==0.16.0 +# httpcore==1.0.9 +# httplib2==0.22.0 +# httpx==0.28.1 +# huggingface-hub==0.33.0 +# idna==3.10 +# Jinja2==3.1.6 +# jiter==0.10.0 +# MarkupSafe==3.0.2 +# mpmath==1.3.0 +# networkx==3.5 +# numpy==2.3.1 +# openai==1.91.0 +# packaging==25.0 +# pycparser==2.22 +# pyparsing==3.2.3 +# PyYAML==6.0.2 +# regex==2024.11.6 +# sentencepiece==0.2.0 +# sniffio==1.3.1 +# srt==3.5.3 +# sympy==1.14.0 +# tqdm==4.67.1 +# typing-inspection==0.4.1 +# typing_extensions==4.14.0 +# urllib3==2.5.0 +# websockets==15.0.1 diff --git a/src/expon/feedback/application/internal/generate_feedback_service.py b/src/expon/feedback/application/internal/generate_feedback_service.py new file mode 100644 index 0000000000000000000000000000000000000000..4d588100c535fdbf5cd22ec95f09e7c0d6fe7f03 --- /dev/null +++ b/src/expon/feedback/application/internal/generate_feedback_service.py @@ -0,0 +1,50 @@ +from datetime import datetime +from uuid import uuid4 +from src.expon.feedback.domain.model.feedback import Feedback +from src.expon.feedback.infrastructure.services.text_generation_service import TextGenerationService +from src.expon.presentation.infrastructure.persistence.jpa.repositories.presentation_repository import PresentationRepository +from src.expon.feedback.infrastructure.persistence.jpa.feedback_repository import FeedbackRepository +from src.expon.presentation.infrastructure.persistence.jpa.models.presentation_orm import PresentationORM + +class GenerateFeedbackService: + def __init__(self, feedback_repo: FeedbackRepository, presentation_repo: PresentationRepository): + self.feedback_repo = feedback_repo + self.presentation_repo = presentation_repo + self.text_gen_service = TextGenerationService() + + def generate_feedback(self, presentation_id: str) -> Feedback: + # 1. Buscar presentación + presentation: PresentationORM = self.presentation_repo.get_by_id(presentation_id) + + if presentation is None: + raise ValueError("Presentación no encontrada") + + user_id = presentation.user_id + emotion = presentation.dominant_emotion + transcription = presentation.transcript or "" + confidence = presentation.confidence or 0.0 + anxiety = 0.3 + + # 2. Generar contenido dinámico con IA + general, language, confidence_fb, anxiety_fb, suggestions = self.text_gen_service.generate_structured_feedback( + transcription=transcription, + emotion=emotion, + confidence=confidence, + anxiety=anxiety + ) + + feedback = Feedback( + id=uuid4(), + user_id=user_id, + presentation_id=presentation_id, + general_feedback=general, + language_feedback=language, + confidence_feedback=confidence_fb, + anxiety_feedback=anxiety_fb, + suggestions=suggestions, + created_at=datetime.utcnow() + ) + + self.feedback_repo.save(feedback) + return feedback + diff --git a/src/expon/feedback/application/internal/query_feedback_service.py b/src/expon/feedback/application/internal/query_feedback_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/expon/feedback/domain/model/feedback.py b/src/expon/feedback/domain/model/feedback.py new file mode 100644 index 0000000000000000000000000000000000000000..37f29c91558059bec87e0faa56e0da440d65059b --- /dev/null +++ b/src/expon/feedback/domain/model/feedback.py @@ -0,0 +1,17 @@ +from datetime import datetime +from uuid import UUID +from pydantic import BaseModel, Field + +class Feedback(BaseModel): + id: UUID + user_id: UUID + presentation_id: UUID + general_feedback: str + language_feedback: str + confidence_feedback: str + anxiety_feedback: str + suggestions: str + created_at: datetime + + class Config: + orm_mode = True diff --git a/src/expon/feedback/domain/services/feedback_generator_service.py b/src/expon/feedback/domain/services/feedback_generator_service.py new file mode 100644 index 0000000000000000000000000000000000000000..0f740c1e78fa7c9f800ea5cf250520f145a32732 --- /dev/null +++ b/src/expon/feedback/domain/services/feedback_generator_service.py @@ -0,0 +1,86 @@ +import re +from typing import Tuple + + +class FeedbackGeneratorService: + + def analyze_emotion_consistency(self, emotion: str, transcription: str) -> str: + """ + Evalúa si el contenido de la presentación es coherente con la emoción dominante detectada. + """ + keywords = { + "motivado": ["lograr", "puedo", "importante", "avanzar"], + "ansioso": ["eh", "bueno", "mmm", "no sé", "tal vez"], + "entusiasta": ["me encanta", "disfruté", "fue genial"], + "seguro": ["claramente", "sin duda", "obviamente"], + "inseguro": ["creo", "quizás", "podría ser"] + } + palabras = transcription.lower().split() + count = sum(p in palabras for p in keywords.get(emotion, [])) + + if count >= 2: + return f"La emoción detectada ({emotion}) fue coherente con el contenido del discurso." + else: + return f"La emoción detectada ({emotion}) parece no coincidir completamente con lo expresado verbalmente. Podrías trabajar en alinear tu expresión emocional con tus ideas." + + def analyze_language_quality(self, transcription: str) -> str: + """ + Detecta uso de jerga, muletillas y falta de conectores. + """ + muletillas = ["eh", "mmm", "bueno", "o sea", "este"] + jergas = ["chévere", "cool", "super", "bacán"] + conectores_formales = ["por lo tanto", "además", "en conclusión", "sin embargo"] + repetidas = set([w for w in transcription.lower().split() if transcription.lower().split().count(w) > 4]) + + issues = [] + + if any(m in transcription.lower() for m in muletillas): + issues.append("Se detectaron muletillas frecuentes, como 'eh' o 'bueno'. Esto puede restar claridad.") + + if any(j in transcription.lower() for j in jergas): + issues.append("El uso de jerga informal no es recomendable en presentaciones académicas.") + + if not any(c in transcription.lower() for c in conectores_formales): + issues.append("No se identificaron conectores formales. Usarlos ayuda a organizar mejor tus ideas.") + + if len(repetidas) > 0: + issues.append("Detectamos repetición excesiva de algunas palabras, lo que puede afectar la riqueza del discurso.") + + return " ".join(issues) if issues else "El lenguaje utilizado fue adecuado, claro y apropiado para el contexto académico." + + def evaluate_confidence(self, confidence_score: float) -> str: + if confidence_score >= 0.8: + return "Tu nivel de confianza fue alto. Mantuviste un discurso fluido y seguro." + elif confidence_score >= 0.5: + return "Confianza aceptable, aunque con espacio para mejorar la entonación o firmeza." + else: + return "Bajo nivel de confianza detectado. Practicar la presentación con antelación puede ayudarte a mejorar." + + def evaluate_anxiety(self, anxiety_score: float) -> str: + if anxiety_score < 0.3: + return "Se detectó buen control de ansiedad durante tu exposición." + elif anxiety_score < 0.6: + return "Ansiedad moderada. Considera técnicas como respiración profunda o pausas conscientes." + else: + return "Alta ansiedad percibida. Practica con simulaciones o ensayos en voz alta para mejorar tu seguridad." + + def generate_suggestions(self) -> str: + return "Prueba practicar en voz alta usando grabaciones. Mejora tu entonación, usa conectores y reduce muletillas." + + def generate_structured_feedback( + self, + emotion: str, + transcription: str, + confidence_score: float, + anxiety_score: float + ) -> Tuple[str, str, str, str, str]: + """ + Devuelve feedback completo dividido en cinco secciones. + """ + general = self.analyze_emotion_consistency(emotion, transcription) + language = self.analyze_language_quality(transcription) + confidence = self.evaluate_confidence(confidence_score) + anxiety = self.evaluate_anxiety(anxiety_score) + suggestions = self.generate_suggestions() + + return general, language, confidence, anxiety, suggestions diff --git a/src/expon/feedback/infrastructure/persistence/jpa/feedback_orm.py b/src/expon/feedback/infrastructure/persistence/jpa/feedback_orm.py new file mode 100644 index 0000000000000000000000000000000000000000..06d6a7d34c5f9bd47bc6959b3e415efb8475e784 --- /dev/null +++ b/src/expon/feedback/infrastructure/persistence/jpa/feedback_orm.py @@ -0,0 +1,18 @@ +from sqlalchemy import Column, String, DateTime +from sqlalchemy.dialects.postgresql import UUID as SA_UUID +from src.expon.shared.infrastructure.database import Base +import uuid +import datetime + +class FeedbackORM(Base): + __tablename__ = "feedback" + + id = Column(SA_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(SA_UUID(as_uuid=True), nullable=False) + presentation_id = Column(SA_UUID(as_uuid=True), nullable=False) + general_feedback = Column(String, nullable=False) + language_feedback = Column(String, nullable=False) + confidence_feedback = Column(String, nullable=False) + anxiety_feedback = Column(String, nullable=False) + suggestions = Column(String, nullable=False) + created_at = Column(DateTime, default=datetime.datetime.utcnow) diff --git a/src/expon/feedback/infrastructure/persistence/jpa/feedback_repository.py b/src/expon/feedback/infrastructure/persistence/jpa/feedback_repository.py new file mode 100644 index 0000000000000000000000000000000000000000..da2ea13bd62c617360ae41a38ae6a6eb9260e8b0 --- /dev/null +++ b/src/expon/feedback/infrastructure/persistence/jpa/feedback_repository.py @@ -0,0 +1,33 @@ +from sqlalchemy.orm import Session +from src.expon.feedback.infrastructure.persistence.jpa.feedback_orm import FeedbackORM +from src.expon.feedback.domain.model.feedback import Feedback +from datetime import datetime +import uuid + +class FeedbackRepository: + def __init__(self, db: Session): + self.db = db + + def save(self, feedback: Feedback): + orm_obj = FeedbackORM( + id=uuid.uuid4(), + user_id=feedback.user_id, + presentation_id=feedback.presentation_id, + general_feedback=feedback.general_feedback, + language_feedback=feedback.language_feedback, + confidence_feedback=feedback.confidence_feedback, + anxiety_feedback=feedback.anxiety_feedback, + suggestions=feedback.suggestions, + created_at=datetime.utcnow() + ) + self.db.add(orm_obj) + self.db.commit() + + def get_all(self): + return self.db.query(FeedbackORM).all() + + def get_by_user(self, user_id): + return self.db.query(FeedbackORM).filter_by(user_id=user_id).all() + + def get_by_presentation(self, presentation_id): + return self.db.query(FeedbackORM).filter_by(presentation_id=presentation_id).all() diff --git a/src/expon/feedback/infrastructure/services/text_generation_service.py b/src/expon/feedback/infrastructure/services/text_generation_service.py new file mode 100644 index 0000000000000000000000000000000000000000..20c0b2fe84817869516821ff565809ff4ab9c11c --- /dev/null +++ b/src/expon/feedback/infrastructure/services/text_generation_service.py @@ -0,0 +1,69 @@ +import os +import google.generativeai as genai +from dotenv import load_dotenv + +# Cargar variables desde .env +load_dotenv() + +class TextGenerationService: + def __init__(self, model="gemini-1.5-flash"): + self.model_name = model + api_key = os.getenv("GEMINI_API_KEY") + if not api_key: + raise ValueError("GEMINI_API_KEY no encontrada en variables de entorno") + + genai.configure(api_key=api_key) + # Usar modelo sin configuración fija para permitir ajustes dinámicos + self.model = genai.GenerativeModel(model) + + def generate_structured_feedback(self, transcription: str, emotion: str, confidence: float, anxiety: float) -> tuple[str, str, str, str, str]: + # Contexto base con información de la presentación + context = ( + f"ANÁLISIS DE PRESENTACIÓN ACADÉMICA\n" + f"====================================\n" + f"Transcripción: \"{transcription}\"\n\n" + f"Métricas detectadas:\n" + f"- Emoción dominante: {emotion}\n" + f"- Nivel de confianza: {int(confidence * 100)}%\n" + f"- Nivel de ansiedad: {int(anxiety * 100)}%\n" + ) + + def ask(prompt: str) -> str: + try: + # Crear el prompt completo con contexto + full_prompt = f"""Eres un experto en análisis de presentaciones académicas. + +{context} + +{prompt} + +IMPORTANTE: Responde en máximo 60 palabras, de forma directa y profesional, sin usar comillas dobles.""" + + # Configuración dinámica como sugiere GPT + response = self.model.generate_content( + full_prompt, + generation_config={ + "temperature": 0.7, + "max_output_tokens": 100 + } + ) + + # Limpiar caracteres de escape y limitaciones + clean_text = response.text.strip().replace('\\"', '"').replace('\\n', ' ') + # Limitar palabras si es muy largo + words = clean_text.split() + if len(words) > 60: + clean_text = ' '.join(words[:60]) + "..." + return clean_text + except Exception as e: + print(f"Error al generar feedback con Gemini: {e}") + return f"Error al generar análisis. Verifique la configuración de la API." + + # Pedir feedback por secciones con prompts más específicos + general = ask("Analiza brevemente la presentación general: fortalezas principales y área de mejora más importante.") + language = ask("Evalúa el lenguaje: ¿es académico o informal? Menciona 2 mejoras específicas para el vocabulario.") + confidence_fb = ask("¿Cómo se percibe la confianza del orador? Analiza el tono y seguridad proyectada.") + anxiety_fb = ask("¿Se detecta ansiedad? Proporciona 2 técnicas específicas para reducirla.") + suggestions = ask("Lista exactamente 3 mejoras concretas y accionables para futuras presentaciones.") + + return general, language, confidence_fb, anxiety_fb, suggestions diff --git a/src/expon/feedback/interfaces/rest/feedback_controller.py b/src/expon/feedback/interfaces/rest/feedback_controller.py new file mode 100644 index 0000000000000000000000000000000000000000..43eeacd5661abe58ae811d0ef3751f99fcf77e4d --- /dev/null +++ b/src/expon/feedback/interfaces/rest/feedback_controller.py @@ -0,0 +1,35 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from uuid import UUID + +from src.expon.shared.infrastructure.dependencies import get_db +from src.expon.feedback.interfaces.rest.feedback_request import FeedbackRequest +from src.expon.feedback.interfaces.rest.feedback_response import FeedbackResponse +from src.expon.feedback.infrastructure.persistence.jpa.feedback_repository import FeedbackRepository +from src.expon.presentation.infrastructure.persistence.jpa.repositories.presentation_repository import PresentationRepository +from src.expon.feedback.application.internal.generate_feedback_service import GenerateFeedbackService + +router = APIRouter() + + +@router.post("/", response_model=FeedbackResponse) +def generate_feedback(request: FeedbackRequest, db: Session = Depends(get_db)): + feedback_repo = FeedbackRepository(db) + presentation_repo = PresentationRepository(db) + service = GenerateFeedbackService(feedback_repo, presentation_repo) + result = service.generate_feedback(request.presentation_id) + return result + + +@router.get("/user/{user_id}", response_model=list[FeedbackResponse]) +def get_feedback_by_user(user_id: UUID, db: Session = Depends(get_db)): + repo = FeedbackRepository(db) + results = repo.get_by_user(user_id) + return results + + +@router.get("/presentation/{presentation_id}", response_model=list[FeedbackResponse]) +def get_feedback_by_presentation(presentation_id: UUID, db: Session = Depends(get_db)): + repo = FeedbackRepository(db) + results = repo.get_by_presentation(presentation_id) + return results diff --git a/src/expon/feedback/interfaces/rest/feedback_request.py b/src/expon/feedback/interfaces/rest/feedback_request.py new file mode 100644 index 0000000000000000000000000000000000000000..882e898fed642ccb7ad4f23d1fe03898b2c5aed2 --- /dev/null +++ b/src/expon/feedback/interfaces/rest/feedback_request.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel +from uuid import UUID + +class FeedbackRequest(BaseModel): + presentation_id: UUID diff --git a/src/expon/feedback/interfaces/rest/feedback_response.py b/src/expon/feedback/interfaces/rest/feedback_response.py new file mode 100644 index 0000000000000000000000000000000000000000..7401da37d450d39095dd6342f051ff36ffa3ef7e --- /dev/null +++ b/src/expon/feedback/interfaces/rest/feedback_response.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel +from uuid import UUID +from datetime import datetime + +class FeedbackResponse(BaseModel): + id: UUID + user_id: UUID + presentation_id: UUID + general_feedback: str + language_feedback: str + confidence_feedback: str + anxiety_feedback: str + suggestions: str + created_at: datetime diff --git a/src/expon/iam/application/acl/iam_context_facade.py b/src/expon/iam/application/acl/iam_context_facade.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/expon/iam/application/internal/commandservices/user_command_service.py b/src/expon/iam/application/internal/commandservices/user_command_service.py new file mode 100644 index 0000000000000000000000000000000000000000..d3d27af2f1be7bad2467724eab83fe857c945401 --- /dev/null +++ b/src/expon/iam/application/internal/commandservices/user_command_service.py @@ -0,0 +1,26 @@ +from uuid import uuid4 +from datetime import datetime +from src.expon.iam.domain.model.aggregates.user import User +from src.expon.iam.domain.model.commands.sign_up_command import SignUpCommand +from src.expon.iam.infrastructure.persistence.jpa.repositories.user_repository import UserRepository +from src.expon.iam.infrastructure.hashing.bcrypt.services.hashing_service import HashingService + + +class UserCommandService: + def __init__(self, user_repository: UserRepository, hashing_service: HashingService): + self.user_repository = user_repository + self.hashing_service = hashing_service + + def handle_sign_up(self, command: SignUpCommand) -> User: + hashed_password = self.hashing_service.hash(command.password) + + user = User( + id=uuid4(), + username=command.username, + email=command.email, + password=hashed_password, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow() + ) + + return self.user_repository.save(user) diff --git a/src/expon/iam/application/internal/queryservices/user_query_service.py b/src/expon/iam/application/internal/queryservices/user_query_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/expon/iam/domain/model/aggregates/user.py b/src/expon/iam/domain/model/aggregates/user.py new file mode 100644 index 0000000000000000000000000000000000000000..79d96dad2f41479a0b79a82fafd38b1f7088ec72 --- /dev/null +++ b/src/expon/iam/domain/model/aggregates/user.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass +from uuid import UUID +from datetime import datetime + + +@dataclass +class User: + id: UUID + username: str + email: str + password: str + created_at: datetime + updated_at: datetime diff --git a/src/expon/iam/domain/model/commands/sign_up_command.py b/src/expon/iam/domain/model/commands/sign_up_command.py new file mode 100644 index 0000000000000000000000000000000000000000..bc9037b50185e5d3e752fa717b780f9c3117d022 --- /dev/null +++ b/src/expon/iam/domain/model/commands/sign_up_command.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, EmailStr, Field + + +class SignUpCommand(BaseModel): + username: str = Field(..., min_length=3, max_length=50) + email: EmailStr + password: str = Field(..., min_length=6) + + class Config: + arbitrary_types_allowed = True diff --git a/src/expon/iam/domain/model/entities/role.py b/src/expon/iam/domain/model/entities/role.py new file mode 100644 index 0000000000000000000000000000000000000000..fa50a9a45e463f7f20173b9e4f8fc5d1241e9d7a --- /dev/null +++ b/src/expon/iam/domain/model/entities/role.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class Role(str, Enum): + ROLE_USER = "ROLE_USER" + ROLE_ADMIN = "ROLE_ADMIN" diff --git a/src/expon/iam/domain/model/queries/get_user_by_email_query.py b/src/expon/iam/domain/model/queries/get_user_by_email_query.py new file mode 100644 index 0000000000000000000000000000000000000000..41aacc99e6532696242aefb2d04b59a5dd5a975f --- /dev/null +++ b/src/expon/iam/domain/model/queries/get_user_by_email_query.py @@ -0,0 +1,6 @@ +from pydantic import EmailStr + + +class GetUserByEmailQuery: + def __init__(self, email: EmailStr): + self.email = email diff --git a/src/expon/iam/domain/model/valueobjects/email.py b/src/expon/iam/domain/model/valueobjects/email.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/expon/iam/domain/services/user_query_service.py b/src/expon/iam/domain/services/user_query_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/expon/iam/infrastructure/authorization/sfs/auth_bearer.py b/src/expon/iam/infrastructure/authorization/sfs/auth_bearer.py new file mode 100644 index 0000000000000000000000000000000000000000..96c72fefe19036306653e46058fa0a4566f6d5c6 --- /dev/null +++ b/src/expon/iam/infrastructure/authorization/sfs/auth_bearer.py @@ -0,0 +1,25 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer +from src.expon.iam.infrastructure.tokens.jwt.services.token_service_impl import TokenService +from src.expon.iam.infrastructure.persistence.jpa.repositories.user_repository import UserRepository +from src.expon.shared.infrastructure.dependencies import get_db +from fastapi.security import HTTPAuthorizationCredentials + +oauth2_scheme = HTTPBearer() + +def get_current_user( + token: HTTPAuthorizationCredentials = Depends(oauth2_scheme), + db=Depends(get_db) +): + payload = TokenService.decode_token(token.credentials) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token inválido o expirado" + ) + user_id = payload.get("sub") + user_repo = UserRepository(db) + user = user_repo.find_by_id(user_id) + if not user: + raise HTTPException(status_code=404, detail="Usuario no encontrado") + return user \ No newline at end of file diff --git a/src/expon/iam/infrastructure/authorization/sfs/configuration/web_security_configuration.py b/src/expon/iam/infrastructure/authorization/sfs/configuration/web_security_configuration.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/expon/iam/infrastructure/authorization/sfs/model/user_details_impl.py b/src/expon/iam/infrastructure/authorization/sfs/model/user_details_impl.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/expon/iam/infrastructure/authorization/sfs/model/username_password_auth_token_builder.py b/src/expon/iam/infrastructure/authorization/sfs/model/username_password_auth_token_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/expon/iam/infrastructure/authorization/sfs/pipeline/bearer_authorization_request_filter.py b/src/expon/iam/infrastructure/authorization/sfs/pipeline/bearer_authorization_request_filter.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/expon/iam/infrastructure/authorization/sfs/pipeline/unauthorized_request_handler_entrypoint.py b/src/expon/iam/infrastructure/authorization/sfs/pipeline/unauthorized_request_handler_entrypoint.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/expon/iam/infrastructure/authorization/sfs/user_details_service_impl.py b/src/expon/iam/infrastructure/authorization/sfs/user_details_service_impl.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/expon/iam/infrastructure/hashing/bcrypt/services/bcrypt_hashing_service.py b/src/expon/iam/infrastructure/hashing/bcrypt/services/bcrypt_hashing_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/expon/iam/infrastructure/hashing/bcrypt/services/hashing_service.py b/src/expon/iam/infrastructure/hashing/bcrypt/services/hashing_service.py new file mode 100644 index 0000000000000000000000000000000000000000..92525ecc01622750bb9daed2c4dc13c188018eb4 --- /dev/null +++ b/src/expon/iam/infrastructure/hashing/bcrypt/services/hashing_service.py @@ -0,0 +1,13 @@ +from passlib.context import CryptContext + + +class HashingService: + def __init__(self): + # Puedes ajustar el esquema si deseas usar otro algoritmo como argon2 + self.pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + def hash(self, password: str) -> str: + return self.pwd_context.hash(password) + + def verify(self, plain_password: str, hashed_password: str) -> bool: + return self.pwd_context.verify(plain_password, hashed_password) diff --git a/src/expon/iam/infrastructure/persistence/jpa/entities/user_entity.py b/src/expon/iam/infrastructure/persistence/jpa/entities/user_entity.py new file mode 100644 index 0000000000000000000000000000000000000000..4ac6112c73d99bccfccf9b9ee3d8e2fbdd0489b3 --- /dev/null +++ b/src/expon/iam/infrastructure/persistence/jpa/entities/user_entity.py @@ -0,0 +1,16 @@ +from sqlalchemy import Column, String, DateTime +from uuid import uuid4 +from sqlalchemy.dialects.postgresql import UUID +from datetime import datetime +from src.expon.shared.infrastructure.database import Base + + +class UserEntity(Base): + __tablename__ = "users" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) + username = Column(String(50), unique=True, nullable=False) + email = Column(String(100), unique=True, nullable=False) + password = Column(String(200), nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/src/expon/iam/infrastructure/persistence/jpa/repositories/role_repository.py b/src/expon/iam/infrastructure/persistence/jpa/repositories/role_repository.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/expon/iam/infrastructure/persistence/jpa/repositories/user_repository.py b/src/expon/iam/infrastructure/persistence/jpa/repositories/user_repository.py new file mode 100644 index 0000000000000000000000000000000000000000..5531f58e774733d7e13da6a2972a91b6e15433f8 --- /dev/null +++ b/src/expon/iam/infrastructure/persistence/jpa/repositories/user_repository.py @@ -0,0 +1,48 @@ +from sqlalchemy.orm import Session +from src.expon.iam.infrastructure.persistence.jpa.entities.user_entity import UserEntity +from src.expon.iam.domain.model.aggregates.user import User + + +class UserRepository: + def __init__(self, db: Session): + self.db = db + + def save(self, user: User) -> User: + entity = UserEntity( + id=user.id, + username=user.username, + email=user.email, + password=user.password, + created_at=user.created_at, + updated_at=user.updated_at + ) + self.db.add(entity) + self.db.commit() + self.db.refresh(entity) + return user + + def find_by_email(self, email: str) -> User | None: + entity = self.db.query(UserEntity).filter_by(email=email).first() + if not entity: + return None + return User( + id=entity.id, + username=entity.username, + email=entity.email, + password=entity.password, + created_at=entity.created_at, + updated_at=entity.updated_at + ) + + def find_by_id(self, user_id: str) -> User | None: + entity = self.db.query(UserEntity).filter_by(id=user_id).first() + if not entity: + return None + return User( + id=entity.id, + username=entity.username, + email=entity.email, + password=entity.password, + created_at=entity.created_at, + updated_at=entity.updated_at + ) diff --git a/src/expon/iam/infrastructure/tokens/jwt/services/bearer_token_service.py b/src/expon/iam/infrastructure/tokens/jwt/services/bearer_token_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/expon/iam/infrastructure/tokens/jwt/services/token_service_impl.py b/src/expon/iam/infrastructure/tokens/jwt/services/token_service_impl.py new file mode 100644 index 0000000000000000000000000000000000000000..96626b6f39a95649ca14b88eea95572c9e966413 --- /dev/null +++ b/src/expon/iam/infrastructure/tokens/jwt/services/token_service_impl.py @@ -0,0 +1,29 @@ +import jwt +from datetime import datetime, timedelta +from src.expon.iam.domain.model.aggregates.user import User +from decouple import config + +class TokenService: + SECRET_KEY = config("JWT_SECRET_KEY", default="secret") + ALGORITHM = "HS256" + EXPIRE_MINUTES = 60 + + @classmethod + def generate_token(cls, user: User) -> str: + payload = { + "sub": str(user.id), + "username": user.username, + "email": user.email, + "exp": datetime.utcnow() + timedelta(minutes=cls.EXPIRE_MINUTES) + } + return jwt.encode(payload, cls.SECRET_KEY, algorithm=cls.ALGORITHM) + + @classmethod + def decode_token(cls, token: str) -> dict | None: + try: + payload = jwt.decode(token, cls.SECRET_KEY, algorithms=[cls.ALGORITHM]) + return payload + except jwt.ExpiredSignatureError: + return None + except jwt.InvalidTokenError: + return None diff --git a/src/expon/iam/interfaces/rest/controllers/auth_controller.py b/src/expon/iam/interfaces/rest/controllers/auth_controller.py new file mode 100644 index 0000000000000000000000000000000000000000..4a98b8bbcfd8c847555723e84ec680ede84a0e46 --- /dev/null +++ b/src/expon/iam/interfaces/rest/controllers/auth_controller.py @@ -0,0 +1,74 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +# Commands y modelos +from src.expon.iam.domain.model.commands.sign_up_command import SignUpCommand +from src.expon.iam.domain.model.aggregates.user import User + +# Servicios de dominio +from src.expon.iam.application.internal.commandservices.user_command_service import UserCommandService + +# Infraestructura: hashing y token +from src.expon.iam.infrastructure.hashing.bcrypt.services.hashing_service import HashingService +from src.expon.iam.infrastructure.tokens.jwt.services.token_service_impl import TokenService + +# Infraestructura: repositorio e inyección de dependencias +from src.expon.iam.infrastructure.persistence.jpa.repositories.user_repository import UserRepository +from src.expon.shared.infrastructure.dependencies import get_db + +# Middleware JWT +from src.expon.iam.infrastructure.authorization.sfs.auth_bearer import get_current_user + +# Esquemas (DTOs REST) +from src.expon.iam.interfaces.rest.schemas.login_request import LoginRequest +from src.expon.iam.interfaces.rest.schemas.auth_response import AuthResponse + +router = APIRouter() + + +@router.post("/signup") +def signup(command: SignUpCommand, db: Session = Depends(get_db)): + user_repository = UserRepository(db) + hashing_service = HashingService() + user_command_service = UserCommandService(user_repository, hashing_service) + + existing_user = user_repository.find_by_email(command.email) + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + user = user_command_service.handle_sign_up(command) + return { + "id": str(user.id), + "username": user.username, + "email": user.email, + "created_at": user.created_at.isoformat() + } + + +@router.post("/login", response_model=AuthResponse) +def login(request: LoginRequest, db: Session = Depends(get_db)): + user_repository = UserRepository(db) + hashing_service = HashingService() + + user = user_repository.find_by_email(request.email) + if not user or not hashing_service.verify(request.password, user.password): + raise HTTPException(status_code=401, detail="Credenciales inválidas") + + token = TokenService.generate_token(user) + return AuthResponse( + access_token=token, + token_type="bearer", + user_id=str(user.id) + ) + + +@router.get("/me") +def get_me(current_user: User = Depends(get_current_user)): + return { + "id": str(current_user.id), + "username": current_user.username, + "email": current_user.email + } diff --git a/src/expon/iam/interfaces/rest/schemas/auth_response.py b/src/expon/iam/interfaces/rest/schemas/auth_response.py new file mode 100644 index 0000000000000000000000000000000000000000..5896268c4541bbbeb0506821a6103f9ba9bd1dbe --- /dev/null +++ b/src/expon/iam/interfaces/rest/schemas/auth_response.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + +class AuthResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user_id: str diff --git a/src/expon/iam/interfaces/rest/schemas/login_request.py b/src/expon/iam/interfaces/rest/schemas/login_request.py new file mode 100644 index 0000000000000000000000000000000000000000..2a065f4b5031d5770bbcc1aa183f1cf8b0c20533 --- /dev/null +++ b/src/expon/iam/interfaces/rest/schemas/login_request.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, EmailStr + +class LoginRequest(BaseModel): + email: EmailStr + password: str diff --git a/src/expon/presentation/application/internal/commandservices/audio_upload_service.py b/src/expon/presentation/application/internal/commandservices/audio_upload_service.py new file mode 100644 index 0000000000000000000000000000000000000000..477bb6a5e0d7d23b70d7245844e832f0be16fd5b --- /dev/null +++ b/src/expon/presentation/application/internal/commandservices/audio_upload_service.py @@ -0,0 +1,71 @@ +from uuid import UUID, uuid4 +from datetime import datetime, timezone +from fastapi import UploadFile +from pydub.utils import mediainfo + +from src.expon.presentation.domain.model.aggregates.presentation import Presentation +from src.expon.presentation.domain.model.valueobjects.audio_metadata import AudioMetadata +from src.expon.presentation.domain.services.transcription_service import TranscriptionService +from src.expon.presentation.domain.services.sentiment_analysis_service import SentimentAnalysisService +from src.expon.presentation.infrastructure.persistence.jpa.repositories.presentation_repository import PresentationRepository +from src.expon.presentation.infrastructure.services.storage.local_storage_service import LocalStorageService + +import os + +class AudioUploadService: + def __init__( + self, + storage_service: LocalStorageService, + transcription_service: TranscriptionService, + sentiment_service: SentimentAnalysisService, + repository: PresentationRepository + ): + self.storage_service = storage_service + self.transcription_service = transcription_service + self.sentiment_service = sentiment_service + self.repository = repository + + def upload_and_analyze(self, file: UploadFile, user_id: UUID = UUID("00000000-0000-0000-0000-000000000000")): + # 1. Guardar archivo original + file_path = self.storage_service.save(file) + + # 2. Transcribir directamente con AssemblyAI + result = self.transcription_service.transcribe(file_path) + + transcript = result["text"] + confidence = result.get("confidence", 1.0) + + # 3. Simular metadata básica (AssemblyAI no devuelve duración ni sample_rate) + metadata = AudioMetadata( + duration=0.0, # Placeholder, se puede estimar si se requiere + sample_rate=16000, # Valor asumido estándar + language="es" + ) + + # 4. Analizar emoción + emotion_data = self.sentiment_service.analyze(transcript) + print("[DEBUG] Transcripción exitosa. Texto:", transcript[:50]) + + # 5. Crear entidad Presentation + presentation = Presentation( + id=uuid4(), + user_id=user_id, + filename=file.filename, + transcript=transcript, + dominant_emotion=emotion_data["dominant_emotion"], + emotion_probabilities=emotion_data["emotion_probabilities"], + confidence=emotion_data["confidence"], + metadata=metadata, + created_at=datetime.now(timezone.utc) + ) + + # 6. Guardar en base de datos + self.repository.save(presentation) + + # 7. Eliminar archivo temporal + try: + os.remove(file_path) + except Exception: + pass + + return presentation diff --git a/src/expon/presentation/application/internal/queryservices/presentation_query_service.py b/src/expon/presentation/application/internal/queryservices/presentation_query_service.py new file mode 100644 index 0000000000000000000000000000000000000000..fb19f730d08e78737ac39539d041fbeeaaa1926b --- /dev/null +++ b/src/expon/presentation/application/internal/queryservices/presentation_query_service.py @@ -0,0 +1,13 @@ +from typing import List, Optional +from src.expon.presentation.infrastructure.persistence.jpa.repositories.presentation_repository import PresentationRepository +from src.expon.presentation.domain.model.aggregates.presentation import Presentation + +class PresentationQueryService: + def __init__(self, repository: PresentationRepository): + self.repository = repository + + def get_presentations_by_user(self, user_id: int) -> List[Presentation]: + return self.repository.get_by_user_id(user_id) + + def get_presentation_by_id_and_user(self, presentation_id: int, user_id: int) -> Optional[Presentation]: + return self.repository.get_by_id_and_user(presentation_id, user_id) diff --git a/src/expon/presentation/domain/model/aggregates/presentation.py b/src/expon/presentation/domain/model/aggregates/presentation.py new file mode 100644 index 0000000000000000000000000000000000000000..1d68a40935f0f9600b5c002b3d8ebd2b385939b0 --- /dev/null +++ b/src/expon/presentation/domain/model/aggregates/presentation.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from uuid import UUID +from datetime import datetime +from typing import Dict +from src.expon.presentation.domain.model.valueobjects.audio_metadata import AudioMetadata + +@dataclass +class Presentation: + id: UUID + user_id: UUID + filename: str + transcript: str + dominant_emotion: str # <- antes era 'sentiment' + emotion_probabilities: Dict[str, float] # <- nuevo campo + confidence: float + created_at: datetime + metadata: AudioMetadata diff --git a/src/expon/presentation/domain/model/valueobjects/audio_metadata.py b/src/expon/presentation/domain/model/valueobjects/audio_metadata.py new file mode 100644 index 0000000000000000000000000000000000000000..fce0055c9978e9f8943947783091681f7a97fa93 --- /dev/null +++ b/src/expon/presentation/domain/model/valueobjects/audio_metadata.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + +@dataclass +class AudioMetadata: + duration: float # en segundos + sample_rate: int + language: str diff --git a/src/expon/presentation/domain/services/assemblyai_service.py b/src/expon/presentation/domain/services/assemblyai_service.py new file mode 100644 index 0000000000000000000000000000000000000000..1d7bb26c5b6030a97d03a7c7c81b8b60f8df8a73 --- /dev/null +++ b/src/expon/presentation/domain/services/assemblyai_service.py @@ -0,0 +1,36 @@ +# presentation/infrastructure/services/transcription/assemblyai_service.py +import requests +import time + +ASSEMBLYAI_API_KEY = "550f6809220c48b29da16e609ab5ae44" +UPLOAD_ENDPOINT = "https://api.assemblyai.com/v2/upload" +TRANSCRIPT_ENDPOINT = "https://api.assemblyai.com/v2/transcript" + +headers = { + "authorization": ASSEMBLYAI_API_KEY +} + +def upload_audio(file_path: str) -> str: + with open(file_path, "rb") as f: + response = requests.post(UPLOAD_ENDPOINT, headers=headers, files={'file': f}) + response.raise_for_status() + return response.json()["upload_url"] + +def transcribe_audio(upload_url: str) -> dict: + transcript_request = { + "audio_url": upload_url, + "language_code": "es" # Español + } + response = requests.post(TRANSCRIPT_ENDPOINT, json=transcript_request, headers=headers) + response.raise_for_status() + transcript_id = response.json()["id"] + + # Polling: esperar hasta que se procese + while True: + polling_response = requests.get(f"{TRANSCRIPT_ENDPOINT}/{transcript_id}", headers=headers) + polling_data = polling_response.json() + if polling_data["status"] == "completed": + return polling_data + elif polling_data["status"] == "error": + raise Exception(f"Transcripción falló: {polling_data['error']}") + time.sleep(3) diff --git a/src/expon/presentation/domain/services/sentiment_analysis_service.py b/src/expon/presentation/domain/services/sentiment_analysis_service.py new file mode 100644 index 0000000000000000000000000000000000000000..0bc5611d5e3f299c8ba4ace314863e6366e8bf27 --- /dev/null +++ b/src/expon/presentation/domain/services/sentiment_analysis_service.py @@ -0,0 +1,73 @@ +from transformers import pipeline +from typing import Dict +from huggingface_hub import login +import shutil +import os + +class SentimentAnalysisService: + def __init__(self): + try: + print("[LOG] Intentando iniciar sesión en Hugging Face...") + token = os.getenv("HUGGINGFACE_TOKEN") + if not token: + raise ValueError("No se encontró HUGGINGFACE_TOKEN en variables de entorno") + login(token) + print("[LOG] Sesión iniciada correctamente.") + except Exception as e: + print("[ERROR] Falló el login:", e) + raise + + try: + print("[LOG] Cargando pipeline...") + self.pipeline = pipeline( + "text2text-generation", # ← importante: este es el task correcto para T5 + model="mrm8488/t5-base-finetuned-emotion" # ← nombre correcto del modelo + ) + print("[LOG] Pipeline cargado correctamente.") + except Exception as e: + print("[ERROR] Falló la carga del modelo:", e) + raise + + def analyze(self, transcript: str) -> Dict: + print("[LOG] Análisis de transcripción recibido.") + prompt = f"emocion: {transcript}" + try: + output = self.pipeline(prompt, max_length=20) + print("[LOG] Resultado del modelo:", output) + raw_emotion = output[0]['generated_text'].strip().lower() + except Exception as e: + print("[ERROR] Falló la predicción:", e) + return { + "dominant_emotion": "error", + "emotion_probabilities": {}, + "confidence": 0.0 + } + + emotion_mapping = { + "confianza": "motivado", + "alegría": "entusiasta", + "tristeza": "desmotivado", + "miedo": "ansioso", + "enfado": "frustrado", + "amor": "conectado", + "sorpresa": "sorprendido", + # etiquetas del modelo (en inglés) + "joy": "entusiasta", + "fear": "ansioso", + "anger": "frustrado", + "love": "conectado", + "surprise": "sorprendido", + "sadness": "desmotivado", + "trust": "motivado" + } + + + mapped_emotion = emotion_mapping.get(raw_emotion, "desconocido") + + return { + "dominant_emotion": mapped_emotion, + "emotion_probabilities": { + mapped_emotion: 1.0 + }, + "confidence": 1.0 + } diff --git a/src/expon/presentation/domain/services/transcription_service.py b/src/expon/presentation/domain/services/transcription_service.py new file mode 100644 index 0000000000000000000000000000000000000000..4ae767267c45e4b1dd90d54eec2bc66fa09b63fd --- /dev/null +++ b/src/expon/presentation/domain/services/transcription_service.py @@ -0,0 +1,54 @@ +import os +import requests +import time +from tempfile import NamedTemporaryFile + +ASSEMBLYAI_API_KEY = "550f6809220c48b29da16e609ab5ae44" +UPLOAD_URL = "https://api.assemblyai.com/v2/upload" +TRANSCRIBE_URL = "https://api.assemblyai.com/v2/transcript" + +class TranscriptionService: + def transcribe(self, file_path: str) -> dict: + print(f"[DEBUG] Transcribiendo desde archivo: {file_path}") + if not os.path.exists(file_path): + raise Exception("Archivo no encontrado para transcripción") + + if os.path.getsize(file_path) == 0: + raise Exception("El archivo está vacío. Verifica que se haya subido correctamente.") + + try: + # Paso 1: Subir archivo + with open(file_path, "rb") as f: + upload_res = requests.post( + UPLOAD_URL, + headers={"authorization": ASSEMBLYAI_API_KEY}, + data=f + ) + upload_res.raise_for_status() + audio_url = upload_res.json()["upload_url"] + + # Paso 2: Solicitar transcripción + transcript_res = requests.post( + TRANSCRIBE_URL, + json={"audio_url": audio_url, "language_code": "es"}, + headers={"authorization": ASSEMBLYAI_API_KEY} + ) + transcript_res.raise_for_status() + transcript_id = transcript_res.json()["id"] + + # Paso 3: Polling + while True: + poll_res = requests.get(f"{TRANSCRIBE_URL}/{transcript_id}", headers={"authorization": ASSEMBLYAI_API_KEY}) + poll_data = poll_res.json() + if poll_data["status"] == "completed": + return { + "text": poll_data["text"], + "confidence": poll_data.get("confidence", 1.0) + } + elif poll_data["status"] == "error": + raise Exception(f"Error en AssemblyAI: {poll_data['error']}") + time.sleep(3) + + except Exception as e: + print(f"[ERROR] Error en transcripción: {e}") + raise diff --git a/src/expon/presentation/infrastructure/persistence/jpa/mappers/presentation_mapper.py b/src/expon/presentation/infrastructure/persistence/jpa/mappers/presentation_mapper.py new file mode 100644 index 0000000000000000000000000000000000000000..6d61423f1e65751314059df3c9e13a99f0146f5e --- /dev/null +++ b/src/expon/presentation/infrastructure/persistence/jpa/mappers/presentation_mapper.py @@ -0,0 +1,38 @@ +from src.expon.presentation.domain.model.aggregates.presentation import Presentation +from src.expon.presentation.domain.model.valueobjects.audio_metadata import AudioMetadata +from src.expon.presentation.infrastructure.persistence.jpa.models.presentation_orm import PresentationORM + +class PresentationMapper: + + def to_domain(self, orm: PresentationORM) -> Presentation: + metadata = AudioMetadata( + duration=orm.duration, + sample_rate=orm.sample_rate, + language=orm.language + ) + return Presentation( + id=orm.id, + user_id=orm.user_id, + filename=orm.filename, + transcript=orm.transcript, + dominant_emotion=orm.dominant_emotion, + emotion_probabilities=orm.emotion_probabilities, + confidence=orm.confidence, + metadata=metadata, + created_at=orm.created_at + ) + + def to_orm(self, entity: Presentation) -> PresentationORM: + return PresentationORM( + id=entity.id, + user_id=entity.user_id, + filename=entity.filename, + transcript=entity.transcript, + dominant_emotion=entity.dominant_emotion, + emotion_probabilities=entity.emotion_probabilities, + confidence=entity.confidence, + duration=entity.metadata.duration, + sample_rate=entity.metadata.sample_rate, + language=entity.metadata.language, + created_at=entity.created_at + ) diff --git a/src/expon/presentation/infrastructure/persistence/jpa/models/presentation_orm.py b/src/expon/presentation/infrastructure/persistence/jpa/models/presentation_orm.py new file mode 100644 index 0000000000000000000000000000000000000000..a9048619dbc3dbc62f28993152455cefed725c96 --- /dev/null +++ b/src/expon/presentation/infrastructure/persistence/jpa/models/presentation_orm.py @@ -0,0 +1,23 @@ +from sqlalchemy import Column, String, Float, DateTime, ForeignKey, JSON +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from src.expon.shared.infrastructure.database import Base +from sqlalchemy.dialects.postgresql import JSONB +import datetime + +class PresentationORM(Base): + __tablename__ = "presentations" + + id = Column(PGUUID(as_uuid=True), primary_key=True, index=True) + user_id = Column(PGUUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + + filename = Column(String, nullable=False) + transcript = Column(String, nullable=True) + dominant_emotion = Column(String, nullable=True) # <- antes 'sentiment' + emotion_probabilities = Column(JSONB, nullable=True) # requiere PostgreSQL + confidence = Column(Float, nullable=True) + + duration = Column(Float, nullable=True) + sample_rate = Column(Float, nullable=True) + language = Column(String, nullable=True) + + created_at = Column(DateTime, default=datetime.datetime.utcnow) diff --git a/src/expon/presentation/infrastructure/persistence/jpa/repositories/presentation_repository.py b/src/expon/presentation/infrastructure/persistence/jpa/repositories/presentation_repository.py new file mode 100644 index 0000000000000000000000000000000000000000..5da491e770c6768016f35fc9a7db6796d7801aaa --- /dev/null +++ b/src/expon/presentation/infrastructure/persistence/jpa/repositories/presentation_repository.py @@ -0,0 +1,40 @@ +from sqlalchemy.orm import Session +from src.expon.presentation.domain.model.aggregates.presentation import Presentation +from src.expon.presentation.domain.model.valueobjects.audio_metadata import AudioMetadata +from src.expon.presentation.infrastructure.persistence.jpa.models.presentation_orm import PresentationORM +from typing import List, Optional +from src.expon.presentation.infrastructure.persistence.jpa.mappers.presentation_mapper import PresentationMapper + +class PresentationRepository: + def __init__(self, db: Session): + self.db = db + self.mapper = PresentationMapper() + + def save(self, presentation: Presentation) -> None: + db_model = PresentationORM( + id=presentation.id, + user_id=presentation.user_id, + filename=presentation.filename, + transcript=presentation.transcript, + dominant_emotion=presentation.dominant_emotion, + emotion_probabilities=presentation.emotion_probabilities, + confidence=presentation.confidence, + duration=presentation.metadata.duration, + sample_rate=presentation.metadata.sample_rate, + language=presentation.metadata.language, + created_at=presentation.created_at + ) + self.db.add(db_model) + self.db.commit() + # return db_model # Descomenta si necesitas retornar el objeto guardado + + def get_by_id(self, presentation_id: str) -> Optional[PresentationORM]: + return self.db.query(PresentationORM).filter(PresentationORM.id == presentation_id).first() + + def get_by_user_id(self, user_id: int) -> List[Presentation]: + entities = self.db.query(PresentationORM).filter_by(user_id=user_id).all() + return [self.mapper.to_domain(e) for e in entities] + + def get_by_id_and_user(self, presentation_id: int, user_id: int) -> Optional[Presentation]: + entity = self.db.query(PresentationORM).filter_by(id=presentation_id, user_id=user_id).first() + return self.mapper.to_domain(entity) if entity else None diff --git a/src/expon/presentation/infrastructure/services/audio/audio_converter.py b/src/expon/presentation/infrastructure/services/audio/audio_converter.py new file mode 100644 index 0000000000000000000000000000000000000000..116b9e44679c511b5e7192b6a87a170cc27484c7 --- /dev/null +++ b/src/expon/presentation/infrastructure/services/audio/audio_converter.py @@ -0,0 +1,18 @@ +import ffmpeg +import uuid +import os + +class AudioConverterService: + def convert_to_pcm(self, input_path: str) -> str: + output_path = f"temp_{uuid.uuid4().hex}.wav" + try: + ( + ffmpeg + .input(input_path) + .output(output_path, ac=1, ar=16000, sample_fmt='s16') + .overwrite_output() + .run(quiet=True) + ) + return output_path + except ffmpeg.Error as e: + raise RuntimeError("Error al convertir el archivo a formato PCM 16-bit mono") from e diff --git a/src/expon/presentation/infrastructure/services/audio/transcriber_google.py b/src/expon/presentation/infrastructure/services/audio/transcriber_google.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/expon/presentation/infrastructure/services/audio/transcriber_vosk.py b/src/expon/presentation/infrastructure/services/audio/transcriber_vosk.py new file mode 100644 index 0000000000000000000000000000000000000000..3d04f1ef3aac19e9e46acb23ad60079d71224d4b --- /dev/null +++ b/src/expon/presentation/infrastructure/services/audio/transcriber_vosk.py @@ -0,0 +1,35 @@ +# presentation/infrastructure/services/audio/transcriber_vosk.py +from vosk import Model, KaldiRecognizer +import wave +import json +import os +from audio_converter import convert_to_pcm16_mono + +model = Model("models/vosk-model-small-es-0.42") + +def transcribe_audio(file_path: str) -> str: + converted_path = convert_to_pcm16_mono(file_path) + + wf = wave.open(converted_path, "rb") + rec = KaldiRecognizer(model, wf.getframerate()) + result_text = [] + + while True: + data = wf.readframes(4000) + if len(data) == 0: + break + if rec.AcceptWaveform(data): + res = json.loads(rec.Result()) + result_text.append(res.get("text", "")) + + final = json.loads(rec.FinalResult()) + result_text.append(final.get("text", "")) + + wf.close() + if converted_path != file_path: + try: + os.remove(converted_path) + except Exception: + pass + + return " ".join(result_text) diff --git a/src/expon/presentation/infrastructure/services/storage/local_storage_service.py b/src/expon/presentation/infrastructure/services/storage/local_storage_service.py new file mode 100644 index 0000000000000000000000000000000000000000..c4e96af7f54c609c784ee2fe62ccfb9599296274 --- /dev/null +++ b/src/expon/presentation/infrastructure/services/storage/local_storage_service.py @@ -0,0 +1,20 @@ +import os +from fastapi import UploadFile +from uuid import uuid4 +from pathlib import Path +import shutil + +class LocalStorageService: + def __init__(self, base_path: str = "storage/audio"): + self.base_path = base_path + os.makedirs(self.base_path, exist_ok=True) + + def save(self, file: UploadFile) -> str: + + filename = f"{uuid4()}_{file.filename}" + file_path = os.path.join(self.base_path, filename) + + with open(file_path, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + return file_path diff --git a/src/expon/presentation/interfaces/rest/controllers/presentation_controller.py b/src/expon/presentation/interfaces/rest/controllers/presentation_controller.py new file mode 100644 index 0000000000000000000000000000000000000000..f637ca0d3a2c509f1d673d868cc10a9e81ebe571 --- /dev/null +++ b/src/expon/presentation/interfaces/rest/controllers/presentation_controller.py @@ -0,0 +1,117 @@ +from fastapi import APIRouter, UploadFile, File, Depends, HTTPException +from sqlalchemy.orm import Session +from starlette.datastructures import UploadFile as StarletteUploadFile +from uuid import UUID # 👈 Agregado + +from src.expon.shared.infrastructure.dependencies import get_db +from src.expon.iam.infrastructure.authorization.sfs.auth_bearer import get_current_user + +from src.expon.presentation.application.internal.commandservices.audio_upload_service import AudioUploadService +from src.expon.presentation.application.internal.queryservices.presentation_query_service import PresentationQueryService + +from src.expon.presentation.infrastructure.persistence.jpa.repositories.presentation_repository import PresentationRepository +from src.expon.presentation.infrastructure.services.storage.local_storage_service import LocalStorageService +from src.expon.presentation.domain.services.transcription_service import TranscriptionService +from src.expon.presentation.domain.services.sentiment_analysis_service import SentimentAnalysisService + +from src.expon.presentation.interfaces.rest.responses.presentation_response import ( + PresentationResponse, + AudioMetadataResponse, + PresentationSummaryResponse +) + +router = APIRouter() + + +@router.post("/upload", response_model=PresentationResponse) +def upload_presentation( + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + try: + contents = file.file.read() + if not contents: + raise HTTPException(status_code=400, detail="El archivo está vacío.") + file.file.seek(0) + + repository = PresentationRepository(db) + storage_service = LocalStorageService() + transcription_service = TranscriptionService() + sentiment_service = SentimentAnalysisService() + + service = AudioUploadService( + storage_service=storage_service, + transcription_service=transcription_service, + sentiment_service=sentiment_service, + repository=repository + ) + + presentation = service.upload_and_analyze(file, current_user.id) + + return PresentationResponse( + id=presentation.id, + transcript=presentation.transcript, + dominant_emotion=presentation.dominant_emotion, + emotion_probabilities=presentation.emotion_probabilities, + confidence=presentation.confidence, + filename=presentation.filename, + metadata=AudioMetadataResponse( + duration=presentation.metadata.duration, + sample_rate=presentation.metadata.sample_rate, + language=presentation.metadata.language + ), + created_at=presentation.created_at + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error al procesar el archivo: {str(e)}") + +@router.get("/summary", response_model=list[PresentationSummaryResponse]) +def get_presentations_summary( + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + repository = PresentationRepository(db) + query_service = PresentationQueryService(repository) + presentations = query_service.get_presentations_by_user(current_user.id) + + return [ + PresentationSummaryResponse( + id=p.id, + filename=p.filename, + dominant_emotion=p.dominant_emotion, + confidence=p.confidence, + created_at=p.created_at + ) for p in presentations + ] + +@router.get("/{presentation_id}", response_model=PresentationResponse) +def get_presentation_by_id( + presentation_id: UUID, # 👈 Cambiado de int a UUID + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + repository = PresentationRepository(db) + query_service = PresentationQueryService(repository) + presentation = query_service.get_presentation_by_id_and_user(presentation_id, current_user.id) + + if presentation is None: + raise HTTPException(status_code=404, detail="Presentación no encontrada") + + return PresentationResponse( + id=presentation.id, + transcript=presentation.transcript, + dominant_emotion=presentation.dominant_emotion, + emotion_probabilities=presentation.emotion_probabilities, + confidence=presentation.confidence, + filename=presentation.filename, + metadata=AudioMetadataResponse( + duration=presentation.metadata.duration, + sample_rate=presentation.metadata.sample_rate, + language=presentation.metadata.language + ), + created_at=presentation.created_at + ) + + diff --git a/src/expon/presentation/interfaces/rest/dependencies/presentation_dependencies.py b/src/expon/presentation/interfaces/rest/dependencies/presentation_dependencies.py new file mode 100644 index 0000000000000000000000000000000000000000..f8c96de3b342a8bd50c44fa5999481d26bf47b93 --- /dev/null +++ b/src/expon/presentation/interfaces/rest/dependencies/presentation_dependencies.py @@ -0,0 +1,20 @@ +from src.expon.presentation.application.internal.commandservices.audio_upload_service import AudioUploadService +from src.expon.presentation.domain.services.transcription_service import TranscriptionService +from src.expon.presentation.domain.services.sentiment_analysis_service import SentimentAnalysisService + +# MOCKS temporales +from src.expon.presentation.infrastructure.services.storage.local_storage_service import LocalStorageService +from src.expon.presentation.infrastructure.persistence.jpa.repositories.presentation_repository import PresentationRepository + +def get_audio_upload_service() -> AudioUploadService: + storage_service = LocalStorageService() # → luego implementaremos + transcription_service = TranscriptionService() + sentiment_service = SentimentAnalysisService() + repository = PresentationRepository() # → luego implementaremos + + return AudioUploadService( + storage_service=storage_service, + transcription_service=transcription_service, + sentiment_service=sentiment_service, + repository=repository + ) diff --git a/src/expon/presentation/interfaces/rest/requests/presentation_upload_request.py b/src/expon/presentation/interfaces/rest/requests/presentation_upload_request.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/expon/presentation/interfaces/rest/resources/audio_upload_resource.py b/src/expon/presentation/interfaces/rest/resources/audio_upload_resource.py new file mode 100644 index 0000000000000000000000000000000000000000..ef63ef8de4d16929b52bc6295f6546b86d7bbd87 --- /dev/null +++ b/src/expon/presentation/interfaces/rest/resources/audio_upload_resource.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter, UploadFile, File, Depends +from src.expon.presentation.application.internal.commandservices.audio_upload_service import AudioUploadService + +router = APIRouter() + +# Este endpoint recibe un archivo y lo procesa +@router.post("/upload-audio") +async def upload_audio( + file: UploadFile = File(...), + service: AudioUploadService = Depends() +): + presentation = service.upload_and_analyze(file) + return { + "id": str(presentation.id), + "filename": presentation.filename, + "transcript": presentation.transcript, + "dominant_emotion": presentation.dominant_emotion, + "emotion_probabilities": presentation.emotion_probabilities, + "confidence": presentation.confidence, + "metadata": { + "duration": presentation.metadata.duration, + "sample_rate": presentation.metadata.sample_rate, + "language": presentation.metadata.language, + }, + "created_at": presentation.created_at.isoformat() + } diff --git a/src/expon/presentation/interfaces/rest/responses/presentation_response.py b/src/expon/presentation/interfaces/rest/responses/presentation_response.py new file mode 100644 index 0000000000000000000000000000000000000000..197928f5d4bfaf1a4694d2222f301053170fd5bd --- /dev/null +++ b/src/expon/presentation/interfaces/rest/responses/presentation_response.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel +from typing import Dict, Optional +from datetime import datetime +from uuid import UUID + +class AudioMetadataResponse(BaseModel): + duration: float + sample_rate: int + language: str + +class PresentationResponse(BaseModel): + id: UUID + transcript: str + dominant_emotion: Optional[str] + emotion_probabilities: Dict[str, float] + confidence: float + filename: str + metadata: AudioMetadataResponse + created_at: datetime + +class PresentationSummaryResponse(BaseModel): + id: UUID + filename: str + dominant_emotion: str + confidence: float + created_at: datetime \ No newline at end of file diff --git a/src/expon/profile/application/internal/commandservices/profile_command_service.py b/src/expon/profile/application/internal/commandservices/profile_command_service.py new file mode 100644 index 0000000000000000000000000000000000000000..107a4633b41d316d1df122552afb9219d566749c --- /dev/null +++ b/src/expon/profile/application/internal/commandservices/profile_command_service.py @@ -0,0 +1,25 @@ +from src.expon.profile.infrastructure.persistence.jpa.repositories.profile_repository import ProfileRepository +from src.expon.profile.domain.model.aggregates.user_profile import UserProfile + +class ProfileCommandService: + def __init__(self, repository: ProfileRepository): + self.repository = repository + + def get_profile_by_user(self, user_id): + return self.repository.get_by_user_id(user_id) + + def update_profile(self, user_id, data: dict): + existing = self.repository.get_by_user_id(user_id) + if not existing: + raise Exception("Profile not found") + + # Actualiza solo los campos presentes en el dict + for key, value in data.items(): + if hasattr(existing, key): + setattr(existing, key, value) + + return self.repository.save_or_update(existing) + + + def get_all_profiles(self): + return self.repository.get_all() \ No newline at end of file diff --git a/src/expon/profile/domain/model/aggregates/user_profile.py b/src/expon/profile/domain/model/aggregates/user_profile.py new file mode 100644 index 0000000000000000000000000000000000000000..0378e649b5d25b1785d322f1156a2c1628fb54bd --- /dev/null +++ b/src/expon/profile/domain/model/aggregates/user_profile.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass +from uuid import UUID +from typing import Optional + +@dataclass +class UserProfile: + id: Optional[UUID] + user_id: UUID + + full_name: Optional[str] + university: Optional[str] + career: Optional[str] + + first_name: Optional[str] + last_name: Optional[str] + gender: Optional[str] + profile_picture: Optional[str] + preferred_presentation: Optional[str] diff --git a/src/expon/profile/infrastructure/persistence/jpa/mappers/user_profile_mapper.py b/src/expon/profile/infrastructure/persistence/jpa/mappers/user_profile_mapper.py new file mode 100644 index 0000000000000000000000000000000000000000..dfc7ebb89555306039ec2534ad40157f05c944ef --- /dev/null +++ b/src/expon/profile/infrastructure/persistence/jpa/mappers/user_profile_mapper.py @@ -0,0 +1,30 @@ +from src.expon.profile.domain.model.aggregates.user_profile import UserProfile +from src.expon.profile.infrastructure.persistence.jpa.models.user_profile_orm import UserProfileORM + +def to_domain(model: UserProfileORM) -> UserProfile: + return UserProfile( + id=model.id, + user_id=model.user_id, + full_name=model.full_name, + university=model.university, + career=model.career, + first_name=model.first_name, + last_name=model.last_name, + gender=model.gender, + profile_picture=model.profile_picture, + preferred_presentation=model.preferred_presentation + ) + +def to_orm(domain: UserProfile) -> UserProfileORM: + return UserProfileORM( + id=domain.id, + user_id=domain.user_id, + full_name=domain.full_name, + university=domain.university, + career=domain.career, + first_name=domain.first_name, + last_name=domain.last_name, + gender=domain.gender, + profile_picture=domain.profile_picture, + preferred_presentation=domain.preferred_presentation + ) diff --git a/src/expon/profile/infrastructure/persistence/jpa/models/user_profile_orm.py b/src/expon/profile/infrastructure/persistence/jpa/models/user_profile_orm.py new file mode 100644 index 0000000000000000000000000000000000000000..d3714fce0960f030af8a851bc9b0dd62bbbc74af --- /dev/null +++ b/src/expon/profile/infrastructure/persistence/jpa/models/user_profile_orm.py @@ -0,0 +1,21 @@ +from sqlalchemy import Column, String, ForeignKey +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from src.expon.shared.infrastructure.database import Base +import uuid + +class UserProfileORM(Base): + __tablename__ = "user_profiles" + + id = Column(PGUUID(as_uuid=True), primary_key=True, index=True, default=uuid.uuid4) + user_id = Column(PGUUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True) + + full_name = Column(String, nullable=True) + university = Column(String, nullable=True) + career = Column(String, nullable=True) + + # Nuevos campos + first_name = Column(String, nullable=True) + last_name = Column(String, nullable=True) + gender = Column(String, nullable=True) + profile_picture = Column(String, nullable=True) + preferred_presentation = Column(String, nullable=True) diff --git a/src/expon/profile/infrastructure/persistence/jpa/repositories/profile_repository.py b/src/expon/profile/infrastructure/persistence/jpa/repositories/profile_repository.py new file mode 100644 index 0000000000000000000000000000000000000000..180f48c4d9535814ef0604edcb8edc5589f92b0a --- /dev/null +++ b/src/expon/profile/infrastructure/persistence/jpa/repositories/profile_repository.py @@ -0,0 +1,35 @@ +from sqlalchemy.orm import Session +from uuid import UUID +from src.expon.profile.infrastructure.persistence.jpa.models.user_profile_orm import UserProfileORM +from src.expon.profile.infrastructure.persistence.jpa.mappers.user_profile_mapper import to_domain, to_orm + +class ProfileRepository: + def __init__(self, db: Session): + self.db = db + + def get_by_user_id(self, user_id: UUID): + orm = self.db.query(UserProfileORM).filter(UserProfileORM.user_id == user_id).first() + return to_domain(orm) if orm else None + + def save_or_update(self, domain_profile): + orm = self.db.query(UserProfileORM).filter(UserProfileORM.user_id == domain_profile.user_id).first() + if orm: + orm.full_name = domain_profile.full_name + orm.university = domain_profile.university + orm.career = domain_profile.career + orm.first_name = domain_profile.first_name + orm.last_name = domain_profile.last_name + orm.gender = domain_profile.gender + orm.profile_picture = domain_profile.profile_picture + orm.preferred_presentation = domain_profile.preferred_presentation + else: + orm = to_orm(domain_profile) + self.db.add(orm) + self.db.commit() + self.db.refresh(orm) + return to_domain(orm) + + def get_all(self): + orm_profiles = self.db.query(UserProfileORM).all() + return [to_domain(orm) for orm in orm_profiles] + diff --git a/src/expon/profile/interfaces/rest/controllers/profile_controller.py b/src/expon/profile/interfaces/rest/controllers/profile_controller.py new file mode 100644 index 0000000000000000000000000000000000000000..150033b2ae79e8fceb9c4e13d50c8b76b55d817b --- /dev/null +++ b/src/expon/profile/interfaces/rest/controllers/profile_controller.py @@ -0,0 +1,68 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from src.expon.shared.infrastructure.dependencies import get_db +from src.expon.iam.infrastructure.authorization.sfs.auth_bearer import get_current_user +from src.expon.profile.infrastructure.persistence.jpa.repositories.profile_repository import ProfileRepository +from src.expon.profile.application.internal.commandservices.profile_command_service import ProfileCommandService +from fastapi import HTTPException +from src.expon.profile.domain.model.aggregates.user_profile import UserProfile +from src.expon.profile.interfaces.rest.requests.user_profile_request import UserProfileRequest + +router = APIRouter() + +@router.get("/me") +def get_my_profile( + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + service = ProfileCommandService(ProfileRepository(db)) + profile = service.get_profile_by_user(current_user.id) + + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + + return profile + +@router.put("/me") +def update_my_profile( + data: UserProfileRequest, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + service = ProfileCommandService(ProfileRepository(db)) + return service.update_profile(current_user.id, data.dict(exclude_unset=True)) + +@router.post("/me") +def create_my_profile( + data: UserProfileRequest, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + service = ProfileCommandService(ProfileRepository(db)) + + existing = service.get_profile_by_user(current_user.id) + if existing: + raise HTTPException(status_code=400, detail="Profile already exists") + + profile = UserProfile( + id=None, + user_id=current_user.id, + full_name=data.full_name, + university=data.university, + career=data.career, + first_name=data.first_name, + last_name=data.last_name, + gender=data.gender, + profile_picture=data.profile_picture, + preferred_presentation=data.preferred_presentation + ) + return service.repository.save_or_update(profile) + + +@router.get("/") +def get_all_profiles( + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + service = ProfileCommandService(ProfileRepository(db)) + return service.get_all_profiles() \ No newline at end of file diff --git a/src/expon/profile/interfaces/rest/requests/user_profile_request.py b/src/expon/profile/interfaces/rest/requests/user_profile_request.py new file mode 100644 index 0000000000000000000000000000000000000000..533bb1e1d8e2549618c3b10bed4667949b44f4f0 --- /dev/null +++ b/src/expon/profile/interfaces/rest/requests/user_profile_request.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel +from typing import Optional + +class UserProfileRequest(BaseModel): + full_name: Optional[str] + university: Optional[str] + career: Optional[str] + first_name: Optional[str] + last_name: Optional[str] + gender: Optional[str] + profile_picture: Optional[str] + preferred_presentation: Optional[str] diff --git a/src/expon/shared/infrastructure/database.py b/src/expon/shared/infrastructure/database.py new file mode 100644 index 0000000000000000000000000000000000000000..2ef141ae2daf79b247f5e36b0115c49508507444 --- /dev/null +++ b/src/expon/shared/infrastructure/database.py @@ -0,0 +1,13 @@ +import os +from dotenv import load_dotenv +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base + +# Cargar variables desde el archivo .env +load_dotenv() + +DATABASE_URL = os.getenv("DATABASE_URL") + +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() \ No newline at end of file diff --git a/src/expon/shared/infrastructure/dependencies.py b/src/expon/shared/infrastructure/dependencies.py new file mode 100644 index 0000000000000000000000000000000000000000..f1c8e5129a69f2eb5db2849bc51014fbc370aa39 --- /dev/null +++ b/src/expon/shared/infrastructure/dependencies.py @@ -0,0 +1,10 @@ +from src.expon.shared.infrastructure.database import SessionLocal +from sqlalchemy.orm import Session +from typing import Generator + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/src/expon/subscription/interfaces/rest/controllers/subscription_controller.py b/src/expon/subscription/interfaces/rest/controllers/subscription_controller.py new file mode 100644 index 0000000000000000000000000000000000000000..4d5089d1c6abc67826ae699c54732a14db496cf6 --- /dev/null +++ b/src/expon/subscription/interfaces/rest/controllers/subscription_controller.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +router = APIRouter() + +@router.get("/") +def get_plan(): + return {"mensaje": "Plan actual del usuario"}