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:
@@ -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
|
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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}>"
|
||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user