Metadata-Version: 2.4
Name: acv-django-core
Version: 0.13.0
Summary: Piezas compartidas de los backends Django de ACV.
Author: mario.aftimos
Requires-Python: >=3.13
Description-Content-Type: text/markdown
Requires-Dist: Django<6,>=5.2
Requires-Dist: djangorestframework<3.17,>=3.16
Requires-Dist: djangorestframework-simplejwt<6,>=5.5
Requires-Dist: channels<5,>=4.2
Requires-Dist: django-filter<26,>=25.1
Requires-Dist: django-simple-history<4,>=3.10
Requires-Dist: drf-simple-api-errors<3,>=2.1
Requires-Dist: requests<3,>=2.32
Requires-Dist: Pillow<13,>=11
Provides-Extra: spectacular
Requires-Dist: drf-spectacular<1,>=0.29; extra == "spectacular"
Provides-Extra: paises
Requires-Dist: django-countries<8,>=7.6; extra == "paises"
Provides-Extra: google
Requires-Dist: google-auth<3,>=2.40; extra == "google"
Provides-Extra: archivos
Requires-Dist: pandas<3,>=2.2; extra == "archivos"
Requires-Dist: numpy<3,>=2; extra == "archivos"
Requires-Dist: openpyxl<4,>=3.1; extra == "archivos"
Requires-Dist: PyYAML<7,>=6; extra == "archivos"
Provides-Extra: reportes
Requires-Dist: acv-django-core[archivos]; extra == "reportes"
Requires-Dist: celery<6,>=5.5; extra == "reportes"
Requires-Dist: django-celery-beat<3,>=2.8; extra == "reportes"
Provides-Extra: dev
Requires-Dist: ruff==0.16.3; extra == "dev"
Requires-Dist: fakeredis<3,>=2.26; extra == "dev"
Requires-Dist: celery<6,>=5.5; extra == "dev"
Requires-Dist: django-celery-beat<3,>=2.8; extra == "dev"

# acv-django-core

Piezas compartidas de los backends Django de ACV: **acv-tms**, **acv-wms** y **acv-suma**.
Es el equivalente de `@acvl/frontend-components` para el backend: lo que los tres repos
tenían copiado a mano, y que con el tiempo se fue separando, vive aquí una sola vez.

Se importa como `acv_core`.

## Qué trae

| Módulo | Qué es | Antes vivía en |
|---|---|---|
| `acv_core.renderers` | `BrowsableAPIRendererWithoutForms`, `PlainTextRenderer` | `core/custom/renderers.py` (tms, wms), `core/shared/renderers.py` (suma) |
| `acv_core.tipos` | `ValueLabel`, `ObjectPermissions`, `ModelPermissions` | `core/shared/classes.py` |
| `acv_core.admin` | `CustomModelAdmin` | `core/custom/admin.py` |
| `acv_core.parsers` | `data_helper`, `parse` | `core/helpers/parsers.py` |
| `acv_core.funciones` | `remove_accents`, `datetime_to_cron` | `core/helpers/functions.py` |
| `acv_core.filters` | `NumberInFilter`, `CharInFilter`, `ConDisponibleFilterSet`, `ConMiasFilterSet` | `core/custom/filters.py` |
| `acv_core.channels_auth` | `JWTAuthMiddleware` para WebSockets | `core/custom/channels_auth.py` |
| `acv_core.actividad` + `acv_core.middleware` | Presencia de usuarios en Redis y `ActividadUsuarioMiddleware` | `core/helpers/actividad.py` + `core/middleware.py` |
| `acv_core.pruebas` | `configurar_pruebas`, `bloquear_red` | `core/settings/testing.py` + `core/bloqueo_de_red.py` |
| `acv_core.pagination` | `CustomPagination` (con `datos_extra` para extender la respuesta), `PaginacionSiSePide`, `NotificationsPagination` | `core/custom/pagination.py` (tms, wms), `core/shared/pagination.py` (suma) |
| `acv_core.exceptions` | `exception_handler` (bajas bloqueadas y errores de listas), `motivo_de_baja_bloqueada`, `enumerar` | `core/custom/exceptions.py` (tms, wms); suma usaba el de la librería directo |
| `acv_core.serializers` | Bases (`DynamicFieldsModelSerializer`, `ReadOnlyModelSerializer`, `WithEditPermissionModelSerializer`, `WriteModelSerializerWithFieldPermissions`, `RestrictedWritableFieldsMixin`), campos (`ChoiceValueLabelField`, `ChoiceLabelField`, `CountryValueLabelField`, `CommaSeparatedSlugField`, `DireccionField`), `ComoOpcionesSerializer`, `BusquedaSerializer`, `EstadoSerializer` e historial (`BaseHistorySerializer`, `HistorySerializerWithLabels`) | `core/custom/serializers.py` + `core/shared/serializers.py` (tms, wms), `core/shared/serializers.py` (suma) |
| `acv_core.secciones` | `remover_seccion`, `crear_seccion_con_serializador`, `actualizar_seccion_con_serializador`, `actualizar_seccion_por_campos`, `actualizar_campos_incluidos` | `core/helpers/serializers.py` (tms, wms), `core/utils/serializers.py` (suma) |
| `acv_core.mixins` | `GetSerializerClassMixin`, `GetQuerysetMixin`, `ConReferenciasEnLaLectura`, `RespuestaDetalladaUpdateMixin` | `core/custom/mixins.py` (tms, wms), `core/shared/mixins.py` (suma) |
| `acv_core.viewsets` | `ConComoOpciones`, `ConContador`, `Historial` | `core/custom/viewsets.py` (tms, wms), `core/shared/viewsets.py` (suma) |
| `acv_core.modelos` | `ConActivo`, `ConActivoQuerySet`, `ComoOpciones`, `ConFrontendURL`, `IsRefed` (con `anotar_referencias`), `ClonableAOtroModelo` | `core/shared/models.py` |
| `acv_core.campos` | `CompressedImageField` (con `miniatura_field` opcional), `CompressedFileField`, `comprimir_imagen`, `comprimir_con_miniatura` | `core/shared/fields.py` |
| `acv_core.vistas` | `armar_vista`, `instanciar_sin_request`, `ruta_de`, `vistas_del_urlconf` | `core/helpers/vistas.py` |
| `acv_core.archivos` | El intercambio de archivos con los listados (extra `archivos`), ver abajo | `core/custom/{importacion,plantilla,csv_export,xlsx_export}.py`, `core/custom/viewsets.py`, `core/custom/mixins.py`, `general/` (tms, wms) |
| `acv_core.notificaciones` | La campana, el push de Expo y el correo, ver abajo | `general/` (tms), `notificaciones/` (wms) |
| `acv_core.sistema` | El tablero de administración (`tablero.TableroView`) y sus acciones (`acciones`: tareas periódicas, mantenimiento, silencio de avisos) | `configuracion/views/{dashboard,tareas,servicios,silencio,sistema}.py` |
| `acv_core.permisos` | `EsSuperUsuario`, `TienePermiso` | `core/custom/permissions.py` (tms), `configuracion/views/dashboard.py` (wms, suma) |
| `acv_core.proyecto` | `prefijo()`: el `ACV_PREFIJO` del proyecto | — |
| `acv_core.reportes` | El constructor de reportes: listados guardados en pantalla, a archivo y por correo programado (extras `archivos` y `reportes`), ver abajo | `reportes/` (wms) |
| `acv_core.tipo_de_cambio` | El tipo de cambio del DOF desde Banxico: la base `TipoDeCambioBase`, el cliente y la conciliación (`banxico`), la tarea (`trabajos`), la vista (`paginas.ConTipoDeCambio`) y el comando (`comando`) | `configuracion/models/monedas.py`, `core/helpers/banxico.py`, `configuracion/tasks.py` (wms) |
| `acv_core.autenticacion` | El acceso: `views` (login, Google, refresco, salir, cambio y restablecimiento de contraseña), `tokens.TokenDelUsuarioSerializer`, `permisos.get_frontend_permissions`, `sesiones.cerrar_sesiones` y la base `modelos.CodigoDeRestablecimientoBase` | `authentication/` (tms, wms), `configuracion/views/auth.py` (suma) |

`acv_core.archivos` se reparte así:

| Módulo | Qué es |
|---|---|
| `modelos` | Las bases abstractas `DocumentBase`, `PreferenciaDeExportacionBase`, `ExportacionCSVBase`, `ImportacionCSVBase`, `FilaDeImportacionBase` y `ConArchivoOrigen`; `modelo_de` encuentra los concretos |
| `viewsets` | `ConPlantilla`, `ConArchivos` (validar y subir) y `ExportarCSV`, que cada recurso suma por herencia |
| `mixins` | `GetCSVMixin`: el contrato `csv` de cada vista |
| `serializers` | `ValidarArchivoSerializer`, `SubirArchivoSerializer`, `ConLlaveNatural` |
| `importacion`, `plantilla` | El motor de importación y la plantilla xlsx |
| `exportacion_csv`, `exportacion_xlsx`, `entrega` | Los escritores y cómo se entrega un archivo guardado |
| `paginas` | Filtros, serializadores y vistas de Documentos, Exportaciones, Importaciones y Preferencias |
| `trabajos` | Lo que corren las tareas de Celery, sin el `@shared_task` |
| `comando` | La base de `manage.py importar_csv` |
| `progreso`, `exportables` | El avance por el socket de notificaciones y las columnas exportables por recurso |

`acv_core.notificaciones`:

| Módulo | Qué es |
|---|---|
| `modelos` | Las bases `NotificacionBase`, `DispositivoBase` y `SilencioDeNotificacionesBase`; `esta_silenciado`, `avisar` y `grupo_del_usuario` |
| `push` | El envío a Expo, en lotes y con baja de tokens muertos |
| `correos` | `send_template_email`, `send_html_email` y el desvío a `EMAIL_DE_PRUEBAS` |
| `consumers`, `senales` | El socket de la campana y la señal que empuja cada aviso nuevo |
| `paginas` | Las vistas de la campana y de los dispositivos, con `registrar` y `dar_de_baja` para la app |
| `trabajos`, `comando` | Lo que corren sus tareas, y la base de `manage.py correo_de_prueba` |

`acv_core.reportes`:

| Módulo | Qué es |
|---|---|
| `modelos` | Las bases `ReporteBase` y `ProgramacionDeReporteBase`; `modelo_de` encuentra los concretos |
| `fuentes` | Qué vistas se ofrecen (`fuente_de_reporte`) y qué columnas, filtros especiales y órdenes traen |
| `condiciones`, `agrupacion`, `parametros` | Campo + operador + valor con su lógica, los resúmenes y los periodos relativos |
| `ejecucion` | `preparar` y `validar_definicion`, por tipo de reporte (los motores) |
| `programacion`, `senales` | La `PeriodicTask` de cada envío y las señales que la siguen |
| `trabajos` | El archivo en segundo plano y el envío programado, sin el `@shared_task` |
| `serializers`, `paginas` | Las bases de los serializadores, el filtro y las vistas |

`ProjectBaseViewset` sigue en cada proyecto, porque cada uno lo arma con mixins distintos, pero ya se
compone sólo con piezas del paquete. Lo que es de un solo proyecto se queda en él: en tms `Moneda`,
las vigencias, `Adjunto` con `ConDescarga` y `ConArchivoSubido`; en wms `EliminarVarios`, el
recorte por agencia o cliente.

## Instalación

En el `requirements.txt` del backend, desde PyPI y fijado a una versión exacta. tms y wms con los
extras; suma sin ellos por ahora:

```
acv-django-core[spectacular,paises,archivos,google]==0.11.0
```

Cada tag `v*` se publica solo en PyPI (`.github/workflows/publish.yml`, con trusted publishing):
la versión del tag tiene que ser la de `pyproject.toml`.

| Extra | Trae | Sin él |
|---|---|---|
| `spectacular` | `drf-spectacular` | Los decoradores del esquema OpenAPI no hacen nada |
| `paises` | `django-countries` | No hay `CountryField` que mapear |
| `archivos` | pandas, numpy, openpyxl, PyYAML | `acv_core.archivos` no se puede importar |
| `google` | `google-auth` | `GoogleLoginView` truena al recibir un login |
| `reportes` | `archivos`, Celery y django-celery-beat | `acv_core.reportes` no se puede importar |

Para desarrollar contra el checkout local (el equivalente de `npm run dev:lib`), monta el
checkout en el contenedor e instálalo en modo editable:

```
pip install -e /ruta/al/checkout/acv-django-core
```

## Settings

| Setting | Obligatorio | Para qué |
|---|---|---|
| `ACV_PREFIJO` | Sí, si se usa `ActividadUsuarioMiddleware` o el tablero del sistema | Prefijo del proyecto en las llaves de Redis (`"tms"`, `"wms"`, `"suma"`). Sin él el proceso no arranca. |
| `ACV_ACTIVIDAD_RUTAS_IGNORADAS` | No | Prefijos de ruta que se suman a los ignorados de fábrica (suma: `("/iclock/",)`). |
| `ACV_APPS_SIN_SUPERUSUARIO` | No | Apps donde ser superusuario no basta para que la ficha ofrezca editar y borrar. Default `("auth", "authentication")`. |
| `ACV_APP_DE_ARCHIVOS` | No | La app con los modelos concretos de archivos. Default `"general"`. |
| `ACV_TAREA_DE_IMPORTACION`, `ACV_TAREA_DE_EXPORTACION` | No | Ruta de la tarea de Celery que procesa una importación o una exportación grande. Default `<app de archivos>.tasks.procesar_importacion_csv` y `.generar_exportacion_csv`. |
| `IMPORT_FILAS_SINCRONAS`, `EXPORT_FILAS_SINCRONAS` | No | Arriba de cuántas hileras el trabajo se va a segundo plano. Default 200 y 50,000. |
| `ACV_APP_DE_NOTIFICACIONES` | No | La app con los modelos concretos de notificaciones. Default `"notificaciones"`; tms pone `"general"`. |
| `ACV_APP_DE_REPORTES` | No | La app con los modelos concretos de reportes. Default `"reportes"`. |
| `ACV_TAREA_DE_ARCHIVO_DE_REPORTE`, `ACV_TAREA_DE_ENVIO_DE_REPORTE` | No | Ruta de las tareas de reportes. Default `<app de reportes>.tasks.generar_archivo_de_reporte` y `.enviar_reporte_programado`. |
| `ACV_ACCIONES_DE_SERVICIO` | No | Qué acciones de mantenimiento deja correr el tablero. Default las tres (`makemigrations`, `migrate`, `flush_cache`); en producción conviene `("migrate", "flush_cache")`. |
| `SIMPLE_JWT["TOKEN_OBTAIN_SERIALIZER"]` | Sí, para el acceso | El serializador del token del proyecto (subclase de `TokenDelUsuarioSerializer`). Lo usan el login, Google y el cambio de contraseña. |
| `ACV_ACCIONES_DE_PERMISO` | No | Las acciones fuera del CRUD que viajan en el claim `permissions` (la lista blanca `ACCIONES_EXTRA` de cada repo). |
| `REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]` | Sí, para el acceso | Los scopes `auth`, `refresco` y, con el restablecimiento, `restablecer_contrasena_solicitud` y `restablecer_contrasena_codigo`. Sin la tasa de un scope que pide una vista, DRF responde 500. |
| `GOOGLE_OAUTH_CLIENT_ID`, `SSO_ALLOWED_EMAIL_DOMAINS` | Para Google | El cliente OAuth y los dominios de correo aceptados. |
| `ACV_APP_DE_AUTENTICACION`, `ACV_TAREA_DE_CODIGO_DE_RESTABLECIMIENTO` | Para el restablecimiento | La app con `CodigoDeRestablecimiento` (default `"authentication"`) y la tarea que manda el código por correo (`.delay(usuario_id, codigo)`). |
| `EXPO_PUSH_URL`, `EXPO_ACCESS_TOKEN` | No | El API de Expo y su token. |
| `EMAIL_DE_PRUEBAS`, `LOGO_DE_CORREOS`, `NOMBRE_DE_REMITENTE`, `DEFAULT_FROM_EMAIL` | No (el último, de Django) | El desvío de correos en desarrollo, el logo de las plantillas y el remitente. |

La configuración por modelo va en su dict `_custom_config`: `subsections_requiring_permission`,
`fields_requiring_permission` y `nested_labels` (ver el docstring de `acv_core.serializers`).

## Uso

```python
# core/settings/base.py
MIDDLEWARE = [..., "acv_core.middleware.ActividadUsuarioMiddleware"]
ACV_PREFIJO = "tms"
REST_FRAMEWORK = {
    "DEFAULT_RENDERER_CLASSES": [
        "rest_framework.renderers.JSONRenderer",
        "acv_core.renderers.BrowsableAPIRendererWithoutForms",
    ],
    "DEFAULT_PAGINATION_CLASS": "acv_core.pagination.CustomPagination",
    "EXCEPTION_HANDLER": "acv_core.exceptions.exception_handler",
}

# core/asgi.py
from acv_core.channels_auth import JWTAuthMiddleware

# core/settings/testing.py
from .base import *  # noqa: F401,F403

from acv_core.pruebas import configurar_pruebas

configurar_pruebas(globals())
```

## Puntos de extensión

Lo que un solo proyecto necesita no vive aquí: se sobrescribe en una subclase.

| Dónde | Método | Quién lo usa |
|---|---|---|
| `CustomPagination` | `datos_extra(view, user)` | wms: `eliminar_varios` |
| `WithHasHistorySerializer` (y sus hijas) | `puede_ver_historial(user)` | Por default se le niega al usuario externo (`es_externo`, el de wms) |
| `RespuestaDetalladaUpdateMixin` | `preparar_lectura_de_lista(queryset)` | Por default anota `is_refed`; un proyecto lo extiende para anotar más |

## Migraciones

Las migraciones de cada proyecto citan los mixins de modelo y los campos por su ruta vieja:
`bases=(core.shared.models.IsRefed, models.Model)`, `core.shared.fields.CompressedImageField(...)`.
Al adoptar el paquete, **`core/shared/models.py` y `core/shared/fields.py` se quedan reexportando**
lo de `acv_core.modelos` y `acv_core.campos`:

```python
# core/shared/fields.py
from acv_core.campos import *  # noqa: F401,F403 -- las migraciones citan esta ruta
```

La ruta vieja resuelve a la misma clase, así que el `deconstruct()` de la migración y el del modelo
coinciden y `makemigrations` no genera nada (lo prueba `tests/legado`). Esos módulos no se pueden
borrar mientras exista una migración que los cite, o sea hasta un squash. Las migraciones nuevas
ya escriben la ruta del paquete.

## Archivos: cómo lo adopta un proyecto

1. **Modelos.** En `general/models.py` cada modelo hereda su base, sin `class Meta` propio
   (`DocumentBase` redeclara `Categories` y `category` con las del proyecto; ver el docstring de
   `acv_core.archivos.modelos`). Lo que sólo tiene un proyecto se agrega en su subclase: wms
   conserva así `ExportacionCSV.definicion_de_reporte`. Las funciones `upload_to` y
   `exportacion_upload_to` se quedan importables en `general/models.py` (reexportadas), porque las
   migraciones viejas las citan.
2. **Migración.** `makemigrations general` genera una sola migración por proyecto. En tms trae
   un `AddField` de `ExportacionCSV.formato` (con default `"csv"`), que es el único cambio con SQL;
   lo demás son `AlterField` y `AlterModelOptions` sin SQL (la ruta de `upload_to`, textos de ayuda,
   `verbose_name`). Conviene revisarlo con `sqlmigrate` antes de aplicarlo.
3. **Tareas.** `general/tasks.py` conserva sus `@shared_task` con los mismos nombres, cada uno de
   una línea sobre `acv_core.archivos.trabajos`, más el aviso con su propio `Notificacion` (ver el
   docstring de `trabajos`).
4. **Comando.** `general/management/commands/importar_csv.py` queda en
   `class Command(ImportarCSV): pass`.
5. **Rutas.** Las vistas de `acv_core.archivos.paginas` se registran en el router; las que el
   proyecto amplía (wms les suma `EliminarVarios` y sus permisos de portal; tms acota
   `DocumentoView.categorias` a `("csv_upload",)`) se registran por su subclase.
6. **Socket.** El consumer de notificaciones maneja el tipo `enviar_progreso`, que es por donde
   viaja el avance de una importación (tms y wms ya lo tienen).

## Acceso: cómo lo adopta un proyecto

El modelo de usuario no se comparte: todo va contra `get_user_model()`, y lo que un usuario pueda
no tener se pregunta (`cambiar_contrasena`, `sesiones_cerradas_en`).

1. **Token.** El `MyTokenObtainPairSerializer` del proyecto hereda `TokenDelUsuarioSerializer` y
   deja en `claims_extra` sólo lo suyo (tms: `operador_id`, `guardia_yardas`; wms: `iniciales`,
   `es_externo`, `agencias`, `cliente`, `idioma`, `csr`...). `SIMPLE_JWT["TOKEN_OBTAIN_SERIALIZER"]`
   lo nombra.
2. **Permisos.** `ACV_ACCIONES_DE_PERMISO` lleva la lista `ACCIONES_EXTRA` que hoy vive en
   `authentication/permisos.py` (tms) o `configuracion/serializers/usuario.py` (wms).
3. **Vistas.** Se montan en las URLs que ya tiene cada uno (`token/`, `token/refresh/`, `google/`...),
   así que los frontends y las apps no cambian. Un proyecto que guarda el cambio obligatorio en
   otro lado sobrescribe `LoginView.debe_cambiar_contrasena` (suma: `user.extra_info`).
4. **Restablecimiento**, opcional: el modelo `CodigoDeRestablecimiento(CodigoDeRestablecimientoBase)`
   en su app de autenticación, la tarea que manda el correo con su plantilla y sus dos scopes.

## Tablero del sistema: cómo lo adopta un proyecto

Las vistas se montan en las URLs que cada proyecto ya tiene (`configuracion/dashboard/`,
`tareas/toggle/`...), así que el frontend no cambia. Lo propio va en una subclase de `TableroView`:
tms le suma Samsara (`servicios_extra`) y sus interruptores (`paneles_extra`). El panel de
`notificaciones` sale solo cuando el proyecto declara `SilencioDeNotificaciones`. El ping a Celery
usa la app en curso (`celery.current_app`), no el `core.celery` de cada repo.

## Notificaciones: cómo lo adopta un proyecto

1. **Modelos.** En su app de notificaciones, `Notificacion(NotificacionBase)` y
   `Dispositivo(DispositivoBase)`, sin `class Meta`. `SilencioDeNotificaciones` es opcional: sin él
   ningún canal se silencia. wms lo declara **con su `history = HistoricalRecords()`**, que la base
   no trae; sin esa línea la migración le borraría la tabla de historial.
2. **Migración.** Sin SQL en tms; en wms sólo `AlterField`/`AlterModelOptions` (las etiquetas de
   `Notificacion.Tipo` con acentos, `verbose_name`). Si tms adopta el silencio, esa sí es una tabla
   nueva.
3. **Señal y socket.** El `ready()` de la app llama `conectar_senales()` (en lugar del `@receiver`),
   y `asgi.py` monta `rutas_de_websocket` detrás de `JWTAuthMiddleware`.
4. **Tareas.** `enviar_push` y `limpiar_notificaciones_antiguas` se quedan en `tasks.py` con su
   nombre, de una línea cada una sobre `acv_core.notificaciones.trabajos`.
5. **Rutas.** `NotificacionView` y `DispositivoView` en el router; wms le suma a la segunda sus
   permisos internos y `EliminarVarios` en una subclase.
6. **Correos.** Las plantillas siguen en `templates/correos/` del proyecto. `correo_de_prueba` es
   una subclase de `CorreoDePrueba` con los `EJEMPLOS` de esas plantillas.

## Reportes: cómo lo adopta un proyecto

1. **Modelos.** En la app `reportes`, `Reporte(ReporteBase)` declara `compartido_con` (wms hacia
   `configuracion.ProxyGroup`, tms hacia `auth.Group`) y su `historial`;
   `ProgramacionDeReporte(ProgramacionDeReporteBase)` su `historial`. Sin `class Meta` propio. Lo
   que el catálogo no deduce va en atributos del concreto (`SELECTORES_POR_NOMBRE`,
   `LLAVES_POR_NOMBRE`), igual que la ficha del aviso (`RUTA_DEL_FRONTEND`). El `ExportacionCSV`
   del proyecto declara `definicion_de_reporte = models.JSONField(null=True, blank=True)`.
2. **Migración.** En wms ninguna: los textos son los de sus tablas. En un proyecto nuevo, la
   inicial de su app.
3. **Señales.** El `ready()` de la app llama `acv_core.reportes.senales.conectar_senales()`.
4. **Tareas.** `generar_archivo_de_reporte` y `enviar_reporte_programado` en `reportes/tasks.py`,
   de una línea cada una sobre `acv_core.reportes.trabajos`; la primera con el
   `completar_exportacion` del proyecto, que es el que avisa en la campana.
5. **Vistas.** `ReporteView(AccionesDeReporte, ProjectBaseViewset)`,
   `ProgramacionDeReporteView(ProgramacionDeReporteViewBase)` con su serializador, y
   `FuenteDeReporteView` tal cual, en el router de la app. Los serializadores y el filtro son
   subclases de las bases con `Meta.model`.
6. **Correo.** La plantilla `correos/reporte_programado` (`.html` y `.txt`).
7. **Fuentes.** Cada vista que se ofrece declara `fuente_de_reporte` (ver `acv_core.reportes.fuentes`).

**Otro tipo de reporte** (el «consulta SQL» de tms): el concreto suma sus campos (`tipo`, `sql`...),
los pone en `definicion()` y registra su motor en `Reporte.MOTORES = {"sql": "reportes.sql.MotorSQL"}`.
El motor tiene `validar(definicion) -> dict` y `preparar(definicion, usuario, hoy) -> Ejecucion`, más
`validar_valores(definicion, valores) -> dict` si el tipo tiene parámetros: una programación los
guarda en `valores` y el envío los pone en `definicion["valores"]`. El motor
entrega sus filas en `FilasFijas` con `vista=None`: la tabla paginada, la descarga, el adjunto del
correo y la exportación en segundo plano no cambian. El serializador del proyecto suma los campos
a `CAMPOS_DE_DEFINICION` y la vista cambia `definicion_serializer_class` para la vista previa.

## Lo que cambia al adoptarlo

Cada repo tenía arreglos que los otros no; al pasar al paquete, cada uno recibe los de los
demás. Lo marcado con ⚠ cambia lo que ve un cliente del API y hay que revisarlo en su frontend.

### tms

- Los settings de pruebas ahora también silencian el logging, apagan el throttling y usan un
  `MEDIA_ROOT` temporal. Para ver los logs al depurar: `configurar_pruebas(globals(),
  silenciar_logs=False)`.
- `WriteModelSerializerWithFieldPermissions` **por fin aplica** `fields_requiring_permission`:
  la copia leía `_custom_config` con `getattr` y nunca restringía. Hoy ningún modelo de tms lo
  declara, así que no cambia nada todavía.
- `ConComoOpciones`: el serializador de opciones se declara en
  `serializer_action_classes["como_opciones"]`, no en `serializer_de_opciones` (14 vistas).
- `CompressedImageField` gana el `miniatura_field` opcional de wms; sin declararlo comprime igual
  que antes.
- El motor de archivos gana lo que wms ya tenía: la exportación a **Excel** (`formato`, con su
  migración), el modo **`ACTUALIZAR`** (`ConLlaveNatural.SOLO_ACTUALIZA`), los errores de
  serializadores anidados con su ruta (`direccion.cp`), las plantillas de variantes y descritas a
  mano, y el borrado de archivos que no se detiene si el storage falla.
- `validar_archivo` publica además `llave_natural` y `modos`, y `subir_archivo` rechaza un modo que
  el recurso no admite.
- Las páginas de Documentos y Exportaciones ganan la acción `enlace` junto a la `descargar` que ya
  usa su frontend.
- Las notificaciones ganan lo que wms ya tenía: el **silencio por canal** (opcional, con tabla
  nueva), adjuntos en `send_template_email` y el comando `correo_de_prueba`.

### wms

- Al quitar el pool de la base de datos en pruebas, el resto de `OPTIONS` se conserva.
- `ConMiasFilterSet` usa `"usuario"` por defecto en vez de `"csr"`; los dos FilterSets de wms que
  lo usan ya declaran el suyo.
- La paginación anuncia `eliminar_varios` desde una subclase que sobrescribe `datos_extra` (y
  `DEFAULT_PAGINATION_CLASS` apunta a ella), porque `EliminarVarios` sigue en wms.
- `WithHasHistorySerializer`: el `es_externo` lo respeta ya el `puede_ver_historial` del paquete.
- ⚠ **El historial baja como lo lee el usuario**: las llaves foráneas por el nombre del registro
  (o `"12 (eliminado)"`) y los choices por su etiqueta, en lugar del id y el código (99
  historiales).
- ⚠ `DireccionField` devuelve la dirección en las llaves de Google Places (`formatted_address`,
  `administrative_area_level_1`, `lat`), que es lo que lee el componente de dirección; la copia de
  wms devolvía las de django-address (`raw`, `state`, `latitude`).
- ⚠ `CommaSeparatedSlugField` exporta con `", "` y por su `slug_field` (antes `","` y `str()`);
  un solo uso, `destinos_no_agrupar`.
- `ChoiceLabelField` también acepta el valor del choice, y `ReadOnlyModelSerializer` acepta
  requisitos por campo (`"fields.<codename>"`); los dos sólo aceptan más.
- `IsRefed` gana `RELACIONES_PROPIAS` y la anotación de tms: el update ya regresa el renglón con
  `is_refed` anotado, y `ConReferenciasEnLaLectura` queda disponible para sus listados (que hoy
  pagan un `EXISTS` por relación y renglón). `clonar_a` acepta `solo=`.
- **La purga de documentos sólo barre los CSV del import** (`old_files`): los de categoría
  `transfer` y `odc` dejan de borrarse a los 7 días. No hay código que los cree hoy.
- ⚠ **El import lee como texto toda columna que el serializador no tipa** como número, fecha o
  booleano, en vez de dejar que pandas adivine: un código `0400` ya no llega como `400` aunque la
  vista no lo haya nombrado en `text_columns`.
- La página de Importaciones cuenta el resumen de cada renglón en el mismo query del listado
  (`con_conteos`), y las de Documentos y Exportaciones ganan `descargar` (con el nombre del archivo
  dentro de la URL firmada de GCS) junto al `enlace` que ya usa su frontend.
- `PreferenciaDeExportacion.administra_compartidas` es método de clase y arma el permiso con la
  etiqueta de la app, en vez de `general.` escrito a mano.
- `DispositivoView` gana `registrar` y `dar_de_baja`, las acciones con que la app de tms registra
  su teléfono; la app de wms puede seguir usando `UsuarioView.dispositivo`.

### wms y suma

- ⚠ Los errores de validación de listas cambian de forma: con `many=True` salen por campo y
  posición (`lecturas.1.ts`), y un `ListField` inválido responde 400 en lugar de 500.
- "Todos" (`page_size=-1`) sobre algo paginado que no es queryset ya no revienta.

### suma

- Gana los settings de pruebas, el bloqueo de red y el manejador de excepciones (una baja
  bloqueada por `PROTECT` responde 400 con el motivo en vez de 500).
- Sus 2 modelos con `MyMeta` pasan esa configuración a `_custom_config`.
- ⚠ **`is_refed` revisa todas las relaciones**: la copia de suma contestaba con la primera relación
  inversa y no miraba las demás, así que un registro referenciado sólo por la segunda se ofrecía
  como borrable. Ahora la ficha deja de ofrecer el borrado en esos casos, que es lo correcto.
- `ComoOpciones` y `ConFrontendURL` dejan de ser modelos abstractos y pasan a mixins, como en tms
  y wms: `AccesoEmpleado(ComoOpciones)`, que no tiene otra base, necesita agregar `models.Model`.
  Ninguna de las dos declara campos, así que no hay migración.
- Para conservar su regla de superusuario, declara sus apps en `ACV_APPS_SIN_SUPERUSUARIO` (su
  copia excluía con la regex `configuracion$|auth`: `auth` y sus tres apps `*configuracion`).
- `como_opciones` se ordena (`ORDEN_DE_OPCIONES`, `Meta.ordering` o `pk`), pero **no** se corta:
  su frontend carga el selector completo y filtra en el navegador, así que su `ConComoOpciones`
  declara `tope_de_opciones = None`.
- Su código invalida el caché con `delete_pattern` (de django-redis); el caché de la suite
  (`acv_core.pruebas.cache.CacheEnMemoria`) lo trae.
- ⚠ `BusquedaSerializer.modelo` es el `verbose_name` del modelo, no el nombre de la clase.
- ⚠ Las fichas agregan `actividad` (`{value, label}` de `activo`/`is_active`).
- `actualizar_seccion_con_serializador` guarda cada renglón con el `update()` de su serializador,
  como tms y wms; la copia de suma asignaba campos con `actualizar_campos_incluidos` y saltaba los
  renglones que sólo traían `id`.

### Los tres

- `datetime_to_cron` toma la zona de `TIME_ZONE` (los tres usan `America/Los_Angeles`).
- `remove_accents` acepta las cadenas diferidas de Django (un `verbose_name_plural` por default),
  que antes tronaban con `TypeError`.

- ⚠ **Las tres dejan correr `makemigrations` desde el tablero**, también en producción: escribe
  archivos de migración dentro del contenedor, fuera del repo, y un `migrate` posterior aplicaría
  un esquema que no está en git. El paquete lo conserva para no cambiar nada al adoptarlo, pero
  `ACV_ACCIONES_DE_SERVICIO = ("migrate", "flush_cache")` en `production.py` lo quita.
- El tablero de wms y suma describe también por `codigo` el objeto cambiado en la bitácora (como
  tms), y el de tms gana la protección de wms para correr sin `django_celery_beat`.

### El acceso, por repo

- **tms**: lo suyo pasa al paquete casi tal cual (es la base de la fusión). Gana, opcional, el
  restablecimiento por código de wms: una tabla nueva, su plantilla y sus dos scopes.
- **wms**:
  - ⚠ Antes de adoptarlo hay que **agregar los scopes `auth` y `refresco`** a
    `DEFAULT_THROTTLE_RATES` (tms usa 60/min y 300/min): login, Google y refresco dejan de contar
    contra el `anon` de 10/min, y sin las tasas DRF responde 500.
  - ⚠ Su `SIMPLE_JWT["TOKEN_OBTAIN_SERIALIZER"]` apunta a `TR_api.serializers...`, que no existe.
    Hoy no muerde porque su vista declara el serializador a mano; el paquete lee el setting, así
    que hay que corregirlo a `authentication.serializers.MyTokenObtainPairSerializer`.
  - Gana `token/salir/` (cierre de sesión con blacklist y baja del teléfono) y el cambio de
    contraseña del primer acceso, si monta las vistas.
  - Restablecer la contraseña ahora también apaga los teléfonos de la cuenta, no sólo sus tokens,
    y sólo cuenta los refresh vivos (`cerrar_sesiones` de tms, en lugar de `User.cerrar_sesiones`).
- **suma**: sobrescribe `debe_cambiar_contrasena` para leer `extra_info.cambiar_contrasena` y
  `GoogleLoginView.preparar_usuario` para crear el `extra_info` que falte, y gana el aviso de cuenta
  desactivada y los throttles (con sus scopes). Su serializador de token sobrescribe
  `permisos_del_frontend`: sus permisos anidan las apps `*_configuracion` bajo su app padre
  (`capital_humano.configuracion.empleado.view`) y sólo cuentan los de sus grupos.

El acceso es también donde vivirá la otra mitad del portal único de SSO cuando se construya: la
vista que recibe el boleto firmado del portal y entrega el par de tokens con `par_de_tokens`.

### suma y el tablero

suma usa el tablero tal cual. Sin `SilencioDeNotificaciones` no le enseña el panel de avisos,
igual que antes.

### suma y las notificaciones

suma las adopta con tres ajustes propios:

- `Notificacion.mensaje` se queda `TextField` (la columna que ya tenía): varios avisos pasan de
  cien caracteres. Una subclase puede redeclarar un campo de la base abstracta.
- El push deja `django-expo-notifications`: una migración de datos copia `Device` a `Dispositivo`,
  y el app sigue registrando su teléfono por las rutas de siempre
  (`configuracion/usuarios/{id}/dispositivo|logout/`), que ahora escriben en `Dispositivo`; el app
  no cambia.
- Sus correos siguen saliendo con plantillas del portal de Brevo (`send_brevo_email`), pero
  respetan el silencio (`correos_silenciados`). Pasarlos a `send_template_email` pide rehacer sus
  cuatro diseños en el repo.

### suma y el motor de archivos

suma lo adopta con su `Document` propio: sus reportes generados (categoría `descarga`) no tienen
dueño, así que redeclara `user` con `null=True` y su `upload_to` (el del paquete usa el id del
usuario), y su purga barre además esos reportes a los 7 días. La categoría por omisión es la del
import (`csv_upload`), que es la que crea `validar_archivo` sin decirla.

Al migrar un repo, deja el módulo viejo como reexportación (`from acv_core.filters import *`)
mientras haya código, o migraciones, que lo importen por su ruta anterior.

## Desarrollo

```
python -m venv .venv
.venv/Scripts/pip install -e ".[dev,spectacular,paises,archivos,google]"   # Linux/macOS: .venv/bin/pip

python runtests.py           # pruebas (acepta los argumentos de manage.py test)
ruff format .                # formato
ruff check .                 # lint
```

CI corre las tres cosas en cada PR y en cada push a `main`, sin `spectacular` ni `paises` (como
suma) y con ellos (como tms y wms); las dos variantes con `archivos`, que la suite prueba.

## Versiones

Semver con tags `vX.Y.Z`. Cada proyecto fija el tag en su `requirements.txt`, así que una
versión nueva no llega a nadie hasta que el proyecto sube su tag. Los cambios van en
[CHANGELOG.md](CHANGELOG.md).
