django-components/src/django_components/extensions/view.py

303 lines
10 KiB
Python

import sys
from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional, Protocol, Type, Union, cast
from weakref import WeakKeyDictionary
import django.urls
from django.http import HttpRequest, HttpResponse
from django.views.generic import View
from django_components.extension import (
ComponentExtension,
ExtensionComponentConfig,
OnComponentClassCreatedContext,
OnComponentClassDeletedContext,
URLRoute,
extensions,
)
from django_components.util.misc import format_url
if TYPE_CHECKING:
from django_components.component import Component
# NOTE: `WeakKeyDictionary` is NOT a generic pre-3.9
if sys.version_info >= (3, 9):
ComponentRouteCache = WeakKeyDictionary[Type["Component"], URLRoute]
else:
ComponentRouteCache = WeakKeyDictionary
class ViewFn(Protocol):
def __call__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any: ...
def _get_component_route_name(component: Union[Type["Component"], "Component"]) -> str:
return f"__component_url__{component.class_id}"
def get_component_url(
component: Union[Type["Component"], "Component"],
query: Optional[Dict] = None,
fragment: Optional[str] = None,
) -> str:
"""
Get the URL for a [`Component`](../api#django_components.Component).
Raises `RuntimeError` if the component is not public.
Read more about [Component views and URLs](../../concepts/fundamentals/component_views_urls).
`get_component_url()` optionally accepts `query` and `fragment` arguments.
**Example:**
```py
from django_components import Component, get_component_url
class MyComponent(Component):
class View:
public = True
# Get the URL for the component
url = get_component_url(
MyComponent,
query={"foo": "bar"},
fragment="baz",
)
# /components/ext/view/components/c1ab2c3?foo=bar#baz
```
"""
view_cls: Optional[Type[ComponentView]] = getattr(component, "View", None)
if not _is_view_public(view_cls):
raise RuntimeError("Component URL is not available - Component is not public")
route_name = _get_component_route_name(component)
url = django.urls.reverse(route_name)
return format_url(url, query=query, fragment=fragment)
class ComponentView(ExtensionComponentConfig, View):
"""
The interface for `Component.View`.
The fields of this class are used to configure the component views and URLs.
This class is a subclass of
[`django.views.View`](https://docs.djangoproject.com/en/5.2/ref/class-based-views/base/#view).
The [`Component`](../api#django_components.Component) class is available
via `self.component_cls`.
Override the methods of this class to define the behavior of the component.
Read more about [Component views and URLs](../../concepts/fundamentals/component_views_urls).
**Example:**
```python
class MyComponent(Component):
class View:
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return HttpResponse("Hello, world!")
```
**Component URL:**
If the `public` attribute is set to `True`, the component will have its own URL
that will point to the Component's View.
```py
from django_components import Component
class MyComponent(Component):
class View:
public = True
def get(self, request, *args, **kwargs):
return HttpResponse("Hello, world!")
```
Will create a URL route like `/components/ext/view/components/a1b2c3/`.
To get the URL for the component, use [`get_component_url()`](../api#django_components.get_component_url):
```py
url = get_component_url(MyComponent)
```
"""
# NOTE: The `component` / `component_cls` attributes are NOT user input, but still must be declared
# on this class for Django's `View.as_view()` to allow us to pass `component` kwarg.
# TODO_v1 - Remove. Superseded by `component_cls` attribute because we don't actually have access to an instance.
component = cast("Component", None)
"""
DEPRECATED: Will be removed in v1.0.
Use [`component_cls`](../api#django_components.ComponentView.component_cls) instead.
This is a dummy instance created solely for the View methods.
It is the same as if you instantiated the component class directly:
```py
component = Calendar()
component.render_to_response(request=request)
```
"""
component_cls = cast("Type[Component]", None)
"""
The parent component class.
**Example:**
```py
class MyComponent(Component):
class View:
def get(self, request):
return self.component_cls.render_to_response(request=request)
```
"""
def __init__(self, component: "Component", **kwargs: Any) -> None:
ComponentExtension.ComponentConfig.__init__(self, component)
View.__init__(self, **kwargs)
# TODO_v1 - Remove. Superseded by `component_cls`. This was used for backwards compatibility.
self.component = component
@property
def url(self) -> str:
"""
The URL for the component.
Raises `RuntimeError` if the component is not public.
This is the same as calling [`get_component_url()`](../api#django_components.get_component_url)
with the parent [`Component`](../api#django_components.Component) class:
```py
class MyComponent(Component):
class View:
def get(self, request):
assert self.url == get_component_url(self.component_cls)
```
"""
return get_component_url(self.component_cls)
# #####################################
# PUBLIC API (Configurable by users)
# #####################################
public: ClassVar[bool] = False
"""
Whether the component should be available via a URL.
**Example:**
```py
from django_components import Component
class MyComponent(Component):
class View:
public = True
```
Will create a URL route like `/components/ext/view/components/a1b2c3/`.
To get the URL for the component, use [`get_component_url()`](../api#django_components.get_component_url):
```py
url = get_component_url(MyComponent)
```
"""
# NOTE: The methods below are defined to satisfy the `View` class. All supported methods
# are defined in `View.http_method_names`.
#
# Each method actually delegates to the component's method of the same name.
# E.g. When `get()` is called, it delegates to `component.get()`.
# TODO_V1 - In v1 handlers like `get()` should be defined on the Component.View class,
# not the Component class directly. This is to align Views with the extensions API
# where each extension should keep its methods in the extension class.
# Instead, the defaults for these methods should be something like
# `return self.component_cls.render_to_response(request, *args, **kwargs)` or similar
# or raise NotImplementedError.
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return self.component_cls().get(request, *args, **kwargs) # type: ignore[attr-defined]
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return self.component_cls().post(request, *args, **kwargs) # type: ignore[attr-defined]
def put(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return self.component_cls().put(request, *args, **kwargs) # type: ignore[attr-defined]
def patch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return self.component_cls().patch(request, *args, **kwargs) # type: ignore[attr-defined]
def delete(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return self.component_cls().delete(request, *args, **kwargs) # type: ignore[attr-defined]
def head(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return self.component_cls().head(request, *args, **kwargs) # type: ignore[attr-defined]
def options(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return self.component_cls().options(request, *args, **kwargs) # type: ignore[attr-defined]
def trace(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return self.component_cls().trace(request, *args, **kwargs) # type: ignore[attr-defined]
class ViewExtension(ComponentExtension):
"""
This extension adds a nested `View` class to each `Component`.
This nested class is a subclass of `django.views.View`, and allows the component
to be used as a view by calling `ComponentView.as_view()`.
This extension also allows the component to be available via a unique URL.
This extension is automatically added to all components.
"""
name = "view"
ComponentConfig = ComponentView
def __init__(self) -> None:
# Remember which route belongs to which component
self.routes_by_component: ComponentRouteCache = WeakKeyDictionary()
# Create URL route on creation
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
comp_cls = ctx.component_cls
view_cls: Optional[Type[ComponentView]] = getattr(comp_cls, "View", None)
if not _is_view_public(view_cls):
return
# Create a URL route like `components/MyTable_a1b2c3/`
# And since this is within the `view` extension, the full URL path will then be:
# `/components/ext/view/components/MyTable_a1b2c3/`
route_path = f"components/{comp_cls.class_id}/"
route_name = _get_component_route_name(comp_cls)
route = URLRoute(
path=route_path,
handler=comp_cls.as_view(),
name=route_name,
)
self.routes_by_component[comp_cls] = route
extensions.add_extension_urls(self.name, [route])
# Remove URL route on deletion
def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None:
comp_cls = ctx.component_cls
route = self.routes_by_component.pop(comp_cls, None)
if route is None:
return
extensions.remove_extension_urls(self.name, [route])
def _is_view_public(view_cls: Optional[Type[ComponentView]]) -> bool:
if view_cls is None:
return False
return getattr(view_cls, "public", False)