From d4ff32dac7e916ace68cde8f2bc6d1948bd03965 Mon Sep 17 00:00:00 2001 From: icamarillo Date: Mon, 9 Feb 2026 13:27:45 -0700 Subject: [PATCH] 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 --- backend/app/api/schemas/attachment.py | 25 +++++ backend/app/api/schemas/client_profile.py | 6 +- .../app/api/v1/endpoints/client_profile.py | 48 ++++++++- backend/app/core/file_handler.py | 101 ++++++++++++++++++ backend/app/models/attachment.py | 62 +++++++++++ 5 files changed, 234 insertions(+), 8 deletions(-) create mode 100644 backend/app/api/schemas/attachment.py create mode 100644 backend/app/core/file_handler.py create mode 100644 backend/app/models/attachment.py diff --git a/backend/app/api/schemas/attachment.py b/backend/app/api/schemas/attachment.py new file mode 100644 index 0000000..3a7d8e2 --- /dev/null +++ b/backend/app/api/schemas/attachment.py @@ -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) diff --git a/backend/app/api/schemas/client_profile.py b/backend/app/api/schemas/client_profile.py index c576e02..bd32390 100644 --- a/backend/app/api/schemas/client_profile.py +++ b/backend/app/api/schemas/client_profile.py @@ -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 diff --git a/backend/app/api/v1/endpoints/client_profile.py b/backend/app/api/v1/endpoints/client_profile.py index 2fbcc80..b6ff9d9 100644 --- a/backend/app/api/v1/endpoints/client_profile.py +++ b/backend/app/api/v1/endpoints/client_profile.py @@ -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 diff --git a/backend/app/core/file_handler.py b/backend/app/core/file_handler.py new file mode 100644 index 0000000..cd71c77 --- /dev/null +++ b/backend/app/core/file_handler.py @@ -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() diff --git a/backend/app/models/attachment.py b/backend/app/models/attachment.py new file mode 100644 index 0000000..95602b0 --- /dev/null +++ b/backend/app/models/attachment.py @@ -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"] + }