easyapi-django 0.30__tar.gz → 0.32__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 (78) hide show
  1. {easyapi_django-0.30/easyapi_django.egg-info → easyapi_django-0.32}/PKG-INFO +10 -7
  2. {easyapi_django-0.30 → easyapi_django-0.32}/README.md +9 -6
  3. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/base.py +39 -9
  4. easyapi_django-0.32/easyapi/exception.py +24 -0
  5. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/rate_limit.py +25 -6
  6. {easyapi_django-0.30 → easyapi_django-0.32/easyapi_django.egg-info}/PKG-INFO +10 -7
  7. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi_django.egg-info/SOURCES.txt +1 -0
  8. {easyapi_django-0.30 → easyapi_django-0.32}/pyproject.toml +1 -1
  9. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_cache_scope.py +28 -12
  10. easyapi_django-0.32/tests/test_exception_render.py +69 -0
  11. easyapi_django-0.30/easyapi/exception.py +0 -11
  12. {easyapi_django-0.30 → easyapi_django-0.32}/LICENSE +0 -0
  13. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/__init__.py +0 -0
  14. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/auth.py +0 -0
  15. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/blocking.py +0 -0
  16. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/calc.py +0 -0
  17. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/calc_resource.py +0 -0
  18. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/client_ip.py +0 -0
  19. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/constants.py +0 -0
  20. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/dates.py +0 -0
  21. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/filters.py +0 -0
  22. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/helpers.py +0 -0
  23. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/management/__init__.py +0 -0
  24. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/management/commands/__init__.py +0 -0
  25. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/management/commands/mcp_serve.py +0 -0
  26. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/mcp/__init__.py +0 -0
  27. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/mcp/bridge.py +0 -0
  28. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/mcp/protocol.py +0 -0
  29. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/mcp/resource.py +0 -0
  30. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/mcp/tools.py +0 -0
  31. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/middleware.py +0 -0
  32. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/openapi.py +0 -0
  33. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/orm/__init__.py +0 -0
  34. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/redis_config.py +0 -0
  35. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/routes.py +0 -0
  36. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/schemas.py +0 -0
  37. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/security.py +0 -0
  38. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/serializer.py +0 -0
  39. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/tenant/__init__.py +0 -0
  40. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/tenant/db_router.py +0 -0
  41. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/tenant/registry.py +0 -0
  42. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/tenant/tenant.py +0 -0
  43. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/util.py +0 -0
  44. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/ws.py +0 -0
  45. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi_django.egg-info/dependency_links.txt +0 -0
  46. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi_django.egg-info/requires.txt +0 -0
  47. {easyapi_django-0.30 → easyapi_django-0.32}/easyapi_django.egg-info/top_level.txt +0 -0
  48. {easyapi_django-0.30 → easyapi_django-0.32}/setup.cfg +0 -0
  49. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_api_key_resolver.py +0 -0
  50. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_auth.py +0 -0
  51. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_base_init.py +0 -0
  52. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_block.py +0 -0
  53. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_cache.py +0 -0
  54. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_cache_auto_account_scope.py +0 -0
  55. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_client_ip.py +0 -0
  56. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_dispatch_error_handling.py +0 -0
  57. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_exports.py +0 -0
  58. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_filter_validation.py +0 -0
  59. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_filters.py +0 -0
  60. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_get_obj_m2m_only.py +0 -0
  61. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_helpers.py +0 -0
  62. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_index_route.py +0 -0
  63. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_mcp.py +0 -0
  64. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_mcp_middleware_chain.py +0 -0
  65. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_normalize_field.py +0 -0
  66. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_openapi.py +0 -0
  67. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_owner_field.py +0 -0
  68. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_redis_config.py +0 -0
  69. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_return_result_falsy.py +0 -0
  70. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_routes_gate.py +0 -0
  71. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_schemas.py +0 -0
  72. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_security.py +0 -0
  73. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_serializer.py +0 -0
  74. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_tenant_connection.py +0 -0
  75. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_tenant_registry.py +0 -0
  76. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_util.py +0 -0
  77. {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_ws_channels.py +0 -0
  78. {easyapi_django-0.30 → easyapi_django-0.32}/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.30
3
+ Version: 0.32
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: MIT
@@ -278,12 +278,15 @@ class TaskResource(BaseResource):
278
278
  cache_scope_fields = ['space_id', ('account', 'plan_id')]
279
279
  ```
280
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`:
281
+ Lenient by default: if the request has authenticated context but a
282
+ configured field is **missing** from the session payload, the framework
283
+ logs a `WARNING` (logger `easyapi.base`) and **skips the scope fold** —
284
+ the request still serves, the cache may be shared across scopes until
285
+ the operator updates the session payload. Anonymous requests also skip
286
+ the fold. `None`, `0` and `''` count as present (a real value).
287
+
288
+ Use `before_cache` for the rare case that needs context outside
289
+ `self.user` / `self.account`:
287
290
 
288
291
  ```python
289
292
  async def before_cache(self, request):
@@ -251,12 +251,15 @@ class TaskResource(BaseResource):
251
251
  cache_scope_fields = ['space_id', ('account', 'plan_id')]
252
252
  ```
253
253
 
254
- Strict by default: if the request has authenticated context but a
255
- configured field is **missing** from the session payload, the request
256
- fails with `HTTPException(500)` instead of silently merging caches
257
- across scopes. Anonymous requests skip the fold. `None`, `0` and `''`
258
- count as present (a real value). Use `before_cache` for the rare case
259
- that needs context outside `self.user` / `self.account`:
254
+ Lenient by default: if the request has authenticated context but a
255
+ configured field is **missing** from the session payload, the framework
256
+ logs a `WARNING` (logger `easyapi.base`) and **skips the scope fold** —
257
+ the request still serves, the cache may be shared across scopes until
258
+ the operator updates the session payload. Anonymous requests also skip
259
+ the fold. `None`, `0` and `''` count as present (a real value).
260
+
261
+ Use `before_cache` for the rare case that needs context outside
262
+ `self.user` / `self.account`:
260
263
 
261
264
  ```python
262
265
  async def before_cache(self, request):
@@ -393,18 +393,21 @@ class BaseResource(View):
393
393
  for source, field in self.cache_scope_fields:
394
394
  container = sources.get(source)
395
395
  if container is None:
396
- raise HTTPException(
397
- 500,
398
- f"Cache scope source {source!r} missing from session "
399
- f"for {self.__class__.__name__}",
396
+ logger.warning(
397
+ "cache scope source %r missing from session for %s; "
398
+ "skipping scope fold (cache may be shared across "
399
+ "scopes — populate the session payload to fix)",
400
+ source, self.__class__.__name__,
400
401
  )
402
+ return None
401
403
  if field not in container:
402
- raise HTTPException(
403
- 500,
404
- f"Cache scope field {field!r} missing from "
405
- f"{source} session payload for "
406
- f"{self.__class__.__name__}",
404
+ logger.warning(
405
+ "cache scope field %r missing from %s session payload "
406
+ "for %s; skipping scope fold (cache may be shared "
407
+ "across scopes — populate the session payload to fix)",
408
+ field, source, self.__class__.__name__,
407
409
  )
410
+ return None
408
411
  parts.append(f'{source}.{field}={container[field]!r}')
409
412
 
410
413
  digest = hashlib.md5(':'.join(parts).encode('utf-8')).hexdigest()[:16]
@@ -514,6 +517,33 @@ class BaseResource(View):
514
517
  )
515
518
 
516
519
  async def dispatch(self, request, *args, **kwargs) -> None:
520
+ """Outer wrapper — guarantees unhandled exceptions never reach
521
+ the wire silently. ``HTTPException`` re-raises (rendered by
522
+ :class:`ExceptionMiddleware`); anything else gets logged with
523
+ full traceback and returns a sanitized JSON 500.
524
+
525
+ Without this net, exceptions from ``_authenticate``, ``aset_tenant``,
526
+ ``_serve_cache``, ``pre_process``, ``_parse_body``, ``build_filters``
527
+ or ``serialize`` bubbled to Django's default async handler with no
528
+ easyapi-side log — Sentry stayed empty and operators were blind.
529
+ """
530
+ try:
531
+ return await self._dispatch(request, *args, **kwargs)
532
+ except HTTPException:
533
+ raise
534
+ except Exception:
535
+ logger.exception(
536
+ 'Unhandled error in dispatch %s %s',
537
+ request.method, request.path,
538
+ )
539
+ if getattr(django_settings, 'DEBUG', False):
540
+ raise
541
+ return JsonResponse(
542
+ {'success': False, 'status': 500, 'detail': 'Internal error'},
543
+ status=500,
544
+ )
545
+
546
+ async def _dispatch(self, request, *args, **kwargs) -> None:
517
547
  self.identifier = get_client_ip(request)
518
548
 
519
549
  await self._enforce_rate_limit(request)
@@ -0,0 +1,24 @@
1
+ import logging
2
+
3
+ from django.http import JsonResponse
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ class HTTPException(Exception):
9
+
10
+ def render(self, exception):
11
+ (status, detail) = exception.args
12
+ # 5xx is a server-side problem — emit a logged stack trace so the
13
+ # response body is not the only signal. Without this, every
14
+ # ``raise HTTPException(500, ...)`` was invisible to logs/Sentry
15
+ # and operators had to guess from black-box symptoms.
16
+ if isinstance(status, int) and status >= 500:
17
+ logger.exception(
18
+ 'HTTPException %s: %s', status, detail,
19
+ exc_info=exception,
20
+ )
21
+ return JsonResponse(
22
+ {'success': False, 'status': status, 'detail': detail},
23
+ status=status,
24
+ )
@@ -1,8 +1,12 @@
1
+ import logging
1
2
  import time
3
+
2
4
  from redis import StrictRedis
3
5
 
4
6
  from .redis_config import REDIS_SERVER, REDIS_DB, KEY_PREFIX
5
7
 
8
+ logger = logging.getLogger(__name__)
9
+
6
10
 
7
11
  class RateLimit(StrictRedis):
8
12
  def __init__(self):
@@ -30,7 +34,15 @@ class RateLimit(StrictRedis):
30
34
  return {"blocked": False}
31
35
 
32
36
  def track_pattern(self, identifier, action="abuse"):
33
- """Detect regular request patterns indicative of abuse."""
37
+ """Detect regular request patterns indicative of abuse.
38
+
39
+ Reports the signal but does NOT auto-write a block key. Frontends
40
+ retrying after a server-side error look identical to a bot when
41
+ you only consider request timing — auto-blocking that traffic
42
+ locked legitimate users out and was opaque to debug. Callers may
43
+ decide to escalate based on the returned ``suspicious`` flag plus
44
+ other signals (e.g. authenticated identity, response codes).
45
+ """
34
46
  pattern_key = f"{KEY_PREFIX}rate_limit:pattern:{action}:{identifier}"
35
47
  now = self.current_milli_time()
36
48
  last_req = self.get(f"{pattern_key}:last")
@@ -50,8 +62,11 @@ class RateLimit(StrictRedis):
50
62
  mean = sum(intervals) / len(intervals)
51
63
  variance = sum((x - mean) ** 2 for x in intervals) / len(intervals)
52
64
  if variance < 100:
53
- block_key = f"{KEY_PREFIX}block:{action}:{identifier}"
54
- self.setex(block_key, 300, "suspicious")
65
+ logger.warning(
66
+ 'rate_limit: suspicious request pattern from %s '
67
+ '(action=%s, mean_interval_ms=%.0f, variance=%.2f)',
68
+ identifier, action, mean, variance,
69
+ )
55
70
  return {"suspicious": True, "reason": "Regular request intervals"}
56
71
  return {"suspicious": False}
57
72
 
@@ -69,9 +84,13 @@ class RateLimit(StrictRedis):
69
84
  return {"rate_limited": True, "abuse": False, "blocked": True}
70
85
 
71
86
  if action in ["api", "login"]:
72
- pattern_result = self.track_pattern(identifier, "abuse")
73
- if pattern_result["suspicious"]:
74
- return {"rate_limited": True, "abuse": True, "reason": pattern_result["reason"]}
87
+ # Pattern detection is recorded for observability but no
88
+ # longer short-circuits to ``abuse=True``. Timing-only
89
+ # signals caught legitimate retry traffic (frontends
90
+ # retrying after server-side errors look identical to bots
91
+ # when judged by intervals alone). Real abuse is caught by
92
+ # the count thresholds below; pattern alone goes to logs.
93
+ self.track_pattern(identifier, "abuse")
75
94
 
76
95
  now = self.current_milli_time()
77
96
  type_to_check = action
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: easyapi_django
3
- Version: 0.30
3
+ Version: 0.32
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: MIT
@@ -278,12 +278,15 @@ class TaskResource(BaseResource):
278
278
  cache_scope_fields = ['space_id', ('account', 'plan_id')]
279
279
  ```
280
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`:
281
+ Lenient by default: if the request has authenticated context but a
282
+ configured field is **missing** from the session payload, the framework
283
+ logs a `WARNING` (logger `easyapi.base`) and **skips the scope fold** —
284
+ the request still serves, the cache may be shared across scopes until
285
+ the operator updates the session payload. Anonymous requests also skip
286
+ the fold. `None`, `0` and `''` count as present (a real value).
287
+
288
+ Use `before_cache` for the rare case that needs context outside
289
+ `self.user` / `self.account`:
287
290
 
288
291
  ```python
289
292
  async def before_cache(self, request):
@@ -50,6 +50,7 @@ tests/test_cache_auto_account_scope.py
50
50
  tests/test_cache_scope.py
51
51
  tests/test_client_ip.py
52
52
  tests/test_dispatch_error_handling.py
53
+ tests/test_exception_render.py
53
54
  tests/test_exports.py
54
55
  tests/test_filter_validation.py
55
56
  tests/test_filters.py
@@ -11,7 +11,7 @@ build-backend = "setuptools.build_meta"
11
11
 
12
12
  [project]
13
13
  name = "easyapi_django"
14
- version = "0.30"
14
+ version = "0.32"
15
15
  authors = [
16
16
  { name="Stamatios Stamou Jr", email="bushier.outsets.0c@icloud.com" },
17
17
  ]
@@ -154,28 +154,44 @@ def test_multiple_dimensions_combine():
154
154
  assert a._build_cache_key(_request(), None) != b._build_cache_key(_request(), None)
155
155
 
156
156
 
157
- # ── strict semantics ───────────────────────────────────────────────
157
+ # ── lenient semantics (warn + skip fold, never 500) ────────────────
158
158
 
159
159
 
160
- def test_authenticated_user_missing_field_raises_500():
160
+ def test_missing_field_logs_warning_and_skips_fold(caplog):
161
+ """Missing scope field on authenticated request must not block the
162
+ request — silent leak between scopes is preferable to taking the
163
+ site down. Operators see a warning to fix the session payload."""
164
+ import logging as _logging
165
+
161
166
  class _R(_Resource):
162
167
  cache_scope_fields = ['space_id']
163
168
  res = _make(_R, user={'id': 1}) # space_id NOT in user
164
- with pytest.raises(HTTPException) as exc:
165
- res._build_cache_key(_request(), None)
166
- assert exc.value.args[0] == 500
167
- assert 'space_id' in exc.value.args[1]
168
169
 
170
+ with caplog.at_level(_logging.WARNING, logger='easyapi.base'):
171
+ key = res._build_cache_key(_request(), None)
172
+
173
+ assert 'scope=' not in key # fold skipped
174
+ records = [r for r in caplog.records if r.name == 'easyapi.base']
175
+ assert records, 'expected a warning when scope field is missing'
176
+ msg = records[0].getMessage()
177
+ assert 'space_id' in msg
178
+ assert '_R' in msg or 'class' in msg.lower()
179
+
180
+
181
+ def test_missing_account_source_logs_warning_and_skips_fold(caplog):
182
+ import logging as _logging
169
183
 
170
- def test_authenticated_user_missing_account_source_raises_500():
171
- """User present, account expected but absent — refuses to cache."""
172
184
  class _R(_Resource):
173
185
  cache_scope_fields = [('account', 'plan_id')]
174
186
  res = _make(_R, user={'id': 1}, account=None)
175
- with pytest.raises(HTTPException) as exc:
176
- res._build_cache_key(_request(), None)
177
- assert exc.value.args[0] == 500
178
- assert 'account' in exc.value.args[1]
187
+
188
+ with caplog.at_level(_logging.WARNING, logger='easyapi.base'):
189
+ key = res._build_cache_key(_request(), None)
190
+
191
+ assert 'scope=' not in key
192
+ records = [r for r in caplog.records if r.name == 'easyapi.base']
193
+ assert records
194
+ assert 'account' in records[0].getMessage()
179
195
 
180
196
 
181
197
  # ── coexistence with session_cache ─────────────────────────────────
@@ -0,0 +1,69 @@
1
+ """HTTPException.render must log 5xx errors so Sentry / log aggregators
2
+ see them. Silent 5xx renders were the root of an opaque-error incident
3
+ in production."""
4
+ import json
5
+ import logging
6
+
7
+ import pytest
8
+
9
+ from easyapi.exception import HTTPException
10
+
11
+
12
+ def _render(status, detail='boom'):
13
+ exc = HTTPException(status, detail)
14
+ response = exc.render(exc)
15
+ body = json.loads(response.content.decode('utf-8'))
16
+ return response, body
17
+
18
+
19
+ def test_500_logs_exception(caplog):
20
+ with caplog.at_level(logging.ERROR, logger='easyapi.exception'):
21
+ response, body = _render(500, 'database unreachable')
22
+
23
+ assert response.status_code == 500
24
+ assert body == {'success': False, 'status': 500, 'detail': 'database unreachable'}
25
+
26
+ records = [r for r in caplog.records if r.name == 'easyapi.exception']
27
+ assert records, 'expected a log record for 500 render'
28
+ record = records[0]
29
+ assert record.levelno == logging.ERROR
30
+ assert '500' in record.getMessage()
31
+ assert 'database unreachable' in record.getMessage()
32
+ # exc_info attached so traceback is preserved for Sentry-style sinks
33
+ assert record.exc_info is not None
34
+
35
+
36
+ def test_503_also_logs(caplog):
37
+ with caplog.at_level(logging.ERROR, logger='easyapi.exception'):
38
+ _render(503, 'upstream down')
39
+ records = [r for r in caplog.records if r.name == 'easyapi.exception']
40
+ assert records, '5xx other than 500 must also log'
41
+
42
+
43
+ def test_400_does_not_log(caplog):
44
+ """4xx is client-side; no log noise — they're routine."""
45
+ with caplog.at_level(logging.DEBUG, logger='easyapi.exception'):
46
+ response, body = _render(400, 'bad input')
47
+ assert response.status_code == 400
48
+ records = [r for r in caplog.records if r.name == 'easyapi.exception']
49
+ assert not records
50
+
51
+
52
+ def test_403_does_not_log(caplog):
53
+ with caplog.at_level(logging.DEBUG, logger='easyapi.exception'):
54
+ _render(403, 'forbidden')
55
+ records = [r for r in caplog.records if r.name == 'easyapi.exception']
56
+ assert not records
57
+
58
+
59
+ def test_404_does_not_log(caplog):
60
+ with caplog.at_level(logging.DEBUG, logger='easyapi.exception'):
61
+ _render(404, 'not found')
62
+ records = [r for r in caplog.records if r.name == 'easyapi.exception']
63
+ assert not records
64
+
65
+
66
+ def test_response_body_unchanged_for_5xx():
67
+ """Logging is additive — must not change the wire response."""
68
+ response, body = _render(500, 'something')
69
+ assert body == {'success': False, 'status': 500, 'detail': 'something'}
@@ -1,11 +0,0 @@
1
- from django.http import JsonResponse
2
-
3
-
4
- class HTTPException(Exception):
5
-
6
- def render(self, exception):
7
- (status, detail) = exception.args
8
- return JsonResponse(
9
- {'success': False, 'status': status, 'detail': detail},
10
- status=status,
11
- )
File without changes
File without changes