From 91ff49cdec3f2b58e539d5ce685209394719e085 Mon Sep 17 00:00:00 2001 From: icamarillo Date: Mon, 9 Feb 2026 13:28:40 -0700 Subject: [PATCH] feat(backend): Update models and endpoints configuration - Enhanced ticket and comment models with proper relationships - Updated client_profile model for better data handling - Improved auth endpoint with better error handling - Updated main app configuration and imports - Added new dependencies to requirements.txt - Enhanced tickets endpoint with attachment support --- backend/app/api/v1/endpoints/auth.py | 69 ++++++---- backend/app/api/v1/endpoints/tickets.py | 173 +++++++++++++++++++++--- backend/app/core/config.py | 85 ++++++------ backend/app/main.py | 1 + backend/app/models/__init__.py | 4 +- backend/app/models/client_profile.py | 5 +- backend/app/models/comment.py | 5 + backend/app/models/ticket.py | 6 + backend/requirements.txt | 1 + 9 files changed, 257 insertions(+), 92 deletions(-) diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index c9a04d0..97fe54b 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, status, Depends from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +from sqlalchemy.orm import selectinload from pydantic import BaseModel, EmailStr from typing import Optional import structlog @@ -257,41 +258,49 @@ async def get_current_user( detail="Invalid token" ) - # TODO: Fetch actual user from database + user_id = payload.get("sub") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload" + ) + + # Fetch actual user from database + query = select(User).where(User.id == user_id).options( + selectinload(User.tenant) + ) + result = await db.execute(query) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User account is disabled" + ) return { - "id": payload["sub"], - "email": payload["email"], - "role": payload["role"], - "tenant_id": payload["tenant_id"] + "id": str(user.id), + "email": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "role": user.role.value if hasattr(user.role, 'value') else user.role, + "tenant_id": str(user.tenant_id), + "tenant_name": user.tenant.name if user.tenant else None, + "is_active": user.is_active, + "is_two_factor_enabled": user.totp_secret is not None, + "last_login": user.last_login.isoformat() if user.last_login else None, + "created_at": user.created_at.isoformat() } # =================================== # DEPENDENCIES # =================================== - -async def get_current_active_user(token: str = Depends(oauth2_scheme)): - """ - Dependency to get current active user from token. - - Args: - token: Access token - - Returns: - Current user data - - Raises: - HTTPException: If token is invalid or user is inactive - """ - payload = security.verify_token(token) - if not payload: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token", - headers={"WWW-Authenticate": "Bearer"}, - ) - - # TODO: Verify user exists and is active - - return payload \ No newline at end of file +# Dependencies are imported from app.api.deps to avoid duplication +# Use get_current_user and get_current_active_superuser from deps.py \ No newline at end of file diff --git a/backend/app/api/v1/endpoints/tickets.py b/backend/app/api/v1/endpoints/tickets.py index 082a88a..f380ab5 100644 --- a/backend/app/api/v1/endpoints/tickets.py +++ b/backend/app/api/v1/endpoints/tickets.py @@ -3,18 +3,24 @@ Tickets endpoints - ServiceManagerWeb """ from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File +from fastapi.responses import FileResponse from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func +from sqlalchemy.orm import selectinload from typing import List, Optional from datetime import datetime from app.core.database import get_db -from app.api.deps import get_current_user +from app.api.deps import get_current_user, get_current_tenant from app.models.ticket import Ticket, TicketStatus, TicketPriority from app.models.user import User -from app.models.category import Category # ✅ CORREGIDO: Era TicketCategory +from app.models.tenant import Tenant +from app.models.category import Category from app.models.system import System from app.models.comment import TicketComment +from app.models.attachment import TicketAttachment +from app.api.schemas.attachment import AttachmentResponse +from app.core.file_handler import file_handler import uuid router = APIRouter() @@ -573,19 +579,60 @@ async def delete_ticket( return {"message": "Ticket deleted successfully"} # =================================== -# ATTACHMENT ENDPOINTS (placeholder) +# =================================== +# ATTACHMENT ENDPOINTS # =================================== -@router.get("/{ticket_id}/attachments") +@router.get("/{ticket_id}/attachments", response_model=List[AttachmentResponse]) async def get_ticket_attachments( ticket_id: str, db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), + current_tenant: Tenant = Depends(get_current_tenant) ): - """ - Obtener adjuntos de un ticket - """ - return [] + """Obtener adjuntos de un ticket""" + try: + ticket_uuid = uuid.UUID(ticket_id) + except ValueError: + raise HTTPException(status_code=400, detail="ID de ticket inválido") + + # Verificar que el ticket existe y pertenece al tenant + result = await db.execute( + select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id) + ) + ticket = result.scalar_one_or_none() + + if not ticket: + raise HTTPException(status_code=404, detail="Ticket no encontrado") + + # Obtener attachments + result = await db.execute( + select(TicketAttachment) + .where(TicketAttachment.ticket_id == ticket_uuid) + .options(selectinload(TicketAttachment.uploaded_by_user)) + .order_by(TicketAttachment.created_at.desc()) + ) + attachments = result.scalars().all() + + # Construir respuesta + response = [] + for att in attachments: + response.append(AttachmentResponse( + id=att.id, + ticket_id=att.ticket_id, + comment_id=att.comment_id, + uploaded_by=att.uploaded_by, + filename=att.filename, + original_filename=att.original_filename, + mime_type=att.mime_type, + file_size=att.file_size, + file_path=att.file_path, + uploaded_by_name=f"{att.uploaded_by_user.first_name} {att.uploaded_by_user.last_name}" if att.uploaded_by_user else "Unknown", + created_at=att.created_at, + download_url=f"/api/v1/tickets/{ticket_id}/attachments/{att.id}/download" + )) + + return response @router.post("/{ticket_id}/attachments", status_code=status.HTTP_201_CREATED) @@ -593,12 +640,106 @@ async def upload_attachment( ticket_id: str, file: UploadFile = File(...), db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), + current_tenant: Tenant = Depends(get_current_tenant) ): - """ - Subir un archivo adjunto a un ticket - """ - raise HTTPException( - status_code=status.HTTP_501_NOT_IMPLEMENTED, - detail="File uploads not yet implemented" + """Subir un archivo adjunto a un ticket""" + try: + ticket_uuid = uuid.UUID(ticket_id) + except ValueError: + raise HTTPException(status_code=400, detail="ID de ticket inválido") + + # Verificar ticket + result = await db.execute( + select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id) + ) + ticket = result.scalar_one_or_none() + + if not ticket: + raise HTTPException(status_code=404, detail="Ticket no encontrado") + + # Guardar archivo + file_metadata = await file_handler.save_upload(file, current_tenant.id, ticket_uuid) + + # Crear registro en BD + attachment = TicketAttachment( + id=uuid.uuid4(), + ticket_id=ticket_uuid, + uploaded_by=current_user.id, + filename=file_metadata["filename"], + original_filename=file_metadata["original_filename"], + mime_type=file_metadata["mime_type"], + file_size=file_metadata["file_size"], + file_path=file_metadata["file_path"], + md5_hash=file_metadata["md5_hash"], + sha256_hash=file_metadata["sha256_hash"], + created_at=datetime.utcnow() + ) + + db.add(attachment) + await db.commit() + await db.refresh(attachment, ["uploaded_by_user"]) + + return { + "success": True, + "message": "Archivo subido exitosamente", + "data": AttachmentResponse( + id=attachment.id, + ticket_id=attachment.ticket_id, + comment_id=attachment.comment_id, + uploaded_by=attachment.uploaded_by, + filename=attachment.filename, + original_filename=attachment.original_filename, + mime_type=attachment.mime_type, + file_size=attachment.file_size, + file_path=attachment.file_path, + uploaded_by_name=f"{attachment.uploaded_by_user.first_name} {attachment.uploaded_by_user.last_name}", + created_at=attachment.created_at, + download_url=f"/api/v1/tickets/{ticket_id}/attachments/{attachment.id}/download" + ) + } + + +@router.get("/{ticket_id}/attachments/{attachment_id}/download") +async def download_attachment( + ticket_id: str, + attachment_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), + current_tenant: Tenant = Depends(get_current_tenant) +): + """Descargar un archivo adjunto""" + try: + ticket_uuid = uuid.UUID(ticket_id) + attachment_uuid = uuid.UUID(attachment_id) + except ValueError: + raise HTTPException(status_code=400, detail="ID inválido") + + # Verificar ticket + result = await db.execute( + select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id) + ) + ticket = result.scalar_one_or_none() + + if not ticket: + raise HTTPException(status_code=404, detail="Ticket no encontrado") + + # Obtener attachment + result = await db.execute( + select(TicketAttachment) + .where(TicketAttachment.id == attachment_uuid, TicketAttachment.ticket_id == ticket_uuid) + ) + attachment = result.scalar_one_or_none() + + if not attachment: + raise HTTPException(status_code=404, detail="Adjunto no encontrado") + + # Obtener path del archivo + file_path = file_handler.get_file_path(attachment.file_path) + + # Retornar archivo + return FileResponse( + path=file_path, + filename=attachment.original_filename, + media_type=attachment.mime_type ) \ No newline at end of file diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2c0a7b9..74c1fad 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -23,101 +23,98 @@ class Settings(BaseSettings): # =================================== # GENERAL # =================================== - ENVIRONMENT: str = Field(default="development", env="ENVIRONMENT") - DEBUG: bool = Field(default=False, env="DEBUG") - SECRET_KEY: str = Field(..., env="SECRET_KEY") - API_VERSION: str = Field(default="v1", env="API_VERSION") + ENVIRONMENT: str = Field(default="development") + DEBUG: bool = Field(default=False) + SECRET_KEY: str = Field(...) + API_VERSION: str = Field(default="v1") # =================================== # DATABASE # =================================== - DATABASE_URL: str = Field(..., env="DATABASE_URL") + DATABASE_URL: str = Field(...) # =================================== # REDIS # =================================== - REDIS_URL: str = Field(..., env="REDIS_URL") + REDIS_URL: str = Field(...) # =================================== # JWT AUTHENTICATION # =================================== - JWT_SECRET_KEY: str = Field(..., env="JWT_SECRET_KEY") - JWT_ALGORITHM: str = Field(default="HS256", env="JWT_ALGORITHM") - ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=60, env="ACCESS_TOKEN_EXPIRE_MINUTES") - REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7, env="REFRESH_TOKEN_EXPIRE_DAYS") + JWT_SECRET_KEY: str = Field(...) + JWT_ALGORITHM: str = Field(default="HS256") + ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=60) + REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7) # =================================== # CORS # =================================== CORS_ORIGINS: str = Field( - default="http://localhost:3000,http://localhost:3001", - env="CORS_ORIGINS" + default="http://localhost:3000,http://localhost:3001" ) # =================================== # EMAIL # =================================== - SMTP_HOST: str = Field(default="localhost", env="SMTP_HOST") - SMTP_PORT: int = Field(default=587, env="SMTP_PORT") - SMTP_USER: Optional[str] = Field(default=None, env="SMTP_USER") - SMTP_PASSWORD: Optional[str] = Field(default=None, env="SMTP_PASSWORD") - SMTP_USE_TLS: bool = Field(default=True, env="SMTP_USE_TLS") - SMTP_USE_SSL: bool = Field(default=False, env="SMTP_USE_SSL") + SMTP_HOST: str = Field(default="localhost") + SMTP_PORT: int = Field(default=587) + SMTP_USER: Optional[str] = Field(default=None) + SMTP_PASSWORD: Optional[str] = Field(default=None) + SMTP_USE_TLS: bool = Field(default=True) + SMTP_USE_SSL: bool = Field(default=False) - DEFAULT_FROM_EMAIL: str = Field(default="noreply@servicemanager.local", env="DEFAULT_FROM_EMAIL") - DEFAULT_FROM_NAME: str = Field(default="ServiceManager", env="DEFAULT_FROM_NAME") + DEFAULT_FROM_EMAIL: str = Field(default="noreply@servicemanager.local") + DEFAULT_FROM_NAME: str = Field(default="ServiceManager") # =================================== # FILE UPLOADS # =================================== - MAX_UPLOAD_SIZE_MB: int = Field(default=10, env="MAX_UPLOAD_SIZE_MB") - ALLOWED_FILE_EXTENSIONS: List[str] = Field( - default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"], - env="ALLOWED_FILE_EXTENSIONS" + MAX_UPLOAD_SIZE_MB: int = Field(default=10) + ALLOWED_FILE_EXTENSIONS_STR: str = Field( + default="pdf,jpg,jpeg,png,doc,docx,xls,xlsx,txt", + alias="ALLOWED_FILE_EXTENSIONS" ) - UPLOAD_PATH: str = Field(default="/app/uploads", env="UPLOAD_PATH") + UPLOAD_PATH: str = Field(default="/app/uploads") - @field_validator("ALLOWED_FILE_EXTENSIONS", mode='before') - @classmethod - def validate_file_extensions(cls, v): - if isinstance(v, str): - return [ext.strip().lower() for ext in v.split(",")] - return [ext.lower() for ext in v] + @property + def ALLOWED_FILE_EXTENSIONS(self) -> List[str]: + """Parse the comma-separated file extensions.""" + return [ext.strip().lower() for ext in self.ALLOWED_FILE_EXTENSIONS_STR.split(",")] # =================================== # SECURITY # =================================== - RATE_LIMIT_ENABLED: bool = Field(default=True, env="RATE_LIMIT_ENABLED") - PASSWORD_MIN_LENGTH: int = Field(default=8, env="PASSWORD_MIN_LENGTH") + RATE_LIMIT_ENABLED: bool = Field(default=True) + PASSWORD_MIN_LENGTH: int = Field(default=8) # Argon2 settings - ARGON2_TIME_COST: int = Field(default=3, env="ARGON2_TIME_COST") - ARGON2_MEMORY_COST: int = Field(default=65536, env="ARGON2_MEMORY_COST") - ARGON2_PARALLELISM: int = Field(default=4, env="ARGON2_PARALLELISM") + ARGON2_TIME_COST: int = Field(default=3) + ARGON2_MEMORY_COST: int = Field(default=65536) + ARGON2_PARALLELISM: int = Field(default=4) # =================================== # LOGGING # =================================== - LOG_LEVEL: str = Field(default="INFO", env="LOG_LEVEL") - LOG_FORMAT: str = Field(default="json", env="LOG_FORMAT") - LOG_FILE: Optional[str] = Field(default=None, env="LOG_FILE") + LOG_LEVEL: str = Field(default="INFO") + LOG_FORMAT: str = Field(default="json") + LOG_FILE: Optional[str] = Field(default=None) # =================================== # FRONTEND URLS # =================================== - CLIENT_FRONTEND_URL: str = Field(default="http://localhost:3000", env="CLIENT_FRONTEND_URL") - INTERNAL_FRONTEND_URL: str = Field(default="http://localhost:3001", env="INTERNAL_FRONTEND_URL") + CLIENT_FRONTEND_URL: str = Field(default="http://localhost:3000") + INTERNAL_FRONTEND_URL: str = Field(default="http://localhost:3001") # =================================== # HEALTH CHECKS # =================================== - HEALTH_CHECK_TIMEOUT: int = Field(default=30, env="HEALTH_CHECK_TIMEOUT") + HEALTH_CHECK_TIMEOUT: int = Field(default=30) # =================================== # CELERY # =================================== - CELERY_BROKER_URL: str = Field(..., env="CELERY_BROKER_URL") - CELERY_RESULT_BACKEND: str = Field(..., env="CELERY_RESULT_BACKEND") + CELERY_BROKER_URL: str = Field(...) + CELERY_RESULT_BACKEND: str = Field(...) def is_production(self) -> bool: """Check if environment is production.""" diff --git a/backend/app/main.py b/backend/app/main.py index 8169f72..a1eb6cf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -22,6 +22,7 @@ from app.models.category import Category from app.models.user import User from app.models.ticket import Ticket from app.models.comment import TicketComment +from app.models.attachment import TicketAttachment from app.core.logging import setup_logging from app.api.v1.router import api_router diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index aba036e..f4fb709 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -7,6 +7,7 @@ from .comment import TicketComment from .system import System from .category import Category from .client_profile import ClientProfile +from .attachment import TicketAttachment __all__ = [ "User", @@ -15,5 +16,6 @@ __all__ = [ "TicketComment", "System", "Category", - "ClientProfile" + "ClientProfile", + "TicketAttachment" ] \ No newline at end of file diff --git a/backend/app/models/client_profile.py b/backend/app/models/client_profile.py index c1407a2..9067305 100644 --- a/backend/app/models/client_profile.py +++ b/backend/app/models/client_profile.py @@ -8,12 +8,15 @@ Almacena información detallada de la empresa cliente from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, Numeric from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.dialects.postgresql import UUID -from typing import Optional +from typing import Optional, TYPE_CHECKING import uuid from datetime import datetime from app.core.database import Base +if TYPE_CHECKING: + from app.models.tenant import Tenant + class ClientProfile(Base): """Modelo de Perfil de Cliente Empresarial.""" diff --git a/backend/app/models/comment.py b/backend/app/models/comment.py index da49d3a..a130a10 100644 --- a/backend/app/models/comment.py +++ b/backend/app/models/comment.py @@ -64,6 +64,11 @@ class TicketComment(Base): # Relationships ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="comments") author: Mapped["User"] = relationship("User") + attachments: Mapped[list["TicketAttachment"]] = relationship( + "TicketAttachment", + back_populates="comment", + cascade="all, delete-orphan" + ) def __repr__(self) -> str: return f"" \ No newline at end of file diff --git a/backend/app/models/ticket.py b/backend/app/models/ticket.py index 2a4bbc0..b100a4b 100644 --- a/backend/app/models/ticket.py +++ b/backend/app/models/ticket.py @@ -126,6 +126,12 @@ class Ticket(Base): cascade="all, delete-orphan" ) + attachments: Mapped[list["TicketAttachment"]] = relationship( + "TicketAttachment", + back_populates="ticket", + cascade="all, delete-orphan" + ) + # ✅ AÑADIDOS: Constraints según schema.sql __table_args__ = ( UniqueConstraint('tenant_id', 'ticket_number', name='uq_tickets_tenant_number'), diff --git a/backend/requirements.txt b/backend/requirements.txt index c189022..61789b5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -69,6 +69,7 @@ prometheus-client==0.19.0 pytest==7.4.3 pytest-asyncio==0.21.1 pytest-cov==4.1.0 +aiosqlite==0.19.0 httpx==0.25.2 # For testing faker==20.1.0 # Test data generation