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 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
# Dependencies are imported from app.api.deps to avoid duplication
# Use get_current_user and get_current_active_superuser from deps.py

View File

@@ -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
)

View File

@@ -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."""

View File

@@ -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

View File

@@ -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"
]

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.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."""

View File

@@ -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"<TicketComment {self.id} by {self.author_id}>"

View File

@@ -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'),

View File

@@ -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