Saltar a contenido

🛠 Unidad ISS-03 · Custom User antes de la primera migración — capa 🛠 CONSTRUIR

🧠 Comprender este bloque → · ✅ GATE de la unidad

Capa Página Para qué
🧠 Aprender Guía de estudio comprender, explicar y relacionar
🛠 Construir esta página ejecutar, programar y verificar
✅ GATE Condiciones de cierre condición para pasar al bloque siguiente

Esta es la guía ejecutable. El cuerpo de abajo es el ISS técnico verbatim: comandos, rutas, versiones, verificaciones y criterios, sin simplificar.


ISS-03 — Custom User antes de la primera migración

Objetivo

Definir el usuario de StoreLab sobre django.contrib.auth y fijar AUTH_USER_MODEL antes de migrar.

Requisitos

  • ISS-02 superado.
  • Decisión explícita entre AbstractUser y AbstractBaseUser.

Construcción

RecordStatus lo van a usar el usuario y, desde el ISS-04, el negocio. El paquete apps/common no es una app: no se registra y no se crea con startapp.

mkdir -p apps/common
cat > apps/common/__init__.py <<'EOF'
EOF
cat > apps/common/status.py <<'EOF'
from django.db import models


class RecordStatus(models.TextChoices):
    ACTIVE = "active", "Active"
    INACTIVE = "inactive", "Inactive"
EOF

La app sí sale de la CLI:

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

UBICAR:
    name = "security"

REEMPLAZAR POR:
    name = "apps.security"
    label = "security"

models.py ya existe y solo tiene un comentario. En este ISS contiene únicamente el usuario. Refresh y la matriz se agregan al final del archivo en la Fase II. No los escriba ahora.

cat > apps/security/models.py <<'EOF'
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)
EOF

create_user deja status en inactive si nadie lo indica. create_superuser lo fuerza a active, para que save() no desactive al superusuario.

Settings ya existe. La app de seguridad va delante de las de negocio, y AUTH_USER_MODEL se declara antes de cualquier migrate.

ARCHIVO: config/settings/__init__.py

ENCIMA DE:
    "apps.client.apps.ClientConfig",

AGREGAR:
    "apps.security.apps.SecurityConfig",

DEBAJO DE:
DATABASES = database_from_environ(os.environ)

AGREGAR:

AUTH_USER_MODEL = "security.User"

El test del motor, prometido en el ISS-01, reemplaza el tests.py vacío de startapp. Todavía no hay login.

cat > apps/security/tests.py <<'EOF'
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)
EOF

Ahora sí se migra. La CLI escribe la migración; no se copia a mano.

python manage.py makemigrations security
python manage.py migrate
python manage.py test apps.security.tests.DatabaseSelectionTests

Quien siga el manual obtiene security.0001 solo con User. En el repositorio publicado esa migración también trae los modelos de la Fase II, porque se generó con el archivo ya completo. El esquema final coincide; la historia de archivos de migración, no. No migre antes de este parche: Django crearía las tablas del usuario por defecto y cambiar AUTH_USER_MODEL después obliga a borrarlas.

Explicación

AbstractUser se prefiere a AbstractBaseUser porque ya resuelve el hash, los validadores, set_password(), check_password() y el admin. Reconstruir eso a mano sería el sistema de contraseñas que este laboratorio prohíbe.

status manda sobre is_active. La columna is_active se conserva porque DRF y SimpleJWT deciden con ella si el usuario puede autenticarse. Las credenciales viven en security.User, con el hash de AbstractUser.

AbstractUser
    + status
    + created_at / updated_at
    + email único
         │
         ▼
AUTH_USER_MODEL = security.User

Criterios de aceptación

  • AC-03-01: AUTH_USER_MODEL apunta a security.User.
  • AC-03-02: Client no tiene password.
  • AC-03-03: un usuario inactive no inicia sesión.
  • AC-03-04: una clave incorrecta responde 401 y la correcta no devuelve el hash.

Verificación

AuthFlowTests.

Evidencias

  • EVI-03-01: migración security.0001_initial aplicada en PostgreSQL.
  • EVI-03-02: login por username y por email PASS.

GATE

AC Verificación Evidencia Resultado
AC-03-01 settings config/settings/__init__.py PASS
AC-03-02 modelo Client sin campo password PASS
AC-03-03 usuario inactivo test 401 PASS
AC-03-04 check_password test de login PASS

✅ GATE de la unidad ISS-03 — este bloque no añade ningún criterio nuevo.

Las condiciones de cierre son las de esta misma página:

Con el GATE en verde queda habilitado el bloque siguiente de la ruta.

Navegación de la ruta: ← ISS-03 · 🧠 Aprender · ↑ Ruta Django · → ISS-04 · 🧠 Aprender