Last active
February 7, 2024 15:10
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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("}", "}}") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
the relative import
decorators
at the top is a straight copy ofrest_framework.decorators.actions
andrest_framework.decorators.MethodMapper
shoved in a file