easyapi-django 0.36__tar.gz → 0.37__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 (80) hide show
  1. {easyapi_django-0.36/easyapi_django.egg-info → easyapi_django-0.37}/PKG-INFO +102 -32
  2. {easyapi_django-0.36 → easyapi_django-0.37}/README.md +101 -31
  3. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/__init__.py +1 -1
  4. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/base.py +57 -66
  5. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/filters.py +41 -0
  6. {easyapi_django-0.36 → easyapi_django-0.37/easyapi_django.egg-info}/PKG-INFO +102 -32
  7. {easyapi_django-0.36 → easyapi_django-0.37}/pyproject.toml +1 -1
  8. {easyapi_django-0.36 → easyapi_django-0.37}/LICENSE +0 -0
  9. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/auth.py +0 -0
  10. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/blocking.py +0 -0
  11. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/calc.py +0 -0
  12. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/calc_resource.py +0 -0
  13. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/client_ip.py +0 -0
  14. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/constants.py +0 -0
  15. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/dates.py +0 -0
  16. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/exception.py +0 -0
  17. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/helpers.py +0 -0
  18. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/management/__init__.py +0 -0
  19. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/management/commands/__init__.py +0 -0
  20. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/management/commands/mcp_serve.py +0 -0
  21. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/mcp/__init__.py +0 -0
  22. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/mcp/bridge.py +0 -0
  23. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/mcp/protocol.py +0 -0
  24. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/mcp/resource.py +0 -0
  25. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/mcp/tools.py +0 -0
  26. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/middleware.py +0 -0
  27. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/openapi.py +0 -0
  28. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/orm/__init__.py +0 -0
  29. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/rate_limit.py +0 -0
  30. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/redis_config.py +0 -0
  31. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/routes.py +0 -0
  32. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/schemas.py +0 -0
  33. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/security.py +0 -0
  34. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/serializer.py +0 -0
  35. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/settings_helper.py +0 -0
  36. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/tenant/__init__.py +0 -0
  37. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/tenant/db_router.py +0 -0
  38. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/tenant/registry.py +0 -0
  39. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/tenant/tenant.py +0 -0
  40. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/util.py +0 -0
  41. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/ws.py +0 -0
  42. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi_django.egg-info/SOURCES.txt +0 -0
  43. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi_django.egg-info/dependency_links.txt +0 -0
  44. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi_django.egg-info/requires.txt +0 -0
  45. {easyapi_django-0.36 → easyapi_django-0.37}/easyapi_django.egg-info/top_level.txt +0 -0
  46. {easyapi_django-0.36 → easyapi_django-0.37}/setup.cfg +0 -0
  47. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_allowed_domain.py +0 -0
  48. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_api_key_resolver.py +0 -0
  49. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_auth.py +0 -0
  50. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_base_init.py +0 -0
  51. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_block.py +0 -0
  52. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_cache.py +0 -0
  53. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_cache_auto_account_scope.py +0 -0
  54. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_cache_scope.py +0 -0
  55. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_client_ip.py +0 -0
  56. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_dispatch_error_handling.py +0 -0
  57. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_exception_render.py +0 -0
  58. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_exports.py +0 -0
  59. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_filter_validation.py +0 -0
  60. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_filters.py +0 -0
  61. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_get_obj_m2m_only.py +0 -0
  62. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_helpers.py +0 -0
  63. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_index_route.py +0 -0
  64. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_mcp.py +0 -0
  65. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_mcp_middleware_chain.py +0 -0
  66. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_normalize_field.py +0 -0
  67. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_openapi.py +0 -0
  68. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_owner_field.py +0 -0
  69. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_redis_config.py +0 -0
  70. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_return_result_falsy.py +0 -0
  71. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_routes_gate.py +0 -0
  72. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_schemas.py +0 -0
  73. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_security.py +0 -0
  74. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_serializer.py +0 -0
  75. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_settings_helper.py +0 -0
  76. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_tenant_connection.py +0 -0
  77. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_tenant_registry.py +0 -0
  78. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_util.py +0 -0
  79. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_ws_channels.py +0 -0
  80. {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_ws_optional.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: easyapi_django
3
- Version: 0.36
3
+ Version: 0.37
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
6
  License-Expression: MIT
@@ -157,48 +157,118 @@ class YourResource(BaseResource):
157
157
  | `?field=value` / `?field__gte=...` | Filter on whitelisted fields |
158
158
  | `?fields=a,b` | Restrict returned fields (filtered by `list_fields`) |
159
159
  | `?filter=<json>` | Advanced filter expression on whitelisted fields |
160
- | `?segment_id=N` | Apply a saved segment (see below) |
160
+ | `?<stored_filter_param>=N` | Apply a server-side stored filter expression (see below) |
161
161
  | `?page=N&limit=M&order_by=field` | Pagination + order |
162
162
  | `?normalize=true` | Return list as `{id: {...}}` instead of array |
163
163
 
164
- ## Saved segments
164
+ ## Saved filter expressions
165
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.
166
+ Layer-2 expressions can be persisted server-side and reapplied by id
167
+ saved views, marketing audiences, dashboard presets. The framework owns
168
+ the URL plumbing; the project owns the storage, lookup and policy.
169
169
 
170
- Wire it up by pointing `EASYAPI.SEGMENT_MODEL` at a model that exposes a
171
- `.conditions` attribute returning the Layer-2 dict:
170
+ Two pieces on the resource:
172
171
 
173
172
  ```python
174
- # settings.py
175
- EASYAPI = {
176
- 'SEGMENT_MODEL': 'modules.segment.models.Segment',
177
- }
173
+ class ClientResource(BaseResource):
174
+ model = Client
175
+ filter_fields = ['active', 'name']
176
+
177
+ stored_filter_param = 'segment_id' # any name — view_id, audience_id, …
178
+
179
+ async def resolve_stored_filter(self, value):
180
+ seg = await Segment.objects.filter(
181
+ id=value, account=self.account_id # tenant scoping etc.
182
+ ).afirst()
183
+ return seg.conditions if seg else None
184
+ ```
185
+
186
+ When a request carries `?segment_id=42`, easyapi calls the hook, trusts
187
+ its return value, and applies it through the same Layer-2 pipeline.
188
+ Returning `None` raises `404`. The hook can raise `HTTPException(403,
189
+ ...)` itself for "exists but you can't access it."
190
+
191
+ `?segment_id=` and `?filter=` compose with AND — saved view *plus*
192
+ ad-hoc narrowing. The URL JSON still validates against `filter_fields`;
193
+ the stored conditions skip that check because the hook owns them.
194
+
195
+ For projects that let end users author stored expressions, validate at
196
+ write time with the public helper:
197
+
198
+ ```python
199
+ from easyapi import validate_conditions
200
+
201
+ class SegmentResource(BaseResource):
202
+ model = Segment
203
+ create_fields = ['name', 'conditions', 'context_id']
204
+
205
+ async def hydrate(self, body):
206
+ conditions = body.get('conditions')
207
+ if conditions:
208
+ allowed = ALLOWED_FIELDS_BY_CONTEXT[body['context_id']]
209
+ validate_conditions(conditions, allowed)
210
+ return body
211
+ ```
212
+
213
+ Server-side admin-only expressions can skip the write-time check; only
214
+ user-authored ones need it.
215
+
216
+ For projects with one `Segment` row type targeting many list resources,
217
+ factor out a mixin so each resource opts in with two lines and the
218
+ `filter_fields` whitelist auto-extends from a project registry:
219
+
220
+ ```python
221
+ # modules/segment/mixin.py
222
+ from django.db.models import Q
223
+ from .constants import INCLUDE_FIELDS
224
+ from .models import Segment, CONTEXT
225
+
226
+ class SegmentMixin:
227
+ stored_filter_param = 'segment_id'
228
+ segment_context_id = None
229
+
230
+ def __init__(self):
231
+ super().__init__()
232
+ if self.segment_context_id is None:
233
+ return
234
+ label = CONTEXT.LABEL[self.segment_context_id]
235
+ bag = INCLUDE_FIELDS.get(label, {})
236
+ extra = list(bag.get('segment_fields') or [])
237
+ extra += [m.split('__')[0] for m in bag.get('related_models') or []]
238
+ self.filter_fields = list(
239
+ dict.fromkeys((self.filter_fields or []) + extra)
240
+ )
241
+
242
+ async def resolve_stored_filter(self, value, **kwargs):
243
+ is_master = bool(self.user and (
244
+ self.user.get('is_admin') or self.user.get('is_owner')
245
+ ))
246
+ qs = Segment.objects.filter(
247
+ id=value, context_id=self.segment_context_id,
248
+ )
249
+ if not is_master:
250
+ qs = qs.filter(
251
+ Q(public=True) | Q(created_by_id=self.user['id'])
252
+ )
253
+ seg = await qs.afirst()
254
+ return seg.conditions if seg else None
255
+
256
+ class AgentResource(SegmentMixin, BaseResource):
257
+ segment_context_id = CONTEXT.AGENT
258
+ model = Agent
259
+ filter_fields = ['agency_id'] # explicit URL shorthands
260
+ # The mixin unions in the segment_fields whitelist + related prefixes,
261
+ # so ?filter=<json> and segment authoring share one source of truth.
262
+ ```
263
+
264
+ The minimum storage model is a JSON column:
178
265
 
179
- # modules/segment/models.py
266
+ ```python
180
267
  class Segment(models.Model):
181
268
  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
- )
269
+ conditions = models.JSONField()
194
270
  ```
195
271
 
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
-
202
272
  ## Pydantic schemas (optional)
203
273
 
204
274
  Set any of `create_schema`, `update_schema`, `list_schema` and easyapi
@@ -398,7 +468,7 @@ middleware in effect.
398
468
 
399
469
  - Session cookie validated against `^[a-zA-Z0-9_\-:]{5,100}$`.
400
470
  - `?fields=` is filtered against `list_fields` to prevent attribute leakage.
401
- - `?filter=` and `segment_id` are validated against `filter_fields`.
471
+ - `?filter=` is validated against `filter_fields`. Stored filter expressions are validated by the project (typically at write time via `validate_conditions`).
402
472
  - `owner_field` scopes PATCH/DELETE to rows owned by the authenticated user.
403
473
  - Request rate limiting runs inside `BaseResource.dispatch`; edge scanner
404
474
  blocking runs in `SecurityMiddleware` before the view.
@@ -130,48 +130,118 @@ class YourResource(BaseResource):
130
130
  | `?field=value` / `?field__gte=...` | Filter on whitelisted fields |
131
131
  | `?fields=a,b` | Restrict returned fields (filtered by `list_fields`) |
132
132
  | `?filter=<json>` | Advanced filter expression on whitelisted fields |
133
- | `?segment_id=N` | Apply a saved segment (see below) |
133
+ | `?<stored_filter_param>=N` | Apply a server-side stored filter expression (see below) |
134
134
  | `?page=N&limit=M&order_by=field` | Pagination + order |
135
135
  | `?normalize=true` | Return list as `{id: {...}}` instead of array |
136
136
 
137
- ## Saved segments
137
+ ## Saved filter expressions
138
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.
139
+ Layer-2 expressions can be persisted server-side and reapplied by id
140
+ saved views, marketing audiences, dashboard presets. The framework owns
141
+ the URL plumbing; the project owns the storage, lookup and policy.
142
142
 
143
- Wire it up by pointing `EASYAPI.SEGMENT_MODEL` at a model that exposes a
144
- `.conditions` attribute returning the Layer-2 dict:
143
+ Two pieces on the resource:
145
144
 
146
145
  ```python
147
- # settings.py
148
- EASYAPI = {
149
- 'SEGMENT_MODEL': 'modules.segment.models.Segment',
150
- }
146
+ class ClientResource(BaseResource):
147
+ model = Client
148
+ filter_fields = ['active', 'name']
149
+
150
+ stored_filter_param = 'segment_id' # any name — view_id, audience_id, …
151
+
152
+ async def resolve_stored_filter(self, value):
153
+ seg = await Segment.objects.filter(
154
+ id=value, account=self.account_id # tenant scoping etc.
155
+ ).afirst()
156
+ return seg.conditions if seg else None
157
+ ```
158
+
159
+ When a request carries `?segment_id=42`, easyapi calls the hook, trusts
160
+ its return value, and applies it through the same Layer-2 pipeline.
161
+ Returning `None` raises `404`. The hook can raise `HTTPException(403,
162
+ ...)` itself for "exists but you can't access it."
163
+
164
+ `?segment_id=` and `?filter=` compose with AND — saved view *plus*
165
+ ad-hoc narrowing. The URL JSON still validates against `filter_fields`;
166
+ the stored conditions skip that check because the hook owns them.
167
+
168
+ For projects that let end users author stored expressions, validate at
169
+ write time with the public helper:
170
+
171
+ ```python
172
+ from easyapi import validate_conditions
173
+
174
+ class SegmentResource(BaseResource):
175
+ model = Segment
176
+ create_fields = ['name', 'conditions', 'context_id']
177
+
178
+ async def hydrate(self, body):
179
+ conditions = body.get('conditions')
180
+ if conditions:
181
+ allowed = ALLOWED_FIELDS_BY_CONTEXT[body['context_id']]
182
+ validate_conditions(conditions, allowed)
183
+ return body
184
+ ```
185
+
186
+ Server-side admin-only expressions can skip the write-time check; only
187
+ user-authored ones need it.
188
+
189
+ For projects with one `Segment` row type targeting many list resources,
190
+ factor out a mixin so each resource opts in with two lines and the
191
+ `filter_fields` whitelist auto-extends from a project registry:
192
+
193
+ ```python
194
+ # modules/segment/mixin.py
195
+ from django.db.models import Q
196
+ from .constants import INCLUDE_FIELDS
197
+ from .models import Segment, CONTEXT
198
+
199
+ class SegmentMixin:
200
+ stored_filter_param = 'segment_id'
201
+ segment_context_id = None
202
+
203
+ def __init__(self):
204
+ super().__init__()
205
+ if self.segment_context_id is None:
206
+ return
207
+ label = CONTEXT.LABEL[self.segment_context_id]
208
+ bag = INCLUDE_FIELDS.get(label, {})
209
+ extra = list(bag.get('segment_fields') or [])
210
+ extra += [m.split('__')[0] for m in bag.get('related_models') or []]
211
+ self.filter_fields = list(
212
+ dict.fromkeys((self.filter_fields or []) + extra)
213
+ )
214
+
215
+ async def resolve_stored_filter(self, value, **kwargs):
216
+ is_master = bool(self.user and (
217
+ self.user.get('is_admin') or self.user.get('is_owner')
218
+ ))
219
+ qs = Segment.objects.filter(
220
+ id=value, context_id=self.segment_context_id,
221
+ )
222
+ if not is_master:
223
+ qs = qs.filter(
224
+ Q(public=True) | Q(created_by_id=self.user['id'])
225
+ )
226
+ seg = await qs.afirst()
227
+ return seg.conditions if seg else None
228
+
229
+ class AgentResource(SegmentMixin, BaseResource):
230
+ segment_context_id = CONTEXT.AGENT
231
+ model = Agent
232
+ filter_fields = ['agency_id'] # explicit URL shorthands
233
+ # The mixin unions in the segment_fields whitelist + related prefixes,
234
+ # so ?filter=<json> and segment authoring share one source of truth.
235
+ ```
236
+
237
+ The minimum storage model is a JSON column:
151
238
 
152
- # modules/segment/models.py
239
+ ```python
153
240
  class Segment(models.Model):
154
241
  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
- )
242
+ conditions = models.JSONField()
167
243
  ```
168
244
 
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
-
175
245
  ## Pydantic schemas (optional)
176
246
 
177
247
  Set any of `create_schema`, `update_schema`, `list_schema` and easyapi
@@ -371,7 +441,7 @@ middleware in effect.
371
441
 
372
442
  - Session cookie validated against `^[a-zA-Z0-9_\-:]{5,100}$`.
373
443
  - `?fields=` is filtered against `list_fields` to prevent attribute leakage.
374
- - `?filter=` and `segment_id` are validated against `filter_fields`.
444
+ - `?filter=` is validated against `filter_fields`. Stored filter expressions are validated by the project (typically at write time via `validate_conditions`).
375
445
  - `owner_field` scopes PATCH/DELETE to rows owned by the authenticated user.
376
446
  - Request rate limiting runs inside `BaseResource.dispatch`; edge scanner
377
447
  blocking runs in `SecurityMiddleware` before the view.
@@ -8,7 +8,7 @@ from easyapi.tenant.db_router import DBRouter # noqa
8
8
  from easyapi.tenant.tenant import aset_tenant, db_state, get_account, get_master_user, get_tenant, set_tenant # noqa
9
9
  # set_default / unset_default are SCRIPT-ONLY (mutate global default DB);
10
10
  # import them directly from easyapi.tenant.tenant when really needed.
11
- from easyapi.filters import Filter as OrmFilter # noqa
11
+ from easyapi.filters import Filter as OrmFilter, validate_conditions # noqa
12
12
  from easyapi.redis_config import get_cache_stats, reset_cache_stats # noqa: F401
13
13
  from easyapi.schemas import openapi # noqa: F401
14
14
  from easyapi.openapi import build_spec # noqa: F401
@@ -23,7 +23,7 @@ from .auth import validate_token_async
23
23
  from .blocking import block as block_identifier
24
24
  from .blocking import is_blocked
25
25
  from .client_ip import get_client_ip
26
- from .filters import Filter as OrmFilter
26
+ from .filters import Filter as OrmFilter, validate_conditions
27
27
  from .exception import HTTPException
28
28
  from .helpers import (
29
29
  LOCAL_HOST,
@@ -71,28 +71,6 @@ RATE_LIMITS = get_setting('RATE_LIMITS', default={
71
71
  logger = logging.getLogger(__name__)
72
72
 
73
73
 
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
94
-
95
-
96
74
  class BaseResource(View):
97
75
  authenticated = True
98
76
  allowed_methods = ['delete', 'get', 'patch', 'post']
@@ -146,6 +124,13 @@ class BaseResource(View):
146
124
  filter_fields = None
147
125
  queryset_filter = None
148
126
 
127
+ # Querystring param that resolves to a server-side stored filter
128
+ # expression (e.g. ``'segment_id'``, ``'view_id'``). When set, the
129
+ # framework reads ``request.GET[stored_filter_param]`` and calls
130
+ # ``resolve_stored_filter(value)`` to load the conditions dict.
131
+ # Combines with ``?filter=<json>`` via AND when both are present.
132
+ stored_filter_param = None
133
+
149
134
  # When set (e.g. owner_field='owner_id'), DELETE/PATCH only operate on
150
135
  # rows whose value of this field matches the authenticated user's id.
151
136
  # Default None disables the check (consumer is responsible for
@@ -663,14 +648,18 @@ class BaseResource(View):
663
648
 
664
649
  if field in self.filter_fields:
665
650
  param = params[key][0]
666
- is_bool_field = False
667
- if self.model:
651
+ # Coerce ``true``/``false`` into bools only when the
652
+ # lookup actually expects a boolean — boolean model
653
+ # fields, or the ``__isnull`` lookup which Django
654
+ # rejects with a string.
655
+ coerce_bool = key.endswith('__isnull')
656
+ if not coerce_bool and self.model:
668
657
  try:
669
658
  model_field = self.model._meta.get_field(field)
670
- is_bool_field = isinstance(model_field, models.BooleanField)
659
+ coerce_bool = isinstance(model_field, models.BooleanField)
671
660
  except Exception:
672
- is_bool_field = False
673
- if is_bool_field:
661
+ pass
662
+ if coerce_bool:
674
663
  if param.lower() == 'false':
675
664
  param = False
676
665
  elif param.lower() == 'true':
@@ -859,30 +848,18 @@ class BaseResource(View):
859
848
  self.count_results = {'count': count}
860
849
 
861
850
  def _validate_filter_fields(self, conditions):
862
- if not self.filter_fields:
863
- raise HTTPException(403, 'Filtering not allowed')
864
-
865
- allowed = set(self.filter_fields)
866
-
867
- def check(node):
868
- if isinstance(node, dict):
869
- if 'logical_operator' in node:
870
- for rule in node.get('rules') or []:
871
- check(rule)
872
- return
873
- field = node.get('field')
874
- if not field:
875
- return
876
- if field.startswith('custom_attributes__') or field.endswith('generated_creation_date'):
877
- return
878
- root = field.split('__')[0]
879
- if field not in allowed and root not in allowed:
880
- raise HTTPException(403, f'Filter on field "{field}" is not allowed')
881
- elif isinstance(node, list):
882
- for item in node:
883
- check(item)
884
-
885
- check(conditions)
851
+ validate_conditions(conditions, self.filter_fields)
852
+
853
+ async def resolve_stored_filter(self, value, **kwargs):
854
+ """Hook: turn a stored-filter id into a Layer-2 conditions dict.
855
+
856
+ Override on resources that declare ``stored_filter_param``. The
857
+ return value is trusted verbatim — apply tenant/RBAC scoping,
858
+ soft-delete filters and any policy check inside the override.
859
+ Return ``None`` to signal not-found (framework raises 404), or a
860
+ conditions dict shaped like the ``?filter=<json>`` payload.
861
+ """
862
+ return None
886
863
 
887
864
  async def get_filters(self, request):
888
865
  if self.filters:
@@ -894,25 +871,39 @@ class BaseResource(View):
894
871
  if normalize_list:
895
872
  self.normalize_list = normalize_list.lower() == 'true'
896
873
 
897
- segment_id = request.GET.get('segment_id')
874
+ stored_value = (
875
+ request.GET.get(self.stored_filter_param)
876
+ if self.stored_filter_param else None
877
+ )
898
878
 
899
- if segment_id is None and filter_ is None:
879
+ if filter_ is None and stored_value is None:
900
880
  return
901
881
 
902
- Segment = _get_segment_model()
903
- if Segment and segment_id:
904
- segment = await Segment.objects.filter(id=segment_id).afirst()
905
- if not segment:
906
- raise HTTPException(404, 'Segment not found')
907
- conditions = segment.conditions
908
-
909
- elif filter_:
910
- conditions = json.loads(filter_)
911
-
912
- if not conditions:
882
+ groups = []
883
+
884
+ if stored_value is not None:
885
+ stored_conditions = await self.resolve_stored_filter(stored_value)
886
+ if stored_conditions is None:
887
+ raise HTTPException(404, 'Stored filter not found')
888
+ if stored_conditions:
889
+ groups.append(stored_conditions)
890
+
891
+ if filter_:
892
+ url_conditions = json.loads(filter_)
893
+ if url_conditions:
894
+ # ``?filter=`` is caller-controlled — must pass the
895
+ # whitelist. Stored filters are trusted because the hook
896
+ # owns the lookup (and any validation, scoping, RBAC).
897
+ self._validate_filter_fields(url_conditions)
898
+ groups.append(url_conditions)
899
+
900
+ if not groups:
913
901
  return
914
902
 
915
- self._validate_filter_fields(conditions)
903
+ if len(groups) == 1:
904
+ conditions = groups[0]
905
+ else:
906
+ conditions = {'logical_operator': 'AND', 'rules': groups}
916
907
 
917
908
  orm_filter = OrmFilter(
918
909
  self.model,
@@ -13,9 +13,50 @@ from pytz import timezone as pytz_timezone
13
13
 
14
14
  from .constants import CustomAttributePresentations
15
15
  from .dates import Dates
16
+ from .exception import HTTPException
16
17
  from .util import make_list, normalize_field
17
18
 
18
19
 
20
+ def validate_conditions(conditions, allowed_fields):
21
+ """Validate a Layer-2 condition tree against an allowed-fields whitelist.
22
+
23
+ ``conditions`` is the JSON tree consumed by :class:`Filter` (dict with
24
+ ``logical_operator`` + ``rules`` or a flat list of rules). ``allowed_fields``
25
+ is any iterable of field names. Each rule's ``field`` (or its root segment
26
+ before ``__``) must appear in the whitelist; otherwise raises
27
+ ``HTTPException(403)``. Custom-attribute fields and the
28
+ ``generated_creation_date`` annotation are exempt.
29
+
30
+ Use this from a segment-creation resource's ``hydrate`` (or equivalent
31
+ write hook) to make the segment row safe before persisting — read paths
32
+ can then trust ``segment.conditions`` without re-validating.
33
+ """
34
+ if not allowed_fields:
35
+ raise HTTPException(403, 'Filtering not allowed')
36
+
37
+ allowed = set(allowed_fields)
38
+
39
+ def check(node):
40
+ if isinstance(node, dict):
41
+ if 'logical_operator' in node:
42
+ for rule in node.get('rules') or []:
43
+ check(rule)
44
+ return
45
+ field = node.get('field')
46
+ if not field:
47
+ return
48
+ if field.startswith('custom_attributes__') or field.endswith('generated_creation_date'):
49
+ return
50
+ root = field.split('__')[0]
51
+ if field not in allowed and root not in allowed:
52
+ raise HTTPException(403, f'Filter on field "{field}" is not allowed')
53
+ elif isinstance(node, list):
54
+ for item in node:
55
+ check(item)
56
+
57
+ check(conditions)
58
+
59
+
19
60
  CHAR = [
20
61
  'CharField', 'BinaryField', 'FileField', 'FilePathField', 'IPAddressField',
21
62
  'GenericIPAddressField', 'SlugField', 'TextField', 'UUIDField'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: easyapi_django
3
- Version: 0.36
3
+ Version: 0.37
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
6
  License-Expression: MIT
@@ -157,48 +157,118 @@ class YourResource(BaseResource):
157
157
  | `?field=value` / `?field__gte=...` | Filter on whitelisted fields |
158
158
  | `?fields=a,b` | Restrict returned fields (filtered by `list_fields`) |
159
159
  | `?filter=<json>` | Advanced filter expression on whitelisted fields |
160
- | `?segment_id=N` | Apply a saved segment (see below) |
160
+ | `?<stored_filter_param>=N` | Apply a server-side stored filter expression (see below) |
161
161
  | `?page=N&limit=M&order_by=field` | Pagination + order |
162
162
  | `?normalize=true` | Return list as `{id: {...}}` instead of array |
163
163
 
164
- ## Saved segments
164
+ ## Saved filter expressions
165
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.
166
+ Layer-2 expressions can be persisted server-side and reapplied by id
167
+ saved views, marketing audiences, dashboard presets. The framework owns
168
+ the URL plumbing; the project owns the storage, lookup and policy.
169
169
 
170
- Wire it up by pointing `EASYAPI.SEGMENT_MODEL` at a model that exposes a
171
- `.conditions` attribute returning the Layer-2 dict:
170
+ Two pieces on the resource:
172
171
 
173
172
  ```python
174
- # settings.py
175
- EASYAPI = {
176
- 'SEGMENT_MODEL': 'modules.segment.models.Segment',
177
- }
173
+ class ClientResource(BaseResource):
174
+ model = Client
175
+ filter_fields = ['active', 'name']
176
+
177
+ stored_filter_param = 'segment_id' # any name — view_id, audience_id, …
178
+
179
+ async def resolve_stored_filter(self, value):
180
+ seg = await Segment.objects.filter(
181
+ id=value, account=self.account_id # tenant scoping etc.
182
+ ).afirst()
183
+ return seg.conditions if seg else None
184
+ ```
185
+
186
+ When a request carries `?segment_id=42`, easyapi calls the hook, trusts
187
+ its return value, and applies it through the same Layer-2 pipeline.
188
+ Returning `None` raises `404`. The hook can raise `HTTPException(403,
189
+ ...)` itself for "exists but you can't access it."
190
+
191
+ `?segment_id=` and `?filter=` compose with AND — saved view *plus*
192
+ ad-hoc narrowing. The URL JSON still validates against `filter_fields`;
193
+ the stored conditions skip that check because the hook owns them.
194
+
195
+ For projects that let end users author stored expressions, validate at
196
+ write time with the public helper:
197
+
198
+ ```python
199
+ from easyapi import validate_conditions
200
+
201
+ class SegmentResource(BaseResource):
202
+ model = Segment
203
+ create_fields = ['name', 'conditions', 'context_id']
204
+
205
+ async def hydrate(self, body):
206
+ conditions = body.get('conditions')
207
+ if conditions:
208
+ allowed = ALLOWED_FIELDS_BY_CONTEXT[body['context_id']]
209
+ validate_conditions(conditions, allowed)
210
+ return body
211
+ ```
212
+
213
+ Server-side admin-only expressions can skip the write-time check; only
214
+ user-authored ones need it.
215
+
216
+ For projects with one `Segment` row type targeting many list resources,
217
+ factor out a mixin so each resource opts in with two lines and the
218
+ `filter_fields` whitelist auto-extends from a project registry:
219
+
220
+ ```python
221
+ # modules/segment/mixin.py
222
+ from django.db.models import Q
223
+ from .constants import INCLUDE_FIELDS
224
+ from .models import Segment, CONTEXT
225
+
226
+ class SegmentMixin:
227
+ stored_filter_param = 'segment_id'
228
+ segment_context_id = None
229
+
230
+ def __init__(self):
231
+ super().__init__()
232
+ if self.segment_context_id is None:
233
+ return
234
+ label = CONTEXT.LABEL[self.segment_context_id]
235
+ bag = INCLUDE_FIELDS.get(label, {})
236
+ extra = list(bag.get('segment_fields') or [])
237
+ extra += [m.split('__')[0] for m in bag.get('related_models') or []]
238
+ self.filter_fields = list(
239
+ dict.fromkeys((self.filter_fields or []) + extra)
240
+ )
241
+
242
+ async def resolve_stored_filter(self, value, **kwargs):
243
+ is_master = bool(self.user and (
244
+ self.user.get('is_admin') or self.user.get('is_owner')
245
+ ))
246
+ qs = Segment.objects.filter(
247
+ id=value, context_id=self.segment_context_id,
248
+ )
249
+ if not is_master:
250
+ qs = qs.filter(
251
+ Q(public=True) | Q(created_by_id=self.user['id'])
252
+ )
253
+ seg = await qs.afirst()
254
+ return seg.conditions if seg else None
255
+
256
+ class AgentResource(SegmentMixin, BaseResource):
257
+ segment_context_id = CONTEXT.AGENT
258
+ model = Agent
259
+ filter_fields = ['agency_id'] # explicit URL shorthands
260
+ # The mixin unions in the segment_fields whitelist + related prefixes,
261
+ # so ?filter=<json> and segment authoring share one source of truth.
262
+ ```
263
+
264
+ The minimum storage model is a JSON column:
178
265
 
179
- # modules/segment/models.py
266
+ ```python
180
267
  class Segment(models.Model):
181
268
  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
- )
269
+ conditions = models.JSONField()
194
270
  ```
195
271
 
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
-
202
272
  ## Pydantic schemas (optional)
203
273
 
204
274
  Set any of `create_schema`, `update_schema`, `list_schema` and easyapi
@@ -398,7 +468,7 @@ middleware in effect.
398
468
 
399
469
  - Session cookie validated against `^[a-zA-Z0-9_\-:]{5,100}$`.
400
470
  - `?fields=` is filtered against `list_fields` to prevent attribute leakage.
401
- - `?filter=` and `segment_id` are validated against `filter_fields`.
471
+ - `?filter=` is validated against `filter_fields`. Stored filter expressions are validated by the project (typically at write time via `validate_conditions`).
402
472
  - `owner_field` scopes PATCH/DELETE to rows owned by the authenticated user.
403
473
  - Request rate limiting runs inside `BaseResource.dispatch`; edge scanner
404
474
  blocking runs in `SecurityMiddleware` before the view.
@@ -11,7 +11,7 @@ build-backend = "setuptools.build_meta"
11
11
 
12
12
  [project]
13
13
  name = "easyapi_django"
14
- version = "0.36"
14
+ version = "0.37"
15
15
  authors = [
16
16
  { name="Stamatios Stamou Jr", email="bushier.outsets.0c@icloud.com" },
17
17
  ]
File without changes
File without changes