Arquitectura Backend¶
El backend del Sistema A3 está construido con Django 3.2, siguiendo una arquitectura multi-app modular que separa las responsabilidades del negocio en aplicaciones independientes pero interconectadas.
Estructura de Apps Django¶
sistema-a3/
├── a3ceros/ # App principal (settings, urls, core)
├── anexos/ # Sistema de archivos adjuntos
├── apartados/ # Módulo de ventas
├── api/ # REST API endpoints
├── asistenteai/ # Asistente con IA
├── blogs/ # Blog interno
├── ch/ # Capital Humano (RRHH)
├── clientes/ # Gestión de clientes
├── cobranza/ # Seguimiento de cobranza
├── comprobaciones/ # Gastos y provisiones
├── crm/ # CRM
├── dashboard/ # Dashboards y KPIs
├── inventario/ # Catálogo de propiedades
├── perfil/ # Perfiles de usuario
├── prospectos/ # Leads y prospectos
├── proyectos/ # Gestión de proyectos
├── push/ # Web Push notifications
├── reportes/ # Generación de reportes
├── services/ # Servicios compartidos
├── tickets/ # Tickets y acuerdos
├── users/ # Usuarios y permisos
└── vendofacil/ # Interfaz de ventas simplificada
App Core: a3ceros¶
Responsabilidades¶
- Configuración global (
settings.py) - URL routing principal (
urls.py) - Context processors
- Middleware personalizado
- Utilidades compartidas
settings.py¶
# a3ceros/settings.py
# Apps instaladas
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
# ...
'rest_framework',
'django_filters',
'notifications',
# Apps del proyecto
'users',
'inventario',
'apartados',
'clientes',
'tickets',
# ...
]
# Configuración de base de datos
DATABASES = {
'default': dj_database_url.config(
conn_max_age=600,
conn_health_checks=True,
)
}
# Configuración de archivos estáticos
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
# AWS S3 para media files
if USE_S3:
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
AWS_STORAGE_BUCKET_NAME = env('AWS_STORAGE_BUCKET_NAME')
urls.py¶
# a3ceros/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('dashboard.urls')),
path('inventario/', include('inventario.urls')),
path('apartados/', include('apartados.urls')),
path('clientes/', include('clientes.urls')),
path('tickets/', include('tickets.urls')),
path('api/', include('api.urls')),
# ... más URLs
]
Patrón de Organización por App¶
Cada app Django sigue una estructura estándar:
nombre_app/
├── __init__.py
├── models.py # Modelos de datos
├── views.py # Vistas (lógica de presentación)
├── urls.py # URLs de la app
├── forms.py # Formularios Django
├── serializers.py # Serializers DRF
├── viewsets.py # ViewSets DRF
├── admin.py # Configuración del admin
├── apps.py # Configuración de la app
├── utils.py # Utilidades específicas
├── signals.py # Django signals
├── tests.py # Tests
├── migrations/ # Migraciones de BD
└── templates/ # Templates específicos
└── nombre_app/
Capa de Modelos¶
Ejemplo: Modelo Casa (Inventario)¶
# inventario/models.py
from django.db import models
from django.contrib.auth.models import User
class Casa(models.Model):
"""Modelo para viviendas en el inventario"""
# Campos básicos
clave = models.CharField(max_length=50, unique=True)
proyecto = models.CharField(max_length=100)
tipo_vivienda = models.CharField(max_length=50)
# Ubicación
direccion = models.TextField()
plaza = models.CharField(max_length=50)
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True)
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True)
# Estado
estatus = models.CharField(max_length=50)
disponible = models.BooleanField(default=True)
# Precios
precio_venta = models.DecimalField(max_digits=12, decimal_places=2)
precio_contado = models.DecimalField(max_digits=12, decimal_places=2)
# Metadatos
fecha_creacion = models.DateTimeField(auto_now_add=True)
fecha_modificacion = models.DateTimeField(auto_now=True)
usuario_creacion = models.ForeignKey(User, on_delete=models.PROTECT)
class Meta:
db_table = 'inventario_casa'
verbose_name = 'Casa'
verbose_name_plural = 'Casas'
ordering = ['-fecha_creacion']
def __str__(self):
return f"{self.clave} - {self.proyecto}"
Patrones de Modelo Comunes¶
1. TimeStampedModel
from model_utils.models import TimeStampedModel
class MiModelo(TimeStampedModel):
# Automáticamente agrega: created, modified
pass
2. Soft Delete
class MiModelo(models.Model):
activo = models.BooleanField(default=True)
cancelado = models.BooleanField(default=False)
def soft_delete(self):
self.activo = False
self.cancelado = True
self.save()
3. Multi-tenancy (Plaza-based)
class MiModelo(models.Model):
plaza = models.CharField(max_length=50)
@classmethod
def para_plaza(cls, plaza):
return cls.objects.filter(plaza=plaza)
Capa de Vistas¶
Function-Based Views (FBV)¶
# inventario/views.py
from django.shortcuts import render, get_object_or_404
from django.contrib.auth.decorators import login_required
from .models import Casa
@login_required
def listado_casas(request):
plaza = request.user.perfil.plaza
casas = Casa.objects.filter(plaza=plaza, disponible=True)
context = {
'casas': casas,
'plaza': plaza
}
return render(request, 'inventario/listado.html', context)
@login_required
def detalle_casa(request, clave):
casa = get_object_or_404(Casa, clave=clave)
return render(request, 'inventario/detalle.html', {'casa': casa})
Class-Based Views (CBV)¶
from django.views.generic import ListView, DetailView
class CasaListView(LoginRequiredMixin, ListView):
model = Casa
template_name = 'inventario/listado.html'
context_object_name = 'casas'
paginate_by = 20
def get_queryset(self):
plaza = self.request.user.perfil.plaza
return Casa.objects.filter(plaza=plaza, disponible=True)
API REST con Django REST Framework¶
Serializers¶
# inventario/serializers.py
from rest_framework import serializers
from .models import Casa
class CasaSerializer(serializers.ModelSerializer):
class Meta:
model = Casa
fields = ['id', 'clave', 'proyecto', 'tipo_vivienda',
'precio_venta', 'estatus', 'disponible']
read_only_fields = ['id', 'fecha_creacion']
def validate_precio_venta(self, value):
if value <= 0:
raise serializers.ValidationError("El precio debe ser mayor a 0")
return value
ViewSets¶
# inventario/viewsets.py
from rest_framework import viewsets, filters
from rest_framework.permissions import IsAuthenticated
from django_filters.rest_framework import DjangoFilterBackend
from .models import Casa
from .serializers import CasaSerializer
class CasaViewSet(viewsets.ModelViewSet):
queryset = Casa.objects.all()
serializer_class = CasaSerializer
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
filterset_fields = ['plaza', 'estatus', 'disponible']
search_fields = ['clave', 'proyecto']
def get_queryset(self):
user = self.request.user
if user.groups.filter(name='Corporativo').exists():
return Casa.objects.all()
return Casa.objects.filter(plaza=user.perfil.plaza)
Router¶
# api/urls.py
from rest_framework import routers
from inventario.viewsets import CasaViewSet
router = routers.DefaultRouter()
router.register('casas', CasaViewSet)
urlpatterns = router.urls
Capa de Servicios¶
# services/inventario_service.py
from inventario.models import Casa
from services.sap_service import SAPService
class InventarioService:
"""Lógica de negocio para inventario"""
@staticmethod
def crear_casa(data, usuario):
# Validación de negocio
if Casa.objects.filter(clave=data['clave']).exists():
raise ValueError("La clave ya existe")
# Crear en BD local
casa = Casa.objects.create(
**data,
usuario_creacion=usuario
)
# Sincronizar con SAP
try:
SAPService.enviar_casa(casa)
except Exception as e:
# Log del error pero no fallar
logging.error(f"Error al sincronizar con SAP: {e}")
return casa
@staticmethod
def cambiar_estatus(casa, nuevo_estatus, usuario):
# Validar transición de estado
if not validar_cambio_estatus(casa.estatus, nuevo_estatus):
raise ValueError("Cambio de estatus no permitido")
casa.estatus = nuevo_estatus
casa.save()
# Notificar a usuarios relevantes
notificar_cambio_estatus(casa, usuario)
return casa
Django Signals¶
# tickets/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from notifications.signals import notify
from .models import Ticket
@receiver(post_save, sender=Ticket)
def notificar_ticket_creado(sender, instance, created, **kwargs):
if created:
# Notificar al asignado
notify.send(
sender=instance.creador,
recipient=instance.asignado,
verb='te asignó un ticket',
action_object=instance,
description=instance.titulo
)
Permisos y Autorizació¶
n
# tickets/permissions.py
from rest_framework import permissions
class EsPropietarioOCorporativo(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
# Corporativo puede todo
if request.user.groups.filter(name='Corporativo').exists():
return True
# El propietario puede ver/editar
return obj.creador == request.user or obj.asignado == request.user
Middleware Personalizado¶
# a3ceros/middleware.py
class PlazaMiddleware:
"""Agrega plaza del usuario al request"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.user.is_authenticated:
request.plaza = request.user.perfil.plaza
else:
request.plaza = None
response = self.get_response(request)
return response
Management Commands¶
# tickets/management/commands/generar_instancias_recurrentes.py
from django.core.management.base import BaseCommand
from tickets.models import TareaProgramada
class Command(BaseCommand):
help = 'Genera instancias de tareas programadas recurrentes'
def handle(self, *args, **options):
tareas = TareaProgramada.objects.filter(activa=True)
for tarea in tareas:
if tarea.debe_generar_instancia_hoy():
tarea.generar_instancia()
self.stdout.write(
self.style.SUCCESS(f'Instancia creada para: {tarea}')
)
Testing¶
# inventario/tests.py
import pytest
from model_bakery import baker
from inventario.models import Casa
@pytest.mark.django_db
class TestCasaModel:
def test_crear_casa(self):
casa = baker.make(Casa, clave='TEST001')
assert casa.clave == 'TEST001'
def test_clave_unique(self):
baker.make(Casa, clave='TEST001')
with pytest.raises(Exception):
baker.make(Casa, clave='TEST001')
Best Practices Implementadas¶
✅ Fat models, thin views: Lógica en modelos y servicios
✅ DRY: Utilidades compartidas en services/
✅ Separation of concerns: Cada app tiene su responsabilidad
✅ Type hints: Donde es posible (Python 3.8+)
✅ Logging: Para debugging y auditoría
✅ Transactions: Para operaciones críticas
✅ Indexing: En campos de búsqueda frecuente
✅ Select/Prefetch related: Para optimizar queries
Para ver cómo los modelos se relacionan en la base de datos, consulta Base de Datos.