sinpapel 0.7.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.
- sinpapel/__init__.py +25 -0
- sinpapel/apps.py +19 -0
- sinpapel/cache.py +169 -0
- sinpapel/decorators.py +121 -0
- sinpapel/exceptions.py +22 -0
- sinpapel/forms.py +186 -0
- sinpapel/injection.py +114 -0
- sinpapel/json_logic.py +90 -0
- sinpapel/management/__init__.py +0 -0
- sinpapel/management/commands/__init__.py +0 -0
- sinpapel/management/commands/sinpapel_export_flujo.py +62 -0
- sinpapel/management/commands/sinpapel_import_flujo.py +100 -0
- sinpapel/management/commands/sinpapel_verificar_slas.py +37 -0
- sinpapel/migrations/0001_initial.py +484 -0
- sinpapel/migrations/0002_condiciontransicion.py +32 -0
- sinpapel/migrations/0003_slaconfiguracion.py +33 -0
- sinpapel/migrations/0004_historicalinstanciadocumento_porcentaje_and_more.py +24 -0
- sinpapel/migrations/0005_historicalinstanciadocumento_archivo_and_more.py +23 -0
- sinpapel/migrations/0006_remove_seguimientoworkflow_monto_aprobado.py +17 -0
- sinpapel/migrations/__init__.py +0 -0
- sinpapel/mixins.py +214 -0
- sinpapel/models/__init__.py +42 -0
- sinpapel/models/attachments.py +69 -0
- sinpapel/models/documents.py +141 -0
- sinpapel/models/predicates.py +60 -0
- sinpapel/models/signatures.py +106 -0
- sinpapel/models/sla.py +55 -0
- sinpapel/models/workflow.py +288 -0
- sinpapel/py.typed +0 -0
- sinpapel/registry.py +102 -0
- sinpapel/schemas/__init__.py +20 -0
- sinpapel/schemas/flujo_export.py +616 -0
- sinpapel/services/__init__.py +16 -0
- sinpapel/services/predicate_engine.py +151 -0
- sinpapel/services/side_effects.py +81 -0
- sinpapel/services/sla_engine.py +147 -0
- sinpapel/services/workflow_engine.py +550 -0
- sinpapel/signals.py +216 -0
- sinpapel/signing/__init__.py +22 -0
- sinpapel/signing/backends/__init__.py +10 -0
- sinpapel/signing/backends/fake.py +48 -0
- sinpapel/signing/backends/fiel.py +267 -0
- sinpapel/signing/backends/manual.py +60 -0
- sinpapel/signing/dto.py +17 -0
- sinpapel/signing/exceptions.py +14 -0
- sinpapel/signing/factory.py +42 -0
- sinpapel/signing/ports.py +74 -0
- sinpapel-0.7.0.dist-info/METADATA +216 -0
- sinpapel-0.7.0.dist-info/RECORD +52 -0
- sinpapel-0.7.0.dist-info/WHEEL +5 -0
- sinpapel-0.7.0.dist-info/licenses/LICENSE +692 -0
- sinpapel-0.7.0.dist-info/top_level.txt +1 -0
sinpapel/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Sinpapel — Trámites y Workflow reutilizable.
|
|
2
|
+
|
|
3
|
+
WorkflowEngine se importa explícitamente desde sinpapel.services.workflow_engine
|
|
4
|
+
(no re-exportado aquí porque carga sinpapel.models y rompería el import order
|
|
5
|
+
durante Django app loading).
|
|
6
|
+
"""
|
|
7
|
+
from sinpapel.decorators import workflow_enabled
|
|
8
|
+
from sinpapel.exceptions import (
|
|
9
|
+
SinpapelError,
|
|
10
|
+
WorkflowConfigurationError,
|
|
11
|
+
WorkflowDuplicateKeyError,
|
|
12
|
+
)
|
|
13
|
+
from sinpapel.registry import WorkflowConfig, WorkflowRegistry
|
|
14
|
+
|
|
15
|
+
__version__ = "0.7.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"SinpapelError",
|
|
19
|
+
"WorkflowConfigurationError",
|
|
20
|
+
"WorkflowDuplicateKeyError",
|
|
21
|
+
"WorkflowConfig",
|
|
22
|
+
"WorkflowRegistry",
|
|
23
|
+
"workflow_enabled",
|
|
24
|
+
"__version__",
|
|
25
|
+
]
|
sinpapel/apps.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Sinpapel — App config.
|
|
2
|
+
|
|
3
|
+
Trámites/workflow reusable extraído desde creditos (E12). Modelos viven en
|
|
4
|
+
sinpapel.models pero las tablas SQL siguen siendo `creditos_*` durante la
|
|
5
|
+
extracción (S12.2 vía db_table override; rename diferido a parking lot).
|
|
6
|
+
"""
|
|
7
|
+
from django.apps import AppConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SinpapelConfig(AppConfig):
|
|
11
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
12
|
+
name = "sinpapel"
|
|
13
|
+
verbose_name = "Sinpapel — Trámites y Workflow"
|
|
14
|
+
|
|
15
|
+
def ready(self):
|
|
16
|
+
# S13.2: registrar signal handlers para invalidación de cache
|
|
17
|
+
# (post_save/post_delete/m2m_changed sobre Estado/VersionFlujo/
|
|
18
|
+
# ConfigT/RequisitoEstadoDocumento)
|
|
19
|
+
from sinpapel import signals # noqa: F401 side-effect: registers receivers
|
sinpapel/cache.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""sinpapel.cache — helpers para cachear catálogos workflow.
|
|
2
|
+
|
|
3
|
+
S13.1 (E13a): cache layer transparente al motor (WorkflowEngine).
|
|
4
|
+
S13.2 agregará invalidación signal-based (post_save/post_delete).
|
|
5
|
+
|
|
6
|
+
Behavior:
|
|
7
|
+
- Cache miss → DB query → cache populate → return
|
|
8
|
+
- Cache hit → return desde memoria (0 SQL queries)
|
|
9
|
+
- DB miss (lookup no existe) → return None / [] (NO cachea — D1: no negative caching)
|
|
10
|
+
- Cache backend down → degrada a "siempre miss" (raise nunca)
|
|
11
|
+
|
|
12
|
+
Settings:
|
|
13
|
+
- SINPAPEL_CACHE_ALIAS: backend name (default 'default')
|
|
14
|
+
- SINPAPEL_CACHE_TIMEOUT: TTL en segundos (default 3600 = 1h)
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
from sinpapel.cache import (
|
|
18
|
+
get_estado_by_name,
|
|
19
|
+
get_active_version_flujo,
|
|
20
|
+
get_transitions_for,
|
|
21
|
+
get_requisitos_for,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
estado = get_estado_by_name("EN_REVISION")
|
|
25
|
+
flujo = get_active_version_flujo("solicitud")
|
|
26
|
+
transitions = get_transitions_for(flujo.id, estado.id)
|
|
27
|
+
|
|
28
|
+
CAVEAT: Sin S13.2 (signal invalidation), mutaciones admin de Estado/
|
|
29
|
+
VersionFlujo/ConfiguracionTransicion NO invalidan cache automáticamente.
|
|
30
|
+
TTL 1h limita stale data al peor escenario. S13.2 resuelve con signals.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from typing import TYPE_CHECKING
|
|
35
|
+
|
|
36
|
+
from django.conf import settings
|
|
37
|
+
from django.core.cache import caches
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from sinpapel.models import (
|
|
41
|
+
ConfiguracionTransicion,
|
|
42
|
+
Estado,
|
|
43
|
+
RequisitoEstadoDocumento,
|
|
44
|
+
VersionFlujo,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Cache key namespace. Hardcoded (D3) — no configurable; sirve como
|
|
49
|
+
# namespace contra otros cache users del consumer (creditos cache, etc.)
|
|
50
|
+
_KEY_PREFIX = "sinpapel"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
54
|
+
# Settings readers (D9: lazy lookup en cada llamada — override_settings safe)
|
|
55
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _cache_alias() -> str:
|
|
59
|
+
"""Backend cache alias. Configurable via SINPAPEL_CACHE_ALIAS."""
|
|
60
|
+
return getattr(settings, "SINPAPEL_CACHE_ALIAS", "default")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _cache_timeout() -> int:
|
|
64
|
+
"""TTL en segundos. Configurable via SINPAPEL_CACHE_TIMEOUT."""
|
|
65
|
+
return getattr(settings, "SINPAPEL_CACHE_TIMEOUT", 3600)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _cache():
|
|
69
|
+
"""Retorna el cache backend configurado."""
|
|
70
|
+
return caches[_cache_alias()]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
# Helpers (D2: local imports lazy anti-circular con sinpapel.models)
|
|
75
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_estado_by_name(nombre: str) -> "Estado | None":
|
|
79
|
+
"""Get Estado por nombre con cache.
|
|
80
|
+
|
|
81
|
+
Returns None si no existe (defensive — D6 reemplaza DoesNotExist).
|
|
82
|
+
NO cachea None (D1: no negative caching).
|
|
83
|
+
"""
|
|
84
|
+
key = f"{_KEY_PREFIX}:estado:nombre:{nombre}"
|
|
85
|
+
cache = _cache()
|
|
86
|
+
obj = cache.get(key)
|
|
87
|
+
if obj is None:
|
|
88
|
+
from sinpapel.models import Estado
|
|
89
|
+
|
|
90
|
+
obj = Estado.objects.filter(nombre=nombre).first()
|
|
91
|
+
if obj is not None:
|
|
92
|
+
cache.set(key, obj, _cache_timeout())
|
|
93
|
+
return obj
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_active_version_flujo(workflow_key: str) -> "VersionFlujo | None":
|
|
97
|
+
"""Get VersionFlujo activo (activo=True) por nombre/workflow_key con cache."""
|
|
98
|
+
key = f"{_KEY_PREFIX}:flujo:active:{workflow_key}"
|
|
99
|
+
cache = _cache()
|
|
100
|
+
obj = cache.get(key)
|
|
101
|
+
if obj is None:
|
|
102
|
+
from sinpapel.models import VersionFlujo
|
|
103
|
+
|
|
104
|
+
obj = VersionFlujo.objects.filter(activo=True, nombre=workflow_key).first()
|
|
105
|
+
if obj is not None:
|
|
106
|
+
cache.set(key, obj, _cache_timeout())
|
|
107
|
+
return obj
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_transitions_for(
|
|
111
|
+
flujo_id: int,
|
|
112
|
+
estado_origen_id: int,
|
|
113
|
+
) -> "list[ConfiguracionTransicion]":
|
|
114
|
+
"""Get ConfiguracionTransicion list desde estado_origen en un flujo.
|
|
115
|
+
|
|
116
|
+
Lista pre-evaluada con select_related("estado_destino") +
|
|
117
|
+
prefetch_related("grupos_permitidos") para evitar N+1 queries en callers
|
|
118
|
+
(engine, permissions S13.6).
|
|
119
|
+
"""
|
|
120
|
+
key = f"{_KEY_PREFIX}:transitions:{flujo_id}:{estado_origen_id}"
|
|
121
|
+
cache = _cache()
|
|
122
|
+
items = cache.get(key)
|
|
123
|
+
if items is None:
|
|
124
|
+
from sinpapel.models import ConfiguracionTransicion
|
|
125
|
+
|
|
126
|
+
items = list(
|
|
127
|
+
ConfiguracionTransicion.objects.filter(
|
|
128
|
+
flujo_id=flujo_id,
|
|
129
|
+
estado_origen_id=estado_origen_id,
|
|
130
|
+
)
|
|
131
|
+
.select_related("estado_destino")
|
|
132
|
+
.prefetch_related("grupos_permitidos")
|
|
133
|
+
)
|
|
134
|
+
cache.set(key, items, _cache_timeout())
|
|
135
|
+
return items
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get_requisitos_for(estado_id: int) -> "list[RequisitoEstadoDocumento]":
|
|
139
|
+
"""Get RequisitoEstadoDocumento list por estado con select_related."""
|
|
140
|
+
key = f"{_KEY_PREFIX}:requisitos:{estado_id}"
|
|
141
|
+
cache = _cache()
|
|
142
|
+
items = cache.get(key)
|
|
143
|
+
if items is None:
|
|
144
|
+
from sinpapel.models import RequisitoEstadoDocumento
|
|
145
|
+
|
|
146
|
+
items = list(
|
|
147
|
+
RequisitoEstadoDocumento.objects.filter(estado_id=estado_id)
|
|
148
|
+
.select_related("tipo_documento")
|
|
149
|
+
)
|
|
150
|
+
cache.set(key, items, _cache_timeout())
|
|
151
|
+
return items
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
155
|
+
# Test/admin helper
|
|
156
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def clear_all() -> None:
|
|
160
|
+
"""Borra TODO el cache backend (NO solo entries sinpapel:).
|
|
161
|
+
|
|
162
|
+
SOLO usar en tests + management commands. NO en producción —
|
|
163
|
+
invalidación granular llega en S13.2 (signal-based).
|
|
164
|
+
|
|
165
|
+
Razón pattern delete: cache.delete_pattern("sinpapel:*") solo soportado
|
|
166
|
+
por django-redis, no por LocMemCache/Memcached default. cache.clear()
|
|
167
|
+
es portable (D4). Tests-only scope acepta el blast.
|
|
168
|
+
"""
|
|
169
|
+
_cache().clear()
|
sinpapel/decorators.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Sinpapel — @workflow_enabled decorator.
|
|
2
|
+
|
|
3
|
+
Decorator factory que marca un modelo Django como elegible para el motor de
|
|
4
|
+
workflow dinámico (ADR-007). Valida campos requeridos, registra en
|
|
5
|
+
WorkflowRegistry, e inyecta 3 métodos uniformes.
|
|
6
|
+
|
|
7
|
+
Uso:
|
|
8
|
+
@workflow_enabled(state_field="estado", workflow_key="solicitud")
|
|
9
|
+
class Solicitud(Trazable):
|
|
10
|
+
estado = models.ForeignKey(Estado, ...)
|
|
11
|
+
|
|
12
|
+
Tras decorar:
|
|
13
|
+
s = Solicitud.objects.first()
|
|
14
|
+
s.available_transitions(user) # lista de Estado destino válidos
|
|
15
|
+
s.can_transition_to("X", user) # (bool, str | None)
|
|
16
|
+
s.transition("X", user, comentarios="...") # ejecuta transición
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
from django.core.exceptions import FieldDoesNotExist
|
|
24
|
+
|
|
25
|
+
from sinpapel.exceptions import WorkflowConfigurationError
|
|
26
|
+
from sinpapel.injection import (
|
|
27
|
+
available_transitions,
|
|
28
|
+
can_transition_to,
|
|
29
|
+
preview_transition,
|
|
30
|
+
transition,
|
|
31
|
+
)
|
|
32
|
+
from sinpapel.registry import WorkflowConfig, WorkflowRegistry
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from django.db import models
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# S13.4: endpoint_slug debe ser URL-safe (kebab-case lowercase + digits + hyphens)
|
|
39
|
+
_SLUG_PATTERN = re.compile(r"^[a-z0-9-]+$")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def workflow_enabled(
|
|
43
|
+
*,
|
|
44
|
+
state_field: str,
|
|
45
|
+
workflow_key: str,
|
|
46
|
+
version_field: str | None = None,
|
|
47
|
+
expose_endpoints: bool = False,
|
|
48
|
+
endpoint_slug: str | None = None,
|
|
49
|
+
):
|
|
50
|
+
"""Marca un modelo Django como elegible para el motor de workflow dinámico.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
state_field: nombre del FK al modelo sinpapel.Estado (ej. "estado")
|
|
54
|
+
workflow_key: identificador único del workflow (ej. "solicitud",
|
|
55
|
+
"tramite_sep"). Debe ser único en el proyecto.
|
|
56
|
+
version_field: nombre opcional del FK a sinpapel.VersionFlujo. Si None,
|
|
57
|
+
el motor resuelve flujo activo por otra vía (ej. via Producto
|
|
58
|
+
en el caso de Solicitud).
|
|
59
|
+
expose_endpoints: S13.4 — si True, el modelo se incluye en
|
|
60
|
+
`WorkflowRegistry.list_exposed()` y sinpapel-drf auto-genera
|
|
61
|
+
endpoints REST `/sinpapel/api/<slug>/<pk>/{action}/`.
|
|
62
|
+
Default False preserva backward compat.
|
|
63
|
+
endpoint_slug: S13.4 — URL slug para los endpoints generados (kebab-case,
|
|
64
|
+
[a-z0-9-]+). Si None, default = workflow_key + 's'.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
WorkflowConfigurationError: state_field o version_field no existen en el modelo,
|
|
68
|
+
O endpoint_slug no es URL-safe ([a-z0-9-]+)
|
|
69
|
+
WorkflowDuplicateKeyError: workflow_key ya está registrado por otro modelo
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
decorator function que retorna la clase decorada (sin alterar la clase
|
|
73
|
+
más allá de inyectar métodos)
|
|
74
|
+
"""
|
|
75
|
+
# S13.4 (D9): validar endpoint_slug en factory time, antes del decorator wrapper
|
|
76
|
+
if endpoint_slug is not None and not _SLUG_PATTERN.match(endpoint_slug):
|
|
77
|
+
raise WorkflowConfigurationError(
|
|
78
|
+
f"endpoint_slug '{endpoint_slug}' must match [a-z0-9-]+ "
|
|
79
|
+
f"(only lowercase letters, digits, hyphens; URL-safe kebab-case)"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def decorator(model_class: type["models.Model"]) -> type["models.Model"]:
|
|
83
|
+
# 1. Validar campos requeridos
|
|
84
|
+
try:
|
|
85
|
+
model_class._meta.get_field(state_field)
|
|
86
|
+
except FieldDoesNotExist as e:
|
|
87
|
+
raise WorkflowConfigurationError(
|
|
88
|
+
f"Model {model_class.__name__} has no field '{state_field}' "
|
|
89
|
+
f"(required by @workflow_enabled state_field=)"
|
|
90
|
+
) from e
|
|
91
|
+
|
|
92
|
+
if version_field is not None:
|
|
93
|
+
try:
|
|
94
|
+
model_class._meta.get_field(version_field)
|
|
95
|
+
except FieldDoesNotExist as e:
|
|
96
|
+
raise WorkflowConfigurationError(
|
|
97
|
+
f"Model {model_class.__name__} has no field '{version_field}' "
|
|
98
|
+
f"(required by @workflow_enabled version_field=)"
|
|
99
|
+
) from e
|
|
100
|
+
|
|
101
|
+
# 2. Registrar en singleton
|
|
102
|
+
config = WorkflowConfig(
|
|
103
|
+
model=model_class,
|
|
104
|
+
state_field=state_field,
|
|
105
|
+
workflow_key=workflow_key,
|
|
106
|
+
version_field=version_field,
|
|
107
|
+
expose_endpoints=expose_endpoints,
|
|
108
|
+
endpoint_slug=endpoint_slug,
|
|
109
|
+
)
|
|
110
|
+
WorkflowRegistry.register(workflow_key, config)
|
|
111
|
+
|
|
112
|
+
# 3. Inyectar métodos + storage del config en la clase
|
|
113
|
+
model_class._workflow_config = config # type: ignore[attr-defined]
|
|
114
|
+
model_class.available_transitions = available_transitions # type: ignore[attr-defined]
|
|
115
|
+
model_class.can_transition_to = can_transition_to # type: ignore[attr-defined]
|
|
116
|
+
model_class.transition = transition # type: ignore[attr-defined]
|
|
117
|
+
model_class.preview_transition = preview_transition # type: ignore[attr-defined]
|
|
118
|
+
|
|
119
|
+
return model_class
|
|
120
|
+
|
|
121
|
+
return decorator
|
sinpapel/exceptions.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Sinpapel — Exceptions específicas del paquete."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SinpapelError(Exception):
|
|
5
|
+
"""Base exception para errores del paquete sinpapel."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WorkflowConfigurationError(SinpapelError, ValueError):
|
|
9
|
+
"""Raised cuando la configuración de @workflow_enabled es inválida.
|
|
10
|
+
|
|
11
|
+
Casos:
|
|
12
|
+
- state_field no existe en el modelo decorado
|
|
13
|
+
- version_field se especifica pero no existe en el modelo
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WorkflowDuplicateKeyError(SinpapelError, ValueError):
|
|
18
|
+
"""Raised cuando se intenta registrar dos modelos distintos con el mismo workflow_key.
|
|
19
|
+
|
|
20
|
+
Permitido: re-registrar el MISMO modelo con la misma key (idempotente,
|
|
21
|
+
útil cuando Django re-importa módulos en dev auto-reload).
|
|
22
|
+
"""
|
sinpapel/forms.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Sinpapel — Form/Serializer Factory for MetadatosCapturables.
|
|
2
|
+
|
|
3
|
+
Generates Django Forms and DRF Serializers dynamically from
|
|
4
|
+
SCHEMA_METADATOS definitions.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from datetime import date
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
from typing import Any, TypeVar
|
|
11
|
+
|
|
12
|
+
from django import forms
|
|
13
|
+
from django.utils.translation import gettext_lazy as _
|
|
14
|
+
|
|
15
|
+
from sinpapel.mixins import CampoMetadato
|
|
16
|
+
|
|
17
|
+
F = TypeVar("F", bound=forms.Form)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _LazyDRFMap:
|
|
21
|
+
"""Descriptor que carga el mapa DRF bajo demanda."""
|
|
22
|
+
|
|
23
|
+
def __get__(
|
|
24
|
+
self, obj: Any | None, owner: type[Any] | None = None
|
|
25
|
+
) -> dict[type, type[Any]]:
|
|
26
|
+
from rest_framework import serializers
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
str: serializers.CharField,
|
|
30
|
+
int: serializers.IntegerField,
|
|
31
|
+
bool: serializers.BooleanField,
|
|
32
|
+
Decimal: serializers.DecimalField,
|
|
33
|
+
date: serializers.DateField,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MetaFormFactory:
|
|
38
|
+
"""Genera Django Forms / DRF Serializers desde SCHEMA_METADATOS."""
|
|
39
|
+
|
|
40
|
+
_DJANGO_FIELD_MAP: dict[type, type[forms.Field]] = {
|
|
41
|
+
str: forms.CharField,
|
|
42
|
+
int: forms.IntegerField,
|
|
43
|
+
bool: forms.BooleanField,
|
|
44
|
+
Decimal: forms.DecimalField,
|
|
45
|
+
date: forms.DateField,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_DRF_FIELD_MAP: dict[type, type[Any]] = _LazyDRFMap() # type: ignore[assignment]
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def build_form(
|
|
52
|
+
cls,
|
|
53
|
+
schema: list[CampoMetadato],
|
|
54
|
+
name: str | None = None,
|
|
55
|
+
**form_class_kwargs: Any,
|
|
56
|
+
) -> type[F]:
|
|
57
|
+
"""Construye una subclase de django.forms.Form a partir de un schema.
|
|
58
|
+
|
|
59
|
+
Nota sobre ``default``:
|
|
60
|
+
En Django Forms, ``default`` se mapea a ``initial``, lo cual solo
|
|
61
|
+
pre-rellena el widget. No actúa como valor por omisión real
|
|
62
|
+
durante la validación. En DRF, ``default`` sí es un valor por
|
|
63
|
+
omisión verdadero.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
schema: lista de CampoMetadato
|
|
67
|
+
name: nombre opcional para la clase generada
|
|
68
|
+
**form_class_kwargs: kwargs adicionales para la clase Form
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Subclase de forms.Form con los campos definidos
|
|
72
|
+
"""
|
|
73
|
+
field_names = {c.nombre for c in schema}
|
|
74
|
+
overlap = field_names & set(form_class_kwargs)
|
|
75
|
+
if overlap:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"Los siguientes kwargs colisionan con nombres de campo: {overlap}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
attrs: dict[str, forms.Field] = {}
|
|
81
|
+
for campo in schema:
|
|
82
|
+
if campo.choices is not None:
|
|
83
|
+
field_class = forms.ChoiceField
|
|
84
|
+
else:
|
|
85
|
+
try:
|
|
86
|
+
field_class = cls._DJANGO_FIELD_MAP[campo.tipo]
|
|
87
|
+
except KeyError:
|
|
88
|
+
raise ValueError(f"Tipo no soportado: {campo.tipo}") from None
|
|
89
|
+
kwargs = cls._build_field_kwargs(campo, is_django=True)
|
|
90
|
+
attrs[campo.nombre] = field_class(**kwargs)
|
|
91
|
+
|
|
92
|
+
class_name = name or (
|
|
93
|
+
"DynamicMetaForm" if not schema else f"DynamicMetaForm_{schema[0].nombre}"
|
|
94
|
+
)
|
|
95
|
+
return type(class_name, (forms.Form,), {**attrs, **form_class_kwargs})
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def build_serializer(
|
|
99
|
+
cls,
|
|
100
|
+
schema: list[CampoMetadato],
|
|
101
|
+
name: str | None = None,
|
|
102
|
+
**serializer_class_kwargs: Any,
|
|
103
|
+
) -> type[Any]:
|
|
104
|
+
"""Construye una subclase de rest_framework.serializers.Serializer.
|
|
105
|
+
|
|
106
|
+
Requiere que 'djangorestframework' esté instalado.
|
|
107
|
+
|
|
108
|
+
Nota sobre ``default``:
|
|
109
|
+
En DRF, ``default`` actúa como valor por omisión real durante la
|
|
110
|
+
serialización/deserialización. En Django Forms, ``default`` se
|
|
111
|
+
mapea a ``initial`` (solo pre-rellena el widget).
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
schema: lista de CampoMetadato
|
|
115
|
+
name: nombre opcional para la clase generada
|
|
116
|
+
**serializer_class_kwargs: kwargs adicionales para la clase Serializer
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Subclase de serializers.Serializer con los campos definidos
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
ImportError: si djangorestframework no está instalado
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
from rest_framework import serializers
|
|
126
|
+
except ImportError as exc:
|
|
127
|
+
raise ImportError(
|
|
128
|
+
"MetaFormFactory.build_serializer() requiere 'djangorestframework'. "
|
|
129
|
+
"Instálalo con: pip install djangorestframework"
|
|
130
|
+
) from exc
|
|
131
|
+
|
|
132
|
+
field_names = {c.nombre for c in schema}
|
|
133
|
+
overlap = field_names & set(serializer_class_kwargs)
|
|
134
|
+
if overlap:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
f"Los siguientes kwargs colisionan con nombres de campo: {overlap}"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
attrs: dict[str, serializers.Field] = {}
|
|
140
|
+
for campo in schema:
|
|
141
|
+
if campo.choices is not None:
|
|
142
|
+
field_class = serializers.ChoiceField
|
|
143
|
+
else:
|
|
144
|
+
try:
|
|
145
|
+
field_class = cls._DRF_FIELD_MAP[campo.tipo]
|
|
146
|
+
except KeyError:
|
|
147
|
+
raise ValueError(f"Tipo no soportado: {campo.tipo}") from None
|
|
148
|
+
kwargs = cls._build_field_kwargs(campo, is_django=False)
|
|
149
|
+
attrs[campo.nombre] = field_class(**kwargs)
|
|
150
|
+
|
|
151
|
+
class_name = name or (
|
|
152
|
+
"DynamicMetaSerializer"
|
|
153
|
+
if not schema
|
|
154
|
+
else f"DynamicMetaSerializer_{schema[0].nombre}"
|
|
155
|
+
)
|
|
156
|
+
return type(
|
|
157
|
+
class_name,
|
|
158
|
+
(serializers.Serializer,),
|
|
159
|
+
{**attrs, **serializer_class_kwargs},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def _build_field_kwargs(
|
|
164
|
+
cls, campo: CampoMetadato, *, is_django: bool
|
|
165
|
+
) -> dict[str, Any]:
|
|
166
|
+
"""Construye kwargs para un campo Django o DRF desde CampoMetadato."""
|
|
167
|
+
kwargs: dict[str, Any] = {
|
|
168
|
+
"required": campo.requerido,
|
|
169
|
+
"label": campo.etiqueta or campo.nombre.replace("_", " ").title(),
|
|
170
|
+
"help_text": campo.ayuda,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if campo.default is not None:
|
|
174
|
+
if is_django:
|
|
175
|
+
kwargs["initial"] = campo.default
|
|
176
|
+
else:
|
|
177
|
+
kwargs["default"] = campo.default
|
|
178
|
+
|
|
179
|
+
if campo.choices is not None:
|
|
180
|
+
kwargs["choices"] = [(c, c) for c in campo.choices]
|
|
181
|
+
|
|
182
|
+
if campo.tipo is Decimal and campo.choices is None:
|
|
183
|
+
kwargs["max_digits"] = 15
|
|
184
|
+
kwargs["decimal_places"] = 2
|
|
185
|
+
|
|
186
|
+
return kwargs
|
sinpapel/injection.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Sinpapel — Métodos inyectados por @workflow_enabled.
|
|
2
|
+
|
|
3
|
+
Estos métodos se vinculan a la clase decorada via setattr en el decorator.
|
|
4
|
+
Se importan localmente desde decorators.py para evitar circular imports.
|
|
5
|
+
|
|
6
|
+
Métodos:
|
|
7
|
+
available_transitions(self, user) -> list[Estado]
|
|
8
|
+
can_transition_to(self, target_state_name, user) -> tuple[bool, str | None]
|
|
9
|
+
transition(self, target_state_name, user, **kwargs)
|
|
10
|
+
preview_transition(self, target_state_name, user) -> dict
|
|
11
|
+
|
|
12
|
+
Estrategia: `available_transitions` consulta `ConfiguracionTransicion` en DB
|
|
13
|
+
directamente; `can_transition_to`, `transition` y `preview_transition` delegan en
|
|
14
|
+
`sinpapel.services.workflow_engine.WorkflowEngine` (el único motor; `WorkflowService`
|
|
15
|
+
no existe).
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from django.contrib.auth.models import User
|
|
23
|
+
|
|
24
|
+
from sinpapel.models import Estado
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def available_transitions(self, user: "User") -> list["Estado"]:
|
|
28
|
+
"""Retorna lista de Estado destino válidos desde el estado actual.
|
|
29
|
+
|
|
30
|
+
Consulta ConfiguracionTransicion filtrando por estado_origen.
|
|
31
|
+
No filtra por user permissions — eso lo hace can_transition_to.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
user: usuario consultando (no se usa para filtrar en S12.3, reservado
|
|
35
|
+
para S12.4 cuando WorkflowEngine considere flujo activo + grupos)
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
lista de instancias Estado destino válidos. Vacía si no hay estado actual.
|
|
39
|
+
"""
|
|
40
|
+
from sinpapel.models import ConfiguracionTransicion
|
|
41
|
+
|
|
42
|
+
config = type(self)._workflow_config # type: ignore[attr-defined]
|
|
43
|
+
estado_actual = getattr(self, config.state_field, None)
|
|
44
|
+
if estado_actual is None:
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
transiciones = ConfiguracionTransicion.objects.filter(
|
|
48
|
+
estado_origen=estado_actual,
|
|
49
|
+
).select_related("estado_destino")
|
|
50
|
+
return [t.estado_destino for t in transiciones]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def can_transition_to(self, target_state_name: str, user: "User") -> tuple[bool, str | None]:
|
|
54
|
+
"""Valida si la transición a target_state_name está permitida.
|
|
55
|
+
|
|
56
|
+
Delega a sinpapel.services.workflow_engine.WorkflowEngine (S12.4).
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
target_state_name: nombre del Estado destino (ej. "EN_JEFATURA")
|
|
60
|
+
user: usuario que intenta la transición
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
tuple (puede: bool, mensaje: str | None)
|
|
64
|
+
"""
|
|
65
|
+
# Import local: WorkflowEngine carga sinpapel.models, no top-level.
|
|
66
|
+
from sinpapel.services.workflow_engine import WorkflowEngine
|
|
67
|
+
|
|
68
|
+
return WorkflowEngine().puede_cambiar_estado(self, target_state_name, user)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def transition(self, target_state_name: str, user: "User", **kwargs: Any) -> Any:
|
|
72
|
+
"""Ejecuta la transición a target_state_name.
|
|
73
|
+
|
|
74
|
+
Delega a sinpapel.services.workflow_engine.WorkflowEngine (S12.4).
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
target_state_name: nombre del Estado destino
|
|
78
|
+
user: usuario que ejecuta la transición
|
|
79
|
+
**kwargs: parámetros adicionales (comentarios, condiciones,
|
|
80
|
+
ip_address, firma_payload)
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
dict con keys: success, instance_id, estado_anterior, estado_nuevo,
|
|
84
|
+
seguimiento_id, + extra del side_effects dispatch
|
|
85
|
+
"""
|
|
86
|
+
from sinpapel.services.workflow_engine import WorkflowEngine
|
|
87
|
+
|
|
88
|
+
comentarios = kwargs.pop("comentarios", "")
|
|
89
|
+
return WorkflowEngine().cambiar_estado(
|
|
90
|
+
instance=self,
|
|
91
|
+
target_state_name=target_state_name,
|
|
92
|
+
user=user,
|
|
93
|
+
comentarios=comentarios,
|
|
94
|
+
**kwargs,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def preview_transition(self, target_state_name: str, user: "User") -> dict:
|
|
99
|
+
"""Simula la transición a target_state_name sin mutar ni persistir nada.
|
|
100
|
+
|
|
101
|
+
Delega a sinpapel.services.workflow_engine.WorkflowEngine.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
target_state_name: nombre del Estado destino
|
|
105
|
+
user: usuario que consulta el preview
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
dict con keys: permitido, razones_bloqueo, side_effects,
|
|
109
|
+
documentos_faltantes, predicados_fallidos, aprobadores_requeridos,
|
|
110
|
+
historial_reciente
|
|
111
|
+
"""
|
|
112
|
+
from sinpapel.services.workflow_engine import WorkflowEngine
|
|
113
|
+
|
|
114
|
+
return WorkflowEngine().preview_transition(self, target_state_name, user)
|