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