nlbone 0.7.21__py3-none-any.whl → 0.7.23__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.
- nlbone/adapters/auth/auth_service.py +10 -16
- nlbone/adapters/db/postgres/query_builder.py +183 -78
- nlbone/adapters/db/postgres/types.py +3 -1
- nlbone/adapters/http_clients/pricing/pricing_service.py +1 -1
- nlbone/adapters/i18n/__init__.py +0 -0
- nlbone/adapters/i18n/engine.py +28 -0
- nlbone/adapters/i18n/loaders.py +58 -0
- nlbone/adapters/i18n/locales/fa.json +12 -0
- nlbone/adapters/messaging/rabbitmq.py +2 -6
- nlbone/adapters/outbox/outbox_consumer.py +21 -21
- nlbone/adapters/outbox/outbox_repo.py +6 -5
- nlbone/adapters/snowflake.py +9 -6
- nlbone/adapters/ticketing/client.py +10 -5
- nlbone/config/settings.py +4 -4
- nlbone/container.py +5 -2
- nlbone/core/domain/base.py +3 -7
- nlbone/core/domain/models.py +2 -1
- nlbone/core/ports/outbox.py +33 -33
- nlbone/core/ports/translation.py +8 -0
- nlbone/interfaces/api/dependencies/async_auth.py +2 -1
- nlbone/interfaces/api/dependencies/auth.py +3 -1
- nlbone/interfaces/api/dependencies/client_credential.py +3 -2
- nlbone/interfaces/api/exceptions.py +8 -0
- nlbone/interfaces/api/middleware/add_request_context.py +2 -1
- nlbone/interfaces/api/middleware/authentication.py +7 -4
- nlbone/interfaces/api/pagination/offset_base.py +1 -1
- nlbone/interfaces/api/schema/base_response_model.py +3 -0
- nlbone/interfaces/cli/ticket.py +16 -13
- nlbone/utils/context.py +8 -0
- nlbone/utils/http.py +1 -1
- {nlbone-0.7.21.dist-info → nlbone-0.7.23.dist-info}/METADATA +1 -1
- {nlbone-0.7.21.dist-info → nlbone-0.7.23.dist-info}/RECORD +35 -30
- {nlbone-0.7.21.dist-info → nlbone-0.7.23.dist-info}/WHEEL +0 -0
- {nlbone-0.7.21.dist-info → nlbone-0.7.23.dist-info}/entry_points.txt +0 -0
- {nlbone-0.7.21.dist-info → nlbone-0.7.23.dist-info}/licenses/LICENSE +0 -0
|
@@ -2,8 +2,8 @@ import requests
|
|
|
2
2
|
|
|
3
3
|
from nlbone.config.settings import get_settings
|
|
4
4
|
from nlbone.core.ports.auth import AuthService as BaseAuthService
|
|
5
|
-
from nlbone.utils.http import normalize_https_base
|
|
6
5
|
from nlbone.utils.cache import cached
|
|
6
|
+
from nlbone.utils.http import normalize_https_base
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class AuthService(BaseAuthService):
|
|
@@ -15,15 +15,12 @@ class AuthService(BaseAuthService):
|
|
|
15
15
|
self._timeout = float(s.HTTP_TIMEOUT_SECONDS)
|
|
16
16
|
self._client = requests.session()
|
|
17
17
|
|
|
18
|
-
def has_access(self, token: str, permissions: list[str]) -> bool:
|
|
19
|
-
...
|
|
18
|
+
def has_access(self, token: str, permissions: list[str]) -> bool: ...
|
|
20
19
|
|
|
21
20
|
@cached(ttl=15 * 60)
|
|
22
21
|
def verify_token(self, token: str) -> dict:
|
|
23
22
|
url = f"{self._base_url}/introspect"
|
|
24
|
-
result = self._client.post(url, data={
|
|
25
|
-
"token": token
|
|
26
|
-
})
|
|
23
|
+
result = self._client.post(url, data={"token": token})
|
|
27
24
|
if result.status_code == 200:
|
|
28
25
|
return result.json()
|
|
29
26
|
return None
|
|
@@ -31,22 +28,20 @@ class AuthService(BaseAuthService):
|
|
|
31
28
|
def get_client_id(self, token: str):
|
|
32
29
|
data = self.verify_token(token)
|
|
33
30
|
if data:
|
|
34
|
-
return data["sub"] if data["sub"].startswith(
|
|
31
|
+
return data["sub"] if data["sub"].startswith("service-account") else None
|
|
35
32
|
return None
|
|
36
33
|
|
|
37
34
|
def get_client_token(self) -> dict | None:
|
|
38
35
|
url = f"{self._base_url}/token"
|
|
39
|
-
result = self._client.post(
|
|
40
|
-
|
|
41
|
-
"client_secret": self.client_secret,
|
|
42
|
-
|
|
43
|
-
})
|
|
36
|
+
result = self._client.post(
|
|
37
|
+
url,
|
|
38
|
+
data={"client_id": self.client_id, "client_secret": self.client_secret, "grant_type": "client_credentials"},
|
|
39
|
+
)
|
|
44
40
|
if result.status_code == 200:
|
|
45
41
|
return result.json()
|
|
46
42
|
return None
|
|
47
43
|
|
|
48
|
-
def is_client_token(self, token: str, allowed_clients: set[str] | None = None) -> bool:
|
|
49
|
-
...
|
|
44
|
+
def is_client_token(self, token: str, allowed_clients: set[str] | None = None) -> bool: ...
|
|
50
45
|
|
|
51
46
|
def client_has_access(self, token: str, permissions: list[str], allowed_clients: set[str] | None = None) -> bool:
|
|
52
47
|
data = self.verify_token(token)
|
|
@@ -55,5 +50,4 @@ class AuthService(BaseAuthService):
|
|
|
55
50
|
has_access = [self.client_id + "#" + perm in data.get("allowed_permissions", []) for perm in permissions]
|
|
56
51
|
return all(has_access)
|
|
57
52
|
|
|
58
|
-
def get_permissions(self, token: str) -> list[str]:
|
|
59
|
-
...
|
|
53
|
+
def get_permissions(self, token: str) -> list[str]: ...
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Any, Callable, Optional, Sequence, Type, Union
|
|
1
|
+
from typing import Any, Callable, List, Optional, Sequence, Type, Union
|
|
2
2
|
|
|
3
3
|
from sqlalchemy import and_, asc, case, desc, literal, or_
|
|
4
4
|
from sqlalchemy.dialects.postgresql import ENUM as PGEnum
|
|
@@ -30,7 +30,14 @@ class _InvalidEnum(Exception):
|
|
|
30
30
|
pass
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
VALID_OPERATORS = {"ilike", "gte", "lte", "lt", "gt", "ne", "in", "notin", "eq"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_column_and_joins(entity, query: Query, field_path: str, join_cache: dict[str, Any]):
|
|
37
|
+
"""
|
|
38
|
+
Resolves nested fields like 'items.product.name' into SQLAlchemy columns,
|
|
39
|
+
performing necessary joins and caching aliases to prevent duplicate joins.
|
|
40
|
+
"""
|
|
34
41
|
parts = [p for p in field_path.split(".") if p]
|
|
35
42
|
if not parts:
|
|
36
43
|
return None, query
|
|
@@ -46,8 +53,8 @@ def _resolve_column_and_joins(entity, query, field_path: str, join_cache: dict[s
|
|
|
46
53
|
return None, query
|
|
47
54
|
|
|
48
55
|
attr = getattr(current_cls_or_alias, part)
|
|
49
|
-
|
|
50
56
|
prop = getattr(attr, "property", None)
|
|
57
|
+
|
|
51
58
|
if isinstance(prop, RelationshipProperty):
|
|
52
59
|
alias = join_cache.get(path_key)
|
|
53
60
|
if alias is None:
|
|
@@ -63,35 +70,51 @@ def _resolve_column_and_joins(entity, query, field_path: str, join_cache: dict[s
|
|
|
63
70
|
else:
|
|
64
71
|
return None, query
|
|
65
72
|
|
|
66
|
-
return None, query
|
|
67
|
-
|
|
68
73
|
return None, query
|
|
69
74
|
|
|
70
75
|
|
|
71
|
-
def
|
|
72
|
-
|
|
76
|
+
def _coerce_value(coltype, value):
|
|
77
|
+
"""
|
|
78
|
+
Single responsibility: Convert raw string/input to the correct python type based on column definition.
|
|
79
|
+
"""
|
|
80
|
+
if value is None:
|
|
81
|
+
return None
|
|
73
82
|
|
|
74
|
-
|
|
75
|
-
if
|
|
76
|
-
|
|
77
|
-
whens = [(id_col == _id, idx) for idx, _id in enumerate(include_ids)]
|
|
78
|
-
order_clauses.append(asc(case(*whens, else_=literal(999_999))))
|
|
83
|
+
# Enums
|
|
84
|
+
if isinstance(coltype, (SAEnum, PGEnum)):
|
|
85
|
+
return _coerce_enum(coltype, value)
|
|
79
86
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
order = sort["order"]
|
|
87
|
+
# Text
|
|
88
|
+
if _is_text_type(coltype):
|
|
89
|
+
return str(value)
|
|
84
90
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
# Numbers
|
|
92
|
+
if isinstance(coltype, (Integer, BigInteger, SmallInteger)):
|
|
93
|
+
try:
|
|
94
|
+
return int(value)
|
|
95
|
+
except (ValueError, TypeError):
|
|
96
|
+
return None
|
|
97
|
+
if isinstance(coltype, (Float, Numeric)):
|
|
98
|
+
try:
|
|
99
|
+
return float(value)
|
|
100
|
+
except (ValueError, TypeError):
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
# Booleans
|
|
104
|
+
if isinstance(coltype, Boolean):
|
|
105
|
+
if isinstance(value, bool):
|
|
106
|
+
return value
|
|
107
|
+
if isinstance(value, (int, float)):
|
|
108
|
+
return bool(value)
|
|
109
|
+
if isinstance(value, str):
|
|
110
|
+
vl = value.strip().lower()
|
|
111
|
+
if vl in {"true", "1", "yes", "y", "t"}:
|
|
112
|
+
return True
|
|
113
|
+
if vl in {"false", "0", "no", "n", "f"}:
|
|
114
|
+
return False
|
|
115
|
+
return None
|
|
91
116
|
|
|
92
|
-
|
|
93
|
-
query = query.order_by(*order_clauses)
|
|
94
|
-
return query
|
|
117
|
+
return value
|
|
95
118
|
|
|
96
119
|
|
|
97
120
|
def _coerce_enum(col_type, raw):
|
|
@@ -121,7 +144,6 @@ def _is_text_type(coltype):
|
|
|
121
144
|
|
|
122
145
|
|
|
123
146
|
def _looks_like_wildcard(s: str) -> bool:
|
|
124
|
-
# treat '*' and '%' as wildcards
|
|
125
147
|
return isinstance(s, str) and ("*" in s or "%" in s)
|
|
126
148
|
|
|
127
149
|
|
|
@@ -137,18 +159,53 @@ def _to_sql_like_pattern(s: str) -> str:
|
|
|
137
159
|
|
|
138
160
|
def _parse_field_and_op(field: str):
|
|
139
161
|
"""
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
162
|
+
Parses 'field__op' syntax.
|
|
163
|
+
Example: 'age__gt' -> ('age', 'gt')
|
|
164
|
+
'items.price__in' -> ('items.price', 'in')
|
|
143
165
|
"""
|
|
144
166
|
if "__" in field:
|
|
145
167
|
base, op = field.rsplit("__", 1)
|
|
146
168
|
op = op.lower()
|
|
147
|
-
if base and op in
|
|
169
|
+
if base and op in VALID_OPERATORS:
|
|
148
170
|
return base, op
|
|
149
171
|
return field, "eq"
|
|
150
172
|
|
|
151
173
|
|
|
174
|
+
def _build_relational_negation(entity, path_parts: List[str], values: List[Any]):
|
|
175
|
+
current_part = path_parts[0]
|
|
176
|
+
remaining_parts = path_parts[1:]
|
|
177
|
+
|
|
178
|
+
if not hasattr(entity, current_part):
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
attr = getattr(entity, current_part)
|
|
182
|
+
prop = getattr(attr, "property", None)
|
|
183
|
+
|
|
184
|
+
if isinstance(prop, RelationshipProperty):
|
|
185
|
+
related_class = prop.mapper.class_
|
|
186
|
+
|
|
187
|
+
inner_clause = _build_relational_negation(related_class, remaining_parts, values)
|
|
188
|
+
|
|
189
|
+
if inner_clause is None:
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
if prop.direction.name in ("MANYTOONE", "ONETOONE"):
|
|
193
|
+
return attr.has(inner_clause)
|
|
194
|
+
else: # ONETOMANY, MANYTOMANY
|
|
195
|
+
return attr.any(inner_clause)
|
|
196
|
+
|
|
197
|
+
else:
|
|
198
|
+
col = attr
|
|
199
|
+
coltype = getattr(col, "type", None)
|
|
200
|
+
|
|
201
|
+
coerced_vals = [_coerce_value(coltype, v) for v in values if v not in (None, "", "null", "None")]
|
|
202
|
+
|
|
203
|
+
if not coerced_vals:
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
return col.in_(coerced_vals)
|
|
207
|
+
|
|
208
|
+
|
|
152
209
|
def _apply_filters(pagination, entity, query):
|
|
153
210
|
if not getattr(pagination, "filters", None) and not getattr(pagination, "include_ids", None):
|
|
154
211
|
return query
|
|
@@ -156,74 +213,90 @@ def _apply_filters(pagination, entity, query):
|
|
|
156
213
|
predicates = []
|
|
157
214
|
join_cache: dict[str, Any] = {}
|
|
158
215
|
|
|
159
|
-
|
|
160
|
-
|
|
216
|
+
filters = getattr(pagination, "filters", {})
|
|
217
|
+
if filters:
|
|
218
|
+
for raw_field, value in filters.items():
|
|
161
219
|
if value is None or value in NULL_SENTINELS or value == [] or value == {}:
|
|
162
|
-
|
|
220
|
+
continue
|
|
163
221
|
|
|
164
222
|
field, op_hint = _parse_field_and_op(raw_field)
|
|
223
|
+
is_nested = "." in field
|
|
224
|
+
if op_hint == "notin" and is_nested:
|
|
225
|
+
vals = []
|
|
226
|
+
if isinstance(value, str):
|
|
227
|
+
clean_value = value.strip().strip("[]")
|
|
228
|
+
if clean_value:
|
|
229
|
+
if "," in clean_value:
|
|
230
|
+
vals = [x.strip() for x in clean_value.split(",")]
|
|
231
|
+
else:
|
|
232
|
+
vals = [clean_value.strip()]
|
|
233
|
+
elif isinstance(value, (list, tuple, set)):
|
|
234
|
+
vals = list(value)
|
|
235
|
+
else:
|
|
236
|
+
vals = [value]
|
|
237
|
+
|
|
238
|
+
if vals:
|
|
239
|
+
path_parts = field.split(".")
|
|
240
|
+
negation_clause = _build_relational_negation(entity, path_parts, vals)
|
|
241
|
+
|
|
242
|
+
if negation_clause is not None:
|
|
243
|
+
predicates.append(~negation_clause)
|
|
244
|
+
continue
|
|
165
245
|
|
|
166
|
-
col,
|
|
246
|
+
col, query = _resolve_column_and_joins(entity, query, field, join_cache)
|
|
167
247
|
if col is None:
|
|
168
248
|
continue
|
|
169
|
-
query = query2
|
|
170
|
-
coltype = getattr(col, "type", None)
|
|
171
249
|
|
|
172
|
-
|
|
173
|
-
if v is None:
|
|
174
|
-
return None
|
|
175
|
-
# Enums
|
|
176
|
-
if isinstance(coltype, (SAEnum, PGEnum)):
|
|
177
|
-
return _coerce_enum(coltype, v)
|
|
178
|
-
# Text
|
|
179
|
-
if _is_text_type(coltype):
|
|
180
|
-
return str(v)
|
|
181
|
-
# Numbers
|
|
182
|
-
if isinstance(coltype, (Integer, BigInteger, SmallInteger)):
|
|
183
|
-
return int(v)
|
|
184
|
-
if isinstance(coltype, (Float, Numeric)):
|
|
185
|
-
return float(v)
|
|
186
|
-
# Booleans
|
|
187
|
-
if isinstance(coltype, Boolean):
|
|
188
|
-
if isinstance(v, bool):
|
|
189
|
-
return v
|
|
190
|
-
if isinstance(v, (int, float)):
|
|
191
|
-
return bool(v)
|
|
192
|
-
if isinstance(v, str):
|
|
193
|
-
vl = v.strip().lower()
|
|
194
|
-
if vl in {"true", "1", "yes", "y", "t"}:
|
|
195
|
-
return True
|
|
196
|
-
if vl in {"false", "0", "no", "n", "f"}:
|
|
197
|
-
return False
|
|
198
|
-
return None
|
|
199
|
-
return v
|
|
250
|
+
coltype = getattr(col, "type", None)
|
|
200
251
|
|
|
201
252
|
try:
|
|
253
|
+
if op_hint in ("in", "notin"):
|
|
254
|
+
if isinstance(value, str):
|
|
255
|
+
clean_value = value.strip().strip("[]")
|
|
256
|
+
if not clean_value:
|
|
257
|
+
continue
|
|
258
|
+
if "," in clean_value:
|
|
259
|
+
vals = [x.strip() for x in clean_value.split(",")]
|
|
260
|
+
else:
|
|
261
|
+
vals = [clean_value.strip()]
|
|
262
|
+
elif isinstance(value, (list, tuple, set)):
|
|
263
|
+
vals = value
|
|
264
|
+
else:
|
|
265
|
+
vals = [value]
|
|
266
|
+
|
|
267
|
+
coerced_vals = [_coerce_value(coltype, v) for v in vals if v not in (None, "", "null", "None")]
|
|
202
268
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if
|
|
207
|
-
|
|
208
|
-
|
|
269
|
+
if not coerced_vals:
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
if op_hint == "in":
|
|
273
|
+
predicates.append(col.in_(coerced_vals))
|
|
274
|
+
else:
|
|
275
|
+
predicates.append(col.notin_(coerced_vals))
|
|
276
|
+
continue
|
|
209
277
|
|
|
210
278
|
if isinstance(value, (list, tuple, set)):
|
|
211
279
|
vals = [v for v in value if v not in (None, "", "null", "None")]
|
|
212
280
|
if not vals:
|
|
213
281
|
continue
|
|
214
282
|
|
|
215
|
-
|
|
283
|
+
use_ilike_any = op_hint == "ilike" or (
|
|
284
|
+
_is_text_type(coltype) and any(_looks_like_wildcard(str(v)) for v in vals)
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
if use_ilike_any and _is_text_type(coltype):
|
|
216
288
|
patterns = [_to_sql_like_pattern(str(v)) for v in vals]
|
|
217
289
|
predicates.append(or_(*[col.ilike(p) for p in patterns]))
|
|
218
290
|
else:
|
|
219
|
-
coerced = [
|
|
291
|
+
coerced = [_coerce_value(coltype, v) for v in vals]
|
|
292
|
+
coerced = [c for c in coerced if c is not None]
|
|
220
293
|
if not coerced:
|
|
221
294
|
continue
|
|
222
295
|
|
|
223
296
|
if op_hint == "eq":
|
|
224
297
|
predicates.append(col.in_(coerced))
|
|
225
298
|
elif op_hint == "ne":
|
|
226
|
-
predicates.append(
|
|
299
|
+
predicates.append(col.notin_(coerced))
|
|
227
300
|
elif op_hint == "gt":
|
|
228
301
|
predicates.append(or_(*[col > v for v in coerced]))
|
|
229
302
|
elif op_hint == "gte":
|
|
@@ -234,17 +307,22 @@ def _apply_filters(pagination, entity, query):
|
|
|
234
307
|
predicates.append(or_(*[col <= v for v in coerced]))
|
|
235
308
|
else:
|
|
236
309
|
predicates.append(col.in_(coerced))
|
|
310
|
+
|
|
237
311
|
else:
|
|
238
|
-
|
|
312
|
+
use_ilike = op_hint == "ilike" or (
|
|
313
|
+
_is_text_type(coltype) and isinstance(value, str) and _looks_like_wildcard(value)
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if use_ilike and _is_text_type(coltype):
|
|
239
317
|
pattern = _to_sql_like_pattern(str(value))
|
|
240
318
|
predicates.append(col.ilike(pattern))
|
|
241
319
|
else:
|
|
242
|
-
v =
|
|
320
|
+
v = _coerce_value(coltype, value)
|
|
243
321
|
if v is None:
|
|
244
322
|
if op_hint in {"eq", "ilike"}:
|
|
245
323
|
predicates.append(col.is_(None))
|
|
246
|
-
|
|
247
|
-
|
|
324
|
+
elif op_hint == "ne":
|
|
325
|
+
predicates.append(col.is_not(None))
|
|
248
326
|
else:
|
|
249
327
|
if op_hint == "eq":
|
|
250
328
|
predicates.append(col == v)
|
|
@@ -271,11 +349,38 @@ def _apply_filters(pagination, entity, query):
|
|
|
271
349
|
if predicates:
|
|
272
350
|
final_pred = or_(and_(*predicates), include_pred)
|
|
273
351
|
else:
|
|
274
|
-
final_pred = or_(and_(*[
|
|
352
|
+
final_pred = or_(and_(*[literal(True)]), include_pred)
|
|
275
353
|
return query.filter(final_pred)
|
|
276
354
|
|
|
277
355
|
if predicates:
|
|
278
356
|
query = query.filter(and_(*predicates))
|
|
357
|
+
|
|
358
|
+
return query
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _apply_order(pagination: PaginateRequest, entity, query):
|
|
362
|
+
order_clauses = []
|
|
363
|
+
|
|
364
|
+
include_ids = getattr(pagination, "include_ids", []) or []
|
|
365
|
+
if include_ids and hasattr(entity, "id"):
|
|
366
|
+
id_col = getattr(entity, "id")
|
|
367
|
+
whens = [(id_col == _id, idx) for idx, _id in enumerate(include_ids)]
|
|
368
|
+
order_clauses.append(asc(case(*whens, else_=literal(999_999))))
|
|
369
|
+
|
|
370
|
+
if pagination.sort:
|
|
371
|
+
for sort in pagination.sort:
|
|
372
|
+
field = sort["field"]
|
|
373
|
+
order = sort["order"]
|
|
374
|
+
|
|
375
|
+
if hasattr(entity, field):
|
|
376
|
+
column = getattr(entity, field)
|
|
377
|
+
if order == "asc":
|
|
378
|
+
order_clauses.append(asc(column))
|
|
379
|
+
else:
|
|
380
|
+
order_clauses.append(desc(column))
|
|
381
|
+
|
|
382
|
+
if order_clauses:
|
|
383
|
+
query = query.order_by(*order_clauses)
|
|
279
384
|
return query
|
|
280
385
|
|
|
281
386
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
from sqlalchemy.types import
|
|
1
|
+
from sqlalchemy.types import BigInteger, TypeDecorator
|
|
2
|
+
|
|
2
3
|
|
|
3
4
|
class BaseIntegerIdType(TypeDecorator):
|
|
4
5
|
"""Maps BaseId subclasses <-> BIGINT transparently (Infrastructure-only)."""
|
|
6
|
+
|
|
5
7
|
impl = BigInteger
|
|
6
8
|
cache_ok = True
|
|
7
9
|
|
|
@@ -86,7 +86,7 @@ class PricingService:
|
|
|
86
86
|
r = self._client.post(
|
|
87
87
|
f"{self._base_url}/price/calculate",
|
|
88
88
|
params={"response": response},
|
|
89
|
-
headers={
|
|
89
|
+
headers={"X-Api-Key": get_settings().PRICING_API_SECRET, "X-Client-Id": get_settings().KEYCLOAK_CLIENT_ID},
|
|
90
90
|
# headers=auth_headers(self._token_provider.get_access_token()),
|
|
91
91
|
json=body,
|
|
92
92
|
timeout=self._timeout,
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from typing import Dict, Optional
|
|
2
|
+
|
|
3
|
+
from nlbone.core.ports.translation import TranslationPort
|
|
4
|
+
from nlbone.utils.context import get_locale
|
|
5
|
+
|
|
6
|
+
from .loaders import BaseLoader
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class I18nAdapter(TranslationPort):
|
|
10
|
+
def __init__(self, loader: BaseLoader, default_locale: str = "fa"):
|
|
11
|
+
self.default_locale = default_locale
|
|
12
|
+
self._translations: Dict[str, Dict[str, str]] = loader.load()
|
|
13
|
+
|
|
14
|
+
def translate(self, key: str, locale: Optional[str] = None, **kwargs) -> str:
|
|
15
|
+
target_locale = locale or get_locale() or self.default_locale
|
|
16
|
+
|
|
17
|
+
locale_data = self._translations.get(target_locale, {})
|
|
18
|
+
text = locale_data.get(key)
|
|
19
|
+
|
|
20
|
+
if text is None:
|
|
21
|
+
text = self._translations.get(self.default_locale, {}).get(key, key)
|
|
22
|
+
|
|
23
|
+
if kwargs:
|
|
24
|
+
try:
|
|
25
|
+
return text.format(**kwargs)
|
|
26
|
+
except KeyError:
|
|
27
|
+
pass
|
|
28
|
+
return text
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseLoader(ABC):
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def load(self) -> Dict[str, Dict[str, str]]:
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JSONFileLoader(BaseLoader):
|
|
17
|
+
def __init__(self, locales_path: str):
|
|
18
|
+
self.locales_path = locales_path
|
|
19
|
+
|
|
20
|
+
def load(self) -> Dict[str, Dict[str, str]]:
|
|
21
|
+
translations: Dict[str, Dict[str, str]] = {}
|
|
22
|
+
|
|
23
|
+
path_obj = Path(self.locales_path)
|
|
24
|
+
|
|
25
|
+
if not path_obj.exists():
|
|
26
|
+
logger.warning(f"Locales directory not found at: {self.locales_path}")
|
|
27
|
+
return translations
|
|
28
|
+
|
|
29
|
+
for file_path in path_obj.glob("*.json"):
|
|
30
|
+
try:
|
|
31
|
+
lang_code = file_path.stem
|
|
32
|
+
|
|
33
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
34
|
+
data = json.load(f)
|
|
35
|
+
|
|
36
|
+
flat_data = self._flatten_dict(data)
|
|
37
|
+
translations[lang_code] = flat_data
|
|
38
|
+
|
|
39
|
+
logger.info(f"Loaded locale '{lang_code}' with {len(flat_data)} keys.")
|
|
40
|
+
|
|
41
|
+
except json.JSONDecodeError as e:
|
|
42
|
+
logger.error(f"Invalid JSON format in {file_path}: {e}")
|
|
43
|
+
except Exception as e:
|
|
44
|
+
logger.error(f"Error loading locale {file_path}: {e}")
|
|
45
|
+
|
|
46
|
+
return translations
|
|
47
|
+
|
|
48
|
+
def _flatten_dict(self, d: Dict[str, Any], parent_key: str = "", sep: str = ".") -> Dict[str, str]:
|
|
49
|
+
items = []
|
|
50
|
+
for k, v in d.items():
|
|
51
|
+
new_key = f"{parent_key}{sep}{k}" if parent_key else k
|
|
52
|
+
|
|
53
|
+
if isinstance(v, dict):
|
|
54
|
+
items.extend(self._flatten_dict(v, new_key, sep=sep).items())
|
|
55
|
+
else:
|
|
56
|
+
items.append((new_key, str(v)))
|
|
57
|
+
|
|
58
|
+
return dict(items)
|
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
import json
|
|
2
|
-
from typing import
|
|
2
|
+
from typing import Any, Mapping, Optional
|
|
3
3
|
|
|
4
4
|
import aio_pika
|
|
5
5
|
from aio_pika import ExchangeType, Message
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
import json
|
|
9
|
-
from typing import Mapping, Any, Optional
|
|
10
|
-
import aio_pika
|
|
11
|
-
from aio_pika import ExchangeType, Message
|
|
12
7
|
from nlbone.core.ports.event_bus import EventBus
|
|
13
8
|
|
|
9
|
+
|
|
14
10
|
class RabbitMQEventBus(EventBus):
|
|
15
11
|
def __init__(self, amqp_url: str, declare_passive: bool = True, exchange_type: ExchangeType = ExchangeType.DIRECT):
|
|
16
12
|
self._amqp_url = amqp_url
|
|
@@ -12,11 +12,11 @@ from nlbone.core.ports import UnitOfWork
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
async def outbox_stream(
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
repo: AsyncOutboxRepository,
|
|
16
|
+
*,
|
|
17
|
+
batch_size: int = 100,
|
|
18
|
+
idle_sleep: float = 1.0,
|
|
19
|
+
stop_event: Optional[asyncio.Event] = None,
|
|
20
20
|
) -> AsyncIterator[Outbox]:
|
|
21
21
|
"""
|
|
22
22
|
Yields Outbox one-by-one. If none available, waits (idle_sleep) and tries again.
|
|
@@ -35,10 +35,10 @@ async def outbox_stream(
|
|
|
35
35
|
|
|
36
36
|
@asynccontextmanager
|
|
37
37
|
async def process_message(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
repo: AsyncOutboxRepository,
|
|
39
|
+
msg: Outbox,
|
|
40
|
+
*,
|
|
41
|
+
backoff: timedelta = timedelta(seconds=30),
|
|
42
42
|
):
|
|
43
43
|
"""
|
|
44
44
|
Usage:
|
|
@@ -57,12 +57,12 @@ async def process_message(
|
|
|
57
57
|
|
|
58
58
|
|
|
59
59
|
async def process_batch(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
60
|
+
repo: AsyncOutboxRepository,
|
|
61
|
+
messages: Iterable[Outbox],
|
|
62
|
+
*,
|
|
63
|
+
backoff: timedelta = timedelta(seconds=30),
|
|
64
|
+
concurrency: int = 1,
|
|
65
|
+
handler=None,
|
|
66
66
|
):
|
|
67
67
|
"""
|
|
68
68
|
Optional helper: run a handler concurrently on a batch.
|
|
@@ -80,12 +80,12 @@ async def process_batch(
|
|
|
80
80
|
|
|
81
81
|
|
|
82
82
|
def outbox_stream_sync(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
repo: OutboxRepository,
|
|
84
|
+
*,
|
|
85
|
+
topics: list[str] = None,
|
|
86
|
+
batch_size: int = 100,
|
|
87
|
+
idle_sleep: float = 1.0,
|
|
88
|
+
stop_flag: Optional[callable] = None,
|
|
89
89
|
) -> Iterator[Outbox]:
|
|
90
90
|
while True:
|
|
91
91
|
if stop_flag and stop_flag():
|