nlbone 0.6.14__tar.gz → 0.6.16__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 (103) hide show
  1. {nlbone-0.6.14 → nlbone-0.6.16}/PKG-INFO +1 -1
  2. {nlbone-0.6.14 → nlbone-0.6.16}/pyproject.toml +1 -1
  3. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/db/postgres/query_builder.py +40 -8
  4. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/http_clients/pricing/pricing_service.py +4 -4
  5. nlbone-0.6.16/src/nlbone/interfaces/api/additional_filed/__init__.py +7 -0
  6. nlbone-0.6.16/src/nlbone/interfaces/api/additional_filed/assembler.py +54 -0
  7. nlbone-0.6.16/src/nlbone/interfaces/api/additional_filed/default_field_rules/__init__.py +1 -0
  8. nlbone-0.6.16/src/nlbone/interfaces/api/additional_filed/default_field_rules/image_field_rules.py +13 -0
  9. nlbone-0.6.16/src/nlbone/interfaces/api/additional_filed/field_registry.py +105 -0
  10. nlbone-0.6.16/src/nlbone/interfaces/api/additional_filed/resolver.py +132 -0
  11. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/api/dependencies/auth.py +33 -32
  12. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/api/exceptions.py +8 -0
  13. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/api/pagination/__init__.py +2 -2
  14. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/api/pagination/offset_base.py +17 -14
  15. nlbone-0.6.16/src/nlbone/interfaces/api/schema/adaptive_schema.py +114 -0
  16. nlbone-0.6.16/src/nlbone/interfaces/api/schema/base_response_model.py +22 -0
  17. nlbone-0.6.16/src/nlbone/interfaces/api/schemas.py +5 -0
  18. nlbone-0.6.14/src/nlbone/interfaces/api/schema/adaptive_schema.py +0 -78
  19. nlbone-0.6.14/src/nlbone/interfaces/api/schema/base_response_model.py +0 -5
  20. nlbone-0.6.14/src/nlbone/interfaces/api/schemas.py +0 -0
  21. {nlbone-0.6.14 → nlbone-0.6.16}/.gitignore +0 -0
  22. {nlbone-0.6.14 → nlbone-0.6.16}/LICENSE +0 -0
  23. {nlbone-0.6.14 → nlbone-0.6.16}/README.md +0 -0
  24. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/__init__.py +0 -0
  25. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/__init__.py +0 -0
  26. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/auth/__init__.py +0 -0
  27. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/auth/keycloak.py +0 -0
  28. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/auth/token_provider.py +0 -0
  29. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/cache/__init__.py +0 -0
  30. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/cache/async_redis.py +0 -0
  31. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/cache/memory.py +0 -0
  32. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/cache/pubsub_listener.py +0 -0
  33. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/cache/redis.py +0 -0
  34. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/db/__init__.py +0 -0
  35. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/db/postgres/__init__.py +0 -0
  36. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/db/postgres/audit.py +0 -0
  37. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/db/postgres/base.py +0 -0
  38. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/db/postgres/engine.py +0 -0
  39. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/db/postgres/repository.py +0 -0
  40. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/db/postgres/schema.py +0 -0
  41. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/db/postgres/uow.py +0 -0
  42. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/db/redis/__init__.py +0 -0
  43. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/db/redis/client.py +0 -0
  44. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/http_clients/__init__.py +0 -0
  45. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/http_clients/pricing/__init__.py +0 -0
  46. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/http_clients/uploadchi/__init__.py +0 -0
  47. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/http_clients/uploadchi/uploadchi.py +0 -0
  48. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/http_clients/uploadchi/uploadchi_async.py +0 -0
  49. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/messaging/__init__.py +0 -0
  50. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/messaging/event_bus.py +0 -0
  51. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/messaging/redis.py +0 -0
  52. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/percolation/__init__.py +0 -0
  53. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/adapters/percolation/connection.py +0 -0
  54. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/config/__init__.py +0 -0
  55. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/config/logging.py +0 -0
  56. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/config/settings.py +0 -0
  57. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/container.py +0 -0
  58. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/core/__init__.py +0 -0
  59. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/core/application/__init__.py +0 -0
  60. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/core/application/base_worker.py +0 -0
  61. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/core/application/events.py +0 -0
  62. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/core/application/services/__init__.py +0 -0
  63. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/core/application/services.py +0 -0
  64. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/core/application/use_case.py +0 -0
  65. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/core/domain/__init__.py +0 -0
  66. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/core/domain/base.py +0 -0
  67. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/core/domain/events.py +0 -0
  68. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/core/domain/models.py +0 -0
  69. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/core/ports/__init__.py +0 -0
  70. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/core/ports/auth.py +0 -0
  71. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/core/ports/cache.py +0 -0
  72. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/core/ports/event_bus.py +0 -0
  73. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/core/ports/files.py +0 -0
  74. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/core/ports/messaging.py +0 -0
  75. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/core/ports/repo.py +0 -0
  76. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/core/ports/uow.py +0 -0
  77. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/__init__.py +0 -0
  78. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/api/__init__.py +0 -0
  79. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/api/dependencies/__init__.py +0 -0
  80. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/api/dependencies/async_auth.py +0 -0
  81. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/api/dependencies/db.py +0 -0
  82. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/api/dependencies/uow.py +0 -0
  83. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/api/exception_handlers.py +0 -0
  84. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/api/middleware/__init__.py +0 -0
  85. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/api/middleware/access_log.py +0 -0
  86. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/api/middleware/add_request_context.py +0 -0
  87. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/api/middleware/authentication.py +0 -0
  88. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/api/routers.py +0 -0
  89. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/api/schema/__init__.py +0 -0
  90. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/cli/__init__.py +0 -0
  91. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/cli/init_db.py +0 -0
  92. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/cli/main.py +0 -0
  93. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/jobs/__init__.py +0 -0
  94. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/interfaces/jobs/sync_tokens.py +0 -0
  95. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/types.py +0 -0
  96. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/utils/__init__.py +0 -0
  97. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/utils/cache.py +0 -0
  98. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/utils/cache_keys.py +0 -0
  99. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/utils/cache_registry.py +0 -0
  100. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/utils/context.py +0 -0
  101. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/utils/http.py +0 -0
  102. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/utils/redactor.py +0 -0
  103. {nlbone-0.6.14 → nlbone-0.6.16}/src/nlbone/utils/time.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nlbone
3
- Version: 0.6.14
3
+ Version: 0.6.16
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.6.14"
7
+ version = "0.6.16"
8
8
  description = "Backbone package for interfaces and infrastructure in Python projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -137,13 +137,15 @@ def _to_sql_like_pattern(s: str) -> str:
137
137
 
138
138
  def _parse_field_and_op(field: str):
139
139
  """
140
- Supports 'field__ilike' to force ILIKE.
141
- Returns (base_field, op) where op in {'eq', 'ilike'}
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'}
142
143
  """
143
144
  if "__" in field:
144
145
  base, op = field.rsplit("__", 1)
145
- if op.lower() == "ilike":
146
- return base, "ilike"
146
+ op = op.lower()
147
+ if base and op in {"ilike", "gte", "lte", "lt", "gt", "ne"}:
148
+ return base, op
147
149
  return field, "eq"
148
150
 
149
151
 
@@ -214,10 +216,24 @@ def _apply_filters(pagination, entity, query):
214
216
  patterns = [_to_sql_like_pattern(str(v)) for v in vals]
215
217
  predicates.append(or_(*[col.ilike(p) for p in patterns]))
216
218
  else:
217
- coerced = [coerce(v) for v in vals]
219
+ coerced = [coerce(v) for v in vals if v is not None]
218
220
  if not coerced:
219
221
  continue
220
- predicates.append(col.in_(coerced))
222
+
223
+ if op_hint == "eq":
224
+ predicates.append(col.in_(coerced))
225
+ elif op_hint == "ne":
226
+ predicates.append(or_(*[col != v for v in coerced]))
227
+ elif op_hint == "gt":
228
+ predicates.append(or_(*[col > v for v in coerced]))
229
+ elif op_hint == "gte":
230
+ predicates.append(or_(*[col >= v for v in coerced]))
231
+ elif op_hint == "lt":
232
+ predicates.append(or_(*[col < v for v in coerced]))
233
+ elif op_hint == "lte":
234
+ predicates.append(or_(*[col <= v for v in coerced]))
235
+ else:
236
+ predicates.append(col.in_(coerced))
221
237
  else:
222
238
  if _use_ilike(value) and _is_text_type(coltype):
223
239
  pattern = _to_sql_like_pattern(str(value))
@@ -225,9 +241,25 @@ def _apply_filters(pagination, entity, query):
225
241
  else:
226
242
  v = coerce(value)
227
243
  if v is None:
228
- predicates.append(col.is_(None))
244
+ if op_hint in {"eq", "ilike"}:
245
+ predicates.append(col.is_(None))
246
+ else:
247
+ continue
229
248
  else:
230
- predicates.append(col == v)
249
+ if op_hint == "eq":
250
+ predicates.append(col == v)
251
+ elif op_hint == "ne":
252
+ predicates.append(col != v)
253
+ elif op_hint == "gt":
254
+ predicates.append(col > v)
255
+ elif op_hint == "gte":
256
+ predicates.append(col >= v)
257
+ elif op_hint == "lt":
258
+ predicates.append(col < v)
259
+ elif op_hint == "lte":
260
+ predicates.append(col <= v)
261
+ else:
262
+ predicates.append(col == v)
231
263
 
232
264
  except _InvalidEnum as e:
233
265
  raise UnprocessableEntityException(str(e), loc=["query", "filters", raw_field]) from e
@@ -1,6 +1,6 @@
1
1
  from decimal import Decimal
2
2
  from enum import Enum
3
- from typing import Dict, List, Literal, Optional, Union
3
+ from typing import Any, Dict, List, Literal, Optional, Union
4
4
 
5
5
  import httpx
6
6
  import requests
@@ -16,7 +16,7 @@ class PricingError(Exception):
16
16
 
17
17
 
18
18
  class CalculatePriceIn(BaseModel):
19
- params: dict[str, str]
19
+ params: dict[str, Any]
20
20
  product_id: NonNegativeInt | None = None
21
21
  product_title: str | None = None
22
22
 
@@ -51,8 +51,8 @@ class Formula(BaseModel):
51
51
  id: int
52
52
  title: str
53
53
  key: str
54
- status: str
55
- description: str
54
+ status: str | None
55
+ description: str | None
56
56
 
57
57
 
58
58
  class PricingRule(BaseModel):
@@ -0,0 +1,7 @@
1
+ from nlbone.interfaces.api.additional_filed.assembler import assemble_response
2
+ from nlbone.interfaces.api.additional_filed.field_registry import FieldRule, ResourceRegistry
3
+ from nlbone.interfaces.api.additional_filed.resolver import (
4
+ AdditionalFieldsRequest,
5
+ build_query_plan,
6
+ resolve_requested_fields,
7
+ )
@@ -0,0 +1,54 @@
1
+ import inspect
2
+ from typing import Any, Dict
3
+
4
+ from pydantic import BaseModel
5
+
6
+ from nlbone.container import Container
7
+ from nlbone.interfaces.api.additional_filed.field_registry import FieldRule, ResourceRegistry
8
+
9
+
10
+ def assemble_response(
11
+ obj: Any,
12
+ reg: ResourceRegistry,
13
+ selected_rules: Dict[str, FieldRule],
14
+ base_schema: type[BaseModel] | None,
15
+ session,
16
+ ) -> Dict[str, Any]:
17
+ base = {f: getattr(obj, f, None) for f in reg.default_fields - set(reg.rules.keys())}
18
+ if base_schema:
19
+ base = base_schema.model_validate(base).model_dump()
20
+
21
+ ctx = {"file_service": Container.file_service(), "entity": obj, "db": session}
22
+ for name, rule in selected_rules.items():
23
+ if rule.loader:
24
+ value = inject_dependencies(rule.loader, dependencies=ctx)
25
+ else:
26
+ value = _get_nested_attr(obj, name)
27
+ _put_nested_key(base, name, value)
28
+
29
+ return base
30
+
31
+
32
+ def inject_dependencies(handler, dependencies):
33
+ params = inspect.signature(handler).parameters
34
+ deps = {name: dependency for name, dependency in dependencies.items() if name in params}
35
+ return handler(**deps)
36
+
37
+
38
+ def _get_nested_attr(obj: Any, dotted: str):
39
+ cur = obj
40
+ for part in dotted.split("."):
41
+ if cur is None:
42
+ return None
43
+ cur = getattr(cur, part, None)
44
+ return cur
45
+
46
+
47
+ def _put_nested_key(base: Dict[str, Any], dotted: str, value: Any):
48
+ parts = dotted.split(".")
49
+ cur = base
50
+ for p in parts[:-1]:
51
+ if p not in cur or not isinstance(cur[p], dict):
52
+ cur[p] = {}
53
+ cur = cur[p]
54
+ cur[parts[-1]] = value
@@ -0,0 +1 @@
1
+ from .image_field_rules import IMAGE_FILED_RULE
@@ -0,0 +1,13 @@
1
+ from nlbone.interfaces.api.additional_filed import FieldRule
2
+
3
+
4
+ def get_image(entity, file_service):
5
+ image_id = entity.image_id
6
+ if image_id:
7
+ try:
8
+ return file_service.get_file(image_id)
9
+ except Exception:
10
+ return None
11
+
12
+
13
+ IMAGE_FILED_RULE = FieldRule(default=True, name="image", loader=get_image)
@@ -0,0 +1,105 @@
1
+ from dataclasses import dataclass, field
2
+ from enum import Enum
3
+ from typing import Any, Callable, Dict, Iterable, Optional, Set, Tuple, Type
4
+
5
+ from pydantic import BaseModel
6
+
7
+ PermissionChecker = Callable[[Any, str], bool]
8
+ Loader = Callable
9
+
10
+
11
+ def _schema_fields(schema: Type[BaseModel], by_alias: bool = True) -> Set[str]:
12
+ names = set()
13
+ for name, f in schema.model_fields.items():
14
+ names.add(f.alias or name if by_alias else name)
15
+ return names
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class FieldRule:
20
+ """
21
+ name: field name (nested with dot)
22
+ permission: None for public
23
+ deps: dependencies
24
+ columns: relationship (SQLAlchemy Column/InstrumentedAttribute)
25
+ join_paths: required paths for eager-load (ex. Product.supplier, Supplier.address)
26
+ loader:
27
+ """
28
+
29
+ name: str
30
+ default: bool = False
31
+ permission: Optional[str] = None
32
+ deps: Tuple[str, ...] = ()
33
+ columns: Tuple[Any, ...] = ()
34
+ join_paths: Tuple[Any, ...] = ()
35
+ loader: Optional[Loader] = None
36
+
37
+
38
+ class DefaultsMergeMode(str, Enum):
39
+ STRICT = "strict" # schema == rules; else raise
40
+ SCHEMA_PRIORITY = "schema" # defaults = schema_fields
41
+ RULES_PRIORITY = "rules" # defaults = rules_default_fields
42
+ UNION = "union" # defaults = schema ∪ rules
43
+ INTERSECTION = "intersection" # defaults = schema ∩ rules
44
+
45
+
46
+ @dataclass
47
+ class ResourceRegistry:
48
+ resource: str
49
+ default_fields: Set[str] = field(default_factory=set)
50
+ base_schema: Optional[Type[BaseModel]] = None
51
+ rules: Dict[str, FieldRule] = field(default_factory=dict)
52
+ bundles: Dict[str, Set[str]] = field(default_factory=dict)
53
+ _schema_fields_cache: Set[str] = field(default_factory=set, repr=False)
54
+
55
+ def add_rule(self, rule: FieldRule) -> "ResourceRegistry":
56
+ self.rules[rule.name] = rule
57
+ return self
58
+
59
+ def add_bundle(self, bundle: str, items: Iterable[str]) -> "ResourceRegistry":
60
+ self.bundles[bundle] = set(items)
61
+ return self
62
+
63
+ def set_defaults(self, fields: Iterable[str]) -> "ResourceRegistry":
64
+ self.default_fields = set(fields)
65
+ return self
66
+
67
+ def recompute_defaults_from_rules(self) -> "ResourceRegistry":
68
+ self.default_fields = {r.name for r in self.rules.values() if r.default}
69
+ return self
70
+
71
+ def from_paydantc(self, schema: Type[BaseModel], *, by_alias: bool = True) -> "ResourceRegistry":
72
+ self.base_schema = schema
73
+ self._schema_fields_cache = _schema_fields(schema, by_alias=by_alias)
74
+ return self
75
+
76
+ def finalize_defaults(self, mode: DefaultsMergeMode = DefaultsMergeMode.UNION) -> "ResourceRegistry":
77
+ schema_set: Set[str] = self._schema_fields_cache if self.base_schema else set()
78
+ rules_set: Set[str] = {r.name for r in self.rules.values() if r.default}
79
+
80
+ manual_set: Set[str] = set(self.default_fields)
81
+
82
+ if mode == DefaultsMergeMode.STRICT:
83
+ if self.base_schema is None or not rules_set:
84
+ base = schema_set or rules_set
85
+ else:
86
+ if schema_set != rules_set:
87
+ raise ValueError(
88
+ f"[{self.resource}] Default fields diverged: "
89
+ f"schema={sorted(schema_set)} vs rules={sorted(rules_set)}"
90
+ )
91
+ base = schema_set
92
+ elif mode == DefaultsMergeMode.SCHEMA_PRIORITY:
93
+ base = schema_set or rules_set
94
+ elif mode == DefaultsMergeMode.RULES_PRIORITY:
95
+ base = rules_set or schema_set
96
+ elif mode == DefaultsMergeMode.UNION:
97
+ base = schema_set | rules_set
98
+ elif mode == DefaultsMergeMode.INTERSECTION:
99
+ base = schema_set & rules_set
100
+ else:
101
+ base = schema_set or rules_set
102
+ final = base | manual_set
103
+
104
+ self.default_fields = final
105
+ return self
@@ -0,0 +1,132 @@
1
+ from typing import Dict, List, Set, Tuple
2
+
3
+ from nlbone.interfaces.api.additional_filed.field_registry import (
4
+ DefaultsMergeMode,
5
+ FieldRule,
6
+ PermissionChecker,
7
+ ResourceRegistry,
8
+ )
9
+ from nlbone.interfaces.api.dependencies.auth import client_or_user_has_access_func
10
+ from nlbone.interfaces.api.exceptions import BadRequestException, InternalServerException
11
+
12
+ MAX_FIELDS = 50
13
+ MAX_BUNDLES = 20
14
+
15
+
16
+ class AdditionalFieldsRequest:
17
+ """
18
+ FastAPI dependency
19
+ - fields: ?fields=rating,costPrice,supplier.address.city
20
+ - bundles: ?bundles=@analytics,@internal
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ fields: str | None = None,
26
+ bundles: str | None = None,
27
+ ) -> None:
28
+ self.fields = self._parse_csv(fields)[:MAX_FIELDS]
29
+ self.bundles = self._parse_csv(bundles)[:MAX_BUNDLES]
30
+
31
+ @staticmethod
32
+ def _parse_csv(s: str | None) -> List[str]:
33
+ if not s:
34
+ return []
35
+ return [x.strip() for x in s.split(",") if x.strip()]
36
+
37
+
38
+ def resolve_requested_fields(
39
+ reg: ResourceRegistry,
40
+ additional_fields: AdditionalFieldsRequest = None,
41
+ can: PermissionChecker = None,
42
+ mode: DefaultsMergeMode = DefaultsMergeMode.UNION,
43
+ ) -> Tuple[Set[str], Dict[str, FieldRule]]:
44
+ if not additional_fields:
45
+ additional_fields = AdditionalFieldsRequest()
46
+ if not can:
47
+ can = client_or_user_has_access_func
48
+ reg.finalize_defaults(mode=mode)
49
+ expanded_from_bundles = _expand_bundles(reg, set(additional_fields.bundles))
50
+
51
+ requested = set(reg.default_fields) | set(additional_fields.fields) | expanded_from_bundles
52
+
53
+ # validation
54
+ unknown = {f for f in requested if (f not in reg.default_fields and f not in reg.rules)}
55
+ if unknown:
56
+ raise BadRequestException(f"unknown_fields: {sorted(unknown)}")
57
+
58
+ # permission
59
+ selected_rules: Dict[str, FieldRule] = {}
60
+ for f in requested:
61
+ rule = reg.rules.get(f)
62
+ if rule and rule.permission:
63
+ try:
64
+ if not can(rule.permission):
65
+ continue
66
+ except Exception:
67
+ continue
68
+
69
+ if f in reg.default_fields and f not in list(reg.rules.keys()):
70
+ continue
71
+
72
+ if not rule.default and f not in additional_fields.fields:
73
+ continue
74
+ selected_rules[f] = rule
75
+
76
+ # dependencies
77
+ final = set(requested)
78
+
79
+ def add_deps(name: str):
80
+ if name in reg.default_fields:
81
+ return
82
+ r = reg.rules.get(name)
83
+ if not r:
84
+ return
85
+ for d in r.deps:
86
+ final.add(d)
87
+ add_deps(d)
88
+
89
+ for f in list(selected_rules.keys()):
90
+ add_deps(f)
91
+
92
+ # validate deps too
93
+ unknown_deps = {d for d in final if (d not in reg.default_fields and d not in reg.rules)}
94
+ if unknown_deps:
95
+ raise InternalServerException(f"registry_missing_rules_for: {sorted(unknown_deps)}")
96
+
97
+ # rules
98
+ return final, selected_rules
99
+
100
+
101
+ def _expand_bundles(reg: ResourceRegistry, bundles: Set[str]) -> Set[str]:
102
+ out: Set[str] = set()
103
+ seen_bundles: Set[str] = set()
104
+
105
+ def dfs(b: str):
106
+ if b not in reg.bundles:
107
+ raise BadRequestException(f"Unknown bundle: {b}")
108
+ if b in seen_bundles:
109
+ return
110
+ seen_bundles.add(b)
111
+ for item in reg.bundles[b]:
112
+ if item.startswith("@"):
113
+ dfs(item)
114
+ else:
115
+ out.add(item)
116
+
117
+ for b in bundles:
118
+ b = b.lstrip("@")
119
+ dfs(b)
120
+ return out
121
+
122
+
123
+ def build_query_plan(
124
+ reg: ResourceRegistry,
125
+ selected_rules: Dict[str, FieldRule],
126
+ ):
127
+ columns = []
128
+ joins = []
129
+ for r in selected_rules.values():
130
+ columns.extend(r.columns or [])
131
+ joins.extend(r.join_paths or [])
132
+ return columns, joins
@@ -20,14 +20,18 @@ def current_client_id() -> str:
20
20
  raise UnauthorizedException()
21
21
 
22
22
 
23
+ def client_has_access_func(*, permissions=None):
24
+ request = current_request()
25
+ if not KeycloakAuthService().client_has_access(request.state.token, permissions=permissions):
26
+ raise ForbiddenException(f"Forbidden {permissions}")
27
+ return True
28
+
29
+
23
30
  def client_has_access(*, permissions=None):
24
31
  def decorator(func):
25
32
  @functools.wraps(func)
26
33
  def wrapper(*args, **kwargs):
27
- request = current_request()
28
- if not KeycloakAuthService().client_has_access(request.state.token, permissions=permissions):
29
- raise ForbiddenException(f"Forbidden {permissions}")
30
-
34
+ client_has_access_func(permissions=permissions)
31
35
  return func(*args, **kwargs)
32
36
 
33
37
  return wrapper
@@ -45,18 +49,22 @@ def user_authenticated(func):
45
49
  return wrapper
46
50
 
47
51
 
52
+ def user_has_access_func(*, permissions=None):
53
+ request = current_request()
54
+ if not current_user_id():
55
+ raise UnauthorizedException()
56
+ user_permissions = get_auth_service().get_permissions(request.state.token)
57
+ for p in permissions or []:
58
+ if p not in user_permissions:
59
+ raise ForbiddenException(f"Forbidden {permissions}")
60
+ return True
61
+
62
+
48
63
  def has_access(*, permissions=None):
49
64
  def decorator(func):
50
65
  @functools.wraps(func)
51
66
  def wrapper(*args, **kwargs):
52
- request = current_request()
53
- if not current_user_id():
54
- raise UnauthorizedException()
55
- user_permissions = get_auth_service().get_permissions(request.state.token)
56
- for p in permissions or []:
57
- if p not in user_permissions:
58
- raise ForbiddenException(f"Forbidden {permissions}")
59
-
67
+ user_has_access_func(permissions=permissions)
60
68
  return func(*args, **kwargs)
61
69
 
62
70
  return wrapper
@@ -64,30 +72,23 @@ def has_access(*, permissions=None):
64
72
  return decorator
65
73
 
66
74
 
75
+ def client_or_user_has_access_func(permissions=None, client_permissions=None):
76
+ request = current_request()
77
+ token = getattr(request.state, "token", None)
78
+ if not token:
79
+ raise UnauthorizedException()
80
+ needed = client_permissions or permissions
81
+ if client_has_access_func(permissions=needed):
82
+ return
83
+ if user_has_access_func(permissions=needed):
84
+ return
85
+
86
+
67
87
  def client_or_user_has_access(*, permissions=None, client_permissions=None):
68
88
  def decorator(func):
69
89
  @functools.wraps(func)
70
90
  def wrapper(*args, **kwargs):
71
- request = current_request()
72
- token = getattr(request.state, "token", None)
73
- if not token:
74
- raise UnauthorizedException()
75
-
76
- auth = KeycloakAuthService()
77
-
78
- if auth.get_client_id(token):
79
- needed = client_permissions or permissions
80
- if not auth.client_has_access(token, permissions=needed):
81
- raise ForbiddenException(f"Forbidden (client) {needed}")
82
- else:
83
- if not current_user_id():
84
- raise UnauthorizedException()
85
-
86
- user_permissions = get_auth_service().get_permissions(request.state.token)
87
- for p in permissions or []:
88
- if p not in user_permissions:
89
- raise ForbiddenException(f"Forbidden {permissions}")
90
-
91
+ client_or_user_has_access_func(permissions=permissions, client_permissions=client_permissions)
91
92
  return func(*args, **kwargs)
92
93
 
93
94
  return wrapper
@@ -67,6 +67,14 @@ class NotSupportedException(BaseHttpException):
67
67
  )
68
68
 
69
69
 
70
+ class InternalServerException(BaseHttpException):
71
+ def __init__(self, detail: str = "internal_server_error"):
72
+ super().__init__(
73
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
74
+ detail=detail,
75
+ )
76
+
77
+
70
78
  class UnprocessableEntityException(BaseHttpException):
71
79
  def __init__(self, detail: str, loc: Iterable[Any] | None = None, type_: str = "unprocessable_entity"):
72
80
  super().__init__(
@@ -14,6 +14,6 @@ T = TypeVar("T")
14
14
 
15
15
 
16
16
  class Paginated(BaseModel, Generic[T]):
17
- total_count: Optional[int]
18
- total_page: Optional[int]
17
+ total_count: Optional[int] = None
18
+ total_page: Optional[int] = None
19
19
  data: List[T]
@@ -13,20 +13,20 @@ class PaginateRequest:
13
13
  """
14
14
 
15
15
  def __init__(
16
- self,
17
- limit: int = 10,
18
- offset: int = 0,
19
- sort: Optional[str] = None,
20
- filters: Optional[str] = Query(None, description="e.g. title:abc"),
21
- include: Optional[str] = None,
16
+ self,
17
+ limit: int = 10,
18
+ offset: int = 0,
19
+ sort: Optional[str] = None,
20
+ filters: Optional[str] = Query(None, description="e.g. title:abc"),
21
+ include: Optional[str] = None,
22
22
  ) -> None:
23
23
  self.limit = max(0, limit)
24
24
  self.offset = max(0, offset)
25
25
  self.sort = self._parse_sort(sort)
26
26
  self.filters = self._parse_filters(filters or "")
27
27
  self.include_ids: List[int] = ([int(x) for x in include.split(",") if x.strip().isdigit()] if include else [])[
28
- :50
29
- ]
28
+ :50
29
+ ]
30
30
 
31
31
  @staticmethod
32
32
  def _parse_sort(sort_str: Optional[str]) -> list[dict[str, str]]:
@@ -78,6 +78,9 @@ class PaginateRequest:
78
78
  filters_dict[key] = value_cast
79
79
  return filters_dict
80
80
 
81
+ def remove_deleted(self, deleted_at_field: str = 'deleted_at'):
82
+ self.filters = self.filters | {'deleted_at': None}
83
+
81
84
 
82
85
  class PaginateResponse:
83
86
  """
@@ -85,12 +88,12 @@ class PaginateResponse:
85
88
  """
86
89
 
87
90
  def __init__(
88
- self,
89
- data: list[Any],
90
- total_count: int | None,
91
- limit: int,
92
- offset: int,
93
- use_data_key: bool = True,
91
+ self,
92
+ data: list[Any],
93
+ total_count: int | None,
94
+ limit: int,
95
+ offset: int,
96
+ use_data_key: bool = True,
94
97
  ) -> None:
95
98
  self.data = data
96
99
  self.total_count = total_count
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from enum import Enum
5
+ from typing import Any, Iterable, Optional, Type, TypeVar, Union, get_args, get_origin
6
+
7
+ from pydantic import BaseModel, TypeAdapter
8
+
9
+
10
+ class ResponsePreference(str, Enum):
11
+ minimal = "minimal"
12
+ lite = "lite"
13
+ full = "full"
14
+
15
+
16
+ M = TypeVar("M", bound=BaseModel)
17
+
18
+
19
+ class AdaptiveSchemaBase(ABC):
20
+ """Base for a *family* of Pydantic schemas of the same resource."""
21
+
22
+ @classmethod
23
+ @abstractmethod
24
+ def minimal(cls) -> Type[M]: ...
25
+ @classmethod
26
+ @abstractmethod
27
+ def lite(cls) -> Type[M]: ...
28
+ @classmethod
29
+ @abstractmethod
30
+ def full(cls) -> Type[M]: ...
31
+
32
+ @classmethod
33
+ def choose(cls, pref: Optional[ResponsePreference]) -> Type[M]:
34
+ if pref is None:
35
+ pref = ResponsePreference.lite
36
+ return {
37
+ ResponsePreference.minimal: cls.minimal(),
38
+ ResponsePreference.lite: cls.lite(),
39
+ ResponsePreference.full: cls.full(),
40
+ }.get(pref, cls.lite())
41
+
42
+ @classmethod
43
+ def serialize(cls, obj: Any, pref: Optional[ResponsePreference] = None) -> Any:
44
+ schema = cls.choose(pref)
45
+ adapter = TypeAdapter(schema)
46
+
47
+ if _is_iterable(obj):
48
+ return [cls._serialize_one(adapter, schema, x, pref) for x in obj]
49
+ return cls._serialize_one(adapter, schema, obj, pref)
50
+
51
+ @classmethod
52
+ def _serialize_one(
53
+ cls, adapter: TypeAdapter, schema_cls: Type[BaseModel], item: Any, pref: Optional[ResponsePreference]
54
+ ) -> dict:
55
+ model = adapter.validate_python(item)
56
+ data = model.model_dump()
57
+
58
+ annotations = getattr(schema_cls, "__annotations__", {})
59
+ for field_name, annotated_type in annotations.items():
60
+ if field_name not in data:
61
+ continue
62
+ value = getattr(item, field_name, None)
63
+ if value is None:
64
+ continue
65
+
66
+ if _is_adaptive(annotated_type):
67
+ # تک‌شیء
68
+ data[field_name] = annotated_type.serialize(value, pref)
69
+ elif _is_list_of_adaptive(annotated_type):
70
+ data[field_name] = annotated_type.__args__[0].serialize(value, pref)
71
+ elif _is_optional_of_adaptive(annotated_type):
72
+ inner = _unwrap_optional(annotated_type)
73
+ data[field_name] = None if value is None else inner.serialize(value, pref)
74
+
75
+ return data
76
+
77
+
78
+ # ---------- helpers ----------
79
+ def _is_iterable(x: Any) -> bool:
80
+ if isinstance(x, (str, bytes, dict)):
81
+ return False
82
+ try:
83
+ iter(x)
84
+ return True
85
+ except TypeError:
86
+ return False
87
+
88
+
89
+ def _is_adaptive(t) -> bool:
90
+ return isinstance(t, type) and issubclass(t, AdaptiveSchemaBase)
91
+
92
+
93
+ def _is_list_of_adaptive(t) -> bool:
94
+ origin = get_origin(t)
95
+ if origin not in (
96
+ list,
97
+ list.__class__,
98
+ Iterable,
99
+ ):
100
+ return False
101
+ args = get_args(t)
102
+ return len(args) == 1 and _is_adaptive(args[0])
103
+
104
+
105
+ def _is_optional_of_adaptive(t) -> bool:
106
+ return (
107
+ get_origin(t) is Union
108
+ and len([a for a in get_args(t) if a is not type(None)]) == 1
109
+ and _is_adaptive([a for a in get_args(t) if a is not type(None)][0])
110
+ )
111
+
112
+
113
+ def _unwrap_optional(t):
114
+ return [a for a in get_args(t) if a is not type(None)][0]
@@ -0,0 +1,22 @@
1
+
2
+ from pydantic import BaseModel, ConfigDict, model_serializer
3
+
4
+ EXCLUDE_NONE = "exclude_none"
5
+
6
+
7
+ class BaseResponseModel(BaseModel):
8
+ model_config = ConfigDict(from_attributes=True)
9
+
10
+ @model_serializer(mode="wrap")
11
+ def _auto_hide_none_fields(self, handler):
12
+ data = handler(self)
13
+
14
+ hide_candidates = {
15
+ name for name, f in self.model_fields.items() if (f.json_schema_extra or {}).get(EXCLUDE_NONE) is True
16
+ }
17
+
18
+ for key in list(data.keys()):
19
+ if key in hide_candidates and data[key] is None:
20
+ data.pop(key)
21
+
22
+ return data
@@ -0,0 +1,5 @@
1
+ from nlbone.interfaces.api.schema import BaseResponseModel
2
+
3
+
4
+ class FileOut(BaseResponseModel):
5
+ url: str
@@ -1,78 +0,0 @@
1
- from abc import ABC, abstractmethod
2
- from enum import Enum
3
- from typing import Any, List, Type, get_args, get_origin
4
-
5
- from pydantic import BaseModel
6
-
7
- from nlbone.utils.context import current_request
8
-
9
-
10
- class ResponsePreference(str, Enum):
11
- minimal = "minimal"
12
- lite = "lite"
13
- full = "full"
14
-
15
-
16
- class AdaptiveSchemaBase(ABC):
17
- @classmethod
18
- @abstractmethod
19
- def minimal(cls) -> Type:
20
- pass
21
-
22
- @classmethod
23
- @abstractmethod
24
- def lite(cls) -> Type:
25
- pass
26
-
27
- @classmethod
28
- @abstractmethod
29
- def full(cls) -> Type:
30
- pass
31
-
32
- @classmethod
33
- def choose(cls, preference: ResponsePreference) -> Type:
34
- if preference == ResponsePreference.minimal:
35
- return cls.minimal()
36
- elif preference == ResponsePreference.lite:
37
- return cls.lite()
38
- elif preference == ResponsePreference.full:
39
- return cls.full()
40
- return cls.lite()
41
-
42
- @classmethod
43
- def serialize(cls, obj: Any, preference: ResponsePreference = None) -> Any:
44
- if not preference:
45
- preference = current_request().state.response_preference
46
-
47
- schema = cls.choose(preference)
48
-
49
- if isinstance(obj, list):
50
- return [cls._recursive_serialize(schema, item) for item in obj]
51
-
52
- return cls._recursive_serialize(schema, obj)
53
-
54
- @classmethod
55
- def _recursive_serialize(cls, schema_cls: type[BaseModel], item: Any) -> dict:
56
- # Create base dict from ORM model
57
- raw_data = schema_cls.model_validate(item).model_dump()
58
-
59
- # Check each field to see if it needs nested serialization
60
- annotations = schema_cls.__annotations__
61
-
62
- for field_name, field_type in annotations.items():
63
- field_value = getattr(item, field_name, None)
64
-
65
- if field_value is None:
66
- continue
67
-
68
- # Check for nested AdaptiveSchemaBase (single object)
69
- if isinstance(field_type, type) and issubclass(field_type, AdaptiveSchemaBase):
70
- raw_data[field_name] = field_type.serialize(field_value)
71
-
72
- # Check for list[AdaptiveSchemaBase]
73
- elif get_origin(field_type) in (list, List):
74
- sub_type = get_args(field_type)[0]
75
- if isinstance(sub_type, type) and issubclass(sub_type, AdaptiveSchemaBase):
76
- raw_data[field_name] = sub_type.serialize(field_value)
77
-
78
- return raw_data
@@ -1,5 +0,0 @@
1
- from pydantic import BaseModel
2
-
3
-
4
- class BaseResponseModel(BaseModel):
5
- pass
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes