import functools
import json
import typing
from collections.abc import Callable
from typing import Any
from django import http, urls
from django.contrib.auth import decorators
from django.core import serializers
from django.db import models
from django.template import loader as django_loader
[docs]
class EnvRequest(http.HttpRequest):
"""A request as seen inside an ``env``-decorated view.
The ``env`` decorator injects these attributes at runtime; this
subclass exists so type checkers know about them.
"""
ajax: bool
context: dict[str, Any] | None
template: str
redirect: Callable[..., http.HttpResponseRedirect]
permanent_redirect: Callable[..., http.HttpResponsePermanentRedirect]
not_found: type[http.HttpResponseNotFound]
reverse: Callable[..., str]
ViewFunction = Callable[..., Any]
[docs]
class ViewError(Exception):
pass
[docs]
class UnknownViewResponseError(ViewError):
pass
[docs]
def json_default_handler(obj: Any) -> str:
if hasattr(obj, 'isoformat'):
return obj.isoformat()
else:
raise TypeError(
f'Object of type {type(obj)} with value of {obj!r} is not JSON '
'serializable'
)
[docs]
def redirect(
url: str = './', *args: Any, **kwargs: Any
) -> http.HttpResponseRedirect:
if '/' not in url or args or kwargs:
url = urls.reverse(url, args=args, kwargs=kwargs)
return http.HttpResponseRedirect(url)
[docs]
def permanent_redirect(
url: str, *args: Any, **kwargs: Any
) -> http.HttpResponsePermanentRedirect:
if '/' not in url or args or kwargs:
url = urls.reverse(url, args=args, kwargs=kwargs)
return http.HttpResponsePermanentRedirect(url)
REQUEST_PROPERTIES: dict[str, Any] = {
'redirect': redirect,
'permanent_redirect': permanent_redirect,
'not_found': http.HttpResponseNotFound,
'reverse': urls.reverse,
}
def _prepare_request(request: EnvRequest, app: str, view: str) -> EnvRequest:
"""Add context and extra methods to the request"""
request.context = dict()
request.context['view'] = view
request.context['app'] = app
request.context['request'] = request
for k, v in REQUEST_PROPERTIES.items():
setattr(request, k, v)
return request
def _serialize_ajax_response(request: EnvRequest, response: Any) -> str:
"""Serialize a dict/list/QuerySet response body for an ajax request"""
if isinstance(response, models.query.QuerySet):
return serializers.serialize('json', response)
elif request.GET.get('debug'):
from django.core.serializers import json as django_json
return json.dumps(
response,
indent=4,
sort_keys=True,
cls=django_json.DjangoJSONEncoder,
default=json_default_handler,
)
else:
return json.dumps(response, default=json_default_handler)
def _process_ajax_response(
request: EnvRequest,
response: Any,
response_class: type[http.HttpResponse],
) -> http.HttpResponse:
"""Turn a dict/list/QuerySet response into an HttpResponse for ajax"""
output = _serialize_ajax_response(request, response)
callback = request.GET.get('callback', False)
if callback:
output = f'{callback}({output})'
if request.GET.get('debug'):
title = f'Rendering {request.context!r} in module {request.context!r}'
output = f"""
<html>
<head>
<title>{title}</title>
<style>
textarea{{
width: 100%;
height: 100%;
}}
</style>
</head>
<body>
<textarea>{output}</textarea>
</body>
</html>
"""
return response_class(output, content_type='text/html')
else:
return response_class(output, content_type='text/plain')
def _process_response(
request: EnvRequest,
response: Any,
response_class: type[http.HttpResponse],
) -> http.HttpResponse:
"""Generic response processing function, always returns HttpResponse"""
"""If we add something to the context stack, pop it after adding"""
if isinstance(response, (dict, list, models.query.QuerySet)):
if request.ajax:
return _process_ajax_response(request, response, response_class)
else:
"""Add the dictionary to the context and let
render_to_response handle it"""
assert request.context is not None
request.context.update(response)
response = None
if isinstance(response, http.HttpResponse):
return response
elif isinstance(response, str):
if request.ajax:
return response_class(response, content_type='text/plain')
else:
return response_class(response)
elif response is None:
render_to_string = django_loader.render_to_string
return response_class(
render_to_string(
request.template, context=request.context, request=request
)
)
else:
raise UnknownViewResponseError(
f'"{type(response)}" is an unsupported response type'
)
def _build_view(
function: ViewFunction,
login_required: bool,
response_class: type[http.HttpResponse],
) -> Callable[..., http.HttpResponse]:
"""Wrap ``function`` in the ``env`` request/response machinery.
Shared by both public call shapes of ``env`` so the overloaded name
never has to be called recursively (which would match no overload).
"""
def _env(
request: http.HttpRequest, *args: Any, **kwargs: Any
) -> http.HttpResponse:
req = typing.cast(EnvRequest, request)
req.ajax = bool(
max(
req.headers.get('x-requested-with') == 'XMLHttpRequest',
int(req.POST.get('ajax', 0)),
int(req.GET.get('ajax', 0)),
)
)
req.context = None
try:
# `function` is a real view function here; ty treats the generic
# `Callable` alias as not guaranteed to expose `__name__`.
name = function.__name__ # ty: ignore[unresolved-attribute]
app = function.__module__.split('.')[0]
req = _prepare_request(req, app, name)
req.template = f'{app}/{name}.html'
response = function(req, *args, **kwargs)
return _process_response(
req, response, response_class
) # pragma: no branch
finally:
"""Remove the context reference from request to prevent leaking"""
try:
del req.context, req.template
for k in REQUEST_PROPERTIES: # pragma: no branch
delattr(req, k)
except AttributeError:
pass # pragma: no branch
functools.update_wrapper(_env, function)
if login_required:
return decorators.login_required(_env)
else:
return _env
@typing.overload
def env(function: ViewFunction) -> Callable[..., http.HttpResponse]: ...
@typing.overload
def env(
function: None = None,
login_required: bool = False,
response_class: type[http.HttpResponse] = http.HttpResponse,
) -> Callable[[ViewFunction], Callable[..., http.HttpResponse]]: ...
[docs]
def env(
function: ViewFunction | None = None,
login_required: bool = False,
response_class: type[http.HttpResponse] = http.HttpResponse,
) -> Any:
"""
View decorator that automatically adds context and renders response
Keyword arguments:
login_required -- is everyone allowed or only authenticated users
Adds a RequestContext (request.context) with the following context items:
name -- current function name
Stores the template in request.template and assumes it to be in
<app>/<view>.html
"""
if function is not None:
return _build_view(function, login_required, response_class)
def inner(function: ViewFunction) -> Callable[..., http.HttpResponse]:
return _build_view(function, login_required, response_class)
return inner