easyapi-django 0.34__tar.gz → 0.36__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 (81) hide show
  1. {easyapi_django-0.34/easyapi_django.egg-info → easyapi_django-0.36}/PKG-INFO +88 -12
  2. {easyapi_django-0.34 → easyapi_django-0.36}/README.md +86 -10
  3. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/auth.py +15 -3
  4. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/base.py +118 -89
  5. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/calc.py +0 -3
  6. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/client_ip.py +3 -4
  7. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/filters.py +0 -1
  8. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/middleware.py +5 -3
  9. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/openapi.py +3 -4
  10. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/rate_limit.py +49 -43
  11. easyapi_django-0.36/easyapi/redis_config.py +162 -0
  12. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/routes.py +2 -4
  13. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/security.py +12 -29
  14. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/serializer.py +1 -2
  15. easyapi_django-0.36/easyapi/settings_helper.py +49 -0
  16. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/tenant/db_router.py +0 -2
  17. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/tenant/tenant.py +6 -10
  18. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/ws.py +2 -5
  19. {easyapi_django-0.34 → easyapi_django-0.36/easyapi_django.egg-info}/PKG-INFO +88 -12
  20. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi_django.egg-info/SOURCES.txt +3 -0
  21. {easyapi_django-0.34 → easyapi_django-0.36}/pyproject.toml +8 -3
  22. easyapi_django-0.36/tests/test_allowed_domain.py +50 -0
  23. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_api_key_resolver.py +4 -2
  24. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_auth.py +17 -0
  25. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_block.py +24 -1
  26. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_cache.py +89 -0
  27. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_openapi.py +2 -2
  28. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_redis_config.py +48 -3
  29. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_serializer.py +11 -0
  30. easyapi_django-0.36/tests/test_settings_helper.py +80 -0
  31. easyapi_django-0.34/easyapi/redis_config.py +0 -106
  32. {easyapi_django-0.34 → easyapi_django-0.36}/LICENSE +0 -0
  33. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/__init__.py +0 -0
  34. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/blocking.py +0 -0
  35. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/calc_resource.py +0 -0
  36. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/constants.py +0 -0
  37. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/dates.py +0 -0
  38. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/exception.py +0 -0
  39. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/helpers.py +0 -0
  40. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/management/__init__.py +0 -0
  41. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/management/commands/__init__.py +0 -0
  42. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/management/commands/mcp_serve.py +0 -0
  43. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/mcp/__init__.py +0 -0
  44. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/mcp/bridge.py +0 -0
  45. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/mcp/protocol.py +0 -0
  46. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/mcp/resource.py +0 -0
  47. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/mcp/tools.py +0 -0
  48. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/orm/__init__.py +0 -0
  49. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/schemas.py +0 -0
  50. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/tenant/__init__.py +0 -0
  51. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/tenant/registry.py +0 -0
  52. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/util.py +0 -0
  53. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi_django.egg-info/dependency_links.txt +0 -0
  54. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi_django.egg-info/requires.txt +0 -0
  55. {easyapi_django-0.34 → easyapi_django-0.36}/easyapi_django.egg-info/top_level.txt +0 -0
  56. {easyapi_django-0.34 → easyapi_django-0.36}/setup.cfg +0 -0
  57. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_base_init.py +0 -0
  58. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_cache_auto_account_scope.py +0 -0
  59. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_cache_scope.py +0 -0
  60. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_client_ip.py +0 -0
  61. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_dispatch_error_handling.py +0 -0
  62. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_exception_render.py +0 -0
  63. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_exports.py +0 -0
  64. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_filter_validation.py +0 -0
  65. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_filters.py +0 -0
  66. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_get_obj_m2m_only.py +0 -0
  67. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_helpers.py +0 -0
  68. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_index_route.py +0 -0
  69. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_mcp.py +0 -0
  70. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_mcp_middleware_chain.py +0 -0
  71. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_normalize_field.py +0 -0
  72. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_owner_field.py +0 -0
  73. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_return_result_falsy.py +0 -0
  74. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_routes_gate.py +0 -0
  75. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_schemas.py +0 -0
  76. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_security.py +0 -0
  77. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_tenant_connection.py +0 -0
  78. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_tenant_registry.py +0 -0
  79. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_util.py +0 -0
  80. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_ws_channels.py +0 -0
  81. {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_ws_optional.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: easyapi_django
3
- Version: 0.34
3
+ Version: 0.36
4
4
  Summary: A simple rest api generator for django based on models
5
5
  Author-email: Stamatios Stamou Jr <bushier.outsets.0c@icloud.com>
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/ssjunior/easyapi-django
8
8
  Project-URL: Bug Tracker, https://github.com/ssjunior/easyapi-django/issues
9
9
  Classifier: Programming Language :: Python :: 3
@@ -75,7 +75,11 @@ MIDDLEWARE = [
75
75
  'easyapi.AuthMiddleware', # session-based auth from Redis
76
76
  'easyapi.ExceptionMiddleware',
77
77
  ]
78
- TRUSTED_PROXIES = ['10.0.0.0/8'] # only trust X-Real-IP from these
78
+
79
+ EASYAPI = {
80
+ 'TRUSTED_PROXIES': ['10.0.0.0/8'], # only trust X-Real-IP from these
81
+ # 'COOKIE_ID': 'sessionid', 'ENFORCE_TOKEN': True, ...
82
+ }
79
83
  ```
80
84
 
81
85
  ## Create a resource
@@ -141,7 +145,7 @@ class YourResource(BaseResource):
141
145
 
142
146
  # Cache
143
147
  cache = True
144
- cache_ttl = 600
148
+ cache_ttl = 600 # default 120s; settings.CACHE_TTL overrides
145
149
  ```
146
150
 
147
151
  ## Querystrings
@@ -153,10 +157,48 @@ class YourResource(BaseResource):
153
157
  | `?field=value` / `?field__gte=...` | Filter on whitelisted fields |
154
158
  | `?fields=a,b` | Restrict returned fields (filtered by `list_fields`) |
155
159
  | `?filter=<json>` | Advanced filter expression on whitelisted fields |
156
- | `?segment_id=N` | Apply a stored segment |
160
+ | `?segment_id=N` | Apply a saved segment (see below) |
157
161
  | `?page=N&limit=M&order_by=field` | Pagination + order |
158
162
  | `?normalize=true` | Return list as `{id: {...}}` instead of array |
159
163
 
164
+ ## Saved segments
165
+
166
+ A segment is a saved JSON filter expression — the same boolean tree
167
+ `?filter=<json>` accepts, stored under a stable id. Useful for CRM-style
168
+ "saved views", marketing audiences, dashboard filters.
169
+
170
+ Wire it up by pointing `EASYAPI.SEGMENT_MODEL` at a model that exposes a
171
+ `.conditions` attribute returning the Layer-2 dict:
172
+
173
+ ```python
174
+ # settings.py
175
+ EASYAPI = {
176
+ 'SEGMENT_MODEL': 'modules.segment.models.Segment',
177
+ }
178
+
179
+ # modules/segment/models.py
180
+ class Segment(models.Model):
181
+ name = models.CharField(max_length=120)
182
+ conditions = models.JSONField() # the Layer-2 boolean tree
183
+
184
+ Segment.objects.create(
185
+ name='Active demo accounts',
186
+ conditions={
187
+ 'logical_operator': 'AND',
188
+ 'rules': [
189
+ {'field': 'active', 'operator': 'exact', 'value': True},
190
+ {'field': 'name', 'operator': 'icontains', 'value': 'demo'},
191
+ ],
192
+ },
193
+ )
194
+ ```
195
+
196
+ Any resource then accepts `GET /clients?segment_id=42`. Conditions are
197
+ validated against the resource's `filter_fields` whitelist; missing rows
198
+ return 404. When `SEGMENT_MODEL` is unset, `?segment_id=` is a no-op. A
199
+ bad path raises `ImportError` at first use — typos fail loudly instead of
200
+ silently disabling segments.
201
+
160
202
  ## Pydantic schemas (optional)
161
203
 
162
204
  Set any of `create_schema`, `update_schema`, `list_schema` and easyapi
@@ -256,13 +298,45 @@ configuration needed; it just works for any project that uses
256
298
  `aset_tenant`. The auto-fold is keyed by `account_id is not None`, so an
257
299
  explicit `account_id = 0` still produces a per-tenant key (real value,
258
300
  not absence). Disable globally via
259
- `AUTO_SCOPE_CACHE_BY_ACCOUNT = False` if you have a single-tenant
260
- deployment and want the legacy key shape.
301
+ `EASYAPI = {'AUTO_SCOPE_CACHE_BY_ACCOUNT': False}` if you have a
302
+ single-tenant deployment and want the legacy key shape.
261
303
 
262
304
  If you override `_build_cache_key` in a project, call
263
305
  `self._account_cache_segment()` and append the result so the override
264
306
  inherits the tenant isolation.
265
307
 
308
+ **TTL settings.** Two project-level knobs in the `EASYAPI` bag:
309
+
310
+ - `CACHE_TTL` — default 120s; overrides the framework default for
311
+ resources that don't declare an explicit `cache_ttl`.
312
+ - `CACHE_TTL_ENABLE` — default `True`; flip to `False` for a global
313
+ kill switch (every `cache=True` resource becomes `cache=False` at
314
+ runtime, no Redis read or write).
315
+
316
+ Every easyapi setting lives inside the `EASYAPI = {...}` dict
317
+ (DRF/Celery-style namespace):
318
+
319
+ ```python
320
+ # settings.py
321
+ EASYAPI = {
322
+ 'CACHE_TTL': 300,
323
+ 'CACHE_TTL_ENABLE': True,
324
+ 'ENFORCE_TOKEN': True,
325
+ 'COOKIE_ID': 'sessionid',
326
+ 'RATE_LIMITS': {...},
327
+ }
328
+ ```
329
+
330
+ Inside the bag the historical `EASYAPI_` prefix is redundant —
331
+ `EASYAPI_API_KEY_RESOLVER` and `API_KEY_RESOLVER` resolve to the
332
+ same setting.
333
+
334
+ `CACHE_TTL` only sets the default — resources that declare
335
+ `cache_ttl = N` keep that explicit value.
336
+ `CACHE_TTL_ENABLE = False` is a kill switch that forces
337
+ `self.cache = False` for every request, useful for incident response
338
+ without code edits.
339
+
266
340
  **Per-scope caching.** When the response varies on a user/account
267
341
  dimension *inside* the same tenant — role, space, plan, country —
268
342
  declare it with `cache_scope_fields` so users sharing the same scope
@@ -482,11 +556,13 @@ pip install -r requirements-dev.txt
482
556
  pytest
483
557
  ```
484
558
 
485
- 216 tests covering util, redis, cache, filters, filter validation, init,
486
- auth tokens (incl. nonce replay), schemas, openapi, helpers, serializer,
487
- client_ip, SecurityMiddleware, dispatch error handling, tenant connection
488
- and registry, MCP middleware chain, route gating, WS subscription
489
- hardening, public exports, and WebSocket optional import.
559
+ 301 tests covering util, redis, cache (incl. per-account auto-fold and
560
+ per-scope keys), filters, filter validation, init, auth tokens (incl.
561
+ nonce replay), schemas, openapi, helpers, serializer (incl. per-call
562
+ timezone subclass), client_ip, allowed-domain checks, SecurityMiddleware,
563
+ dispatch error handling, tenant connection and registry, MCP middleware
564
+ chain, route gating, WS subscription hardening, public exports, and
565
+ WebSocket optional import.
490
566
 
491
567
  ## Author
492
568
 
@@ -48,7 +48,11 @@ MIDDLEWARE = [
48
48
  'easyapi.AuthMiddleware', # session-based auth from Redis
49
49
  'easyapi.ExceptionMiddleware',
50
50
  ]
51
- TRUSTED_PROXIES = ['10.0.0.0/8'] # only trust X-Real-IP from these
51
+
52
+ EASYAPI = {
53
+ 'TRUSTED_PROXIES': ['10.0.0.0/8'], # only trust X-Real-IP from these
54
+ # 'COOKIE_ID': 'sessionid', 'ENFORCE_TOKEN': True, ...
55
+ }
52
56
  ```
53
57
 
54
58
  ## Create a resource
@@ -114,7 +118,7 @@ class YourResource(BaseResource):
114
118
 
115
119
  # Cache
116
120
  cache = True
117
- cache_ttl = 600
121
+ cache_ttl = 600 # default 120s; settings.CACHE_TTL overrides
118
122
  ```
119
123
 
120
124
  ## Querystrings
@@ -126,10 +130,48 @@ class YourResource(BaseResource):
126
130
  | `?field=value` / `?field__gte=...` | Filter on whitelisted fields |
127
131
  | `?fields=a,b` | Restrict returned fields (filtered by `list_fields`) |
128
132
  | `?filter=<json>` | Advanced filter expression on whitelisted fields |
129
- | `?segment_id=N` | Apply a stored segment |
133
+ | `?segment_id=N` | Apply a saved segment (see below) |
130
134
  | `?page=N&limit=M&order_by=field` | Pagination + order |
131
135
  | `?normalize=true` | Return list as `{id: {...}}` instead of array |
132
136
 
137
+ ## Saved segments
138
+
139
+ A segment is a saved JSON filter expression — the same boolean tree
140
+ `?filter=<json>` accepts, stored under a stable id. Useful for CRM-style
141
+ "saved views", marketing audiences, dashboard filters.
142
+
143
+ Wire it up by pointing `EASYAPI.SEGMENT_MODEL` at a model that exposes a
144
+ `.conditions` attribute returning the Layer-2 dict:
145
+
146
+ ```python
147
+ # settings.py
148
+ EASYAPI = {
149
+ 'SEGMENT_MODEL': 'modules.segment.models.Segment',
150
+ }
151
+
152
+ # modules/segment/models.py
153
+ class Segment(models.Model):
154
+ name = models.CharField(max_length=120)
155
+ conditions = models.JSONField() # the Layer-2 boolean tree
156
+
157
+ Segment.objects.create(
158
+ name='Active demo accounts',
159
+ conditions={
160
+ 'logical_operator': 'AND',
161
+ 'rules': [
162
+ {'field': 'active', 'operator': 'exact', 'value': True},
163
+ {'field': 'name', 'operator': 'icontains', 'value': 'demo'},
164
+ ],
165
+ },
166
+ )
167
+ ```
168
+
169
+ Any resource then accepts `GET /clients?segment_id=42`. Conditions are
170
+ validated against the resource's `filter_fields` whitelist; missing rows
171
+ return 404. When `SEGMENT_MODEL` is unset, `?segment_id=` is a no-op. A
172
+ bad path raises `ImportError` at first use — typos fail loudly instead of
173
+ silently disabling segments.
174
+
133
175
  ## Pydantic schemas (optional)
134
176
 
135
177
  Set any of `create_schema`, `update_schema`, `list_schema` and easyapi
@@ -229,13 +271,45 @@ configuration needed; it just works for any project that uses
229
271
  `aset_tenant`. The auto-fold is keyed by `account_id is not None`, so an
230
272
  explicit `account_id = 0` still produces a per-tenant key (real value,
231
273
  not absence). Disable globally via
232
- `AUTO_SCOPE_CACHE_BY_ACCOUNT = False` if you have a single-tenant
233
- deployment and want the legacy key shape.
274
+ `EASYAPI = {'AUTO_SCOPE_CACHE_BY_ACCOUNT': False}` if you have a
275
+ single-tenant deployment and want the legacy key shape.
234
276
 
235
277
  If you override `_build_cache_key` in a project, call
236
278
  `self._account_cache_segment()` and append the result so the override
237
279
  inherits the tenant isolation.
238
280
 
281
+ **TTL settings.** Two project-level knobs in the `EASYAPI` bag:
282
+
283
+ - `CACHE_TTL` — default 120s; overrides the framework default for
284
+ resources that don't declare an explicit `cache_ttl`.
285
+ - `CACHE_TTL_ENABLE` — default `True`; flip to `False` for a global
286
+ kill switch (every `cache=True` resource becomes `cache=False` at
287
+ runtime, no Redis read or write).
288
+
289
+ Every easyapi setting lives inside the `EASYAPI = {...}` dict
290
+ (DRF/Celery-style namespace):
291
+
292
+ ```python
293
+ # settings.py
294
+ EASYAPI = {
295
+ 'CACHE_TTL': 300,
296
+ 'CACHE_TTL_ENABLE': True,
297
+ 'ENFORCE_TOKEN': True,
298
+ 'COOKIE_ID': 'sessionid',
299
+ 'RATE_LIMITS': {...},
300
+ }
301
+ ```
302
+
303
+ Inside the bag the historical `EASYAPI_` prefix is redundant —
304
+ `EASYAPI_API_KEY_RESOLVER` and `API_KEY_RESOLVER` resolve to the
305
+ same setting.
306
+
307
+ `CACHE_TTL` only sets the default — resources that declare
308
+ `cache_ttl = N` keep that explicit value.
309
+ `CACHE_TTL_ENABLE = False` is a kill switch that forces
310
+ `self.cache = False` for every request, useful for incident response
311
+ without code edits.
312
+
239
313
  **Per-scope caching.** When the response varies on a user/account
240
314
  dimension *inside* the same tenant — role, space, plan, country —
241
315
  declare it with `cache_scope_fields` so users sharing the same scope
@@ -455,11 +529,13 @@ pip install -r requirements-dev.txt
455
529
  pytest
456
530
  ```
457
531
 
458
- 216 tests covering util, redis, cache, filters, filter validation, init,
459
- auth tokens (incl. nonce replay), schemas, openapi, helpers, serializer,
460
- client_ip, SecurityMiddleware, dispatch error handling, tenant connection
461
- and registry, MCP middleware chain, route gating, WS subscription
462
- hardening, public exports, and WebSocket optional import.
532
+ 301 tests covering util, redis, cache (incl. per-account auto-fold and
533
+ per-scope keys), filters, filter validation, init, auth tokens (incl.
534
+ nonce replay), schemas, openapi, helpers, serializer (incl. per-call
535
+ timezone subclass), client_ip, allowed-domain checks, SecurityMiddleware,
536
+ dispatch error handling, tenant connection and registry, MCP middleware
537
+ chain, route gating, WS subscription hardening, public exports, and
538
+ WebSocket optional import.
463
539
 
464
540
  ## Author
465
541
 
@@ -5,10 +5,17 @@ from time import time
5
5
 
6
6
  from .exception import HTTPException
7
7
  from .redis_config import KEY_PREFIX, get_redis
8
+ from .settings_helper import get_token_max_drift_ms
8
9
 
9
10
  _NONCE_RE = re.compile(r'^[A-Za-z0-9_\-]{1,64}$')
10
11
 
11
12
 
13
+ def _resolve_max_drift_ms(max_drift_ms):
14
+ if max_drift_ms is None:
15
+ return get_token_max_drift_ms()
16
+ return max_drift_ms
17
+
18
+
12
19
  def make_token(session_token, nonce, timestamp_ms=None):
13
20
  """Build an X-Token value from the session token, a nonce and a
14
21
  timestamp. Format: ``<timestamp_ms>.<nonce>.<hex_hmac>``.
@@ -25,7 +32,7 @@ def make_token(session_token, nonce, timestamp_ms=None):
25
32
  return f'{timestamp_ms}.{nonce}.{digest}'
26
33
 
27
34
 
28
- def validate_token(token, session_token, max_drift_ms=5000):
35
+ def validate_token(token, session_token, max_drift_ms=None):
29
36
  """Validate the X-Token anti-replay header.
30
37
 
31
38
  Token format: ``<timestamp_ms>.<nonce>.<hex_hmac>``.
@@ -33,6 +40,8 @@ def validate_token(token, session_token, max_drift_ms=5000):
33
40
 
34
41
  Raises HTTPException(403) on any failure.
35
42
  """
43
+ max_drift_ms = _resolve_max_drift_ms(max_drift_ms)
44
+
36
45
  if not token:
37
46
  raise HTTPException(403, 'Not allowed, missing token')
38
47
  if not session_token:
@@ -61,12 +70,14 @@ def validate_token(token, session_token, max_drift_ms=5000):
61
70
  return nonce, timestamp_ms
62
71
 
63
72
 
64
- async def consume_nonce(session_token, nonce, timestamp_ms, max_drift_ms=5000):
73
+ async def consume_nonce(session_token, nonce, timestamp_ms, max_drift_ms=None):
65
74
  """Reserve nonce in Redis to prevent replay within the drift window.
66
75
 
67
76
  Keyed by sha256(session_token):nonce. TTL = 2 * max_drift_ms (ms).
68
77
  Raises HTTPException(403) if nonce was already used.
69
78
  """
79
+ max_drift_ms = _resolve_max_drift_ms(max_drift_ms)
80
+
70
81
  redis = get_redis()
71
82
  sess_hash = hashlib.sha256(session_token.encode('utf-8')).hexdigest()[:16]
72
83
  key = f'{KEY_PREFIX}nonce:{sess_hash}:{nonce}'
@@ -76,7 +87,8 @@ async def consume_nonce(session_token, nonce, timestamp_ms, max_drift_ms=5000):
76
87
  raise HTTPException(403, 'Not allowed, replayed token')
77
88
 
78
89
 
79
- async def validate_token_async(token, session_token, max_drift_ms=5000):
90
+ async def validate_token_async(token, session_token, max_drift_ms=None):
80
91
  """Validate X-Token and consume nonce atomically (Redis SETNX)."""
92
+ max_drift_ms = _resolve_max_drift_ms(max_drift_ms)
81
93
  nonce, timestamp_ms = validate_token(token, session_token, max_drift_ms)
82
94
  await consume_nonce(session_token, nonce, timestamp_ms, max_drift_ms)
@@ -41,52 +41,56 @@ from .util import validate_session_key
41
41
  from .rate_limit import RateLimiter
42
42
  from .tenant.tenant import aset_tenant, get_api_session
43
43
  from .redis_config import KEY_PREFIX, get_redis
44
- from settings.settings import COOKIE_ID
45
-
46
- try:
47
- from settings.settings import ALLOWED_ORIGINS
48
- except Exception:
49
- ALLOWED_ORIGINS = []
50
-
51
- try:
52
- from settings.settings import ENFORCE_TOKEN
53
- except Exception:
54
- ENFORCE_TOKEN = False
55
-
56
- try:
57
- from settings.settings import AUTO_SCOPE_CACHE_BY_ACCOUNT
58
- except Exception:
59
- AUTO_SCOPE_CACHE_BY_ACCOUNT = True
60
-
61
- try:
62
- from settings.settings import RATE_LIMITS
63
- except Exception:
64
- RATE_LIMITS = {
65
- 'api': [
66
- {'interval': 1000, 'limit': 4},
67
- {'interval': 5000, 'limit': 20}
68
- ],
69
- 'login': [
70
- {'interval': 5000, 'limit': 3}, # Janela curta
71
- {'interval': 3600000, 'limit': 50} # Janela longa
72
- ],
73
- 'abuse': [
74
- {'interval': 5000, 'limit': 20},
75
- {'interval': 3600000, 'limit': 200}
76
- ]
77
- }
44
+ from .settings_helper import get_cookie_id, get_setting, get_token_max_drift_ms
45
+
46
+ COOKIE_ID = get_cookie_id()
47
+ TOKEN_MAX_DRIFT_MS = get_token_max_drift_ms()
48
+ ALLOWED_ORIGINS = get_setting('ALLOWED_ORIGINS', default=[])
49
+ ENFORCE_TOKEN = get_setting('ENFORCE_TOKEN', default=False)
50
+ AUTO_SCOPE_CACHE_BY_ACCOUNT = get_setting(
51
+ 'AUTO_SCOPE_CACHE_BY_ACCOUNT', default=True,
52
+ )
53
+ CACHE_TTL = get_setting('CACHE_TTL')
54
+ CACHE_TTL_ENABLE = get_setting('CACHE_TTL_ENABLE', default=True)
55
+ RATE_LIMITS = get_setting('RATE_LIMITS', default={
56
+ 'api': [
57
+ {'interval': 1000, 'limit': 4},
58
+ {'interval': 5000, 'limit': 20},
59
+ ],
60
+ 'login': [
61
+ {'interval': 5000, 'limit': 3},
62
+ {'interval': 3600000, 'limit': 50},
63
+ ],
64
+ 'abuse': [
65
+ {'interval': 5000, 'limit': 20},
66
+ {'interval': 3600000, 'limit': 200},
67
+ ],
68
+ })
78
69
 
79
70
 
80
71
  logger = logging.getLogger(__name__)
81
72
 
82
73
 
83
- try:
84
- Segment = getattr(
85
- import_module('modules.segment.models'),
86
- 'Segment'
87
- )
88
- except ImportError:
89
- Segment = None
74
+ _SEGMENT_MODEL_PATH = get_setting('SEGMENT_MODEL')
75
+ _segment_model_cache = False # sentinel; resolved lazily on first use
76
+
77
+
78
+ def _get_segment_model():
79
+ """Lazy-resolve the project's Segment model.
80
+
81
+ Reads the dotted path from ``EASYAPI = {'SEGMENT_MODEL': ...}``.
82
+ When unset, ``?segment_id=`` is a no-op. Bad path raises so a typo
83
+ fails loudly instead of silently disabling segments.
84
+ """
85
+ global _segment_model_cache
86
+ if _segment_model_cache is not False:
87
+ return _segment_model_cache
88
+ if not _SEGMENT_MODEL_PATH:
89
+ _segment_model_cache = None
90
+ return None
91
+ module_path, attr = _SEGMENT_MODEL_PATH.rsplit('.', 1)
92
+ _segment_model_cache = getattr(import_module(module_path), attr)
93
+ return _segment_model_cache
90
94
 
91
95
 
92
96
  class BaseResource(View):
@@ -106,7 +110,9 @@ class BaseResource(View):
106
110
 
107
111
  account_db = 'default'
108
112
  cache = False
109
- cache_ttl = 60
113
+ # Default TTL: settings.CACHE_TTL when set, else 120s.
114
+ # Subclasses can still override via ``cache_ttl = N`` and that wins.
115
+ cache_ttl = CACHE_TTL if CACHE_TTL is not None else 120
110
116
  cache_namespace = None
111
117
  route_cache = False
112
118
  session_cache = False
@@ -218,15 +224,6 @@ class BaseResource(View):
218
224
  self.m2m_fields.append(field.name)
219
225
  continue
220
226
 
221
- # if field.concrete and field.many_to_one:
222
- # self.fk_fields.append(field.name)
223
- # fields.append(f'{field.name}_id')
224
- # continue
225
-
226
- # if not field.concrete and field.one_to_many:
227
- # self.related_fields.append(field.name)
228
- # continue
229
-
230
227
  all_fields = [
231
228
  field.name for field in self.model._meta.local_fields
232
229
  ]
@@ -252,25 +249,21 @@ class BaseResource(View):
252
249
  return None, None, None
253
250
 
254
251
  def get_allowed_domain(self, request):
255
- if ALLOWED_ORIGINS:
256
- allowed = False
257
-
258
- host = request.headers.get('Host')
259
- match = LOCAL_HOST.match(host)
260
-
261
- if match:
262
- allowed = True
252
+ if not ALLOWED_ORIGINS:
253
+ return
263
254
 
264
- else:
265
- referer = request.headers.get('Referer')
266
- if referer:
267
- parsed_url = urlparse(referer)
268
- domain = parsed_url.netloc
269
- domain = domain.split(':')[0]
270
- for origin in ALLOWED_ORIGINS:
271
- if domain.endswith(origin):
272
- allowed = True
273
- break
255
+ allowed = False
256
+ host = request.headers.get('Host') or ''
257
+ if LOCAL_HOST.match(host):
258
+ allowed = True
259
+ else:
260
+ referer = request.headers.get('Referer')
261
+ if referer:
262
+ domain = urlparse(referer).netloc.split(':')[0]
263
+ for origin in ALLOWED_ORIGINS:
264
+ if domain.endswith(origin):
265
+ allowed = True
266
+ break
274
267
 
275
268
  if not allowed:
276
269
  raise HTTPException(403, 'Not allowed')
@@ -290,12 +283,12 @@ class BaseResource(View):
290
283
  await self.check_is_blocked(self.identifier)
291
284
 
292
285
  if request.path.startswith('/login'):
293
- result = RateLimiter.login_limited(self.identifier, RATE_LIMITS)
286
+ result = await RateLimiter.login_limited(self.identifier, RATE_LIMITS)
294
287
  if result['rate_limited']:
295
288
  await self.block(self.identifier)
296
289
  return
297
290
 
298
- result = RateLimiter.api_limited(self.identifier, RATE_LIMITS)
291
+ result = await RateLimiter.api_limited(self.identifier, RATE_LIMITS)
299
292
  if result['abuse']:
300
293
  await self.block(self.identifier)
301
294
  if result['rate_limited']:
@@ -338,6 +331,7 @@ class BaseResource(View):
338
331
  await validate_token_async(
339
332
  request.headers.get('X-Token'),
340
333
  self.user.get('token') if self.user else None,
334
+ max_drift_ms=TOKEN_MAX_DRIFT_MS,
341
335
  )
342
336
 
343
337
  _CACHE_SCOPE_SOURCES = ('user', 'account')
@@ -482,8 +476,15 @@ class BaseResource(View):
482
476
 
483
477
  label = self.model._meta.label_lower if self.model else 'unknown'
484
478
  outcome = 'hits' if response else 'misses'
485
- await redis.incr(f'{KEY_PREFIX}cache_stats:{outcome}')
486
- await redis.incr(f'{KEY_PREFIX}cache_stats:{outcome}:{label}')
479
+ # Per-model counters live in a single hash so ``get_cache_stats``
480
+ # can read them with one HGETALL instead of SCAN over the whole
481
+ # keyspace (the original ``cache_stats:hits:<label>`` layout
482
+ # cost 10+ seconds on production-sized Redis).
483
+ from .redis_config import _BY_MODEL_KEY, cache_stats_field
484
+ pipe = redis.pipeline()
485
+ pipe.incr(f'{KEY_PREFIX}cache_stats:{outcome}')
486
+ pipe.hincrby(_BY_MODEL_KEY, cache_stats_field(outcome, label), 1)
487
+ await pipe.execute()
487
488
 
488
489
  if response:
489
490
  return HttpResponse(response, content_type='application/json')
@@ -586,7 +587,15 @@ class BaseResource(View):
586
587
  handler = getattr(self, self.method, method_not_allowed) if not func else None
587
588
 
588
589
  is_custom_route = func is not None
589
- self.cache = self.cache and self.method == 'get' and (not is_custom_route or self.route_cache)
590
+ # ``CACHE_TTL_ENABLE = False`` is a global kill switch treats
591
+ # every ``cache=True`` resource as off without code edits.
592
+ # Useful for incident response (cache poisoning, stale data).
593
+ self.cache = (
594
+ self.cache
595
+ and CACHE_TTL_ENABLE
596
+ and self.method == 'get'
597
+ and (not is_custom_route or self.route_cache)
598
+ )
590
599
  if self.cache:
591
600
  cached = await self._serve_cache(request, session_key, is_custom_route)
592
601
  if cached is not None:
@@ -654,10 +663,18 @@ class BaseResource(View):
654
663
 
655
664
  if field in self.filter_fields:
656
665
  param = params[key][0]
657
- if param.lower() == 'false':
658
- param = False
659
- elif param.lower() == 'true':
660
- param = True
666
+ is_bool_field = False
667
+ if self.model:
668
+ try:
669
+ model_field = self.model._meta.get_field(field)
670
+ is_bool_field = isinstance(model_field, models.BooleanField)
671
+ except Exception:
672
+ is_bool_field = False
673
+ if is_bool_field:
674
+ if param.lower() == 'false':
675
+ param = False
676
+ elif param.lower() == 'true':
677
+ param = True
661
678
 
662
679
  filter[key] = param
663
680
 
@@ -697,9 +714,6 @@ class BaseResource(View):
697
714
  except Exception:
698
715
  pass
699
716
 
700
- #########################################################
701
- # Funçoes dentro do Resource
702
- #########################################################
703
717
  async def serialize(self, result, **kwargs):
704
718
 
705
719
  if type(result) is JsonResponse:
@@ -819,7 +833,6 @@ class BaseResource(View):
819
833
  # GET
820
834
  #########################################################
821
835
 
822
- # count só se aplica a listagens
823
836
  @sync_to_async
824
837
  def count(self):
825
838
  # InnoDB count(*) and Django's queryset.count() are slow on large tables
@@ -871,7 +884,6 @@ class BaseResource(View):
871
884
 
872
885
  check(conditions)
873
886
 
874
- # filtro por segmento/filter só se aplica a listagens
875
887
  async def get_filters(self, request):
876
888
  if self.filters:
877
889
  self.queryset = self.queryset.filter(self.filters)
@@ -887,6 +899,7 @@ class BaseResource(View):
887
899
  if segment_id is None and filter_ is None:
888
900
  return
889
901
 
902
+ Segment = _get_segment_model()
890
903
  if Segment and segment_id:
891
904
  segment = await Segment.objects.filter(id=segment_id).afirst()
892
905
  if not segment:
@@ -1171,8 +1184,12 @@ class BaseResource(View):
1171
1184
 
1172
1185
  try:
1173
1186
  await qs.adelete()
1174
- except Exception as err:
1175
- raise HTTPException(400, err.__class__.__name__ + ': ' + err.__str__())
1187
+ except Exception:
1188
+ logger.exception(
1189
+ 'delete failed for %s id=%s',
1190
+ self.model._meta.label if self.model else '<unknown>', id,
1191
+ )
1192
+ raise HTTPException(400, 'Could not delete item')
1176
1193
 
1177
1194
  return {'success': True, 'id': id, 'message': 'Deleted'}
1178
1195
 
@@ -1231,7 +1248,14 @@ class BaseResource(View):
1231
1248
  field = getattr(self.model, key)
1232
1249
  if field.field.primary_key:
1233
1250
  key += '_id'
1234
- value = int(value)
1251
+ target = getattr(field.field, 'target_field', field.field)
1252
+ if target.get_internal_type() in (
1253
+ 'AutoField', 'BigAutoField', 'SmallAutoField',
1254
+ 'IntegerField', 'BigIntegerField',
1255
+ 'PositiveIntegerField', 'PositiveBigIntegerField',
1256
+ 'PositiveSmallIntegerField', 'SmallIntegerField',
1257
+ ):
1258
+ value = int(value)
1235
1259
 
1236
1260
  if isinstance(field.field, models.ForeignKey):
1237
1261
  if not key.endswith('_id'):
@@ -1335,10 +1359,15 @@ class BaseResource(View):
1335
1359
  except IntegrityError as error:
1336
1360
  error_message = str(error)
1337
1361
  if "Duplicate entry" in error_message:
1338
- duplicate_value = error_message.split("'")[1]
1339
- error_message = f'{duplicate_value} already exist'
1362
+ parts = error_message.split("'")
1363
+ duplicate_value = parts[1] if len(parts) > 1 else 'value'
1364
+ raise HTTPException(409, f'{duplicate_value} already exist')
1340
1365
 
1341
- raise HTTPException(409, error_message)
1366
+ logger.exception(
1367
+ 'integrity error on create for %s',
1368
+ self.model._meta.label if self.model else '<unknown>',
1369
+ )
1370
+ raise HTTPException(409, 'Conflict creating item')
1342
1371
 
1343
1372
  for key in custom.keys():
1344
1373
  try: