Multi-inquilino#

Arquitectura multiinquilino de Construbot con jerarquía de tres niveles.

Descripción general#

Construbot utiliza una arquitectura multiinquilino de tres niveles:

  1. Cliente - Cuenta de nivel superior (organización)

  2. Empresa: entidad comercial dentro del cliente

  3. Usuario - Particular con acceso a una o más empresas

Aislamiento de datos: Todos los datos comerciales tienen como alcance el nivel de empresa.

Estructura jerárquica#

Customer (Acme Corporation)
├── Company A (Acme Construction NY)
│   ├── User: john@acme.com
│   ├── User: jane@acme.com
│   └── Data: Contracts, Estimates, etc.
└── Company B (Acme Construction LA)
    ├── User: bob@acme.com
    ├── User: jane@acme.com (shared user)
    └── Data: Contracts, Estimates, etc.

Puntos clave:

  • Los usuarios pueden pertenecer a varias empresas.

  • Los datos nunca se comparten entre empresas

  • Los usuarios cambian de empresa sin volver a iniciar sesión

Modelos#

Modelo de cliente#

Archivo: construbot/users/models.py

class Customer(models.Model):
    """Top-level account/organization"""

    nombre = models.CharField(max_length=200)  # Name
    slug = models.SlugField(unique=True)
    activo = models.BooleanField(default=True)  # Active status
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.nombre

Objetivo:

  • Empresas vinculadas al grupo

  • Agregación de facturación (si es necesario)

  • Informes a nivel corporativo

Modelo de empresa#

class Company(models.Model):
    """Business entity - main tenant unit"""

    customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
    nombre = models.CharField(max_length=200)
    slug = models.SlugField()
    activo = models.BooleanField(default=True)

    class Meta:
        unique_together = ('customer', 'slug')
        verbose_name_plural = 'Companies'

    def __str__(self):
        return self.nombre

Objetivo:

  • Límite del inquilino principal

  • Todos los datos comerciales incluidos aquí

  • Entidades operativas separadas

Modelo de usuario#

class User(AbstractUser):
    """Custom user with multi-company support"""

    username = None  # Removed - using email
    email = models.EmailField(unique=True)

    # Multi-company relationships
    companies = models.ManyToManyField(
        Company,
        through='UserCompany',
        related_name='users'
    )

    # Current active company in session
    active_company = models.ForeignKey(
        Company,
        on_delete=models.SET_NULL,
        null=True,
        related_name='active_users'
    )

    # Permission level
    nivel_acceso = models.ForeignKey(
        'NivelAcceso',
        on_delete=models.PROTECT
    )

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = []

UserCompany (a través del modelo)#

class UserCompany(models.Model):
    """User-Company relationship with permissions"""

    user = models.ForeignKey(User, on_delete=models.CASCADE)
    company = models.ForeignKey(Company, on_delete=models.CASCADE)
    is_active = models.BooleanField(default=True)
    joined_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        unique_together = ('user', 'company')

Aislamiento de datos#

Modelos de ámbito empresarial#

Todos los modelos de negocio incluyen clave externa de la empresa:

class Contrato(MP_Node):
    """Contract - main business entity"""

    company = models.ForeignKey(Company, on_delete=models.CASCADE)
    folio = models.CharField(max_length=100)
    # ... other fields

    class Meta:
        unique_together = ('company', 'folio')

Otros modelos con alcance:

  • Contraparte (Contraparte)

  • Sitio (Sitio)

  • Estimación (Estimación)

  • Concepto (Artículo de línea)

  • Retenciones

Consulta con filtro de empresa#

Filtrar siempre por empresa:

# In views
def contract_list(request):
    company = request.user.active_company
    contracts = Contrato.objects.filter(company=company)
    return render(request, 'contracts.html', {'contracts': contracts})

Administrador de modelos (patrón opcional):

class CompanyQuerySet(models.QuerySet):
    def for_company(self, company):
        return self.filter(company=company)

class Contrato(MP_Node):
    objects = CompanyQuerySet.as_manager()

    # Usage:
    # Contrato.objects.for_company(company)

Gestión Activa de la Empresa#

Configuración de empresa activa#

Durante el inicio de sesión:

def login_view(request):
    user = authenticate(email=email, password=password)
    if user:
        login(request, user)

        # Set active company (default to first)
        if not user.active_company:
            user.active_company = user.companies.first()
            user.save()

        return redirect('dashboard')

Cambio de empresa:

def switch_company(request, company_id):
    company = get_object_or_404(Company, id=company_id)

    # Verify user has access
    if company not in request.user.companies.all():
        return HttpResponseForbidden()

    # Switch active company
    request.user.active_company = company
    request.user.save()

    return redirect('dashboard')

Middleware (opcional)#

class ActiveCompanyMiddleware:
    """Ensure active_company is set"""

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if request.user.is_authenticated:
            if not request.user.active_company:
                # Set to first company or redirect to selection
                request.user.active_company = request.user.companies.first()
                request.user.save()

        return self.get_response(request)

Contexto de plantilla#

Acceso en plantillas:

<h1>{{ user.active_company.nombre }}</h1>

{% if user.companies.count > 1 %}
    <select onchange="window.location=this.value">
        {% for company in user.companies.all %}
            <option value="{% url 'switch_company' company.id %}"
                    {% if company == user.active_company %}selected{% endif %}>
                {{ company.nombre }}
            </option>
        {% endfor %}
    </select>
{% endif %}

Formularios multiempresa#

Limite las opciones a la empresa actual#

class ContratoForm(forms.ModelForm):
    class Meta:
        model = Contrato
        fields = ['contraparte', 'sitio', ...]

    def __init__(self, *args, company=None, **kwargs):
        super().__init__(*args, **kwargs)

        # Limit counterparties to current company
        if company:
            self.fields['contraparte'].queryset = \\
                Contraparte.objects.filter(company=company)
            self.fields['sitio'].queryset = \\
                Sitio.objects.filter(company=company)

En vistas:

def create_contract(request):
    if request.method == 'POST':
        form = ContratoForm(
            request.POST,
            company=request.user.active_company
        )
        if form.is_valid():
            contract = form.save(commit=False)
            contract.company = request.user.active_company
            contract.save()
    else:
        form = ContratoForm(company=request.user.active_company)

Interfaz de administración#

Administrador centrado en la empresa#

@admin.register(Contrato)
class ContratoAdmin(admin.ModelAdmin):
    list_display = ['folio', 'company', 'contraparte']
    list_filter = ['company']

    def get_queryset(self, request):
        qs = super().get_queryset(request)

        # Superusers see all
        if request.user.is_superuser:
            return qs

        # Others see only their companies
        return qs.filter(company__in=request.user.companies.all())

    def save_model(self, request, obj, form, change):
        if not change:  # New object
            obj.company = request.user.active_company
        super().save_model(request, obj, form, change)

Consideraciones de API#

Empresa de Token#

from rest_framework.views import APIView

class ContractListAPI(APIView):
    permission_classes = [IsAuthenticated]

    def get(self, request):
        company = request.user.active_company
        contracts = Contrato.objects.filter(company=company)
        serializer = ContratoSerializer(contracts, many=True)
        return Response(serializer.data)

Cabecera de empresa (alternativa)#

# Client sends company ID in header
# X-Company-ID: 123

class CompanyFromHeaderMiddleware:
    def process_request(self, request):
        if request.user.is_authenticated:
            company_id = request.META.get('HTTP_X_COMPANY_ID')
            if company_id:
                company = Company.objects.filter(
                    id=company_id,
                    users=request.user
                ).first()
                if company:
                    request.company = company

Mejores prácticas#

1. Consultas de alcance siempre:

# GOOD
contracts = Contrato.objects.filter(company=request.user.active_company)

# BAD - returns all companies' data!
contracts = Contrato.objects.all()

2. Validar acceso de empresa:

company = get_object_or_404(Company, id=company_id)
if company not in request.user.companies.all():
    raise PermissionDenied

3. Establecer empresa al crear:

contract = form.save(commit=False)
contract.company = request.user.active_company  # Always set!
contract.save()

4. Utilice Unique_together:

class Meta:
    unique_together = ('company', 'folio')  # Unique per company

5. Aislamiento de datos de prueba:

def test_company_isolation(self):
    company1 = Company.objects.create(name='Company 1')
    company2 = Company.objects.create(name='Company 2')

    contract1 = Contrato.objects.create(company=company1, ...)
    contract2 = Contrato.objects.create(company=company2, ...)

    # Should only see own company's data
    assert Contrato.objects.filter(company=company1).count() == 1

Patrones comunes#

Administrador de contexto de la empresa#

class CompanyMixin:
    """Mixin for company-scoped views"""

    def get_queryset(self):
        return super().get_queryset().filter(
            company=self.request.user.active_company
        )

    def form_valid(self, form):
        form.instance.company = self.request.user.active_company
        return super().form_valid(form)

Uso:

class ContractListView(CompanyMixin, ListView):
    model = Contrato

Permiso de usuario por empresa#

def has_company_permission(user, company, level=1):
    """Check if user has permission level for company"""
    if company not in user.companies.all():
        return False
    if user.nivel_acceso.nivel < level:
        return False
    return True

# Usage:
if not has_company_permission(request.user, company, level=3):
    raise PermissionDenied

Solución de problemas#

El usuario no puede ver los datos:

  • Verifique que active_company esté configurada

  • Verificar que el usuario esté en los usuarios de la empresa.

  • Confirmar consultas filtradas por empresa

Fuga de datos entre empresas:

  • Revisar conjuntos de consultas: debe filtrar por empresa

  • Los formularios de verificación limitan las opciones a la empresa actual

  • Verifique que unique_together incluya empresa

El interruptor de empresa no funciona:

  • Asegúrese de que active_company esté guardado

  • La sesión de verificación persiste

  • Verificar que el usuario tenga acceso a la empresa objetivo

Ver también#