Saltar a contenido

📚 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.

⛶ Ver presentación completa ⬇ Archivo editable (.pptx)

19 diapositivas · se visualiza 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

Infografía de la unidad

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

  1. ISS-02 superado satisfactoriamente: Estructura de paquetes de aplicaciones (apps/client, apps/product, apps/sale) creada y registrada en la configuración.
  2. 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.).
  3. Decisión explícita de diseño de arquitectura: Elección argumentada entre extender AbstractUser frente a AbstractBaseUser.

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 en config/settings/__init__.py con 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 modelo auth.User integrado por el modelo personalizado.
  • UserManager: Clase base del manager de modelos de autenticación en Django. Se extiende mediante StoreUserManager para 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_active vs. status:
    • status: Campo propio del dominio StoreLab de tipo CharField, gobernado por RecordStatus ("active" o "inactive"). Representa el ciclo de vida del registro dentro del sistema de negocio.
    • is_active: Campo booleano heredado de AbstractUser en Django. Librerías internas, DRF y SimpleJWT utilizan explícitamente is_active para decidir si un usuario tiene permitido autenticarse y generar tokens.
    • Regla de sincronización: El campo status manda sobre is_active. En el hook save(), is_active se recalcula como un reflejo directo de la condición status == 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):

mkdir -p apps/common
touch apps/common/__init__.py

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:

mkdir -p apps/security
python manage.py startapp security apps/security

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 de django.contrib.auth.models para extender las capacidades de autenticación predeterminadas.
  • models: Módulo del ORM de Django.
  • RecordStatus: Enumeración importada desde apps.common.status para 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 a RecordStatus.INACTIVE.
  • create_superuser(...): Sobrescribe el método utilizado por comandos CLI como createsuperuser. Fuerza de forma obligatoria que status sea RecordStatus.ACTIVE, además de establecer is_staff=True e is_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 utiliza choices=RecordStatus.choices y tiene como valor por defecto RecordStatus.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 propiedad objects del 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 a users.
  • verbose_name y verbose_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) llamada users_status_valid que asegura mediante SQL que la columna status contenga únicamente los valores válidos definidos en RecordStatus.values ("active" o "inactive").

Bloque 5: Hook save() y Reglas de Negocio

  • Normalización de Correo: Comprueba si existe el valor self.email y 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_MODEL debe declararse y apuntar al modelo de usuario personalizado ANTES de ejecutar el comando python manage.py migrate por 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

  1. Ejecutar python manage.py migrate antes de configurar el Custom User:
  2. Efecto: Creación de las tablas por defecto de django.contrib.auth (auth_user), imposibilitando la migración limpia hacia users.
  3. Solución: Asegurar que la primera migración creada en el sistema sea security.0001_initial con AUTH_USER_MODEL = "security.User" ya configurado en settings.

  4. Crear un superusuario inactivo:

  5. Efecto: Si en create_superuser no se establece explícitamente status = RecordStatus.ACTIVE, el manager utilizará el valor predeterminado INACTIVE. Al ejecutarse el método save(), la instrucción self.is_active = self.status == RecordStatus.ACTIVE asignará is_active = False, bloqueando inmediatamente el acceso del superusuario a la consola de administración.
  6. Solución: Incluir extra_fields.setdefault("status", RecordStatus.ACTIVE) dentro del método create_superuser de StoreUserManager.

  7. No normalizar el correo electrónico en el modelo:

  8. Efecto: Inserción de correos duplicados por variaciones de caja (Usuario@Dominio.com frente a usuario@dominio.com), violando la restricción semántica de unicidad.
  9. Solución: Aplicar .strip().lower() sobre self.email en el hook save() antes de invocar la persistencia en base de datos.

  10. Desincronizar is_active respecto a status:

  11. Efecto: Inconsistencias en el control de acceso donde un usuario con status = "inactive" puede autenticarse porque su campo is_active se mantuvo en True (o viceversa).
  12. Solución: Centralizar la asignación self.is_active = self.status == RecordStatus.ACTIVE dentro del método save() del modelo User.

I. Criterios de Aceptación (AC)

  • AC-03-01: La variable de configuración AUTH_USER_MODEL apunta explícitamente a "security.User".
  • AC-03-02: El modelo de negocio Client no posee campo de contraseña (password). Las credenciales residen de manera exclusiva en security.User.
  • AC-03-03: Un usuario cuyo estado sea inactive no 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_initial en el motor activo (PostgreSQL).
  • EVI-03-02: Ejecución en estado PASS de las pruebas de autenticación de usuario por username y por email (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

  1. ¿Por qué se seleccionó AbstractUser en lugar de AbstractBaseUser para implementar el usuario de StoreLab?
  2. Respuesta Justificada: AbstractUser se 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). AbstractBaseUser exigirí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.

  3. ¿Cuál es la función técnica de la variable AUTH_USER_MODEL y qué ocurre si se define después de haber ejecutado python manage.py migrate?

  4. Respuesta Justificada: AUTH_USER_MODEL le 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 ejecutar migrate, Django habrá creado previamente la tabla por defecto auth_user y 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.

  5. ¿Por qué coexisten los campos status e is_active en el modelo User y cómo se garantiza su coherencia?

  6. Respuesta Justificada: status es el campo de texto explícito del dominio StoreLab ("active" / "inactive"). is_active es 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 hook save() del modelo: self.is_active = self.status == RecordStatus.ACTIVE.

  7. ¿Cómo asegura el modelo User que no existan correos duplicados causados por el uso de mayúsculas y minúsculas?

  8. Respuesta Justificada: Mediante la combinación de dos mecanismos: el campo se define con la restricción de unicidad unique=True en la base de datos, y en el método save() se fuerza la normalización mediante la instrucción self.email = self.email.strip().lower().

  9. ¿Qué función cumple la clase StoreUserManager al extender UserManager?

  10. Respuesta Justificada: Garantiza que los estados del registro del dominio se asignen correctamente según el flujo de creación. En create_user establece status = INACTIVE por defecto, mientras que en create_superuser fuerza status = ACTIVE (junto a is_staff=True e is_superuser=True), evitando que el hook save() desactive accidentalmente a un superusuario recién creado.

  11. ¿Por qué el paquete apps/common/ no se añade a la lista INSTALLED_APPS en la configuración del proyecto?

  12. Respuesta Justificada: Porque apps/common/ no es una aplicación Django (no posee apps.py ni modelos con tabla propia). Es un paquete Python puro destinado únicamente a alojar código y utilidades compartidas (como la enumeración RecordStatus), 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