diff --git a/Zpracticante/Backups/respaldo_docker_v1.sql b/Zpracticante/Backups/respaldo_docker_v1.sql
new file mode 100644
index 0000000..8716152
Binary files /dev/null and b/Zpracticante/Backups/respaldo_docker_v1.sql differ
diff --git a/Zpracticante/Documentacion Practicas.docx b/Zpracticante/Documentacion Practicas.docx
new file mode 100644
index 0000000..a54e233
Binary files /dev/null and b/Zpracticante/Documentacion Practicas.docx differ
diff --git a/backend/alembic.ini b/backend/alembic.ini
new file mode 100644
index 0000000..19584b4
--- /dev/null
+++ b/backend/alembic.ini
@@ -0,0 +1,36 @@
+[alembic]
+script_location = backend/alembic
+sqlalchemy.url = postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
\ No newline at end of file
diff --git a/backend/alembic/env.py b/backend/alembic/env.py
new file mode 100644
index 0000000..6468855
--- /dev/null
+++ b/backend/alembic/env.py
@@ -0,0 +1,65 @@
+from logging.config import fileConfig
+from sqlalchemy import engine_from_config
+from sqlalchemy import pool
+from alembic import context
+import os
+from dotenv import load_dotenv
+import sys
+import os
+from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
+import asyncio
+
+# Cargar variables de entorno desde .env
+load_dotenv()
+
+# Agregar el directorio 'backend' al PYTHONPATH
+sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+
+# Configuración de Alembic
+config = context.config
+
+# Configurar logging
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+# Agregar la URL de la base de datos desde las variables de entorno
+config.set_main_option(
+ "sqlalchemy.url",
+ os.getenv("DATABASE_URL", "postgresql+asyncpg://user:password@localhost/dbname")
+)
+
+# Importar modelos para las migraciones
+from app.models import * # Importa todos los modelos aquí
+from app.core.database import Base
+
+target_metadata = Base.metadata # Reemplaza con tu metadata
+
+# Cambiar el motor a asíncrono para Alembic
+from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
+
+# Crear el motor asíncrono
+connectable = create_async_engine(
+ os.getenv("DATABASE_URL", "postgresql+asyncpg://user:password@localhost/dbname"),
+ echo=True, # Habilitar logs para depuración
+)
+
+async def run_migrations():
+ async with connectable.connect() as connection:
+ await connection.run_sync(do_run_migrations)
+
+def do_run_migrations(connection):
+ context.configure(connection=connection, target_metadata=target_metadata)
+ with context.begin_transaction():
+ context.run_migrations()
+
+# Configurar migraciones en modo offline
+def run_migrations_offline():
+ context.configure(url=os.getenv("DATABASE_URL"), target_metadata=target_metadata, literal_binds=True)
+ with context.begin_transaction():
+ context.run_migrations()
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ asyncio.run(run_migrations())
\ No newline at end of file
diff --git a/backend/alembic/versions/add_clients_table.py b/backend/alembic/versions/add_clients_table.py
new file mode 100644
index 0000000..c55273f
--- /dev/null
+++ b/backend/alembic/versions/add_clients_table.py
@@ -0,0 +1,28 @@
+"""
+Add clients table
+"""
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import inspect
+
+# revision identifiers, used by Alembic.
+revision = "add_clients_table"
+down_revision = None
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ bind = op.get_bind()
+ inspector = inspect(bind)
+ if 'clients' not in inspector.get_table_names():
+ op.create_table(
+ 'clients',
+ sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
+ sa.Column('name', sa.String, nullable=False),
+ sa.Column('email', sa.String, nullable=False, unique=True),
+ sa.Column('phone', sa.String, nullable=False),
+ )
+
+def downgrade():
+ op.drop_table('clients')
\ No newline at end of file
diff --git a/backend/alembic/versions/add_system_id_to_tickets.py b/backend/alembic/versions/add_system_id_to_tickets.py
new file mode 100644
index 0000000..dbe7f18
--- /dev/null
+++ b/backend/alembic/versions/add_system_id_to_tickets.py
@@ -0,0 +1,20 @@
+"""
+Agregar columna system_id a la tabla tickets
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+# Revisión ID y dependencias
+revision = 'add_system_id_to_tickets'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ """Agregar columna system_id a la tabla tickets."""
+ op.add_column('tickets', sa.Column('system_id', sa.UUID, nullable=True))
+
+def downgrade():
+ """Eliminar columna system_id de la tabla tickets."""
+ op.drop_column('tickets', 'system_id')
\ No newline at end of file
diff --git a/backend/alembic/versions/add_updated_at_to_category.py b/backend/alembic/versions/add_updated_at_to_category.py
new file mode 100644
index 0000000..8b885da
--- /dev/null
+++ b/backend/alembic/versions/add_updated_at_to_category.py
@@ -0,0 +1,18 @@
+"""
+Add updated_at column to ticket_categories
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = "add_updated_at_to_category"
+down_revision = "add_clients_table"
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ op.add_column("ticket_categories", sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True))
+
+def downgrade():
+ op.drop_column("ticket_categories", "updated_at")
\ No newline at end of file
diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py
index 742d254..23fe1dc 100644
--- a/backend/app/api/deps.py
+++ b/backend/app/api/deps.py
@@ -55,3 +55,13 @@ async def get_current_active_superuser(
status_code=403, detail="The user doesn't have enough privileges"
)
return current_user
+
+async def get_current_active_user(
+ current_user: User = Depends(get_current_user),
+) -> User:
+ if not current_user.is_active:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Inactive user",
+ )
+ return current_user
diff --git a/backend/app/api/v1/endpoints/categories.py b/backend/app/api/v1/endpoints/categories.py
index 4aaabd4..91ca67c 100644
--- a/backend/app/api/v1/endpoints/categories.py
+++ b/backend/app/api/v1/endpoints/categories.py
@@ -4,6 +4,7 @@ from sqlalchemy import select
from pydantic import BaseModel, ConfigDict
from typing import List, Optional
import uuid
+import logging
from app.core.database import get_db
from app.models.category import Category
@@ -11,6 +12,8 @@ from app.api import deps
router = APIRouter()
+logger = logging.getLogger("categories")
+
class CategoryBase(BaseModel):
name: str
description: Optional[str] = None
@@ -38,18 +41,56 @@ async def read_categories(
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser)
):
- query = select(Category).offset(skip).limit(limit)
- result = await db.execute(query)
- return result.scalars().all()
+ try:
+ logger.debug("Fetching categories with skip=%d, limit=%d", skip, limit)
+ query = select(Category).offset(skip).limit(limit)
+ result = await db.execute(query)
+ categories = result.scalars().all()
+ logger.debug("Fetched %d categories", len(categories))
+ return categories
+ except Exception as e:
+ logger.exception("Unhandled exception occurred while fetching categories.")
+ raise HTTPException(status_code=500, detail="Internal Server Error")
@router.post("/", response_model=CategoryResponse)
async def create_category(
- category: CategoryCreate,
+ category: CategoryCreate,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser)
):
- db_category = Category(**category.model_dump())
- db.add(db_category)
+ new_category = Category(**category.model_dump())
+ db.add(new_category)
+ await db.commit()
+ await db.refresh(new_category)
+ return new_category
+
+@router.put("/{category_id}", response_model=CategoryResponse)
+async def update_category(
+ category_id: uuid.UUID,
+ category: CategoryUpdate,
+ db: AsyncSession = Depends(get_db),
+ current_user = Depends(deps.get_current_active_superuser)
+):
+ db_category = await db.get(Category, category_id)
+ if not db_category:
+ raise HTTPException(status_code=404, detail="Category not found")
+
+ for key, value in category.model_dump(exclude_unset=True).items():
+ setattr(db_category, key, value)
+
await db.commit()
await db.refresh(db_category)
return db_category
+
+@router.delete("/{category_id}", status_code=204)
+async def delete_category(
+ category_id: uuid.UUID,
+ db: AsyncSession = Depends(get_db),
+ current_user = Depends(deps.get_current_active_superuser)
+):
+ db_category = await db.get(Category, category_id)
+ if not db_category:
+ raise HTTPException(status_code=404, detail="Category not found")
+
+ await db.delete(db_category)
+ await db.commit()
diff --git a/backend/app/api/v1/endpoints/clients.py b/backend/app/api/v1/endpoints/clients.py
new file mode 100644
index 0000000..23a4498
--- /dev/null
+++ b/backend/app/api/v1/endpoints/clients.py
@@ -0,0 +1,61 @@
+from fastapi import APIRouter, HTTPException, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import text # <--- IMPORTANTE: Necesario para consultas SQL
+from app.core.database import get_db
+
+# IMPORTANTE: Renombramos para evitar conflictos
+from app.models.client import Client as ClientModel
+from app.schemas.client import ClientCreate, ClientUpdate, Client as ClientSchema
+
+router = APIRouter()
+
+# GET: Obtener todos los clientes
+@router.get("/clients", response_model=list[ClientSchema])
+async def get_clients(db: AsyncSession = Depends(get_db)):
+ # Corrección: Usamos text() y mappings().all()
+ query = text("SELECT * FROM clients")
+ result = await db.execute(query)
+ return result.mappings().all()
+
+# POST: Crear cliente
+@router.post("/clients", response_model=ClientSchema)
+async def create_client(client: ClientCreate, db: AsyncSession = Depends(get_db)):
+ # Usamos ClientModel para guardar en BD
+ new_client = ClientModel(**client.dict())
+ db.add(new_client)
+ await db.commit()
+ await db.refresh(new_client)
+ return new_client
+
+# GET ONE: Obtener un cliente por ID
+@router.get("/clients/{client_id}", response_model=ClientSchema)
+async def read_client(client_id: int, db: AsyncSession = Depends(get_db)):
+ db_client = await db.get(ClientModel, client_id)
+ if not db_client:
+ raise HTTPException(status_code=404, detail="Client not found")
+ return db_client
+
+# PUT: Actualizar cliente
+@router.put("/clients/{client_id}", response_model=ClientSchema)
+async def update_client(client_id: int, client: ClientUpdate, db: AsyncSession = Depends(get_db)):
+ db_client = await db.get(ClientModel, client_id)
+ if not db_client:
+ raise HTTPException(status_code=404, detail="Client not found")
+
+ for key, value in client.dict(exclude_unset=True).items():
+ setattr(db_client, key, value)
+
+ await db.commit()
+ await db.refresh(db_client)
+ return db_client
+
+# DELETE: Borrar cliente
+@router.delete("/clients/{client_id}")
+async def delete_client(client_id: int, db: AsyncSession = Depends(get_db)):
+ db_client = await db.get(ClientModel, client_id)
+ if not db_client:
+ raise HTTPException(status_code=404, detail="Client not found")
+
+ await db.delete(db_client)
+ await db.commit()
+ return {"message": "Client deleted successfully"}
\ No newline at end of file
diff --git a/backend/app/api/v1/endpoints/tickets.py b/backend/app/api/v1/endpoints/tickets.py
new file mode 100644
index 0000000..f20642b
--- /dev/null
+++ b/backend/app/api/v1/endpoints/tickets.py
@@ -0,0 +1,75 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from pydantic import BaseModel
+from typing import List
+import uuid
+from sqlalchemy.exc import NoResultFound
+
+from app.core.database import get_db
+from app.models.ticket import Ticket # Asegúrate de tener el modelo Ticket
+from app.models.category import Category # Ensure Category model is imported
+from app.api import deps # Para autenticación y permisos
+from app.models.user import User # Asegúrate de importar el modelo User
+
+router = APIRouter()
+
+# Esquema para crear un ticket
+class TicketCreate(BaseModel):
+ title: str
+ description: str
+ category_id: uuid.UUID
+ priority: str
+
+# Esquema para devolver un ticket
+class TicketResponse(BaseModel):
+ id: uuid.UUID
+ title: str
+ description: str
+ category_id: uuid.UUID
+ priority: str
+ created_at: str
+
+@router.post("/", response_model=TicketResponse)
+async def create_ticket(
+ ticket: TicketCreate,
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(deps.get_current_active_user) # Verifica que el usuario esté autenticado
+):
+ # Validate if the category exists
+ try:
+ category = await db.execute(select(Category).where(Category.id == ticket.category_id))
+ category = category.scalar_one()
+ except NoResultFound:
+ raise HTTPException(status_code=404, detail="Category not found")
+
+ # Crear un nuevo ticket
+ db_ticket = Ticket(
+ ticket_number=str(uuid.uuid4())[:8], # Generate a unique ticket number
+ subject=ticket.title,
+ description=ticket.description,
+ category_id=ticket.category_id,
+ priority=ticket.priority,
+ created_by=current_user.id,
+ tenant_id=current_user.tenant_id
+ )
+ db.add(db_ticket)
+ await db.commit()
+ await db.refresh(db_ticket)
+ return db_ticket
+
+@router.get("/", response_model=List[TicketResponse])
+async def get_tickets(
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(deps.get_current_active_user) # Verifica que el usuario esté autenticado
+):
+ try:
+ result = await db.execute(select(Ticket).where(Ticket.tenant_id == current_user.tenant_id))
+ tickets = result.scalars().all()
+ return tickets
+ except Exception as e:
+ # Log the error for debugging purposes
+ import logging
+ logger = logging.getLogger("tickets")
+ logger.exception("Error fetching tickets for tenant_id=%s", current_user.tenant_id)
+ raise HTTPException(status_code=500, detail="An error occurred while fetching tickets.")
\ No newline at end of file
diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py
index 924133f..ceb69fa 100644
--- a/backend/app/api/v1/router.py
+++ b/backend/app/api/v1/router.py
@@ -5,7 +5,7 @@ Router principal para la API v1
"""
from fastapi import APIRouter
-from app.api.v1.endpoints import auth, health, tenants, users, systems, categories
+from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, clients, tickets
api_router = APIRouter()
@@ -45,3 +45,15 @@ api_router.include_router(
prefix="/categories",
tags=["categories"]
)
+
+api_router.include_router(
+ clients.router,
+ prefix="/clients",
+ tags=["clients"]
+)
+
+api_router.include_router(
+ tickets.router,
+ prefix="/tickets",
+ tags=["tickets"]
+)
\ No newline at end of file
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index 2c0a7b9..4d23ef7 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -119,6 +119,18 @@ class Settings(BaseSettings):
CELERY_BROKER_URL: str = Field(..., env="CELERY_BROKER_URL")
CELERY_RESULT_BACKEND: str = Field(..., env="CELERY_RESULT_BACKEND")
+ # ===================================
+ # CAMPOS ADICIONALES
+ # ===================================
+ POSTGRES_DB: str = Field(..., env="POSTGRES_DB")
+ POSTGRES_USER: str = Field(..., env="POSTGRES_USER")
+ POSTGRES_PASSWORD: str = Field(..., env="POSTGRES_PASSWORD")
+ SMTP_USERNAME: Optional[str] = Field(default=None, env="SMTP_USERNAME")
+ MAX_UPLOAD_SIZE: int = Field(default=10485760, env="MAX_UPLOAD_SIZE")
+ UPLOAD_DIR: str = Field(default="./uploads", env="UPLOAD_DIR")
+ RATE_LIMIT_REQUESTS: int = Field(default=100, env="RATE_LIMIT_REQUESTS")
+ RATE_LIMIT_WINDOW: int = Field(default=60, env="RATE_LIMIT_WINDOW")
+
def is_production(self) -> bool:
"""Check if environment is production."""
return self.ENVIRONMENT.lower() == "production"
@@ -139,4 +151,7 @@ def get_settings() -> Settings:
Using lru_cache to create a singleton pattern for settings.
"""
- return Settings()
\ No newline at end of file
+ return Settings()
+
+# Crear una instancia global de Settings
+settings = Settings()
\ No newline at end of file
diff --git a/backend/app/core/database.py b/backend/app/core/database.py
index 46ec699..e878458 100644
--- a/backend/app/core/database.py
+++ b/backend/app/core/database.py
@@ -10,6 +10,7 @@ from sqlalchemy import String, DateTime, func
from typing import AsyncGenerator
import uuid
from datetime import datetime
+import logging
from app.core.config import get_settings
@@ -34,6 +35,8 @@ AsyncSessionLocal = async_sessionmaker(
autocommit=False
)
+logger = logging.getLogger("database")
+
class Base(DeclarativeBase):
"""Base class para todos los modelos SQLAlchemy."""
@@ -57,13 +60,16 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
async with AsyncSessionLocal() as session:
try:
+ logger.debug("Intentando crear una nueva sesión de base de datos.")
yield session
await session.commit()
- except Exception:
+ except Exception as e:
+ logger.error("Error al crear la sesión de base de datos: %s", str(e))
await session.rollback()
raise
finally:
await session.close()
+ logger.debug("Sesión de base de datos cerrada.")
async def create_tables():
diff --git a/backend/app/middleware/tenant.py b/backend/app/middleware/tenant.py
index 11aa855..1836c56 100644
--- a/backend/app/middleware/tenant.py
+++ b/backend/app/middleware/tenant.py
@@ -8,10 +8,20 @@ from fastapi import Request, HTTPException, status
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
import structlog
+from contextlib import asynccontextmanager
+from sqlalchemy.sql import text
logger = structlog.get_logger(__name__)
+# Correct the usage of async for by wrapping it in an async function
+@asynccontextmanager
+async def get_db_context():
+ from app.core.database import get_db
+ async for db in get_db():
+ yield db
+
+
class TenantMiddleware(BaseHTTPMiddleware):
"""
Middleware para extraer y validar información del tenant.
@@ -34,33 +44,56 @@ class TenantMiddleware(BaseHTTPMiddleware):
"""Process request and add tenant information."""
# Skip tenant validation for excluded paths
- if request.url.path in self.EXCLUDED_PATHS or request.url.path.startswith("/docs"):
+ if request.url.path in self.EXCLUDED_PATHS or any(request.url.path.startswith(path) for path in self.EXCLUDED_PATHS):
return await call_next(request)
# Extract tenant from header
tenant_id = request.headers.get("X-Tenant-ID")
tenant_slug = request.headers.get("X-Tenant-Slug")
- # For now, we'll be more permissive in development
- # In production, tenant should be strictly required
+ # Revertir cambios para requerir encabezados de tenant
if not tenant_id and not tenant_slug:
- logger.warning(
- "Request without tenant information",
- path=request.url.path,
- method=request.method
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"
)
- # For now, continue without tenant for development
- # raise HTTPException(
- # status_code=status.HTTP_400_BAD_REQUEST,
- # detail="Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"
- # )
# Store tenant info in request state
request.state.tenant_id = tenant_id
request.state.tenant_slug = tenant_slug
- # TODO: Validate tenant exists and is active
- # This would involve a database query which we'll implement later
+ # Validate tenant exists and is active
+ if tenant_id or tenant_slug:
+ from app.core.database import get_db
+ from app.models.tenant import Tenant
+
+ # Update the middleware to use the new context manager
+ async with get_db_context() as db:
+ tenant = await db.execute(
+ text(
+ """
+ SELECT * FROM tenants
+ WHERE id = :tenant_id OR slug = :tenant_slug AND status = 'active'
+ """
+ ),
+ {"tenant_id": tenant_id, "tenant_slug": tenant_slug}
+ )
+ tenant = tenant.fetchone()
+
+ if not tenant:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Tenant not found or inactive"
+ )
+
+ # Store validated tenant info
+ request.state.tenant = tenant
+
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"
+ )
logger.debug(
"Tenant middleware processed",
diff --git a/backend/app/models/category.py b/backend/app/models/category.py
index 8ccb9b5..d54cf98 100644
--- a/backend/app/models/category.py
+++ b/backend/app/models/category.py
@@ -1,17 +1,17 @@
-
"""
Category Model - ServiceManagerWeb
"""
-from sqlalchemy import String, Text, Boolean, ForeignKey
+from sqlalchemy import String, Text, Boolean, ForeignKey, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.sql import func
from typing import List, Optional
import uuid
from app.core.database import Base
class Category(Base):
- __tablename__ = "categories"
+ __tablename__ = "ticket_categories"
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text)
@@ -20,6 +20,10 @@ class Category(Base):
# Optional: Tenant specific categories?
tenant_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True)
+ # Timestamp columns
+ created_at: Mapped[Optional[DateTime]] = mapped_column(DateTime(timezone=True), server_default=func.now())
+ updated_at: Mapped[Optional[DateTime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
+
# Relationships
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="category")
tenant: Mapped["Tenant"] = relationship("Tenant") # Assuming Tenant model is imported
diff --git a/backend/app/models/client.py b/backend/app/models/client.py
new file mode 100644
index 0000000..1738672
--- /dev/null
+++ b/backend/app/models/client.py
@@ -0,0 +1,29 @@
+from sqlalchemy import Column, Integer, String
+from app.core.database import Base
+
+class Client(Base):
+ __tablename__ = "clients"
+
+ id = Column(Integer, primary_key=True, index=True)
+ clave = Column(String, nullable=False)
+ tipo_cliente = Column(String, nullable=False)
+ nombre = Column(String, nullable=False)
+ pais = Column(String, nullable=False)
+ estado = Column(String, nullable=False)
+ ciudad = Column(String, nullable=False)
+ direccion = Column(String, nullable=False)
+ numero_ext = Column(String, nullable=True)
+ cp = Column(String, nullable=True)
+ colonia = Column(String, nullable=True)
+ lada = Column(String, nullable=True)
+ telefono1 = Column(String, nullable=True)
+ telefono2 = Column(String, nullable=True)
+ tel_directo = Column(String, nullable=True)
+ ext = Column(String, nullable=True)
+ fax = Column(String, nullable=True)
+ horario = Column(String, nullable=True)
+ pagina = Column(String, nullable=True)
+ correo = Column(String, nullable=True)
+ medio_publicidad = Column(String, nullable=True)
+ nacionalidad = Column(String, nullable=True)
+ logo = Column(String, nullable=True)
\ No newline at end of file
diff --git a/backend/app/models/ticket.py b/backend/app/models/ticket.py
index a78aadb..44a916e 100644
--- a/backend/app/models/ticket.py
+++ b/backend/app/models/ticket.py
@@ -9,6 +9,8 @@ import enum
import uuid
from app.core.database import Base
+from app.models.system import System
+from app.models.category import Category
class TicketStatus(str, enum.Enum):
NEW = "NEW"
@@ -43,8 +45,8 @@ class Ticket(Base):
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
assigned_to: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
- system_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("systems.id"), nullable=True)
- category_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("categories.id"), nullable=True)
+ affected_system_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("systems.id"), nullable=True)
+ category_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("ticket_categories.id"), nullable=True)
# Relationships
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="tickets")
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index c492222..8fd639f 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -13,6 +13,7 @@ import uuid
from datetime import datetime
from app.core.database import Base
+from app.models.ticket import Ticket
class UserRole(str, enum.Enum):
diff --git a/backend/app/schemas/client.py b/backend/app/schemas/client.py
new file mode 100644
index 0000000..08d8c2e
--- /dev/null
+++ b/backend/app/schemas/client.py
@@ -0,0 +1,37 @@
+from pydantic import BaseModel
+
+class ClientBase(BaseModel):
+ clave: str
+ tipo_cliente: str
+ nombre: str
+ pais: str
+ estado: str
+ ciudad: str
+ direccion: str
+ numero_ext: str | None = None
+ cp: str | None = None
+ colonia: str | None = None
+ lada: str | None = None
+ telefono1: str | None = None
+ telefono2: str | None = None
+ tel_directo: str | None = None
+ ext: str | None = None
+ fax: str | None = None
+ horario: str | None = None
+ pagina: str | None = None
+ correo: str | None = None
+ medio_publicidad: str | None = None
+ nacionalidad: str | None = None
+ logo: str | None = None
+
+class ClientCreate(ClientBase):
+ pass
+
+class ClientUpdate(ClientBase):
+ pass
+
+class Client(ClientBase):
+ id: int
+
+ class Config:
+ from_attributes = True
\ No newline at end of file
diff --git a/backend/fix_password_script.py b/backend/fix_password_script.py
index 81fd583..829d050 100644
--- a/backend/fix_password_script.py
+++ b/backend/fix_password_script.py
@@ -1,41 +1,40 @@
-
-import asyncio
import sys
import os
+import json
-# Add parent directory to path so we can import 'app'
+# Asegurar que el directorio raíz esté en PYTHONPATH
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
-from sqlalchemy import select
-from app.core.database import AsyncSessionLocal
-from app.models.tenant import Tenant # Import Tenant to register it
-from app.models.ticket import Ticket # Import Ticket to register it
-from app.models.user import User
from app.core.security import SecurityUtils
+from app.core.config import settings
+from app.models.user import User
+from app.models.tenant import Tenant
+from app.core.database import AsyncSessionLocal
+from sqlalchemy.future import select
+import asyncio
-async def fix_password():
- async with AsyncSessionLocal() as session:
- # Find the admin user
- email = "admin@aduanasoft.com"
- result = await session.execute(select(User).where(User.email == email))
- user = result.scalar_one_or_none()
-
- if user:
- print(f"User {email} found.")
- # Reset password to 'admin123'
- new_password = "admin123"
- hashed = SecurityUtils.hash_password(new_password)
- user.password_hash = hashed
-
- try:
- await session.commit()
- print(f"Password for {email} updated successfully!")
- print(f"New password is: {new_password}")
- except Exception as e:
- await session.rollback()
- print(f"Error updating password: {e}")
- else:
- print(f"User {email} not found!")
+# Generar un token predeterminado para el usuario admin
+def generate_default_token():
+ async def async_task():
+ async with AsyncSessionLocal() as db:
+ result = await db.execute(select(User).filter(User.email == "admin@aduanasoft.com"))
+ user = result.scalar_one_or_none()
+ if user:
+ print(f"User found: {user.email}")
+ token = SecurityUtils.create_access_token({"sub": str(user.id), "email": user.email})
+ print(f"Generated token: {token}")
-if __name__ == "__main__":
- asyncio.run(fix_password())
+ # Validate the token
+ payload = SecurityUtils.verify_token(token)
+ if payload:
+ print("Token is valid. Payload:")
+ print(json.dumps(payload, indent=4))
+ else:
+ print("Token validation failed.")
+ else:
+ print("User not found. Token not generated.")
+
+ asyncio.run(async_task())
+
+generate_default_token()
+print("Token generation process completed.")
diff --git a/backend/requirements.txt b/backend/requirements.txt
index c189022..342124d 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -15,7 +15,6 @@ pydantic-settings==2.1.0
sqlalchemy==2.0.23
alembic==1.13.0
asyncpg==0.29.0 # PostgreSQL async driver
-psycopg2-binary==2.9.9 # PostgreSQL sync driver (for migrations)
# ===================================
# AUTHENTICATION & SECURITY
@@ -42,7 +41,7 @@ jinja2==3.1.2 # Email templates
# FILE HANDLING
# ===================================
python-magic==0.4.27 # MIME type detection
-pillow==10.1.0 # Image processing
+# pillow==10.1.0 # Image processing
# ===================================
# HTTP & REQUESTS
diff --git a/backend/test_db_connection.py b/backend/test_db_connection.py
new file mode 100644
index 0000000..85c8c1e
--- /dev/null
+++ b/backend/test_db_connection.py
@@ -0,0 +1,18 @@
+import os
+import asyncio
+import asyncpg
+
+def get_database_url():
+ # Replace 'postgresql+asyncpg' with 'postgresql' for asyncpg compatibility
+ return os.getenv("DATABASE_URL", "postgresql://servicemanager:servicemanager123@servicemanager-db:5432/servicemanager").replace("postgresql+asyncpg", "postgresql")
+
+async def test_connection():
+ try:
+ conn = await asyncpg.connect(get_database_url())
+ print("Connection to the database was successful!")
+ await conn.close()
+ except Exception as e:
+ print(f"Failed to connect to the database: {e}")
+
+if __name__ == "__main__":
+ asyncio.run(test_connection())
\ No newline at end of file
diff --git a/backend/tests/test_clients.py b/backend/tests/test_clients.py
new file mode 100644
index 0000000..08f40e2
--- /dev/null
+++ b/backend/tests/test_clients.py
@@ -0,0 +1,71 @@
+import pytest
+from fastapi.testclient import TestClient
+from app.main import app
+
+client = TestClient(app)
+
+@pytest.fixture
+def sample_client_data():
+ return {
+ "clave": "12345",
+ "tipo_cliente": "Regular",
+ "nombre": "Cliente Prueba",
+ "pais": "México",
+ "estado": "Chihuahua",
+ "ciudad": "Cd. Juárez",
+ "direccion": "Calle Falsa 123",
+ "numero_ext": "12",
+ "cp": "32000",
+ "colonia": "Centro",
+ "lada": "656",
+ "telefono1": "1234567890",
+ "telefono2": "0987654321",
+ "tel_directo": "1231231234",
+ "ext": "101",
+ "fax": "1231231235",
+ "horario": "9:00 - 18:00",
+ "pagina": "www.clienteprueba.com",
+ "correo": "cliente@prueba.com",
+ "medio_publicidad": "Internet",
+ "nacionalidad": "Mexicana",
+ "logo": "logo.png"
+ }
+
+def test_create_client(sample_client_data):
+ response = client.post("/api/v1/clients", json=sample_client_data)
+ assert response.status_code == 200
+ assert response.json()["clave"] == sample_client_data["clave"]
+
+def test_read_client(sample_client_data):
+ # Create a client first
+ create_response = client.post("/api/v1/clients", json=sample_client_data)
+ client_id = create_response.json()["id"]
+
+ # Read the client
+ response = client.get(f"/api/v1/clients/{client_id}")
+ assert response.status_code == 200
+ assert response.json()["id"] == client_id
+
+def test_update_client(sample_client_data):
+ # Create a client first
+ create_response = client.post("/api/v1/clients", json=sample_client_data)
+ client_id = create_response.json()["id"]
+
+ # Update the client
+ updated_data = {"nombre": "Cliente Actualizado"}
+ response = client.put(f"/api/v1/clients/{client_id}", json=updated_data)
+ assert response.status_code == 200
+ assert response.json()["nombre"] == "Cliente Actualizado"
+
+def test_delete_client(sample_client_data):
+ # Create a client first
+ create_response = client.post("/api/v1/clients", json=sample_client_data)
+ client_id = create_response.json()["id"]
+
+ # Delete the client
+ response = client.delete(f"/api/v1/clients/{client_id}")
+ assert response.status_code == 200
+
+ # Verify deletion
+ response = client.get(f"/api/v1/clients/{client_id}")
+ assert response.status_code == 404
\ No newline at end of file
diff --git a/backend/tests/test_security.py b/backend/tests/test_security.py
new file mode 100644
index 0000000..41ff069
--- /dev/null
+++ b/backend/tests/test_security.py
@@ -0,0 +1,27 @@
+import pytest
+from app.core.security import SecurityUtils
+from datetime import timedelta
+
+# Test password hashing and verification
+def test_password_hashing():
+ plain_password = "securepassword123"
+ hashed_password = SecurityUtils.hash_password(plain_password)
+
+ assert SecurityUtils.verify_password(plain_password, hashed_password) == True
+ assert SecurityUtils.verify_password("wrongpassword", hashed_password) == False
+
+# Test JWT token generation and validation
+def test_jwt_token_generation():
+ payload = {"sub": "12345", "email": "test@example.com"}
+ token = SecurityUtils.create_access_token(payload, expires_delta=timedelta(minutes=15))
+
+ decoded_payload = SecurityUtils.verify_token(token)
+ assert decoded_payload is not None
+ assert decoded_payload["sub"] == "12345"
+ assert decoded_payload["email"] == "test@example.com"
+
+# Test TOTP secret generation
+def test_totp_secret_generation():
+ secret = SecurityUtils.generate_totp_secret()
+ assert len(secret) > 0
+ assert isinstance(secret, str)
\ No newline at end of file
diff --git a/db/schema.sql b/db/schema.sql
index c38f58d..e6f5916 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -38,6 +38,74 @@ CREATE INDEX idx_tenants_slug ON tenants(slug);
CREATE INDEX idx_tenants_status ON tenants(status);
CREATE INDEX idx_tenants_domain ON tenants(domain);
+-- ===================================
+-- DOMINIO: CLIENTS (Cartera de Clientes)
+-- ===================================
+
+-- Enum para los radio buttons de estatus
+CREATE TYPE client_status_enum AS ENUM (
+ 'prospect',
+ 'active',
+ 'suspended',
+ 'cancelled'
+);
+
+CREATE TABLE clients (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+
+ -- Identificación (Sección General)
+ code VARCHAR(50), -- Clave del cliente
+ name VARCHAR(200) NOT NULL, -- Nombre / Razón Social
+ tax_id VARCHAR(20), -- RFC
+ client_type VARCHAR(50), -- Tipo de Cliente
+ account_manager VARCHAR(100), -- Encargado Cliente (Texto simple por ahora)
+
+ -- Dirección (Desglosada)
+ address_street TEXT, -- Dirección / Calle
+ address_ext_num VARCHAR(20), -- Núm. Ext
+ neighborhood VARCHAR(100), -- Colonia
+ zip_code VARCHAR(10), -- CP
+ city VARCHAR(100), -- Ciudad
+ state VARCHAR(100), -- Estado
+ country VARCHAR(100) DEFAULT 'Mexico',
+
+ -- Contacto
+ phone_primary VARCHAR(20), -- Teléfono 1
+ phone_secondary VARCHAR(20),-- Teléfono 2
+ fax VARCHAR(20),
+ email VARCHAR(320), -- Correo
+ website VARCHAR(255), -- Página Web
+
+ -- Configuración y Flexibilidad
+ status client_status_enum DEFAULT 'prospect',
+ logo_url VARCHAR(500), -- Ruta al archivo PDF/Imagen del logo
+
+ -- Campo JSONB para lo que sobre (Horario, Nacionalidad, Medio Publicidad, Lada, etc.)
+ properties JSONB DEFAULT '{}'::jsonb,
+
+ -- Auditoría estándar
+ is_active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ -- Restricciones: No repetir RFC ni Clave dentro del mismo Tenant
+ UNIQUE(tenant_id, code),
+ UNIQUE(tenant_id, tax_id)
+);
+
+-- Índices para búsqueda rápida
+CREATE INDEX idx_clients_tenant_id ON clients(tenant_id);
+CREATE INDEX idx_clients_tax_id ON clients(tax_id);
+CREATE INDEX idx_clients_name ON clients(name);
+CREATE INDEX idx_clients_properties ON clients USING gin (properties);
+
+-- Trigger para mantener actualizado el campo updated_at
+-- (Usa la función que ya definiste al final de tu script)
+CREATE TRIGGER update_clients_updated_at BEFORE UPDATE ON clients
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+
-- ===================================
-- DOMINIO: AUTH (Autenticación)
-- ===================================
diff --git a/docker-compose.yml b/docker-compose.yml
index 7a296c7..b06998c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -55,6 +55,7 @@ services:
dockerfile: ../docker/Dockerfile.backend
container_name: servicemanager-backend
restart: unless-stopped
+ command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
env_file:
- .env
environment:
@@ -70,8 +71,10 @@ services:
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL}
+ - PYTHONPATH=/app
volumes:
- ./backend:/app
+ - ./tests:/app/tests
- uploads_data:/app/uploads
- logs_data:/app/logs
ports:
@@ -161,7 +164,7 @@ services:
restart: unless-stopped
environment:
- NODE_ENV=${ENVIRONMENT:-development}
- - PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000}
+ - PUBLIC_API_URL=http://backend:8000
- PUBLIC_APP_NAME=ServiceManager Cliente
volumes:
- ./frontend-client:/app
@@ -186,7 +189,7 @@ services:
restart: unless-stopped
environment:
- NODE_ENV=${ENVIRONMENT:-development}
- - PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000}
+ - PUBLIC_API_URL=http://backend:8000
- PUBLIC_APP_NAME=ServiceManager Admin
volumes:
- ./frontend-internal:/app
diff --git a/frontend-client/src/lib/stores/app.ts b/frontend-client/src/lib/stores/app.ts
index 300ea5f..5260ffd 100644
--- a/frontend-client/src/lib/stores/app.ts
+++ b/frontend-client/src/lib/stores/app.ts
@@ -29,12 +29,13 @@ const initialState: AppState = {
// API helper function
async function apiCall(endpoint: string, options: RequestInit = {}) {
const authState = get(auth);
-
+
const response = await fetch(`/api/v1${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authState.token}`,
+ 'X-Tenant-ID': authState.tenantId, // Added tenant header
...options.headers
}
});
@@ -60,8 +61,10 @@ function createAppStore() {
try {
const categories = await apiCall('/categories/');
+ console.log('Loaded categories:', categories); // Debugging
update(state => ({ ...state, categories, isLoading: false }));
} catch (error) {
+ console.error('Error loading categories:', error); // Debugging
update(state => ({
...state,
isLoading: false,
diff --git a/frontend-client/src/lib/stores/auth.ts b/frontend-client/src/lib/stores/auth.ts
index 0823712..94f4ac9 100644
--- a/frontend-client/src/lib/stores/auth.ts
+++ b/frontend-client/src/lib/stores/auth.ts
@@ -107,6 +107,7 @@ function createAuthStore() {
isLoading: false
});
} catch (error) {
+ console.error('Login error:', error); // Debugging the error
update(state => ({ ...state, isLoading: false }));
throw error;
}
diff --git a/frontend-client/src/lib/stores/clientes.ts b/frontend-client/src/lib/stores/clientes.ts
new file mode 100644
index 0000000..7e4e4d1
--- /dev/null
+++ b/frontend-client/src/lib/stores/clientes.ts
@@ -0,0 +1,44 @@
+import { writable } from 'svelte/store';
+
+export const clients = writable([]);
+
+export async function fetchClients() {
+ const response = await fetch('/api/v1/clients');
+ const data = await response.json();
+ clients.set(data);
+}
+
+export async function createClient(client) {
+ const response = await fetch('/api/v1/clients', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(client),
+ });
+ if (response.ok) {
+ fetchClients();
+ }
+}
+
+export async function updateClient(clientId, client) {
+ const response = await fetch(`/api/v1/clients/${clientId}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(client),
+ });
+ if (response.ok) {
+ fetchClients();
+ }
+}
+
+export async function deleteClient(clientId) {
+ const response = await fetch(`/api/v1/clients/${clientId}`, {
+ method: 'DELETE',
+ });
+ if (response.ok) {
+ fetchClients();
+ }
+}
\ No newline at end of file
diff --git a/frontend-client/src/routes/clients/+page.svelte b/frontend-client/src/routes/clients/+page.svelte
new file mode 100644
index 0000000..ab0b6a3
--- /dev/null
+++ b/frontend-client/src/routes/clients/+page.svelte
@@ -0,0 +1,53 @@
+
+
+
Cargando clientes...
+ {:else if clientList.length === 0} +No hay clientes registrados.
+ {:else} +| Nombre | +Acciones | +|
|---|---|---|
| {client.name} | +{client.email} | ++ Editar + | +
+ {ticket.description} +
+ ++ Gestiona tus tickets de soporte de manera eficiente. Crea nuevos tickets, + da seguimiento a los existentes y mantente actualizado con el estado de tus solicitudes. +
++ Reporta un problema o solicita soporte técnico +
++ Consulta el estado de todos tus tickets +
++ Actualiza tu información personal +
+Últimos tickets que has creado o actualizado
+Cargando tickets...
+Error al cargar los tickets
+ ++ {ticket.description} +
+
+ + Portal de atención a clientes. Genere tickets de soporte técnico para nuestros sistemas y reciba asistencia especializada para garantizar la continuidad de su operación. +
+Ingrese a su cuenta corporativa
++ Gestiona tu información personal y configuración de seguridad +
+Actualiza tu información básica
+Gestiona tu contraseña y configuración de seguridad
++ {$auth.user?.is_two_factor_enabled + ? 'La autenticación de dos factores está habilitada' + : 'Mejora la seguridad habilitando 2FA'} +
+Detalles sobre tu cuenta y organización
++ Gestiona y da seguimiento a todos tus tickets de soporte +
+Total
+{$tickets.tickets.length}
+En Progreso
++ {statusCounts['IN_PROGRESS'] || 0} +
+Esperando
++ {statusCounts['WAITING_FOR_CLIENT'] || 0} +
+Resueltos
++ {(statusCounts['RESOLVED'] || 0) + (statusCounts['CLOSED'] || 0)} +
+Cargando tickets...
+{$tickets.error}
+ ++ {$tickets.tickets.length === 0 + ? 'Crea tu primer ticket para comenzar' + : 'Intenta ajustar los filtros de búsqueda'} +
+ {#if $tickets.tickets.length === 0} + + Crear Ticket + + {:else} + + {/if} ++ Describe tu problema o solicitud de soporte con el mayor detalle posible +
+Cargando ticket...
+{$tickets.error}
+ ++ {$tickets.currentTicket.description} +
++ {$tickets.currentTicket.resolution} +
++ {attachment.original_filename} +
++ {Math.round(attachment.size_bytes / 1024)} KB • + Subido por {attachment.uploaded_by_name} • + {formatDate(attachment.uploaded_at)} +
++ No hay comentarios aún. ¡Sé el primero en comentar! +
+ {:else} ++ {comment.content} +
++ ¿Estás seguro de que quieres cerrar este ticket? Esta acción indica que el problema ha sido resuelto satisfactoriamente. +
+Panel Interno
++ {$auth.user?.first_name} {$auth.user?.last_name} +
++ {$auth.user?.role === 'ADMIN' ? 'Administrador' : + $auth.user?.role === 'SUPPORT_MANAGER' ? 'Gerente de Soporte' : + $auth.user?.role === 'AGENT' ? 'Agente' : 'Auditor'} +
++ Bienvenido al sistema de gestión interna. +
+Gestión de categorías para clasificación de tickets.
+| Nombre | +Descripción | +Tipo (Cliente) | +Estado | ++ Acciones + | +
|---|---|---|---|---|
| Cargando... | ||||
| No hay categorías registradas | ||||
| {category.name} | +{category.description || '-'} | ++ + {getTenantName(category.tenant_id)} + + | ++ + {category.is_active ? 'Activo' : 'Inactivo'} + + | ++ + | +
+ Plataforma de gestión de servicios, monitoreo de tickets y administración de usuarios. Acceso restringido únicamente a personal autorizado. +
+Acceso al sistema central
+Aduanasoft Internal Systems © 2024
+Catálogo de sistemas informáticos gestionados.
+| Nombre | +Descripción | +Estado | ++ Acciones + | +
|---|---|---|---|
| Cargando... | |||
| No hay sistemas registrados | |||
| {system.name} | +{system.description || '-'} | ++ + {system.is_active ? 'Activo' : 'Inactivo'} + + | ++ + | +
Lista de todas las organizaciones/clientes registrados en el sistema.
+| Nombre | +Slug | +Dominio | +Estado | ++ Acciones + | +
|---|---|---|---|---|
| Cargando... | ||||
| No hay clientes registrados | ||||
| {tenant.name} | +{tenant.slug} | +{tenant.domain || '-'} | ++ + {tenant.is_active ? 'Activo' : 'Inactivo'} + + | ++ + | +
Gestión de usuarios internos y de clientes.
+| Usuario | +Rol | +Cliente (Tenant) | +Estado | ++ Acciones + | +
|---|---|---|---|---|
| Cargando... | ||||
| No hay usuarios registrados | ||||
|
+ {user.first_name} {user.last_name}
+ {user.email}
+ |
+ {user.role} | +{getTenantName(user.tenant_id)} | ++ + {user.is_active ? 'Activo' : 'Inactivo'} + + | ++ + | +
Hola {{ user_name }},
+Se ha creado un nuevo ticket:
+Saludos,
Equipo de Soporte
Hola {{ agent_name }},
+Se te ha asignado el ticket:
+