nlbone 0.7.21__tar.gz → 0.7.23__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 (121) hide show
  1. {nlbone-0.7.21 → nlbone-0.7.23}/PKG-INFO +1 -1
  2. {nlbone-0.7.21 → nlbone-0.7.23}/pyproject.toml +1 -1
  3. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/auth/auth_service.py +10 -16
  4. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/db/postgres/query_builder.py +183 -78
  5. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/db/postgres/types.py +3 -1
  6. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/http_clients/pricing/pricing_service.py +1 -1
  7. nlbone-0.7.23/src/nlbone/adapters/i18n/engine.py +28 -0
  8. nlbone-0.7.23/src/nlbone/adapters/i18n/loaders.py +58 -0
  9. nlbone-0.7.23/src/nlbone/adapters/i18n/locales/fa.json +12 -0
  10. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/messaging/rabbitmq.py +2 -6
  11. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/outbox/outbox_consumer.py +21 -21
  12. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/outbox/outbox_repo.py +6 -5
  13. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/snowflake.py +9 -6
  14. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/ticketing/client.py +10 -5
  15. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/config/settings.py +4 -4
  16. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/container.py +5 -2
  17. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/core/domain/base.py +3 -7
  18. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/core/domain/models.py +2 -1
  19. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/core/ports/outbox.py +33 -33
  20. nlbone-0.7.23/src/nlbone/core/ports/translation.py +8 -0
  21. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/dependencies/async_auth.py +2 -1
  22. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/dependencies/auth.py +3 -1
  23. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/dependencies/client_credential.py +3 -2
  24. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/exceptions.py +8 -0
  25. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/middleware/add_request_context.py +2 -1
  26. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/middleware/authentication.py +7 -4
  27. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/pagination/offset_base.py +1 -1
  28. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/schema/base_response_model.py +3 -0
  29. nlbone-0.7.23/src/nlbone/interfaces/cli/ticket.py +32 -0
  30. nlbone-0.7.23/src/nlbone/utils/__init__.py +0 -0
  31. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/utils/context.py +8 -0
  32. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/utils/http.py +1 -1
  33. nlbone-0.7.21/src/nlbone/interfaces/cli/ticket.py +0 -29
  34. {nlbone-0.7.21 → nlbone-0.7.23}/.gitignore +0 -0
  35. {nlbone-0.7.21 → nlbone-0.7.23}/LICENSE +0 -0
  36. {nlbone-0.7.21 → nlbone-0.7.23}/README.md +0 -0
  37. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/__init__.py +0 -0
  38. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/__init__.py +0 -0
  39. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/auth/__init__.py +0 -0
  40. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/auth/keycloak.py +0 -0
  41. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/auth/token_provider.py +0 -0
  42. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/cache/__init__.py +0 -0
  43. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/cache/async_redis.py +0 -0
  44. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/cache/memory.py +0 -0
  45. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/cache/pubsub_listener.py +0 -0
  46. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/cache/redis.py +0 -0
  47. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/db/__init__.py +0 -0
  48. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/db/postgres/__init__.py +0 -0
  49. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/db/postgres/audit.py +0 -0
  50. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/db/postgres/base.py +0 -0
  51. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/db/postgres/engine.py +0 -0
  52. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/db/postgres/repository.py +0 -0
  53. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/db/postgres/schema.py +0 -0
  54. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/db/postgres/uow.py +0 -0
  55. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/db/redis/__init__.py +0 -0
  56. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/db/redis/client.py +0 -0
  57. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/http_clients/__init__.py +0 -0
  58. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/http_clients/pricing/__init__.py +0 -0
  59. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/http_clients/uploadchi/__init__.py +0 -0
  60. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/http_clients/uploadchi/uploadchi.py +0 -0
  61. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/http_clients/uploadchi/uploadchi_async.py +0 -0
  62. {nlbone-0.7.21/src/nlbone/adapters/repositories → nlbone-0.7.23/src/nlbone/adapters/i18n}/__init__.py +0 -0
  63. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/messaging/__init__.py +0 -0
  64. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/messaging/event_bus.py +0 -0
  65. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/outbox/__init__.py +0 -0
  66. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/percolation/__init__.py +0 -0
  67. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/adapters/percolation/connection.py +0 -0
  68. {nlbone-0.7.21/src/nlbone/adapters/ticketing → nlbone-0.7.23/src/nlbone/adapters/repositories}/__init__.py +0 -0
  69. {nlbone-0.7.21/src/nlbone/config → nlbone-0.7.23/src/nlbone/adapters/ticketing}/__init__.py +0 -0
  70. {nlbone-0.7.21/src/nlbone/core → nlbone-0.7.23/src/nlbone/config}/__init__.py +0 -0
  71. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/config/logging.py +0 -0
  72. {nlbone-0.7.21/src/nlbone/core/application → nlbone-0.7.23/src/nlbone/core}/__init__.py +0 -0
  73. {nlbone-0.7.21/src/nlbone/core/application/services → nlbone-0.7.23/src/nlbone/core/application}/__init__.py +0 -0
  74. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/core/application/base_worker.py +0 -0
  75. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/core/application/bus.py +0 -0
  76. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/core/application/di.py +0 -0
  77. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/core/application/registry.py +0 -0
  78. {nlbone-0.7.21/src/nlbone/core/domain → nlbone-0.7.23/src/nlbone/core/application/services}/__init__.py +0 -0
  79. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/core/application/use_case.py +0 -0
  80. {nlbone-0.7.21/src/nlbone/interfaces → nlbone-0.7.23/src/nlbone/core/domain}/__init__.py +0 -0
  81. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/core/ports/__init__.py +0 -0
  82. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/core/ports/auth.py +0 -0
  83. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/core/ports/cache.py +0 -0
  84. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/core/ports/event_bus.py +0 -0
  85. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/core/ports/files.py +0 -0
  86. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/core/ports/repository.py +0 -0
  87. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/core/ports/uow.py +0 -0
  88. {nlbone-0.7.21/src/nlbone/interfaces/api → nlbone-0.7.23/src/nlbone/interfaces}/__init__.py +0 -0
  89. {nlbone-0.7.21/src/nlbone/interfaces/cli → nlbone-0.7.23/src/nlbone/interfaces/api}/__init__.py +0 -0
  90. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/additional_filed/__init__.py +0 -0
  91. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/additional_filed/assembler.py +0 -0
  92. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/additional_filed/default_field_rules/__init__.py +0 -0
  93. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/additional_filed/default_field_rules/image_field_rules.py +0 -0
  94. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/additional_filed/field_registry.py +0 -0
  95. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/additional_filed/resolver.py +0 -0
  96. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/dependencies/__init__.py +0 -0
  97. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/dependencies/db.py +0 -0
  98. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/dependencies/uow.py +0 -0
  99. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/exception_handlers.py +0 -0
  100. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/middleware/__init__.py +0 -0
  101. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/middleware/access_log.py +0 -0
  102. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/pagination/__init__.py +0 -0
  103. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/routers.py +0 -0
  104. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/schema/__init__.py +0 -0
  105. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/schema/adaptive_schema.py +0 -0
  106. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/api/schemas.py +0 -0
  107. {nlbone-0.7.21/src/nlbone/interfaces/jobs → nlbone-0.7.23/src/nlbone/interfaces/cli}/__init__.py +0 -0
  108. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/cli/crypto.py +0 -0
  109. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/cli/init_db.py +0 -0
  110. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/cli/main.py +0 -0
  111. {nlbone-0.7.21/src/nlbone/utils → nlbone-0.7.23/src/nlbone/interfaces/jobs}/__init__.py +0 -0
  112. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/jobs/dispatch_outbox.py +0 -0
  113. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/interfaces/jobs/sync_tokens.py +0 -0
  114. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/types.py +0 -0
  115. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/utils/cache.py +0 -0
  116. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/utils/cache_keys.py +0 -0
  117. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/utils/cache_registry.py +0 -0
  118. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/utils/crypto.py +0 -0
  119. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/utils/normalize_mobile.py +0 -0
  120. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/utils/redactor.py +0 -0
  121. {nlbone-0.7.21 → nlbone-0.7.23}/src/nlbone/utils/time.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nlbone
3
- Version: 0.7.21
3
+ Version: 0.7.23
4
4
  Summary: Backbone package for interfaces and infrastructure in Python projects
5
5
  Author-email: Amir Hosein Kahkbazzadeh <a.khakbazzadeh@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nlbone"
7
- version = "0.7.21"
7
+ version = "0.7.23"
8
8
  description = "Backbone package for interfaces and infrastructure in Python projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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('service-account') else None
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(url, data={
40
- "client_id": self.client_id,
41
- "client_secret": self.client_secret,
42
- "grant_type": "client_credentials"
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
- def _resolve_column_and_joins(entity, query, field_path: str, join_cache: dict[str, Any]):
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 _apply_order(pagination: PaginateRequest, entity, query):
72
- order_clauses = []
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
- include_ids = getattr(pagination, "include_ids", []) or []
75
- if include_ids and hasattr(entity, "id"):
76
- id_col = getattr(entity, "id")
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
- if pagination.sort:
81
- for sort in pagination.sort:
82
- field = sort["field"]
83
- order = sort["order"]
87
+ # Text
88
+ if _is_text_type(coltype):
89
+ return str(value)
84
90
 
85
- if hasattr(entity, field):
86
- column = getattr(entity, field)
87
- if order == "asc":
88
- order_clauses.append(asc(column))
89
- else:
90
- order_clauses.append(desc(column))
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
- if order_clauses:
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
- Supports suffix operators like:
141
- __ilike, __gte, __lte, __lt, __gt, __ne
142
- Returns (base_field, op) where op in {'eq','ilike','gte','lte','lt','gt','ne'}
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 {"ilike", "gte", "lte", "lt", "gt", "ne"}:
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
- if getattr(pagination, "filters", None):
160
- for raw_field, value in pagination.filters.items():
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
- value = None
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, query2 = _resolve_column_and_joins(entity, query, field, join_cache)
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
- def coerce(v):
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
- def _use_ilike(v) -> bool:
204
- if op_hint == "ilike":
205
- return True
206
- if _is_text_type(coltype) and isinstance(v, str) and _looks_like_wildcard(v):
207
- return True
208
- return False
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
- if any(_use_ilike(v) for v in vals) and _is_text_type(coltype):
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 = [coerce(v) for v in vals if v is not None]
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(or_(*[col != v for v in coerced]))
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
- if _use_ilike(value) and _is_text_type(coltype):
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 = coerce(value)
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
- else:
247
- continue
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_(*[1 == 1]), include_pred)
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 TypeDecorator, BigInteger
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={'X-Api-Key': get_settings().PRICING_API_SECRET, 'X-Client-Id': get_settings().KEYCLOAK_CLIENT_ID},
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,
@@ -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)
@@ -0,0 +1,12 @@
1
+ {
2
+ "general": {
3
+ "welcome": "خوش آمدید",
4
+ "buttons": {
5
+ "save": "ذخیره",
6
+ "cancel": "لغو"
7
+ }
8
+ },
9
+ "auth": {
10
+ "otp_sent": "کد تایید برای {mobile} ارسال شد."
11
+ }
12
+ }
@@ -1,16 +1,12 @@
1
1
  import json
2
- from typing import Mapping, Any, Optional
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
- repo: AsyncOutboxRepository,
16
- *,
17
- batch_size: int = 100,
18
- idle_sleep: float = 1.0,
19
- stop_event: Optional[asyncio.Event] = None,
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
- repo: AsyncOutboxRepository,
39
- msg: Outbox,
40
- *,
41
- backoff: timedelta = timedelta(seconds=30),
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
- repo: AsyncOutboxRepository,
61
- messages: Iterable[Outbox],
62
- *,
63
- backoff: timedelta = timedelta(seconds=30),
64
- concurrency: int = 1,
65
- handler=None,
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
- 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,
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():