Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/source/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,42 @@ if DEBUG:

See this [Windows MIME type detection pitfalls](https://www.taricorp.net/2020/windows-mime-pitfalls/) article, this [StackOverflow answer](https://stackoverflow.com/a/16355034), or [issue #201](https://github.com/adamghill/django-unicorn/issues/201) for more details.

## Components not working on public pages with LoginRequiredMiddleware

Django 5.1 introduced
[`LoginRequiredMiddleware`](https://docs.djangoproject.com/en/stable/ref/middleware/#django.contrib.auth.middleware.LoginRequiredMiddleware)
which redirects every unauthenticated request to the login page by default.
Because Unicorn communicates via AJAX, an unauthenticated user on a public page
will see a token or parse error in the browser console (the endpoint received a
redirect instead of the expected JSON response).

**Fix: mark the component as public via `Meta.login_not_required = True`.**

```python
# newsletter_signup.py
from django_unicorn.components import UnicornView

class NewsletterSignupView(UnicornView):
email = ""

class Meta:
login_not_required = True # allow unauthenticated users

def subscribe(self):
...
```

Unicorn checks this flag at request time and returns a `401` JSON response
(instead of a redirect) for any component that does *not* set
`Meta.login_not_required = True` when the middleware is active and the user is not
authenticated. Components that require login can omit the `Meta` flag entirely and
rely on the default protected behaviour.

```{note}
`Meta.login_not_required` has no effect on Django versions older than 5.1 because
`LoginRequiredMiddleware` was not available before that release.
```

## Missing CSRF token or 403 Forbidden errors

`Unicorn` uses CSRF to protect its endpoint from malicious actors. The two parts that are required for CSRF are `"django.middleware.csrf.CsrfViewMiddleware"` in `MIDDLEWARE` and `{% csrf_token %}` in the template that includes any `Unicorn` components.
Expand Down
21 changes: 14 additions & 7 deletions docs/source/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ class BookForm(forms.Form):
publish_date = forms.DateField(required=True)

class BookView(UnicornView):
form_class = BookForm

title = ""
publish_date = ""

class Meta:
form_class = BookForm
```

```html
Expand All @@ -35,7 +36,11 @@ class BookView(UnicornView):
</div>
```

Because of the `form_class = BookForm` defined on the `UnicornView` above, `Unicorn` will automatically validate that the title has a value and is less than 100 characters. The `publish_date` will also be converted into a `datetime` from the string representation in the text input.
Because of the `Meta.form_class = BookForm` defined on the `UnicornView` above, `Unicorn` will automatically validate that the title has a value and is less than 100 characters. The `publish_date` will also be converted into a `datetime` from the string representation in the text input.

```{note}
Setting `form_class` directly as a class attribute also works and is supported for backwards compatibility.
```

### Validate the entire component

Expand All @@ -60,10 +65,11 @@ class BookForm(forms.Form):
title = forms.CharField(max_length=6, required=True)

class BookView(UnicornView):
form_class = BookForm

text = "hello"

class Meta:
form_class = BookForm

def set_text(self):
self.text = "hello world"
self.validate()
Expand All @@ -80,10 +86,11 @@ class BookForm(forms.Form):
title = forms.CharField(max_length=6, required=True)

class BookView(UnicornView):
form_class = BookForm

text = "hello"

class Meta:
form_class = BookForm

def set_text(self):
if self.is_valid():
self.text = "hello world"
Expand Down
125 changes: 121 additions & 4 deletions docs/source/views.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,26 +125,32 @@ Never put sensitive data into a public property because that information will pu

### template_name

By default, the component name is used to determine what template should be used. For example, `hello_world.HelloWorldView` would by default use `unicorn/hello-world.html`. However, you can specify a particular template by setting `template_name` in the component.
By default, the component name is used to determine what template should be used. For example, `hello_world.HelloWorldView` would by default use `unicorn/hello-world.html`. Set `template_name` inside `Meta` to override it.

```python
# hello_world.py
from django_unicorn.components import UnicornView

class HelloWorldView(UnicornView):
template_name = "unicorn/hello-world.html"
class Meta:
template_name = "unicorn/hello-world.html"
```

```{note}
Setting `template_name` directly as a class attribute also works and is supported for backwards compatibility.
```

### template_html

Template HTML can be defined inline on the component instead of using an external HTML file.
Template HTML can be defined inline on the component instead of using an external HTML file. Set it inside `Meta`.

```python
# hello_world.py
from django_unicorn.components import UnicornView

class HelloWorldView(UnicornView):
template_html = """<div>
class Meta:
template_html = """<div>
<div>
Count: {{ count }}
</div>
Expand All @@ -157,6 +163,10 @@ class HelloWorldView(UnicornView):
...
```

```{note}
Setting `template_html` directly as a class attribute also works and is supported for backwards compatibility.
```

## Instance properties

### component_args
Expand Down Expand Up @@ -499,6 +509,113 @@ A context variable can also be marked as `safe` in the template with the normal
```
````

### login_not_required

By default, every component requires the user to be authenticated when Django's
[`LoginRequiredMiddleware`](https://docs.djangoproject.com/en/stable/ref/middleware/#django.contrib.auth.middleware.LoginRequiredMiddleware)
is active (Django 5.1+). Set `login_not_required = True` inside the `Meta` class to allow
unauthenticated users to interact with the component on public pages.

```python
# newsletter_signup.py
from django_unicorn.components import UnicornView

class NewsletterSignupView(UnicornView):
email = ""

class Meta:
login_not_required = True

def subscribe(self):
...
```

```{note}
`Meta.login_not_required` has no effect on Django versions older than 5.1 because
`LoginRequiredMiddleware` was not available before that release.
```

```{warning}
Only set `Meta.login_not_required = True` on components whose actions are safe to
execute without authentication. Any sensitive operation (e.g. accessing private
data, modifying records) should still verify `self.request.user.is_authenticated`
inside the relevant component methods.
```

### template_name

Override the template path used to render the component.

```python
# hello_world.py
from django_unicorn.components import UnicornView

class HelloWorldView(UnicornView):
class Meta:
template_name = "unicorn/hello-world.html"
```

### template_html

Define the component template as an inline HTML string instead of a separate file.

```python
# hello_world.py
from django_unicorn.components import UnicornView

class HelloWorldView(UnicornView):
count = 0

class Meta:
template_html = """<div>
<div>Count: {{ count }}</div>
<button unicorn:click="increment">+</button>
</div>"""
```

### component_key

Set a default key for the component class. This is applied when the template tag
does not supply a `key=` argument and is useful when you always want a specific
component to be keyed the same way.

```python
# signup.py
from django_unicorn.components import UnicornView

class SignupView(UnicornView):
class Meta:
component_key = "signup"
```

```{note}
A `key=` value provided in the template tag always takes precedence over
`Meta.component_key`.
```

### form_class

Attach a Django form for validation. Errors from the form are merged into the
component's `errors` dict.

```python
# book_form.py
from django_unicorn.components import UnicornView
from .forms import BookForm

class BookFormView(UnicornView):
title = ""
year = None

class Meta:
form_class = BookForm
```

```{note}
Setting `form_class` directly as a class attribute also works and is supported for
backwards compatibility.
```

## Pickling and Caching

Components are pickled and cached for the duration of the AJAX request. This means that any instance variable on the component must be pickleable.
Expand Down
51 changes: 46 additions & 5 deletions src/django_unicorn/components/unicorn_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,21 +237,38 @@ def __init__(self, component_args: list | None = None, **kwargs):
self._init_script: str = ""
self._validate_called = False
self.errors: dict[Any, Any] = {}

# Apply Meta.component_key as a class-level default when the template
# tag has not provided a key (i.e. self.component_key is still empty).
if not self.component_key and hasattr(self, "Meta") and hasattr(self.Meta, "component_key"):
self.component_key = cast(str, self.Meta.component_key)

self._set_default_template_name()
self._set_caches()

@timed
def _set_default_template_name(self) -> None:
"""Sets a default template name based on component's name if necessary.

Also handles `template_html` if it is set on the component which overrides `template_name`.
Also handles `template_html` (via Meta or direct attribute) which overrides
`template_name`. Meta attributes take precedence over direct class attributes.
"""

if hasattr(self, "template_html"):
# Resolve template_html — Meta takes precedence over direct attribute
template_html = None
if hasattr(self, "Meta") and hasattr(self.Meta, "template_html"):
template_html = self.Meta.template_html
elif hasattr(self, "template_html"):
template_html = self.template_html

if template_html:
try:
self.template_name = create_template(self.template_html) # type: ignore
self.template_name = create_template(template_html) # type: ignore
except AssertionError:
pass
elif hasattr(self, "Meta") and hasattr(self.Meta, "template_name"):
# Meta.template_name overrides a direct class attribute when set
self.template_name = self.Meta.template_name

get_template_names_is_valid = False

Expand Down Expand Up @@ -578,9 +595,15 @@ def get_frontend_context_variables(self) -> str:

@timed
def _get_form(self, data):
if hasattr(self, "form_class"):
form_class = None
if hasattr(self, "Meta") and hasattr(self.Meta, "form_class"):
form_class = self.Meta.form_class
elif hasattr(self, "form_class"):
form_class = self.form_class

if form_class:
try:
form = cast(Callable, self.form_class)(data=data)
form = cast(Callable, form_class)(data=data)
form.is_valid()

return form
Expand Down Expand Up @@ -870,9 +893,27 @@ def _is_public(self, name: str) -> bool:
"resolved",
"calling",
"called",
"login_not_required",
"form_class",
)
excludes = []

if hasattr(self, "Meta") and hasattr(self.Meta, "login_not_required"):
if not isinstance(self.Meta.login_not_required, bool):
raise AssertionError("Meta.login_not_required should be a bool")

if hasattr(self, "Meta") and hasattr(self.Meta, "template_name"):
if not isinstance(self.Meta.template_name, str):
raise AssertionError("Meta.template_name should be a str")

if hasattr(self, "Meta") and hasattr(self.Meta, "template_html"):
if not isinstance(self.Meta.template_html, str):
raise AssertionError("Meta.template_html should be a str")

if hasattr(self, "Meta") and hasattr(self.Meta, "component_key"):
if not isinstance(self.Meta.component_key, str):
raise AssertionError("Meta.component_key should be a str")

if hasattr(self, "Meta") and hasattr(self.Meta, "exclude"):
if not is_non_string_sequence(self.Meta.exclude):
raise AssertionError("Meta.exclude should be a list, tuple, or set")
Expand Down
4 changes: 4 additions & 0 deletions src/django_unicorn/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ class UnicornCacheError(Exception):
pass


class UnicornAuthenticationError(Exception):
pass


class UnicornViewError(Exception):
pass

Expand Down
Loading
Loading