Compare commits

...

1 Commits

Author SHA1 Message Date
7bb3d1a0ac Nuevos cambios en el servidor 2026-01-27 13:22:13 -07:00
33 changed files with 8737 additions and 169 deletions

37
alembic.ini Normal file
View File

@@ -0,0 +1,37 @@
[alembic]
script_location = backend/migrations
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers = console
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers = console
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(asctime)s %(levelname)-5.5s [%(name)s] %(message)s

38
backend/alembic.ini Normal file
View File

@@ -0,0 +1,38 @@
[alembic]
script_location = backend/migrations
sqlalchemy.url = postgresql://servicemanager:servicemanager123@172.19.0.3:5432/servicemanager
target_metadata = app.core.database.Base.metadata
default_environment = development
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers = console
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers = console
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
formatter = generic
[formatter_generic]
format = %(asctime)s %(levelname)-5.5s [%(name)s] %(message)s

View File

@@ -85,6 +85,9 @@ async def update_tenant(
raise HTTPException(status_code=404, detail="Tenant not found")
update_data = tenant_in.model_dump(exclude_unset=True)
if "status" in update_data:
tenant.is_active = update_data.pop("status") == TenantStatus.active
for field, value in update_data.items():
setattr(tenant, field, value)
@@ -92,3 +95,18 @@ async def update_tenant(
await db.commit()
await db.refresh(tenant)
return tenant
@router.delete("/{tenant_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tenant(
tenant_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser)
):
"""Eliminar un cliente (tenant) por ID."""
tenant = await db.get(Tenant, tenant_id)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
await db.delete(tenant)
await db.commit()
return {"message": "Tenant deleted successfully"}

View File

@@ -1,8 +1,9 @@
"""
Tickets endpoints - ServiceManagerWeb
Tickets endpoints - ServiceManagerWeb (VERSION FINAL CORREGIDA)
"""
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from typing import List, Optional
@@ -11,10 +12,13 @@ from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.ticket import Ticket, TicketStatus, TicketPriority
from app.models.user import User
from pydantic import BaseModel
from app.models.category import Category
from app.models.system import System
import uuid
import logging
router = APIRouter()
logger = logging.getLogger(__name__)
# ===================================
# SCHEMAS
@@ -24,7 +28,7 @@ class TicketCreate(BaseModel):
subject: str
description: str
category_id: Optional[str] = None
system_id: Optional[str] = None
affected_system_id: Optional[str] = None # Updated field name
priority: str = "MEDIUM"
class TicketUpdate(BaseModel):
@@ -42,7 +46,7 @@ class TicketResponse(BaseModel):
status: str
priority: str
category_id: Optional[str] = None
system_id: Optional[str] = None
affected_system_id: Optional[str] = None # Updated field name
created_by: str
assigned_to: Optional[str] = None
created_at: datetime
@@ -78,8 +82,27 @@ async def create_ticket(
# Convertir IDs de string a UUID si son proporcionados
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
system_uuid = uuid.UUID(ticket.system_id) if ticket.system_id else None
system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None
# ✅ CORRECCIÓN: Validar en la tabla correcta 'categories'
if category_uuid:
category = await db.get(Category, category_uuid)
if not category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"La categoría con ID {ticket.category_id} no existe."
)
# Validar si el system_id existe en la tabla systems
if system_uuid:
system = await db.get(System, system_uuid)
if not system:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"El sistema con ID {ticket.affected_system_id} no existe."
)
# ✅ CORRECCIÓN: Usar 'affected_system_id' (nombre real en la BD)
db_ticket = Ticket(
id=uuid.uuid4(),
tenant_id=current_user.tenant_id,
@@ -87,7 +110,7 @@ async def create_ticket(
subject=ticket.subject,
description=ticket.description,
category_id=category_uuid,
system_id=system_uuid,
affected_system_id=system_uuid, # Updated field name
priority=TicketPriority[ticket.priority.upper()],
created_by=current_user.id,
status=TicketStatus.NEW,
@@ -99,7 +122,9 @@ async def create_ticket(
await db.commit()
await db.refresh(db_ticket)
# Convertir a respuesta
logger.info(f"Ticket {db_ticket.ticket_number} created by {current_user.email}")
# ✅ CORRECCIÓN: Usar affected_system_id
return {
"id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number,
@@ -108,7 +133,7 @@ async def create_ticket(
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"system_id": str(db_ticket.system_id) if db_ticket.system_id else None,
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, # ✅ CORRECTO
"created_by": str(db_ticket.created_by),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at,
@@ -117,12 +142,17 @@ async def create_ticket(
except ValueError as e:
await db.rollback()
logger.error(f"Invalid UUID format: {str(e)}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid UUID format: {str(e)}"
)
except HTTPException:
await db.rollback()
raise
except Exception as e:
await db.rollback()
logger.error(f"Error creating ticket: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error creating ticket: {str(e)}"
@@ -140,43 +170,55 @@ async def get_tickets(
"""
Obtener tickets del usuario actual
"""
query = select(Ticket).where(
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
if status_filter:
try:
status_enum = TicketStatus[status_filter.upper()]
query = query.where(Ticket.status == status_enum)
except KeyError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid status: {status_filter}"
)
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
tickets = result.scalars().all()
return [
{
"id": str(t.id),
"ticket_number": t.ticket_number,
"subject": t.subject,
"description": t.description,
"status": t.status.value,
"priority": t.priority.value,
"category_id": str(t.category_id) if t.category_id else None,
"system_id": str(t.system_id) if t.system_id else None,
"created_by": str(t.created_by),
"assigned_to": str(t.assigned_to) if t.assigned_to else None,
"created_at": t.created_at,
"updated_at": t.updated_at
}
for t in tickets
]
try:
query = select(Ticket).where(
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
if status_filter:
try:
status_enum = TicketStatus[status_filter.upper()]
query = query.where(Ticket.status == status_enum)
except KeyError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid status: {status_filter}"
)
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
tickets = result.scalars().all()
logger.info(f"Listed {len(tickets)} tickets for user {current_user.email}")
# ✅ CORRECCIÓN: Usar affected_system_id
return [
{
"id": str(t.id),
"ticket_number": t.ticket_number,
"subject": t.subject,
"description": t.description,
"status": t.status.value,
"priority": t.priority.value,
"category_id": str(t.category_id) if t.category_id else None,
"system_id": str(t.affected_system_id) if t.affected_system_id else None, # ✅ CORRECTO
"created_by": str(t.created_by),
"assigned_to": str(t.assigned_to) if t.assigned_to else None,
"created_at": t.created_at,
"updated_at": t.updated_at
}
for t in tickets
]
except HTTPException:
raise
except Exception as e:
logger.error(f"Error listing tickets: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error listing tickets: {str(e)}"
)
@router.get("/{ticket_id}", response_model=TicketResponse)
@@ -196,35 +238,45 @@ async def get_ticket(
detail="Invalid ticket ID format"
)
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
result = await db.execute(query)
ticket = result.scalars().first()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Ticket {ticket_id} not found"
try:
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
result = await db.execute(query)
ticket = result.scalars().first()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Ticket {ticket_id} not found"
)
# ✅ CORRECCIÓN: Usar affected_system_id
return {
"id": str(ticket.id),
"ticket_number": ticket.ticket_number,
"subject": ticket.subject,
"description": ticket.description,
"status": ticket.status.value,
"priority": ticket.priority.value,
"category_id": str(ticket.category_id) if ticket.category_id else None,
"system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None, # ✅ CORRECTO
"created_by": str(ticket.created_by),
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
"created_at": ticket.created_at,
"updated_at": ticket.updated_at
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting ticket: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error getting ticket: {str(e)}"
)
return {
"id": str(ticket.id),
"ticket_number": ticket.ticket_number,
"subject": ticket.subject,
"description": ticket.description,
"status": ticket.status.value,
"priority": ticket.priority.value,
"category_id": str(ticket.category_id) if ticket.category_id else None,
"system_id": str(ticket.system_id) if ticket.system_id else None,
"created_by": str(ticket.created_by),
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
"created_at": ticket.created_at,
"updated_at": ticket.updated_at
}
@router.patch("/{ticket_id}", response_model=TicketResponse)
@@ -245,22 +297,22 @@ async def update_ticket(
detail="Invalid ticket ID format"
)
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
result = await db.execute(query)
db_ticket = result.scalars().first()
if not db_ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Ticket {ticket_id} not found"
)
try:
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
result = await db.execute(query)
db_ticket = result.scalars().first()
if not db_ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Ticket {ticket_id} not found"
)
update_data = ticket_update.dict(exclude_unset=True)
for field, value in update_data.items():
@@ -278,6 +330,9 @@ async def update_ticket(
await db.commit()
await db.refresh(db_ticket)
logger.info(f"Ticket {db_ticket.ticket_number} updated by {current_user.email}")
# ✅ CORRECCIÓN: Usar affected_system_id
return {
"id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number,
@@ -286,15 +341,19 @@ async def update_ticket(
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"system_id": str(db_ticket.system_id) if db_ticket.system_id else None,
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, # ✅ CORRECTO
"created_by": str(db_ticket.created_by),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at,
"updated_at": db_ticket.updated_at
}
except HTTPException:
await db.rollback()
raise
except Exception as e:
await db.rollback()
logger.error(f"Error updating ticket: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error updating ticket: {str(e)}"
@@ -319,28 +378,31 @@ async def close_ticket(
detail="Invalid ticket ID format"
)
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
result = await db.execute(query)
db_ticket = result.scalars().first()
if not db_ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Ticket {ticket_id} not found"
)
try:
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
result = await db.execute(query)
db_ticket = result.scalars().first()
if not db_ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Ticket {ticket_id} not found"
)
db_ticket.status = TicketStatus.CLOSED
db_ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(db_ticket)
logger.info(f"Ticket {db_ticket.ticket_number} closed by {current_user.email}")
# ✅ CORRECCIÓN: Usar affected_system_id
return {
"id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number,
@@ -349,15 +411,19 @@ async def close_ticket(
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"system_id": str(db_ticket.system_id) if db_ticket.system_id else None,
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, # ✅ CORRECTO
"created_by": str(db_ticket.created_by),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at,
"updated_at": db_ticket.updated_at
}
except HTTPException:
await db.rollback()
raise
except Exception as e:
await db.rollback()
logger.error(f"Error closing ticket: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error closing ticket: {str(e)}"

View File

@@ -5,6 +5,7 @@ Router principal para la API v1
"""
from fastapi import APIRouter
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets
api_router = APIRouter()
@@ -51,4 +52,5 @@ api_router.include_router(
tickets.router,
prefix="/tickets",
tags=["tickets"]
)
)

View File

@@ -139,4 +139,8 @@ def get_settings() -> 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

@@ -1,24 +1,29 @@
"""
Category Model - ServiceManagerWeb
"""
from sqlalchemy import String, Text, Boolean, ForeignKey
from sqlalchemy import String, Text, Boolean, ForeignKey, Column, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from typing import List, Optional
import uuid
from app.core.database import Base
from app.models.tenant import Tenant
from app.models.ticket import Ticket
class Category(Base):
__tablename__ = "categories"
__tablename__ = "ticket_categories" # Updated table name
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Optional: Tenant specific categories?
tenant_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True)
# Updated tenant_id to be required
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False
)
# Relationships
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="category")

View File

@@ -1,4 +1,3 @@
"""
System Model - ServiceManagerWeb
"""
@@ -19,7 +18,11 @@ class System(Base):
# Relationships
# If we want tickets to link to systems, we will add relationship in Ticket later or now.
# We will assume Ticket links to System.
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="system")
tickets: Mapped[List["Ticket"]] = relationship(
"Ticket",
back_populates="affected_system",
foreign_keys="Ticket.affected_system_id"
)
def __repr__(self) -> str:
return f"<System(id={self.id}, name='{self.name}')>"

View File

@@ -54,15 +54,12 @@ class Tenant(Base):
String(20),
default=TenantStatus.ACTIVE
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Relaciones
users: Mapped[List["User"]] = relationship("User", back_populates="tenant")
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="tenant")
categories: Mapped[List["Category"]] = relationship("Category", back_populates="tenant")
def __repr__(self) -> str:
return f"<Tenant(id={self.id}, name='{self.name}', slug='{self.slug}')>"
@property
def is_active(self) -> bool:
"""Check if tenant is active."""
return self.status == TenantStatus.ACTIVE
return f"<Tenant(id={self.id}, name='{self.name}', slug='{self.slug}')>"

View File

@@ -4,11 +4,17 @@ Ticket Model - ServiceManagerWeb
from sqlalchemy import String, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, ENUM
from typing import Optional
from typing import Optional, TYPE_CHECKING
import enum
import uuid
from app.core.database import Base
from .tenant import Tenant
from .system import System
from .user import User
if TYPE_CHECKING:
from app.models.category import Category
class TicketStatus(str, enum.Enum):
NEW = "NEW"
@@ -43,7 +49,7 @@ 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)
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)
# Relationships

View File

@@ -0,0 +1 @@
Generic single-database configuration.

View File

@@ -0,0 +1,38 @@
[alembic]
script_location = backend/migrations
sqlalchemy.url = postgresql://servicemanager:servicemanager123@172.19.0.3:5432/servicemanager
target_metadata = app.core.database.Base.metadata
default_environment = development
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers = console
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers = console
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
formatter = generic
[formatter_generic]
format = %(asctime)s %(levelname)-5.5s [%(name)s] %(message)s

View File

@@ -0,0 +1,50 @@
# backend/migrations/env.py
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import create_async_engine
from alembic import context
import asyncio
# Importa tus settings
from app.core.config import settings
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# metadata de tus modelos
from app.core.database import Base
target_metadata = Base.metadata
def run_migrations_offline():
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online():
connectable = create_async_engine(
settings.DATABASE_URL, poolclass=pool.NullPool
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,30 @@
"""Fix ambiguous foreign key in System.tickets relationship
Revision ID: 1b894c060a94
Revises: d77aa93a2cf2
Create Date: 2026-01-27 19:20:19.670006
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '1b894c060a94'
down_revision: Union[str, None] = 'd77aa93a2cf2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass # No changes needed for the database schema
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass # No changes needed for the database schema
# ### end Alembic commands ###

View File

@@ -0,0 +1,37 @@
"""Fix affected_system relationship in Ticket model
Revision ID: d77aa93a2cf2
Revises:
Create Date: 2026-01-27 19:18:04.005182
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'd77aa93a2cf2'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_foreign_key(
'fk_tickets_affected_system_id',
'tickets',
'systems',
['affected_system_id'],
['id'],
ondelete='SET NULL'
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('fk_tickets_affected_system_id', 'tickets', type_='foreignkey')
# ### end Alembic commands ###

View File

@@ -0,0 +1,23 @@
from sqlalchemy import create_engine
from alembic import command
from alembic.config import Config
# Cargar la URL de la base de datos desde el entorno
from os import getenv
DATABASE_URL = getenv("DATABASE_URL", "postgresql://servicemanager:servicemanager123@postgres:5432/servicemanager")
# Cambiar el dialecto a uno síncrono
DATABASE_URL = DATABASE_URL.replace("+asyncpg", "")
# Crear un motor síncrono
engine = create_engine(DATABASE_URL)
# Configurar Alembic
alembic_cfg = Config("backend/migrations/alembic.ini")
alembic_cfg.attributes['connection'] = engine.connect()
# Ejecutar las migraciones
if __name__ == "__main__":
print("Ejecutando migraciones...")
command.upgrade(alembic_cfg, "head")
print("Migraciones aplicadas correctamente.")

74
backend/migrations/env.py Normal file
View File

@@ -0,0 +1,74 @@
from logging.config import fileConfig
import os
from sqlalchemy import create_engine, pool
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlalchemy.engine import engine_from_config
from alembic import context
import asyncio
# Import Base and all models
from app.core.database import Base
from app.models import tenant # Import all models explicitly
# Alembic Config object
config = context.config
# Logging configuration
if config.config_file_name:
fileConfig(config.config_file_name)
# Get DATABASE_URL and convert to synchronous
DATABASE_URL = os.getenv("DATABASE_URL")
if not DATABASE_URL:
raise RuntimeError("DATABASE_URL environment variable is not set")
SYNC_DATABASE_URL = DATABASE_URL.replace("+asyncpg", "")
# Metadata for autogenerate
target_metadata = Base.metadata
def run_migrations_offline():
"""
Run migrations in 'offline' mode.
"""
context.configure(
url=SYNC_DATABASE_URL,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online():
"""
Run migrations in 'online' mode.
"""
# Fetch the URL from Alembic configuration
alembic_config = config.get_section(config.config_ini_section)
alembic_config["sqlalchemy.url"] = DATABASE_URL # Use async URL
connectable: AsyncEngine = create_async_engine(
DATABASE_URL,
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(
lambda sync_connection: context.configure(
connection=sync_connection, target_metadata=target_metadata,
compare_type=True # Ensure column types are compared
)
)
await connection.run_sync(context.run_migrations())
asyncio.run(do_run_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

28
backend/run_migrations.py Normal file
View File

@@ -0,0 +1,28 @@
import asyncio
from alembic.config import Config
from alembic.script import ScriptDirectory
from alembic.runtime.environment import EnvironmentContext
from sqlalchemy.ext.asyncio import create_async_engine
async def run_async_migrations():
alembic_cfg = Config("alembic.ini")
script = ScriptDirectory.from_config(alembic_cfg)
engine = create_async_engine("postgresql+asyncpg://servicemanager:servicemanager123@172.19.0.3:5432/servicemanager")
async with engine.connect() as connection:
def do_run_migrations(connection):
with EnvironmentContext(
alembic_cfg,
script,
connection=connection,
fn=lambda rev, context: script.run_env()
) as context:
context.configure(connection=connection, target_metadata=None)
with context.begin_transaction():
context.run_migrations()
await connection.run_sync(do_run_migrations)
if __name__ == "__main__":
asyncio.run(run_async_migrations())

View File

@@ -0,0 +1,23 @@
from sqlalchemy import create_engine
from alembic import command
from alembic.config import Config
# Cargar la URL de la base de datos desde el entorno
from os import getenv
DATABASE_URL = getenv("DATABASE_URL", "postgresql://servicemanager:servicemanager123@postgres:5432/servicemanager")
# Cambiar el dialecto a uno síncrono
DATABASE_URL = DATABASE_URL.replace("+asyncpg", "")
# Crear un motor síncrono
engine = create_engine(DATABASE_URL)
# Configurar Alembic
alembic_cfg = Config("backend/migrations/alembic.ini")
alembic_cfg.attributes['connection'] = engine.connect()
# Ejecutar las migraciones
if __name__ == "__main__":
print("Ejecutando migraciones...")
command.upgrade(alembic_cfg, "head")
print("Migraciones aplicadas correctamente.")

View File

@@ -0,0 +1,19 @@
import asyncio
import asyncpg
async def test_connection():
try:
conn = await asyncpg.connect(
user="servicemanager",
password="servicemanager123",
database="servicemanager",
host="postgres",
port=5432
)
print("Connection successful!")
await conn.close()
except Exception as e:
print(f"Connection failed: {e}")
if __name__ == "__main__":
asyncio.run(test_connection())

View File

@@ -0,0 +1,37 @@
-- Migration Script: Fix Schema Inconsistencies
-- Step 1: Migrate data from `categories` to `ticket_categories`
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'categories') THEN
INSERT INTO ticket_categories (id, tenant_id, name, description)
SELECT id, COALESCE(tenant_id, (SELECT id FROM tenants LIMIT 1)), name, description
FROM categories;
END IF;
END $$;
-- Step 2: Update foreign key for `tickets.category_id`
ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_category_id_fkey;
ALTER TABLE tickets ADD CONSTRAINT tickets_category_id_fkey FOREIGN KEY (category_id) REFERENCES ticket_categories (id) ON DELETE CASCADE;
-- Step 3: Drop `categories` table
DROP TABLE IF EXISTS categories CASCADE;
-- Step 4: Rename `tickets.system_id` to `affected_system_id`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'tickets' AND column_name = 'system_id'
) THEN
ALTER TABLE tickets RENAME COLUMN system_id TO affected_system_id;
END IF;
END $$;
-- Step 5: Update foreign key for `tickets.affected_system_id`
ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_system_id_fkey;
ALTER TABLE tickets ADD CONSTRAINT tickets_affected_system_id_fkey FOREIGN KEY (affected_system_id) REFERENCES affected_systems (id) ON DELETE CASCADE;
-- Step 6: Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_tickets_category_id ON tickets (category_id);
CREATE INDEX IF NOT EXISTS idx_tickets_affected_system_id ON tickets (affected_system_id);

View File

@@ -51,8 +51,8 @@ services:
# ===================================
backend:
build:
context: ./backend
dockerfile: ../docker/Dockerfile.backend
context: .
dockerfile: docker/Dockerfile.backend
container_name: servicemanager-backend
restart: unless-stopped
env_file:
@@ -74,6 +74,7 @@ services:
- ./backend:/app
- uploads_data:/app/uploads
- logs_data:/app/logs
- ./alembic.ini:/app/alembic.ini
ports:
- "8000:8000"
depends_on:
@@ -161,7 +162,7 @@ services:
restart: unless-stopped
environment:
- NODE_ENV=${ENVIRONMENT:-development}
- PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000}
- PUBLIC_API_URL=http://servicemanager-backend:8000
- PUBLIC_APP_NAME=ServiceManager Cliente
volumes:
- ./frontend-client:/app
@@ -186,7 +187,7 @@ services:
restart: unless-stopped
environment:
- NODE_ENV=${ENVIRONMENT:-development}
- PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000}
- PUBLIC_API_URL=http://servicemanager-backend:8000
- PUBLIC_APP_NAME=ServiceManager Admin
volumes:
- ./frontend-internal:/app

View File

@@ -1,4 +1,3 @@
# FastAPI Backend Dockerfile
FROM python:3.11-slim
# Instalar dependencias del sistema
@@ -8,13 +7,20 @@ RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
# Configurar directorio de trabajo
# Directorio de trabajo y PYTHONPATH
WORKDIR /app
ENV PYTHONPATH=/app
# Copiar requirements y instalar dependencias Python
COPY requirements.txt .
# Instalar dependencias Python
COPY backend/requirements.txt requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# Copiar todo el contenido del directorio backend
COPY backend /app/backend
# Copiar el script de migraciones
COPY backend/run_sync_migrations.py /app/backend/run_sync_migrations.py
# Crear usuario no root
RUN useradd --create-home --shell /bin/bash app \
&& chown -R app:app /app
@@ -23,9 +29,6 @@ RUN useradd --create-home --shell /bin/bash app \
RUN mkdir -p /app/uploads /app/logs \
&& chown -R app:app /app/uploads /app/logs
# Copiar código de la aplicación
COPY . .
# Cambiar permisos
RUN chown -R app:app /app
@@ -35,9 +38,9 @@ USER app
# Exponer puerto
EXPOSE 8000
# Health check
# Healthcheck
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# Comando por defecto
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
# CMD por defecto
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

3946
frontend-client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -59,6 +59,9 @@
<button
on:click={toggleMenu}
class="flex items-center space-x-2 text-gray-700 hover:text-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded-md p-2"
role="button"
tabindex="0"
on:keydown={(e) => e.key === 'Enter' && toggleMenu()}
>
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
<span class="text-primary-600 text-sm font-medium">
@@ -87,6 +90,9 @@
<button
on:click={handleLogout}
class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
role="button"
tabindex="0"
on:keydown={(e) => e.key === 'Enter' && handleLogout()}
>
Cerrar Sesión
</button>

View File

@@ -0,0 +1,15 @@
import { writable } from 'svelte/store';
import { api } from '$utils/api';
export const tickets = writable({
tickets: [],
async loadTickets() {
try {
const data = await api.get('/tickets');
this.update((state) => ({ ...state, tickets: data }));
} catch (error) {
console.error('Error al cargar los tickets:', error);
}
}
});

View File

@@ -1,7 +1,6 @@
import { writable } from 'svelte/store';
import { auth } from './auth.js';
import { get } from 'svelte/store';
import { writable, get } from 'svelte/store';
import type { Writable } from 'svelte/store';
import { auth } from './auth';
// Types
export interface Ticket {
@@ -121,13 +120,13 @@ function createTicketsStore() {
// Load user's tickets
loadTickets: async () => {
update(state => ({ ...state, isLoading: true, error: null }));
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
try {
const tickets = await apiCall('/tickets/');
update(state => ({ ...state, tickets, isLoading: false }));
update((state: TicketsState) => ({ ...state, tickets, isLoading: false }));
} catch (error) {
update(state => ({
update((state: TicketsState) => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load tickets'
@@ -137,7 +136,7 @@ function createTicketsStore() {
// Load specific ticket with details
loadTicket: async (ticketId: string) => {
update(state => ({ ...state, isLoading: true, error: null }));
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
try {
const [ticket, comments, attachments] = await Promise.all([
@@ -146,7 +145,7 @@ function createTicketsStore() {
apiCall(`/tickets/${ticketId}/attachments`)
]);
update(state => ({
update((state: TicketsState) => ({
...state,
currentTicket: ticket,
comments,
@@ -154,7 +153,7 @@ function createTicketsStore() {
isLoading: false
}));
} catch (error) {
update(state => ({
update((state: TicketsState) => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load ticket'
@@ -164,7 +163,7 @@ function createTicketsStore() {
// Create new ticket
createTicket: async (ticket: CreateTicketRequest) => {
update(state => ({ ...state, isLoading: true, error: null }));
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
try {
// Mapear campos del frontend al formato del backend
@@ -184,7 +183,7 @@ function createTicketsStore() {
body: JSON.stringify(ticketData)
});
update(state => ({
update((state: TicketsState) => ({
...state,
tickets: [newTicket, ...state.tickets],
isLoading: false
@@ -193,7 +192,7 @@ function createTicketsStore() {
return newTicket;
} catch (error) {
console.error('Create ticket error:', error);
update(state => ({
update((state: TicketsState) => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to create ticket'
@@ -210,14 +209,14 @@ function createTicketsStore() {
body: JSON.stringify({ content })
});
update(state => ({
update((state: TicketsState) => ({
...state,
comments: [...state.comments, comment]
}));
return comment;
} catch (error) {
update(state => ({
update((state: TicketsState) => ({
...state,
error: error instanceof Error ? error.message : 'Failed to add comment'
}));
@@ -247,14 +246,14 @@ function createTicketsStore() {
const attachment = await response.json();
update(state => ({
update((state: TicketsState) => ({
...state,
attachments: [...state.attachments, attachment]
}));
return attachment;
} catch (error) {
update(state => ({
update((state: TicketsState) => ({
...state,
error: error instanceof Error ? error.message : 'Failed to upload attachment'
}));
@@ -270,15 +269,15 @@ function createTicketsStore() {
body: JSON.stringify({ resolution })
});
update(state => ({
update((state: TicketsState) => ({
...state,
currentTicket: state.currentTicket?.id === ticketId ? updatedTicket : state.currentTicket,
tickets: state.tickets.map(t => t.id === ticketId ? updatedTicket : t)
tickets: state.tickets.map((t: Ticket) => t.id === ticketId ? updatedTicket : t)
}));
return updatedTicket;
} catch (error) {
update(state => ({
update((state: TicketsState) => ({
...state,
error: error instanceof Error ? error.message : 'Failed to close ticket'
}));
@@ -288,12 +287,12 @@ function createTicketsStore() {
// Clear error
clearError: () => {
update(state => ({ ...state, error: null }));
update((state: TicketsState) => ({ ...state, error: null }));
},
// Clear current ticket
clearCurrentTicket: () => {
update(state => ({
update((state: TicketsState) => ({
...state,
currentTicket: null,
comments: [],

View File

3974
frontend-internal/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,7 @@
"postcss": "^8.4.24",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1",
"svelte": "^4.0.5",
"svelte": "^4.2.20",
"svelte-check": "^3.4.3",
"tailwindcss": "^3.3.0",
"tslib": "^2.4.1",
@@ -37,13 +37,13 @@
"vitest": "^0.34.0"
},
"dependencies": {
"@heroicons/react": "^2.0.18",
"@tailwindcss/forms": "^0.5.4",
"@tailwindcss/typography": "^0.5.9",
"@heroicons/react": "^2.0.18",
"heroicons": "^2.0.18",
"zod": "^3.22.2",
"date-fns": "^2.30.0",
"chart.js": "^4.3.0",
"chartjs-adapter-date-fns": "^3.0.0"
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^2.30.0",
"heroicons": "^2.0.18",
"zod": "^3.22.2"
}
}
}

View File

@@ -43,11 +43,15 @@
async function handleSubmit() {
try {
if (editingTenant) {
await api.put(`/tenants/${editingTenant.id}`, formData);
toast.success('Cliente actualizado');
// Asegurarse de enviar el campo "status" correctamente
const updatedData = { ...formData, status: formData.is_active ? 'active' : 'inactive' };
await api.put(`/tenants/${editingTenant.id}`, updatedData);
toast.success(`Cliente ${formData.is_active ? 'activado' : 'desactivado'} correctamente`);
} else {
await api.post('/tenants/', formData);
toast.success('Cliente creado');
// Asegurarse de enviar el campo "status" al crear un cliente
const newData = { ...formData, status: formData.is_active ? 'active' : 'inactive' };
await api.post('/tenants/', newData);
toast.success('Cliente creado correctamente');
}
showModal = false;
loadTenants();
@@ -152,6 +156,11 @@
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
Cancelar
</button>
{#if editingTenant}
<button type="button" on:click={async () => { await api.delete(`/tenants/${editingTenant.id}`); toast.success('Cliente eliminado'); showModal = false; loadTenants(); }} class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:col-start-1 sm:text-sm">
Eliminar
</button>
{/if}
</div>
</form>
</Modal>

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api'; // ajusta el path si es distinto
let tickets: any[] = [];
let loading = true;
let error: string | null = null;
onMount(async () => {
try {
loading = true;
tickets = await api.get('/tickets');
} catch (err: any) {
console.error(err);
error = err.message ?? 'Error cargando tickets';
} finally {
loading = false;
}
});
</script>
<h1>Tickets</h1>
{#if loading}
<p>Cargando tickets...</p>
{:else if error}
<p style="color: red;">{error}</p>
{:else if tickets.length === 0}
<p>No hay tickets</p>
{:else}
<table border="1" cellpadding="8">
<thead>
<tr>
<th>Número</th>
<th>Asunto</th>
<th>Estado</th>
<th>Prioridad</th>
<th>Creado</th>
</tr>
</thead>
<tbody>
{#each tickets as ticket}
<tr>
<td>{ticket.ticket_number}</td>
<td>{ticket.subject}</td>
<td>{ticket.status}</td>
<td>{ticket.priority}</td>
<td>{new Date(ticket.created_at).toLocaleString()}</td>
</tr>
{/each}
</tbody>
</table>
{/if}