sipa/blueprints/register.py

Summary

Maintainability
B
5 hrs
Test Coverage
"""Blueprint for the online registration.
"""

import logging
from dataclasses import dataclass, asdict
from datetime import date
from functools import wraps

from flask import Blueprint, g, session, url_for, redirect, render_template, flash, request
from flask.globals import current_app
from flask_babel import gettext
from flask_login import current_user
from werkzeug.local import LocalProxy

from sipa.backends.extension import backends, _dorm_summary
from sipa.forms import flash_formerrors, RegisterIdentifyForm, RegisterRoomForm, RegisterFinishForm
from sipa.model.pycroft.api import PycroftApi, PycroftApiError
from sipa.model.pycroft.exc import PycroftBackendError
from sipa.utils import parse_date

logger = logging.getLogger(__name__)
api: PycroftApi = LocalProxy(lambda: current_app.extensions['pycroft_api'])

bp_register = Blueprint('register', __name__, url_prefix='/register')


@dataclass
class RegisterState:
    # Current step of the registration process
    step: str = 'identify'

    first_name: str = None
    last_name: str = None
    tenant_number: int | None = None
    birthdate: date = None
    no_swdd_tenant: bool = None
    previous_dorm: str | None = None

    move_in_date: date | None = None
    room_id: int | None = None
    building: str | None = None
    room: str | None = None

    skipped_verification: bool = False
    room_confirmed: bool = False

    result: str = None

    def __post_init__(self):
        if isinstance(self.birthdate, str):
            self.birthdate = parse_date(self.birthdate)
        if isinstance(self.move_in_date, str):
            self.move_in_date = parse_date(self.move_in_date)

    @property
    def confirmed_room_id(self):
        return self.room_id if self.room_confirmed else None

    def to_json(self) -> dict:
        return asdict(self)

    @classmethod
    def from_json(cls, json: dict):
        return RegisterState(**json) if json is not None else None


@bp_register.before_request
def load_register_state():
    reg_state = session.get('reg_state')
    if reg_state is not None:
        g.reg_state = RegisterState.from_json(reg_state)


@bp_register.after_request
def save_register_state(response):
    if 'reg_state' in g:
        session['reg_state'] = g.reg_state.to_json()
    return response


def register_redirect(func):
    @wraps(func)
    def wrapper_decorator(*args, **kwargs):
        if 'reg_state' not in g:
            g.reg_state = RegisterState()

        endpoint = f'register.{g.reg_state.step}'
        if endpoint != request.endpoint:
            return redirect(url_for(endpoint))

        return func(g.reg_state, *args, **kwargs)

    return wrapper_decorator


def goto_step(step):
    g.reg_state.step = step
    return redirect(url_for(f'.{step}'))


def handle_backend_error(ex: PycroftBackendError):
    flash(gettext('Fehler bei der Kommunikation mit dem Backend-Server. Bitte versuche es erneut.'),
          'error')
    logger.critical(
        'Backend error: %s', ex.backend_name,
        extra={'data': {'exception_args': ex.args}},
        exc_info=True,
    )


@bp_register.route("/")
def landing():
    return redirect("../pages/membership/registration")


@bp_register.route("/identify", methods=['GET', 'POST'])
@register_redirect
def identify(reg_state: RegisterState):
    form = RegisterIdentifyForm()
    form.previous_dorm.choices = [_dorm_summary('', '')] + backends.dormitories_short

    suggest_skip = False
    if form.validate_on_submit():
        reg_state.first_name = form.first_name.data
        reg_state.last_name = form.last_name.data
        reg_state.birthdate = form.birthdate.data
        reg_state.no_swdd_tenant = form.no_swdd_tenant.data
        previous_dorm = backends.get_dormitory(form.previous_dorm.data)
        reg_state.previous_dorm = previous_dorm.display_name if previous_dorm else None

        if form.no_swdd_tenant.data or 'skip_verification' in request.form:
            reg_state.skipped_verification = True
            return goto_step('data')

        try:
            match = api.match_person(form.first_name.data, form.last_name.data,
                                     form.birthdate.data, form.tenant_number.data,
                                     reg_state.previous_dorm)
            reg_state.move_in_date = match.begin
            reg_state.room_id = match.room_id
            reg_state.building = match.building
            reg_state.room = match.room
            reg_state.tenant_number = form.tenant_number.data
            return goto_step('room')
        except PycroftApiError as e:
            if e.code == 'user_exists':
                flash(gettext(
                    'Zu den von dir angegebenen Daten existiert bereits eine Mitgliedschaft. '
                    'Bitte wähle aus, in welchem Wohnheim du vorher gewohnt hast.'), 'error')
            elif e.code == 'similar_user_exists':
                flash(gettext('Für den dir zugeordneten Raum gibt es bereits ein Konto. '
                              'Falls du denkst, dass es sich dabei um einen Fehler handelt, '
                              'kannst du den entsprechenden Button klicken. Die Verifizierung wird '
                              'dann später manuell durchgeführt.'), 'error')
                suggest_skip = True
            elif e.code == 'no_room_for_tenancies':
                reg_state.tenant_number = form.tenant_number.data
                return goto_step('data')
            else:
                flash(gettext(
                    'Die Verifizierung deiner Daten mit dem Studentenwerk Dresden ist fehlgeschlagen. '
                    'Bitte überprüfe, dass du die exakt selben Daten, wie beim Studentenwerk Dresden '
                    'bzw. wie auf deinem Mietvertrag, angegeben hast. '
                    'Um die Verifizierung zu überspringen, kannst du den entsprechenden Button '
                    'klicken. Die Verifizierung wird dann später manuell durchgeführt.'), 'error')
                suggest_skip = True
        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)


@bp_register.route("/room", methods=['GET', 'POST'])
@register_redirect
def room(reg_state: RegisterState):
    form = RegisterRoomForm()
    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:
        form.building.data = reg_state.building
        form.room.data = reg_state.room
        form.move_in_date.data = reg_state.move_in_date

    return render_template('register/room.html', title=gettext('Raumbestätigung'), form=form)


@bp_register.route("/data", methods=['GET', 'POST'])
@register_redirect
def data(reg_state: RegisterState):
    form = RegisterFinishForm()
    form.member_begin_date.min = date.today()
    if form.validate_on_submit():
        try:
            api.member_request(
                email=form.email.data,
                login=form.login.data,
                password=form.password.data,
                first_name=reg_state.first_name,
                last_name=reg_state.last_name,
                birthdate=reg_state.birthdate,
                move_in_date=form.member_begin_date.data,
                tenant_number=reg_state.tenant_number,
                room_id=reg_state.confirmed_room_id,
                previous_dorm=reg_state.previous_dorm)

            return goto_step('finish')
        except PycroftApiError as e:
            if e.code == 'user_exists':
                flash(gettext(
                    'Zu den von dir angegebenen Daten existiert bereits eine Mitgliedschaft.'),
                      'error')
            elif e.code == 'similar_user_exists':
                flash(gettext('Für den dir zugeordneten Raum gibt es bereits eine Mitgliedschaft.'),
                      'error')
            elif e.code == 'email_taken':
                flash(gettext('E-Mail-Adresse ist bereits in Verwendung.'), 'error')
            elif e.code == 'login_taken':
                flash(gettext('Login ist bereits vergeben.'), 'error')
            elif e.code == 'email_illegal':
                flash(gettext("E-Mail-Adresse hat ein ungültiges Format!"), 'error')
            elif e.code == 'login_illegal':
                flash(gettext("Nutzername hat ein ungültiges Format!"), 'error')
            elif e.code == 'move_in_date_invalid':
                flash(gettext("Das Einzugsdatum ist ungültig."), 'error')
            else:
                logger.exception(e)
                logger.error(f"Got unexpected error ({e.code}) from Pycroft: {e.message}")
                flash(gettext('Registrierung aus unbekanntem Grund fehlgeschlagen.'), 'error')
        except PycroftBackendError as e:
            handle_backend_error(e)

    elif form.is_submitted():
        flash_formerrors(form)
    else:
        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()

    skipped_verification = reg_state.skipped_verification or (reg_state.room_id is not None and not reg_state.room_confirmed)

    if skipped_verification:
        flash(gettext("Du hast die Verifikation übersprungen oder dein Zimmer nicht bestätigt. "
                      "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',
                           })


@bp_register.route("/finish")
@register_redirect
def finish(reg_state: RegisterState):
    return render_template('register/finish.html', title=gettext("Bestätigung"))


@bp_register.route("/confirm/<token>")
def confirm(token: str):
    try:
        api_result = api.confirm_email(token)
        if api_result['type'] == 'pre_member':
            # Email confirmation as part of the registration process
            reg_state = g.setdefault('reg_state', RegisterState())
            reg_state.result = api_result['reg_result']
            return goto_step('success')
        else:
            # Regular email confirmation
            flash(gettext('Bestätigung erfolgreich.'), 'success')
            if current_user.is_authenticated:
                return redirect(url_for('usersuite.index'))
            else:
                return redirect(url_for('generic.index'))
    except PycroftApiError:
        flash(gettext('Bestätigung fehlgeschlagen.'), 'error')
        result = gettext(
            'Der Bestätigungslink ist nicht gültig. '
            'Möglicherweise hast du dein Konto bereits bestätigt, '
            'oder der Bestätigungszeitraum ist verstrichen.')
    except PycroftBackendError as e:
        result = gettext('Bestätigung fehlgeschlagen.')
        handle_backend_error(e)

    return render_template('register/confirm.html', title=gettext("Bestätigung"), result=result)


@bp_register.route("/success")
@register_redirect
def success(reg_state: RegisterState):
    return redirect(f'../pages/membership/registration_{reg_state.result}')


@bp_register.route("/cancel")
def cancel():
    g.reg_state = RegisterState()
    return redirect(url_for('generic.index'))


@bp_register.route("/restart")
def restart():
    g.reg_state = RegisterState()
    return goto_step('identify')