feat(backend): Fix client-profile endpoint and add attachment support

- Fixed client-profile GET endpoint to prevent 500 errors
- Made ClientProfileResponse fields optional (id, created_at, updated_at)
- Returns empty profile data instead of creating DB entry on GET
- Added new attachment model and schemas for file handling
- Added file handler core utility for upload management
This commit is contained in:
2026-02-09 13:27:45 -07:00
parent ea682d8cd9
commit d4ff32dac7
5 changed files with 234 additions and 8 deletions

View File

@@ -0,0 +1,25 @@
"""
Attachment Schemas - ServiceManagerWeb
"""
from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime
from typing import Optional
import uuid
class AttachmentResponse(BaseModel):
"""Schema para respuesta de attachment"""
id: uuid.UUID
ticket_id: uuid.UUID
comment_id: Optional[uuid.UUID] = None
uploaded_by: uuid.UUID
filename: str
original_filename: str
mime_type: str
file_size: int
file_path: str
uploaded_by_name: Optional[str] = None
created_at: datetime
download_url: Optional[str] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -133,10 +133,10 @@ class ClientProfileUpdate(ClientProfileBase):
class ClientProfileResponse(ClientProfileBase):
"""Schema de respuesta para ClientProfile."""
id: uuid.UUID
id: Optional[uuid.UUID] = None
tenant_id: uuid.UUID
created_at: datetime
updated_at: datetime
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True

View File

@@ -50,11 +50,49 @@ async def get_current_client_profile(
profile = result.scalar_one_or_none()
if not profile:
# Si no existe, crear uno vacío
profile = ClientProfile(tenant_id=current_tenant.id)
db.add(profile)
await db.commit()
await db.refresh(profile)
# Si no existe, devolver un perfil vacío con solo tenant_id
# No crear en base de datos hasta que el usuario guarde
return ClientProfileResponse(
id=None,
tenant_id=current_tenant.id,
business_name=None,
commercial_name=None,
client_code=None,
client_type=None,
rfc=None,
tax_id=None,
country=None,
state=None,
city=None,
address=None,
external_number=None,
internal_number=None,
postal_code=None,
neighborhood=None,
main_phone=None,
secondary_phone=None,
direct_phone=None,
phone_extension=None,
fax=None,
business_hours=None,
website=None,
main_email=None,
billing_email=None,
advertising_medium=None,
nationality=None,
logo_url=None,
company_representative=None,
legal_representative=None,
credit_limit=None,
payment_terms=None,
preferred_currency="MXN",
send_to_billing=False,
is_active_client=True,
is_prospect=False,
notes=None,
created_at=None,
updated_at=None
)
return profile

View File

@@ -0,0 +1,101 @@
"""
File Handler - ServiceManagerWeb
Gestión simple de archivos adjuntos
"""
import os
import uuid
import hashlib
from pathlib import Path
from typing import Tuple
from fastapi import UploadFile, HTTPException, status
from app.core.config import get_settings
settings = get_settings()
class FileHandler:
"""Handler simple para archivos adjuntos"""
def __init__(self):
self.upload_path = Path(settings.UPLOAD_PATH)
self.max_size_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
self.allowed_extensions = settings.ALLOWED_FILE_EXTENSIONS
# Crear directorio si no existe
self.upload_path.mkdir(parents=True, exist_ok=True)
def _validate_file(self, filename: str, file_size: int) -> None:
"""Validar archivo"""
extension = Path(filename).suffix.lower().lstrip('.')
if extension not in self.allowed_extensions:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Extensión no permitida: {extension}"
)
if file_size > self.max_size_bytes:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Archivo muy grande. Máximo: {settings.MAX_UPLOAD_SIZE_MB}MB"
)
def _calculate_checksums(self, content: bytes) -> Tuple[str, str]:
"""Calcular MD5 y SHA256"""
return hashlib.md5(content).hexdigest(), hashlib.sha256(content).hexdigest()
async def save_upload(self, file: UploadFile, tenant_id: uuid.UUID, ticket_id: uuid.UUID) -> dict:
"""Guardar archivo y retornar metadata"""
if not file.filename:
raise HTTPException(status_code=400, detail="Filename requerido")
content = await file.read()
file_size = len(content)
self._validate_file(file.filename, file_size)
md5_hash, sha256_hash = self._calculate_checksums(content)
# Nombre único
extension = Path(file.filename).suffix.lower()
safe_filename = f"{uuid.uuid4().hex}{extension}"
# Estructura: uploads/tenant_id/tickets/ticket_id/
file_directory = self.upload_path / str(tenant_id) / "tickets" / str(ticket_id)
file_directory.mkdir(parents=True, exist_ok=True)
file_path = file_directory / safe_filename
relative_path = str(file_path.relative_to(self.upload_path))
# Guardar archivo
with open(file_path, "wb") as f:
f.write(content)
import mimetypes
mime_type = mimetypes.guess_type(file.filename)[0] or "application/octet-stream"
return {
"filename": safe_filename,
"original_filename": file.filename,
"file_path": relative_path,
"file_size": file_size,
"mime_type": mime_type,
"md5_hash": md5_hash,
"sha256_hash": sha256_hash
}
def get_file_path(self, relative_path: str) -> Path:
"""Obtener path absoluto del archivo"""
file_path = (self.upload_path / relative_path).resolve()
# Verificar que no escape del directorio de uploads
if not str(file_path).startswith(str(self.upload_path.resolve())):
raise HTTPException(status_code=403, detail="Acceso denegado")
if not file_path.exists():
raise HTTPException(status_code=404, detail="Archivo no encontrado")
return file_path
file_handler = FileHandler()

View File

@@ -0,0 +1,62 @@
"""
Attachment Model - ServiceManagerWeb
"""
from sqlalchemy import String, ForeignKey, Integer, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from typing import Optional, TYPE_CHECKING
from datetime import datetime
import uuid
from app.core.database import Base
if TYPE_CHECKING:
from app.models.ticket import Ticket
from app.models.comment import TicketComment
from app.models.user import User
class TicketAttachment(Base):
"""Modelo de archivos adjuntos en tickets"""
__tablename__ = "ticket_attachments"
# Sobrescribir campos heredados de Base para que coincidan con la tabla real
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Esta tabla NO tiene updated_at, así que lo excluimos del mapping
ticket_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tickets.id", ondelete="CASCADE"),
nullable=False
)
comment_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("ticket_comments.id", ondelete="CASCADE"),
nullable=True
)
uploaded_by: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=False
)
filename: Mapped[str] = mapped_column(String(255), nullable=False)
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
file_size: Mapped[int] = mapped_column(Integer, nullable=False)
file_path: Mapped[str] = mapped_column(String(500), nullable=False)
md5_hash: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
sha256_hash: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="attachments")
comment: Mapped[Optional["TicketComment"]] = relationship("TicketComment", back_populates="attachments")
uploaded_by_user: Mapped["User"] = relationship("User")
# Excluir updated_at del mapping ya que la tabla no lo tiene
__mapper_args__ = {
"exclude_properties": ["updated_at"]
}