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.
- {easyapi_django-0.36/easyapi_django.egg-info → easyapi_django-0.37}/PKG-INFO +102 -32
- {easyapi_django-0.36 → easyapi_django-0.37}/README.md +101 -31
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/__init__.py +1 -1
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/base.py +57 -66
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/filters.py +41 -0
- {easyapi_django-0.36 → easyapi_django-0.37/easyapi_django.egg-info}/PKG-INFO +102 -32
- {easyapi_django-0.36 → easyapi_django-0.37}/pyproject.toml +1 -1
- {easyapi_django-0.36 → easyapi_django-0.37}/LICENSE +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/auth.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/blocking.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/calc.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/calc_resource.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/client_ip.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/constants.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/dates.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/exception.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/helpers.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/management/__init__.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/management/commands/__init__.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/management/commands/mcp_serve.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/mcp/__init__.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/mcp/bridge.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/mcp/protocol.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/mcp/resource.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/mcp/tools.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/middleware.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/openapi.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/orm/__init__.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/rate_limit.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/redis_config.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/routes.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/schemas.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/security.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/serializer.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/settings_helper.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/tenant/__init__.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/tenant/db_router.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/tenant/registry.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/tenant/tenant.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/util.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi/ws.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi_django.egg-info/SOURCES.txt +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi_django.egg-info/dependency_links.txt +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi_django.egg-info/requires.txt +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/easyapi_django.egg-info/top_level.txt +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/setup.cfg +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_allowed_domain.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_api_key_resolver.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_auth.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_base_init.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_block.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_cache.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_cache_auto_account_scope.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_cache_scope.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_client_ip.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_dispatch_error_handling.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_exception_render.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_exports.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_filter_validation.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_filters.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_get_obj_m2m_only.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_helpers.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_index_route.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_mcp.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_mcp_middleware_chain.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_normalize_field.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_openapi.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_owner_field.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_redis_config.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_return_result_falsy.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_routes_gate.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_schemas.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_security.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_serializer.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_settings_helper.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_tenant_connection.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_tenant_registry.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_util.py +0 -0
- {easyapi_django-0.36 → easyapi_django-0.37}/tests/test_ws_channels.py +0 -0
- {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.
|
|
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
|
-
|
|
|
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
|
|
164
|
+
## Saved filter expressions
|
|
165
165
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
`.conditions` attribute returning the Layer-2 dict:
|
|
170
|
+
Two pieces on the resource:
|
|
172
171
|
|
|
173
172
|
```python
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
'
|
|
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
|
-
|
|
266
|
+
```python
|
|
180
267
|
class Segment(models.Model):
|
|
181
268
|
name = models.CharField(max_length=120)
|
|
182
|
-
conditions = models.JSONField()
|
|
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=`
|
|
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
|
-
|
|
|
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
|
|
137
|
+
## Saved filter expressions
|
|
138
138
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
`.conditions` attribute returning the Layer-2 dict:
|
|
143
|
+
Two pieces on the resource:
|
|
145
144
|
|
|
146
145
|
```python
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
'
|
|
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
|
-
|
|
239
|
+
```python
|
|
153
240
|
class Segment(models.Model):
|
|
154
241
|
name = models.CharField(max_length=120)
|
|
155
|
-
conditions = models.JSONField()
|
|
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=`
|
|
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
|
-
|
|
667
|
-
|
|
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
|
-
|
|
659
|
+
coerce_bool = isinstance(model_field, models.BooleanField)
|
|
671
660
|
except Exception:
|
|
672
|
-
|
|
673
|
-
if
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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
|
-
|
|
874
|
+
stored_value = (
|
|
875
|
+
request.GET.get(self.stored_filter_param)
|
|
876
|
+
if self.stored_filter_param else None
|
|
877
|
+
)
|
|
898
878
|
|
|
899
|
-
if
|
|
879
|
+
if filter_ is None and stored_value is None:
|
|
900
880
|
return
|
|
901
881
|
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
|
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
|
|
164
|
+
## Saved filter expressions
|
|
165
165
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
`.conditions` attribute returning the Layer-2 dict:
|
|
170
|
+
Two pieces on the resource:
|
|
172
171
|
|
|
173
172
|
```python
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
'
|
|
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
|
-
|
|
266
|
+
```python
|
|
180
267
|
class Segment(models.Model):
|
|
181
268
|
name = models.CharField(max_length=120)
|
|
182
|
-
conditions = models.JSONField()
|
|
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=`
|
|
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.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|