diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4022882bcbd013fb6bee4f38c3ed3d3574dc92a2..e2801658f04b6b17666e5194debb0a8e0d7c1d61 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,6 +17,9 @@ Changed Fixed ~~~~~ +* No person was created and linked to the PersonInvitation object when invite by e-mail is used +* No valid data in the second e-mail field of the signup form when it was disabled +* Invitation options were displayed even when the feature was disabled * Inviting newly created persons for registration failed * [Docker] Do not clear cache in migration container die to session invalidation issues * Notification email about user changes was broken diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py index 58bbba7f63869e1212c50ecd7131fdcd4e72999a..ef4184812f73d22bb1dcfa1ff84f74f722c3c774 100644 --- a/aleksis/core/forms.py +++ b/aleksis/core/forms.py @@ -18,6 +18,7 @@ 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 @@ -29,6 +30,7 @@ from .models import ( GroupType, OAuthApplication, Person, + PersonInvitation, SchoolTerm, ) from .registries import ( @@ -410,6 +412,31 @@ class InvitationCodeForm(forms.Form): 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.""" @@ -597,6 +624,7 @@ class AccountRegisterForm(SignupForm, ExtensibleForm): 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 diff --git a/aleksis/core/models.py b/aleksis/core/models.py index d8f63e606073f92e8db799206012cfc9a998a5d8..812a9d254ca4bff2e9372ed7969d0cb2b811c176 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -1150,8 +1150,16 @@ class PersonInvitation(AbstractBaseInvitation, PureDjangoModel): def __str__(self) -> str: return f"{self.email} ({self.inviter})" - key_expired = Invitation.key_expired + @classmethod + def create(cls, email, inviter=None, **kwargs): + length = get_site_preferences()["auth__invite_code_length"] + packet_size = get_site_preferences()["auth__invite_code_packet_size"] + code = generate_random_code(length, packet_size) + instance = cls.objects.create(email=email, inviter=inviter, key=code, **kwargs) + return instance + + key_expired = Invitation.key_expired send_invitation = Invitation.send_invitation diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py index ad1f55fcc44f470fac11c362c456451a56620785..022fa11a00671a3acb0775c2229a378bd3981342 100644 --- a/aleksis/core/rules.py +++ b/aleksis/core/rules.py @@ -329,7 +329,7 @@ rules.add_perm("core.can_change_password", can_change_password_predicate) invite_enabled_predicate = is_site_preference_set(section="auth", pref="invite_enabled") rules.add_perm("core.invite_enabled", invite_enabled_predicate) -can_invite_predicate = has_person & has_global_perm("core.invite") +can_invite_predicate = invite_enabled_predicate & has_person & has_global_perm("core.invite") rules.add_perm("core.can_invite", can_invite_predicate) # OAuth2 permissions diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index 22d77d8c8baf42c823d8c8efc8d13a2039270e8a..087762de88b667f90c942f3596a7df4991faf627 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -372,6 +372,8 @@ INVITATIONS_INVITATION_EXPIRY = _settings.get("auth.invitation.expiry", 3) INVITATIONS_EMAIL_SUBJECT_PREFIX = ACCOUNT_EMAIL_SUBJECT_PREFIX # Use custom invitation model INVITATIONS_INVITATION_MODEL = "core.PersonInvitation" +# Use custom invitation form +INVITATIONS_INVITE_FORM = "aleksis.core.forms.PersonCreateInviteForm" # Display error message if invitation code is invalid INVITATIONS_GONE_ON_ACCEPT_ERROR = False # Mark invitation as accepted after signup diff --git a/aleksis/core/templates/core/person/full.html b/aleksis/core/templates/core/person/full.html index 24e267db3caf7b6a9886f10269a4db654514f696..23f8ba29b30188dd4cdf5b76af23991a0f618119 100644 --- a/aleksis/core/templates/core/person/full.html +++ b/aleksis/core/templates/core/person/full.html @@ -48,7 +48,7 @@ </a> {% endif %} - {% if can_invite and not person.user %} + {% if invite_enabled and can_invite and not person.user %} <a href="{% url "invite_person_by_id" person.id %}" class="btn waves-effect waves-light"> <i class="material-icons left">card_giftcard</i> {% trans "Invite user" %} diff --git a/aleksis/core/templates/invitations/disabled.html b/aleksis/core/templates/invitations/disabled.html new file mode 100644 index 0000000000000000000000000000000000000000..2515a01f2437c7f617cc14fcee0226bedeacd341 --- /dev/null +++ b/aleksis/core/templates/invitations/disabled.html @@ -0,0 +1,21 @@ +{% extends "core/base.html" %} + +{% load i18n %} + +{% block browser_title %}{% blocktrans %}The invite feature is disabled{% endblocktrans %}{% endblock %} +{% block no_page_title %}{% endblock %} + +{% block content %} + <div class="container"> + <div class="card red"> + <div class="card-content white-text"> + <i class="material-icons small left">disabled_by_default</i> + <span class="card-title">{% blocktrans %}The invite feature is disabled.{% endblocktrans %}</span> + <p> + {% trans "To enable it, switch on the corresponding checkbox in the authentication section of the " %} + <a href="{% url "preferences_site" %}">{% trans "site preferences page" %}</a>. + </p> + </div> + </div> + </div> +{% endblock %} diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index 20c6bad6174aa27f619828366ac5d937faa17bd3..c91bae7f29c978d30e96361f92213eec7c5679d1 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -40,6 +40,7 @@ urlpatterns = [ views.GenerateInvitationCode.as_view(), name="generate_invitation_code", ), + path("invitations/disabled", views.InviteDisabledView.as_view(), name="invite_disabled"), path("invitations/", include("invitations.urls")), path( "accounts/social/connections/<int:pk>/delete", diff --git a/aleksis/core/views.py b/aleksis/core/views.py index c3100c7bf789c3e4ed12d03a20f8e34ae8adb3b3..fc32d75982a26e9fafbcd0f4a98e2b0569e585c4 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -330,6 +330,9 @@ def person(request: HttpRequest, id_: Optional[int] = None) -> HttpResponse: # Get groups where person is member of context["groups"] = person.member_of.all() + # Get whether the invitation feature is enabled in the preferences + context["invite_enabled"] = get_site_preferences()["auth__invite_enabled"] + return render(request, "core/person/full.html", context) @@ -1075,6 +1078,11 @@ class InvitePerson(PermissionRequiredMixin, SingleTableView, SendInvite): table_class = InvitationsTable context = {} + def dispatch(self, request, *args, **kwargs): + if not get_site_preferences()["auth__invite_enabled"]: + return HttpResponseRedirect(reverse_lazy("invite_disabled")) + return super().dispatch(request, *args, **kwargs) + # Get queryset of invitations def get_context_data(self, **kwargs): queryset = kwargs.pop("object_list", None) @@ -1102,6 +1110,7 @@ class EnterInvitationCode(FormView): accept_invitation( invitation=invitation, request=self.request, signal_sender=self.request.user ) + self.request.session["invitation_code_entered"] = True return redirect("account_signup") return redirect("invitations:accept-invite", code) @@ -1409,8 +1418,10 @@ class AccountRegisterView(SignupView): success_url = reverse_lazy("index") def dispatch(self, request, *args, **kwargs): - if not request.user.has_perm("core.can_register") and not request.session.get( - "account_verified_email" + if ( + not request.user.has_perm("core.can_register") + and not request.session.get("account_verified_email") + and not request.session.get("invitation_code_entered") ): raise PermissionDenied() return super(AccountRegisterView, self).dispatch(request, *args, **kwargs) @@ -1421,10 +1432,17 @@ class AccountRegisterView(SignupView): return kwargs -class InvitePersonByID(SingleObjectMixin, View): +class InvitePersonByID(PermissionRequiredMixin, SingleObjectMixin, View): """Custom view to invite person by their ID.""" model = Person + success_url = reverse_lazy("persons") + permission_required = "core.can_invite" + + def dispatch(self, request, *args, **kwargs): + if not get_site_preferences()["auth__invite_enabled"]: + return HttpResponseRedirect(reverse_lazy("invite_disabled")) + return super().dispatch(request, *args, **kwargs) def get(self, request, *args, **kwargs): person = self.get_object() @@ -1447,6 +1465,18 @@ class InvitePersonByID(SingleObjectMixin, View): return HttpResponseRedirect(person.get_absolute_url()) +class InviteDisabledView(PermissionRequiredMixin, TemplateView): + """View to display a notice that the invite feature is disabled and how to enable it.""" + + template_name = "invitations/disabled.html" + permission_required = "core.change_site_preferences_rule" + + def dispatch(self, request, *args, **kwargs): + if get_site_preferences()["auth__invite_enabled"]: + raise PermissionDenied() + return super().dispatch(request, *args, **kwargs) + + class LoginView(AllAuthLoginView): """Custom login view covering e-mail verification if mandatory.