Skip to content

Commit 8dbe9e4

Browse files
committed
Make FormView generic over form type
Subclasses can now parameterize `FormView[MyForm]` to get type-safe access to their specific form in `form_valid`, `get_success_url`, etc. without Liskov violations. The `form_class` attribute must still be set separately at runtime — a future typed-views arc explores DRY options via __init_subclass__. Updates plain-loginlink and plain-passwords to use the generic parameterization, dropping 4 `# ty: ignore[invalid-method-override]` suppressions and the `assert isinstance(form, LoginLinkForm)` runtime check added in the previous ty upgrade commit.
1 parent 9cb678b commit 8dbe9e4

3 files changed

Lines changed: 23 additions & 20 deletions

File tree

plain-loginlink/plain/loginlink/views.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
from plain.auth import login, logout
66
from plain.auth.views import AuthView
7-
from plain.forms import BaseForm
87
from plain.http import RedirectResponse, Response
98
from plain.runtime import settings
109
from plain.urls import reverse, reverse_lazy
@@ -19,7 +18,7 @@
1918
)
2019

2120

22-
class LoginLinkFormView(AuthView, FormView):
21+
class LoginLinkFormView(AuthView, FormView[LoginLinkForm]):
2322
form_class = LoginLinkForm
2423
success_url = reverse_lazy("loginlink:sent")
2524

@@ -31,12 +30,11 @@ def get(self) -> Response:
3130

3231
return super().get()
3332

34-
def form_valid(self, form: BaseForm) -> Response:
35-
assert isinstance(form, LoginLinkForm)
33+
def form_valid(self, form: LoginLinkForm) -> Response:
3634
form.maybe_send_link(self.request)
3735
return super().form_valid(form)
3836

39-
def get_success_url(self, form: BaseForm) -> str:
37+
def get_success_url(self, form: LoginLinkForm) -> str:
4038
if next_url := form.cleaned_data.get("next"):
4139
# Keep the next URL in the query string so the sent
4240
# view can redirect to it if reloaded and logged in already.

plain-passwords/plain/passwords/views.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
from plain.postgres import Model
3333

3434

35-
class PasswordForgotView(FormView):
35+
class PasswordForgotView(FormView[PasswordResetForm]):
3636
form_class = PasswordResetForm
3737
reset_confirm_url_name: str
3838

@@ -52,14 +52,14 @@ def generate_password_reset_url(self, user: Any) -> str:
5252
url = reverse(self.reset_confirm_url_name) + f"?token={token}"
5353
return self.request.build_absolute_uri(url)
5454

55-
def form_valid(self, form: PasswordResetForm) -> Response: # ty: ignore[invalid-method-override]
55+
def form_valid(self, form: PasswordResetForm) -> Response:
5656
form.save(
5757
generate_reset_url=self.generate_password_reset_url,
5858
)
5959
return super().form_valid(form)
6060

6161

62-
class PasswordResetView(AuthView, FormView):
62+
class PasswordResetView(AuthView, FormView[PasswordSetForm]):
6363
form_class = PasswordSetForm
6464
reset_token_max_age = 60 * 60 # 1 hour
6565
_reset_token_session_key = "_password_reset_token"
@@ -135,15 +135,15 @@ def get_form_kwargs(self) -> dict:
135135
kwargs["user"] = self.get_user()
136136
return kwargs
137137

138-
def form_valid(self, form: PasswordSetForm) -> Response: # ty: ignore[invalid-method-override]
138+
def form_valid(self, form: PasswordSetForm) -> Response:
139139
form.save()
140140
del self.session[self._reset_token_session_key]
141141
# If you wanted, you could log in the user here so they don't have to
142142
# go through the log in form again.
143143
return super().form_valid(form)
144144

145145

146-
class PasswordChangeView(AuthView, FormView):
146+
class PasswordChangeView(AuthView, FormView[PasswordChangeForm]):
147147
# Change to PasswordSetForm if you want to set new passwords
148148
# without confirming the old one.
149149
form_class = PasswordChangeForm
@@ -153,15 +153,15 @@ def get_form_kwargs(self) -> dict:
153153
kwargs["user"] = self.user
154154
return kwargs
155155

156-
def form_valid(self, form: PasswordChangeForm) -> Response: # ty: ignore[invalid-method-override]
156+
def form_valid(self, form: PasswordChangeForm) -> Response:
157157
form.save()
158158
# Updating the password logs out all other sessions for the user
159159
# except the current one.
160160
update_session_auth_hash(self.request, form.user)
161161
return super().form_valid(form)
162162

163163

164-
class PasswordLoginView(AuthView, FormView):
164+
class PasswordLoginView(AuthView, FormView[PasswordLoginForm]):
165165
form_class = PasswordLoginForm
166166
success_url = "/"
167167

@@ -172,7 +172,7 @@ def get(self) -> Response:
172172

173173
return super().get()
174174

175-
def form_valid(self, form: PasswordLoginForm) -> Response: # ty: ignore[invalid-method-override]
175+
def form_valid(self, form: PasswordLoginForm) -> Response:
176176
# Log the user in and redirect
177177
auth_login(self.request, form.get_user())
178178

plain/plain/views/forms.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,18 @@
1010
from plain.forms import BaseForm
1111

1212

13-
class FormView(TemplateView):
14-
"""A view for displaying a form and rendering a template response."""
13+
class FormView[F: "BaseForm"](TemplateView):
14+
"""A view for displaying a form and rendering a template response.
1515
16-
form_class: type["BaseForm"] | None = None
16+
Generic over the form type. Subclasses that want type-safe access to
17+
their specific form should parameterize: `FormView[MyForm]`. The
18+
`form_class` attribute must still be set separately at runtime.
19+
"""
20+
21+
form_class: type[F] | None = None
1722
success_url: Callable | str | None = None
1823

19-
def get_form(self) -> "BaseForm":
24+
def get_form(self) -> F:
2025
"""Return an instance of the form to be used in this view."""
2126
if not self.form_class:
2227
raise ImproperlyConfigured(
@@ -32,17 +37,17 @@ def get_form_kwargs(self) -> dict[str, Any]:
3237
"request": self.request,
3338
}
3439

35-
def get_success_url(self, form: "BaseForm") -> str:
40+
def get_success_url(self, form: F) -> str:
3641
"""Return the URL to redirect to after processing a valid form."""
3742
if not self.success_url:
3843
raise ImproperlyConfigured("No URL to redirect to. Provide a success_url.")
3944
return str(self.success_url) # success_url may be lazy
4045

41-
def form_valid(self, form: "BaseForm") -> Response:
46+
def form_valid(self, form: F) -> Response:
4247
"""If the form is valid, redirect to the supplied URL."""
4348
return RedirectResponse(self.get_success_url(form))
4449

45-
def form_invalid(self, form: "BaseForm") -> Response:
50+
def form_invalid(self, form: F) -> Response:
4651
"""If the form is invalid, render the invalid form."""
4752
context = {
4853
**self.get_template_context(),

0 commit comments

Comments
 (0)