sipa/forms.py
import re
from datetime import date
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 werkzeug.local import LocalProxy
from wtforms import (
BooleanField,
HiddenField,
PasswordField,
SelectField,
StringField,
TextAreaField,
IntegerField,
DateField,
)
from wtforms.validators import (
DataRequired,
Email,
EqualTo,
InputRequired,
Regexp,
ValidationError,
NumberRange,
Optional,
Length,
)
from sipa.backends.extension import backends
mac_regex = re.compile(r"^[a-f0-9]{2}((:|-|lo)[a-f0-9]{2}){5}$", re.IGNORECASE)
hex_value = re.compile(r"^[a-fA-F0-9]+$")
class MacAddress(Regexp):
def __init__(self, message=None):
super().__init__(mac_regex, message=message)
class PasswordComplexity:
character_classes = ((re.compile(r'[a-z]'), lazy_gettext("Kleinbuchstaben (a-z)")),
(re.compile(r'[A-Z]'), lazy_gettext("Großbuchstaben (A-Z)")),
(re.compile(r'[0-9]'), lazy_gettext("Ziffern (0-9)")),
(re.compile(r'[^a-zA-Z0-9]'), lazy_gettext("andere Zeichen")))
default_message = lazy_gettext(
"Dein Passwort muss mindestens {min_length} Zeichen lang sein und "
"mindestens {min_classes} verschiedene Klassen von Zeichen "
"enthalten. Zeichen von Klassen sind: {classes}."
)
def __init__(self, min_length=8, min_classes=3, message=None):
self.min_length = min_length
self.min_classes = min_classes
self.message = message
def __call__(self, form, field, message=None):
password = field.data or ''
if len(password) < self.min_length:
self.raise_error(message)
matched_classes = sum(1 for pattern, name in self.character_classes
if pattern.search(password))
if matched_classes < self.min_classes:
self.raise_error(message)
def raise_error(self, message):
if message is None:
if self.message is None:
message = self.default_message
else:
message = self.message
classes_descriptions = map(itemgetter(1), self.character_classes)
classes = ', '.join(map(str, classes_descriptions))
raise ValidationError(message.format(min_length=self.min_length,
min_classes=self.min_classes,
classes=classes))
class OptionalIf(Optional):
# makes a field optional if some other data is supplied or is not supplied
def __init__(self, deciding_field, invert=False, *args, **kwargs):
self.deciding_field = deciding_field
self.invert = invert
super().__init__(*args, **kwargs)
def __call__(self, form, field):
deciding_field = form._fields.get(self.deciding_field)
deciding_has_data = deciding_field is not None and bool(
deciding_field.data) and deciding_field.data != 'None'
if deciding_has_data ^ self.invert:
super().__call__(form, field)
def lower_filter(string):
return string.lower() if string else None
def strip_filter(string):
return string.strip() if string else None
class StrippedStringField(StringField):
def __init__(self, *args, **kwargs):
kwargs['filters'] = kwargs.get('filters', []) + [strip_filter]
super().__init__(*args, **kwargs)
class ReadonlyStringField(StrippedStringField):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def __call__(self, *args, **kwargs):
return super().__call__(
*args, readonly=True, **kwargs)
class EmailField(StrippedStringField):
def __init__(self, *args, **kwargs):
validators = [
DataRequired(lazy_gettext("E-Mail-Adresse hat ein ungültiges Format!")),
Email(lazy_gettext("E-Mail-Adresse hat ein ungültiges Format!"))
]
if 'validators' in kwargs:
kwargs['validators'] = validators + kwargs['validators']
else:
kwargs['validators'] = validators
super().__init__(*args, **kwargs)
class NativeDateField(DateField):
min: date = None
max: date = None
def __init__(self, *args, **kwargs):
kwargs.setdefault('render_kw', {})['type'] = 'date'
super().__init__(*args, **kwargs)
def __call__(self, *args, **kwargs):
assert isinstance(self.format, list)
format = self.format[0]
if self.min is not None:
kwargs['min'] = self.min.strftime(format)
if self.max is not None:
kwargs['max'] = self.max.strftime(format)
return super().__call__(*args, **kwargs)
class SpamCheckField(StringField):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def __call__(self, *args, **kwargs):
c = kwargs.pop('class', '') or kwargs.pop('class_', '')
kwargs['class'] = '{} {}'.format('honey', c)
kwargs['autocomplete'] = 'off'
return super().__call__(*args, **kwargs)
class SpamProtectedForm(FlaskForm):
# Adds a honypot for bots to the form.
# This field must not be filled out to submit the form.
# We're using 'website' as the field-name since we won't give bots a hint.
website = SpamCheckField(label="", validators=[Length(0, 0, "You seem to like honey.")])
class ContactForm(SpamProtectedForm):
email = EmailField(label=lazy_gettext("Deine E-Mail-Adresse"))
type = SelectField(label=lazy_gettext("Kategorie"), choices=[
("frage", lazy_gettext("Allgemeine Fragen")),
("stoerung", lazy_gettext("Störung im Netzwerk")),
("finanzen", lazy_gettext("Finanzfragen (Beiträge, Gebühren)")),
("eigene-technik", lazy_gettext("Probleme mit privater Technik"))
])
subject = StrippedStringField(label=lazy_gettext("Betreff"), validators=[
DataRequired(lazy_gettext("Betreff muss angegeben werden!"))])
message = TextAreaField(label=lazy_gettext("Nachricht"), validators=[
DataRequired(lazy_gettext("Nachricht fehlt!"))
])
class AnonymousContactForm(SpamProtectedForm):
email = EmailField(label=lazy_gettext("Deine E-Mail-Adresse"))
name = StringField(
label=lazy_gettext("Dein Name"),
validators=[DataRequired(lazy_gettext("Bitte gib einen Namen an!"))],
)
dormitory = SelectField(
label=lazy_gettext("Wohnheim"),
# TODO set dormitory.choices in view function
# choices=LocalProxy(lambda: backends.dormitories_short),
default=LocalProxy(lambda: backends.preferred_dormitory_name()),
)
subject = StrippedStringField(label=lazy_gettext("Betreff"), validators=[
DataRequired(lazy_gettext("Betreff muss angegeben werden!"))])
message = TextAreaField(label=lazy_gettext("Nachricht"), validators=[
DataRequired(lazy_gettext("Nachricht fehlt!"))
])
class OfficialContactForm(SpamProtectedForm):
email = EmailField(label=lazy_gettext("E-Mail-Adresse"))
name = StringField(
label=lazy_gettext("Name / Organisation"),
validators=[DataRequired(lazy_gettext("Bitte gib einen Namen an!"))],
)
subject = StrippedStringField(label=lazy_gettext("Betreff"), validators=[
DataRequired(lazy_gettext("Betreff muss angegeben werden!"))])
message = TextAreaField(label=lazy_gettext("Nachricht"), validators=[
DataRequired(lazy_gettext("Nachricht fehlt!"))
])
class ChangePasswordForm(FlaskForm):
old = PasswordField(label=lazy_gettext("Altes Passwort"), validators=[
DataRequired(lazy_gettext("Altes Passwort muss angegeben werden!"))])
new = PasswordField(label=lazy_gettext("Neues Passwort"), validators=[
DataRequired(lazy_gettext("Neues Passwort fehlt!")),
PasswordComplexity(),
])
confirm = PasswordField(label=lazy_gettext("Bestätigung"), validators=[
DataRequired(lazy_gettext("Bestätigung des neuen Passworts fehlt!")),
EqualTo('new',
message=lazy_gettext("Neue Passwörter stimmen nicht überein!"))
])
class ChangeMailForm(FlaskForm):
password = PasswordField(
label=lazy_gettext("Passwort"),
validators=[DataRequired(lazy_gettext("Passwort nicht angegeben!"))])
email = EmailField(label=lazy_gettext("E-Mail-Adresse"))
forwarded = BooleanField(
label=LocalProxy(lambda:
lazy_gettext("Mails für mein AG DSN E-Mail-Konto ({agdsn_email}) an private "
"E-Mail-Adresse weiterleiten")
.format(agdsn_email=f'{current_user.login.value}@agdsn.me')))
def require_unicast_mac(form, field):
"""
Validator for unicast mac adress.
A MAC-adress is defined to be “broadcast” if the least significant bit
of the first octet is 1. Therefore, it has to be 0 to be valid.
"""
try:
if int(field.data[1], 16) % 2:
raise ValidationError(gettext("MAC muss unicast-Adresse sein!"))
except ValueError:
raise ValidationError(gettext("keine gültige MAC adresse ")) from None
except IndexError:
raise ValidationError(gettext("Mac zu kurz!")) from None
class ChangeMACForm(FlaskForm):
password = PasswordField(
label=lazy_gettext("Passwort"),
validators=[DataRequired(lazy_gettext("Passwort nicht angegeben!"))])
mac = StrippedStringField(
label=lazy_gettext("Neue MAC"),
validators=[DataRequired(lazy_gettext("MAC-Adresse nicht angegeben!")),
MacAddress(lazy_gettext("MAC ist nicht in gültigem Format!")),
require_unicast_mac],
description="XX:XX:XX:XX:XX:XX")
host_name = StringField(
label=lazy_gettext("Neuer Gerätename (Optional)"),
validators=[Regexp(regex="^[a-zA-Z0-9 ]+",
message=lazy_gettext("Gerätename ist ungültig")),
Optional(),
Length(-1, 30, lazy_gettext("Gerätename zu lang"))],
description=lazy_gettext("TL-WR841N, MacBook, FritzBox, PC, Laptop, o.Ä."),
)
class ActivateNetworkAccessForm(FlaskForm):
password = PasswordField(
label=lazy_gettext("Passwort"),
validators=[DataRequired(lazy_gettext("Passwort nicht angegeben!"))])
mac = StrippedStringField(
label=lazy_gettext("MAC-Adresse"),
validators=[DataRequired(lazy_gettext("MAC-Adresse nicht angegeben!")),
MacAddress(lazy_gettext("MAC ist nicht in gültigem Format!")),
require_unicast_mac],
description="XX:XX:XX:XX:XX:XX")
birthdate = NativeDateField(label=lazy_gettext("Geburtsdatum"),
validators=[DataRequired(lazy_gettext("Geburtsdatum nicht angegeben!"))],
description=lazy_gettext("YYYY-MM-DD (z.B. 1995-10-23)"))
host_name = StringField(
label=lazy_gettext("Gerätename (Optional)"),
validators=[Regexp(regex="^[a-zA-Z0-9 ]+",
message=lazy_gettext("Gerätename ist ungültig")),
Optional(),
Length(-1, 30, lazy_gettext("Gerätename zu lang"))],
description=lazy_gettext("TL-WR841N, MacBook, FritzBox, PC, Laptop, o.Ä.")
)
class TerminateMembershipForm(FlaskForm):
end_date = NativeDateField(label=lazy_gettext("Austrittsdatum"),
validators=[DataRequired(lazy_gettext("Austrittsdatum nicht angegeben!"))],
description=lazy_gettext("YYYY-MM-DD (z.B. 2018-10-01)"))
def validate_end_date(form, field):
if field.data < date.today():
raise ValidationError(lazy_gettext("Das Austrittsdatum darf nicht in der Vergangenheit "
"liegen!"))
class TerminateMembershipConfirmForm(FlaskForm):
end_date = NativeDateField(
label=lazy_gettext("Austrittsdatum"),
render_kw={"readonly": True, "disabled": True},
validators=[DataRequired("invalid end date")],
)
estimated_balance = StringField(
label=lazy_gettext(
"Geschätzter Kontostand (in EUR) zum Ende der Mitgliedschaft"
),
render_kw={"readonly": True, "disabled": True},
validators=[DataRequired("invalid balance")],
)
confirm_termination = BooleanField(label=lazy_gettext(
"Ich bestätige, dass ich meine Mitgliedschaft zum obenstehenden Datum beenden möchte"),
validators=[
DataRequired(lazy_gettext("Bitte bestätige die Beendigung der Mitgliedschaft"))])
confirm_settlement = BooleanField(label=lazy_gettext(
"Ich bestätige, dass ich ggf. ausstehende Beiträge baldmöglichst bezahle"),
validators=[
DataRequired(lazy_gettext(
"Bitte bestätige die baldmöglichste Bezahlung von ausstehenden Beiträgen."))])
confirm_donation = BooleanField(label=lazy_gettext(
"Ich bestätige, dass ich zu viel gezahltes Guthaben spende, wenn ich nicht innerhalb "
"von 31 Tagen nach Mitgliedschaftsende einen Rückerüberweisungsantrag stelle"),
validators=[
DataRequired(lazy_gettext("Bitte bestätige die Spendeneinwilligung."))])
class ContinueMembershipForm(FlaskForm):
confirm_continuation = BooleanField(label=lazy_gettext(
"Ich bestätige, dass ich die Kündigung meiner Mitgliedschaft zurückziehe"),
validators=[
DataRequired(lazy_gettext("Bitte bestätige die Aufhebung der Kündigung"))])
class LoginForm(FlaskForm):
#dormitory = SelectField(
# lazy_gettext("Wohnheim"),
# # choices=LocalProxy(lambda: backends.dormitories_short),
# # TODO set this at instantiation time
# choices=LocalProxy(lambda: backends.dormitories_short),
# default=LocalProxy(lambda: backends.preferred_dormitory_name()),
# validators=[LocalProxy(
# lambda: AnyOf([dorm.name for dorm in backends.dormitories],
# message=lazy_gettext("Kein gültiges Wohnheim!"))
# )]
#)
username = StrippedStringField(
label=lazy_gettext("Nutzername"),
validators=[
DataRequired(lazy_gettext("Nutzername muss angegeben werden!")),
Regexp("^[^,+\"\\<>;#]+$", message=lazy_gettext(
"Nutzername enthält ungültige Zeichen!")),
],
)
password = PasswordField(
label=lazy_gettext("Passwort"),
validators=[DataRequired(lazy_gettext("Kein Passwort eingegeben!"))]
)
remember = BooleanField(label=lazy_gettext("Anmeldung merken"))
class PasswordRequestResetForm(FlaskForm):
ident = StrippedStringField(
label=lazy_gettext("Nutzername oder Nutzer-ID"),
description='XXXXX-YY',
validators=[
DataRequired(lazy_gettext("Nutzername muss angegeben werden!")),
Regexp("^[^,+\"\\<>;#]+$", message=lazy_gettext(
"Identifizierung enthält ungültige Zeichen!")),
],
)
email = EmailField(label=lazy_gettext("Hinterlegte E-Mail-Adresse"))
class PasswordResetForm(FlaskForm):
password = PasswordField(
label=lazy_gettext("Neues Passwort"),
validators=[
DataRequired(lazy_gettext("Passwort muss angegeben werden!")),
PasswordComplexity(),
]
)
password_repeat = PasswordField(
label=lazy_gettext("Passwort erneut eingeben"),
validators=[
DataRequired(lazy_gettext("Passwort muss angegeben werden!")),
EqualTo("password", lazy_gettext("Passwörter stimmen nicht überein!")),
]
)
class HostingForm(FlaskForm):
password = PasswordField(lazy_gettext("Passwort"), validators=[
DataRequired(lazy_gettext("Kein Passwort eingegeben!")),
PasswordComplexity(),
])
password_confirmation = PasswordField(lazy_gettext("Bestätigung"), validators=[
DataRequired(lazy_gettext("Bestätigung des neuen Passworts fehlt!")),
EqualTo('password',
message=lazy_gettext("Neue Passwörter stimmen nicht überein!"))
])
action = HiddenField()
class PaymentForm(FlaskForm):
months = IntegerField(lazy_gettext("Monate"), default=1,
validators=[NumberRange(min=1, message=lazy_gettext(
"Muss mindestens 1 Monat sein."))])
class RegisterIdentifyForm(FlaskForm):
first_name = StrippedStringField(
label=lazy_gettext("Vorname"),
validators=[DataRequired(lazy_gettext("Bitte gib deinen Vornamen ein."))]
)
last_name = StrippedStringField(
label=lazy_gettext("Nachname"),
validators=[DataRequired(lazy_gettext("Bitte gib deinen Nachnamen ein."))]
)
birthdate = NativeDateField(
label=lazy_gettext("Geburtsdatum"),
validators=[DataRequired(lazy_gettext("Bitte gib dein Geburtsdatum an."))],
description=lazy_gettext("YYYY-MM-DD (z.B. 1995-10-23)")
)
no_swdd_tenant = BooleanField(
label=lazy_gettext(
"Ich bin Untermieter oder habe meinen Mietvertrag nicht direkt "
"vom Studentenwerk Dresden."),
)
tenant_number = IntegerField(
label=lazy_gettext("Debitorennummer (siehe Mietvertrag)"),
validators=[
OptionalIf("no_swdd_tenant"),
OptionalIf("skip_verification"),
InputRequired(lazy_gettext("Bitte gib deine Debitorennummer ein.")),
NumberRange(min=0,
message=lazy_gettext("Debitorennummer muss eine positive Zahl sein.")),
]
)
agdsn_history = BooleanField(
label=lazy_gettext(
"Ich hatte schon einmal einen Internetanschluss durch die AG\u00a0DSN."),
)
previous_dorm = SelectField(
label=lazy_gettext("Vorheriges Wohnheim"),
# choices=LocalProxy(lambda: [_dorm_summary('', '')] + backends.dormitories_short),
validators=[
OptionalIf("agdsn_history", invert=True),
DataRequired("Bitte vorheriges Wohnheim auswählen."),
],
default='',
)
class RegisterRoomForm(FlaskForm):
building = ReadonlyStringField(label=lazy_gettext("Wohnheim"))
room = ReadonlyStringField(label=lazy_gettext("Raum"))
move_in_date = NativeDateField(
label=lazy_gettext("Einzugsdatum"),
render_kw={'readonly': True, 'required': True}
)
class RegisterFinishForm(FlaskForm):
_LOGIN_REGEX = re.compile(r"""
^
# Must begin with a lowercase character
[a-z]
# Can continue with lowercase characters, numbers and some punctuation
# but between punctuation characters must be characters or numbers
(?:[.-]?[a-z0-9])+$
""", re.VERBOSE)
login = StringField(
label=lazy_gettext("Gewünschter Nutzername"),
validators=[
DataRequired(lazy_gettext("Nutzername muss angegeben werden!")),
Regexp(regex=_LOGIN_REGEX, message=lazy_gettext(
"Dein Nutzername muss mit einem Kleinbuchstaben beginnen und kann mit "
"Kleinbuchstaben, Zahlen und Interpunktionszeichen (Punkt und Bindestrich) "
"fortgesetzt werden, aber es müssen Kleinbuchstaben oder Zahlen zwischen "
"den Interpunktionszeichen stehen.")),
],
filters=[lower_filter]
)
password = PasswordField(
label=lazy_gettext("Passwort"),
validators=[
DataRequired(lazy_gettext("Passwort muss angegeben werden!")),
PasswordComplexity(),
]
)
password_repeat = PasswordField(
label=lazy_gettext("Passwort erneut eingeben"),
validators=[
DataRequired(lazy_gettext("Passwort muss angegeben werden!")),
EqualTo("password", lazy_gettext("Passwörter stimmen nicht überein!")),
]
)
email = EmailField(label=lazy_gettext("E-Mail-Adresse"))
email_repeat = EmailField(
label=lazy_gettext("E-Mail-Adresse erneut eingeben"),
validators=[EqualTo("email", lazy_gettext("E-Mail-Adressen stimmen nicht überein!"))]
)
member_begin_date = NativeDateField(
label=lazy_gettext("Gewünschter Mitgliedschaftsbeginn"),
validators=[
DataRequired(lazy_gettext("Mitgliedschaftsbeginn muss angegeben werden!")),
]
)
confirm_legal_1 = BooleanField(
label=lazy_gettext("Ich bestätige, dass meine Angaben korrekt und vollständig sind und ich "
"die Vorraussetzungen für die Mitgliedschaft (Student oder Bewohner "
"eines Studentenwohnheimes) erfülle."),
validators=[
DataRequired(lazy_gettext(
"Bitte bestätige, dass deine Angaben korrekt sind."))
]
)
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)."),
validators=[
DataRequired(lazy_gettext(
"Bitte bestätige deine Zustimmung zur Satzung und weiteren Ordnungen."))
]
)
confirm_legal_3 = BooleanField(
label=lazy_gettext("Ich habe die [Datenschutzbestimmungen](privacy_policy) verstanden "
"und stimme diesen zu."),
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<text>[^\]]+)\]\((?P<link>[^)]+)\)')
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'<a target="_blank" href="{links[link]}">{match.group("text")}</a>'
else:
return match.group(0)
return _LINK_PLACEHOLDER.sub(render_link, raw)