🛠 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
AbstractUseryAbstractBaseUser.
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:
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.
Criterios de aceptación
- AC-03-01:
AUTH_USER_MODELapunta asecurity.User. - AC-03-02:
Clientno tiene password. - AC-03-03: un usuario
inactiveno 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_initialaplicada 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:
- 📋 Criterios de aceptación → Criterios de aceptación
- ✅ GATE → GATE
- 🔎 Verificación → Verificación
- 🧠 Autoevaluación → Evaluación del cuaderno
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