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.
- acv_core/__init__.py +3 -0
- acv_core/_opcionales.py +51 -0
- acv_core/actividad.py +124 -0
- acv_core/admin.py +13 -0
- acv_core/archivos/__init__.py +14 -0
- acv_core/archivos/comando.py +265 -0
- acv_core/archivos/entrega.py +86 -0
- acv_core/archivos/exportables.py +47 -0
- acv_core/archivos/exportacion_csv.py +87 -0
- acv_core/archivos/exportacion_xlsx.py +130 -0
- acv_core/archivos/importacion.py +574 -0
- acv_core/archivos/mixins.py +220 -0
- acv_core/archivos/modelos.py +832 -0
- acv_core/archivos/paginas.py +730 -0
- acv_core/archivos/plantilla.py +343 -0
- acv_core/archivos/progreso.py +60 -0
- acv_core/archivos/serializers.py +166 -0
- acv_core/archivos/trabajos.py +270 -0
- acv_core/archivos/viewsets.py +677 -0
- acv_core/autenticacion/__init__.py +13 -0
- acv_core/autenticacion/modelos.py +100 -0
- acv_core/autenticacion/permisos.py +72 -0
- acv_core/autenticacion/serializers.py +150 -0
- acv_core/autenticacion/sesiones.py +70 -0
- acv_core/autenticacion/tokens.py +55 -0
- acv_core/autenticacion/views.py +342 -0
- acv_core/campos.py +257 -0
- acv_core/channels_auth.py +42 -0
- acv_core/exceptions.py +104 -0
- acv_core/filters.py +86 -0
- acv_core/funciones.py +32 -0
- acv_core/middleware.py +43 -0
- acv_core/mixins.py +165 -0
- acv_core/modelos.py +208 -0
- acv_core/notificaciones/__init__.py +12 -0
- acv_core/notificaciones/comando.py +119 -0
- acv_core/notificaciones/consumers.py +69 -0
- acv_core/notificaciones/correos.py +134 -0
- acv_core/notificaciones/modelos.py +365 -0
- acv_core/notificaciones/paginas.py +277 -0
- acv_core/notificaciones/push.py +138 -0
- acv_core/notificaciones/senales.py +46 -0
- acv_core/notificaciones/trabajos.py +50 -0
- acv_core/pagination.py +103 -0
- acv_core/parsers.py +24 -0
- acv_core/permisos.py +14 -0
- acv_core/proyecto.py +21 -0
- acv_core/pruebas/__init__.py +6 -0
- acv_core/pruebas/red.py +59 -0
- acv_core/pruebas/settings.py +117 -0
- acv_core/renderers.py +35 -0
- acv_core/secciones.py +123 -0
- acv_core/serializers.py +675 -0
- acv_core/sistema/__init__.py +12 -0
- acv_core/sistema/acciones.py +279 -0
- acv_core/sistema/tablero.py +344 -0
- acv_core/tipos.py +15 -0
- acv_core/viewsets.py +122 -0
- acv_core/vistas.py +96 -0
- acv_django_core-0.9.0.dist-info/METADATA +436 -0
- acv_django_core-0.9.0.dist-info/RECORD +63 -0
- acv_django_core-0.9.0.dist-info/WHEEL +5 -0
- acv_django_core-0.9.0.dist-info/top_level.txt +1 -0
acv_core/__init__.py
ADDED
acv_core/_opcionales.py
ADDED
|
@@ -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
|