From 2280ba68ec42ca2448946d36614aaf7628f36776 Mon Sep 17 00:00:00 2001 From: Tom Teichler <tom.teichler@teckids.org> Date: Sun, 12 Dec 2021 16:18:03 +0100 Subject: [PATCH] Implement invitation logic --- aleksis/core/forms.py | 25 ++++++++++++- aleksis/core/menus.py | 18 +++++++++ aleksis/core/preferences.py | 47 +++++++++++++++++++++++ aleksis/core/rules.py | 8 +++- aleksis/core/tables.py | 30 +++++++++++++++ aleksis/core/urls.py | 10 +++++ aleksis/core/views.py | 74 ++++++++++++++++++++++++++++++++++++- 7 files changed, 207 insertions(+), 5 deletions(-) diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py index 40e6f91c2..a364ec684 100644 --- a/aleksis/core/forms.py +++ b/aleksis/core/forms.py @@ -3,7 +3,6 @@ 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 ValidationError @@ -13,7 +12,8 @@ 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 allauth.account.utils import get_user_model, 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 @@ -393,6 +393,27 @@ DashboardWidgetOrderFormSet = forms.formset_factory( ) +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 SelectPermissionForm(forms.Form): """Select a permission to assign.""" diff --git a/aleksis/core/menus.py b/aleksis/core/menus.py index 924e34af9..068294007 100644 --- a/aleksis/core/menus.py +++ b/aleksis/core/menus.py @@ -20,6 +20,15 @@ MENUS = { ("aleksis.core.util.predicates.permission_validator", "core.can_register"), ], }, + { + "name": _("Accept invitation"), + "url": "enter_invitation_code", + "icon": "vpn_key", + "validators": [ + "menu_generator.validators.is_anonymous", + ("aleksis.core.util.predicates.permission_validator", "core.can_invite"), + ], + }, { "name": _("Dashboard"), "url": "index", @@ -307,6 +316,15 @@ MENUS = { ) ], }, + { + "name": _("Invite person"), + "url": "invite_person", + "icon": "card_giftcard", + "validators": [ + "menu_generator.validators.is_authenticated", + ("aleksis.core.util.predicates.permission_validator", "core.can_invite"), + ], + }, ], }, ], diff --git a/aleksis/core/preferences.py b/aleksis/core/preferences.py index d0de22fe0..057ea9479 100644 --- a/aleksis/core/preferences.py +++ b/aleksis/core/preferences.py @@ -264,6 +264,53 @@ class SignupEnabled(BooleanPreference): @site_preferences_registry.register +class SignupOpen(BooleanPreference): + section = auth + name = "signup_open" + default = False + verbose_name = _("Signup open for everyone.") + + +@site_preferences_registry.register +class InviteOnly(BooleanPreference): + section = auth + name = "invite_only" + default = False + verbose_name = _("Signup invite only.") + + +@site_preferences_registry.register +class InviteEnabled(BooleanPreference): + section = auth + name = "invite_enabled" + default = False + verbose_name = _("Enable invitations") + + +@site_preferences_registry.register +class InviteCodeLength(IntegerPreference): + section = auth + name = "invite_code_length" + default = 3 + verbose_name = _("Length of invite code. (Default 3: abcde-acbde-abcde)") + + +@site_preferences_registry.register +class InviteCodePacketSize(IntegerPreference): + section = auth + name = "invite_code_packet_size" + default = 5 + verbose_name = _("Size of packets. (Default 5: abcde)") + + +@site_preferences_registry.register +class InviteDayExpiry(IntegerPreference): + section = auth + name = "invite_day_expiry" + default = 3 + verbose_name = _("Expiration time of invitations in days.") + + class OAuthAllowedGrants(MultipleChoicePreference): """Grant Flows allowed for OAuth applications.""" diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py index 9fc08709e..f54d95b94 100644 --- a/aleksis/core/rules.py +++ b/aleksis/core/rules.py @@ -313,12 +313,18 @@ edit_default_dashboard_predicate = has_person & has_global_perm("core.edit_defau rules.add_perm("core.edit_default_dashboard_rule", edit_default_dashboard_predicate) # django-allauth -can_register_predicate = is_site_preference_set(section="auth", pref="signup_enabled") +can_register_predicate = is_site_preference_set( + section="auth", pref="signup_enabled" +) & is_site_preference_set(section="auth", pref="signup_open") rules.add_perm("core.can_register", can_register_predicate) can_change_password_predicate = is_site_preference_set(section="auth", pref="allow_password_change") rules.add_perm("core.can_change_password", can_change_password_predicate) +# django-invitations +can_invite_predicate = is_site_preference_set(section="auth", pref="invite_enabled") +rules.add_perm("core.can_invite", can_invite_predicate) + # OAuth2 permissions create_oauthapplication_predicate = has_person & has_global_perm("core.add_oauthapplication") rules.add_perm("core.create_oauthapplication_rule", create_oauthapplication_predicate) diff --git a/aleksis/core/tables.py b/aleksis/core/tables.py index 8f0e0b93b..480476a6c 100644 --- a/aleksis/core/tables.py +++ b/aleksis/core/tables.py @@ -1,8 +1,12 @@ +from textwrap import wrap + from django.utils.translation import gettext_lazy as _ import django_tables2 as tables from django_tables2.utils import A +from .models import Person + class SchoolTermTable(tables.Table): """Table to list persons.""" @@ -93,6 +97,32 @@ class DashboardWidgetTable(tables.Table): return record._meta.verbose_name +class PersonColumn(tables.Column): + """Returns person object from given id.""" + + def render(self, value): + return Person.objects.get(user__id=value) + + +class InvitationCodeColumn(tables.Column): + """Returns invitation code in a more readable format.""" + + def render(self, value): + return "-".join(wrap(value, 5)) + + +class InvitationsTable(tables.Table): + """Table to list persons.""" + + email = tables.EmailColumn() + sent = tables.DateColumn() + inviter_id = PersonColumn() + key = InvitationCodeColumn() + accepted = tables.BooleanColumn( + yesno="check,cancel", attrs={"span": {"class": "material-icons"}} + ) + + class PermissionDeleteColumn(tables.LinkColumn): """Link column with label 'Delete'.""" diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index 0c00bb8b2..1b03b2d89 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -30,6 +30,16 @@ urlpatterns = [ name="account_change_password", ), path("accounts/", include("allauth.urls")), + path("invitations/send-invite", views.InvitePerson.as_view(), name="invite_person"), + path( + "invitations/code/enter", views.EnterInvitationCode.as_view(), name="enter_invitation_code" + ), + path( + "invitations/code/generate", + views.GenerateInvitationCode.as_view(), + name="generate_invitation_code", + ), + path("invitations/", include("invitations.urls")), path( "accounts/social/connections/<int:pk>/delete", views.SocialAccountDeleteView.as_view(), diff --git a/aleksis/core/views.py b/aleksis/core/views.py index a1e117715..9f4a05480 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -1,3 +1,4 @@ +from textwrap import wrap from typing import Any, Dict, Optional, Type from urllib.parse import urlencode @@ -22,15 +23,15 @@ from django.http import ( from django.shortcuts import get_object_or_404, redirect, render from django.template import loader from django.urls import reverse, reverse_lazy +from django.utils import timezone from django.utils.decorators import method_decorator from django.utils.translation import get_language from django.utils.translation import gettext_lazy as _ from django.views.decorators.cache import never_cache from django.views.defaults import ERROR_500_TEMPLATE_NAME -from django.views.generic import FormView from django.views.generic.base import TemplateView, View from django.views.generic.detail import DetailView, SingleObjectMixin -from django.views.generic.edit import DeleteView +from django.views.generic.edit import DeleteView, FormView from django.views.generic.list import ListView import reversion @@ -48,6 +49,7 @@ from haystack.inputs import AutoQuery from haystack.query import SearchQuerySet from haystack.utils.loading import UnifiedIndex from health_check.views import MainView +from invitations.views import SendInvite, accept_invitation from reversion import set_user from reversion.views import RevisionMixin from rules.contrib.views import PermissionRequiredMixin, permission_required @@ -72,6 +74,7 @@ from .forms import ( EditGroupForm, EditGroupTypeForm, GroupPreferenceForm, + InvitationCodeForm, OAuthApplicationForm, PersonForm, PersonPreferenceForm, @@ -93,6 +96,7 @@ from .models import ( OAuthApplication, PDFFile, Person, + PersonInvitation, SchoolTerm, TaskUserAssignment, ) @@ -108,6 +112,7 @@ from .tables import ( GroupObjectPermissionTable, GroupsTable, GroupTypesTable, + InvitationsTable, PersonsTable, SchoolTermTable, UserGlobalPermissionTable, @@ -117,6 +122,7 @@ from .util import messages from .util.apps import AppConfig from .util.celery_progress import render_progress_page from .util.core_helpers import ( + generate_random_code, get_allowed_object_ids, get_pwa_icons, get_site_preferences, @@ -1051,6 +1057,70 @@ class EditDashboardView(PermissionRequiredMixin, View): return render(request, "core/edit_dashboard.html", context=context) +class InvitePerson(PermissionRequiredMixin, SingleTableView, SendInvite): + """View to invite a person to register an account.""" + + template_name = "invitations/forms/_invite.html" + permission_required = "core.can_invite" + model = PersonInvitation + table_class = InvitationsTable + context = {} + + def get_initial(self): + if person: + return {"person": person} + return super().get_initial(**kwargs) + + def get_context_data(self, **kwargs): + queryset = kwargs.pop("object_list", None) + if queryset is None: + self.object_list = self.model.objects.all()[:5] + return super().get_context_data(**kwargs) + + +class EnterInvitationCode(FormView): + """View to enter an invitation code.""" + + template_name = "invitations/enter.html" + form_class = InvitationCodeForm + + def form_valid(self, form): + code = "".join(form.cleaned_data["code"].lower().split("-")) + if ( + PersonInvitation.objects.filter(key=code).exists() + and not PersonInvitation.objects.get(key=code).accepted + and not PersonInvitation.objects.get(key=code).key_expired() + ): + invitation = PersonInvitation.objects.get(key=code) + accept_invitation( + invitation=invitation, request=self.request, signal_sender=self.request.user + ) + return redirect("account_signup") + return redirect("invitations:accept-invite", code) + + +class GenerateInvitationCode(View): + """View to generate an invitation code.""" + + def get(self, request): + 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) + + invitation = PersonInvitation.objects.create( + email="", inviter=request.user, key=code, sent=timezone.now() + ) + + code = "-".join(wrap(invitation.key, 5)) + + messages.success( + request, + _(f"The invitation was successfully created. The invitation code is {code}"), + ) + + return redirect("invite_person") + + class PermissionsListBaseView(PermissionRequiredMixin, SingleTableMixin, FilterView): """Base view for list of all permissions.""" -- GitLab