Librería/app de Django con utilidades y abstracciones reutilizables entre proyectos: modelos base con
UUID + id numérico secuencial, campos y constraints custom, managers/querysets con bulk_create/bulk_update
"seguros", helpers de DRF (filtros, paginación, validaciones, campos anidados), formularios, factories de
test, y más. No es un proyecto Django en sí mismo — se instala como dependencia dentro de otro proyecto.
Requiere PostgreSQL en producción. Varias piezas (
ArrayField, búsqueda por trigramas/vector, las funciones demodels/functions/) son específicas de Postgres y no funcionan sobre otros motores.
- Python ≥ 3.9
- Django ≥ 4.2.4, < 5.0
- PostgreSQL (para los features Postgres-only mencionados arriba)
Ver pyproject.toml para la lista completa de dependencias.
El paquete se gestiona con uv. Para agregarlo a otro proyecto:
uv add "django-general-utils @ git+https://github.com/tsepulvedacaroca01/django-general-utils"o con pip, apuntando al repositorio (no se publica en PyPI):
pip install "git+https://github.com/tsepulvedacaroca01/django-general-utils"Agregar 'django_general_utils' a INSTALLED_APPS.
django_general_utils/
├── models/ modelos abstractos, managers, querysets, campos y constraints custom
├── management/commands/ management commands (ver Management commands)
├── utils/ helpers de DRF, formularios, factories de test, formato, imágenes, etc.
├── templatetags/ tags de template genéricos
├── context_processors/ context processors genéricos
└── test/ helpers para tests (vacío por ahora)
Todo lo que expone models/ son mixins abstractos (Meta.abstract = True) — este repo no define
modelos de negocio concretos; los proyectos consumidores heredan de estas clases.
Mixin abstracto con uuid (PK), is_active, created_at/created_by, updated_at/updated_by,
stopped_at. Expone id_as_code (código con padding tipo VE-0000001) y next_code() — depende de
safedelete's all_with_deleted(), así que solo tiene sentido combinado con BaseModel.
Igual que UUIDModel pero con generación de id numérico secuencial propia (no depende de
AutoField/secuencias de la DB): usa un lock de Postgres (pg_advisory_xact_lock) + MAX(id) + 1 dentro
de una transacción, con reintentos ante colisión. Puntos clave:
- Punto de partida configurable: sobrescribí
_INITIAL_ID_(entero) oget_initial_id()(classmethod, para lógica dinámica — p. ej. resolver el punto de partida desde una consulta) en tu modelo concreto. Se usa únicamente cuando la tabla está vacía; con filas existentes siempre continúa desdeMAX(id) + 1. bulk_createtambién asignaid: los querysets (BaseModelWithoutSafeDeleteQuerySet/BaseModelQuerySet) llaman a_assign_auto_ids()dentro de la misma transacción que elINSERT, así queModel.objects.bulk_create([...])funciona igual que.save()uno por uno.- En SQLite el lock es un no-op (
connection.vendor != 'postgresql') — los tests corren igual, solo sin el lock real.
Igual que UUIDModelV2, con dos cambios:
uuid(la PK) usa uuid7 (utils/uuid7.py— implementación propia, sin dependencia externa, porqueuuid.uuid7del stdlib recién existe en Python 3.14 y este paquete soporta>=3.9) en vez de uuid4. Al ser time-ordered (48 bits de timestamp Unix en ms + versión + random), los inserts en el índice de la PK son secuenciales en vez de aleatorios — mejor locality/menos fragmentación de índice con volumen alto. Por esoMeta.ordering = ('-uuid',)en vez de-created_at: ordenar por la PK reusa su propio índice en vez de requerir uno aparte paracreated_at.id_as_codees una columna real (CharField,db_index=True), no unaqueryable_propertyanotada en cada query como enUUIDModelV2/UUIDModel. Se puebla automáticamente dondequiera que se resuelveid— creación individual (.save()),idpreseteado a mano, retry ante colisión deid, ybulk_create()— así que no hay que llamar nada a mano en el flujo normal.
Si cambiás _ID_AS_CODE_PREFIX_/_ID_AS_CODE_SUFFIX_/_ID_AS_CODE_LENGTH_ en un modelo concreto después
de tener filas existentes, esas filas quedan con el id_as_code viejo — correr el comando
sync_id_as_code (ver Management commands) para generar la migración de datos que
las actualiza.
SafeDeleteModel + OrderedModel + UUIDModel, con soft-delete (SOFT_DELETE_CASCADE), historial
(django-simple-history, agregado automáticamente por el metaclass) y FieldTracker (django-model-utils,
también automático). Requiere usar los campos fields.ForeignKey/fields.OneToOneField de este mismo
paquete (no los de django.db.models) — el metaclass lo valida y lanza TypeError si no.
Igual que BaseModel pero sobre UUIDModelV2, sin soft-delete. Agrega automáticamente métodos
get_<campo>_format_decimal() / get_<campo>_format_currency() (formato Babel, locale es_CL por
defecto) a cualquier campo numérico.
Nombre anterior: esta clase se llamaba
BaseWithoutSafeDeleteModel(models/base_without_safe_delete.py) y su manager se llamabaBaseWithoutSafeDeleteModelManager(models/managers/base_without_safe_delete.py). Ambos módulos/nombres siguen funcionando — quedaron como shims de compatibilidad que re-exportan las clases reales (BaseWithoutSafeDeleteModel is BaseV2,BaseWithoutSafeDeleteModelManager is BaseV2Manager— misma clase, no una copia), así que el código existente que los usa no necesita cambios de código. Sí van a empezar a ver unDeprecationWarningal importarlos ('...BaseWithoutSafeDeleteModel' is deprecated ... use '...BaseV2' instead) — es solo un aviso, nada se rompe. Python ignoraDeprecationWarningpor default fuera de__main__, así que puede no imprimirse salvo que el proyecto consumidor corra conpython -W default::DeprecationWarningo tenga algo comowarnings.simplefilter('always', DeprecationWarning)/filterwarnings = always::DeprecationWarning(pytest) configurado. Para código nuevo, usarBaseV2/BaseV2Managerdirectamente.
Igual que BaseV2 pero sobre UUIDModelV3 en vez de UUIDModelV2 — hereda uuid7 e id_as_code como
columna real de esa sección. Reutiliza el mismo metaclass (ModelBaseV2Meta) y el mismo manager
(BaseV2Manager, models/managers/base_v2.py) que BaseV2 — ninguno de los dos depende de qué
UUIDModelV* se mezcle. No tiene nombre anterior; es la clase nueva de esta sesión.
orderinges-created_at, no-uuid— a diferencia deUUIDModelV3(que sí usa-uuidpor default). Hay dos razones, no solo compatibilidad de API:
- Los proyectos que migran de
BaseV2aBaseV3típicamente tienen filas viejas con PKuuid4(aleatoria) conviviendo con filas nuevas enuuid7(time-ordered) — bajo-uuid, las filas viejas quedan en un orden efectivamente aleatorio (uuid4 no codifica tiempo), aunque las nuevas sí ordenen bien; solo tiene sentido ordenar por-uuiden un modelo que nace 100% enuuid7, sin datos heredados.-created_ates correcto para ambos casos por igual.- Cambiar el default silenciosamente al pisar
BaseV2→BaseV3también sería una regresión de comportamiento visible (API, listados) para quien ya consume esos endpoints.
created_at(UUIDModelV2, heredado porUUIDModelV3/BaseV3) tienedb_index=Trueexplícito — sin eso,AutoCreatedFieldno indexa la columna por default yORDER BY created_at DESCsería un sort completo en cada query. Con el índice,-created_ates prácticamente tan eficiente como-uuid(que reusa el índice de la propia PK) sin el problema de correctitud del punto anterior.
Si tu modelo concreto define su propio
Meta, tiene que heredar delMetadeBaseV2/BaseV3(class Meta(BaseV3.Meta): ...) para conservar elordering— Django no combina automáticamente elMetade una clase abstracta con unMetanuevo que no lo subclasea.
FieldTrackery PKsuuid7: el paquete parcheamodel_utils.FieldTrackeral importarse (models/_field_tracker_patch.py) para que distinga correctamente instancias nuevas de instancias cargadas desde la base cuando la PK ya viene asignada al construirse en memoria (comouuid7enBaseV2/BaseV3) — sin el parche,tracker.has_changed()/tracker.previous()daban resultados incorrectos para instancias recién construidas (afecta directamente aCheckFlowStatusConstraint, que depende de eso para distinguir el estado inicial de una transición). Se aplica automáticamente, no requiere nada del lado del proyecto consumidor.
Ambas familias (base con safedelete y base_without_safe_delete sin él) exponen:
bulk_create()/bulk_update()— correnfull_clean()por objeto antes de persistir (salvofull_clean=False), agregando errores comoListValidationErroren vez de dejarlos pasar sueltos.bulk_create_or_update_dict(values, update_fields, unique_fields, full_clean=True, delete_others=False)— dado una lista de dicts, separa creación/actualización segúnunique_fields, correfull_clean()y hacebulk_create/bulk_update.delete_others=Trueborra filas no incluidas envalues.
| Campo | Qué hace |
|---|---|
ForeignKey / OneToOneField |
Excluyen automáticamente relaciones hacia filas soft-deleted (salvo en /admin/) — solo cuando ambos lados de la relación son BaseModel; usarlos entre modelos BaseV2/BaseV3 (sin deleted_at) funciona igual pero sin esa restricción |
AdvancedCharField |
to_upper/to_lower/to_title (excluyentes) + left_strip/right_strip/strip en get_prep_value |
FloatField / IntegerField / PositiveIntegerField |
Igual que sus equivalentes de Django + métodos get_<campo>_format_decimal/currency() |
JSONSchemaField |
JSONField que valida contra un JSON Schema (schema=<archivo>, relativo al módulo del modelo) |
ChoiceArrayField |
ArrayField con formfield() como checkboxes — Postgres-only |
VectorField |
Wrapper de pgvector.django.VectorField — Postgres-only |
Todas heredan de BaseConstraint, que permite pasar violation_error_message como dict ({campo: mensaje}) en vez de un string plano.
| Constraint | Uso |
|---|---|
UniqueConstraint / UniqueWithoutSafeDeleteConstraint |
Unicidad excluyendo (o no) filas soft-deleted; nombre autogenerado si no se pasa name |
CheckConstraint / CheckErrorConstraint |
Variantes de CheckConstraint con mensaje dict |
CheckEditableConstraint |
Impide editar ciertos campos tras la creación (usa tracker.has_changed()) |
CheckFlowStatusConstraint |
Máquina de estados: valida transiciones permitidas de un campo choices contra un dict {estado: [siguientes]} |
CheckModelRelationConstraint |
Valida una condición arbitraria (check(instance)) — ver nota abajo |
CheckRowsModelConstraint / CheckRowsModelWithoutSafeDeleteConstraint |
Límite máximo de filas que cumplen una condición |
Nota sobre
CheckModelRelationConstraint:check(instance)debe devolverFalsepara pasar. Si devuelveTrueoNone(p. ej. una función sinreturnexplícito), se considera violación. No es la convención habitual de "True= válido" — vertests/test_constraints_pure.py.
Detecta, para cada subclase concreta de UUIDModelV3 instalada en el proyecto consumidor, filas cuyo
id_as_code guardado ya no coincide con lo que generarían el _ID_AS_CODE_PREFIX_/_ID_AS_CODE_SUFFIX_/
_ID_AS_CODE_LENGTH_ actuales del modelo (prefijo/sufijo cambiado, o filas nunca backfilleadas tras
agregar la columna). No es un reemplazo de makemigrations — no toca el esquema, solo genera una
migración de datos (RunPython) por cada app afectada, con el prefijo/sufijo/length horneados como
literales en el archivo generado.
# Reporta qué modelos tienen id_as_code desincronizado y termina con error si hay alguno (para CI)
python manage.py sync_id_as_code --check
# Genera la(s) migración(es) de datos correspondientes
python manage.py sync_id_as_code
python manage.py migrateFlujo típico al cambiar _ID_AS_CODE_PREFIX_/_SUFFIX_/_LENGTH_ de un modelo (o al agregar id_as_code
por primera vez sobre una tabla con filas existentes):
python manage.py makemigrations— siid_as_codees un campo nuevo, esto genera la migración de esquema (agregar la columna). No hace falta si el campo ya existía y solo cambió el prefijo/sufijo.python manage.py migrate— aplica el esquema. Este paso es obligatorio antes quesync_id_as_code: la tabla/columna tiene que existir físicamente antes de poder consultarla/actualizarla — si un modelo sobreUUIDModelV3(oBaseV3) todavía no tiene esta migración aplicada en la base/schema donde corrés el comando (incluye setups multi-tenant tipodjango-tenants, donde el comando puede estar corriendo contra un schema donde ese modelo ni siquiera tiene tabla),sync_id_as_codelo salta con un aviso en vez de fallar para el resto de modelos.python manage.py sync_id_as_code— detecta el drift y genera la migración de datos.python manage.py migrate— backfilleaid_as_codepara las filas afectadas.
sync_id_as_code no sabe nada de tenants — solo mira la conexión/schema actual. En un proyecto con
django-tenants, cada schema (el public compartido y cada tenant) tiene su propio subconjunto de tablas
visibles, así que hay que recorrerlos explícitamente con los comandos que ya trae django-tenants:
tenant_command/all_tenants_command (correr un comando en un schema puntual o en todos, ver su
documentación).
1. Migrar el esquema en todos lados primero (siempre antes de sync_id_as_code — ver el paso 2 de
arriba):
python manage.py migrate_schemas --shared # aplica el esquema en public (SHARED_APPS)
python manage.py migrate_schemas # aplica el esquema en cada tenant (TENANT_APPS)2. Revisar drift en todos lados (--check, informativo, no escribe nada):
python manage.py sync_id_as_code --check # apps de public (SHARED_APPS)
python manage.py all_tenants_command sync_id_as_code --check # apps de cada tenant (TENANT_APPS)3. Generar las migraciones de datos — a mano, corriendo el modo escritura (sin --check) UNA sola vez
por schema representativo, no con all_tenants_command en un solo paso:
python manage.py sync_id_as_code # apps de public, si el check de arriba mostró drift ahí
python manage.py tenant_command sync_id_as_code --schema=<un_tenant_con_drift>
⚠️ Por qué noall_tenants_command sync_id_as_code(sin--check) directo: el archivo de migración generado (NNNN_sync_id_as_code.py) no depende del contenido de un tenant en particular — elRunPythonque escribe recalculaid_as_codea partir deidcon el prefijo/sufijo/length actuales, y corre igual sea cual sea el schema donde después se aplique conmigrate. Si corrés el modo escritura tenant por tenant conall_tenants_command, cada tenant que todavía muestre drift para la misma app va a generar otro archivo de migración para esa app (0001_sync_id_as_code.py,0002_sync_id_as_code.py, ...) — funcionalmente inofensivo (cada uno vuelve a aplicar el mismo cálculo, idempotente) pero deja migraciones redundantes en el historial. Alcanza con generarla una vez por app, contra cualquier schema donde ese modelo tenga al menos una fila con drift — no hace falta que sea el mismo tenant para todas las apps: siorganization.Companysolo muestra drift entenant_aeinventory.Productsolo entenant_b, corré el paso 3 una vez contra cada uno. Reviságit statusdespués de cada corrida — si no aparece ningún archivo nuevo, ya no queda nada por generar.
4. Aplicar las migraciones de datos generadas, en todos lados:
python manage.py migrate_schemas --shared
python manage.py migrate_schemasArrayAppend, ArrayToString, CleanHtml, FormattedDatetime, RandomNumber, SubqueryCount,
SubquerySum, WithChoices, etc. — la mayoría son Postgres-only (usan array_cat, regexp_replace
con flags de Postgres, to_char, etc.).
is_valid_uuid,str_to_boolean,file_to_json,formats.format_currency/format_decimal(Babel),uuid7(generador UUIDv7 propio, sin dependencias — usado comodefaultdeUUIDModelV3.uuid) — funciones puras de propósito general.factory/—DjangoModelFactory(factory_boy) con_get_or_createque intentacreate()primero y solo cae aget()anteIntegrityError;to_dict()/generate_dict_factory()para volcar un factory a dict sin tocar la DB (usa.stub());Provider(Faker) con RUT chileno y coordenadas de Santiago.drf/— parser multipart anidado, paginación conobject_query, filtros (BackendFilter,OrFilter,OrderingFiltercon orden aleatorio,PostgresSearchFilter), campos (PrimaryKeyRelatedFieldcononly_pk,NestedPrimaryKeyRelatedField,LazyRefSerializerField), validaciones (MinMaxElementsValidator,ids_in_query,unique_fields,validate_unique_together),exception_handlerpara convertirValidationError/ListValidationErroren400.postgres/— búsqueda combinando trigramas + full-text search +icontains/istartswithcon ranking — Postgres-only (pg_trgm).forms/—ModelFormque separa miles/decimales segúnsettings.THOUSAND_SEPARATOR/DECIMAL_SEPARATORy widgetDataAttributesSelectpara inyectardata-*a los<option>.image/blur_img_to_base64— genera un thumbnail borroso en base64 (BMP) con fallback silencioso.safedelete/admin,ajax_datatable/,drf_spectacular/,rest_ql/— integraciones con esos paquetes (admin con soft-delete + historial, datatables con búsqueda Postgres, generación de schema OpenAPI, campos dinámicos por query).
Varias de estas piezas tienen comportamientos no obvios (parámetros invertidos, convenciones poco
intuitivas, algún bug conocido) — están documentados con tests específicos en tests/ y en CLAUDE.md.
{% dict_get d key default=None %}—d.get(key, default).{% call_method obj "nombre_metodo" arg1 kw=val %}— invoca un método del objeto desde el template.
export_envs— agrega{'ENV': os.environ}al contexto de todos los templates.
Ver docs/00-contexto-libreria.md para el contexto completo (por qué esta librería se testea distinto a
un proyecto Django normal) y docs/01-tests.md / docs/02-python-style.md para convenciones de tests y
estilo. Resumen rápido:
# Construir la imagen de desarrollo
docker-compose -f docker-compose.dev.yml build
# Correr toda la suite de tests
docker-compose -f docker-compose.dev.yml up --abort-on-container-exit
# Lint
docker-compose -f docker-compose.dev.yml run --rm app-django-django-general-utils-dev \
bash -c "uv run ruff check ."MIT