diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index af5d314dbcc470d8e5aa3791d3ebe3957f26996c..a4349087629ec1c1a04444327e503b6b775cbc4b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,8 @@ include: - project: "AlekSIS/official/AlekSIS" file: /ci/general.yml + - project: "AlekSIS/official/AlekSIS" + file: /ci/prepare/lock.yml - project: "AlekSIS/official/AlekSIS" file: /ci/test/test.yml - project: "AlekSIS/official/AlekSIS" diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2eae801df4cf4d6c911bf567a699900cbc810e17..6d1065772560708021b9d7a7941ec2f3980b2f2a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,6 +13,8 @@ Added ~~~~~ * Allow configuration of database options +* User invitations with invite codes and targeted invites for existing + persons Fixed ~~~~~ @@ -27,6 +29,8 @@ Changed ~~~~~~~ * Modified the appearance of tables for mobile users to be more user friendly +* [Dev] Remove lock file; locking dependencies is the distribution's + responsibility Removed ~~~~~~~ diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py index 40e6f91c250bfd15dd2dee238fd46295a9184a3a..78dc9dce29f7519905012193469477ce383f9f9e 100644 --- a/aleksis/core/forms.py +++ b/aleksis/core/forms.py @@ -6,7 +6,7 @@ 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 +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 _ @@ -14,6 +14,7 @@ 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 @@ -393,6 +394,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.""" @@ -513,13 +535,42 @@ class AccountRegisterForm(SignupForm, ExtensibleForm): """Form to register new user accounts.""" class Meta: - model = Group - fields = [] + 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", "last_name"), + Row("first_name", "additional_name", "last_name"), + "short_name", + ), + Fieldset( + _("Adress 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"), @@ -527,72 +578,48 @@ class AccountRegisterForm(SignupForm, ExtensibleForm): Row("email", "email2"), Row("password1", "password2"), ), - Fieldset( - _("Consents"), - Row("privacy_policy"), - ), ) - def __init__(self, *args, **kwargs): - super(AccountRegisterForm, self).__init__(*args, **kwargs) - self.fields["password1"] = forms.CharField(label=_("Password"), widget=forms.PasswordInput) - - privacy_policy = get_site_preferences()["footer__privacy_url"] - - if settings.SIGNUP_PASSWORD_ENTER_TWICE: - self.fields["password2"] = forms.CharField( - label=_("Password (again)"), widget=forms.PasswordInput - ) + password1 = forms.CharField(label=_("Password"), widget=forms.PasswordInput) - self.fields["first_name"] = forms.CharField( - required=True, - ) + privacy_policy = get_site_preferences()["footer__privacy_url"] - self.fields["last_name"] = forms.CharField( - required=True, - ) + if settings.SIGNUP_PASSWORD_ENTER_TWICE: + password2 = forms.CharField(label=_("Password (again)"), widget=forms.PasswordInput) - self.fields["privacy_policy"] = forms.BooleanField( - help_text=_( - f"I have read the <a href='{privacy_policy}'>Privacy policy</a>" - " and agree with them." - ), - required=True, - ) + def __init__(self, *args, **kwargs): + request = kwargs.pop("request", None) + super(AccountRegisterForm, self).__init__(*args, **kwargs) - def clean(self): - super(AccountRegisterForm, self).clean() + if request.session.get("account_verified_email"): + email = request.session["account_verified_email"] - dummy_user = get_user_model() - password = self.cleaned_data.get("password1") - if password: try: - get_adapter().clean_password(password, user=dummy_user) - except forms.ValidationError as e: - self.add_error("password1", e) + person = Person.objects.get(email=email) + except (Person.DoesNotExist, Person.MultipleObjectsReturned): + raise SuspiciousOperation() - if ( - settings.SIGNUP_PASSWORD_ENTER_TWICE - and "password1" in self.cleaned_data - and "password2" in self.cleaned_data - ): - if self.cleaned_data["password1"] != self.cleaned_data["password2"]: - self.add_error( - "password2", - _("You must type the same password each time."), - ) - return self.cleaned_data + self.fields["email"].disabled = True + self.fields["email2"].disabled = True + + if person: + available_fields = [field.name for field in Person._meta.get_fields()] + 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) - Person.objects.create( - first_name=self.cleaned_data["first_name"], - last_name=self.cleaned_data["last_name"], - email=self.cleaned_data["email"], - user=user, - ) + # 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] + if not Person.objects.filter(email=data["email"]): + _person, created = Person.objects.update_or_create(user=user, **data) self.custom_signup(request, user) setup_user_email(request, user, []) return user diff --git a/aleksis/core/menus.py b/aleksis/core/menus.py index 9e1b3a2dd0431771af45b0861c2cf891c623e9d7..7938539c53327936d86d6c08334834e8e2a4fb0a 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.invite_enabled"), + ], + }, { "name": _("Dashboard"), "url": "index", @@ -296,6 +305,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/migrations/0029_invitations.py b/aleksis/core/migrations/0029_invitations.py new file mode 100644 index 0000000000000000000000000000000000000000..0c4410267bf7064dbb0b8c9b57ff7f15a9645e93 --- /dev/null +++ b/aleksis/core/migrations/0029_invitations.py @@ -0,0 +1,34 @@ +# Generated by Django 3.1.7 on 2021-03-31 16:55 + +import aleksis.core.mixins +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('core', '0028_char_field_not_null'), + ] + + operations = [ + migrations.CreateModel( + name='PersonInvitation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('accepted', models.BooleanField(default=False, verbose_name='accepted')), + ('key', models.CharField(max_length=64, unique=True, verbose_name='key')), + ('sent', models.DateTimeField(null=True, verbose_name='sent')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='E-Mail address')), + ('inviter', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('person', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invitation', to='core.person')) + ], + options={ + 'abstract': False, + }, + bases=(models.Model, aleksis.core.mixins.PureDjangoModel), + ), + ] diff --git a/aleksis/core/models.py b/aleksis/core/models.py index 8a8ec40af86ccb91fded46f58c2b0c9b9201ce84..aa20122bff05c234df71ef99db5a6fda318e10cd 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -29,6 +29,10 @@ from cachalot.api import cachalot_disabled from cache_memoize import cache_memoize from django_celery_results.models import TaskResult from dynamic_preferences.models import PerInstancePreferenceModel +from invitations import signals +from invitations.adapters import get_invitations_adapter +from invitations.base_invitation import AbstractBaseInvitation +from invitations.models import Invitation from model_utils import FieldTracker from model_utils.models import TimeStampedModel from oauth2_provider.models import ( @@ -59,7 +63,7 @@ from .mixins import ( SchoolTermRelatedExtensibleModel, ) from .tasks import send_notification -from .util.core_helpers import get_site_preferences, now_tomorrow +from .util.core_helpers import generate_random_code, get_site_preferences, now_tomorrow from .util.model_helpers import ICONS FIELD_CHOICES = ( @@ -1060,6 +1064,22 @@ class DataCheckResult(ExtensibleModel): ) +class PersonInvitation(AbstractBaseInvitation, PureDjangoModel): + """Custom model for invitations to allow to generate invitations codes without email address.""" + + email = models.EmailField(verbose_name=_("E-Mail address"), blank=True) + person = models.ForeignKey( + Person, on_delete=models.CASCADE, blank=True, related_name="invitation", null=True + ) + + def __str__(self) -> str: + return f"{self.email} ({self.inviter})" + + key_expired = Invitation.key_expired + + send_invitation = Invitation.send_invitation + + class PDFFile(ExtensibleModel): """Link to a rendered PDF file.""" diff --git a/aleksis/core/preferences.py b/aleksis/core/preferences.py index 0110d9aa84d3848912bf411a00063d86609f6c1d..15ca0e67478ed6483a8cd0df03aa38c1529b8762 100644 --- a/aleksis/core/preferences.py +++ b/aleksis/core/preferences.py @@ -265,6 +265,29 @@ class SignupEnabled(BooleanPreference): @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)") + + class OAuthAllowedGrants(MultipleChoicePreference): """Grant Flows allowed for OAuth applications.""" diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index 7c7082e82037aadfd3390677d09a23538f7b4ec4..94971e5c5bbc11277afc245720b12ed8ab979a01 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -93,6 +93,7 @@ INSTALLED_APPS = [ "rules.apps.AutodiscoverRulesConfig", "haystack", "polymorphic", + "dj_cleavejs.apps.DjCleaveJSConfig", "dbbackup", "django_celery_beat", "django_celery_results", @@ -121,6 +122,7 @@ INSTALLED_APPS = [ "allauth", "allauth.account", "allauth.socialaccount", + "invitations", "health_check", "health_check.db", "health_check.cache", @@ -326,8 +328,11 @@ ACCOUNT_AUTHENTICATION_METHOD = _settings.get("auth.registration.method", "usern ACCOUNT_EMAIL_REQUIRED = _settings.get("auth.registration.email_required", True) SOCIALACCOUNT_EMAIL_REQUIRED = False +# Cooldown for verification mails +ACCOUNT_EMAIL_CONFIRMATION_COOLDOWN = _settings.get("auth.registration.verification_cooldown", 180) + # Require email verification after sign up -ACCOUNT_EMAIL_VERIFICATION = _settings.get("auth.registration.email_verification", "mandatory") +ACCOUNT_EMAIL_VERIFICATION = _settings.get("auth.registration.email_verification", "optional") SOCIALACCOUNT_EMAIL_VERIFICATION = False # Email subject prefix for verification mails @@ -345,6 +350,21 @@ ACCOUNT_SIGNUP_EMAIL_ENTER_TWICE = True # Enforce uniqueness of email addresses ACCOUNT_UNIQUE_EMAIL = _settings.get("auth.login.registration.unique_email", True) +# Configuration for django-invitations + +# Use custom account adapter +ACCOUNT_ADAPTER = "invitations.models.InvitationsAdapter" +# Expire invitations are configured amout of days +INVITATIONS_INVITATION_EXPIRY = _settings.get("auth.invitation.expiry", 3) +# Use email prefix configured for django-allauth +INVITATIONS_EMAIL_SUBJECT_PREFIX = ACCOUNT_EMAIL_SUBJECT_PREFIX +# Use custom invitation model +INVITATIONS_INVITATION_MODEL = "core.PersonInvitation" +# Display error message if invitation code is invalid +INVITATIONS_GONE_ON_ACCEPT_ERROR = False +# Mark invitation as accepted after signup +INVITATIONS_ACCEPT_INVITE_AFTER_SIGNUP = True + # Configuration for OAuth2 provider OAUTH2_PROVIDER = {"SCOPES_BACKEND_CLASS": "aleksis.core.util.auth_helpers.AppScopes"} OAUTH2_PROVIDER_APPLICATION_MODEL = "core.OAuthApplication" @@ -504,6 +524,7 @@ MEDIA_ROOT = _settings.get("media.root", os.path.join(BASE_DIR, "media")) NODE_MODULES_ROOT = _settings.get("node_modules.root", os.path.join(BASE_DIR, "node_modules")) YARN_INSTALLED_APPS = [ + "cleave.js", "@fontsource/roboto", "jquery", "@materializecss/materialize", @@ -545,10 +566,13 @@ ANY_JS = { "Roboto700": {"css_url": JS_URL + "/@fontsource/roboto/700.css"}, "Roboto900": {"css_url": JS_URL + "/@fontsource/roboto/900.css"}, "Sentry": {"js_url": JS_URL + "/@sentry/tracing/build/bundle.tracing.js"}, + "cleavejs": {"js_url": "cleave.js/dist/cleave.min.js"}, } merge_app_settings("ANY_JS", ANY_JS, True) +CLEAVE_JS = ANY_JS["cleavejs"]["js_url"] + SASS_PROCESSOR_ENABLED = True SASS_PROCESSOR_AUTO_INCLUDE = False SASS_PROCESSOR_CUSTOM_FUNCTIONS = { diff --git a/aleksis/core/tables.py b/aleksis/core/tables.py index 2557b126ceeaebae567df5488b6284783962ac50..ab19c80608f1a8966dab7ae2094eca7caf579252 100644 --- a/aleksis/core/tables.py +++ b/aleksis/core/tables.py @@ -1,8 +1,13 @@ +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 +from .util.core_helpers import get_site_preferences + class SchoolTermTable(tables.Table): """Table to list persons.""" @@ -93,6 +98,33 @@ 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): + packet_size = get_site_preferences()["auth__invite_code_packet_size"] + return "-".join(wrap(value, packet_size)) + + +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/templates/account/account_inactive.html b/aleksis/core/templates/account/account_inactive.html index 62b03a3db859699aabb816ef1d05455777965bd9..5c328abcfbca48c6499f71b4b11fac5f1f91ccad 100644 --- a/aleksis/core/templates/account/account_inactive.html +++ b/aleksis/core/templates/account/account_inactive.html @@ -9,8 +9,10 @@ <div class="container"> <div class="card red"> <div class="card-content white-text"> - <div class="material-icons small left">error_outline</div> - <span class="card-title">{% blocktrans %}Account inactive.{% endblocktrans %}</span> + <div class="card-title"> + <i class="material-icons small left">error_outline</i> + {% blocktrans %}Account inactive.{% endblocktrans %} + </div> <p> {% blocktrans %} This account is currently inactive. If you think this is an diff --git a/aleksis/core/templates/account/signup_closed.html b/aleksis/core/templates/account/signup_closed.html index 33ef749f5598fbd209e3f72dc2b4a79620f17b1a..0d5cbd51095e9c24fceacbc4ba8f6a510a5705a9 100644 --- a/aleksis/core/templates/account/signup_closed.html +++ b/aleksis/core/templates/account/signup_closed.html @@ -9,8 +9,10 @@ <div class="container"> <div class="card red"> <div class="card-content white-text"> - <div class="material-icons small left">error_outline</div> - <span class="card-title">{% blocktrans %}Signup closed.{% endblocktrans %}</span> + <div class="card-title"> + <i class="material-icons small left">error_outline</i> + {% blocktrans %}Signup closed.{% endblocktrans %} + </div> <p> {% blocktrans %} This sign up is currently closed. If you think this is an diff --git a/aleksis/core/templates/account/verification_sent.html b/aleksis/core/templates/account/verification_sent.html index dd486aeb2bf212838a014fe868db4cfe6716cf8d..df3ae47c82911a01997ac9f4d7919c9e1b95a36e 100644 --- a/aleksis/core/templates/account/verification_sent.html +++ b/aleksis/core/templates/account/verification_sent.html @@ -25,10 +25,6 @@ contact us if you do not receive it within a few minutes. {% endblocktrans %} </p> - <p> - {% url 'account_email' as email_url %} - {% blocktrans with email_url=email_url %}<strong>Note:</strong> you can still <a href="{{ email_url }}">change your e-mail address</a>{% endblocktrans %} - </p> {% include "core/partials/admins_list.html" %} </div> </div> diff --git a/aleksis/core/templates/core/person/full.html b/aleksis/core/templates/core/person/full.html index d16354f00ebcd2162ffc7bab16715707b41adb81..4a2e68a09517d1888f708b8ece3f757c6b1d8b1f 100644 --- a/aleksis/core/templates/core/person/full.html +++ b/aleksis/core/templates/core/person/full.html @@ -14,6 +14,7 @@ {% has_perm 'core.change_person_preferences_rule' user person as can_change_person_preferences %} {% has_perm 'core.delete_person_rule' user person as can_delete_person %} {% has_perm "core.impersonate_rule" user person as can_impersonate %} + {% has_perm "core.can_invite" user person as can_invite %} {% if can_change_person or can_change_person_preferences or can_delete_person or can_impersonate %} <p> @@ -43,7 +44,13 @@ <i class="material-icons left">portrait</i> {% trans "Impersonate" %} </a> - {% endif %} + {% endif %} + {% if 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" %} + </a> + {% endif %} </p> {% endif %} diff --git a/aleksis/core/templates/invitations/enter.html b/aleksis/core/templates/invitations/enter.html new file mode 100644 index 0000000000000000000000000000000000000000..03605a710697334567384a74a289783b64a7f55e --- /dev/null +++ b/aleksis/core/templates/invitations/enter.html @@ -0,0 +1,44 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} + +{% load material_form i18n %} + +{% block browser_title %}{% blocktrans %}Accept invitation{% endblocktrans %}{% endblock %} +{% block no_page_title %}{% endblock %} + +{% block extra_head %} + {{ form.media.css }} +{% endblock %} + +{% block content %} + <div class="row"> + <div class="col m1 l2 xl3"></div> + <div class="col s12 m10 l8 xl6"> + <div class="card"> + <form method="post"> + <div class="card-content"> + <div class="card-title">{% trans "Accept your invitation" %}</div> + <div class="alert primary"> + <div> + <i class="material-icons left">info</i> + {% blocktrans %} + Please enter your invitation code to register + your new user account: + {% endblocktrans %} + </div> + </div> + {% csrf_token %} + {% form form=form %}{% endform %} + </div> + <div class="card-action-light"> + <button type="submit" class="btn green waves-effect waves-light"> + <i class="material-icons left">card_giftcard</i> + {% trans "Accept invite" %} + </button> + </div> + </form> + </div> + </div> + {{ form.media.js }} +{% endblock %} diff --git a/aleksis/core/templates/invitations/forms/_invite.html b/aleksis/core/templates/invitations/forms/_invite.html new file mode 100644 index 0000000000000000000000000000000000000000..297997e0b04f3d99a1e1279c8900d0bcc9c50024 --- /dev/null +++ b/aleksis/core/templates/invitations/forms/_invite.html @@ -0,0 +1,38 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} + +{% load material_form i18n %} + +{% load render_table from django_tables2 %} + +{% block browser_title %}{% blocktrans %}Invite{% endblocktrans %}{% endblock %} +{% block page_title %}{% blocktrans %}Invite{% endblocktrans %}{% endblock %} + + +{% block content %} + + <div class="row"> + <div class="col s6"> + <h5>{% trans "Invite by email address" %}</h5> + <form method="post"> + {% csrf_token %} + {% form form=form %}{% endform %} + {% trans "Invite" as caption %} + {% include "core/partials/save_button.html" with caption=caption icon="card_giftcard" %} + </form> + </div> + <div class="col s6"> + <h5>{% trans "Generate invitation code" %}</h5> + + <a class="btn waves-effect waves-light hundred-percent" href="{% url 'generate_invitation_code' %}"> + {% trans "Generate code" %} + </a> + </div> + <div class="col s12"> + <h5>{% trans "Invitations" %}</h5> + + {% render_table table %} + </div> + </div> +{% endblock %} diff --git a/aleksis/core/templates/invitations/messages/invite_accepted.txt b/aleksis/core/templates/invitations/messages/invite_accepted.txt new file mode 100644 index 0000000000000000000000000000000000000000..59699adee30108f241eb6dd4db1478f75399292d --- /dev/null +++ b/aleksis/core/templates/invitations/messages/invite_accepted.txt @@ -0,0 +1,3 @@ +{% load i18n %} + +{% blocktrans %}The invitation for {{ email }} has been accepted.{% endblocktrans %} diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index 0c00bb8b26b397f2b0bdfb591bd232fcb3d13e74..753d95085b6c8812927f2db376a6f3e2e6429c4b 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -23,6 +23,7 @@ urlpatterns = [ path("serviceworker.js", views.ServiceWorkerView.as_view(), name="service_worker"), path("offline/", views.OfflineView.as_view(), name="offline"), path("about/", views.about, name="about_aleksis"), + path("accounts/signup/", views.AccountRegisterView.as_view(), name="account_signup"), path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"), path( "accounts/password/change/", @@ -30,6 +31,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(), @@ -39,6 +50,7 @@ urlpatterns = [ path("admin/uwsgi/", include("django_uwsgi.urls")), path("data_management/", views.data_management, name="data_management"), path("status/", views.SystemStatus.as_view(), name="system_status"), + path("account/login/", views.LoginView.as_view(), name="login"), path("", include(tf_urls)), path("celery_progress/<str:task_id>/", views.CeleryProgressView.as_view(), name="task_status"), path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"), @@ -51,6 +63,7 @@ urlpatterns = [ path("person/<int:id_>/", views.person, name="person_by_id"), path("person/<int:pk>/edit/", views.EditPersonView.as_view(), name="edit_person_by_id"), path("person/<int:id_>/delete/", views.delete_person, name="delete_person_by_id"), + path("person/<int:id_>/invite/", views.InvitePersonByID.as_view(), name="invite_person_by_id"), path("groups", views.groups, name="groups"), path("groups/additional_fields", views.additional_fields, name="additional_fields"), path("groups/child_groups/", views.groups_child_groups, name="groups_child_groups"), diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py index 53ad87a6e71edd3562c642b91e2d4dbf03e17253..3b3c934a85341e75092b6ef1e8df77bdc838687e 100644 --- a/aleksis/core/util/core_helpers.py +++ b/aleksis/core/util/core_helpers.py @@ -13,6 +13,7 @@ from django.db.models import Model, QuerySet from django.http import HttpRequest from django.shortcuts import get_object_or_404 from django.utils import timezone +from django.utils.crypto import get_random_string from django.utils.functional import lazy from django.utils.module_loading import import_string @@ -279,6 +280,11 @@ def queryset_rules_filter( return queryset.filter(pk__in=wanted_objects) +def generate_random_code(length, packet_size) -> str: + """Generate random code for e.g. invitations.""" + return get_random_string(packet_size * length).lower() + + def unread_notifications_badge(request: HttpRequest) -> int: """Generate badge content with the number of unread notifications.""" return request.user.person.unread_notifications_count diff --git a/aleksis/core/views.py b/aleksis/core/views.py index 285f28b606370aeb77fb88add55bc9fa16e38169..c3825b7b6548cf3bc9633a4153cb15d78012faee 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,19 +23,20 @@ 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 -from allauth.account.views import PasswordChangeView +from allauth.account.utils import _has_verified_for_login, send_email_confirmation +from allauth.account.views import PasswordChangeView, SignupView from allauth.socialaccount.adapter import get_adapter from allauth.socialaccount.models import SocialAccount from celery_progress.views import get_progress @@ -48,9 +50,12 @@ 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 import test_rule from rules.contrib.views import PermissionRequiredMixin, permission_required +from two_factor.views.core import LoginView as AllAuthLoginView from aleksis.core.data_checks import DataCheckRegistry, check_data @@ -64,6 +69,7 @@ from .filters import ( UserObjectPermissionFilter, ) from .forms import ( + AccountRegisterForm, AnnouncementForm, AssignPermissionForm, ChildGroupsForm, @@ -72,6 +78,7 @@ from .forms import ( EditGroupForm, EditGroupTypeForm, GroupPreferenceForm, + InvitationCodeForm, OAuthApplicationForm, PersonForm, PersonPreferenceForm, @@ -93,6 +100,7 @@ from .models import ( OAuthApplication, PDFFile, Person, + PersonInvitation, SchoolTerm, TaskUserAssignment, ) @@ -108,6 +116,7 @@ from .tables import ( GroupObjectPermissionTable, GroupsTable, GroupTypesTable, + InvitationsTable, PersonsTable, SchoolTermTable, UserGlobalPermissionTable, @@ -117,6 +126,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, @@ -1054,6 +1064,72 @@ 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 = {} + + # Get queryset of invitations + def get_context_data(self, **kwargs): + queryset = kwargs.pop("object_list", None) + if queryset is None: + self.object_list = self.model.objects.all() + 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("-")) + # Check if valid invitations exists + 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) + # Mark invitation as accepted and redirect to signup + 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): + # Build code + 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) + + # Create invitation object + invitation = PersonInvitation.objects.create( + email="", inviter=request.user, key=code, sent=timezone.now() + ) + + # Make code more readable + code = "-".join(wrap(invitation.key, 5)) + + # Generate success message and print code + 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.""" @@ -1318,3 +1394,70 @@ def server_error( context = {"request": request} return HttpResponseServerError(template.render(context)) + + +class AccountRegisterView(SignupView): + """Custom view to register a user account. + + Rewrites dispatch function from allauth to check if signup is open or if the user + has a verified email address from an invitation; otherwise raises permission denied. + """ + + form_class = AccountRegisterForm + success_url = "index" + + def dispatch(self, request, *args, **kwargs): + if not test_rule("core.can_register") and not request.session.get("account_verified_email"): + raise PermissionDenied() + return super(AccountRegisterView, self).dispatch(request, *args, **kwargs) + + def get_form_kwargs(self): + kwargs = super(AccountRegisterView, self).get_form_kwargs() + kwargs["request"] = self.request + return kwargs + + +class InvitePersonByID(View): + """Custom view to invite person by their ID.""" + + success_url = reverse_lazy("persons") + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + success_url = reverze_lazy("person_by_id", self.object.pk) + person = self.object + + if not PersonInvitation.objects.filter(email=person.email).exists(): + length = get_site_preferences()["auth__invite_code_length"] + packet_size = get_site_preferences()["auth__invite_code_packet_size"] + key = generate_random_code(length, packet_size) + invite = PersonInvitation.objects.create(person=person, key=key) + if person.email: + invite.email = person.email + invite.inviter = self.request.user + invite.save() + + invite.send_invitation(self.request) + messages.success(self.request, _("Person was invited successfully.")) + else: + messages.success(self.request, _("Person was already invited.")) + + return HttpResponseRedirect(success_url) + + +class LoginView(AllAuthLoginView): + """Custom login view covering e-mail verification if mandatory. + + Overrides view from allauth to check if email verification from django-invitations is + mandatory. If it i, checks if the user has a verified email address, if not, + it re-sends verification. + """ + + def done(self, form_list, **kwargs): + if settings.ACCOUNT_EMAIL_VERIFICATION == "mandatory": + user = self.get_user() + if not _has_verified_for_login(user, user.email): + send_email_confirmation(self.request, user, signup=False, email=user.email) + return render(self.request, "account/verification_sent.html") + + return super().done(form_list, **kwargs) diff --git a/pyproject.toml b/pyproject.toml index 9a3d5b31260fb18f8c157541e2fe0ce7557eb73f..170725240c480ba107af172560fde9f0dcab1a6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,8 @@ django-cachalot = "^2.3.2" django-prometheus = "^2.1.0" django-model-utils = "^4.0.0" bs4 = "^0.0.1" +django-invitations = "^1.9.3" +django-cleavejs = "^0.1.0" django-allauth = "^0.47.0" django-uwsgi-ng = "^1.1.0" django-extensions = "^3.1.1"