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.
Files changed (52) hide show
  1. sinpapel/__init__.py +25 -0
  2. sinpapel/apps.py +19 -0
  3. sinpapel/cache.py +169 -0
  4. sinpapel/decorators.py +121 -0
  5. sinpapel/exceptions.py +22 -0
  6. sinpapel/forms.py +186 -0
  7. sinpapel/injection.py +114 -0
  8. sinpapel/json_logic.py +90 -0
  9. sinpapel/management/__init__.py +0 -0
  10. sinpapel/management/commands/__init__.py +0 -0
  11. sinpapel/management/commands/sinpapel_export_flujo.py +62 -0
  12. sinpapel/management/commands/sinpapel_import_flujo.py +100 -0
  13. sinpapel/management/commands/sinpapel_verificar_slas.py +37 -0
  14. sinpapel/migrations/0001_initial.py +484 -0
  15. sinpapel/migrations/0002_condiciontransicion.py +32 -0
  16. sinpapel/migrations/0003_slaconfiguracion.py +33 -0
  17. sinpapel/migrations/0004_historicalinstanciadocumento_porcentaje_and_more.py +24 -0
  18. sinpapel/migrations/0005_historicalinstanciadocumento_archivo_and_more.py +23 -0
  19. sinpapel/migrations/0006_remove_seguimientoworkflow_monto_aprobado.py +17 -0
  20. sinpapel/migrations/__init__.py +0 -0
  21. sinpapel/mixins.py +214 -0
  22. sinpapel/models/__init__.py +42 -0
  23. sinpapel/models/attachments.py +69 -0
  24. sinpapel/models/documents.py +141 -0
  25. sinpapel/models/predicates.py +60 -0
  26. sinpapel/models/signatures.py +106 -0
  27. sinpapel/models/sla.py +55 -0
  28. sinpapel/models/workflow.py +288 -0
  29. sinpapel/py.typed +0 -0
  30. sinpapel/registry.py +102 -0
  31. sinpapel/schemas/__init__.py +20 -0
  32. sinpapel/schemas/flujo_export.py +616 -0
  33. sinpapel/services/__init__.py +16 -0
  34. sinpapel/services/predicate_engine.py +151 -0
  35. sinpapel/services/side_effects.py +81 -0
  36. sinpapel/services/sla_engine.py +147 -0
  37. sinpapel/services/workflow_engine.py +550 -0
  38. sinpapel/signals.py +216 -0
  39. sinpapel/signing/__init__.py +22 -0
  40. sinpapel/signing/backends/__init__.py +10 -0
  41. sinpapel/signing/backends/fake.py +48 -0
  42. sinpapel/signing/backends/fiel.py +267 -0
  43. sinpapel/signing/backends/manual.py +60 -0
  44. sinpapel/signing/dto.py +17 -0
  45. sinpapel/signing/exceptions.py +14 -0
  46. sinpapel/signing/factory.py +42 -0
  47. sinpapel/signing/ports.py +74 -0
  48. sinpapel-0.7.0.dist-info/METADATA +216 -0
  49. sinpapel-0.7.0.dist-info/RECORD +52 -0
  50. sinpapel-0.7.0.dist-info/WHEEL +5 -0
  51. sinpapel-0.7.0.dist-info/licenses/LICENSE +692 -0
  52. 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)