Antrag auf Mitgliedschaft (#164)

Fixes #156.

Co-authored-by: Tobias Bölz <tobias@boelz.eu>
Reviewed-on: #164
pull/166/head
Tobias Bölz 1 month ago
parent a5114a4705
commit 336be17369

@ -3,14 +3,14 @@ from django.utils.translation import gettext_lazy as _
from mfa.middleware import MFAEnforceMiddleware
class EnforceMFAForSuperusersMiddleware(MFAEnforceMiddleware):
"""Middleware that enforces multi-factor authentication for superusers."""
class EnforceMFAForStaffMiddleware(MFAEnforceMiddleware):
"""Middleware that enforces multi-factor authentication for staff users."""
def process_view(self, request, view_func, view_args, view_kwargs):
response = super() \
.process_view(request, view_func, view_args, view_kwargs)
if response and request.user.is_superuser:
if response and request.user.is_staff:
messages.error(
request,
_('Multi-factor authentication is required for your account.'

@ -7,14 +7,22 @@ from mfa.models import MFAKey
def enable_enforce_mfa_middleware(settings):
"""Enable EnforceMFAForSuperusersMiddleware for tests in this module."""
settings.MIDDLEWARE.append(
'accounts.middleware.EnforceMFAForSuperusersMiddleware'
)
'accounts.middleware.EnforceMFAForStaffMiddleware')
yield
settings.MIDDLEWARE.remove(
'accounts.middleware.EnforceMFAForSuperusersMiddleware'
'accounts.middleware.EnforceMFAForStaffMiddleware'
)
def test_ensure_mfa_staff(client, django_user_model):
"""Redirect staff without MFA key to MFA list."""
user = django_user_model.objects.create(username='test', is_staff=True)
client.force_login(user)
response = client.get(reverse('profile'))
assert response.status_code == 302
assert response.url == '/accounts/mfa/'
def test_ensure_mfa_superuser(client, admin_user):
"""Redirect superuser without MFA key to MFA list."""
client.force_login(admin_user)
@ -37,7 +45,7 @@ def test_ensure_mfa_superuser_has_key(client, admin_user):
def test_ensure_mfa_normal_user(client, django_user_model):
"""Dont redirect non-superuser without keys."""
"""Dont redirect non-staff without keys."""
user = django_user_model.objects.create(username='test')
client.force_login(user)
response = client.get(reverse('profile'))

@ -5,11 +5,12 @@ from django.contrib import admin
from django.db import models
from django.utils.translation import gettext_lazy as _
from core.models import TimeStampedModel
from .. import managers
from ..utils import upload_path
from ..validators import validate_markdown
from .applicationforfunding import ApplicationForFunding
from .timestampedmodel import TimeStampedModel
class Action(TimeStampedModel):

@ -6,10 +6,12 @@ from django.core.validators import RegexValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
from core.models import TimeStampedModel
from core.utils import send_mail, send_matrix_message
from .. import managers
from ..conf import settings
from ..utils import send_mail, send_matrix_message, upload_path
from .timestampedmodel import TimeStampedModel
from ..utils import upload_path
class ApplicationForFunding(TimeStampedModel):

@ -1,62 +1,2 @@
import logging
import urllib
from typing import List, Optional, Union
import requests
from django.core.mail import EmailMessage
from .conf import settings
logger = logging.getLogger(__name__)
def upload_path(instance, filename):
return f'antraege/{instance.upload_folder()}/{filename}'
def send_mail(subject: str, body: str, to: Union[List[str], str],
cc: Optional[Union[List[str], str]] = None,
reply_to: Optional[List[str]] = None,
fail_silently=False):
"""Send mail with configured subject prefix."""
subject = f'{settings.EMAIL_SUBJECT_PREFIX}{subject}'
if isinstance(to, str):
to = [to]
if isinstance(cc, str):
cc = [cc]
if isinstance(reply_to, str):
reply_to = [reply_to]
email = EmailMessage(
subject=subject,
body=body,
to=to,
cc=cc,
reply_to=reply_to
)
email.send(fail_silently=fail_silently)
def send_matrix_message(body: str, to: Union[List[str], str]):
"""Send message to Matrix server."""
if not settings.APPLICATIONS_MATRIX_SERVER \
or not settings.APPLICATIONS_MATRIX_ACCESS_TOKEN:
return
if isinstance(to, str):
to = [to]
data = {
'msgtype': 'm.text',
'body': body,
}
headers = {
'Authorization': f'Bearer {settings.APPLICATIONS_MATRIX_ACCESS_TOKEN}',
}
for room_id in to:
path = urllib.parse.quote(
f'_matrix/client/r0/rooms/{room_id}/send/m.room.message'
)
url = urllib.parse.urljoin(settings.APPLICATIONS_MATRIX_SERVER, path)
try:
r = requests.post(url, json=data, headers=headers)
r.raise_for_status()
except requests.exceptions.RequestException:
logger.exception('Error sending message to Matrix server.')

@ -1,66 +1,14 @@
from crispy_forms.helper import FormHelper
from crispy_forms.layout import HTML, ButtonHolder, Layout, Submit
from django.core.exceptions import ImproperlyConfigured
from django.db import models, transaction
from django.db import transaction
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils import translation
from django.utils.translation import gettext_lazy as _
from .models import ApplicationForFunding
class NotificationMixin:
"""Mixin for class bases views that sends notifications."""
notification_template_name = None
notification_template_name_suffix = '_notification'
def get_notification_template_names(self):
names = []
if self.notification_template_name:
names.append(self.notification_template_name)
if getattr(self, 'object', None) is not None \
and isinstance(self.object, models.Model):
names.append(
f'{self.object._meta.app_label}/{self.object._meta.model_name}'
f'{self.notification_template_name_suffix}.txt'
)
elif getattr(self, 'model', None) is not None \
and issubclass(self.model, models.Model):
names.append(
f'{self.model._meta.app_label}/{self.model._meta.model_name}'
f'{self.notification_template_name_suffix}.txt'
)
if not names:
raise ImproperlyConfigured(
'NotificationMixin requires either a model object or a '
"definition of 'notification_template_name' or.")
return names
def get_notification_message(self, object):
url = self.request.build_absolute_uri(object.get_absolute_url())
return render_to_string(
self.get_notification_template_names(),
{
'object': object,
'url': url
}
)
def send_notification(self):
with translation.override('de'):
message = self.get_notification_message(self.object)
self.object.send_notification(message)
def form_valid(self, form):
"""Send notification after call to `super().form_valid(form)`."""
response = super().form_valid(form)
self.send_notification()
return response
class ActionMixin:
"""Mixin for views with forms that handle `Action` objects."""
from_states = None

@ -21,6 +21,8 @@ from django.views.generic.dates import YearArchiveView
from django.views.generic.detail import DetailView
from django.views.generic.edit import CreateView
from core.viewmixins import NotificationMixin
from .forms import (ApplicationForNearTermFundingForm,
ApplicationForNextYearFundingForm, PaymentForm,
RequestForPaymentForm, VotingResultForm)
@ -31,7 +33,7 @@ from .models import (ApplicationConfirmation, ApplicationForFunding,
Rejection, RequestForPayment, Vote, VotingResult)
from .tokens import default_token_generator
from .viewmixins import (ActionMixin, ApplicationYearArchiveMixin,
FormActionsMixin, NotificationMixin)
FormActionsMixin)
# CSP needs to be relaxed for views that contain a django-crispy-forms
# form with a file field. This is specific to the bootstrap4 theme and should

@ -0,0 +1,18 @@
from django.conf import settings as django_settings
class Defaults:
MATRIX_SERVER = None
MATRIX_ACCESS_TOKEN = None
class Settings:
def __getattr__(self, name):
try:
return getattr(django_settings, name)
except AttributeError:
return getattr(Defaults, name)
settings = Settings()

@ -5,10 +5,10 @@ from requests.exceptions import ConnectionError, HTTPError
from ..utils import send_matrix_message
@patch('applicationsforfunding.utils.requests.post')
@patch('core.utils.requests.post')
def test_send_matrix_message(mock_post, settings):
settings.APPLICATIONS_MATRIX_SERVER = 'https://example.com'
settings.APPLICATIONS_MATRIX_ACCESS_TOKEN = 'foobar'
settings.MATRIX_SERVER = 'https://example.com'
settings.MATRIX_ACCESS_TOKEN = 'foobar'
mock_response = Mock()
mock_response.raise_for_status.return_value = None
mock_post.return_value = mock_response
@ -22,15 +22,16 @@ def test_send_matrix_message(mock_post, settings):
},
headers={
'Authorization': 'Bearer foobar',
}
},
timeout=30,
)
mock_response.raise_for_status.assert_called_once()
@patch('applicationsforfunding.utils.requests.post')
@patch('core.utils.requests.post')
def test_send_matrix_message_multiple_room(mock_post, settings):
settings.APPLICATIONS_MATRIX_SERVER = 'https://example.com'
settings.APPLICATIONS_MATRIX_ACCESS_TOKEN = 'foobar'
settings.MATRIX_SERVER = 'https://example.com'
settings.MATRIX_ACCESS_TOKEN = 'foobar'
mock_response = Mock()
mock_response.raise_for_status = MagicMock()
mock_post.return_value = mock_response
@ -45,7 +46,8 @@ def test_send_matrix_message_multiple_room(mock_post, settings):
},
headers={
'Authorization': 'Bearer foobar',
}
},
timeout=30,
)
mock_post.assert_any_call(
'https://example.com/_matrix/client/r0/rooms/%21fgh%3Aexample.com/'
@ -56,34 +58,35 @@ def test_send_matrix_message_multiple_room(mock_post, settings):
},
headers={
'Authorization': 'Bearer foobar',
}
},
timeout=30,
)
@patch('applicationsforfunding.utils.requests.post')
@patch('core.utils.requests.post')
def test_send_matrix_message_not_configured(mock_post, settings):
"""Do nothing if server or access token arent configured."""
settings.APPLICATIONS_MATRIX_SERVER = None
settings.APPLICATIONS_MATRIX_ACCESS_TOKEN = None
settings.MATRIX_SERVER = None
settings.MATRIX_ACCESS_TOKEN = None
send_matrix_message('test', '!asdfgh:example.com')
mock_post.assert_not_called()
@patch('applicationsforfunding.utils.requests.post')
@patch('core.utils.requests.post')
def test_send_matrix_message_connection_error(mock_post, settings, caplog):
"""Exception during post request is logged but otherwise ignored."""
settings.APPLICATIONS_MATRIX_SERVER = 'https://example.com'
settings.APPLICATIONS_MATRIX_ACCESS_TOKEN = 'foobar'
settings.MATRIX_SERVER = 'https://example.com'
settings.MATRIX_ACCESS_TOKEN = 'foobar'
mock_post.side_effect = ConnectionError()
send_matrix_message('test', '!asdfgh:example.com')
assert 'Error sending message' in caplog.text
@patch('applicationsforfunding.utils.requests.post')
@patch('core.utils.requests.post')
def test_send_matrix_message_http_error(mock_post, settings, caplog):
"""HTTP errors are logged but otherwise ignored."""
settings.APPLICATIONS_MATRIX_SERVER = 'https://example.com'
settings.APPLICATIONS_MATRIX_ACCESS_TOKEN = 'foobar'
settings.MATRIX_SERVER = 'https://example.com'
settings.MATRIX_ACCESS_TOKEN = 'foobar'
mock_response = Mock()
mock_response.raise_for_status.side_effect = HTTPError()
mock_post.return_value = mock_response

@ -0,0 +1,58 @@
import logging
import urllib
from typing import List, Optional, Union
import requests
from django.core.mail import EmailMessage
from .conf import settings
logger = logging.getLogger(__name__)
def send_mail(subject: str, body: str, to: Union[List[str], str],
cc: Optional[Union[List[str], str]] = None,
reply_to: Optional[List[str]] = None,
fail_silently=False):
"""Send mail with configured subject prefix."""
subject = f'{settings.EMAIL_SUBJECT_PREFIX}{subject}'
if isinstance(to, str):
to = [to]
if isinstance(cc, str):
cc = [cc]
if isinstance(reply_to, str):
reply_to = [reply_to]
email = EmailMessage(
subject=subject,
body=body,
to=to,
cc=cc,
reply_to=reply_to
)
email.send(fail_silently=fail_silently)
def send_matrix_message(body: str, to: Union[List[str], str]):
"""Send message to Matrix server."""
if not settings.MATRIX_SERVER \
or not settings.MATRIX_ACCESS_TOKEN:
return
if isinstance(to, str):
to = [to]
data = {
'msgtype': 'm.text',
'body': body,
}
headers = {
'Authorization': f'Bearer {settings.MATRIX_ACCESS_TOKEN}',
}
for room_id in to:
path = urllib.parse.quote(
f'_matrix/client/r0/rooms/{room_id}/send/m.room.message'
)
url = urllib.parse.urljoin(settings.MATRIX_SERVER, path)
try:
r = requests.post(url, json=data, headers=headers, timeout=30)
r.raise_for_status()
except requests.exceptions.RequestException:
logger.exception('Error sending message to Matrix server.')

@ -0,0 +1,53 @@
from django.core.exceptions import ImproperlyConfigured
from django.db import models
from django.template.loader import render_to_string
from django.utils import translation
class NotificationMixin:
"""Mixin for class bases views that sends notifications."""
notification_template_name = None
notification_template_name_suffix = '_notification'
def get_notification_template_names(self):
names = []
if self.notification_template_name:
names.append(self.notification_template_name)
if getattr(self, 'object', None) is not None \
and isinstance(self.object, models.Model):
names.append(
f'{self.object._meta.app_label}/{self.object._meta.model_name}'
f'{self.notification_template_name_suffix}.txt'
)
elif getattr(self, 'model', None) is not None \
and issubclass(self.model, models.Model):
names.append(
f'{self.model._meta.app_label}/{self.model._meta.model_name}'
f'{self.notification_template_name_suffix}.txt'
)
if not names:
raise ImproperlyConfigured(
'NotificationMixin requires either a model object or a '
"definition of 'notification_template_name' or.")
return names
def get_notification_message(self, object):
url = self.request.build_absolute_uri(object.get_absolute_url())
return render_to_string(
self.get_notification_template_names(),
{
'object': object,
'url': url
}
)
def send_notification(self):
with translation.override('de'):
message = self.get_notification_message(self.object)
self.object.send_notification(message)
def form_valid(self, form):
"""Send notification after call to `super().form_valid(form)`."""
response = super().form_valid(form)
self.send_notification()
return response

@ -2,6 +2,7 @@ import csv
import io
from datetime import datetime
import schwifty
from charset_normalizer import from_bytes
from django import forms
from django.contrib import admin, messages
@ -11,7 +12,7 @@ from django.shortcuts import redirect, render
from django.urls import path
from django.utils.translation import gettext_lazy as _
from .models import Member
from .models import ApplicationForMembership, Member
class IsActiveFilter(admin.SimpleListFilter):
@ -160,3 +161,39 @@ UserAdmin.readonly_fields += ('member',)
UserAdmin.list_display += ('member',)
UserAdmin.list_filter += (('member', admin.EmptyFieldListFilter),)
UserAdmin.fieldsets += ((_('Member'), {'fields': ('member',)}),)
@admin.register(ApplicationForMembership)
class ApplicationForMembershipAdmin(admin.ModelAdmin):
readonly_fields = [
'created',
'name',
'extended_address',
'street_address',
'locality',
'postal_code',
'country',
'email',
'payment_method',
'additional_payment_amount',
'additional_payment_frequency',
'iban',
'bank',
]
list_display = ['name', 'created']
@admin.display(description=_('Bank'))
def bank(self, instance):
if not instance.iban:
return ''
try:
iban = schwifty.IBAN(instance.iban)
return f'{iban.bank_short_name} ({iban.bic})'
except ValueError:
return _('Invalid IBAN')
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False

@ -0,0 +1,20 @@
from django.conf import settings as django_settings
class Defaults:
# These is a list of full addresses that might contain a display name and
# not tuples of (name, email address) like ADMINS and MANAGERS settings!
MEMBERS_NOTIFICATION_TO_EMAIL = []
MEMBERS_NOTIFICATION_TO_MATRIX = []
class Settings:
def __getattr__(self, name):
try:
return getattr(django_settings, name)
except AttributeError:
return getattr(Defaults, name)
settings = Settings()

@ -0,0 +1,97 @@
from crispy_forms.bootstrap import AppendedText, InlineRadios
from crispy_forms.helper import FormHelper
from crispy_forms.layout import (HTML, ButtonHolder, Div, Field, Fieldset,
Layout, Submit)
from django.forms import ModelForm, NumberInput, RadioSelect, ValidationError
from django.utils.translation import gettext_lazy as _
from schwifty import IBAN
from .models import ApplicationForMembership
class ApplicationForMembershipForm(ModelForm):
helper = FormHelper()
helper.layout = Layout(
HTML('{% include "members/_i_hereby_declare.html" %}'),
'name',
'extended_address',
'street_address',
Div(
Field('postal_code', wrapper_class='col-md-3'),
Field('locality', wrapper_class='col-md-9'),
css_class='form-row'
),
'country',
'email',
InlineRadios('payment_method'),
AppendedText('additional_payment_amount', ''),
InlineRadios('additional_payment_frequency'),
Fieldset(
_('SEPA Direct Debit Mandate'),
HTML('{% include "members/_direct_debit_mandate.html" %}'),
'iban',
),
ButtonHolder(
Submit(
'submit',
_('Continue'),
css_class='btn btn-primary float-right ml-2'),
css_class='clearfix'
)
)
def clean(self):
cleaned_data = super().clean()
payment_method_is_direct_debit = cleaned_data['payment_method'] \
== ApplicationForMembership.PaymentMethod.DIRECT_DEBIT
iban_is_empty = 'iban' in cleaned_data and not cleaned_data['iban']
if payment_method_is_direct_debit and iban_is_empty:
msg = _('Enter an IBAN for direct debit payment method.')
self.add_error('payment_method', msg)
self.add_error('iban', msg)
elif not payment_method_is_direct_debit and not iban_is_empty:
msg = _('Dont enter an IBAN for bank transfer payment method.')
self.add_error('payment_method', msg)
self.add_error('iban', msg)
return cleaned_data
def clean_iban(self):
if iban := self.cleaned_data['iban']:
try:
return IBAN(iban).compact
except ValueError:
raise ValidationError(_('Invalid IBAN.'))
return ''
class Meta:
model = ApplicationForMembership
fields = [
'name',
'extended_address',
'street_address',
'locality',
'postal_code',
'country',
'email',
'payment_method',
'additional_payment_amount',
'additional_payment_frequency',
'iban'
]
localized_fields = '__all__'
labels = {
'payment_method': _(
'I wish to pay the membership dues of currently €15.00/year '
'for individuals or €50.00/year for legal entities via'
),
'additional_payment_amount': _(
'In addition to the membership dues, I want to pay the '
'following amount'
),
'additional_payment_frequency': False,
}
widgets = {
'payment_method': RadioSelect,
'additional_payment_amount': NumberInput(attrs={'min': 0}),
'additional_payment_frequency': RadioSelect,
}

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-04-14 13:34+0200\n"
"POT-Creation-Date: 2023-04-28 19:57+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,95 +18,305 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: admin.py:18 admin.py:62
#: .\admin.py:19 .\admin.py:63
msgid "Active"
msgstr "Aktiv"
#: admin.py:23
#: .\admin.py:24
msgid "Yes"
msgstr "Ja"
#: admin.py:24
#: .\admin.py:25
msgid "No"
msgstr "Nein"
#: admin.py:38
#: .\admin.py:39
msgid "CSV File"
msgstr "CSV-Datei"
#: admin.py:58
#: .\admin.py:59
msgid "Membership number"
msgstr "Mitglied-Nr."
#: admin.py:93
#: .\admin.py:98
msgid "Member {} updated."
msgstr "Mitglied {} aktualisiert."
#: admin.py:98
#: .\admin.py:103
msgid "Member {} added."
msgstr "Mitglied {} hinzugefügt."
#: admin.py:110
#: .\admin.py:115
msgid "Import/Update Members"
msgstr "Mitglieder importieren/aktualisieren"
#: admin.py:158 models.py:76
#: .\admin.py:163 .\models.py:75
msgid "Member"
msgstr "Mitglied"
#: apps.py:8
#: .\admin.py:185 .\templates\members\applicationformembership_preview.html:65
msgid "Bank"
msgstr "Bank"
#: .\admin.py:193
msgid "Invalid IBAN"
msgstr "Ungültige IBAN"
#: .\apps.py:8
msgid "Member management"
msgstr "Mitgliederverwaltung"
#: models.py:31
#: .\forms.py:38 .\templates\members\applicationformembership_preview.html:52
msgid "SEPA Direct Debit Mandate"
msgstr "SEPA-Lastschriftmandat"
#: .\forms.py:61
msgid "Continue"
msgstr "Fortfahren"
#: .\forms.py:73
msgid "Enter an IBAN for direct debit payment method."
msgstr "Bitte IBAN bei Zahlung per Lastschrift angegeben."
#: .\forms.py:77
msgid "Dont enter an IBAN for bank transfer payment method."
msgstr "Bitte keine IBAN bei Zahlung per Überweisung angeben."
#: .\forms.py:87
msgid "Invalid IBAN."
msgstr "Ungültige IBAN."
#: .\forms.py:108
msgid ""
"I wish to pay the membership dues of currently €15.00/year for individuals "
"or €50.00/year for legal entities via"
msgstr ""
"Den Mitgliedsbeitrag in Höhe von derzeit 15,00 €/Jahr für natürliche "
"Personen bzw. 50,00 €/Jahr für juristische Personen möchte ich begleichen per"
#: .\forms.py:112
msgid "In addition to the membership dues, I want to pay the following amount"
msgstr ""
"Zusätzlich zum Mitgliedsbeitrag möchte ich den folgenden Betrag entrichten"
#: .\models.py:37
msgid "Individual"
msgstr "Person"
#: models.py:32
#: .\models.py:38
msgid "Organization"
msgstr "Einrichtung"
#: models.py:36
#: .\models.py:42
msgid "ID"
msgstr "ID"
#: models.py:40
#: .\models.py:46 .\templates\members\applicationformembership_preview.html:14
msgid "Name"
msgstr "Name"
#: models.py:44
#: .\models.py:50 .\models.py:122
#: .\templates\members\applicationformembership_preview.html:29
msgid "Email address"
msgstr "E-Mail-Adresse"
#: models.py:48
#: .\models.py:54
msgid "Kind of member"
msgstr "Mitgliedsart"
#: models.py:51
#: .\models.py:57
msgid "Start of membership"
msgstr "Eintritt"
#: models.py:56
#: .\models.py:62
msgid "End of membership"
msgstr "Austritt"
#: models.py:63
#: .\models.py:69
msgid "User"
msgstr "Benutzer"
#: models.py:77
#: .\models.py:76
msgid "Members"
msgstr "Mitglieder"
#: templates/admin/members_changelist.html:7
#: .\models.py:88
msgid "Bank transfer"
msgstr "Überweisung"
#: .\models.py:89
msgid "Direct debit"
msgstr "Lastschrift"
#: .\models.py:92
msgid "once"
msgstr "einmalig"
#: .\models.py:93
msgid "annual"
msgstr "jährlich"
#: .\models.py:97
msgid "Full name"
msgstr "Vor- und Zuname"
#: .\models.py:102
msgid "Extended address"
msgstr "Adresszusatz"
#: .\models.py:106
msgid "Street and number"
msgstr "Straße und Hausnummer"
#: .\models.py:110
msgid "Town"
msgstr "Ort"
#: .\models.py:114
msgid "Postcode"
msgstr "PLZ"
#: .\models.py:119
msgid "Country"
msgstr "Land"
#: .\models.py:127
msgid "Payment method"
msgstr "Zahlungsmethode"
#: .\models.py:134
msgid "Additional payment amount (Euros)"
msgstr "Zusatzbeitrag (Euro)"
#: .\models.py:139
msgid "Additional payment frequency"
msgstr "Häufigkeit des Zusatzbeitrag"
#: .\models.py:144 .\templates\members\applicationformembership_preview.html:60
msgid "IBAN"
msgstr "IBAN"
#: .\models.py:148
msgid "Application for membership"
msgstr "Antrag auf Mitgliedschaft"
#: .\models.py:149
msgid "Applications for membership"
msgstr "Anträge auf Mitgliedschaft"
#: .\models.py:155
msgid "IBAN is required for direct debit payment method."
msgstr "Bei Zahlung per Lastschrift muss eine IBAN angegeben werden."
#: .\models.py:161
msgid "IBAN is not allowed for bank transfer payment method."
msgstr "IBAN nur bei Zahlung per Lastschrift angeben."
#: .\templates\admin\members_changelist.html:7
msgid "Import Members"
msgstr "Mitglieder importieren"
#: templates/admin/members_import.html:10
#: .\templates\admin\members_import.html:10
msgid "Home"
msgstr "Start"
#: templates/admin/members_import.html:13
#: templates/admin/members_import.html:32
#: .\templates\admin\members_import.html:13
#: .\templates\admin\members_import.html:32
msgid "Import"
msgstr "Importieren"
#: .\templates\members\_direct_debit_mandate.html:3
msgid ""
"Identifier of the Creditor: DE07FVK00000453318. The mandate reference will "
"be communicated separately."
msgstr ""
"Gläubiger-Identifikationsnummer: DE07FVK00000453318. Die Mandatsreferenz "
"wird separat mitgeteilt."
#: .\templates\members\_direct_debit_mandate.html:6
msgid ""
"By signing this mandate form, you authorise (A) Förderverein der "
"Studierendenschaft des KIT e.V. to send instructions to your bank to debit "
"your account and (B) your bank to debit your account in accordance with the "
"instructions from the Creditor. As part of your rights, you are entitled to "
"a refund from your bank under the terms and conditions of your agreement "
"with your bank. A refund must be claimed within 8 weeks starting from the "
"date on which your account was debited. Your rights are explained in a "
"statement that you can obtain from your bank."
msgstr ""
"Ich ermächtige den Förderverein der Studierendenschaft des KIT e. V., "
"Zahlungen von meinem Konto mittels Lastschrift einzuziehen. Zugleich weise "
"ich mein Kreditinstitut an, die vom Förderverein der Studierendenschaft des "
"KIT e.V. auf mein Konto gezogenen Lastschriften einzulösen. Hinweis: Ich "
"kann innerhalb von acht Wochen, beginnend mit dem Belastungsdatum, die "
"Erstattung des belasteten Betrages verlangen. Es gelten dabei die mit meinem "
"Kreditinstitut vereinbarten Bedingungen."
#: .\templates\members\_i_hereby_declare.html:3
msgid ""
"I hereby declare my intent to join the Förderverein der Studierendenschaft "
"des Karlsruher Instituts für Technologie e.V."
msgstr ""
"Hiermit erkläre ich meinen Willen, dem Förderverein der Studierendenschaft "
"des Karlsruher Instituts für Technologie e.V. beizutreten."
#: .\templates\members\applicationformembership_form.html:6
#: .\templates\members\applicationformembership_form.html:9
#: .\templates\members\applicationformembership_preview.html:5
#: .\templates\members\applicationformembership_preview.html:8
msgid "Application for Membership"
msgstr "Antrag auf Mitgliedschaft"
#: .\templates\members\applicationformembership_preview.html:19
msgid "Address"
msgstr "Anschrift"
#: .\templates\members\applicationformembership_preview.html:35
msgid ""
"I wish to pay the membership dues of currently €15.00/year for individuals "
"or €50.00/year for legal entities via bank transfer."
msgstr ""
"Den Mitgliedsbeitrag in Höhe von derzeit 15,00 €/Jahr für natürliche "
"Personen bzw. 50,00 €/Jahr für juristische Personen möchte ich per "
"Überweisung begleichen."
#: .\templates\members\applicationformembership_preview.html:37
msgid ""
"I wish to pay the membership dues of currently €15.00/year for individuals "
"or €50.00/year for legal entities via direct debit."
msgstr ""
"Den Mitgliedsbeitrag in Höhe von derzeit 15,00 €/Jahr für natürliche "
"Personen bzw. 50,00 €/Jahr für juristische Personen möchte ich per "
"Lastschrift begleichen."
#: .\templates\members\applicationformembership_preview.html:44
#, python-format
msgid "In addition to the membership dues, I want to pay €%(amount)s once."
msgstr ""
"Zusätzlich zum Mitgliedsbeitrag möchte ich %(amount)s € einmalig entrichten."
#: .\templates\members\applicationformembership_preview.html:46
#, python-format
msgid "In addition to the membership dues, I want to pay €%(amount)s annually."
msgstr ""
"Zusätzlich zum Mitgliedsbeitrag möchte ich %(amount)s € jährlich entrichten."
#: .\templates\members\applicationformembership_preview.html:55
msgid "Name of the debtor"
msgstr "Kontoinhaber"
#: .\templates\members\applicationformembership_preview.html:75
msgid "Submit Application"
msgstr "Antrag absenden"
#: .\templates\members\thanks.html:5 .\templates\members\thanks.html:8
msgid "Thank you for your application for membership!"
msgstr "Vielen Dank für deinen Antrag auf Mitgliedschaft!"
#: .\templates\members\thanks.html:9
msgid ""
"We will process your application for membership in the coming weeks and "
"contact you afterwards."
msgstr ""
"Wir werden deinen Antrag auf Mitgliedschaft in den nächsten Wochen "
"bearbeiten und uns dann mit dir in Verbindung setzen."

@ -0,0 +1,44 @@
# Generated by Django 4.2 on 2023-04-26 18:14
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('members', '0004_alter_member_table'),
]
operations = [
migrations.CreateModel(
name='ApplicationForMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
('name', models.CharField(max_length=200, verbose_name='Full name')),
('extended_address', models.CharField(blank=True, max_length=200, verbose_name='Extended address')),
('street_address', models.CharField(max_length=200, verbose_name='Street and number')),
('locality', models.CharField(max_length=200, verbose_name='Town')),
('postal_code', models.CharField(max_length=10, verbose_name='Postcode')),
('country', models.CharField(default='Deutschland', max_length=200, verbose_name='Country')),
('email', models.EmailField(max_length=254, verbose_name='Email address')),
('payment_method', models.IntegerField(choices=[(0, 'Bank transfer'), (1, 'Direct debit')], default=0, verbose_name='Payment method')),
('additional_payment_amount', models.DecimalField(decimal_places=2, default=0, max_digits=9, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Additional payment amount (Euros)')),
('additional_payment_frequency', models.IntegerField(choices=[(0, 'once'), (1, 'annual')], default=0, verbose_name='Additional payment frequency')),
('iban', models.CharField(blank=True, max_length=34, verbose_name='IBAN')),
],
options={
'verbose_name': 'Application for membership',
'verbose_name_plural': 'Applications for membership',
},
),
migrations.AddConstraint(
model_name='applicationformembership',
constraint=models.CheckConstraint(check=models.Q(('payment_method', 0), models.Q(('iban', ''), _negated=True), _connector='OR'), name='iban_required', violation_error_message='IBAN is required for direct debit payment method.'),
),
migrations.AddConstraint(
model_name='applicationformembership',
constraint=models.CheckConstraint(check=models.Q(('payment_method', 1), ('iban', ''), _connector='OR'), name='iban_not_allowed', violation_error_message='IBAN is not allowed for bank transfer payment method.'),
),
]

@ -0,0 +1,43 @@
# Generated by Django 4.2 on 2023-04-26 18:31
from django.contrib.auth.management import create_permissions
from django.db import migrations
PERMISSIONS = {
'vorstand': [
'view_applicationformembership',
],
}
def assign_permissions(apps, schema_editor):
"""Assign the above specified permissions to groups."""
# See 0023_assign_permissions.py.
app_config = apps.get_app_config('members')
from members import models
app_config.models_module = models
create_permissions(app_config, verbosity=1, apps=apps)
Group = apps.get_model('auth', 'Group')
Permission = apps.get_model('auth', 'Permission')
for group_name, permissions in PERMISSIONS.items():
group = Group.objects.get(name=group_name)
for permission_name in permissions:
permission = Permission.objects.get(
content_type__app_label='members',
codename=permission_name
)
group.permissions.add(permission)
group.save()
class Migration(migrations.Migration):
dependencies = [
('members', '0005_applicationformembership_and_more'),
]
operations = [
migrations.RunPython(assign_permissions, migrations.RunPython.noop)
]

@ -1,10 +1,16 @@
from datetime import date
from django.conf import settings
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Q
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from core.models import TimeStampedModel
from core.utils import send_mail, send_matrix_message
from .conf import settings
class MemberManager(models.Manager):
def get_queryset(self):
@ -63,15 +69,124 @@ class Member(models.Model):
verbose_name=_('User'),
)
members = MemberManager()
class Meta:
verbose_name = _('Member')
verbose_name_plural = _('Members')
def __str__(self):
return self.name
@property
def membership_number(self):
return f'{self.id + 10000}' if self.id else None
members = MemberManager()
class ApplicationForMembership(TimeStampedModel):
class PaymentMethod(models.IntegerChoices):
BANK_TRANSFER = 0, _('Bank transfer')
DIRECT_DEBIT = 1, _('Direct debit')
class AdditionalPaymentFrequency(models.IntegerChoices):
ONCE = 0, _('once')
ANNUAL = 1, _('annual')
name = models.CharField(
max_length=200,
verbose_name=_('Full name')
)
extended_address = models.CharField(
blank=True,
max_length=200,
verbose_name=_('Extended address')
)
street_address = models.CharField(
max_length=200,
verbose_name=_('Street and number')
)
locality = models.CharField(
max_length=200,
verbose_name=_('Town')
)
postal_code = models.CharField(
max_length=10,
verbose_name=_('Postcode')
)
country = models.CharField(
default='Deutschland',
max_length=200,
verbose_name=_('Country')
)
email = models.EmailField(
verbose_name=_('Email address')
)
payment_method = models.IntegerField(
choices=PaymentMethod.choices,
default=PaymentMethod.BANK_TRANSFER,
verbose_name=_('Payment method')
)
additional_payment_amount = models.DecimalField(
default=0,
max_digits=9,
decimal_places=2,
validators=[MinValueValidator(0)],
verbose_name=_('Additional payment amount (Euros)')
)
additional_payment_frequency = models.IntegerField(
choices=AdditionalPaymentFrequency.choices,
default=AdditionalPaymentFrequency.ONCE,
verbose_name=_('Additional payment frequency')
)
iban = models.CharField(
blank=True,
max_length=34,
verbose_name=_('IBAN')
)
class Meta:
verbose_name = _('Application for membership')
verbose_name_plural = _('Applications for membership')
constraints = [
models.CheckConstraint(
check=Q(payment_method=0) | ~Q(iban=''),
name='iban_required',
violation_error_message=_('IBAN is required for direct debit '
'payment method.')
),
models.CheckConstraint(
check=Q(payment_method=1) | Q(iban=''),
name='iban_not_allowed',
violation_error_message=_('IBAN is not allowed for bank '
'transfer payment method.')
),
]
def __str__(self):
return self.name
class Meta:
verbose_name = _('Member')
verbose_name_plural = _('Members')
def get_absolute_url(self):
return reverse(
'admin:members_applicationformembership_change',
args=[self.id]
)
def send_notification(self, message):
self._send_email_notification(message)
self._send_matrix_notification(message)
def _send_email_notification(self, message):
"""Send an email notification to the configured address."""
to = settings.MEMBERS_NOTIFICATION_TO_EMAIL
if not to:
return
subject = self._meta.verbose_name
send_mail(subject, message, to, fail_silently=True)
def _send_matrix_notification(self, message):
"""Send notifications to the configured Matrix rooms."""
to = settings.MEMBERS_NOTIFICATION_TO_MATRIX
if not to:
return
send_matrix_message(message, to)

@ -0,0 +1,7 @@
{% load i18n %}
<p>
{% translate 'Identifier of the Creditor: DE07FVK00000453318. The mandate reference will be communicated separately.' %}
</p>
<p>
{% translate 'By signing this mandate form, you authorise (A) Förderverein der Studierendenschaft des KIT e.V. to send instructions to your bank to debit your account and (B) your bank to debit your account in accordance with the instructions from the Creditor. As part of your rights, you are entitled to a refund from your bank under the terms and conditions of your agreement with your bank. A refund must be claimed within 8 weeks starting from the date on which your account was debited. Your rights are explained in a statement that you can obtain from your bank.' %}
</p>

@ -0,0 +1,4 @@
{% load i18n %}
<p>
{% translate 'I hereby declare my intent to join the Förderverein der Studierendenschaft des Karlsruher Instituts für Technologie e.V.' %}
</p>

@ -0,0 +1,13 @@
{% extends 'members/base.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Application for Membership' %}{% endblock title %}
{% block content %}
<h1>{% translate 'Application for Membership' %}</h1>
{% crispy form %}
{% endblock content %}

@ -0,0 +1,5 @@
{% autoescape off %}Neuer Antrag auf Mitgliedschaft:
{{ object }}
Siehe {{ url }}.{% endautoescape %}

@ -0,0 +1,78 @@
{% extends 'members/base.html' %}
{% load i18n %}
{% block title %}{% translate 'Application for Membership' %}{% endblock title %}
{% block content %}
<h1>{% translate 'Application for Membership' %}</h1>
<form action="" method="post">{% csrf_token %}
{% include 'members/_i_hereby_declare.html' %}
<div class="row">
<div class="col-sm-2">{% translate 'Name' %}:</div>
<div class="col-sm-10">{{ name }}</div>
</div>
<div class="row">
<div class="col-sm-2">{% translate 'Address' %}:</div>
<div class="col-sm-10">
{% if extended_address %}{{ extended_address }}<br>{% endif %}
{{ street_address }}<br>
{{ postal_code }} {{ locality }}<br>
{{ country }}
</div>
</div>
<div class="row mb-4">
<div class="col-sm-2">{% translate 'Email address' %}:</div>
<div class="col-sm-10">{{ email }}</div>
</div>
<p class="text-justify">
{% if payment_method == payment_method_choices.BANK_TRANSFER %}
{% translate 'I wish to pay the membership dues of currently €15.00/year for individuals or €50.00/year for legal entities via bank transfer.' %}
{% elif payment_method == payment_method_choices.DIRECT_DEBIT %}
{% translate 'I wish to pay the membership dues of currently €15.00/year for individuals or €50.00/year for legal entities via direct debit.' %}
{% endif %}
</p>
{% if additional_payment_amount != 0 %}<p>
{% with amount=additional_payment_amount|floatformat:2 %}
{% if additional_payment_frequency == additional_payment_frequency_choices.ONCE %}
{% blocktranslate %}In addition to the membership dues, I want to pay €{{ amount }} once.{% endblocktranslate %}
{% elif additional_payment_frequency == additional_payment_frequency_choices.ANNUAL %}
{% blocktranslate %}In addition to the membership dues, I want to pay €{{ amount }} annually.{% endblocktranslate %}
{% endif %}
{% endwith %}
</p>{% endif %}
{% if iban %}
<p class="h4">{% translate 'SEPA Direct Debit Mandate' %}</p>
{% include 'members/_direct_debit_mandate.html' %}
<div class="row">
<div class="col-sm-2">{% translate 'Name of the debtor' %}:</div>
<div class="col-sm-10">{{ name }}</div>
</div>
<div class="row">
<div class="col-sm-2">{% translate 'IBAN' %}:</div>
<div class="col-sm-10">{{ iban.formatted }}</div>
</div>
<div class="row mb-4">
<div class="col-sm-2">{% translate 'Bank' %}:</div>
<div class="col-sm-10">{{ iban.bank_short_name }} ({{ iban.bic }})</div>
</div>
{% endif %}
{% for field in form %}
{{ field.as_hidden }}
{% endfor %}
<input type="hidden" name="{{ stage_field }}" value="2">
<input type="hidden" name="{{ hash_field }}" value="{{ hash_value }}">
<div class="clearfix">
<input class="btn btn-primary btn btn-primary float-right ml-2" type="submit" value="{% translate 'Submit Application' %}" />
</div>
</form>
{% endblock content %}

@ -0,0 +1 @@
{% extends 'base.html' %}

@ -0,0 +1,10 @@
{% extends 'members/base.html' %}
{% load i18n %}
{% block title %}{% translate 'Thank you for your application for membership!' %}{% endblock title %}
{% block content %}
<h1>{% translate 'Thank you for your application for membership!' %}</h1>
<p>{% translate 'We will process your application for membership in the coming weeks and contact you afterwards.' %}</p>
{% endblock content %}

@ -0,0 +1,65 @@
import pytest
from django.utils.translation import activate
from ..forms import ApplicationForMembershipForm
from ..models import ApplicationForMembership
@pytest.fixture(autouse=True)
def set_default_language():
activate('en')
class TestApplicationForMembershipForm:
@pytest.mark.django_db
def test_iban(self):
form = ApplicationForMembershipForm(data={
'payment_method': ApplicationForMembership.PaymentMethod
.DIRECT_DEBIT,
'iban': 'DE63 3702 0500 0005 0233 07'
})
form.is_valid()
assert form.cleaned_data['iban'] == 'DE63370205000005023307'
def test_iban_invalid(self):
form = ApplicationForMembershipForm(data={
'payment_method': ApplicationForMembership.PaymentMethod
.DIRECT_DEBIT,
'iban': 'foo'
})
assert not form.is_valid()
assert form.errors['iban'][0] == 'Invalid IBAN.'
@pytest.mark.django_db
def test_iban_empty(self):
form = ApplicationForMembershipForm(data={
'payment_method': ApplicationForMembership.PaymentMethod
.BANK_TRANSFER,
'iban': ''
})
form.is_valid()
assert form.cleaned_data['iban'] == ''
def test_payment_method_direct_debit_and_no_iban(self):
form = ApplicationForMembershipForm(data={
'payment_method': ApplicationForMembership.PaymentMethod
.DIRECT_DEBIT,
'iban': ''
})
assert not form.is_valid()
assert 'Enter an IBAN for direct debit payment method.' \
in form.errors['payment_method']
assert 'Enter an IBAN for direct debit payment method.' \
in form.errors['iban']
def test_payment_method_bank_transfer_and_iban(self):
form = ApplicationForMembershipForm(data={
'payment_method': ApplicationForMembership.PaymentMethod
.BANK_TRANSFER,
'iban': 'DE63 3702 0500 0005 0233 07'
})
assert not form.is_valid()
assert 'Dont enter an IBAN for bank transfer payment method.' \
in form.errors['payment_method']
assert 'Dont enter an IBAN for bank transfer payment method.' \
in form.errors['iban']

@ -0,0 +1,71 @@
import pytest
from django.core.exceptions import ValidationError
from django.utils.translation import activate
from ..models import ApplicationForMembership, Member
@pytest.fixture(autouse=True)
def set_default_language():
activate('en')
class TestMembers:
def test_membership_number(self):
member = Member(id=42)
assert member.membership_number == '10042'
def test_membership_number_no_id(self):
member = Member()
assert member.membership_number is None
def test_str(self):
member = Member(name='Hans Wurst')
assert str(member) == 'Hans Wurst'