eco-back 0.1.0__tar.gz → 0.2.0__tar.gz

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 (32) hide show
  1. {eco_back-0.1.0/src/eco_back.egg-info → eco_back-0.2.0}/PKG-INFO +14 -6
  2. {eco_back-0.1.0 → eco_back-0.2.0}/README.md +161 -155
  3. {eco_back-0.1.0 → eco_back-0.2.0}/pyproject.toml +3 -1
  4. eco_back-0.2.0/src/eco_back/__init__.py +13 -0
  5. eco_back-0.2.0/src/eco_back/documento/__init__.py +6 -0
  6. eco_back-0.2.0/src/eco_back/documento/anexos.py +481 -0
  7. eco_back-0.2.0/src/eco_back/registro/__init__.py +7 -0
  8. eco_back-0.2.0/src/eco_back/registro/trazabilidad.py +0 -0
  9. {eco_back-0.1.0 → eco_back-0.2.0/src/eco_back.egg-info}/PKG-INFO +14 -6
  10. {eco_back-0.1.0 → eco_back-0.2.0}/src/eco_back.egg-info/SOURCES.txt +5 -0
  11. {eco_back-0.1.0 → eco_back-0.2.0}/src/eco_back.egg-info/requires.txt +2 -0
  12. eco_back-0.2.0/tests/test_anexos.py +277 -0
  13. eco_back-0.1.0/src/eco_back/__init__.py +0 -11
  14. {eco_back-0.1.0 → eco_back-0.2.0}/LICENSE +0 -0
  15. {eco_back-0.1.0 → eco_back-0.2.0}/MANIFEST.in +0 -0
  16. {eco_back-0.1.0 → eco_back-0.2.0}/setup.cfg +0 -0
  17. {eco_back-0.1.0 → eco_back-0.2.0}/src/eco_back/api/__init__.py +0 -0
  18. {eco_back-0.1.0 → eco_back-0.2.0}/src/eco_back/api/client.py +0 -0
  19. {eco_back-0.1.0 → eco_back-0.2.0}/src/eco_back/api/config.py +0 -0
  20. {eco_back-0.1.0 → eco_back-0.2.0}/src/eco_back/api/registro.py +0 -0
  21. {eco_back-0.1.0 → eco_back-0.2.0}/src/eco_back/database/__init__.py +0 -0
  22. {eco_back-0.1.0 → eco_back-0.2.0}/src/eco_back/database/config.py +0 -0
  23. {eco_back-0.1.0 → eco_back-0.2.0}/src/eco_back/database/connection.py +0 -0
  24. {eco_back-0.1.0 → eco_back-0.2.0}/src/eco_back/database/models.py +0 -0
  25. {eco_back-0.1.0 → eco_back-0.2.0}/src/eco_back/database/postgis.py +0 -0
  26. {eco_back-0.1.0 → eco_back-0.2.0}/src/eco_back/database/repository.py +0 -0
  27. {eco_back-0.1.0 → eco_back-0.2.0}/src/eco_back.egg-info/dependency_links.txt +0 -0
  28. {eco_back-0.1.0 → eco_back-0.2.0}/src/eco_back.egg-info/top_level.txt +0 -0
  29. {eco_back-0.1.0 → eco_back-0.2.0}/tests/test_api.py +0 -0
  30. {eco_back-0.1.0 → eco_back-0.2.0}/tests/test_database.py +0 -0
  31. {eco_back-0.1.0 → eco_back-0.2.0}/tests/test_example.py +0 -0
  32. {eco_back-0.1.0 → eco_back-0.2.0}/tests/test_postgis.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eco-back
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Librería Python eco-back
5
5
  Author-email: EI UNP <ecosistema@unp.gov.co>
6
6
  License: MIT
@@ -22,6 +22,8 @@ License-File: LICENSE
22
22
  Requires-Dist: psycopg2-binary>=2.9.0
23
23
  Requires-Dist: geoalchemy2>=0.14.0
24
24
  Requires-Dist: requests>=2.31.0
25
+ Requires-Dist: djangorestframework>=3.14.0
26
+ Requires-Dist: django>=4.0.0
25
27
  Provides-Extra: dev
26
28
  Requires-Dist: pytest>=7.0; extra == "dev"
27
29
  Requires-Dist: pytest-cov>=4.0; extra == "dev"
@@ -34,16 +36,16 @@ Requires-Dist: shapely>=2.0.0; extra == "geo"
34
36
  Requires-Dist: geojson>=3.0.0; extra == "geo"
35
37
  Dynamic: license-file
36
38
 
37
- # eco-back
39
+ # eco-back
38
40
 
39
41
  Librería Python para backend con soporte para PostgreSQL/PostGIS y clientes API
40
42
 
41
43
  ## Características
42
44
 
43
- -**Base de datos**: Conexión y operaciones con PostgreSQL
44
- -**PostGIS**: Operaciones geoespaciales (puntos, polígonos, búsquedas por proximidad)
45
- -**Cliente API**: Cliente HTTP genérico y cliente específico UNP
46
- -**Patrón Repository**: Implementación de patrones de diseño para acceso a datos
45
+ - **Base de datos**: Conexión y operaciones con PostgreSQL
46
+ - **PostGIS**: Operaciones geoespaciales (puntos, polígonos, búsquedas por proximidad)
47
+ - **Cliente API**: Cliente HTTP genérico y cliente específico UNP
48
+ - **Patrón Repository**: Implementación de patrones de diseño para acceso a datos
47
49
 
48
50
  ## Descripción
49
51
 
@@ -51,6 +53,12 @@ eco-back es una librería modular que facilita el desarrollo de aplicaciones bac
51
53
 
52
54
  ## Instalación
53
55
 
56
+ ### Desde PyPI
57
+
58
+ ```bash
59
+ pip install eco-back
60
+ ```
61
+
54
62
  ### Desde el código fuente
55
63
 
56
64
  ```bash
@@ -1,155 +1,161 @@
1
- # eco-back
2
-
3
- Librería Python para backend con soporte para PostgreSQL/PostGIS y clientes API
4
-
5
- ## Características
6
-
7
- -**Base de datos**: Conexión y operaciones con PostgreSQL
8
- -**PostGIS**: Operaciones geoespaciales (puntos, polígonos, búsquedas por proximidad)
9
- -**Cliente API**: Cliente HTTP genérico y cliente específico UNP
10
- -**Patrón Repository**: Implementación de patrones de diseño para acceso a datos
11
-
12
- ## Descripción
13
-
14
- eco-back es una librería modular que facilita el desarrollo de aplicaciones backend, proporcionando abstracciones para bases de datos geoespaciales y consumo de APIs REST.
15
-
16
- ## Instalación
17
-
18
- ### Desde el código fuente
19
-
20
- ```bash
21
- pip install -e .
22
- ```
23
-
24
- ### Para desarrollo
25
-
26
- ```bash
27
- pip install -e ".[dev]"
28
- ```
29
-
30
- ## Uso
31
-
32
- ### Cliente de Consecutivos (UNP)
33
-
34
- ```python
35
- from eco_back.api import Consecutivo
36
-
37
- # Crear cliente
38
- client = Consecutivo(base_url="https://api.unp.example.com")
39
-
40
- # Obtener consecutivo
41
- consecutivo = client.obtener(origen=1)
42
- if consecutivo:
43
- print(f"Consecutivo: {consecutivo}")
44
-
45
- client.close()
46
-
47
- # Forma recomendada: usar context manager
48
- with Consecutivo(base_url="https://api.unp.example.com") as client:
49
- consecutivo = client.obtener(origen=1)
50
- # Usar el consecutivo...
51
- ```
52
-
53
- ### Cliente API Genérico
54
-
55
- ```python
56
- from eco_back.api import APIConfig, APIClient
57
-
58
- config = APIConfig(
59
- base_url="https://api.example.com",
60
- timeout=30,
61
- headers={"Authorization": "Bearer token"}
62
- )
63
-
64
- with APIClient(config) as client:
65
- # GET request
66
- data = client.get("/endpoint")
67
-
68
- # POST request
69
- result = client.post("/endpoint", json={"key": "value"})
70
- ```
71
-
72
- ### Conexión básica a PostgreSQL
73
-
74
- ```python
75
- from eco_back.database import DatabaseConfig, DatabaseConnection
76
-
77
- config = DatabaseConfig(
78
- host="localhost",
79
- port=5432,
80
- database="mi_base_datos",
81
- user="usuario",
82
- password="password"
83
- )
84
-
85
- with DatabaseConnection(config) as db:
86
- resultados = db.execute_query("SELECT * FROM tabla")
87
- for row in resultados:
88
- print(row)
89
- ```
90
-
91
- ### Uso de PostGIS
92
-
93
- ```python
94
- from eco_back.database import DatabaseConfig, DatabaseConnection, PostGISHelper
95
-
96
- config = DatabaseConfig(
97
- host="localhost",
98
- port=5432,
99
- database="mi_db_geo",
100
- user="postgres",
101
- password="password"
102
- )
103
-
104
- with DatabaseConnection(config) as db:
105
- postgis = PostGISHelper(db)
106
-
107
- # Habilitar PostGIS
108
- postgis.enable_postgis()
109
-
110
- # Insertar un punto geográfico
111
- punto_id = postgis.insert_point(
112
- table_name="ubicaciones",
113
- lat=40.4168,
114
- lon=-3.7038,
115
- data={"nombre": "Madrid", "tipo": "ciudad"}
116
- )
117
-
118
- # Buscar puntos cercanos (5km)
119
- cercanos = postgis.find_within_distance(
120
- table_name="ubicaciones",
121
- lat=40.4168,
122
- lon=-3.7038,
123
- distance_meters=5000
124
- )
125
- ```
126
-
127
- ## Desarrollo
128
-
129
- ### Ejecutar tests
130
-
131
- ```bash
132
- pytest
133
- ```
134
-
135
- ### Formatear código
136
-
137
- ```bash
138
- black src/ tests/
139
- ```
140
-
141
- ### Linting
142
-
143
- ```bash
144
- flake8 src/ tests/
145
- ```
146
-
147
- ### Type checking
148
-
149
- ```bash
150
- mypy src/
151
- ```
152
-
153
- ## Licencia
154
-
155
- MIT
1
+ # eco-back
2
+
3
+ Librería Python para backend con soporte para PostgreSQL/PostGIS y clientes API
4
+
5
+ ## Características
6
+
7
+ - **Base de datos**: Conexión y operaciones con PostgreSQL
8
+ - **PostGIS**: Operaciones geoespaciales (puntos, polígonos, búsquedas por proximidad)
9
+ - **Cliente API**: Cliente HTTP genérico y cliente específico UNP
10
+ - **Patrón Repository**: Implementación de patrones de diseño para acceso a datos
11
+
12
+ ## Descripción
13
+
14
+ eco-back es una librería modular que facilita el desarrollo de aplicaciones backend, proporcionando abstracciones para bases de datos geoespaciales y consumo de APIs REST.
15
+
16
+ ## Instalación
17
+
18
+ ### Desde PyPI
19
+
20
+ ```bash
21
+ pip install eco-back
22
+ ```
23
+
24
+ ### Desde el código fuente
25
+
26
+ ```bash
27
+ pip install -e .
28
+ ```
29
+
30
+ ### Para desarrollo
31
+
32
+ ```bash
33
+ pip install -e ".[dev]"
34
+ ```
35
+
36
+ ## Uso
37
+
38
+ ### Cliente de Consecutivos (UNP)
39
+
40
+ ```python
41
+ from eco_back.api import Consecutivo
42
+
43
+ # Crear cliente
44
+ client = Consecutivo(base_url="https://api.unp.example.com")
45
+
46
+ # Obtener consecutivo
47
+ consecutivo = client.obtener(origen=1)
48
+ if consecutivo:
49
+ print(f"Consecutivo: {consecutivo}")
50
+
51
+ client.close()
52
+
53
+ # Forma recomendada: usar context manager
54
+ with Consecutivo(base_url="https://api.unp.example.com") as client:
55
+ consecutivo = client.obtener(origen=1)
56
+ # Usar el consecutivo...
57
+ ```
58
+
59
+ ### Cliente API Genérico
60
+
61
+ ```python
62
+ from eco_back.api import APIConfig, APIClient
63
+
64
+ config = APIConfig(
65
+ base_url="https://api.example.com",
66
+ timeout=30,
67
+ headers={"Authorization": "Bearer token"}
68
+ )
69
+
70
+ with APIClient(config) as client:
71
+ # GET request
72
+ data = client.get("/endpoint")
73
+
74
+ # POST request
75
+ result = client.post("/endpoint", json={"key": "value"})
76
+ ```
77
+
78
+ ### Conexión básica a PostgreSQL
79
+
80
+ ```python
81
+ from eco_back.database import DatabaseConfig, DatabaseConnection
82
+
83
+ config = DatabaseConfig(
84
+ host="localhost",
85
+ port=5432,
86
+ database="mi_base_datos",
87
+ user="usuario",
88
+ password="password"
89
+ )
90
+
91
+ with DatabaseConnection(config) as db:
92
+ resultados = db.execute_query("SELECT * FROM tabla")
93
+ for row in resultados:
94
+ print(row)
95
+ ```
96
+
97
+ ### Uso de PostGIS
98
+
99
+ ```python
100
+ from eco_back.database import DatabaseConfig, DatabaseConnection, PostGISHelper
101
+
102
+ config = DatabaseConfig(
103
+ host="localhost",
104
+ port=5432,
105
+ database="mi_db_geo",
106
+ user="postgres",
107
+ password="password"
108
+ )
109
+
110
+ with DatabaseConnection(config) as db:
111
+ postgis = PostGISHelper(db)
112
+
113
+ # Habilitar PostGIS
114
+ postgis.enable_postgis()
115
+
116
+ # Insertar un punto geográfico
117
+ punto_id = postgis.insert_point(
118
+ table_name="ubicaciones",
119
+ lat=40.4168,
120
+ lon=-3.7038,
121
+ data={"nombre": "Madrid", "tipo": "ciudad"}
122
+ )
123
+
124
+ # Buscar puntos cercanos (5km)
125
+ cercanos = postgis.find_within_distance(
126
+ table_name="ubicaciones",
127
+ lat=40.4168,
128
+ lon=-3.7038,
129
+ distance_meters=5000
130
+ )
131
+ ```
132
+
133
+ ## Desarrollo
134
+
135
+ ### Ejecutar tests
136
+
137
+ ```bash
138
+ pytest
139
+ ```
140
+
141
+ ### Formatear código
142
+
143
+ ```bash
144
+ black src/ tests/
145
+ ```
146
+
147
+ ### Linting
148
+
149
+ ```bash
150
+ flake8 src/ tests/
151
+ ```
152
+
153
+ ### Type checking
154
+
155
+ ```bash
156
+ mypy src/
157
+ ```
158
+
159
+ ## Licencia
160
+
161
+ MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "eco-back"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Librería Python eco-back"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -27,6 +27,8 @@ dependencies = [
27
27
  "psycopg2-binary>=2.9.0",
28
28
  "geoalchemy2>=0.14.0",
29
29
  "requests>=2.31.0",
30
+ "djangorestframework>=3.14.0",
31
+ "django>=4.0.0",
30
32
  ]
31
33
 
32
34
  [project.optional-dependencies]
@@ -0,0 +1,13 @@
1
+ """
2
+ eco-back - Librería Python
3
+ """
4
+
5
+ __version__ = "0.2.0"
6
+
7
+ # Módulos principales
8
+ from . import database
9
+ from . import api
10
+ from . import documento
11
+ from . import registro
12
+
13
+ __all__ = ["database", "api", "documento", "registro", "__version__"]
@@ -0,0 +1,6 @@
1
+ """
2
+ Módulo de gestión de documentos y anexos
3
+ """
4
+ from .anexos import AnexoManager, AnexoConfig
5
+
6
+ __all__ = ['AnexoManager', 'AnexoConfig']
@@ -0,0 +1,481 @@
1
+ """
2
+ Cliente para gestión de anexos y documentos UNP
3
+
4
+ Maneja el proceso de validación, escaneo y envío de documentos
5
+ a los servicios de la API UNP.
6
+ """
7
+ import logging
8
+ import os
9
+ import shutil
10
+ import tempfile
11
+ import threading
12
+ import time
13
+ import uuid
14
+ from dataclasses import dataclass
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+ from typing import Optional, Dict, Any, Callable, BinaryIO, Union
18
+ from io import BytesIO
19
+
20
+ import requests
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ # Constantes
26
+ MAX_SIZE = 10 * 1024 * 1024 # 10MB
27
+ MAX_RETRIES = 3
28
+ TIMEOUT = 30
29
+
30
+ # Extensiones MIME permitidas
31
+ ALLOWED_EXTENSIONS = {
32
+ 'pdf': 'application/pdf',
33
+ 'jpg': 'image/jpeg',
34
+ 'jpeg': 'image/jpeg',
35
+ 'png': 'image/png',
36
+ 'doc': 'application/msword',
37
+ 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
38
+ 'xls': 'application/vnd.ms-excel',
39
+ 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
40
+ }
41
+
42
+
43
+ @dataclass
44
+ class AnexoConfig:
45
+ """
46
+ Configuración para el gestor de anexos
47
+
48
+ Attributes:
49
+ url_base: URL base del API UNP
50
+ max_size: Tamaño máximo de archivo en bytes (default: 10MB)
51
+ max_retries: Número máximo de reintentos (default: 3)
52
+ timeout: Timeout para las peticiones en segundos (default: 30)
53
+ scan_function: Función opcional para escaneo de archivos
54
+ diccionario_registro: Función para mapear tipos de documento de registro
55
+ diccionario_persona: Función para mapear tipos de documento de persona
56
+ diccionario_colectivo: Función para mapear tipos de documento de colectivo
57
+ """
58
+ url_base: str
59
+ max_size: int = MAX_SIZE
60
+ max_retries: int = MAX_RETRIES
61
+ timeout: int = TIMEOUT
62
+ scan_function: Optional[Callable] = None
63
+ diccionario_registro: Optional[Callable] = None
64
+ diccionario_persona: Optional[Callable] = None
65
+ diccionario_colectivo: Optional[Callable] = None
66
+
67
+
68
+ class DocumentoResult:
69
+ """Resultado del procesamiento de un documento"""
70
+
71
+ def __init__(self, status: str, mensaje: str, detalles: Optional[Dict] = None):
72
+ self.status = status
73
+ self.mensaje = mensaje
74
+ self.detalles = detalles or {}
75
+ self.exitoso = status in ['success', 'processing']
76
+
77
+ def to_dict(self) -> Dict[str, Any]:
78
+ """Convierte el resultado a diccionario"""
79
+ return {
80
+ 'status': self.status,
81
+ 'mensaje': self.mensaje,
82
+ 'detalles': self.detalles,
83
+ 'exitoso': self.exitoso
84
+ }
85
+
86
+
87
+ class AnexoManager:
88
+ """
89
+ Gestor de anexos y documentos para la API UNP
90
+
91
+ Maneja el proceso completo de:
92
+ - Validación de archivos
93
+ - Escaneo antivirus (opcional)
94
+ - Envío asíncrono a la API UNP
95
+
96
+ Ejemplo:
97
+ >>> from eco_back.documento import AnexoManager, AnexoConfig
98
+ >>>
99
+ >>> config = AnexoConfig(url_base="https://api.unp.gov.co")
100
+ >>> manager = AnexoManager(config)
101
+ >>>
102
+ >>> # Subir archivo desde ruta
103
+ >>> with open("documento.pdf", "rb") as f:
104
+ ... resultado = manager.guardar_anexo(
105
+ ... archivo=f,
106
+ ... nombre_archivo="documento.pdf",
107
+ ... llave="12345",
108
+ ... grupo="grupo1",
109
+ ... tipo_documento="cedula",
110
+ ... diccionario=1
111
+ ... )
112
+ >>> print(resultado.mensaje)
113
+ """
114
+
115
+ def __init__(self, config: AnexoConfig):
116
+ """
117
+ Inicializa el gestor de anexos
118
+
119
+ Args:
120
+ config: Configuración del gestor
121
+ """
122
+ self.config = config
123
+
124
+ def obtener_extension(self, nombre_archivo: str) -> str:
125
+ """
126
+ Obtiene la extensión del archivo (incluye el punto, ej: '.pdf')
127
+
128
+ Args:
129
+ nombre_archivo: Nombre del archivo
130
+
131
+ Returns:
132
+ Extensión del archivo en minúsculas (con punto)
133
+ """
134
+ return os.path.splitext(nombre_archivo)[1].lower()
135
+
136
+ def validar_extension(self, extension: str) -> bool:
137
+ """
138
+ Valida si la extensión es permitida
139
+
140
+ Args:
141
+ extension: Extensión a validar (con o sin punto)
142
+
143
+ Returns:
144
+ True si es válida, False en caso contrario
145
+ """
146
+ ext = extension.lstrip('.')
147
+ return ext in ALLOWED_EXTENSIONS
148
+
149
+ def obtener_mime_type(self, nombre_archivo: str) -> str:
150
+ """
151
+ Obtiene el tipo MIME basado en la extensión del archivo
152
+
153
+ Args:
154
+ nombre_archivo: Nombre del archivo
155
+
156
+ Returns:
157
+ Tipo MIME correspondiente
158
+ """
159
+ ext = nombre_archivo.split('.')[-1].lower()
160
+ return ALLOWED_EXTENSIONS.get(ext, 'application/octet-stream')
161
+
162
+ def _escanear_archivo(self, ruta_archivo: str, extension: str) -> Dict[str, Any]:
163
+ """
164
+ Escanea el archivo en busca de amenazas
165
+
166
+ Args:
167
+ ruta_archivo: Ruta del archivo a escanear
168
+ extension: Extensión del archivo
169
+
170
+ Returns:
171
+ Diccionario con status y detalles del escaneo
172
+ """
173
+ if self.config.scan_function:
174
+ try:
175
+ return self.config.scan_function(ruta_archivo, extension)
176
+ except Exception as e:
177
+ logger.error(f"Error en escaneo: {e}")
178
+ return {'status': 'ERROR', 'details': str(e)}
179
+
180
+ # Sin función de escaneo, asumir limpio
181
+ return {'status': 'CLEAN', 'details': 'No scan configured'}
182
+
183
+ def guardar_anexo(
184
+ self,
185
+ archivo: Union[BinaryIO, BytesIO, bytes],
186
+ nombre_archivo: str,
187
+ llave: str,
188
+ grupo: str,
189
+ tipo_documento: str,
190
+ diccionario: int,
191
+ asincrono: bool = True
192
+ ) -> DocumentoResult:
193
+ """
194
+ Guarda y envía un anexo a la API UNP
195
+
196
+ Args:
197
+ archivo: Archivo a subir (file-like object o bytes)
198
+ nombre_archivo: Nombre original del archivo
199
+ llave: Identificador único del registro
200
+ grupo: Grupo del documento
201
+ tipo_documento: Tipo de documento a enviar
202
+ diccionario: Tipo de diccionario (1=registro, 2=persona, 3=colectivo)
203
+ asincrono: Si True, envía en hilo separado (default: True)
204
+
205
+ Returns:
206
+ DocumentoResult con el resultado del procesamiento
207
+ """
208
+ temp_dir = tempfile.mkdtemp()
209
+
210
+ try:
211
+ # Validar extensión
212
+ extension = self.obtener_extension(nombre_archivo)
213
+ if not self.validar_extension(extension):
214
+ return DocumentoResult(
215
+ status='error',
216
+ mensaje=f"Extensión {extension} no permitida",
217
+ detalles={'extensiones_permitidas': list(ALLOWED_EXTENSIONS.keys())}
218
+ )
219
+
220
+ # Leer contenido del archivo
221
+ if isinstance(archivo, bytes):
222
+ contenido = archivo
223
+ else:
224
+ contenido = archivo.read()
225
+
226
+ # Validar tamaño
227
+ if len(contenido) > self.config.max_size:
228
+ return DocumentoResult(
229
+ status='error',
230
+ mensaje=f"Archivo excede tamaño máximo de {self.config.max_size / 1024 / 1024}MB",
231
+ detalles={'tamaño': len(contenido), 'max_size': self.config.max_size}
232
+ )
233
+
234
+ # Guardar temporalmente
235
+ with tempfile.NamedTemporaryFile(delete=False, dir=temp_dir, suffix=extension) as tmp:
236
+ tmp.write(contenido)
237
+ temp_path = tmp.name
238
+
239
+ # Escaneo antivirus
240
+ scan_result = self._escanear_archivo(temp_path, extension)
241
+ if scan_result['status'] != "CLEAN":
242
+ os.remove(temp_path)
243
+ shutil.rmtree(temp_dir, ignore_errors=True)
244
+ return DocumentoResult(
245
+ status='blocked',
246
+ mensaje=f"Archivo {nombre_archivo} bloqueado por escaneo",
247
+ detalles=scan_result
248
+ )
249
+
250
+ # Renombrar archivo con timestamp
251
+ ext = extension.lstrip('.') or 'bin'
252
+ timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
253
+ nuevo_nombre = f"{llave}_anexo_{timestamp}.{ext}"
254
+ destino = os.path.join(temp_dir, nuevo_nombre)
255
+ os.replace(temp_path, destino)
256
+
257
+ file_meta = {
258
+ 'field': 'anexo',
259
+ 'path': destino,
260
+ 'original_name': nombre_archivo,
261
+ 'tipo_documento': tipo_documento,
262
+ }
263
+
264
+ # Enviar archivo
265
+ if asincrono:
266
+ threading.Thread(
267
+ target=self._enviar_documento,
268
+ args=(file_meta, llave, grupo, tipo_documento, temp_dir, diccionario)
269
+ ).start()
270
+
271
+ return DocumentoResult(
272
+ status='processing',
273
+ mensaje='Documento en proceso de envío',
274
+ detalles={'archivo': nuevo_nombre}
275
+ )
276
+ else:
277
+ # Envío síncrono
278
+ resultado = self._enviar_documento_sincrono(
279
+ file_meta, llave, grupo, tipo_documento, temp_dir, diccionario
280
+ )
281
+ return resultado
282
+
283
+ except Exception as e:
284
+ logger.error(f"Error en guardar_anexo: {e}")
285
+ shutil.rmtree(temp_dir, ignore_errors=True)
286
+ return DocumentoResult(
287
+ status='error',
288
+ mensaje=str(e),
289
+ detalles={'error_type': type(e).__name__}
290
+ )
291
+
292
+ def _obtener_tipo_documento(self, tipo: str, diccionario: int) -> Optional[str]:
293
+ """
294
+ Obtiene el tipo de documento usando el diccionario correspondiente
295
+
296
+ Args:
297
+ tipo: Tipo de documento original
298
+ diccionario: Tipo de diccionario (1=registro, 2=persona, 3=colectivo)
299
+
300
+ Returns:
301
+ Tipo de documento mapeado o None
302
+ """
303
+ if diccionario == 1 and self.config.diccionario_registro:
304
+ return self.config.diccionario_registro(tipo)
305
+ elif diccionario == 2 and self.config.diccionario_persona:
306
+ return self.config.diccionario_persona(tipo)
307
+ elif diccionario == 3 and self.config.diccionario_colectivo:
308
+ return self.config.diccionario_colectivo(tipo)
309
+
310
+ # Si no hay diccionario, devolver el tipo original
311
+ return tipo
312
+
313
+ def _obtener_endpoint(self, diccionario: int) -> str:
314
+ """
315
+ Obtiene el endpoint según el tipo de diccionario
316
+
317
+ Args:
318
+ diccionario: Tipo de diccionario (1=registro, 2=persona, 3=colectivo)
319
+
320
+ Returns:
321
+ Endpoint correspondiente
322
+ """
323
+ endpoints = {
324
+ 1: 'usuario',
325
+ 2: 'persona',
326
+ 3: 'colectivo'
327
+ }
328
+ return endpoints.get(diccionario, 'usuario')
329
+
330
+ def _enviar_documento(
331
+ self,
332
+ file_meta: Dict[str, str],
333
+ llave: str,
334
+ grupo: str,
335
+ tipo: str,
336
+ temp_dir: str,
337
+ diccionario: int
338
+ ) -> None:
339
+ """
340
+ Envía el documento a la API UNP (versión asíncrona)
341
+
342
+ Args:
343
+ file_meta: Metadatos del archivo
344
+ llave: Identificador del registro
345
+ grupo: Grupo del documento
346
+ tipo: Tipo de documento
347
+ temp_dir: Directorio temporal
348
+ diccionario: Tipo de diccionario
349
+ """
350
+ try:
351
+ resultado = self._enviar_documento_sincrono(
352
+ file_meta, llave, grupo, tipo, temp_dir, diccionario
353
+ )
354
+ if resultado.exitoso:
355
+ logger.info(f"Documento enviado exitosamente: {file_meta['original_name']}")
356
+ else:
357
+ logger.error(f"Error enviando documento: {resultado.mensaje}")
358
+ except Exception as e:
359
+ logger.error(f"Error en envío asíncrono: {e}")
360
+ finally:
361
+ shutil.rmtree(temp_dir, ignore_errors=True)
362
+
363
+ def _enviar_documento_sincrono(
364
+ self,
365
+ file_meta: Dict[str, str],
366
+ llave: str,
367
+ grupo: str,
368
+ tipo: str,
369
+ temp_dir: str,
370
+ diccionario: int
371
+ ) -> DocumentoResult:
372
+ """
373
+ Envía el documento a la API UNP de forma síncrona
374
+
375
+ Args:
376
+ file_meta: Metadatos del archivo
377
+ llave: Identificador del registro
378
+ grupo: Grupo del documento
379
+ tipo: Tipo de documento
380
+ temp_dir: Directorio temporal
381
+ diccionario: Tipo de diccionario
382
+
383
+ Returns:
384
+ DocumentoResult con el resultado del envío
385
+ """
386
+ try:
387
+ if not os.path.exists(file_meta['path']):
388
+ return DocumentoResult(
389
+ status='error',
390
+ mensaje=f"Archivo no encontrado: {file_meta['path']}"
391
+ )
392
+
393
+ # Leer contenido
394
+ with open(file_meta['path'], 'rb') as f:
395
+ content = f.read()
396
+ if len(content) > self.config.max_size:
397
+ return DocumentoResult(
398
+ status='error',
399
+ mensaje=f"Archivo {file_meta['original_name']} excede tamaño permitido"
400
+ )
401
+
402
+ # Determinar MIME
403
+ mime_type = self.obtener_mime_type(file_meta['original_name'])
404
+
405
+ # Preparar archivos para envío
406
+ files = [
407
+ (file_meta['field'], (file_meta['original_name'], content, mime_type))
408
+ ]
409
+
410
+ # Headers
411
+ headers = {
412
+ 'User-Agent': 'EcoBack-DocumentSender/1.0',
413
+ 'X-Request-ID': str(uuid.uuid4())
414
+ }
415
+
416
+ # Obtener endpoint y tipo de documento
417
+ endpoint = self._obtener_endpoint(diccionario)
418
+ tipo_docu = self._obtener_tipo_documento(tipo, diccionario)
419
+
420
+ # URL completa
421
+ url = f"{self.config.url_base}/api-doc/{endpoint}/guardaranexo"
422
+
423
+ # Datos del formulario
424
+ data = {
425
+ 'llave': llave,
426
+ 'grupo': grupo,
427
+ 'tipo_documento': tipo_docu
428
+ }
429
+
430
+ # Envío con reintentos
431
+ for intento in range(self.config.max_retries):
432
+ try:
433
+ response = requests.post(
434
+ url,
435
+ files=files,
436
+ data=data,
437
+ headers=headers,
438
+ timeout=self.config.timeout
439
+ )
440
+
441
+ if response.status_code in [200, 201]:
442
+ logger.info(f"Envío exitoso en intento {intento + 1}")
443
+ return DocumentoResult(
444
+ status='success',
445
+ mensaje='Documento enviado exitosamente',
446
+ detalles={
447
+ 'intentos': intento + 1,
448
+ 'response': response.json() if response.content else {}
449
+ }
450
+ )
451
+ elif 500 <= response.status_code < 600:
452
+ # Error del servidor, reintentar
453
+ logger.warning(f"Error del servidor ({response.status_code}), reintentando...")
454
+ time.sleep(2 ** intento)
455
+ continue
456
+ else:
457
+ # Error del cliente, no reintentar
458
+ return DocumentoResult(
459
+ status='error',
460
+ mensaje=f"Error en envío: {response.status_code}",
461
+ detalles={'response_text': response.text}
462
+ )
463
+
464
+ except requests.RequestException as e:
465
+ logger.error(f"Error de conexión en intento {intento + 1}: {e}")
466
+ if intento == self.config.max_retries - 1:
467
+ return DocumentoResult(
468
+ status='error',
469
+ mensaje='Máximo número de reintentos alcanzado',
470
+ detalles={'error': str(e)}
471
+ )
472
+ time.sleep(2 ** intento)
473
+
474
+ return DocumentoResult(
475
+ status='error',
476
+ mensaje='No se pudo enviar el documento después de todos los reintentos'
477
+ )
478
+
479
+ finally:
480
+ if os.path.exists(temp_dir):
481
+ shutil.rmtree(temp_dir, ignore_errors=True)
@@ -0,0 +1,7 @@
1
+ """
2
+ Módulo de registro y trazabilidad
3
+ """
4
+
5
+ from .trazabilidad import TrazabilidadRegistroPermiso
6
+
7
+ __all__ = ["TrazabilidadRegistroPermiso"]
File without changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eco-back
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Librería Python eco-back
5
5
  Author-email: EI UNP <ecosistema@unp.gov.co>
6
6
  License: MIT
@@ -22,6 +22,8 @@ License-File: LICENSE
22
22
  Requires-Dist: psycopg2-binary>=2.9.0
23
23
  Requires-Dist: geoalchemy2>=0.14.0
24
24
  Requires-Dist: requests>=2.31.0
25
+ Requires-Dist: djangorestframework>=3.14.0
26
+ Requires-Dist: django>=4.0.0
25
27
  Provides-Extra: dev
26
28
  Requires-Dist: pytest>=7.0; extra == "dev"
27
29
  Requires-Dist: pytest-cov>=4.0; extra == "dev"
@@ -34,16 +36,16 @@ Requires-Dist: shapely>=2.0.0; extra == "geo"
34
36
  Requires-Dist: geojson>=3.0.0; extra == "geo"
35
37
  Dynamic: license-file
36
38
 
37
- # eco-back
39
+ # eco-back
38
40
 
39
41
  Librería Python para backend con soporte para PostgreSQL/PostGIS y clientes API
40
42
 
41
43
  ## Características
42
44
 
43
- -**Base de datos**: Conexión y operaciones con PostgreSQL
44
- -**PostGIS**: Operaciones geoespaciales (puntos, polígonos, búsquedas por proximidad)
45
- -**Cliente API**: Cliente HTTP genérico y cliente específico UNP
46
- -**Patrón Repository**: Implementación de patrones de diseño para acceso a datos
45
+ - **Base de datos**: Conexión y operaciones con PostgreSQL
46
+ - **PostGIS**: Operaciones geoespaciales (puntos, polígonos, búsquedas por proximidad)
47
+ - **Cliente API**: Cliente HTTP genérico y cliente específico UNP
48
+ - **Patrón Repository**: Implementación de patrones de diseño para acceso a datos
47
49
 
48
50
  ## Descripción
49
51
 
@@ -51,6 +53,12 @@ eco-back es una librería modular que facilita el desarrollo de aplicaciones bac
51
53
 
52
54
  ## Instalación
53
55
 
56
+ ### Desde PyPI
57
+
58
+ ```bash
59
+ pip install eco-back
60
+ ```
61
+
54
62
  ### Desde el código fuente
55
63
 
56
64
  ```bash
@@ -18,6 +18,11 @@ src/eco_back/database/connection.py
18
18
  src/eco_back/database/models.py
19
19
  src/eco_back/database/postgis.py
20
20
  src/eco_back/database/repository.py
21
+ src/eco_back/documento/__init__.py
22
+ src/eco_back/documento/anexos.py
23
+ src/eco_back/registro/__init__.py
24
+ src/eco_back/registro/trazabilidad.py
25
+ tests/test_anexos.py
21
26
  tests/test_api.py
22
27
  tests/test_database.py
23
28
  tests/test_example.py
@@ -1,6 +1,8 @@
1
1
  psycopg2-binary>=2.9.0
2
2
  geoalchemy2>=0.14.0
3
3
  requests>=2.31.0
4
+ djangorestframework>=3.14.0
5
+ django>=4.0.0
4
6
 
5
7
  [dev]
6
8
  pytest>=7.0
@@ -0,0 +1,277 @@
1
+ """
2
+ Tests para el módulo de anexos y documentos
3
+ """
4
+ import pytest
5
+ import tempfile
6
+ import os
7
+ from io import BytesIO
8
+ from unittest.mock import Mock, patch, MagicMock
9
+
10
+ from eco_back.documento import AnexoManager, AnexoConfig, DocumentoResult
11
+
12
+
13
+ @pytest.fixture
14
+ def config_basica():
15
+ """Configuración básica para tests"""
16
+ return AnexoConfig(
17
+ url_base="https://api.test.com",
18
+ max_size=1024 * 1024, # 1MB para tests
19
+ max_retries=2,
20
+ timeout=10
21
+ )
22
+
23
+
24
+ @pytest.fixture
25
+ def manager(config_basica):
26
+ """Fixture del manager"""
27
+ return AnexoManager(config_basica)
28
+
29
+
30
+ def test_obtener_extension(manager):
31
+ """Test de obtención de extensión"""
32
+ assert manager.obtener_extension("archivo.pdf") == ".pdf"
33
+ assert manager.obtener_extension("documento.PDF") == ".pdf"
34
+ assert manager.obtener_extension("imagen.JPG") == ".jpg"
35
+ assert manager.obtener_extension("sin_extension") == ""
36
+
37
+
38
+ def test_validar_extension(manager):
39
+ """Test de validación de extensiones"""
40
+ assert manager.validar_extension(".pdf") is True
41
+ assert manager.validar_extension("pdf") is True
42
+ assert manager.validar_extension(".jpg") is True
43
+ assert manager.validar_extension(".exe") is False
44
+ assert manager.validar_extension(".bat") is False
45
+
46
+
47
+ def test_obtener_mime_type(manager):
48
+ """Test de obtención de tipo MIME"""
49
+ assert manager.obtener_mime_type("doc.pdf") == "application/pdf"
50
+ assert manager.obtener_mime_type("foto.jpg") == "image/jpeg"
51
+ assert manager.obtener_mime_type("foto.jpeg") == "image/jpeg"
52
+ assert manager.obtener_mime_type("imagen.png") == "image/png"
53
+ assert manager.obtener_mime_type("archivo.xyz") == "application/octet-stream"
54
+
55
+
56
+ def test_documento_result():
57
+ """Test de DocumentoResult"""
58
+ resultado = DocumentoResult(
59
+ status="success",
60
+ mensaje="Todo bien",
61
+ detalles={"key": "value"}
62
+ )
63
+
64
+ assert resultado.status == "success"
65
+ assert resultado.exitoso is True
66
+ assert resultado.detalles["key"] == "value"
67
+
68
+ dict_result = resultado.to_dict()
69
+ assert dict_result["status"] == "success"
70
+ assert dict_result["exitoso"] is True
71
+
72
+
73
+ def test_documento_result_error():
74
+ """Test de DocumentoResult con error"""
75
+ resultado = DocumentoResult(
76
+ status="error",
77
+ mensaje="Falló",
78
+ )
79
+
80
+ assert resultado.exitoso is False
81
+
82
+
83
+ def test_guardar_anexo_extension_invalida(manager):
84
+ """Test de rechazo por extensión inválida"""
85
+ archivo = BytesIO(b"contenido")
86
+
87
+ resultado = manager.guardar_anexo(
88
+ archivo=archivo,
89
+ nombre_archivo="malware.exe",
90
+ llave="123",
91
+ grupo="test",
92
+ tipo_documento="doc",
93
+ diccionario=1,
94
+ asincrono=False
95
+ )
96
+
97
+ assert resultado.exitoso is False
98
+ assert "no permitida" in resultado.mensaje
99
+
100
+
101
+ def test_guardar_anexo_tamano_excedido(manager):
102
+ """Test de rechazo por tamaño excedido"""
103
+ # Crear contenido que excede el límite
104
+ contenido = b"X" * (2 * 1024 * 1024) # 2MB (límite es 1MB)
105
+
106
+ resultado = manager.guardar_anexo(
107
+ archivo=contenido,
108
+ nombre_archivo="grande.pdf",
109
+ llave="123",
110
+ grupo="test",
111
+ tipo_documento="doc",
112
+ diccionario=1,
113
+ asincrono=False
114
+ )
115
+
116
+ assert resultado.exitoso is False
117
+ assert "excede tamaño" in resultado.mensaje
118
+
119
+
120
+ def test_escanear_archivo_sin_funcion(manager):
121
+ """Test de escaneo cuando no hay función configurada"""
122
+ with tempfile.NamedTemporaryFile(suffix=".pdf") as tmp:
123
+ tmp.write(b"PDF content")
124
+ tmp.flush()
125
+
126
+ scan_result = manager._escanear_archivo(tmp.name, ".pdf")
127
+ assert scan_result['status'] == 'CLEAN'
128
+
129
+
130
+ def test_escanear_archivo_con_funcion():
131
+ """Test de escaneo con función personalizada"""
132
+ def scan_mock(path, ext):
133
+ return {'status': 'CLEAN', 'details': 'OK'}
134
+
135
+ config = AnexoConfig(
136
+ url_base="https://test.com",
137
+ scan_function=scan_mock
138
+ )
139
+ manager = AnexoManager(config)
140
+
141
+ with tempfile.NamedTemporaryFile(suffix=".pdf") as tmp:
142
+ tmp.write(b"PDF content")
143
+ tmp.flush()
144
+
145
+ scan_result = manager._escanear_archivo(tmp.name, ".pdf")
146
+ assert scan_result['status'] == 'CLEAN'
147
+
148
+
149
+ def test_obtener_endpoint(manager):
150
+ """Test de obtención de endpoint según diccionario"""
151
+ assert manager._obtener_endpoint(1) == "usuario"
152
+ assert manager._obtener_endpoint(2) == "persona"
153
+ assert manager._obtener_endpoint(3) == "colectivo"
154
+ assert manager._obtener_endpoint(99) == "usuario" # Default
155
+
156
+
157
+ def test_obtener_tipo_documento_sin_diccionario(manager):
158
+ """Test de tipo de documento sin función de diccionario"""
159
+ tipo = manager._obtener_tipo_documento("cedula", 1)
160
+ assert tipo == "cedula" # Sin mapeo, devuelve original
161
+
162
+
163
+ def test_obtener_tipo_documento_con_diccionario():
164
+ """Test de tipo de documento con función de diccionario"""
165
+ def dict_func(tipo):
166
+ return {"cedula": "CC"}.get(tipo, tipo)
167
+
168
+ config = AnexoConfig(
169
+ url_base="https://test.com",
170
+ diccionario_registro=dict_func
171
+ )
172
+ manager = AnexoManager(config)
173
+
174
+ tipo = manager._obtener_tipo_documento("cedula", 1)
175
+ assert tipo == "CC"
176
+
177
+
178
+ @patch('eco_back.documento.anexos.requests.post')
179
+ def test_enviar_documento_sincrono_exitoso(mock_post, manager):
180
+ """Test de envío síncrono exitoso"""
181
+ # Mock de respuesta exitosa
182
+ mock_response = Mock()
183
+ mock_response.status_code = 200
184
+ mock_response.json.return_value = {"success": True}
185
+ mock_response.content = b'{"success": true}'
186
+ mock_post.return_value = mock_response
187
+
188
+ # Crear archivo temporal
189
+ with tempfile.TemporaryDirectory() as temp_dir:
190
+ archivo_path = os.path.join(temp_dir, "test.pdf")
191
+ with open(archivo_path, 'wb') as f:
192
+ f.write(b"PDF content")
193
+
194
+ file_meta = {
195
+ 'field': 'anexo',
196
+ 'path': archivo_path,
197
+ 'original_name': 'test.pdf',
198
+ 'tipo_documento': 'cedula'
199
+ }
200
+
201
+ resultado = manager._enviar_documento_sincrono(
202
+ file_meta=file_meta,
203
+ llave="123",
204
+ grupo="test",
205
+ tipo="cedula",
206
+ temp_dir=temp_dir,
207
+ diccionario=1
208
+ )
209
+
210
+ assert resultado.exitoso is True
211
+ assert resultado.status == "success"
212
+ assert mock_post.called
213
+
214
+
215
+ @patch('eco_back.documento.anexos.requests.post')
216
+ def test_enviar_documento_con_reintentos(mock_post, manager):
217
+ """Test de reintentos en caso de error del servidor"""
218
+ # Primera llamada falla con 500, segunda con 200
219
+ mock_response_error = Mock()
220
+ mock_response_error.status_code = 500
221
+
222
+ mock_response_ok = Mock()
223
+ mock_response_ok.status_code = 200
224
+ mock_response_ok.json.return_value = {"success": True}
225
+ mock_response_ok.content = b'{"success": true}'
226
+
227
+ mock_post.side_effect = [mock_response_error, mock_response_ok]
228
+
229
+ with tempfile.TemporaryDirectory() as temp_dir:
230
+ archivo_path = os.path.join(temp_dir, "test.pdf")
231
+ with open(archivo_path, 'wb') as f:
232
+ f.write(b"PDF content")
233
+
234
+ file_meta = {
235
+ 'field': 'anexo',
236
+ 'path': archivo_path,
237
+ 'original_name': 'test.pdf',
238
+ 'tipo_documento': 'cedula'
239
+ }
240
+
241
+ resultado = manager._enviar_documento_sincrono(
242
+ file_meta=file_meta,
243
+ llave="123",
244
+ grupo="test",
245
+ tipo="cedula",
246
+ temp_dir=temp_dir,
247
+ diccionario=1
248
+ )
249
+
250
+ assert resultado.exitoso is True
251
+ assert mock_post.call_count == 2
252
+
253
+
254
+ @patch('eco_back.documento.anexos.requests.post')
255
+ def test_guardar_anexo_asincrono(mock_post, manager):
256
+ """Test de guardado asíncrono"""
257
+ mock_response = Mock()
258
+ mock_response.status_code = 200
259
+ mock_response.json.return_value = {"success": True}
260
+ mock_response.content = b'{"success": true}'
261
+ mock_post.return_value = mock_response
262
+
263
+ contenido = b"PDF content"
264
+
265
+ resultado = manager.guardar_anexo(
266
+ archivo=contenido,
267
+ nombre_archivo="test.pdf",
268
+ llave="123",
269
+ grupo="test",
270
+ tipo_documento="cedula",
271
+ diccionario=1,
272
+ asincrono=True
273
+ )
274
+
275
+ assert resultado.exitoso is True
276
+ assert resultado.status == "processing"
277
+ assert "proceso" in resultado.mensaje
@@ -1,11 +0,0 @@
1
- """
2
- eco-back - Librería Python
3
- """
4
-
5
- __version__ = "0.1.0"
6
-
7
- # Módulos principales
8
- from . import database
9
- from . import api
10
-
11
- __all__ = ["database", "api", "__version__"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes