sipa/backends/extension.py

Summary

Maintainability
A
0 mins
Test Coverage
from __future__ import annotations

from typing import NamedTuple, cast

from flask import request, current_app, Flask
from flask_login import AnonymousUserMixin
from werkzeug.local import LocalProxy

from .datasource import DataSource, Dormitory
from .exceptions import InvalidConfiguration
from .logging import logger
from .types import UserLike


class Backends:
    """The `Backends` flask extension

    This extension lets you initialize some of the available
    datasources and provides some central methods to look things up,
    like for example the user object from some ip.

    `Backends` builds upon the following concept:

    A user is enabled to log in using different backends, called
    *datasources*.  A :py:class:`DataSource` provides information such
    as its name, the email suffix, the user class, the initialization
    method, and so on.

    Originating from the needs of the `AG DSN <https://github.com/agdsn>`_, the
    user should not select the backend, but the location where he
    lives.  Thus, he selects a :py:class:`Dormitory`, which has not
    only a name, but also a `display_name` and ip subnets.  The latter
    are needed to semi-authenticate a user based on his ip.

    **Usage:**

    >>> app = Flask('appname')
    >>> datasource = DataSource(name='name1', user_class=object,
    >>>                         mail_server='srv')
    >>> app.config['BACKEND'] = 'name1'
    >>> backends = Backends(available_datasources=[datasource])
    >>> backends.init_app(app)
    >>> app.run()

    This class provides methods concerning:

    * *initialization* of the extension and backends
    * *lists* of the currently (un)supported dormitories and
       datasources
    * *lookup properties* to access the datasources/dormitories/users
      given certain information (ip, name, …)
    * *proxy methods* to access the current datasource/dormitory
      (similiar to current_user)

    """

    def __init__(self, available_datasources: list[DataSource]):
        #: Which datasources are available
        self.available_datasources = {d.name: d for d in available_datasources}
        self.app: Flask = None
        self.datasource: DataSource = None

    def init_app(self, app: Flask):
        """Register self to app and initialize datasources

        The datasources will be registered according to the app's
        config.

        :param app: The flask app object to register against
        """
        if "backends" in app.extensions:
            logger.warning("Backends extension already initialized. Skipping.")
            return
        app.extensions['backends'] = self
        self.app = app

        if "BACKENDS" in app.config:
            logger.warning(
                "BACKENDS is deprecated. Use BACKEND instead. "
                "Multiple backends at the same time is unsupported."
            )
        backend_name = app.config["BACKEND"]
        try:
            new_datasource = self.available_datasources[backend_name]
        except KeyError:
            raise InvalidConfiguration(
                f"{backend_name} is not an available datasource"
            ) from None
        self.datasource = new_datasource
        if self.datasource.init_app:
            self.datasource.init_app(self.app)

    # CENTRAL PROPERTIES

    @property
    def dormitories(self) -> list[Dormitory]:
        """A list of the currently registered dormitories"""
        return self.datasource.dormitories

    # CONVENIENCE PROPERTIES

    @property
    def dormitories_short(self) -> list[_dorm_summary]:
        """Return a list of dormitories as tuples instead of objects"""
        return sorted(
            _dorm_summary(name=dormitory.name,
                          display_name=dormitory.display_name)
            for dormitory in self.dormitories
        )

    # LOOKUP METHODS

    def get_dormitory(self, name: str) -> Dormitory | None:
        """Lookup the dormitory with name ``name``."""
        return self.datasource.get_dormitory(name)

    def dormitory_from_ip(self, ip: str) -> Dormitory | None:
        """Return the dormitory whose subnets contain ``ip``"""
        return self.datasource.dormitory_from_ip(ip)

    def preferred_dormitory_name(self) -> str | None:
        """Return the name of the preferred dormitory based on the
        request's ip

        :return: name of the dormitory
        """
        dormitory = self.dormitory_from_ip(request.remote_addr)
        if dormitory:
            return dormitory.name
        return None

    def user_from_ip(self, ip: str) -> UserLike | None:
        """Return the User that corresponds to ``ip`` according to the
        datasource.

        :param ip: The ip

        :return: The corresponding User in the sense of the
                 datasource.
        """
        # initial check: IP in known ranges?
        if not self.dormitory_from_ip(ip):
            return AnonymousUserMixin()

        return self.datasource.user_class.from_ip(ip)


#: A namedtuple to improve readability of some return values
class _dorm_summary(NamedTuple):
    name: str
    display_name: str
backends: Backends = cast(Backends,
                          LocalProxy(lambda: current_app.extensions['backends']))