Files
crochet/crochet/forms.py
T
pablo 4b0d404116
CI / test (push) Successful in 2m13s
CI / build (push) Successful in 55s
feat: included 2FA and lots of feature from django-htmx-template
2026-09-16 13:20:44 +02:00

212 lines
8.3 KiB
Python

from django import forms
from django.conf import settings
from django.contrib.auth.forms import (
AuthenticationForm,
PasswordChangeForm,
PasswordResetForm,
SetPasswordForm,
UserCreationForm,
)
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _
# Los formularios de auth de Django no traen ninguna clase CSS en sus
# widgets (piensan en HTML sin estilar): se añade aquí la clase de daisyUI
# para que login/registro (ver registration/*.html) tengan la misma pinta
# que el resto de inputs del sitio, sin depender de un paquete aparte
# (django-widget-tweaks) solo para dos formularios de tres campos.
INPUT_CLASSES = 'input input-bordered w-full'
def _send_account_notice(subject_template, text_template, html_template, context, to_email):
# Compartido por los avisos de seguridad de la cuenta (cambio de email,
# cambio de contraseña): mismo esqueleto de email (texto plano +
# alternativa HTML de crochet/email/base.html), solo cambian las
# plantillas y el destinatario.
subject = ''.join(render_to_string(subject_template, context).splitlines())
send_mail(
subject=subject,
message=render_to_string(text_template, context),
from_email=None,
recipient_list=[to_email],
html_message=render_to_string(html_template, context),
)
class StyledAuthenticationForm(AuthenticationForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs['class'] = INPUT_CLASSES
class StyledUserCreationForm(UserCreationForm):
# UserCreationForm no pide email por defecto (piensa solo en
# usuario/contraseña); sin él, PasswordResetForm no tiene a qué
# dirección mandar el enlace de recuperación de estos usuarios.
email = forms.EmailField(label=_('Email'))
class Meta(UserCreationForm.Meta):
fields = ('username', 'email')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs['class'] = INPUT_CLASSES
class StyledPasswordChangeForm(PasswordChangeForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs['class'] = INPUT_CLASSES
def save(self, commit=True):
user = super().save(commit=commit)
# A diferencia del cambio de email, aquí no hay una dirección
# "anterior" distinta: el aviso va al propio email de la cuenta.
# Sin email no hay a quién avisar (cuentas antiguas sin uno).
if user.email:
context = {'user': user, 'site_name': settings.SITE_NAME, 'support_email': settings.SUPPORT_EMAIL}
_send_account_notice(
'crochet/email/password_changed_subject.txt',
'crochet/email/password_changed_notice.txt',
'crochet/email/password_changed_notice.html',
context, user.email,
)
return user
class StyledPasswordResetForm(PasswordResetForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs['class'] = INPUT_CLASSES
class StyledSetPasswordForm(SetPasswordForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs['class'] = INPUT_CLASSES
class EmailUpdateForm(forms.Form):
email = forms.EmailField(label=_('Email'), widget=forms.EmailInput(attrs={'class': INPUT_CLASSES}))
def __init__(self, *args, user, **kwargs):
self.user = user
super().__init__(*args, **kwargs)
def save(self):
old_email = self.user.email
self.user.email = self.cleaned_data['email']
self.user.save(update_fields=['email'])
# Aviso a la dirección ANTERIOR (no a la nueva): así, si alguien más
# ha cambiado el email de la cuenta (sesión robada, dispositivo
# compartido...), quien de verdad era el dueño se entera por donde
# todavía puede leerlo. Si no había email antes, o si "cambiar" no
# ha cambiado nada, no hay a quién avisar ni de qué.
if old_email and old_email != self.user.email:
context = {
'user': self.user, 'old_email': old_email,
'site_name': settings.SITE_NAME, 'support_email': settings.SUPPORT_EMAIL,
}
_send_account_notice(
'crochet/email/email_changed_subject.txt',
'crochet/email/email_changed_notice.txt',
'crochet/email/email_changed_notice.html',
context, old_email,
)
class DisplayNameForm(forms.Form):
# Se guarda en el propio first_name de auth.User (no se usa para nada
# más en la app): no hace falta un modelo de perfil aparte solo para un
# campo de texto opcional. Se propone como autor por defecto al crear
# un patrón nuevo (ver PatternCreateView) y se muestra en el navbar/
# saludo de "Mis patrones" en vez del username, si está puesto.
display_name = forms.CharField(
label=_('Nombre para mostrar'), required=False, max_length=150,
widget=forms.TextInput(attrs={'class': INPUT_CLASSES}),
)
def __init__(self, *args, user, **kwargs):
self.user = user
super().__init__(*args, **kwargs)
def save(self):
self.user.first_name = self.cleaned_data['display_name']
self.user.save(update_fields=['first_name'])
class TwoFactorTokenForm(forms.Form):
# Un único campo de texto tanto para el código de 6 dígitos de la app
# (TOTP) como para un código de respaldo (StaticDevice): cuál de los
# dos es se decide al verificarlo (ver TwoFactorVerifyView/
# TwoFactorSetupView en crochet/views.py), no aquí. autocomplete
# "one-time-code" es el valor estándar para que el navegador/gestor de
# contraseñas ofrezca autorrellenar con el código recibido, si puede.
token = forms.CharField(
label=_('Código'),
widget=forms.TextInput(attrs={'class': INPUT_CLASSES, 'autocomplete': 'one-time-code'}),
)
class TwoFactorVerifyForm(TwoFactorTokenForm):
# Solo en el login (ver TwoFactorVerifyView), no en el alta
# (TwoFactorSetupView reusa TwoFactorTokenForm sin este campo): ahí no
# tiene sentido "recordar" un dispositivo que todavía se está
# configurando.
remember_device = forms.BooleanField(
label=_('Recordar este dispositivo 30 días'),
required=False,
widget=forms.CheckboxInput(attrs={'class': 'checkbox checkbox-sm'}),
)
class TwoFactorDisableForm(forms.Form):
password = forms.CharField(
label=_('Contraseña'),
widget=forms.PasswordInput(attrs={'class': INPUT_CLASSES}),
)
def __init__(self, *args, user, **kwargs):
self.user = user
super().__init__(*args, **kwargs)
def clean_password(self):
password = self.cleaned_data['password']
if not self.user.check_password(password):
raise forms.ValidationError(_('Contraseña incorrecta.'))
return password
class AccountDeleteForm(forms.Form):
password = forms.CharField(
label=_('Contraseña'), widget=forms.PasswordInput(attrs={'class': INPUT_CLASSES}),
)
def __init__(self, *args, user, **kwargs):
self.user = user
super().__init__(*args, **kwargs)
def clean_password(self):
password = self.cleaned_data['password']
if not self.user.check_password(password):
raise forms.ValidationError(_('Contraseña incorrecta.'))
return password
def notify_account_deleted(self):
# Antes de borrar la cuenta (ver AccountDeleteView), mientras el
# email todavía existe. Sin email no hay a quién avisar.
if self.user.email:
context = {'user': self.user, 'site_name': settings.SITE_NAME, 'support_email': settings.SUPPORT_EMAIL}
_send_account_notice(
'crochet/email/account_deleted_subject.txt',
'crochet/email/account_deleted_notice.txt',
'crochet/email/account_deleted_notice.html',
context, self.user.email,
)