class BaseUser(AuthenticatedUserMixin, metaclass=ABCMeta):
    """Abstract base class defining what a user must have in order to
    work properly with sipa.

    Note that initialization shouldn't be done via :meth:`__init__`
    directly, but usign one of the classmethods :meth:`get`,
    :meth:`authenticate` or :meth:`from_ip`!

    Apart from being an ABC, this class provides some logic already.

    This includes setting :data:`uid` in :meth:`__init__`, defining
    equality, and some others.

    This class adheres to the protocol imposed by
    :class:`Flask-Login <flask_login:flask_login.UserMixin>`

    Abstract methods / properties are prepended with ``[Abstract]``.
    """

    def __init__(self, uid):
        #: A unique unicode identifier for the User, needed for
        #: :meth:`get_id`
        self.uid = uid

    def __eq__(self, other):
        return self.uid == other.uid and self.datasource == other.datasource

    datasource = None

    def get_id(self):
        """This method is Required by flask-login."""
        return self.uid

    @classmethod
    @abstractmethod
    def get(cls: Type[T], username: str) -> T:
        """Fetch a user given his username.

        :param username: the username
        :return: the user object
        """
        pass

    @classmethod
    @abstractmethod
    def from_ip(cls: Type[T], ip: str) -> T:
        """Return a user based on an ip.

        If there is no user associated with this ip, return
        :py:class:`~flask_login.AnonymousUserMixin`.

        :param ip: the ip
        :return: the user object
        """
        pass

    def re_authenticate(self, password):
        self.authenticate(self.uid, password)

    @contextmanager
    def tmp_authentication(self, password):
        """Check and temporarily store the given password.

        Returns a context manager.  The password is stored in
        `self._tmp_password`.

        This is quite an ugly hack, only existing because some datasources
        need the user password to change certain user properties such as mail
        address and MAC address. The need for the password breaks
        compatability with the usual `instance.property = value`.

        I could not think of a better way to get around this.
        """
        self.re_authenticate(password)
        self._tmp_password = password
        yield
        del self._tmp_password

    @classmethod
    @abstractmethod
    def authenticate(cls, username, password):
        """Return a User instance or raise PasswordInvalid"""
        pass

    @property
    @abstractmethod
    def can_change_password(self) -> bool:
        """Whether password change is possible/permitted."""
        pass

    @abstractmethod
    def change_password(self, old, new):
        """Change the user's password from old to new."""
        pass

    @property
    @abstractmethod
    def traffic_history(self) -> List[Dict]:
        """Return the current traffic history as a dict.

        The history should cover one week. The assumed unit is KiB.

        The list syntax is as follows::

            [{
                'day': day.weekday(),
                'input': in,
                'output': out,
                'throughput': in + out,
            }, …]

        The traffic values shall be in KiB, as usual.

        :return: The history of the used traffic
        """
        pass

    def generate_rows(self, description_dict: Dict):
        for key, val in description_dict.items():
            yield Row(description=val, property=getattr(self, key))

    @active_prop
    @abstractmethod
    def realname(self) -> PropertyBase:
        """**[Abstract]** The real-life name"""
        pass

    @active_prop
    @abstractmethod
    def login(self) -> PropertyBase:
        """**[Abstract]** The login"""
        pass

    @active_prop
    @abstractmethod
    def mac(self) -> PropertyBase:
        """**[Abstract]** The MAC Address"""
        pass

    @active_prop
    @abstractmethod
    def mail(self) -> PropertyBase:
        """**[Abstract]** The mail address.

        (``"{login}@{server}"``)
        """
        pass

    @active_prop
    @abstractmethod
    def birthdate(self) -> PropertyBase:
        """**[Abstract]** Date of birth"""
        pass

    @active_prop
    @abstractmethod
    def mail_forwarded(self) -> PropertyBase:
        """**[Abstract]** Whether mail forwarding is enabled."""
        pass

    @property
    @abstractmethod
    def mail_confirmed(self) -> PropertyBase:
        """**[Abstract]** Whether mail is confirmed."""
        pass

    @abstractmethod
    def resend_confirm_mail(self) -> bool:
        """**[Abstract]** Resend the confirmation mail."""
        pass

    @active_prop
    @abstractmethod
    def address(self) -> PropertyBase:
        """**[Abstract]** Where the user lives"""
        pass

    @active_prop
    @abstractmethod
    def status(self) -> PropertyBase:
        """**[Abstract]** The current membership status in the sense of
        the AG DSN constitution.

        This mostly means active, ex-active or passive.
        """
        pass

    @active_prop
    @abstractmethod
    def id(self) -> PropertyBase:
        """**[Abstract]** The “user-id”.

        Some Backends provide a secondary id besides the login.
        """
        pass

    @active_prop
    @abstractmethod
    def hostname(self) -> PropertyBase:
        """**[Abstract]** The hostname.

        This usually is an alias consisting of the last digits of the
        mac/ip.
        """
        pass

    @active_prop
    @abstractmethod
    def hostalias(self) -> PropertyBase:
        """**[Abstract]** The hostalias.

        An optionally configurable alias for the device.
        """
        pass

    @property
    @abstractmethod
    def userdb_status(self) -> PropertyBase:
        """The status of the user's db, if available."""
        pass

    @property
    @abstractmethod
    def userdb(self) -> BaseUserDB:
        """**[Abstract]** The :class:`BaseUserDB` object, if available.

        If :data:`userdb_status` is non-empty, it is assumed to exist.
        """
        pass

    @property
    @abstractmethod
    def has_connection(self) -> PropertyBase:
        """**[Abstract]** Whether the user has a connection"""
        pass

    @property
    @abstractmethod
    def finance_information(self) -> Optional[BaseFinanceInformation]:
        """**[Abstract]** Finance information about the User.

        If not supported, set to None.
        """
        pass

    @property
    def finance_balance(self) -> PropertyBase:
        """The :class:`fancy property <sipa.model.fancy_property.PropertyBase>`
        representing the finance balance"""
        info = self.finance_information
        if not info:
            return UnsupportedProperty('finance_balance')
        return info.balance

    @abstractmethod
    def payment_details(self) -> PaymentDetails:
        """**[Abstract]** Payment details for the User."""
        pass

    def has_property(self, property):
        """Method to check if a user has a property"""
        return False

    @unsupported_prop
    def membership_end_date(self):
        """Date when the membership ends"""
        raise NotImplementedError

    @unsupported_prop
    def network_access_active(self):
        """Whether or not the network access is active"""
        raise NotImplementedError

    def activate_network_access(self, password, mac, birthdate, host_name):
        """Method to activate network access"""
        raise NotImplementedError

    def terminate_membership(self, end_date):
        """Method to terminate membership"""
        raise NotImplementedError

    def continue_membership(self):
        """Calculate balance at a given end_date"""
        raise NotImplementedError

    def estimate_balance(self, end_date):
        """Calculate balance at a given end_date"""
        raise NotImplementedError

    @property
    @abstractmethod
    def is_member(self):
        pass

    @unsupported_prop
    def wifi_password(self):
        pass

    @classmethod
    def request_password_reset(cls, user_ident, email):
        raise NotImplementedError

    @classmethod
    def password_reset(cls, token, new_password):
        raise NotImplementedError class BaseFinanceInformation(metaclass=ABCMeta):
    """A Class providing finance information about a user.

    This class bundles informations such as whether the current user
    is obliged to pay, the current balance, history, and the time of
    the last update.  The balance is being provided as a
    FancyProperty, so it can be used as a row.

    For subclassing, implement the respective abstract
    methods/properties.
    """

    @property
    def balance(self):
        """The current balance as a
        :py:class:`~sipa.model.fancy_property.ActiveProperty`

        If :attr:`has_to_pay` is False, return an empty property with
        the note that the user doesn't have to pay.  Else, return the
        balance formatted and styled (red/green) as money and mark
        this property editable.
        """
        if not self.has_to_pay:
            return ActiveProperty('finance_balance',
                                  value=gettext("Beitrag in Miete inbegriffen"),
                                  raw_value=0, empty=True)
        return ActiveProperty('finance_balance',
                              value=format_money(self.raw_balance),
                              raw_value=self.raw_balance)

    @property
    @abstractmethod
    def raw_balance(self) -> float:
        """**[Abstract]** The current balance

        If :py:meth:`has_to_pay` is False, this method will not be
        used implicitly.
        """
        pass

    @property
    @abstractmethod
    def has_to_pay(self) -> bool:
        """**[Abstract]** Whether the user is obliged to pay."""
        pass

    @property
    @abstractmethod
    def history(self):
        """**[Abstract]** History of payments

        This method should return an iterable of a (datetime, int, description)
        tuple.
        """
        pass

    @property
    @abstractmethod
    def last_update(self):
        """**[Abstract]** The time of the last update."""
        pass

    def __eq__(self, other):
        return compare_all_attributes(self, other, ['raw_balance', 'has_to_pay',
                                                    'history', 'last_update']) NO_CAPABILITIES = Capabilities(edit=False, delete=False)
class PropertyBase(metaclass=ABCMeta):
class UnsupportedProperty(PropertyBase):
class ActiveProperty(PropertyBase):
class active_prop(property):
    def __init__(self, name, value, raw_value, capabilities=NO_CAPABILITIES,
                 style=None, empty=False, description_url=None):
    def __repr__(self):
    def supported(self):
    def __eq__(self, other):
    def __contains__(self, item):
    def __bool__(self):
    def __init__(self, name):
    def __repr__(self):
    def __eq__(self, other):
    def __init__(self, name, value=None, raw_value=None, capabilities=NO_CAPABILITIES,
                 style=None, empty=False, description_url=None):
    def __repr__(self):
def unsupported_prop(func):
    def __init__(self, fget, fset=None, fdel=None, doc=None,
                 fake_setter=False):
        def wrapped_getter(*args, **kwargs):
    def __repr__(self):
    def getter(self, func):
    def setter(self, func):
    def deleter(self, func):
    def fake_setter(self):
def connection_dependent(func):
    def _connection_dependent(self, *args, **kwargs): class UserNotFound(InvalidCredentials):
    pass class PasswordInvalid(InvalidCredentials):
    pass class MacAlreadyExists(Exception):
    pass class NetworkAccessAlreadyActive(Exception):
    pass class TerminationNotPossible(Exception):
    pass class UnknownError(Exception):
    pass class ContinuationNotPossible(Exception):
    pass class SubnetFull(Exception):
    pass class UserNotContactableError(Exception):
    pass class TokenNotFound(InvalidCredentials):
    pass class LoginNotAllowed(InvalidCredentials):
    pass class PycroftApi():
    def __init__(self, endpoint: str, api_key: str):
        if not endpoint.endswith("/"):
            raise InvalidConfiguration("API endpoint must end with a '/'")
        self._endpoint = endpoint
        self._api_key = api_key

    def get_user(self, username: str) -> Tuple[int, dict]:
        return self.get('user/{}'.format(username))

    def get_user_from_ip(self, ip):
        return self.get('user/from-ip', params={'ip': ip}, no_raise=True)

    def authenticate(self, username, password):
        return self.post('user/authenticate',
                         data={'login': username, 'password': password})

    def change_password(self, user_id, old_password, new_password):
        return self.post('user/{}/change-password'.format(user_id),
                         data={'password': old_password,
                               'new_password': new_password})

    def change_mail(self, user_id, password, new_mail, forwarded):
        return self.post('user/{}/change-email'.format(user_id),
                         data={'password': password, 'new_email': new_mail, 'forwarded': forwarded})

    def change_mac(self, user_id, password, interface_id, new_mac, host_name):
        return self.post('user/{}/change-mac/{}'.format(user_id, interface_id),
                         data={'password': password, 'mac': new_mac, 'host_name': host_name})

    def activate_network_access(self, user_id, password, mac, birthdate, host_name):
        return self.post('user/{}/activate-network-access'.format(user_id),
                         data={'password': password, 'mac': mac,
                               'birthdate': birthdate, 'host_name': host_name})

    def estimate_balance_at_end_of_membership(self, user_id, end_date):
        return self.get("user/{}/terminate-membership".format(user_id),
                        params={'end_date': end_date})

    def terminate_membership(self, user_id, end_date):
        return self.post("user/{}/terminate-membership".format(user_id),
                         data={'end_date': end_date,
                               'comment': 'Move-out by SIPA'})

    def continue_membership(self, user_id):
        return self.delete("user/{}/terminate-membership".format(user_id))

    def reset_wifi_password(self, user_id):
        return self.patch("user/{}/reset-wifi-password".format(user_id))

    def request_password_reset(self, user_ident: str, email: str):
        return self.post("user/reset-password", data={
            'ident': user_ident,
            'email': email,
        })

    def reset_password(self, token, new_password):
        return self.patch("user/reset-password", data={
            'token':  token,
            'password': new_password,
        })

    def match_person(self, first_name: str, last_name: str, birthdate: date, tenant_number: int,
                     previous_dorm: Optional[str]) -> MatchPersonResult:
        """
        Get the newest tenancy for the supplied user data.

        :raises PycroftApiError: if the matching was unsuccessful
        :return: the match result
        """
        # if first_name == 's':
        #     status, result = 200, {
        #         'building': 'Zw 41',
        #         'room': 'Room 407',
        #         'room_id': 1337,
        #         'begin': '2020-10-01',
        #         'end': '2021-10-01',
        #     }
        # else:
        #     status, result = 404, {
        #         'code': 'user_exists',
        #         'message': 'No tenancies found for this data',
        #     }

        params = {'first_name': first_name, 'last_name': last_name,
                  'birthdate': birthdate, 'person_id': tenant_number}

        if previous_dorm is not None:
            params['previous_dorm'] = previous_dorm

        status, result = self.get("register", params)

        if status != 200:
            raise PycroftApiError(result['code'], result['message'])

        return MatchPersonResult.from_json(result)

    def member_request(self, email: str, login: str, password: str,
                       first_name: str, last_name: str, birthdate: date,
                       move_in_date: date, tenant_number: Optional[int],
                       room_id: Optional[int], previous_dorm: Optional[str]) -> None:
        """
        Creates a member request in pycroft.

        :raises PycroftApiError: if the member request was unsuccessful
        """
        # if login == 's':
        #     status, result = 200, None
        # else:
        #     status, result = 404, {
        #         'code': 'user_exists',
        #         'message': 'User already exists',
        #     }

        data = {
            'first_name': first_name, 'last_name': last_name, 'birthdate': birthdate,
            'email': email, 'login': login, 'password': password,
            'move_in_date': move_in_date.isoformat()
        }

        # Verification was not skipped
        if tenant_number is not None:
            data['person_id'] = tenant_number

        # Room was not rejected
        if room_id is not None:
            data['room_id'] = room_id

        if previous_dorm is not None:
            data['previous_dorm'] = previous_dorm

        status, result = self.post("register", data=data)

        if status != 200:
            raise PycroftApiError(result['code'], result['message'])
        else:
            return

    def resend_confirm_email(self, user_id: int) -> bool:
        status, _ = self.get("register/confirm", params={'user_id': user_id})
        return status == 200

    def confirm_email(self, token: str):
        """
        Confirms a member request.

        :raises PycroftApiError: if the confirmation was unsuccessful
        :return: the confirmation type, either `user` or `pre_member`
        """

        # if token == 's':
        #     status, result = 200, None
        # else:
        #     status, result = 404, {
        #         'code': 'bad_key',
        #         'message': 'Bad key',
        #     }

        status, result = self.post("register/confirm", data={'key': token})

        if status != 200:
            raise PycroftApiError(result['code'], result['message'])

        return result

    def get(self, url, params=None, no_raise=False):
        request_function = partial(requests.get, params=params or {})
        return self._do_api_call(request_function, url)

    def post(self, url, data=None, no_raise=False):
        request_function = partial(requests.post, data=data or {})
        return self._do_api_call(request_function, url)

    def delete(self, url, data=None, no_raise=False):
        request_function = partial(requests.delete, data=data or {})
        return self._do_api_call(request_function, url)

    def patch(self, url, data=None, no_raise=False):
        request_function = partial(requests.patch, data=data or {})
        return self._do_api_call(request_function, url)

    def _do_api_call(self, request_function: Callable, url: str, no_raise: bool = False) -> Tuple[int, Any]:
        try:
            response = request_function(
                self._endpoint + url,
                headers={'Authorization': 'ApiKey {}'.format(self._api_key)},
            )
        except ConnectionError as e:
            if no_raise:
                return 0, None

            logger.error("Caught a ConnectionError when accessing Pycroft API",
                         extra={'data': {'endpoint': self._endpoint + url}})
            raise PycroftBackendError("Pycroft API unreachable") from e

        if response.status_code not in [200, 400, 401, 403, 404, 412, 422] and not no_raise:
            try:
                response.raise_for_status()
            except HTTPError as e:
                raise PycroftBackendError(f"Pycroft API returned status"
                                          f" {response.status_code}") from e

        return response.status_code, response.json() class UserData:
    id: int
    user_id: str
    login: str
    name: str
    status: UserStatus
    room: str
    mail: str
    mail_forwarded: bool
    mail_confirmed: bool
    properties: List[str]
    traffic_history: List[TrafficHistoryEntry]
    interfaces: List[Interface]
    finance_balance: Decimal
    finance_history: List[FinanceHistoryEntry]
    last_finance_update: date

    # TODO introduce properties once they can be excluded

    birthdate: Optional[date]
    membership_end_date: Optional[date]
    membership_begin_date: Optional[date]
    wifi_password: Optional[str] class UserStatus:
    member: bool
    traffic_exceeded: bool
    network_access: bool
    account_balanced: bool
    violation: bool class UnserializationError(Exception):
    pass class UserDB(BaseUserDB):
    def __init__(self, user):
        super().__init__(user)

        mask = current_app.config.get('DB_HELIOS_IP_MASK')
        self.test_ipmask_validity(mask)
        self.ip_mask = mask

    @staticmethod
    def test_ipmask_validity(mask):
        """Test whether a valid ip mask (at max one consecutive '%') was given

        This is being done by replacing '%' with the maximum possible
        value ('255').  Thus, everything surrounding the '%' except
        for dots causes an invalid IPv4Address and thus a
        `ValueError`.
        """
        try:
            IPv4Address(mask.replace("%", "255"))
        except AddressValueError:
            raise ValueError("Mask {} is not a valid IP address or contains "
                             "more than one consecutive '%' sign".format(mask))

    @staticmethod
    def sql_query(query: str, args=()):
        """Prepare and execute a raw sql query.

        :param query: See :py:meth:`pymysql.cursors.Cursor.execute`.
        :param args: is a tuple needed for string replacement.
            See :py:meth:`pymysql.cursors.Cursor.execute`.
        """
        database = current_app.extensions['db_helios']
        # Connection.__enter__ returns Cursor, Cursor.__enter__ returns itself
        # and we need both things for their `__exit__` commands
        with database.connect() as cursor, cursor:
            result = cursor.execute(query, args)
        return result

    @property
    def has_db(self):
        try:
            userdb = self.sql_query(
                "SELECT SCHEMA_NAME "
                "FROM INFORMATION_SCHEMA.SCHEMATA "
                "WHERE SCHEMA_NAME = %s",
                (self.db_name(),),
            ).fetchone()

            return userdb is not None
        except OperationalError:
            logger.critical("User db of user %s unreachable", self.db_name(),
                            exc_info=True)
            return None

    def create(self, password):
        self.sql_query(
            "CREATE DATABASE "
            "IF NOT EXISTS `%s`" % self.db_name(),
        )
        self.change_password(password)

    def drop(self):
        self.sql_query(
            "DROP DATABASE "
            "IF EXISTS `%s`" % self.db_name(),
        )

        self.sql_query(
            "DROP USER %s@%s",
            (self.db_name(), self.ip_mask),
        )

    def change_password(self, password):
        user = self.sql_query(
            "SELECT user "
            "FROM mysql.user "
            "WHERE user = %s",
            (self.db_name(),),
        ).fetchall()

        if not user:
            self.sql_query(
                "CREATE USER %s@%s "
                "IDENTIFIED BY %s",
                (self.db_name(), self.ip_mask, password,),
            )
        else:
            self.sql_query(
                "SET PASSWORD "
                "FOR %s@%s = PASSWORD(%s)",
                (self.db_name(), self.ip_mask, password,),
            )

        self.sql_query(
            "GRANT SELECT, INSERT, UPDATE, DELETE, "
            "ALTER, CREATE, DROP, INDEX, LOCK TABLES "
            "ON `{}`.* "
            "TO %s@%s".format(self.db_name()),
            (self.db_name(), self.ip_mask),
        )

    def db_name(self):
        return self.user.login.value
import logging
from sipa.model.user import BaseUser
from sipa.model.finance import BaseFinanceInformation
from sipa.model.fancy_property import active_prop, connection_dependent, \
    unsupported_prop, ActiveProperty, UnsupportedProperty, Capabilities
from sipa.model.misc import PaymentDetails
from sipa.model.exceptions import UserNotFound, PasswordInvalid, \
    MacAlreadyExists, NetworkAccessAlreadyActive, TerminationNotPossible, UnknownError, \
    ContinuationNotPossible, SubnetFull, UserNotContactableError, TokenNotFound, LoginNotAllowed
from .api import PycroftApi
from .exc import PycroftBackendError
from .schema import UserData, UserStatus
from .unserialize import UnserializationError
from .userdb import UserDB
from flask_login import AnonymousUserMixin
from flask.globals import current_app
from flask_babel import gettext
from werkzeug.local import LocalProxy
from werkzeug.http import parse_date
# -*- coding: utf-8 -*-



logger = logging.getLogger(__name__)

api: PycroftApi = LocalProxy(lambda: current_app.extensions['pycroft_api'])


class User(BaseUser):
    user_data: UserData

    def __init__(self, user_data: dict):
        try:
            self.user_data: UserData = UserData(user_data)

            self._userdb: UserDB = UserDB(self)