CreaRack-SL

Servicio core.services.signup — alta de organización invite-only (F1 PR-A)

Descripción

Servicio (core/services/signup.py, 123 LOC) que implementa el flujo atómico de alta de una nueva organización mediante código de invitación.

Está EXENTO del TenantRLSMiddleware (corre en autocommit) porque es público y anónimo. Abre su PROPIA transacción — no escribe en ninguna tabla con política RLS (verificado tabla por tabla en el plan v5).

Sin este servicio no hay manera de registrarse en CreaRack desde la web.

Interfaz pública

create_signup(*, code, org_name, email, username) → Tuple[Organization, User]

Parámetros:

  • code (str): Código de invitación (será normalizado a uppercase)
  • org_name (str): Nombre de la organización
  • email (str): Email del admin (será lowercased)
  • username (str): Username único

Retorna: Tupla (Organization, User) creados si todo es exitoso

Lanza: SignupError(mensaje_user_facing) si algo falla

Transacción: Abre una con transaction.atomic(). Todos los pasos (validación → creación org/user → consumo del código) ocurren en la misma tx o revierten juntos.

Flujo detallado

  1. Normalización: código a UPPERCASE, email a lowercase, stripping de espacios
  2. Validación del código (sin lock):
    • Existe (InvitationCode.objects.filter(code=code_norm).first())
    • Activo (inv.is_active == True)
    • No expirado (not inv.is_expired)
    • Tiene usos (inv.has_uses_left)
    • → Si falla cualquiera, raise SignupError(_("This invitation code is not valid/expired/exhausted"))
  3. Unicidad de organización y user:
    • Org con ese nombre NO existe (Organization.objects.filter(name__iexact=org_name))
    • Email NO existe (User.objects.filter(email__iexact=email))
    • Username NO existe (User.objects.filter(username__iexact=username))
  4. Crear organización (provision inline):
    • plan = inv.plan
    • max_racks = PLAN_MAX_RACKS.get(plan.slug, 10) (fuente única definida en core.models)
    • Asignar módulos: core (siempre) + plan modules → org.extra_modules.set(core_pks | plan_pks)
  5. Crear user admin (sin contraseña):
    • password=None → inusable por defecto
    • NO tocar el password después de save() — el token del email se calcula sobre el hash, un segundo set_password() lo invalida (bug del link muerto, PR-0)
    • role="admin" (primer usuario de la org)
  6. Consumo atómico del código:
    • select_for_update() lo más tarde posible para minimizar la ventana de lock
    • InvitationCode.objects.select_for_update().get(pk=inv.pk)
    • Re-validar (otro thread pudo consumir el último uso): is_active, not is_expired, has_uses_left
    • Incrementar: locked.uses_count += 1; locked.save(update_fields=["uses_count"])
    • Si falla, transacción revierte → sin org, sin user, sin consumo

Integración con vistas y emails

Llamador: core/views_signup.py::signup_view(request, method=POST)

Email: El welcome email se dispara automáticamente vía post_save signal en el User (no en el servicio):

  • Usuario recibe enlace de activación para setear contraseña
  • Signal solo dispara si la transacción hace on_commit() — garantizado en PROD

Dependencias

from django.conf import settings  # PLAN_MAX_RACKS
from django.db import transaction
from django.utils.translation import gettext_lazy as _

from core.models import PLAN_MAX_RACKS, InvitationCode, Organization, SaaSModule, User

El módulo NO importa SystemLog — intencionalmente, porque esa tabla tiene política RLS con CHECK que excluye org=NULL. Los fallos van a logger.error() solamente.

Validación externa: Turnstile

El servicio NO valida Turnstile (eso es responsabilidad de la vista). Recibe token via form POST, la vista lo verifica server-side en verify_turnstile() ANTES de llamar a create_signup().

Flujo: HTTP POST → verify_turnstile() → create_signup() en tx nueva.

Errors y mensajes

Todos los SignupError tienen mensajes i18n (gettext_lazy(_("..."))):

  • “This invitation code is not valid.”
  • “This invitation code has expired.”
  • “This invitation code has no uses left.”
  • “An organization with this name already exists.”
  • “This email is already registered.”
  • “This username is already taken.”

Testing

Batería tests/api/test_invitation_signup.py (42 tests):

  • Alta feliz (código válido → org + user creados)
  • Rechazos sin crear nada (código inválido, expirado, sin usos, org duplicate, etc.)
  • Race condition del último uso (hilos reales, transaction=True) — solo uno gana
  • Provisión correcta de módulos
  • CSP y exención RLS exacta

Comando: python manage.py test tests.api.test_invitation_signup

Notas técnicas

  • Autocommit exento: La vista corre FUERA del TenantRLSMiddleware, pero el middleware de auth ya ha corrido → request.user existe (anónimo para /signup).
  • PLAN_MAX_RACKS es fuente única: Antes estaba duplicada en admin y endpoint — ahora vive en core.models y el admin la reutiliza.
  • select_for_update() tardío: Minimiza contención. Los validates tempranos NO llevan lock; solo el consumo final.

Véase también

  • [[entity—core—endpoint—signup]]
  • [[entity—core—model—invitation-code]]
  • [[feature—core—registro-invite-only-plan-f1]]
  • [[concept—saas—multi-tenancy]]
  • [[entity—core—service—signup-activation]]