📚 Unidad ISS-03 · Custom User antes de la primera migración — capa 🧠 APRENDER
🛠 Construir este bloque → · ✅ Condiciones de cierre (GATE) · 📝 Evaluación
Capa Página Para qué 🧠 Aprender esta página comprender, explicar y relacionar 🛠 Construir Custom User antes de la primera migración ejecutar, programar y verificar ✅ GATE Cierre de la unidad condición para pasar al bloque siguiente
Mapa de correspondencias. Cada fila enlaza el mismo tema en las dos capas; los enlaces apuntan a secciones reales del material.
| Tema | 🧠 Aprender (esta página) | 🛠 Construir (ISS técnico) |
|---|---|---|
| Objetivo | A. Objetivos y Requisitos de ISS-03 | Objetivo |
| Recorrido | C. Estructura de Archivos y Paquetes | Construcción |
| Cierre | I. Criterios de Aceptación (AC) | Criterios de aceptación · GATE |
| Evaluación | K. Cuestionario de Defensa Oral Técnica | GATE |
🖥 Presentación del ISS
Presentación de la unidad. Diapositivas de ISS-03 — Custom User antes de la primera migración (19 diapositivas). Se visualiza aquí, dentro del sitio.
🎬 Video explicativo
Recorrido audiovisual de la unidad. El video recorre el ISS técnico de ISS-03 — Custom User antes de la primera migración, bloque por bloque.
9:47 min · narración en español · subtítulos activables desde el reproductor.
Infografía

Mapa conceptual
- Paquete Common (No-App)
- apps/common/status.py
- RecordStatus: ACTIVE ('active'), INACTIVE ('inactive')
- App Security & Modelo User
- apps/security/models.py
- AbstractUser (preserva hash, validadores y admin)
- Campos: email (único), status, created_at, updated_at
- Constraints: CheckConstraint sobre RecordStatus.values
- Hook save(): is_active = (status == ACTIVE) y normaliza email
- Manager Personalizado
- StoreUserManager(UserManager)
- create_user: por defecto status=INACTIVE
- create_superuser: por defecto status=ACTIVE
- Configuración en Settings
- INSTALLED_APPS: apps.security.apps.SecurityConfig
- AUTH_USER_MODEL: security.User (declarado antes de migrar)
- Migraciones & Pruebas
- python manage.py makemigrations security
- python manage.py migrate
- apps/security/tests.py: DatabaseSelectionTests (4 motores)
- Criterios de Aceptación & Gate
- AC-03-01: AUTH_USER_MODEL configurado
- AC-03-02: Client sin campo password
- AC-03-03: Usuario inactivo no inicia sesión
- AC-03-04: check_password y respuesta 401
- Evidencias: EVI-03-01 / EVI-03-02 (PASS)
Guía de Estudio Exhaustiva: ISS-03 — Custom User antes de la primera migración
A. Objetivos y Requisitos de ISS-03
Objetivo Principal
Definir el modelo de usuario personalizado (User) de StoreLab extendiendo django.contrib.auth y fijar de manera explícita la variable de configuración AUTH_USER_MODEL antes de ejecutar la primera migración en la base de datos.
Requisitos Previos
- ISS-02 superado satisfactoriamente: Estructura de paquetes de aplicaciones (
apps/client,apps/product,apps/sale) creada y registrada en la configuración. - Inexistencia de migraciones previas de autenticación: La base de datos no debe contener las tablas por defecto del sistema de autenticación de Django (
auth_user, etc.). - Decisión explícita de diseño de arquitectura: Elección argumentada entre extender
AbstractUserfrente aAbstractBaseUser.
B. Conceptos Esenciales y Vocabulario Técnico
AbstractUser: Clase abstracta proporcionada por Django que incluye la implementación completa de un usuario funcional (nombre de usuario, contraseña hasheada, permisos, grupos, correo, estado del personal y validadores integrados). Se selecciona cuando se desea conservar el comportamiento estándar de autenticación y administración de Django pero añadiendo campos del dominio.AbstractBaseUser: Clase abstracta de más bajo nivel en Django que solo proporciona la representación mínima de un usuario (campo de contraseña y mecanismos de hash/autenticación). Obliga a reimplementar manualmente la gestión de permisos, el modelo de administración, los validadores y la infraestructura de claves, lo cual está expresamente prohibido en el marco de este laboratorio.AUTH_USER_MODEL: Ajuste global enconfig/settings/__init__.pycon la sintaxis"nombre_app.NombreModelo"(para StoreLab:"security.User"). Informa a Django y a sus librerías dependientes (como DRF o SimpleJWT) que deben sustituir el modeloauth.Userintegrado por el modelo personalizado.UserManager: Clase base del manager de modelos de autenticación en Django. Se extiende medianteStoreUserManagerpara controlar la creación programática de usuarios (create_user) y superusuarios (create_superuser), garantizando los estados por defecto del dominio.TextChoices: Clase especial de enumeración basada en texto (models.TextChoices) que ofrece el ORM de Django para definir listas cerradas de opciones con tipo seguro, facilitando la gestión de etiquetas legibles y valores persistidos en la base de datos.CheckConstraint: Restricción declarativa a nivel de base de datos (models.CheckConstraint) que utiliza la sintaxis de expresiones del ORM (models.Q) para traducir las reglas del modelo a código SQL estricto (CHECK), impidiendo que se inserten valores no válidos directamente en la tabla física.is_activevs.status:status: Campo propio del dominio StoreLab de tipoCharField, gobernado porRecordStatus("active"o"inactive"). Representa el ciclo de vida del registro dentro del sistema de negocio.is_active: Campo booleano heredado deAbstractUseren Django. Librerías internas, DRF y SimpleJWT utilizan explícitamenteis_activepara decidir si un usuario tiene permitido autenticarse y generar tokens.- Regla de sincronización: El campo
statusmanda sobreis_active. En el hooksave(),is_activese recalcula como un reflejo directo de la condiciónstatus == RecordStatus.ACTIVE.
C. Estructura de Archivos y Paquetes
1. Creación del Paquete Común (apps/common/)
El paquete apps/common/ almacena lógica compartida transversalmente en todo el proyecto. No es una aplicación de Django (no posee apps.py, no utiliza startapp y no se registra en INSTALLED_APPS):
Creación del módulo de estados apps/common/status.py:
from django.db import models
class RecordStatus(models.TextChoices):
ACTIVE = "active", "Active"
INACTIVE = "inactive", "Inactive"
2. Creación y Configuración de la App de Seguridad (apps/security/)
La aplicación de seguridad gestiona las credenciales, los usuarios y la matriz de autorización:
Parche de configuración en apps/security/apps.py para adecuar la ruta del paquete e independizar el identificador de migración:
# ARCHIVO: apps/security/apps.py
from django.apps import AppConfig
class SecurityConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.security"
label = "security"
D. Explicación por Bloques Semánticos del Modelo User
El archivo apps/security/models.py define la entidad de usuario del sistema. A continuación se presenta la implementación completa y su desglose detallado por bloques semánticos:
from django.contrib.auth.models import AbstractUser, UserManager
from django.db import models
from apps.common.status import RecordStatus
class StoreUserManager(UserManager):
def create_user(self, username, email=None, password=None, **extra_fields):
extra_fields.setdefault("status", RecordStatus.INACTIVE)
return super().create_user(username, email, password, **extra_fields)
def create_superuser(self, username, email=None, password=None, **extra_fields):
extra_fields.setdefault("status", RecordStatus.ACTIVE)
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
return super().create_superuser(username, email, password, **extra_fields)
class User(AbstractUser):
email = models.EmailField("correo electrónico", max_length=150, unique=True)
status = models.CharField(
max_length=8,
choices=RecordStatus.choices,
default=RecordStatus.INACTIVE,
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = StoreUserManager()
class Meta:
db_table = "users"
verbose_name = "usuario"
verbose_name_plural = "usuarios"
constraints = [
models.CheckConstraint(
condition=models.Q(status__in=RecordStatus.values),
name="users_status_valid",
)
]
def save(self, *args, **kwargs):
if self.email:
self.email = self.email.strip().lower()
self.is_active = self.status == RecordStatus.ACTIVE
super().save(*args, **kwargs)
Análisis del Código por Bloques
Bloque 1: Imports e Integración del Entorno
AbstractUser,UserManager: Clases base importadas dedjango.contrib.auth.modelspara extender las capacidades de autenticación predeterminadas.models: Módulo del ORM de Django.RecordStatus: Enumeración importada desdeapps.common.statuspara estandarizar el estado del registro.
Bloque 2: Custom Manager (StoreUserManager)
create_user(...): Sobrescribe el método de creación estándar de usuarios. Garantiza que si no se proporciona un estado explícito, este se asigne por defecto aRecordStatus.INACTIVE.create_superuser(...): Sobrescribe el método utilizado por comandos CLI comocreatesuperuser. Fuerza de forma obligatoria questatusseaRecordStatus.ACTIVE, además de estableceris_staff=Trueeis_superuser=True. Esto evita que un superusuario recién creado sea desactivado inadvertidamente por el ciclo de vida del modelo.
Bloque 3: Declaración de Clase y Campos del Modelo
class User(AbstractUser): Hereda toda la infraestructura estándar de Django (contraseñas hasheadas, validadores, relación con grupos/permisos).email: Se redefine sobre la clase base para forzar que sea obligatorio, único (unique=True), con una longitud máxima de 150 caracteres y un nombre legible ("correo electrónico").status: Campo de texto de longitud 8 que utilizachoices=RecordStatus.choicesy tiene como valor por defectoRecordStatus.INACTIVE.created_at: Marca temporal de creación gestionada automáticamente (auto_now_add=True).updated_at: Marca temporal de última modificación actualizada automáticamente (auto_now=True).objects = StoreUserManager(): Asigna el manager personalizado a la propiedadobjectsdel modelo.
Bloque 4: Configuración de Metadatos (Meta)
db_table = "users": Sobrescribe el nombre de la tabla en la base de datos para omitir el prefijo por defecto (security_user) y mapearla directamente ausers.verbose_nameyverbose_name_plural: Nombres legibles en español para la interfaz gráfica y de administración.constraints: Define una restricción explícita de base de datos (CheckConstraint) llamadausers_status_validque asegura mediante SQL que la columnastatuscontenga únicamente los valores válidos definidos enRecordStatus.values("active"o"inactive").
Bloque 5: Hook save() y Reglas de Negocio
- Normalización de Correo: Comprueba si existe el valor
self.emaily remueve espacios en blanco en los extremos (.strip()) convirtiendo todo el texto a minúsculas (.lower()). - Sincronización de Estado: Evalúa la regla de negocio
self.is_active = self.status == RecordStatus.ACTIVE. Esto asegura la coincidencia lógica permanente entre el estado de negocio y la bandera interna que consulta la seguridad de Django/DRF. - Invocación Superior: Ejecuta
super().save(*args, **kwargs)para escribir los cambios en la base de datos.
E. Parches Aplicados en config/settings/__init__.py
Para activar la aplicación de seguridad y reemplazar el modelo de usuario por defecto del marco de trabajo, se deben realizar las siguientes modificaciones en el archivo de configuración global:
# ARCHIVO: config/settings/__init__.py
# 1. Adición de la app de seguridad en INSTALLED_APPS (Ubicada ANTES de las apps de negocio):
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# App de Seguridad
"apps.security.apps.SecurityConfig",
# Apps de Negocio
"apps.client.apps.ClientConfig",
"apps.product.apps.ProductConfig",
"apps.sale.apps.SaleConfig",
]
# 2. Declaración explícita del modelo de usuario personalizado (Debajo de DATABASES):
DATABASES = database_from_environ(os.environ)
AUTH_USER_MODEL = "security.User"
F. Pruebas Unitarias en apps/security/tests.py
En este ISS se consolida la prueba unitaria de selección dinámica del motor de base de datos (DatabaseSelectionTests), heredada estructuralmente del ISS-01 pero ubicada técnicamente dentro de apps/security/tests.py para permitir su descubrimiento y ejecución dentro del paquete de aplicaciones del proyecto:
from django.core.exceptions import ImproperlyConfigured
from django.test import SimpleTestCase
from config.database import database_from_environ
class DatabaseSelectionTests(SimpleTestCase):
def _env(self, engine):
return {
"DB_ENGINE": engine,
"POSTGRES_DB": "storelab",
"POSTGRES_USER": "storelab",
"POSTGRES_PASSWORD": "storelab",
"POSTGRES_HOST": "127.0.0.1",
"POSTGRES_PORT": "5432",
"MYSQL_DB": "storelab",
"MYSQL_USER": "storelab",
"MYSQL_PASSWORD": "storelab",
"MYSQL_HOST": "127.0.0.1",
"MYSQL_PORT": "3306",
"MSSQL_DB": "storelab",
"MSSQL_USER": "storelab",
"MSSQL_PASSWORD": "storelab",
"MSSQL_HOST": "127.0.0.1",
"MSSQL_PORT": "1433",
"ORACLE_DB": "storelab",
"ORACLE_USER": "storelab",
"ORACLE_PASSWORD": "storelab",
"ORACLE_HOST": "127.0.0.1",
"ORACLE_PORT": "1521",
"ORACLE_SERVICE_NAME": "FREEPDB1",
}
def test_postgresql_engine(self):
config = database_from_environ(self._env("postgresql"))
self.assertEqual(config["default"]["ENGINE"], "django.db.backends.postgresql")
def test_mysql_engine(self):
config = database_from_environ(self._env("mysql"))
self.assertEqual(config["default"]["ENGINE"], "django.db.backends.mysql")
self.assertEqual(config["default"]["OPTIONS"]["charset"], "utf8mb4")
def test_mssql_engine(self):
config = database_from_environ(self._env("mssql"))
self.assertEqual(config["default"]["ENGINE"], "mssql")
def test_oracle_uses_service_name(self):
config = database_from_environ(self._env("oracle"))
self.assertIn("SERVICE_NAME=FREEPDB1", config["default"]["NAME"])
self.assertEqual(config["default"]["PORT"], "")
def test_invalid_engine(self):
with self.assertRaises(ImproperlyConfigured):
database_from_environ({"DB_ENGINE": "sqlite"})
def test_missing_variable(self):
env = self._env("postgresql")
env["POSTGRES_DB"] = ""
with self.assertRaises(ImproperlyConfigured):
database_from_environ(env)
G. Regla de Oro de Migración
REGLA CRÍTICA DE ARQUITECTURA: La variable de configuración
AUTH_USER_MODELdebe declararse y apuntar al modelo de usuario personalizado ANTES de ejecutar el comandopython manage.py migratepor primera vez en el proyecto.
Justificación Técnica
Django genera referencias cruzadas mediante claves foráneas (FK) hacia el modelo de usuario desde múltiples aplicaciones integradas desde el inicio (django.contrib.admin, django.contrib.auth, django.contrib.sessions, etc.).
Si se ejecuta migrate antes de definir AUTH_USER_MODEL:
1. El ORM de Django creará la estructura de tablas por defecto basada en auth_user.
2. Si posteriormente se modifica AUTH_USER_MODEL = "security.User", las migraciones subsecuentes fallarán al intentar vincular las claves foráneas existentes hacia la nueva tabla física users.
3. La única solución técnica ante este error es destruir por completo la base de datos, eliminar los archivos de migración generados e iniciar el proceso desde cero.
H. Errores Frecuentes y Estrategias de Prevención
- Ejecutar
python manage.py migrateantes de configurar el Custom User: - Efecto: Creación de las tablas por defecto de
django.contrib.auth(auth_user), imposibilitando la migración limpia haciausers. -
Solución: Asegurar que la primera migración creada en el sistema sea
security.0001_initialconAUTH_USER_MODEL = "security.User"ya configurado en settings. -
Crear un superusuario inactivo:
- Efecto: Si en
create_superuserno se establece explícitamentestatus = RecordStatus.ACTIVE, el manager utilizará el valor predeterminadoINACTIVE. Al ejecutarse el métodosave(), la instrucciónself.is_active = self.status == RecordStatus.ACTIVEasignaráis_active = False, bloqueando inmediatamente el acceso del superusuario a la consola de administración. -
Solución: Incluir
extra_fields.setdefault("status", RecordStatus.ACTIVE)dentro del métodocreate_superuserdeStoreUserManager. -
No normalizar el correo electrónico en el modelo:
- Efecto: Inserción de correos duplicados por variaciones de caja (
Usuario@Dominio.comfrente ausuario@dominio.com), violando la restricción semántica de unicidad. -
Solución: Aplicar
.strip().lower()sobreself.emailen el hooksave()antes de invocar la persistencia en base de datos. -
Desincronizar
is_activerespecto astatus: - Efecto: Inconsistencias en el control de acceso donde un usuario con
status = "inactive"puede autenticarse porque su campois_activese mantuvo enTrue(o viceversa). - Solución: Centralizar la asignación
self.is_active = self.status == RecordStatus.ACTIVEdentro del métodosave()del modeloUser.
I. Criterios de Aceptación (AC)
- AC-03-01: La variable de configuración
AUTH_USER_MODELapunta explícitamente a"security.User". - AC-03-02: El modelo de negocio
Clientno posee campo de contraseña (password). Las credenciales residen de manera exclusiva ensecurity.User. - AC-03-03: Un usuario cuyo estado sea
inactiveno puede iniciar sesión ni autenticarse en la API. - AC-03-04: Una petición de autenticación con clave incorrecta responde con un código de estado
HTTP 401 Unauthorized, y una autenticación correcta no expone el hash de la contraseña en la respuesta JSON.
J. Verificación, Evidencias y Tabla GATE
Comandos de Construcción y Verificación
Para generar las migraciones por primera vez y aplicar la estructura en la base de datos se ejecutan los comandos CLI:
python manage.py makemigrations security
python manage.py migrate
python manage.py test apps.security.tests.DatabaseSelectionTests
Evidencias Requeridas
- EVI-03-01: Aplicación exitosa de la migración inicial
security.0001_initialen el motor activo (PostgreSQL). - EVI-03-02: Ejecución en estado
PASSde las pruebas de autenticación de usuario porusernamey poremail(AuthFlowTests).
Tabla GATE de Control de Fase
| Criterio de Aceptación | Método de Verificación | Evidencia Vinculada | Resultado |
|---|---|---|---|
| AC-03-01 | Inspección directa del archivo config/settings/__init__.py |
AUTH_USER_MODEL = "security.User" |
PASS |
| AC-03-02 | Inspección del modelo Client en apps/client/models.py |
Modelo de cliente sin campo password |
PASS |
| AC-03-03 | Ejecución de prueba unitaria de usuario inactivo | Respuesta HTTP 401 confirmada en suite de pruebas | PASS |
| AC-03-04 | Ejecución del test de verificación de contraseñas (check_password) |
Login exitoso/fallido verificado en suite de pruebas | PASS |
K. Cuestionario de Defensa Oral Técnica
- ¿Por qué se seleccionó
AbstractUseren lugar deAbstractBaseUserpara implementar el usuario de StoreLab? -
Respuesta Justificada:
AbstractUserse selecciona porque ya provee toda la infraestructura estándar de autenticación de Django: hashing seguro de contraseñas, validadores, integración con grupos, permisos y la compatibilidad con el panel de administración (django.contrib.admin).AbstractBaseUserexigiría reimplementar manualmente todo el sistema de claves, validaciones y administración desde cero, lo cual constituiría una reinvención innecesaria y prohibida por las reglas del proyecto. -
¿Cuál es la función técnica de la variable
AUTH_USER_MODELy qué ocurre si se define después de haber ejecutadopython manage.py migrate? -
Respuesta Justificada:
AUTH_USER_MODELle indica al ORM de Django y a los paquetes de terceros qué modelo representa la entidad principal de usuario. Si se define después de ejecutarmigrate, Django habrá creado previamente la tabla por defectoauth_usery sus relaciones. Cambiar el modelo a posteriori rompe la integridad referencial de las claves foráneas del sistema, obligando a reconstruir totalmente la base de datos. -
¿Por qué coexisten los campos
statuseis_activeen el modeloUsery cómo se garantiza su coherencia? -
Respuesta Justificada:
statuses el campo de texto explícito del dominio StoreLab ("active"/"inactive").is_activees un campo booleano requerido por el framework Django y librerías como DRF y SimpleJWT para autorizar el acceso. Su coherencia se garantiza mediante una regla de negocio en el hooksave()del modelo:self.is_active = self.status == RecordStatus.ACTIVE. -
¿Cómo asegura el modelo
Userque no existan correos duplicados causados por el uso de mayúsculas y minúsculas? -
Respuesta Justificada: Mediante la combinación de dos mecanismos: el campo se define con la restricción de unicidad
unique=Trueen la base de datos, y en el métodosave()se fuerza la normalización mediante la instrucciónself.email = self.email.strip().lower(). -
¿Qué función cumple la clase
StoreUserManageral extenderUserManager? -
Respuesta Justificada: Garantiza que los estados del registro del dominio se asignen correctamente según el flujo de creación. En
create_userestablecestatus = INACTIVEpor defecto, mientras que encreate_superuserfuerzastatus = ACTIVE(junto ais_staff=Trueeis_superuser=True), evitando que el hooksave()desactive accidentalmente a un superusuario recién creado. -
¿Por qué el paquete
apps/common/no se añade a la listaINSTALLED_APPSen la configuración del proyecto? - Respuesta Justificada: Porque
apps/common/no es una aplicación Django (no poseeapps.pyni modelos con tabla propia). Es un paquete Python puro destinado únicamente a alojar código y utilidades compartidas (como la enumeraciónRecordStatus), por lo que no requiere registro dentro del mecanismo de descubrimiento de aplicaciones del framework.
Navegación de la ruta: ← ISS-02 · 🛠 Construir · ↑ Ruta Django · → ISS-03 · 🛠 Construir