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
This commit is contained in:
2026-02-09 13:28:40 -07:00
parent ac0cb6f132
commit 91ff49cdec
9 changed files with 257 additions and 92 deletions

View File

@@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from typing import Optional from typing import Optional
import structlog import structlog
@@ -257,41 +258,49 @@ async def get_current_user(
detail="Invalid token" 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 { return {
"id": payload["sub"], "id": str(user.id),
"email": payload["email"], "email": user.email,
"role": payload["role"], "first_name": user.first_name,
"tenant_id": payload["tenant_id"] "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 # DEPENDENCIES
# =================================== # ===================================
# Dependencies are imported from app.api.deps to avoid duplication
async def get_current_active_user(token: str = Depends(oauth2_scheme)): # Use get_current_user and get_current_active_superuser from deps.py
"""
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

View File

@@ -3,18 +3,24 @@ Tickets endpoints - ServiceManagerWeb
""" """
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from typing import List, Optional from typing import List, Optional
from datetime import datetime from datetime import datetime
from app.core.database import get_db 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.ticket import Ticket, TicketStatus, TicketPriority
from app.models.user import User 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.system import System
from app.models.comment import TicketComment 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 import uuid
router = APIRouter() router = APIRouter()
@@ -573,19 +579,60 @@ async def delete_ticket(
return {"message": "Ticket deleted successfully"} 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( async def get_ticket_attachments(
ticket_id: str, ticket_id: str,
db: AsyncSession = Depends(get_db), 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"""
Obtener adjuntos de un ticket try:
""" ticket_uuid = uuid.UUID(ticket_id)
return [] 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) @router.post("/{ticket_id}/attachments", status_code=status.HTTP_201_CREATED)
@@ -593,12 +640,106 @@ async def upload_attachment(
ticket_id: str, ticket_id: str,
file: UploadFile = File(...), file: UploadFile = File(...),
db: AsyncSession = Depends(get_db), 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"""
Subir un archivo adjunto a un ticket try:
""" ticket_uuid = uuid.UUID(ticket_id)
raise HTTPException( except ValueError:
status_code=status.HTTP_501_NOT_IMPLEMENTED, raise HTTPException(status_code=400, detail="ID de ticket inválido")
detail="File uploads not yet implemented"
# 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
) )

View File

@@ -23,101 +23,98 @@ class Settings(BaseSettings):
# =================================== # ===================================
# GENERAL # GENERAL
# =================================== # ===================================
ENVIRONMENT: str = Field(default="development", env="ENVIRONMENT") ENVIRONMENT: str = Field(default="development")
DEBUG: bool = Field(default=False, env="DEBUG") DEBUG: bool = Field(default=False)
SECRET_KEY: str = Field(..., env="SECRET_KEY") SECRET_KEY: str = Field(...)
API_VERSION: str = Field(default="v1", env="API_VERSION") API_VERSION: str = Field(default="v1")
# =================================== # ===================================
# DATABASE # DATABASE
# =================================== # ===================================
DATABASE_URL: str = Field(..., env="DATABASE_URL") DATABASE_URL: str = Field(...)
# =================================== # ===================================
# REDIS # REDIS
# =================================== # ===================================
REDIS_URL: str = Field(..., env="REDIS_URL") REDIS_URL: str = Field(...)
# =================================== # ===================================
# JWT AUTHENTICATION # JWT AUTHENTICATION
# =================================== # ===================================
JWT_SECRET_KEY: str = Field(..., env="JWT_SECRET_KEY") JWT_SECRET_KEY: str = Field(...)
JWT_ALGORITHM: str = Field(default="HS256", env="JWT_ALGORITHM") JWT_ALGORITHM: str = Field(default="HS256")
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=60, env="ACCESS_TOKEN_EXPIRE_MINUTES") ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=60)
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7, env="REFRESH_TOKEN_EXPIRE_DAYS") REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7)
# =================================== # ===================================
# CORS # CORS
# =================================== # ===================================
CORS_ORIGINS: str = Field( CORS_ORIGINS: str = Field(
default="http://localhost:3000,http://localhost:3001", default="http://localhost:3000,http://localhost:3001"
env="CORS_ORIGINS"
) )
# =================================== # ===================================
# EMAIL # EMAIL
# =================================== # ===================================
SMTP_HOST: str = Field(default="localhost", env="SMTP_HOST") SMTP_HOST: str = Field(default="localhost")
SMTP_PORT: int = Field(default=587, env="SMTP_PORT") SMTP_PORT: int = Field(default=587)
SMTP_USER: Optional[str] = Field(default=None, env="SMTP_USER") SMTP_USER: Optional[str] = Field(default=None)
SMTP_PASSWORD: Optional[str] = Field(default=None, env="SMTP_PASSWORD") SMTP_PASSWORD: Optional[str] = Field(default=None)
SMTP_USE_TLS: bool = Field(default=True, env="SMTP_USE_TLS") SMTP_USE_TLS: bool = Field(default=True)
SMTP_USE_SSL: bool = Field(default=False, env="SMTP_USE_SSL") SMTP_USE_SSL: bool = Field(default=False)
DEFAULT_FROM_EMAIL: str = Field(default="noreply@servicemanager.local", env="DEFAULT_FROM_EMAIL") DEFAULT_FROM_EMAIL: str = Field(default="noreply@servicemanager.local")
DEFAULT_FROM_NAME: str = Field(default="ServiceManager", env="DEFAULT_FROM_NAME") DEFAULT_FROM_NAME: str = Field(default="ServiceManager")
# =================================== # ===================================
# FILE UPLOADS # FILE UPLOADS
# =================================== # ===================================
MAX_UPLOAD_SIZE_MB: int = Field(default=10, env="MAX_UPLOAD_SIZE_MB") MAX_UPLOAD_SIZE_MB: int = Field(default=10)
ALLOWED_FILE_EXTENSIONS: List[str] = Field( ALLOWED_FILE_EXTENSIONS_STR: str = Field(
default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"], default="pdf,jpg,jpeg,png,doc,docx,xls,xlsx,txt",
env="ALLOWED_FILE_EXTENSIONS" 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') @property
@classmethod def ALLOWED_FILE_EXTENSIONS(self) -> List[str]:
def validate_file_extensions(cls, v): """Parse the comma-separated file extensions."""
if isinstance(v, str): return [ext.strip().lower() for ext in self.ALLOWED_FILE_EXTENSIONS_STR.split(",")]
return [ext.strip().lower() for ext in v.split(",")]
return [ext.lower() for ext in v]
# =================================== # ===================================
# SECURITY # SECURITY
# =================================== # ===================================
RATE_LIMIT_ENABLED: bool = Field(default=True, env="RATE_LIMIT_ENABLED") RATE_LIMIT_ENABLED: bool = Field(default=True)
PASSWORD_MIN_LENGTH: int = Field(default=8, env="PASSWORD_MIN_LENGTH") PASSWORD_MIN_LENGTH: int = Field(default=8)
# Argon2 settings # Argon2 settings
ARGON2_TIME_COST: int = Field(default=3, env="ARGON2_TIME_COST") ARGON2_TIME_COST: int = Field(default=3)
ARGON2_MEMORY_COST: int = Field(default=65536, env="ARGON2_MEMORY_COST") ARGON2_MEMORY_COST: int = Field(default=65536)
ARGON2_PARALLELISM: int = Field(default=4, env="ARGON2_PARALLELISM") ARGON2_PARALLELISM: int = Field(default=4)
# =================================== # ===================================
# LOGGING # LOGGING
# =================================== # ===================================
LOG_LEVEL: str = Field(default="INFO", env="LOG_LEVEL") LOG_LEVEL: str = Field(default="INFO")
LOG_FORMAT: str = Field(default="json", env="LOG_FORMAT") LOG_FORMAT: str = Field(default="json")
LOG_FILE: Optional[str] = Field(default=None, env="LOG_FILE") LOG_FILE: Optional[str] = Field(default=None)
# =================================== # ===================================
# FRONTEND URLS # FRONTEND URLS
# =================================== # ===================================
CLIENT_FRONTEND_URL: str = Field(default="http://localhost:3000", env="CLIENT_FRONTEND_URL") CLIENT_FRONTEND_URL: str = Field(default="http://localhost:3000")
INTERNAL_FRONTEND_URL: str = Field(default="http://localhost:3001", env="INTERNAL_FRONTEND_URL") INTERNAL_FRONTEND_URL: str = Field(default="http://localhost:3001")
# =================================== # ===================================
# HEALTH CHECKS # HEALTH CHECKS
# =================================== # ===================================
HEALTH_CHECK_TIMEOUT: int = Field(default=30, env="HEALTH_CHECK_TIMEOUT") HEALTH_CHECK_TIMEOUT: int = Field(default=30)
# =================================== # ===================================
# CELERY # CELERY
# =================================== # ===================================
CELERY_BROKER_URL: str = Field(..., env="CELERY_BROKER_URL") CELERY_BROKER_URL: str = Field(...)
CELERY_RESULT_BACKEND: str = Field(..., env="CELERY_RESULT_BACKEND") CELERY_RESULT_BACKEND: str = Field(...)
def is_production(self) -> bool: def is_production(self) -> bool:
"""Check if environment is production.""" """Check if environment is production."""

View File

@@ -22,6 +22,7 @@ from app.models.category import Category
from app.models.user import User from app.models.user import User
from app.models.ticket import Ticket from app.models.ticket import Ticket
from app.models.comment import TicketComment from app.models.comment import TicketComment
from app.models.attachment import TicketAttachment
from app.core.logging import setup_logging from app.core.logging import setup_logging
from app.api.v1.router import api_router from app.api.v1.router import api_router

View File

@@ -7,6 +7,7 @@ from .comment import TicketComment
from .system import System from .system import System
from .category import Category from .category import Category
from .client_profile import ClientProfile from .client_profile import ClientProfile
from .attachment import TicketAttachment
__all__ = [ __all__ = [
"User", "User",
@@ -15,5 +16,6 @@ __all__ = [
"TicketComment", "TicketComment",
"System", "System",
"Category", "Category",
"ClientProfile" "ClientProfile",
"TicketAttachment"
] ]

View File

@@ -8,12 +8,15 @@ Almacena información detallada de la empresa cliente
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, Numeric from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, Numeric
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from typing import Optional from typing import Optional, TYPE_CHECKING
import uuid import uuid
from datetime import datetime from datetime import datetime
from app.core.database import Base from app.core.database import Base
if TYPE_CHECKING:
from app.models.tenant import Tenant
class ClientProfile(Base): class ClientProfile(Base):
"""Modelo de Perfil de Cliente Empresarial.""" """Modelo de Perfil de Cliente Empresarial."""

View File

@@ -64,6 +64,11 @@ class TicketComment(Base):
# Relationships # Relationships
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="comments") ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="comments")
author: Mapped["User"] = relationship("User") author: Mapped["User"] = relationship("User")
attachments: Mapped[list["TicketAttachment"]] = relationship(
"TicketAttachment",
back_populates="comment",
cascade="all, delete-orphan"
)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<TicketComment {self.id} by {self.author_id}>" return f"<TicketComment {self.id} by {self.author_id}>"

View File

@@ -126,6 +126,12 @@ class Ticket(Base):
cascade="all, delete-orphan" cascade="all, delete-orphan"
) )
attachments: Mapped[list["TicketAttachment"]] = relationship(
"TicketAttachment",
back_populates="ticket",
cascade="all, delete-orphan"
)
# ✅ AÑADIDOS: Constraints según schema.sql # ✅ AÑADIDOS: Constraints según schema.sql
__table_args__ = ( __table_args__ = (
UniqueConstraint('tenant_id', 'ticket_number', name='uq_tickets_tenant_number'), UniqueConstraint('tenant_id', 'ticket_number', name='uq_tickets_tenant_number'),

View File

@@ -69,6 +69,7 @@ prometheus-client==0.19.0
pytest==7.4.3 pytest==7.4.3
pytest-asyncio==0.21.1 pytest-asyncio==0.21.1
pytest-cov==4.1.0 pytest-cov==4.1.0
aiosqlite==0.19.0
httpx==0.25.2 # For testing httpx==0.25.2 # For testing
faker==20.1.0 # Test data generation faker==20.1.0 # Test data generation