From 0a147f177cfb42e1419dbac7cfc90fe8f66c1556 Mon Sep 17 00:00:00 2001 From: Ludwig Neste Date: Sun, 22 Jan 2023 06:08:45 +0100 Subject: [PATCH 1/2] added draft lastschriftmandat --- member_database/admin_views.py | 115 ++--- member_database/forms.py | 166 +++++++- member_database/main.py | 396 ++++++++++++------ member_database/models/__init__.py | 3 +- member_database/models/sepa.py | 47 +++ .../templates/mail/confirm_sepa_mail.txt | 11 + .../templates/mail/request_sepa_mail.txt | 13 + member_database/templates/navbar.html | 1 + member_database/templates/request_sepa.html | 14 + member_database/templates/sepa.html | 8 + migrations/versions/25a80caf27de_add_sepa.py | 40 ++ 11 files changed, 616 insertions(+), 198 deletions(-) create mode 100644 member_database/models/sepa.py create mode 100644 member_database/templates/mail/confirm_sepa_mail.txt create mode 100644 member_database/templates/mail/request_sepa_mail.txt create mode 100644 member_database/templates/request_sepa.html create mode 100644 member_database/templates/sepa.html create mode 100644 migrations/versions/25a80caf27de_add_sepa.py diff --git a/member_database/admin_views.py b/member_database/admin_views.py index 7a1db74..864208f 100644 --- a/member_database/admin_views.py +++ b/member_database/admin_views.py @@ -7,7 +7,7 @@ from flask_admin.form import fields from wtforms.fields import PasswordField -from .models import db, Person, TUStatus +from .models import db, Person, TUStatus, SepaMandate from .events import Event, EventRegistration from .authentication import User, Role, AccessLevel, handle_needs_login, ACCESS_LEVELS @@ -19,11 +19,11 @@ def _value(self): elif self.data: return json.dumps(self.data, ensure_ascii=False, indent=2) else: - return '' + return "" class IndexView(AdminIndexView): - @expose('/') + @expose("/") def index(self): if not current_user.is_authenticated: return handle_needs_login() @@ -37,13 +37,12 @@ class AuthorizedView(ModelView): details_modal = True def __init_subclass__(cls): - '''Add a subclasses access level to the set of access levels''' + """Add a subclasses access level to the set of access levels""" ACCESS_LEVELS.add(cls.access_level) def is_accessible(self): - return ( - current_user.is_authenticated - and current_user.has_access(self.access_level) + return current_user.is_authenticated and current_user.has_access( + self.access_level ) def inaccessible_callback(self, name, **kwargs): @@ -56,47 +55,53 @@ def inaccessible_callback(self, name, **kwargs): class EventView(AuthorizedView): - access_level = 'event_admin' - column_list = ['name', 'notify_email', 'max_participants', 'force_tu_mail', - 'registration_open'] - column_filters = ['name', 'max_participants', 'force_tu_mail', - 'registration_open', 'notify_email'] - form_excluded_columns = ['registrations'] - column_editable_list = ['name', 'registration_open'] - column_descriptions = { - 'description': 'HTML is allowed in this field.' - } + access_level = "event_admin" + column_list = [ + "name", + "notify_email", + "max_participants", + "force_tu_mail", + "registration_open", + ] + column_filters = [ + "name", + "max_participants", + "force_tu_mail", + "registration_open", + "notify_email", + ] + form_excluded_columns = ["registrations"] + column_editable_list = ["name", "registration_open"] + column_descriptions = {"description": "HTML is allowed in this field."} form_widget_args = { - 'description': { - 'rows': 10, - 'style': 'width: 100%; font-family: monospace;', + "description": { + "rows": 10, + "style": "width: 100%; font-family: monospace;", + }, + "registration_schema": { + "rows": 16, + "style": "width: 100%; font-family: monospace;", }, - 'registration_schema': { - 'rows': 16, - 'style': 'width: 100%; font-family: monospace;', - } - } - form_overrides = { - 'registration_schema': PrettyJSONField } + form_overrides = {"registration_schema": PrettyJSONField} class RoleView(AuthorizedView): column_display_pk = True - column_list = ['id', 'access_levels', 'users'] - form_columns = ['id', 'access_levels', 'users'] - access_level = 'role_admin' + column_list = ["id", "access_levels", "users"] + form_columns = ["id", "access_levels", "users"] + access_level = "role_admin" class AccessLevelView(AuthorizedView): column_display_pk = True - column_list = ['id', 'roles'] - form_columns = ['id', 'roles'] - access_level = 'access_level_admin' + column_list = ["id", "roles"] + form_columns = ["id", "roles"] + access_level = "access_level_admin" class EventRegistrationView(AuthorizedView): - access_level = 'event_registration_admin' + access_level = "event_registration_admin" column_filters = [ Event.id, Event.name, @@ -105,31 +110,39 @@ class EventRegistrationView(AuthorizedView): ] form_columns = [ - 'id', 'event', 'person', 'status', 'data', 'timestamp', + "id", + "event", + "person", + "status", + "data", + "timestamp", ] - class PersonView(AuthorizedView): - access_level = 'person_admin' + access_level = "person_admin" column_list = [ - 'name', 'email', 'user', 'event_registrations', - 'membership_status', 'joining_date', + "name", + "email", + "user", + "event_registrations", + "membership_status", + "joining_date", ] - column_filters = ['name', 'email', Person.membership_status_id] + column_filters = ["name", "email", Person.membership_status_id] class TUStatusView(AuthorizedView): - access_level = 'person_admin' + access_level = "person_admin" class UserView(AuthorizedView): - access_level = 'user_admin' - column_list = ['username', 'person', 'roles'] - column_filters = ['username', Person.email] - form_excluded_columns = ['password_hash'] + access_level = "user_admin" + column_list = ["username", "person", "roles"] + column_filters = ["username", Person.email] + form_excluded_columns = ["password_hash"] form_extra_fields = { - 'new_password': PasswordField('New Password'), + "new_password": PasswordField("New Password"), } def on_model_change(self, form, user, is_created): @@ -137,11 +150,16 @@ def on_model_change(self, form, user, is_created): user.set_password(form.new_password.data) +class SepaView(AuthorizedView): + access_level = "user_admin" + column_list = ["id", "person", "creation_date"] + + def create_admin_views(): admin = Admin( index_view=IndexView(), - template_mode='bootstrap4', - base_template="admin_master.html" + template_mode="bootstrap4", + base_template="admin_master.html", ) admin.add_view(EventView(Event, db.session)) admin.add_view(EventRegistrationView(EventRegistration, db.session)) @@ -150,4 +168,5 @@ def create_admin_views(): admin.add_view(RoleView(Role, db.session)) admin.add_view(AccessLevelView(AccessLevel, db.session)) admin.add_view(TUStatusView(TUStatus, db.session)) + admin.add_view(SepaView(SepaMandate, db.session)) return admin diff --git a/member_database/forms.py b/member_database/forms.py index a595949..b693408 100644 --- a/member_database/forms.py +++ b/member_database/forms.py @@ -2,17 +2,31 @@ from flask_wtf import FlaskForm from flask_babel import lazy_gettext as _l -from wtforms import StringField, SubmitField, ValidationError, RadioField -from wtforms.fields import EmailField, DateField -from wtforms.validators import DataRequired, Email, Optional +from wtforms import ( + StringField, + SubmitField, + ValidationError, + RadioField, + BooleanField, + IntegerField, +) +from wtforms.fields import EmailField, DateField, TextAreaField +from wtforms.validators import ( + DataRequired, + Email, + Optional, + Regexp, + InputRequired, + StopValidation, +) -from .models import Person, MembershipType +from .models import Person, MembershipType, SepaMandate def known_email(form, field): p = Person.query.filter_by(email=field.data).one_or_none() if p is None: - raise ValidationError('Unbekannte Email-Adresse') + raise ValidationError("Unbekannte Email-Adresse") def not_in_future(form, field): @@ -21,35 +35,38 @@ def not_in_future(form, field): class PersonEditForm(FlaskForm): - name = StringField(_l('Name'), validators=[DataRequired()]) + name = StringField(_l("Name"), validators=[DataRequired()]) email = EmailField( - _l('E-Mail-Adresse'), + _l("E-Mail-Adresse"), validators=[DataRequired(), Email()], - render_kw={'readonly': True}, + render_kw={"readonly": True}, ) - tu_status = RadioField(_l('Aktuelles Verhältnis zur TU Dortmund'), validators=[Optional()]) - date_of_birth = DateField(_l('Geburtstag'), validators=[Optional(), not_in_future]) + tu_status = RadioField( + _l("Aktuelles Verhältnis zur TU Dortmund"), validators=[Optional()] + ) + date_of_birth = DateField(_l("Geburtstag"), validators=[Optional(), not_in_future]) joining_date = DateField( - _l('Mitglied seit'), - render_kw={'readonly': True}, validators=[Optional()] + _l("Mitglied seit"), render_kw={"readonly": True}, validators=[Optional()] + ) + membership_status = StringField( + _l("Mitgliedschaftsstatus"), render_kw={"readonly": True} ) - membership_status = StringField(_l('Mitgliedschaftsstatus'), render_kw={'readonly': True}) membership_type = RadioField( - _l('Art der Mitgliedschaft'), + _l("Art der Mitgliedschaft"), choices=[ (MembershipType.ORDENTLICH, "Ordentliches Mitglied"), (MembershipType.AUSSERORDENTLICH, "Außerordentliches Mitglied"), ], validators=[Optional()], ) - submit = SubmitField(_l('Speichern')) + submit = SubmitField(_l("Speichern")) class MembershipForm(FlaskForm): - name = StringField(_l('Name'), validators=[DataRequired()]) - email = EmailField(_l('E-Mail-Adresse'), validators=[DataRequired(), Email()]) + name = StringField(_l("Name"), validators=[DataRequired()]) + email = EmailField(_l("E-Mail-Adresse"), validators=[DataRequired(), Email()]) membership_type = RadioField( - _l('Art der Mitgliedschaft'), + _l("Art der Mitgliedschaft"), choices=[ (MembershipType.ORDENTLICH, "Ordentliches Mitglied"), (MembershipType.AUSSERORDENTLICH, "Außerordentliches Mitglied"), @@ -58,11 +75,118 @@ class MembershipForm(FlaskForm): description=( "Alle Angehörigen oder ehemaligen Angehörigen der Fakultät Physik der TU Dortmund können und sollten ordentliche Mitglieder werden." " Für alle anderen besteht die Möglichkeit einer außerordentlichen Mitgliedschaft." - ) + ), ) - submit = SubmitField(_l('Mitgliedsantrag abschicken')) + submit = SubmitField(_l("Mitgliedsantrag abschicken")) class RequestLinkForm(FlaskForm): - email = EmailField(_l('E-Mail-Adresse'), validators=[DataRequired(), Email(), known_email]) + email = EmailField( + _l("E-Mail-Adresse"), validators=[DataRequired(), Email(), known_email] + ) submit = SubmitField() + + +def iban_validator(form, field): + if field.data == "": + return + try: + SepaMandate.validate_iban(None, None, field.data.replace(" ", "")) + except ValueError: + raise ValidationError("Invalid IBAN") + + +# https://stackoverflow.com/questions/8463209/how-to-make-a-field-conditionally-optional-in-wtforms +# Custom class so html elements dont get the required mark +class RequiredIfNot: + # a validator which makes a field required if + # another field is set and has a truthy value + + def __init__(self, other_field_name, message=None): + self.other_field_name = other_field_name + self.message = message + + def __call__(self, form, field): + other_field = form._fields.get(self.other_field_name) + if other_field is None: + raise Exception('no field named "%s" in form' % self.other_field_name) + if not bool(other_field.data): + if not field.raw_data or not field.raw_data[0]: + if self.message is None: + message = field.gettext("This field is required.") + else: + message = self.message + + field.errors[:] = [] + raise StopValidation(message) + + +class RequiredIf: + # a validator which makes a field required if + # another field is set and has a truthy value + + def __init__(self, other_field_name, message=None): + self.other_field_name = other_field_name + self.message = message + + def __call__(self, form, field): + other_field = form._fields.get(self.other_field_name) + if other_field is None: + raise Exception('no field named "%s" in form' % self.other_field_name) + if bool(other_field.data): + if not field.raw_data or not field.raw_data[0]: + if self.message is None: + message = field.gettext("This field is required.") + else: + message = self.message + + field.errors[:] = [] + raise StopValidation(message) + + +def beitrag_validator(form, field): + other_field = form._fields.get("given") + if bool(other_field.data): + RequiredIfNot( + "default", + message="Muss ausgefüllt werden, falls nicht der vorgeschlagenene Beitrag bezahlt werden soll.", + )(form, field) + + +class SepaForm(FlaskForm): + adress = TextAreaField(_l("Adresse"), validators=[RequiredIf("given")]) + iban = StringField(_l("IBAN"), validators=[RequiredIf("given"), iban_validator]) + bank = StringField(_l("Bankinstitut"), validators=[RequiredIf("given")]) + + default = BooleanField( + _l("Von der Beitragsordnung vorgeschlagenen Beitrag zahlen"), + validators=[], + description="Ich möchte den in im Jahr der Abbuchung für mich in der vom Vorstand beschlossenen Beitragsordnung vorgeschlagenen Mitgliedsbeitrag zahlen.", + ) + value = StringField( + _l("Förderbeitrag in Euro"), + validators=[ + Regexp( + "^(|\d+((,|\.)\d{2})?)$", + message="Muss der Form '4.20', '42,10' oder '50' entsprechend.", + ), + beitrag_validator, + ], + description="Einfach leer lassen, wenn der vorgeschlagene Beitrag bezahlt werden soll.", + ) + + # can also be un-given + given = BooleanField( + # _l("Mandat erteilen und folgende Bedingungen akzeptieren"), + ( + "Ich ermächtige PeP et al. eV. (SEPA Gläubiger-ID: XXX), Zahlungen von meinem Konto mittels Lastschrift einzuziehen. " + "Zugleich weise ich mein Kreditinstitut an, die von PeP et al. eV. auf mein Konto " + "gezogenen Lastschriften einzulösen. " + "Der Mitglieds oder Förderbeitrag wird dabei einmal im Jahr von PeP et al. eV. eingezogen" + "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." + ), + validators=[], + ) + submit = SubmitField(_l("Speichern")) diff --git a/member_database/main.py b/member_database/main.py index 5b0c17f..8920354 100644 --- a/member_database/main.py +++ b/member_database/main.py @@ -1,11 +1,19 @@ from datetime import date from flask import ( - jsonify, request, url_for, render_template, redirect, - flash, abort, Blueprint, current_app + jsonify, + request, + url_for, + render_template, + redirect, + flash, + abort, + Blueprint, + current_app, ) from flask_login import current_user, login_user, logout_user from flask_babel import _ from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadData +from datetime import datetime, timezone from sqlalchemy.exc import IntegrityError @@ -16,14 +24,15 @@ MembershipStatus, MembershipType, TUStatus, + SepaMandate, ) from .utils import get_or_create, ext_url_for from .authentication import access_required -from .forms import PersonEditForm, MembershipForm, RequestLinkForm +from .forms import PersonEditForm, MembershipForm, RequestLinkForm, SepaForm from .mail import send_email -main = Blueprint('main', __name__) +main = Blueprint("main", __name__) @main.before_app_first_request @@ -40,56 +49,64 @@ def init_database(): db.session.commit() -@main.route('/') +@main.route("/") def index(): - return render_template('index.html') + return render_template("index.html") -@main.route('/persons', methods=['GET']) -@access_required('get_persons') +@main.route("/persons", methods=["GET"]) +@access_required("get_persons") def get_persons(): persons = [as_dict(person) for person in Person.query.all()] - return jsonify(status='success', persons=persons) + return jsonify(status="success", persons=persons) -@main.route('/persons', methods=['POST']) +@main.route("/persons", methods=["POST"]) def add_person(): - ''' + """ Add a new person via a http post request using a json payload that has name and email. - ''' + """ data = request.get_json() try: - p = Person(name=data['name'], email=data['email']) + p = Person(name=data["name"], email=data["email"]) db.session.add(p) db.session.commit() except KeyError as e: - return jsonify( - status='error', - message='Missing required parameter {}'.format(e.args[0]) - ), 422 + return ( + jsonify( + status="error", + message="Missing required parameter {}".format(e.args[0]), + ), + 422, + ) except IntegrityError: - return jsonify( - status='error', - message='Person already exists', - ), 422 + return ( + jsonify( + status="error", + message="Person already exists", + ), + 422, + ) - return jsonify(status='success') + return jsonify(status="success") -@main.route('/members/', methods=['GET']) -@access_required('get_members') +@main.route("/members/", methods=["GET"]) +@access_required("get_members") def get_members(): - '''Return a json list with all current members''' - members = Person.query.filter_by(membership_status_id=MembershipStatus.CONFIRMED).all() + """Return a json list with all current members""" + members = Person.query.filter_by( + membership_status_id=MembershipStatus.CONFIRMED + ).all() members = [as_dict(member) for member in members] - return jsonify(status='success', members=members) + return jsonify(status="success", members=members) -@main.route('/register/', methods=['GET', 'POST']) +@main.route("/register/", methods=["GET", "POST"]) def register(): - ''' + """ Endpoint for membership registration. Simple form to sign up to the club, only requiring name and email. @@ -97,7 +114,7 @@ def register(): After a request has been made, the applicant has to confirm their email. After the email was confirmed, the board gets notified that a new application was made and can accept/deny it. - ''' + """ form = MembershipForm() @@ -105,36 +122,36 @@ def register(): p, _new = get_or_create( Person, email=form.email.data, - defaults={'name': form.name.data}, + defaults={"name": form.name.data}, ) if p.membership_status_id == MembershipStatus.DENIED: flash( - 'Du hast bereits einen Mitgliedsantrag eingereicht, der abgelehnt wurde.' - ' Bitte kontaktiere uns, falls du dies für einen Irrtum hälst.' + "Du hast bereits einen Mitgliedsantrag eingereicht, der abgelehnt wurde." + " Bitte kontaktiere uns, falls du dies für einen Irrtum hälst." ) - return redirect(url_for('main.index')) + return redirect(url_for("main.index")) if p.membership_status_id == MembershipStatus.EMAIL_UNVERIFIED: flash( - 'Du hast bereits einen Mitgliedsantrag eingereicht,' - ' aber deine Email noch nicht bestätigt.' - f' Wir haben die Bestätigungsemail erneut an {p.email} versendet.', - category='warning', + "Du hast bereits einen Mitgliedsantrag eingereicht," + " aber deine Email noch nicht bestätigt." + f" Wir haben die Bestätigungsemail erneut an {p.email} versendet.", + category="warning", ) if p.membership_status_id == MembershipStatus.PENDING: flash( - 'Du hast bereits einen Mitgliedsantrag eingereicht,' - ' aber dieser ist noch nicht vom Vorstand bestätigt worden.' - ' Dies kann ein paar Tage dauern.', - category='info', + "Du hast bereits einen Mitgliedsantrag eingereicht," + " aber dieser ist noch nicht vom Vorstand bestätigt worden." + " Dies kann ein paar Tage dauern.", + category="info", ) - return redirect(url_for('main.index')) + return redirect(url_for("main.index")) if p.membership_status_id == MembershipStatus.CONFIRMED: - flash('Du bist bereits Mitglied', category='danger') - return redirect(url_for('main.index')) + flash("Du bist bereits Mitglied", category="danger") + return redirect(url_for("main.index")) p.name = form.name.data p.membership_status_id = MembershipStatus.EMAIL_UNVERIFIED @@ -143,104 +160,230 @@ def register(): db.session.commit() ts = URLSafeTimedSerializer(current_app.config["SECRET_KEY"]) - token = ts.dumps(p.email, salt='edit-key') + token = ts.dumps(p.email, salt="edit-key") send_email( - subject=_('PeP et al. Mitgliedsantrag: Email bestätigen'), - sender=current_app.config['MAIL_SENDER'], + subject=_("PeP et al. Mitgliedsantrag: Email bestätigen"), + sender=current_app.config["MAIL_SENDER"], recipients=[p.email], body=render_template( - 'mail/verify.txt', + "mail/verify.txt", new_member=p, - url=ext_url_for('main.edit', token=token), - ) + url=ext_url_for("main.edit", token=token), + ), ) - max_age = current_app.config['TOKEN_MAX_AGE'] // 60 + max_age = current_app.config["TOKEN_MAX_AGE"] // 60 flash( - 'Um den Vorgang abzuschließen, klicke auf den Link in der' - ' Bestätigungsemail. Vorher können wir deinen Antrag' - f' nicht bearbeiten. Der Link ist {max_age} Minuten gültig.', - category='warning', + "Um den Vorgang abzuschließen, klicke auf den Link in der" + " Bestätigungsemail. Vorher können wir deinen Antrag" + f" nicht bearbeiten. Der Link ist {max_age} Minuten gültig.", + category="warning", ) - return redirect(url_for('main.index')) + return redirect(url_for("main.index")) + + return render_template("member_registration.html", form=form) + + +def get_confirmed_member_or_none(email): + """Checks wether the mail belongs to a member.""" + p = Person.query.filter_by(email=email).first() + if p is None: + return p + if p.membership_status_id == MembershipStatus.CONFIRMED: + return p + return None + + +@main.route("/request_sepa", methods=["POST", "GET"]) +def request_sepa(): + """ + Request a link to add a sepa mandate + """ + form = RequestLinkForm() + + if form.validate_on_submit(): + person = get_confirmed_member_or_none(form.email.data) + if person is not None: + ts = URLSafeTimedSerializer(current_app.config["SECRET_KEY"]) + token = ts.dumps(person.id, salt="sepa-key") + + send_email( + subject=_("PeP et al. e.V. SEPA Lastschriftmandat"), + sender=current_app.config["MAIL_SENDER"], + recipients=[form.email.data], + body=render_template( + "mail/request_sepa_mail.txt", + link=ext_url_for("main.sepa", token=token), + name=person.name, + ), + ) + flash( + "E-Mail mit Link für das Erstellen eines SEPA-Mandates verschickt.", + "success", + ) + else: + flash( + "E-Mail gehört nicht zu einem Mitglied mit bestätigter E-Mail Adresse.", + "danger", + ) + return redirect(url_for("main.index")) return render_template( - 'member_registration.html', form=form + "request_sepa.html", + form=form, + title="SEPA-Lastschriftmandat erstellen oder ändern", ) -@main.route('/request_edit', methods=['POST', 'GET']) +@main.route("/sepa/", methods=["GET", "POST"]) +def sepa(token): + try: + ts = URLSafeTimedSerializer(current_app.config["SECRET_KEY"]) + person_id = ts.loads( + token, + salt="sepa-key", + max_age=current_app.config["TOKEN_MAX_AGE"], + ) + except SignatureExpired: + flash(_("Ihre Sitzung ist abgelaufen"), category="danger") + return redirect(url_for("main.index")) + except BadData: + abort(404) + + p: Person = Person.query.get(person_id) + if p is None: + abort(404) + + if p.membership_status_id != MembershipStatus.CONFIRMED: + flash("Du musst ein bestätigtes Mitglied in unserer Datenbank sein.", "warn") + return redirect(url_for("main.index")) + + m = SepaMandate.query.filter_by(person_id=p.id).first() + + old_given = False + + if m is not None: + form = SepaForm( + adress=m.adress, + iban=" ".join(m.iban[i : i + 4] for i in range(0, len(m.iban), 4)), + bank=m.bank, + default=m.default, + value=m.value, + given=m.given, + ) + old_given = m.given + else: + form = SepaForm() + + if form.validate_on_submit(): + m, _new = get_or_create( + SepaMandate, + person_id=p.id, + ) + m.adress = form.adress.data + m.iban = form.iban.data.replace(" ", "") + m.bank = form.bank.data + m.default = form.default.data + m.value = form.value.data + m.given = form.given.data + + if _new: + m.creation_date = datetime.now() + + db.session.commit() + + if old_given == False and m.given == True: + send_email( + subject=_("PeP et al. e.V. SEPA Lastschriftmandat erteilt"), + sender=current_app.config["MAIL_SENDER"], + recipients=[p.email], + body=render_template( + "mail/confirm_sepa_mail.txt", + link=ext_url_for("main.sepa", token=token), + name=p.name, + reference=m.id, + ), + ) + + flash(_("Ihre Daten wurden erfolgreich aktualisiert."), "success") + return redirect(url_for("main.sepa", token=token)) + + return render_template("sepa.html", form=form) + + +@main.route("/request_edit", methods=["POST", "GET"]) def request_edit(): - ''' + """ Request a link to edit personal data - ''' + """ form = RequestLinkForm() if form.validate_on_submit(): ts = URLSafeTimedSerializer(current_app.config["SECRET_KEY"]) - token = ts.dumps(form.email.data, salt='edit-key') + token = ts.dumps(form.email.data, salt="edit-key") send_email( - subject=_('PeP et al. e.V. Mitgliedsdatenänderung'), - sender=current_app.config['MAIL_SENDER'], + subject=_("PeP et al. e.V. Mitgliedsdatenänderung"), + sender=current_app.config["MAIL_SENDER"], recipients=[form.email.data], body=render_template( - 'mail/edit_mail.txt', - edit_link=ext_url_for('main.edit', token=token), - ) + "mail/edit_mail.txt", + edit_link=ext_url_for("main.edit", token=token), + ), ) - flash('E-Mail mit Link für die Datenänderung verschickt', 'success') - return redirect(url_for('main.index')) + flash("E-Mail mit Link für die Datenänderung verschickt", "success") + return redirect(url_for("main.index")) return render_template( - 'request_edit.html', + "request_edit.html", form=form, - title='Persönliche Daten ändern', + title="Persönliche Daten ändern", ) -@main.route('/request_gdpr_data', methods=['POST', 'GET']) +@main.route("/request_gdpr_data", methods=["POST", "GET"]) def request_gdpr_data(): - ''' + """ Request a link to view personal data - ''' + """ form = RequestLinkForm() if form.validate_on_submit(): ts = URLSafeTimedSerializer(current_app.config["SECRET_KEY"]) - token = ts.dumps(form.email.data, salt='request_gdpr_data-key') + token = ts.dumps(form.email.data, salt="request_gdpr_data-key") send_email( - subject='PeP et al. e.V. - Einsicht in gespeicherte Daten', - sender=current_app.config['MAIL_SENDER'], + subject="PeP et al. e.V. - Einsicht in gespeicherte Daten", + sender=current_app.config["MAIL_SENDER"], recipients=[form.email.data], body=render_template( - 'mail/request_data_mail.txt', - data_link=ext_url_for('main.view_data', token=token), - ) + "mail/request_data_mail.txt", + data_link=ext_url_for("main.view_data", token=token), + ), ) - flash('E-Mail mit Link für die Dateneinsicht verschickt', 'success') - return redirect(url_for('main.index')) + flash("E-Mail mit Link für die Dateneinsicht verschickt", "success") + return redirect(url_for("main.index")) return render_template( - 'gdpr_form.html', + "gdpr_form.html", form=form, - title='DSGVO-Anfrage', + title="DSGVO-Anfrage", ) -@main.route('/edit/', methods=['GET', 'POST']) + +@main.route("/edit/", methods=["GET", "POST"]) def edit(token): try: ts = URLSafeTimedSerializer(current_app.config["SECRET_KEY"]) email = ts.loads( token, - salt='edit-key', - max_age=current_app.config['TOKEN_MAX_AGE'], + salt="edit-key", + max_age=current_app.config["TOKEN_MAX_AGE"], ) except SignatureExpired: - flash(_('Ihre Sitzung ist abgelaufen'), category='danger') - return redirect(url_for('main.index')) + flash(_("Ihre Sitzung ist abgelaufen"), category="danger") + return redirect(url_for("main.index")) except BadData: abort(404) @@ -250,14 +393,14 @@ def edit(token): if p.membership_status_id == MembershipStatus.EMAIL_UNVERIFIED: send_email( - subject='Neuer Mitgliedsantrag', - sender=current_app.config['MAIL_SENDER'], - recipients=[current_app.config['APPROVE_MAIL']], + subject="Neuer Mitgliedsantrag", + sender=current_app.config["MAIL_SENDER"], + recipients=[current_app.config["APPROVE_MAIL"]], body=render_template( - 'mail/approve_member.txt', + "mail/approve_member.txt", new_member=p, - url=ext_url_for('main.applications'), - ) + url=ext_url_for("main.applications"), + ), ) p.membership_status_id = MembershipStatus.PENDING @@ -265,11 +408,11 @@ def edit(token): db.session.add(p) db.session.commit() - flash(_('Email erfolgreich bestätigt'), category="success") + flash(_("Email erfolgreich bestätigt"), category="success") if p.membership_status_id == MembershipStatus.PENDING: flash( - _('Dein Mitgliedsantrag wartet auf Bestätigung durch den Vorstand'), + _("Dein Mitgliedsantrag wartet auf Bestätigung durch den Vorstand"), category="info", ) @@ -283,8 +426,8 @@ def edit(token): tu_status=p.tu_status_id, ) form.tu_status.choices = [(state.id, state.name) for state in TUStatus.query.all()] - form.tu_status.choices.append(('', 'Keine Angabe')) - + form.tu_status.choices.append(("", "Keine Angabe")) + # don't show these fields for non-members if p.membership_status is None: del form.membership_type @@ -295,26 +438,26 @@ def edit(token): p.name = form.name.data p.date_of_birth = form.date_of_birth.data - if form.tu_status.data != '': + if form.tu_status.data != "": p.tu_status_id = form.tu_status.data if p.membership_status is not None: p.membership_type_id = form.membership_type.data db.session.commit() - flash(_('Ihre Daten wurden erfolgreich aktualisiert.'), 'success') - return redirect(url_for('main.edit', token=token)) + flash(_("Ihre Daten wurden erfolgreich aktualisiert."), "success") + return redirect(url_for("main.edit", token=token)) - return render_template('edit.html', form=form) + return render_template("edit.html", form=form) -@main.route('/view_data/') +@main.route("/view_data/") def view_data(token): try: ts = URLSafeTimedSerializer(current_app.config["SECRET_KEY"]) - email = ts.loads(token, salt='request_gdpr_data-key') + email = ts.loads(token, salt="request_gdpr_data-key") except SignatureExpired: - flash(_('Ihre Sitzung ist abgelaufen')) + flash(_("Ihre Sitzung ist abgelaufen")) except BadData: abort(404) @@ -324,38 +467,35 @@ def view_data(token): abort(404) personal_data = as_dict(p) - personal_data['event_registrations'] = [] + personal_data["event_registrations"] = [] for reg in p.event_registrations: - personal_data['event_registrations'].append({ - 'event': as_dict(reg.event), - 'registration': as_dict(reg) - }) + personal_data["event_registrations"].append( + {"event": as_dict(reg.event), "registration": as_dict(reg)} + ) - return jsonify(status='success', personal_data=personal_data) + return jsonify(status="success", personal_data=personal_data) -@main.route('/applications') -@access_required('member_management') +@main.route("/applications") +@access_required("member_management") def applications(): applications = Person.query.filter_by(membership_status_id="pending").all() - return render_template('applications.html', applications=applications) + return render_template("applications.html", applications=applications) -@main.route('/applications//', methods=['POST']) -@access_required('member_management') +@main.route("/applications//", methods=["POST"]) +@access_required("member_management") def handle_application(person_id): - application = ( - Person.query - .filter_by(id=person_id, membership_status_id="pending") - .one_or_none() - ) + application = Person.query.filter_by( + id=person_id, membership_status_id="pending" + ).one_or_none() if application is None: flash("Kein offener Mitgliedsantrag für diese Person", category="danger") abort(404) - decision = request.form.get('decision') + decision = request.form.get("decision") if decision == "accept": application.joining_date = date.today() db.session.add(application) @@ -363,16 +503,16 @@ def handle_application(person_id): flash(f"Mitgliedsantrag für {application.name} angenommen", category="success") send_email( - subject=_('Willkommen bei PeP et al. e.V.'), - sender=current_app.config['MAIL_SENDER'], + subject=_("Willkommen bei PeP et al. e.V."), + sender=current_app.config["MAIL_SENDER"], recipients=[application.email], body=render_template( - 'mail/welcome.txt', + "mail/welcome.txt", new_member=application, - ) + ), ) application.membership_status_id = MembershipStatus.CONFIRMED - elif decision == 'deny': + elif decision == "deny": flash(f"Mitgliedsantrag für {application.name} abgelehnt", category="danger") application.membership_status_id = MembershipStatus.DENIED else: diff --git a/member_database/models/__init__.py b/member_database/models/__init__.py index 206d9e4..029e043 100644 --- a/member_database/models/__init__.py +++ b/member_database/models/__init__.py @@ -1,7 +1,8 @@ from .base import db, as_dict from .person import Person, MembershipStatus, MembershipType, TUStatus +from .sepa import SepaMandate __all__ = [ - 'db', 'as_dict', + 'db', 'as_dict', 'SepaMandate', 'Person', 'MembershipStatus', 'TUStatus', 'MembershipType', ] diff --git a/member_database/models/sepa.py b/member_database/models/sepa.py new file mode 100644 index 0000000..376e627 --- /dev/null +++ b/member_database/models/sepa.py @@ -0,0 +1,47 @@ +from datetime import date +from sqlalchemy.orm import validates +from .base import db +import re + +# Let's only start with german IBANS +IBAN_RE = re.compile("^DE\d{20}$") + + +class SepaMandate(db.Model): + id = db.Column(db.Integer, primary_key=True) + + person_id = db.Column(db.Integer, db.ForeignKey("person.id")) + person = db.relationship("Person", backref="sepamandates", lazy="subquery") + + adress = db.Column(db.UnicodeText(), nullable=False) + iban = db.Column(db.String, nullable=False) + bank = db.Column(db.String, nullable=False) + # can also be un-given + given = db.Column(db.Boolean, nullable=False) + + # wether to use standard value + default = db.Column(db.Boolean, nullable=False) + # value = null means use right membership fee + value = db.Column(db.Integer, nullable=True) + + creation_date = db.Column(db.DateTime(timezone=True), nullable=False) + + @validates("iban") + def validate_iban(self, key, iban): + if IBAN_RE.match(iban) is None: + raise ValueError("Incorrect IBAN") + + iban_rearranged = iban[4:] + iban[0:4] + # replace letters + iban_replaced = "" + for c in iban_rearranged: + if c.isdigit(): + iban_replaced += c + else: + iban_replaced += str(10 + ord(c) - ord("A")) + if int(iban_replaced) % 97 != 1: + raise ValueError("Not a valid IBAN.") + return iban + + def __repr__(self): + return f"Sepa mandat for {'NULL' if self.person is None else self.person.name}" diff --git a/member_database/templates/mail/confirm_sepa_mail.txt b/member_database/templates/mail/confirm_sepa_mail.txt new file mode 100644 index 0000000..4199f5f --- /dev/null +++ b/member_database/templates/mail/confirm_sepa_mail.txt @@ -0,0 +1,11 @@ +Liebe*r {{ name }}, + +Sie haben PeP et al. e.V. ein SEPA-Lastschriftmandat erteilt. +Ihr SEPA-Lastschriftmandat hat die Mandatsreferenz {{ reference }}. +Unter folgendem Link können Sie das SEPA-Lastschriftmandat bearbeiten und widerrufen: +{{ link }} + +Viele Grüße +Die PeP Mitgliederverwaltung + +{% include "mail/signature.txt" %} diff --git a/member_database/templates/mail/request_sepa_mail.txt b/member_database/templates/mail/request_sepa_mail.txt new file mode 100644 index 0000000..fdfb518 --- /dev/null +++ b/member_database/templates/mail/request_sepa_mail.txt @@ -0,0 +1,13 @@ +Liebe*r {{ name }}, + +ein Link um PeP et al. ein SEPA-Lastschriftmandat zu erteilen oder ein bestehendes +zu ändern, wurde angefordert. +Falls Sie das nicht angefordert haben, können Sie diese E-Mail einfach ignorieren. + +Unter folgendem Link können Sie ein SEPA-Lastschriftmandat erteilen bzw. bearbeiten: +{{ link }} + +Viele Grüße +Die PeP Mitgliederverwaltung + +{% include "mail/signature.txt" %} diff --git a/member_database/templates/navbar.html b/member_database/templates/navbar.html index 07fe9b0..8b36cd9 100644 --- a/member_database/templates/navbar.html +++ b/member_database/templates/navbar.html @@ -25,6 +25,7 @@ diff --git a/member_database/templates/request_sepa.html b/member_database/templates/request_sepa.html new file mode 100644 index 0000000..a78fb10 --- /dev/null +++ b/member_database/templates/request_sepa.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block main %} +

{{ title }}

+

+ Falls Sie Mitglied unseres Vereines sind, können Sie uns jetzt ein SEPA-Lastschriftmandat erteilen + um Ihren Mitglieds- oder Förderbeitrag zu bezahlen. + Falls Sie uns bereits ein SEPA-Mandat erteilt haben, können Sie ein + bestehendes Mandat ändern oder widerrufen. + Geben Sie dazu bitte Ihre E-Mail-Adresse unten an.

+ +{% from 'bootstrap/form.html' import render_form %} +{{ render_form(form, method='POST', action=action) }} + +{% endblock %} diff --git a/member_database/templates/sepa.html b/member_database/templates/sepa.html new file mode 100644 index 0000000..ae5abfe --- /dev/null +++ b/member_database/templates/sepa.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block main %} +

{{ _('SEPA-Lastschriftmandat erteilen oder ändern') }}

+ +{% from 'bootstrap/form.html' import render_form %} +{{ render_form(form, method='POST') }} + +{% endblock %} diff --git a/migrations/versions/25a80caf27de_add_sepa.py b/migrations/versions/25a80caf27de_add_sepa.py new file mode 100644 index 0000000..f4ac0f4 --- /dev/null +++ b/migrations/versions/25a80caf27de_add_sepa.py @@ -0,0 +1,40 @@ +"""Add SEPA + +Revision ID: 25a80caf27de +Revises: 94cbf12be25a +Create Date: 2023-01-22 02:46:56.783644 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '25a80caf27de' +down_revision = '94cbf12be25a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('sepa_mandate', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('person_id', sa.Integer(), nullable=True), + sa.Column('creation_date', sa.Date(), nullable=False), + sa.Column('adress', sa.UnicodeText(), nullable=False), + sa.Column('iban', sa.String(), nullable=False), + sa.Column('bank', sa.String(), nullable=False), + sa.Column('given', sa.Boolean(), nullable=False), + sa.Column('default', sa.Boolean(), nullable=False), + sa.Column('value', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['person_id'], ['person.id'], name=op.f('fk_sepa_mandate_person_id_person')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_sepa_mandate')) + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('sepa_mandate') + # ### end Alembic commands ### From 3bd9d990dcb857b16ad6178c839dc87895be9bb9 Mon Sep 17 00:00:00 2001 From: Ludwig Neste Date: Tue, 6 Jun 2023 23:23:50 +0200 Subject: [PATCH 2/2] changed db format and added test for sepa validation --- member_database/models/sepa.py | 47 ++++++++++++++++++++-------------- tests/test_sepa.py | 9 +++++++ 2 files changed, 37 insertions(+), 19 deletions(-) create mode 100644 tests/test_sepa.py diff --git a/member_database/models/sepa.py b/member_database/models/sepa.py index 376e627..87e05dc 100644 --- a/member_database/models/sepa.py +++ b/member_database/models/sepa.py @@ -6,6 +6,23 @@ # Let's only start with german IBANS IBAN_RE = re.compile("^DE\d{20}$") +def is_valid_iban(iban: str): + """Returns True if the IBAN is valid, false otherwise.""" + if IBAN_RE.match(iban) is None: + return False + + iban_rearranged = iban[4:] + iban[0:4] + # replace letters + iban_replaced = "" + for c in iban_rearranged: + if c.isdigit(): + iban_replaced += c + else: + iban_replaced += str(10 + ord(c) - ord("A")) + if int(iban_replaced) % 97 != 1: + return False + return True + class SepaMandate(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -13,35 +30,27 @@ class SepaMandate(db.Model): person_id = db.Column(db.Integer, db.ForeignKey("person.id")) person = db.relationship("Person", backref="sepamandates", lazy="subquery") - adress = db.Column(db.UnicodeText(), nullable=False) - iban = db.Column(db.String, nullable=False) + adress_encrypted = db.Column(db.UnicodeText(), nullable=False) + adress_last_8_places = db.Column(db.UnicodeText(), nullable=False) + iban_encrypted = db.Column(db.String, nullable=False) + iban_last_4_places = db.Column(db.String, nullable=False) bank = db.Column(db.String, nullable=False) + # can also be un-given given = db.Column(db.Boolean, nullable=False) - # wether to use standard value + # weather to use standard value default = db.Column(db.Boolean, nullable=False) # value = null means use right membership fee value = db.Column(db.Integer, nullable=True) creation_date = db.Column(db.DateTime(timezone=True), nullable=False) - @validates("iban") - def validate_iban(self, key, iban): - if IBAN_RE.match(iban) is None: - raise ValueError("Incorrect IBAN") - - iban_rearranged = iban[4:] + iban[0:4] - # replace letters - iban_replaced = "" - for c in iban_rearranged: - if c.isdigit(): - iban_replaced += c - else: - iban_replaced += str(10 + ord(c) - ord("A")) - if int(iban_replaced) % 97 != 1: - raise ValueError("Not a valid IBAN.") - return iban + # @validates("iban") + # def validate_iban(self, key, iban): + # if not is_valid_iban(iban): + # raise ValueError("Incorrect IBAN") + # return iban def __repr__(self): return f"Sepa mandat for {'NULL' if self.person is None else self.person.name}" diff --git a/tests/test_sepa.py b/tests/test_sepa.py new file mode 100644 index 0000000..5777927 --- /dev/null +++ b/tests/test_sepa.py @@ -0,0 +1,9 @@ +from member_database.models.sepa import is_valid_iban + + +def test_iban_validator(): + # Random example from the internet + assert is_valid_iban("DE89370400440532013000") + # Changed numbers, so validation digits are wrong + assert not is_valid_iban("DE893704004240532013000") +