Compare commits

...

1 Commits
v1.6.2 ... v1.1

Author SHA1 Message Date
arielit3
99427cd48c Subir todos los cambios recientes a la rama principal 2026-01-16 13:39:55 -07:00
38 changed files with 15383 additions and 66 deletions

Binary file not shown.

Binary file not shown.

36
backend/alembic.ini Normal file
View File

@@ -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

65
backend/alembic/env.py Normal file
View File

@@ -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())

View File

@@ -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')

View File

@@ -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')

View File

@@ -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")

View File

@@ -55,3 +55,13 @@ async def get_current_active_superuser(
status_code=403, detail="The user doesn't have enough privileges" status_code=403, detail="The user doesn't have enough privileges"
) )
return current_user 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

View File

@@ -4,6 +4,7 @@ from sqlalchemy import select
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from typing import List, Optional from typing import List, Optional
import uuid import uuid
import logging
from app.core.database import get_db from app.core.database import get_db
from app.models.category import Category from app.models.category import Category
@@ -11,6 +12,8 @@ from app.api import deps
router = APIRouter() router = APIRouter()
logger = logging.getLogger("categories")
class CategoryBase(BaseModel): class CategoryBase(BaseModel):
name: str name: str
description: Optional[str] = None description: Optional[str] = None
@@ -38,18 +41,56 @@ async def read_categories(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser) current_user = Depends(deps.get_current_active_superuser)
): ):
query = select(Category).offset(skip).limit(limit) try:
result = await db.execute(query) logger.debug("Fetching categories with skip=%d, limit=%d", skip, limit)
return result.scalars().all() 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) @router.post("/", response_model=CategoryResponse)
async def create_category( async def create_category(
category: CategoryCreate, category: CategoryCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser) current_user = Depends(deps.get_current_active_superuser)
): ):
db_category = Category(**category.model_dump()) new_category = Category(**category.model_dump())
db.add(db_category) 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.commit()
await db.refresh(db_category) await db.refresh(db_category)
return 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()

View File

@@ -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"}

View File

@@ -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.")

View File

@@ -5,7 +5,7 @@ Router principal para la API v1
""" """
from fastapi import APIRouter 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() api_router = APIRouter()
@@ -45,3 +45,15 @@ api_router.include_router(
prefix="/categories", prefix="/categories",
tags=["categories"] tags=["categories"]
) )
api_router.include_router(
clients.router,
prefix="/clients",
tags=["clients"]
)
api_router.include_router(
tickets.router,
prefix="/tickets",
tags=["tickets"]
)

View File

@@ -119,6 +119,18 @@ class Settings(BaseSettings):
CELERY_BROKER_URL: str = Field(..., env="CELERY_BROKER_URL") CELERY_BROKER_URL: str = Field(..., env="CELERY_BROKER_URL")
CELERY_RESULT_BACKEND: str = Field(..., env="CELERY_RESULT_BACKEND") 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: def is_production(self) -> bool:
"""Check if environment is production.""" """Check if environment is production."""
return self.ENVIRONMENT.lower() == "production" return self.ENVIRONMENT.lower() == "production"
@@ -139,4 +151,7 @@ def get_settings() -> Settings:
Using lru_cache to create a singleton pattern for settings. Using lru_cache to create a singleton pattern for settings.
""" """
return Settings() return Settings()
# Crear una instancia global de Settings
settings = Settings()

View File

@@ -10,6 +10,7 @@ from sqlalchemy import String, DateTime, func
from typing import AsyncGenerator from typing import AsyncGenerator
import uuid import uuid
from datetime import datetime from datetime import datetime
import logging
from app.core.config import get_settings from app.core.config import get_settings
@@ -34,6 +35,8 @@ AsyncSessionLocal = async_sessionmaker(
autocommit=False autocommit=False
) )
logger = logging.getLogger("database")
class Base(DeclarativeBase): class Base(DeclarativeBase):
"""Base class para todos los modelos SQLAlchemy.""" """Base class para todos los modelos SQLAlchemy."""
@@ -57,13 +60,16 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
""" """
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
try: try:
logger.debug("Intentando crear una nueva sesión de base de datos.")
yield session yield session
await session.commit() 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() await session.rollback()
raise raise
finally: finally:
await session.close() await session.close()
logger.debug("Sesión de base de datos cerrada.")
async def create_tables(): async def create_tables():

View File

@@ -8,10 +8,20 @@ from fastapi import Request, HTTPException, status
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response from starlette.responses import Response
import structlog import structlog
from contextlib import asynccontextmanager
from sqlalchemy.sql import text
logger = structlog.get_logger(__name__) 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): class TenantMiddleware(BaseHTTPMiddleware):
""" """
Middleware para extraer y validar información del tenant. Middleware para extraer y validar información del tenant.
@@ -34,33 +44,56 @@ class TenantMiddleware(BaseHTTPMiddleware):
"""Process request and add tenant information.""" """Process request and add tenant information."""
# Skip tenant validation for excluded paths # 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) return await call_next(request)
# Extract tenant from header # Extract tenant from header
tenant_id = request.headers.get("X-Tenant-ID") tenant_id = request.headers.get("X-Tenant-ID")
tenant_slug = request.headers.get("X-Tenant-Slug") tenant_slug = request.headers.get("X-Tenant-Slug")
# For now, we'll be more permissive in development # Revertir cambios para requerir encabezados de tenant
# In production, tenant should be strictly required
if not tenant_id and not tenant_slug: if not tenant_id and not tenant_slug:
logger.warning( raise HTTPException(
"Request without tenant information", status_code=status.HTTP_400_BAD_REQUEST,
path=request.url.path, detail="Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"
method=request.method
) )
# 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 # Store tenant info in request state
request.state.tenant_id = tenant_id request.state.tenant_id = tenant_id
request.state.tenant_slug = tenant_slug request.state.tenant_slug = tenant_slug
# TODO: Validate tenant exists and is active # Validate tenant exists and is active
# This would involve a database query which we'll implement later 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( logger.debug(
"Tenant middleware processed", "Tenant middleware processed",

View File

@@ -1,17 +1,17 @@
""" """
Category Model - ServiceManagerWeb 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.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
from typing import List, Optional from typing import List, Optional
import uuid import uuid
from app.core.database import Base from app.core.database import Base
class Category(Base): class Category(Base):
__tablename__ = "categories" __tablename__ = "ticket_categories"
name: Mapped[str] = mapped_column(String(100), nullable=False) name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text) description: Mapped[Optional[str]] = mapped_column(Text)
@@ -20,6 +20,10 @@ class Category(Base):
# Optional: Tenant specific categories? # Optional: Tenant specific categories?
tenant_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True) 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 # Relationships
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="category") tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="category")
tenant: Mapped["Tenant"] = relationship("Tenant") # Assuming Tenant model is imported tenant: Mapped["Tenant"] = relationship("Tenant") # Assuming Tenant model is imported

View File

@@ -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)

View File

@@ -9,6 +9,8 @@ import enum
import uuid import uuid
from app.core.database import Base from app.core.database import Base
from app.models.system import System
from app.models.category import Category
class TicketStatus(str, enum.Enum): class TicketStatus(str, enum.Enum):
NEW = "NEW" 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) 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) 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) 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("categories.id"), nullable=True) category_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("ticket_categories.id"), nullable=True)
# Relationships # Relationships
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="tickets") tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="tickets")

View File

@@ -13,6 +13,7 @@ import uuid
from datetime import datetime from datetime import datetime
from app.core.database import Base from app.core.database import Base
from app.models.ticket import Ticket
class UserRole(str, enum.Enum): class UserRole(str, enum.Enum):

View File

@@ -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

View File

@@ -1,41 +1,40 @@
import asyncio
import sys import sys
import os 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__))) 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.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(): # Generar un token predeterminado para el usuario admin
async with AsyncSessionLocal() as session: def generate_default_token():
# Find the admin user async def async_task():
email = "admin@aduanasoft.com" async with AsyncSessionLocal() as db:
result = await session.execute(select(User).where(User.email == email)) result = await db.execute(select(User).filter(User.email == "admin@aduanasoft.com"))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user:
if user: print(f"User found: {user.email}")
print(f"User {email} found.") token = SecurityUtils.create_access_token({"sub": str(user.id), "email": user.email})
# Reset password to 'admin123' print(f"Generated token: {token}")
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!")
if __name__ == "__main__": # Validate the token
asyncio.run(fix_password()) 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.")

View File

@@ -15,7 +15,6 @@ pydantic-settings==2.1.0
sqlalchemy==2.0.23 sqlalchemy==2.0.23
alembic==1.13.0 alembic==1.13.0
asyncpg==0.29.0 # PostgreSQL async driver asyncpg==0.29.0 # PostgreSQL async driver
psycopg2-binary==2.9.9 # PostgreSQL sync driver (for migrations)
# =================================== # ===================================
# AUTHENTICATION & SECURITY # AUTHENTICATION & SECURITY
@@ -42,7 +41,7 @@ jinja2==3.1.2 # Email templates
# FILE HANDLING # FILE HANDLING
# =================================== # ===================================
python-magic==0.4.27 # MIME type detection python-magic==0.4.27 # MIME type detection
pillow==10.1.0 # Image processing # pillow==10.1.0 # Image processing
# =================================== # ===================================
# HTTP & REQUESTS # HTTP & REQUESTS

View File

@@ -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())

View File

@@ -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

View File

@@ -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)

View File

@@ -38,6 +38,74 @@ CREATE INDEX idx_tenants_slug ON tenants(slug);
CREATE INDEX idx_tenants_status ON tenants(status); CREATE INDEX idx_tenants_status ON tenants(status);
CREATE INDEX idx_tenants_domain ON tenants(domain); 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) -- DOMINIO: AUTH (Autenticación)
-- =================================== -- ===================================

View File

@@ -55,6 +55,7 @@ services:
dockerfile: ../docker/Dockerfile.backend dockerfile: ../docker/Dockerfile.backend
container_name: servicemanager-backend container_name: servicemanager-backend
restart: unless-stopped restart: unless-stopped
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
env_file: env_file:
- .env - .env
environment: environment:
@@ -70,8 +71,10 @@ services:
- SMTP_HOST=${SMTP_HOST} - SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT} - SMTP_PORT=${SMTP_PORT}
- DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL} - DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL}
- PYTHONPATH=/app
volumes: volumes:
- ./backend:/app - ./backend:/app
- ./tests:/app/tests
- uploads_data:/app/uploads - uploads_data:/app/uploads
- logs_data:/app/logs - logs_data:/app/logs
ports: ports:
@@ -161,7 +164,7 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
- NODE_ENV=${ENVIRONMENT:-development} - NODE_ENV=${ENVIRONMENT:-development}
- PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000} - PUBLIC_API_URL=http://backend:8000
- PUBLIC_APP_NAME=ServiceManager Cliente - PUBLIC_APP_NAME=ServiceManager Cliente
volumes: volumes:
- ./frontend-client:/app - ./frontend-client:/app
@@ -186,7 +189,7 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
- NODE_ENV=${ENVIRONMENT:-development} - NODE_ENV=${ENVIRONMENT:-development}
- PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000} - PUBLIC_API_URL=http://backend:8000
- PUBLIC_APP_NAME=ServiceManager Admin - PUBLIC_APP_NAME=ServiceManager Admin
volumes: volumes:
- ./frontend-internal:/app - ./frontend-internal:/app

View File

@@ -29,12 +29,13 @@ const initialState: AppState = {
// API helper function // API helper function
async function apiCall(endpoint: string, options: RequestInit = {}) { async function apiCall(endpoint: string, options: RequestInit = {}) {
const authState = get(auth); const authState = get(auth);
const response = await fetch(`/api/v1${endpoint}`, { const response = await fetch(`/api/v1${endpoint}`, {
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${authState.token}`, 'Authorization': `Bearer ${authState.token}`,
'X-Tenant-ID': authState.tenantId, // Added tenant header
...options.headers ...options.headers
} }
}); });
@@ -60,8 +61,10 @@ function createAppStore() {
try { try {
const categories = await apiCall('/categories/'); const categories = await apiCall('/categories/');
console.log('Loaded categories:', categories); // Debugging
update(state => ({ ...state, categories, isLoading: false })); update(state => ({ ...state, categories, isLoading: false }));
} catch (error) { } catch (error) {
console.error('Error loading categories:', error); // Debugging
update(state => ({ update(state => ({
...state, ...state,
isLoading: false, isLoading: false,

View File

@@ -107,6 +107,7 @@ function createAuthStore() {
isLoading: false isLoading: false
}); });
} catch (error) { } catch (error) {
console.error('Login error:', error); // Debugging the error
update(state => ({ ...state, isLoading: false })); update(state => ({ ...state, isLoading: false }));
throw error; throw error;
} }

View File

@@ -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();
}
}

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import { onMount } from 'svelte';
import { clients } from '$lib/stores/clients';
import { toast } from '$lib/stores/toast';
let clientList = [];
let isLoading = true;
onMount(async () => {
try {
clientList = await clients.loadClients();
} catch (error) {
toast.error('Error al cargar los clientes');
} finally {
isLoading = false;
}
});
</script>
<svelte:head>
<title>Clientes - ServiceManager</title>
</svelte:head>
<div class="container mx-auto py-8">
<h1 class="text-2xl font-bold mb-4">Listado de Clientes</h1>
{#if isLoading}
<p>Cargando clientes...</p>
{:else if clientList.length === 0}
<p>No hay clientes registrados.</p>
{:else}
<table class="table-auto w-full border-collapse border border-gray-300">
<thead>
<tr>
<th class="border border-gray-300 px-4 py-2">Nombre</th>
<th class="border border-gray-300 px-4 py-2">Email</th>
<th class="border border-gray-300 px-4 py-2">Acciones</th>
</tr>
</thead>
<tbody>
{#each clientList as client}
<tr>
<td class="border border-gray-300 px-4 py-2">{client.name}</td>
<td class="border border-gray-300 px-4 py-2">{client.email}</td>
<td class="border border-gray-300 px-4 py-2">
<a href={`/clients/${client.id}/edit`} class="text-blue-500 hover:underline">Editar</a>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import { onMount } from 'svelte';
import { clients } from '$lib/stores/clients';
import { toast } from '$lib/stores/toast';
import { goto, page } from '$app/navigation';
let clientId = $page.params.id;
let name = '';
let email = '';
let isSubmitting = false;
let errors: Record<string, string> = {};
onMount(async () => {
try {
const client = await clients.loadClient(clientId);
name = client.name;
email = client.email;
} catch (error) {
toast.error('Error al cargar el cliente');
goto('/clients');
}
});
function validateForm() {
errors = {};
if (!name.trim()) {
errors.name = 'El nombre es requerido';
}
if (!email.trim()) {
errors.email = 'El email es requerido';
}
return Object.keys(errors).length === 0;
}
async function handleSubmit() {
if (!validateForm()) return;
isSubmitting = true;
try {
await clients.updateClient(clientId, { name, email });
toast.success('Cliente actualizado exitosamente');
goto('/clients');
} catch (error) {
toast.error('Error al actualizar el cliente');
} finally {
isSubmitting = false;
}
}
</script>
<svelte:head>
<title>Editar Cliente - ServiceManager</title>
</svelte:head>
<div class="container mx-auto py-8">
<h1 class="text-2xl font-bold mb-4">Editar Cliente</h1>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
<input id="name" type="text" bind:value={name} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm" />
{#if errors.name}
<p class="text-red-500 text-sm">{errors.name}</p>
{/if}
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input id="email" type="email" bind:value={email} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm" />
{#if errors.email}
<p class="text-red-500 text-sm">{errors.email}</p>
{/if}
</div>
<button type="submit" class="btn btn-primary" disabled={isSubmitting}>
{isSubmitting ? 'Guardando...' : 'Guardar Cambios'}
</button>
</form>
</div>

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import { onMount } from 'svelte';
import { clients } from '$lib/stores/clients';
import { toast } from '$lib/stores/toast';
import { goto } from '$app/navigation';
let name = '';
let email = '';
let isSubmitting = false;
let errors: Record<string, string> = {};
function validateForm() {
errors = {};
if (!name.trim()) {
errors.name = 'El nombre es requerido';
}
if (!email.trim()) {
errors.email = 'El email es requerido';
}
return Object.keys(errors).length === 0;
}
async function handleSubmit() {
if (!validateForm()) return;
isSubmitting = true;
try {
await clients.createClient({ name, email });
toast.success('Cliente creado exitosamente');
goto('/clients');
} catch (error) {
toast.error('Error al crear el cliente');
} finally {
isSubmitting = false;
}
}
</script>
<svelte:head>
<title>Crear Cliente - ServiceManager</title>
</svelte:head>
<div class="container mx-auto py-8">
<h1 class="text-2xl font-bold mb-4">Crear Nuevo Cliente</h1>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
<input id="name" type="text" bind:value={name} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm" />
{#if errors.name}
<p class="text-red-500 text-sm">{errors.name}</p>
{/if}
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input id="email" type="email" bind:value={email} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm" />
{#if errors.email}
<p class="text-red-500 text-sm">{errors.email}</p>
{/if}
</div>
<button type="submit" class="btn btn-primary" disabled={isSubmitting}>
{isSubmitting ? 'Creando...' : 'Crear Cliente'}
</button>
</form>
</div>

View File

@@ -0,0 +1,45 @@
import { writable } from 'svelte/store';
export const api = {
async getClients() {
const response = await fetch('/api/v1/clients');
if (!response.ok) {
throw new Error('Error fetching clients');
}
return await response.json();
},
async createClient(client) {
const response = await fetch('/api/v1/clients', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(client),
});
if (!response.ok) {
throw new Error('Error creating client');
}
return await response.json();
},
async 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) {
throw new Error('Error updating client');
}
return await response.json();
},
async deleteClient(clientId) {
const response = await fetch(`/api/v1/clients/${clientId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Error deleting client');
}
return await response.json();
},
};

BIN
login_payload.json Normal file

Binary file not shown.

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "ServiceManagerWeb",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

14279
proyecto_completo.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
import sys
import os
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.tenant import Tenant
from app.core.database import get_db
# Add the project root to PYTHONPATH
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
from app.main import app
@pytest.fixture(scope="function")
async def setup_test_data():
async with get_db() as db:
# Insert a valid tenant
valid_tenant = Tenant(id="valid-tenant-id", slug="valid-tenant", status="active")
await db.add(valid_tenant)
await db.commit()
# Insert an invalid tenant
invalid_tenant = Tenant(id="invalid-tenant-id", slug="invalid-tenant", status="inactive")
await db.add(invalid_tenant)
await db.commit()
yield
# Cleanup
await db.delete(valid_tenant)
await db.delete(invalid_tenant)
await db.commit()
@pytest.mark.asyncio
async def test_request_without_tenant(setup_test_data):
async with AsyncClient(app=app, base_url="http://testserver") as client:
response = await client.get("/v1/categories/")
assert response.status_code == 400
assert response.json()["detail"] == "Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"
@pytest.mark.asyncio
async def test_request_with_invalid_tenant(setup_test_data):
async with AsyncClient(app=app, base_url="http://testserver") as client:
headers = {"X-Tenant-ID": "invalid-tenant-id"}
response = await client.get("/v1/categories/", headers=headers)
assert response.status_code == 404
assert response.json()["detail"] == "Tenant not found or inactive"
@pytest.mark.asyncio
async def test_request_with_valid_tenant(setup_test_data):
async with AsyncClient(app=app, base_url="http://testserver") as client:
headers = {"X-Tenant-ID": "valid-tenant-id"}
response = await client.get("/v1/categories/", headers=headers)
assert response.status_code == 200