# # Copyright 2016,2017 # - Skia # - Sli # # 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