acv-django-core 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. acv_core/__init__.py +3 -0
  2. acv_core/_opcionales.py +51 -0
  3. acv_core/actividad.py +124 -0
  4. acv_core/admin.py +13 -0
  5. acv_core/archivos/__init__.py +14 -0
  6. acv_core/archivos/comando.py +265 -0
  7. acv_core/archivos/entrega.py +86 -0
  8. acv_core/archivos/exportables.py +47 -0
  9. acv_core/archivos/exportacion_csv.py +87 -0
  10. acv_core/archivos/exportacion_xlsx.py +130 -0
  11. acv_core/archivos/importacion.py +574 -0
  12. acv_core/archivos/mixins.py +220 -0
  13. acv_core/archivos/modelos.py +832 -0
  14. acv_core/archivos/paginas.py +730 -0
  15. acv_core/archivos/plantilla.py +343 -0
  16. acv_core/archivos/progreso.py +60 -0
  17. acv_core/archivos/serializers.py +166 -0
  18. acv_core/archivos/trabajos.py +270 -0
  19. acv_core/archivos/viewsets.py +677 -0
  20. acv_core/autenticacion/__init__.py +13 -0
  21. acv_core/autenticacion/modelos.py +100 -0
  22. acv_core/autenticacion/permisos.py +72 -0
  23. acv_core/autenticacion/serializers.py +150 -0
  24. acv_core/autenticacion/sesiones.py +70 -0
  25. acv_core/autenticacion/tokens.py +55 -0
  26. acv_core/autenticacion/views.py +342 -0
  27. acv_core/campos.py +257 -0
  28. acv_core/channels_auth.py +42 -0
  29. acv_core/exceptions.py +104 -0
  30. acv_core/filters.py +86 -0
  31. acv_core/funciones.py +32 -0
  32. acv_core/middleware.py +43 -0
  33. acv_core/mixins.py +165 -0
  34. acv_core/modelos.py +208 -0
  35. acv_core/notificaciones/__init__.py +12 -0
  36. acv_core/notificaciones/comando.py +119 -0
  37. acv_core/notificaciones/consumers.py +69 -0
  38. acv_core/notificaciones/correos.py +134 -0
  39. acv_core/notificaciones/modelos.py +365 -0
  40. acv_core/notificaciones/paginas.py +277 -0
  41. acv_core/notificaciones/push.py +138 -0
  42. acv_core/notificaciones/senales.py +46 -0
  43. acv_core/notificaciones/trabajos.py +50 -0
  44. acv_core/pagination.py +103 -0
  45. acv_core/parsers.py +24 -0
  46. acv_core/permisos.py +14 -0
  47. acv_core/proyecto.py +21 -0
  48. acv_core/pruebas/__init__.py +6 -0
  49. acv_core/pruebas/red.py +59 -0
  50. acv_core/pruebas/settings.py +117 -0
  51. acv_core/renderers.py +35 -0
  52. acv_core/secciones.py +123 -0
  53. acv_core/serializers.py +675 -0
  54. acv_core/sistema/__init__.py +12 -0
  55. acv_core/sistema/acciones.py +279 -0
  56. acv_core/sistema/tablero.py +344 -0
  57. acv_core/tipos.py +15 -0
  58. acv_core/viewsets.py +122 -0
  59. acv_core/vistas.py +96 -0
  60. acv_django_core-0.9.0.dist-info/METADATA +436 -0
  61. acv_django_core-0.9.0.dist-info/RECORD +63 -0
  62. acv_django_core-0.9.0.dist-info/WHEEL +5 -0
  63. acv_django_core-0.9.0.dist-info/top_level.txt +1 -0
acv_core/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Piezas compartidas de los backends Django de ACV (acv-tms, acv-wms, acv-suma)."""
2
+
3
+ __version__ = "0.9.0"
@@ -0,0 +1,51 @@
1
+ """Dependencias que no todos los proyectos instalan (suma no tiene ninguna de las dos).
2
+
3
+ Sin ``drf-spectacular`` los decoradores del esquema OpenAPI no hacen nada; sin
4
+ ``django-countries`` no hay ``CountryField`` que mapear, así que el mixin sobra.
5
+ """
6
+
7
+ try:
8
+ from drf_spectacular.types import OpenApiTypes
9
+ from drf_spectacular.utils import (
10
+ OpenApiParameter,
11
+ extend_schema,
12
+ extend_schema_field,
13
+ inline_serializer,
14
+ )
15
+ except ImportError: # pragma: no cover -- depende de lo instalado
16
+
17
+ class OpenApiTypes:
18
+ OBJECT = STR = BINARY = None
19
+
20
+ class OpenApiParameter:
21
+ QUERY = "query"
22
+
23
+ def __init__(self, *args, **kwargs):
24
+ pass
25
+
26
+ def extend_schema_field(*args, **kwargs):
27
+ return lambda decorado: decorado
28
+
29
+ def extend_schema(*args, **kwargs):
30
+ return lambda decorado: decorado
31
+
32
+ def inline_serializer(*args, **kwargs):
33
+ return None
34
+
35
+
36
+ try:
37
+ from django_countries.serializers import CountryFieldMixin
38
+ except ImportError: # pragma: no cover -- depende de lo instalado
39
+
40
+ class CountryFieldMixin:
41
+ pass
42
+
43
+
44
+ __all__ = [
45
+ "CountryFieldMixin",
46
+ "OpenApiParameter",
47
+ "OpenApiTypes",
48
+ "extend_schema",
49
+ "extend_schema_field",
50
+ "inline_serializer",
51
+ ]
acv_core/actividad.py ADDED
@@ -0,0 +1,124 @@
1
+ """Registro de presencia de usuarios en Redis.
2
+
3
+ El sistema usa JWT sin sesiones, así que no hay ninguna tabla que diga quién está
4
+ usando el portal en este momento: `last_login` solo se actualiza al iniciar sesión y el
5
+ refresh token dura mucho, por lo que alguien puede estar trabajando durante semanas sin
6
+ un `last_login` nuevo.
7
+
8
+ Para resolverlo se guarda un latido por request en un sorted set de Redis
9
+ (`member` = id del usuario, `score` = epoch del último request). Consultar "quién estuvo
10
+ activo en los últimos N minutos" es entonces un `ZRANGEBYSCORE`, sin tocar la base de
11
+ datos y sin migraciones. Como es cache, perder la llave no rompe nada: se vuelve a llenar
12
+ con el siguiente request de cada usuario.
13
+
14
+ Las llaves llevan el prefijo de ``settings.ACV_PREFIJO`` (``"tms"``, ``"wms"``,
15
+ ``"suma"``): `cache.client` no aplica el KEY_PREFIX de Django, así que el prefijo es lo único
16
+ que las separa de las de otro proyecto en el mismo Redis.
17
+ """
18
+
19
+ import logging
20
+ import time
21
+
22
+ from django.core.cache import cache
23
+
24
+ from acv_core.proyecto import prefijo
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # Ventana que se conserva; más allá de esto la respuesta ya no lo reporta como activo.
29
+ RETENCION = 60 * 60 * 24
30
+
31
+
32
+ def llave_actividad():
33
+ return f"{prefijo()}:actividad:usuarios"
34
+
35
+
36
+ def llave_rutas():
37
+ return f"{prefijo()}:actividad:rutas"
38
+
39
+
40
+ def _redis():
41
+ """Cliente crudo de Redis, o `None` si el backend de cache no es Redis.
42
+
43
+ Se accede al cliente directo porque la API de Django no expone sorted sets, que es lo
44
+ que hace barata la consulta por ventana de tiempo.
45
+ """
46
+ try:
47
+ return cache.client.get_client(write=True)
48
+ except (AttributeError, NotImplementedError):
49
+ return None
50
+
51
+
52
+ def registrar_actividad(usuario_id, ruta=None):
53
+ """Marca al usuario como activo *ahora*. Nunca lanza excepción.
54
+
55
+ Corre en cada request autenticado, así que si Redis está caído se ignora el error: la
56
+ presencia es telemetría, no puede tumbar el request que la generó.
57
+ """
58
+ redis = _redis()
59
+ if redis is None:
60
+ return
61
+
62
+ try:
63
+ pipe = redis.pipeline()
64
+ pipe.zadd(llave_actividad(), {str(usuario_id): time.time()})
65
+ if ruta:
66
+ pipe.hset(llave_rutas(), str(usuario_id), ruta)
67
+ pipe.execute()
68
+ except Exception:
69
+ logger.debug("No se pudo registrar la actividad del usuario", exc_info=True)
70
+
71
+
72
+ def usuarios_activos_desde(segundos):
73
+ """Regresa `{usuario_id: epoch de su último request}` dentro de la ventana dada."""
74
+ redis = _redis()
75
+ if redis is None:
76
+ return {}
77
+
78
+ try:
79
+ corte = time.time() - segundos
80
+ pares = redis.zrangebyscore(llave_actividad(), corte, "+inf", withscores=True)
81
+ return {int(miembro): score for miembro, score in pares}
82
+ except Exception:
83
+ logger.warning("No se pudo consultar la actividad de usuarios", exc_info=True)
84
+ return {}
85
+
86
+
87
+ def rutas_actuales(usuario_ids):
88
+ """Última ruta vista por cada usuario, para los ids pedidos."""
89
+ redis = _redis()
90
+ if redis is None or not usuario_ids:
91
+ return {}
92
+
93
+ try:
94
+ ids = [str(uid) for uid in usuario_ids]
95
+ valores = redis.hmget(llave_rutas(), ids)
96
+ return {
97
+ int(uid): valor.decode() if isinstance(valor, bytes) else valor
98
+ for uid, valor in zip(ids, valores, strict=True)
99
+ if valor
100
+ }
101
+ except Exception:
102
+ logger.debug("No se pudieron consultar las rutas actuales", exc_info=True)
103
+ return {}
104
+
105
+
106
+ def purgar_actividad_vieja():
107
+ """Descarta latidos más viejos que `RETENCION`.
108
+
109
+ Se llama al leer el dashboard en lugar de por request: el sorted set crece cuando
110
+ mucho hasta el número de usuarios del sistema, así que no urge, pero evita que las
111
+ rutas guardadas de usuarios que ya no entran se queden para siempre.
112
+ """
113
+ redis = _redis()
114
+ if redis is None:
115
+ return
116
+
117
+ try:
118
+ corte = time.time() - RETENCION
119
+ vencidos = redis.zrangebyscore(llave_actividad(), "-inf", corte)
120
+ if vencidos:
121
+ redis.zremrangebyscore(llave_actividad(), "-inf", corte)
122
+ redis.hdel(llave_rutas(), *vencidos)
123
+ except Exception:
124
+ logger.debug("No se pudo purgar la actividad vieja", exc_info=True)
acv_core/admin.py ADDED
@@ -0,0 +1,13 @@
1
+ from simple_history.admin import SimpleHistoryAdmin
2
+
3
+
4
+ class CustomModelAdmin(SimpleHistoryAdmin):
5
+ """Despliega todos los campos del modelo como columnas del panel de admin"""
6
+
7
+ show_full_result_count = False
8
+
9
+ def __init__(self, model, admin_site):
10
+ fields = [f for f in model._meta.fields if f.name != "id"]
11
+ self.list_display = [f.name for f in fields]
12
+ self.list_select_related = [f.name for f in fields if f.is_relation]
13
+ super().__init__(model, admin_site)
@@ -0,0 +1,14 @@
1
+ """El intercambio de archivos con los listados: importar CSV/xlsx, exportar CSV/xlsx y plantillas.
2
+
3
+ Requiere el extra ``archivos`` (pandas, numpy, openpyxl, PyYAML).
4
+
5
+ **Los modelos concretos viven en cada proyecto**, en su app ``general`` (o la que nombre
6
+ ``ACV_APP_DE_ARCHIVOS``): el paquete trae sus bases abstractas (``acv_core.archivos.modelos``) y el
7
+ proyecto declara ``Document``, ``PreferenciaDeExportacion``, ``ExportacionCSV``, ``ImportacionCSV`` y
8
+ ``FilaDeImportacion`` heredándolas. Así cada proyecto conserva sus tablas y su historia de
9
+ migraciones, y el motor encuentra los modelos por nombre (``modelo_de``).
10
+
11
+ Las tareas de Celery también se quedan en el proyecto, como envolturas de una línea sobre
12
+ ``acv_core.archivos.trabajos``: el nombre de una tarea es la ruta de su módulo, y las filas de
13
+ ``PeriodicTask`` y las tareas en cola lo guardan.
14
+ """
@@ -0,0 +1,265 @@
1
+ """
2
+ ``manage.py importar_csv`` — corre un plan de importación de CSV contra los recursos de la API.
3
+
4
+ Cada proyecto lo publica en su app de archivos con una subclase de una línea, para que aparezca
5
+ en su ``manage.py`` sin volver a ``acv_core`` una app instalada::
6
+
7
+ # general/management/commands/importar_csv.py
8
+ from acv_core.archivos.comando import ImportarCSV
9
+
10
+ class Command(ImportarCSV):
11
+ pass
12
+
13
+ **Es un manejador delgado sobre la API, no un segundo importador.** Resuelve la ruta del recurso,
14
+ rearma su viewset y llama exactamente el mismo motor que ``subir_archivo``: mismas columnas, mismo
15
+ serializador, misma resolución de FK por código o nombre, mismas dos políticas. Una carga masiva
16
+ —sembrar los catálogos de configuración en un ambiente nuevo, por ejemplo— es así la prueba de
17
+ carga del importador que la gente usa todos los días, en vez de un camino aparte que se descubre
18
+ roto el día que se corre.
19
+
20
+ **El orden del plan ES el orden de construcción.** Un ``SlugRelatedField`` exige que la fila
21
+ referida exista —una ruta pide sus locaciones, un operador su transportista—, así que el orden
22
+ tiene que vivir en un archivo versionado y no en el orden en que alguien abra las pestañas.
23
+
24
+ El plan es un YAML::
25
+
26
+ version: 1
27
+ modo: CREAR_O_ACTUALIZAR # por omisión CREAR
28
+ tolerancia: ESTRICTA # por omisión ESTRICTA
29
+ pasos:
30
+ - id: "01"
31
+ recurso: /configuracion/destinos/
32
+ archivo: destinos.csv # relativo al plan
33
+ - id: "02"
34
+ recurso: /configuracion/rutas/
35
+ archivo: rutas.csv
36
+ tolerancia: PARCIAL # cada paso puede pisar las dos políticas
37
+
38
+ Uso:
39
+
40
+ manage.py importar_csv --plan carga/plan.yml --usuario mario --dry-run
41
+ manage.py importar_csv --plan carga/plan.yml --usuario mario
42
+ manage.py importar_csv --plan carga/plan.yml --usuario mario --desde 02
43
+ manage.py importar_csv --plan carga/plan.yml --usuario mario --solo /configuracion/rutas/
44
+ """
45
+
46
+ from pathlib import Path
47
+
48
+ import yaml
49
+ from django.contrib.auth import get_user_model
50
+ from django.core.files.uploadedfile import SimpleUploadedFile
51
+ from django.core.management.base import BaseCommand, CommandError
52
+ from django.db import transaction
53
+ from django.urls import Resolver404, resolve
54
+
55
+ from acv_core.archivos.importacion import (
56
+ csv_de_rechazadas,
57
+ hileras_del_dataframe,
58
+ leer_tabla,
59
+ preparar_importacion,
60
+ procesar_importacion,
61
+ )
62
+ from acv_core.archivos.modelos import ImportacionCSVBase, modelo_de
63
+ from acv_core.vistas import armar_vista, ruta_de
64
+
65
+
66
+ class _Ensayo(Exception):
67
+ """Señal para revertir el trabajo de un ``--dry-run`` después de haberlo medido."""
68
+
69
+
70
+ class ImportarCSV(BaseCommand):
71
+ help = "Corre un plan de importación de CSV contra los recursos de la API."
72
+
73
+ def add_arguments(self, parser):
74
+ parser.add_argument("--plan", required=True, help="Archivo YAML con el plan.")
75
+ parser.add_argument(
76
+ "--usuario",
77
+ required=True,
78
+ help=(
79
+ "Username o correo de quien queda como autor de la carga. Es obligatorio y no "
80
+ "tiene default: una carga atribuida a quien haya quedado primero en la tabla "
81
+ "es peor que una que se niega a correr, porque el historial ya no se puede "
82
+ "corregir después."
83
+ ),
84
+ )
85
+ parser.add_argument("--desde", help="Empieza en el paso con este id (para reanudar).")
86
+ parser.add_argument("--solo", help="Corre únicamente el paso de este recurso.")
87
+ parser.add_argument(
88
+ "--dry-run",
89
+ action="store_true",
90
+ help=(
91
+ "Corre todo y lo revierte. Valida contra la base de verdad —restricciones "
92
+ "incluidas— y deja el CSV de rechazadas junto al archivo de entrada."
93
+ ),
94
+ )
95
+
96
+ # ==============================|| ENTRADA ||============================== #
97
+
98
+ def handle(self, *args, **opciones):
99
+ plan_ruta = Path(opciones["plan"]).resolve()
100
+ if not plan_ruta.exists():
101
+ raise CommandError(f"No existe el plan: {plan_ruta}")
102
+
103
+ plan = yaml.safe_load(plan_ruta.read_text(encoding="utf-8")) or {}
104
+ usuario = self._resolver_usuario(opciones["usuario"])
105
+ pasos = self._pasos_a_correr(plan, opciones)
106
+
107
+ modo_global = plan.get("modo", ImportacionCSVBase.Modos.CREAR)
108
+ tolerancia_global = plan.get("tolerancia", ImportacionCSVBase.Tolerancias.ESTRICTA)
109
+
110
+ if opciones["dry_run"]:
111
+ self.stdout.write(self.style.WARNING("ENSAYO: todo se revierte al terminar.\n"))
112
+
113
+ for paso in pasos:
114
+ self._correr_paso(
115
+ paso,
116
+ base=plan_ruta.parent,
117
+ usuario=usuario,
118
+ modo=paso.get("modo", modo_global),
119
+ tolerancia=paso.get("tolerancia", tolerancia_global),
120
+ ensayo=opciones["dry_run"],
121
+ )
122
+
123
+ # ==============================|| PLAN ||============================== #
124
+
125
+ def _resolver_usuario(self, identificador: str):
126
+ User = get_user_model()
127
+ usuario = (
128
+ User.objects.filter(username=identificador).first()
129
+ or User.objects.filter(email__iexact=identificador).first()
130
+ )
131
+ if usuario is None:
132
+ raise CommandError(f"No hay usuario '{identificador}'.")
133
+
134
+ return usuario
135
+
136
+ def _pasos_a_correr(self, plan: dict, opciones: dict) -> list[dict]:
137
+ pasos = plan.get("pasos") or []
138
+ if not pasos:
139
+ raise CommandError("El plan no tiene pasos.")
140
+
141
+ if opciones["solo"]:
142
+ pasos = [p for p in pasos if p["recurso"] == opciones["solo"]]
143
+ if not pasos:
144
+ raise CommandError(f"El plan no tiene un paso para '{opciones['solo']}'.")
145
+
146
+ if opciones["desde"]:
147
+ ids = [str(p.get("id", "")) for p in pasos]
148
+ if opciones["desde"] not in ids:
149
+ raise CommandError(f"El plan no tiene un paso con id '{opciones['desde']}'.")
150
+ pasos = pasos[ids.index(opciones["desde"]) :]
151
+
152
+ return pasos
153
+
154
+ def _vista_de(self, ruta_api: str, usuario):
155
+ """Resuelve la ruta de la API a su viewset, ya armado con el usuario de la carga.
156
+
157
+ Se llavea por **ruta y no por ``app_label.model``** porque un mismo modelo puede tener
158
+ dos imports con columnas distintas, y el modelo no los distingue. La ruta sí, es estable, y
159
+ de paso vuelve el plan la documentación de qué entró por dónde.
160
+ """
161
+
162
+ try:
163
+ coincidencia = resolve(ruta_api)
164
+ except Resolver404 as error:
165
+ raise CommandError(f"La ruta '{ruta_api}' no resuelve a ningún recurso.") from error
166
+
167
+ clase = getattr(coincidencia.func, "cls", None)
168
+ if clase is None:
169
+ raise CommandError(f"La ruta '{ruta_api}' no es un viewset.")
170
+
171
+ vista = armar_vista(ruta_de(clase), usuario, accion="subir_archivo")
172
+
173
+ if "rename_columns" not in (getattr(vista, "csv", None) or {}):
174
+ raise CommandError(f"El recurso '{ruta_api}' no importa archivos.")
175
+
176
+ return vista
177
+
178
+ # ==============================|| UN PASO ||============================== #
179
+
180
+ def _correr_paso(self, paso, *, base: Path, usuario, modo, tolerancia, ensayo: bool) -> None:
181
+ etiqueta = f"{paso.get('id', '?')} {paso['recurso']}"
182
+ archivo_ruta = (base / paso["archivo"]).resolve()
183
+
184
+ if not archivo_ruta.exists():
185
+ raise CommandError(f"[{etiqueta}] No existe el archivo: {archivo_ruta}")
186
+
187
+ vista = self._vista_de(paso["recurso"], usuario)
188
+
189
+ if modo not in vista.get_csv_modos():
190
+ raise CommandError(
191
+ f"[{etiqueta}] El recurso no se importa en modo {modo}; admite "
192
+ f"{', '.join(vista.get_csv_modos())}. Corrige el modo en el plan."
193
+ )
194
+
195
+ self.stdout.write(f"[{etiqueta}] {archivo_ruta.name} · {modo} · {tolerancia}")
196
+
197
+ importacion = None
198
+ try:
199
+ with transaction.atomic():
200
+ importacion = self._aplicar(vista, usuario, archivo_ruta, modo, tolerancia)
201
+ self._reportar(etiqueta, importacion)
202
+ self._escribir_rechazadas(vista, importacion, archivo_ruta)
203
+
204
+ if ensayo:
205
+ raise _Ensayo
206
+ except _Ensayo:
207
+ self.stdout.write(self.style.WARNING(f"[{etiqueta}] revertido (ensayo).\n"))
208
+ return
209
+
210
+ if importacion.estado == ImportacionCSVBase.Estados.RECHAZADA:
211
+ raise CommandError(
212
+ f"[{etiqueta}] Archivo rechazado y la tolerancia es estricta. "
213
+ "Corrige el CSV de rechazadas y vuelve a correr desde este paso."
214
+ )
215
+
216
+ def _aplicar(self, vista, usuario, archivo_ruta: Path, modo, tolerancia):
217
+ """Mismo camino que ``subir_archivo``, sin la mitad HTTP.
218
+
219
+ Siempre síncrono: aquí no hay un ``timeout`` de gunicorn del que huir, y encolarlo sólo
220
+ haría que el comando terminara antes que el trabajo.
221
+ """
222
+
223
+ contenido = archivo_ruta.read_bytes()
224
+ subido = SimpleUploadedFile(archivo_ruta.name, contenido, content_type="text/csv")
225
+
226
+ hileras = hileras_del_dataframe(leer_tabla(subido, vista))
227
+ if not hileras:
228
+ raise CommandError(f"{archivo_ruta.name} no trae hileras de datos.")
229
+
230
+ subido.seek(0)
231
+ documento = modelo_de("Document")(file=subido, user=usuario)
232
+ documento.set_serialized_rows(hileras)
233
+ documento.save()
234
+
235
+ importacion = preparar_importacion(vista, usuario, documento, modo, tolerancia)
236
+
237
+ return procesar_importacion(importacion, vista=vista)
238
+
239
+ # ==============================|| SALIDA ||============================== #
240
+
241
+ def _reportar(self, etiqueta: str, importacion) -> None:
242
+ resumen = importacion.resumen()
243
+ linea = (
244
+ f"[{etiqueta}] {importacion.get_estado_display()} · "
245
+ f"{resumen['creadas']} creadas, {resumen['actualizadas']} actualizadas, "
246
+ f"{resumen['con_error']} con error, {resumen['omitidas']} omitidas "
247
+ f"de {resumen['total']}"
248
+ )
249
+
250
+ if importacion.estado == ImportacionCSVBase.Estados.COMPLETADA:
251
+ self.stdout.write(self.style.SUCCESS(linea))
252
+ elif importacion.estado == ImportacionCSVBase.Estados.ERROR:
253
+ self.stdout.write(self.style.ERROR(linea))
254
+ else:
255
+ self.stdout.write(self.style.WARNING(linea))
256
+
257
+ def _escribir_rechazadas(self, vista, importacion, archivo_ruta: Path) -> None:
258
+ """Deja el CSV de rechazadas junto al de entrada, listo para corregir y volver a correr."""
259
+
260
+ if not importacion.resumen()["con_error"]:
261
+ return
262
+
263
+ destino = archivo_ruta.with_name(f"{archivo_ruta.stem}.rechazadas.csv")
264
+ destino.write_text("".join(csv_de_rechazadas(importacion, vista=vista)), encoding="utf-8")
265
+ self.stdout.write(f" rechazadas -> {destino}")
@@ -0,0 +1,86 @@
1
+ """Cómo se entrega un archivo guardado, sin que quien lo pide tenga que saber dónde vive.
2
+
3
+ En local el media lo sirve el propio proceso y el archivo sale por un ``FileResponse``; en
4
+ producción vive en Google Cloud Storage, ``url()`` devuelve una URL firmada y se redirige a ella
5
+ en vez de proxear megas por un worker de gunicorn. Las dos ramas ya estaban escritas dos veces
6
+ —en la descarga de un adjunto y en la de una exportación— y copiadas les faltaba lo mismo:
7
+
8
+ **Un redirect no lleva cabeceras propias.** Todo lo que se dijera en el ``FileResponse`` —cómo se
9
+ llama el archivo, si se baja o se abre— se perdía justo en el entorno donde importa: el navegador
10
+ seguía el redirect a GCS y guardaba el blob con el nombre generado del storage
11
+ (``adjunto/13_20260907232827``, sin extensión). Es decir que el arreglo de "lo que se baja siempre
12
+ lleva extensión" funcionaba sólo en local, que es donde no hacía falta.
13
+
14
+ La firma sí puede decirlo: ``generate_signed_url`` acepta ``response_disposition`` y
15
+ ``response_type``, y GCS contesta con esas cabeceras. Van **dentro de la firma** a propósito —no
16
+ son un parámetro que el cliente pueda cambiar—, así que el nombre con el que se baja lo sigue
17
+ decidiendo el servidor.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import unicodedata
23
+ from urllib.parse import quote
24
+
25
+ from django.http import FileResponse, HttpResponseRedirect
26
+
27
+
28
+ def _disposicion(nombre: str, as_attachment: bool) -> str:
29
+ """La cabecera ``Content-Disposition``, con el nombre escrito de las dos maneras (RFC 6266).
30
+
31
+ ``filename`` en ASCII para el cliente que no entienda más, y ``filename*`` en UTF-8 para el
32
+ resto. Hace falta porque los nombres reales de un expediente llevan acentos y eñes, y un
33
+ ``filename="Licencia Federal.pdf"`` con acentos se recibe como bytes que nadie sabe decodificar.
34
+ """
35
+
36
+ tipo = "attachment" if as_attachment else "inline"
37
+
38
+ # Sin comillas dobles: cerrarían el valor de la cabecera antes de tiempo.
39
+ plano = (
40
+ unicodedata.normalize("NFKD", nombre).encode("ascii", "ignore").decode().replace('"', "")
41
+ )
42
+
43
+ return f"{tipo}; filename=\"{plano or 'archivo'}\"; filename*=UTF-8''{quote(nombre)}"
44
+
45
+
46
+ def _url_del_storage(archivo, disposicion: str, content_type: str | None) -> str | None:
47
+ """La URL absoluta con la que el storage sirve el archivo, o ``None`` si no la hay.
48
+
49
+ Lo que decide es la URL y no el backend: en local ``FileSystemStorage`` contesta una ruta
50
+ relativa que nada sirve, y entonces el archivo tiene que salir por el proceso.
51
+ """
52
+
53
+ parametros = {"response_disposition": disposicion}
54
+ if content_type:
55
+ parametros["response_type"] = content_type
56
+
57
+ try:
58
+ url = archivo.storage.url(archivo.name, parameters=parametros)
59
+ except TypeError:
60
+ # El storage de disco no acepta parámetros. Se pregunta igual, porque lo que decide es
61
+ # si la URL es absoluta.
62
+ url = archivo.url
63
+
64
+ return url if url.startswith("http") else None
65
+
66
+
67
+ def entregar_archivo(archivo, *, nombre: str, as_attachment: bool = True, content_type=None):
68
+ """La respuesta con la que se entrega ``archivo``, sirva quien lo sirva.
69
+
70
+ ``nombre`` y ``as_attachment`` valen igual por las dos ramas, que es el punto: quien llama
71
+ dice con qué nombre se baja y si se baja o se abre, y no tiene que saber si eso va a acabar
72
+ en una cabecera o dentro de una firma.
73
+ """
74
+
75
+ disposicion = _disposicion(nombre, as_attachment)
76
+
77
+ url = _url_del_storage(archivo, disposicion, content_type)
78
+ if url is not None:
79
+ return HttpResponseRedirect(url)
80
+
81
+ return FileResponse(
82
+ archivo.open("rb"),
83
+ as_attachment=as_attachment,
84
+ filename=nombre,
85
+ content_type=content_type,
86
+ )
@@ -0,0 +1,47 @@
1
+ """
2
+ Las columnas que se pueden exportar de un recurso (``app_label.modelname``) y una variante.
3
+
4
+ Una preferencia de exportación se llavea por recurso y no por la ruta del listado (ver
5
+ ``PreferenciaDeExportacion``), así que para editar sus columnas fuera del diálogo de exportar —que
6
+ sí conoce su listado— hay que encontrar la vista que exporta ese recurso. Se descubre recorriendo
7
+ el URLconf: no hay lista central que mantener.
8
+
9
+ Si dos vistas exportan el mismo recurso con la misma variante, gana la primera del URLconf.
10
+ """
11
+
12
+ from functools import cache
13
+
14
+ from acv_core.vistas import instanciar_sin_request, vistas_del_urlconf
15
+
16
+
17
+ def _variantes_de(vista) -> set[str]:
18
+ declaradas = (getattr(vista, "csv", None) or {}).get("export") or {}
19
+ return set(declaradas) or {"main"}
20
+
21
+
22
+ @cache
23
+ def _vistas_por_recurso() -> dict[str, list[type]]:
24
+ """``{recurso: [vistas]}`` de las vistas que exportan CSV, en el orden del URLconf."""
25
+
26
+ from acv_core.archivos.viewsets import ExportarCSV
27
+
28
+ encontradas: dict[str, list[type]] = {}
29
+ for vista_cls, _ruta, _nombre in vistas_del_urlconf():
30
+ if not issubclass(vista_cls, ExportarCSV) or getattr(vista_cls, "queryset", None) is None:
31
+ continue
32
+ recurso = instanciar_sin_request(vista_cls).get_csv_export_resource()
33
+ vistas = encontradas.setdefault(recurso, [])
34
+ if vista_cls not in vistas:
35
+ vistas.append(vista_cls)
36
+ return encontradas
37
+
38
+
39
+ def columnas_de(recurso: str, variante: str = "main") -> dict[str, str] | None:
40
+ """``{campo: etiqueta}`` exportable de ``recurso`` en ``variante``, en el orden declarado; o
41
+ ``None`` si ninguna vista lo exporta así."""
42
+
43
+ for vista_cls in _vistas_por_recurso().get(recurso, []):
44
+ vista = instanciar_sin_request(vista_cls)
45
+ if variante in _variantes_de(vista):
46
+ return vista.get_csv_export_allowlist(variant=variante)
47
+ return None