Multi-inquilino#
Arquitectura multiinquilino de Construbot con jerarquía de tres niveles.
Descripción general#
Construbot utiliza una arquitectura multiinquilino de tres niveles:
Cliente - Cuenta de nivel superior (organización)
Empresa: entidad comercial dentro del cliente
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_companyesté configuradaVerificar 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
empresaLos formularios de verificación limitan las opciones a la empresa actual
Verifique que
unique_togetherincluyaempresa
El interruptor de empresa no funciona:
Asegúrese de que
active_companyesté guardadoLa sesión de verificación persiste
Verificar que el usuario tenga acceso a la empresa objetivo
Ver también#
Descripción general - Architecture overview
Autenticación - Authentication system
Niveles de permiso - Permission system
../models/users - User model details