from datetime import datetime, time from typing import Any, Callable, Dict, Sequence from django import forms from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission from django.contrib.sites.models import Site from django.core.exceptions import SuspiciousOperation, ValidationError from django.db.models import QuerySet from django.http import HttpRequest from django.utils.translation import gettext_lazy as _ from allauth.account.adapter import get_adapter from allauth.account.forms import SignupForm from allauth.account.utils import setup_user_email from dj_cleavejs import CleaveWidget from django_select2.forms import ModelSelect2MultipleWidget, ModelSelect2Widget, Select2Widget from dynamic_preferences.forms import PreferenceForm from guardian.shortcuts import assign_perm from invitations.forms import InviteForm from material import Fieldset, Layout, Row from .mixins import ExtensibleForm, SchoolTermRelatedExtensibleForm from .models import ( AdditionalField, Announcement, DashboardWidget, Group, GroupType, OAuthApplication, Person, PersonInvitation, SchoolTerm, ) from .registries import ( group_preferences_registry, person_preferences_registry, site_preferences_registry, ) from .util.auth_helpers import AppScopes from .util.core_helpers import get_site_preferences, queryset_rules_filter class PersonForm(ExtensibleForm): """Form to edit or add a person object in the frontend.""" layout = Layout( Fieldset( _("Base data"), "short_name", Row("user", "primary_group"), Row("first_name", "additional_name", "last_name"), ), Fieldset(_("Address"), Row("street", "housenumber"), Row("postal_code", "place")), Fieldset(_("Contact data"), "email", Row("phone_number", "mobile_number")), Fieldset( _("Advanced personal data"), Row("date_of_birth", "place_of_birth"), Row("sex"), Row("photo", "avatar"), "guardians", ), ) class Meta: model = Person fields = [ "user", "first_name", "last_name", "additional_name", "short_name", "street", "housenumber", "postal_code", "place", "phone_number", "mobile_number", "email", "date_of_birth", "place_of_birth", "sex", "photo", "avatar", "guardians", "primary_group", ] widgets = { "user": Select2Widget(attrs={"class": "browser-default"}), "primary_group": ModelSelect2Widget( search_fields=["name__icontains", "short_name__icontains"], attrs={"data-minimum-input-length": 0, "class": "browser-default"}, ), "guardians": ModelSelect2MultipleWidget( search_fields=[ "first_name__icontains", "last_name__icontains", "short_name__icontains", ], attrs={"data-minimum-input-length": 0, "class": "browser-default"}, ), } new_user = forms.CharField( required=False, label=_("New user"), help_text=_("Create a new account") ) def __init__(self, *args, **kwargs): request = kwargs.pop("request", None) super().__init__(*args, **kwargs) if ( request and self.instance and not request.user.has_perm("core.change_person", self.instance) ): # Disable non-editable fields allowed_person_fields = get_site_preferences()["account__editable_fields_person"] for field in self.fields: if field not in allowed_person_fields: self.fields[field].disabled = True def clean(self) -> None: user = get_user_model() if self.cleaned_data.get("new_user", None): if self.cleaned_data.get("user", None): # The user selected both an existing user and provided a name to create a new one self.add_error( "new_user", _("You cannot set a new username when also selecting an existing user."), ) elif user.objects.filter(username=self.cleaned_data["new_user"]).exists(): # The user tried to create a new user with the name of an existing user self.add_error("new_user", _("This username is already in use.")) else: # Create new User object and assign to form field for existing user new_user_obj = user.objects.create_user( self.cleaned_data["new_user"], self.instance.email, first_name=self.instance.first_name, last_name=self.instance.last_name, ) self.cleaned_data["user"] = new_user_obj class EditGroupForm(SchoolTermRelatedExtensibleForm): """Form to edit an existing group in the frontend.""" layout = Layout( Fieldset(_("School term"), "school_term"), Fieldset(_("Common data"), "name", "short_name", "group_type"), Fieldset(_("Persons"), "members", "owners", "parent_groups"), Fieldset(_("Additional data"), "additional_fields"), Fieldset(_("Photo"), "photo", "avatar"), ) class Meta: model = Group exclude = [] widgets = { "members": ModelSelect2MultipleWidget( search_fields=[ "first_name__icontains", "last_name__icontains", "short_name__icontains", ], attrs={"data-minimum-input-length": 0, "class": "browser-default"}, ), "owners": ModelSelect2MultipleWidget( search_fields=[ "first_name__icontains", "last_name__icontains", "short_name__icontains", ], attrs={"data-minimum-input-length": 0, "class": "browser-default"}, ), "parent_groups": ModelSelect2MultipleWidget( search_fields=["name__icontains", "short_name__icontains"], attrs={"data-minimum-input-length": 0, "class": "browser-default"}, ), "additional_fields": ModelSelect2MultipleWidget( search_fields=[ "title__icontains", ], attrs={"data-minimum-input-length": 0, "class": "browser-default"}, ), } class AnnouncementForm(ExtensibleForm): """Form to create or edit an announcement in the frontend.""" valid_from = forms.DateTimeField(required=False) valid_until = forms.DateTimeField(required=False) valid_from_date = forms.DateField(label=_("Date")) valid_from_time = forms.TimeField(label=_("Time")) valid_until_date = forms.DateField(label=_("Date")) valid_until_time = forms.TimeField(label=_("Time")) persons = forms.ModelMultipleChoiceField( queryset=Person.objects.all(), label=_("Persons"), required=False, widget=ModelSelect2MultipleWidget( search_fields=[ "first_name__icontains", "last_name__icontains", "short_name__icontains", ], attrs={"data-minimum-input-length": 0, "class": "browser-default"}, ), ) groups = forms.ModelMultipleChoiceField( queryset=None, label=_("Groups"), required=False, widget=ModelSelect2MultipleWidget( search_fields=[ "name__icontains", "short_name__icontains", ], attrs={"data-minimum-input-length": 0, "class": "browser-default"}, ), ) layout = Layout( Fieldset( _("From when until when should the announcement be displayed?"), Row("valid_from_date", "valid_from_time", "valid_until_date", "valid_until_time"), ), Fieldset(_("Who should see the announcement?"), Row("groups", "persons")), Fieldset(_("Write your announcement:"), "title", "description"), ) def __init__(self, *args, **kwargs): if "instance" not in kwargs: # Default to today and whole day for new announcements kwargs["initial"] = { "valid_from_date": datetime.now(), "valid_from_time": time(0, 0), "valid_until_date": datetime.now(), "valid_until_time": time(23, 59), } else: announcement = kwargs["instance"] # Fill special fields from given announcement instance kwargs["initial"] = { "valid_from_date": announcement.valid_from.date(), "valid_from_time": announcement.valid_from.time(), "valid_until_date": announcement.valid_until.date(), "valid_until_time": announcement.valid_until.time(), "groups": announcement.get_recipients_for_model(Group), "persons": announcement.get_recipients_for_model(Person), } super().__init__(*args, **kwargs) self.fields["groups"].queryset = Group.objects.for_current_school_term_or_all() def clean(self): data = super().clean() # Combine date and time fields into datetime objects valid_from = datetime.combine(data["valid_from_date"], data["valid_from_time"]) valid_until = datetime.combine(data["valid_until_date"], data["valid_until_time"]) # Sanity check validity range if valid_until < datetime.now(): raise ValidationError( _("You are not allowed to create announcements which are only valid in the past.") ) elif valid_from > valid_until: raise ValidationError( _("The from date and time must be earlier then the until date and time.") ) # Inject real time data if all went well data["valid_from"] = valid_from data["valid_until"] = valid_until # Ensure at least one group or one person is set as recipient if "groups" not in data and "persons" not in data: raise ValidationError(_("You need at least one recipient.")) # Unwrap all recipients into single user objects and generate final list data["recipients"] = [] data["recipients"] += data.get("groups", []) data["recipients"] += data.get("persons", []) return data def save(self, _=False): # Save announcement, respecting data injected in clean() if self.instance is None: self.instance = Announcement() self.instance.valid_from = self.cleaned_data["valid_from"] self.instance.valid_until = self.cleaned_data["valid_until"] self.instance.title = self.cleaned_data["title"] self.instance.description = self.cleaned_data["description"] self.instance.save() # Save recipients self.instance.recipients.all().delete() for recipient in self.cleaned_data["recipients"]: self.instance.recipients.create(recipient=recipient) self.instance.save() return self.instance class Meta: model = Announcement exclude = [] class ChildGroupsForm(forms.Form): """Inline form for group editing to select child groups.""" child_groups = forms.ModelMultipleChoiceField(queryset=Group.objects.all()) class SitePreferenceForm(PreferenceForm): """Form to edit site preferences.""" registry = site_preferences_registry class PersonPreferenceForm(PreferenceForm): """Form to edit preferences valid for one person.""" registry = person_preferences_registry class GroupPreferenceForm(PreferenceForm): """Form to edit preferences valid for members of a group.""" registry = group_preferences_registry class EditAdditionalFieldForm(forms.ModelForm): """Form to manage additional fields.""" class Meta: model = AdditionalField exclude = [] class EditGroupTypeForm(forms.ModelForm): """Form to manage group types.""" class Meta: model = GroupType fields = ["name", "description"] class SchoolTermForm(ExtensibleForm): """Form for managing school years.""" layout = Layout("name", Row("date_start", "date_end")) class Meta: model = SchoolTerm exclude = [] class DashboardWidgetOrderForm(ExtensibleForm): pk = forms.ModelChoiceField( queryset=None, widget=forms.HiddenInput(attrs={"class": "pk-input"}), ) order = forms.IntegerField(initial=0, widget=forms.HiddenInput(attrs={"class": "order-input"})) class Meta: model = DashboardWidget fields = [] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Set queryset here to prevent problems with not migrated database due to special queryset self.fields["pk"].queryset = DashboardWidget.objects.all() DashboardWidgetOrderFormSet = forms.formset_factory( form=DashboardWidgetOrderForm, max_num=0, extra=0 ) class InvitationCodeForm(forms.Form): """Form to enter an invitation code.""" code = forms.CharField( label=_("Invitation code"), help_text=_("Please enter your invitation code."), ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Calculate number of fields length = get_site_preferences()["auth__invite_code_length"] packet_size = get_site_preferences()["auth__invite_code_packet_size"] blocks = [ packet_size, ] * length self.fields["code"].widget = CleaveWidget(blocks=blocks, delimiter="-", uppercase=True) class PersonCreateInviteForm(InviteForm): """Custom form to create a person and invite them.""" first_name = forms.CharField(label=_("First name"), required=True) last_name = forms.CharField(label=_("Last name"), required=True) layout = Layout( Row("first_name", "last_name"), Row("email"), ) def clean_email(self): if Person.objects.filter(email=self.cleaned_data["email"]).exists(): raise ValidationError(_("A person is using this e-mail address")) return super().clean_email() def save(self, email): person = Person.objects.create( first_name=self.cleaned_data["first_name"], last_name=self.cleaned_data["last_name"], email=email, ) return PersonInvitation.create(email=email, person=person) class SelectPermissionForm(forms.Form): """Select a permission to assign.""" selected_permission = forms.ModelChoiceField( queryset=Permission.objects.all(), widget=ModelSelect2Widget( search_fields=["name__icontains", "codename__icontains"], attrs={"data-minimum-input-length": 0, "class": "browser-default"}, ), ) class AssignPermissionForm(forms.Form): """Assign a permission to user/groups for all/some objects.""" layout = Layout( Fieldset(_("Who should get the permission?"), "groups", "persons"), Fieldset(_("On what?"), "objects", "all_objects"), ) groups = forms.ModelMultipleChoiceField( queryset=Group.objects.all(), widget=ModelSelect2MultipleWidget( search_fields=["name__icontains", "short_name__icontains"], attrs={"data-minimum-input-length": 0, "class": "browser-default"}, ), required=False, ) persons = forms.ModelMultipleChoiceField( queryset=Person.objects.all(), widget=ModelSelect2MultipleWidget( search_fields=[ "first_name__icontains", "last_name__icontains", "short_name__icontains", ], attrs={"data-minimum-input-length": 0, "class": "browser-default"}, ), required=False, ) objects = forms.ModelMultipleChoiceField( queryset=None, required=False, label=_("Select objects which the permission should be granted for:"), ) all_objects = forms.BooleanField( required=False, label=_("Grant the permission for all objects") ) def clean(self) -> Dict[str, Any]: """Clean form to ensure that at least one target and one type is selected.""" cleaned_data = super().clean() if not cleaned_data.get("persons") and not cleaned_data.get("groups"): raise ValidationError( _("You must select at least one group or person which should get the permission.") ) if not cleaned_data.get("objects") and not cleaned_data.get("all_objects"): raise ValidationError( _("You must grant the permission to all objects or to specific objects.") ) return cleaned_data def __init__(self, *args, permission: Permission, **kwargs): self.permission = permission super().__init__(*args, **kwargs) model_class = self.permission.content_type.model_class() if model_class._meta.managed and not model_class._meta.abstract: queryset = model_class.objects.all() else: # The following queryset is just a dummy one. It has no real meaning. # We need it as there are permissions without real objects, # but we want to use the same form. queryset = Site.objects.none() self.fields["objects"].queryset = queryset search_fields = getattr(model_class, "get_filter_fields", lambda: [])() # Use select2 only if there are any searchable fields as it can't work without if search_fields: self.fields["objects"].widget = ModelSelect2MultipleWidget( search_fields=search_fields, queryset=queryset, attrs={"data-minimum-input-length": 0, "class": "browser-default"}, ) def save_perms(self): """Save permissions for selected user/groups and selected/all objects.""" persons = self.cleaned_data["persons"] groups = self.cleaned_data["groups"] all_objects = self.cleaned_data["all_objects"] objects = self.cleaned_data["objects"] permission_name = f"{self.permission.content_type.app_label}.{self.permission.codename}" created = 0 # Create permissions for users for person in persons: if getattr(person, "user", None): # Global permission if all_objects: assign_perm(permission_name, person.user) # Object permissions for instance in objects: assign_perm(permission_name, person.user, instance) # Create permissions for users for group in groups: django_group = group.django_group # Global permission if all_objects: assign_perm(permission_name, django_group) # Object permissions for instance in objects: assign_perm(permission_name, django_group, instance) class AccountRegisterForm(SignupForm, ExtensibleForm): """Form to register new user accounts.""" class Meta: model = Person fields = [ "first_name", "additional_name", "last_name", "street", "housenumber", "postal_code", "place", "date_of_birth", "place_of_birth", "sex", "photo", "mobile_number", "phone_number", "short_name", "description", ] layout = Layout( Fieldset( _("Base data"), Row("first_name", "additional_name", "last_name"), "short_name", ), Fieldset( _("Address data"), Row("street", "housenumber"), Row("postal_code", "place"), ), Fieldset(_("Contact data"), Row("mobile_number", "phone_number")), Fieldset( _("Additional data"), Row("date_of_birth", "place_of_birth"), Row("sex", "photo"), "description", ), Fieldset( _("Account data"), "username", Row("email", "email2"), Row("password1", "password2"), ), ) password1 = forms.CharField(label=_("Password"), widget=forms.PasswordInput) if settings.SIGNUP_PASSWORD_ENTER_TWICE: password2 = forms.CharField(label=_("Password (again)"), widget=forms.PasswordInput) def __init__(self, *args, **kwargs): request = kwargs.pop("request", None) super(AccountRegisterForm, self).__init__(*args, **kwargs) if request.session.get("account_verified_email"): email = request.session["account_verified_email"] try: person = Person.objects.get(email=email) except (Person.DoesNotExist, Person.MultipleObjectsReturned): raise SuspiciousOperation() self.fields["email"].disabled = True self.fields["email2"].disabled = True if person: available_fields = [field.name for field in Person._meta.get_fields()] self.fields["email2"].initial = person.email for field in self.fields: if field in available_fields and getattr(person, field): self.fields[field].disabled = True self.fields[field].initial = getattr(person, field) def save(self, request): adapter = get_adapter(request) user = adapter.new_user(request) adapter.save_user(request, user, self) # Create person data = {} for field in Person._meta.get_fields(): if field.name in self.cleaned_data: data[field.name] = self.cleaned_data[field.name] person_qs = Person.objects.filter(email=data["email"]) if not person_qs.exists(): if get_site_preferences()["account__auto_create_person"]: Person.objects.create(user=user, **data) else: person_qs.update(user=user, **data) self.custom_signup(request, user) setup_user_email(request, user, []) return user class ActionForm(forms.Form): """Generic form for executing actions on multiple items of a queryset. This should be used together with a ``Table`` from django-tables2 which includes a ``SelectColumn``. The queryset can be defined in two different ways: You can use ``get_queryset`` or provide ``queryset`` as keyword argument at the initialization of this form class. If both are declared, it will use the keyword argument. Any actions can be defined using the ``actions`` class attribute or overriding the method ``get_actions``. The actions use the same syntax like the Django Admin actions with one important difference: Instead of the related model admin, these actions will get the related ``ActionForm`` as first argument. Here you can see an example for such an action: .. code-block:: python from django.utils.translation import gettext as _ def example_action(form, request, queryset): # Do something with this queryset example_action.short_description = _("Example action") If you can include the ``ActionForm`` like any other form in your views, but you must add the request as first argument. When the form is valid, you should run ``execute``: .. code-block:: python from aleksis.core.forms import ActionForm def your_view(request, ...): # Something action_form = ActionForm(request, request.POST or None, ...) if request.method == "POST" and form.is_valid(): form.execute() # Something """ layout = Layout("action") actions = [] def get_actions(self) -> Sequence[Callable]: """Get all defined actions.""" return self.actions def _get_actions_dict(self) -> dict[str, Callable]: """Get all defined actions as dictionary.""" return {value.__name__: value for value in self.get_actions()} def _get_action_choices(self) -> list[tuple[str, str]]: """Get all defined actions as Django choices.""" return [ (value.__name__, getattr(value, "short_description", value.__name__)) for value in self.get_actions() ] def get_queryset(self) -> QuerySet: """Get the related queryset.""" raise NotImplementedError("Queryset necessary.") action = forms.ChoiceField(choices=[]) selected_objects = forms.ModelMultipleChoiceField(queryset=None) def __init__(self, request: HttpRequest, *args, queryset: QuerySet = None, **kwargs): self.request = request self.queryset = queryset if isinstance(queryset, QuerySet) else self.get_queryset() super().__init__(*args, **kwargs) self.fields["selected_objects"].queryset = self.queryset self.fields["action"].choices = self._get_action_choices() def clean_action(self): action = self._get_actions_dict().get(self.cleaned_data["action"], None) if not action: raise ValidationError(_("The selected action does not exist.")) return action def clean_selected_objects(self): action = self.cleaned_data["action"] if hasattr(action, "permission"): selected_objects = queryset_rules_filter( self.request, self.cleaned_data["selected_objects"], action.permission ) if selected_objects.count() < self.cleaned_data["selected_objects"].count(): raise ValidationError( _("You do not have permission to run {} on all selected objects.").format( getattr(value, "short_description", value.__name__) ) ) return self.cleaned_data["selected_objects"] def execute(self) -> Any: """Execute the selected action on all selected objects. :return: the return value of the action """ if self.is_valid(): data = self.cleaned_data["selected_objects"] action = self.cleaned_data["action"] return action(None, self.request, data) raise TypeError("execute() must be called on a pre-validated form.") class ListActionForm(ActionForm): """Generic form for executing actions on multiple items of a list of dictionaries. Sometimes you want to implement actions for data from different sources than querysets or even querysets from multiple models. For these cases, you can use this form. To provide an unique identification of each item, the dictionaries **must** include the attribute ``pk``. This attribute has to be unique for the whole list. If you don't mind this aspect, this will cause unexpected behavior. Any actions can be defined as described in ``ActionForm``, but, of course, the last argument won't be a queryset but a list of dictionaries. For further information on usage, you can take a look at ``ActionForm``. """ selected_objects = forms.MultipleChoiceField(choices=[]) def get_queryset(self): # Return None in order not to raise an unwanted exception return None def _get_dict(self) -> dict[str, dict]: """Get the items sorted by pk attribute.""" return {item["pk"]: item for item in self.items} def _get_choices(self) -> list[tuple[str, str]]: """Get the items as Django choices.""" return [(item["pk"], item["pk"]) for item in self.items] def _get_real_items(self, items: Sequence[dict]) -> list[dict]: """Get the real dictionaries from a list of pks.""" items_dict = self._get_dict() real_items = [] for item in items: if item not in items_dict: raise ValidationError(_("No valid selection.")) real_items.append(items_dict[item]) return real_items def clean_selected_objects(self) -> list[dict]: data = self.cleaned_data["selected_objects"] items = self._get_real_items(data) return items def __init__(self, request: HttpRequest, items, *args, **kwargs): self.items = items super().__init__(request, *args, **kwargs) self.fields["selected_objects"].choices = self._get_choices() class OAuthApplicationForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["allowed_scopes"].widget = forms.SelectMultiple( choices=list(AppScopes().get_all_scopes().items()) ) class Meta: model = OAuthApplication fields = ( "name", "icon", "client_id", "client_secret", "client_type", "algorithm", "allowed_scopes", "redirect_uris", "skip_authorization", )