From e178f56369099094f7b02f6b98450105c415c55f Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sat, 23 Nov 2024 22:30:37 +0100 Subject: [PATCH] members: invite member as user --- jdav_web/jdav_web/settings/components/jet.py | 3 +- .../jdav_web/settings/components/texts.py | 15 ++++ jdav_web/jdav_web/urls.py | 3 +- jdav_web/logindata/admin.py | 5 +- jdav_web/logindata/apps.py | 2 + jdav_web/logindata/migrations/0001_initial.py | 55 ++++++++++++++ jdav_web/logindata/models.py | 30 +++++++- .../templates/logindata/register_failed.html | 17 +++++ .../templates/logindata/register_form.html | 33 +++++++++ .../logindata/register_password.html | 26 +++++++ .../templates/logindata/register_success.html | 15 ++++ jdav_web/logindata/urls.py | 8 ++ jdav_web/logindata/views.py | 73 +++++++++++++++++++ jdav_web/mailer/mailutils.py | 4 + jdav_web/members/admin.py | 41 ++++++++++- .../0024_member_invite_as_user_key.py | 18 +++++ jdav_web/members/models.py | 11 ++- jdav_web/static/startpage/css/base.css | 5 ++ .../member/change_form_object_tools.html | 12 +++ 19 files changed, 367 insertions(+), 9 deletions(-) create mode 100644 jdav_web/logindata/migrations/0001_initial.py create mode 100644 jdav_web/logindata/templates/logindata/register_failed.html create mode 100644 jdav_web/logindata/templates/logindata/register_form.html create mode 100644 jdav_web/logindata/templates/logindata/register_password.html create mode 100644 jdav_web/logindata/templates/logindata/register_success.html create mode 100644 jdav_web/logindata/urls.py create mode 100644 jdav_web/members/migrations/0024_member_invite_as_user_key.py create mode 100644 jdav_web/templates/admin/members/member/change_form_object_tools.html diff --git a/jdav_web/jdav_web/settings/components/jet.py b/jdav_web/jdav_web/settings/components/jet.py index 96c77bd..110e110 100644 --- a/jdav_web/jdav_web/settings/components/jet.py +++ b/jdav_web/jdav_web/settings/components/jet.py @@ -5,9 +5,10 @@ JET_DEFAULT_THEME = 'jdav-green' JET_CHANGE_FORM_SIBLING_LINKS = False JET_SIDE_MENU_ITEMS = [ - {'app_label': 'auth', 'permissions': ['auth'], 'items': [ + {'app_label': 'logindata', 'permissions': ['auth'], 'items': [ {'name': 'authgroup', 'permissions': ['auth.group'] }, {'name': 'logindatum', 'permissions': ['auth.user']}, + {'name': 'registrationpassword', 'permissions': ['auth.user']}, ]}, {'app_label': 'django_celery_beat', 'permissions': ['django_celery_beat'], 'items': [ {'name': 'crontabschedule'}, diff --git a/jdav_web/jdav_web/settings/components/texts.py b/jdav_web/jdav_web/settings/components/texts.py index 5116c8f..6371e57 100644 --- a/jdav_web/jdav_web/settings/components/texts.py +++ b/jdav_web/jdav_web/settings/components/texts.py @@ -140,3 +140,18 @@ verschickt. Wenn Du in Zukunft keine Emails mehr erhalten möchtest, kannst Du hier den Newsletter deabonnieren: {link}""" % { 'SEKTION': SEKTION } + + +INVITE_AS_USER_TEXT = """Hallo {name}, + +du bist Jugendleiter:in in der Sektion %(SEKTION)s. Die Verwaltung unserer Jugendgruppen, +Ausfahrten und Finanzen erfolgt in unserer Online Plattform Kompass. Deine Stammdaten sind +dort bereits hinterlegt. Damit du dich auch anmelden kannst, folge bitte dem folgenden Link +und wähle ein Passwort. + +{link} + +Bei Fragen, wende dich gerne an %(RESPONSIBLE_MAIL)s. + +Viele Grüße +Deine JDAV %(SEKTION)s""" % { 'SEKTION': SEKTION, 'RESPONSIBLE_MAIL': RESPONSIBLE_MAIL } diff --git a/jdav_web/jdav_web/urls.py b/jdav_web/jdav_web/urls.py index 4c5f283..e78056a 100644 --- a/jdav_web/jdav_web/urls.py +++ b/jdav_web/jdav_web/urls.py @@ -27,11 +27,12 @@ admin.site.index_title = _('Startpage') admin.site.site_header = 'Kompass' urlpatterns += i18n_patterns( - re_path(r'^kompass/?', admin.site.urls), + re_path(r'^kompass/?', admin.site.urls, name='kompass'), re_path(r'^jet/', include('jet.urls', 'jet')), # Django JET URLS re_path(r'^admin/?', RedirectView.as_view(url='/kompass')), re_path(r'^newsletter/', include('mailer.urls', namespace="mailer")), re_path(r'^members/', include('members.urls', namespace="members")), + re_path(r'^login/', include('logindata.urls', namespace="logindata")), re_path(r'^LBAlpin/Programm(/)?(20)?[0-9]{0,2}', include('ludwigsburgalpin.urls', namespace="ludwigsburgalpin")), re_path(r'^_nested_admin/', include('nested_admin.urls')), diff --git a/jdav_web/logindata/admin.py b/jdav_web/logindata/admin.py index be22d5c..377e974 100644 --- a/jdav_web/logindata/admin.py +++ b/jdav_web/logindata/admin.py @@ -2,7 +2,7 @@ from django.utils.translation import gettext_lazy as _ from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin, GroupAdmin as BaseAuthGroupAdmin from django.contrib.auth.models import User as BaseUser, Group as BaseAuthGroup -from .models import AuthGroup, LoginDatum +from .models import AuthGroup, LoginDatum, RegistrationPassword from members.models import Member # Register your models here. @@ -40,7 +40,7 @@ class LoginDatumAdmin(BaseUserAdmin): None, { "classes": ("wide",), - "fields": ("username", "usable_password", "password1", "password2"), + "fields": ("username", "password1", "password2"), }, ), ) @@ -49,3 +49,4 @@ admin.site.unregister(BaseUser) admin.site.unregister(BaseAuthGroup) admin.site.register(LoginDatum, LoginDatumAdmin) admin.site.register(AuthGroup, AuthGroupAdmin) +admin.site.register(RegistrationPassword) diff --git a/jdav_web/logindata/apps.py b/jdav_web/logindata/apps.py index bb1c250..b1a5370 100644 --- a/jdav_web/logindata/apps.py +++ b/jdav_web/logindata/apps.py @@ -1,6 +1,8 @@ from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ class LoginDataConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'logindata' + verbose_name = _('Authentication') diff --git a/jdav_web/logindata/migrations/0001_initial.py b/jdav_web/logindata/migrations/0001_initial.py new file mode 100644 index 0000000..0a26b33 --- /dev/null +++ b/jdav_web/logindata/migrations/0001_initial.py @@ -0,0 +1,55 @@ +# Generated by Django 4.0.1 on 2024-11-23 21:15 + +import django.contrib.auth.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='RegistrationPassword', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=100, verbose_name='Password')), + ], + ), + migrations.CreateModel( + name='AuthGroup', + fields=[ + ], + options={ + 'verbose_name': 'Permission group', + 'verbose_name_plural': 'Permission groups', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('auth.group',), + managers=[ + ('objects', django.contrib.auth.models.GroupManager()), + ], + ), + migrations.CreateModel( + name='LoginDatum', + fields=[ + ], + options={ + 'verbose_name': 'Login Datum', + 'verbose_name_plural': 'Login Data', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('auth.user',), + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/jdav_web/logindata/models.py b/jdav_web/logindata/models.py index af8cfcd..c86deaa 100644 --- a/jdav_web/logindata/models.py +++ b/jdav_web/logindata/models.py @@ -9,7 +9,6 @@ class AuthGroup(BaseAuthGroup): proxy = True verbose_name = _('Permission group') verbose_name_plural = _('Permission groups') - app_label = "auth" class LoginDatum(BaseUser): @@ -17,4 +16,31 @@ class LoginDatum(BaseUser): proxy = True verbose_name = _('Login Datum') verbose_name_plural = _('Login Data') - app_label = "auth" + + +class RegistrationPassword(models.Model): + """ + A password that can be used to register after inviting a member. + """ + password = models.CharField(max_length=100, verbose_name=_('Password')) + + def __str__(self): + return self.password + + class Meta: + verbose_name = _('Active registration password') + verbose_name_plural = _('Active registration passwords') + +def initial_user_setup(user, member): + try: + standard_group = AuthGroup.objects.get(name='Standard') + except AuthGroup.DoesNotExist: + return False + + user.is_staff = True + user.save() + user.groups.add(standard_group) + member.user = user + member.invite_as_user_key = '' + member.save() + return True diff --git a/jdav_web/logindata/templates/logindata/register_failed.html b/jdav_web/logindata/templates/logindata/register_failed.html new file mode 100644 index 0000000..433815a --- /dev/null +++ b/jdav_web/logindata/templates/logindata/register_failed.html @@ -0,0 +1,17 @@ +{% extends "members/base.html" %} +{% load i18n %} +{% load static %} + +{% block title %} +{% trans "Registration" %} +{% endblock %} + +{% block content %} + +

{% trans "Set login data" %}

+ +

{% trans "Something went wrong. The registration key is invalid or has expired." %}

+ +

{% trans "If you think this is a mistake, please" %} {% trans "contact us." %}

+ +{% endblock %} diff --git a/jdav_web/logindata/templates/logindata/register_form.html b/jdav_web/logindata/templates/logindata/register_form.html new file mode 100644 index 0000000..f917961 --- /dev/null +++ b/jdav_web/logindata/templates/logindata/register_form.html @@ -0,0 +1,33 @@ +{% extends "members/base.html" %} +{% load i18n %} +{% load static %} + +{% block title %} +{% trans "Register" %} +{% endblock %} + +{% block content %} + + + +

{% trans "Set login data" %}

+ +

{% trans "Welcome, " %} {{ member.prename }}. +{% blocktrans %}To set your personal login data, please enter the password that you received.{% endblocktrans %}

+ +{% if error_message %} +

{{ error_message }}

+{% endif %} + +
+ + {% csrf_token %} + {{form}} +
+ + + + +
+ +{% endblock %} diff --git a/jdav_web/logindata/templates/logindata/register_password.html b/jdav_web/logindata/templates/logindata/register_password.html new file mode 100644 index 0000000..df26e47 --- /dev/null +++ b/jdav_web/logindata/templates/logindata/register_password.html @@ -0,0 +1,26 @@ +{% extends "members/base.html" %} +{% load i18n %} +{% load static %} + +{% block title %} +{% trans "Register" %} +{% endblock %} + +{% block content %} + +

{% trans "Set login data" %}

+ +

{% trans "Welcome, " %} {{ member.prename }}. {% blocktrans %}To set your personal login data for Kompass, please enter the password that you received.{% endblocktrans%}

+ +{% if error_message %} +

{{ error_message }}

+{% endif %} + +
+ {% csrf_token %} + + +

+
+ +{% endblock %} diff --git a/jdav_web/logindata/templates/logindata/register_success.html b/jdav_web/logindata/templates/logindata/register_success.html new file mode 100644 index 0000000..b357fe4 --- /dev/null +++ b/jdav_web/logindata/templates/logindata/register_success.html @@ -0,0 +1,15 @@ +{% extends "members/base.html" %} +{% load i18n %} + +{% block title %} +{% trans "Registration successful" %} +{% endblock %} + +{% block content %} + +

{% trans "Set login data" %}

+ +

{% blocktrans %}You successfully set your login data. You can now proceed to{% endblocktrans%} +login.

+ +{% endblock %} diff --git a/jdav_web/logindata/urls.py b/jdav_web/logindata/urls.py new file mode 100644 index 0000000..80d08b5 --- /dev/null +++ b/jdav_web/logindata/urls.py @@ -0,0 +1,8 @@ +from django.urls import re_path + +from . import views + +app_name = "logindata" +urlpatterns = [ + re_path(r'^register', views.register , name='register'), +] diff --git a/jdav_web/logindata/views.py b/jdav_web/logindata/views.py index 91ea44a..4415e09 100644 --- a/jdav_web/logindata/views.py +++ b/jdav_web/logindata/views.py @@ -1,3 +1,76 @@ +from django import forms from django.shortcuts import render +from django.http import HttpResponseRedirect +from django.utils.translation import gettext_lazy as _ +from django.urls import reverse +from django.contrib.auth.forms import UserCreationForm +from members.models import Member +from .models import initial_user_setup, RegistrationPassword + + +def render_register_password(request, key, member, error_message=''): + return render(request, 'logindata/register_password.html', + context={'key': key, + 'member': member, + 'error_message': error_message}) + + +def render_register_failed(request): + return render(request, 'logindata/register_failed.html') + + +def render_register_form(request, key, password, member, form): + return render(request, 'logindata/register_form.html', + context={'key': key, + 'password': password, + 'member': member, + 'form': form}) + + +def render_register_success(request): + return render(request, 'logindata/register_success.html') + # Create your views here. +def register(request): + if request.method == 'GET' and 'key' not in request.GET: + return HttpResponseRedirect(reverse('startpage:index')) + if request.method == 'POST' and 'key' not in request.POST: + return HttpResponseRedirect(reverse('startpage:index')) + + key = request.GET['key'] if request.method == 'GET' else request.POST['key'] + if not key: + return render_register_failed(request) + try: + member = Member.objects.get(invite_as_user_key=key) + except (Member.DoesNotExist, Member.MultipleObjectsReturned): + return render_register_failed(request) + + if request.method == 'GET': + return render_register_password(request, request.GET['key'], member) + + if 'password' not in request.POST: + return render_register_failed(request) + + password = request.POST['password'] + + # check if the entered password is one of the active registration passwords + if RegistrationPassword.objects.filter(password=password).count() == 0: + return render_register_password(request, key, member, error_message=_('You entered a wrong password.')) + + if "save" in request.POST: + form = UserCreationForm(request.POST) + if not form.is_valid(): + # form is invalid, reprint form with (automatic) error messages + return render_register_form(request, key, password, member, form) + user = form.save(commit=False) + success = initial_user_setup(user, member) + if success: + return render_register_success(request) + else: + return render_register_failed(request) + else: + prefill = { + 'username': '{prename}.{lastname}'.format(prename=member.prename.lower(), lastname=member.lastname.lower()) } + form = UserCreationForm(initial=prefill) + return render_register_form(request, key, password, member, form) diff --git a/jdav_web/mailer/mailutils.py b/jdav_web/mailer/mailutils.py index af2f38f..701de61 100644 --- a/jdav_web/mailer/mailutils.py +++ b/jdav_web/mailer/mailutils.py @@ -81,5 +81,9 @@ def get_mail_confirmation_link(key): return prepend_base_url("/members/mail/confirm?key={}".format(key)) +def get_invite_as_user_key(key): + return prepend_base_url("/login/register?key={}".format(key)) + + def prepend_base_url(absolutelink): return "{protocol}://{base}{link}".format(protocol=settings.PROTOCOL, base=settings.BASE_URL, link=absolutelink) diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index 63f08e7..4423481 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -214,7 +214,7 @@ class MemberAdmin(CommonAdminMixin, admin.ModelAdmin): #} change_form_template = "members/change_member.html" ordering = ('lastname',) - actions = ['send_mail_to', 'request_echo'] + actions = ['request_echo', 'invite_as_user'] list_per_page = 25 sensitive_fields = ['iban', 'registration_form', 'comments'] @@ -234,6 +234,24 @@ class MemberAdmin(CommonAdminMixin, admin.ModelAdmin): 'has_free_ticket_gym': 'members.may_change_organizationals', } + def get_urls(self): + urls = super().get_urls() + + def wrap(view): + def wrapper(*args, **kwargs): + return self.admin_site.admin_view(view)(*args, **kwargs) + + wrapper.model_admin = self + return update_wrapper(wrapper, view) + + custom_urls = [ + path( + "/inviteasuser/", wrap(self.invite_as_user_view), + name="%s_%s_inviteasuser" % (self.opts.app_label, self.opts.model_name), + ), + ] + return custom_urls + urls + def get_queryset(self, request): queryset = super().get_queryset(request) return annotate_activity_score(queryset.prefetch_related('group')) @@ -263,9 +281,28 @@ class MemberAdmin(CommonAdminMixin, admin.ModelAdmin): continue member.send_mail(_("Echo required"), settings.ECHO_TEXT.format(name=member.prename, link=get_echo_link(member))) - messages.success(request, _("Successfully requested echo from selected members.")) + messages.success(request, _("Successfully requested echo from selected members.")) request_echo.short_description = _('Request echo from selected members') + def invite_as_user(self, request, queryset): + for member in queryset: + member.invite_as_user() + if queryset.count() == 1: + messages.success(request, _('Successfully invited %(name)s as user.') % {'name': queryset[0].name}) + else: + messages.success(request, _('Successfully invited selected members to join as users.')) + invite_as_user.short_description = _('Invite selected members to join Kompass as users.') + + def invite_as_user_view(self, request, object_id): + try: + m = Member.objects.get(pk=object_id) + except Member.DoesNotExist: + messages.error(request, _("Member not found.")) + return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) + self.invite_as_user(request, Member.objects.filter(pk=object_id)) + return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), + args=(object_id,))) + def activity_score(self, obj): score = obj._activity_score # show 1 to 5 climbers based on activity in last year diff --git a/jdav_web/members/migrations/0024_member_invite_as_user_key.py b/jdav_web/members/migrations/0024_member_invite_as_user_key.py new file mode 100644 index 0000000..deb5b3e --- /dev/null +++ b/jdav_web/members/migrations/0024_member_invite_as_user_key.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.1 on 2024-11-23 19:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('members', '0023_alter_member_user'), + ] + + operations = [ + migrations.AddField( + model_name='member', + name='invite_as_user_key', + field=models.CharField(default='', max_length=32), + ), + ] diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index c8af812..4241e8a 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -15,7 +15,7 @@ from utils import RestrictedFileField import os from mailer.mailutils import send as send_mail, get_mail_confirmation_link,\ prepend_base_url, get_registration_link, get_wait_confirmation_link,\ - get_invitation_reject_link + get_invitation_reject_link, get_invite_as_user_key from django.contrib.auth.models import User from django.conf import settings from django.core.validators import MinValueValidator @@ -275,6 +275,7 @@ class Member(Person): confirmed = models.BooleanField(default=True, verbose_name=_('Confirmed')) user = models.OneToOneField(User, blank=True, null=True, on_delete=models.SET_NULL, verbose_name=_('Login data')) + invite_as_user_key = models.CharField(max_length=32, default="") objects = MemberManager() @@ -652,6 +653,14 @@ class Member(Person): return False + def invite_as_user(self): + """Invites the member to join Kompass as a user.""" + self.invite_as_user_key = uuid.uuid4().hex + self.save() + self.send_mail(_('Set login data for Kompass'), + settings.INVITE_AS_USER_TEXT.format(name=self.prename, + link=get_invite_as_user_key(self.invite_as_user_key))) + class EmergencyContact(ContactWithPhoneNumber): """ diff --git a/jdav_web/static/startpage/css/base.css b/jdav_web/static/startpage/css/base.css index 6040432..821b862 100644 --- a/jdav_web/static/startpage/css/base.css +++ b/jdav_web/static/startpage/css/base.css @@ -349,3 +349,8 @@ table.termine { border-collapse:separate; border-spacing: 0 4pt; } + +.errorlist { + font-size: 9pt; + color: darkred; +} diff --git a/jdav_web/templates/admin/members/member/change_form_object_tools.html b/jdav_web/templates/admin/members/member/change_form_object_tools.html new file mode 100644 index 0000000..df9cfc7 --- /dev/null +++ b/jdav_web/templates/admin/members/member/change_form_object_tools.html @@ -0,0 +1,12 @@ +{% extends "admin/change_form_object_tools.html" %} +{% load i18n admin_urls %} + +{% block object-tools-items %} + +
  • + {% trans 'Invite as user' %} +
  • + +{{block.super}} + +{% endblock %}