Skip to content

Commit b06c28c

Browse files
Merge branch 'main' into object-validation-for-fields
2 parents 05b872e + 751cfd0 commit b06c28c

File tree

9 files changed

+553
-18
lines changed

9 files changed

+553
-18
lines changed

docs/source/troubleshooting.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,42 @@ if DEBUG:
1616

1717
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.
1818

19+
## Components not working on public pages with LoginRequiredMiddleware
20+
21+
Django 5.1 introduced
22+
[`LoginRequiredMiddleware`](https://docs.djangoproject.com/en/stable/ref/middleware/#django.contrib.auth.middleware.LoginRequiredMiddleware)
23+
which redirects every unauthenticated request to the login page by default.
24+
Because Unicorn communicates via AJAX, an unauthenticated user on a public page
25+
will see a token or parse error in the browser console (the endpoint received a
26+
redirect instead of the expected JSON response).
27+
28+
**Fix: mark the component as public via `Meta.login_not_required = True`.**
29+
30+
```python
31+
# newsletter_signup.py
32+
from django_unicorn.components import UnicornView
33+
34+
class NewsletterSignupView(UnicornView):
35+
email = ""
36+
37+
class Meta:
38+
login_not_required = True # allow unauthenticated users
39+
40+
def subscribe(self):
41+
...
42+
```
43+
44+
Unicorn checks this flag at request time and returns a `401` JSON response
45+
(instead of a redirect) for any component that does *not* set
46+
`Meta.login_not_required = True` when the middleware is active and the user is not
47+
authenticated. Components that require login can omit the `Meta` flag entirely and
48+
rely on the default protected behaviour.
49+
50+
```{note}
51+
`Meta.login_not_required` has no effect on Django versions older than 5.1 because
52+
`LoginRequiredMiddleware` was not available before that release.
53+
```
54+
1955
## Missing CSRF token or 403 Forbidden errors
2056

2157
`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.

docs/source/validation.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@ class BookForm(forms.Form):
2020
publish_date = forms.DateField(required=True)
2121

2222
class BookView(UnicornView):
23-
form_class = BookForm
24-
2523
title = ""
2624
publish_date = ""
25+
26+
class Meta:
27+
form_class = BookForm
2728
```
2829

2930
```html
@@ -35,7 +36,11 @@ class BookView(UnicornView):
3536
</div>
3637
```
3738

38-
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.
39+
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.
40+
41+
```{note}
42+
Setting `form_class` directly as a class attribute also works and is supported for backwards compatibility.
43+
```
3944

4045
### Validate the entire component
4146

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

6267
class BookView(UnicornView):
63-
form_class = BookForm
64-
6568
text = "hello"
6669

70+
class Meta:
71+
form_class = BookForm
72+
6773
def set_text(self):
6874
self.text = "hello world"
6975
self.validate()
@@ -80,10 +86,11 @@ class BookForm(forms.Form):
8086
title = forms.CharField(max_length=6, required=True)
8187

8288
class BookView(UnicornView):
83-
form_class = BookForm
84-
8589
text = "hello"
8690

91+
class Meta:
92+
form_class = BookForm
93+
8794
def set_text(self):
8895
if self.is_valid():
8996
self.text = "hello world"

docs/source/views.md

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,26 +125,32 @@ Never put sensitive data into a public property because that information will pu
125125

126126
### template_name
127127

128-
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.
128+
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.
129129

130130
```python
131131
# hello_world.py
132132
from django_unicorn.components import UnicornView
133133

134134
class HelloWorldView(UnicornView):
135-
template_name = "unicorn/hello-world.html"
135+
class Meta:
136+
template_name = "unicorn/hello-world.html"
137+
```
138+
139+
```{note}
140+
Setting `template_name` directly as a class attribute also works and is supported for backwards compatibility.
136141
```
137142

138143
### template_html
139144

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

142147
```python
143148
# hello_world.py
144149
from django_unicorn.components import UnicornView
145150

146151
class HelloWorldView(UnicornView):
147-
template_html = """<div>
152+
class Meta:
153+
template_html = """<div>
148154
<div>
149155
Count: {{ count }}
150156
</div>
@@ -157,6 +163,10 @@ class HelloWorldView(UnicornView):
157163
...
158164
```
159165

166+
```{note}
167+
Setting `template_html` directly as a class attribute also works and is supported for backwards compatibility.
168+
```
169+
160170
## Instance properties
161171

162172
### component_args
@@ -499,6 +509,113 @@ A context variable can also be marked as `safe` in the template with the normal
499509
```
500510
````
501511

512+
### login_not_required
513+
514+
By default, every component requires the user to be authenticated when Django's
515+
[`LoginRequiredMiddleware`](https://docs.djangoproject.com/en/stable/ref/middleware/#django.contrib.auth.middleware.LoginRequiredMiddleware)
516+
is active (Django 5.1+). Set `login_not_required = True` inside the `Meta` class to allow
517+
unauthenticated users to interact with the component on public pages.
518+
519+
```python
520+
# newsletter_signup.py
521+
from django_unicorn.components import UnicornView
522+
523+
class NewsletterSignupView(UnicornView):
524+
email = ""
525+
526+
class Meta:
527+
login_not_required = True
528+
529+
def subscribe(self):
530+
...
531+
```
532+
533+
```{note}
534+
`Meta.login_not_required` has no effect on Django versions older than 5.1 because
535+
`LoginRequiredMiddleware` was not available before that release.
536+
```
537+
538+
```{warning}
539+
Only set `Meta.login_not_required = True` on components whose actions are safe to
540+
execute without authentication. Any sensitive operation (e.g. accessing private
541+
data, modifying records) should still verify `self.request.user.is_authenticated`
542+
inside the relevant component methods.
543+
```
544+
545+
### template_name
546+
547+
Override the template path used to render the component.
548+
549+
```python
550+
# hello_world.py
551+
from django_unicorn.components import UnicornView
552+
553+
class HelloWorldView(UnicornView):
554+
class Meta:
555+
template_name = "unicorn/hello-world.html"
556+
```
557+
558+
### template_html
559+
560+
Define the component template as an inline HTML string instead of a separate file.
561+
562+
```python
563+
# hello_world.py
564+
from django_unicorn.components import UnicornView
565+
566+
class HelloWorldView(UnicornView):
567+
count = 0
568+
569+
class Meta:
570+
template_html = """<div>
571+
<div>Count: {{ count }}</div>
572+
<button unicorn:click="increment">+</button>
573+
</div>"""
574+
```
575+
576+
### component_key
577+
578+
Set a default key for the component class. This is applied when the template tag
579+
does not supply a `key=` argument and is useful when you always want a specific
580+
component to be keyed the same way.
581+
582+
```python
583+
# signup.py
584+
from django_unicorn.components import UnicornView
585+
586+
class SignupView(UnicornView):
587+
class Meta:
588+
component_key = "signup"
589+
```
590+
591+
```{note}
592+
A `key=` value provided in the template tag always takes precedence over
593+
`Meta.component_key`.
594+
```
595+
596+
### form_class
597+
598+
Attach a Django form for validation. Errors from the form are merged into the
599+
component's `errors` dict.
600+
601+
```python
602+
# book_form.py
603+
from django_unicorn.components import UnicornView
604+
from .forms import BookForm
605+
606+
class BookFormView(UnicornView):
607+
title = ""
608+
year = None
609+
610+
class Meta:
611+
form_class = BookForm
612+
```
613+
614+
```{note}
615+
Setting `form_class` directly as a class attribute also works and is supported for
616+
backwards compatibility.
617+
```
618+
502619
## Pickling and Caching
503620

504621
Components are pickled and cached for the duration of the AJAX request. This means that any instance variable on the component must be pickleable.

src/django_unicorn/components/unicorn_view.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -238,21 +238,38 @@ def __init__(self, component_args: list | None = None, **kwargs):
238238
self._init_script: str = ""
239239
self._validate_called = False
240240
self.errors: dict[Any, Any] = {}
241+
242+
# Apply Meta.component_key as a class-level default when the template
243+
# tag has not provided a key (i.e. self.component_key is still empty).
244+
if not self.component_key and hasattr(self, "Meta") and hasattr(self.Meta, "component_key"):
245+
self.component_key = cast(str, self.Meta.component_key)
246+
241247
self._set_default_template_name()
242248
self._set_caches()
243249

244250
@timed
245251
def _set_default_template_name(self) -> None:
246252
"""Sets a default template name based on component's name if necessary.
247253
248-
Also handles `template_html` if it is set on the component which overrides `template_name`.
254+
Also handles `template_html` (via Meta or direct attribute) which overrides
255+
`template_name`. Meta attributes take precedence over direct class attributes.
249256
"""
250257

251-
if hasattr(self, "template_html"):
258+
# Resolve template_html — Meta takes precedence over direct attribute
259+
template_html = None
260+
if hasattr(self, "Meta") and hasattr(self.Meta, "template_html"):
261+
template_html = self.Meta.template_html
262+
elif hasattr(self, "template_html"):
263+
template_html = self.template_html
264+
265+
if template_html:
252266
try:
253-
self.template_name = create_template(self.template_html) # type: ignore
267+
self.template_name = create_template(template_html) # type: ignore
254268
except AssertionError:
255269
pass
270+
elif hasattr(self, "Meta") and hasattr(self.Meta, "template_name"):
271+
# Meta.template_name overrides a direct class attribute when set
272+
self.template_name = self.Meta.template_name
256273

257274
get_template_names_is_valid = False
258275

@@ -579,9 +596,15 @@ def get_frontend_context_variables(self) -> str:
579596

580597
@timed
581598
def _get_form(self, data):
582-
if hasattr(self, "form_class"):
599+
form_class = None
600+
if hasattr(self, "Meta") and hasattr(self.Meta, "form_class"):
601+
form_class = self.Meta.form_class
602+
elif hasattr(self, "form_class"):
603+
form_class = self.form_class
604+
605+
if form_class:
583606
try:
584-
form = cast(Callable, self.form_class)(data=data)
607+
form = cast(Callable, form_class)(data=data)
585608
form.is_valid()
586609

587610
return form
@@ -960,9 +983,27 @@ def _is_public(self, name: str) -> bool:
960983
# Form validation configuration (server-side only, not synced to frontend)
961984
"form_class",
962985
"form_classes",
986+
"login_not_required",
987+
"form_class",
963988
)
964989
excludes = []
965990

991+
if hasattr(self, "Meta") and hasattr(self.Meta, "login_not_required"):
992+
if not isinstance(self.Meta.login_not_required, bool):
993+
raise AssertionError("Meta.login_not_required should be a bool")
994+
995+
if hasattr(self, "Meta") and hasattr(self.Meta, "template_name"):
996+
if not isinstance(self.Meta.template_name, str):
997+
raise AssertionError("Meta.template_name should be a str")
998+
999+
if hasattr(self, "Meta") and hasattr(self.Meta, "template_html"):
1000+
if not isinstance(self.Meta.template_html, str):
1001+
raise AssertionError("Meta.template_html should be a str")
1002+
1003+
if hasattr(self, "Meta") and hasattr(self.Meta, "component_key"):
1004+
if not isinstance(self.Meta.component_key, str):
1005+
raise AssertionError("Meta.component_key should be a str")
1006+
9661007
if hasattr(self, "Meta") and hasattr(self.Meta, "exclude"):
9671008
if not is_non_string_sequence(self.Meta.exclude):
9681009
raise AssertionError("Meta.exclude should be a list, tuple, or set")

src/django_unicorn/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ class UnicornCacheError(Exception):
22
pass
33

44

5+
class UnicornAuthenticationError(Exception):
6+
pass
7+
8+
59
class UnicornViewError(Exception):
610
pass
711

0 commit comments

Comments
 (0)