mtpk-postgres 0.1.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.
- mtpk_postgres/__init__.py +5 -0
- mtpk_postgres/async_adapter.py +333 -0
- mtpk_postgres/core_sync.py +1688 -0
- mtpk_postgres/crud.py +333 -0
- mtpk_postgres/excepciones.py +46 -0
- mtpk_postgres/interface.py +69 -0
- mtpk_postgres/utils.py +294 -0
- mtpk_postgres-0.1.0.dist-info/METADATA +47 -0
- mtpk_postgres-0.1.0.dist-info/RECORD +12 -0
- mtpk_postgres-0.1.0.dist-info/WHEEL +5 -0
- mtpk_postgres-0.1.0.dist-info/licenses/LICENSE +21 -0
- mtpk_postgres-0.1.0.dist-info/top_level.txt +1 -0
mtpk_postgres/utils.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#-*- coding: UTF-8 -*-
|
|
3
|
+
# ----------------------------------------
|
|
4
|
+
# jjandres 2025
|
|
5
|
+
# ----------------------------------------
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
import bcrypt
|
|
9
|
+
import hashlib, hmac
|
|
10
|
+
import secrets
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Optional, List, Dict, Any, Union, Literal, Callable
|
|
14
|
+
from .core_sync import Columna
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
from decimal import Decimal
|
|
17
|
+
from functools import wraps
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def hash_password(plain_password: str) -> str:
|
|
21
|
+
"""
|
|
22
|
+
Genera un hash seguro de una contraseña en texto plano.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
plain_password (str): Contraseña original en texto plano.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
str: Hash seguro (en formato string) listo para almacenar en la base de datos.
|
|
29
|
+
"""
|
|
30
|
+
salt = bcrypt.gensalt()
|
|
31
|
+
hashed = bcrypt.hashpw(plain_password.encode(), salt)
|
|
32
|
+
return hashed.decode()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
36
|
+
"""
|
|
37
|
+
Verifica si una contraseña en texto plano coincide con su hash almacenado.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
plain_password (str): Contraseña introducida por el usuario.
|
|
41
|
+
hashed_password (str): Hash previamente almacenado.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
bool: True si coinciden, False si no.
|
|
45
|
+
"""
|
|
46
|
+
return bcrypt.checkpw(
|
|
47
|
+
plain_password.encode(),
|
|
48
|
+
hashed_password.encode()
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def generar_api_key() -> str:
|
|
53
|
+
"""
|
|
54
|
+
Genera una api_key aleatoria de ~43 caracteres seguros.
|
|
55
|
+
- Returns:
|
|
56
|
+
- str: cadena api_key aleatoria
|
|
57
|
+
"""
|
|
58
|
+
return secrets.token_urlsafe(32)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def generar_api_secret() -> str:
|
|
62
|
+
"""
|
|
63
|
+
Genera una api_key aleatoria de ~65 caracteres seguros
|
|
64
|
+
- Returns:
|
|
65
|
+
2-tuple:
|
|
66
|
+
- str: cadena api_secret aleatoria.
|
|
67
|
+
- str: cadena api_secret hasheada
|
|
68
|
+
"""
|
|
69
|
+
secreto = secrets.token_urlsafe(48) # ~65 caracteres
|
|
70
|
+
secreto_hash = hashlib.sha256(secreto.encode()).hexdigest()
|
|
71
|
+
return secreto, secreto_hash # Retorna secreto plano y su hash
|
|
72
|
+
|
|
73
|
+
def verificar_api_secret(api_secret: str, hashed_api_secret: str) -> bool:
|
|
74
|
+
"""
|
|
75
|
+
Verifica si un API_SECRET en texto plano coincide con su hash SHA-256.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
api_secret (str): API_SECRET en texto plano.
|
|
79
|
+
hashed_api_secret (str): Hash SHA-256 (hex) previamente almacenado.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
bool: True si coinciden, False en caso contrario.
|
|
83
|
+
"""
|
|
84
|
+
# Calcula el hash del secreto proporcionado
|
|
85
|
+
calc_hash = hashlib.sha256(api_secret.encode()).hexdigest()
|
|
86
|
+
# Compara de forma segura para evitar ataques de tiempo
|
|
87
|
+
return hmac.compare_digest(calc_hash, hashed_api_secret)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def generar_lista_campos(campos, alias=None, excluir=None, prefijo_alias=None):
|
|
91
|
+
"""
|
|
92
|
+
Genera una lista de campos SQL a partir de un diccionario, lista de strings o lista de objetos Columna,
|
|
93
|
+
con alias opcional, campos a excluir y alias renombrado con prefijo.
|
|
94
|
+
|
|
95
|
+
:param `campos`: (Requerido) Diccionario {nombre_campo: Column}, lista de strings o lista de objetos Columna
|
|
96
|
+
:param `alias`: (Opcional) Alias para los campos (string), ej: 'u'
|
|
97
|
+
:param `excluir`: (Opcional) Lista de campos a excluir (lista de strings)
|
|
98
|
+
:param `prefijo_alias`: (Opcional) Prefijo para renombrar los campos con alias: <alias>.<campo> AS <prefijo><campo>
|
|
99
|
+
:return: String con lista de campos separados por coma
|
|
100
|
+
"""
|
|
101
|
+
excluir = excluir or []
|
|
102
|
+
lista = []
|
|
103
|
+
|
|
104
|
+
if isinstance(campos, dict):
|
|
105
|
+
iterable = campos.items()
|
|
106
|
+
elif isinstance(campos, list):
|
|
107
|
+
if all(hasattr(col, 'nombre') for col in campos):
|
|
108
|
+
iterable = [(col.nombre, col) for col in campos]
|
|
109
|
+
else:
|
|
110
|
+
iterable = [(nombre, None) for nombre in campos]
|
|
111
|
+
else:
|
|
112
|
+
raise TypeError("El parámetro 'campos' debe ser un dict, una lista de strings o una lista de objetos Columna.")
|
|
113
|
+
|
|
114
|
+
for nombre, _ in iterable:
|
|
115
|
+
if nombre in excluir:
|
|
116
|
+
continue
|
|
117
|
+
if alias:
|
|
118
|
+
campo_sql = f"{alias}.{nombre}"
|
|
119
|
+
else:
|
|
120
|
+
campo_sql = nombre
|
|
121
|
+
|
|
122
|
+
if prefijo_alias:
|
|
123
|
+
alias_sql = f"{prefijo_alias}{nombre}"
|
|
124
|
+
lista.append(f"{campo_sql} AS {alias_sql}")
|
|
125
|
+
else:
|
|
126
|
+
lista.append(campo_sql)
|
|
127
|
+
|
|
128
|
+
return ", ".join(lista)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class FiltroCampo(BaseModel):
|
|
132
|
+
"""Crea modelo para definir un filtro de campo SQL."""
|
|
133
|
+
op: Literal["=", "!=", ">", "<", ">=", "<=", "like", "in", "between"]
|
|
134
|
+
valor: Any
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def construir_condiciones_sql(filtros: dict[str, dict[str, Union[str, Any]]], alias: Optional[dict[str, str]] = None) -> tuple[str, list[Any]]:
|
|
139
|
+
"""
|
|
140
|
+
Construye una cláusula WHERE SQL a partir de un diccionario de filtros.
|
|
141
|
+
|
|
142
|
+
Cada entrada del diccionario debe tener la forma:
|
|
143
|
+
campo: {"op": operador_sql, "valor": valor_o_lista}
|
|
144
|
+
|
|
145
|
+
Operadores soportados (case-insensitive):
|
|
146
|
+
'=', '!=', '>', '<', '>=', '<=', 'like', 'in', 'between'
|
|
147
|
+
|
|
148
|
+
Parámetros:
|
|
149
|
+
- `filtros`: Diccionario de filtros con campo, operador y valor.
|
|
150
|
+
- `alias`: Diccionario opcional con alias por campo, p.ej. {'nombre_campo': 'a.nombre_alias'}
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
(condiciones_sql, valores): la cláusula WHERE (sin "WHERE") y la lista de parámetros.
|
|
154
|
+
"""
|
|
155
|
+
condiciones: List[str] = []
|
|
156
|
+
valores: List[Any] = []
|
|
157
|
+
|
|
158
|
+
def _col(campo: str) -> str:
|
|
159
|
+
return alias.get(campo, campo) if alias else campo
|
|
160
|
+
|
|
161
|
+
def _is_seq(x: Any) -> bool:
|
|
162
|
+
return isinstance(x, (list, tuple))
|
|
163
|
+
|
|
164
|
+
for campo, cond in (filtros or {}).items():
|
|
165
|
+
if not isinstance(cond, dict):
|
|
166
|
+
# Compatibilidad: ignorar entradas mal formadas
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
op_raw = cond.get("op")
|
|
170
|
+
val = cond.get("valor")
|
|
171
|
+
if not isinstance(op_raw, str):
|
|
172
|
+
# Compatibilidad: ignorar operador inválido
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
op = op_raw.strip().lower()
|
|
176
|
+
campo_sql = _col(campo)
|
|
177
|
+
|
|
178
|
+
# Operadores simples y nulos
|
|
179
|
+
if op in {"=", "!="}:
|
|
180
|
+
# None -> IS NULL / IS NOT NULL
|
|
181
|
+
if val is None:
|
|
182
|
+
condiciones.append(f"{campo_sql} IS {'NOT ' if op == '!=' else ''}NULL")
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
# List/Tuple -> IN / NOT IN
|
|
186
|
+
if _is_seq(val):
|
|
187
|
+
seq = list(val)
|
|
188
|
+
if not seq:
|
|
189
|
+
# Compatibilidad: ignorar si viene vacío
|
|
190
|
+
continue
|
|
191
|
+
placeholders = ", ".join(["%s"] * len(seq))
|
|
192
|
+
condiciones.append(f"{campo_sql} {'NOT ' if op == '!=' else ''}IN ({placeholders})")
|
|
193
|
+
valores.extend(seq)
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
# Escalar
|
|
197
|
+
condiciones.append(f"{campo_sql} {op} %s")
|
|
198
|
+
valores.append(val)
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
if op in {">", "<", ">=", "<="}:
|
|
202
|
+
condiciones.append(f"{campo_sql} {op} %s")
|
|
203
|
+
valores.append(val)
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
if op == "like":
|
|
207
|
+
if val is None:
|
|
208
|
+
# Compatibilidad: ignorar filtro inválido
|
|
209
|
+
continue
|
|
210
|
+
condiciones.append(f"{campo_sql} LIKE %s")
|
|
211
|
+
valores.append(val)
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
if op == "in":
|
|
215
|
+
if not _is_seq(val):
|
|
216
|
+
# Compatibilidad: ignorar si no es lista/tupla
|
|
217
|
+
continue
|
|
218
|
+
seq = list(val)
|
|
219
|
+
if not seq:
|
|
220
|
+
# Compatibilidad: ignorar IN vacío
|
|
221
|
+
continue
|
|
222
|
+
placeholders = ", ".join(["%s"] * len(seq))
|
|
223
|
+
condiciones.append(f"{campo_sql} IN ({placeholders})")
|
|
224
|
+
valores.extend(seq)
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
if op == "between":
|
|
228
|
+
# Acepta [desde, hasta] o {"desde": x, "hasta": y}
|
|
229
|
+
desde = hasta = None
|
|
230
|
+
if isinstance(val, dict):
|
|
231
|
+
desde = val.get("desde", None)
|
|
232
|
+
hasta = val.get("hasta", None)
|
|
233
|
+
elif _is_seq(val) and len(val) >= 2:
|
|
234
|
+
desde, hasta = val[0], val[1]
|
|
235
|
+
elif val is None:
|
|
236
|
+
# Compatibilidad: ignorar
|
|
237
|
+
continue
|
|
238
|
+
else:
|
|
239
|
+
# Compatibilidad/utility: si viene solo un valor, tratar como '='
|
|
240
|
+
condiciones.append(f"{campo_sql} = %s")
|
|
241
|
+
valores.append(val)
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
if desde is not None and hasta is not None:
|
|
245
|
+
condiciones.append(f"{campo_sql} BETWEEN %s AND %s")
|
|
246
|
+
valores.extend([desde, hasta])
|
|
247
|
+
elif desde is not None:
|
|
248
|
+
condiciones.append(f"{campo_sql} >= %s")
|
|
249
|
+
valores.append(desde)
|
|
250
|
+
elif hasta is not None:
|
|
251
|
+
condiciones.append(f"{campo_sql} <= %s")
|
|
252
|
+
valores.append(hasta)
|
|
253
|
+
# si ambos None, no añadimos nada (compatibilidad)
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
# Operador no soportado: compatibilidad -> ignorar
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
condiciones_sql = " AND ".join(condiciones)
|
|
260
|
+
return condiciones_sql, valores
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def resolver_orden_sql(orden: str, alias: Optional[dict[str, str]] = None) -> str:
|
|
264
|
+
"""
|
|
265
|
+
Convierte una cadena de orden (como 'nombre DESC') en una cláusula SQL segura,
|
|
266
|
+
aplicando alias si están definidos.
|
|
267
|
+
|
|
268
|
+
Ejemplo:
|
|
269
|
+
orden = "nombre DESC"
|
|
270
|
+
alias = {"nombre": "a.nombre"}
|
|
271
|
+
=> "a.nombre DESC"
|
|
272
|
+
|
|
273
|
+
Si el campo no está en alias, se usa tal cual (bajo tu responsabilidad).
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
orden (str): Campo o campos por los que ordenar, separados por coma.
|
|
277
|
+
alias (dict[str, str], optional): Diccionario de alias.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
str: Expresión segura de ordenación SQL.
|
|
281
|
+
"""
|
|
282
|
+
if not orden:
|
|
283
|
+
return ""
|
|
284
|
+
|
|
285
|
+
campos_orden = []
|
|
286
|
+
for parte in orden.split(","):
|
|
287
|
+
tokens = parte.strip().split()
|
|
288
|
+
campo = tokens[0]
|
|
289
|
+
direccion = tokens[1].upper() if len(tokens) > 1 and tokens[1].upper() in {"ASC", "DESC"} else "ASC"
|
|
290
|
+
|
|
291
|
+
campo_sql = alias.get(campo, campo) if alias else campo
|
|
292
|
+
campos_orden.append(f"{campo_sql} {direccion}")
|
|
293
|
+
|
|
294
|
+
return ", ".join(campos_orden)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mtpk_postgres
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Librería para sincronización estructural de bases de datos Postgres con modelos Python (adaptación de mtpk_mariadb)
|
|
5
|
+
Author-email: José Jesús Andrés Zambrana <jjandres@multiplika.es>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jjandres/mtpk_postgres
|
|
8
|
+
Project-URL: Repository, https://github.com/jjandres/mtpk_postgres
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Topic :: Database
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: psycopg[binary,pool]
|
|
17
|
+
Requires-Dist: pydantic
|
|
18
|
+
Requires-Dist: bcrypt
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# mtpk_postgres
|
|
22
|
+
|
|
23
|
+
Adaptación a Postgres (vía [psycopg 3](https://www.psycopg.org/psycopg3/)) de `mtpk_mariadb`: define tus tablas con
|
|
24
|
+
dataclasses (`Tabla`, `Columna`, `ForeignKey`, `Index`), sincroniza la estructura de la base de datos automáticamente
|
|
25
|
+
(CREATE/ALTER TABLE, índices, claves foráneas) y usa `AsyncCrudBase` para CRUD asíncrono.
|
|
26
|
+
|
|
27
|
+
No modifica ni depende de `mtpk_mariadb`; es un paquete independiente. Ver `docs/mtpk-postgres-adaptacion.md` en el
|
|
28
|
+
repo para el detalle de las diferencias frente a la versión MariaDB.
|
|
29
|
+
|
|
30
|
+
## Instalación
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
pip install -e .
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Notas de la adaptación
|
|
37
|
+
|
|
38
|
+
- Identificadores citados con `"comillas dobles"` en vez de backticks.
|
|
39
|
+
- `auto_increment=True` genera `GENERATED ALWAYS AS IDENTITY` en vez de `AUTO_INCREMENT`; como Postgres no tiene
|
|
40
|
+
`lastrowid`, las inserciones vía `AsyncCrudBase.insert()`/`insertar()` añaden `RETURNING id`.
|
|
41
|
+
- `Tabla.to_sql()` ya no incluye índices ni comentarios inline (Postgres no lo permite dentro de `CREATE TABLE`):
|
|
42
|
+
usa `Tabla.to_sql_indices()` y `Tabla.to_sql_comentarios()` para las sentencias `CREATE INDEX` y `COMMENT ON`
|
|
43
|
+
correspondientes. `ManagerDB` ya las ejecuta automáticamente al crear tablas.
|
|
44
|
+
- `ENUM`/`SET` se traducen a `TEXT` + `CHECK`; al reconstruir la estructura desde la base de datos ambos se
|
|
45
|
+
reportan como `ENUM` (Postgres no permite distinguirlos después del hecho).
|
|
46
|
+
- A diferencia de MariaDB, el DDL en Postgres es transaccional: los métodos de `ManagerDB` confirman (`COMMIT`)
|
|
47
|
+
explícitamente tras crear tablas, triggers y procedimientos.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
mtpk_postgres/__init__.py,sha256=GOvt_tgF0FaeTLJ5UKYawMTkQcEAzB4CSMrVHO_HxwI,104
|
|
2
|
+
mtpk_postgres/async_adapter.py,sha256=625g5_ObLuwdZ4Ck5sGW9ZgwLz34UIi8zlRNGsDESv8,13332
|
|
3
|
+
mtpk_postgres/core_sync.py,sha256=bspjGTZi3eoUE3o2yXirSW6yBJuveflMeZHTa017PFQ,74153
|
|
4
|
+
mtpk_postgres/crud.py,sha256=ks3Wewwp-1LAvfaDAcz665gI4I_-Nx_xTw3h3VC9cNE,14348
|
|
5
|
+
mtpk_postgres/excepciones.py,sha256=e3QUo5yOCeIpAMl9T7d5vVLLRievEDy0KYk3NnsuKvY,1486
|
|
6
|
+
mtpk_postgres/interface.py,sha256=ke9CJKuvVcwWKG1kEXrPuPl0Tb5A_jjMPJTctV6y7zs,2039
|
|
7
|
+
mtpk_postgres/utils.py,sha256=pG_wU1JMpfUbSE_90X4TIu5Ind41o0h3j3gaYUFlhfI,9869
|
|
8
|
+
mtpk_postgres-0.1.0.dist-info/licenses/LICENSE,sha256=9P-e1cTXCTuepzDnhWFnAyWg-SiAsVzPTZymN2nH4Pw,1086
|
|
9
|
+
mtpk_postgres-0.1.0.dist-info/METADATA,sha256=81x8zseU_2Ogek5uvuSnKavNajwAe5rXx8THgkW6TKE,2325
|
|
10
|
+
mtpk_postgres-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
mtpk_postgres-0.1.0.dist-info/top_level.txt,sha256=UqdjUccq2zmrlXW616wuwUrog-SRqL0JouIKg5-eMPw,14
|
|
12
|
+
mtpk_postgres-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 José Jesús Andrés Zambrana
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mtpk_postgres
|