Files
Sith/core/views/forms.py
T
2026-09-07 13:32:13 +02:00

492 lines
16 KiB
Python

#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
# - Sli <antoine@bartuccio.fr> #
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import re
from copy import copy
from io import BytesIO
from typing import TYPE_CHECKING
from captcha.fields import CaptchaField
from django import forms
from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.contrib.auth.models import Permission
from django.contrib.staticfiles.management.commands.collectstatic import (
staticfiles_storage,
)
from django.core.exceptions import ValidationError
from django.db import transaction
from django.forms import (
CheckboxSelectMultiple,
DateInput,
DateTimeInput,
TextInput,
Widget,
)
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from PIL import Image
from antispam.forms import AntiSpamEmailField
from core.models import Gift, Group, Page, PageRev, Preferences, SithFile, User
from core.utils import resize_image
from core.views.widgets.ajax_select import (
AutoCompleteSelect,
AutoCompleteSelectGroup,
AutoCompleteSelectMultipleGroup,
AutoCompleteSelectMultipleUser,
AutoCompleteSelectUser,
)
from core.views.widgets.markdown import MarkdownInput
if TYPE_CHECKING:
from datetime import date, datetime
# Widgets
class SelectDateTime(DateTimeInput):
def __init__(self, attrs=None, format=None): # noqa A002
default = {"type": "datetime-local"}
attrs = default if attrs is None else default | attrs
super().__init__(attrs=attrs, format=format or "%Y-%m-%d %H:%M")
class SelectDate(DateInput):
def __init__(self, attrs=None, format=None): # noqa A002
default = {"type": "date"}
attrs = default if attrs is None else default | attrs
super().__init__(attrs=attrs, format=format or "%Y-%m-%d")
class NFCTextInput(TextInput):
template_name = "core/widgets/nfc.jinja"
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context["statics"] = {
"js": staticfiles_storage.url("bundled/core/components/nfc-input-index.ts"),
"css": staticfiles_storage.url("core/components/nfc-input.scss"),
}
return context
# Fields
def validate_future_timestamp(value: date | datetime):
if value <= now():
raise ValidationError(_("Ensure this timestamp is set in the future"))
class FutureDateTimeField(forms.DateTimeField):
"""A datetime field that accepts only future timestamps."""
default_validators = [validate_future_timestamp]
def widget_attrs(self, widget: Widget) -> dict[str, str]:
return {"min": widget.format_value(now())}
# Forms
class LoginForm(AuthenticationForm):
def __init__(self, *arg, **kwargs):
super().__init__(*arg, **kwargs)
self.fields["username"].label = _("Username, email, or account number")
def clean_username(self):
identifier: str = self.cleaned_data["username"]
account_code = re.compile(r"^[0-9]+[A-Za-z]$")
if account_code.match(identifier):
qs_filter = "customer__account_id__iexact"
elif identifier.count("@") == 1:
qs_filter = "email"
else:
qs_filter = None
if qs_filter:
# if the user gave an email or an account code instead of
# a username, retrieve and return the corresponding username.
# If there is no username, return an empty string, so that
# Django will properly handle the error when failing the authentication
identifier = (
User.objects.filter(**{qs_filter: identifier})
.values_list("username", flat=True)
.first()
or ""
)
return identifier
class RegisteringForm(UserCreationForm):
error_css_class = "error"
required_css_class = "required"
captcha = CaptchaField()
class Meta:
model = User
fields = ("first_name", "last_name", "email")
field_classes = {"email": AntiSpamEmailField}
class UserProfileForm(forms.ModelForm):
"""Form handling the user profile, managing the files"""
required_css_class = "required"
error_css_class = "error"
class Meta:
model = User
fields = [
"first_name",
"last_name",
"nick_name",
"email",
"date_of_birth",
"profile_pict",
"avatar_pict",
"scrub_pict",
"sex",
"pronouns",
"second_email",
"address",
"parent_address",
"phone",
"parent_phone",
"tshirt_size",
"role",
"department",
"dpt_option",
"semester",
"quote",
"school",
"promo",
"forum_signature",
]
widgets = {
"date_of_birth": SelectDate,
"phone": RegionalPhoneNumberWidget,
"parent_phone": RegionalPhoneNumberWidget,
"quote": forms.Textarea,
}
def __init__(self, *args, label_suffix: str = "", **kwargs):
super().__init__(*args, label_suffix=label_suffix, **kwargs)
# Image fields are injected here to override the file field provided by the model
# This would be better if we could have a SithImage sort of model input instead of a generic SithFile
self.fields["profile_pict"] = forms.ImageField(
required=False,
label=_(
"Profile: you need to be visible on the picture, in order to be recognized (e.g. by the barmen)"
),
)
self.fields["avatar_pict"] = forms.ImageField(
required=False,
label=_("Avatar: used on the forum"),
)
self.fields["scrub_pict"] = forms.ImageField(
required=False,
label=_("Scrub: let other know how your scrub looks like!"),
)
def process(self, files):
avatar = self.instance.avatar_pict
profile = self.instance.profile_pict
scrub = self.instance.scrub_pict
self.full_clean()
self.cleaned_data["avatar_pict"] = avatar
self.cleaned_data["profile_pict"] = profile
self.cleaned_data["scrub_pict"] = scrub
parent = SithFile.objects.filter(parent=None, name="profiles").first()
for field, f in files:
with transaction.atomic():
try:
im = Image.open(BytesIO(f.read()))
new_file = SithFile(
parent=parent,
name=f"{field.removesuffix('_pict')}_{self.instance.id}.webp",
file=resize_image(im, 400, "webp"),
owner=self.instance,
is_folder=False,
mime_type="image/wepb",
size=f.size,
moderator=self.instance,
is_moderated=True,
)
new_file.file.name = new_file.name
old = SithFile.objects.filter(
parent=parent, name=new_file.name
).first()
if old:
old.delete()
new_file.clean()
new_file.save()
self.cleaned_data[field] = new_file
self._errors.pop(field, None)
except ValidationError as e:
self._errors.pop(field, None)
self.add_error(
field,
_("Error uploading file %(file_name)s: %(msg)s")
% {"file_name": f, "msg": str(e.message)},
)
except IOError:
self._errors.pop(field, None)
self.add_error(
field,
_("Error uploading file %(file_name)s: %(msg)s")
% {
"file_name": f,
"msg": _(
"Bad image format, only jpeg, png, webp and gif are accepted"
),
},
)
self._post_clean()
class UserVisibilityForm(forms.ModelForm):
class Meta:
model = User
fields = ["is_viewable", "whitelisted_users"]
widgets = {
"is_viewable": forms.CheckboxInput(attrs={"class": "switch"}),
"whitelisted_users": AutoCompleteSelectMultipleUser,
}
__preferences_fields = forms.fields_for_model(
Preferences,
["show_my_stats"],
widgets={"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"})},
)
show_my_stats = __preferences_fields["show_my_stats"]
def __init__(
self, *args, initial: dict | None = None, instance: User | None = None, **kwargs
):
if instance:
initial = initial or {}
initial["show_my_stats"] = instance.preferences.show_my_stats
super().__init__(*args, initial=initial, instance=instance, **kwargs)
def save(self, commit=True) -> User: # noqa: FBT002
instance = super().save(commit=commit)
if commit:
instance.preferences.show_my_stats = self.cleaned_data["show_my_stats"]
instance.preferences.save()
return instance
class UserGroupsForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
groups = forms.ModelMultipleChoiceField(
queryset=Group.objects.filter(is_manually_manageable=True),
widget=CheckboxSelectMultiple,
label=_("Groups"),
required=False,
)
class Meta:
model = User
fields = ["groups"]
def save(self, *args, **kwargs) -> User:
# make the super method manage error without persisting in db
super().save(commit=False)
# Don't forget to add the non-manageable groups when setting groups,
# or the user would lose all of those when the form is submitted
self.instance.groups.set(
[
*self.cleaned_data["groups"],
*self.instance.groups.filter(is_manually_manageable=False),
]
)
return self.instance
class UserGodfathersForm(forms.Form):
type = forms.ChoiceField(
choices=[
("godfather", _("Godfather / Godmother")),
("godchild", _("Godchild")),
],
label=_("Add"),
)
user = forms.ModelChoiceField(
label=_("Select user"),
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
)
def __init__(self, *args, user: User, **kwargs):
super().__init__(*args, **kwargs)
self.target_user = user
def clean_user(self):
other_user = self.cleaned_data.get("user")
if other_user == self.target_user:
raise ValidationError(_("You cannot be related to yourself"))
return other_user
def clean(self):
super().clean()
if not self.is_valid():
return self.cleaned_data
other_user = self.cleaned_data["user"]
if self.cleaned_data["type"] == "godfather":
if self.target_user.godfathers.contains(other_user):
self.add_error(
"user",
_("%s is already your godfather") % (other_user.get_short_name()),
)
else:
if self.target_user.godchildren.contains(other_user):
self.add_error(
"user",
_("%s is already your godchild") % (other_user.get_short_name()),
)
return self.cleaned_data
class PagePropForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
class Meta:
model = Page
fields = ["parent", "name", "owner_group", "edit_groups", "view_groups"]
widgets = {
"parent": AutoCompleteSelect,
"owner_group": AutoCompleteSelectGroup,
"edit_groups": AutoCompleteSelectMultipleGroup,
"view_groups": AutoCompleteSelectMultipleGroup,
}
def __init__(self, *arg, **kwargs):
super().__init__(*arg, **kwargs)
self.fields["edit_groups"].required = False
self.fields["view_groups"].required = False
class PageForm(forms.ModelForm):
class Meta:
model = Page
fields = ["parent", "name", "owner_group", "edit_groups", "view_groups"]
widgets = {
"parent": AutoCompleteSelect,
"owner_group": AutoCompleteSelectGroup,
"edit_groups": AutoCompleteSelectMultipleGroup,
"view_groups": AutoCompleteSelectMultipleGroup,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["parent"].queryset = (
self.fields["parent"]
.queryset.exclude(name=settings.SITH_CLUB_ROOT_PAGE)
.filter(club=None)
)
class PageRevisionForm(forms.ModelForm):
"""Form to add a new revision to a page.
Notes:
Saving this form won't always result in a new revision.
If the previous revision on the same page was made :
- less than 20 minutes ago
- by the same author
- with a similarity ratio higher than 80%
then the latter will be edited and the new revision won't be created.
"""
class Meta:
model = PageRev
fields = ["title", "content"]
widgets = {"content": MarkdownInput}
def __init__(
self, *args, author: User, page: Page, instance: PageRev | None = None, **kwargs
):
super().__init__(*args, instance=instance, **kwargs)
self.author = author
self.page = page
self.initial_obj: PageRev = copy(self.instance)
def save(self, commit=True): # noqa FBT002
revision: PageRev = self.instance
if not self.initial_obj.should_merge(self.instance):
revision.author = self.author
revision.page = self.page
revision.id = None # if id is None, Django will create a new record
return super().save(commit=commit)
class GiftForm(forms.ModelForm):
class Meta:
model = Gift
fields = ["label", "user"]
label = forms.ChoiceField(choices=settings.SITH_GIFT_LIST)
def __init__(self, *args, **kwargs):
user_id = kwargs.pop("user_id", None)
super().__init__(*args, **kwargs)
if user_id:
self.fields["user"].queryset = self.fields["user"].queryset.filter(
id=user_id
)
self.fields["user"].widget = forms.HiddenInput()
class PermissionGroupsForm(forms.ModelForm):
"""Manage the groups that have a specific permission."""
class Meta:
model = Permission
fields = []
groups = forms.ModelMultipleChoiceField(
Group.objects.all(),
label=_("Groups"),
widget=AutoCompleteSelectMultipleGroup,
required=False,
)
def __init__(self, instance: Permission, **kwargs):
super().__init__(instance=instance, **kwargs)
self.fields["groups"].initial = instance.group_set.all()
def save(self, commit: bool = True): # noqa FTB001
instance = super().save(commit=False)
if commit:
instance.group_set.set(self.cleaned_data["groups"])
return instance