easyapi-django 0.25__tar.gz → 0.30__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 (85) hide show
  1. easyapi_django-0.30/PKG-INFO +488 -0
  2. easyapi_django-0.30/README.md +461 -0
  3. easyapi_django-0.30/easyapi/__init__.py +21 -0
  4. easyapi_django-0.30/easyapi/auth.py +82 -0
  5. {easyapi_django-0.25 → easyapi_django-0.30}/easyapi/base.py +457 -378
  6. easyapi_django-0.30/easyapi/blocking.py +32 -0
  7. {easyapi_django-0.25 → easyapi_django-0.30}/easyapi/calc.py +68 -33
  8. easyapi_django-0.30/easyapi/client_ip.py +53 -0
  9. easyapi_django-0.30/easyapi/exception.py +11 -0
  10. {easyapi_django-0.25 → easyapi_django-0.30}/easyapi/filters.py +151 -124
  11. easyapi_django-0.30/easyapi/helpers.py +119 -0
  12. easyapi_django-0.30/easyapi/management/commands/__init__.py +0 -0
  13. easyapi_django-0.30/easyapi/management/commands/mcp_serve.py +100 -0
  14. easyapi_django-0.30/easyapi/mcp/__init__.py +23 -0
  15. easyapi_django-0.30/easyapi/mcp/bridge.py +216 -0
  16. easyapi_django-0.30/easyapi/mcp/protocol.py +160 -0
  17. easyapi_django-0.30/easyapi/mcp/resource.py +88 -0
  18. easyapi_django-0.30/easyapi/mcp/tools.py +208 -0
  19. {easyapi_django-0.25 → easyapi_django-0.30}/easyapi/middleware.py +5 -15
  20. easyapi_django-0.30/easyapi/openapi.py +324 -0
  21. {easyapi_django-0.25 → easyapi_django-0.30}/easyapi/rate_limit.py +19 -22
  22. easyapi_django-0.30/easyapi/redis_config.py +64 -0
  23. easyapi_django-0.30/easyapi/routes.py +277 -0
  24. easyapi_django-0.30/easyapi/schemas.py +74 -0
  25. easyapi_django-0.30/easyapi/security.py +157 -0
  26. easyapi_django-0.30/easyapi/serializer.py +17 -0
  27. easyapi_django-0.30/easyapi/tenant/__init__.py +0 -0
  28. easyapi_django-0.30/easyapi/tenant/registry.py +114 -0
  29. easyapi_django-0.30/easyapi/tenant/tenant.py +314 -0
  30. easyapi_django-0.30/easyapi/util.py +54 -0
  31. easyapi_django-0.30/easyapi/ws.py +384 -0
  32. easyapi_django-0.30/easyapi_django.egg-info/PKG-INFO +488 -0
  33. easyapi_django-0.30/easyapi_django.egg-info/SOURCES.txt +74 -0
  34. easyapi_django-0.30/easyapi_django.egg-info/requires.txt +15 -0
  35. {easyapi_django-0.25 → easyapi_django-0.30}/pyproject.toml +20 -1
  36. easyapi_django-0.30/tests/test_api_key_resolver.py +101 -0
  37. easyapi_django-0.30/tests/test_auth.py +146 -0
  38. easyapi_django-0.30/tests/test_base_init.py +62 -0
  39. easyapi_django-0.30/tests/test_block.py +40 -0
  40. easyapi_django-0.30/tests/test_cache.py +108 -0
  41. easyapi_django-0.30/tests/test_cache_auto_account_scope.py +139 -0
  42. easyapi_django-0.30/tests/test_cache_scope.py +191 -0
  43. easyapi_django-0.30/tests/test_client_ip.py +93 -0
  44. easyapi_django-0.30/tests/test_dispatch_error_handling.py +85 -0
  45. easyapi_django-0.30/tests/test_exports.py +28 -0
  46. easyapi_django-0.30/tests/test_filter_validation.py +84 -0
  47. easyapi_django-0.30/tests/test_filters.py +211 -0
  48. easyapi_django-0.30/tests/test_get_obj_m2m_only.py +133 -0
  49. easyapi_django-0.30/tests/test_helpers.py +57 -0
  50. easyapi_django-0.30/tests/test_index_route.py +43 -0
  51. easyapi_django-0.30/tests/test_mcp.py +264 -0
  52. easyapi_django-0.30/tests/test_mcp_middleware_chain.py +98 -0
  53. easyapi_django-0.30/tests/test_normalize_field.py +42 -0
  54. easyapi_django-0.30/tests/test_openapi.py +120 -0
  55. easyapi_django-0.30/tests/test_owner_field.py +41 -0
  56. easyapi_django-0.30/tests/test_redis_config.py +72 -0
  57. easyapi_django-0.30/tests/test_return_result_falsy.py +91 -0
  58. easyapi_django-0.30/tests/test_routes_gate.py +69 -0
  59. easyapi_django-0.30/tests/test_schemas.py +80 -0
  60. easyapi_django-0.30/tests/test_security.py +141 -0
  61. easyapi_django-0.30/tests/test_serializer.py +30 -0
  62. easyapi_django-0.30/tests/test_tenant_connection.py +56 -0
  63. easyapi_django-0.30/tests/test_tenant_registry.py +269 -0
  64. easyapi_django-0.30/tests/test_util.py +64 -0
  65. easyapi_django-0.30/tests/test_ws_channels.py +95 -0
  66. easyapi_django-0.30/tests/test_ws_optional.py +31 -0
  67. easyapi_django-0.25/PKG-INFO +0 -215
  68. easyapi_django-0.25/README.md +0 -200
  69. easyapi_django-0.25/easyapi/__init__.py +0 -7
  70. easyapi_django-0.25/easyapi/exception.py +0 -18
  71. easyapi_django-0.25/easyapi/routes.py +0 -204
  72. easyapi_django-0.25/easyapi/tenant/tenant.py +0 -313
  73. easyapi_django-0.25/easyapi/util.py +0 -21
  74. easyapi_django-0.25/easyapi_django.egg-info/PKG-INFO +0 -215
  75. easyapi_django-0.25/easyapi_django.egg-info/SOURCES.txt +0 -23
  76. {easyapi_django-0.25 → easyapi_django-0.30}/LICENSE +0 -0
  77. {easyapi_django-0.25 → easyapi_django-0.30}/easyapi/calc_resource.py +0 -0
  78. {easyapi_django-0.25 → easyapi_django-0.30}/easyapi/constants.py +0 -0
  79. {easyapi_django-0.25 → easyapi_django-0.30}/easyapi/dates.py +0 -0
  80. {easyapi_django-0.25/easyapi/tenant → easyapi_django-0.30/easyapi/management}/__init__.py +0 -0
  81. {easyapi_django-0.25 → easyapi_django-0.30}/easyapi/orm/__init__.py +0 -0
  82. {easyapi_django-0.25 → easyapi_django-0.30}/easyapi/tenant/db_router.py +0 -0
  83. {easyapi_django-0.25 → easyapi_django-0.30}/easyapi_django.egg-info/dependency_links.txt +0 -0
  84. {easyapi_django-0.25 → easyapi_django-0.30}/easyapi_django.egg-info/top_level.txt +0 -0
  85. {easyapi_django-0.25 → easyapi_django-0.30}/setup.cfg +0 -0
@@ -0,0 +1,488 @@
1
+ Metadata-Version: 2.4
2
+ Name: easyapi_django
3
+ Version: 0.30
4
+ Summary: A simple rest api generator for django based on models
5
+ Author-email: Stamatios Stamou Jr <bushier.outsets.0c@icloud.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ssjunior/easyapi-django
8
+ Project-URL: Bug Tracker, https://github.com/ssjunior/easyapi-django/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.7
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: Django>=5.0
15
+ Requires-Dist: redis
16
+ Requires-Dist: pandas
17
+ Requires-Dist: pytz
18
+ Provides-Extra: schemas
19
+ Requires-Dist: pydantic>=2; extra == "schemas"
20
+ Provides-Extra: mcp
21
+ Requires-Dist: jsonschema>=4; extra == "mcp"
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7; extra == "dev"
24
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
25
+ Requires-Dist: fakeredis>=2.20; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # easyapi-django
29
+
30
+ A REST API generator for Django. Define a class, point it at a model, get
31
+ full async CRUD endpoints with authentication, filtering, pagination,
32
+ caching, rate limiting, multi-tenancy, Pydantic validation and OpenAPI
33
+ docs out of the box.
34
+
35
+ - **Docs** — full guide and reference
36
+ - **GitHub** — https://github.com/ssjunior/easyapi-django
37
+ - **License** — MIT
38
+
39
+ ## Why
40
+
41
+ Most Django REST resources end up as hundreds of lines of plumbing:
42
+ list/detail views, write handlers with field whitelists, session auth,
43
+ rate limit, Redis caching with invalidation, multi-tenant DB switching.
44
+ easyapi packages all of that as a class with attributes — usually under
45
+ 30 lines per resource.
46
+
47
+ ## Install
48
+
49
+ ```
50
+ pip install easyapi-django
51
+ ```
52
+
53
+ Optional Pydantic schemas for input validation and response shaping:
54
+
55
+ ```
56
+ pip install 'easyapi-django[schemas]'
57
+ ```
58
+
59
+ ## Required environment
60
+
61
+ ```
62
+ REDIS_SERVER=localhost
63
+ REDIS_DB=0
64
+ REDIS_PREFIX=myapp # optional; namespaces all Redis keys
65
+ ```
66
+
67
+ Redis is used for sessions, cache, rate limiting and abuse blocking.
68
+
69
+ ## Add middleware in Django settings
70
+
71
+ ```python
72
+ MIDDLEWARE = [
73
+ ...
74
+ 'easyapi.SecurityMiddleware', # pattern/UA/4xx-flood instant block
75
+ 'easyapi.AuthMiddleware', # session-based auth from Redis
76
+ 'easyapi.ExceptionMiddleware',
77
+ ]
78
+ TRUSTED_PROXIES = ['10.0.0.0/8'] # only trust X-Real-IP from these
79
+ ```
80
+
81
+ ## Create a resource
82
+
83
+ ```python
84
+ from easyapi import BaseResource
85
+ from your_models import YourModel
86
+
87
+ class YourResource(BaseResource):
88
+ model = YourModel
89
+ ```
90
+
91
+ ## Wire up routes
92
+
93
+ ```python
94
+ from easyapi import get_routes
95
+ from your_resources import YourResource
96
+
97
+ endpoints = {
98
+ r'yourendpoint(.*)$': YourResource,
99
+ }
100
+ urlpatterns = [...] + get_routes(endpoints)
101
+ ```
102
+
103
+ GET, POST, PATCH, DELETE are ready. You also get:
104
+
105
+ - `GET /openapi.json` — OpenAPI 3.0.3 spec
106
+ - `GET /docs` — interactive Scalar UI
107
+
108
+ ## Configuration cheat sheet
109
+
110
+ ```python
111
+ class YourResource(BaseResource):
112
+ model = YourModel
113
+
114
+ authenticated = True # default; set False to allow anonymous
115
+ allowed_methods = ['get', 'post', 'patch', 'delete']
116
+
117
+ # Listing
118
+ list_fields = ['id', 'name']
119
+ list_related_fields = {'account': ['name', 'plan']}
120
+ list_exclude_fields = []
121
+ normalize_list = False # return {id: {...}} instead of [{...}]
122
+
123
+ # Filtering / searching / ordering
124
+ filter_fields = ['name', 'active']
125
+ search_fields = ['name', 'email']
126
+ search_operator = 'icontains'
127
+ order_fields = ['id', 'name']
128
+
129
+ # Detail / write
130
+ edit_fields = ['id', 'name']
131
+ update_fields = ['name']
132
+ create_fields = ['name']
133
+ normalize_obj = False # return {id: {...}} from PATCH/POST
134
+
135
+ # Ownership (DELETE/PATCH scoped to rows owned by user)
136
+ owner_field = 'owner_id'
137
+
138
+ # Pagination
139
+ limit = 25 # 0 returns everything
140
+ order_by = 'id'
141
+
142
+ # Cache
143
+ cache = True
144
+ cache_ttl = 600
145
+ ```
146
+
147
+ ## Querystrings
148
+
149
+ | Param | Effect |
150
+ |------------------------------------|-------------------------------------------------|
151
+ | `?count=true` | Return only `{count: N}` |
152
+ | `?search=value` | Search across `search_fields` with OR |
153
+ | `?field=value` / `?field__gte=...` | Filter on whitelisted fields |
154
+ | `?fields=a,b` | Restrict returned fields (filtered by `list_fields`) |
155
+ | `?filter=<json>` | Advanced filter expression on whitelisted fields |
156
+ | `?segment_id=N` | Apply a stored segment |
157
+ | `?page=N&limit=M&order_by=field` | Pagination + order |
158
+ | `?normalize=true` | Return list as `{id: {...}}` instead of array |
159
+
160
+ ## Pydantic schemas (optional)
161
+
162
+ Set any of `create_schema`, `update_schema`, `list_schema` and easyapi
163
+ validates inputs and shapes outputs through the schema. Resources without
164
+ schemas keep the legacy field-list behaviour.
165
+
166
+ ```python
167
+ from pydantic import BaseModel, EmailStr, Field
168
+
169
+ class UserCreate(BaseModel):
170
+ email: EmailStr
171
+ password: str = Field(min_length=8)
172
+
173
+ class UserOut(BaseModel):
174
+ id: int
175
+ email: EmailStr
176
+ name: str
177
+
178
+ class UserResource(BaseResource):
179
+ model = User
180
+ create_schema = UserCreate # validates POST body, 422 on failure
181
+ list_schema = UserOut # shapes GET responses
182
+ ```
183
+
184
+ Validation errors are returned as `HTTPException(422, [...])`:
185
+
186
+ ```json
187
+ {
188
+ "success": false,
189
+ "status": 422,
190
+ "detail": [
191
+ {"field": "email", "message": "value is not a valid email address"}
192
+ ]
193
+ }
194
+ ```
195
+
196
+ ## OpenAPI
197
+
198
+ `get_routes()` always registers two routes:
199
+
200
+ - `/openapi.json` — generated from your resources. Pydantic schemas are
201
+ emitted as JSON Schema; resources without schemas fall back to Django
202
+ model introspection.
203
+ - `/docs` — Scalar API reference (two-column layout, search, dark mode,
204
+ try-it-out). The Scalar AI assistant is disabled in this build.
205
+
206
+ Custom routes can be enriched with the `@openapi(...)` decorator:
207
+
208
+ ```python
209
+ from easyapi import openapi
210
+
211
+ class UserResource(BaseResource):
212
+ routes = [{'path': r'/me$', 'func': 'me', 'allowed_methods': ['get']}]
213
+
214
+ @openapi(summary='Current user', response=UserOut)
215
+ async def me(self, request, match=None):
216
+ return {'id': self.user['id'], 'email': self.user['email']}
217
+ ```
218
+
219
+ ## Custom routes
220
+
221
+ ```python
222
+ class YourResource(BaseResource):
223
+ model = YourModel
224
+ routes = [
225
+ {'path': r'(\d+)/accept$', 'func': 'accept', 'allowed_methods': ['patch']},
226
+ {'path': r'me$', 'func': 'get_me', 'cache': True},
227
+ ]
228
+
229
+ async def accept(self, request, match=None, body=None):
230
+ ...
231
+
232
+ async def get_me(self, request, match=None):
233
+ ...
234
+ ```
235
+
236
+ ## Cache
237
+
238
+ Per-resource opt-in Redis cache. Namespaced invalidation — editing row 5
239
+ does not drop the cache for row 7.
240
+
241
+ | Operation | Cache effect |
242
+ |------------------------------------|----------------------------------------------------|
243
+ | GET `/spaces` | Cached under `list:<model>` namespace |
244
+ | GET `/spaces/5` | Cached under `detail:<model>:5` |
245
+ | PATCH `/spaces/5` | Invalidates `list:<model>` + `detail:<model>:5` |
246
+ | DELETE `/spaces/5` | Same as PATCH |
247
+ | POST `/spaces` | Invalidates `list:<model>` only |
248
+
249
+ Cache key includes a hash of the querystring, so different filters do not
250
+ collide.
251
+
252
+ **Tenant isolation is automatic.** Multi-tenant deployments share Redis,
253
+ so `_build_cache_key` folds `self.account_id` into the key whenever it is
254
+ set — different tenants hitting the same path get different keys. No
255
+ configuration needed; it just works for any project that uses
256
+ `aset_tenant`. The auto-fold is keyed by `account_id is not None`, so an
257
+ explicit `account_id = 0` still produces a per-tenant key (real value,
258
+ 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.
261
+
262
+ If you override `_build_cache_key` in a project, call
263
+ `self._account_cache_segment()` and append the result so the override
264
+ inherits the tenant isolation.
265
+
266
+ **Per-scope caching.** When the response varies on a user/account
267
+ dimension *inside* the same tenant — role, space, plan, country —
268
+ declare it with `cache_scope_fields` so users sharing the same scope
269
+ share the cache and different scopes get isolated keys:
270
+
271
+ ```python
272
+ class TaskResource(BaseResource):
273
+ model = Task
274
+ cache = True
275
+ # Strings are shorthand for `self.user[field]`. Tuples select the
276
+ # source explicitly: ('user', ...) or ('account', ...).
277
+ # Don't add ('account', 'id') — tenant isolation is already automatic.
278
+ cache_scope_fields = ['space_id', ('account', 'plan_id')]
279
+ ```
280
+
281
+ Strict by default: if the request has authenticated context but a
282
+ configured field is **missing** from the session payload, the request
283
+ fails with `HTTPException(500)` instead of silently merging caches
284
+ across scopes. Anonymous requests skip the fold. `None`, `0` and `''`
285
+ count as present (a real value). Use `before_cache` for the rare case
286
+ that needs context outside `self.user` / `self.account`:
287
+
288
+ ```python
289
+ async def before_cache(self, request):
290
+ """Escape hatch for scope sources not covered by cache_scope_fields."""
291
+ feature = await get_feature_flag(self.user)
292
+ self.cache_key += f':flag={feature}'
293
+ ```
294
+
295
+ Hit/miss stats:
296
+
297
+ ```python
298
+ from easyapi import get_cache_stats
299
+
300
+ stats = await get_cache_stats()
301
+ # {'hits': ..., 'misses': ..., 'total': ..., 'ratio': ..., 'by_model': {...}}
302
+ ```
303
+
304
+ ## Authentication
305
+
306
+ Two mechanisms, both Redis-backed:
307
+
308
+ - **Session cookie** — `Cookie: <COOKIE_ID>=<key>`, validated against a
309
+ strict regex before any Redis lookup.
310
+ - **API key** — `X-Api-Key: <token>`. Format is your project's choice;
311
+ easyapi resolves the token to a session via your `UserApi` model.
312
+ See the docs for the default resolution flow and how to issue keys.
313
+
314
+ When both are present, the API key wins. `authenticated = False` opts a
315
+ resource out of authentication while keeping rate limit and security
316
+ middleware in effect.
317
+
318
+ ## Security defaults
319
+
320
+ - Session cookie validated against `^[a-zA-Z0-9_\-:]{5,100}$`.
321
+ - `?fields=` is filtered against `list_fields` to prevent attribute leakage.
322
+ - `?filter=` and `segment_id` are validated against `filter_fields`.
323
+ - `owner_field` scopes PATCH/DELETE to rows owned by the authenticated user.
324
+ - Request rate limiting runs inside `BaseResource.dispatch`; edge scanner
325
+ blocking runs in `SecurityMiddleware` before the view.
326
+ - Both layers converge on the same blocked-IP store in Redis
327
+ (`rate_limit:blocked:<ip>`), with automatic 24h blocking.
328
+ - `SecurityMiddleware` instant-blocks scanner paths/UAs and 4xx floods.
329
+ - `get_client_ip` honours `X-Real-IP` only from `TRUSTED_PROXIES`.
330
+ - Unhandled handler exceptions return a sanitized JSON 500 in production
331
+ (no stack trace in the response). Full trace still goes to
332
+ `logger.exception`.
333
+ - Optional anti-replay token via `ENFORCE_TOKEN=True` (`X-Token` header).
334
+ Server validates HMAC, timestamp drift and a Redis-tracked nonce
335
+ (`SET NX PX`, TTL = 2× drift). Replayed nonces inside the window are
336
+ rejected. Helpers: `make_token` (mint), `validate_token` (sync HMAC
337
+ check), `validate_token_async` (HMAC + nonce reservation).
338
+
339
+ ## Tenancy
340
+
341
+ Multi-tenant database routing through `easyapi.DBRouter` and
342
+ `aset_tenant(account_id)`. Configure in your settings:
343
+
344
+ ```python
345
+ DEFAULT_DATABASE = DATABASES['default']
346
+ TENANT_ACCOUNT_MODEL = 'core.Account'
347
+ TENANT_USER_MODEL = 'core.User'
348
+ TENANT_USER_API_MODEL = 'core.UserApi'
349
+ TENANT_DB_PREFIX = 'tenant'
350
+ HASH_LENGTH = 32
351
+ DATABASE_ROUTERS = ['easyapi.DBRouter']
352
+ ```
353
+
354
+ `set_default(account_id)` and `unset_default(account_id)` are
355
+ **script-only** — they mutate the global `default` connection and are
356
+ unsafe inside ASGI request handling. They are *not* re-exported from the
357
+ top-level `easyapi` package; import them from `easyapi.tenant.tenant`
358
+ when you really need them in a management command or one-off script.
359
+ For per-request tenant switching, use `aset_tenant`.
360
+
361
+ ## MCP server (agent-callable tools)
362
+
363
+ Optional. Expose every resource as a typed tool that LLM agents can
364
+ call — same auth, same rate limit, same Pydantic schemas, same dispatch.
365
+
366
+ ```bash
367
+ pip install 'easyapi-django[mcp]'
368
+ ```
369
+
370
+ One liner — adds `POST /api/mcp`:
371
+
372
+ ```python
373
+ urlpatterns = [
374
+ path('api/', include(get_routes(endpoints, mcp=True))),
375
+ ]
376
+ ```
377
+
378
+ Or subclass for custom behaviour:
379
+
380
+ ```python
381
+ from easyapi import MCPResource
382
+
383
+ class MyMCP(MCPResource):
384
+ endpoints = my_endpoints
385
+ summary = 'agent-tools'
386
+
387
+ async def post_process(self, response):
388
+ await audit_log(self.user, self.body, response)
389
+ return response
390
+
391
+ urlpatterns = [path('mcp/', MyMCP.as_view())]
392
+ ```
393
+
394
+ For desktop agents (Claude Desktop, Cursor) over stdio:
395
+
396
+ ```bash
397
+ EASYAPI_MCP_API_KEY="<key>" python manage.py mcp_serve myapp.urls.endpoints
398
+ ```
399
+
400
+ Tool calls run through the **same `BaseResource.dispatch` as REST** —
401
+ no parallel handlers, no schema duplication. The bridge also wraps the
402
+ view in your project's `settings.MIDDLEWARE`, so `SecurityMiddleware`,
403
+ `AuthMiddleware`, `ExceptionMiddleware` and any custom **async-capable**
404
+ middleware run exactly as on a REST hit. Sync-only middleware is
405
+ skipped — mark it `async_capable = True` or enforce the equivalent
406
+ invariant inside `dispatch` if it is critical. Hide a resource from MCP
407
+ with `mcp_expose = False`; restrict to read-only with
408
+ `mcp_expose = ['list', 'get']`. See the docs for details.
409
+
410
+ ## Metrics endpoint
411
+
412
+ `get_routes()` automatically registers `POST /metrics` for aggregations
413
+ and group-bys. Useful for charts, dashboards and reports — one endpoint
414
+ covers what would otherwise be dozens of bespoke routes.
415
+
416
+ ```json
417
+ POST /metrics
418
+ {
419
+ "model": "myapp.Order",
420
+ "calc": {"formula": ["sum"], "field": "total"},
421
+ "group_by": {"date": {"field": "created_at", "group_by": "month"}},
422
+ "filter_by": {"period": "this_year"}
423
+ }
424
+ ```
425
+
426
+ Supports `count`, `sum`, `avg`, `min`, `max`, `variance`, `std dev`, with
427
+ optional grouping by field and date period (year/quarter/month/day/
428
+ weekday/hour).
429
+
430
+ ## WebSocket consumer
431
+
432
+ ```python
433
+ from easyapi import BaseWSConsumer
434
+
435
+ class MyConsumer(BaseWSConsumer):
436
+ # Defaults — override per consumer when needed
437
+ allow_unauthenticated = False # UUID-based connections opt-in
438
+ track_online = False # Redis-backed presence tracking
439
+
440
+ async def on_connect(self, user):
441
+ await self.send_state(['ready'], True)
442
+
443
+ async def allowed_channels(self, user):
444
+ # Return an iterable of channel suffix names this user may
445
+ # subscribe to. Channel names are also gated server-side by
446
+ # ^[A-Za-z0-9_\-.]{1,64}$. Return None to allow any well-formed
447
+ # name (legacy default), an empty list to block extra subs.
448
+ return ['inbox', 'alerts']
449
+ ```
450
+
451
+ Requires Django Channels. `allow_unauthenticated` defaults to `False`
452
+ since 0.30 — set it to `True` explicitly on consumers that need the
453
+ UUID-based signup flow.
454
+
455
+ ## Hooks
456
+
457
+ Override on your resource:
458
+
459
+ | Hook | When |
460
+ |---------------------|--------------------------------------------|
461
+ | `pre_process` | After auth, before body parsing |
462
+ | `before_cache` | Before the cache lookup (GET) |
463
+ | `hydrate(body)` | Before write (POST/PATCH) |
464
+ | `dehydrate(row)` | Per row before serialize |
465
+ | `alter_list` | Mutate list result |
466
+ | `alter_detail` | Mutate detail result |
467
+ | `post_process` | Last chance before save_cache + response |
468
+ | `add_m2m(result)` | Custom M2M handling |
469
+
470
+ `BaseTagsResource` and `BaseCustomResource` are ready-made subclasses for
471
+ projects that use tags and user-defined custom attributes.
472
+
473
+ ## Tests
474
+
475
+ ```
476
+ pip install -r requirements-dev.txt
477
+ pytest
478
+ ```
479
+
480
+ 216 tests covering util, redis, cache, filters, filter validation, init,
481
+ auth tokens (incl. nonce replay), schemas, openapi, helpers, serializer,
482
+ client_ip, SecurityMiddleware, dispatch error handling, tenant connection
483
+ and registry, MCP middleware chain, route gating, WS subscription
484
+ hardening, public exports, and WebSocket optional import.
485
+
486
+ ## Author
487
+
488
+ Stamatios Stamou Jr — [github.com/ssjunior](https://github.com/ssjunior)