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ónemail(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
- Normalización: código a UPPERCASE, email a lowercase, stripping de espacios
- 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"))
- Existe (
- 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))
- Org con ese nombre NO existe (
- Crear organización (provision inline):
plan = inv.planmax_racks = PLAN_MAX_RACKS.get(plan.slug, 10)(fuente única definida encore.models)- Asignar módulos: core (siempre) + plan modules →
org.extra_modules.set(core_pks | plan_pks)
- 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 segundoset_password()lo invalida (bug del link muerto, PR-0) role="admin"(primer usuario de la org)
- Consumo atómico del código:
select_for_update()lo más tarde posible para minimizar la ventana de lockInvitationCode.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.userexiste (anónimo para /signup). - PLAN_MAX_RACKS es fuente única: Antes estaba duplicada en admin y endpoint — ahora vive en
core.modelsy 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]]