diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..18b16248 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "wtforms_widgets"] + path = wtforms_widgets + url = https://github.com/agdsn/wtforms-widgets.git + branch = pycroft diff --git a/build/Dockerfile b/build/Dockerfile index c9987f3e..b4a8cf9e 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -23,6 +23,7 @@ RUN apt-get update && apt-get install -y --force-yes --no-install-recommends \ WORKDIR /opt/sipa COPY --chown=sipa:sipa ./build /opt/sipa/build/ +COPY --chown=sipa:sipa ./wtforms_widgets /opt/sipa/wtforms_widgets ARG additional_requirements RUN ./build/install_requirements.py $additional_requirements diff --git a/build/requirements/requirements.txt b/build/requirements/requirements.txt index 43d52113..5c45f0d7 100644 --- a/build/requirements/requirements.txt +++ b/build/requirements/requirements.txt @@ -24,3 +24,4 @@ recurring_ical_events~=1.0.2b0 cachetools~=5.2.0 python-dotenv~=0.21.0 pydantic~=2.4.2 +-e wtforms_widgets diff --git a/sipa/blueprints/generic.py b/sipa/blueprints/generic.py index ee52faa2..94843aed 100644 --- a/sipa/blueprints/generic.py +++ b/sipa/blueprints/generic.py @@ -18,8 +18,13 @@ from sqlalchemy.exc import DatabaseError from sipa.backends.exceptions import BackendError -from sipa.forms import flash_formerrors, LoginForm, AnonymousContactForm, \ - OfficialContactForm, PasswordRequestResetForm, PasswordResetForm +from sipa.forms import ( + LoginForm, + AnonymousContactForm, + OfficialContactForm, + PasswordRequestResetForm, + PasswordResetForm, +) from sipa.mail import send_official_contact_mail, send_contact_mail from sipa.backends.extension import backends from sipa.model import pycroft @@ -154,8 +159,6 @@ def login(): logger.info('Authentication successful', extra={'tags': {'user': username}}) flash(gettext("Anmeldung erfolgreich!"), "success") - elif form.is_submitted(): - flash_formerrors(form) if current_user.is_authenticated: # `url_redirect` would not be bad here because this would allow for URL @@ -198,8 +201,6 @@ def request_password_reset(): "Falls du die Nachricht nicht erhälst, wende dich an den Support."), "success") return redirect(url_for('.login')) - elif form.is_submitted(): - flash_formerrors(form) return render_template('generic_form.html', page_title=gettext("Passwort zurücksetzen"), form_args={'form': form, 'cancel_to': url_for('.login')}) @@ -222,8 +223,6 @@ def reset_password(token): flash(gettext("Dein Passwort wurde geändert."), "success") return redirect(url_for('.login')) - elif form.is_submitted(): - flash_formerrors(form) return render_template('generic_form.html', page_title=gettext("Passwort zurücksetzen"), form_args={'form': form, 'cancel_to': url_for('.login')}) @@ -330,8 +329,6 @@ def contact(): flash(gettext("Es gab einen Fehler beim Versenden der Nachricht."), 'error') return redirect(url_for('.index')) - elif form.is_submitted(): - flash_formerrors(form) return render_template('anonymous_contact.html', form=form) @@ -354,8 +351,6 @@ def contact_official(): flash(gettext("Es gab einen Fehler beim Versenden der Nachricht."), 'error') return redirect(url_for('.index')) - elif form.is_submitted(): - flash_formerrors(form) return render_template( 'official_contact.html', diff --git a/sipa/blueprints/register.py b/sipa/blueprints/register.py index 6ec6d340..c58bb550 100644 --- a/sipa/blueprints/register.py +++ b/sipa/blueprints/register.py @@ -13,7 +13,7 @@ from werkzeug.local import LocalProxy from sipa.backends.extension import backends, _dorm_summary -from sipa.forms import flash_formerrors, RegisterIdentifyForm, RegisterRoomForm, RegisterFinishForm +from sipa.forms import RegisterIdentifyForm, RegisterRoomForm, RegisterFinishForm from sipa.model.pycroft.api import PycroftApi, PycroftApiError from sipa.model.pycroft.exc import PycroftBackendError from sipa.utils import parse_date @@ -167,9 +167,6 @@ def identify(reg_state: RegisterState): except PycroftBackendError as e: handle_backend_error(e) - elif form.is_submitted(): - flash_formerrors(form) - return render_template('register/identify.html', title=gettext('Identifizierung'), form=form, skip_verification=suggest_skip) @@ -181,9 +178,7 @@ def room(reg_state: RegisterState): if form.validate_on_submit(): reg_state.room_confirmed = 'wrong_room' not in request.form return goto_step('data') - elif form.is_submitted(): - flash_formerrors(form) - else: + elif not form.is_submitted(): form.building.data = reg_state.building form.room.data = reg_state.room form.move_in_date.data = reg_state.move_in_date @@ -236,9 +231,7 @@ def data(reg_state: RegisterState): except PycroftBackendError as e: handle_backend_error(e) - elif form.is_submitted(): - flash_formerrors(form) - else: + elif not form.is_submitted(): form.member_begin_date.data = max(reg_state.move_in_date, date.today()) \ if reg_state.move_in_date is not None else date.today() @@ -249,13 +242,9 @@ def data(reg_state: RegisterState): "Dadurch kann dein Antrag nicht automatisch bearbeitet werden. " "Eine manuelle Bearbeitung kann mehrere Tage dauern."), 'warning') - return render_template('register/data.html', title=gettext('Konto erstellen'), form=form, - links={ - 'constitution': '../pages/legal/agdsn_constitution', - 'fee_regulation': '../pages/legal/membership_fee_regulations', - 'network_constitution': '../pages/legal/network_constitution', - 'privacy_policy': '../pages/legal/agdsn_dataprotection', - }) + return render_template( + "register/data.html", title=gettext("Konto erstellen"), form=form + ) @bp_register.route("/finish") diff --git a/sipa/blueprints/usersuite.py b/sipa/blueprints/usersuite.py index be6a58e5..bf6ec055 100644 --- a/sipa/blueprints/usersuite.py +++ b/sipa/blueprints/usersuite.py @@ -22,10 +22,18 @@ from flask_wtf import FlaskForm from markupsafe import Markup -from sipa.forms import ContactForm, ChangeMACForm, ChangeMailForm, \ - ChangePasswordForm, flash_formerrors, HostingForm, \ - PaymentForm, ActivateNetworkAccessForm, TerminateMembershipForm, \ - TerminateMembershipConfirmForm, ContinueMembershipForm +from sipa.forms import ( + ContactForm, + ChangeMACForm, + ChangeMailForm, + ChangePasswordForm, + HostingForm, + PaymentForm, + ActivateNetworkAccessForm, + TerminateMembershipForm, + TerminateMembershipConfirmForm, + ContinueMembershipForm, +) from sipa.mail import send_usersuite_contact_mail from sipa.model.fancy_property import ActiveProperty from sipa.utils import password_changeable, subscribe_to_status_page @@ -109,7 +117,6 @@ def index(): months = payment_form.months.data else: months = payment_form.months.default - flash_formerrors(payment_form) datasource = current_user.datasource context = dict(rows=rows, @@ -168,8 +175,6 @@ def contact(): .format(current_user.datasource.support_mail), 'error') return redirect(url_for('.index')) - elif form.is_submitted(): - flash_formerrors(form) form.email.default = current_user.mail.raw_value @@ -276,8 +281,6 @@ def change_password(): else: flash(gettext("Passwort wurde geändert"), "success") return redirect(url_for('.index')) - elif form.is_submitted(): - flash_formerrors(form) return render_template("generic_form.html", page_title=gettext("Passwort ändern"), form_args={'form': form, 'reset_button': True, 'cancel_to': url_for('.index')}) @@ -309,9 +312,7 @@ def change_mail(): else: flash(gettext("E-Mail-Adresse wurde geändert"), "success") return redirect(url_for('.index')) - elif form.is_submitted(): - flash_formerrors(form) - else: + elif not form.is_submitted(): form.email.data = current_user.mail.raw_value form.forwarded.data = current_user.mail_forwarded.raw_value @@ -338,8 +339,6 @@ def resend_confirm_mail(): flash(gettext('Versenden der Bestätigungs-E-Mail ist fehlgeschlagen!'), 'error') return redirect(url_for('.index')) - elif form.is_submitted(): - flash_formerrors(form) form_args = { 'form': form, @@ -384,9 +383,6 @@ def change_mac(): return redirect(url_for('.index')) - elif form.is_submitted(): - flash_formerrors(form) - form.mac.default = current_user.mac.value return render_template('usersuite/change_mac.html', @@ -428,9 +424,6 @@ def activate_network_access(): return redirect(url_for('.index')) - elif form.is_submitted(): - flash_formerrors(form) - return render_template('generic_form.html', page_title=gettext("Netzwerkanschluss aktivieren"), form_args={'form': form, 'cancel_to': url_for('.index')}) @@ -457,8 +450,6 @@ def hosting(action=None): flash(gettext("Deine Datenbank wurde erstellt."), 'success') else: current_user.userdb.change_password(form.password.data) - elif form.is_submitted(): - flash_formerrors(form) try: user_has_db = current_user.userdb.has_db @@ -495,8 +486,6 @@ def terminate_membership(): return redirect(url_for('.terminate_membership_confirm', end_date=end_date)) - elif form.is_submitted(): - flash_formerrors(form) form_args = { 'form': form, @@ -553,8 +542,6 @@ def terminate_membership_confirm(): flash(gettext("Deine Mitgliedschaft wird zum angegebenen Datum beendet."), 'success') return redirect(url_for('.index')) - elif form.is_submitted(): - flash_formerrors(form) form_args = { 'form': form, @@ -595,8 +582,6 @@ def continue_membership(): flash(gettext("Deine Mitgliedschaft wird fortgesetzt."), 'success') return redirect(url_for('.index')) - elif form.is_submitted(): - flash_formerrors(form) form_args = { 'form': form, @@ -631,8 +616,6 @@ def reset_wifi_password(): flash(Markup("{}:
{}
".format(gettext("Es wurde ein neues WLAN Passwort generiert"), new_password)), 'success') return redirect(url_for('.index')) - elif form.is_submitted(): - flash_formerrors(form) form_args = { 'form': form, diff --git a/sipa/forms.py b/sipa/forms.py index fb775dc5..d615f084 100644 --- a/sipa/forms.py +++ b/sipa/forms.py @@ -1,13 +1,15 @@ import re +import typing as t from datetime import date +from dataclasses import dataclass +from functools import partial from operator import itemgetter from flask_babel import gettext, lazy_gettext -from flask import flash from flask_login import current_user -from flask_wtf import FlaskForm +from wtforms_widgets.base_form import BaseForm as FlaskForm from werkzeug.local import LocalProxy -from wtforms import ( +from wtforms_widgets.fields.core import ( BooleanField, HiddenField, PasswordField, @@ -40,6 +42,38 @@ def __init__(self, message=None): super().__init__(mac_regex, message=message) +_LINK_PLACEHOLDER = re.compile(r"\[(?P[^\]]+)\]\((?P[^)]+)\)") + + +def render_links(raw: str, links: dict): + """ + Replace link placeholders in label of BooleanFields. + + :param raw: Text that contains the link placeholders. + :param links: Link placeholder to url mapping. + """ + + def render_link(match: re.Match) -> str: + link = match.group("link") + if link in links: + return f'{match.group("text")}' + else: + return match.group(0) + + return _LINK_PLACEHOLDER.sub(render_link, raw) + + +@dataclass +class LazilyProcessed: + message: str + process: t.Callable + + def __str__(self): + return self.process(self.message.__str__()) + + def __html__(self): + return self.process(self.message.__html__()) + class PasswordComplexity: character_classes = ((re.compile(r'[a-z]'), lazy_gettext("Kleinbuchstaben (a-z)")), @@ -545,12 +579,25 @@ class RegisterFinishForm(FlaskForm): "Bitte bestätige, dass deine Angaben korrekt sind.")) ] ) - + _render_links = partial( + render_links, + links={ + "constitution": "../pages/legal/agdsn_constitution", + "fee_regulation": "../pages/legal/membership_fee_regulations", + "network_constitution": "../pages/legal/network_constitution", + "privacy_policy": "../pages/legal/agdsn_dataprotection", + }, + ) confirm_legal_2 = BooleanField( - label=lazy_gettext("Ich bestätige, dass ich die [Satzung](constitution) und Ordnungen " - "der AG DSN in ihrer jeweils aktuellen Fassung anerkenne, " - "insbesondere die [Netzordnungen](network_constitution) " - "und die [Beitragsordnung](fee_regulation)."), + label=LazilyProcessed( + lazy_gettext( + "Ich bestätige, dass ich die [Satzung](constitution) und Ordnungen " + "der AG DSN in ihrer jeweils aktuellen Fassung anerkenne, " + "insbesondere die [Netzordnungen](network_constitution) " + "und die [Beitragsordnung](fee_regulation)." + ), + process=_render_links, + ), validators=[ DataRequired(lazy_gettext( "Bitte bestätige deine Zustimmung zur Satzung und weiteren Ordnungen.")) @@ -558,40 +605,15 @@ class RegisterFinishForm(FlaskForm): ) confirm_legal_3 = BooleanField( - label=lazy_gettext("Ich habe die [Datenschutzbestimmungen](privacy_policy) verstanden " - "und stimme diesen zu."), + label=LazilyProcessed( + lazy_gettext( + "Ich habe die [Datenschutzbestimmungen](privacy_policy) verstanden " + "und stimme diesen zu." + ), + process=_render_links, + ), validators=[ DataRequired(lazy_gettext( "Bitte bestätige deine Zustimmung zu der Datenschutzbelehrung.")) ] ) - - -def flash_formerrors(form): - """If a form is submitted but could not be validated, the routing passes - the form and this method returns all form errors (form.errors) - as flash messages. - """ - for _field, errors in list(form.errors.items()): - for e in errors: - flash(e, "error") - - -_LINK_PLACEHOLDER = re.compile(r'\[(?P[^\]]+)\]\((?P[^)]+)\)') - - -def render_links(raw: str, links: dict): - """ - Replace link placeholders in label of BooleanFields. - - :param raw: Text that contains the link placeholders. - :param links: Link placeholder to url mapping. - """ - def render_link(match: re.Match) -> str: - link = match.group('link') - if link in links: - return f'{match.group("text")}' - else: - return match.group(0) - - return _LINK_PLACEHOLDER.sub(render_link, raw) diff --git a/sipa/initialization.py b/sipa/initialization.py index 8d76de74..7ce0c420 100644 --- a/sipa/initialization.py +++ b/sipa/initialization.py @@ -24,7 +24,6 @@ from sipa.blueprints.usersuite import get_attribute_endpoint from sipa.defaults import DEFAULT_CONFIG from sipa.flatpages import CategorizedFlatPages -from sipa.forms import render_links from sipa.model import AVAILABLE_DATASOURCES from sipa.model.misc import should_display_traffic_data from sipa.session import SeparateLocaleCookieSessionInterface @@ -99,7 +98,6 @@ def init_app(app, **kwargs): url_self=url_self, now=datetime.now(UTC), ) - app.add_template_filter(render_links) logger.debug("Jinja globals have been set", extra={'data': {'jinja_globals': app.jinja_env.globals}}) diff --git a/sipa/templates/login.html b/sipa/templates/login.html index 6df90102..75202c76 100644 --- a/sipa/templates/login.html +++ b/sipa/templates/login.html @@ -5,8 +5,12 @@ {% block content %} {% include "heading.html" %} {% call forms.render(form=form, form_id="loginform", cancel_to=url_for('.index')) %} -