feat: Funcion de sistema tenants

This commit is contained in:
2026-02-23 13:01:24 -07:00
parent ceea67eb2b
commit 1ccc39732b
58 changed files with 1889 additions and 315 deletions

View File

@@ -3,12 +3,11 @@ 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
from app.core.database import Base, GUID
if TYPE_CHECKING:
from app.models.ticket import Ticket
@@ -21,24 +20,24 @@ class TicketAttachment(Base):
__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)
id: Mapped[uuid.UUID] = mapped_column(GUID(), 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),
GUID(),
ForeignKey("tickets.id", ondelete="CASCADE"),
nullable=False
)
comment_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
GUID(),
ForeignKey("ticket_comments.id", ondelete="CASCADE"),
nullable=True
)
uploaded_by: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
GUID(),
ForeignKey("users.id"),
nullable=False
)

View File

@@ -5,14 +5,14 @@ Modelo para bitácora de auditoría y compliance.
Registra todas las acciones importantes del sistema.
"""
from sqlalchemy import String, Text, DateTime, ForeignKey, Index
from sqlalchemy import String, Text, DateTime, ForeignKey, Index, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, INET, JSONB
from sqlalchemy.dialects.postgresql import INET, JSONB
from typing import Optional, Dict, Any, TYPE_CHECKING
import uuid
from datetime import datetime
from app.core.database import Base
from app.core.database import Base, GUID
if TYPE_CHECKING:
from app.models.tenant import Tenant
@@ -36,7 +36,7 @@ class AuditLog(Base):
# Multi-tenancy
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
GUID(),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
index=True
@@ -44,7 +44,7 @@ class AuditLog(Base):
# Usuario que ejecut├│ la acci├│n (NULL = acci├│n del sistema)
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
GUID(),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True
@@ -66,30 +66,33 @@ class AuditLog(Base):
# ID del recurso afectado
resource_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
GUID(),
nullable=True
)
# Contexto de la request
ip_address: Mapped[Optional[str]] = mapped_column(INET, nullable=True)
ip_address: Mapped[Optional[str]] = mapped_column(
String(45).with_variant(INET, "postgresql"),
nullable=True,
)
user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Correlation ID para rastrear requests relacionadas
correlation_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
GUID(),
nullable=True,
index=True
)
# Valores antes del cambio (JSON)
old_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
JSONB,
JSON().with_variant(JSONB, "postgresql"),
nullable=True
)
# Valores despu├®s del cambio (JSON)
new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
JSONB,
JSON().with_variant(JSONB, "postgresql"),
nullable=True
)
@@ -97,7 +100,7 @@ class AuditLog(Base):
# Nota: 'metadata' está reservado en SQLAlchemy, usamos 'extra_metadata'
extra_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column(
'metadata', # Nombre real de la columna en BD
JSONB,
JSON().with_variant(JSONB, "postgresql"),
nullable=True
)

View File

@@ -4,11 +4,10 @@ Categorías de tickets por tenant
"""
from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from typing import List, Optional
import uuid
from app.core.database import Base
from app.core.database import Base, GUID
class Category(Base):
"""Modelo de categorías de tickets (ticket_categories en BD)"""
@@ -21,7 +20,7 @@ class Category(Base):
# ✅ CORREGIDO: tenant_id es obligatorio para multi-tenancy
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
GUID(),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False # ✅ Obligatorio
)
@@ -31,7 +30,7 @@ class Category(Base):
sla_response_hours: Mapped[int] = mapped_column(Integer, default=24, nullable=False)
sla_resolution_hours: Mapped[int] = mapped_column(Integer, default=72, nullable=False)
auto_assign_to: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
GUID(),
ForeignKey("users.id"),
nullable=True
)

View File

@@ -7,12 +7,11 @@ 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, TYPE_CHECKING
import uuid
from datetime import datetime
from app.core.database import Base
from app.core.database import Base, GUID
if TYPE_CHECKING:
from app.models.tenant import Tenant
@@ -25,7 +24,7 @@ class ClientProfile(Base):
# Relación con tenant (uno a uno)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
GUID(),
ForeignKey("tenants.id", ondelete="CASCADE"),
unique=True,
nullable=False,

View File

@@ -5,12 +5,11 @@ Modelo para comentarios en tickets
"""
from sqlalchemy import Column, String, Text, Boolean, ForeignKey, DateTime
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
import uuid
from app.core.database import Base
from app.core.database import Base, GUID
class TicketComment(Base):
@@ -20,20 +19,20 @@ class TicketComment(Base):
# Columnas
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
GUID(),
primary_key=True,
default=uuid.uuid4
)
ticket_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
GUID(),
ForeignKey("tickets.id", ondelete="CASCADE"),
nullable=False,
index=True
)
author_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
GUID(),
ForeignKey("users.id"),
nullable=False,
index=True

View File

@@ -6,12 +6,11 @@ Modelo para persistencia de refresh tokens con revocaci├│n y tracking.
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Index, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from typing import Optional, TYPE_CHECKING
import uuid
from datetime import datetime
from datetime import datetime, timezone
from app.core.database import Base
from app.core.database import Base, GUID
if TYPE_CHECKING:
from app.models.user import User
@@ -36,7 +35,7 @@ class RefreshToken(Base):
# User relationship
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
GUID(),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True
@@ -92,7 +91,7 @@ class RefreshToken(Base):
)
revoked_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
GUID(),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True
)
@@ -146,12 +145,12 @@ class RefreshToken(Base):
- No está revocado
- No ha expirado
"""
return not self.revoked and self.expires_at > datetime.utcnow()
return not self.revoked and self.expires_at > datetime.now(timezone.utc)
@property
def is_expired(self) -> bool:
"""Verificar si el token ha expirado."""
return datetime.utcnow() >= self.expires_at
return datetime.now(timezone.utc) >= self.expires_at
def revoke(self, revoked_by: Optional[uuid.UUID] = None) -> None:
"""
@@ -161,11 +160,11 @@ class RefreshToken(Base):
revoked_by: ID del usuario que revoc├│ el token
"""
self.revoked = True
self.revoked_at = datetime.utcnow()
self.revoked_at = datetime.now(timezone.utc)
if revoked_by:
self.revoked_by = revoked_by
def track_usage(self) -> None:
"""Registrar uso del token."""
self.last_used_at = datetime.utcnow()
self.last_used_at = datetime.now(timezone.utc)
self.usage_count += 1

View File

@@ -4,11 +4,10 @@ Sistemas afectados por tenant
"""
from sqlalchemy import String, Text, Boolean, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from typing import List, Optional
import uuid
from app.core.database import Base
from app.core.database import Base, GUID
class System(Base):
"""Modelo de sistemas afectados (affected_systems en BD)"""
@@ -21,7 +20,7 @@ class System(Base):
# ✅ AÑADIDO: tenant_id obligatorio para multi-tenancy (faltaba completamente)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
GUID(),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False
)

View File

@@ -2,9 +2,9 @@
Tenant Model - ServiceManagerWeb
Modelo para organizaciones cliente (multi-tenancy)
"""
from sqlalchemy import String, Integer, Text, Boolean, ARRAY
from sqlalchemy import String, Integer, Text, Boolean, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, ENUM
from sqlalchemy.dialects.postgresql import UUID, ENUM, ARRAY as PG_ARRAY
from typing import List, Optional
import enum
import uuid
@@ -40,7 +40,7 @@ class Tenant(Base):
max_users: Mapped[int] = mapped_column(Integer, default=50)
max_storage_mb: Mapped[int] = mapped_column(Integer, default=1024)
allowed_file_types: Mapped[List[str]] = mapped_column(
ARRAY(String),
JSON().with_variant(PG_ARRAY(String), "postgresql"),
default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"]
)

View File

@@ -2,15 +2,20 @@
Ticket Model - ServiceManagerWeb
Tickets de soporte - Core del negocio
"""
from sqlalchemy import String, ForeignKey, Text, Integer, CheckConstraint, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, ENUM
from sqlalchemy import String, ForeignKey, Text, Integer, CheckConstraint, UniqueConstraint, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
from typing import Optional
from datetime import datetime
import enum
import uuid
from app.core.database import Base
from app.core.database import Base, GUID
def _generate_fallback_ticket_number() -> str:
# Matches helper format "TK-000001" and stays within VARCHAR(20)
return f"TK-{(uuid.uuid4().int % 1_000_000):06d}"
class TicketStatus(str, enum.Enum):
"""Estados posibles de un ticket"""
@@ -35,50 +40,64 @@ class Ticket(Base):
# Multi-tenancy
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
GUID(),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False
)
# Campos básicos
ticket_number: Mapped[str] = mapped_column(String(20), nullable=False)
ticket_number: Mapped[str] = mapped_column(
String(20),
nullable=False,
default=_generate_fallback_ticket_number,
)
subject: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
# Compatibility aliases (API/UI/tests often use these names)
title = synonym("subject")
system_id = synonym("affected_system_id")
# Estado y Prioridad
status: Mapped[TicketStatus] = mapped_column(
ENUM(TicketStatus, name="ticket_status_enum", create_type=False),
SAEnum(TicketStatus, name="ticket_status_enum", native_enum=False).with_variant(
PG_ENUM(TicketStatus, name="ticket_status_enum", create_type=True),
"postgresql",
),
default=TicketStatus.NEW,
nullable=False
)
priority: Mapped[TicketPriority] = mapped_column(
ENUM(TicketPriority, name="ticket_priority_enum", create_type=False),
SAEnum(TicketPriority, name="ticket_priority_enum", native_enum=False).with_variant(
PG_ENUM(TicketPriority, name="ticket_priority_enum", create_type=True),
"postgresql",
),
default=TicketPriority.MEDIUM,
nullable=False
)
# ✅ CORREGIDO: Foreign Keys apuntan a tablas correctas
created_by: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
GUID(),
ForeignKey("users.id"),
nullable=False
)
assigned_to: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
GUID(),
ForeignKey("users.id"),
nullable=True
)
# ✅ CORREGIDO: Renombrado de system_id a affected_system_id
affected_system_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
GUID(),
ForeignKey("affected_systems.id"), # ✅ Tabla correcta
nullable=True
)
# ✅ CORREGIDO: Foreign key a tabla correcta
category_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
GUID(),
ForeignKey("ticket_categories.id"), # ✅ Tabla correcta
nullable=True
)

View File

@@ -4,15 +4,15 @@ User Model - ServiceManagerWeb
Modelo para usuarios del sistema (internos y clientes)
"""
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, ARRAY
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, JSON, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, ENUM
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, ARRAY as PG_ARRAY
from typing import Optional, List
import enum
import uuid
from datetime import datetime
from app.core.database import Base
from app.core.database import Base, GUID
class UserRole(str, enum.Enum):
@@ -35,7 +35,7 @@ class User(Base):
# Relación con tenant
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
GUID(),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False
)
@@ -48,12 +48,20 @@ class User(Base):
# Autenticación
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(ENUM(UserRole, name="user_role_enum"), nullable=False)
role: Mapped[UserRole] = mapped_column(
SAEnum(UserRole, name="user_role_enum", native_enum=False).with_variant(
PG_ENUM(UserRole, name="user_role_enum", create_type=True),
"postgresql",
),
nullable=False,
)
# 2FA (opcional para staff interno)
totp_secret: Mapped[Optional[str]] = mapped_column(String(32))
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
backup_codes: Mapped[Optional[List[str]]] = mapped_column(ARRAY(String))
backup_codes: Mapped[Optional[List[str]]] = mapped_column(
JSON().with_variant(PG_ARRAY(String), "postgresql")
)
# Estado
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
@@ -85,11 +93,6 @@ class User(Base):
cascade="all, delete-orphan"
)
# Unique constraint por tenant
__table_args__ = (
{"postgresql_tablespace": "users"},
)
def __repr__(self) -> str:
return f"<User(id={self.id}, email='{self.email}', role='{self.role}')>"