CreaRack-SL

Servicio signup-activation — flujo de registro de nueva organización

Ubicación y dependencias

Módulo: core.api.signup
Endpoints:

  • POST /api/signup/ (viewset handler signup(request, payload: SignupInput))

Dependencias principales:

  • core.models: User, Organization, SaaSModule, Plan
  • core.email_service: send_welcome_email (vía post_save signal)
  • django.db.transaction: para commit de estado final
  • allauth.account.models: PasswordResetTokenGenerator (en el email, no aquí)

Responsabilidades

  1. Crear una nueva organización a partir de organization_name
  2. Asignar plan y módulos basados en flags (add_racks, add_network, add_monitoring)
  3. Crear el usuario admin sin password (set vía email)
  4. Enviar email de bienvenida con enlace de set password (vía señal post_save)

Diagrama de flujo (v1.65.3)

POST /api/signup/
  ├─ Valida payload: organization_name, email, username, flags
  ├─ Verifica no existe org con mismo name
  ├─ Crea Organization (extra_modules vacío inicialmente)
  ├─ Selecciona Plan según flags (Core Free, Core Pro, etc.)
  ├─ Asigna módulos de la app (racks, network, monitoring, signage)
  ├─ Asigna módulos de la suscripción del plan
  ├─ Crea User con password=None (inusable)
  │  └─ POST_SAVE SIGNAL DISPARA → transaction.on_commit(send_welcome_email)
  └─ Return: 201 + organization_id, user_id, username

Nota (v1.65.3): El password se fija una sola vez en create_user(password=None). La línea user.set_unusable_password(); user.save(update_fields=["password"]) fue eliminada porque invalidaba el token del email (el token hashea el password).

Sub-componentes

Input: SignupInput

Pydantic model con campos:

CampoTipoRequeridoNota
organization_namestrSíNombre único de la org
emailstrSíEmail del admin creator
usernamestrSíUsername único
add_racksboolNo (default False)Incluir módulo Racks
add_networkboolNo (default False)Incluir módulo Network
add_monitoringboolNo (default False)Incluir módulo Monitoring

Paso 1: Validación y creación de Organization

# Verificar unicidad
if Organization.objects.filter(name=organization_name).exists():
    raise BusinessLogicException(...)

# Crear org
org = Organization.objects.create(
    name=organization_name,
    created_by_user_id=None,  # nadie, es el primer actor
)

Modelos involucrados:

  • Organization (PK, name, extra_modules ManyToMany)

Paso 2: Selección de Plan

# Flag-driven plan selection
if payload.add_racks or payload.add_network or payload.add_monitoring:
    plan = Plan.objects.get(slug="core-pro")
else:
    plan = Plan.objects.get(slug="core-free")

Datos:

  • Plan.modules → ManyToMany a SaaSModule
  • Plan.slug → identificador (ej core-pro, core-free)

Paso 3: Asignación de módulos

# Módulos de la app (core)
core_pks = set(SaaSModule.objects.filter(app="core").values_list("pk", flat=True))

# Módulos de la suscripción
plan_pks = set(plan.modules.values_list("pk", flat=True))

# Asignar a org (union)
org.extra_modules.set(core_pks | plan_pks)

Nota: Los módulos se guardan en la ManyToMany Organization.extra_modules (campo through).

Paso 4: Creación del usuario

User.objects.create_user(
    username=username,
    email=email,
    password=None,              # ← inusable, set vía email
    first_name="",
    role="admin",
    organization=org,
)
# NO hacer set_unusable_password() aquí; el password ya es inusable
# La señal post_save dispara send_welcome_email vía transaction.on_commit

Por qué NO set_unusable_password() después (v1.65.3):

  • create_user(password=None) ya deja el password inusable
  • Si se toca el password de nuevo en la misma transacción, el token del email (que se calcula dentro de post_save) se invalida
  • Solución: enviar el email vía transaction.on_commit, no inmediatamente

Señal post_save: envío del email

Invocador: core.signals.send_welcome_on_create

@receiver(post_save, sender="core.User")
def send_welcome_on_create(sender, instance, created, **kwargs):
    if created and instance.email:
        from .email_service import send_welcome_email
        transaction.on_commit(lambda: send_welcome_email(instance))

El email (send_welcome_email) genera un token que hashea el password del usuario. Si el password cambia entre el save y el envío, el token muere. Solución: transaction.on_commit garantiza que el email sale cuando la transacción ya se escribió en BD.

Email: Contiene un enlace “Crea tu contraseña” que apunta a /accounts/password/reset/key/{uidb36}-{token}/ (endpoint de allauth).

Datos de entrada y salida

Entrada (request body JSON)

{
  "organization_name": "Acme Datacenters",
  "email": "founder@acme.example",
  "username": "acmefounder",
  "add_racks": true,
  "add_network": true,
  "add_monitoring": false
}

Salida (201 Created)

{
  "organization_id": 42,
  "user_id": 123,
  "username": "acmefounder",
  "email": "founder@acme.example"
}

Errores y excepciones

CasoCódigoMensaje
Org name ya existe400“Organization with this name already exists”
Email inválido400(Pydantic validation)
Username duplicado400(Django constraint)
Plan no existe500“Plan not found” (no debe ocurrir en prod)

Contexto histórico

v1.65.2

  • Se cerró una puerta de registro no intencionada (endpoint sin UI)
  • Endpoint /api/signup/ aún no tiene página pública, así que el high fue limitado

v1.65.3

  • Fix: El enlace del email de bienvenida nacía inválido debido a recalculado del password
  • Causa: El endpoint hacía set_unusable_password() después de create_user(), invalidando el token
  • Solución:
    1. Password fijado una sola vez en create_user(password=None)
    2. Email enviado vía transaction.on_commit (garantiza estado final de BD)
  • Testing: Nuevo test suite tests/api/test_signup_activation.py caza el bug

Testing

File: tests/api/test_signup_activation.py (109 líneas, v1.65.3)

Coverage:

  1. test_activation_link_token_is_valid: el flujo completo y el token del email valida
  2. test_email_links_respect_site_url: con SITE_URL fijada, los enlaces salen correctos
  3. test_admin_created_user_token_also_valid: crear user por admin también genera tokens válidos

Setup crítico: django_capture_on_commit_callbacks(execute=True) en cada test, porque transaction.on_commit no dispara bajo pytest sin esto.

Verificación en rojo: El test pasa en verde (con fix) y pasa en rojo (reproduce el bug original del token muerto).

Véase también

  • [[feature—auth—email-welcome-activation-link]]
  • [[entity—core—service—send-welcome-email]]
  • [[entity—core—model—user]]
  • [[entity—core—model—organization]]
  • [[entity—core—endpoint—signup]]