Skip to content

Instantly share code, notes, and snippets.

@joshuadavidthomas
Last active February 7, 2024 15:10
Show Gist options
  • Save joshuadavidthomas/cca48d87caf9c0637a750eea5ae9f6a9 to your computer and use it in GitHub Desktop.
Save joshuadavidthomas/cca48d87caf9c0637a750eea5ae9f6a9 to your computer and use it in GitHub Desktop.
if `neapolitan.views.CRUDView` and `rest_framework.viewsets.Viewset`/`rest_framework.routers.SimpleRouter` had a baby, it would be ugly as hell
def route(methods=None, detail=None, url_path=None, url_name=None, **kwargs):
"""
Mark a ViewSet method as a routable action.
`@action`-decorated functions will be endowed with a `mapping` property,
a `MethodMapper` that can be used to add additional method-based behaviors
on the routed action.
:param methods: A list of HTTP method names this action responds to.
Defaults to GET only.
:param detail: Required. Determines whether this action applies to
instance/detail requests or collection/list requests.
:param url_path: Define the URL segment for this action. Defaults to the
name of the method decorated.
:param url_name: Define the internal (`reverse`) URL name for this action.
Defaults to the name of the method decorated with underscores
replaced with dashes.
:param kwargs: Additional properties to set on the view. This can be used
to override viewset-level *_classes settings, equivalent to
how the `@renderer_classes` etc. decorators work for function-
based API views.
"""
methods = ['get'] if methods is None else methods
methods = [method.lower() for method in methods]
assert detail is not None, (
"@action() missing required argument: 'detail'"
)
# name and suffix are mutually exclusive
if 'name' in kwargs and 'suffix' in kwargs:
raise TypeError("`name` and `suffix` are mutually exclusive arguments.")
def decorator(func):
func.mapping = MethodMapper(func, methods)
func.detail = detail
func.url_path = url_path if url_path else func.__name__
func.url_name = url_name if url_name else func.__name__.replace('_', '-')
# These kwargs will end up being passed to `ViewSet.as_view()` within
# the router, which eventually delegates to Django's CBV `View`,
# which assigns them as instance attributes for each request.
func.kwargs = kwargs
# Set descriptive arguments for viewsets
if 'name' not in kwargs and 'suffix' not in kwargs:
func.kwargs['name'] = pretty_name(func.__name__)
func.kwargs['description'] = func.__doc__ or None
return func
return decorator
class MethodMapper(dict):
"""
Enables mapping HTTP methods to different ViewSet methods for a single,
logical action.
Example usage:
class MyViewSet(ViewSet):
@action(detail=False)
def example(self, request, **kwargs):
...
@example.mapping.post
def create_example(self, request, **kwargs):
...
"""
def __init__(self, action, methods):
self.action = action
for method in methods:
self[method] = self.action.__name__
def _map(self, method, func):
assert method not in self, (
"Method '%s' has already been mapped to '.%s'." % (method, self[method]))
assert func.__name__ != self.action.__name__, (
"Method mapping does not behave like the property decorator. You "
"cannot use the same method name for each mapping declaration.")
self[method] = func.__name__
return func
def get(self, func):
return self._map('get', func)
def post(self, func):
return self._map('post', func)
def put(self, func):
return self._map('put', func)
def patch(self, func):
return self._map('patch', func)
def delete(self, func):
return self._map('delete', func)
def head(self, func):
return self._map('head', func)
def options(self, func):
return self._map('options', func)
def trace(self, func):
return self._map('trace', func)
from __future__ import annotations
from dataclasses import dataclass
from functools import update_wrapper
from inspect import getmembers
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import re_path
from django.urls import reverse
from django.utils.decorators import classonlymethod
from django.views.decorators.csrf import csrf_exempt
from neapolitan.views import CRUDView
from .decorators import MethodMapper
class BaseCRUDView(CRUDView):
@classonlymethod
def as_view(cls, *, role=None, actions=None, **initkwargs):
"""
Because of the way class based views create a closure around the
instantiated view, we need to totally reimplement `.as_view`,
and slightly modify the view function that is created and returned.
"""
## fallback to `CRUDView` if role is supplied, otherwise move forward with DRF copied code - JT
if role:
return super().as_view(role)
## most all of this was straight copied from DRF, adjusted slightly in spots to make neapolitan happy - JT
# The name and description initkwargs may be explicitly overridden for
# certain route configurations. eg, names of extra actions.
cls.name = None
cls.description = None
# The suffix initkwarg is reserved for displaying the viewset type.
# This initkwarg should have no effect if the name is provided.
# eg. 'List' or 'Instance'.
cls.suffix = None
# The detail initkwarg is reserved for introspecting the viewset type.
cls.detail = None
# Setting a basename allows a view to reverse its action urls. This
# value is provided by the router through the initkwargs.
cls.basename = None
# actions must not be empty
if not actions:
raise TypeError(
"The `actions` argument must be provided when "
"calling `.as_view()` on a ViewSet. For example "
"`.as_view({'get': 'list'})`"
)
# sanitize keyword arguments
for key in initkwargs:
if key in cls.http_method_names:
raise TypeError(
"You tried to pass in the %s method name as a "
"keyword argument to %s(). Don't do that." % (key, cls.__name__)
)
if not hasattr(cls, key):
raise TypeError(
"%s() received an invalid keyword %r" % (cls.__name__, key)
)
# name and suffix are mutually exclusive
if "name" in initkwargs and "suffix" in initkwargs:
raise TypeError(
"%s() received both `name` and `suffix`, which are "
"mutually exclusive arguments." % (cls.__name__)
)
def view(request, *args, **kwargs):
self = cls(
**initkwargs,
## this part is needed for Neapolitan's `CRUDView.get_template_names` - JT
template_name_suffix=f"_{actions[request.method.lower()]}",
)
if "get" in actions and "head" not in actions:
actions["head"] = actions["get"]
# We also store the mapping of request methods to actions,
# so that we can later set the action attribute.
# eg. `self.action = 'list'` on an incoming GET request.
self.action_map = actions
# Bind methods to actions
# This is the bit that's different to a standard view
for method, action in actions.items():
handler = getattr(self, action)
setattr(self, method, handler)
self.request = request
self.args = args
self.kwargs = kwargs
# And continue as usual
return self.dispatch(request, *args, **kwargs)
# take name and docstring from class
update_wrapper(view, cls, updated=())
# and possible attributes set by decorators
# like csrf_exempt from dispatch
update_wrapper(view, cls.dispatch, assigned=())
# We need to set these on the view function, so that breadcrumb
# generation can pick out these bits of information from a
# resolved URL.
view.cls = cls
view.initkwargs = initkwargs
view.actions = actions
return csrf_exempt(view)
@classonlymethod
def get_urls(cls):
urlpatterns = super().get_urls()
extra_actions = cls.get_extra_actions()
routes = []
for action in extra_actions:
route = Route.from_action(action, cls.model._meta.model_name)
routes.append(
re_path(
route.url,
cls.as_view(actions=route.mapping, **route.initkwargs),
name=route.name,
)
)
return urlpatterns + routes
@classmethod
def get_extra_actions(cls):
"""
Get the methods that are marked as an extra ViewSet `@action`.
"""
return [
_check_attr_name(method, name)
for name, method in getmembers(cls, _is_extra_action)
]
def _check_attr_name(func, name):
assert func.__name__ == name, (
"Expected function (`{func.__name__}`) to match its attribute name "
"(`{name}`). If using a decorator, ensure the inner function is "
"decorated with `functools.wraps`, or that `{func.__name__}.__name__` "
"is otherwise set to `{name}`."
).format(func=func, name=name)
return func
def _is_extra_action(attr):
return hasattr(attr, "mapping") and isinstance(attr.mapping, MethodMapper)
@dataclass(frozen=True)
class Route:
url: str
mapping: MethodMapper
name: str
detail: bool
initkwargs: dict[str, object]
@classmethod
def from_action(cls, action, basename):
initkwargs = {}
initkwargs.update(action.kwargs)
url_path = _escape_curly_brackets(action.url_path)
name = "{basename}-{url_name}".replace("{url_name}", action.url_name).replace(
"{basename}", basename
)
if action.detail:
url = r"^{prefix}/{lookup}/{url_path}{trailing_slash}$"
detail = True
else:
url = r"^{prefix}/{url_path}{trailing_slash}$"
detail = False
return cls(
url=url.replace("{prefix}", basename)
.replace("{url_path}", url_path)
.replace("{trailing_slash}", settings.APPEND_SLASH and "/" or ""),
mapping=action.mapping,
name=name,
detail=detail,
initkwargs=initkwargs,
)
def _escape_curly_brackets(url_path):
"""
Double brackets in regex of url_path for escape string formatting
"""
return url_path.replace("{", "{{").replace("}", "}}")
@joshuadavidthomas
Copy link
Author

joshuadavidthomas commented Nov 8, 2023

the relative import decorators at the top is a straight copy of rest_framework.decorators.actions and rest_framework.decorators.MethodMapper shoved in a file

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment